agent-trellis 0.17.1 → 0.19.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.
package/dist/cli.js CHANGED
File without changes
@@ -17,8 +17,9 @@
17
17
  */
18
18
  import type { McpServerDef, SecretsPolicy } from "../core/types.js";
19
19
  import { type McpClientInfo } from "./mcpConnect.js";
20
+ import type { FetchLike } from "./oauth/discovery.js";
20
21
  import { type AggregatedTool } from "./mcpToolRegistry.js";
21
- import type { Resource, ReadResourceResult } from "@modelcontextprotocol/client";
22
+ import type { AuthProvider, Resource, ReadResourceResult } from "@modelcontextprotocol/client";
22
23
  import type { SkillEntry } from "./skillManifest.js";
23
24
  export interface GatewayBackend {
24
25
  /** Every tool available through this backend, under its aggregated name. */
@@ -40,7 +41,11 @@ export interface UpstreamSpec {
40
41
  name: string;
41
42
  def: McpServerDef;
42
43
  }
43
- 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";
44
49
  export interface UpstreamStatus {
45
50
  name: string;
46
51
  transport: "stdio" | "http" | "sse";
@@ -74,6 +79,10 @@ export interface LocalBackendOptions {
74
79
  * no terminal attached (design.md D9).
75
80
  */
76
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;
77
86
  }
78
87
  /**
79
88
  * v1's backend: connects each upstream itself and aggregates the result.
@@ -86,9 +95,26 @@ export interface LocalBackendOptions {
86
95
  */
87
96
  export declare class LocalBackend implements GatewayBackend {
88
97
  private readonly warn;
98
+ private readonly sessionCooldownMs;
89
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. */
90
104
  private readonly connected;
91
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;
92
118
  private closed;
93
119
  private readonly resources;
94
120
  private constructor();
@@ -102,8 +128,68 @@ export declare class LocalBackend implements GatewayBackend {
102
128
  getSkill(uri: string): Promise<SkillEntry | undefined>;
103
129
  listStatus(): readonly UpstreamStatus[];
104
130
  close(): Promise<void>;
105
- private closeUpstream;
131
+ private closeClient;
132
+ /** A dead grant surfaces mid-session: this upstream's calls now fail,
133
+ * but its tools stay registered and every other upstream keeps serving.
134
+ * The credential resolves per request, so the human's re-authorization
135
+ * takes effect on the next call — no session restart required. */
136
+ private markCredentialFailure;
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;
106
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;
184
+ /**
185
+ * Whether the gateway manages the OAuth credential for this definition:
186
+ * Trellis-owned OAuth, and no explicit `Authorization` header — someone
187
+ * who wrote one in canonical meant it, and silently overriding it with a
188
+ * stored token would be the kind of invisible precedence that is
189
+ * impossible to debug. Both `withOAuthHeader` and the per-request
190
+ * `AuthProvider` attach on exactly this predicate.
191
+ */
192
+ export declare function oauthAttachApplies(def: McpServerDef): boolean;
107
193
  /**
108
194
  * Attaches a stored OAuth token, refreshing it first if it has expired.
109
195
  *
@@ -112,8 +198,37 @@ export declare class LocalBackend implements GatewayBackend {
112
198
  * OAuth at all, or one whose credential lives in `headers` already, and
113
199
  * neither should be disturbed.
114
200
  *
115
- * An explicit `Authorization` header in canonical wins. Someone who wrote
116
- * one meant it, and silently overriding it with a stored token would be
117
- * the kind of invisible precedence that is impossible to debug.
201
+ * An explicit `Authorization` header in canonical wins.
118
202
  */
119
203
  export declare function withOAuthHeader(name: string, def: McpServerDef, homeDir: string): Promise<McpServerDef>;
204
+ /**
205
+ * The early-refresh window on the gateway's per-request path. Minute-scale
206
+ * on purpose: a request that passes the freshness check must not carry a
207
+ * token that dies mid-flight. `store.ts`'s 60s default is untouched —
208
+ * `mcp auth`/`mcp renew` don't have this problem.
209
+ */
210
+ export declare const GATEWAY_REFRESH_SKEW_MS = 300000;
211
+ export interface TrellisAuthProviderOptions {
212
+ homeDir: string;
213
+ serverName: string;
214
+ /** Test seam; defaults to the gateway window. */
215
+ skewMs?: number;
216
+ fetchImpl?: FetchLike;
217
+ now?: () => number;
218
+ /** Fired when pre-request resolution produced a credential while this
219
+ * server sat in auth-required — the backend restores its status. */
220
+ onCredentialRecovered?: () => void;
221
+ /** Fired when the 401-forced refresh failed — the backend marks this
222
+ * server auth-required with the OAuth remediation. */
223
+ onCredentialFailure?: (err: unknown) => void;
224
+ }
225
+ /**
226
+ * The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
227
+ * runs as each outbound request's headers are assembled, so credentials
228
+ * resolve per request instead of once at connect time.
229
+ *
230
+ * Every read goes back to the token file with no memory cache — that is
231
+ * what makes a refresh by `trellis mcp auth` in another terminal take
232
+ * effect on the very next request of a running gateway (D6).
233
+ */
234
+ export declare function trellisAuthProvider(opts: TrellisAuthProviderOptions): AuthProvider;
@@ -18,9 +18,10 @@
18
18
  import { isOAuthAuth, oauthOwner } from "../core/types.js";
19
19
  import { connectServer } from "./mcpConnect.js";
20
20
  import { withTimeout } from "./mcpConnect.js";
21
- import { ensureFreshToken } from "./oauth/refresh.js";
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 {
@@ -65,16 +89,42 @@ export class LocalBackend {
65
89
  return;
66
90
  }
67
91
  }
92
+ // Per-request credential resolution: the SDK calls token() as each
93
+ // request's headers are assembled, so a token refreshed here — by this
94
+ // process, or written to disk by `trellis mcp auth` in another
95
+ // terminal — affects the very next request, no restart. The static
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.)
101
+ const authProvider = options.homeDir && (transport === "http" || transport === "sse") && oauthAttachApplies(def)
102
+ ? trellisAuthProvider({
103
+ homeDir: options.homeDir,
104
+ serverName: name,
105
+ onCredentialRecovered: () => this.restoreIfAuthRequired(name),
106
+ onCredentialFailure: (err) => this.markCredentialFailure(name, transport, err),
107
+ })
108
+ : undefined;
68
109
  let client;
69
110
  try {
70
- client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo);
111
+ client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo, authProvider);
71
112
  }
72
113
  catch (err) {
73
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);
74
120
  this.statuses.set(name, failureStatus(name, def, err));
75
121
  return;
76
122
  }
77
- 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);
78
128
  let tools;
79
129
  try {
80
130
  const result = client.getServerCapabilities()?.tools
@@ -84,11 +134,19 @@ export class LocalBackend {
84
134
  }
85
135
  catch (err) {
86
136
  this.warn(`trellis-mcp-gateway: failed to list tools for MCP server "${name}": ${errorText(err)}`);
87
- await this.closeUpstream(client);
137
+ this.connected.delete(name);
138
+ if (replaceOf !== undefined)
139
+ this.connected.set(name, replaceOf);
140
+ await this.closeClient(client);
88
141
  this.statuses.set(name, failureStatus(name, def, err));
89
142
  return;
90
143
  }
91
- 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);
92
150
  this.resources.add(name, client);
