@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
@@ -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])];
@@ -13,6 +13,7 @@ import type {
13
13
  JsonRpcRequest,
14
14
  JsonRpcResponse,
15
15
  MCPRequestOptions,
16
+ MCPStdioPreparedLaunch,
16
17
  MCPStdioServerConfig,
17
18
  MCPTransport,
18
19
  } from "../../runtime-mcp/types";
@@ -56,6 +57,22 @@ function buildMinimalStdioEnv(explicit?: Record<string, string>): Record<string,
56
57
  return { ...env, ...explicit };
57
58
  }
58
59
 
60
+ function combinedOperationAndCleanupFailure(operationError: unknown, cleanupError: unknown): MCPExpectedFailure {
61
+ return new MCPExpectedFailure(
62
+ new AggregateError(
63
+ [operationError, cleanupError],
64
+ "MCP stdio operation failed and subprocess cleanup also failed",
65
+ ),
66
+ );
67
+ }
68
+
69
+ type StdioConnectAttempt = {
70
+ generation: number;
71
+ revoked: boolean;
72
+ settled: boolean;
73
+ completion: Promise<void>;
74
+ };
75
+
59
76
  export class StdioTransport implements MCPTransport {
60
77
  #process: OwnedProcess | null = null;
61
78
  #pendingRequests = new Map<
@@ -69,6 +86,10 @@ export class StdioTransport implements MCPTransport {
69
86
  #readLoop: Promise<void> | null = null;
70
87
  #stderrLoop: Promise<void> | null = null;
71
88
  #closePromise: Promise<void> | null = null;
89
+ #connectGeneration = 0;
90
+ #connectAttempt: StdioConnectAttempt | null = null;
91
+ /** Retained until the per-attempt cleanup resolves successfully. */
92
+ #afterProcessExit: (() => Promise<void>) | null = null;
72
93
 
73
94
  onClose?: () => void;
74
95
  onError?: (error: Error) => void;
@@ -85,33 +106,122 @@ export class StdioTransport implements MCPTransport {
85
106
  return true;
86
107
  }
87
108
 
109
+ #registerCleanup(attempt: StdioConnectAttempt, cleanup: () => Promise<void>): void {
110
+ if (this.#connectAttempt !== attempt) {
111
+ throw new Error("MCP stdio attempt registered cleanup after its ownership ended");
112
+ }
113
+ if (this.#afterProcessExit && this.#afterProcessExit !== cleanup) {
114
+ throw new Error("MCP stdio attempt registered conflicting cleanup owners");
115
+ }
116
+ this.#afterProcessExit = cleanup;
117
+ }
118
+
119
+ #assertConnectAttemptActive(attempt: StdioConnectAttempt): void {
120
+ if (attempt.revoked || this.#connectAttempt !== attempt || this.#connectGeneration !== attempt.generation) {
121
+ throw new Error("MCP stdio connection attempt was closed");
122
+ }
123
+ }
124
+
125
+ #revokeConnectAttempt(): StdioConnectAttempt | null {
126
+ const attempt = this.#connectAttempt;
127
+ if (attempt && !attempt.revoked) {
128
+ attempt.revoked = true;
129
+ this.#connectGeneration++;
130
+ }
131
+ return attempt;
132
+ }
133
+
88
134
  /**
89
135
  * Start the subprocess and begin reading.
90
136
  */
