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/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: typeof r.before === "string" ? r.before : null,
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: hunks.map((h) => ({
158
- oldText: typeof h.oldText === "string" ? h.oldText : null,
159
- newText: h.newText
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
- if (perHunk.length) return perHunk.every((v) => v === "accepted" || v === "rejected");
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, Array.isArray(record.hunks) ? record.hunks.length : 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
- if (perHunk.length) for (const v of perHunk) if (v === "accepted") accepted++;
213
- else if (v === "rejected") rejected++;
214
- else pending++;
215
- else if (record.decisions.call === "accepted") accepted = n;
216
- else if (record.decisions.call === "rejected") rejected = n;
217
- else pending = n;
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 perHunk = Array.isArray(rec.decisions.perHunk) ? rec.decisions.perHunk : [];
237
- for (let i = 0; i < rec.hunks.length; i++) if ((perHunk.length ? perHunk[i] : rec.decisions.call) === "pending") pending.push({
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
- let out = content;
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: out,
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
- if (cache.has(path)) return cache.get(path) ?? null;
626
+ const cached = cache.get(path);
627
+ if (cached) return cached;
414
628
  const fs = ctx.get("fs");
415
629
  if (!fs) {
416
- cache.set(path, null);
417
- return null;
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
- cache.set(path, null);
424
- return null;
638
+ const state = { kind: "missing" };
639
+ cache.set(path, state);
640
+ return state;
425
641
  }
426
642
  if ((info.size ?? 0) > 8388608) {
427
- cache.set(path, "");
428
- return "";
643
+ const state = { kind: "unavailable" };
644
+ cache.set(path, state);
645
+ return state;
429
646
  }
430
- const content = await fs.readText(target);
431
- cache.set(path, content);
432
- return content;
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
- cache.set(path, null);
435
- return null;
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 perHunk = Array.isArray(rec.decisions.perHunk) ? rec.decisions.perHunk : [];
447
- let hasLocatablePending = false;
448
- for (let i = 0; i < rec.hunks.length; i++) {
449
- const st = perHunk.length ? perHunk[i] : rec.decisions.call;
450
- if (st === "accepted" || st === "rejected") continue;
451
- if (rec.create === true) {
452
- if (await readForCache(ctx, session, cache, rec.path) !== null) {
453
- hasLocatablePending = true;
454
- break;
455
- }
456
- continue;
457
- }
458
- const precise = rec.toolName === "edit" && rec.callHunk ? rec.callHunk : rec.hunks[i];
459
- const oldText = precise?.oldText ?? null;
460
- const newText = precise?.newText ?? null;
461
- if (oldText !== null && oldText === newText) continue;
462
- if (newText === null || newText === void 0) continue;
463
- const content = await readForCache(ctx, session, cache, rec.path);
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
- return !hasLocatablePending;
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 synthHunk = create ? {
605
- oldText: null,
606
- newText: typeof args.content === "string" ? args.content : typeof value.after === "string" ? value.after : ""
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 hunk = record.hunks[idx];
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.editText(target, {
828
- oldString: precise.newText,
829
- newString: precise.oldText === null ? "" : precise.oldText
830
- }, void 0, void 0, policyOf(ctx, session));
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.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪)");
2201
+ installIsolation(ctx);
2202
+ ctx.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)");
1294
2203
  }
1295
2204
  //#endregion
1296
2205
  export { apply, inject, name };