@gajae-code/coding-agent 0.17.4 → 0.17.5

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 (84) hide show
  1. package/CHANGELOG.md +73 -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/managed-task-dag.d.ts +1335 -0
  18. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  19. package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
  20. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  21. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
  22. package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
  23. package/dist/types/sdk/session.d.ts +3 -3
  24. package/dist/types/session/agent-session.d.ts +7 -2
  25. package/dist/types/task/types.d.ts +2 -0
  26. package/dist/types/tools/bash-interactive.d.ts +1 -0
  27. package/dist/types/tools/fetch.d.ts +10 -1
  28. package/dist/types/tools/subagent.d.ts +8 -1
  29. package/package.json +7 -7
  30. package/src/cli/public-command-registry.ts +8 -2
  31. package/src/cli/setup-cli.ts +1 -0
  32. package/src/cli/stats-cli.ts +11 -1
  33. package/src/config/autorouting-tier-map.ts +529 -3
  34. package/src/config/file-lock.ts +59 -14
  35. package/src/config/model-discovery-manager.ts +4 -4
  36. package/src/config/model-profile-activation.ts +83 -23
  37. package/src/config/model-registry.ts +72 -12
  38. package/src/config/model-resolver.ts +6 -1
  39. package/src/coordinator-mcp/question-state.ts +4 -1
  40. package/src/coordinator-mcp/server.ts +14 -6
  41. package/src/exec/bash-executor.ts +3 -0
  42. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  43. package/src/gjc-runtime/state-writer.ts +317 -6
  44. package/src/harness-control-plane/control-endpoint.ts +24 -6
  45. package/src/harness-control-plane/finalize.ts +312 -25
  46. package/src/harness-control-plane/owner.ts +323 -89
  47. package/src/internal-urls/docs-index.generated.ts +6 -4
  48. package/src/main.ts +69 -19
  49. package/src/modes/acp/acp-agent.ts +581 -37
  50. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  51. package/src/modes/components/tool-status-header.ts +72 -6
  52. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  53. package/src/runtime-mcp/loader.ts +6 -3
  54. package/src/runtime-mcp/manager.ts +177 -75
  55. package/src/sdk/broker/broker.ts +1057 -150
  56. package/src/sdk/broker/ensure.ts +21 -3
  57. package/src/sdk/broker/identity.ts +13 -0
  58. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  59. package/src/sdk/broker/managed-task-verification.ts +642 -0
  60. package/src/sdk/broker/spawn-authority.ts +140 -7
  61. package/src/sdk/broker/startup-failure.ts +80 -1
  62. package/src/sdk/broker/transport.ts +1 -0
  63. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  64. package/src/sdk/bus/index.ts +22 -6
  65. package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
  66. package/src/sdk/cli/session-cli.ts +18 -0
  67. package/src/sdk/host/session-runtime.test.ts +46 -0
  68. package/src/sdk/host/session-runtime.ts +13 -6
  69. package/src/sdk/lifecycle/service.ts +2 -0
  70. package/src/sdk/prompt-deadline-flush.ts +26 -4
  71. package/src/sdk/session.ts +587 -115
  72. package/src/session/agent-session.ts +40 -12
  73. package/src/session/internal/managed-session-scope.ts +10 -0
  74. package/src/session/internal/managed-session-storage.ts +30 -1
  75. package/src/setup/provider-presets.json +2 -1
  76. package/src/slash-commands/builtin-registry.ts +1 -1
  77. package/src/task/executor.ts +46 -17
  78. package/src/task/types.ts +2 -0
  79. package/src/tools/bash-interactive.ts +2 -0
  80. package/src/tools/bash.ts +20 -0
  81. package/src/tools/fetch.ts +21 -5
  82. package/src/tools/subagent-render.ts +24 -1
  83. package/src/tools/subagent.ts +27 -1
  84. package/src/utils/mupdf.ts +11 -4
@@ -1,4 +1,4 @@
1
- import { Container, type SelectItem, SelectList, Spacer, Text } from "@gajae-code/tui";
1
+ import { Container, type SelectItem, SelectList, Spacer, Text, truncateToWidth } from "@gajae-code/tui";
2
2
  import {
3
3
  applyGjcBundleUpdate,
4
4
  type GjcLifecycleContext,
@@ -20,8 +20,11 @@ import type {
20
20
  GjcUpdatePreview,
21
21
  } from "../../extensibility/gjc-plugins/types";
22
22
  import { getSelectListTheme, theme } from "../../modes/theme/theme";
23
+ import { sanitizeStatusText } from "../shared";
23
24
  import { DynamicBorder } from "./dynamic-border";
24
25
 
26
+ const MAX_RUNTIME_FINDING_DISPLAY_WIDTH = 120;
27
+
25
28
  /** Injectable lifecycle boundary; Settings never reads registries or executes bundle code. */
26
29
  export interface GjcBundleLifecyclePort {
27
30
  listGjcBundles(ctx: GjcLifecycleContext): Promise<GjcBundleSummary[]>;
@@ -244,6 +247,23 @@ export class GjcBundleSettingsComponent extends Container {
244
247
  ),
245
248
  );
246
249
  }
