dsh-vscode-mode 0.16.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.
Files changed (45) hide show
  1. package/README.md +15 -9
  2. package/lib/client.js +954 -133
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +5026 -4387
  5. package/lib/index.js.map +1 -1
  6. package/locale/en.json +4 -2
  7. package/locale/zh.json +4 -2
  8. package/package.json +4 -2
  9. package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
  10. package/src/capture.ts +2 -2
  11. package/src/client/compat.ts +2 -1
  12. package/src/client/index.ts +37 -7
  13. package/src/client/localeClient.ts +97 -0
  14. package/src/client/mcpSessionState.ts +108 -0
  15. package/src/client/monaco/lsp/providers.ts +32 -3
  16. package/src/client/officialSidebar.ts +4 -1
  17. package/src/client/state/modelWarmer.ts +198 -0
  18. package/src/client/state/recordsFeed.ts +241 -0
  19. package/src/client/styles/editor.css +5 -1
  20. package/src/client/ui/ConversationDiffDock.ts +10 -19
  21. package/src/client/ui/DiffBadge.ts +12 -19
  22. package/src/client/ui/EditorPane.ts +16 -4
  23. package/src/client/ui/EditorView.ts +114 -51
  24. package/src/client/ui/McpSessionStatus.ts +75 -0
  25. package/src/client/ui/McpSettings.ts +47 -13
  26. package/src/client/ui/PerfSizeBadge.ts +7 -4
  27. package/src/client/ui/PluginDetailSection.ts +82 -0
  28. package/src/dshVersion.ts +4 -2
  29. package/src/fileVersions.ts +2 -2
  30. package/src/lsp/rpc.ts +2 -2
  31. package/src/mcp.ts +1 -0
  32. package/src/mcpBridge.ts +163 -0
  33. package/src/mcpDiscover.ts +38 -0
  34. package/src/mcpPool.ts +245 -0
  35. package/src/mcpProject.ts +15 -12
  36. package/src/mcpRuntime.ts +301 -255
  37. package/src/registry.ts +15 -2
  38. package/src/rpc.ts +15 -3
  39. package/src/rules.ts +9 -4
  40. package/src/search/fallback.ts +2 -2
  41. package/src/search/ripgrep.ts +2 -2
  42. package/src/shared/mcp.ts +47 -0
  43. package/src/shared/rpc.ts +5 -1
  44. package/src/store.ts +26 -1
  45. package/src/svn.ts +2 -2
package/src/mcpRuntime.ts CHANGED
@@ -1,315 +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'
12
+ import { cwdOfSession } from './store.js'
9
13
 
10
- export type ProjectDef = { config: MpcConfig; enabled: boolean }
11
- type Instance = { fiber?: any; key: string; path: string; error?: string; stopping?: Promise<void> }
12
- 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
+ }
13
27
  export type McpLoader = () => Promise<any>
14
28
  const runtimes = new WeakMap<object, McpRuntime>()
15
29
 
16
- /**
17
- * Load the official plugin from the running host's installation tree.
18
- * @private @author ddj 2026年09月28号
19
- * @returns Official MCP plugin namespace; rejects if the host package is absent.
20
- */
21
- async function loadMcp(): Promise<any> {
22
- return loadHostModule(MCP_PACKAGE)
23
- }
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) }
24
32
 
25
- /**
26
- * Resolve one runtime per host, shared by lifecycle and RPC contexts.
27
- * @public @author ddj 2026年09月28号
28
- * @param ctx Host context.
29
- * @returns Lazy runtime; allocating it does not start connections.
30
- */
33
+ /** @public @author ddj 2026年09月28号 @param ctx Host context. @returns Lazy per-host runtime, without opening connections. */
31
34
  export function runtimeOf(ctx: Ctx): McpRuntime {
32
35
  const owner = ctx.root ?? ctx
33
36
  let runtime = runtimes.get(owner)
34
- if (!runtime) {
35
- runtime = new McpRuntime(ctx)
36
- runtimes.set(owner, runtime)
37
- }
37
+ if (!runtime) { runtime = new McpRuntime(ctx); runtimes.set(owner, runtime) }
38
38
  return runtime
39
39
  }