93
151
  for (const collided of skipped) {
94
152
  this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
@@ -109,12 +167,63 @@ export class LocalBackend {
109
167
  else {
110
168
  this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
111
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);
112
176
  }
113
177
  async listTools() {
114
178
  return this.registry.listTools();
115
179
  }
116
180
  async callTool(name, args) {
117
- 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
+ }
118
227
  }
119
228
  listResources() { return this.resources.listResources(); }
120
229
  readResource(uri) { return this.resources.readResource(uri); }
@@ -127,12 +236,11 @@ export class LocalBackend {
127
236
  if (this.closed)
128
237
  return;
129
238
  this.closed = true;
130
- const pending = [...this.connected];
239
+ const pending = [...this.connected.values()];
131
240
  this.connected.clear();
132
- await Promise.all(pending.map((client) => this.closeUpstream(client)));
241
+ await Promise.all(pending.map((client) => this.closeClient(client)));
133
242
  }
134
- async closeUpstream(client) {
135
- this.connected.delete(client);
243
+ async closeClient(client) {
136
244
  try {
137
245
  await client.close();
138
246
  }
@@ -141,6 +249,185 @@ export class LocalBackend {
141
249
  this.warn(`trellis-mcp-gateway: failed to close MCP client: ${errorText(err)}`);
142
250
  }
143
251
  }
252
+ /** A dead grant surfaces mid-session: this upstream's calls now fail,
253
+ * but its tools stay registered and every other upstream keeps serving.
254
+ * The credential resolves per request, so the human's re-authorization
255
+ * takes effect on the next call — no session restart required. */
256
+ markCredentialFailure(name, transport, err) {
257
+ const previous = this.statuses.get(name);
258
+ this.statuses.set(name, {
259
+ name,
260
+ transport,
261
+ status: "auth-required",
262
+ toolCount: previous?.toolCount,
263
+ detail: errorText(err).slice(0, 300),
264
+ remediationKind: "oauth",
265
+ requiresHuman: true,
266
+ requiresSessionRestart: false,
267
+ remediation: `run \`trellis mcp auth ${name}\` in a human terminal — the running gateway picks the new credential up on its next request`,
268
+ });
269
+ this.warn(`trellis-mcp-gateway: MCP server "${name}" needs re-authorization: ${errorText(err)}`);
270
+ }
271
+ restoreIfAuthRequired(name) {
272
+ const current = this.statuses.get(name);
273
+ if (!current || current.status !== "auth-required")
274
+ return;
275
+ this.statuses.set(name, {
276
+ ...current,
277
+ status: "ready",
278
+ detail: `credential recovered; ${current.toolCount ?? 0} tool(s) available`,
279
+ remediation: undefined,
280
+ remediationKind: undefined,
281
+ requiresHuman: undefined,
282
+ requiresSessionRestart: undefined,
283
+ });
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;
144
431
  }
145
432
  function failureStatus(name, def, error) {
146
433
  const detail = errorText(error).slice(0, 300);
@@ -189,6 +476,18 @@ function failureStatus(name, def, error) {
189
476
  : `check that \`${def.command ?? "the MCP command"}\` is installed and responsive`,
190
477
  };
191
478
  }
479
+ /**
480
+ * Whether the gateway manages the OAuth credential for this definition:
481
+ * Trellis-owned OAuth, and no explicit `Authorization` header — someone
482
+ * who wrote one in canonical meant it, and silently overriding it with a
483
+ * stored token would be the kind of invisible precedence that is
484
+ * impossible to debug. Both `withOAuthHeader` and the per-request
485
+ * `AuthProvider` attach on exactly this predicate.
486
+ */
487
+ export function oauthAttachApplies(def) {
488
+ return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis"
489
+ && !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
490
+ }
192
491
  /**
193
492
  * Attaches a stored OAuth token, refreshing it first if it has expired.
194
493
  *
@@ -197,15 +496,10 @@ function failureStatus(name, def, error) {
197
496
  * OAuth at all, or one whose credential lives in `headers` already, and
198
497
  * neither should be disturbed.
199
498
  *
200
- * An explicit `Authorization` header in canonical wins. Someone who wrote
201
- * one meant it, and silently overriding it with a stored token would be
202
- * the kind of invisible precedence that is impossible to debug.
499
+ * An explicit `Authorization` header in canonical wins.
203
500
  */
204
501
  export async function withOAuthHeader(name, def, homeDir) {
205
- if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
206
- return def;
207
- const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
208
- if (hasExplicitAuth)
502
+ if (!oauthAttachApplies(def))
209
503
  return def;
210
504
  const stored = readToken(homeDir, name);
211
505
  const resource = new URL(def.url);
@@ -217,6 +511,68 @@ export async function withOAuthHeader(name, def, homeDir) {
217
511
  return def;
218
512
  return { ...def, headers: { ...(def.headers ?? {}), Authorization: `Bearer ${token.accessToken}` } };
219
513
  }
514
+ /**
515
+ * The early-refresh window on the gateway's per-request path. Minute-scale
516
+ * on purpose: a request that passes the freshness check must not carry a
517
+ * token that dies mid-flight. `store.ts`'s 60s default is untouched —
518
+ * `mcp auth`/`mcp renew` don't have this problem.
519
+ */
520
+ export const GATEWAY_REFRESH_SKEW_MS = 300_000;
521
+ /**
522
+ * The SDK `AuthProvider` for one Trellis-owned OAuth upstream: `token()`
523
+ * runs as each outbound request's headers are assembled, so credentials
524
+ * resolve per request instead of once at connect time.
525
+ *
526
+ * Every read goes back to the token file with no memory cache — that is
527
+ * what makes a refresh by `trellis mcp auth` in another terminal take
528
+ * effect on the very next request of a running gateway (D6).
529
+ */
530
+ export function trellisAuthProvider(opts) {
531
+ let lastIssued;
532
+ return {
533
+ token: async () => {
534
+ try {
535
+ const token = await ensureFreshToken(opts.homeDir, opts.serverName, {
536
+ skewMs: opts.skewMs ?? GATEWAY_REFRESH_SKEW_MS,
537
+ fetchImpl: opts.fetchImpl,
538
+ now: opts.now,
539
+ });
540
+ if (!token)
541
+ return undefined;
542
+ lastIssued = token.accessToken;
543
+ opts.onCredentialRecovered?.();
544
+ return token.accessToken;
545
+ }
546
+ catch {
547
+ // The pre-request credential check must never abort the request
548
+ // pipeline (D3): send whatever is stored — possibly stale — and
549
+ // let the server's 401 path decide the outcome.
550
+ return readToken(opts.homeDir, opts.serverName)?.accessToken;
551
+ }
552
+ },
553
+ onUnauthorized: async () => {
554
+ // The server just rejected a token that looked fine locally; the
555
+ // expiry check is exactly what must be bypassed. The transport
556
+ // retries the request once after this resolves — and when this
557
+ // throws instead, the SDK propagates the error to this one call
558
+ // as-is (pinned by test), so the message names the way out.
559
+ try {
560
+ await forceRefresh(opts.homeDir, opts.serverName, {
561
+ previousAccessToken: lastIssued,
562
+ fetchImpl: opts.fetchImpl,
563
+ now: opts.now,
564
+ });
565
+ }
566
+ catch (err) {
567
+ opts.onCredentialFailure?.(err);
568
+ const base = err instanceof Error ? err.message : String(err);
569
+ throw new RefreshError(base.includes("trellis mcp auth")
570
+ ? base
571
+ : `${base} — run \`trellis mcp auth ${opts.serverName}\` in a human terminal; the running gateway picks the new credential up on its next request`, err instanceof RefreshError ? err.oauthError : undefined);
572
+ }
573
+ },
574
+ };
575
+ }
220
576
  function errorText(err) {
221
577
  return err instanceof Error ? err.message : String(err);
222
578
  }
@@ -12,7 +12,7 @@
12
12
  * which servers are in scope, or knows what a tool is; callers isolate
13
13
  * failures in whatever way suits them.
14
14
  */
15
- import { Client } from "@modelcontextprotocol/client";
15
+ import { Client, type AuthProvider } from "@modelcontextprotocol/client";
16
16
  import type { McpServerDef, SecretsPolicy } from "../core/types.js";
17
17
  /** What an MCP upstream is told this client is. Each consumer passes its
18
18
  * own so an upstream's logs name the thing that actually connected. */
@@ -46,11 +46,11 @@ export declare function connectWithCleanup<T, TTransport extends {
46
46
  }, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
47
47
  export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
48
48
  export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
49
- export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
50
- export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
49
+ export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
50
+ export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
51
51
  /**
52
52
  * Transport dispatch, matching the original bridge's own ordering: `http`
53
53
  * and `sse` are explicit, everything else (including an absent
54
54
  * `transport`) is stdio — a default the canonical schema relies on.
55
55
  */
56
- export declare function connectServer(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
56
+ export declare function connectServer(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
@@ -102,17 +102,27 @@ export function resolveHeaders(def, secretsPolicy) {
102
102
  }
103
103
  return result;
104
104
  }
105
- export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
105
+ export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
106
106
  const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
107
107
  const headers = resolveHeaders(def, secretsPolicy);
108
- const opts = headers ? { requestInit: { headers } } : undefined;
108
+ // authProvider is the SDK's per-request credential seam: `token()` runs
109
+ // as each request's headers are assembled and its result overrides
110
+ // `requestInit`'s Authorization — the alternative to burning a Bearer
111
+ // into the static headers once at connect time.
112
+ const opts = {
113
+ ...(headers ? { requestInit: { headers } } : {}),
114
+ ...(authProvider ? { authProvider } : {}),
115
+ };
109
116
  return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
110
117
  }
111
- export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
118
+ export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
112
119
  // HTTP+SSE is a legacy transport; it cannot carry modern discovery.
113
120
  const client = new Client(clientInfo, { capabilities: {} });
114
121
  const headers = resolveHeaders(def, secretsPolicy);
115
- const opts = headers ? { requestInit: { headers } } : undefined;
122
+ const opts = {
123
+ ...(headers ? { requestInit: { headers } } : {}),
124
+ ...(authProvider ? { authProvider } : {}),
125
+ };
116
126
  return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
117
127
  }
118
128
  /**
@@ -120,10 +130,10 @@ export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
120
130
  * and `sse` are explicit, everything else (including an absent
121
131
  * `transport`) is stdio — a default the canonical schema relies on.
122
132
  */
123
- export function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
133
+ export function connectServer(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
124
134
  if (def.transport === "http")
125
- return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
135
+ return connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
126
136
  if (def.transport === "sse")
127
- return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
137
+ return connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
128
138
  return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
129
139
  }
@@ -82,6 +82,16 @@ export declare class McpToolRegistry {
82
82
  * aggregated, with no special-casing here.
83
83
  */
84
84
  add(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
85
+ /**
86
+ * Swap one server's entire registration — the mid-session recovery path.
87
+ * Every candidate the server previously contributed is dropped before the
88
+ * fresh tool set is added, so a changed upstream tool set is reflected
89
+ * exactly rather than accumulated. Exposed names are re-allocated across
90
+ * the whole registry, so a name this server frees may be re-assigned
91
+ * deterministically to another server's colliding tool — accepted drift,
92
+ * pinned by the spec scenario.
93
+ */
94
+ replace(serverName: string, upstream: ToolUpstream, tools: readonly AggregatedTool[]): AddResult;
85
95
  private exposedNameFor;
86
96
  /** Every registered tool, under its prefixed name, in registration
87
97
  * order. Each tool's own `description`/`inputSchema` and any additional
@@ -127,6 +127,25 @@ export class McpToolRegistry {
127
127
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
128
128
  return { added, skipped: [] };
129
129
  }
130
+ /**
131
+ * Swap one server's entire registration — the mid-session recovery path.
132
+ * Every candidate the server previously contributed is dropped before the
133
+ * fresh tool set is added, so a changed upstream tool set is reflected
134
+ * exactly rather than accumulated. Exposed names are re-allocated across
135
+ * the whole registry, so a name this server frees may be re-assigned
136
+ * deterministically to another server's colliding tool — accepted drift,
137
+ * pinned by the spec scenario.
138
+ */
139
+ replace(serverName, upstream, tools) {
140
+ const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
141
+ this.candidates.length = 0;
142
+ this.candidates.push(...kept);
143
+ for (const tool of tools)
144
+ this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
145
+ this.rebuild();
146
+ const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== undefined);
147
+ return { added, skipped: [] };
148
+ }
130
149
  exposedNameFor(serverName, toolName) {
131
150
  for (const [name, entry] of this.entries) {
132
151
  if (entry.serverName === serverName && entry.toolName === toolName)
@@ -33,4 +33,19 @@ export declare class RefreshError extends Error {
33
33
  * would hide a revoked grant.
34
34
  */
35
35
  export declare function ensureFreshToken(homeDir: string, serverName: string, opts?: RefreshOptions): Promise<StoredToken | undefined>;
36
+ export interface ForceRefreshOptions extends RefreshOptions {
37
+ /** The access token the caller last saw rejected (e.g. by a 401). When
38
+ * the stored credential already holds a different access token, another
39
+ * process refreshed in between and that result is returned as-is. */
40
+ previousAccessToken?: string;
41
+ }
42
+ /**
43
+ * Refreshes regardless of expiry — the 401 recovery path.
44
+ *
45
+ * The server just rejected a token that still looks unexpired locally
46
+ * (revoked early, clock skew, a stale read), so `ensureFreshToken`'s
47
+ * freshness check is exactly the thing that must be bypassed. Everything
48
+ * else matches it: per-server lock, in-lock re-read, rotation persisted.
49
+ */
50
+ export declare function forceRefresh(homeDir: string, serverName: string, opts?: ForceRefreshOptions): Promise<StoredToken>;
36
51
  export declare function performRefresh(serverName: string, token: StoredToken, opts?: RefreshOptions): Promise<StoredToken>;
@@ -53,6 +53,36 @@ export async function ensureFreshToken(homeDir, serverName, opts = {}) {
53
53
  return refreshed;
54
54
  }, { timeoutMs: opts.lockTimeoutMs, now });
55
55
  }
56
+ /**
57
+ * Refreshes regardless of expiry — the 401 recovery path.
58
+ *
59
+ * The server just rejected a token that still looks unexpired locally
60
+ * (revoked early, clock skew, a stale read), so `ensureFreshToken`'s
61
+ * freshness check is exactly the thing that must be bypassed. Everything
62
+ * else matches it: per-server lock, in-lock re-read, rotation persisted.
63
+ */
64
+ export async function forceRefresh(homeDir, serverName, opts = {}) {
65
+ const existing = readToken(homeDir, serverName);
66
+ if (!existing) {
67
+ throw new RefreshError(`OAuth token for "${serverName}" is gone — run \`trellis mcp auth ${serverName}\``);
68
+ }
69
+ return withServerLock(homeDir, serverName, async () => {
70
+ // Re-read inside the lock. With the expiry check gone, "someone else
71
+ // got here first" can only be detected by identity: if the stored
72
+ // access token is no longer the one we saw rejected, a concurrent
73
+ // refresh already fixed things and rotating again would kill its
74
+ // result.
75
+ const current = readToken(homeDir, serverName) ?? existing;
76
+ if (opts.previousAccessToken !== undefined && current.accessToken !== opts.previousAccessToken)
77
+ return current;
78
+ if (!current.refreshToken) {
79
+ throw new RefreshError(`OAuth token for "${serverName}" has no refresh token — run \`trellis mcp auth ${serverName}\``);
80
+ }
81
+ const refreshed = await performRefresh(serverName, current, opts);
82
+ writeToken(homeDir, serverName, refreshed);
83
+ return refreshed;
84
+ }, { timeoutMs: opts.lockTimeoutMs, now: opts.now });
85
+ }
56
86
  export async function performRefresh(serverName, token, opts = {}) {
57
87
  const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) }));