250
+ const runtime = findingsForBundle(
251
+ this.dependencies.runtimeSnapshotProvider,
252
+ bundle.identity,
253
+ this.dependencies.activationGeneration ?? 0,
254
+ );
255
+ if (runtime.status === "current") {
256
+ for (const finding of runtime.findings) {
257
+ const detail = truncateToWidth(
258
+ sanitizeStatusText(`${finding.surfaceId}: ${finding.message}`),
259
+ MAX_RUNTIME_FINDING_DISPLAY_WIDTH,
260
+ );
261
+ const label = finding.decision === "error" ? "Runtime error" : "Runtime finding";
262
+ this.addChild(
263
+ new Text(theme.fg(finding.decision === "error" ? "error" : "warning", ` ${label}: ${detail}`), 0, 0),
264
+ );
265
+ }
266
+ }
247
267
  if (this.#message) this.addChild(new Text(theme.fg("warning", ` ${this.#message}`), 0, 0));
248
268
  const actions: SelectItem[] = [];
249
269
  if (bundle.enabled || !bundle.quarantined) {
@@ -41,6 +41,33 @@ import { getSeparator } from "./status-line/separators";
41
41
  import { calculateTokensPerSecond } from "./status-line/token-rate";
42
42
  import type { SeparatorDef } from "./status-line/types";
43
43
 
44
+ function usageReportAccountId(report: unknown): string | undefined {
45
+ if (!report || typeof report !== "object") return undefined;
46
+ const record = report as {
47
+ metadata?: unknown;
48
+ limits?: unknown;
49
+ };
50
+ const metadata = record.metadata;
51
+ const rawMetadataAccountId =
52
+ metadata && typeof metadata === "object" ? (metadata as { accountId?: unknown }).accountId : undefined;
53
+ const metadataAccountId =
54
+ typeof rawMetadataAccountId === "string" && rawMetadataAccountId.trim() ? rawMetadataAccountId.trim() : undefined;
55
+ if (!Array.isArray(record.limits)) return metadataAccountId;
56
+
57
+ const scopeAccountIds = new Set<string>();
58
+ for (const limit of record.limits) {
59
+ if (!limit || typeof limit !== "object") continue;
60
+ const scope = (limit as { scope?: unknown }).scope;
61
+ if (!scope || typeof scope !== "object") continue;
62
+ const accountId = (scope as { accountId?: unknown }).accountId;
63
+ if (typeof accountId === "string" && accountId.trim()) scopeAccountIds.add(accountId.trim());
64
+ }
65
+ if (scopeAccountIds.size > 1) return undefined;
66
+ const [scopeAccountId] = scopeAccountIds;
67
+ if (metadataAccountId && scopeAccountId && metadataAccountId !== scopeAccountId) return undefined;
68
+ return metadataAccountId ?? scopeAccountId;
69
+ }
70
+
44
71
  export interface StatusLineSegmentOptions {
45
72
  model?: { showThinkingLevel?: boolean; showContextPercent?: boolean };
46
73
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
@@ -218,6 +245,7 @@ export class StatusLineComponent implements Component {
218
245
  #cachedUsage: SegmentContext["usage"] = null;
219
246
  #cachedUsageReports: unknown = null;
220
247
  #cachedUsageProvider: string | undefined;
248
+ #cachedUsageAccountId: string | undefined;
221
249
  #usageFetchedAt = 0;
222
250
  #usageInFlight = false;
223
251
 
@@ -702,7 +730,12 @@ export class StatusLineComponent implements Component {
702
730
  .then(reports => {
703
731
  this.#cachedUsageReports = reports;
704
732
  this.#cachedUsageProvider = this.#activeUsageProvider();
705
- this.#cachedUsage = this.#normalizeUsageReports(reports);
733
+ this.#cachedUsageAccountId = this.#activeCodexAccountId(this.#cachedUsageProvider);
734
+ this.#cachedUsage = this.#normalizeUsageReports(
735
+ reports,
736
+ this.#cachedUsageProvider,
737
+ this.#cachedUsageAccountId,
738
+ );
706
739
  this.#usageFetchedAt = Date.now();
707
740
  if (this.#onBranchChange) {
708
741
  this.#onBranchChange();
@@ -720,10 +753,25 @@ export class StatusLineComponent implements Component {
720
753
  });
721
754
  }
722
755
 
723
- #normalizeUsageReports(reports: unknown): SegmentContext["usage"] {
724
- const activeProvider = this.#activeUsageProvider();
725
-
756
+ #normalizeUsageReports(
757
+ reports: unknown,
758
+ activeProvider = this.#activeUsageProvider(),
759
+ activeCodexAccountId = this.#activeCodexAccountId(activeProvider),
760
+ ): SegmentContext["usage"] {
726
761
  if (!activeProvider || !Array.isArray(reports)) return null;
762
+ const activeCodexReportCount =
763
+ activeProvider === "openai-codex" && activeCodexAccountId
764
+ ? reports.filter(report => {
765
+ if (!report || typeof report !== "object") return false;
766
+ const provider = (report as { provider?: unknown }).provider;
767
+ return (
768
+ typeof provider === "string" &&
769
+ resolveOAuthStorageProvider(provider) === activeProvider &&
770
+ usageReportAccountId(report) === activeCodexAccountId
771
+ );
772
+ }).length
773
+ : 0;
774
+ const ambiguousActiveCodexReports = activeCodexReportCount > 1;
727
775
  const windows: NonNullable<SegmentContext["usage"]>["windows"] = [];
728
776
  const seen = new Set<string>();
729
777
  const now = Date.now();
@@ -759,6 +807,14 @@ export class StatusLineComponent implements Component {
759
807
  const provider = (report as { provider?: unknown }).provider;
760
808
  const providerId = typeof provider === "string" ? resolveOAuthStorageProvider(provider) : undefined;
761
809
  if (providerId !== activeProvider) continue;
810
+ // Codex usage fetches cover every OAuth account; fail closed unless this session's account matches uniquely.
811
+ if (
812
+ providerId === "openai-codex" &&
813
+ (!activeCodexAccountId ||
814
+ ambiguousActiveCodexReports ||
815
+ usageReportAccountId(report) !== activeCodexAccountId)
816
+ )
817
+ continue;
762
818
  const limits = (report as { limits?: unknown }).limits;
763
819
  if (!Array.isArray(limits)) continue;
764
820
  for (const limit of limits) {
@@ -808,11 +864,21 @@ export class StatusLineComponent implements Component {
808
864
  return typeof provider === "string" && provider.length > 0 ? resolveOAuthStorageProvider(provider) : undefined;
809
865
  }
810
866
 
867
+ #activeCodexAccountId(activeProvider: string | undefined): string | undefined {
868
+ if (activeProvider !== "openai-codex") return undefined;
869
+ const modelRegistry = this.session.modelRegistry;
870
+ return modelRegistry.authStorage.getOAuthAccountId(activeProvider, this.session.credentialSessionId, {
871
+ owner: modelRegistry.getAuthStorageOwner(),
872
+ });
873
+ }
874
+
811
875
  #syncCachedUsageForActiveProvider(): void {
812
876
  const activeProvider = this.#activeUsageProvider();
813
- if (this.#cachedUsageProvider === activeProvider) return;
877
+ const activeCodexAccountId = this.#activeCodexAccountId(activeProvider);
878
+ if (this.#cachedUsageProvider === activeProvider && this.#cachedUsageAccountId === activeCodexAccountId) return;
814
879
  this.#cachedUsageProvider = activeProvider;
815
- this.#cachedUsage = this.#normalizeUsageReports(this.#cachedUsageReports);
880
+ this.#cachedUsageAccountId = activeCodexAccountId;
881
+ this.#cachedUsage = this.#normalizeUsageReports(this.#cachedUsageReports, activeProvider, activeCodexAccountId);
816
882
  }
817
883
 
818
884
  #buildSegmentContext(
@@ -1606,7 +1606,7 @@ export class MCPCommandController {
1606
1606
  if (!this.ctx.mcpManager) {
1607
1607
  return;
1608
1608
  }
1609
- if (this.ctx.mcpManager.isConnectionSetSealed()) {
1609
+ if (this.ctx.mcpManager.isConnectionSetMutationBlocked()) {
1610
1610
  throw new Error("This session's plugin-bundle MCP connections are fixed for its lifetime.");
1611
1611
  }
1612
1612
 
@@ -44,10 +44,13 @@ export interface MCPToolsLoadOptions {
44
44
  sharedPoolIdleMs?: number;
45
45
  }
46
46
 
47
- async function resolveToolCache(storage: AgentStorage | null | undefined): Promise<MCPToolCache | null> {
47
+ export async function resolveMCPToolCache(
48
+ storage?: AgentStorage | null,
49
+ dbPath?: string,
50
+ ): Promise<MCPToolCache | null> {
48
51
  if (storage === null) return null;
49
52
  try {
50
- const resolved = storage ?? (await AgentStorage.open());
53
+ const resolved = storage ?? (await AgentStorage.open(dbPath));
51
54
  return new MCPToolCache(resolved);
52
55
  } catch (error) {
53
56
  logger.warn("MCP tool cache unavailable", { error: String(error) });
@@ -63,7 +66,7 @@ async function resolveToolCache(storage: AgentStorage | null | undefined): Promi
63
66
  * @returns MCP tools in LoadedCustomTool format for integration
64
67
  */
65
68
  export async function discoverAndLoadMCPTools(cwd: string, options?: MCPToolsLoadOptions): Promise<MCPToolsLoadResult> {
66
- const toolCache = await resolveToolCache(options?.cacheStorage);
69
+ const toolCache = await resolveMCPToolCache(options?.cacheStorage);
67
70
  const manager = new MCPManager(cwd, toolCache, { sharedPoolIdleMs: options?.sharedPoolIdleMs });
68
71
  if (options?.authStorage) {
69
72
  manager.setAuthStorage(options.authStorage);
@@ -52,7 +52,7 @@ import type {
52
52
  MCPServerConnection,
53
53
  MCPToolDefinition,
54
54
  } from "./types";
55
- import { MCPExpectedFailure, MCPNotificationMethods } from "./types";
55
+ import { MCPExpectedFailure, MCPHttpRequestError, MCPJsonRpcError, MCPNotificationMethods } from "./types";
56
56
 
57
57
  type ToolLoadResult = {
58
58
  connection: MCPServerConnection;
@@ -158,6 +158,24 @@ export function withinDeclaredConnectionWindow(config: MCPServerConfig, elapsedM
158
158
  return elapsedMs < timeout;
159
159
  }
160
160
 
161
+ /** Keep startup logs bounded and free of remote response text. */
162
+ function classifyMCPStartupFailure(error: unknown): string {
163
+ if (error instanceof MCPExpectedFailure) {
164
+ return error.cause === undefined ? "expected-mcp-failure" : classifyMCPStartupFailure(error.cause);
165
+ }
166
+ if (error instanceof MCPHttpRequestError) {
167
+ return Number.isInteger(error.status) && error.status >= 100 && error.status <= 599
168
+ ? `http-status:${error.status}`
169
+ : "http-request-error";
170
+ }
171
+ if (error instanceof MCPJsonRpcError) {
172
+ return Number.isInteger(error.code) && Math.abs(error.code) <= 1_000_000
173
+ ? `json-rpc-code:${error.code}`
174
+ : "json-rpc-error";
175
+ }
176
+ return "transport-error";
177
+ }
178
+
161
179
  function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
162
180
  const tracked: TrackedPromise<T> = { promise, status: "pending" };
163
181
  promise.then(
@@ -312,10 +330,10 @@ export interface MCPManagerOptions {
312
330
  /** Restrict this instance to tools from an explicit MCP config. */
313
331
  toolsOnly?: boolean;
314
332
  /**
315
- * Ceiling for the startup wait, in milliseconds. Only ACP lifecycle launches
316
- * set this, so a slow ACP MCP handshake gets the readiness budget while every
317
- * other consumer keeps the short default. Non-positive or non-finite values
318
- * are ignored and the default applies.
333
+ * Ceiling for the startup wait, in milliseconds. ACP lifecycle launches and
334
+ * plugin MCP startup use bounded caller-owned budgets; other consumers keep
335
+ * the short default. Non-positive or non-finite values are ignored and the
336
+ * default applies.
319
337
  */
320
338
  maxStartupTimeoutMs?: number;
321
339
  /** Connection pool used for every physical MCP open/close. */
@@ -324,6 +342,8 @@ export interface MCPManagerOptions {
324
342
  sessionId?: string;
325
343
  /** Idle retention for shared pool entries. */
326
344
  sharedPoolIdleMs?: number;
345
+ /** Limit tool-cache reads and writes to this manager's conventional servers. */
346
+ toolCacheServerNames?: ReadonlySet<string>;
327
347
  /** Test seam for deterministic reconnect backoff scheduling. */
328
348
  sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
329
349
  /** Test seam for fencing the acquisition-to-registration replacement race. */
@@ -412,7 +432,8 @@ export class MCPManager {
412
432
  #authStorage: AuthStorage | null = null;
413
433
  #inputRequestHandler: MCPInputRequestHandler | null = null;
414
434
  #onNotification?: (serverName: string, method: string, params: unknown) => void;
415
- #onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
435
+ #onToolsChanged?: (tools: readonly Readonly<CustomTool<TSchema, MCPToolDetails>>[]) => void;
436
+ readonly #toolChangeListeners = new Set<(tools: readonly Readonly<CustomTool<TSchema, MCPToolDetails>>[]) => void>();
416
437
  #onResourcesChanged?: (serverName: string, uri: string) => void;
417
438
  #onPromptsChanged?: (serverName: string) => void;
418
439
  #notificationsEnabled = false;
@@ -438,13 +459,21 @@ export class MCPManager {
438
459
  #scopedLifecycle: "open" | "disconnecting" | "reconnecting" = "open";
439
460
  #scopedLifecycleEpoch = 0;
440
461
  readonly #toolsOnly: boolean;
462
+ #toolCacheServerNames: Set<string> | undefined;
441
463
  #toolsOnlyConfigLoaded = false;
464
+ #connectionSetMutationBlocked = false;
442
465
  #connectionSetSealed = false;
443
466
 
444
467
  #serverError(message: string): string {
445
468
  return this.#toolsOnly ? "MCP server unavailable" : message;
446
469
  }
447
470
 
471
+ #shouldCacheServerTools(name: string): boolean {
472
+ return (
473
+ this.toolCache !== null && (this.#toolCacheServerNames === undefined || this.#toolCacheServerNames.has(name))
474
+ );
475
+ }
476
+
448
477
  #removePendingAcquireCleanup(name: string, error: MCPPoolAcquireAbortError): void {
449
478
  const pending = this.#pendingAcquireCleanups.get(name);
450
479
  pending?.delete(error);
@@ -548,13 +577,27 @@ export class MCPManager {
548
577
  return this.#scopedLifecycleEpoch;
549
578
  }
550
579
  #assertConnectionSetMutable(): void {
551
- if (this.#connectionSetSealed) throw new Error("MCP manager connection set is sealed");
580
+ if (this.#connectionSetMutationBlocked) {
581
+ throw new Error(
582
+ this.#connectionSetSealed ? "MCP manager connection set is sealed" : "MCP manager connection set is frozen",
583
+ );
584
+ }
585
+ }
586
+
587
+ /** Block config mutations and reloads while keeping existing servers reconnectable. */
588
+ freezeConnectionSet(): void {
589
+ this.#connectionSetMutationBlocked = true;
552
590
  }
553
591
 
554
592
  sealConnectionSet(): void {
593
+ this.freezeConnectionSet();
555
594
  this.#connectionSetSealed = true;
556
595
  }
557
596
 
597
+ isConnectionSetMutationBlocked(): boolean {
598
+ return this.#connectionSetMutationBlocked;
599
+ }
600
+
558
601
  isConnectionSetSealed(): boolean {
559
602
  return this.#connectionSetSealed;
560
603
  }
@@ -951,6 +994,7 @@ export class MCPManager {
951
994
  options: MCPManagerOptions = {},
952
995
  ) {
953
996
  this.#toolsOnly = options.toolsOnly === true;
997
+ this.#toolCacheServerNames = options.toolCacheServerNames ? new Set(options.toolCacheServerNames) : undefined;
954
998
  this.#maxStartupTimeoutMs = options.maxStartupTimeoutMs;
955
999
  this.#sleep = options.sleep ?? delay;
956
1000
  this.#afterLeaseAcquiredForTests = options.afterLeaseAcquiredForTests;
@@ -972,13 +1016,46 @@ export class MCPManager {
972
1016
  }
973
1017
 
974
1018
  /**
975
- * Set a callback to fire when any server's tools change.
1019
+ * Set a callback to receive a read-only catalog snapshot when server tools change.
976
1020
  */
977
- setOnToolsChanged(handler: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void): void {
1021
+ setOnToolsChanged(handler: (tools: readonly Readonly<CustomTool<TSchema, MCPToolDetails>>[]) => void): void {
978
1022
  if (this.#toolsOnly) return;
979
1023
  this.#onToolsChanged = handler;
980
1024
  }
981
1025
 
1026
+ /** Subscribe to readonly catalog snapshots without replacing the manager owner's callback. */
1027
+ subscribeToToolsChanged(
1028
+ handler: (tools: readonly Readonly<CustomTool<TSchema, MCPToolDetails>>[]) => void,
1029
+ ): () => void {
1030
+ if (this.#toolsOnly) return () => {};
1031
+ this.#toolChangeListeners.add(handler);
1032
+ return () => {
1033
+ this.#toolChangeListeners.delete(handler);
1034
+ };
1035
+ }
1036
+
1037
+ #notifyToolsChanged(): void {
1038
+ const listeners = [...this.#toolChangeListeners];
1039
+ // Keep live MCPTool instances for reconnect behavior, but never expose the
1040
+ // manager-owned array; callback types make their tool descriptors readonly.
1041
+ const snapshot = this.#onToolsChanged || listeners.length > 0 ? Object.freeze([...this.#tools]) : undefined;
1042
+ if (snapshot && this.#onToolsChanged) {
1043
+ try {
1044
+ this.#onToolsChanged(snapshot);
1045
+ } catch (error) {
1046
+ logger.warn("MCP tool catalog owner callback failed", { error: classifyMCPStartupFailure(error) });
1047
+ }
1048
+ }
1049
+ if (!snapshot) return;
1050
+ for (const listener of listeners) {
1051
+ try {
1052
+ listener(snapshot);
1053
+ } catch (error) {
1054
+ logger.warn("MCP tool catalog listener failed", { error: classifyMCPStartupFailure(error) });
1055
+ }
1056
+ }
1057
+ }
1058
+
982
1059
  /**
983
1060
  * Set a callback to fire when any server's resources change.
984
1061
  */
@@ -1107,6 +1184,11 @@ export class MCPManager {
1107
1184
  nativeOnly: options?.nativeOnly,
1108
1185
  configPath: options?.configPath,
1109
1186
  });
1187
+ if (this.#toolCacheServerNames !== undefined) {
1188
+ this.#toolCacheServerNames = new Set(
1189
+ Object.keys(configs).filter(name => sources[name]?.provider !== "gjc-plugins"),
1190
+ );
1191
+ }
1110
1192
  const result = await this.#connectServers(configs, sources, options?.onConnecting);
1111
1193
  if (configurationWarning) result.errors.set("$config", "MCP configuration unavailable");
1112
1194
  result.exaApiKeys = exaApiKeys;
@@ -1362,8 +1444,9 @@ export class MCPManager {
1362
1444
  this.#exactToolOptions(name, config.sharing === "shared"),
1363
1445
  );
1364
1446
  this.#replaceServerTools(name, customTools);
1365
- if (!this.#toolsOnly) this.#onToolsChanged?.(this.#tools);
1366
- if (!this.#toolsOnly) void this.toolCache?.set(name, config, serverTools);
1447
+ if (!this.#toolsOnly) this.#notifyToolsChanged();
1448
+ if (!this.#toolsOnly && this.#shouldCacheServerTools(name))
1449
+ void this.toolCache?.set(name, config, serverTools);
1367
1450
  if (!this.#toolsOnly) await this.#loadServerResourcesAndPrompts(name, connection);
1368
1451
  })
1369
1452
  .catch(error => {
@@ -1374,7 +1457,11 @@ export class MCPManager {
1374
1457
  const message = error instanceof Error ? error.message : String(error);
1375
1458
  errors.set(name, this.#serverError(message));
1376
1459
  reportedErrors.add(name);
1377
- logger.error("MCP tool load failed", { path: `mcp:${name}`, serverName: name, error: message });
1460
+ logger.error("MCP tool load failed", {
1461
+ path: `mcp:${name}`,
1462
+ serverName: name,
1463
+ error: classifyMCPStartupFailure(error),
1464
+ });
1378
1465
  });
1379
1466
  }
1380
1467
 
@@ -1430,6 +1517,7 @@ export class MCPManager {
1430
1517
  if (this.toolCache && !this.#toolsOnly) {
1431
1518
  await Promise.all(
1432
1519
  pendingTasks.map(async task => {
1520
+ if (!this.#shouldCacheServerTools(task.name)) return;
1433
1521
  const cached = await this.toolCache?.get(task.name, task.config);
1434
1522
  if (cached) {
1435
1523
  cachedTools.set(task.name, cached);
@@ -1438,51 +1526,55 @@ export class MCPManager {
1438
1526
  );
1439
1527
  }
1440
1528
 
1441
- const pendingWithoutCache = pendingTasks.filter(task => !cachedTools.has(task.name));
1442
- if (pendingWithoutCache.length > 0) {
1443
- // The startup wait elapsing means "stop blocking session start", not
1444
- // "this server failed". A server whose operator declared a `timeout`
1445
- // (`gjc mcp add --timeout`) asked to wait that long for it, so while it
1446
- // is still inside that window it keeps connecting in the background
1447
- // under `connectToServer`'s own timeout, and the background tool load
1448
- // adopts it. Only a server that declared no window, or already spent it,
1449
- // is torn down and reported. Exact-config (`toolsOnly`) startup still
1450
- // fails fast: it builds a catalog once, so a missing server is an error.
1451
- const startupElapsedMs = Date.now() - startupStartedAt;
1452
- for (const task of pendingWithoutCache) {
1453
- if (!this.#toolsOnly && withinDeclaredConnectionWindow(task.config, startupElapsedMs)) {
1454
- logger.warn("MCP server still connecting after the startup wait", {
1455
- path: `mcp:${task.name}`,
1456
- serverName: task.name,
1457
- startupWaitMs: startupTimeoutMs,
1458
- declaredTimeoutMs: task.config.timeout,
1459
- });
1460
- continue;
1461
- }
1462
- const message = `MCP server connection timed out during startup: ${task.name}`;
1463
- if (!this.#toolsOnly) {
1464
- logger.warn("MCP server connection timed out during startup", {
1465
- path: `mcp:${task.name}`,
1466
- serverName: task.name,
1467
- startupWaitMs: startupTimeoutMs,
1468
- declaredTimeoutMs: task.config.timeout,
1469
- remediation: "Set a per-server timeout with `gjc mcp add --timeout <ms>` for slower servers.",
1470
- });
1471
- }
1472
- errors.set(task.name, this.#serverError(message));
1473
- reportedErrors.add(task.name);
1474
- task.connectionAbort.abort(new Error(message));
1475
- if (this.#pendingConnections.has(task.name)) this.#pendingConnections.delete(task.name);
1476
- if (this.#pendingToolLoads.get(task.name) === task.toolsPromise)
1477
- this.#pendingToolLoads.delete(task.name);
1478
- this.#pendingConnectionControllers.delete(task.name);
1479
- void this.#disconnectServer(task.name, { preserveConfig: this.#toolsOnly }).catch(error => {
1480
- this.#logLeaseReleaseFailure(task.name, undefined, error);
1529
+ // Cached tools are only a fallback snapshot; they do not exempt the live
1530
+ // connection from the startup cleanup contract. The startup wait elapsing
1531
+ // means "stop blocking session start", not "this server failed". A server
1532
+ // whose operator declared a `timeout` (`gjc mcp add --timeout`) asked to
1533
+ // wait that long for it, so while it is still inside that window it keeps
1534
+ // connecting in the background under `connectToServer`'s own timeout, and
1535
+ // the background tool load adopts it. Only a server that declared no
1536
+ // window, or already spent it, is torn down and reported. Exact-config
1537
+ // (`toolsOnly`) startup still fails fast: it builds a catalog once, so a
1538
+ // missing server is an error.
1539
+ // Abort and disconnect in the background: a misbehaving stdio/MCP transport can
1540
+ // ignore AbortSignal and keep startup blocked indefinitely, but it must not remain
1541
+ // registered if it eventually connects.
1542
+ const startupElapsedMs = Date.now() - startupStartedAt;
1543
+ for (const task of pendingTasks) {
1544
+ if (task.tracked.status !== "pending") continue;
1545
+ if (!this.#toolsOnly && withinDeclaredConnectionWindow(task.config, startupElapsedMs)) {
1546
+ logger.warn("MCP server still connecting after the startup wait", {
1547
+ path: `mcp:${task.name}`,
1548
+ serverName: task.name,
1549
+ startupWaitMs: startupTimeoutMs,
1550
+ declaredTimeoutMs: task.config.timeout,
1551
+ });
1552
+ continue;
1553
+ }
1554
+ const message = `MCP server connection timed out during startup: ${task.name}`;
1555
+ if (!this.#toolsOnly) {
1556
+ logger.warn("MCP server connection timed out during startup", {
1557
+ path: `mcp:${task.name}`,
1558
+ serverName: task.name,
1559
+ startupWaitMs: startupTimeoutMs,
1560
+ declaredTimeoutMs: task.config.timeout,
1561
+ remediation: "Set a per-server timeout with `gjc mcp add --timeout <ms>` for slower servers.",
1481
1562
  });
1482
1563
  }
1483
- // Abort and disconnect in the background: a misbehaving stdio/MCP transport can
1484
- // ignore AbortSignal and keep startup blocked indefinitely, but it must not remain
1485
- // registered if it eventually connects.
1564
+ errors.set(task.name, this.#serverError(message));
1565
+ reportedErrors.add(task.name);
1566
+ task.connectionAbort.abort(new Error(message));
1567
+ if (this.#pendingConnections.has(task.name)) this.#pendingConnections.delete(task.name);
1568
+ if (this.#pendingToolLoads.get(task.name) === task.toolsPromise)
1569
+ this.#pendingToolLoads.delete(task.name);
1570
+ this.#pendingConnectionControllers.delete(task.name);
1571
+ void this.#disconnectServer(task.name, {
1572
+ preserveConfig: this.#toolsOnly || cachedTools.has(task.name),
1573
+ preserveSource: cachedTools.has(task.name),
1574
+ preserveTools: cachedTools.has(task.name),
1575
+ }).catch(error => {
1576
+ this.#logLeaseReleaseFailure(task.name, undefined, error);
1577
+ });
1486
1578
  }
1487
1579
  }
1488
1580
 
@@ -1524,7 +1616,7 @@ export class MCPManager {
1524
1616
  logger.warn("MCP server connection failed during startup", {
1525
1617
  path: `mcp:${name}`,
1526
1618
  serverName: name,
1527
- error: message,
1619
+ error: classifyMCPStartupFailure(reason),
1528
1620
  remediation:
1529
1621
  "Check the server command and set --timeout <ms> when startup is expected to be slow.",
1530
1622
  });
@@ -1573,7 +1665,10 @@ export class MCPManager {
1573
1665
  }
1574
1666
 
1575
1667
  // Update cached tools
1576
- if (shouldPublishToolSnapshot) this.#publishToolCatalog(allTools);
1668
+ if (shouldPublishToolSnapshot) {
1669
+ this.#publishToolCatalog(allTools);
1670
+ this.#notifyToolsChanged();
1671
+ }
1577
1672
  allowBackgroundLogging = true;
1578
1673
 
1579
1674
  return {
@@ -2386,7 +2481,10 @@ export class MCPManager {
2386
2481
  await this.#disconnectServer(name);
2387
2482
  }
2388
2483
 
2389
- async #disconnectServer(name: string, options: { preserveConfig?: boolean } = {}): Promise<void> {
2484
+ async #disconnectServer(
2485
+ name: string,
2486
+ options: { preserveConfig?: boolean; preserveSource?: boolean; preserveTools?: boolean } = {},
2487
+ ): Promise<void> {
2390
2488
  const nextEpoch = (this.#disconnectEpochs.get(name) ?? 0) + 1;
2391
2489
  this.#disconnectEpochs.set(name, nextEpoch);
2392
2490
  this.#pendingConnectionControllers.get(name)?.abort(new Error(`MCP server disconnected: ${name}`));
@@ -2396,7 +2494,7 @@ export class MCPManager {
2396
2494
  this.#pendingConnections.delete(name);
2397
2495
  this.#pendingToolLoads.delete(name);
2398
2496
  this.#pendingReconnections.delete(name);
2399
- this.#sources.delete(name);
2497
+ if (!options.preserveSource) this.#sources.delete(name);
2400
2498
  if (!options.preserveConfig) this.#serverConfigs.delete(name);
2401
2499
  this.#pendingResourceRefresh.delete(name);
2402
2500
  const connection = this.#connections.get(name);
@@ -2426,18 +2524,20 @@ export class MCPManager {
2426
2524
  if (this.#connections.get(name) === connection) this.#connections.delete(name);
2427
2525
  }
2428
2526
 
2429
- // Remove tools from this server and notify consumers
2430
- const hadTools = this.#tools.some(
2431
- tool => (tool instanceof MCPTool || tool instanceof DeferredMCPTool) && tool.mcpServerName === name,
2432
- );
2433
- const remainingTools = this.#tools.filter(
2434
- tool => !((tool instanceof MCPTool || tool instanceof DeferredMCPTool) && tool.mcpServerName === name),
2435
- );
2436
- if (hadTools) {
2437
- this.#publishToolCatalog(remainingTools);
2438
- this.#onToolsChanged?.(this.#tools);
2439
- } else {
2440
- this.#tools = remainingTools;
2527
+ // Keep a published cache fallback available while its expired live connection closes.
2528
+ if (!options.preserveTools) {
2529
+ const hadTools = this.#tools.some(
2530
+ tool => (tool instanceof MCPTool || tool instanceof DeferredMCPTool) && tool.mcpServerName === name,
2531
+ );
2532
+ const remainingTools = this.#tools.filter(
2533
+ tool => !((tool instanceof MCPTool || tool instanceof DeferredMCPTool) && tool.mcpServerName === name),
2534
+ );
2535
+ if (hadTools) {
2536
+ this.#publishToolCatalog(remainingTools);
2537
+ this.#notifyToolsChanged();
2538
+ } else {
2539
+ this.#tools = remainingTools;
2540
+ }
2441
2541
  }
2442
2542
 
2443
2543
  // Notify prompt consumers so stale commands are cleared
@@ -2545,7 +2645,9 @@ export class MCPManager {
2545
2645
  this.#pendingExactSuspensions.clear();
2546
2646
  this.#pendingExactControlServers.clear();
2547
2647
  this.#connections.clear();
2648
+ const hadTools = this.#tools.length > 0;
2548
2649
  this.#publishToolCatalog([]);
2650
+ if (hadTools) this.#notifyToolsChanged();
2549
2651
  this.#subscribedResources.clear();
2550
2652
  const releaseFailures = [
2551
2653
  ...scopedReleaseFailures,
@@ -2835,9 +2937,9 @@ export class MCPManager {
2835
2937
  reconnect,
2836
2938
  this.#exactToolOptions(name, config.sharing === "shared"),
2837
2939
  );
2838
- void this.toolCache?.set(name, config, serverTools);
2940
+ if (this.#shouldCacheServerTools(name)) void this.toolCache?.set(name, config, serverTools);
2839
2941
  this.#replaceServerTools(name, customTools);
2840
- this.#onToolsChanged?.(this.#tools);
2942
+ this.#notifyToolsChanged();
2841
2943
  void this.#loadServerResourcesAndPrompts(name, connection);
2842
2944
  return connection;
2843
2945
  } catch (error) {
@@ -2905,11 +3007,11 @@ export class MCPManager {
2905
3007
  reconnect,
2906
3008
  this.#exactToolOptions(name, connection.config.sharing === "shared"),
2907
3009
  );
2908
- void this.toolCache?.set(name, connection.config, serverTools);
3010
+ if (this.#shouldCacheServerTools(name)) void this.toolCache?.set(name, connection.config, serverTools);
2909
3011
 
2910
3012
  // Replace tools from this server
2911
3013
  this.#replaceServerTools(name, customTools);
2912
- this.#onToolsChanged?.(this.#tools);
3014
+ this.#notifyToolsChanged();
2913
3015
  }
2914
3016
 
2915
3017
  /**