dsh-vscode-mode 0.12.0 → 0.13.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 (71) hide show
  1. package/README.md +129 -103
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +12465 -11526
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +3889 -2336
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/addToConversation.ts +4 -2
  14. package/src/client/ai/inlineProvider.ts +1 -8
  15. package/src/client/commandBridge.ts +6 -2
  16. package/src/client/externalOpen.ts +40 -19
  17. package/src/client/index.ts +10 -4
  18. package/src/client/keybindings.ts +18 -1
  19. package/src/client/monaco/loader.ts +3 -1
  20. package/src/client/openFlow.ts +36 -28
  21. package/src/client/openReceipt.ts +64 -0
  22. package/src/client/panelCommands.ts +101 -0
  23. package/src/client/pdf/pdfLoader.ts +4 -2
  24. package/src/client/receiptState.ts +120 -0
  25. package/src/client/rpc.ts +30 -4
  26. package/src/client/shortcutRecorder.ts +161 -0
  27. package/src/client/shortcutsOfficial.ts +13 -4
  28. package/src/client/sidebarBridge.ts +7 -0
  29. package/src/client/ui/DiffBadge.ts +2 -1
  30. package/src/client/ui/EditorView.ts +237 -55
  31. package/src/client/ui/McpSettings.ts +6 -3
  32. package/src/client/ui/OfficialSideTab.ts +5 -2
  33. package/src/client/ui/QuickOpen.ts +10 -9
  34. package/src/client/ui/ShortcutSettings.ts +34 -54
  35. package/src/client/ui/commandCatalog.ts +5 -4
  36. package/src/client/useOpenReceipt.ts +128 -0
  37. package/src/compat.ts +5 -3
  38. package/src/dap/manager.ts +46 -13
  39. package/src/devForm.ts +252 -92
  40. package/src/externalHandoff.ts +93 -52
  41. package/src/fileOpenSettings.ts +1 -24
  42. package/src/hostImport.ts +19 -0
  43. package/src/httpGuard.ts +101 -0
  44. package/src/index.ts +106 -97
  45. package/src/integrate.ts +56 -50
  46. package/src/lsp/transport.ts +84 -127
  47. package/src/mcp.ts +58 -9
  48. package/src/mcpIsolation.ts +211 -102
  49. package/src/mcpProject.ts +220 -133
  50. package/src/mcpRuntime.ts +343 -0
  51. package/src/openBridge.ts +69 -0
  52. package/src/openInbox.ts +207 -0
  53. package/src/paths.ts +8 -5
  54. package/src/processTree.ts +63 -0
  55. package/src/reveal.ts +5 -3
  56. package/src/routes.ts +156 -154
  57. package/src/rpc.ts +14 -13
  58. package/src/shared/appUrl.ts +19 -0
  59. package/src/shared/externalOpen.ts +71 -0
  60. package/src/shared/integration.ts +2 -0
  61. package/src/shared/keybindings.ts +8 -5
  62. package/src/shared/mcp.ts +4 -1
  63. package/src/shared/rpc.ts +7 -4
  64. package/src/shared/svn.ts +6 -8
  65. package/src/svnPlanTool.ts +51 -0
  66. package/src/unityBridge.ts +26 -3
  67. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  68. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  69. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  70. package/unity/com.dsh.editor/README.md +16 -25
  71. package/unity/com.dsh.editor/package.json +2 -2
package/src/mcpProject.ts CHANGED
@@ -1,42 +1,65 @@
1
- /**
2
- * dsh-vscode-mode host — 项目级 MCP 管理。
3
- * 配置以项目根目录 .mcp.json(mcpServers 格式,对齐 Claude Code/Cursor)存储;
4
- * 工具经 loader 动态激活(entry id 带 PROJECT_ENTRY_PREFIX,全局生效)。
5
- * 重启后由本项目从各 .mcp.json 恢复激活;.mcp.json 为持久真相,reconcile 以文件为准。
6
- * 作者 ddj 2026年08月22号
7
- */
1
+ /** Project MCP configuration; .mcp.json is the source of truth. @author ddj 2026年09月28号 */
8
2
  import { createHash } from 'node:crypto'