91
137
  async connect(): Promise<void> {
92
138
  if (this.#connected) return;
93
- if (this.#closePromise || this.#process) {
139
+ if (this.#closePromise) {
140
+ throw new MCPExpectedFailure(new Error("MCP stdio child teardown is incomplete"));
141
+ }
142
+ if (this.#connectAttempt) {
143
+ if (!this.#connectAttempt.settled) return this.#connectAttempt.completion;
144
+ if (this.#process || this.#afterProcessExit) {
145
+ throw new MCPExpectedFailure(new Error("MCP stdio child teardown is incomplete"));
146
+ }
147
+ this.#connectAttempt = null;
148
+ }
149
+ if (this.#process || this.#afterProcessExit) {
94
150
  throw new MCPExpectedFailure(new Error("MCP stdio child teardown is incomplete"));
95
151
  }
96
152
 
97
- const args = this.config.args ?? [];
153
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
154
+ const attempt: StdioConnectAttempt = {
155
+ generation: ++this.#connectGeneration,
156
+ revoked: false,
157
+ settled: false,
158
+ completion: promise,
159
+ };
160
+ this.#connectAttempt = attempt;
161
+ void this.#finishConnect(attempt).then(
162
+ () => {
163
+ attempt.settled = true;
164
+ resolve();
165
+ },
166
+ error => {
167
+ attempt.settled = true;
168
+ reject(error);
169
+ },
170
+ );
171
+ void promise.catch(() => {});
172
+ return promise;
173
+ }
174
+
175
+ async #finishConnect(attempt: StdioConnectAttempt): Promise<void> {
176
+ let launch: MCPStdioPreparedLaunch = {
177
+ command: this.config.command,
178
+ args: Object.freeze([...(this.config.args ?? [])]),
179
+ cwd: this.config.cwd ?? process.cwd(),
180
+ registerCleanup: cleanup => this.#registerCleanup(attempt, cleanup),
181
+ };
98
182
  const env = this.config.noInheritEnv
99
183
  ? buildMinimalStdioEnv(this.config.env)
100
184
  : {
101
185
  ...Bun.env,
102
186
  ...this.config.env,
103
187
  };
104
- const cwd = this.config.cwd ?? process.cwd();
105
-
106
188
  try {
107
- this.#process = spawnOwnedProcess([this.config.command, ...args], {
108
- cwd,
189
+ this.#assertConnectAttemptActive(attempt);
190
+ if (this.config.prepareSpawn) launch = await this.config.prepareSpawn(launch);
191
+ if (launch.afterProcessExit) this.#registerCleanup(attempt, launch.afterProcessExit);
192
+ else if (!this.#afterProcessExit && this.config.afterProcessExit) {
193
+ this.#registerCleanup(attempt, this.config.afterProcessExit);
194
+ }
195
+ this.#assertConnectAttemptActive(attempt);
196
+ await this.config.spawnGuard?.(launch);
197
+ this.#assertConnectAttemptActive(attempt);
198
+ await this.config.afterSpawnGuardForTest?.();
199
+ this.#assertConnectAttemptActive(attempt);
200
+ const process = spawnOwnedProcess([launch.command, ...launch.args], {
201
+ cwd: launch.cwd,
109
202
  env,
110
203
  stdin: "pipe",
111
204
  gracefulMs: CLOSE_WAIT_MS,
112
- name: `mcp-stdio:${this.config.command}`,
205
+ name: `mcp-stdio:${launch.command}`,
113
206
  });
207
+ if (attempt.revoked) {
208
+ const teardown = await process.dispose();
209
+ await process.awaitExit({ timeoutMs: CLOSE_WAIT_MS }).catch(() => ({ exited: false, code: null }));
210
+ if (teardown.status !== "terminated") {
211
+ this.#process = process;
212
+ throw new MCPExpectedFailure(new Error(`stdio child teardown ${teardown.status}`));
213
+ }
214
+ throw new Error("MCP stdio connection attempt was closed");
215
+ }
216
+ this.#process = process;
114
217
  } catch (error) {
218
+ if (!this.#process) {
219
+ try {
220
+ await this.#runAfterProcessExit();
221
+ } catch (cleanupError) {
222
+ throw combinedOperationAndCleanupFailure(error, cleanupError);
223
+ }
224
+ }
115
225
  throw new MCPExpectedFailure(error);
116
226
  }
117
227
 
@@ -367,19 +477,32 @@ export class StdioTransport implements MCPTransport {
367
477
  }
368
478
  }
369
479
 
480
+ async #runAfterProcessExit(): Promise<void> {
481
+ const cleanup = this.#afterProcessExit;
482
+ if (!cleanup) return;
483
+ await cleanup();
484
+ if (this.#afterProcessExit === cleanup) this.#afterProcessExit = null;
485
+ }
486
+
370
487
  async close(): Promise<void> {
371
488
  await this.#closeInternal(false);
372
489
  }
373
490
 
374
491
  #closeInternal(fromReadLoop: boolean, failure?: MCPExpectedFailure): Promise<void> {
375
492
  if (this.#closePromise) return this.#closePromise;
376
- this.#closePromise = this.#finishClose(fromReadLoop, failure).finally(() => {
493
+ const connectAttempt = this.#revokeConnectAttempt();
494
+ this.#closePromise = this.#finishClose(fromReadLoop, failure, connectAttempt).finally(() => {
495
+ if (this.#connectAttempt === connectAttempt) this.#connectAttempt = null;
377
496
  this.#closePromise = null;
378
497
  });
379
498
  return this.#closePromise;
380
499
  }
381
500
 
