dsh-vscode-mode 0.1.7 → 0.1.11
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/README.md +12 -0
- package/lib/client.js +725 -89
- package/lib/client.js.map +1 -1
- package/lib/index.js +1003 -94
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +44 -8
- package/src/client/index.ts +11 -2
- package/src/client/monaco/diffRender.ts +17 -2
- package/src/client/monaco/loader.ts +56 -9
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/editor.css +16 -2
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/EditorView.ts +70 -8
- package/src/client/ui/McpSettings.ts +233 -0
- package/src/index.ts +4 -2
- package/src/mcp.ts +123 -0
- package/src/mcpIsolation.ts +160 -0
- package/src/mcpProject.ts +258 -0
- package/src/model.ts +66 -34
- package/src/revert.ts +10 -3
- package/src/rpc.ts +44 -0
- package/src/shared/diff.ts +201 -0
- package/src/shared/mcp.ts +53 -0
- package/src/shared/rpc.ts +21 -0
- package/src/shared/types.ts +15 -0
- package/src/store.ts +29 -31
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
//#region src/shared/rpc.ts
|
|
5
6
|
/** webServer 精确路由。 */
|
|
6
7
|
const RPC_PATH = "/edrv/rpc";
|
|
@@ -132,6 +133,190 @@ function registerRoutes(ctx, config, handleRpc) {
|
|
|
132
133
|
}
|
|
133
134
|
}), "edrv: /edrv/vendor prefix");
|
|
134
135
|
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/shared/diff.ts
|
|
138
|
+
/** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
|
|
139
|
+
function preciseHunk(record, idx) {
|
|
140
|
+
return hunkOf(record, idx);
|
|
141
|
+
}
|
|
142
|
+
/** 轻量、确定性的字符串指纹;不引入 Node crypto。 */
|
|
143
|
+
function fingerprint(text) {
|
|
144
|
+
if (typeof text !== "string") return null;
|
|
145
|
+
let hash = 2166136261;
|
|
146
|
+
for (let i = 0; i < text.length; i++) {
|
|
147
|
+
hash ^= text.charCodeAt(i);
|
|
148
|
+
hash = Math.imul(hash, 16777619);
|
|
149
|
+
}
|
|
150
|
+
return "fnv1a:" + (hash >>> 0).toString(16) + ":" + text.length;
|
|
151
|
+
}
|
|
152
|
+
/** 取记录第 idx 个有效 hunk;只有记录缺 hunk 时才回退 callHunk。 */
|
|
153
|
+
function hunkOf(record, idx) {
|
|
154
|
+
const hunk = Array.isArray(record.hunks) ? record.hunks[idx] : void 0;
|
|
155
|
+
if (hunk && typeof hunk.newText === "string") return hunk;
|
|
156
|
+
if (idx === 0 && record.callHunk && typeof record.callHunk.newText === "string") return record.callHunk;
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/** hunk 是否表示没有实际内容变化。 */
|
|
160
|
+
function isNoopHunk(hunk) {
|
|
161
|
+
return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText;
|
|
162
|
+
}
|
|
163
|
+
/** 按文件顺序为多 hunk 写入不重复的 after 坐标。 */
|
|
164
|
+
function annotateHunks(hunks, before, after) {
|
|
165
|
+
if (typeof after !== "string") return hunks;
|
|
166
|
+
let cursor = 0;
|
|
167
|
+
return hunks.map((hunk) => {
|
|
168
|
+
if (hunk.newText.length > 0) {
|
|
169
|
+
const start = after.indexOf(hunk.newText, cursor);
|
|
170
|
+
if (start >= 0) {
|
|
171
|
+
cursor = start + hunk.newText.length;
|
|
172
|
+
return {
|
|
173
|
+
...hunk,
|
|
174
|
+
afterStart: start,
|
|
175
|
+
afterEnd: cursor
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return hunk;
|
|
179
|
+
}
|
|
180
|
+
if (hunks.length !== 1 || typeof before !== "string" || before === after || hunk.oldText === null) return hunk;
|
|
181
|
+
const prefix = commonPrefix(before, after);
|
|
182
|
+
const suffix = commonSuffix(before, after, prefix);
|
|
183
|
+
const removedStart = prefix;
|
|
184
|
+
const removedEnd = Math.max(removedStart, before.length - suffix);
|
|
185
|
+
return {
|
|
186
|
+
...hunk,
|
|
187
|
+
afterStart: removedStart,
|
|
188
|
+
afterEnd: removedStart,
|
|
189
|
+
beforeStart: removedStart,
|
|
190
|
+
beforeEnd: removedEnd
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** 计算两个文本的最长公共前缀长度。 */
|
|
195
|
+
function commonPrefix(left, right) {
|
|
196
|
+
const limit = Math.min(left.length, right.length);
|
|
197
|
+
let i = 0;
|
|
198
|
+
while (i < limit && left.charCodeAt(i) === right.charCodeAt(i)) i++;
|
|
199
|
+
return i;
|
|
200
|
+
}
|
|
201
|
+
/** 计算不与 prefix 重叠的最长公共后缀长度。 */
|
|
202
|
+
function commonSuffix(left, right, prefix) {
|
|
203
|
+
const limit = Math.min(left.length, right.length) - prefix;
|
|
204
|
+
let i = 0;
|
|
205
|
+
while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++;
|
|
206
|
+
return i;
|
|
207
|
+
}
|
|
208
|
+
/** 找出文本的全部非重叠出现位置。 */
|
|
209
|
+
function allMatches(content, needle) {
|
|
210
|
+
if (!needle.length) return [];
|
|
211
|
+
const out = [];
|
|
212
|
+
let from = 0;
|
|
213
|
+
while (from <= content.length - needle.length) {
|
|
214
|
+
const at = content.indexOf(needle, from);
|
|
215
|
+
if (at < 0) break;
|
|
216
|
+
out.push({
|
|
217
|
+
start: at,
|
|
218
|
+
end: at + needle.length
|
|
219
|
+
});
|
|
220
|
+
from = at + Math.max(1, needle.length);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
|
|
226
|
+
* 若 hunk 带 afterStart/afterEnd,则优先验证快照坐标,避免重复文本错配。
|
|
227
|
+
* @author ddj 2026年08月24号
|
|
228
|
+
* @param content 当前文件内容
|
|
229
|
+
* @param hunks 待定位 hunk
|
|
230
|
+
* @returns 每个 hunk 一个结果,matched=false 表示冲突/stale
|
|
231
|
+
*/
|
|
232
|
+
function locateHunks(content, hunks) {
|
|
233
|
+
const locations = [];
|
|
234
|
+
let cursor = 0;
|
|
235
|
+
const occupied = [];
|
|
236
|
+
for (let idx = 0; idx < hunks.length; idx++) {
|
|
237
|
+
const hunk = hunks[idx];
|
|
238
|
+
const newText = hunk.newText;
|
|
239
|
+
const snapshot = Number.isInteger(hunk.afterStart) && Number.isInteger(hunk.afterEnd) ? {
|
|
240
|
+
start: hunk.afterStart,
|
|
241
|
+
end: hunk.afterEnd
|
|
242
|
+
} : null;
|
|
243
|
+
const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText) ? snapshot : null;
|
|
244
|
+
const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText);
|
|
245
|
+
const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used))) ?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)));
|
|
246
|
+
if (candidate) {
|
|
247
|
+
locations.push({
|
|
248
|
+
idx,
|
|
249
|
+
hunk,
|
|
250
|
+
...candidate,
|
|
251
|
+
matched: true
|
|
252
|
+
});
|
|
253
|
+
occupied.push(candidate);
|
|
254
|
+
cursor = Math.max(cursor, candidate.end);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (newText.length === 0 && hunk.afterStart === void 0 && hunk.afterEnd === void 0 && content.length === 0) {
|
|
258
|
+
locations.push({
|
|
259
|
+
idx,
|
|
260
|
+
hunk,
|
|
261
|
+
start: 0,
|
|
262
|
+
end: 0,
|
|
263
|
+
matched: true
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (newText.length === 0 && hunk.afterStart !== void 0 && hunk.afterEnd !== void 0 && hunk.afterStart === hunk.afterEnd && hunk.afterStart >= 0 && hunk.afterStart <= content.length) {
|
|
268
|
+
const point = {
|
|
269
|
+
start: hunk.afterStart,
|
|
270
|
+
end: hunk.afterEnd
|
|
271
|
+
};
|
|
272
|
+
if (!occupied.some((used) => overlaps(point, used))) {
|
|
273
|
+
locations.push({
|
|
274
|
+
idx,
|
|
275
|
+
hunk,
|
|
276
|
+
...point,
|
|
277
|
+
matched: true
|
|
278
|
+
});
|
|
279
|
+
occupied.push(point);
|
|
280
|
+
cursor = Math.max(cursor, point.end);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
locations.push({
|
|
285
|
+
idx,
|
|
286
|
+
hunk,
|
|
287
|
+
start: -1,
|
|
288
|
+
end: -1,
|
|
289
|
+
matched: false
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return locations;
|
|
293
|
+
}
|
|
294
|
+
/** 判断两个半开区间是否重叠。零长度区间只与同一位置的零长度区间重叠。 */
|
|
295
|
+
function overlaps(left, right) {
|
|
296
|
+
if (left.start === left.end || right.start === right.end) return left.start === right.start && left.end === right.end;
|
|
297
|
+
return left.start < right.end && right.start < left.end;
|
|
298
|
+
}
|
|
299
|
+
/** 在可定位区间上执行一次 hunk 反向替换。 */
|
|
300
|
+
function replaceRange(content, location, reverse = false) {
|
|
301
|
+
if (!location.matched || location.start < 0) return null;
|
|
302
|
+
const replacement = reverse ? location.hunk.oldText ?? "" : location.hunk.newText;
|
|
303
|
+
return content.slice(0, location.start) + replacement + content.slice(location.end);
|
|
304
|
+
}
|
|
305
|
+
/** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
|
|
306
|
+
function applyLocations(content, locations, reverse = false) {
|
|
307
|
+
const stale = locations.filter((location) => !location.matched).map((location) => location.idx);
|
|
308
|
+
let out = content;
|
|
309
|
+
const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start);
|
|
310
|
+
for (const location of matched) {
|
|
311
|
+
const next = replaceRange(out, location, reverse);
|
|
312
|
+
if (next === null) stale.push(location.idx);
|
|
313
|
+
else out = next;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
content: out,
|
|
317
|
+
stale: stale.sort((a, b) => a - b)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
135
320
|
/**
|
|
136
321
|
* 归一化一条持久化记录(容忍旧版/损坏字段)。
|
|
137
322
|
* @author ddj 2026年08月20号
|
|
@@ -142,25 +327,48 @@ function normalizeRecord(raw) {
|
|
|
142
327
|
if (!raw || typeof raw !== "object") return null;
|
|
143
328
|
const r = raw;
|
|
144
329
|
if (typeof r.callId !== "string" || typeof r.path !== "string") return null;
|
|
145
|
-
const hunks = Array.isArray(r.hunks) ? r.hunks.filter((h) => h && typeof h.newText === "string")
|
|
330
|
+
const hunks = Array.isArray(r.hunks) ? r.hunks.filter((h) => h && typeof h.newText === "string").map((h) => {
|
|
331
|
+
const item = h;
|
|
332
|
+
return {
|
|
333
|
+
oldText: typeof item.oldText === "string" ? item.oldText : null,
|
|
334
|
+
newText: item.newText,
|
|
335
|
+
...Number.isInteger(item.afterStart) ? { afterStart: item.afterStart } : {},
|
|
336
|
+
...Number.isInteger(item.afterEnd) ? { afterEnd: item.afterEnd } : {},
|
|
337
|
+
...Number.isInteger(item.beforeStart) ? { beforeStart: item.beforeStart } : {},
|
|
338
|
+
...Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd } : {}
|
|
339
|
+
};
|
|
340
|
+
}) : [];
|
|
146
341
|
const toolName = r.toolName === "write" ? "write" : "edit";
|
|
342
|
+
const rawDecisions = r.decisions && typeof r.decisions === "object" ? r.decisions : {};
|
|
343
|
+
const call = rawDecisions.call === "accepted" || rawDecisions.call === "rejected" ? rawDecisions.call : "pending";
|
|
344
|
+
const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : [];
|
|
345
|
+
const perHunk = hunks.map((_, i) => {
|
|
346
|
+
const value = rawPerHunk[i];
|
|
347
|
+
if (value === "accepted" || value === "rejected") return value;
|
|
348
|
+
return call === "accepted" || call === "rejected" ? call : "pending";
|
|
349
|
+
});
|
|
350
|
+
const before = typeof r.before === "string" ? r.before : null;
|
|
351
|
+
const hasAfter = Object.prototype.hasOwnProperty.call(r, "after");
|
|
352
|
+
const after = typeof r.after === "string" ? r.after : null;
|
|
147
353
|
return {
|
|
148
354
|
callId: r.callId,
|
|
149
355
|
toolName,
|
|
150
356
|
path: r.path,
|
|
151
|
-
before
|
|
357
|
+
before,
|
|
358
|
+
after,
|
|
359
|
+
baseFingerprint: typeof r.baseFingerprint === "string" ? r.baseFingerprint : fingerprint(before),
|
|
360
|
+
afterFingerprint: typeof r.afterFingerprint === "string" ? r.afterFingerprint : fingerprint(after),
|
|
361
|
+
legacy: !hasAfter,
|
|
362
|
+
conflict: r.conflict === true,
|
|
152
363
|
create: r.create === true,
|
|
153
|
-
callHunk: r.callHunk && typeof r.callHunk.oldText === "string" ? {
|
|
364
|
+
callHunk: r.callHunk && typeof r.callHunk.oldText === "string" && typeof r.callHunk.newText === "string" ? {
|
|
154
365
|
oldText: r.callHunk.oldText,
|
|
155
366
|
newText: r.callHunk.newText
|
|
156
367
|
} : null,
|
|
157
|
-
hunks
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
decisions: r.decisions && typeof r.decisions === "object" ? r.decisions : {
|
|
162
|
-
call: "pending",
|
|
163
|
-
perHunk: hunks.map(() => "pending")
|
|
368
|
+
hunks,
|
|
369
|
+
decisions: {
|
|
370
|
+
call,
|
|
371
|
+
perHunk
|
|
164
372
|
},
|
|
165
373
|
note: typeof r.note === "string" ? r.note : null,
|
|
166
374
|
superseded: r.superseded === true,
|
|
@@ -193,9 +401,17 @@ function markDecision(record, scope, idx, value) {
|
|
|
193
401
|
/** 记录是否已无任何待决策差异(superseded 或全部已决策)。 */
|
|
194
402
|
function recordResolved(record) {
|
|
195
403
|
if (record.superseded === true) return true;
|
|
404
|
+
const hunks = Array.isArray(record.hunks) ? record.hunks : [];
|
|
196
405
|
const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
|
|
197
|
-
|
|
198
|
-
return record.decisions.call === "accepted" || record.decisions.call === "rejected";
|
|
406
|
+
const count = Math.max(hunks.length, perHunk.length);
|
|
407
|
+
if (!count) return record.decisions.call === "accepted" || record.decisions.call === "rejected";
|
|
408
|
+
for (let idx = 0; idx < count; idx++) {
|
|
409
|
+
const precise = preciseHunk(record, idx);
|
|
410
|
+
const value = perHunk[idx] ?? record.decisions.call;
|
|
411
|
+
if (isNoopHunk(precise) && value === "pending") return false;
|
|
412
|
+
if (value !== "accepted" && value !== "rejected") return false;
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
199
415
|
}
|
|
200
416
|
/**
|
|
201
417
|
* 记录决策摘要(采纳/拒绝/待处理计数,superseded 标记)。
|
|
@@ -204,17 +420,20 @@ function recordResolved(record) {
|
|
|
204
420
|
* @returns 摘要
|
|
205
421
|
*/
|
|
206
422
|
function recSummary(record) {
|
|
423
|
+
const hunks = Array.isArray(record.hunks) ? record.hunks : [];
|
|
207
424
|
const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
|
|
208
|
-
const n = Math.max(1,
|
|
425
|
+
const n = Math.max(1, hunks.length, perHunk.length);
|
|
209
426
|
let accepted = 0;
|
|
210
427
|
let rejected = 0;
|
|
211
428
|
let pending = 0;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
429
|
+
for (let i = 0; i < n; i++) {
|
|
430
|
+
const hunk = preciseHunk(record, i);
|
|
431
|
+
const value = perHunk[i] ?? record.decisions.call;
|
|
432
|
+
if (i < hunks.length && isNoopHunk(hunk) && value === "pending") continue;
|
|
433
|
+
if (value === "accepted") accepted++;
|
|
434
|
+
else if (value === "rejected") rejected++;
|
|
435
|
+
else pending++;
|
|
436
|
+
}
|
|
218
437
|
return {
|
|
219
438
|
accepted,
|
|
220
439
|
rejected,
|
|
@@ -232,34 +451,23 @@ function recSummary(record) {
|
|
|
232
451
|
*/
|
|
233
452
|
function reconstructOriginal(records, content) {
|
|
234
453
|
const pending = [];
|
|
235
|
-
for (const rec of records) {
|
|
236
|
-
const
|
|
237
|
-
|
|
454
|
+
for (const rec of records) for (let i = 0; i < rec.hunks.length; i++) {
|
|
455
|
+
const st = rec.decisions.perHunk[i] ?? rec.decisions.call;
|
|
456
|
+
const hunk = preciseHunk(rec, i);
|
|
457
|
+
if (st === "pending" && hunk && !isNoopHunk(hunk)) pending.push({
|
|
238
458
|
rec,
|
|
239
|
-
idx: i
|
|
459
|
+
idx: i,
|
|
460
|
+
hunk
|
|
240
461
|
});
|
|
241
462
|
}
|
|
242
463
|
pending.sort((a, b) => a.rec.at < b.rec.at ? 1 : a.rec.at > b.rec.at ? -1 : 0);
|
|
243
|
-
|
|
244
|
-
const stale = [];
|
|
245
|
-
for (const p of pending) {
|
|
246
|
-
const h = p.rec.hunks[p.idx];
|
|
247
|
-
const precise = p.rec.toolName === "edit" && p.rec.hunks.length <= 1 && p.rec.callHunk ? p.rec.callHunk : h;
|
|
248
|
-
const newText = precise.newText ?? "";
|
|
249
|
-
const at = out.indexOf(newText);
|
|
250
|
-
if (at < 0) {
|
|
251
|
-
stale.push({
|
|
252
|
-
callId: p.rec.callId,
|
|
253
|
-
idx: p.idx
|
|
254
|
-
});
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
const oldText = precise.oldText === null ? "" : precise.oldText;
|
|
258
|
-
out = out.slice(0, at) + oldText + out.slice(at + newText.length);
|
|
259
|
-
}
|
|
464
|
+
const rebuilt = applyLocations(content, locateHunks(content, pending.map((item) => item.hunk)), true);
|
|
260
465
|
return {
|
|
261
|
-
content:
|
|
262
|
-
stale
|
|
466
|
+
content: rebuilt.content,
|
|
467
|
+
stale: rebuilt.stale.map((idx) => ({
|
|
468
|
+
callId: pending[idx].rec.callId,
|
|
469
|
+
idx: pending[idx].idx
|
|
470
|
+
}))
|
|
263
471
|
};
|
|
264
472
|
}
|
|
265
473
|
/** 文件当前最大批次号(无记录为 0)。 */
|
|
@@ -309,6 +517,11 @@ function archiveEntryFor(recs, cwd, reason) {
|
|
|
309
517
|
decisions: r.decisions,
|
|
310
518
|
note: r.note ?? null,
|
|
311
519
|
before: typeof r.before === "string" ? r.before : null,
|
|
520
|
+
after: typeof r.after === "string" ? r.after : null,
|
|
521
|
+
baseFingerprint: r.baseFingerprint ?? null,
|
|
522
|
+
afterFingerprint: r.afterFingerprint ?? null,
|
|
523
|
+
conflict: r.conflict === true,
|
|
524
|
+
legacy: r.legacy === true,
|
|
312
525
|
superseded: r.superseded === true,
|
|
313
526
|
batch: Number.isInteger(r.batch) ? r.batch : null,
|
|
314
527
|
at: r.at,
|
|
@@ -410,29 +623,37 @@ async function saveBucket(ctx, cwd, recsMap, session) {
|
|
|
410
623
|
* @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
|
|
411
624
|
*/
|
|
412
625
|
async function readForCache(ctx, session, cache, path) {
|
|
413
|
-
|
|
626
|
+
const cached = cache.get(path);
|
|
627
|
+
if (cached) return cached;
|
|
414
628
|
const fs = ctx.get("fs");
|
|
415
629
|
if (!fs) {
|
|
416
|
-
|
|
417
|
-
|
|
630
|
+
const state = { kind: "unavailable" };
|
|
631
|
+
cache.set(path, state);
|
|
632
|
+
return state;
|
|
418
633
|
}
|
|
419
634
|
try {
|
|
420
635
|
const target = await resolveTarget(ctx, session, path);
|
|
421
636
|
const info = await fs.stat(target);
|
|
422
637
|
if (!info || info.type !== "file") {
|
|
423
|
-
|
|
424
|
-
|
|
638
|
+
const state = { kind: "missing" };
|
|
639
|
+
cache.set(path, state);
|
|
640
|
+
return state;
|
|
425
641
|
}
|
|
426
642
|
if ((info.size ?? 0) > 8388608) {
|
|
427
|
-
|
|
428
|
-
|
|
643
|
+
const state = { kind: "unavailable" };
|
|
644
|
+
cache.set(path, state);
|
|
645
|
+
return state;
|
|
429
646
|
}
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
647
|
+
const state = {
|
|
648
|
+
kind: "content",
|
|
649
|
+
content: await fs.readText(target)
|
|
650
|
+
};
|
|
651
|
+
cache.set(path, state);
|
|
652
|
+
return state;
|
|
433
653
|
} catch (error) {
|
|
434
|
-
|
|
435
|
-
|
|
654
|
+
const state = { kind: "unavailable" };
|
|
655
|
+
cache.set(path, state);
|
|
656
|
+
return state;
|
|
436
657
|
}
|
|
437
658
|
}
|
|
438
659
|
/**
|
|
@@ -443,30 +664,27 @@ async function readForCache(ctx, session, cache, path) {
|
|
|
443
664
|
async function recordIsStale(ctx, session, cache, rec) {
|
|
444
665
|
if (rec.superseded === true) return true;
|
|
445
666
|
if (!Array.isArray(rec.hunks) || !rec.hunks.length) return true;
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (content !== null && content.indexOf(newText) >= 0) {
|
|
465
|
-
hasLocatablePending = true;
|
|
466
|
-
break;
|
|
467
|
-
}
|
|
667
|
+
const state = await readForCache(ctx, session, cache, rec.path);
|
|
668
|
+
if (state.kind === "unavailable") return false;
|
|
669
|
+
if (state.kind === "missing") return true;
|
|
670
|
+
if (rec.create === true) return false;
|
|
671
|
+
const pending = rec.hunks.map((_, idx) => ({
|
|
672
|
+
idx,
|
|
673
|
+
hunk: preciseHunk(rec, idx),
|
|
674
|
+
status: rec.decisions.perHunk[idx] ?? rec.decisions.call
|
|
675
|
+
})).filter((item) => item.status === "pending" && item.hunk && !isNoopHunk(item.hunk));
|
|
676
|
+
if (!pending.length) return true;
|
|
677
|
+
const currentFingerprint = fingerprint(state.content);
|
|
678
|
+
if (rec.afterFingerprint && currentFingerprint === rec.afterFingerprint) {
|
|
679
|
+
rec.conflict = false;
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
if (locateHunks(state.content, pending.map((item) => item.hunk)).some((location) => location.matched)) {
|
|
683
|
+
rec.conflict = false;
|
|
684
|
+
return false;
|
|
468
685
|
}
|
|
469
|
-
|
|
686
|
+
rec.conflict = true;
|
|
687
|
+
return false;
|
|
470
688
|
}
|
|
471
689
|
/**
|
|
472
690
|
* edrv.list 全量轮询时自动清理 stale 记录:标记 superseded 并归档,
|
|
@@ -574,6 +792,42 @@ function bucketOf(registry, ctx, cwd) {
|
|
|
574
792
|
}
|
|
575
793
|
//#endregion
|
|
576
794
|
//#region src/capture.ts
|
|
795
|
+
/** 校验并提取工具 result meta 中的文件 hunk。 */
|
|
796
|
+
function validHunks(raw) {
|
|
797
|
+
if (!Array.isArray(raw)) return [];
|
|
798
|
+
const hunks = [];
|
|
799
|
+
for (const item of raw) {
|
|
800
|
+
if (!item || typeof item !== "object") continue;
|
|
801
|
+
const hunk = item;
|
|
802
|
+
if (typeof hunk.newText !== "string") continue;
|
|
803
|
+
if (hunk.oldText !== null && typeof hunk.oldText !== "string") continue;
|
|
804
|
+
hunks.push({
|
|
805
|
+
oldText: hunk.oldText,
|
|
806
|
+
newText: hunk.newText
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return hunks;
|
|
810
|
+
}
|
|
811
|
+
/** 已有文件 write 的元数据缺失时,用权威整文件快照保底。 */
|
|
812
|
+
function fallbackHunks(toolName, args, before, after, create) {
|
|
813
|
+
if (create) return [{
|
|
814
|
+
oldText: null,
|
|
815
|
+
newText: after ?? (typeof args.content === "string" ? args.content : "")
|
|
816
|
+
}];
|
|
817
|
+
if (toolName === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string") return [{
|
|
818
|
+
oldText: args.old_string,
|
|
819
|
+
newText: args.new_string
|
|
820
|
+
}];
|
|
821
|
+
if (toolName === "write" && before !== null && after !== null && before !== after) return [{
|
|
822
|
+
oldText: before,
|
|
823
|
+
newText: after
|
|
824
|
+
}];
|
|
825
|
+
if (toolName === "write" && after !== null) return [{
|
|
826
|
+
oldText: null,
|
|
827
|
+
newText: after
|
|
828
|
+
}];
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
577
831
|
/**
|
|
578
832
|
* 捕获一次工具执行结果(tools/result):edit/write 成功时构造记录、
|
|
579
833
|
* 递增文件批次、融合归档被后续修改取代的旧差异、落盘。
|
|
@@ -593,25 +847,27 @@ async function captureToolResult(ctx, registry, exec, result) {
|
|
|
593
847
|
const value = result.value;
|
|
594
848
|
const args = exec.arguments || {};
|
|
595
849
|
const path = typeof value.path === "string" ? value.path : args.file_path;
|
|
596
|
-
if (!path) return;
|
|
597
|
-
const metaDiffs = Array.isArray(result.meta?.diffs) ? result.meta.diffs : [];
|
|
850
|
+
if (typeof path !== "string" || !path) return;
|
|
598
851
|
const before = typeof value.before === "string" ? value.before : null;
|
|
852
|
+
const after = typeof value.after === "string" ? value.after : typeof args.content === "string" ? args.content : null;
|
|
599
853
|
const create = exec.name === "write" && value.before === null;
|
|
600
|
-
const callHunk = exec.name === "edit" && typeof args.old_string === "string" ? {
|
|
854
|
+
const callHunk = exec.name === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string" ? {
|
|
601
855
|
oldText: args.old_string,
|
|
602
856
|
newText: args.new_string
|
|
603
857
|
} : null;
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
} : null;
|
|
608
|
-
const hunks = metaDiffs.length ? metaDiffs : synthHunk ? [synthHunk] : callHunk ? [callHunk] : [];
|
|
609
|
-
if (!hunks.length) return;
|
|
858
|
+
const metaHunks = validHunks(result.meta?.diffs);
|
|
859
|
+
const hunks = annotateHunks(metaHunks.length ? metaHunks : fallbackHunks(exec.name, args, before, after, create), before, after);
|
|
860
|
+
if (!hunks.length || typeof exec.callId !== "string") return;
|
|
610
861
|
const record = {
|
|
611
862
|
callId: exec.callId,
|
|
612
863
|
toolName: exec.name,
|
|
613
864
|
path,
|
|
614
865
|
before,
|
|
866
|
+
after,
|
|
867
|
+
baseFingerprint: fingerprint(before),
|
|
868
|
+
afterFingerprint: fingerprint(after),
|
|
869
|
+
legacy: false,
|
|
870
|
+
conflict: false,
|
|
615
871
|
create,
|
|
616
872
|
callHunk,
|
|
617
873
|
hunks,
|
|
@@ -816,18 +1072,30 @@ async function revertHunk(ctx, session, record, idx) {
|
|
|
816
1072
|
ok: false,
|
|
817
1073
|
error: "回滚不可用:缺少 fs"
|
|
818
1074
|
};
|
|
819
|
-
const
|
|
820
|
-
const precise = record.toolName === "edit" && record.hunks.length <= 1 && record.callHunk ? record.callHunk : hunk;
|
|
1075
|
+
const precise = preciseHunk(record, idx);
|
|
821
1076
|
if (!precise) return {
|
|
822
1077
|
ok: false,
|
|
823
1078
|
error: "找不到该差异块"
|
|
824
1079
|
};
|
|
825
1080
|
try {
|
|
826
1081
|
const target = await resolveTarget(ctx, session, record.path);
|
|
827
|
-
await fs.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1082
|
+
const info = await fs.stat(target);
|
|
1083
|
+
if (!info || info.type !== "file") return {
|
|
1084
|
+
ok: false,
|
|
1085
|
+
error: "回滚失败:文件不存在"
|
|
1086
|
+
};
|
|
1087
|
+
if ((info.size ?? 0) > 8388608) return {
|
|
1088
|
+
ok: false,
|
|
1089
|
+
error: "回滚失败:文件过大,无法安全定位"
|
|
1090
|
+
};
|
|
1091
|
+
const content = await fs.readText(target);
|
|
1092
|
+
const location = locateHunks(content, [precise])[0];
|
|
1093
|
+
if (!location?.matched) return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
error: "回滚失败:该区域可能已被后续修改影响"
|
|
1096
|
+
};
|
|
1097
|
+
const result = applyLocations(content, [location], true);
|
|
1098
|
+
await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session));
|
|
831
1099
|
return { ok: true };
|
|
832
1100
|
} catch (error) {
|
|
833
1101
|
return {
|
|
@@ -859,6 +1127,383 @@ async function restoreFile(ctx, session, rec) {
|
|
|
859
1127
|
}
|
|
860
1128
|
}
|
|
861
1129
|
//#endregion
|
|
1130
|
+
//#region src/mcp.ts
|
|
1131
|
+
const MCP_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
|
1132
|
+
/** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;注意 loader id 不能用 ':' 分隔,故用 '.')。 */
|
|
1133
|
+
const PROJECT_ENTRY_PREFIX = "vsm-mcp.";
|
|
1134
|
+
const LEGACY_PROJECT_PREFIX = "vsm-mcp:";
|
|
1135
|
+
/** 判断新旧格式项目 entry id,避免旧残留被显示为全局 MCP。 */
|
|
1136
|
+
function isProjectEntryId(id) {
|
|
1137
|
+
return id.startsWith("vsm-mcp.") || id.startsWith(LEGACY_PROJECT_PREFIX);
|
|
1138
|
+
}
|
|
1139
|
+
/** 列出当前 loader 中的 MCP entry。 */
|
|
1140
|
+
function entriesOf(ctx) {
|
|
1141
|
+
const loader = ctx.get("loader");
|
|
1142
|
+
if (!loader || typeof loader.entries !== "function") return [];
|
|
1143
|
+
return [...loader.entries()].filter((entry) => entry?.options?.name === MCP_PACKAGE);
|
|
1144
|
+
}
|
|
1145
|
+
/** 脱敏配置并保留可编辑字段的存在性。 */
|
|
1146
|
+
function publicConfig(config) {
|
|
1147
|
+
const out = { ...config };
|
|
1148
|
+
if (out.env && typeof out.env === "object") out.env = Object.fromEntries(Object.keys(out.env).map((key) => [key, "••••••"]));
|
|
1149
|
+
if (out.headers && typeof out.headers === "object") out.headers = Object.fromEntries(Object.keys(out.headers).map((key) => [key, "••••••"]));
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|
|
1152
|
+
/** 从工具注册表读取服务器命名空间下的工具摘要。 */
|
|
1153
|
+
function toolsOf(ctx, serverName) {
|
|
1154
|
+
const visible = (ctx.get("tools")?.view?.(void 0))?.visible;
|
|
1155
|
+
if (!(visible instanceof Map)) return [];
|
|
1156
|
+
const prefix = `mcp__${serverName}__`;
|
|
1157
|
+
return [...visible.entries()].filter(([name]) => String(name).startsWith(prefix)).map(([name, definition]) => ({
|
|
1158
|
+
name: String(name).slice(prefix.length),
|
|
1159
|
+
description: typeof definition?.description === "string" ? definition.description : void 0
|
|
1160
|
+
}));
|
|
1161
|
+
}
|
|
1162
|
+
/** 将 loader entry 映射为设置页摘要。 */
|
|
1163
|
+
function serverOf(ctx, entry) {
|
|
1164
|
+
const config = entry.options?.config ?? {};
|
|
1165
|
+
const serverName = typeof config.serverName === "string" ? config.serverName : entry.options?.id ?? "unknown";
|
|
1166
|
+
const tools = toolsOf(ctx, serverName);
|
|
1167
|
+
const state = entry.fiber?.state;
|
|
1168
|
+
return {
|
|
1169
|
+
id: String(entry.id ?? entry.options?.id ?? serverName),
|
|
1170
|
+
serverName,
|
|
1171
|
+
enabled: entry.disabled !== true,
|
|
1172
|
+
transport: config.transport === "streamable-http" ? "streamable-http" : "stdio",
|
|
1173
|
+
config: publicConfig(config),
|
|
1174
|
+
status: state === 2 ? "connected" : state === 1 ? "connecting" : "error",
|
|
1175
|
+
toolCount: tools.length,
|
|
1176
|
+
tools,
|
|
1177
|
+
error: state === 3 ? "MCP 插件未正常运行" : void 0
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/** 校验 MCP 配置。 */
|
|
1181
|
+
function validateConfig(config) {
|
|
1182
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error("serverName 只能包含字母、数字、下划线和连字符(最多 32 位)");
|
|
1183
|
+
if (config.transport === "stdio" && !config.command?.trim()) throw new Error("stdio MCP 必须填写 command");
|
|
1184
|
+
if (config.transport === "streamable-http" && !/^https?:\/\//.test(config.url ?? "")) throw new Error("HTTP MCP 必须填写 http(s) URL");
|
|
1185
|
+
}
|
|
1186
|
+
/** 列出全局(profile)MCP 服务,过滤掉新旧格式的项目级条目。 */
|
|
1187
|
+
function listMcp(ctx) {
|
|
1188
|
+
return { servers: entriesOf(ctx).filter((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? ""))).map((entry) => serverOf(ctx, entry)) };
|
|
1189
|
+
}
|
|
1190
|
+
/** 保存 MCP 服务配置并触发 loader 热更新。 */
|
|
1191
|
+
async function saveMcp(ctx, config) {
|
|
1192
|
+
validateConfig(config);
|
|
1193
|
+
const loader = ctx.get("loader");
|
|
1194
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1195
|
+
const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? "")) && entry.options?.config?.serverName === config.serverName);
|
|
1196
|
+
if (existing) {
|
|
1197
|
+
await loader.update(existing.id, { config: { ...config } });
|
|
1198
|
+
return serverOf(ctx, loader.resolve(existing.id));
|
|
1199
|
+
}
|
|
1200
|
+
const id = await loader.create({
|
|
1201
|
+
name: MCP_PACKAGE,
|
|
1202
|
+
config: { ...config }
|
|
1203
|
+
});
|
|
1204
|
+
return serverOf(ctx, loader.resolve(id));
|
|
1205
|
+
}
|
|
1206
|
+
/** 删除 MCP 服务。 */
|
|
1207
|
+
async function removeMcp(ctx, id) {
|
|
1208
|
+
const loader = ctx.get("loader");
|
|
1209
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1210
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id);
|
|
1211
|
+
if (!entry) throw new Error("MCP 服务不存在");
|
|
1212
|
+
await loader.remove(entry.id);
|
|
1213
|
+
}
|
|
1214
|
+
/** 切换 MCP 服务启用状态。 */
|
|
1215
|
+
async function toggleMcp(ctx, id, enabled) {
|
|
1216
|
+
const loader = ctx.get("loader");
|
|
1217
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1218
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id);
|
|
1219
|
+
if (!entry) throw new Error("MCP 服务不存在");
|
|
1220
|
+
await loader.update(entry.id, { disabled: !enabled });
|
|
1221
|
+
return serverOf(ctx, loader.resolve(entry.id));
|
|
1222
|
+
}
|
|
1223
|
+
/** 重新加载 MCP 服务。 */
|
|
1224
|
+
async function refreshMcp(ctx, id) {
|
|
1225
|
+
const loader = ctx.get("loader");
|
|
1226
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1227
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id);
|
|
1228
|
+
if (!entry) throw new Error("MCP 服务不存在");
|
|
1229
|
+
await loader.update(entry.id, { config: { ...entry.options?.config ?? {} } });
|
|
1230
|
+
return serverOf(ctx, loader.resolve(entry.id));
|
|
1231
|
+
}
|
|
1232
|
+
//#endregion
|
|
1233
|
+
//#region src/mcpProject.ts
|
|
1234
|
+
/**
|
|
1235
|
+
* dsh-vscode-mode host — 项目级 MCP 管理。
|
|
1236
|
+
* 配置以项目根目录 .mcp.json(mcpServers 格式,对齐 Claude Code/Cursor)存储;
|
|
1237
|
+
* 工具经 loader 动态激活(entry id 带 PROJECT_ENTRY_PREFIX,全局生效)。
|
|
1238
|
+
* 重启后由本项目从各 .mcp.json 恢复激活;.mcp.json 为持久真相,reconcile 以文件为准。
|
|
1239
|
+
* 作者 ddj 2026年08月22号
|
|
1240
|
+
*/
|
|
1241
|
+
const FILE_NAME = ".mcp.json";
|
|
1242
|
+
/** 校验项目路径属于 DSH 已注册 workspace,避免 RPC 写入任意目录。 */
|
|
1243
|
+
function requireWorkspace(ctx, workspacePath) {
|
|
1244
|
+
const workspace = (ctx.get("workspaceRegistry")?.list?.() ?? []).find((item) => item.path === workspacePath);
|
|
1245
|
+
if (!workspace) throw new Error("项目未注册为 DSH workspace,不能管理项目 MCP");
|
|
1246
|
+
return workspace;
|
|
1247
|
+
}
|
|
1248
|
+
/** 用户显式 GUI 管理操作的写策略:danger-full-access(写入路径固定为 workspacePath/.mcp.json)。 */
|
|
1249
|
+
function fullPolicy(ctx) {
|
|
1250
|
+
const svc = ctx.get("sandboxPolicy");
|
|
1251
|
+
if (!svc || typeof svc.resolve !== "function") return void 0;
|
|
1252
|
+
return svc.resolve({ mode: "danger-full-access" });
|
|
1253
|
+
}
|
|
1254
|
+
/** workspace 路径 → 确定性短哈希(entry id 归属段,重启后稳定可解码)。 */
|
|
1255
|
+
function hashWorkspace(path) {
|
|
1256
|
+
return createHash("sha1").update(path).digest("hex").slice(0, 10);
|
|
1257
|
+
}
|
|
1258
|
+
/** 项目 MCP 的 loader entry id:vsm-mcp.<pathHash>.<serverName>(loader id 不能用冒号)。 */
|
|
1259
|
+
function projectEntryId(workspacePath, serverName) {
|
|
1260
|
+
return PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + "." + serverName;
|
|
1261
|
+
}
|
|
1262
|
+
/** 读取项目 .mcp.json 顶层对象(缺失 → 空;非法 → 抛错带文案)。 */
|
|
1263
|
+
async function readProjectJson(ctx, workspacePath) {
|
|
1264
|
+
const fs = ctx.get("fs");
|
|
1265
|
+
if (!fs) throw new Error("缺少 fs 服务");
|
|
1266
|
+
const target = await fs.resolve(FILE_NAME, { cwd: workspacePath });
|
|
1267
|
+
let text;
|
|
1268
|
+
try {
|
|
1269
|
+
text = await fs.readText(target);
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
return {};
|
|
1272
|
+
}
|
|
1273
|
+
let data;
|
|
1274
|
+
try {
|
|
1275
|
+
data = JSON.parse(text);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
throw new Error(".mcp.json 解析失败:" + String(error));
|
|
1278
|
+
}
|
|
1279
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) throw new Error(".mcp.json 顶层必须是对象");
|
|
1280
|
+
return data;
|
|
1281
|
+
}
|
|
1282
|
+
/** 读取并校验 .mcp.json 的 mcpServers 映射(纯函数,可单测)。 */
|
|
1283
|
+
function serversOf(data) {
|
|
1284
|
+
const raw = data.mcpServers;
|
|
1285
|
+
if (raw === void 0) return {};
|
|
1286
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(".mcp.json 的 mcpServers 必须是对象");
|
|
1287
|
+
const out = {};
|
|
1288
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
1289
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
|
|
1290
|
+
out[name] = value;
|
|
1291
|
+
}
|
|
1292
|
+
return out;
|
|
1293
|
+
}
|
|
1294
|
+
/** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null,纯函数可单测)。 */
|
|
1295
|
+
function configFromDef(def, serverName) {
|
|
1296
|
+
if (!def) return null;
|
|
1297
|
+
const transport = typeof def.url === "string" ? "streamable-http" : "stdio";
|
|
1298
|
+
const config = {
|
|
1299
|
+
serverName,
|
|
1300
|
+
transport
|
|
1301
|
+
};
|
|
1302
|
+
if (transport === "stdio") {
|
|
1303
|
+
config.command = typeof def.command === "string" ? def.command : "";
|
|
1304
|
+
config.args = Array.isArray(def.args) ? def.args.map(String) : [];
|
|
1305
|
+
if (typeof def.cwd === "string") config.cwd = def.cwd;
|
|
1306
|
+
if (def.env && typeof def.env === "object") config.env = Object.fromEntries(Object.entries(def.env).map(([k, v]) => [k, String(v)]));
|
|
1307
|
+
} else {
|
|
1308
|
+
config.url = def.url;
|
|
1309
|
+
if (def.headers && typeof def.headers === "object") config.headers = Object.fromEntries(Object.entries(def.headers).map(([k, v]) => [k, String(v)]));
|
|
1310
|
+
}
|
|
1311
|
+
if (typeof def.toolCallTimeoutMs === "number") config.toolCallTimeoutMs = def.toolCallTimeoutMs;
|
|
1312
|
+
return config;
|
|
1313
|
+
}
|
|
1314
|
+
/** 写回 .mcp.json(保留顶层未知字段与其余 mcpServers,仅改目标 serverName)。 */
|
|
1315
|
+
async function writeProjectJson(ctx, workspacePath, data, mcpServers) {
|
|
1316
|
+
const fs = ctx.get("fs");
|
|
1317
|
+
if (!fs) throw new Error("缺少 fs 服务");
|
|
1318
|
+
const target = await fs.resolve(FILE_NAME, { cwd: workspacePath });
|
|
1319
|
+
const next = {
|
|
1320
|
+
...data,
|
|
1321
|
+
mcpServers
|
|
1322
|
+
};
|
|
1323
|
+
await fs.writeText(target, JSON.stringify(next, null, 2) + "\n", void 0, void 0, fullPolicy(ctx));
|
|
1324
|
+
}
|
|
1325
|
+
/** 查重:目标 serverName 是否已被其他 loader entry(全局或他项目)占用。 */
|
|
1326
|
+
function findConflict(ctx, workspacePath, serverName) {
|
|
1327
|
+
const ownId = projectEntryId(workspacePath, serverName);
|
|
1328
|
+
for (const entry of entriesOf(ctx)) {
|
|
1329
|
+
if (String(entry.id ?? entry.options?.id ?? "") === ownId) continue;
|
|
1330
|
+
if (entry.options?.config?.serverName === serverName) return "serverName \"" + serverName + "\" 已被另一个 MCP 使用(全局或其他项目),请换一个名称";
|
|
1331
|
+
}
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
/** 激活一个项目 MCP:校验 + 查重 + loader.create。 */
|
|
1335
|
+
async function activateProjectMcp(ctx, workspacePath, config) {
|
|
1336
|
+
validateConfig(config);
|
|
1337
|
+
const conflict = findConflict(ctx, workspacePath, config.serverName);
|
|
1338
|
+
if (conflict) throw new Error(conflict);
|
|
1339
|
+
const loader = ctx.get("loader");
|
|
1340
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1341
|
+
const id = projectEntryId(workspacePath, config.serverName);
|
|
1342
|
+
const existing = entriesOf(ctx).find((entry) => String(entry.id ?? entry.options?.id ?? "") === id);
|
|
1343
|
+
if (existing) {
|
|
1344
|
+
await loader.update(existing.id, { config: { ...config } });
|
|
1345
|
+
return serverOf(ctx, loader.resolve(existing.id));
|
|
1346
|
+
}
|
|
1347
|
+
await loader.create({
|
|
1348
|
+
id,
|
|
1349
|
+
name: "@deepseek-ai/dsh-mcp-client",
|
|
1350
|
+
config: { ...config }
|
|
1351
|
+
});
|
|
1352
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? "") === id);
|
|
1353
|
+
if (!entry) throw new Error("MCP 已创建但未能找到 entry(id=" + id + ")");
|
|
1354
|
+
return serverOf(ctx, entry);
|
|
1355
|
+
}
|
|
1356
|
+
/** 停用一个项目 MCP(按 entry id)。 */
|
|
1357
|
+
async function deactivateMcp(ctx, entryId) {
|
|
1358
|
+
const loader = ctx.get("loader");
|
|
1359
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1360
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? "") === entryId);
|
|
1361
|
+
if (!entry) return;
|
|
1362
|
+
await loader.remove(entry.id);
|
|
1363
|
+
}
|
|
1364
|
+
/** 以 .mcp.json 为真相 reconcile 该项目的激活状态(幂等)。 */
|
|
1365
|
+
async function reconcileProject(ctx, workspacePath) {
|
|
1366
|
+
const data = await readProjectJson(ctx, workspacePath);
|
|
1367
|
+
const fileServers = new Set(Object.keys(serversOf(data)));
|
|
1368
|
+
const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + ".";
|
|
1369
|
+
for (const entry of entriesOf(ctx)) {
|
|
1370
|
+
const id = String(entry.id ?? entry.options?.id ?? "");
|
|
1371
|
+
if (!id.startsWith(prefix)) continue;
|
|
1372
|
+
const serverName = id.slice(prefix.length);
|
|
1373
|
+
if (fileServers.has(serverName)) continue;
|
|
1374
|
+
await deactivateMcp(ctx, id);
|
|
1375
|
+
}
|
|
1376
|
+
for (const serverName of fileServers) {
|
|
1377
|
+
const config = await configOfServer(ctx, workspacePath, serverName);
|
|
1378
|
+
if (config === null) continue;
|
|
1379
|
+
const id = projectEntryId(workspacePath, serverName);
|
|
1380
|
+
if (entriesOf(ctx).some((entry) => String(entry.id ?? entry.options?.id ?? "") === id)) continue;
|
|
1381
|
+
await activateProjectMcp(ctx, workspacePath, {
|
|
1382
|
+
...config,
|
|
1383
|
+
serverName
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
/** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null)。 */
|
|
1388
|
+
async function configOfServer(ctx, workspacePath, serverName) {
|
|
1389
|
+
return configFromDef(serversOf(await readProjectJson(ctx, workspacePath))[serverName], serverName);
|
|
1390
|
+
}
|
|
1391
|
+
/** 单个项目的完整视图(fileData + 激活状态合并)。 */
|
|
1392
|
+
async function projectOf(ctx, workspacePath, title) {
|
|
1393
|
+
const fs = ctx.get("fs");
|
|
1394
|
+
if (!fs) return {
|
|
1395
|
+
workspacePath,
|
|
1396
|
+
title,
|
|
1397
|
+
servers: [],
|
|
1398
|
+
source: "project"
|
|
1399
|
+
};
|
|
1400
|
+
try {
|
|
1401
|
+
const target = await fs.resolve(workspacePath);
|
|
1402
|
+
const info = await fs.stat(target);
|
|
1403
|
+
if (!info || info.type !== "directory") return {
|
|
1404
|
+
workspacePath,
|
|
1405
|
+
title,
|
|
1406
|
+
servers: [],
|
|
1407
|
+
source: "project",
|
|
1408
|
+
missingDir: true
|
|
1409
|
+
};
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
return {
|
|
1412
|
+
workspacePath,
|
|
1413
|
+
title,
|
|
1414
|
+
servers: [],
|
|
1415
|
+
source: "project",
|
|
1416
|
+
missingDir: true
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
await reconcileProject(ctx, workspacePath);
|
|
1420
|
+
let servers = [];
|
|
1421
|
+
let fileError;
|
|
1422
|
+
try {
|
|
1423
|
+
await readProjectJson(ctx, workspacePath);
|
|
1424
|
+
const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + ".";
|
|
1425
|
+
servers = entriesOf(ctx).filter((entry) => String(entry.id ?? entry.options?.id ?? "").startsWith(prefix)).map((entry) => serverOf(ctx, entry));
|
|
1426
|
+
servers.sort((a, b) => a.serverName < b.serverName ? -1 : 1);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
fileError = String(error);
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
workspacePath,
|
|
1432
|
+
title,
|
|
1433
|
+
servers,
|
|
1434
|
+
source: "project",
|
|
1435
|
+
fileError
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
/** 列出全部项目及其项目级 MCP。 */
|
|
1439
|
+
async function listProjects(ctx) {
|
|
1440
|
+
const workspaces = ctx.get("workspaceRegistry")?.list?.() ?? [];
|
|
1441
|
+
const projects = [];
|
|
1442
|
+
for (const ws of workspaces) projects.push(await projectOf(ctx, ws.path, ws.title ?? ""));
|
|
1443
|
+
return { projects };
|
|
1444
|
+
}
|
|
1445
|
+
/** 保存(新增/更新)一个项目 MCP:写 .mcp.json → 激活。 */
|
|
1446
|
+
async function projectSave(ctx, workspacePath, serverName, config) {
|
|
1447
|
+
const workspace = requireWorkspace(ctx, workspacePath);
|
|
1448
|
+
validateConfig({
|
|
1449
|
+
...config,
|
|
1450
|
+
serverName
|
|
1451
|
+
});
|
|
1452
|
+
const conflict = findConflict(ctx, workspacePath, serverName);
|
|
1453
|
+
if (conflict) throw new Error(conflict);
|
|
1454
|
+
const data = await readProjectJson(ctx, workspacePath);
|
|
1455
|
+
const servers = serversOf(data);
|
|
1456
|
+
const next = { ...config };
|
|
1457
|
+
delete next.serverName;
|
|
1458
|
+
delete next.transport;
|
|
1459
|
+
delete next.disabled;
|
|
1460
|
+
servers[serverName] = next;
|
|
1461
|
+
await writeProjectJson(ctx, workspacePath, data, servers);
|
|
1462
|
+
await activateProjectMcp(ctx, workspacePath, {
|
|
1463
|
+
...config,
|
|
1464
|
+
serverName
|
|
1465
|
+
});
|
|
1466
|
+
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
1467
|
+
}
|
|
1468
|
+
/** 删除一个项目 MCP:停用 → 从 .mcp.json 移除。 */
|
|
1469
|
+
async function projectRemove(ctx, workspacePath, serverName) {
|
|
1470
|
+
const workspace = requireWorkspace(ctx, workspacePath);
|
|
1471
|
+
await deactivateMcp(ctx, projectEntryId(workspacePath, serverName));
|
|
1472
|
+
const data = await readProjectJson(ctx, workspacePath);
|
|
1473
|
+
const servers = serversOf(data);
|
|
1474
|
+
delete servers[serverName];
|
|
1475
|
+
await writeProjectJson(ctx, workspacePath, data, servers);
|
|
1476
|
+
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
1477
|
+
}
|
|
1478
|
+
/** 切换项目 MCP 启用状态(持久化到 .mcp.json + loader.update)。 */
|
|
1479
|
+
async function projectToggle(ctx, workspacePath, serverName, enabled) {
|
|
1480
|
+
const workspace = requireWorkspace(ctx, workspacePath);
|
|
1481
|
+
const data = await readProjectJson(ctx, workspacePath);
|
|
1482
|
+
const servers = serversOf(data);
|
|
1483
|
+
const def = servers[serverName];
|
|
1484
|
+
if (!def) throw new Error("项目中不存在该 MCP");
|
|
1485
|
+
if (enabled) delete def.disabled;
|
|
1486
|
+
else def.disabled = true;
|
|
1487
|
+
await writeProjectJson(ctx, workspacePath, data, servers);
|
|
1488
|
+
const loader = ctx.get("loader");
|
|
1489
|
+
if (loader) {
|
|
1490
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? "") === projectEntryId(workspacePath, serverName));
|
|
1491
|
+
if (entry) await loader.update(entry.id, { disabled: !enabled });
|
|
1492
|
+
}
|
|
1493
|
+
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
1494
|
+
}
|
|
1495
|
+
/** 刷新(重连)项目 MCP。 */
|
|
1496
|
+
async function projectRefresh(ctx, workspacePath, serverName) {
|
|
1497
|
+
const workspace = requireWorkspace(ctx, workspacePath);
|
|
1498
|
+
const loader = ctx.get("loader");
|
|
1499
|
+
if (!loader) throw new Error("缺少 loader 服务");
|
|
1500
|
+
const id = projectEntryId(workspacePath, serverName);
|
|
1501
|
+
const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? "") === id);
|
|
1502
|
+
if (!entry) throw new Error("该项目 MCP 未激活");
|
|
1503
|
+
await loader.update(entry.id, { config: { ...entry.options?.config ?? {} } });
|
|
1504
|
+
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
1505
|
+
}
|
|
1506
|
+
//#endregion
|
|
862
1507
|
//#region src/rpc.ts
|
|
863
1508
|
/** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
|
|
864
1509
|
const debugWriteQueues = /* @__PURE__ */ new Map();
|
|
@@ -875,6 +1520,11 @@ function recView(record) {
|
|
|
875
1520
|
decisions: record.decisions,
|
|
876
1521
|
note: record.note ?? null,
|
|
877
1522
|
superseded: record.superseded === true,
|
|
1523
|
+
after: record.after ?? null,
|
|
1524
|
+
baseFingerprint: record.baseFingerprint ?? null,
|
|
1525
|
+
afterFingerprint: record.afterFingerprint ?? null,
|
|
1526
|
+
conflict: record.conflict === true,
|
|
1527
|
+
legacy: record.legacy === true,
|
|
878
1528
|
at: record.at
|
|
879
1529
|
};
|
|
880
1530
|
}
|
|
@@ -1241,6 +1891,125 @@ function buildHandlers(ctx, registry) {
|
|
|
1241
1891
|
files,
|
|
1242
1892
|
truncated: files.length >= 50
|
|
1243
1893
|
};
|
|
1894
|
+
},
|
|
1895
|
+
"mcp.list": async () => ({
|
|
1896
|
+
ok: true,
|
|
1897
|
+
...listMcp(ctx)
|
|
1898
|
+
}),
|
|
1899
|
+
"mcp.save": async (args) => {
|
|
1900
|
+
try {
|
|
1901
|
+
return {
|
|
1902
|
+
ok: true,
|
|
1903
|
+
server: await saveMcp(ctx, args.config)
|
|
1904
|
+
};
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
return {
|
|
1907
|
+
ok: false,
|
|
1908
|
+
error: String(error)
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
"mcp.remove": async (args) => {
|
|
1913
|
+
try {
|
|
1914
|
+
await removeMcp(ctx, args.id);
|
|
1915
|
+
return { ok: true };
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
return {
|
|
1918
|
+
ok: false,
|
|
1919
|
+
error: String(error)
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
},
|
|
1923
|
+
"mcp.toggle": async (args) => {
|
|
1924
|
+
try {
|
|
1925
|
+
return {
|
|
1926
|
+
ok: true,
|
|
1927
|
+
server: await toggleMcp(ctx, args.id, args.enabled)
|
|
1928
|
+
};
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
return {
|
|
1931
|
+
ok: false,
|
|
1932
|
+
error: String(error)
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
},
|
|
1936
|
+
"mcp.refresh": async (args) => {
|
|
1937
|
+
try {
|
|
1938
|
+
return {
|
|
1939
|
+
ok: true,
|
|
1940
|
+
server: await refreshMcp(ctx, args.id)
|
|
1941
|
+
};
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
return {
|
|
1944
|
+
ok: false,
|
|
1945
|
+
error: String(error)
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
},
|
|
1949
|
+
"mcp.projects": async () => {
|
|
1950
|
+
try {
|
|
1951
|
+
return {
|
|
1952
|
+
ok: true,
|
|
1953
|
+
...await listProjects(ctx)
|
|
1954
|
+
};
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
return {
|
|
1957
|
+
ok: false,
|
|
1958
|
+
error: String(error)
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
},
|
|
1962
|
+
"mcp.projectSave": async (args) => {
|
|
1963
|
+
try {
|
|
1964
|
+
return {
|
|
1965
|
+
ok: true,
|
|
1966
|
+
project: await projectSave(ctx, args.workspacePath, args.serverName, args.config)
|
|
1967
|
+
};
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
return {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
error: String(error)
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
},
|
|
1975
|
+
"mcp.projectRemove": async (args) => {
|
|
1976
|
+
try {
|
|
1977
|
+
return {
|
|
1978
|
+
ok: true,
|
|
1979
|
+
project: await projectRemove(ctx, args.workspacePath, args.serverName)
|
|
1980
|
+
};
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
return {
|
|
1983
|
+
ok: false,
|
|
1984
|
+
error: String(error)
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
"mcp.projectToggle": async (args) => {
|
|
1989
|
+
try {
|
|
1990
|
+
return {
|
|
1991
|
+
ok: true,
|
|
1992
|
+
project: await projectToggle(ctx, args.workspacePath, args.serverName, args.enabled)
|
|
1993
|
+
};
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
return {
|
|
1996
|
+
ok: false,
|
|
1997
|
+
error: String(error)
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
"mcp.projectRefresh": async (args) => {
|
|
2002
|
+
try {
|
|
2003
|
+
return {
|
|
2004
|
+
ok: true,
|
|
2005
|
+
project: await projectRefresh(ctx, args.workspacePath, args.serverName)
|
|
2006
|
+
};
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
return {
|
|
2009
|
+
ok: false,
|
|
2010
|
+
error: String(error)
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
1244
2013
|
}
|
|
1245
2014
|
};
|
|
1246
2015
|
}
|
|
@@ -1257,6 +2026,141 @@ async function handleRpc(ctx, registry, method, args) {
|
|
|
1257
2026
|
return handler(args);
|
|
1258
2027
|
}
|
|
1259
2028
|
//#endregion
|
|
2029
|
+
//#region src/mcpIsolation.ts
|
|
2030
|
+
/**
|
|
2031
|
+
* dsh-vscode-mode host — 项目 MCP 的 agent 级隔离。
|
|
2032
|
+
* 项目 MCP 连接仍由 Host 维护,但每个 agent 只继承当前 workspace 的项目工具;
|
|
2033
|
+
* tools.restrict() 负责模型可见性,tools.guard() 负责执行层兜底拒绝。
|
|
2034
|
+
* 作者 ddj 2026年08月22号
|
|
2035
|
+
*/
|
|
2036
|
+
const LEGACY_PREFIX = "vsm-mcp:";
|
|
2037
|
+
const UNKNOWN_PROJECT = "__unknown_project__";
|
|
2038
|
+
/** 统一 workspace 路径,供 cwd 父路径匹配。 */
|
|
2039
|
+
function normalizePath(path) {
|
|
2040
|
+
return String(path ?? "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "").toLowerCase();
|
|
2041
|
+
}
|
|
2042
|
+
/** 在已注册 workspace 中匹配 cwd,优先最长父路径。 */
|
|
2043
|
+
function matchWorkspace(cwd, paths) {
|
|
2044
|
+
if (!cwd) return void 0;
|
|
2045
|
+
const target = normalizePath(cwd);
|
|
2046
|
+
const matches = paths.filter((path) => {
|
|
2047
|
+
const root = normalizePath(path);
|
|
2048
|
+
return target === root || target.startsWith(root + "/");
|
|
2049
|
+
});
|
|
2050
|
+
matches.sort((a, b) => normalizePath(b).length - normalizePath(a).length);
|
|
2051
|
+
return matches[0];
|
|
2052
|
+
}
|
|
2053
|
+
/** 根据 entry id 判断是否为新旧格式项目 MCP。 */
|
|
2054
|
+
function isProjectEntry(id) {
|
|
2055
|
+
return id.startsWith("vsm-mcp.") || id.startsWith(LEGACY_PREFIX);
|
|
2056
|
+
}
|
|
2057
|
+
/** 从项目 entry id 解析 workspace hash。 */
|
|
2058
|
+
function entryHash(id) {
|
|
2059
|
+
if (id.startsWith("vsm-mcp.")) return id.slice(8).split(".")[0];
|
|
2060
|
+
if (id.startsWith(LEGACY_PREFIX)) return id.slice(8).split(":")[0];
|
|
2061
|
+
}
|
|
2062
|
+
/** 返回一个 agent 不应继承的项目工具名。 */
|
|
2063
|
+
function denyTools(toolProjects, currentPath) {
|
|
2064
|
+
return [...toolProjects.entries()].filter(([, owner]) => owner !== currentPath).map(([name]) => name).sort();
|
|
2065
|
+
}
|
|
2066
|
+
/** 从 loader entry 和工具注册表建立工具到项目路径的映射。 */
|
|
2067
|
+
function projectTools(ctx) {
|
|
2068
|
+
const result = /* @__PURE__ */ new Map();
|
|
2069
|
+
const workspaces = ctx.get("workspaceRegistry")?.list?.() ?? [];
|
|
2070
|
+
const byHash = new Map(workspaces.map((ws) => [hashWorkspace(ws.path), ws.path]));
|
|
2071
|
+
const visible = ctx.get("tools")?.view?.(void 0)?.visible;
|
|
2072
|
+
if (!(visible instanceof Map)) return result;
|
|
2073
|
+
for (const entry of entriesOf(ctx)) {
|
|
2074
|
+
const id = String(entry.id ?? entry.options?.id ?? "");
|
|
2075
|
+
if (!isProjectEntry(id)) continue;
|
|
2076
|
+
const hash = entryHash(id);
|
|
2077
|
+
const owner = hash ? byHash.get(hash) ?? UNKNOWN_PROJECT : UNKNOWN_PROJECT;
|
|
2078
|
+
const serverName = entry.options?.config?.serverName;
|
|
2079
|
+
if (typeof serverName !== "string") continue;
|
|
2080
|
+
const prefix = `mcp__${serverName}__`;
|
|
2081
|
+
for (const name of visible.keys()) if (String(name).startsWith(prefix)) result.set(String(name), owner);
|
|
2082
|
+
}
|
|
2083
|
+
return result;
|
|
2084
|
+
}
|
|
2085
|
+
/** 取得 agent 当前 cwd 所属 workspace。 */
|
|
2086
|
+
function agentWorkspace(ctx, agent) {
|
|
2087
|
+
const cwd = agent?.session?.header?.cwd;
|
|
2088
|
+
return matchWorkspace(cwd, (ctx.get("workspaceRegistry")?.list?.() ?? []).map((ws) => ws.path));
|
|
2089
|
+
}
|
|
2090
|
+
/** 同步一个 agent 的项目工具 deny 列表。 */
|
|
2091
|
+
function syncAgent(ctx, state, agent, tools) {
|
|
2092
|
+
const denied = denyTools(tools, agentWorkspace(ctx, agent));
|
|
2093
|
+
const nextKey = denied.join("\n");
|
|
2094
|
+
if (state.denyKey === nextKey) return;
|
|
2095
|
+
state.dispose?.();
|
|
2096
|
+
state.dispose = void 0;
|
|
2097
|
+
if (denied.length > 0) try {
|
|
2098
|
+
state.dispose = agent.ctx.tools.restrict({ deny: denied });
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
ctx.logger?.warn?.("[dsh-vscode-mode] MCP agent restriction failed: " + String(error));
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
state.denyKey = nextKey;
|
|
2104
|
+
}
|
|
2105
|
+
/** 为当前 Host 安装项目 MCP 的 agent restriction 与执行 guard。 */
|
|
2106
|
+
function installIsolation(ctx) {
|
|
2107
|
+
const tools = ctx.get("tools");
|
|
2108
|
+
const agents = ctx.get("agents");
|
|
2109
|
+
if (!tools || !agents) return;
|
|
2110
|
+
const states = /* @__PURE__ */ new Map();
|
|
2111
|
+
let toolProjects = projectTools(ctx);
|
|
2112
|
+
let scheduled = false;
|
|
2113
|
+
let syncing = false;
|
|
2114
|
+
const syncAll = () => {
|
|
2115
|
+
if (syncing) return;
|
|
2116
|
+
syncing = true;
|
|
2117
|
+
try {
|
|
2118
|
+
toolProjects = projectTools(ctx);
|
|
2119
|
+
for (const agent of agents.list?.() ?? []) {
|
|
2120
|
+
const state = states.get(agent) ?? { denyKey: "" };
|
|
2121
|
+
states.set(agent, state);
|
|
2122
|
+
syncAgent(ctx, state, agent, toolProjects);
|
|
2123
|
+
}
|
|
2124
|
+
} finally {
|
|
2125
|
+
syncing = false;
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
const scheduleSync = () => {
|
|
2129
|
+
if (scheduled) return;
|
|
2130
|
+
scheduled = true;
|
|
2131
|
+
queueMicrotask(() => {
|
|
2132
|
+
scheduled = false;
|
|
2133
|
+
syncAll();
|
|
2134
|
+
});
|
|
2135
|
+
};
|
|
2136
|
+
const onCreated = ctx.on("agent/created", ({ agent }) => {
|
|
2137
|
+
const state = { denyKey: "" };
|
|
2138
|
+
states.set(agent, state);
|
|
2139
|
+
syncAgent(ctx, state, agent, toolProjects);
|
|
2140
|
+
});
|
|
2141
|
+
const onDisposed = ctx.on("agent/disposed", ({ agent }) => {
|
|
2142
|
+
states.get(agent)?.dispose?.();
|
|
2143
|
+
states.delete(agent);
|
|
2144
|
+
});
|
|
2145
|
+
const onToolsChange = ctx.on("tools/change", scheduleSync);
|
|
2146
|
+
const stopGuard = tools.guard((exec) => {
|
|
2147
|
+
const owner = toolProjects.get(String(exec?.name ?? ""));
|
|
2148
|
+
if (!owner) return void 0;
|
|
2149
|
+
const current = agentWorkspace(ctx, exec.agent);
|
|
2150
|
+
if (owner === current) return void 0;
|
|
2151
|
+
return current === void 0 ? "项目 MCP 需要在已注册工作区的对话中使用" : "已拒绝:当前对话工作区不能使用其他项目的 MCP";
|
|
2152
|
+
});
|
|
2153
|
+
syncAll();
|
|
2154
|
+
ctx.effect(() => () => {
|
|
2155
|
+
onCreated?.();
|
|
2156
|
+
onDisposed?.();
|
|
2157
|
+
onToolsChange?.();
|
|
2158
|
+
stopGuard?.();
|
|
2159
|
+
for (const state of states.values()) state.dispose?.();
|
|
2160
|
+
states.clear();
|
|
2161
|
+
}, "vscode-mode:mcp-isolation");
|
|
2162
|
+
}
|
|
2163
|
+
//#endregion
|
|
1260
2164
|
//#region src/index.ts
|
|
1261
2165
|
/**
|
|
1262
2166
|
* @dsh-external 生态 → dsh-vscode-mode:DSH 上的类 VSCode 编码体验(Host 半入口)。
|
|
@@ -1269,7 +2173,11 @@ const name = "dsh-vscode-mode";
|
|
|
1269
2173
|
const inject = [
|
|
1270
2174
|
"sessions",
|
|
1271
2175
|
"fs",
|
|
1272
|
-
"webServer"
|
|
2176
|
+
"webServer",
|
|
2177
|
+
"loader",
|
|
2178
|
+
"tools",
|
|
2179
|
+
"workspaceRegistry",
|
|
2180
|
+
"agents"
|
|
1273
2181
|
];
|
|
1274
2182
|
/**
|
|
1275
2183
|
* 装配插件:挂事件监听、注册路由。
|
|
@@ -1290,7 +2198,8 @@ function apply(ctx, config) {
|
|
|
1290
2198
|
}
|
|
1291
2199
|
});
|
|
1292
2200
|
registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args));
|
|
1293
|
-
ctx
|
|
2201
|
+
installIsolation(ctx);
|
|
2202
|
+
ctx.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)");
|
|
1294
2203
|
}
|
|
1295
2204
|
//#endregion
|
|
1296
2205
|
export { apply, inject, name };
|