40
40
 
41
- /** Agent -> server -> fiber ledger. Configuration alone never opens a connection. */
41
+ /** The pool owns transports; each agent owns only revocable public capabilities and leases. */
42
42
  export class McpRuntime {
43
43
  readonly projects = new Map<string, Map<string, ProjectDef>>()
44
44
  readonly agents = new Map<any, AgentRecord>()
45
- private tail: Promise<unknown> = Promise.resolve()
45
+ readonly pool: McpPool
46
46
  private stopped = false
47
+ private offDiscover?: () => void
47
48
  private readonly retired = new WeakSet<object>()
48
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
+ }
49
57
 
50
- /**
51
- * Create an idle ledger with an injectable official-plugin resolver.
52
- * @public @author ddj 2026年09月28号
53
- * @param ctx Host services and lifecycle owner.
54
- * @param load Official module resolver, replaceable in targeted tests.
55
- */
56
- constructor(private readonly ctx: Ctx, private readonly load: McpLoader = loadMcp) {}
57
-
58
- /**
59
- * Serialize reconfiguration and creation; a failed operation cannot poison the queue.
60
- * @private @author ddj 2026年09月28号
61
- * @param action Mutation to execute while the runtime remains live.
62
- * @returns Completion of this mutation, including official startup readiness.
63
- */
64
- private enqueue(action: () => Promise<void>): Promise<void> {
65
- const run = async () => { if (!this.stopped) await action() }
66
- const next = this.tail.then(run)
67
- this.tail = next.catch(() => undefined)
68
- 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))
69
63
  }
70
64
 
71
- /**
72
- * Find the longest registered workspace containing an agent's cwd.
73
- * @public @author ddj 2026年09月28号
74
- * @param agent Live agent.
75
- * @returns Registered workspace path, or undefined outside managed workspaces.
76
- */
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. */
77
86
  workspace(agent: any): string | undefined {
78
87
  const paths = (this.ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string }) => ws.path)
79
- return matchWorkspace(agent?.session?.header?.cwd, paths)
88
+ return matchWorkspace(cwdOfSession(this.ctx, agent?.session) ?? undefined, paths)
80
89
  }
81
90
 
82
- /**
83
- * Read configured server ownership, including disabled definitions.
84
- * @public @author ddj 2026年09月28号
85
- * @returns Server name to workspace mapping.
86
- */
91
+ /** @public @author ddj 2026年09月28号 @returns Configured namespaces including disabled definitions. */
87
92
  owners(): Map<string, string> {
88
93
  const names = new Map<string, string>()
89
- for (const record of this.agents.values()) {
90
- for (const [name, instance] of record.instances) names.set(name, instance.path)
91
- }
94
+ for (const record of this.agents.values()) for (const [name, instance] of record.instances) names.set(name, instance.path)
92
95
  for (const [path, defs] of this.projects) for (const name of defs.keys()) names.set(name, path)
93
96
  return names
94
97
  }
95
98
 
96
- /**
97
- * Publish ownership changes before any provider can become visible.
98
- * @private @author ddj 2026年09月28号
99
- * @sideEffects Refreshes agent restrictions and scoped prompt shadows synchronously.
100
- */
101
- private changed(): void {
102
- 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])
103
141
  }
104
142
 
105
- /**
106
- * Replace one validated file snapshot and update its active agents.
107
- * @public @author ddj 2026年09月28号
108
- * @param path Registered workspace path.
109
- * @param defs Validated source definitions.
110
- * @param refresh Optional server whose unchanged fiber must also be recreated.
111
- * @returns Completion after old fibers close and replacement startups settle.
112
- */
113
- replace(path: string, defs: Map<string, ProjectDef>, refresh?: string): Promise<void> {
114
- const update = async () => {
115
- this.projects.set(path, defs)
116
- this.changed()
117
- for (const [agent, record] of this.agents) await this.syncAgent(agent, record, this.workspace(agent) === path ? refresh : undefined)
118
- 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)
119
148
  }
