dsh-vscode-mode 0.17.0 → 0.18.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/src/mcpRuntime.ts CHANGED
@@ -1,316 +1,363 @@
1
- /** Agent-owned project MCP fibers. @author ddj 2026年09月28号 */
1
+ /** Project-owned MCP connections and scoped on-demand capabilities. @author ddj 2026年10月20号 */
2
2
  import { loadHostModule } from './hostImport.js'
3
3
  import { entriesOf, MCP_PACKAGE, publicConfig, toolsOf } from './mcp.js'
4
4
  import { isProjectEntryId } from './compat.js'
5
5
  import { matchWorkspace } from './mcpIsolation.js'
6
6
  import { listProjects, projectEntryId, reconcileProject } from './mcpProject.js'
7
- import type { MpcConfig, MpcServer } from './shared/mcp.js'
7
+ import { parseMcpPolicy, type McpPolicy, type McpPhase, type McpSessionServer, type McpSessionStatus, type MpcConfig, type MpcServer } from './shared/mcp.js'
8
+ import { McpPool, waitMcp, type PoolLease, type BridgeMaker } from './mcpPool.js'
9
+ import { McpBinding } from './mcpBridge.js'
10
+ import { installDiscover, discoverPrompt } from './mcpDiscover.js'
8
11
  import type { Ctx } from './store.js'
9
12
  import { cwdOfSession } from './store.js'
10
13
 
11
- export type ProjectDef = { config: MpcConfig; enabled: boolean }
12
- type Instance = { fiber?: any; key: string; path: string; error?: string; stopping?: Promise<void> }
13
- type AgentRecord = { instances: Map<string, Instance>; live: boolean }
14
+ export type ProjectDef = { config: MpcConfig; enabled: boolean; policy?: McpPolicy }
15
+ type Instance = { key: string; path: string; lease: PoolLease; binding: McpBinding; ready: Promise<void>; startedAt: number; reused: boolean; firstCallMs?: number }
16
+ type AgentRecord = {
17
+ instances: Map<string, Instance>
18
+ resources: Map<string, () => void>
19
+ requested: Set<string>
20
+ live: boolean
21
+ tail: Promise<void>
22
+ remove?: () => void
23
+ startedAt: number
24
+ finishedAt?: number
25
+ error?: string
26
+ }
14
27
  export type McpLoader = () => Promise<any>
15
28
  const runtimes = new WeakMap<object, McpRuntime>()
16
29
 
17
- /**
18
- * Load the official plugin from the running host's installation tree.
19
- * @private @author ddj 2026年09月28号
20
- * @returns Official MCP plugin namespace; rejects if the host package is absent.
21
- */
22
- async function loadMcp(): Promise<any> {
23
- return loadHostModule(MCP_PACKAGE)
24
- }
30
+ /** @private @author ddj 2026年09月28号 @returns Official namespace from the running Host installation. */
31
+ async function loadMcp(): Promise<any> { return loadHostModule(MCP_PACKAGE) }
25
32
 
26
- /**
27
- * Resolve one runtime per host, shared by lifecycle and RPC contexts.
28
- * @public @author ddj 2026年09月28号
29
- * @param ctx Host context.
30
- * @returns Lazy runtime; allocating it does not start connections.
31
- */
33
+ /** @public @author ddj 2026年09月28号 @param ctx Host context. @returns Lazy per-host runtime, without opening connections. */
32
34
  export function runtimeOf(ctx: Ctx): McpRuntime {
33
35
  const owner = ctx.root ?? ctx
34
36
  let runtime = runtimes.get(owner)
35
- if (!runtime) {
36
- runtime = new McpRuntime(ctx)
37
- runtimes.set(owner, runtime)
38
- }
37
+ if (!runtime) { runtime = new McpRuntime(ctx); runtimes.set(owner, runtime) }
39
38
  return runtime
40
39
  }
41
40
 