382
- async #finishClose(fromReadLoop: boolean, failure?: MCPExpectedFailure): Promise<void> {
501
+ async #finishClose(
502
+ fromReadLoop: boolean,
503
+ failure: MCPExpectedFailure | undefined,
504
+ connectAttempt: StdioConnectAttempt | null,
505
+ ): Promise<void> {
383
506
  const wasConnected = this.#connected;
384
507
  this.#connected = false;
385
508
 
@@ -388,16 +511,36 @@ export class StdioTransport implements MCPTransport {
388
511
  }
389
512
  this.#pendingRequests.clear();
390
513
 
514
+ let primaryFailure: unknown = failure;
515
+ if (connectAttempt) {
516
+ try {
517
+ await connectAttempt.completion;
518
+ } catch (error) {
519
+ if (primaryFailure === undefined && this.#process) primaryFailure = error;
520
+ }
521
+ }
391
522
  const stdin = this.#getStdin();
392
523
  const process = this.#process;
393
524
  if (process) {
394
- stdin?.end();
395
- const teardown = await process.dispose();
396
- await process.awaitExit({ timeoutMs: CLOSE_WAIT_MS }).catch(() => ({ exited: false, code: null }));
397
- if (teardown.status !== "terminated") {
398
- throw new MCPExpectedFailure(new Error(`stdio child teardown ${teardown.status}`));
525
+ try {
526
+ stdin?.end();
527
+ const teardown = await process.dispose();
528
+ await process.awaitExit({ timeoutMs: CLOSE_WAIT_MS }).catch(() => ({ exited: false, code: null }));
529
+ if (teardown.status !== "terminated") {
530
+ throw new MCPExpectedFailure(new Error(`stdio child teardown ${teardown.status}`));
531
+ }
532
+ this.#process = null;
533
+ } catch (cleanupError) {
534
+ if (primaryFailure) throw combinedOperationAndCleanupFailure(primaryFailure, cleanupError);
535
+ throw cleanupError;
399
536
  }
400
- this.#process = null;
537
+ }
538
+
539
+ try {
540
+ await this.#runAfterProcessExit();
541
+ } catch (cleanupError) {
542
+ if (primaryFailure) throw combinedOperationAndCleanupFailure(primaryFailure, cleanupError);
543
+ throw cleanupError;
401
544
  }
402
545
 
403
546
  if (!fromReadLoop && this.#readLoop) {
@@ -413,12 +556,3 @@ export class StdioTransport implements MCPTransport {
413
556
  if (wasConnected) this.onClose?.();
414
557
  }
415
558
  }
416
-
417
- /**
418
- * Create and connect a stdio transport.
419
- */
420
- export async function createStdioTransport(config: MCPStdioServerConfig): Promise<StdioTransport> {
421
- const transport = new StdioTransport(config);
422
- await transport.connect();
423
- return transport;
424
- }
@@ -95,6 +95,22 @@ export interface MCPServerConfigBase {
95
95
  }
96
96
 
97
97
  /** Stdio server configuration */
98
+ export interface MCPStdioSpawnLaunch {
99
+ command: string;
100
+ args: readonly string[];
101
+ cwd: string;
102
+ /** Transport-supplied registrar for retryable cleanup before preparation allocates resources. */
103
+ registerCleanup?: (cleanup: () => Promise<void>) => void;
104
+ }
105
+
106
+ export interface MCPStdioPreparedLaunch extends MCPStdioSpawnLaunch {
107
+ /**
108
+ * Per-attempt cleanup run after confirmed child exit or failed spawn. The same
109
+ * callback may already have been registered during preparation.
110
+ */
111
+ afterProcessExit?: () => Promise<void>;
112
+ }
113
+
98
114
  export interface MCPStdioServerConfig extends MCPServerConfigBase {
99
115
  type?: "stdio"; // Default if not specified
100
116
  command: string;
@@ -108,6 +124,14 @@ export interface MCPStdioServerConfig extends MCPServerConfigBase {
108
124
  */
109
125
  noInheritEnv?: boolean;
110
126
  cwd?: string;
127
+ /** Internal trusted-boundary preparation of immutable per-spawn authority. */
128
+ prepareSpawn?: (launch: MCPStdioSpawnLaunch) => Promise<MCPStdioPreparedLaunch>;
129
+ /** Internal trusted-boundary check run immediately before process creation. */
130
+ spawnGuard?: (launch: MCPStdioSpawnLaunch) => Promise<void>;
131
+ /** Internal cleanup run only after the owned stdio child is confirmed exited. */
132
+ afterProcessExit?: () => Promise<void>;
133
+ /** Test seam for deterministic mutation after the guard and before process creation. */
134
+ afterSpawnGuardForTest?: () => Promise<void>;
111
135
  }
112
136
 
113
137
  /** HTTP server configuration (Streamable HTTP transport) */
@@ -875,7 +875,7 @@ export class AcpSdkAdapter {
875
875
 
876
876
  #expiredOwnedLeaseFrame(frame: Record<string, unknown>): boolean {
877
877
  const response = this.#leaseErrorFrame(frame);
878
- if (!response || response.ok !== false) return false;
878
+ if (response?.ok !== false) return false;
879
879
  const leaseId = typeof response.leaseId === "string" ? response.leaseId : "";
880
880
  const connectionId = typeof response.connectionId === "string" ? response.connectionId : "";
881
881
  if (!leaseId || !this.#ownsLeaseId(leaseId)) return false;
@@ -3095,11 +3095,12 @@ export class Broker {
3095
3095
  }
3096
3096
  const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
3097
3097
  const observedAt = stored?.observedAt ?? (scope === undefined ? undefined : new Date().toISOString());
3098
+ const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
3098
3099
  const snapshot = stored ?? {
3099
- sessions:
3100
- scope === undefined
3101
- ? [...result.sessions]
3102
- : result.sessions.filter(session => scopeMatchesLocator(scope, session.locator)),
3100
+ sessions: (scope === undefined
3101
+ ? [...result.sessions]
3102
+ : result.sessions.filter(session => scopeMatchesLocator(scope, session.locator))
3103
+ ).filter(session => resolveSessionId === undefined || session.sessionId === resolveSessionId),
3103
3104
  indexSeq: result.indexSeq,
3104
3105
  warnings: [...result.warnings],
3105
3106
  limit,