58
88
  const now = opts.now ?? Date.now;
@@ -13,6 +13,11 @@ export declare class UpstreamResources {
13
13
  private readonly files;
14
14
  constructor(timeoutMs: number, warn: (message: string) => void);
15
15
  add(source: string, client: Client): void;
16
+ /** Forget one source entirely: its client and every file route it
17
+ * registered. Called before a source is re-added mid-session — stale
18
+ * routes would otherwise route reads at the closed previous client
19
+ * (`listResources`/`listSkills` re-register the fresh client's routes). */
20
+ removeSource(source: string): void;
16
21
  private skillsEnabled;
17
22
  private isolated;
18
23
  listResources(): Promise<Resource[]>;
@@ -54,6 +54,16 @@ export class UpstreamResources {
54
54
  this.warn = warn;
55
55
  }
56
56
  add(source, client) { this.clients.set(source, client); }
57
+ /** Forget one source entirely: its client and every file route it
58
+ * registered. Called before a source is re-added mid-session — stale
59
+ * routes would otherwise route reads at the closed previous client
60
+ * (`listResources`/`listSkills` re-register the fresh client's routes). */
61
+ removeSource(source) {
62
+ this.clients.delete(source);
63
+ for (const [uri, route] of this.files)
64
+ if (route.source === source)
65
+ this.files.delete(uri);
66
+ }
57
67
  skillsEnabled(client) {
58
68
  return Boolean(client.getServerCapabilities()?.resources && client.getDiscoverResult()?.capabilities.extensions?.[EXTENSION]);
59
69
  }
@@ -45929,10 +45929,13 @@ function resolveHeaders(def, secretsPolicy) {
45929
45929
  }
45930
45930
  return result;
45931
45931
  }