120
- 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
121
153
  }
122
154
 
123
- /**
124
- * Attach an agent once and await its project plugin startups.
125
- * @public @author ddj 2026年09月28号
126
- * @param agent Created or already-active agent.
127
- * @returns Completion suitable for the serial agent/created listener.
128
- */
155
+ /** @public @author ddj 2026年10月20号 @param agent Live agent. @returns Lightweight capability installation; never waits for MCP startup. */
129
156
  attach(agent: any): Promise<void> {
130
- if (this.stopped || this.retired.has(agent)) return Promise.resolve()
131
- let record = this.agents.get(agent)
132
- if (!record) {
133
- record = { instances: new Map(), live: true }
134
- 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))
135
180
  }
136
- const target = record
137
- const attach = async () => { await this.syncAgent(agent, target); this.changed() }
138
- return this.enqueue(attach)
181
+ record.finishedAt ??= Date.now()
182
+ this.changed()
139
183
  }
140
184
 
141
- /**
142
- * Dispose a single connection before releasing its ledger slot.
143
- * @private @author ddj 2026年09月28号
144
- * @param record Agent ledger.
145
- * @param name Server name to stop.
146
- * @returns Awaitable Cordis teardown; failures remain visible to the caller.
147
- */
148
- private async stopOne(record: AgentRecord, name: string): Promise<void> {
149
- const instance = record.instances.get(name)
150
- if (instance) await this.stopFiber(instance)
151
- 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
+ })
152
230
  }
153
231
 
154
- /**
155
- * Await exact fiber teardown, including a disposer already claimed by its owning scope.
156
- * @private @author ddj 2026年09月28号
157
- * @param instance Connection ledger entry.
158
- * @returns Shared completion of asynchronous Cordis effects.
159
- */
160
- private stopFiber(instance: Instance): Promise<void> {
161
- const close = async () => {
162
- const fiber = instance.fiber
163
- await fiber?.dispose()
164
- 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
165
263
  }
166
- 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.' }
167
272
  }
168
273
 
169
- /**
170
- * Cancel and drain every fiber owned by a disposed agent.
171
- * @public @author ddj 2026年09月28号
172
- * @param agent Disposed agent.
173
- * @returns Completion of connection cleanup.
174
- */
274
+ /** @public @author ddj 2026年10月20号 @param agent Disposed owner. @returns Scoped teardown; shared transports remain owned by other leases. */
175
275
  async detach(agent: any): Promise<void> {
176
276
  this.retired.add(agent)
177
277
  const record = this.agents.get(agent)
178
278
  if (!record) return
179
279
  record.live = false
180
- 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()
181
284
  this.agents.delete(agent)
182
285
  this.changed()
183
286
  }
184
287
 
185
- /**
186
- * Match the agent ledger to the current enabled definitions.
187
- * @private @author ddj 2026年09月28号
188
- * @param agent Scoped plugin owner.
189
- * @param record Agent ledger.
190
- * @param refresh Optional server to reconnect.
191
- * @returns Completion of removals and scoped mounts.
192
- */
193
- private async syncAgent(agent: any, record: AgentRecord, refresh?: string): Promise<void> {
194
- if (!record.live || this.stopped) return
195
- const path = this.workspace(agent)
196
- const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
197
- for (const [name, instance] of record.instances) {
198
- const def = defs.get(name)
199
- if (def?.enabled && instance.path === path && instance.key === JSON.stringify(def.config) && name !== refresh) continue
200
- await this.stopOne(record, name)
201
- }
202
- for (const [name, def] of defs) {
203
- if (!record.live || this.stopped) break
204
- if (!def.enabled || record.instances.has(name)) continue
205
- await this.mount(agent, record, def.config, path!)
206
- }
207
- }
208
-
209
- /**
210
- * Cache successful module loads while allowing explicit refresh after transient failures.
211
- * @private @author ddj 2026年10月08号
212
- * @returns Official plugin module; load failures propagate to the instance diagnostic.
213
- */
288
+ /** @private @author ddj 2026年10月08号 @returns Cached official module; failures are retryable. */
214
289
  private async getModule(): Promise<any> {
215
290
  const loading = this.module ??= this.load()
216
- try {
217
- return await loading
218
- } catch (error) {
219
- if (this.module === loading) this.module = undefined
220
- throw error
221
- }
291
+ try { return await loading } catch (error) { if (this.module === loading) this.module = undefined; throw error }
222
292
  }
