dsh-vscode-mode 0.14.2 → 0.14.4

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/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** dsh-vscode-mode Host composition. @author ddj 2026年09月28号 */
2
2
  import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
+ import { closeArchiveDbs } from './archiveDb.js'
4
5
  import { handleRpc } from './rpc.js'
5
6
  import { newSearcher } from './search/orchestrator.js'
6
7
  import { newContentSearcher } from './search/content.js'
@@ -83,6 +84,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
83
84
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
84
85
  async function cleanup(): Promise<void> {
85
86
  runtime.fileVersions.dispose()
87
+ closeArchiveDbs()
86
88
  disposeAllServers()
87
89
  const tasks = [() => runtime.lspManager.disposeAll(), () => runtime.svnRpc.dispose(), () => runtime.dapRpc.dispose()]
88
90
  await Promise.allSettled(tasks.map((run) => Promise.resolve().then(run)))
package/src/mcp.ts CHANGED
@@ -13,6 +13,49 @@ import {
13
13
  import type { MpcConfig, MpcServer, MpcTool } from './shared/mcp.js'
14
14
  import type { Ctx } from './store.js'
15
15
  import { checkMcpName } from './mcpProject.js'
16
+ import { runtimeOf } from './mcpRuntime.js'
17
+
18
+ /**
19
+ * Compare the official tool-name namespaces without guessing the longest prefix.
20
+ * @public @author ddj 2026年10月08号
21
+ * @param left First server name.
22
+ * @param right Second server name.
23
+ * @returns Whether distinct names generate overlapping public tool names.
24
+ */
25
+ export function nameOverlap(left: string, right: string): boolean {
26
+ return left !== right && (left.startsWith(right + '__') || right.startsWith(left + '__'))
27
+ }
28
+
29
+ /**
30
+ * Collect known global and project namespaces without opening connections.
31
+ * @public @author ddj 2026年10月08号
32
+ * @param ctx Host context.
33
+ * @returns Distinct server names used for fail-closed attribution.
34
+ */
35
+ export function mcpNames(ctx: Ctx): Set<string> {
36
+ const names = new Set(runtimeOf(ctx).owners().keys())
37
+ for (const entry of entriesOf(ctx)) {
38
+ const name = entry.options?.config?.serverName
39
+ if (typeof name === 'string') names.add(name)
40
+ }
41
+ return names
42
+ }
43
+
44
+ const edits = new WeakMap<object, Promise<unknown>>()
45
+
46
+ /**
47
+ * Serialize profile/project edits together to preserve files and cross-scope name reservations.
48
+ * @public @author ddj 2026年10月08号
49
+ * @param ctx Host owner.
50
+ * @param action Validated configuration operation.
51
+ * @returns Operation result; a rejection never poisons the next edit.
52
+ */
53
+ export function mcpEdit<T>(ctx: Ctx, action: () => Promise<T>): Promise<T> {
54
+ const owner = ctx.root ?? ctx
55
+ const next = (edits.get(owner) ?? Promise.resolve()).then(action, action)
56
+ edits.set(owner, next)
57
+ return next
58
+ }
16
59
 
17
60
  export { LEGACY_PROJECT_PREFIX, MCP_PACKAGE, PROJECT_ENTRY_PREFIX, isProjectEntryId }
18
61
 
@@ -46,7 +89,11 @@ export function toolsOf(ctx: Ctx, serverName: string, agent?: any): MpcTool[] {
46
89
  const visible = view?.visible
47
90
  if (!(visible instanceof Map)) return []
48
91
  const prefix = `mcp__${serverName}__`
49
- return [...visible.entries()].filter(([name]) => String(name).startsWith(prefix)).map(([name, definition]) => ({
92
+ const namespaces = mcpNames(ctx)
93
+ namespaces.add(serverName)
94
+ const belongs = (name: string) => name.startsWith(prefix)
95
+ && [...namespaces].filter((server) => name.startsWith('mcp__' + server + '__')).length === 1
96
+ return [...visible.entries()].filter(([name]) => belongs(String(name))).map(([name, definition]) => ({
50
97
  name: String(name).slice(prefix.length),
51
98
  description: typeof definition?.description === 'string' ? definition.description : undefined,
52
99
  }))
@@ -76,6 +123,20 @@ export function mergeSecrets(next: Record<string, unknown>, previous: Record<str
76
123
  return merged
77
124
  }
78
125
 
126
+ /**
127
+ * Merge editable transport fields while retaining advanced settings and secret values.
128
+ * @public @author ddj 2026年10月08号
129
+ * @param previous Last saved configuration.
130
+ * @param config Submitted transport configuration.
131
+ * @returns Detached configuration without fields belonging to the old transport.
132
+ */
133
+ export function mergeMcp(previous: Record<string, unknown>, config: MpcConfig): Record<string, unknown> {
134
+ const next = mergeSecrets({ ...previous, ...config }, previous)
135
+ const stale = config.transport === 'stdio' ? ['url', 'headers'] : ['command', 'args', 'cwd', 'env']
136
+ for (const key of stale) delete next[key]
137
+ return next
138
+ }
139
+
79
140
  /**
80
141
  * Summarize a global entry without treating plugin activation as a successful handshake.
81
142
  * @public @author ddj 2026年09月28号
@@ -103,11 +164,40 @@ export function serverOf(ctx: Ctx, entry: any): MpcServer {
103
164
  }
104
165
  }
105
166
 
106
- /** 校验 MCP 配置。 */
167
+ /**
168
+ * Validate transport fields before any loader or source mutation.
169
+ * @public @author ddj 2026年10月08号
170
+ * @param config Submitted configuration.
171
+ * @throws Error for unsupported transport or malformed endpoint/arguments/maps.
172
+ */
107
173
  export function validateConfig(config: MpcConfig): void {
108
- if (!/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error('serverName 只能包含字母、数字、下划线和连字符(最多 32 位)')
109
- if (config.transport === 'stdio' && !config.command?.trim()) throw new Error('stdio MCP 必须填写 command')
110
- if (config.transport === 'streamable-http' && !/^https?:\/\//.test(config.url ?? '')) throw new Error('HTTP MCP 必须填写 http(s) URL')
174
+ if (!config || typeof config !== 'object') throw new Error('MCP 配置必须是对象')
175
+ if (typeof config.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error('serverName 只能包含字母、数字、下划线和连字符(最多 32 位)')
176
+ if (!['stdio', 'streamable-http'].includes(config.transport)) throw new Error('不支持的 MCP transport')
177
+ if (config.transport === 'stdio') {
178
+ if (typeof config.command !== 'string' || !config.command.trim()) throw new Error('stdio MCP 必须填写 command')
179
+ if (config.args !== undefined && (!Array.isArray(config.args) || config.args.some((arg) => typeof arg !== 'string'))) throw new Error('MCP args 必须是字符串数组')
180
+ if (config.cwd !== undefined && typeof config.cwd !== 'string') throw new Error('MCP cwd 必须是字符串')
181
+ validatePairs(config.env, 'env')
182
+ } else {
183
+ let url: URL
184
+ try { url = new URL(config.url ?? '') } catch { throw new Error('HTTP MCP 必须填写有效 http(s) URL') }
185
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error('HTTP MCP 必须填写有效 http(s) URL,凭据请使用请求头')
186
+ validatePairs(config.headers, 'headers')
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Validate an optional string-valued map without coercing malformed input.
192
+ * @private @author ddj 2026年10月08号
193
+ * @param values Submitted map.
194
+ * @param field Field used in the diagnostic.
195
+ * @throws Error for non-object or non-string values.
196
+ */
197
+ function validatePairs(values: unknown, field: string): void {
198
+ if (values === undefined) return
199
+ if (!values || typeof values !== 'object' || Array.isArray(values)
200
+ || Object.entries(values).some(([key, value]) => !key || typeof value !== 'string')) throw new Error('MCP ' + field + ' 必须是字符串字典')
111
201
  }
112
202
 
113
203
  /** 列出全局(profile)MCP 服务,过滤掉新旧格式的项目级条目。 */
@@ -123,13 +213,18 @@ export function listMcp(ctx: Ctx): { servers: MpcServer[] } {
123
213
  * @returns Masked server status; project name collisions reject before writing.
124
214
  */
125
215
  export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
216
+ return mcpEdit(ctx, () => saveGlobal(ctx, config))
217
+ }
218
+
219
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param config Configuration. @returns Saved status; validation failures do not write. */
220
+ async function saveGlobal(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
126
221
  validateConfig(config)
127
222
  await checkMcpName(ctx, config.serverName)
128
223
  const loader = ctx.get('loader')
129
224
  if (!loader) throw new Error('缺少 loader 服务')
130
225
  const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
131
226
  const previous = existing?.options?.config ?? {}
132
- const next = mergeSecrets({ ...previous, ...config }, previous)
227
+ const next = mergeMcp(previous, config)
133
228
  if (existing) {
134
229
  await loader.update(existing.id, { config: next })
135
230
  return serverOf(ctx, loader.resolve(existing.id))
@@ -138,33 +233,46 @@ export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
138
233
  return serverOf(ctx, loader.resolve(id))
139
234
  }
140
235
 
141
- /** 删除 MCP 服务。 */
142
- export async function removeMcp(ctx: Ctx, id: string): Promise<void> {
236
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader and verified global entry. @throws Missing service, entry or wrong scope. */
237
+ function globalEntry(ctx: Ctx, id: string): { loader: any; entry: any } {
143
238
  const loader = ctx.get('loader')
144
239
  if (!loader) throw new Error('缺少 loader 服务')
145
240
  const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
146
241
  if (!entry) throw new Error('MCP 服务不存在')
147
- await loader.remove(entry.id)
242
+ if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))) throw new Error('项目 MCP 必须通过项目入口操作')
243
+ return { loader, entry }
244
+ }
245
+
246
+ /** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Persisted removal, serialized with all settings writes. */
247
+ export async function removeMcp(ctx: Ctx, id: string): Promise<void> {
248
+ /** @private @author ddj 2026年10月08号 @returns Loader removal after scope validation. */
249
+ const remove = async () => {
250
+ const { loader, entry } = globalEntry(ctx, id)
251
+ await loader.remove(entry.id)
252
+ }
253
+ return mcpEdit(ctx, remove)
148
254
  }
149
255
 
150
- /** 切换 MCP 服务启用状态。 */
256
+ /** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @param enabled Desired state. @returns Persisted status after serialized toggle. */
151
257
  export async function toggleMcp(ctx: Ctx, id: string, enabled: boolean): Promise<MpcServer> {
152
- const loader = ctx.get('loader')
153
- if (!loader) throw new Error('缺少 loader 服务')
154
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
155
- if (!entry) throw new Error('MCP 服务不存在')
156
- await loader.update(entry.id, { disabled: !enabled })
157
- return serverOf(ctx, loader.resolve(entry.id))
258
+ /** @private @author ddj 2026年10月08号 @returns Loader status after toggle. */
259
+ const toggle = async () => {
260
+ const { loader, entry } = globalEntry(ctx, id)
261
+ await loader.update(entry.id, { disabled: !enabled })
262
+ return serverOf(ctx, loader.resolve(entry.id))
263
+ }
264
+ return mcpEdit(ctx, toggle)
158
265
  }
159
266
 
160
- /** 重新加载 MCP 服务。 */
267
+ /** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader status after serialized refresh. */
161
268
  export async function refreshMcp(ctx: Ctx, id: string): Promise<MpcServer> {
162
- const loader = ctx.get('loader')
163
- if (!loader) throw new Error('缺少 loader 服务')
164
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id)
165
- if (!entry) throw new Error('MCP 服务不存在')
166
- await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
167
- return serverOf(ctx, loader.resolve(entry.id))
269
+ /** @private @author ddj 2026年10月08号 @returns Loader status after refresh. */
270
+ const refresh = async () => {
271
+ const { loader, entry } = globalEntry(ctx, id)
272
+ await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
273
+ return serverOf(ctx, loader.resolve(entry.id))
274
+ }
275
+ return mcpEdit(ctx, refresh)
168
276
  }
169
277
 
170
278
  export type { MpcConfig, MpcServer }
@@ -1,7 +1,7 @@
1
1
  /** Project MCP visibility, instruction shadows, and execution guards. @author ddj 2026年09月28号 */
2
2
  import { posix, win32 } from 'node:path'
3
3
  import { LEGACY_PROJECT_PREFIX, entryHash, isProjectEntryId } from './compat.js'
4
- import { entriesOf } from './mcp.js'
4
+ import { entriesOf, mcpNames } from './mcp.js'
5
5
  import { hashWorkspace } from './mcpProject.js'
6
6
  import { runtimeOf } from './mcpRuntime.js'
7
7
  import type { Ctx } from './store.js'
@@ -101,6 +101,7 @@ export function guardMcp(ctx: Ctx, exec: any): string | undefined {
101
101
  const server = callServer(exec)
102
102
  const owners = serverOwners(ctx)
103
103
  const current = runtimeOf(ctx).workspace(exec.agent)
104
+ if (!RESOURCE_TOOLS.has(name) && [...mcpNames(ctx)].filter((key) => name.startsWith('mcp__' + key + '__')).length > 1) return '已拒绝:MCP 命名空间冲突,无法确认工具归属'
104
105
  const owned = RESOURCE_TOOLS.has(name) ? [...owners].filter(([key]) => key === server)
105
106
  : [...owners].filter(([key]) => name.startsWith('mcp__' + key + '__'))
106
107
  if (!owned.some(([, owner]) => owner !== current)) return undefined
package/src/mcpProject.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Project MCP configuration; .mcp.json is the source of truth. @author ddj 2026年09月28号 */
2
2
  import { createHash } from 'node:crypto'
3
- import { PROJECT_ENTRY_PREFIX, entriesOf, mergeSecrets, validateConfig } from './mcp.js'
3
+ import { PROJECT_ENTRY_PREFIX, entriesOf, mergeMcp, validateConfig, nameOverlap, listMcp, mcpEdit } from './mcp.js'
4
+
4
5
  import { isProjectEntryId } from './compat.js'
5
6
  import { clearLegacy, runtimeOf } from './mcpRuntime.js'
6
7
  import type { ProjectDef } from './mcpRuntime.js'
@@ -8,6 +9,7 @@ import type { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
8
9
  import type { Ctx } from './store.js'
9
10
 
10
11
  const FILE_NAME = '.mcp.json'
12
+ const projectStates = new WeakMap<object, Map<string, { fileError?: string; missingDir?: boolean }>>()
11
13
 
12
14
  /**
13
15
  * Require an exact registered workspace before any project file mutation.
@@ -54,7 +56,18 @@ export function projectEntryId(workspacePath: string, serverName: string): strin
54
56
  }
55
57
 
56
58
  /**
57
- * Read the complete source document; only ENOENT denotes an absent configuration.
59
+ * Identify an absent target using official host and legacy Node filesystem codes.
60
+ * @private @author ddj 2026年10月08号
61
+ * @param error Filesystem failure; messages and class identity are not authoritative.
62
+ * @returns Whether the backend explicitly reports a missing target.
63
+ */
64
+ function isMissingFile(error: unknown): boolean {
65
+ const code = (error as { code?: unknown } | null)?.code
66
+ return code === 'FS_NOT_FOUND' || code === 'ENOENT'
67
+ }
68
+
69
+ /**
70
+ * Read the complete source document; only missing-target codes denote an absent configuration.
58
71
  * @private @author ddj 2026年09月28号
59
72
  * @param ctx Host filesystem.
60
73
  * @param workspacePath Registered workspace path.
@@ -68,7 +81,7 @@ async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<
68
81
  try {
69
82
  text = await fs.readText(target)
70
83
  } catch (error) {
71
- if ((error as { code?: string })?.code === 'ENOENT') return {}
84
+ if (isMissingFile(error)) return {}
72
85
  throw new Error('.mcp.json 读取失败:' + String(error))
73
86
  }
74
87
  let data: unknown
@@ -108,7 +121,14 @@ export function serversOf(data: Record<string, unknown>): Record<string, Record<
108
121
  */
109
122
  export function configFromDef(def: Record<string, unknown> | undefined, serverName: string): MpcConfig | null {
110
123
  if (!def) return null
111
- const transport = typeof def.url === 'string' ? 'streamable-http' : 'stdio'
124
+ if (def.transport !== undefined && !['stdio', 'streamable-http'].includes(def.transport as string)) throw new Error('不支持的 MCP transport:' + serverName)
125
+ if (def.args !== undefined && (!Array.isArray(def.args) || def.args.some((arg) => typeof arg !== 'string'))) throw new Error('MCP args 必须是字符串数组:' + serverName)
126
+ for (const field of ['env', 'headers']) {
127
+ const values = def[field]
128
+ if (values !== undefined && (!values || typeof values !== 'object' || Array.isArray(values)
129
+ || Object.values(values).some((value) => value === null || !['string', 'number', 'boolean'].includes(typeof value)))) throw new Error('MCP ' + field + ' 必须是标量字典:' + serverName)
130
+ }
131
+ const transport = def.transport as MpcConfig['transport'] | undefined ?? (typeof def.url === 'string' ? 'streamable-http' : 'stdio')
112
132
  const config: MpcConfig = { serverName, transport }
113
133
  if (transport === 'stdio') {
114
134
  config.command = typeof def.command === 'string' ? def.command : ''
@@ -150,16 +170,19 @@ async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<st
150
170
  * @returns Completion; throws on collision or an unreadable competing source document.
151
171
  */
152
172
  export async function checkMcpName(ctx: Ctx, serverName: string, workspacePath?: string): Promise<void> {
153
- const conflict = 'serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称'
173
+ /** @private @author ddj 2026年10月08号 @param other Existing name. @param sameOwner Whether updates share ownership. @throws Error for ambiguous namespaces. */
174
+ const checkName = (other: string, sameOwner: boolean): void => {
175
+ if (nameOverlap(serverName, other)) throw new Error('MCP 命名空间冲突:' + serverName + ' / ' + other + ',请避免 __ 前缀交叠')
176
+ if (other === serverName && !sameOwner) throw new Error('serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称')
177
+ }
154
178
  for (const entry of entriesOf(ctx)) {
155
- const project = isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))
156
- if (project || workspacePath === undefined) continue
157
- if (entry.options?.config?.serverName === serverName) throw new Error(conflict)
179
+ if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))) continue
180
+ const name = entry.options?.config?.serverName
181
+ if (typeof name === 'string') checkName(name, workspacePath === undefined)
158
182
  }
159
183
  for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) {
160
- if (ws.path === workspacePath) continue
161
184
  const servers = serversOf(await readProjectJson(ctx, ws.path))
162
- if (Object.hasOwn(servers, serverName)) throw new Error(conflict)
185
+ for (const name of Object.keys(servers)) checkName(name, ws.path === workspacePath)
163
186
  }
164
187
  }
165
188
 
@@ -232,9 +255,13 @@ async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promis
232
255
  missingDir = !info || info.type !== 'directory'
233
256
  if (!missingDir) await reconcileProject(ctx, workspacePath)
234
257
  } catch (error) {
235
- missingDir = (error as { code?: string })?.code === 'ENOENT' || undefined
258
+ missingDir = isMissingFile(error) || undefined
236
259
  fileError = String(error)
237
260
  }
261
+ const owner = ctx.root ?? ctx
262
+ const states = projectStates.get(owner) ?? new Map()
263
+ states.set(workspacePath, { missingDir, fileError })
264
+ projectStates.set(owner, states)
238
265
  return { workspacePath, title, servers: runtimeOf(ctx).views(workspacePath), source: 'project', missingDir, fileError }
239
266
  }
240
267
 
@@ -250,6 +277,21 @@ export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }
250
277
  return { projects }
251
278
  }
252
279
 
280
+ /**
281
+ * Read the current ledger only; polling never reconciles files or starts connections.
282
+ * @public @author ddj 2026年10月08号
283
+ * @param ctx Host services.
284
+ * @returns Masked global/project snapshots with last configuration diagnostics.
285
+ */
286
+ export function mcpSnapshot(ctx: Ctx): { servers: MpcServer[]; projects: MpcProject[] } {
287
+ const states = projectStates.get(ctx.root ?? ctx)
288
+ const projects: MpcProject[] = (ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string; title?: string }) => ({
289
+ workspacePath: ws.path, title: ws.title ?? '', source: 'project' as const,
290
+ servers: runtimeOf(ctx).views(ws.path), ...states?.get(ws.path),
291
+ }))
292
+ return { ...listMcp(ctx), projects }
293
+ }
294
+
253
295
  /**
254
296
  * Merge editable fields while retaining advanced fields and unchanged masked credentials.
255
297
  * @private @author ddj 2026年09月28号
@@ -258,12 +300,10 @@ export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }
258
300
  * @returns Enabled source definition without runtime-only keys or stale transport fields.
259
301
  */
260
302
  function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig): Record<string, unknown> {
261
- const next = mergeSecrets({ ...previous, ...config }, previous)
303
+ const next = mergeMcp(previous, config)
262
304
  delete next.serverName
263
305
  delete next.transport
264
306
  delete next.disabled
265
- const stale = config.transport === 'stdio' ? ['url', 'headers'] : ['command', 'args', 'cwd', 'env']
266
- for (const key of stale) delete next[key]
267
307
  return next
268
308
  }