45932
- async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
45932
+ async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
45933
45933
  const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
45934
45934
  const headers = resolveHeaders(def, secretsPolicy);
45935
- const opts = headers ? { requestInit: { headers } } : void 0;
45935
+ const opts = {
45936
+ ...headers ? { requestInit: { headers } } : {},
45937
+ ...authProvider ? { authProvider } : {}
45938
+ };
45936
45939
  return connectWithCleanup(
45937
45940
  client,
45938
45941
  new StreamableHTTPClientTransport(new URL(def.url), opts),
@@ -45940,10 +45943,13 @@ async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
45940
45943
  `connect timed out after ${timeoutMs}ms`
45941
45944
  );
45942
45945
  }
45943
- async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
45946
+ async function connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
45944
45947
  const client = new Client(clientInfo, { capabilities: {} });
45945
45948
  const headers = resolveHeaders(def, secretsPolicy);
45946
- const opts = headers ? { requestInit: { headers } } : void 0;
45949
+ const opts = {
45950
+ ...headers ? { requestInit: { headers } } : {},
45951
+ ...authProvider ? { authProvider } : {}
45952
+ };
45947
45953
  return connectWithCleanup(
45948
45954
  client,
45949
45955
  new SSEClientTransport(new URL(def.url), opts),
@@ -45951,9 +45957,9 @@ async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
45951
45957
  `connect timed out after ${timeoutMs}ms`
45952
45958
  );
45953
45959
  }
