agent-trellis 0.18.0 → 0.20.0

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 (100) hide show
  1. package/dist/cli.js +12 -1
  2. package/dist/commands/doctor.d.ts +8 -0
  3. package/dist/commands/doctor.js +87 -2
  4. package/dist/commands/evolve.d.ts +43 -0
  5. package/dist/commands/evolve.js +479 -0
  6. package/dist/commands/mcpGateway.d.ts +28 -0
  7. package/dist/commands/mcpGateway.js +40 -6
  8. package/dist/evolve/agentView.d.ts +32 -0
  9. package/dist/evolve/agentView.js +34 -0
  10. package/dist/evolve/apply.d.ts +71 -0
  11. package/dist/evolve/apply.js +162 -0
  12. package/dist/evolve/archive.d.ts +23 -0
  13. package/dist/evolve/archive.js +58 -0
  14. package/dist/evolve/erase.d.ts +17 -0
  15. package/dist/evolve/erase.js +65 -0
  16. package/dist/evolve/extension.d.ts +37 -0
  17. package/dist/evolve/extension.js +63 -0
  18. package/dist/evolve/gate.d.ts +95 -0
  19. package/dist/evolve/gate.js +295 -0
  20. package/dist/evolve/importHistory.d.ts +91 -0
  21. package/dist/evolve/importHistory.js +239 -0
  22. package/dist/evolve/jobMain.d.ts +22 -0
  23. package/dist/evolve/jobMain.js +52 -0
  24. package/dist/evolve/jobRun.d.ts +94 -0
  25. package/dist/evolve/jobRun.js +273 -0
  26. package/dist/evolve/jobShell.d.ts +46 -0
  27. package/dist/evolve/jobShell.js +75 -0
  28. package/dist/evolve/l2.d.ts +93 -0
  29. package/dist/evolve/l2.js +81 -0
  30. package/dist/evolve/lesson.d.ts +57 -0
  31. package/dist/evolve/lesson.js +97 -0
  32. package/dist/evolve/pending.d.ts +69 -0
  33. package/dist/evolve/pending.js +116 -0
  34. package/dist/evolve/probeWorkspace.d.ts +57 -0
  35. package/dist/evolve/probeWorkspace.js +69 -0
  36. package/dist/evolve/propose.d.ts +73 -0
  37. package/dist/evolve/propose.js +136 -0
  38. package/dist/evolve/recorder.d.ts +68 -0
  39. package/dist/evolve/recorder.js +169 -0
  40. package/dist/evolve/report.d.ts +26 -0
  41. package/dist/evolve/report.js +37 -0
  42. package/dist/evolve/reportView.d.ts +96 -0
  43. package/dist/evolve/reportView.js +311 -0
  44. package/dist/evolve/risk.d.ts +106 -0
  45. package/dist/evolve/risk.js +224 -0
  46. package/dist/evolve/sources.d.ts +19 -0
  47. package/dist/evolve/sources.js +29 -0
  48. package/dist/evolve/stateDir.d.ts +21 -0
  49. package/dist/evolve/stateDir.js +56 -0
  50. package/dist/evolve/surface.d.ts +34 -0
  51. package/dist/evolve/surface.js +72 -0
  52. package/dist/evolve/trajectory.d.ts +25 -0
  53. package/dist/evolve/trajectory.js +88 -0
  54. package/dist/evolve/treeToCanonical.d.ts +28 -0
  55. package/dist/evolve/treeToCanonical.js +79 -0
  56. package/dist/evolve/trellisSubstrate.d.ts +65 -0
  57. package/dist/evolve/trellisSubstrate.js +154 -0
  58. package/dist/evolve/trigger.d.ts +177 -0
  59. package/dist/evolve/trigger.js +400 -0
  60. package/dist/evolve/types.d.ts +126 -0
  61. package/dist/evolve/types.js +15 -0
  62. package/dist/evolve/validate.d.ts +139 -0
  63. package/dist/evolve/validate.js +315 -0
  64. package/dist/host/checks.d.ts +57 -0
  65. package/dist/host/checks.js +126 -0
  66. package/dist/host/delivery.d.ts +42 -0
  67. package/dist/host/delivery.js +59 -0
  68. package/dist/host/edits.d.ts +87 -0
  69. package/dist/host/edits.js +102 -0
  70. package/dist/host/hostEvents.d.ts +74 -0
  71. package/dist/host/hostEvents.js +45 -0
  72. package/dist/host/index.d.ts +22 -0
  73. package/dist/host/index.js +17 -0
  74. package/dist/host/levels.d.ts +106 -0
  75. package/dist/host/levels.js +206 -0
  76. package/dist/host/staging.d.ts +56 -0
  77. package/dist/host/staging.js +106 -0
  78. package/dist/host/surface.d.ts +36 -0
  79. package/dist/host/surface.js +20 -0
  80. package/dist/lib/backup.js +12 -3
  81. package/dist/lib/fsAtomic.d.ts +7 -0
  82. package/dist/lib/fsAtomic.js +14 -0
  83. package/dist/lib/gatewayBackend.d.ts +72 -2
  84. package/dist/lib/gatewayBackend.js +254 -13
  85. package/dist/lib/hostEventBus.d.ts +47 -0
  86. package/dist/lib/hostEventBus.js +94 -0
  87. package/dist/lib/hostExtensions.d.ts +75 -0
  88. package/dist/lib/hostExtensions.js +196 -0
  89. package/dist/lib/mcpRuntime.d.ts +12 -1
  90. package/dist/lib/mcpRuntime.js +34 -2
  91. package/dist/lib/mcpToolRegistry.d.ts +28 -2
  92. package/dist/lib/mcpToolRegistry.js +47 -8
  93. package/dist/lib/upstreamResources.d.ts +5 -0
  94. package/dist/lib/upstreamResources.js +10 -0
  95. package/dist/pi-bridge/bundle.js +33 -5
  96. package/docs/magpie-gateway-research.md +422 -0
  97. package/docs/roadmap.md +22 -12
  98. package/docs/runtime-infra.md +74 -0
  99. package/docs/self-evolution.md +138 -44
  100. package/package.json +7 -2