269
309
 
@@ -277,6 +317,11 @@ function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig):
277
317
  * @returns Updated project view; invalid sources or conflicts reject before writing.
278
318
  */
279
319
  export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
320
+ return mcpEdit(ctx, () => saveProject(ctx, workspacePath, serverName, config))
321
+ }
322
+
323
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param config Editable fields. @returns Persisted project status. */
324
+ async function saveProject(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
280
325
  const workspace = requireWorkspace(ctx, workspacePath)
281
326
  validateConfig({ ...config, serverName })
282
327
  await checkMcpName(ctx, serverName, workspacePath)
@@ -297,6 +342,11 @@ export async function projectSave(ctx: Ctx, workspacePath: string, serverName: s
297
342
  * @returns Updated project view; write failures leave active configuration intact.
298
343
  */
299
344
  export async function projectRemove(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
345
+ return mcpEdit(ctx, () => removeProject(ctx, workspacePath, serverName))
346
+ }
347
+
348
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Project after persisted removal. */
349
+ async function removeProject(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
300
350
  const workspace = requireWorkspace(ctx, workspacePath)
301
351
  const data = await readProjectJson(ctx, workspacePath)
302
352
  const servers = serversOf(data)
@@ -316,6 +366,11 @@ export async function projectRemove(ctx: Ctx, workspacePath: string, serverName:
316
366
  * @returns Updated project view, including retained disabled definitions.
317
367
  */
318
368
  export async function projectToggle(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
369
+ return mcpEdit(ctx, () => toggleProject(ctx, workspacePath, serverName, enabled))
370
+ }
371
+
372
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param enabled Desired state. @returns Project after persisted toggle. */
373
+ async function toggleProject(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
319
374
  const workspace = requireWorkspace(ctx, workspacePath)
320
375
  const data = await readProjectJson(ctx, workspacePath)
321
376
  const servers = serversOf(data)
@@ -337,6 +392,11 @@ export async function projectToggle(ctx: Ctx, workspacePath: string, serverName:
337
392
  * @returns Updated project view; disabled or idle definitions stay connection-free.
338
393
  */
339
394
  export async function projectRefresh(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
395
+ return mcpEdit(ctx, () => refreshProject(ctx, workspacePath, serverName))
396
+ }
397
+
398
+ /** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Refreshed project status without racing edits. */
399
+ async function refreshProject(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
340
400
  const workspace = requireWorkspace(ctx, workspacePath)
341
401
  const data = await readProjectJson(ctx, workspacePath)
342
402
  if (!Object.hasOwn(serversOf(data), serverName)) throw new Error('项目中不存在该 MCP')
package/src/mcpRuntime.ts CHANGED
@@ -114,7 +114,7 @@ export class McpRuntime {
114
114
  const update = async () => {
115
115
  this.projects.set(path, defs)
116
116
  this.changed()
117
- for (const [agent, record] of this.agents) await this.syncAgent(agent, record, refresh)
117
+ for (const [agent, record] of this.agents) await this.syncAgent(agent, record, this.workspace(agent) === path ? refresh : undefined)
118
118
  this.changed()
119
119
  }
120
120
  return this.enqueue(update)
@@ -196,7 +196,7 @@ export class McpRuntime {
196
196
  const defs = this.projects.get(path ?? '') ?? new Map<string, ProjectDef>()
197
197
  for (const [name, instance] of record.instances) {
198
198
  const def = defs.get(name)
199
- if (def?.enabled && instance.key === JSON.stringify(def.config) && name !== refresh) continue
199
+ if (def?.enabled && instance.path === path && instance.key === JSON.stringify(def.config) && name !== refresh) continue
200
200
  await this.stopOne(record, name)
201
201
  }
202
202
  for (const [name, def] of defs) {
@@ -206,6 +206,21 @@ export class McpRuntime {
206
206
  }
207
207
  }
208
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
+ */
214
+ private async getModule(): Promise<any> {
215
+ 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
+ }
222
+ }
223
+
209
224
  /**
210
225
  * Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
211
226
  * @private @author ddj 2026年09月28号
@@ -219,7 +234,7 @@ export class McpRuntime {
219
234
  const instance: Instance = { key: JSON.stringify(config), path }
220
235
  record.instances.set(config.serverName, instance)
221
236
  try {
222
- const officialMcp = await (this.module ??= this.load())
237
+ const officialMcp = await this.getModule()
223
238
  if (!record.live || this.stopped) return
224
239
  const global = entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
225
240
  if (global) throw new Error('serverName 已被全局 MCP 使用:' + config.serverName)
package/src/paths.ts CHANGED
@@ -193,6 +193,17 @@ export function debugLogFile(cwd: string, home = dshHome()): string {
193
193
  return join(pluginLogRoot(home), 'debug.' + hashOf(cwd) + '.log')
194
194
  }
195
195
 
196
+ /**
197
+ * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
198
+ * @author ddj 2026年09月30号
199
+ * @param cwd 工作区绝对路径
200
+ * @param home DSH home
201
+ * @returns 归档数据库绝对路径
202
+ */
203
+ export function archiveDbFile(cwd: string, home = dshHome()): string {
204
+ return join(home, PLUGIN_ID, 'data', 'archive', hashOf(cwd) + '.sqlite')
205
+ }
206
+
196
207
  /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
197
208
  export function assetsDirOf(moduleUrl: string): string {
198
209
  return join(dirname(fileURLToPath(moduleUrl)), '..', 'assets')
package/src/perf.ts CHANGED
@@ -10,10 +10,9 @@
10
10
  */
11
11
  import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
12
12
  import { dirname, join, resolve, sep } from 'node:path'
13
- import { parseSidecar } from './store.js'
14
- import { recSummary } from './model.js'
13
+ import { activeSummary } from './archiveDb.js'
15
14
  import { log } from './log.js'
16
- import { SIDECAR, SIDECAR_ARCHIVE, dshHome, sessionIdSegment, sessionWorkspaceKey, sessionsRoot } from './paths.js'
15
+ import { SIDECAR, SIDECAR_ARCHIVE, archiveDbFile, dshHome, sessionIdSegment, sessionWorkspaceKey, sessionsRoot } from './paths.js'
17
16
  import type { PerfMoveFailure, PerfMoveItem, PerfSession, PerfTotals, PerfWorkspace, SidecarPerfSummary } from './shared/rpc.js'
18
17
  import type { Ctx } from './store.js'
19
18
 
@@ -370,29 +369,23 @@ export async function sessionSizeOf(home: string, cwd: string, sessionId: string
370
369
  */
371
370
  export async function sidecarSummaryOf(ctx: Ctx, cwd: string | null): Promise<SidecarPerfSummary> {
372
371
  const fs = ctx.get('fs')
373
- const summary: SidecarPerfSummary = { active: 0, pendingByFile: [], archiveBytes: 0 }
372
+ const summary: SidecarPerfSummary = { active: 0, pendingByFile: [], archiveBytes: 0, legacyArchiveBytes: 0, legacyActiveBytes: 0 }
374
373
  if (!fs || !cwd) return summary
374
+ const current = activeSummary(cwd)
375
+ summary.active = current.active
376
+ summary.pendingByFile = current.pendingByFile
375
377
  try {
376
- const text = await fs.readText(await fs.resolve(SIDECAR, { cwd }))
377
- const data = parseSidecar(text)
378
- const recs = data?.workspaces?.[cwd]?.records
379
- if (recs) {
380
- const byFile = new Map<string, number>()
381
- let active = 0
382
- for (const rec of Object.values(recs)) {
383
- if (rec.archived) continue
384
- active += 1
385
- const sm = recSummary(rec)
386
- if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending)
387
- }
388
- summary.active = active
389
- summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({ path, pending })).sort((a, b) => b.pending - a.pending)
390
- }
391
- try {
392
- const info = await fs.stat(await fs.resolve(SIDECAR_ARCHIVE, { cwd }))
393
- summary.archiveBytes = info?.size ?? 0
394
- } catch (error) { /* 归档缺失忽略 */ }
395
- } catch (error) { /* sidecar 不可读返回空摘要 */ }
378
+ const info = await fs.stat(await fs.resolve(SIDECAR_ARCHIVE, { cwd }))
379
+ summary.legacyArchiveBytes = info?.size ?? 0
380
+ } catch { /* 旧归档缺失忽略 */ }
381
+ try {
382
+ const info = await fs.stat(await fs.resolve(SIDECAR, { cwd }))
383
+ summary.legacyActiveBytes = info?.size ?? 0
384
+ } catch { /* 旧活跃文件缺失忽略 */ }
385
+ const dbPath = archiveDbFile(cwd)
386
+ for (const file of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
387
+ try { summary.archiveBytes += (await stat(file)).size } catch { /* 数据库尚未创建 */ }
388
+ }
396
389
  return summary
397
390
  }
398
391