@gajae-code/coding-agent 0.17.4 → 0.17.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 (86) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
  3. package/dist/types/config/file-lock.d.ts +4 -2
  4. package/dist/types/config/model-registry.d.ts +3 -1
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/exec/bash-executor.d.ts +2 -0
  7. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
  8. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  9. package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
  10. package/dist/types/harness-control-plane/finalize.d.ts +14 -3
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  13. package/dist/types/runtime-mcp/manager.d.ts +13 -6
  14. package/dist/types/sdk/broker/broker.d.ts +4 -2
  15. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  16. package/dist/types/sdk/broker/identity.d.ts +2 -0
  17. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +11 -3
  18. package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
  19. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  20. package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
  21. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  22. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
  23. package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +3 -3
  25. package/dist/types/session/agent-session.d.ts +7 -2
  26. package/dist/types/task/types.d.ts +2 -0
  27. package/dist/types/tools/bash-interactive.d.ts +1 -0
  28. package/dist/types/tools/fetch.d.ts +10 -1
  29. package/dist/types/tools/subagent.d.ts +8 -1
  30. package/package.json +7 -7
  31. package/src/cli/public-command-registry.ts +8 -2
  32. package/src/cli/setup-cli.ts +1 -0
  33. package/src/cli/stats-cli.ts +11 -1
  34. package/src/config/autorouting-tier-map.ts +529 -3
  35. package/src/config/file-lock.ts +59 -14
  36. package/src/config/model-discovery-manager.ts +4 -4
  37. package/src/config/model-profile-activation.ts +83 -23
  38. package/src/config/model-registry.ts +72 -12
  39. package/src/config/model-resolver.ts +6 -1
  40. package/src/coordinator-mcp/question-state.ts +4 -1
  41. package/src/coordinator-mcp/server.ts +14 -6
  42. package/src/exec/bash-executor.ts +3 -0
  43. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  44. package/src/gjc-runtime/state-writer.ts +317 -6
  45. package/src/harness-control-plane/control-endpoint.ts +24 -6
  46. package/src/harness-control-plane/finalize.ts +312 -25
  47. package/src/harness-control-plane/owner.ts +323 -89
  48. package/src/internal-urls/docs-index.generated.ts +6 -4
  49. package/src/main.ts +69 -19
  50. package/src/modes/acp/acp-agent.ts +581 -37
  51. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  52. package/src/modes/components/tool-status-header.ts +72 -6
  53. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  54. package/src/runtime-mcp/loader.ts +6 -3
  55. package/src/runtime-mcp/manager.ts +177 -75
  56. package/src/sdk/broker/broker.ts +1058 -150
  57. package/src/sdk/broker/ensure.ts +65 -8
  58. package/src/sdk/broker/identity.ts +13 -0
  59. package/src/sdk/broker/lifecycle-ledger.ts +16 -4
  60. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  61. package/src/sdk/broker/managed-task-verification.ts +642 -0
  62. package/src/sdk/broker/spawn-authority.ts +140 -7
  63. package/src/sdk/broker/startup-failure.ts +80 -1
  64. package/src/sdk/broker/transport.ts +1 -0
  65. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  66. package/src/sdk/bus/index.ts +22 -6
  67. package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
  68. package/src/sdk/cli/session-cli.ts +18 -0
  69. package/src/sdk/host/session-runtime.test.ts +46 -0
  70. package/src/sdk/host/session-runtime.ts +13 -6
  71. package/src/sdk/lifecycle/service.ts +2 -0
  72. package/src/sdk/prompt-deadline-flush.ts +26 -4
  73. package/src/sdk/session.ts +587 -115
  74. package/src/session/agent-session.ts +60 -14
  75. package/src/session/internal/managed-session-scope.ts +10 -0
  76. package/src/session/internal/managed-session-storage.ts +30 -1
  77. package/src/setup/provider-presets.json +2 -1
  78. package/src/slash-commands/builtin-registry.ts +1 -1
  79. package/src/task/executor.ts +46 -17
  80. package/src/task/types.ts +2 -0
  81. package/src/tools/bash-interactive.ts +2 -0
  82. package/src/tools/bash.ts +20 -0
  83. package/src/tools/fetch.ts +21 -5
  84. package/src/tools/subagent-render.ts +24 -1
  85. package/src/tools/subagent.ts +27 -1
  86. package/src/utils/mupdf.ts +11 -4
@@ -67,9 +67,11 @@ import {
67
67
  formatModelString,
68
68
  parseModelPattern,
69
69
  parseModelString,
70
+ refreshMissingQualifiedModelProviders,
70
71
  resolveAllowedModels,
71
72
  resolveModelChainWithAuth,
72
73
  resolveModelRoleValue,
74
+ resolveStartupModelRefreshSelectors,
73
75
  type ScopedModelSelection,
74
76
  } from "../config/model-resolver";
75
77
  import { normalizeModelSelectorValue } from "../config/model-selector-value";
@@ -116,9 +118,11 @@ import { resolveCurrentPhaseForParent } from "../extensibility/gjc-plugins/injec
116
118
  import { currentActivationFingerprint } from "../extensibility/gjc-plugins/lifecycle";
117
119
  import {
118
120
  buildPluginMcpConfigs,
121
+ type GjcPluginMcpServerProvenance,
119
122
  getGjcPluginToolDeclarations,
120
123
  loadAlwaysOnPluginTools,
121
124
  renderAlwaysOnSystemAppendices,
125
+ safePluginMcpDiagnostic,
122
126
  } from "../extensibility/gjc-plugins/runtime-adapters";