45954
- function connectServer(def, secretsPolicy, timeoutMs, clientInfo) {
45955
- if (def.transport === "http") return connectHttp(def, secretsPolicy, timeoutMs, clientInfo);
45956
- if (def.transport === "sse") return connectSse(def, secretsPolicy, timeoutMs, clientInfo);
45960
+ function connectServer(def, secretsPolicy, timeoutMs, clientInfo, authProvider) {
45961
+ if (def.transport === "http") return connectHttp(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
45962
+ if (def.transport === "sse") return connectSse(def, secretsPolicy, timeoutMs, clientInfo, authProvider);
45957
45963
  return connectStdio(def, secretsPolicy, timeoutMs, clientInfo);
45958
45964
  }
45959
45965
 
@@ -46041,6 +46047,24 @@ var McpToolRegistry = class {
46041
46047
  const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46042
46048
  return { added, skipped: [] };
46043
46049
  }
46050
+ /**
46051
+ * Swap one server's entire registration — the mid-session recovery path.
46052
+ * Every candidate the server previously contributed is dropped before the
46053
+ * fresh tool set is added, so a changed upstream tool set is reflected
46054
+ * exactly rather than accumulated. Exposed names are re-allocated across
46055
+ * the whole registry, so a name this server frees may be re-assigned
46056
+ * deterministically to another server's colliding tool — accepted drift,
46057
+ * pinned by the spec scenario.
46058
+ */
46059
+ replace(serverName, upstream, tools) {
46060
+ const kept = this.candidates.filter((candidate) => candidate.serverName !== serverName);
46061
+ this.candidates.length = 0;
46062
+ this.candidates.push(...kept);
46063
+ for (const tool of tools) this.candidates.push({ serverName, toolName: tool.name, upstream, tool });
46064
+ this.rebuild();
46065
+ const added = tools.map((tool) => this.exposedNameFor(serverName, tool.name)).filter((name) => name !== void 0);
46066
+ return { added, skipped: [] };
46067
+ }
46044
46068
  exposedNameFor(serverName, toolName) {
46045
46069
  for (const [name, entry] of this.entries) {
46046
46070
  if (entry.serverName === serverName && entry.toolName === toolName) return name;
@@ -46293,10 +46317,11 @@ var listSchema = external_exports.looseObject({ skills: external_exports.array(e
46293
46317
  var getSchema = external_exports.looseObject({ skill: external_exports.unknown() });
46294
46318
 
46295
46319
  // src/lib/gatewayBackend.ts
46320
+ function oauthAttachApplies(def) {
46321
+ return isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis" && !Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
46322
+ }
46296
46323
  async function withOAuthHeader(name, def, homeDir) {
46297
- if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis") return def;
46298
- const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
46299
- if (hasExplicitAuth) return def;
46324
+ if (!oauthAttachApplies(def)) return def;
46300
46325
  const stored = readToken(homeDir, name);
46301
46326
  const resource = new URL(def.url);
46302
46327
  resource.hash = "";
package/docs/roadmap.md CHANGED
@@ -971,7 +971,12 @@ health, honest credential status in `trellis.mcp.status`, browser callback
971
971
  page). v0.14.0 — pi native MCP delivery (pi ≥ 1.1.0 `mcp.json` writes,
972
972
  bridge retirement in the same sync; supersedes P4's default path).
973
973
  v0.14.1 — canonical `auth.client_name` for providers that gate OAuth dynamic
974
- client registration by client name (Figma).
974
+ client registration by client name (Figma). v0.15.0 — workspace scope: a
975
+ second canonical layer in the repo itself (`<repo>/.agents/`), merged
976
+ workspace-wins-by-ID, delivered layer-preserving to all six agents.
977
+ v0.15.1 — `--flag=value` accepted everywhere; `mcp remove --workspace`.
978
+ v0.16.0 — doctor validates workspace `.codex/config.toml` TOML syntax.
979
+ v0.17.0 — doctor reports diverging `trellis` entrypoints on PATH.
975
980
 
976
981
  **Cross-machine / cloud sharing — confirmed future direction, not yet
977
982
  scoped as a phase.** Surveyed 2026-09-15 (`docs/research.md`'s "Cross-
@@ -988,6 +993,20 @@ coordination, evaluated only after the simpler pieces are proven). See
988
993
  and `daytonaio/daytona` were both evaluated and not adopted (preview-only
989
994
  API instability, and unmaintained-as-of-June-2026, respectively).
990
995
 
996
+ **Self-evolution — direction captured 2026-10-20, not yet scoped as a
997
+ phase.** Trellis is two decoupled subsystems: hosting (delivery and
998
+ consistency of canonical content) and self-evolution (content that improves
999
+ with use, running ambiently on the runtime substrate, opt-in and graded
1000
+ off → observe → propose → auto-A). Dependency runs one way, evolution →
1001
+ hosting, through a narrow port; uninstalling evolution leaves hosting
1002
+ byte-identical. Methodology ported from google-research/harness2 (no usable
1003
+ SDK exists); first step is a set of generic hosting extension points, not
1004
+ evolution code. See [`self-evolution.md`](self-evolution.md) for the full
1005
+ idea, what is verified versus assumed, and the open decisions; the
1006
+ verifiable contract is the consolidated draft at
1007
+ `openspec/changes/trellis-evolve/` (one change, two capabilities:
1008
+ `host-extension-points` and `self-evolution`; unimplemented).
1009
+
991
1010
  No dates. This is scoped by verification milestones, not calendar time.
992
1011
  Execution order for P11–P16: P11 → P12 → P13 → P14 → P15 → P16 — CRUD
993
1012
  (P12) lands before the larger features (P14, P15, P16) so each can be
@@ -0,0 +1,55 @@
1
+ # Runtime 基建调研议程
2
+
3
+ > **状态:调研议程,尚未实施。** 记录于 2026-10-23。四个方向是"我们的 runtime"离不开的
4
+ > 基建层;逐项调研,调研结果各自沉淀后再决定实施。自我进化(第 4 项)已有完整草案,
5
+ > 见 `docs/self-evolution.md` 与 `openspec/changes/trellis-evolve/`。
6
+
7
+ ## 建议顺序:1 → 2 → 3,第 4 项按既有 tasks 推进
8
+
9
+ 先吃透模型接入层(网关),再往网络层(代理、多机)走;进化已是实施轨道,不是调研轨道。
10
+
11
+ ## 1. 大模型网关(Magpie)——子仓库化 + 核心实现研究
12
+
13
+ 已知:
14
+ - Magpie 已在 Air 两层实测运行(macOS 与 OrbStack worker 各自 `127.0.0.1:3425/v1`);
15
+ worker 层 Codex 流式/工具往返/Responses 通过。用法见 `$local-model-gateway`、
16
+ `$mac-air-usage`。
17
+
18
+ 调研问题:
19
+ - Magpie 源码在本机的位置(**未定位,第一步**);
20
+ - 子仓库形态:git submodule / subtree / 研究副本(先例:`~/www/morphix-research/sources/`);
21
+ - 核心实现:provider 路由与适配、流式转发、工具调用透传、错误与重试语义。
22
+
23
+ ## 2. 代理——稳定访问层
24
+
25
+ 调研问题:
26
+ - Magpie 到各上游 provider 的代理路由(per-provider 配置、failover 策略);
27
+ - npm/git 等开发工具链的代理;
28
+ - 与网关层的边界:重试/切换由代理层还是网关层负责;
29
+ - 与 Nore 的职责区分(设备互联 vs 外网代理,别混为一谈)。
30
+
31
+ 已有数据点:2026-10-22 npm publish 上传连接 ECONNRESET,IPv4-first + 单 socket 解决;
32
+ GET/ping 正常、仅上传路径异常。
33
+
34
+ ## 3. 多机互通——相互委派任务的网络底座
35
+
36
+ 已知锚点:
37
+ - Pro↔Air 私网由 Nore 设备互联提供(`ssh morphix-air-private` → `100.127.171.120`);
38
+ - 本机有 `~/www/nore/` 与 `~/www/nore-next/` 两个仓库,调研以 Nore 源码为主对象之一;
39
+ - 现网痛点:SSH 中继偶发超时(Air doctor 详情两次读取均超时)。
40
+
41
+ 委派已有雏形(分散,未统一):Trellis `tasks_handoff`(MCP)、Air 侧 Dagu
42
+ (`morphix-air-tasks`,当前已禁用)、agent-collab mailbox。
43
+
44
+ 调研问题:网络可靠性(重连/唤醒)、服务发现、委派语义与安全边界。
45
+
46
+ ## 4. 自我进化——已是实施轨道
47
+
48
+ 不在本文重复。接手入口:`docs/self-evolution.md` 的"接手指南";下一步是其
49
+ tasks 第 1 组的三个只读前置验证(元数据信号采样、provider 读取路径、扩展加载机制),
50
+ 之后才是托管扩展点(P1)。
51
+
52
+ ## 对照样本(见记忆 research-subjects-backlog)
53
+
54
+ Raven(harness of harnesses)、Octop(腾讯云多 Agent,coordinator 调度与 workspace
55
+ 分层)、asyar(轻量启动器,仅交互效率参照)。
@@ -0,0 +1,209 @@
1
+ # 自我进化:Trellis 的第二个子系统(想法沉淀)
2
+
3
+ > **状态:整合草案,尚未实现。** 记录于 2026-10-20,来自一轮对 google-research/harness2 的调研与讨论;同日已整合为 `openspec/changes/trellis-evolve/`(一份草案、两个解耦的 capability)。
4
+ > 本文是**想法与取舍**:是什么、为什么、边界在哪、已核实什么、还没定什么。可验证的行为约束在 change 的 `specs/` 里,**二者冲突时以 `specs/` 为准**。
5
+
6
+ ## 接手指南
7
+
8
+ **一句话现状**:方向、边界、评测方法与护栏已定并写成可校验的草案;没有一行实现代码;下一步是先做三项只读前置验证,再做托管扩展点。
9
+
10
+ **文档地图(各管什么,避免重复维护)**
11
+
12
+ | 文件 | 管什么 | 更新时机 |
13
+ |---|---|---|
14
+ | `docs/self-evolution.md`(本文) | 想法、取舍、已核实/未核实、路线 | 每次核实出新事实或做出决定 |
15
+ | `openspec/changes/trellis-evolve/proposal.md` | 为什么、改什么、影响面 | 范围变化时 |
16
+ | `…/specs/host-extension-points/spec.md` | 托管侧通用扩展点的行为契约 | 接口决定后 |
17
+ | `…/specs/self-evolution/spec.md` | 进化子系统的行为契约 | 行为决定后 |
18
+ | `…/design.md` | 技术决策、已核实现状(带代码位置)、风险、开放问题 | 决策或核实后 |
19
+ | `…/tasks.md` | 实施顺序与逐项验证方式 | 实施中勾选 |
20
+ | `docs/roadmap.md` | 一段指针 | 阶段变化时 |
21
+
22
+ **建议阅读顺序**:本文"一句话"与"核心原则" → `design.md` 的 Context(已核实表)与 Open Questions → `tasks.md` 前三组 → 需要细节再读 `specs/`。
23
+
24
+ **接手后第一步(tasks 第 1 组,全部只读)**:① 真机采样元数据事件能产生什么信号;② 核实 skills/instructions provider 是实时读取还是启动时缓存;③ 定下扩展加载机制与状态目录路径。这三项会决定 `host-extension-points` 的具体接口,**不要在它们之前开始写实现**。
25
+
26
+ **需要接手人特别留意的三件事**
27
+
28
+ 1. **解耦是硬约束**:依赖只能是进化 → 托管。`src/evolve/` 只允许 import `src/host/*`,有测试强制。任何让托管"知道"进化的改动都是方向性错误。
29
+ 2. **带外批准目前不是完全可靠的**:有 shell 能力的 agent 理论上能执行管理面命令,TTY 检测只是启发式。`auto-A` 开启之前必须再评估(见 design 的 Risks 与 Open Questions)。
30
+ 3. **覆盖范围有边界**:事件只来自经 gateway/runtime 投递的 agent,仅走原生渲染的 agent 没有工具调用事件。不要在文档或 doctor 输出里假装全覆盖。
31
+
32
+ **本草案的形成过程(便于判断哪些结论可信)**:最初按"用户运行 `trellis evolve` 命令触发"建模,被用户纠正为"环境式、跑在 runtime 上、与托管解耦、可选开启"后整体重写。命令式阶段遗留的"单备份会话写入""staging 复现层分裂"等需求已下沉为托管的公开接口。
33
+
34
+ ## 一句话
35
+
36
+ Trellis 由两个**解耦**的部分组成:**托管**(让配置被正确投递并保持一致)和**自我进化**(让被托管的内容随使用变得更好)。进化是挂在 runtime 底座上的环境式能力,跑在其上的 agent 自己持续进化,可选安装、分级开启;它**不是**一条要用户去敲的命令。
37
+
38
+ ## 为什么要做
39
+
40
+ 托管已经覆盖 skills、MCP、指令、scope、memory,但托管内容的**改进完全靠人**。信号其实一直存在,缺的是把"信号 → 提案 → 验证 → 采纳 → 教训沉淀"闭环起来的机制。2026-10 的两个真实事故说明了这一点:
41
+
42
+ - workspace `.codex/config.toml` 重复键,让该项目的所有 Codex 会话静默失败约 19 小时,没有任何机制主动发现;
43
+ - PATH 上 0.12.0 旧入口遮蔽 0.16.0,把版本分叉误诊成 `servers.yaml` 配置错误。
44
+
45
+ 两件事 doctor 都在事后补上了检查,但"事故 → 人发现 → 人修 → 人补检查"这条链全靠人。进化要缩短的就是这条链。
46
+
47
+ ## 核心原则:两个子系统,一个窄接口
48
+
49
+ | | 托管 (Hosting) | 自我进化 (Evolution) |
50
+ |---|---|---|
51
+ | 职责 | canonical 内容被正确投递并保持一致 | 被托管的内容随使用变好 |
52
+ | 拥有 | canonical、adapters、gateway、backup、doctor、memory 管道 | 信号、提案、验证、风险门、lesson、运行档案 |
53
+ | 不知道 | 进化的存在 | 具体 agent 怎么渲染、怎么投递 |
54
+ | 缺席时 | 完全正常 | (本来就可选) |
55
+
56
+ - **依赖方向只有一条:进化 → 托管,绝不反向。** 卸载进化,托管行为逐字节不变。这比"功能开关"更强:它是**可选安装**。
57
+ - **进化自己声明它需要的 `HostPort`,托管去实现**,和 harness2 的 `Substrate` 协议同构(方法只依赖协议,不 import 具体框架)。好处:进化可以用 fake host 独立测试;托管的 `scope.yaml`(怎么渲染)与进化的"可编辑表面"声明(哪些能改、有什么静默失效坑)各管各的;架构上不堵死将来对接别的宿主。
58
+ - 早期进化放在同仓库独立目录(`src/evolve/` 只允许 import 托管的公开 `host` 子路径,用测试强制边界),接口稳定后再考虑拆独立包。
59
+
60
+ ```
61
+ 进化(只依赖这个接口) 托管(实现它)
62
+ HostPort {
63
+ readSurface(layer) ───────→ loadCanonicalSource(只读,已有)
64
+ stage(edits) ───────→ scratch HOME + 临时 workspace(测试夹具模式已有)
65
+ check(staged|live) ───────→ doctor + sync --check + secrets audit
66
+ apply(edits, session) ───────→ backup session 写入(已有,未公开)
67
+ rollback(session) ───────→ trellis rollback(已有,未公开)
68
+ subscribe(events) ───────→ 会话事件流(新增,通用)
69
+ remember(lesson) ───────→ memory 管道(已有)
70
+ delegate(task) ───────→ agent-bridge(进行中)
71
+ }
72
+ ```
73
+
74
+ ## 环境式运行:触发、观察、通知、生效
75
+
76
+ Trellis 的 gateway 是按会话拉起的 stdio 进程,规范明确 **"without a resident daemon"**,所以进化也**不引入常驻进程**,靠事件驱动:
77
+
78
+ 1. **观察**:被动加主动并行。被动 = gateway 路由层记录元数据(工具/skill/记忆的调用、耗时、错误类别);主动 = agent 在用户纠正时自己调用 `trellis.evolve.note` 上报。主动信号最贵、质量最高(例如一次"配置改反方向"的纠正)。
79
+ 2. **触发**:会话结束(provider 的 `close()` 钩子)时判断信号量/严重度/冷却期,满足则 detached 拉起**一次性**后台作业,单实例锁。
80
+ 3. **提案 → 验证 → 过门**:见下节,即原五环的 Propose / Probe / Compose。
81
+ 4. **通知**:pending 提案经 provider 暴露给 agent(`pending` 工具或 resource),agent 在会话里告知用户;doctor 与 GUI 也可查看。
82
+ 5. **生效**:runtime 模式下 skills / instructions 由 provider 从 canonical 实时提供,采纳后**下个会话自然读到新版本,无需 sync**;只用原生渲染的 agent,采纳后触发 sync。
83
+ 6. **分级开启**:`off`(默认)→ `observe` → `propose` → `auto-A`(仅 Class A 自动采纳)。粒度为全局默认 + per-agent + per-workspace,沿用 `onboard --memory on` 的显式选择方式,写进 canonical,doctor 可见,不静默开启。
84
+
85
+ CLI 退居**管理面**:`status | pending | approve | reject | enable | disable | rollback`,不再是触发器。
86
+
87
+ ## 进化循环(五环)
88
+
89
+ ```
90
+ Observe → Propose → Probe → Compose → Apply+Reflect
91
+ │
92
+ lesson 回流为下次 Propose 的上下文 ┘
93
+ ```
94
+
95
+ - **Propose**:经 agent-bridge `runDelegatedCall` 委派 editor,产出结构化 `proposal.yaml`。editor 是**不可信输出**:机械校验器复核 kind/target 白名单与证据要求,错误写成可执行散文喂回重试一次,再错丢弃。**没有 evidence 链接的提案直接丢弃。**
96
+ - **Probe**:提案先写入 staging 候选(scratch HOME,按需加临时 workspace root),不动真配置。
97
+ - **Compose**:风险门,见下。采纳铁律:任何验收器结果不得差于 baseline,宁可不进化。
98
+ - **Apply+Reflect**:写入走一个备份会话,单点 `trellis rollback` 整体回滚;被采纳的编辑蒸馏成 lesson `{cue, lesson, evidence, confidence?}` 进 `memories/`,作为下次提案上下文(harness2 的 improvement harness)。
99
+
100
+ ## 评测:怎么对比修改前后(最关键的一环)
101
+
102
+ harness2 的评分是**两段式且互相隔离**:选择(匿名对比、无 ground truth)与评分(checklist,选择承诺之后才跑)物理分离,选择器永远看不到评分材料。我们按"有无现成 oracle"分层:
103
+
104
+ - **L1 配置类 — 确定性差集,零 LLM**:baseline 与 staging 候选各跑 doctor / `sync --check` / secrets audit,可采纳 ⟺ 无新 finding ∧ 目标 finding 已消除 ∧ 其余不变。配置类的检查本身就是 ground truth,比 LLM 选择器更强也更便宜。两个事故都属这一类。
105
+ - **L2 内容类 — 沙盒执行 + 匿名 A/B**:同一道探针任务在 base 与 candidate 下各跑一次,交付物冻结后,以种子匿名 id 呈现给独立选择器(不给 verdict、不含评分字样、不标明哪臂是新)。选择器失败 / 任一臂未交付 / 选中 base ⟹ 一律不采纳。**一处刻意反转**:harness2 把 base 排除出候选池("构造上无法弃权",因为它必须进化);我们把 base **留在池里**,因为我们不能退化。
106
+ - **L3 成绩单**:一小组带显式 checklist 的探针,定期对已采纳修改跑 `n_passed/n_criteria` 看趋势。评分失败记 `None`,不计入分母,**永不记 0 分**;结果只给人看,不给选择器看。
107
+ - **缺口**:内容类(L2)缺**探针任务集**,这是真正的前置资产,比代码更难。来源优先级:真实事故复现题 > 用户纠正转化题 > 按 skill 领域合成题。因此路线先做 L1。
108
+
109
+ 执行隔离:本机用"穷人沙盒"——`mkdtemp` workspace + staging 渲染配置 + 重定向的 agent 配置 HOME + 一次性会话,**沙盒保证每次执行干净可复现,对比本身发生在冻结的产物上,不在沙盒里**。容器(本机有 OrbStack)后置,等探针要跑不可信代码或需并行 K 候选时再上。
110
+
111
+ ## 风险分级与护栏
112
+
113
+ 分级由**代码按 kind + target 计算**,不信 editor 自报的 `risk_class`:
114
+
115
+ - **Class A**:可逆、零爆炸半径、证据确凿(如补一个 `${VAR}` 名、修复 doctor 已报告的 drift)——仅 `auto-A` 级别且 probe 通过时自动采纳,事后通知。
116
+ - **Class B**:skill/指令内容编辑、MCP 路由变更——永远人批。
117
+ - **Class C**:secrets、auth、受管 agent 集合、用户手写文件、`evolve` 自身配置——校验阶段拒绝,**连提案都不生成**。
118
+ - 无法分类 → 按 Class B。
119
+
120
+ 环境式运行比命令式危险得多,必须有的护栏:
121
+
122
+ 1. **被进化者不能批准自己的进化**:Class B 批准**只走带外通道**(CLI/GUI),agent 只能展示和提醒。否则传入 `confirm=true` 就能自批,批准形同虚设。
123
+ 2. **层级隔离**:信号、提案、verdict、lesson 全部携带 `global | workspace(<root>)` 归属;跨层写入被拒;**单个项目的证据不自动升格为全局规则**(需另一上下文佐证或显式批准);workspace 编辑继承可提交约束(`${VAR}` 引用,绝不写凭据字面值——那个文件 git 跟踪在别人的仓里)。
124
+ 3. **隐私**:观察只存元数据,不存 payload,并过 secrets 擦除。
125
+ 4. **防自激**:evolver 自己的调用与编辑不进信号源。
126
+ 5. **成本与频率**:委派 editor 有预算上限、冷却期、每日提案上限。
127
+ 6. **会话中途漂移**:gateway 启动时 pin 住 canonical 版本摘要,采纳只在会话边界生效。
128
+ 7. **并发**:信号只追加存储,evolver 作业单实例锁。
129
+ 8. **失败收口**:probe / verdict / 选择过程的任何失败一律记为"不采纳",测量失败记 unknown,不当作通过。
130
+ 9. **不 bypass 目标 agent 的审批与沙盒**:harness2 在宿主机上用 `--dangerously-bypass-approvals-and-sandbox`,这是研究代码的取舍,**我们明确不采纳**。
131
+
132
+ ## 托管侧需要新增的通用扩展点
133
+
134
+ 目前 SDK 只导出 `loadCanonicalSource`、`ALL_AGENTS`、`resolveScope` 与类型(只读),`trellis-sdk` spec 还禁止导出 adapters / commands 内部。进化要的能力托管侧都没有公开接口,所以接缝要新建,且必须是**通用扩展点,不带进化字样**:
135
+
136
+ 1. **事件流**:会话开始/结束、工具调用已路由(仅元数据)。指标、审计同样可用。
137
+ 2. **公开 `host` 子路径**:写入(带备份会话)、回滚、检查。不放开 `src/commands/*` 内部实现,而是新增明确的导出面。
138
+ 3. **provider 外部注册约定**:`TrellisProvider` 已是开放接口(tools / resources / prompts / skill 条目 / `close()`),进化作为外部包注册自己的 provider,托管核心无需认识它。
139
+
140
+ "单备份会话写入""probe staging 复现层分裂"这类本属托管的能力,已在整合草案里下沉为 `host` 门面(备份会话内应用/回滚、staging、结构化检查);进化只调用,不拥有。
141
+
142
+ ## 从 harness2 学到什么(已对照源码核实)
143
+
144
+ 来源:`github.com/google-research/harness2`,本机副本 `~/www/morphix-research/sources/harness2`。
145
+
146
+ | 机制 | 出处 | 我们怎么用 |
147
+ |---|---|---|
148
+ | Propose–Probe–Compose,无 ground truth 的行为对比 | `core/proposer.py` `selection.py` | 循环骨架;L2 对比 |
149
+ | 两段式测量(选择与评分隔离) | `core/selection.py` `judge.py` | L2 选择器看不到评分材料 |
150
+ | 失败语义收口(judge 失败 = None;选择器失败 = 回退 base;preflight 带修复命令) | `judge.py` `selection.py` | 全局失败收口原则 |
151
+ | 指纹 + 身份守卫的复用 | `core/runmark.py` | probe 缓存键 = 被测切片 sha1 |
152
+ | 差异即数据(`SurfaceSpec`,frozen dataclass) | `core/tree.py` | "可编辑表面 + 静默失效模式"声明为数据 |
153
+ | 三轴正交 + 一格子一实例 | `core/spec.py` `benchmarks/workbuddy.py` | `HostPort` 与 probe adapter 的形态 |
154
+ | 穷人沙盒 | `substrate/codex.py:149-185` | probe workspace 边界 |
155
+ | 校验错误即 prompt(原样喂回作重试反馈) | `validate_tree` | editor 重试 |
156
+
157
+ **没有可直接用的 SDK**:无 PyPI 包、`harness2/__init__.py` 为空(无公开 API)、7 个 console script、仅 Linux、Python 3.12、绑 Vertex AI 与 Docker、与三个 benchmark 耦合。**移植方法论,不移植代码。**
158
+
159
+ ## 已核实 / 未核实
160
+
161
+ | 已核实(读过代码或 spec;位置见 design.md Context) | 未核实 / 假设 |
162
+ |---|---|
163
+ | gateway 无常驻进程,client 断开即释放上游 | 观察元数据是否足以支撑有价值的信号(需真机采样,tasks 1.1) |
164
+ | **所有工具调用(内建与上游)汇入同一咽喉点** `BuiltinRegistry.callTool`,由 `tools/call` handler 调用——事件可在此一处挂载 | skills / instructions provider 是实时读取还是启动时缓存(tasks 1.2),决定"会话快照"是否需要 |
165
+ | `RuntimeContext` 只有 `{agentId, homeDir}`,**无会话标识**——须由事件层生成 | 扩展加载机制与状态目录路径(tasks 1.3) |
166
+ | 关闭路径只处理 stdin `end`/`close` 与 SIGTERM/SIGINT/SIGHUP;**SIGKILL/崩溃不触发 `close()`**——因此必须有"下次启动补偿检查" | 全局记忆未启用时,lesson 回流的实际效果 |
167
+ | `openBackupSession` 与 `collectDoctorReport` 存在,但**都未经 SDK 公开**;SDK 根入口只导出 `loadCanonicalSource`/`ALL_AGENTS`/`resolveScope`,且 spec 禁止导出 adapters/commands 内部 | 带外批准在 agent 有 shell 能力时的强度(TTY 检测只是启发式) |
168
+ | `TrellisProvider` 含 `close()` 与 skill 条目接口 | |
169
+ | harness2 各机制的行为(见上表) | |
170
+
171
+ ## 已定倾向 / 待决
172
+
173
+ - **倾向(未最终确认)**:Class B 批准带外为主;后台作业由会话结束拉起而不靠外部调度器;进化先同仓库独立目录;先做 L1。
174
+ - **待决**:用户纠正的采集形态(手工 `note` vs 从会话收割);探针任务集放 canonical 还是 per-workspace;`auto-A` 之前需要多少次有人监督的运行才算"证明不误报";运行档案的轮转策略。
175
+
176
+ ## 前置依赖
177
+
178
+ - agent-bridge 还有 5 个未完成项(委托存储、doctor 委托 section、Claude Code blocked、多跳、文档);editor 委派依赖 `runDelegatedCall`,不可用时 L1 可降级为 rule-based 提案。
179
+ - 全局记忆需启用(`trellis onboard --memory on`)才能让 lesson 回流有真实载体;本机目前未启用。
180
+ - 探针任务集(L2 的前置资产)。
181
+
182
+ ## 分阶段路线
183
+
184
+ | 阶段 | 内容 | 状态 |
185
+ |---|---|---|
186
+ | P0 | doctor oracle、backup/rollback、memory 管道、agent-bridge 委派原语 | 已就位 |
187
+ | P1 | **托管扩展点**(事件流、`host` 子路径、provider 外部注册) | 先做;进化侧一切依赖它 |
188
+ | P2 | 进化 Observe + Propose,仅配置类、人审批 | |
189
+ | P3 | Probe:L1 verdict 差集;`auto-A` | |
190
+ | P4 | 内容类:L2 匿名 A/B,依赖探针任务集;lesson 回流提质 | |
191
+ | P5 | 递归预算(K 候选 parallel / K 步 sequential)、容器化 probe | |
192
+
193
+ ## 明确不做
194
+
195
+ - 进化 Trellis 自身代码、secrets、auth、受管 agent 集合、用户手写文件。
196
+ - 让托管层依赖进化(依赖方向永远是进化 → 托管)。
197
+ - 绕过目标 agent 的审批 / 沙盒机制。
198
+ - 在没有现成 oracle 的类别上抢跑自动采纳(内容类必须等探针任务集)。
199
+
200
+ ## 下一步
201
+
202
+ 已完成:整合为 `openspec/changes/trellis-evolve/`(一份草案、两个 capability:`host-extension-points` 与 `self-evolution`),并已 `openspec validate --strict`;gateway 咽喉点与 `close()` 覆盖范围已核实(见上表)。
203
+
204
+ 待做(按 `tasks.md` 顺序):
205
+
206
+ 1. **前置验证(第 1 组,只读)**:真机采样元数据信号质量;核实 skills/instructions provider 读取路径;定下扩展加载机制与状态目录路径。
207
+ 2. **托管扩展点(第 2 组)**:事件总线、`host` 子路径门面、外部 provider 注册、"托管不依赖消费者"构建测试。纯增量、默认零行为变化,可单独发版。
208
+ 3. **进化 `off`/`observe` → `propose` → `auto-A`**(第 3–8 组),`auto-A` 之前须再评估带外批准的强度。
209
+ 4. L2 执行、探针任务集、递归预算为后续独立变更。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",