223
293
 
224
- /**
225
- * Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
226
- * @private @author ddj 2026年09月28号
227
- * @param agent Scoped context owner.
228
- * @param record Agent ledger.
229
- * @param config Validated source config.
230
- * @param path Default stdio working directory.
231
- * @returns Settled startup; errors are retained for the project status view.
232
- */
233
- private async mount(agent: any, record: AgentRecord, config: MpcConfig, path: string): Promise<void> {
234
- const instance: Instance = { key: JSON.stringify(config), path }
235
- record.instances.set(config.serverName, instance)
236
- try {
237
- const officialMcp = await this.getModule()
238
- if (!record.live || this.stopped) return
239
- const global = entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
240
- if (global) throw new Error('serverName 已被全局 MCP 使用:' + config.serverName)
241
- const options = config.transport === 'stdio' ? { ...config, cwd: config.cwd || path } : { ...config }
242
- instance.fiber = agent.ctx.plugin(officialMcp, options)
243
- await instance.fiber
244
- } catch (error) {
245
- instance.error = String(error)
246
- await this.stopFiber(instance)
247
- 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 })
248
311
  }
312
+ return { sessions, sampledAt: Date.now() }
249
313
  }
250
314
 
251
- /**
252
- * Build project status from exact agent scopes without assuming ACTIVE means connected.
253
- * @public @author ddj 2026年09月28号
254
- * @param path Workspace to summarize.
255
- * @returns Masked server views, including configured-only and disabled definitions.
256
- */
257
- views(path: string): MpcServer[] {
258
- const defs = this.projects.get(path) ?? new Map<string, ProjectDef>()
259
- 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
+ })
260
326
  }
261
327
 
262
- /**
263
- * Summarize one definition and its connection instances across matching agents.
264
- * @private @author ddj 2026年09月28号
265
- * @param path Owning workspace.
266
- * @param name Server name.
267
- * @param def Persisted definition.
268
- * @returns Honest status and union of tools visible to its agents.
269
- */
270
- private serverView(path: string, name: string, def: ProjectDef): MpcServer {
271
- const tools = new Map<string, MpcServer['tools'][number]>()
272
- const instances: Instance[] = []
273
- for (const [agent, record] of this.agents) {
274
- if (this.workspace(agent) !== path) continue
275
- const instance = record.instances.get(name)
276
- if (!instance) continue
277
- instances.push(instance)
278
- for (const tool of toolsOf(this.ctx, name, agent)) tools.set(tool.name, tool)
279
- }
280
- const error = instances.find((item) => item.error)?.error
281
- const fibers = instances.filter((item) => item.fiber)
282
- const failed = error || fibers.some((item) => item.fiber.state === 3)
283
- const status = !def.enabled ? 'disabled' : failed ? 'error' : fibers.length === 0 ? 'configured'
284
- : fibers.some((item) => item.fiber.state === 1 || item.fiber.state === 0) ? 'connecting' : 'unverified'
285
- return {
286
- id: projectEntryId(path, name), serverName: name, enabled: def.enabled,
287
- transport: def.config.transport, config: publicConfig(def.config as unknown as Record<string, unknown>),
288
- status, instanceCount: fibers.length, toolCount: tools.size, tools: [...tools.values()],
289
- error: error || (failed ? 'MCP 插件未正常运行' : undefined),
290
- }
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
+ })
291
346
  }