9
- import { PROJECT_ENTRY_PREFIX, entriesOf, serverOf, validateConfig } from './mcp.js'
3
+ import { PROJECT_ENTRY_PREFIX, entriesOf, mergeSecrets, validateConfig } from './mcp.js'
4
+ import { isProjectEntryId } from './compat.js'
5
+ import { clearLegacy, runtimeOf } from './mcpRuntime.js'
6
+ import type { ProjectDef } from './mcpRuntime.js'
10
7
  import type { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
11
8
  import type { Ctx } from './store.js'
12
9
 
13
10
  const FILE_NAME = '.mcp.json'
14
11
 
15
- /** 校验项目路径属于 DSH 已注册 workspace,避免 RPC 写入任意目录。 */
12
+ /**
13
+ * Require an exact registered workspace before any project file mutation.
14
+ * @private @author ddj 2026年09月28号
15
+ * @param ctx Host services.
16
+ * @param workspacePath Requested workspace.
17
+ * @returns Registered workspace; throws for an unmanaged path.
18
+ */
16
19
  function requireWorkspace(ctx: Ctx, workspacePath: string): { path: string; title?: string } {
17
20
  const workspace = (ctx.get('workspaceRegistry')?.list?.() ?? []).find((item: { path: string }) => item.path === workspacePath)
18
21
  if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目 MCP')
19
22
  return workspace
20
23
  }
21
24
 
22
- /** 用户显式 GUI 管理操作的写策略:danger-full-access(写入路径固定为 workspacePath/.mcp.json)。 */
25
+ /**
26
+ * Resolve the explicit settings UI write policy for a fixed project configuration path.
27
+ * @private @author ddj 2026年09月28号
28
+ * @param ctx Host services.
29
+ * @returns Resolved policy if the host supports it.
30
+ */
23
31
  function fullPolicy(ctx: Ctx): unknown {
24
- const svc = ctx.get('sandboxPolicy')
25
- if (!svc || typeof svc.resolve !== 'function') return undefined
26
- return svc.resolve({ mode: 'danger-full-access' })
32
+ return ctx.get('sandboxPolicy')?.resolve?.({ mode: 'danger-full-access' })
27
33
  }
28
34
 
29
- /** workspace 路径 → 确定性短哈希(entry id 归属段,重启后稳定可解码)。 */
35
+ /**
36
+ * Hash a workspace path for stable project server identities.
37
+ * @public @author ddj 2026年09月28号
38
+ * @param path Registered workspace path.
39
+ * @returns Ten hexadecimal characters.
40
+ */
30
41
  export function hashWorkspace(path: string): string {
31
42
  return createHash('sha1').update(path).digest('hex').slice(0, 10)
32
43
  }
33
44
 
34
- /** 项目 MCP 的 loader entry id:vsm-mcp.<pathHash>.<serverName>(loader id 不能用冒号)。 */
45
+ /**
46
+ * Preserve the historical project entry identity for RPC and UI consumers.
47
+ * @public @author ddj 2026年09月28号
48
+ * @param workspacePath Owning workspace.
49
+ * @param serverName Configured name.
50
+ * @returns Stable identity; this no longer denotes a global loader entry.
51
+ */
35
52
  export function projectEntryId(workspacePath: string, serverName: string): string {
36
53
  return PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.' + serverName
37
54
  }
38
55
 
39
- /** 读取项目 .mcp.json 顶层对象(缺失 → 空;非法 → 抛错带文案)。 */
56
+ /**
57
+ * Read the complete source document; only ENOENT denotes an absent configuration.
58
+ * @private @author ddj 2026年09月28号
59
+ * @param ctx Host filesystem.
60
+ * @param workspacePath Registered workspace path.
61
+ * @returns Parsed document; throws on unreadable or malformed input without changing runtime state.
62
+ */
40
63
  async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<string, unknown>> {
41
64
  const fs = ctx.get('fs')
42
65
  if (!fs) throw new Error('缺少 fs 服务')
@@ -45,7 +68,8 @@ async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<
45
68
  try {
46
69
  text = await fs.readText(target)
47
70
  } catch (error) {
48
- return {}
71
+ if ((error as { code?: string })?.code === 'ENOENT') return {}
72
+ throw new Error('.mcp.json 读取失败:' + String(error))
49
73
  }
50
74
  let data: unknown
51
75
  try {
@@ -57,20 +81,31 @@ async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<
57
81
  return data as Record<string, unknown>
58
82
  }
59
83
 
60
- /** 读取并校验 .mcp.json 的 mcpServers 映射(纯函数,可单测)。 */
84
+ /**
85
+ * Validate the server dictionary while retaining all definition fields.
86
+ * @public @author ddj 2026年09月28号
87
+ * @param data Source document.
88
+ * @returns Server definitions; malformed definitions reject the entire snapshot.
89
+ */
61
90
  export function serversOf(data: Record<string, unknown>): Record<string, Record<string, unknown>> {
62
91
  const raw = data.mcpServers
63
- if (raw === undefined) return {}
92
+ if (raw === undefined) return Object.create(null)
64
93
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('.mcp.json 的 mcpServers 必须是对象')
65
- const out: Record<string, Record<string, unknown>> = {}
94
+ const servers: Record<string, Record<string, unknown>> = Object.create(null)
66
95
  for (const [name, value] of Object.entries(raw)) {
67
- if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
68
- out[name] = value as Record<string, unknown>
96
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('.mcp.json 的 MCP 定义必须是对象:' + name)
97
+ servers[name] = value as Record<string, unknown>
69
98
  }
70
- return out
99
+ return servers
71
100
  }
72
101
 
73
- /** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null,纯函数可单测)。 */
102
+ /**
103
+ * Normalize a definition for the official plugin, retaining supported advanced options.
104
+ * @public @author ddj 2026年09月28号
105
+ * @param def Persisted server definition.
106
+ * @param serverName Dictionary key.
107
+ * @returns Plugin config, or null for a missing definition.
108
+ */
74
109
  export function configFromDef(def: Record<string, unknown> | undefined, serverName: string): MpcConfig | null {
75
110
  if (!def) return null
76
111
  const transport = typeof def.url === 'string' ? 'streamable-http' : 'stdio'
@@ -79,155 +114,207 @@ export function configFromDef(def: Record<string, unknown> | undefined, serverNa
79
114
  config.command = typeof def.command === 'string' ? def.command : ''
80
115
  config.args = Array.isArray(def.args) ? def.args.map(String) : []
81
116
  if (typeof def.cwd === 'string') config.cwd = def.cwd
82
- if (def.env && typeof def.env === 'object') config.env = Object.fromEntries(Object.entries(def.env as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
117
+ if (def.env && typeof def.env === 'object') config.env = Object.fromEntries(Object.entries(def.env).map(([k, v]) => [k, String(v)]))
83
118
  } else {
84
119
  config.url = def.url as string
85
- if (def.headers && typeof def.headers === 'object') config.headers = Object.fromEntries(Object.entries(def.headers as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
120
+ if (def.headers && typeof def.headers === 'object') config.headers = Object.fromEntries(Object.entries(def.headers).map(([k, v]) => [k, String(v)]))
86
121
  }
87
122
  if (typeof def.toolCallTimeoutMs === 'number') config.toolCallTimeoutMs = def.toolCallTimeoutMs
123
+ if (typeof def.maxInstructionBytes === 'number') config.maxInstructionBytes = def.maxInstructionBytes
124
+ if (typeof def.failOnStartupError === 'boolean') config.failOnStartupError = def.failOnStartupError
125
+ if (def.reconnect && typeof def.reconnect === 'object' && !Array.isArray(def.reconnect)) config.reconnect = { ...def.reconnect }
88
126
  return config
89
127
  }
90
128
 
91
- /** 写回 .mcp.json(保留顶层未知字段与其余 mcpServers,仅改目标 serverName)。 */
92
- async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<string, unknown>, mcpServers: Record<string, Record<string, unknown>>): Promise<void> {
129
+ /**
130
+ * Write a validated source document without dropping unrelated keys.
131
+ * @private @author ddj 2026年09月28号
132
+ * @param ctx Host filesystem.
133
+ * @param workspacePath Registered workspace path.
134
+ * @param data Complete next document.
135
+ * @returns Completion of the authorized settings write.
136
+ */
137
+ async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<string, unknown>): Promise<void> {
93
138
  const fs = ctx.get('fs')
94
139
  if (!fs) throw new Error('缺少 fs 服务')
95
140
  const target = await fs.resolve(FILE_NAME, { cwd: workspacePath })
96
- const next = { ...data, mcpServers }
97
- await fs.writeText(target, JSON.stringify(next, null, 2) + '\n', void 0, void 0, fullPolicy(ctx))
141
+ await fs.writeText(target, JSON.stringify(data, null, 2) + '\n', void 0, void 0, fullPolicy(ctx))
98
142
  }
99
143
 
100
- /** 查重:目标 serverName 是否已被其他 loader entry(全局或他项目)占用。 */
101
- function findConflict(ctx: Ctx, workspacePath: string, serverName: string): string | null {
102
- const ownId = projectEntryId(workspacePath, serverName)
144
+ /**
145
+ * Reserve names across global entries and configured project files, including idle projects.
146
+ * @public @author ddj 2026年09月28号
147
+ * @param ctx Host services.
148
+ * @param serverName Proposed name.
149
+ * @param workspacePath Owning project, omitted for a global save.
150
+ * @returns Completion; throws on collision or an unreadable competing source document.
151
+ */
152
+ export async function checkMcpName(ctx: Ctx, serverName: string, workspacePath?: string): Promise<void> {
153
+ const conflict = 'serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称'
103
154
  for (const entry of entriesOf(ctx)) {
104
- if (String(entry.id ?? entry.options?.id ?? '') === ownId) continue
105
- if (entry.options?.config?.serverName === serverName) {
106
- return 'serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称'
107
- }
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)
158
+ }
159
+ for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) {
160
+ if (ws.path === workspacePath) continue
161
+ const servers = serversOf(await readProjectJson(ctx, ws.path))
162
+ if (Object.hasOwn(servers, serverName)) throw new Error(conflict)
108
163
  }
109
- return null
110
164
  }
111
165
 
112
- /** 激活一个项目 MCP:校验 + 查重 + loader.create。 */
113
- export async function activateProjectMcp(ctx: Ctx, workspacePath: string, config: MpcConfig): Promise<MpcServer> {
114
- validateConfig(config)
115
- const conflict = findConflict(ctx, workspacePath, config.serverName)
116
- if (conflict) throw new Error(conflict)
117
- const loader = ctx.get('loader')
118
- if (!loader) throw new Error('缺少 loader 服务')
119
- const id = projectEntryId(workspacePath, config.serverName)
120
- const existing = entriesOf(ctx).find((entry) => String(entry.id ?? entry.options?.id ?? '') === id)
121
- if (existing) {
122
- await loader.update(existing.id, { config: { ...config } })
123
- return serverOf(ctx, loader.resolve(existing.id))
166
+ /**
167
+ * Reconcile one complete, validated snapshot; invalid reads preserve all active connections.
168
+ * @public @author ddj 2026年09月28号
169
+ * @param ctx Host services.
170
+ * @param workspacePath Registered workspace.
171
+ * @param refresh Optional unchanged server to reconnect.
172
+ * @returns Completion after scoped fibers match the file, including disabled flags.
173
+ */
174
+ export async function reconcileProject(ctx: Ctx, workspacePath: string, refresh?: string): Promise<void> {
175
+ const data = await readProjectJson(ctx, workspacePath)
176
+ const defs = new Map<string, ProjectDef>()
177
+ for (const [name, def] of Object.entries(serversOf(data))) {
178
+ const config = configFromDef(def, name)!
179
+ validateConfig(config)
180
+ await checkMcpName(ctx, name, workspacePath)
181
+ defs.set(name, { config, enabled: def.disabled !== true })
124
182
  }
125
- const created = await loader.create({ id, name: '@deepseek-ai/dsh-mcp-client', config: { ...config } })
126
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
127
- if (!entry) throw new Error('MCP 已创建但未能找到 entry(id=' + id + ')')
128
- return serverOf(ctx, entry)
183
+ await clearLegacy(ctx)
184
+ await runtimeOf(ctx).replace(workspacePath, defs, refresh)
129
185
  }
130
186
 
131
- /** 停用一个项目 MCP(按 entry id)。 */
132
- export async function deactivateMcp(ctx: Ctx, entryId: string): Promise<void> {
133
- const loader = ctx.get('loader')
134
- if (!loader) throw new Error('缺少 loader 服务')
135
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === entryId)
136
- if (!entry) return
137
- await loader.remove(entry.id)
187
+ /**
188
+ * Preserve the historical activation entrypoint while using the source file and agent scopes.
189
+ * @public @author ddj 2026年09月28号
190
+ * @param ctx Host services.
191
+ * @param workspacePath Registered workspace.
192
+ * @param config Requested configuration to persist.
193
+ * @returns Masked runtime summary after saving and attaching matching agents.
194
+ */
195
+ export async function activateProjectMcp(ctx: Ctx, workspacePath: string, config: MpcConfig): Promise<MpcServer> {
196
+ const project = await projectSave(ctx, workspacePath, config.serverName, config)
197
+ return project.servers.find((server) => server.serverName === config.serverName)!
138
198
  }
139
199
 
140
- /** 以 .mcp.json 为真相 reconcile 该项目的激活状态(幂等)。 */
141
- export async function reconcileProject(ctx: Ctx, workspacePath: string): Promise<void> {
142
- const data = await readProjectJson(ctx, workspacePath)
143
- const fileServers = new Set(Object.keys(serversOf(data)))
144
- const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
145
- for (const entry of entriesOf(ctx)) {
146
- const id = String(entry.id ?? entry.options?.id ?? '')
147
- if (!id.startsWith(prefix)) continue
148
- const serverName = id.slice(prefix.length)
149
- if (fileServers.has(serverName)) continue
150
- await deactivateMcp(ctx, id)
151
- }
152
- for (const serverName of fileServers) {
153
- const config = await configOfServer(ctx, workspacePath, serverName)
154
- if (config === null) continue
155
- const id = projectEntryId(workspacePath, serverName)
156
- if (entriesOf(ctx).some((entry) => String(entry.id ?? entry.options?.id ?? '') === id)) continue
157
- await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
200
+ /**
201
+ * Disable a project definition by its stable id, retaining its persisted configuration.
202
+ * @public @author ddj 2026年09月28号
203
+ * @param ctx Host services.
204
+ * @param entryId Stable project identity.
205
+ * @returns Completion of disabled persistence and connection teardown.
206
+ */
207
+ export async function deactivateMcp(ctx: Ctx, entryId: string): Promise<void> {
208
+ for (const [path, defs] of runtimeOf(ctx).projects) {
209
+ for (const name of defs.keys()) {
210
+ if (projectEntryId(path, name) !== entryId) continue
211
+ await projectToggle(ctx, path, name, false)
212
+ return
213
+ }
158
214
  }
159
215
  }
160
216
 
161
- /** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null)。 */
162
- async function configOfServer(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcConfig | null> {
163
- const data = await readProjectJson(ctx, workspacePath)
164
- return configFromDef(serversOf(data)[serverName], serverName)
165
- }
166
-
167
- /** 单个项目的完整视图(fileData + 激活状态合并)。 */
217
+ /**
218
+ * List one project, retaining the last good runtime view on file errors.
219
+ * @private @author ddj 2026年09月28号
220
+ * @param ctx Host services.
221
+ * @param workspacePath Registered path.
222
+ * @param title Workspace title.
223
+ * @returns Configuration and honest runtime status with any read error attached.
224
+ */
168
225
  async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promise<MpcProject> {
169
- const fs = ctx.get('fs')
170
- if (!fs) return { workspacePath, title, servers: [], source: 'project' }
171
- try {
172
- const target = await fs.resolve(workspacePath)
173
- const info = await fs.stat(target)
174
- if (!info || info.type !== 'directory') return { workspacePath, title, servers: [], source: 'project', missingDir: true }
175
- } catch (error) {
176
- return { workspacePath, title, servers: [], source: 'project', missingDir: true }
177
- }
178
- await reconcileProject(ctx, workspacePath)
179
- let servers: MpcServer[] = []
180
226
  let fileError: string | undefined
227
+ let missingDir: boolean | undefined
181
228
  try {
182
- const data = await readProjectJson(ctx, workspacePath)
183
- const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
184
- servers = entriesOf(ctx).filter((entry) => String(entry.id ?? entry.options?.id ?? '').startsWith(prefix)).map((entry) => serverOf(ctx, entry))
185
- servers.sort((a, b) => (a.serverName < b.serverName ? -1 : 1))
229
+ const fs = ctx.get('fs')
230
+ if (!fs) throw new Error('缺少 fs 服务')
231
+ const info = await fs.stat(await fs.resolve(workspacePath))
232
+ missingDir = !info || info.type !== 'directory'
233
+ if (!missingDir) await reconcileProject(ctx, workspacePath)
186
234
  } catch (error) {
235
+ missingDir = (error as { code?: string })?.code === 'ENOENT' || undefined
187
236
  fileError = String(error)
188
237
  }
189
- return { workspacePath, title, servers, source: 'project', fileError }
238
+ return { workspacePath, title, servers: runtimeOf(ctx).views(workspacePath), source: 'project', missingDir, fileError }
190
239
  }
191
240
 
192
- /** 列出全部项目及其项目级 MCP。 */
241
+ /**
242
+ * List registered projects and reconcile readable source documents.
243
+ * @public @author ddj 2026年09月28号
244
+ * @param ctx Host services.
245
+ * @returns Project list; individual malformed files are reported on their project.
246
+ */
193
247
  export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }> {
194
- const registry = ctx.get('workspaceRegistry')
195
- const workspaces = registry?.list?.() ?? []
196
248
  const projects: MpcProject[] = []
197
- for (const ws of workspaces) projects.push(await projectOf(ctx, ws.path, ws.title ?? ''))
249
+ for (const ws of ctx.get('workspaceRegistry')?.list?.() ?? []) projects.push(await projectOf(ctx, ws.path, ws.title ?? ''))
198
250
  return { projects }
199
251
  }
200
252
 
201
- /** 保存(新增/更新)一个项目 MCP:写 .mcp.json → 激活。 */
253
+ /**
254
+ * Merge editable fields while retaining advanced fields and unchanged masked credentials.
255
+ * @private @author ddj 2026年09月28号
256
+ * @param previous Persisted server definition.
257
+ * @param config Submitted form config.
258
+ * @returns Enabled source definition without runtime-only keys or stale transport fields.
259
+ */
260
+ function mergeDefinition(previous: Record<string, unknown>, config: MpcConfig): Record<string, unknown> {
261
+ const next = mergeSecrets({ ...previous, ...config }, previous)
262
+ delete next.serverName
263
+ delete next.transport
264
+ 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
+ return next
268
+ }
269
+
270
+ /**
271
+ * Save one definition, preserving unrelated fields and masked secrets, then reconcile.
272
+ * @public @author ddj 2026年09月28号
273
+ * @param ctx Host services.
274
+ * @param workspacePath Registered workspace.
275
+ * @param serverName Dictionary key.
276
+ * @param config Submitted configuration.
277
+ * @returns Updated project view; invalid sources or conflicts reject before writing.
278
+ */
202
279
  export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
203
280
  const workspace = requireWorkspace(ctx, workspacePath)
204
281
  validateConfig({ ...config, serverName })
205
- const conflict = findConflict(ctx, workspacePath, serverName)
206
- if (conflict) throw new Error(conflict)
282
+ await checkMcpName(ctx, serverName, workspacePath)
207
283
  const data = await readProjectJson(ctx, workspacePath)
208
284
  const servers = serversOf(data)
209
- const next = { ...config } as unknown as Record<string, unknown>
210
- delete next.serverName
211
- delete next.transport
212
- delete next.disabled // disabled 由 projectToggle 管理,保存恒为启用
213
- servers[serverName] = next
214
- await writeProjectJson(ctx, workspacePath, data, servers)
215
- await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
285
+ servers[serverName] = mergeDefinition(servers[serverName] ?? {}, { ...config, serverName })
286
+ await writeProjectJson(ctx, workspacePath, { ...data, mcpServers: servers })
287
+ await reconcileProject(ctx, workspacePath)
216
288
  return projectOf(ctx, workspacePath, workspace.title ?? '')
217
289
  }
218
290
 
219
- /** 删除一个项目 MCP:停用 → 从 .mcp.json 移除。 */
291
+ /**
292
+ * Persist removal before shutting down its scoped fibers.
293
+ * @public @author ddj 2026年09月28号
294
+ * @param ctx Host services.
295
+ * @param workspacePath Registered workspace.
296
+ * @param serverName Target definition.
297
+ * @returns Updated project view; write failures leave active configuration intact.
298
+ */
220
299
  export async function projectRemove(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
221
300
  const workspace = requireWorkspace(ctx, workspacePath)
222
- await deactivateMcp(ctx, projectEntryId(workspacePath, serverName))
223
301
  const data = await readProjectJson(ctx, workspacePath)
224
302
  const servers = serversOf(data)
225
303
  delete servers[serverName]
226
- await writeProjectJson(ctx, workspacePath, data, servers)
304
+ await writeProjectJson(ctx, workspacePath, { ...data, mcpServers: servers })
305
+ await reconcileProject(ctx, workspacePath)
227
306
  return projectOf(ctx, workspacePath, workspace.title ?? '')
228
307
  }
229
308
 
230
- /** 切换项目 MCP 启用状态(持久化到 .mcp.json + loader.update)。 */
309
+ /**
310
+ * Persist enabled state and reconcile all matching agents immediately.
311
+ * @public @author ddj 2026年09月28号
312
+ * @param ctx Host services.
313
+ * @param workspacePath Registered workspace.
314
+ * @param serverName Target definition.
315
+ * @param enabled Desired enabled state.
316
+ * @returns Updated project view, including retained disabled definitions.
317
+ */
231
318
  export async function projectToggle(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
232
319
  const workspace = requireWorkspace(ctx, workspacePath)
233
320
  const data = await readProjectJson(ctx, workspacePath)
@@ -236,23 +323,23 @@ export async function projectToggle(ctx: Ctx, workspacePath: string, serverName:
236
323
  if (!def) throw new Error('项目中不存在该 MCP')
237
324
  if (enabled) delete def.disabled
238
325
  else def.disabled = true
239
- await writeProjectJson(ctx, workspacePath, data, servers)
240
- const loader = ctx.get('loader')
241
- if (loader) {
242
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === projectEntryId(workspacePath, serverName))
243
- if (entry) await loader.update(entry.id, { disabled: !enabled })
244
- }
326
+ await writeProjectJson(ctx, workspacePath, { ...data, mcpServers: servers })
327
+ await reconcileProject(ctx, workspacePath)
245
328
  return projectOf(ctx, workspacePath, workspace.title ?? '')
246
329
  }
247
330
 
248
- /** 刷新(重连)项目 MCP。 */
331
+ /**
332
+ * Reconnect one enabled source definition across its active agents.
333
+ * @public @author ddj 2026年09月28号
334
+ * @param ctx Host services.
335
+ * @param workspacePath Registered workspace.
336
+ * @param serverName Target definition.
337
+ * @returns Updated project view; disabled or idle definitions stay connection-free.
338
+ */
249
339
  export async function projectRefresh(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
250
340
  const workspace = requireWorkspace(ctx, workspacePath)
251
- const loader = ctx.get('loader')
252
- if (!loader) throw new Error('缺少 loader 服务')
253
- const id = projectEntryId(workspacePath, serverName)
254
- const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
255
- if (!entry) throw new Error('该项目 MCP 未激活')
256
- await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
341
+ const data = await readProjectJson(ctx, workspacePath)
342
+ if (!Object.hasOwn(serversOf(data), serverName)) throw new Error('项目中不存在该 MCP')
343
+ await reconcileProject(ctx, workspacePath, serverName)
257
344
  return projectOf(ctx, workspacePath, workspace.title ?? '')
258
345
  }