42
- /** Agent -> server -> fiber ledger. Configuration alone never opens a connection. */
41
+ /** The pool owns transports; each agent owns only revocable public capabilities and leases. */
43
42
  export class McpRuntime {
44
43
  readonly projects = new Map<string, Map<string, ProjectDef>>()
45
44
  readonly agents = new Map<any, AgentRecord>()
46
- private tail: Promise<unknown> = Promise.resolve()
45
+ readonly pool: McpPool
47
46
  private stopped = false
47
+ private offDiscover?: () => void
48
48
  private readonly retired = new WeakSet<object>()
49
49
  private module?: Promise<any>
50
+ private readonly pending = new Set<Promise<unknown>>()
51
+ private readonly barriers = new Map<string, Promise<void>>()
52
+
53
+ /** @public @author ddj 2026年10月20号 @param ctx Host services. @param load Official module resolver. @param make Optional controlled private-scope factory for tests. */
54
+ constructor(private readonly ctx: Ctx, private readonly load: McpLoader = loadMcp, make?: BridgeMaker) {
55
+ this.pool = new McpPool(ctx, () => this.getModule(), make)
56
+ }
50
57
 
51
- /**
52
- * Create an idle ledger with an injectable official-plugin resolver.
53
- * @public @author ddj 2026年09月28号
54
- * @param ctx Host services and lifecycle owner.
55
- * @param load Official module resolver, replaceable in targeted tests.
56
- */
57
- constructor(private readonly ctx: Ctx, private readonly load: McpLoader = loadMcp) {}
58
-
59
- /**
60
- * Serialize reconfiguration and creation; a failed operation cannot poison the queue.
61
- * @private @author ddj 2026年09月28号
62
- * @param action Mutation to execute while the runtime remains live.
63
- * @returns Completion of this mutation, including official startup readiness.
64
- */
65
- private enqueue(action: () => Promise<void>): Promise<void> {
66
- const run = async () => { if (!this.stopped) await action() }
67
- const next = this.tail.then(run)
68
- this.tail = next.catch(() => undefined)
69
- return next
58
+ /** @private @author ddj 2026年10月20号 @param task Owned background operation. Tracks failures without abandoning work. */
59
+ private track(task: Promise<unknown>): void {
60
+ this.pending.add(task)
61
+ void task.catch((error) => this.ctx.logger?.warn?.('Project MCP task failed: ' + String(error)))
62
+ .finally(() => this.pending.delete(task))
70
63
  }
71
64
 
72
- /**
73
- * Find the longest registered workspace containing an agent's cwd.
74
- * @public @author ddj 2026年09月28号
75
- * @param agent Live agent.
76
- * @returns Registered workspace path, or undefined outside managed workspaces.
77
- */
65
+ /** @private @author ddj 2026年10月20号 @param agent Scope owner. @returns Live record; retired agents cannot be resurrected. */
66
+ private recordOf(agent: any): AgentRecord | undefined {
67
+ if (this.stopped || this.retired.has(agent)) return undefined
68
+ let record = this.agents.get(agent)
69
+ if (!record) {
70
+ record = { instances: new Map(), resources: new Map(), requested: new Set(), live: true, tail: Promise.resolve(), startedAt: Date.now() }
71
+ this.agents.set(agent, record)
72
+ }
73
+ return record
74
+ }
75
+
76
+ /** @public @author ddj 2026年10月20号 @param agent Newly created scope. Starts only configuration timing. */
77
+ prepare(agent: any): void { this.recordOf(agent) }
78
+
79
+ /** @public @author ddj 2026年10月20号 @param agent Owner. @param error Configuration failure, never sent raw to clients. */
80
+ configError(agent: any, error?: unknown): void {
81
+ const record = this.recordOf(agent)
82
+ if (record) record.error = error === undefined ? undefined : '项目 MCP 配置读取或校验失败,保留最后有效配置;请查看 Host 日志'
83
+ }
84
+
85
+ /** @public @author ddj 2026年09月28号 @param agent Agent identity. @returns Longest registered workspace containing its cwd. */
78
86
  workspace(agent: any): string | undefined {
79
87
  const paths = (this.ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string }) => ws.path)
80
88
  return matchWorkspace(cwdOfSession(this.ctx, agent?.session) ?? undefined, paths)
81
89
  }
82
90
 
83
- /**
84
- * Read configured server ownership, including disabled definitions.
85
- * @public @author ddj 2026年09月28号
86
- * @returns Server name to workspace mapping.
87
- */
91
+ /** @public @author ddj 2026年09月28号 @returns Configured namespaces including disabled definitions. */
88
92
  owners(): Map<string, string> {
89
93
  const names = new Map<string, string>()
90
- for (const record of this.agents.values()) {
91
- for (const [name, instance] of record.instances) names.set(name, instance.path)
92
- }
94
+ for (const record of this.agents.values()) for (const [name, instance] of record.instances) names.set(name, instance.path)
93
95
  for (const [path, defs] of this.projects) for (const name of defs.keys()) names.set(name, path)
94
96
  return names
95
97
  }
96
98
 