292
347
 
293
- /**
294
- * Close every owned connection and prevent queued work from mounting replacements.
295
- * @public @author ddj 2026年09月28号
296
- * @returns Completion after connection disposal and pending startup work settle.
297
- */
348
+ /** @public @author ddj 2026年10月20号 @returns Complete owned transport and capability cleanup. */
298
349
  async dispose(): Promise<void> {
299
350
  this.stopped = true
351
+ this.offDiscover?.()
300
352
  await Promise.all([...this.agents.keys()].map((agent) => this.detach(agent)))
301
- await this.tail
353
+ await this.pool.dispose()
354
+ await Promise.allSettled([...this.pending])
302
355
  this.projects.clear()
303
356
  this.module = undefined
304
357
  }
305
358
  }
306
359
 
307
- /**
308
- * Remove every old global project entry before any agent-scoped mount is attempted.
309
- * @public @author ddj 2026年09月28号
310
- * @param ctx Host loader context.
311
- * @returns Completion of loader removal and connection teardown.
312
- */
360
+ /** @public @author ddj 2026年09月28号 @param ctx Host loader. @returns Removal of legacy global project entries before any scoped entry is installed. */
313
361
  export async function clearLegacy(ctx: Ctx): Promise<void> {
314
362
  const loader = ctx.get('loader')
315
363
  for (const entry of entriesOf(ctx)) {
@@ -318,41 +366,39 @@ export async function clearLegacy(ctx: Ctx): Promise<void> {
318
366
  }
319
367
  }
320
368
 
321
- /**
322
- * Install awaited creation hooks, migrate global entries, and attach existing agents.
323
- * @public @author ddj 2026年09月28号
324
- * @param ctx Host lifecycle context; installIsolation must already be installed.
325
- * @returns Readiness for index.apply to await before advertising startup completion.
326
- */
369
+ /** @public @author ddj 2026年10月20号 @param ctx Host lifecycle. @returns Config/catalogue readiness, independent from all project MCP handshakes. */
327
370
  export async function installMcpRuntime(ctx: Ctx): Promise<void> {
328
371
  const runtime = runtimeOf(ctx)
329
372
  let ready: Promise<void>
330
- const initialize = async () => {
373
+ /** @author ddj 2026年10月20号 Loads validated snapshots before exposing lazy capabilities. */
374
+ const initialize = async (): Promise<void> => {
331
375
  await clearLegacy(ctx)
332
- await listProjects(ctx)
333
- 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
+ }
334
382
  }
335
- 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)
336
386
  await ready
337
387
  const path = runtime.workspace(agent)
338
388
  if (path) {
339
- try { await reconcileProject(ctx, path) }
340
- 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)) }
341
391
  }
342
392
  await runtime.attach(agent)
343
393
  }
344
- const disposed = ({ agent }: { agent: any }) => {
345
- void runtime.detach(agent).catch((error) => ctx.logger?.warn?.('MCP disposal failed: ' + String(error)))
346
- }
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))) }
347
396
  const offCreate = ctx.on('agent/created', created)
348
397
  const offDispose = ctx.on('agent/disposed', disposed)
349
- const cleanup = async () => {
350
- offCreate?.()
351
- offDispose?.()
352
- await runtime.dispose()
353
- runtimes.delete(ctx.root ?? ctx)
354
- }
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) }
355
400
  ctx.effect(() => cleanup, 'vscode-mode:mcp-runtime')
356
401
  ready = initialize()
357
402
  await ready
403
+ await Promise.all((ctx.get('agents')?.list?.() ?? []).map((agent: any) => runtime.attach(agent)))
358
404
  }