123
127
  import {
124
128
  GjcRuntimeFindingAccumulator,
@@ -145,8 +149,16 @@ import {
145
149
  createOptionalRuntimeServices,
146
150
  type OptionalRuntimeServicesOverrides,
147
151
  } from "../runtime/optional-runtime-services";
148
- import { isMCPStartupTimeoutError, loadAllMCPConfigs, MCPManager } from "../runtime-mcp";
152
+ import {
153
+ DeferredMCPTool,
154
+ isMCPStartupTimeoutError,
155
+ loadAllMCPConfigs,
156
+ MCPManager,
157
+ type MCPToolCache,
158
+ resolveMCPToolCache,
159
+ } from "../runtime-mcp";
149
160
  import type { MCPLoadResult } from "../runtime-mcp/manager";
161
+ import { MCP_STARTUP_WAIT_GRACE_MS } from "../runtime-mcp/startup-policy";
150
162
  import type { MCPServerConfig } from "../runtime-mcp/types";
151
163
  import {
152
164
  getNotificationConfig,
@@ -609,9 +621,9 @@ export interface CreateAgentSessionOptions {
609
621
  inheritedAsyncJobManager?: AsyncJobManager;
610
622
  /**
611
623
  * W6b: the parent's scope-held MCP facade, handed to a canonical sub-session so
612
- * it can inherit always-on MCP tools without owning the manager. Replaces the
613
- * removed `MCPManager.instance()` inheritance path; the sub-session never
614
- * connects, registers callbacks, or disposes this manager.
624
+ * it can inherit MCP tools without owning the manager. Replaces the removed
625
+ * `MCPManager.instance()` inheritance path; the sub-session subscribes to catalog
626
+ * changes but never connects or disposes this manager.
615
627
  */
616
628
  inheritedMcpManager?: import("../runtime-mcp/manager").MCPManager;
617
629
 
@@ -1248,6 +1260,29 @@ const DEFERRED_MCP_CONFIG_STARTUP_ERROR = "MCP tools could not be loaded.";
1248
1260
  const MAX_EXACT_MCP_TOOL_NAME_LENGTH = 100;
1249
1261
  const pluginMcpManagerServers = new WeakMap<MCPManager, ReadonlySet<string>>();
1250
1262
  const conventionalMcpManagerServers = new WeakMap<MCPManager, ReadonlySet<string>>();
1263
+ const failedPluginMcpManagerServers = new WeakMap<MCPManager, ReadonlySet<string>>();
1264
+ const pluginMcpCleanupFailures = new WeakSet<MCPManager>();
1265
+
1266
+ function sameMcpToolSnapshot(first: readonly CustomTool[], second: readonly CustomTool[]): boolean {
1267
+ return first.length === second.length && first.every((tool, index) => tool === second[index]);
1268
+ }
1269
+
1270
+ function classifyInheritedMcpToolNames(
1271
+ manager: MCPManager,
1272
+ tools: readonly CustomTool[],
1273
+ ): { pluginMcpToolNames: string[]; conventionalMcpToolNames: string[] } {
1274
+ const pluginServers = pluginMcpManagerServers.get(manager);
1275
+ const conventionalServers = conventionalMcpManagerServers.get(manager);
1276
+ const pluginMcpToolNames: string[] = [];
1277
+ const conventionalMcpToolNames: string[] = [];
1278
+ for (const tool of tools) {
1279
+ const serverName = tool.mcpServerName;
1280
+ if (serverName === undefined) continue;
1281
+ if (pluginServers?.has(serverName)) pluginMcpToolNames.push(tool.name);
1282
+ else if (conventionalServers?.has(serverName)) conventionalMcpToolNames.push(tool.name);
1283
+ }
1284
+ return { pluginMcpToolNames, conventionalMcpToolNames };
1285
+ }
1251
1286
 
1252
1287
  class ExactMcpToolNameCollisionError extends Error {
1253
1288
  constructor(toolNames: Iterable<string>) {
@@ -1262,23 +1297,43 @@ class ExactMcpToolNameCollisionError extends Error {
1262
1297
  class McpManagerCleanupError extends Error {
1263
1298
  readonly code = "MCP_MANAGER_CLEANUP_FAILED";
1264
1299
  constructor(cause: unknown) {
1265
- super(`Owned MCP manager cleanup failed: ${safeErrorDescription(cause)}`, { cause });
1300
+ super(`Owned MCP manager cleanup failed: ${safePluginMcpDiagnostic(cause)}`, { cause });
1266
1301
  this.name = "McpManagerCleanupError";
1267
1302
  }
1268
1303
  }
1269
1304
 
1305
+ class McpStartupCleanupRetryError extends Error {
1306
+ readonly primaryError: unknown;
1307
+ constructor(primaryError: unknown) {
1308
+ super("Owned MCP manager cleanup failed during startup", { cause: primaryError });
1309
+ this.name = "McpStartupCleanupRetryError";
1310
+ this.primaryError = primaryError;
1311
+ }
1312
+ }
1313
+
1270
1314
  class McpManagerCleanupDiagnosticError extends Error {
1271
1315
  readonly code = "MCP_MANAGER_CLEANUP_FAILED";
1272
1316
  readonly primaryError: unknown;
1273
1317
  readonly cleanupDiagnostic: { code: "MCP_MANAGER_CLEANUP_FAILED"; cause: unknown };
1274
1318
  constructor(primaryError: unknown, cleanupError: unknown) {
1275
- super(safeErrorDescription(primaryError), { cause: primaryError });
1319
+ super(safePluginMcpDiagnostic(primaryError), { cause: primaryError });
1276
1320
  this.name = "McpManagerCleanupDiagnosticError";
1277
1321
  this.primaryError = primaryError;
1278
1322
  this.cleanupDiagnostic = { code: "MCP_MANAGER_CLEANUP_FAILED", cause: cleanupError };
1279
1323
  }
1280
1324
  }
1281
1325
 
1326
+ class McpPluginStartupCleanupDiagnosticError extends Error {
1327
+ readonly primaryError: unknown;
1328
+ readonly cleanupDiagnostic: { code: "MCP_MANAGER_CLEANUP_FAILED"; cause: unknown };
1329
+ constructor(primaryError: unknown, cleanupError: unknown) {
1330
+ super("GJC plugin MCP startup and cleanup failed");
1331
+ this.name = "McpPluginStartupCleanupDiagnosticError";
1332
+ this.primaryError = primaryError;
1333
+ this.cleanupDiagnostic = { code: "MCP_MANAGER_CLEANUP_FAILED", cause: cleanupError };
1334
+ }
1335
+ }
1336
+
1282
1337
  function safeIsInstanceOf<T extends object>(value: unknown, ctor: abstract new (...args: any[]) => T): boolean {
1283
1338
  try {
1284
1339
  return value instanceof ctor;
@@ -1301,8 +1356,8 @@ function safeCleanupDiagnosticForLog(value: unknown): { code: string; cause: str
1301
1356
  const code = safeReadProperty(value, "code");
1302
1357
  const nestedCause = safeReadProperty(value, "cause");
1303
1358
  return {
1304
- code: typeof code === "string" ? code : "MCP_MANAGER_CLEANUP_FAILED",
1305
- cause: safeErrorDescription(nestedCause === undefined ? value : nestedCause),
1359
+ code: typeof code === "string" ? safePluginMcpDiagnostic(code) : "MCP_MANAGER_CLEANUP_FAILED",
1360
+ cause: safePluginMcpDiagnostic(nestedCause === undefined ? value : nestedCause),
1306
1361
  };
1307
1362
  }
1308
1363
  function safeReadCleanupDiagnostic(value: unknown): unknown {
@@ -1520,7 +1575,33 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1520
1575
  let asyncJobManagerOwned = false;
1521
1576
  let asyncJobManagerAdmitted = false;
1522
1577
  let priorAsyncJobManager: AsyncJobManager | undefined;
1523
- let cleanupOwnedMcpManager: (() => Promise<void>) | undefined;
1578
+ type OwnedMcpManagerCleanup = () => Promise<void>;
1579
+ let cleanupOwnedMcpManager: OwnedMcpManagerCleanup | undefined;
1580
+ let cleanupOwnedMcpManagerOwner: MCPManager | undefined;
1581
+ const registerOwnedMcpManagerCleanup = (owned: MCPManager): OwnedMcpManagerCleanup => {
1582
+ let cleanupAttempt: Promise<void> | undefined;
1583
+ const cleanup: OwnedMcpManagerCleanup = () => {
1584
+ if (cleanupAttempt) return cleanupAttempt;
1585
+ cleanupAttempt = Promise.resolve()
1586
+ .then(() => owned.disconnectAll())
1587
+ .then(
1588
+ () => {
1589
+ if (cleanupOwnedMcpManager === cleanup) {
1590
+ cleanupOwnedMcpManager = undefined;
1591
+ cleanupOwnedMcpManagerOwner = undefined;
1592
+ }
1593
+ },
1594
+ error => {
1595
+ cleanupAttempt = undefined;
1596
+ throw error;
1597
+ },
1598
+ );
1599
+ return cleanupAttempt;
1600
+ };
1601
+ cleanupOwnedMcpManager = cleanup;
1602
+ cleanupOwnedMcpManagerOwner = owned;
1603
+ return cleanup;
1604
+ };
1524
1605
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
1525
1606
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
1526
1607
  const resolvedAgentDisplayName = options.agentDisplayName ?? (isCanonicalSubSession ? "sub" : "main");
@@ -1558,6 +1639,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1558
1639
  unsubscribeCredentialDisabled = undefined;
1559
1640
  unsubscribe?.();
1560
1641
  };
1642
+ let inheritedMcpToolsPublisher: ((tools: readonly CustomTool[]) => void) | undefined;
1643
+ let inheritedMcpToolsUnsubscribe: (() => void) | undefined;
1644
+ const stopInheritedMcpToolsSubscription = (): void => {
1645
+ inheritedMcpToolsPublisher = undefined;
1646
+ const unsubscribe = inheritedMcpToolsUnsubscribe;
1647
+ inheritedMcpToolsUnsubscribe = undefined;
1648
+ unsubscribe?.();
1649
+ };
1561
1650
 
1562
1651
  try {
1563
1652
  // Subscribe before any getApiKey() call so startup model probes can't fire a
@@ -1904,6 +1993,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1904
1993
  );
1905
1994
  }
1906
1995
  if (!options.modelRegistry && !attemptedStartupCacheAdmission) {
1996
+ // Match the CLI's awaited startup discovery: the background refresh below
1997
+ // is not a readiness barrier, so resolving a fresh configured default first
1998
+ // can reject a model that the provider would discover moments later.
1999
+ const startupModelSelectors = resolveStartupModelRefreshSelectors(
2000
+ {
2001
+ model: options.modelPattern ?? (options.model ? formatModelString(options.model) : undefined),
2002
+ credential: options.credentialSelector ? "session" : undefined,
2003
+ resume: hasExistingSession,
2004
+ hasStartupProfile: Boolean(options.activeModelProfile || settings.get("modelProfile.default")),
2005
+ },
2006
+ settings,
2007
+ );
2008
+ await refreshMissingQualifiedModelProviders(startupModelSelectors, modelRegistry, credentialSessionId);
1907
2009
  modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
1908
2010
  }
1909
2011
 
@@ -2357,11 +2459,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2357
2459
  };
2358
2460
  let mcpManager: MCPManager | undefined = options.mcpManager;
2359
2461
  let ownsMcpManager = false;
2462
+ let ownedMcpToolCache: MCPToolCache | null | undefined;
2463
+ const getOwnedMcpToolCache = async (): Promise<MCPToolCache | null> => {
2464
+ if (ownedMcpToolCache === undefined)
2465
+ ownedMcpToolCache = await resolveMCPToolCache(undefined, getAgentDbPath(agentDir));
2466
+ return ownedMcpToolCache;
2467
+ };
2360
2468
  const cwdCapturingToolNames: string[] = [];
2361
2469
  const ownedConventionalMcpServerNames = new Set<string>();
2362
- let ownedConventionalMcpToolNames: string[] = [];
2363
- let publishOwnedConventionalMcpTools = false;
2364
- let ownedPluginServersConnected = false;
2470
+ const cachedConventionalMcpServerNames = new Set<string>();
2471
+ let ownedMcpManagerToolNames: string[] = [];
2472
+ let ownedMcpManagerTools: readonly CustomTool[] = [];
2473
+ let publishOwnedMcpTools = false;
2365
2474
  const notificationDebounceTimers = new Map<string, Timer>();
2366
2475
  const wireMcpManagerCallbacks = (manager: MCPManager): void => {
2367
2476
  manager.setOnPromptsChanged(serverName => {
@@ -2416,6 +2525,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2416
2525
  if (previousManager) await previousManager.disconnectAll().catch(() => {});
2417
2526
  pluginMcpToolNames.length = 0;
2418
2527
  conventionalMcpToolNames.length = 0;
2528
+ ownedConventionalMcpServerNames.clear();
2529
+ cachedConventionalMcpServerNames.clear();
2530
+ ownedMcpManagerToolNames = [];
2531
+ ownedMcpManagerTools = [];
2532
+ publishOwnedMcpTools = false;
2419
2533
  let nextManager: MCPManager | undefined;
2420
2534
  try {
2421
2535
  const loaded = await loadAllMCPConfigs(to, {
@@ -2439,11 +2553,51 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2439
2553
  ]),
2440
2554
  ),
2441
2555
  };
2556
+ const conventionalCacheServerNames = new Set(
2557
+ Object.keys(loaded.configs).filter(name => !pluginNames.has(name)),
2558
+ );
2559
+ for (const name of conventionalCacheServerNames) ownedConventionalMcpServerNames.add(name);
2560
+ publishOwnedMcpTools = Object.keys(mergedConfigs).length > 0;
2442
2561
  if (Object.keys(mergedConfigs).length > 0) {
2443
- nextManager = new MCPManager(to, null, { sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs") });
2562
+ nextManager = new MCPManager(
2563
+ to,
2564
+ conventionalCacheServerNames.size > 0 ? await getOwnedMcpToolCache() : null,
2565
+ {
2566
+ sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs"),
2567
+ toolCacheServerNames: conventionalCacheServerNames,
2568
+ },
2569
+ );
2444
2570
  nextManager.setAuthStorage(authStorage);
2445
2571
  wireMcpManagerCallbacks(nextManager);
2446
2572
  const result = await nextManager.connectServers(mergedConfigs, mergedSources as never);
2573
+ const connectedPluginNames = new Set(result.connectedServers.filter(name => pluginNames.has(name)));
2574
+ pluginMcpManagerServers.set(nextManager, new Set(pluginNames));
2575
+ conventionalMcpManagerServers.set(nextManager, conventionalCacheServerNames);
2576
+ failedPluginMcpManagerServers.set(nextManager, new Set());
2577
+ for (const tool of result.tools) {
2578
+ const serverName = tool.mcpServerName;
2579
+ if (
2580
+ tool instanceof DeferredMCPTool &&
2581
+ serverName &&
2582
+ conventionalCacheServerNames.has(serverName)
2583
+ ) {
2584
+ cachedConventionalMcpServerNames.add(serverName);
2585
+ }
2586
+ }
2587
+ ownedMcpManagerTools = result.tools as CustomTool[];
2588
+ ownedMcpManagerToolNames = result.tools.map(tool => tool.name);
2589
+ const unsettledConventionalNames = [...conventionalCacheServerNames].filter(
2590
+ name =>
2591
+ nextManager?.getConnectionStatus(name) === "connecting" ||
2592
+ cachedConventionalMcpServerNames.has(name),
2593
+ );
2594
+ if (pluginNames.size > 0) {
2595
+ if (connectedPluginNames.size > 0 && unsettledConventionalNames.length === 0) {
2596
+ nextManager.sealConnectionSet();
2597
+ } else {
2598
+ nextManager.freezeConnectionSet();
2599
+ }
2600
+ }
2447
2601
  nextCustomTools.push(...(result.tools as CustomTool[]));
2448
2602
  nextCwdCapturing.push(...result.tools.map(tool => tool.name));
2449
2603
  for (const tool of result.tools) {
@@ -2461,14 +2615,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2461
2615
  mcpManager = nextManager;
2462
2616
  ownsMcpManager = Boolean(nextManager);
2463
2617
  await session.replaceOwnedMcpManager(nextManager);
2464
- await session.refreshMCPTools((nextManager?.getTools() ?? []) as CustomTool[]);
2618
+ await session.refreshMCPTools((nextManager?.getTools() ?? []) as CustomTool[], {
2619
+ mandatoryMCPToolNames: pluginMcpToolNames,
2620
+ });
2465
2621
  }
2466
2622
  cwdCapturingToolNames.length = 0;
2467
2623
  cwdCapturingToolNames.push(...nextCwdCapturing);
2468
2624
  await session.replaceNamedCustomTools(
2469
2625
  previousCwdCapturing.filter(name => !nextCustomTools.some(tool => tool.name === name)),
2470
2626
  nextCustomTools,
2627
+ { mandatoryMCPToolNames: pluginMcpToolNames },
2471
2628
  );
2629
+ wireOwnedMcpToolSync();
2472
2630
  };
2473
2631
 
2474
2632
  /**
@@ -3031,10 +3189,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3031
3189
  const exactMcpToolNames: string[] = [];
3032
3190
  const pluginMcpToolNames: string[] = [];
3033
3191
  const conventionalMcpToolNames: string[] = [];
3192
+ let inheritedMcpManager: MCPManager | undefined;
3193
+ let inheritedMcpManagerToolNames: string[] = [];
3194
+ let inheritedMcpToolsSnapshot: readonly CustomTool[] = [];
3195
+ let pendingInheritedMcpToolsSnapshot: readonly CustomTool[] | undefined;
3034
3196
  let deferredExactMcpConfig: { manager: MCPManager; configPath: string } | undefined;
3035
3197
  let deferredConventionalMcp:
3036
3198
  | {
3037
3199
  manager: MCPManager;
3200
+ cleanup: OwnedMcpManagerCleanup;
3038
3201
  configs: Record<string, MCPServerConfig>;
3039
3202
  sources: Record<string, SourceMeta>;
3040
3203
  conventionalConfigs: Record<string, MCPServerConfig>;
@@ -3154,76 +3317,179 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3154
3317
  mergedSources: Record<string, SourceMeta>,
3155
3318
  conventionalConfigs: Record<string, MCPServerConfig>,
3156
3319
  pluginNames: ReadonlySet<string>,
3320
+ pluginMcpProvenance: ReadonlyMap<string, GjcPluginMcpServerProvenance>,
3321
+ cleanupOwned: OwnedMcpManagerCleanup,
3157
3322
  ): Promise<MCPLoadResult> => {
3158
3323
  let result: MCPLoadResult;
3159
3324
  try {
3160
3325
  result = await owned.connectServers(mergedConfigs, mergedSources as never);
3161
3326
  } catch (error) {
3162
- if (safeIsInstanceOf(error, McpManagerCleanupError)) throw error;
3327
+ if (
3328
+ safeIsInstanceOf(error, McpManagerCleanupError) ||
3329
+ safeIsInstanceOf(error, McpManagerCleanupDiagnosticError) ||
3330
+ safeReadCleanupDiagnostic(error) !== undefined
3331
+ )
3332
+ throw error;
3163
3333
  // Avoid leaking partially-started server processes on failure.
3334
+ const retryAfterStartupCleanupFailure = options.enableMCP === false && !options.deferMcpConfigStartup;
3164
3335
  let cleanupError: unknown;
3336
+ let cleanupFailed = false;
3165
3337
  try {
3166
- await owned.disconnectAll();
3338
+ await cleanupOwned();
3167
3339
  } catch (disconnectError) {
3340
+ cleanupFailed = true;
3168
3341
  cleanupError = disconnectError;
3169
- } finally {
3170
- cleanupOwnedMcpManager = undefined;
3171
3342
  }
3172
- if (cleanupError !== undefined) throw attachMcpCleanupDiagnostic(error, cleanupError);
3343
+ if (cleanupFailed) {
3344
+ if (retryAfterStartupCleanupFailure) throw new McpStartupCleanupRetryError(error);
3345
+ if (pluginNames.size > 0) {
3346
+ throw new McpPluginStartupCleanupDiagnosticError(error, cleanupError);
3347
+ }
3348
+ throw attachMcpCleanupDiagnostic(error, cleanupError);
3349
+ }
3173
3350
  throw error;
3174
3351
  }
3352
+ const conventionalServerNames = new Set(
3353
+ Object.keys(conventionalConfigs).filter(name => !pluginNames.has(name)),
3354
+ );
3355
+ for (const tool of result.tools) {
3356
+ if (tool instanceof DeferredMCPTool && conventionalServerNames.has(tool.mcpServerName)) {
3357
+ cachedConventionalMcpServerNames.add(tool.mcpServerName);
3358
+ }
3359
+ }
3175
3360
  for (const [server, err] of result.errors) {
3176
- // A server that failed to connect leaves this generation incomplete: its
3177
- // surfaces produced no evidence, so publishing would present a partial
3178
- // pass as a clear one. On the deferred path this flag is already
3179
- // consumed (publication happens before the starter can run), which is
3180
- // acceptable only because deferral never carries plugin-bundle servers.
3181
- gjcProducersComplete = false;
3182
- if (!isMCPStartupTimeoutError(err)) {
3183
- logger.warn("GJC plugin MCP connect failed", {
3361
+ const provenance = pluginMcpProvenance.get(server);
3362
+ if (provenance) {
3363
+ gjcFindings.add({
3364
+ identity: provenance.identity,
3365
+ surfaceId: provenance.surfaceId,
3366
+ code: "runtime_mismatch",
3367
+ message: `MCP server connection failed: ${safePluginMcpDiagnostic(err)}`,
3368
+ decision: "error",
3369
+ provenance: {
3370
+ source: "plugin-bundle",
3371
+ plugin: provenance.identity.name,
3372
+ scope: provenance.identity.scope,
3373
+ },
3374
+ });
3375
+ }
3376
+ if (provenance) {
3377
+ logger.error("GJC plugin MCP connection failed", {
3378
+ path: `mcp:${server}`,
3379
+ error: safePluginMcpDiagnostic(err),
3380
+ });
3381
+ } else if (!isMCPStartupTimeoutError(err)) {
3382
+ logger.warn("MCP server connection failed", {
3184
3383
  path: `mcp:${server}`,
3185
- error: safeErrorForLog(err),
3384
+ // MCPLoadResult stores failures as strings, so their original
3385
+ // type is unavailable here; use the manager classifier's safe fallback.
3386
+ error: "transport-error",
3186
3387
  });
3187
3388
  }
3188
3389
  }
3189
- const connectedPluginNames = new Set(result.connectedServers.filter(name => pluginNames.has(name)));
3190
- // Retain while any conventional server is still live: "connecting"
3191
- // covers the declared-timeout window, and a server that landed in
3192
- // "connected" inside the microtask between connectServers() resolving
3193
- // and this synchronous check must not be torn down either.
3194
- const unsettledConventionalNames = Object.keys(conventionalConfigs).filter(
3195
- name => owned.getConnectionStatus(name) !== "disconnected",
3390
+ const failedPluginNames = new Set([...result.errors.keys()].filter(name => pluginMcpProvenance.has(name)));
3391
+ const connectedPluginNames = new Set(
3392
+ result.connectedServers.filter(name => pluginNames.has(name) && !failedPluginNames.has(name)),
3196
3393
  );
3197
- const retainOwnedManager = result.connectedServers.length > 0 || unsettledConventionalNames.length > 0;
3394
+ const trustedPluginServerNames = new Set(
3395
+ [...pluginMcpProvenance.keys()].filter(name => !failedPluginNames.has(name)),
3396
+ );
3397
+ const connectedTrustedPluginNames = new Set(
3398
+ [...connectedPluginNames].filter(name => trustedPluginServerNames.has(name)),
3399
+ );
3400
+ const unsettledPluginNames = [...pluginNames].filter(
3401
+ name =>
3402
+ !failedPluginNames.has(name) &&
3403
+ !connectedPluginNames.has(name) &&
3404
+ owned.getConnectionStatus(name) !== "disconnected",
3405
+ );
3406
+ let pluginCleanupFailed = false;
3407
+ for (const server of unsettledPluginNames) {
3408
+ const provenance = pluginMcpProvenance.get(server);
3409
+ if (!provenance) continue;
3410
+ let message =
3411
+ "MCP server did not produce a complete startup result within its declared startup window; its tools are unavailable for this session";
3412
+ let cleanupDiagnostic: string | undefined;
3413
+ failedPluginNames.add(server);
3414
+ try {
3415
+ await owned.disconnectServer(server);
3416
+ } catch (error) {
3417
+ pluginCleanupFailed = true;
3418
+ cleanupDiagnostic = safePluginMcpDiagnostic(error);
3419
+ }
3420
+ if (owned.getConnectionStatus(server) !== "disconnected") {
3421
+ pluginCleanupFailed = true;
3422
+ cleanupDiagnostic ??= "server remained connected or connecting after disconnect";
3423
+ }
3424
+ if (cleanupDiagnostic !== undefined) message += `; cleanup diagnostic: ${cleanupDiagnostic}`;
3425
+ gjcFindings.add({
3426
+ identity: provenance.identity,
3427
+ surfaceId: provenance.surfaceId,
3428
+ code: "runtime_mismatch",
3429
+ message,
3430
+ decision: "error",
3431
+ provenance: {
3432
+ source: "plugin-bundle",
3433
+ plugin: provenance.identity.name,
3434
+ scope: provenance.identity.scope,
3435
+ },
3436
+ });
3437
+ logger.error("GJC plugin MCP startup did not settle", {
3438
+ path: `mcp:${server}`,
3439
+ error: message,
3440
+ ...(cleanupDiagnostic === undefined ? {} : { cleanupDiagnostic }),
3441
+ });
3442
+ }
3443
+ failedPluginMcpManagerServers.set(owned, new Set(failedPluginNames));
3444
+ if (pluginCleanupFailed) pluginMcpCleanupFailures.add(owned);
3445
+ const successfulTools = result.tools.filter(
3446
+ tool => tool.mcpServerName === undefined || !failedPluginNames.has(tool.mcpServerName),
3447
+ );
3448
+ // Only a connection still in its startup window or a cached fallback
3449
+ // needs the mixed manager to remain mutable. Successfully connected
3450
+ // ordinary tools do not delay the synchronous seal; cached fallbacks keep
3451
+ // their reconnect path available while they remain published.
3452
+ const unsettledConventionalNames = [...conventionalServerNames].filter(
3453
+ name => owned.getConnectionStatus(name) === "connecting" || cachedConventionalMcpServerNames.has(name),
3454
+ );
3455
+ const retainOwnedManager =
3456
+ result.connectedServers.length > 0 ||
3457
+ unsettledConventionalNames.length > 0 ||
3458
+ successfulTools.length > 0 ||
3459
+ pluginCleanupFailed;
3198
3460
  if (retainOwnedManager) {
3199
3461
  mcpManager = owned;
3200
3462
  ownsMcpManager = true;
3201
- customTools.push(...(result.tools as CustomTool[]));
3202
- cwdCapturingToolNames.push(...result.tools.map(tool => tool.name));
3203
- for (const name of Object.keys(conventionalConfigs)) ownedConventionalMcpServerNames.add(name);
3204
- pluginMcpManagerServers.set(owned, connectedPluginNames);
3205
- conventionalMcpManagerServers.set(owned, new Set(Object.keys(conventionalConfigs)));
3206
- for (const tool of result.tools) {
3463
+ customTools.push(...(successfulTools as CustomTool[]));
3464
+ ownedMcpManagerTools = successfulTools as CustomTool[];
3465
+ ownedMcpManagerToolNames = successfulTools.map(tool => tool.name);
3466
+ cwdCapturingToolNames.push(...successfulTools.map(tool => tool.name));
3467
+ for (const name of conventionalServerNames) ownedConventionalMcpServerNames.add(name);
3468
+ pluginMcpManagerServers.set(owned, trustedPluginServerNames);
3469
+ conventionalMcpManagerServers.set(owned, conventionalServerNames);
3470
+ for (const tool of successfulTools) {
3207
3471
  const serverName = tool.mcpServerName;
3208
3472
  if (serverName === undefined) continue;
3209
- if (connectedPluginNames.has(serverName)) pluginMcpToolNames.push(tool.name);
3473
+ if (connectedTrustedPluginNames.has(serverName)) pluginMcpToolNames.push(tool.name);
3210
3474
  else {
3211
3475
  conventionalMcpToolNames.push(tool.name);
3212
- ownedConventionalMcpToolNames.push(tool.name);
3213
3476
  }
3214
3477
  }
3215
- publishOwnedConventionalMcpTools = ownedConventionalMcpServerNames.size > 0;
3216
- ownedPluginServersConnected = connectedPluginNames.size > 0;
3217
- // Plugin-bundle connections are fixed for the session lifetime only
3218
- // when no conventional server is still connecting in the same manager;
3219
- // otherwise the seal is re-applied once they settle (below).
3220
- if (connectedPluginNames.size > 0 && unsettledConventionalNames.length === 0) owned.sealConnectionSet();
3478
+ publishOwnedMcpTools = true;
3479
+ // Keep plugin config authority fixed even while a conventional cached
3480
+ // fallback needs reconnects; a full reload would disconnect and lose the
3481
+ // plugin-bundle configs because native discovery does not reload them.
3482
+ if (pluginNames.size > 0) {
3483
+ if (connectedTrustedPluginNames.size > 0 && unsettledConventionalNames.length === 0) {
3484
+ owned.sealConnectionSet();
3485
+ } else {
3486
+ owned.freezeConnectionSet();
3487
+ }
3488
+ }
3221
3489
  } else {
3222
3490
  try {
3223
- await owned.disconnectAll();
3224
- cleanupOwnedMcpManager = undefined;
3491
+ await cleanupOwned();
3225
3492
  } catch (cleanupError) {
3226
- cleanupOwnedMcpManager = undefined;
3227
3493
  throw new McpManagerCleanupError(cleanupError);
3228
3494
  }
3229
3495
  }
@@ -3240,7 +3506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3240
3506
  owned.setAuthStorage(authStorage);
3241
3507
  mcpManager = owned;
3242
3508
  ownsMcpManager = true;
3243
- cleanupOwnedMcpManager = () => owned.disconnectAll();
3509
+ registerOwnedMcpManagerCleanup(owned);
3244
3510
  if (options.deferMcpConfigStartup) {
3245
3511
  deferredExactMcpConfig = { manager: owned, configPath: explicitMcpConfigPath };
3246
3512
  } else {
@@ -3308,7 +3574,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3308
3574
  // tools are surfaced as always-on tools rather than gated behind MCP
3309
3575
  // selection.
3310
3576
  try {
3311
- const { configs, quarantine } = await buildPluginMcpConfigs({ cwd });
3577
+ const { configs, quarantine, serverProvenance: pluginMcpProvenance } = await buildPluginMcpConfigs({ cwd });
3312
3578
  for (const q of quarantine) {
3313
3579
  gjcFindings.add({
3314
3580
  identity: q.identity,
@@ -3335,9 +3601,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3335
3601
  // own bypass of the same mismatch at the manager boundary).
3336
3602
  const mergedSourceMetas = mergedSources as Record<string, SourceMeta>;
3337
3603
  if (Object.keys(mergedConfigs).length > 0) {
3338
- const owned = new MCPManager(cwd, null, { sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs") });
3604
+ const conventionalCacheServerNames = new Set(
3605
+ Object.keys(conventionalConfigs).filter(name => !pluginNames.has(name)),
3606
+ );
3607
+ const maxPluginMcpTimeoutMs = Object.values(configs).reduce((maximum, config) => {
3608
+ const timeout = config.timeout;
3609
+ return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
3610
+ ? Math.max(maximum, timeout)
3611
+ : maximum;
3612
+ }, 0);
3613
+ const maxPluginMcpStartupWaitMs =
3614
+ maxPluginMcpTimeoutMs > 0 ? maxPluginMcpTimeoutMs + MCP_STARTUP_WAIT_GRACE_MS : undefined;
3615
+ const owned = new MCPManager(
3616
+ cwd,
3617
+ conventionalCacheServerNames.size > 0 ? await getOwnedMcpToolCache() : null,
3618
+ {
3619
+ sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs"),
3620
+ toolCacheServerNames: conventionalCacheServerNames,
3621
+ ...(maxPluginMcpStartupWaitMs === undefined
3622
+ ? {}
3623
+ : { maxStartupTimeoutMs: maxPluginMcpStartupWaitMs }),
3624
+ },
3625
+ );
3339
3626
  owned.setAuthStorage(authStorage);
3340
- cleanupOwnedMcpManager = () => owned.disconnectAll();
3627
+ const cleanupOwned = registerOwnedMcpManagerCleanup(owned);
3341
3628
  // Interactive launches may paint before MCP connects; the deferred
3342
3629
  // starter reconnects this manager after first paint and a startup
3343
3630
  // turn barrier keeps the first prompt from racing tool registration.
@@ -3347,6 +3634,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3347
3634
  if (options.deferMcpConfigStartup && pluginNames.size === 0) {
3348
3635
  deferredConventionalMcp = {
3349
3636
  manager: owned,
3637
+ cleanup: cleanupOwned,
3350
3638
  configs: mergedConfigs,
3351
3639
  sources: mergedSourceMetas,
3352
3640
  conventionalConfigs,
@@ -3363,37 +3651,60 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3363
3651
  mergedSourceMetas,
3364
3652
  conventionalConfigs,
3365
3653
  pluginNames,
3654
+ pluginMcpProvenance,
3655
+ cleanupOwned,
3366
3656
  );
3367
3657
  }
3368
3658
  }
3369
3659
  } catch (error) {
3370
- if (safeIsInstanceOf(error, McpManagerCleanupError)) throw error;
3660
+ if (safeIsInstanceOf(error, McpStartupCleanupRetryError)) {
3661
+ throw (error as McpStartupCleanupRetryError).primaryError;
3662
+ }
3663
+ const pluginStartupCleanupFailure = safeIsInstanceOf(error, McpPluginStartupCleanupDiagnosticError)
3664
+ ? (error as McpPluginStartupCleanupDiagnosticError)
3665
+ : undefined;
3666
+ if (
3667
+ !pluginStartupCleanupFailure &&
3668
+ (safeIsInstanceOf(error, McpManagerCleanupError) ||
3669
+ safeIsInstanceOf(error, McpManagerCleanupDiagnosticError) ||
3670
+ safeReadCleanupDiagnostic(error) !== undefined)
3671
+ )
3672
+ throw error;
3371
3673
  gjcProducersComplete = false;
3372
- const cleanupDiagnostic = safeReadCleanupDiagnostic(error);
3674
+ const cleanupDiagnostic =
3675
+ pluginStartupCleanupFailure?.cleanupDiagnostic ?? safeReadCleanupDiagnostic(error);
3373
3676
  logger.warn("Failed to wire GJC plugin MCP servers", {
3374
- error: safeErrorForLog(error),
3677
+ error: pluginStartupCleanupFailure
3678
+ ? safePluginMcpDiagnostic(pluginStartupCleanupFailure.primaryError)
3679
+ : safeErrorForLog(error),
3375
3680
  cleanupDiagnostic: safeCleanupDiagnosticForLog(cleanupDiagnostic),
3376
3681
  });
3377
3682
  }
3378
3683
  } else if (isCanonicalSubSession) {
3379
- // Subagents inherit the parent's always-on plugin and conventional MCP
3380
- // tools WITHOUT owning the manager (no connect, no callbacks, no
3381
- // disposal). The facade is carried explicitly by the parent session
3382
- // scope; process-global state is never consulted for MCP routing.
3684
+ // Subagents inherit the parent's MCP tools without owning the manager.
3685
+ // They subscribe to catalog changes but never connect or dispose it; the
3686
+ // facade is carried explicitly by the parent scope.
3383
3687
  const inherited = mcpManager ?? options.inheritedMcpManager;
3384
3688
  if (inherited) {
3385
3689
  try {
3386
- const inheritedTools = inherited.getTools();
3387
- if (inheritedTools.length > 0) customTools.push(...(inheritedTools as CustomTool[]));
3388
- const pluginServers = pluginMcpManagerServers.get(inherited);
3389
- const conventionalServers = conventionalMcpManagerServers.get(inherited);
3390
- for (const tool of inheritedTools) {
3391
- const serverName = tool.mcpServerName;
3392
- if (serverName === undefined) continue;
3393
- if (pluginServers?.has(serverName)) pluginMcpToolNames.push(tool.name);
3394
- else if (conventionalServers?.has(serverName)) conventionalMcpToolNames.push(tool.name);
3395
- }
3690
+ inheritedMcpManager = inherited;
3691
+ inheritedMcpToolsUnsubscribe = inherited.subscribeToToolsChanged(tools => {
3692
+ const snapshot = tools.map(tool => tool as CustomTool);
3693
+ if (inheritedMcpToolsPublisher) inheritedMcpToolsPublisher(snapshot);
3694
+ else pendingInheritedMcpToolsSnapshot = snapshot;
3695
+ });
3696
+ const inheritedTools = inherited.getTools() as CustomTool[];
3697
+ inheritedMcpToolsSnapshot = inheritedTools;
3698
+ inheritedMcpManagerToolNames = inheritedTools.map(tool => tool.name);
3699
+ customTools.push(...inheritedTools);
3700
+ const classifiedTools = classifyInheritedMcpToolNames(inherited, inheritedTools);
3701
+ pluginMcpToolNames.push(...classifiedTools.pluginMcpToolNames);
3702
+ conventionalMcpToolNames.push(...classifiedTools.conventionalMcpToolNames);
3396
3703
  } catch (error) {
3704
+ stopInheritedMcpToolsSubscription();
3705
+ inheritedMcpManager = undefined;
3706
+ inheritedMcpToolsSnapshot = [];
3707
+ pendingInheritedMcpToolsSnapshot = undefined;
3397
3708
  logger.warn("Failed to inherit MCP tools in subagent", { error: safeErrorForLog(error) });
3398
3709
  }
3399
3710
  }
@@ -4960,6 +5271,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
4960
5271
  // carried by the host replay ring through the internal runtime seam above.
4961
5272
  if (autoroutingInactive) session.configWarnings.push(AUTOROUTING_INACTIVE_WARNING);
4962
5273
  hasSession = true;
5274
+ const cleanupOwnedManager = cleanupOwnedMcpManager;
5275
+ const sessionOwnedMcpManager = ownsMcpManager ? mcpManager : undefined;
5276
+ if (cleanupOwnedManager && cleanupOwnedMcpManagerOwner !== sessionOwnedMcpManager) {
5277
+ session.registerToolSessionCleanup(cleanupOwnedManager);
5278
+ }
4963
5279
  if (masterModeContext) {
4964
5280
  // One scoped, no-probe peer snapshot immediately before the FIRST accepted
4965
5281
  // provider request; see createMasterPeerSnapshotContributor for the
@@ -5141,59 +5457,166 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5141
5457
  // createAgentSession rather than surfacing as an unhandled rejection.
5142
5458
  if (!options.deferMemoryBackendStartup) await startMemoryBackend();
5143
5459
 
5144
- // Wire the owned manager's late tool publications into the live session.
5145
- // Shared by the eager startup path below and by the deferred conventional
5146
- // starter once its connect lands. No-op when there is no owned manager or
5147
- // nothing conventional to publish.
5148
- const wireOwnedConventionalToolSync = (): void => {
5149
- if (!mcpManager || !publishOwnedConventionalMcpTools) return;
5150
- // Late conventional connections can publish near-simultaneously.
5460
+ // Wire late catalog publications from every owned manager into the live
5461
+ // session. Plugin-only managers also change after startup through
5462
+ // tools/list_changed; mixed managers additionally publish cached fallbacks.
5463
+ // Shared by eager startup and the deferred starter.
5464
+ const wireOwnedMcpToolSync = (initialTools?: readonly CustomTool[]): void => {
5465
+ const manager = mcpManager;
5466
+ if (!manager || !publishOwnedMcpTools || manager.isToolsOnly()) return;
5467
+ // Late MCP connections can publish near-simultaneously.
5151
5468
  // Serialize the swaps so an older snapshot cannot interleave with a
5152
5469
  // newer one inside replaceNamedCustomTools and leave a stale list.
5153
5470
  // Each link swallows (and logs) its own failure so one bad
5154
5471
  // publication cannot kill the chain for every later one.
5155
- let conventionalToolsSync: Promise<void> = Promise.resolve();
5156
- const syncConventionalTools = (tools: CustomTool[]): Promise<void> => {
5157
- conventionalToolsSync = conventionalToolsSync
5472
+ let ownedMcpToolsSync: Promise<void> = Promise.resolve();
5473
+ let acceptingOwnedMcpTools = true;
5474
+ const syncOwnedMcpTools = (tools: readonly CustomTool[]): Promise<void> => {
5475
+ if (!acceptingOwnedMcpTools) return ownedMcpToolsSync;
5476
+ ownedMcpToolsSync = ownedMcpToolsSync
5158
5477
  .then(async () => {
5159
- if (session.isDisposed) return;
5160
- const nextTools = tools.filter(tool =>
5161
- tool.mcpServerName ? ownedConventionalMcpServerNames.has(tool.mcpServerName) : false,
5162
- );
5163
- const previousNames = ownedConventionalMcpToolNames;
5164
- ownedConventionalMcpToolNames = nextTools.map(tool => tool.name);
5478
+ if (session.isDisposed || mcpManager !== manager) return;
5479
+ const pluginServerNames = new Set(pluginMcpManagerServers.get(manager));
5480
+ const conventionalServerNames = new Set(conventionalMcpManagerServers.get(manager));
5481
+ // Plugin authority is fixed from validated bundle provenance when
5482
+ // this manager is created. SourceMeta can be caller-supplied later.
5483
+ for (const serverName of manager.getAllServerNames()) {
5484
+ if (!pluginServerNames.has(serverName)) conventionalServerNames.add(serverName);
5485
+ }
5486
+ for (const tool of tools) {
5487
+ const serverName = tool.mcpServerName;
5488
+ if (!serverName) continue;
5489
+ if (!pluginServerNames.has(serverName)) conventionalServerNames.add(serverName);
5490
+ }
5491
+ conventionalMcpManagerServers.set(manager, conventionalServerNames);
5492
+ const failedPluginServers = failedPluginMcpManagerServers.get(manager);
5493
+ const nextCachedConventionalMcpServerNames = new Set(cachedConventionalMcpServerNames);
5494
+ for (const serverName of nextCachedConventionalMcpServerNames) {
5495
+ if (!tools.some(tool => tool.mcpServerName === serverName)) {
5496
+ nextCachedConventionalMcpServerNames.delete(serverName);
5497
+ }
5498
+ }
5499
+ for (const tool of tools) {
5500
+ if (
5501
+ tool instanceof DeferredMCPTool &&
5502
+ tool.mcpServerName &&
5503
+ conventionalServerNames.has(tool.mcpServerName)
5504
+ ) {
5505
+ nextCachedConventionalMcpServerNames.add(tool.mcpServerName);
5506
+ }
5507
+ }
5508
+ const nextTools = tools.filter(tool => {
5509
+ const serverName = tool.mcpServerName;
5510
+ if (!serverName) return false;
5511
+ if (
5512
+ failedPluginServers?.has(serverName) &&
5513
+ manager.getConnectionStatus(serverName) !== "connected"
5514
+ ) {
5515
+ return false;
5516
+ }
5517
+ return pluginServerNames.has(serverName) || conventionalServerNames.has(serverName);
5518
+ });
5519
+ const previousNames = ownedMcpManagerToolNames;
5520
+ const nextToolNames = nextTools.map(tool => tool.name);
5165
5521
  const previousSet = new Set(previousNames);
5166
- cwdCapturingToolNames.splice(
5167
- 0,
5168
- cwdCapturingToolNames.length,
5522
+ const nextPluginToolNames = nextTools
5523
+ .filter(tool => tool.mcpServerName && pluginServerNames.has(tool.mcpServerName))
5524
+ .map(tool => tool.name);
5525
+ const nextConventionalToolNames = nextTools
5526
+ .filter(tool => tool.mcpServerName && conventionalServerNames.has(tool.mcpServerName))
5527
+ .map(tool => tool.name);
5528
+ const nextMandatoryMcpToolNames = [
5529
+ ...pluginMcpToolNames.filter(name => !previousSet.has(name)),
5530
+ ...nextPluginToolNames,
5531
+ ];
5532
+ const nextConventionalMcpToolNames = [
5533
+ ...conventionalMcpToolNames.filter(name => !previousSet.has(name)),
5534
+ ...nextConventionalToolNames,
5535
+ ];
5536
+ const nextCwdCapturingToolNames = [
5169
5537
  ...cwdCapturingToolNames.filter(name => !previousSet.has(name)),
5170
- ...ownedConventionalMcpToolNames,
5538
+ ...nextToolNames,
5539
+ ];
5540
+ const nextOwnedPluginServersConnected = [...pluginServerNames].some(
5541
+ name => manager.getConnectionStatus(name) === "connected",
5171
5542
  );
5172
- await session.replaceNamedCustomTools(previousNames, nextTools);
5173
- // Mixed plugin + conventional sessions deferred the seal while
5174
- // a conventional server was still connecting; restore the
5175
- // fixed-connection plugin contract once every conventional
5176
- // server has reached a terminal state.
5543
+ const nextOwnedConventionalMcpServerNames = new Set(conventionalServerNames);
5544
+ const hasPublishedCachedTool = nextTools.some(
5545
+ tool =>
5546
+ tool.mcpServerName !== undefined &&
5547
+ nextCachedConventionalMcpServerNames.has(tool.mcpServerName),
5548
+ );
5549
+ try {
5550
+ await session.replaceNamedCustomTools(previousNames, nextTools, {
5551
+ mandatoryMCPToolNames: nextMandatoryMcpToolNames,
5552
+ activateNewTools: false,
5553
+ });
5554
+ } catch (error) {
5555
+ ownedMcpManagerToolNames = [...new Set([...previousNames, ...nextToolNames])];
5556
+ throw error;
5557
+ }
5558
+ ownedMcpManagerToolNames = nextToolNames;
5559
+ ownedMcpManagerTools = nextTools;
5560
+ pluginMcpToolNames.splice(0, pluginMcpToolNames.length, ...nextMandatoryMcpToolNames);
5561
+ conventionalMcpToolNames.splice(0, conventionalMcpToolNames.length, ...nextConventionalMcpToolNames);
5562
+ cwdCapturingToolNames.splice(0, cwdCapturingToolNames.length, ...nextCwdCapturingToolNames);
5563
+ ownedConventionalMcpServerNames.clear();
5564
+ for (const name of nextOwnedConventionalMcpServerNames) ownedConventionalMcpServerNames.add(name);
5565
+ cachedConventionalMcpServerNames.clear();
5566
+ for (const name of nextCachedConventionalMcpServerNames) cachedConventionalMcpServerNames.add(name);
5567
+ // Mixed plugin + conventional sessions defer the seal while a
5568
+ // conventional server is connecting. Cached fallback servers retain
5569
+ // their reconnect path while any of their tools remain published,
5570
+ // including after reconnect replaces DeferredMCPTool with MCPTool.
5571
+ // Derive that hold from this snapshot so removing/replacing the tools
5572
+ // restores the fixed-connection plugin contract.
5177
5573
  if (
5178
- ownedPluginServersConnected &&
5179
- mcpManager !== undefined &&
5180
- !mcpManager.isConnectionSetSealed() &&
5181
- ![...ownedConventionalMcpServerNames].some(
5182
- name => mcpManager?.getConnectionStatus(name) === "connecting",
5183
- )
5574
+ nextOwnedPluginServersConnected &&
5575
+ !manager.isConnectionSetSealed() &&
5576
+ ![...nextOwnedConventionalMcpServerNames].some(
5577
+ name => manager.getConnectionStatus(name) === "connecting",
5578
+ ) &&
5579
+ !hasPublishedCachedTool
5184
5580
  ) {
5185
- mcpManager.sealConnectionSet();
5581
+ manager.sealConnectionSet();
5186
5582
  }
5187
5583
  })
5188
5584
  .catch(error => {
5189
- logger.warn("Failed to publish conventional MCP tools", { error: safeErrorForLog(error) });
5585
+ logger.warn("Failed to publish owned MCP tools", { error: safeErrorForLog(error) });
5190
5586
  });
5191
- return conventionalToolsSync;
5587
+ return ownedMcpToolsSync;
5192
5588
  };
5193
- mcpManager.setOnToolsChanged(tools => {
5194
- void syncConventionalTools(tools as CustomTool[]);
5589
+ session.registerToolSessionCleanup(async () => {
5590
+ acceptingOwnedMcpTools = false;
5591
+ await ownedMcpToolsSync;
5592
+ });
5593
+ manager.setOnToolsChanged(tools => {
5594
+ void syncOwnedMcpTools(tools);
5195
5595
  });
5196
- void syncConventionalTools(mcpManager.getTools() as CustomTool[]);
5596
+ const catalog = manager.getToolCatalogSnapshot();
5597
+ if (initialTools !== undefined) {
5598
+ // Deferred startup already registered its captured result. Re-read
5599
+ // after that await so a publication during registration is not lost.
5600
+ const currentTools =
5601
+ catalog.publication === "published" ? catalog.tools.map(tool => tool as CustomTool) : initialTools;
5602
+ void syncOwnedMcpTools(currentTools);
5603
+ } else if (catalog.publication === "published") {
5604
+ const failedPluginServers = failedPluginMcpManagerServers.get(manager);
5605
+ const catalogTools = catalog.tools
5606
+ .filter(tool => {
5607
+ const serverName = tool.mcpServerName;
5608
+ return (
5609
+ serverName !== undefined &&
5610
+ (!failedPluginServers?.has(serverName) || manager.getConnectionStatus(serverName) === "connected")
5611
+ );
5612
+ })
5613
+ .map(tool => tool as CustomTool);
5614
+ if (!sameMcpToolSnapshot(catalogTools, ownedMcpManagerTools)) {
5615
+ void syncOwnedMcpTools(catalog.tools.map(tool => tool as CustomTool));
5616
+ }
5617
+ } else if (!pluginMcpCleanupFailures.has(manager)) {
5618
+ void syncOwnedMcpTools(ownedMcpManagerTools);
5619
+ }
5197
5620
  };
5198
5621
  const wireOwnedMcpManagerLifecycle = (): void => {
5199
5622
  if (!mcpManager) return;
@@ -5204,20 +5627,66 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5204
5627
  postmortem.register("mcp-notification-cleanup", clearDebounceTimers);
5205
5628
  wireMcpManagerCallbacks(mcpManager);
5206
5629
  };
5630
+ const wireInheritedMcpToolSync = async (): Promise<void> => {
5631
+ const manager = inheritedMcpManager;
5632
+ if (!manager) return;
5633
+ let inheritedMcpToolsSync: Promise<void> = Promise.resolve();
5634
+ const syncInheritedTools = (tools: readonly CustomTool[]): Promise<void> => {
5635
+ const snapshot = [...tools];
5636
+ inheritedMcpToolsSync = inheritedMcpToolsSync
5637
+ .then(async () => {
5638
+ if (session.isDisposed) return;
5639
+ const previousNames = inheritedMcpManagerToolNames;
5640
+ const nextNames = snapshot.map(tool => tool.name);
5641
+ const classifiedTools = classifyInheritedMcpToolNames(manager, snapshot);
5642
+ try {
5643
+ await session.replaceNamedCustomTools(previousNames, snapshot, {
5644
+ mandatoryMCPToolNames: classifiedTools.pluginMcpToolNames,
5645
+ activateNewTools: false,
5646
+ });
5647
+ } catch (error) {
5648
+ inheritedMcpManagerToolNames = [...new Set([...previousNames, ...nextNames])];
5649
+ throw error;
5650
+ }
5651
+ inheritedMcpManagerToolNames = nextNames;
5652
+ pluginMcpToolNames.splice(0, pluginMcpToolNames.length, ...classifiedTools.pluginMcpToolNames);
5653
+ conventionalMcpToolNames.splice(
5654
+ 0,
5655
+ conventionalMcpToolNames.length,
5656
+ ...classifiedTools.conventionalMcpToolNames,
5657
+ );
5658
+ })
5659
+ .catch(error => {
5660
+ logger.warn("Failed to publish inherited MCP tools", { error: safeErrorForLog(error) });
5661
+ });
5662
+ return inheritedMcpToolsSync;
5663
+ };
5664
+ inheritedMcpToolsPublisher = tools => {
5665
+ void syncInheritedTools(tools);
5666
+ };
5667
+ session.registerToolSessionCleanup(async () => {
5668
+ stopInheritedMcpToolsSubscription();
5669
+ await inheritedMcpToolsSync;
5670
+ });
5671
+ const initialSnapshot = pendingInheritedMcpToolsSnapshot ?? inheritedMcpToolsSnapshot;
5672
+ pendingInheritedMcpToolsSnapshot = undefined;
5673
+ await syncInheritedTools(initialSnapshot);
5674
+ };
5207
5675
  // Exact-config managers do not receive reactive callbacks; their tools are
5208
5676
  // registered once in the session-owned catalog. A pending conventional
5209
5677
  // deferral wires its own callbacks from the starter after it connects, so
5210
5678
  // nothing is wired here yet.
5211
5679
  if (mcpManager && !options.mcpManager && explicitMcpConfigPath === undefined && !deferredConventionalMcp) {
5212
- if (publishOwnedConventionalMcpTools) {
5213
- wireOwnedConventionalToolSync();
5680
+ if (publishOwnedMcpTools) {
5681
+ wireOwnedMcpToolSync();
5214
5682
  } else if (!ownsMcpManager) {
5215
5683
  mcpManager.setOnToolsChanged(tools => {
5216
- void session.refreshMCPTools(tools);
5684
+ void session.refreshMCPTools(tools.map(tool => tool as CustomTool));
5217
5685
  });
5218
5686
  }
5219
5687
  wireOwnedMcpManagerLifecycle();
5220
5688
  }
5689
+ await wireInheritedMcpToolSync();
5221
5690
 
5222
5691
  // Constructor-time workflow-gate tool restoration is deferred by one
5223
5692
  // microtask (the ToolSession closure needs `session` assigned). Await it
@@ -5289,6 +5758,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5289
5758
  conventional.sources,
5290
5759
  conventional.conventionalConfigs,
5291
5760
  new Set<string>(),
5761
+ new Map<string, GjcPluginMcpServerProvenance>(),
5762
+ conventional.cleanup,
5292
5763
  );
5293
5764
  const resultTools = result.tools as CustomTool[];
5294
5765
  if (!cancelled && !session.isDisposed) {
@@ -5298,7 +5769,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5298
5769
  // before this registration would therefore drop a persisted conventional
5299
5770
  // MCP selection and persist the empty set.
5300
5771
  await session.refreshMCPTools(resultTools);
5301
- wireOwnedConventionalToolSync();
5772
+ wireOwnedMcpToolSync(resultTools);
5302
5773
  wireOwnedMcpManagerLifecycle();
5303
5774
  if (
5304
5775
  !session.isDisposed &&
@@ -5356,6 +5827,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5356
5827
  } catch (error) {
5357
5828
  // Release the subscription if the throw happened after install but before the
5358
5829
  // dispose-wrap took ownership.
5830
+ stopInheritedMcpToolsSubscription();
5359
5831
  releaseCredentialDisabledSubscription();
5360
5832
  let cleanupDiagnostic: unknown;
5361
5833
  try {