97
- /**
98
- * Publish ownership changes before any provider can become visible.
99
- * @private @author ddj 2026年09月28号
100
- * @sideEffects Refreshes agent restrictions and scoped prompt shadows synchronously.
101
- */
102
- private changed(): void {
103
- this.ctx.emit?.('vscode-mode/mcp-change')
99
+ /** @private @author ddj 2026年10月20号 @param def Effective definition. @returns Config and policy generation identity. */
100
+ private defKey(def: ProjectDef): string { return JSON.stringify([def.config, parseMcpPolicy(def.policy)]) }
101
+
102
+ /** @private @author ddj 2026年09月28号 Refreshes existing isolation restrictions synchronously. */
103
+ private changed(): void { this.ctx.emit?.('vscode-mode/mcp-change') }
104
+
105
+ /** @private @author ddj 2026年10月20号 @param instance Public lease to revoke. Revokes synchronously before lease release. */
106
+ private revoke(instance: Instance): void { instance.binding.dispose(); instance.lease.release() }
107
+
108
+ /** @public @author ddj 2026年10月20号 @param path Owner workspace. @param defs Validated snapshot. @param refresh Optional explicitly invalidated service. @param wait Await configuration cleanup, never connection readiness. @returns Publication and optionally cleanup completion. */
109
+ async replace(path: string, defs: Map<string, ProjectDef>, refresh?: string, wait = true): Promise<void> {
110
+ if (this.stopped) return
111
+ const previous = this.projects.get(path)
112
+ if (!refresh && previous && JSON.stringify([...previous]) === JSON.stringify([...defs])) return
113
+ this.projects.set(path, defs)
114
+ const closing = new Set<ReturnType<McpPool['acquire']>['entry']>()
115
+ for (const [agent, record] of this.agents) {
116
+ for (const [name, instance] of record.instances) {
117
+ const def = defs.get(name)
118
+ if (instance.path !== path) continue
119
+ if (this.workspace(agent) === path && def?.enabled && this.defKey(def) === instance.key && name !== refresh) continue
120
+ record.instances.delete(name)
121
+ this.revoke(instance)
122
+ closing.add(instance.lease.entry)
123
+ }
124
+ }
125
+ // Warm entries may have no active subscribers, but must still respect config invalidation.
126
+ for (const entry of this.pool.entries.values()) {
127
+ if (entry.spec.workspace !== path) continue
128
+ const def = defs.get(entry.spec.config.serverName)
129
+ const config = def && this.options(def.config, path)
130
+ if (!def?.enabled || JSON.stringify(config) !== JSON.stringify(entry.spec.config)
131
+ || JSON.stringify(parseMcpPolicy(def.policy)) !== JSON.stringify(entry.spec.policy) || entry.spec.config.serverName === refresh) closing.add(entry)
132
+ }
133
+ const prior = this.barriers.get(path) ?? Promise.resolve()
134
+ const cleanup = Promise.all([...closing].map((entry) => this.pool.retire(entry))).then(() => undefined)
135
+ const barrier = Promise.all([prior, cleanup]).then(() => undefined)
136
+ this.barriers.set(path, barrier)
137
+ this.track(barrier)
138
+ this.changed()
139
+ const updates = [...this.agents].map(([agent, record]) => this.enqueue(agent, record))
140
+ if (wait) await Promise.all([barrier, ...updates])
104
141
  }
105
142
 
106
- /**
107
- * Replace one validated file snapshot and update its active agents.
108
- * @public @author ddj 2026年09月28号
109
- * @param path Registered workspace path.
110
- * @param defs Validated source definitions.
111
- * @param refresh Optional server whose unchanged fiber must also be recreated.
112
- * @returns Completion after old fibers close and replacement startups settle.
113
- */
114
- replace(path: string, defs: Map<string, ProjectDef>, refresh?: string): Promise<void> {
115
- const update = async () => {
116
- this.projects.set(path, defs)
117
- this.changed()
118
- for (const [agent, record] of this.agents) await this.syncAgent(agent, record, this.workspace(agent) === path ? refresh : undefined)
119
- this.changed()
143
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Lightweight queue. @returns Scoped catalogue/resource update, not transport initialization. */
144
+ private enqueue(agent: any, record: AgentRecord): Promise<void> {
145
+ const update = async (): Promise<void> => {
146
+ if (!record.live || this.stopped) return
147
+ this.syncAgent(agent, record)
120
148
  }
121
- return this.enqueue(update)
149
+ const task = record.tail.then(update)
150
+ record.tail = task.catch(() => { record.error = 'MCP 清理或入口更新失败,请查看 Host 日志' })
151
+ this.track(task)
152
+ return task
122
153
  }
123
154
 
124
- /**
125
- * Attach an agent once and await its project plugin startups.
126
- * @public @author ddj 2026年09月28号
127
- * @param agent Created or already-active agent.
128
- * @returns Completion suitable for the serial agent/created listener.
129
- */
155
+ /** @public @author ddj 2026年10月20号 @param agent Live agent. @returns Lightweight capability installation; never waits for MCP startup. */
130
156
  attach(agent: any): Promise<void> {
131
- if (this.stopped || this.retired.has(agent)) return Promise.resolve()
132
- let record = this.agents.get(agent)
133
- if (!record) {
134
- record = { instances: new Map(), live: true }
135
- this.agents.set(agent, record)
157
+ const record = this.recordOf(agent)
158
+ if (!record) return Promise.resolve()
159
+ this.offDiscover ??= installDiscover(this, this.ctx)
160
+ if (!record.remove) record.remove = discoverPrompt(this, agent)
161
+ return this.enqueue(agent, record)
162
+ }
163
+
164
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Scope record. Updates lazy resources and schedules explicit background services. */
165
+ private syncAgent(agent: any, record: AgentRecord): void {
166
+ const path = this.workspace(agent)
167
+ const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
168
+ for (const [name, instance] of record.instances) {
169
+ const def = defs.get(name)
170
+ if (instance.path === path && def?.enabled && this.defKey(def) === instance.key) continue
171
+ record.instances.delete(name)
172
+ this.revoke(instance)
173
+ }
174
+ for (const remove of record.resources.values()) remove()
175
+ record.resources.clear()
176
+ for (const [name, def] of defs) {
177
+ if (!def.enabled) continue
178
+ this.lazyResource(agent, record, name)
179
+ if (!record.instances.has(name) && (parseMcpPolicy(def.policy).startup === 'background' || record.requested.has(name))) this.track(this.connect(agent, name))
136
180
  }
137
- const target = record
138
- const attach = async () => { await this.syncAgent(agent, target); this.changed() }
139
- return this.enqueue(attach)
181
+ record.finishedAt ??= Date.now()
182
+ this.changed()
140
183
  }
141
184
 
142
- /**
143
- * Dispose a single connection before releasing its ledger slot.
144
- * @private @author ddj 2026年09月28号
145
- * @param record Agent ledger.
146
- * @param name Server name to stop.
147
- * @returns Awaitable Cordis teardown; failures remain visible to the caller.
148
- */
149
- private async stopOne(record: AgentRecord, name: string): Promise<void> {
150
- const instance = record.instances.get(name)
151
- if (instance) await this.stopFiber(instance)
152
- if (record.instances.get(name) === instance) record.instances.delete(name)
185
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Ledger. @param name Enabled namespace. Installs a resource-only on-demand entry without starting a connection. */
186
+ private lazyResource(agent: any, record: AgentRecord, name: string): void {
187
+ const resources = agent.ctx.get('mcpResources')
188
+ /** @author ddj 2026年10月20号 @param request Resource operation. @param execution Actual registry caller. @returns Official provider result after demand connection. */
189
+ const request = async (request: any, execution: any): Promise<any> => {
190
+ if (execution?.agent !== agent) throw new Error('不能借用其他会话的 MCP 资源')
191
+ await this.connect(agent, name, execution.signal)
192
+ const instance = record.instances.get(name)
193
+ if (!instance || !this.valid(agent, record, name, instance)) throw new Error('项目 MCP 配置已失效')
194
+ return this.pool.run(instance.lease.entry, async () => {
195
+ if (!this.valid(agent, record, name, instance)) throw new Error('项目 MCP 配置已失效')
196
+ const provider = instance.lease.entry.bridge?.resources.get(name)
197
+ if (!provider) throw new Error('MCP 未提供资源入口')
198
+ const startedAt = Date.now()
199
+ const result = await provider.request(request, execution)
200
+ instance.firstCallMs ??= Date.now() - startedAt
201
+ return result
202
+ }, execution.signal)
203
+ }
204
+ record.resources.set(name, resources.register(name, { request }))
205
+ }
206
+
207
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Ledger. @param name Namespace. @param instance Exact lease. @returns Whether a previously issued proxy can still execute. */
208
+ private valid(agent: any, record: AgentRecord, name: string, instance: Instance): boolean {
209
+ const path = this.workspace(agent)
210
+ const def = this.projects.get(path ?? '')?.get(name)
211
+ return !this.stopped && record.live && record.instances.get(name) === instance && instance.path === path
212
+ && def?.enabled === true && this.defKey(def) === instance.key
213
+ }
214
+
215
+ /** @private @author ddj 2026年10月20号 @param config Official config. @param path Default working directory. @returns Detached effective config; project strategy is never forwarded. */
216
+ private options(config: MpcConfig, path: string): MpcConfig {
217
+ return config.transport === 'stdio' ? { ...config, cwd: config.cwd || path } : { ...config }
218
+ }
219
+
220
+ /** @public @author ddj 2026年10月20号 @param agent Actual execution owner. @returns Safe enabled catalogue without opening connections. */
221
+ catalogue(agent: any): Array<{ serverName: string; phase: McpPhase }> {
222
+ const record = this.agents.get(agent)
223
+ if (!record?.live || this.stopped) return []
224
+ return [...this.projects.get(this.workspace(agent) ?? '') ?? []].filter(([, def]) => def.enabled)
225
+ .map(([serverName]) => {
226
+ const entry = record.instances.get(serverName)?.lease.entry
227
+ const phase: McpPhase = entry?.fiber?.state === 3 ? 'error' : entry?.phase ?? 'idle'
228
+ return { serverName, phase }
229
+ })
153
230
  }
154
231
 
155
- /**
156
- * Await exact fiber teardown, including a disposer already claimed by its owning scope.
157
- * @private @author ddj 2026年09月28号
158
- * @param instance Connection ledger entry.
159
- * @returns Shared completion of asynchronous Cordis effects.
160
- */
161
- private stopFiber(instance: Instance): Promise<void> {
162
- const close = async () => {
163
- const fiber = instance.fiber
164
- await fiber?.dispose()
165
- while (fiber?.inertia !== undefined) await fiber.inertia
232
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Ledger. @param path Workspace. @param name Namespace. @param def Validated definition. @returns Synchronously reserved lease and public binding. */
233
+ private reserve(agent: any, record: AgentRecord, path: string, name: string, def: ProjectDef): Instance {
234
+ const lease = this.pool.acquire({ workspace: path, owner: agent, config: this.options(def.config, path), policy: parseMcpPolicy(def.policy) })
235
+ const instance = { key: this.defKey(def), path, lease, startedAt: Date.now(), reused: lease.entry.refs > 1 || lease.entry.phase === 'ready' } as Instance
236
+ const valid = (): boolean => this.valid(agent, record, name, instance)
237
+ instance.binding = new McpBinding(agent, this.pool, lease.entry, valid, (elapsed) => { instance.firstCallMs ??= elapsed })
238
+ record.instances.set(name, instance)
239
+ instance.ready = lease.entry.ready.then(() => {
240
+ if (!valid()) throw new Error('项目 MCP 配置已失效')
241
+ instance.binding.sync()
242
+ this.changed()
243
+ })
244
+ this.track(instance.ready)
245
+ return instance
246
+ }
247
+
248
+ /** @public @author ddj 2026年10月20号 @param agent Actual active caller. @param name Enabled configured server. @param signal Caller-local cancellation. @returns Real tool schemas after private initial sync; no guessed tools. */
249
+ async connect(agent: any, name: string, signal?: AbortSignal): Promise<{ serverName: string; tools: any[]; resources: boolean; nextRequest: string }> {
250
+ const record = this.agents.get(agent)
251
+ if (!record?.live || this.stopped) throw new Error('会话未激活,不能连接项目 MCP')
252
+ await waitMcp(record.tail, signal)
253
+ const path = this.workspace(agent)
254
+ await waitMcp(this.barriers.get(path ?? '') ?? Promise.resolve(), signal)
255
+ if (!record.live || this.workspace(agent) !== path) throw new Error('项目 MCP 作用域已失效')
256
+ const def = this.projects.get(path ?? '')?.get(name)
257
+ if (!def?.enabled || !path) throw new Error('当前工作区未启用该项目 MCP')
258
+ const global = entriesOf(this.ctx).some((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === name)
259
+ if (global) throw new Error('serverName 已被全局 MCP 使用')
260
+ let instance = record.instances.get(name)
261
+ if (instance?.lease.entry.phase === 'error' && (instance.lease.entry.retryAt ?? Infinity) <= Date.now()) {
262
+ record.instances.delete(name); this.revoke(instance); instance = undefined
166
263
  }
167
- return instance.stopping ??= close()
264
+ record.requested.add(name)
265
+ instance ??= this.reserve(agent, record, path, name, def)
266
+ await waitMcp(instance.ready, signal)
267
+ if (!this.valid(agent, record, name, instance)) throw new Error('项目 MCP 配置已失效')
268
+ const visible = agent.ctx.get('tools')?.view?.(agent)?.visible
269
+ const tools = [...instance.lease.entry.bridge!.tools.values()].filter((tool) => visible?.has(tool.name))
270
+ .map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
271
+ return { serverName: name, tools, resources: instance.lease.entry.bridge!.resources.has(name), nextRequest: 'Use these tools in the next model request; PTC must start a new program to refresh its SDK.' }
168
272
  }
169
273
 
170
- /**
171
- * Cancel and drain every fiber owned by a disposed agent.
172
- * @public @author ddj 2026年09月28号
173
- * @param agent Disposed agent.
174
- * @returns Completion of connection cleanup.
175
- */
274
+ /** @public @author ddj 2026年10月20号 @param agent Disposed owner. @returns Scoped teardown; shared transports remain owned by other leases. */
176
275
  async detach(agent: any): Promise<void> {
177
276
  this.retired.add(agent)
178
277
  const record = this.agents.get(agent)
179
278
  if (!record) return
180
279
  record.live = false
181
- await Promise.all([...record.instances.keys()].map((name) => this.stopOne(record, name)))
280
+ record.remove?.()
281
+ for (const remove of record.resources.values()) remove()
282
+ for (const instance of record.instances.values()) this.revoke(instance)
283
+ record.instances.clear()
182
284
  this.agents.delete(agent)
183
285
  this.changed()
184
286
  }
185
287
 
186
- /**
187
- * Match the agent ledger to the current enabled definitions.
188
- * @private @author ddj 2026年09月28号
189
- * @param agent Scoped plugin owner.
190
- * @param record Agent ledger.
191
- * @param refresh Optional server to reconnect.
192
- * @returns Completion of removals and scoped mounts.
193
- */
194
- private async syncAgent(agent: any, record: AgentRecord, refresh?: string): Promise<void> {
195
- if (!record.live || this.stopped) return
196
- const path = this.workspace(agent)
197
- const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
198
- for (const [name, instance] of record.instances) {
199
- const def = defs.get(name)
200
- if (def?.enabled && instance.path === path && instance.key === JSON.stringify(def.config) && name !== refresh) continue
201
- await this.stopOne(record, name)
202
- }
203
- for (const [name, def] of defs) {
204
- if (!record.live || this.stopped) break
205
- if (!def.enabled || record.instances.has(name)) continue
206
- await this.mount(agent, record, def.config, path!)
207
- }
208
- }
209
-
210
- /**
211
- * Cache successful module loads while allowing explicit refresh after transient failures.
212
- * @private @author ddj 2026年10月08号
213
- * @returns Official plugin module; load failures propagate to the instance diagnostic.
214
- */
288
+ /** @private @author ddj 2026年10月08号 @returns Cached official module; failures are retryable. */
215
289
  private async getModule(): Promise<any> {
216
290
  const loading = this.module ??= this.load()
217
- try {
218
- return await loading
219
- } catch (error) {
220
- if (this.module === loading) this.module = undefined
221
- throw error
222
- }
291
+ try { return await loading } catch (error) { if (this.module === loading) this.module = undefined; throw error }
223
292
  }
224
293
 
225
- /**
226
- * Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
227
- * @private @author ddj 2026年09月28号
228
- * @param agent Scoped context owner.
229
- * @param record Agent ledger.
230
- * @param config Validated source config.
231
- * @param path Default stdio working directory.
232
- * @returns Settled startup; errors are retained for the project status view.
233
- */
234
- private async mount(agent: any, record: AgentRecord, config: MpcConfig, path: string): Promise<void> {
235
- const instance: Instance = { key: JSON.stringify(config), path }
236
- record.instances.set(config.serverName, instance)
237
- try {
238
- const officialMcp = await this.getModule()
239
- if (!record.live || this.stopped) return
240
- const global = entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
241
- if (global) throw new Error('serverName 已被全局 MCP 使用:' + config.serverName)
242
- const options = config.transport === 'stdio' ? { ...config, cwd: config.cwd || path } : { ...config }
243
- instance.fiber = agent.ctx.plugin(officialMcp, options)
244
- await instance.fiber
245
- } catch (error) {
246
- instance.error = String(error)
247
- await this.stopFiber(instance)
248
- instance.fiber = undefined
294
+ /** @public @author ddj 2026年10月20号 @returns Detached read-only active session statuses, without IO or activation. */
295
+ sessionSnapshot(): { sessions: McpSessionStatus[]; sampledAt: number } {
296
+ const sessions: McpSessionStatus[] = []
297
+ for (const [agent, record] of this.agents) {
298
+ if (!record.live || typeof agent?.session?.id !== 'string') continue
299
+ const servers = this.sessionServers(agent, record)
300
+ const active = servers.find((server) => ['stopping', 'starting', 'loading', 'queued'].includes(server.phase))
301
+ let phase: McpPhase = 'ready'
302
+ if (record.error || servers.some((server) => server.phase === 'error')) phase = 'error'
303
+ else if (active) phase = active.phase
304
+ else if (!record.finishedAt) phase = 'preparing'
305
+ else if (!servers.length) phase = 'none'
306
+ else if (servers.some((server) => server.phase === 'idle')) phase = 'idle'
307
+ const attempts = servers.filter((server) => server.phase !== 'idle')
308
+ const startedAt = attempts.length ? Math.min(...attempts.map((server) => server.startedAt)) : record.startedAt
309
+ sessions.push({ configMs: Math.max(0, (record.finishedAt ?? Date.now()) - record.startedAt), sessionId: agent.session.id, phase, startedAt, phaseAt: active?.phaseAt ?? startedAt,
310
+ finishedAt: active ? undefined : Math.max(record.finishedAt ?? record.startedAt, ...servers.map((server) => server.finishedAt ?? 0)), error: record.error, servers })
249
311
  }
312
+ return { sessions, sampledAt: Date.now() }
250
313
  }
251
314
 
252
- /**
253
- * Build project status from exact agent scopes without assuming ACTIVE means connected.
254
- * @public @author ddj 2026年09月28号
255
- * @param path Workspace to summarize.
256
- * @returns Masked server views, including configured-only and disabled definitions.
257
- */
258
- views(path: string): MpcServer[] {
259
- const defs = this.projects.get(path) ?? new Map<string, ProjectDef>()
260
- return [...defs].map(([name, def]) => this.serverView(path, name, def))
315
+ /** @private @author ddj 2026年10月20号 @param agent Owner. @param record Ledger. @returns Server phases with caller-local timing, not pooled connection age. */
316
+ private sessionServers(agent: any, record: AgentRecord): McpSessionServer[] {
317
+ return this.catalogue(agent).map(({ serverName, phase }) => {
318
+ const instance = record.instances.get(serverName)
319
+ if (!instance) return { serverName, phase: 'idle', startedAt: record.finishedAt ?? record.startedAt, phaseAt: record.finishedAt ?? record.startedAt }
320
+ const entry = instance.lease.entry
321
+ return { serverName, phase, startedAt: instance.startedAt, phaseAt: Math.max(instance.startedAt, entry.phaseAt),
322
+ finishedAt: entry.finishedAt && Math.max(instance.startedAt, entry.finishedAt), error: entry.error, reused: instance.reused,
323
+ queuedMs: Math.max(0, (entry.startAt ?? Date.now()) - instance.startedAt),
324
+ connectMs: entry.connectedAt === undefined ? undefined : Math.max(0, entry.connectedAt - Math.max(instance.startedAt, entry.startAt ?? instance.startedAt)), firstCallMs: instance.firstCallMs }
325
+ })
261
326
  }
262
327
 
263
- /**
264
- * Summarize one definition and its connection instances across matching agents.
265
- * @private @author ddj 2026年09月28号
266
- * @param path Owning workspace.
267
- * @param name Server name.
268
- * @param def Persisted definition.
269
- * @returns Honest status and union of tools visible to its agents.
270
- */
271
- private serverView(path: string, name: string, def: ProjectDef): MpcServer {
272
- const tools = new Map<string, MpcServer['tools'][number]>()
273
- const instances: Instance[] = []
274
- for (const [agent, record] of this.agents) {
275
- if (this.workspace(agent) !== path) continue
276
- const instance = record.instances.get(name)
277
- if (!instance) continue
278
- instances.push(instance)
279
- for (const tool of toolsOf(this.ctx, name, agent)) tools.set(tool.name, tool)
280
- }
281
- const error = instances.find((item) => item.error)?.error
282
- const fibers = instances.filter((item) => item.fiber)
283
- const failed = error || fibers.some((item) => item.fiber.state === 3)
284
- const status = !def.enabled ? 'disabled' : failed ? 'error' : fibers.length === 0 ? 'configured'
285
- : fibers.some((item) => item.fiber.state === 1 || item.fiber.state === 0) ? 'connecting' : 'unverified'
286
- return {
287
- id: projectEntryId(path, name), serverName: name, enabled: def.enabled,
288
- transport: def.config.transport, config: publicConfig(def.config as unknown as Record<string, unknown>),
289
- status, instanceCount: fibers.length, toolCount: tools.size, tools: [...tools.values()],
290
- error: error || (failed ? 'MCP 插件未正常运行' : undefined),
291
- }
328
+ /** @public @author ddj 2026年09月28号 @param path Workspace. @returns Masked configuration and pool/lease counts; initialization is not health. */
329
+ views(path: string): MpcServer[] {
330
+ return [...this.projects.get(path) ?? []].map(([name, def]) => {
331
+ const entries = [...this.pool.entries.values()].filter((entry) => entry.spec.workspace === path && entry.spec.config.serverName === name)
332
+ const tools = new Map<string, MpcServer['tools'][number]>()
333
+ for (const [agent] of this.agents) if (this.workspace(agent) === path) for (const tool of toolsOf(this.ctx, name, agent)) tools.set(tool.name, tool)
334
+ const failed = entries.some((entry) => entry.phase === 'error' || entry.error)
335
+ const busy = entries.some((entry) => ['queued', 'loading', 'starting', 'stopping'].includes(entry.phase))
336
+ let status: MpcServer['status'] = 'configured'
337
+ if (!def.enabled) status = 'disabled'
338
+ else if (failed) status = 'error'
339
+ else if (busy) status = 'connecting'
340
+ else if (entries.some((entry) => entry.phase === 'ready')) status = 'unverified'
341
+ return { id: projectEntryId(path, name), serverName: name, enabled: def.enabled, transport: def.config.transport,
342
+ config: publicConfig(def.config as unknown as Record<string, unknown>), policy: parseMcpPolicy(def.policy), status,
343
+ instanceCount: entries.filter((entry) => entry.fiber).length, sessionCount: entries.reduce((total, entry) => total + entry.refs, 0),
344
+ tools: [...tools.values()], toolCount: tools.size, error: failed ? 'MCP 初始化或清理异常,请查看 Host 日志' : undefined }
345
+ })
292
346
  }
293
347
 
294
- /**
295
- * Close every owned connection and prevent queued work from mounting replacements.
296
- * @public @author ddj 2026年09月28号
297
- * @returns Completion after connection disposal and pending startup work settle.
298
- */
348
+ /** @public @author ddj 2026年10月20号 @returns Complete owned transport and capability cleanup. */
299
349
  async dispose(): Promise<void> {
300
350
  this.stopped = true
351
+ this.offDiscover?.()
301
352
  await Promise.all([...this.agents.keys()].map((agent) => this.detach(agent)))
302
- await this.tail
353
+ await this.pool.dispose()
354
+ await Promise.allSettled([...this.pending])
303
355
  this.projects.clear()
304
356
  this.module = undefined
305
357
  }
306
358
  }
307
359
 
308
- /**
309
- * Remove every old global project entry before any agent-scoped mount is attempted.
310
- * @public @author ddj 2026年09月28号
311
- * @param ctx Host loader context.
312
- * @returns Completion of loader removal and connection teardown.
313
- */
360
+ /** @public @author ddj 2026年09月28号 @param ctx Host loader. @returns Removal of legacy global project entries before any scoped entry is installed. */
314
361
  export async function clearLegacy(ctx: Ctx): Promise<void> {
315
362
  const loader = ctx.get('loader')
316
363
  for (const entry of entriesOf(ctx)) {
@@ -319,41 +366,39 @@ export async function clearLegacy(ctx: Ctx): Promise<void> {
319
366
  }
320
367
  }
321
368
 
322
- /**
323
- * Install awaited creation hooks, migrate global entries, and attach existing agents.
324
- * @public @author ddj 2026年09月28号
325
- * @param ctx Host lifecycle context; installIsolation must already be installed.
326
- * @returns Readiness for index.apply to await before advertising startup completion.
327
- */
369
+ /** @public @author ddj 2026年10月20号 @param ctx Host lifecycle. @returns Config/catalogue readiness, independent from all project MCP handshakes. */
328
370
  export async function installMcpRuntime(ctx: Ctx): Promise<void> {
329
371
  const runtime = runtimeOf(ctx)
330
372
  let ready: Promise<void>
331
- const initialize = async () => {
373
+ /** @author ddj 2026年10月20号 Loads validated snapshots before exposing lazy capabilities. */
374
+ const initialize = async (): Promise<void> => {
332
375
  await clearLegacy(ctx)
333
- await listProjects(ctx)
334
- for (const agent of ctx.get('agents')?.list?.() ?? []) await runtime.attach(agent)
376
+ const { projects } = await listProjects(ctx, false)
377
+ for (const agent of ctx.get('agents')?.list?.() ?? []) {
378
+ runtime.prepare(agent)
379
+ const project = projects.find((item) => item.workspacePath === runtime.workspace(agent))
380
+ if (project?.fileError) runtime.configError(agent, project.fileError)
381
+ }
335
382
  }
336
- const created = async ({ agent }: { agent: any }) => {
383
+ /** @author ddj 2026年10月20号 @param event Actual created agent. Reads configuration but never waits for connection startup. */
384
+ const created = async ({ agent }: { agent: any }): Promise<void> => {
385
+ runtime.prepare(agent)
337
386
  await ready
338
387
  const path = runtime.workspace(agent)
339
388
  if (path) {
340
- try { await reconcileProject(ctx, path) }
341
- catch (error) { ctx.logger?.warn?.('MCP configuration retained: ' + String(error)) }
389
+ try { await reconcileProject(ctx, path, undefined, false); runtime.configError(agent) }
390
+ catch (error) { runtime.configError(agent, error); ctx.logger?.warn?.('MCP configuration retained: ' + String(error)) }
342
391
  }
343
392
  await runtime.attach(agent)
344
393
  }
345
- const disposed = ({ agent }: { agent: any }) => {
346
- void runtime.detach(agent).catch((error) => ctx.logger?.warn?.('MCP disposal failed: ' + String(error)))
347
- }
394
+ /** @author ddj 2026年10月20号 @param event Actual disposed agent. Revokes public access synchronously. */
395
+ const disposed = ({ agent }: { agent: any }): void => { void runtime.detach(agent).catch((error) => ctx.logger?.warn?.('MCP disposal failed: ' + String(error))) }
348
396
  const offCreate = ctx.on('agent/created', created)
349
397
  const offDispose = ctx.on('agent/disposed', disposed)
350
- const cleanup = async () => {
351
- offCreate?.()
352
- offDispose?.()
353
- await runtime.dispose()
354
- runtimes.delete(ctx.root ?? ctx)
355
- }
398
+ /** @author ddj 2026年10月20号 @returns Full lifecycle teardown. */
399
+ const cleanup = async (): Promise<void> => { offCreate?.(); offDispose?.(); await runtime.dispose(); runtimes.delete(ctx.root ?? ctx) }
356
400
  ctx.effect(() => cleanup, 'vscode-mode:mcp-runtime')
357
401
  ready = initialize()
358
402
  await ready
403
+ await Promise.all((ctx.get('agents')?.list?.() ?? []).map((agent: any) => runtime.attach(agent)))
359
404
  }