@@ -41,7 +41,11 @@ export interface UpstreamSpec {
41
41
  name: string;
42
42
  def: McpServerDef;
43
43
  }
44
- export type UpstreamStatusKind = "ready" | "auth-required" | "unavailable" | "timeout" | "degraded";
44
+ /** `reconnecting` is the transient state between a detected session
45
+ * invalidation and the recovery outcome (spec: an upstream whose MCP
46
+ * session is invalidated mid-session is recovered alone, with a status
47
+ * that never claims ready in the meantime). */
48
+ export type UpstreamStatusKind = "ready" | "auth-required" | "unavailable" | "timeout" | "degraded" | "reconnecting";
45
49
  export interface UpstreamStatus {
46
50
  name: string;
47
51
  transport: "stdio" | "http" | "sse";
@@ -75,6 +79,10 @@ export interface LocalBackendOptions {
75
79
  * no terminal attached (design.md D9).
76
80
  */
77
81
  homeDir?: string;
82
+ /** How long a server whose mid-session recovery failed refuses automatic
83
+ * re-attempts, so a down upstream cannot trigger a reconnect storm. Test
84
+ * seam; defaults to `SESSION_RECOVERY_COOLDOWN_MS`. */
85
+ sessionRecoveryCooldownMs?: number;
78
86
  }
79
87
  /**
80
88
  * v1's backend: connects each upstream itself and aggregates the result.
@@ -87,9 +95,26 @@ export interface LocalBackendOptions {
87
95
  */
88
96
  export declare class LocalBackend implements GatewayBackend {
89
97
  private readonly warn;
98
+ private readonly sessionCooldownMs;
90
99
  private readonly registry;
100
+ /** server name → the client currently serving that upstream. Keyed by
101
+ * name (not a Set of clients) because recovery swaps the client under a
102
+ * stable name and callTool must ask "is this still the client I called?"
103
+ * by comparing against the current registration. */
91
104
  private readonly connected;
92
105
  private readonly statuses;
106
+ /** Saved at connect time so a mid-session recovery can rebuild the
107
+ * upstream through `addUpstream` itself — the recovery path IS the
108
+ * startup path, and inherits its OAuth resolution and special cases
109
+ * (mcp-router) wholesale instead of growing a parallel one. */
110
+ private readonly upstreamConfigs;
111
+ /** server name → in-flight session recovery; one recovery per server at
112
+ * a time, every racing caller awaits the same promise. */
113
+ private readonly recoveries;
114
+ /** server name → cooldown expiry; a recently failed recovery is not
115
+ * re-attempted until it lapses, so a down upstream sees one reconnect
116
+ * attempt per window, not a storm. */
117
+ private readonly sessionCooldowns;
93
118
  private closed;
94
119
  private readonly resources;
95
120
  private constructor();
@@ -103,14 +128,59 @@ export declare class LocalBackend implements GatewayBackend {
103
128
  getSkill(uri: string): Promise<SkillEntry | undefined>;
104
129
  listStatus(): readonly UpstreamStatus[];
105
130
  close(): Promise<void>;
106
- private closeUpstream;
131
+ private closeClient;
107
132
  /** A dead grant surfaces mid-session: this upstream's calls now fail,
108
133
  * but its tools stay registered and every other upstream keeps serving.
109
134
  * The credential resolves per request, so the human's re-authorization
110
135
  * takes effect on the next call — no session restart required. */
111
136
  private markCredentialFailure;
112
137
  private restoreIfAuthRequired;
138
+ /** The pre-call hard failure for a server inside its recovery cooldown. */
139
+ private sessionCooldownBlock;
140
+ private enterSessionCooldown;
141
+ /** Join the server's in-flight recovery, or start exactly one. */
142
+ private ensureSessionRecovered;
143
+ /** Drop and rebuild one upstream through the exact startup path
144
+ * (`addUpstream`), keeping the old client connected until the fresh
145
+ * registration serves — so a failed recovery still surfaces the old
146
+ * session errors that re-trigger recovery after the cooldown, and the
147
+ * replaced connection is never closed before its successor exists. */
148
+ private reconnectUpstream;
149
+ /** The retried request failed on the fresh session too: an honest
150
+ * terminal state plus a cooldown — never a stale ready (D4). */
151
+ private markRetrySessionFailure;
152
+ /** Upstream-originated failures name their server (spec: call errors
153
+ * name the failing upstream server). */
154
+ private upstreamCallError;
113
155
  }
156
+ /**
157
+ * How long a server whose session recovery failed refuses automatic
158
+ * re-attempts. Fixed by design: a reconnect storm against a down upstream
159
+ * buys nothing; the honest failure state plus this window is the v1
160
+ * answer, to be revisited with real-world data.
161
+ */
162
+ export declare const SESSION_RECOVERY_COOLDOWN_MS = 30000;
163
+ /**
164
+ * Whether an upstream error means "your MCP session no longer exists".
165
+ *
166
+ * The official TypeScript server SDK rejects an unknown session with HTTP
167
+ * 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
168
+ * with -32000 for authentication rejections — so 401/403 gate the match
169
+ * out first, and an auth rejection can never enter recovery. The body
170
+ * reaches us inside the client SDK's `Error POSTing to endpoint: …`
171
+ * wrapper, so a word match covers custom upstream wordings (the real-world
172
+ * one says "MCP session no longer exists"). Real-machine verification
173
+ * (2026-10-23) showed custom upstreams also answer with other non-auth
174
+ * statuses — a 400 carrying that same -32000 body took the plain
175
+ * `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
176
+ * status gate is "not an auth rejection" rather than "exactly 404". A
177
+ * stray non-auth response that merely mentions "session" costs one
178
+ * idempotent-safe reconnect+retry, an acceptable price for covering every
179
+ * upstream wording. The `ProtocolError` branch covers the HTTP-200
180
+ * JSON-RPC error case, where the client SDK parses the envelope and
181
+ * rejects with the upstream's error code instead.
182
+ */
183
+ export declare function isSessionInvalidation(err: unknown): boolean;
114
184
  /**
115
185
  * Whether the gateway manages the OAuth credential for this definition:
116
186
  * Trellis-owned OAuth, and no explicit `Authorization` header — someone
@@ -21,6 +21,7 @@ import { withTimeout } from "./mcpConnect.js";
21
21
  import { ensureFreshToken, forceRefresh, RefreshError } from "./oauth/refresh.js";
22
22
  import { readToken } from "./oauth/store.js";
23
23
  import { McpToolRegistry } from "./mcpToolRegistry.js";
24
+ import { ProtocolError, SdkHttpError } from "@modelcontextprotocol/client";
24
25
  import { UpstreamResources } from "./upstreamResources.js";
25
26
  /**
26
27
  * v1's backend: connects each upstream itself and aggregates the result.
@@ -33,24 +34,47 @@ import { UpstreamResources } from "./upstreamResources.js";
33
34
  */
34
35
  export class LocalBackend {
35
36
  warn;
37
+ sessionCooldownMs;
36
38
  registry = new McpToolRegistry();
37
- connected = new Set();
39
+ /** server name → the client currently serving that upstream. Keyed by
40
+ * name (not a Set of clients) because recovery swaps the client under a
41
+ * stable name and callTool must ask "is this still the client I called?"
42
+ * by comparing against the current registration. */
43
+ connected = new Map();
38
44
  statuses = new Map();
45
+ /** Saved at connect time so a mid-session recovery can rebuild the
46
+ * upstream through `addUpstream` itself — the recovery path IS the
47
+ * startup path, and inherits its OAuth resolution and special cases
48
+ * (mcp-router) wholesale instead of growing a parallel one. */
49
+ upstreamConfigs = new Map();
50
+ /** server name → in-flight session recovery; one recovery per server at
51
+ * a time, every racing caller awaits the same promise. */
52
+ recoveries = new Map();
53
+ /** server name → cooldown expiry; a recently failed recovery is not
54
+ * re-attempted until it lapses, so a down upstream sees one reconnect
55
+ * attempt per window, not a storm. */
56
+ sessionCooldowns = new Map();
39
57
  closed = false;
40
58
  resources;
41
- constructor(warn, timeoutMs) {
59
+ constructor(warn, timeoutMs, sessionCooldownMs) {
42
60
  this.warn = warn;
61
+ this.sessionCooldownMs = sessionCooldownMs;
43
62
  this.resources = new UpstreamResources(timeoutMs, warn);
44
63
  }
45
64
  static async connect(upstreams, options) {
46
65
  const warn = options.onWarning ?? ((message) => console.error(message));
47
- const backend = new LocalBackend(warn, options.connectTimeoutMs);
66
+ const backend = new LocalBackend(warn, options.connectTimeoutMs, options.sessionRecoveryCooldownMs ?? SESSION_RECOVERY_COOLDOWN_MS);
48
67
  await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
49
68
  return backend;
50
69
  }
51
- async addUpstream({ name, def }, options) {
70
+ async addUpstream({ name, def }, options, replaceOf) {
71
+ this.upstreamConfigs.set(name, { spec: { name, def }, options });
52
72
  const transport = def.transport === "http" || def.transport === "sse" ? def.transport : "stdio";
53
- this.statuses.set(name, { name, transport, status: "unavailable", detail: "connecting" });
73
+ // A recovery set `reconnecting` before calling us; a first connect
74
+ // reports "connecting". The recovery's status stays until the outcome.
75
+ if (replaceOf === undefined) {
76
+ this.statuses.set(name, { name, transport, status: "unavailable", detail: "connecting" });
77
+ }
54
78
  let effectiveDef = def;
55
79
  if (options.homeDir && def.url) {
56
80
  try {
@@ -70,6 +94,10 @@ export class LocalBackend {
70
94
  // process, or written to disk by `trellis mcp auth` in another
71
95
  // terminal — affects the very next request, no restart. The static
72
96
  // header from withOAuthHeader stays as the connect-time fallback.
97
+ // (A mid-session recovery rebuilds this provider, so its `lastIssued`
98
+ // starts empty again: the first post-recovery 401 force-refreshes with
99
+ // no previous-token guard — at most one extra rotation, and safe,
100
+ // because forceRefresh re-reads the token file under its lock.)
73
101
  const authProvider = options.homeDir && (transport === "http" || transport === "sse") && oauthAttachApplies(def)
74
102
  ? trellisAuthProvider({
75
103
  homeDir: options.homeDir,
@@ -84,10 +112,19 @@ export class LocalBackend {
84
112
  }
85
113
  catch (err) {
86
114
  this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
115
+ // A failed recovery keeps the replaced client: its dead session is
116
+ // what keeps surfacing the error that re-triggers recovery once the
117
+ // cooldown expires — close-after-success, never orphaned mid-failure.
118
+ if (replaceOf !== undefined)
119
+ this.connected.set(name, replaceOf);
87
120
  this.statuses.set(name, failureStatus(name, def, err));
88
121
  return;
89
122
  }
90
- this.connected.add(client);
123
+ this.connected.set(name, client);
124
+ // Routes (and any skill registrations) a previous incarnation of this
125
+ // source left behind would route reads at the replaced client — forget
126
+ // the source entirely; the fresh registration re-adds its routes below.
127
+ this.resources.removeSource(name);
91
128
  let tools;
92
129
  try {
93
130
  const result = client.getServerCapabilities()?.tools
@@ -97,11 +134,19 @@ export class LocalBackend {
97
134
  }
98
135
  catch (err) {
99
136
  this.warn(`trellis-mcp-gateway: failed to list tools for MCP server "${name}": ${errorText(err)}`);
100
- await this.closeUpstream(client);
137
+ this.connected.delete(name);
138
+ if (replaceOf !== undefined)
139
+ this.connected.set(name, replaceOf);
140
+ await this.closeClient(client);
101
141
  this.statuses.set(name, failureStatus(name, def, err));
102
142
  return;
103
143
  }
104
- const { skipped } = this.registry.add(name, client, tools);
144
+ // A recovery swaps this server's whole registration: its previous
145
+ // candidates are dropped first, so a changed upstream tool set is
146
+ // reflected exactly rather than accumulated.
147
+ const { skipped } = replaceOf !== undefined
148
+ ? this.registry.replace(name, client, tools)
149
+ : this.registry.add(name, client, tools);
105
150
  this.resources.add(name, client);
106
151
  for (const collided of skipped) {
107
152
  this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
@@ -122,12 +167,63 @@ export class LocalBackend {
122
167
  else {
123
168
  this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
124
169
  }
170
+ // The fresh registration serves; only now is the replaced client
171
+ // retired. Callers mid-flight on it finish against it — their stale
172
+ // session errors are guarded in callTool and never start a second
173
+ // recovery — and a failed recovery left the old client connected.
174
+ if (replaceOf !== undefined)
175
+ await this.closeClient(replaceOf);
125
176
  }
126
177
  async listTools() {
127
178
  return this.registry.listTools();
128
179
  }
129
180
  async callTool(name, args) {
130
- return this.registry.callTool(name, args);
181
+ let target = this.registry.resolve(name);
182
+ if (!target)
183
+ throw new Error(`unknown tool "${name}"`);
184
+ // A server inside its recovery cooldown fails hard and immediately:
185
+ // its automatic recovery is known to be failing, and a reconnect
186
+ // storm against a down upstream buys nothing (D3).
187
+ const blocked = this.sessionCooldownBlock(target.serverName);
188
+ if (blocked)
189
+ throw blocked;
190
+ // A recovery already in flight — started by a concurrent caller.
191
+ // Wait it out and aim at whatever registration it lands.
192
+ const pending = this.recoveries.get(target.serverName);
193
+ if (pending) {
194
+ await pending;
195
+ target = this.registry.resolve(name);
196
+ if (!target)
197
+ throw new Error(`unknown tool "${name}"`);
198
+ }
199
+ try {
200
+ return await target.upstream.callTool({ name: target.toolName, arguments: args });
201
+ }
202
+ catch (err) {
203
+ const { serverName } = target;
204
+ if (this.closed || !isSessionInvalidation(err))
205
+ throw this.upstreamCallError(serverName, err);
206
+ // A completed recovery already replaced the client this error came
207
+ // from (a racing caller's reconnect won): the stale error must not
208
+ // start a second recovery — just aim at the fresh registration.
209
+ const current = this.connected.get(serverName);
210
+ if (current === undefined || current === target.upstream) {
211
+ await this.ensureSessionRecovered(serverName, err);
212
+ }
213
+ const retried = this.registry.resolve(name);
214
+ if (!retried)
215
+ throw new Error(`unknown tool "${name}"`);
216
+ try {
217
+ return await retried.upstream.callTool({ name: retried.toolName, arguments: args });
218
+ }
219
+ catch (retryErr) {
220
+ // The fresh session failed the same way: an honest terminal state
221
+ // plus a cooldown — never a stale ready (D4).
222
+ if (isSessionInvalidation(retryErr))
223
+ this.markRetrySessionFailure(retried.serverName, retryErr);
224
+ throw this.upstreamCallError(retried.serverName, retryErr);
225
+ }
226
+ }
131
227
  }
132
228
  listResources() { return this.resources.listResources(); }
133
229
  readResource(uri) { return this.resources.readResource(uri); }
@@ -140,12 +236,11 @@ export class LocalBackend {
140
236
  if (this.closed)
141
237
  return;
142
238
  this.closed = true;
143
- const pending = [...this.connected];
239
+ const pending = [...this.connected.values()];
144
240
  this.connected.clear();
145
- await Promise.all(pending.map((client) => this.closeUpstream(client)));
241
+ await Promise.all(pending.map((client) => this.closeClient(client)));
146
242
  }
147
- async closeUpstream(client) {
148
- this.connected.delete(client);
243
+ async closeClient(client) {
149
244
  try {
150
245
  await client.close();
151
246
  }
@@ -187,6 +282,152 @@ export class LocalBackend {
187
282
  requiresSessionRestart: undefined,
188
283
  });
189
284
  }
285
+ // ---------------------------------------------------------------------------
286
+ // Mid-session recovery (spec: an upstream whose MCP session is invalidated
287
+ // is recovered alone, in flight — restart is the remediation of last
288
+ // resort, not the default)
289
+ // ---------------------------------------------------------------------------
290
+ /** The pre-call hard failure for a server inside its recovery cooldown. */
291
+ sessionCooldownBlock(serverName) {
292
+ const until = this.sessionCooldowns.get(serverName);
293
+ if (until === undefined || Date.now() >= until)
294
+ return undefined;
295
+ return new Error(sessionRecoveryHint(serverName, "a recovery attempt failed recently and its cooldown has not expired"));
296
+ }
297
+ enterSessionCooldown(serverName) {
298
+ this.sessionCooldowns.set(serverName, Date.now() + this.sessionCooldownMs);
299
+ }
300
+ /** Join the server's in-flight recovery, or start exactly one. */
301
+ ensureSessionRecovered(serverName, trigger) {
302
+ const blocked = this.sessionCooldownBlock(serverName);
303
+ if (blocked)
304
+ return Promise.reject(blocked);
305
+ const existing = this.recoveries.get(serverName);
306
+ if (existing)
307
+ return existing;
308
+ const pending = this.reconnectUpstream(serverName, trigger);
309
+ this.recoveries.set(serverName, pending);
310
+ const cleanup = () => {
311
+ if (this.recoveries.get(serverName) === pending)
312
+ this.recoveries.delete(serverName);
313
+ };
314
+ pending.then(cleanup, cleanup);
315
+ return pending;
316
+ }
317
+ /** Drop and rebuild one upstream through the exact startup path
318
+ * (`addUpstream`), keeping the old client connected until the fresh
319
+ * registration serves — so a failed recovery still surfaces the old
320
+ * session errors that re-trigger recovery after the cooldown, and the
321
+ * replaced connection is never closed before its successor exists. */
322
+ async reconnectUpstream(serverName, trigger) {
323
+ const config = this.upstreamConfigs.get(serverName);
324
+ if (!config)
325
+ throw new Error(sessionRecoveryHint(serverName, "no saved connection config exists for it"));
326
+ // Preserve the tool count through reconnecting and failure: Agents
327
+ // reasoning about capability see the same number on both sides of a
328
+ // recovery, whatever its outcome (a success overwrites it with the
329
+ // fresh count inside addUpstream).
330
+ const toolCount = this.statuses.get(serverName)?.toolCount;
331
+ this.statuses.set(serverName, {
332
+ name: serverName,
333
+ transport: transportOf(config.spec.def),
334
+ status: "reconnecting",
335
+ toolCount,
336
+ detail: `MCP session invalidated; reconnecting (${errorText(trigger).slice(0, 160)})`,
337
+ });
338
+ try {
339
+ await this.addUpstream(config.spec, config.options, this.connected.get(serverName));
340
+ }
341
+ catch (err) {
342
+ // addUpstream isolates its own failures into statuses; reaching here
343
+ // is a bug guard, not the expected failure path.
344
+ this.statuses.set(serverName, { ...failureStatus(serverName, config.spec.def, err), toolCount, remediation: sessionRestartRemediation(serverName) });
345
+ this.enterSessionCooldown(serverName);
346
+ throw new Error(sessionRecoveryHint(serverName, errorText(err)));
347
+ }
348
+ const status = this.statuses.get(serverName);
349
+ if (status?.status === "ready" || status?.status === "degraded") {
350
+ this.sessionCooldowns.delete(serverName);
351
+ return;
352
+ }
353
+ // A failed recovery already landed in its classified failure status;
354
+ // sharpen its remediation to the restart hint and open the cooldown.
355
+ this.statuses.set(serverName, { ...status, toolCount, remediation: sessionRestartRemediation(serverName) });
356
+ this.enterSessionCooldown(serverName);
357
+ throw new Error(sessionRecoveryHint(serverName, status?.detail ?? "the reconnect failed"));
358
+ }
359
+ /** The retried request failed on the fresh session too: an honest
360
+ * terminal state plus a cooldown — never a stale ready (D4). */
361
+ markRetrySessionFailure(serverName, err) {
362
+ const config = this.upstreamConfigs.get(serverName);
363
+ const previous = this.statuses.get(serverName);
364
+ const base = config
365
+ ? failureStatus(serverName, config.spec.def, err)
366
+ : {
367
+ name: serverName,
368
+ transport: "http",
369
+ status: "unavailable",
370
+ detail: errorText(err).slice(0, 300),
371
+ requiresHuman: true,
372
+ requiresSessionRestart: true,
373
+ };
374
+ this.statuses.set(serverName, {
375
+ ...base,
376
+ toolCount: previous?.toolCount,
377
+ remediation: sessionRestartRemediation(serverName),
378
+ });
379
+ this.enterSessionCooldown(serverName);
380
+ }
381
+ /** Upstream-originated failures name their server (spec: call errors
382
+ * name the failing upstream server). */
383
+ upstreamCallError(serverName, err) {
384
+ return new Error(`upstream "${serverName}" tool call failed: ${errorText(err)}`, err instanceof Error ? { cause: err } : undefined);
385
+ }
386
+ }
387
+ /**
388
+ * How long a server whose session recovery failed refuses automatic
389
+ * re-attempts. Fixed by design: a reconnect storm against a down upstream
390
+ * buys nothing; the honest failure state plus this window is the v1
391
+ * answer, to be revisited with real-world data.
392
+ */
393
+ export const SESSION_RECOVERY_COOLDOWN_MS = 30_000;
394
+ function transportOf(def) {
395
+ if (def.transport === "sse")
396
+ return "sse";
397
+ return def.url ? "http" : "stdio";
398
+ }
399
+ function sessionRestartRemediation(serverName) {
400
+ return `restart the Agent session to re-establish the MCP session for "${serverName}"`;
401
+ }
402
+ function sessionRecoveryHint(serverName, reason) {
403
+ return `upstream "${serverName}" MCP session invalidated and automatic recovery failed (${reason}) — ${sessionRestartRemediation(serverName)}`;
404
+ }
405
+ /**
406
+ * Whether an upstream error means "your MCP session no longer exists".
407
+ *
408
+ * The official TypeScript server SDK rejects an unknown session with HTTP
409
+ * 404 carrying the JSON-RPC error "Session not found", and pairs 401/403
410
+ * with -32000 for authentication rejections — so 401/403 gate the match
411
+ * out first, and an auth rejection can never enter recovery. The body
412
+ * reaches us inside the client SDK's `Error POSTing to endpoint: …`
413
+ * wrapper, so a word match covers custom upstream wordings (the real-world
414
+ * one says "MCP session no longer exists"). Real-machine verification
415
+ * (2026-10-23) showed custom upstreams also answer with other non-auth
416
+ * statuses — a 400 carrying that same -32000 body took the plain
417
+ * `SdkHttpError` path, not the enveloped `ProtocolError` one — hence the
418
+ * status gate is "not an auth rejection" rather than "exactly 404". A
419
+ * stray non-auth response that merely mentions "session" costs one
420
+ * idempotent-safe reconnect+retry, an acceptable price for covering every
421
+ * upstream wording. The `ProtocolError` branch covers the HTTP-200
422
+ * JSON-RPC error case, where the client SDK parses the envelope and
423
+ * rejects with the upstream's error code instead.
424
+ */
425
+ export function isSessionInvalidation(err) {
426
+ if (err instanceof SdkHttpError)
427
+ return err.status !== 401 && err.status !== 403 && /session/i.test(err.message);
428
+ if (err instanceof ProtocolError)
429
+ return err.code === -32000 && /session/i.test(err.message);
430
+ return false;
190
431
  }
191
432
  function failureStatus(name, def, error) {
192
433
  const detail = errorText(error).slice(0, 300);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * In-process host event bus (host-extension-points spec).
3
+ *
4
+ * Dispatch is synchronous and ordered: `emit` walks listeners at the event
5
+ * point so a session-end event is observably delivered before upstream
6
+ * release continues. Failures are contained per listener — a synchronous
7
+ * throw or a rejected promise is swallowed and warned, never propagated to
8
+ * the tool call or to sibling listeners. With no subscribers `emit` is a
9
+ * single length check (zero overhead; runtime behavior stays byte-identical
10
+ * to the pre-event world). Listeners are expected to return promptly; slow
11
+ * work belongs behind the returned promise, which the caller never awaits.
12
+ */
13
+ import { type HostEvent, type ToolCallInfo } from "../host/hostEvents.js";
14
+ export type HostEventListener = (event: HostEvent) => void | Promise<void>;
15
+ export declare class HostEventBus {
16
+ private readonly warn;
17
+ private readonly listeners;
18
+ constructor(warn?: (message: string) => void);
19
+ /** Register a listener; returns its unsubscribe function. Registration
20
+ * failure isolation for extensions is the loader's job (8.6) — this method
21
+ * only ever pushes. */
22
+ subscribe(listener: HostEventListener): () => void;
23
+ emit(event: HostEvent): void;
24
+ get subscriberCount(): number;
25
+ }
26
+ /**
27
+ * Stamps events with process-level identity. One instance per gateway
28
+ * process: `sessionId` is generated here and carried unchanged on every
29
+ * event the process emits ("会话开始"/tool calls/"会话结束").
30
+ */
31
+ export declare class HostEventEmitter {
32
+ private readonly opts;
33
+ readonly sessionId: string;
34
+ constructor(opts: {
35
+ bus: HostEventBus;
36
+ agent: string;
37
+ /** Workspace root observed by the process; null = no workspace
38
+ * attribution (consumers derive layer from this). */
39
+ workspaceRoot: string | null;
40
+ now?: () => number;
41
+ sessionId?: string;
42
+ });
43
+ private ts;
44
+ sessionStart(): void;
45
+ toolCall(info: ToolCallInfo): void;
46
+ sessionEnd(): void;
47
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * In-process host event bus (host-extension-points spec).
3
+ *
4
+ * Dispatch is synchronous and ordered: `emit` walks listeners at the event
5
+ * point so a session-end event is observably delivered before upstream
6
+ * release continues. Failures are contained per listener — a synchronous
7
+ * throw or a rejected promise is swallowed and warned, never propagated to
8
+ * the tool call or to sibling listeners. With no subscribers `emit` is a
9
+ * single length check (zero overhead; runtime behavior stays byte-identical
10
+ * to the pre-event world). Listeners are expected to return promptly; slow
11
+ * work belongs behind the returned promise, which the caller never awaits.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { sessionEndEvent, sessionStartEvent, toolCallEvent, } from "../host/hostEvents.js";
15
+ export class HostEventBus {
16
+ warn;
17
+ listeners = [];
18
+ constructor(warn = console.error) {
19
+ this.warn = warn;
20
+ }
21
+ /** Register a listener; returns its unsubscribe function. Registration
22
+ * failure isolation for extensions is the loader's job (8.6) — this method
23
+ * only ever pushes. */
24
+ subscribe(listener) {
25
+ this.listeners.push(listener);
26
+ return () => {
27
+ const index = this.listeners.indexOf(listener);
28
+ if (index >= 0)
29
+ this.listeners.splice(index, 1);
30
+ };
31
+ }
32
+ emit(event) {
33
+ if (this.listeners.length === 0)
34
+ return;
35
+ for (const listener of this.listeners) {
36
+ try {
37
+ const pending = listener(event);
38
+ if (pending && typeof pending.then === "function") {
39
+ void pending.catch((err) => {
40
+ this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
41
+ });
42
+ }
43
+ }
44
+ catch (err) {
45
+ this.warn(`trellis-host-events: subscriber failed: ${err instanceof Error ? err.message : String(err)}`);
46
+ }
47
+ }
48
+ }
49
+ get subscriberCount() {
50
+ return this.listeners.length;
51
+ }
52
+ }
53
+ /**
54
+ * Stamps events with process-level identity. One instance per gateway
55
+ * process: `sessionId` is generated here and carried unchanged on every
56
+ * event the process emits ("会话开始"/tool calls/"会话结束").
57
+ */
58
+ export class HostEventEmitter {
59
+ opts;
60
+ sessionId;
61
+ constructor(opts) {
62
+ this.opts = opts;
63
+ this.sessionId = opts.sessionId ?? randomUUID();
64
+ }
65
+ ts() {
66
+ return (this.opts.now ?? Date.now)();
67
+ }
68
+ sessionStart() {
69
+ this.opts.bus.emit(sessionStartEvent({
70
+ ts: this.ts(),
71
+ agent: this.opts.agent,
72
+ session: this.sessionId,
73
+ workspaceRoot: this.opts.workspaceRoot,
74
+ }));
75
+ }
76
+ toolCall(info) {
77
+ this.opts.bus.emit(toolCallEvent({
78
+ ts: this.ts(),
79
+ agent: this.opts.agent,
80
+ session: this.sessionId,
81
+ provider: info.provider,
82
+ tool: info.tool,
83
+ durationMs: info.durationMs,
84
+ result: info.result,
85
+ }));
86
+ }
87
+ sessionEnd() {
88
+ this.opts.bus.emit(sessionEndEvent({
89
+ ts: this.ts(),
90
+ agent: this.opts.agent,
91
+ session: this.sessionId,
92
+ }));
93
+ }
94
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * External host extension loading (host-extension-points spec, 8.6).
3
+ *
4
+ * The ONLY way an external module becomes a Trellis provider or event
5
+ * subscriber: it is declared in the canonical extension manifest at
6
+ * `~/.trellis/mcp/extensions.yaml` (entries carry `id` + a `load` module
7
+ * specifier) and its module exports `activate(ctx)` returning
8
+ * `{ providers?, subscribers? }`. There is no directory scan and no
9
+ * naming convention — a module on disk that the manifest does not name
10
+ * is never imported.
11
+ *
12
+ * Fault isolation is per extension and total: a manifest that fails to
13
+ * parse, an entry of the wrong shape, an import that throws, an
14
+ * `activate` that throws, or an activation of the wrong shape are each
15
+ * recorded as a failure (and warned), and none of them affect the core
16
+ * providers, upstream servers, or any other extension. With no manifest
17
+ * the loader returns empty everything and the gateway is byte-identical
18
+ * to the pre-extension world.
19
+ */
20
+ import type { AgentId } from "../core/types.js";
21
+ import type { HostEventListener } from "./hostEventBus.js";
22
+ import type { TrellisProvider } from "./mcpRuntime.js";
23
+ /** Manifest location relative to the home directory — canonical, not
24
+ * user-home-adjacent: extension declarations are canonical content. */
25
+ export declare const EXTENSIONS_MANIFEST_REL: string;
26
+ export declare function extensionsManifestPath(homeDir: string): string;
27
+ export interface HostExtensionEntry {
28
+ id: string;
29
+ /** Module specifier for dynamic import — an absolute path or a file:
30
+ * URL is the reliable form; bare specifiers resolve by Node's rules
31
+ * from the trellis package itself. */
32
+ load: string;
33
+ }
34
+ /** What an extension's `activate` receives. Metadata only — the extension
35
+ * never gets the bus or registry itself; it RETURNS providers and
36
+ * subscribers and the host wires them through the same registration
37
+ * surface as everything else. */
38
+ export interface HostExtensionContext {
39
+ homeDir: string;
40
+ agent: AgentId;
41
+ workspaceRoot: string | null;
42
+ }
43
+ export interface HostExtensionActivation {
44
+ providers?: TrellisProvider[];
45
+ subscribers?: HostEventListener[];
46
+ }
47
+ export type HostExtensionFailureStage = "manifest" | "invalid" | "import" | "activate";
48
+ export interface HostExtensionFailure {
49
+ /** The manifest entry id, or the manifest itself when it could not be
50
+ * read as an extension list at all. */
51
+ id: string;
52
+ stage: HostExtensionFailureStage;
53
+ message: string;
54
+ }
55
+ export interface LoadedHostExtensions {
56
+ providers: TrellisProvider[];
57
+ subscribers: HostEventListener[];
58
+ failures: HostExtensionFailure[];
59
+ }
60
+ /**
61
+ * Load every extension the canonical manifest declares. Never throws:
62
+ * the worst case is a `failures` entry per broken extension.
63
+ */
64
+ export declare function loadHostExtensions(context: HostExtensionContext, warn?: (message: string) => void): Promise<LoadedHostExtensions>;
65
+ /**
66
+ * 写入(或原位更新)一条清单条目;幂等——同 id 旧条目被替换,其余条目
67
+ * 与未知键原样保留。清单是 canonical 内容,写入是显式管理动作(进化
68
+ * 启用命令 8.1),loader 侧永不写。
69
+ */
70
+ export declare function upsertExtensionEntry(homeDir: string, entry: HostExtensionEntry): void;
71
+ /**
72
+ * 移除一条清单条目;不存在时是无副作用的 no-op(返回 false)。不清空
73
+ * 文件、不动其他条目——清单还可能声明别的扩展。
74
+ */
75
+ export declare function removeExtensionEntry(homeDir: string, id: string): boolean;