dsh-vscode-mode 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +37 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +16 -0
  4. package/lib/client.js +2804 -300
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1975 -191
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +4 -2
  11. package/src/ai/inline.ts +19 -2
  12. package/src/ai/svnTriage.ts +150 -0
  13. package/src/capture.ts +225 -51
  14. package/src/client/addToConversation.ts +203 -1
  15. package/src/client/compat.ts +134 -4
  16. package/src/client/imagePreview.ts +76 -2
  17. package/src/client/index.ts +65 -36
  18. package/src/client/monaco/lsp/lspClient.ts +46 -0
  19. package/src/client/monaco/lsp/providers.ts +245 -1
  20. package/src/client/nativeOpenStore.ts +91 -0
  21. package/src/client/officialSidebar.ts +11 -45
  22. package/src/client/outline/index.ts +1 -1
  23. package/src/client/pdf/pdfPanel.ts +18 -0
  24. package/src/client/pendingNav.ts +141 -0
  25. package/src/client/settingsContext.ts +6 -4
  26. package/src/client/sidebar/panels/FileExplorer.ts +2 -15
  27. package/src/client/sidebar/panels/SvnPanel.ts +682 -72
  28. package/src/client/sidebar/panels/index.ts +1 -1
  29. package/src/client/sidebar/panels/svnListBudget.ts +58 -0
  30. package/src/client/state/records.ts +7 -1
  31. package/src/client/styles/editor.css +47 -5
  32. package/src/client/svnStatus.ts +206 -1
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/ui/AiSettings.ts +47 -14
  35. package/src/client/ui/DiffBox.ts +1 -1
  36. package/src/client/ui/DiffLauncher.ts +6 -4
  37. package/src/client/ui/EditorView.ts +149 -22
  38. package/src/client/ui/McpSettings.ts +30 -0
  39. package/src/client/ui/PerfSettings.ts +4 -1
  40. package/src/client/ui/SvnAiPlanDialog.ts +293 -0
  41. package/src/client/ui/icons.ts +91 -0
  42. package/src/compat.ts +26 -5
  43. package/src/dshVersion.ts +2 -1
  44. package/src/fileOpenSettings.ts +274 -49
  45. package/src/index.ts +18 -1
  46. package/src/lsp/client.ts +24 -0
  47. package/src/lsp/rpc.ts +57 -0
  48. package/src/lsp/server.ts +272 -2
  49. package/src/model.ts +2 -1
  50. package/src/perf.ts +90 -4
  51. package/src/remoteWorkspace.ts +177 -0
  52. package/src/routes.ts +13 -1
  53. package/src/rpc.ts +115 -39
  54. package/src/shared/ai.ts +12 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. package/src/shared/rpc.ts +162 -2
  58. package/src/shared/svn.ts +296 -10
  59. package/src/shared/types.ts +5 -1
  60. package/src/svn.ts +235 -13
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { homedir, tmpdir } from "node:os";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { Buffer as Buffer$1 } from "node:buffer";
9
9
  import { execFile, spawn } from "node:child_process";
10
+ import z from "@deepseek-ai/schemastery";
10
11
  import { inflateRawSync } from "node:zlib";
11
12
  //#region src/mcp.ts
12
13
  /**
@@ -190,6 +191,17 @@ function tortoiseLaunchArgv(exe, action, absPath) {
190
191
  "/path:" + absPath
191
192
  ];
192
193
  }
194
+ /**
195
+ * 是否属于「工作副本变更」(`normal`/`external` 不算;`ignored` 由开关决定是否收录)。
196
+ * @author ddj 2026年09月16号
197
+ * @param entry 变更条目
198
+ * @returns 是否计入变更清单
199
+ */
200
+ function isSvnChange(entry) {
201
+ if (entry.status === "normal" || entry.status === "external") return false;
202
+ if (entry.status === "ignored") return false;
203
+ return true;
204
+ }
193
205
  /** W2-6 内容护栏嗅探窗(字符数;NUL/U+FFFD 只看前 8K,全量扫描大文本得不偿失)。 */
194
206
  const GUARD_SNIFF_CHARS = 8192;
195
207
  /**
@@ -207,6 +219,245 @@ function contentGuardOf(text) {
207
219
  if (window.includes("�")) return { encodingHint: true };
208
220
  return {};
209
221
  }
222
+ /**
223
+ * 分区名(changelist 名)合法性校验:返回错误文案,合法返回 null。
224
+ * 规则:去首尾空白后非空、不以 `-` 开头(防被 svn 当成选项解析)、长度 ≤ 上限。
225
+ * client 预检 + host 权威校验共用,避免两处漂移。
226
+ * @author ddj 2026年09月23号
227
+ * @param name 用户输入的分区名
228
+ * @returns 错误文案;合法为 null
229
+ */
230
+ function svnChangelistNameErrorOf(name) {
231
+ const trimmed = String(name ?? "").trim();
232
+ if (!trimmed) return "分区名不能为空";
233
+ if (trimmed.startsWith("-")) return "分区名不能以 - 开头";
234
+ if (trimmed.length > 64) return "分区名最长 64 字符";
235
+ return null;
236
+ }
237
+ /** AI 方案分析条目数上限(超出仅按路径分析并标记截断)。 */
238
+ const AI_PLAN_PATHS_CAP = 1500;
239
+ /** AI 方案 diff 上下文字节上限(超出降级 paths-only)。 */
240
+ const AI_PLAN_DIFF_CAP = 2097152;
241
+ /** AI 方案 LLM 调用超时(毫秒;推理模型 + 高思考档位 + diff 上下文实测 120s 不够(huoshan/glm-5.3-flash),放宽到 5 分钟)。 */
242
+ const AI_PLAN_TIMEOUT_MS = 3e5;
243
+ /** 松散数组取值(非数组按空处理;AI 输出形状容错)。 */
244
+ function listOf(raw) {
245
+ return Array.isArray(raw) ? raw : [];
246
+ }
247
+ /**
248
+ * AI 方案条目解析(宽松:接受 `{path, reason}` 或裸路径字符串;反斜杠归一为 `/`)。
249
+ * @author ddj 2026年09月23号
250
+ * @param raw 原始元素
251
+ * @returns 解析结果(不可解析返回 null——非路径垃圾不作数)
252
+ */
253
+ function planItemOf(raw) {
254
+ const obj = typeof raw === "string" ? { path: raw } : raw && typeof raw === "object" ? raw : null;
255
+ if (!obj) return null;
256
+ const path = typeof obj.path === "string" ? obj.path.trim().replace(/\\/g, "/") : "";
257
+ if (!path) return null;
258
+ const reason = typeof obj.reason === "string" ? obj.reason.trim().slice(0, 120) : "";
259
+ return reason ? {
260
+ path,
261
+ reason
262
+ } : { path };
263
+ }
264
+ /**
265
+ * AI 方案分组解析(宽松:name + paths 数组;路径逐个规范化)。
266
+ * @author ddj 2026年09月23号
267
+ * @param raw 原始元素
268
+ * @returns 解析结果(无合法名返回 null)
269
+ */
270
+ function planGroupOf(raw) {
271
+ if (!raw || typeof raw !== "object") return null;
272
+ const obj = raw;
273
+ const name = typeof obj.name === "string" ? obj.name.trim() : "";
274
+ if (!name) return null;
275
+ const paths = [];
276
+ for (const p of listOf(obj.paths)) {
277
+ const path = typeof p === "string" ? p.trim().replace(/\\/g, "/") : "";
278
+ if (path) paths.push(path);
279
+ }
280
+ const reason = typeof obj.reason === "string" ? obj.reason.trim().slice(0, 120) : "";
281
+ return reason ? {
282
+ name,
283
+ paths,
284
+ reason
285
+ } : {
286
+ name,
287
+ paths
288
+ };
289
+ }
290
+ /**
291
+ * AI 方案归一化(纯函数):以真实变更清单为白名单裁决三段建议。
292
+ * - 幻觉路径(不在清单中)/状态不符(revert 须 versioned、ignore 须 unversioned)→ 丢弃并计数;
293
+ * - 互斥优先级 revert > ignore > group(同一路径至多归一段,还原后条目消失故优先);
294
+ * - 组名过 svnChangelistNameErrorOf、组数上限 AI_PLAN_GROUPS_CAP,空组剔除。
295
+ * @author ddj 2026年09月23号
296
+ * @param raw AI 输出解析结果(形状不定,内部容错)
297
+ * @param entries 真实变更清单(白名单来源)
298
+ * @returns 归一化方案与被丢弃的候选路径数
299
+ */
300
+ function normalizeAiPlan(raw, entries) {
301
+ const plan = {
302
+ groups: [],
303
+ reverts: [],
304
+ ignores: []
305
+ };
306
+ if (!raw || typeof raw !== "object") return {
307
+ plan,
308
+ dropped: 0
309
+ };
310
+ const byPath = /* @__PURE__ */ new Map();
311
+ for (const entry of entries) if (entry?.path) byPath.set(entry.path, entry);
312
+ const claimed = /* @__PURE__ */ new Set();
313
+ let dropped = 0;
314
+ /** 单条裁决:真实存在 + 状态符合 + 未被更高优先级段认领。 */
315
+ const accept = (path, wantVersioned) => {
316
+ const entry = byPath.get(path);
317
+ if (!entry || !isSvnChange(entry) || entry.versioned !== wantVersioned || claimed.has(path)) {
318
+ dropped++;
319
+ return false;
320
+ }
321
+ claimed.add(path);
322
+ return true;
323
+ };
324
+ const src = raw;
325
+ for (const el of listOf(src.reverts)) {
326
+ const item = planItemOf(el);
327
+ if (item && accept(item.path, true)) plan.reverts.push(item);
328
+ }
329
+ for (const el of listOf(src.ignores)) {
330
+ const item = planItemOf(el);
331
+ if (item && accept(item.path, false)) plan.ignores.push(item);
332
+ }
333
+ for (const el of listOf(src.groups)) {
334
+ const group = planGroupOf(el);
335
+ if (!group) {
336
+ const obj = el && typeof el === "object" ? el : null;
337
+ for (const p of listOf(obj?.paths)) if (typeof p === "string" && p.trim()) dropped++;
338
+ continue;
339
+ }
340
+ const nameError = svnChangelistNameErrorOf(group.name) !== null;
341
+ const over = plan.groups.length >= 20;
342
+ const valid = [];
343
+ for (const path of group.paths) {
344
+ if (nameError || over) {
345
+ dropped++;
346
+ continue;
347
+ }
348
+ if (accept(path, true)) valid.push(path);
349
+ }
350
+ if (valid.length && !nameError && !over) plan.groups.push(group.reason ? {
351
+ name: group.name,
352
+ paths: valid,
353
+ reason: group.reason
354
+ } : {
355
+ name: group.name,
356
+ paths: valid
357
+ });
358
+ }
359
+ return {
360
+ plan,
361
+ dropped
362
+ };
363
+ }
364
+ //#endregion
365
+ //#region src/shared/nativeOpen.ts
366
+ /**
367
+ * dsh-vscode-mode shared — 原生打开范围判定(host/client 双面契约)。
368
+ * 背景:DSH 文件预览双形态——本插件认领 `dsh-resource://file/**` 进 Monaco 编辑,
369
+ * 官方渲染器(Office 预览/不可预览提示/表格预览)看认领让位。本模块承载「原生打开」
370
+ * 范围的单一事实源:默认集 = 让位清单(插件本来就不认领的后缀),用户可经设置
371
+ * `nativeOpenExts`(逗号分隔)增删;host 的 Config schema 默认值与 client 的
372
+ * 文件树点击判定 / claim 认领判定共用,保证三处永不同源漂移。
373
+ * 决策沿袭(不因本模块迁移而变):CSV/TSV **不在**默认集——官方 0.1.7 有表格预览,
374
+ * 但编辑器可编辑性优先,仅用户显式加入 nativeOpenExts 才让位(同 avif 例外先例)。
375
+ * 作者 ddj 2026年09月22号
376
+ */
377
+ /**
378
+ * Office 文档后缀:官方 `dsh-client-ui-sidebar-documentpreview` 的 Office 渲染器
379
+ * 声明 `doc/docx/xls/xlsx/ppt/pptx`(0.1.6 起侧栏 Office 预览;0.1.7 扩展表格 CSV/TSV
380
+ * 只读预览——本清单刻意不含 csv/tsv,见文件头决策)。本插件让位官方(不认领),
381
+ * 否则会把这些文件路由进 Monaco 而丢掉官方预览。
382
+ */
383
+ const OFFICE_EXT = [
384
+ "doc",
385
+ "docx",
386
+ "xls",
387
+ "xlsx",
388
+ "ppt",
389
+ "pptx",
390
+ "odt",
391
+ "ods",
392
+ "odp",
393
+ "pages",
394
+ "numbers"
395
+ ];
396
+ /**
397
+ * 官方「不可预览二进制容器」清单(`UNVIEWABLE_BINARY_EXTENSIONS`,逐项取自
398
+ * `dsh-client-ui-sidebar-documentpreview/lib/client.js`)。这些后缀官方会给出
399
+ * 「无法预览」提示;本插件让位官方,避免把二进制当文本读成乱码。
400
+ *
401
+ * ⚠️ 刻意例外:官方清单含 `avif`,但本插件图片预览支持 avif(浏览器原生解码),
402
+ * 故从本表**移除** `avif` —— 保留本插件的图片预览优于官方的「不可预览」。
403
+ * @author ddj 2026年09月18号
404
+ */
405
+ const BLIND_EXT = [
406
+ "mp4",
407
+ "mov",
408
+ "avi",
409
+ "mkv",
410
+ "webm",
411
+ "flv",
412
+ "wmv",
413
+ "m4v",
414
+ "mp3",
415
+ "wav",
416
+ "flac",
417
+ "ogg",
418
+ "m4a",
419
+ "aac",
420
+ "wma",
421
+ "opus",
422
+ "zip",
423
+ "gz",
424
+ "tgz",
425
+ "bz2",
426
+ "xz",
427
+ "zst",
428
+ "7z",
429
+ "rar",
430
+ "tar",
431
+ "jar",
432
+ "exe",
433
+ "dll",
434
+ "so",
435
+ "dylib",
436
+ "bin",
437
+ "o",
438
+ "class",
439
+ "pyc",
440
+ "wasm",
441
+ "ttf",
442
+ "otf",
443
+ "woff",
444
+ "woff2",
445
+ "eot",
446
+ "dmg",
447
+ "iso",
448
+ "img",
449
+ "sqlite",
450
+ "db",
451
+ "psd",
452
+ "ai",
453
+ "sketch",
454
+ "tiff",
455
+ "tif",
456
+ "heic",
457
+ "heif"
458
+ ];
459
+ /** 默认范围的设置序列(逗号分隔;Config schema 默认值与设置页回显同源)。 */
460
+ const DEFAULT_NATIVE_CSV = [...OFFICE_EXT, ...BLIND_EXT].join(",");
210
461
  //#endregion
211
462
  //#region src/shared/logger.ts
212
463
  /**
@@ -322,19 +573,23 @@ function bindHostLog(ctx) {
322
573
  * dsh-vscode-mode host — 文件链接打开工具 + 快捷键的持久化设置。
323
574
  * 依赖守卫:schemastery 动态加载;@deepseek-ai/dsh-settings 仅作 legacy 探测——
324
575
  * rc 线导出 installSettingsSection free function,0.1.2-alpha 起移除(改由 settings
325
- * 服务的 installSection 方法承载,见 runSettingsInstall 三策略)。
576
+ * 服务的 installSection 方法承载),0.1.7 再移除 installSection(设置并入 profile
577
+ * 插件 Config schema,见 runSettingsInstall 四策略:legacy/service/forms/none)。
326
578
  * 缺失/任一策略失败时插件仍可加载(fileOpenTool 降级为配置值,compat 报告可见),
327
579
  * 全程 try/catch,不产生未捕获 rejection。
328
- * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号
580
+ * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号 / 2026年09月22号 / 2026年09月23号
329
581
  */
330
582
  const FILE_OPEN_SETTINGS_NS = "dsh-vscode-mode";
331
583
  const FILE_OPEN_DEFAULT = "auto";
332
- /** AI 内联补全配置默认值(默认关闭;路由空 = 自动;档位空 = 跟随模型默认)。 */
584
+ /** AI 配置默认值(补全默认关;路由空 = 自动;档位空 = 跟随模型默认;任务模型空 = 跟随补全配置)。 */
333
585
  const AI_CONFIG_DEFAULT = {
334
586
  enabled: false,
335
587
  provider: "",
336
588
  model: "",
337
- effort: ""
589
+ effort: "",
590
+ taskProvider: "",
591
+ taskModel: "",
592
+ taskEffort: ""
338
593
  };
339
594
  let depsPromise;
340
595
  /**
@@ -437,6 +692,7 @@ function loadSettingsDeps(importFn = hostImport) {
437
692
  const INSTALL_UNMOUNTED = "设置 section 尚未装配";
438
693
  const INSTALL_LEGACY = "rc 线:dsh-settings.installSettingsSection";
439
694
  const INSTALL_SERVICE = "0.1.2-alpha 线:settings 服务 installSection";
695
+ const INSTALL_FORMS = "0.1.7 线:设置并入插件 Config schema(SettingsForms)";
440
696
  const INSTALL_NONE = "两路均不可用:设置持久化降级为配置值";
441
697
  let observedInstall = {
442
698
  strategy: "unknown",
@@ -459,14 +715,51 @@ function settingsInstallNote() {
459
715
  return observedInstall.note;
460
716
  }
461
717
  /**
718
+ * 判定设置写冲突错误(0.1.7 SettingsConflictError:code 常量 / 构造器名双通道)。
719
+ * @author ddj 2026年09月22号
720
+ * @param error 捕获到的错误
721
+ * @returns 是否为 revision 冲突
722
+ */
723
+ function isConflictError(error) {
724
+ if (!error || typeof error !== "object") return false;
725
+ const e = error;
726
+ return e.code === "SETTINGS_CONFLICT" || e.name === "SettingsConflictError";
727
+ }
728
+ /**
729
+ * 0.1.7 forms 策略:订阅 settings/document-updated 事件,目标 namespace 变化时
730
+ * 把 describe 最新值推给 hooks(setSource + onChange),替代旧 installSection 的
731
+ * 变更回调;disposer 经 ctx.effect 随插件 fiber 卸载(热重载不泄漏)。
732
+ * @author ddj 2026年09月22号
733
+ * @param ctx DSH host 上下文
734
+ * @param provider settings 服务(SettingsForms)
735
+ * @param ns 目标设置命名空间
736
+ * @param hooks 设置源绑定与变更回调
737
+ */
738
+ function watchDocUpdates(ctx, provider, ns, hooks) {
739
+ try {
740
+ const bus = ctx;
741
+ if (typeof bus.on !== "function") return;
742
+ const off = bus.on("settings/document-updated", (updatedNs) => {
743
+ if (updatedNs !== ns) return;
744
+ hooks.setSource(() => readSectionValue(provider, ns));
745
+ hooks.onChange();
746
+ });
747
+ if (typeof bus.effect === "function" && typeof off === "function") bus.effect(() => off);
748
+ } catch (error) {
749
+ log.warn("settings/document-updated 订阅失败:" + String(error));
750
+ }
751
+ }
752
+ /**
462
753
  * 设置 section 版本自适应安装(核心策略分派)。
463
754
  * legacy:dsh-settings 仍导出 installSettingsSection(rc 线)→ 原样调用,行为与旧版一致。
464
755
  * service:该导出已移除(0.1.2-alpha 起)→ 经 ctx.inject(['settings']) 走服务方法
465
756
  * provider.installSection(owner, ns, schema, entry, hooks)(等义封装,含
466
- * base 层与 fiber 卸载回退)。回调内方法缺失再降级记录 none。
757
+ * base 层与 fiber 卸载回退)。
758
+ * forms:0.1.7 起 installSection 移除,设置并入 profile 插件 Config(SettingsForms
759
+ * 的 describe+update 在场即成立)→ 不装 section,改订阅 document-updated。
467
760
  * none:两条路由都不存在 → 仅记录并告警,调用方按配置值运行。
468
761
  * 全程不抛错、不产生未捕获 rejection。
469
- * @author ddj 2026年09月02号
762
+ * @author ddj 2026年09月02号(2026年09月22 增补 forms 策略)
470
763
  * @param ctx DSH host 上下文(inject 可选探测)
471
764
  * @param ns 设置命名空间
472
765
  * @param schema schemastery schema
@@ -499,6 +792,12 @@ async function runSettingsInstall(ctx, ns, schema, entry, hooks, loader = loadSe
499
792
  const provider = typeof sc.get === "function" ? sc.get("settings") : sc.settings;
500
793
  const install = provider?.installSection;
501
794
  if (typeof install !== "function") {
795
+ const forms = provider;
796
+ if (typeof forms?.describe === "function" && typeof forms?.update === "function") {
797
+ recordInstall("forms", INSTALL_FORMS);
798
+ watchDocUpdates(ctx, forms, ns, hooks);
799
+ return;
800
+ }
502
801
  log.warn("settings 服务无 installSection(DSH 版本 API 变化),section " + ns + " 降级为配置值");
503
802
  recordInstall("none", INSTALL_NONE);
504
803
  return;
@@ -529,26 +828,205 @@ function keybindingsShape(z) {
529
828
  for (const [id, chord] of Object.entries(KEYBINDING_DEFAULTS)) shape[id] = z.string().default(chord);
530
829
  return shape;
531
830
  }
831
+ /**
832
+ * 设置节 describe 项的形状校验:value 须为普通对象。
833
+ * ns 在两代语义不同(旧=自装 section 名,0.1.7=profile entry id),同名撞车时
834
+ * 以形状兜底防误命中非设置数据;0.1.7 下本插件 entry 的 Config 值与旧 section
835
+ * 值同为设置键对象,两形状天然兼容。
836
+ * @author ddj 2026年09月22号
837
+ * @param value describe 项的 value 字段
838
+ * @returns 是否具备设置节形状
839
+ */
840
+ function isSectionShaped(value) {
841
+ return typeof value === "object" && value !== null && !Array.isArray(value);
842
+ }
843
+ /**
844
+ * 按 ns + 形状校验查找设置节(host 读路径统一入口)。
845
+ * @author ddj 2026年09月22号
846
+ * @param provider settings 服务(可空)
847
+ * @param ns 设置命名空间
848
+ * @returns 命中的 describe 项;未命中返回 undefined
849
+ */
850
+ function sectionOf(provider, ns) {
851
+ const items = provider?.describe?.({ redactSecrets: true });
852
+ if (!items) return void 0;
853
+ return items.find((item) => item.ns === ns && isSectionShaped(item.value));
854
+ }
855
+ /** 读设置节存储值(未就绪/形状不符返回 undefined)。 */
856
+ function readSectionValue(provider, ns) {
857
+ return sectionOf(provider, ns)?.value;
858
+ }
859
+ /**
860
+ * 带冲突自愈的设置写入:0.1.7 SettingsConflictError(revision 拒写)时重读
861
+ * revision 重试一次;其余错误与服务缺失一律结构化返回,绝不抛未捕获异常。
862
+ * @author ddj 2026年09月22号
863
+ * @param provider settings 服务(可空)
864
+ * @param ns 设置命名空间
865
+ * @param patch 写入字段
866
+ * @param expectedRevision 读侧携带的 revision(缺省用 describe 最新值)
867
+ * @returns 写入结果
868
+ */
869
+ async function updateSection(provider, ns, patch, expectedRevision) {
870
+ if (!provider?.update) return {
871
+ ok: false,
872
+ error: "settings 服务无 update"
873
+ };
874
+ const first = expectedRevision ?? sectionOf(provider, ns)?.revision;
875
+ try {
876
+ await provider.update(ns, patch, first);
877
+ return { ok: true };
878
+ } catch (error) {
879
+ if (!isConflictError(error)) return {
880
+ ok: false,
881
+ error: String(error)
882
+ };
883
+ }
884
+ const fresh = sectionOf(provider, ns)?.revision;
885
+ try {
886
+ await provider.update(ns, patch, fresh);
887
+ return {
888
+ ok: true,
889
+ conflict: true
890
+ };
891
+ } catch (error) {
892
+ return {
893
+ ok: false,
894
+ conflict: true,
895
+ error: String(error)
896
+ };
897
+ }
898
+ }
899
+ /**
900
+ * 构建设置 section schema(install 路径与插件 Config 声明共用,保证两代形状同源)。
901
+ * 字段集 = fileOpenTool/keybindings/sidebarMinWidth/maxOpenEditors/integrationBaseUrl/
902
+ * aiInline/aiProvider/aiModel/aiEffort/svnPath/tortoisePath/nativeOpenExts,全部带默认值
903
+ * (Config 启动校验在 undefined/空配置下自动填充,rc/alpha 两代 cordis 均通过)。
904
+ * options.volatile:DSH 0.1.7 线的 Config 导出专用——SettingsForms.describe 只下发
905
+ * 含 volatile 字段的 entry(volatileForm 门槛),不标记则 ns 永不出现在 describe、
906
+ * client configForms 恒 unavailable;且字段须位于固定 object 路径(keybindings 整个
907
+ * object 标记、子键不标)。section 安装路径(legacy/service)**不标**:rc/0.1.6 的
908
+ * register→resolve 无该门槛,标了反而让 scope.get() 返回引用污染读取。
909
+ * 标记经 markVolatile 能力守卫:z 无 .volatile()(旧 schemastery)或调用抛错时保持
910
+ * 原字段并记录观测态,模块加载绝不抛错(降级=维持现状,兼容性报告可见)。
911
+ * @author ddj 2026年09月22号(2026年09月23号 增补 options.volatile 与观测)
912
+ * @param z schemastery 命名空间(静态 import 或动态加载均可)
913
+ * @param options volatile:是否按 0.1.7 Config 语义标记字段
914
+ * @returns schemastery object schema
915
+ */
916
+ function buildSettingsSchema(z, options) {
917
+ const wantVolatile = options?.volatile === true;
918
+ let marked = 0;
919
+ const withVol = (field) => {
920
+ if (!wantVolatile) return field;
921
+ const result = markVolatile(field);
922
+ if (result.marked) marked += 1;
923
+ return result.field;
924
+ };
925
+ const shape = {
926
+ fileOpenTool: withVol(z.string().default(FILE_OPEN_DEFAULT)),
927
+ keybindings: withVol(z.object(keybindingsShape(z)).default({ ...KEYBINDING_DEFAULTS })),
928
+ sidebarMinWidth: withVol(z.number().default(300)),
929
+ maxOpenEditors: withVol(z.number().default(10)),
930
+ integrationBaseUrl: withVol(z.string().default(INTEGRATION_BASE_DEFAULT)),
931
+ aiInline: withVol(z.boolean().default(AI_CONFIG_DEFAULT.enabled)),
932
+ aiProvider: withVol(z.string().default(AI_CONFIG_DEFAULT.provider)),
933
+ aiModel: withVol(z.string().default(AI_CONFIG_DEFAULT.model)),
934
+ aiEffort: withVol(z.string().default(AI_CONFIG_DEFAULT.effort)),
935
+ aiTaskProvider: withVol(z.string().default(AI_CONFIG_DEFAULT.taskProvider)),
936
+ aiTaskModel: withVol(z.string().default(AI_CONFIG_DEFAULT.taskModel)),
937
+ aiTaskEffort: withVol(z.string().default(AI_CONFIG_DEFAULT.taskEffort)),
938
+ svnPath: withVol(z.string().default("")),
939
+ tortoisePath: withVol(z.string().default(TORTOISE_DIR_DEFAULT)),
940
+ nativeOpenExts: withVol(z.string().default(DEFAULT_NATIVE_CSV))
941
+ };
942
+ if (wantVolatile) configVolatile = {
943
+ requested: true,
944
+ marked,
945
+ total: Object.keys(shape).length
946
+ };
947
+ return z.object(shape);
948
+ }
949
+ let configVolatile = {
950
+ requested: false,
951
+ marked: 0,
952
+ total: 0
953
+ };
954
+ /**
955
+ * 读取 Config volatile 标记观测(0.1.7 设置页可用性的构建期判据)。
956
+ * @author ddj 2026年09月23号
957
+ * @returns 观测态(requested=是否按 Config 语义构建;marked/total=成功标记字段数)
958
+ */
959
+ function configVolatileState() {
960
+ return configVolatile;
961
+ }
962
+ /**
963
+ * 为已完成 default 链的 schema 字段追加 volatile 标记(能力守卫)。
964
+ * @author ddj 2026年09月23号
965
+ * @param field schema 字段(两代 schemastery 兼容面,可能没有 volatile 方法)
966
+ * @returns 处理后字段与是否成功标记
967
+ */
968
+ function markVolatile(field) {
969
+ const target = field;
970
+ if (!target || typeof target.volatile !== "function") return {
971
+ field,
972
+ marked: false
973
+ };
974
+ try {
975
+ return {
976
+ field: target.volatile(),
977
+ marked: true
978
+ };
979
+ } catch {
980
+ return {
981
+ field,
982
+ marked: false
983
+ };
984
+ }
985
+ }
986
+ /** cosmokit volatile 引用写协议符号(Symbol.for 跨拷贝一致,同 cosmokit.isVolatile 判据)。 */
987
+ const VOLATILE_WRITE = Symbol.for("cosmokit.volatile.write");
988
+ /**
989
+ * 解引用 0.1.7 volatile 配置引用(普通值直传)。
990
+ * cordis resolveConfig 会把 .volatile() 字段解析成稳定引用,直接读会得到引用对象;
991
+ * 不解引用则配置回退值全部落入默认值分支。
992
+ * @author ddj 2026年09月23号
993
+ * @param value 配置字段原始值
994
+ * @returns 引用的当前快照,或原值
995
+ */
996
+ function unref(value) {
997
+ if (value !== null && typeof value === "object" && VOLATILE_WRITE in value) return value.get();
998
+ return value;
999
+ }
1000
+ /**
1001
+ * 读插件组合配置字段并解引用(配置读取唯一入口)。
1002
+ * @author ddj 2026年09月23号
1003
+ * @param config 插件组合配置(apply 收到的 Config 值)
1004
+ * @param key 字段名
1005
+ * @returns 字段值(引用已解包;缺失为 undefined)
1006
+ */
1007
+ function configField(config, key) {
1008
+ return unref(config?.[key]);
1009
+ }
532
1010
  function normalizeValue(value) {
533
1011
  if (typeof value !== "string" || value.trim() === "") return FILE_OPEN_DEFAULT;
534
1012
  return value.trim();
535
1013
  }
536
1014
  function configValue(config) {
537
- return normalizeValue(config?.fileOpenTool);
1015
+ return normalizeValue(configField(config, "fileOpenTool"));
538
1016
  }
539
1017
  /** 配置/设置里的深链基址(缺省/非法回退默认值)。 */
540
1018
  function baseValueOf(config) {
541
- const raw = config?.integrationBaseUrl;
1019
+ const raw = configField(config, "integrationBaseUrl");
542
1020
  return typeof raw === "string" && raw.trim() ? raw.trim() : INTEGRATION_BASE_DEFAULT;
543
1021
  }
544
1022
  /** 配置/设置里的 svn CLI 覆盖(空 = 从 PATH 解析 'svn')。 */
545
1023
  function svnPathValueOf(config) {
546
- const raw = config?.svnPath;
1024
+ const raw = configField(config, "svnPath");
547
1025
  return typeof raw === "string" ? raw.trim() : "";
548
1026
  }
549
1027
  /** 配置/设置里的 TortoiseSVN 目录(空 = 默认安装目录)。 */
550
1028
  function tortoiseDirValueOf(config) {
551
- const raw = config?.tortoisePath;
1029
+ const raw = configField(config, "tortoisePath");
552
1030
  return typeof raw === "string" && raw.trim() ? raw.trim() : TORTOISE_DIR_DEFAULT;
553
1031
  }
554
1032
  /**
@@ -567,23 +1045,11 @@ async function installOpenSettingsSection(ctx, ns, entry, hooks, loader = loadSe
567
1045
  deps = await loader();
568
1046
  } catch {}
569
1047
  if (!deps) return false;
570
- const strategy = await runSettingsInstall(ctx, ns, deps.z.object({
571
- fileOpenTool: deps.z.string().default(FILE_OPEN_DEFAULT),
572
- keybindings: deps.z.object(keybindingsShape(deps.z)).default({ ...KEYBINDING_DEFAULTS }),
573
- sidebarMinWidth: deps.z.number().default(300),
574
- maxOpenEditors: deps.z.number().default(10),
575
- integrationBaseUrl: deps.z.string().default(INTEGRATION_BASE_DEFAULT),
576
- aiInline: deps.z.boolean().default(AI_CONFIG_DEFAULT.enabled),
577
- aiProvider: deps.z.string().default(AI_CONFIG_DEFAULT.provider),
578
- aiModel: deps.z.string().default(AI_CONFIG_DEFAULT.model),
579
- aiEffort: deps.z.string().default(AI_CONFIG_DEFAULT.effort),
580
- svnPath: deps.z.string().default(""),
581
- tortoisePath: deps.z.string().default(TORTOISE_DIR_DEFAULT)
582
- }), entry, {
1048
+ const strategy = await runSettingsInstall(ctx, ns, buildSettingsSchema(deps.z), entry, {
583
1049
  setSource: (source) => hooks.setSource(source),
584
1050
  onChange: hooks.onChange
585
1051
  }, loader);
586
- return strategy === "legacy" || strategy === "service";
1052
+ return strategy === "legacy" || strategy === "service" || strategy === "forms";
587
1053
  }
588
1054
  /** AI 配置脏值读取(settings 未就绪时回退默认)。 */
589
1055
  function aiValueOf(stored) {
@@ -592,7 +1058,10 @@ function aiValueOf(stored) {
592
1058
  enabled: raw.aiInline === true,
593
1059
  provider: typeof raw.aiProvider === "string" ? raw.aiProvider : AI_CONFIG_DEFAULT.provider,
594
1060
  model: typeof raw.aiModel === "string" ? raw.aiModel : AI_CONFIG_DEFAULT.model,
595
- effort: typeof raw.aiEffort === "string" ? raw.aiEffort : AI_CONFIG_DEFAULT.effort
1061
+ effort: typeof raw.aiEffort === "string" ? raw.aiEffort : AI_CONFIG_DEFAULT.effort,
1062
+ taskProvider: typeof raw.aiTaskProvider === "string" ? raw.aiTaskProvider : AI_CONFIG_DEFAULT.taskProvider,
1063
+ taskModel: typeof raw.aiTaskModel === "string" ? raw.aiTaskModel : AI_CONFIG_DEFAULT.taskModel,
1064
+ taskEffort: typeof raw.aiTaskEffort === "string" ? raw.aiTaskEffort : AI_CONFIG_DEFAULT.taskEffort
596
1065
  };
597
1066
  }
598
1067
  /**
@@ -612,13 +1081,13 @@ function setupOpenSettings(ctx, config, onChange) {
612
1081
  onChange(current);
613
1082
  };
614
1083
  const syncRevision = () => {
615
- revision = (provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.revision;
1084
+ revision = sectionOf(provider, FILE_OPEN_SETTINGS_NS)?.revision;
616
1085
  };
617
1086
  const settingsChange = () => syncRevision();
618
1087
  /** AI 配置当前值(settings 未就绪回退默认)。 */
619
1088
  let aiCurrent = { ...AI_CONFIG_DEFAULT };
620
1089
  const aiSync = () => {
621
- const stored = (provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
1090
+ const stored = readSectionValue(provider, FILE_OPEN_SETTINGS_NS);
622
1091
  aiCurrent = stored !== void 0 ? aiValueOf(stored) : aiCurrent;
623
1092
  };
624
1093
  /** SVN 路径当前值(settings 未就绪回退配置值)。 */
@@ -634,7 +1103,7 @@ function setupOpenSettings(ctx, config, onChange) {
634
1103
  };
635
1104
  /** 读取 settings 存储值(describe 未就绪返回 undefined)。 */
636
1105
  const storedValue = () => {
637
- return provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS)?.value;
1106
+ return readSectionValue(provider, FILE_OPEN_SETTINGS_NS);
638
1107
  };
639
1108
  installOpenSettingsSection(ctx, FILE_OPEN_SETTINGS_NS, {
640
1109
  fileOpenTool: current,
@@ -652,8 +1121,11 @@ function setupOpenSettings(ctx, config, onChange) {
652
1121
  });
653
1122
  ctx.inject?.(["settings"], (settingsCtx) => {
654
1123
  provider = settingsCtx.get("settings");
655
- aiSync();
656
- svnSync(storedValue());
1124
+ const stored = storedValue();
1125
+ const section = stored;
1126
+ if (section?.fileOpenTool !== void 0) notify(section.fileOpenTool);
1127
+ aiCurrent = stored !== void 0 ? aiValueOf(stored) : aiCurrent;
1128
+ svnSync(stored);
657
1129
  syncRevision();
658
1130
  });
659
1131
  return {
@@ -665,25 +1137,15 @@ function setupOpenSettings(ctx, config, onChange) {
665
1137
  },
666
1138
  update: async (value, expectedRevision) => {
667
1139
  const next = normalizeValue(value);
668
- if (!provider?.update) {
669
- notify(next);
670
- return;
671
- }
672
- await provider.update(FILE_OPEN_SETTINGS_NS, { fileOpenTool: next }, expectedRevision);
673
- const stored = (provider.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
1140
+ const result = await updateSection(provider, FILE_OPEN_SETTINGS_NS, { fileOpenTool: next }, expectedRevision);
1141
+ if (!result.ok) log.warn("fileOpenTool 设置写入失败:" + (result.error ?? "未知原因"));
1142
+ const stored = readSectionValue(provider, FILE_OPEN_SETTINGS_NS);
674
1143
  notify(stored?.fileOpenTool ?? next);
675
1144
  syncRevision();
676
1145
  },
677
1146
  ai: () => aiCurrent,
678
1147
  svn: () => svnCurrent,
679
1148
  aiUpdate: async (patch, expectedRevision) => {
680
- if (!provider?.update) {
681
- if (patch.enabled !== void 0) aiCurrent.enabled = patch.enabled;
682
- if (patch.provider !== void 0) aiCurrent.provider = patch.provider;
683
- if (patch.model !== void 0) aiCurrent.model = patch.model;
684
- if (patch.effort !== void 0) aiCurrent.effort = patch.effort;
685
- return aiCurrent;
686
- }
687
1149
  const stored = { ...aiCurrent };
688
1150
  const body = {};
689
1151
  if (patch.enabled !== void 0) {
@@ -702,7 +1164,22 @@ function setupOpenSettings(ctx, config, onChange) {
702
1164
  stored.effort = patch.effort;
703
1165
  body.aiEffort = patch.effort;
704
1166
  }
705
- await provider.update(FILE_OPEN_SETTINGS_NS, body, expectedRevision);
1167
+ if (patch.taskProvider !== void 0) {
1168
+ stored.taskProvider = patch.taskProvider;
1169
+ body.aiTaskProvider = patch.taskProvider;
1170
+ }
1171
+ if (patch.taskModel !== void 0) {
1172
+ stored.taskModel = patch.taskModel;
1173
+ body.aiTaskModel = patch.taskModel;
1174
+ }
1175
+ if (patch.taskEffort !== void 0) {
1176
+ stored.taskEffort = patch.taskEffort;
1177
+ body.aiTaskEffort = patch.taskEffort;
1178
+ }
1179
+ if (!(await updateSection(provider, "dsh-vscode-mode", body, expectedRevision)).ok) {
1180
+ aiCurrent = stored;
1181
+ return aiCurrent;
1182
+ }
706
1183
  aiSync();
707
1184
  return aiCurrent;
708
1185
  }
@@ -919,13 +1396,14 @@ function inDshRange(version, range) {
919
1396
  /**
920
1397
  * 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
921
1398
  * 逐线自新到旧匹配,先命中先返回(区间无上界,故顺序即优先级)。
922
- * @author ddj 2026年09月02号 / 2026年09月18号
1399
+ * @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号
923
1400
  * @param input 版本串(如 '0.1.6-alpha.2')
924
1401
  * @returns 版本线标签
925
1402
  */
926
1403
  function familyLabel(input) {
927
1404
  const version = parseDshVersion(input);
928
1405
  if (!version) return "未知";
1406
+ if (inDshRange(version, { from: "0.1.7-alpha.1" })) return "0.1.7-alpha.1 及更新(设置=profile Config + configForms 客户端面 + 会话 V4)";
929
1407
  if (inDshRange(version, { from: "0.1.6-alpha.2" })) return "0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)";
930
1408
  if (inDshRange(version, { from: "0.1.6-alpha.1" })) return "0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)";
931
1409
  if (inDshRange(version, { from: "0.1.5-alpha.1" })) return "0.1.5-alpha 及更新(官方右侧 Sidebar 编辑区 + sidebar.panellist)";
@@ -1864,8 +2342,10 @@ function detectGuards(ctx) {
1864
2342
  }];
1865
2343
  }
1866
2344
  /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
1867
- const TESTED_DSH_MAX = "0.1.6-alpha.2";
1868
- /** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
2345
+ const TESTED_DSH_MAX = "0.1.7-alpha.2";
2346
+ /** Config volatile 标记起效的版本线(此前设置走 section 安装,与 volatile 无关)。 */
2347
+ const CONFIG_VOLATILE_MIN = "0.1.7-alpha.1";
2348
+ /** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略 + Config volatile 标记。 */
1869
2349
  function versionAdapters(dshVersion) {
1870
2350
  const adapters = [];
1871
2351
  const parsed = parseDshVersion(dshVersion);
@@ -1887,14 +2367,23 @@ function versionAdapters(dshVersion) {
1887
2367
  const strategy = settingsInstallStrategy();
1888
2368
  adapters.push({
1889
2369
  name: "设置 section 安装(版本适配)",
1890
- active: strategy === "legacy" || strategy === "service",
2370
+ active: strategy === "legacy" || strategy === "service" || strategy === "forms",
1891
2371
  note: settingsInstallNote()
1892
2372
  });
2373
+ const volatileState = configVolatileState();
2374
+ if (volatileState.requested) {
2375
+ const volatileOk = volatileState.total > 0 && volatileState.marked === volatileState.total;
2376
+ adapters.push({
2377
+ name: "Config volatile 字段(0.1.7 设置页)",
2378
+ active: volatileOk,
2379
+ note: volatileOk ? volatileState.marked + "/" + volatileState.total + " 已标记(SettingsForms 可下发本插件 ns)" : volatileState.marked + "/" + volatileState.total + " 已标记:schemastery 依赖过旧无 .volatile(),0.1.7 设置页不可用"
2380
+ });
2381
+ }
1893
2382
  return adapters;
1894
2383
  }
1895
2384
  /**
1896
2385
  * 构建完整兼容性报告(RPC 与启动日志共用)。
1897
- * @author ddj 2026年08月24号 / 2026年09月02号
2386
+ * @author ddj 2026年08月24号 / 2026年09月02号 / 2026年09月23号
1898
2387
  * @param ctx DSH host 上下文
1899
2388
  * @param options 测试注入:depsAvailable 跳过动态导入、version 固定插件版本、dshVersion 固定 DSH 版本
1900
2389
  * @returns 兼容性报告
@@ -1910,7 +2399,10 @@ async function buildReport(ctx, options) {
1910
2399
  if (settingsInstallStrategy() === "none") warnings.push("设置 section 安装不可用:" + settingsInstallNote());
1911
2400
  const parsed = parseDshVersion(dshVersion);
1912
2401
  const testedMax = parseDshVersion(TESTED_DSH_MAX);
1913
- if (parsed && testedMax && compareDshVersions(parsed, testedMax) > 0) warnings.push("DSH " + dshVersion + " 高于已实测版本(0.1.6-alpha.2):设置 API 按能力探测运行,异常时请回报适配矩阵");
2402
+ if (parsed && testedMax && compareDshVersions(parsed, testedMax) > 0) warnings.push("DSH " + dshVersion + " 高于已实测版本(0.1.7-alpha.2):设置 API 按能力探测运行,异常时请回报适配矩阵");
2403
+ const volatileState = configVolatileState();
2404
+ const volatileMin = parseDshVersion(CONFIG_VOLATILE_MIN);
2405
+ if (volatileState.requested && volatileState.marked < volatileState.total && parsed && volatileMin && compareDshVersions(parsed, volatileMin) >= 0) warnings.push("插件 Config volatile 标记不完整(" + volatileState.marked + "/" + volatileState.total + "):@deepseek-ai/schemastery 依赖过旧,0.1.7 设置页不可用(重装插件依赖后重启 DSH)");
1914
2406
  for (const guard of guards) if (!guard.active && guard.note) warnings.push(guard.note);
1915
2407
  return {
1916
2408
  pluginVersion: options?.version ?? pluginVersionOf(),
@@ -2000,6 +2492,16 @@ function registerRoutes(ctx, config, handleRpc, onWarning) {
2000
2492
  input = {};
2001
2493
  }
2002
2494
  const result = await handleRpc(typeof input.method === "string" ? input.method : "", input.args ?? {});
2495
+ const bin = result?.binary;
2496
+ if (result && result.ok === true && bin?.bytes instanceof Uint8Array) {
2497
+ res.statusCode = 200;
2498
+ res.setHeader("content-type", "application/octet-stream");
2499
+ res.setHeader("x-edrv-mime", String(bin.mime ?? ""));
2500
+ res.setHeader("x-edrv-version", String(bin.version ?? ""));
2501
+ res.setHeader("x-edrv-size", String(bin.size ?? bin.bytes.byteLength));
2502
+ res.end(Buffer.from(bin.bytes));
2503
+ return;
2504
+ }
2003
2505
  res.statusCode = 200;
2004
2506
  res.setHeader("content-type", "application/json");
2005
2507
  res.end(JSON.stringify(result));
@@ -2302,7 +2804,7 @@ function normalizeRecord(raw) {
2302
2804
  ...Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd } : {}
2303
2805
  };
2304
2806
  }) : [];
2305
- const toolName = r.toolName === "write" ? "write" : "edit";
2807
+ const toolName = r.toolName === "write" || r.toolName === "remote_ssh_write" ? r.toolName : "edit";
2306
2808
  const rawDecisions = r.decisions && typeof r.decisions === "object" ? r.decisions : {};
2307
2809
  const call = rawDecisions.call === "accepted" || rawDecisions.call === "rejected" ? rawDecisions.call : "pending";
2308
2810
  const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : [];
@@ -2761,6 +3263,149 @@ function bucketOf(registry, ctx, cwd) {
2761
3263
  });
2762
3264
  }
2763
3265
  //#endregion
3266
+ //#region src/remoteWorkspace.ts
3267
+ /**
3268
+ * dsh-vscode-mode host — dsh-remote-ssh 远程工作区探测与镜像↔远端路径映射。
3269
+ * 背景(issue #7):dsh-remote-ssh 的「原生远程工作区」cwd 指向本机镜像目录
3270
+ * (如 ~/.dsh/remote-workspaces/<id>,根下含 .remote-ssh.json {profileId, host,
3271
+ * user, remotePath}),agent 的 remote_ssh_write 直写远端;差异审查需要把
3272
+ * 远端参数路径映射回镜像(捕获/读/决策复用本地链路),并把展示路径翻译成远端形态。
3273
+ * 判据与上游 dsh-remote-ssh 的 findMirrorRoot 对齐(host/user/remotePath 必填、
3274
+ * 向上 ≤8 层有界);纯函数为主,node:fs 仅用于标记读取(带 60s TTL 缓存)。
3275
+ * 作者 ddj 2026年09月23号
3276
+ */
3277
+ /** dsh-remote-ssh 工作区标记文件名。 */
3278
+ const MARKER = ".remote-ssh.json";
3279
+ /** 向上探测的最大层数(含 cwd 自身;与上游 findMirrorRoot 的 8 层有界一致)。 */
3280
+ const MAX_DEPTH = 8;
3281
+ /** 探测结果 TTL(edrv.list 为 5s 轮询,避免每轮都做文件 IO)。 */
3282
+ const TTL_MS = 6e4;
3283
+ /** cwd → { at, ws } 探测缓存(含阴性结果)。 */
3284
+ const cache$2 = /* @__PURE__ */ new Map();
3285
+ /**
3286
+ * 标记内容 → 远程工作区(host/user/remotePath 缺任一字段判非法,与上游一致)。
3287
+ * @author ddj 2026年09月23号
3288
+ * @param root 标记所在目录
3289
+ * @param raw 标记文件文本
3290
+ * @returns 合法返回 RemoteWs;非法返回 null
3291
+ */
3292
+ function markerWsOf(root, raw) {
3293
+ try {
3294
+ const info = JSON.parse(raw);
3295
+ if (typeof info.host !== "string" || !info.host) return null;
3296
+ if (typeof info.user !== "string" || !info.user) return null;
3297
+ if (typeof info.remotePath !== "string" || !info.remotePath) return null;
3298
+ const ws = {
3299
+ root,
3300
+ host: info.host,
3301
+ user: info.user,
3302
+ remotePath: info.remotePath
3303
+ };
3304
+ if (typeof info.profileId === "string" && info.profileId) ws.profileId = info.profileId;
3305
+ return ws;
3306
+ } catch {
3307
+ return null;
3308
+ }
3309
+ }
3310
+ /**
3311
+ * 从 cwd 起向上有界探测标记(不走缓存)。
3312
+ * @author ddj 2026年09月23号
3313
+ * @param cwd 会话工作区根
3314
+ * @returns 命中合法标记返回 RemoteWs;否则 null
3315
+ */
3316
+ function walkRemoteWs(cwd) {
3317
+ let dir = cwd;
3318
+ for (let i = 0; i < MAX_DEPTH; i++) {
3319
+ try {
3320
+ const ws = markerWsOf(dir, readFileSync(join(dir, MARKER), "utf8"));
3321
+ if (ws) return ws;
3322
+ } catch {}
3323
+ const parent = dirname(dir);
3324
+ if (parent === dir) break;
3325
+ dir = parent;
3326
+ }
3327
+ return null;
3328
+ }
3329
+ /**
3330
+ * 探测工作区是否为 dsh-remote-ssh 远程工作区(cwd 或向上 ≤8 层找 .remote-ssh.json)。
3331
+ * 结果按 cwd 缓存 60s(含阴性);标记变更后可用 clearWsCache 立即重置。
3332
+ * @author ddj 2026年09月23号
3333
+ * @param cwd 会话工作区根(可空)
3334
+ * @returns 远程工作区信息;非远程或标记非法返回 null
3335
+ */
3336
+ function findRemoteWs(cwd) {
3337
+ if (!cwd) return null;
3338
+ const now = Date.now();
3339
+ const hit = cache$2.get(cwd);
3340
+ if (hit && now - hit.at < TTL_MS) return hit.ws;
3341
+ const ws = walkRemoteWs(cwd);
3342
+ cache$2.set(cwd, {
3343
+ at: now,
3344
+ ws
3345
+ });
3346
+ return ws;
3347
+ }
3348
+ /**
3349
+ * 前缀剥离:path 必须严格位于 root 之下;等于 root 或不匹配返回 null。
3350
+ * @author ddj 2026年09月23号
3351
+ * @param path 待剥离路径(posix 形态)
3352
+ * @param root 前缀(posix 形态、无尾斜杠)
3353
+ * @returns 相对段;不匹配返回 null
3354
+ */
3355
+ function prefixRel(path, root) {
3356
+ if (path === root || !path.startsWith(root + "/")) return null;
3357
+ return path.slice(root.length + 1);
3358
+ }
3359
+ /**
3360
+ * 远端参数路径 → 相对远端工作区目录的相对段。
3361
+ * 支持:相对路径(工具语义即基于远端工作区目录)、绝对路径且 remotePath 为绝对
3362
+ * 形态并为其前缀、`~` 形态且 remotePath 同为 `~` 前缀;Windows 盘符形态按
3363
+ * 非远端语义拒绝。含 `..` 逃逸或空相对段返回 null。
3364
+ * @author ddj 2026年09月23号
3365
+ * @param argPath remote_ssh_write 的 path 参数
3366
+ * @param ws 远程工作区
3367
+ * @returns posix 相对段;不可映射返回 null
3368
+ */
3369
+ function remoteRelOf(argPath, ws) {
3370
+ const path = argPath.replace(/\\/g, "/").replace(/^\.\//, "");
3371
+ if (!path) return null;
3372
+ const root = ws.remotePath.replace(/\\/g, "/").replace(/\/+$/, "");
3373
+ if (!root) return null;
3374
+ let rel;
3375
+ if (path.startsWith("/")) rel = root.startsWith("/") ? prefixRel(path, root) : null;
3376
+ else if (path === "~" || path.startsWith("~/")) rel = root.startsWith("~/") ? prefixRel(path, root) : null;
3377
+ else if (/^[A-Za-z]:\//.test(path)) rel = null;
3378
+ else rel = path;
3379
+ if (rel === null || rel.split("/").some((seg) => seg === "..")) return null;
3380
+ return rel;
3381
+ }
3382
+ /**
3383
+ * 远端工具参数路径 → 本机镜像绝对路径。
3384
+ * @author ddj 2026年09月23号
3385
+ * @param argPath remote_ssh_write 的 path 参数
3386
+ * @param ws 远程工作区
3387
+ * @returns 镜像内绝对路径;镜像外/前缀形态不一致/逃逸返回 null
3388
+ */
3389
+ function mirrorTargetOf(argPath, ws) {
3390
+ const rel = remoteRelOf(argPath, ws);
3391
+ return rel === null ? null : join(ws.root, rel);
3392
+ }
3393
+ /**
3394
+ * 镜像内绝对路径 → 远端展示路径(remotePath + 相对段,posix 拼接)。
3395
+ * @author ddj 2026年09月23号
3396
+ * @param mirrorAbs 镜像内绝对路径(通常是记录 path)
3397
+ * @param ws 远程工作区
3398
+ * @returns 远端路径;不在镜像根下返回 null(调用方回落原 path)
3399
+ */
3400
+ function remoteDisplayOf(mirrorAbs, ws) {
3401
+ const path = mirrorAbs.replace(/\\/g, "/");
3402
+ const root = ws.root.replace(/\\/g, "/").replace(/\/+$/, "");
3403
+ if (!root) return null;
3404
+ const rel = prefixRel(path, root);
3405
+ if (rel === null || rel.split("/").some((seg) => seg === "..")) return null;
3406
+ return ws.remotePath.replace(/\/+$/, "") + "/" + rel;
3407
+ }
3408
+ //#endregion
2764
3409
  //#region src/tree.ts
2765
3410
  /** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
2766
3411
  function entryOrder(a, b) {
@@ -3215,6 +3860,16 @@ async function listDirCached(ctx, cwd, rel, force) {
3215
3860
  }
3216
3861
  //#endregion
3217
3862
  //#region src/capture.ts
3863
+ /**
3864
+ * dsh-vscode-mode host — 捕获层:监听 tools/result,把 edit/write/remote_ssh_write
3865
+ * 差异落成审查记录。
3866
+ * 迁移自原 src/index.ts 的 tools/result 处理;2026-09-23(issue #7)增补
3867
+ * remote_ssh_write:远端工作区会话里 agent 直写远端的全量覆写——before 取本地
3868
+ * 镜像快照、after 取参数 content,捕获后把同一内容**写穿**回镜像(node:fs 直写,
3869
+ * 刻意不走 ctx.fs,避开 dsh-remote-ssh 写桥的重复推送与补丁次序问题),使镜像 =
3870
+ * 远端最新内容,读/stale/决策口径与远端一致。
3871
+ * 作者 ddj 2026-08-20(2026-09-23 扩展 remote_ssh_write 捕获)
3872
+ */
3218
3873
  /** 校验并提取工具 result meta 中的文件 hunk。 */
3219
3874
  function validHunks(raw) {
3220
3875
  if (!Array.isArray(raw)) return [];
@@ -3252,8 +3907,190 @@ function fallbackHunks(toolName, args, before, after, create) {
3252
3907
  return [];
3253
3908
  }
3254
3909
  /**
3255
- * 捕获一次工具执行结果(tools/result):edit/write 成功时构造记录、
3256
- * 递增文件批次、融合归档被后续修改取代的旧差异、落盘。
3910
+ * 是否为纳入审查的编辑类工具(本地 edit/write + 远端 remote_ssh_write)。
3911
+ * @author ddj 2026年09月23号
3912
+ * @param name 工具名
3913
+ * @returns 纳入审查返回 true
3914
+ */
3915
+ function isCapturable(name) {
3916
+ return name === "edit" || name === "write" || name === "remote_ssh_write";
3917
+ }
3918
+ /**
3919
+ * 本地 edit/write 执行结果 → 审查记录(原 captureToolResult 主体语义不变)。
3920
+ * @author ddj 2026年09月23号
3921
+ * @param exec 工具执行描述
3922
+ * @param args 工具参数
3923
+ * @param result 工具执行结果
3924
+ * @returns 记录;hunk 为空或缺 callId 返回 null
3925
+ */
3926
+ function buildLocalRecord(exec, args, result) {
3927
+ const value = result.value;
3928
+ const path = value && typeof value.path === "string" ? value.path : args.file_path;
3929
+ if (typeof path !== "string" || !path) return null;
3930
+ const before = value && typeof value.before === "string" ? value.before : null;
3931
+ const after = value && typeof value.after === "string" ? value.after : typeof args.content === "string" ? args.content : null;
3932
+ const create = exec.name === "write" && !!value && value.before === null;
3933
+ const callHunk = exec.name === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string" ? {
3934
+ oldText: args.old_string,
3935
+ newText: args.new_string
3936
+ } : null;
3937
+ const metaHunks = validHunks(result.meta?.diffs);
3938
+ const hunks = annotateHunks(metaHunks.length ? metaHunks : fallbackHunks(String(exec.name), args, before, after, create), before, after);
3939
+ if (!hunks.length || typeof exec.callId !== "string") return null;
3940
+ return {
3941
+ callId: exec.callId,
3942
+ toolName: exec.name === "write" ? "write" : "edit",
3943
+ path,
3944
+ before,
3945
+ after,
3946
+ baseFingerprint: fingerprint(before),
3947
+ afterFingerprint: fingerprint(after),
3948
+ legacy: false,
3949
+ conflict: false,
3950
+ create,
3951
+ callHunk,
3952
+ hunks,
3953
+ decisions: {
3954
+ call: "pending",
3955
+ perHunk: hunks.map(() => "pending")
3956
+ },
3957
+ note: create ? "新建文件:全部拒绝将删除该文件" : before === null ? "未捕获修改前内容(大文件)" : null,
3958
+ superseded: false,
3959
+ archived: false,
3960
+ batch: 0,
3961
+ at: (/* @__PURE__ */ new Date()).toISOString()
3962
+ };
3963
+ }
3964
+ /**
3965
+ * 读取镜像文件当前内容(node:fs 直读;缺失 → missing,非文件/过大/读失败 → unreadable)。
3966
+ * @author ddj 2026年09月23号
3967
+ * @param absPath 镜像内绝对路径
3968
+ * @returns 读取状态
3969
+ */
3970
+ async function readMirrorState(absPath) {
3971
+ let info;
3972
+ try {
3973
+ info = await stat(absPath);
3974
+ } catch {
3975
+ return { kind: "missing" };
3976
+ }
3977
+ if (!info.isFile()) return { kind: "unreadable" };
3978
+ if ((info.size ?? 0) > 8388608) return { kind: "unreadable" };
3979
+ try {
3980
+ return {
3981
+ kind: "content",
3982
+ content: await readFile(absPath, "utf8")
3983
+ };
3984
+ } catch {
3985
+ return { kind: "unreadable" };
3986
+ }
3987
+ }
3988
+ /**
3989
+ * 把远端写入内容写穿回本地镜像(node:fs 直写 + mkdir -p 父目录)。
3990
+ * 刻意不用 ctx.fs:避免触发 dsh-remote-ssh 写桥把相同内容再推回远端。
3991
+ * @author ddj 2026年09月23号
3992
+ * @param absPath 镜像内绝对路径
3993
+ * @param content 远端写入后的完整内容
3994
+ * @returns 成功返回 null;失败返回错误文案(不抛)
3995
+ */
3996
+ async function syncMirrorFile(absPath, content) {
3997
+ try {
3998
+ await mkdir(dirname(absPath), { recursive: true });
3999
+ await writeFile(absPath, content, "utf8");
4000
+ return null;
4001
+ } catch (error) {
4002
+ return String(error);
4003
+ }
4004
+ }
4005
+ /**
4006
+ * 远端记录的 note 组装(新建/未捕获 before/镜像外路径/写穿失败可叠加)。
4007
+ * @author ddj 2026年09月23号
4008
+ * @param create 镜像中目标不存在(远端新建)
4009
+ * @param before 镜像快照内容(null = 未捕获)
4010
+ * @param mirrorAbs 可映射的镜像绝对路径(null = 路径不在镜像内)
4011
+ * @param syncErr 写穿失败原因(null = 成功)
4012
+ * @returns note 文本;无需提示返回 null
4013
+ */
4014
+ function remoteNoteOf(create, before, mirrorAbs, syncErr) {
4015
+ const notes = [];
4016
+ if (!mirrorAbs) notes.push("远端路径不在工作区镜像内(仅记录,无法本地审查)");
4017
+ else if (create) notes.push("新建文件:全部拒绝将删除该文件");
4018
+ else if (before === null) notes.push("未捕获修改前内容(大文件)");
4019
+ if (syncErr) notes.push("远端内容写回镜像失败(记录仍已保存):" + syncErr);
4020
+ return notes.length ? notes.join(";") : null;
4021
+ }
4022
+ /**
4023
+ * remote_ssh_write 执行 → 审查记录(含镜像写穿,issue #7)。
4024
+ * 非远程工作区、参数不合法或内容无变化返回 null(不产生记录)。
4025
+ * @author ddj 2026年09月23号
4026
+ * @param cwd 会话工作区(远程会话即镜像根)
4027
+ * @param exec 工具执行描述
4028
+ * @param args 工具参数(path 相对远端工作区目录,或绝对/`~` 形态)
4029
+ * @returns 记录;不满足捕获条件返回 null
4030
+ */
4031
+ async function buildRemoteRecord(cwd, exec, args) {
4032
+ const ws = findRemoteWs(cwd);
4033
+ if (!ws) return null;
4034
+ const argPath = typeof args.path === "string" ? args.path : "";
4035
+ const after = typeof args.content === "string" ? args.content : null;
4036
+ if (!argPath || after === null || typeof exec.callId !== "string") return null;
4037
+ const mirrorAbs = mirrorTargetOf(argPath, ws);
4038
+ const state = mirrorAbs ? await readMirrorState(mirrorAbs) : { kind: "unreadable" };
4039
+ const before = state.kind === "content" ? state.content : null;
4040
+ const create = state.kind === "missing";
4041
+ if (before !== null && before === after) return null;
4042
+ const syncErr = mirrorAbs ? await syncMirrorFile(mirrorAbs, after) : null;
4043
+ const hunks = annotateHunks([{
4044
+ oldText: before,
4045
+ newText: after
4046
+ }], before, after);
4047
+ return {
4048
+ callId: exec.callId,
4049
+ toolName: "remote_ssh_write",
4050
+ path: mirrorAbs ?? argPath,
4051
+ before,
4052
+ after,
4053
+ baseFingerprint: fingerprint(before),
4054
+ afterFingerprint: fingerprint(after),
4055
+ legacy: false,
4056
+ conflict: false,
4057
+ create,
4058
+ callHunk: null,
4059
+ hunks,
4060
+ decisions: {
4061
+ call: "pending",
4062
+ perHunk: hunks.map(() => "pending")
4063
+ },
4064
+ note: remoteNoteOf(create, before, mirrorAbs, syncErr),
4065
+ superseded: false,
4066
+ archived: false,
4067
+ batch: 0,
4068
+ at: (/* @__PURE__ */ new Date()).toISOString()
4069
+ };
4070
+ }
4071
+ /**
4072
+ * 记录入桶:按工作区分桶(缺失先加载)、按文件递增批次、裁剪、落盘。
4073
+ * @author ddj 2026年09月23号
4074
+ * @param ctx DSH 上下文
4075
+ * @param registry 工作区记录桶注册表
4076
+ * @param session 会话
4077
+ * @param cwd 会话工作区
4078
+ * @param record 待入桶记录
4079
+ */
4080
+ async function persistRecord(ctx, registry, session, cwd, record) {
4081
+ let bucket = registry.get(cwd);
4082
+ if (!bucket) {
4083
+ bucket = await loadBucket(ctx, cwd);
4084
+ registry.set(cwd, bucket);
4085
+ }
4086
+ record.batch = fileMaxBatch(bucket, record.path) + 1;
4087
+ bucket.set(record.callId, record);
4088
+ prune(bucket);
4089
+ await saveBucket(ctx, cwd, bucket, session);
4090
+ }
4091
+ /**
4092
+ * 捕获一次工具执行结果(tools/result):edit/write/remote_ssh_write 成功时
4093
+ * 构造记录、递增文件批次、落盘;远端写入额外做镜像写穿(issue #7)。
3257
4094
  * @author ddj 2026年08月20号
3258
4095
  * @param ctx DSH 上下文
3259
4096
  * @param registry 工作区记录桶注册表
@@ -3262,58 +4099,17 @@ function fallbackHunks(toolName, args, before, after, create) {
3262
4099
  */
3263
4100
  async function captureToolResult(ctx, registry, exec, result) {
3264
4101
  const session = exec?.agent?.session;
3265
- if (!session || exec?.name !== "edit" && exec?.name !== "write") return;
3266
- if (result?.isError || !result?.value) return;
4102
+ if (!session || !isCapturable(exec?.name)) return;
4103
+ if (result?.isError) return;
4104
+ if (exec.name !== "remote_ssh_write" && !result?.value) return;
3267
4105
  try {
3268
4106
  const cwd = cwdOf(session);
3269
4107
  if (!cwd) return;
3270
- const value = result.value;
3271
4108
  const args = exec.arguments || {};
3272
- const path = typeof value.path === "string" ? value.path : args.file_path;
3273
- if (typeof path !== "string" || !path) return;
3274
- const before = typeof value.before === "string" ? value.before : null;
3275
- const after = typeof value.after === "string" ? value.after : typeof args.content === "string" ? args.content : null;
3276
- const create = exec.name === "write" && value.before === null;
3277
- const callHunk = exec.name === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string" ? {
3278
- oldText: args.old_string,
3279
- newText: args.new_string
3280
- } : null;
3281
- const metaHunks = validHunks(result.meta?.diffs);
3282
- const hunks = annotateHunks(metaHunks.length ? metaHunks : fallbackHunks(exec.name, args, before, after, create), before, after);
3283
- if (!hunks.length || typeof exec.callId !== "string") return;
3284
- const record = {
3285
- callId: exec.callId,
3286
- toolName: exec.name,
3287
- path,
3288
- before,
3289
- after,
3290
- baseFingerprint: fingerprint(before),
3291
- afterFingerprint: fingerprint(after),
3292
- legacy: false,
3293
- conflict: false,
3294
- create,
3295
- callHunk,
3296
- hunks,
3297
- decisions: {
3298
- call: "pending",
3299
- perHunk: hunks.map(() => "pending")
3300
- },
3301
- note: create ? "新建文件:全部拒绝将删除该文件" : before === null ? "未捕获修改前内容(大文件)" : null,
3302
- superseded: false,
3303
- archived: false,
3304
- batch: 0,
3305
- at: (/* @__PURE__ */ new Date()).toISOString()
3306
- };
3307
- let bucket = registry.get(cwd);
3308
- if (!bucket) {
3309
- bucket = await loadBucket(ctx, cwd);
3310
- registry.set(cwd, bucket);
3311
- }
3312
- record.batch = fileMaxBatch(bucket, path) + 1;
3313
- bucket.set(exec.callId, record);
3314
- prune(bucket);
3315
- await saveBucket(ctx, cwd, bucket, session);
3316
- invalidateIndex(ctx, cwd, path);
4109
+ const record = exec.name === "remote_ssh_write" ? await buildRemoteRecord(cwd, exec, args) : buildLocalRecord(exec, args, result);
4110
+ if (!record) return;
4111
+ await persistRecord(ctx, registry, session, cwd, record);
4112
+ invalidateIndex(ctx, cwd, record.path);
3317
4113
  } catch (error) {
3318
4114
  log.error("capture failed: " + String(error));
3319
4115
  }
@@ -7104,28 +7900,86 @@ async function scanSessionInventory(home = dshHome(), archive = sessionsArchiveR
7104
7900
  }
7105
7901
  };
7106
7902
  }
7107
- /** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
7108
- function markActiveSessions(sessions, activeIds) {
7109
- const raw = new Set(activeIds);
7903
+ /** id 双向匹配器:原始 id 与编码段名任一命中(兼容编码差异)。 */
7904
+ function idMatcher(ids) {
7905
+ const raw = new Set(ids);
7110
7906
  const encoded = /* @__PURE__ */ new Set();
7111
7907
  for (const id of raw) encoded.add(sessionIdSegment(id));
7112
- for (const s of sessions) if (raw.has(s.sessionId) || encoded.has(s.sessionId)) s.active = true;
7908
+ return (id) => raw.has(id) || encoded.has(id);
7909
+ }
7910
+ /** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
7911
+ function markActiveSessions(sessions, activeIds) {
7912
+ const match = idMatcher(activeIds);
7913
+ for (const s of sessions) if (match(s.sessionId)) s.active = true;
7914
+ }
7915
+ /** 空标志集合(服务/字段缺失的统一降级返回值)。 */
7916
+ function emptyFlagIds() {
7917
+ return {
7918
+ archived: /* @__PURE__ */ new Set(),
7919
+ pinned: /* @__PURE__ */ new Set()
7920
+ };
7921
+ }
7922
+ /** 任意值 → id 字符串集合(非数组或非字符串元素一律丢弃)。 */
7923
+ function toIdSet(ids) {
7924
+ if (!Array.isArray(ids)) return /* @__PURE__ */ new Set();
7925
+ return new Set(ids.filter((id) => typeof id === "string"));
7113
7926
  }
7114
7927
  /**
7115
- * 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
7116
- * 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
7117
- * @author ddj 2026年09月02
7118
- * @param inventory 盘点结果
7119
- * @param criteria 圈选条件
7120
- * @returns 待移出清单与释放字节
7928
+ * 一次读取 workspaceRegistry 的官方归档/置顶 id 集合(摊到盘点各行使用)。
7929
+ * 服务缺失、字段缺失或非数组(DSH 0.1.6 及更旧没有)一律视为空集,绝不抛错。
7930
+ * @author ddj 2026年09月22
7931
+ * @param ctx DSH 上下文
7932
+ * @returns archived/pinned 两组 id 集合(缺失即空集)
7121
7933
  */
7122
- function planMoveOut(inventory, criteria) {
7123
- const items = [];
7124
- const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0;
7125
- const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1e3 : 0;
7126
- for (const s of inventory.sessions) {
7127
- if (s.active) continue;
7128
- if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue;
7934
+ function registryFlags(ctx) {
7935
+ try {
7936
+ const registry = ctx.get("workspaceRegistry");
7937
+ if (!registry) return emptyFlagIds();
7938
+ return {
7939
+ archived: toIdSet(registry.archivedSessionIds),
7940
+ pinned: toIdSet(registry.pinnedSessionIds)
7941
+ };
7942
+ } catch (error) {
7943
+ log.debug("registryFlags 跳过(服务缺失或读取失败):" + String(error));
7944
+ return emptyFlagIds();
7945
+ }
7946
+ }
7947
+ /**
7948
+ * 给盘点行附官方归档/置顶标志(best-effort:仅命中附 true,缺失/未命中不附字段),
7949
+ * 让 PerfSettings 会话行能看到官方侧栏的归档/置顶状态。id 匹配复用 markActiveSessions
7950
+ * 的双向(原始 id / 编码段名)口径;服务/字段缺失不附字段、绝不抛错(旧 host 兼容降级)。
7951
+ * @author ddj 2026年09月22号
7952
+ * @param sessions 盘点会话行(就地附加 archived/pinned)
7953
+ * @param ctx DSH 上下文
7954
+ */
7955
+ function markOfficialFlags(sessions, ctx) {
7956
+ try {
7957
+ const flags = registryFlags(ctx);
7958
+ const archived = idMatcher(flags.archived);
7959
+ const pinned = idMatcher(flags.pinned);
7960
+ for (const s of sessions) {
7961
+ if (archived(s.sessionId)) s.archived = true;
7962
+ if (pinned(s.sessionId)) s.pinned = true;
7963
+ }
7964
+ } catch (error) {
7965
+ log.debug("markOfficialFlags 跳过(读取失败,保持无标志):" + String(error));
7966
+ }
7967
+ }
7968
+ /**
7969
+ * 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
7970
+ * 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
7971
+ * @author ddj 2026年09月02号
7972
+ * @param inventory 盘点结果
7973
+ * @param criteria 圈选条件
7974
+ * @returns 待移出清单与释放字节
7975
+ */
7976
+ function planMoveOut(inventory, criteria) {
7977
+ const items = [];
7978
+ const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0;
7979
+ const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1e3 : 0;
7980
+ for (const s of inventory.sessions) {
7981
+ if (s.active) continue;
7982
+ if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue;
7129
7983
  if (explicit) {
7130
7984
  if (!criteria.sessionIds.includes(s.sessionId)) continue;
7131
7985
  } else {
@@ -7273,6 +8127,27 @@ async function restoreSession(home, archive, workspaceKey, sessionId) {
7273
8127
  };
7274
8128
  }
7275
8129
  }
8130
+ /**
8131
+ * 恢复后对齐官方归档标志(best-effort):官方 archive 是 workspaceRegistry 的
8132
+ * archivedSessionIds 持久标志(只藏 UI/拦模型步骤,不搬目录;dsh-workspace 源码注明
8133
+ * unarchiveSession 对未归档与未知 id 均为无写入 no-op、对失踪会话也容忍)。
8134
+ * 插件把目录搬回后若该会话曾被官方归档,官方侧栏会继续隐藏它、0.1.7 的
8135
+ * archived-session-gate 会继续拦它的模型步骤——这里探测 host 的 workspaceRegistry
8136
+ * 服务做一次幂等清除;服务缺失、id 编码差异未命中或调用抛错一律静默跳过
8137
+ * (保持现状,不加猜的行为)。有效期:仅恢复成功后触发一次,不轮询不重试。
8138
+ * @author ddj 2026年09月22号
8139
+ * @param ctx DSH 上下文
8140
+ * @param sessionId 恢复的会话 id(目录段名与原始 id 同形时可命中官方标志)
8141
+ */
8142
+ async function unarchiveOfficial(ctx, sessionId) {
8143
+ try {
8144
+ const registry = ctx.get("workspaceRegistry");
8145
+ if (!registry || typeof registry.unarchiveSession !== "function") return;
8146
+ await registry.unarchiveSession(sessionId);
8147
+ } catch (error) {
8148
+ log.debug("unarchiveOfficial 跳过(服务缺失或调用失败,保持现状):" + String(error));
8149
+ }
8150
+ }
7276
8151
  /** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
7277
8152
  async function purgeArchive(archive, olderThanDays) {
7278
8153
  const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1e3;
@@ -7465,8 +8340,8 @@ function patchInsertPerfConfig(text) {
7465
8340
  const staleCheckedAt = /* @__PURE__ */ new Map();
7466
8341
  /** stale 自动清理最小间隔。 */
7467
8342
  const STALE_CHECK_MIN_MS = 1e4;
7468
- /** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
7469
- function recView(record) {
8343
+ /** 记录 → 客户端视图(不含 before 全文,仅长度);远程工作区附远端展示路径(issue #7)。 */
8344
+ function recView(record, ws = null) {
7470
8345
  return {
7471
8346
  callId: record.callId,
7472
8347
  toolName: record.toolName,
@@ -7483,7 +8358,8 @@ function recView(record) {
7483
8358
  afterFingerprint: record.afterFingerprint ?? null,
7484
8359
  conflict: record.conflict === true,
7485
8360
  legacy: record.legacy === true,
7486
- at: record.at
8361
+ at: record.at,
8362
+ ...ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? void 0 } : {}
7487
8363
  };
7488
8364
  }
7489
8365
  /** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
@@ -7508,6 +8384,52 @@ function snippetTargetOf(path) {
7508
8384
  return isSnippetFilePath(path) ? path.replace(/\\/g, "/") : null;
7509
8385
  }
7510
8386
  /**
8387
+ * 二进制/文本读取共用前置(edrv.read 与 edrv.readBinary):解析路径 + debugRecord +
8388
+ * stat + 文件类型校验 + 32MB 二进制上限,保证两条读通道的错误口径完全一致。
8389
+ * @author ddj 2026年09月22号
8390
+ * @param ctx DSH 上下文
8391
+ * @param sc requireSession 成功结果(session + cwd)
8392
+ * @param path 客户端请求路径
8393
+ * @returns 成功返回解析目标与 stat;失败返回错误文案(文件不存在时附 resolvedPath)
8394
+ */
8395
+ async function readTargetOf(ctx, sc, path) {
8396
+ const fs = ctx.get("fs");
8397
+ if (!fs) return { err: "缺少 fs" };
8398
+ try {
8399
+ const target = await resolveTarget(ctx, sc.session, path);
8400
+ debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(path ?? "") + " resolved=" + fs.processPath(target), "debug");
8401
+ const info = await fs.stat(target);
8402
+ if (!info || info.type !== "file") return {
8403
+ err: "文件不存在",
8404
+ resolvedPath: fs.processPath(target)
8405
+ };
8406
+ if ((info.size ?? 0) > 33554432) return { err: "文件过大(>32MB),不支持整文件预览" };
8407
+ return {
8408
+ target,
8409
+ fs,
8410
+ info
8411
+ };
8412
+ } catch (error) {
8413
+ return { err: "读取失败:" + String(error) };
8414
+ }
8415
+ }
8416
+ /**
8417
+ * readTargetOf 失败结果 → RPC 错误载荷(文件不存在时保留 resolvedPath 供诊断)。
8418
+ * @author ddj 2026年09月22号
8419
+ * @param prep readTargetOf 的失败分支
8420
+ * @returns { ok:false } 形态载荷
8421
+ */
8422
+ function targetErrOf(prep) {
8423
+ return prep.resolvedPath !== void 0 ? {
8424
+ ok: false,
8425
+ error: prep.err,
8426
+ resolvedPath: prep.resolvedPath
8427
+ } : {
8428
+ ok: false,
8429
+ error: prep.err
8430
+ };
8431
+ }
8432
+ /**
7511
8433
  * 本地文件系统版本令牌(mtime+size):与 ctx.fs 的不透明版本串用途相同,
7512
8434
  * 仅供全局片段文件(走 node:fs 直读、不经 ctx.fs)在 edrv.read 时回带。
7513
8435
  * @author ddj 2026年09月15号
@@ -7567,6 +8489,7 @@ async function afterManualSave(ctx, registry, sc, path) {
7567
8489
  async function applyDecisions(ctx, session, cwd, bucket, items) {
7568
8490
  const results = [];
7569
8491
  const resolved = [];
8492
+ const ws = findRemoteWs(cwd);
7570
8493
  let changed = false;
7571
8494
  for (const item of items) {
7572
8495
  const record = bucket.get(item.callId);
@@ -7601,7 +8524,7 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
7601
8524
  const result = {
7602
8525
  callId: item.callId,
7603
8526
  ok: true,
7604
- record: recView(record)
8527
+ record: recView(record, ws)
7605
8528
  };
7606
8529
  if (revertedStale) result.stale = true;
7607
8530
  results.push(result);
@@ -7633,7 +8556,7 @@ async function latestPatchBackup(patchPath) {
7633
8556
  }
7634
8557
  /** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
7635
8558
  async function integrationBaseUrlOf(ctx) {
7636
- const value = (ctx.get("settings")?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
8559
+ const value = sectionOf(ctx.get("settings"), FILE_OPEN_SETTINGS_NS)?.value;
7637
8560
  return (typeof value?.integrationBaseUrl === "string" ? value.integrationBaseUrl.trim() : "") || "http://127.0.0.1:3080";
7638
8561
  }
7639
8562
  /**
@@ -7756,10 +8679,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7756
8679
  }
7757
8680
  }
7758
8681
  const out = [];
8682
+ const ws = findRemoteWs(sc.cwd);
7759
8683
  for (const rec of bucket.values()) {
7760
8684
  if (!want && rec.archived) continue;
7761
8685
  if (want && !want.has(rec.callId)) continue;
7762
- out.push(recView(rec));
8686
+ out.push(recView(rec, ws));
7763
8687
  }
7764
8688
  out.sort((a, b) => a.at < b.at ? -1 : 1);
7765
8689
  return {
@@ -7846,45 +8770,56 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
7846
8770
  ok: false,
7847
8771
  error: sc.err
7848
8772
  };
7849
- const fs = ctx.get("fs");
7850
- if (!fs) return {
7851
- ok: false,
7852
- error: "缺少 fs"
7853
- };
8773
+ const prep = await readTargetOf(ctx, sc, args.path);
8774
+ if ("err" in prep) return targetErrOf(prep);
7854
8775
  try {
7855
- const target = await resolveTarget(ctx, sc.session, args.path);
7856
- debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(args.path ?? "") + " resolved=" + fs.processPath(target), "debug");
7857
- const info = await fs.stat(target);
7858
- if (!info || info.type !== "file") return {
7859
- ok: false,
7860
- error: "文件不存在",
7861
- resolvedPath: fs.processPath(target)
7862
- };
7863
- if ((info.size ?? 0) > 33554432) return {
7864
- ok: false,
7865
- error: "文件过大(>32MB),不支持整文件预览"
7866
- };
7867
8776
  if (args.encoding === "base64") {
7868
- const bytes = await fs.readBytes(target, void 0, BINARY_READ_CAP);
8777
+ const bytes = await prep.fs.readBytes(prep.target, void 0, BINARY_READ_CAP);
7869
8778
  return {
7870
8779
  ok: true,
7871
8780
  content: Buffer.from(bytes).toString("base64"),
7872
8781
  size: bytes.byteLength,
7873
8782
  encoding: "base64",
7874
8783
  mime: binaryMimeOf(args.path),
7875
- version: String(info.version ?? "")
8784
+ version: String(prep.info.version ?? "")
7876
8785
  };
7877
8786
  }
7878
- if ((info.size ?? 0) > 8388608) return {
8787
+ if ((prep.info.size ?? 0) > 8388608) return {
7879
8788
  ok: false,
7880
8789
  error: "文件过大(>8MB),不支持整文件预览"
7881
8790
  };
7882
- const content = await fs.readText(target);
8791
+ const content = await prep.fs.readText(prep.target);
7883
8792
  return {
7884
8793
  ok: true,
7885
8794
  content,
7886
8795
  size: content.length,
7887
- version: String(info.version ?? "")
8796
+ version: String(prep.info.version ?? "")
8797
+ };
8798
+ } catch (error) {
8799
+ return {
8800
+ ok: false,
8801
+ error: "读取失败:" + String(error)
8802
+ };
8803
+ }
8804
+ },
8805
+ "edrv.readBinary": async (args) => {
8806
+ const sc = await requireSession(ctx, args.sessionId);
8807
+ if ("err" in sc) return {
8808
+ ok: false,
8809
+ error: sc.err
8810
+ };
8811
+ const prep = await readTargetOf(ctx, sc, args.path);
8812
+ if ("err" in prep) return targetErrOf(prep);
8813
+ try {
8814
+ const bytes = await prep.fs.readBytes(prep.target, void 0, BINARY_READ_CAP);
8815
+ return {
8816
+ ok: true,
8817
+ binary: {
8818
+ bytes,
8819
+ mime: binaryMimeOf(args.path),
8820
+ size: bytes.byteLength,
8821
+ version: String(prep.info.version ?? "")
8822
+ }
7888
8823
  };
7889
8824
  } catch (error) {
7890
8825
  return {
@@ -8054,7 +8989,9 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8054
8989
  ok: false,
8055
8990
  error: sc.err
8056
8991
  };
8057
- const entries = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd).map((b) => {
8992
+ const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd);
8993
+ const ws = findRemoteWs(sc.cwd);
8994
+ const entries = batches.map((b) => {
8058
8995
  const recs = Array.isArray(b.records) ? b.records : [];
8059
8996
  const sum = recs.reduce((s, r) => {
8060
8997
  const sm = r.summary || {
@@ -8078,6 +9015,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8078
9015
  at: b.at,
8079
9016
  lastAt: b.lastAt || b.at,
8080
9017
  path: b.path,
9018
+ ...ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? void 0 } : {},
8081
9019
  batch: b.batch ?? null,
8082
9020
  reason: b.reason ?? null,
8083
9021
  nRecords: recs.length,
@@ -8633,39 +9571,32 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8633
9571
  }
8634
9572
  },
8635
9573
  "vscode.fileOpenSettingsGet": async () => {
8636
- const descriptor = ctx.get("settings")?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS);
8637
- const value = descriptor?.value;
9574
+ const section = sectionOf(ctx.get("settings"), FILE_OPEN_SETTINGS_NS);
9575
+ const value = section?.value;
8638
9576
  return {
8639
9577
  ok: true,
8640
9578
  fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? "auto"),
8641
9579
  integrationBaseUrl: await integrationBaseUrlOf(ctx),
8642
- revision: descriptor?.revision
9580
+ revision: section?.revision
8643
9581
  };
8644
9582
  },
8645
9583
  "vscode.fileOpenSettingsUpdate": async (args) => {
8646
9584
  const settings = ctx.get("settings");
8647
- if (!settings?.update) return {
9585
+ const patch = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) };
9586
+ if (typeof args.integrationBaseUrl === "string" && args.integrationBaseUrl.trim()) patch.integrationBaseUrl = args.integrationBaseUrl.trim();
9587
+ const result = await updateSection(settings, FILE_OPEN_SETTINGS_NS, patch, args.expectedRevision);
9588
+ if (!result.ok) return {
8648
9589
  ok: false,
8649
- error: "设置服务不可用"
9590
+ error: result.error ?? "设置服务不可用"
9591
+ };
9592
+ const section = sectionOf(settings, FILE_OPEN_SETTINGS_NS);
9593
+ const value = section?.value;
9594
+ return {
9595
+ ok: true,
9596
+ fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool),
9597
+ integrationBaseUrl: await integrationBaseUrlOf(ctx),
9598
+ revision: section?.revision
8650
9599
  };
8651
- try {
8652
- const patch = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) };
8653
- if (typeof args.integrationBaseUrl === "string" && args.integrationBaseUrl.trim()) patch.integrationBaseUrl = args.integrationBaseUrl.trim();
8654
- await settings.update(FILE_OPEN_SETTINGS_NS, patch, args.expectedRevision);
8655
- const descriptor = settings.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS);
8656
- const value = descriptor?.value;
8657
- return {
8658
- ok: true,
8659
- fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool),
8660
- integrationBaseUrl: await integrationBaseUrlOf(ctx),
8661
- revision: descriptor?.revision
8662
- };
8663
- } catch (error) {
8664
- return {
8665
- ok: false,
8666
- error: String(error)
8667
- };
8668
- }
8669
9600
  },
8670
9601
  "compat": async () => ({
8671
9602
  ok: true,
@@ -8829,6 +9760,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8829
9760
  const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
8830
9761
  const active = activeSessionIds(ctx);
8831
9762
  markActiveSessions(inventory.sessions, active);
9763
+ markOfficialFlags(inventory.sessions, ctx);
8832
9764
  return {
8833
9765
  ok: true,
8834
9766
  ...inventory,
@@ -8897,13 +9829,15 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8897
9829
  "edrv.perf.restore": async (args) => {
8898
9830
  try {
8899
9831
  const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId);
8900
- return result.ok ? {
8901
- ok: true,
8902
- restored: true
8903
- } : {
9832
+ if (!result.ok) return {
8904
9833
  ok: false,
8905
9834
  error: result.error ?? "恢复失败"
8906
9835
  };
9836
+ await unarchiveOfficial(ctx, args.sessionId);
9837
+ return {
9838
+ ok: true,
9839
+ restored: true
9840
+ };
8907
9841
  } catch (error) {
8908
9842
  return {
8909
9843
  ok: false,
@@ -9616,6 +10550,9 @@ function createLspClient(transport, events = {}) {
9616
10550
  workspaceSymbol: false,
9617
10551
  hover: false,
9618
10552
  semanticTokens: false,
10553
+ completion: false,
10554
+ completionResolve: false,
10555
+ signatureHelp: false,
9619
10556
  semanticTokenTypes: [],
9620
10557
  semanticTokenModifiers: []
9621
10558
  };
@@ -9749,6 +10686,23 @@ function createLspClient(transport, events = {}) {
9749
10686
  references: { dynamicRegistration: false },
9750
10687
  documentSymbol: { dynamicRegistration: false },
9751
10688
  hover: { dynamicRegistration: false },
10689
+ completion: {
10690
+ dynamicRegistration: false,
10691
+ contextSupport: true,
10692
+ completionItem: {
10693
+ snippetSupport: true,
10694
+ documentationFormat: ["markdown", "plaintext"],
10695
+ resolveSupport: { properties: [
10696
+ "documentation",
10697
+ "detail",
10698
+ "additionalTextEdits"
10699
+ ] }
10700
+ }
10701
+ },
10702
+ signatureHelp: {
10703
+ dynamicRegistration: false,
10704
+ signatureInformation: { documentationFormat: ["markdown", "plaintext"] }
10705
+ },
9752
10706
  semanticTokens: {
9753
10707
  dynamicRegistration: false,
9754
10708
  requests: {
@@ -9771,6 +10725,9 @@ function createLspClient(transport, events = {}) {
9771
10725
  workspaceSymbol: Boolean(caps.workspaceSymbolProvider),
9772
10726
  hover: Boolean(caps.hoverProvider),
9773
10727
  semanticTokens: Boolean(caps.semanticTokensProvider),
10728
+ completion: Boolean(caps.completionProvider),
10729
+ completionResolve: Boolean(caps.completionProvider?.resolveProvider),
10730
+ signatureHelp: Boolean(caps.signatureHelpProvider),
9774
10731
  semanticTokenTypes: Array.isArray(legend?.tokenTypes) ? legend.tokenTypes.filter((item) => typeof item === "string") : [...LSP_SEMANTIC_TOKEN_TYPES],
9775
10732
  semanticTokenModifiers: Array.isArray(legend?.tokenModifiers) ? legend.tokenModifiers.filter((item) => typeof item === "string") : [...LSP_SEMANTIC_TOKEN_MODIFIERS]
9776
10733
  };
@@ -9972,6 +10929,9 @@ function createLspServer(spec, root, languageId, logger) {
9972
10929
  workspaceSymbol: false,
9973
10930
  hover: false,
9974
10931
  semanticTokens: false,
10932
+ completion: false,
10933
+ completionResolve: false,
10934
+ signatureHelp: false,
9975
10935
  semanticTokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES],
9976
10936
  semanticTokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS]
9977
10937
  };
@@ -10228,6 +11188,68 @@ function createLspServer(spec, root, languageId, logger) {
10228
11188
  if (!result || !result.contents) return null;
10229
11189
  return { contents: stringifyHoverContents(result.contents) };
10230
11190
  },
11191
+ /**
11192
+ * 查询补全列表(textDocument/completion)。
11193
+ *
11194
+ * 列表阶段**剥离 documentation**:EmmyLua 把文档挂在 resolve 阶段(我方 initialize 已声明
11195
+ * resolveSupport),但并非所有服务器都遵守;每条都带长文档会让单次 RPC 载荷暴涨。
11196
+ * `data` 原样透传,resolve 时由服务器回认该条目。
11197
+ * @author ddj 2026年09月22号
11198
+ * @param path 工作区相对路径
11199
+ * @param line 0-based 行
11200
+ * @param character 0-based 列
11201
+ * @param context LSP CompletionContext(触发字符/触发类型)
11202
+ * @returns 归一化补全列表;不支持或文档未打开时 null
11203
+ */
11204
+ async completion(path, line, character, context) {
11205
+ const doc = docs.get(path);
11206
+ if (!doc || !await readyWait || !capabilities.completion) return null;
11207
+ const params = {
11208
+ textDocument: { uri: doc.uri },
11209
+ position: {
11210
+ line,
11211
+ character
11212
+ }
11213
+ };
11214
+ if (context) params.context = context;
11215
+ return normCompletion(await ensureClient().request("textDocument/completion", params));
11216
+ },
11217
+ /**
11218
+ * 补全项惰性补全(completionItem/resolve):按 `data` 取回 documentation/detail/additionalTextEdits。
11219
+ *
11220
+ * 必须回传**完整条目**(服务器按 data + label 精确匹配),只发 label/data 可能被服务器忽略。
11221
+ * @author ddj 2026年09月22号
11222
+ * @param path 工作区相对路径
11223
+ * @param item 列表阶段返回的补全项
11224
+ * @returns 补全后的条目;服务器不支持时原样返回
11225
+ */
11226
+ async resolveCompletion(path, item) {
11227
+ if (!item || typeof item.label !== "string") return null;
11228
+ if (!await readyWait || !capabilities.completionResolve) return item;
11229
+ return normCompItem(await ensureClient().request("completionItem/resolve", toLspCompItem(item))) ?? item;
11230
+ },
11231
+ /**
11232
+ * 查询签名帮助(textDocument/signatureHelp)。
11233
+ *
11234
+ * activeSignature/activeParameter 做边界裁剪:服务器可能给越界值(多签名时按实参推进出错),
11235
+ * Monaco 拿到越界索引会取不到签名而不渲染 —— 裁剪后至少显示首个签名。
11236
+ * @author ddj 2026年09月22号
11237
+ * @param path 工作区相对路径
11238
+ * @param line 0-based 行
11239
+ * @param character 0-based 列
11240
+ * @returns 归一化签名帮助;无签名/不支持时 null
11241
+ */
11242
+ async signatureHelp(path, line, character) {
11243
+ const doc = docs.get(path);
11244
+ if (!doc || !await readyWait || !capabilities.signatureHelp) return null;
11245
+ return toSignatureHelp(await ensureClient().request("textDocument/signatureHelp", {
11246
+ textDocument: { uri: doc.uri },
11247
+ position: {
11248
+ line,
11249
+ character
11250
+ }
11251
+ }));
11252
+ },
10231
11253
  /** 查询全文 semantic tokens,并将服务器 legend 归一到插件固定 legend。 */
10232
11254
  async semanticTokens(path) {
10233
11255
  const doc = docs.get(path);
@@ -10333,8 +11355,205 @@ function normalizeSymbol(item) {
10333
11355
  }
10334
11356
  return symbol;
10335
11357
  }
10336
- /** hover contents文本行(MarkupContent | MarkedString | MarkedString[])。 */
10337
- function stringifyHoverContents(contents) {
11358
+ /** hover/markdown 内容纯文本(string | MarkupContent | MarkedString[] | MarkedString)。 */
11359
+ function plainText(value) {
11360
+ if (typeof value === "string") return value || void 0;
11361
+ if (Array.isArray(value)) {
11362
+ const parts = value.map(plainText).filter((part) => Boolean(part));
11363
+ return parts.length ? parts.join("\n\n") : void 0;
11364
+ }
11365
+ if (value && typeof value === "object") {
11366
+ const text = value.value;
11367
+ if (typeof text === "string") return text || void 0;
11368
+ }
11369
+ }
11370
+ /**
11371
+ * 归一化补全文本编辑范围(单 range 与 insert/replace 双 range 两种形态)。
11372
+ *
11373
+ * 两种形态必须都认:只认一种会让采用另一种形态的服务器补全落点错位(替换范围算错,
11374
+ * 已输入的前缀被重复插入)。双 range 时 insert 用于「保留前缀插入」,replace 用于覆盖。
11375
+ * @author ddj 2026年09月22号
11376
+ * @param raw 原始 textEdit
11377
+ * @returns 归一化文本编辑;无有效范围或文本时 null
11378
+ */
11379
+ function normalizeTextEdit(raw) {
11380
+ if (!raw || typeof raw !== "object") return null;
11381
+ const obj = raw;
11382
+ const newText = typeof obj.newText === "string" ? obj.newText : "";
11383
+ if (!newText) return null;
11384
+ const single = normalizeRange(obj.range);
11385
+ if (single) return {
11386
+ range: single,
11387
+ newText
11388
+ };
11389
+ const insert = normalizeRange(obj.insert);
11390
+ const replace = normalizeRange(obj.replace);
11391
+ if (!insert && !replace) return null;
11392
+ return {
11393
+ insert: insert ?? replace,
11394
+ replace: replace ?? insert,
11395
+ newText
11396
+ };
11397
+ }
11398
+ /** 归一化 LSP Range(缺字段返回 null,避免产出畸形范围)。 */
11399
+ function normalizeRange(raw) {
11400
+ if (!raw || typeof raw !== "object") return null;
11401
+ const r = raw;
11402
+ if (!r.start || !r.end) return null;
11403
+ return {
11404
+ start: {
11405
+ line: toInt(r.start.line),
11406
+ character: toInt(r.start.character)
11407
+ },
11408
+ end: {
11409
+ line: toInt(r.end.line),
11410
+ character: toInt(r.end.character)
11411
+ }
11412
+ };
11413
+ }
11414
+ /**
11415
+ * 归一化单个补全项。
11416
+ * @author ddj 2026年09月22号
11417
+ * @param raw 原始条目
11418
+ * @returns 归一化条目;缺 label 时 null
11419
+ */
11420
+ function normCompItem(raw) {
11421
+ if (!raw || typeof raw !== "object") return null;
11422
+ const obj = raw;
11423
+ const label = typeof obj.label === "string" ? obj.label : "";
11424
+ if (!label) return null;
11425
+ const item = { label };
11426
+ if (typeof obj.kind === "number") item.kind = toInt(obj.kind);
11427
+ if (typeof obj.detail === "string" && obj.detail) item.detail = obj.detail;
11428
+ const documentation = plainText(obj.documentation);
11429
+ if (documentation) item.documentation = documentation;
11430
+ if (typeof obj.insertText === "string") item.insertText = obj.insertText;
11431
+ if (typeof obj.insertTextFormat === "number") item.insertTextFormat = toInt(obj.insertTextFormat);
11432
+ const textEdit = normalizeTextEdit(obj.textEdit);
11433
+ if (textEdit) item.textEdit = textEdit;
11434
+ if (obj.data !== void 0) item.data = obj.data;
11435
+ if (typeof obj.sortText === "string") item.sortText = obj.sortText;
11436
+ if (typeof obj.filterText === "string") item.filterText = obj.filterText;
11437
+ if (obj.preselect === true) item.preselect = true;
11438
+ if (Array.isArray(obj.commitCharacters)) {
11439
+ const chars = obj.commitCharacters.filter((c) => typeof c === "string");
11440
+ if (chars.length) item.commitCharacters = chars;
11441
+ }
11442
+ const tags = Array.isArray(obj.tags) ? obj.tags : [];
11443
+ if (obj.deprecated === true || tags.some((tag) => toInt(tag) === 1)) item.deprecated = true;
11444
+ if (Array.isArray(obj.additionalTextEdits)) {
11445
+ const edits = obj.additionalTextEdits.map((edit) => {
11446
+ const range = normalizeRange(edit?.range);
11447
+ const text = edit?.newText;
11448
+ if (!range || typeof text !== "string") return null;
11449
+ return {
11450
+ range,
11451
+ newText: text
11452
+ };
11453
+ }).filter((edit) => edit !== null);
11454
+ if (edits.length) item.additionalTextEdits = edits;
11455
+ }
11456
+ return item;
11457
+ }
11458
+ /**
11459
+ * 归一化补全结果(CompletionList | CompletionItem[])。
11460
+ *
11461
+ * 两种顶层形态都要认:`{ isIncomplete, items }` 与裸数组。裸数组是最常见形态,
11462
+ * 只认 CompletionList 会让「补全列表恒为空」且无报错。
11463
+ * @author ddj 2026年09月22号
11464
+ * @param result 原始响应
11465
+ * @returns 归一化列表;无效时 null
11466
+ */
11467
+ function normCompletion(result) {
11468
+ if (result == null) return null;
11469
+ const list = Array.isArray(result) ? {
11470
+ items: result,
11471
+ isIncomplete: false
11472
+ } : result;
11473
+ if (!Array.isArray(list.items)) return null;
11474
+ return {
11475
+ items: list.items.map(normCompItem).filter((item) => item !== null),
11476
+ incomplete: list.isIncomplete === true
11477
+ };
11478
+ }
11479
+ /**
11480
+ * 补全项 → LSP 载荷(resolve 回传用)。
11481
+ * 只回传协议字段,剔除 client 侧衍生字段;`data` 必须原样带上,服务器靠它认条目。
11482
+ * @author ddj 2026年09月22号
11483
+ * @param item 归一化条目
11484
+ * @returns LSP CompletionItem 载荷
11485
+ */
11486
+ function toLspCompItem(item) {
11487
+ const out = { label: item.label };
11488
+ if (item.kind !== void 0) out.kind = item.kind;
11489
+ if (item.detail !== void 0) out.detail = item.detail;
11490
+ if (item.documentation !== void 0) out.documentation = item.documentation;
11491
+ if (item.insertText !== void 0) out.insertText = item.insertText;
11492
+ if (item.insertTextFormat !== void 0) out.insertTextFormat = item.insertTextFormat;
11493
+ if (item.data !== void 0) out.data = item.data;
11494
+ if (item.sortText !== void 0) out.sortText = item.sortText;
11495
+ if (item.filterText !== void 0) out.filterText = item.filterText;
11496
+ if (item.textEdit) out.textEdit = item.textEdit.range ? {
11497
+ range: item.textEdit.range,
11498
+ newText: item.textEdit.newText
11499
+ } : item.textEdit;
11500
+ if (item.additionalTextEdits) out.additionalTextEdits = item.additionalTextEdits;
11501
+ if (item.commitCharacters) out.commitCharacters = item.commitCharacters;
11502
+ return out;
11503
+ }
11504
+ /** 归一化单个签名参数(label 支持字符串与 [start, end] 元组,两者原样透传)。 */
11505
+ function toSignatureParam(raw) {
11506
+ if (!raw || typeof raw !== "object") return null;
11507
+ const obj = raw;
11508
+ const tuple = Array.isArray(obj.label) && obj.label.length >= 2 ? [toInt(obj.label[0]), toInt(obj.label[1])] : null;
11509
+ if (typeof obj.label !== "string" && !tuple) return null;
11510
+ const param = { label: typeof obj.label === "string" ? obj.label : tuple };
11511
+ const documentation = plainText(obj.documentation);
11512
+ if (documentation) param.documentation = documentation;
11513
+ return param;
11514
+ }
11515
+ /** 归一化单个签名。 */
11516
+ function toSignature(raw) {
11517
+ if (!raw || typeof raw !== "object") return null;
11518
+ const obj = raw;
11519
+ if (typeof obj.label !== "string") return null;
11520
+ const signature = {
11521
+ label: obj.label,
11522
+ parameters: []
11523
+ };
11524
+ const documentation = plainText(obj.documentation);
11525
+ if (documentation) signature.documentation = documentation;
11526
+ if (Array.isArray(obj.parameters)) signature.parameters = obj.parameters.map(toSignatureParam).filter((param) => param !== null);
11527
+ if (typeof obj.activeParameter === "number") signature.activeParameter = toInt(obj.activeParameter);
11528
+ return signature;
11529
+ }
11530
+ /**
11531
+ * 归一化签名帮助,并裁剪越界的 activeSignature / activeParameter。
11532
+ *
11533
+ * 为什么必须裁:Monaco 用这两个索引直接取签名与参数,越界时取不到签名就整个浮窗不渲染;
11534
+ * 服务器在「实参刚敲下逗号」等边界时刻常给超前一位的索引。裁剪后至少显示首个签名/末个参数,
11535
+ * 观感是「高亮没跟上」,而不是「参数提示整个不见」。
11536
+ * @author ddj 2026年09月22号
11537
+ * @param result 原始响应
11538
+ * @returns 归一化结果;无有效签名时 null
11539
+ */
11540
+ function toSignatureHelp(result) {
11541
+ if (!result || typeof result !== "object") return null;
11542
+ const obj = result;
11543
+ if (!Array.isArray(obj.signatures)) return null;
11544
+ const signatures = obj.signatures.map(toSignature).filter((signature) => signature !== null);
11545
+ if (!signatures.length) return null;
11546
+ const activeSignature = Math.min(Math.max(0, toInt(obj.activeSignature)), signatures.length - 1);
11547
+ const current = signatures[activeSignature];
11548
+ const rawActive = typeof obj.activeParameter === "number" ? toInt(obj.activeParameter) : current.activeParameter ?? 0;
11549
+ const count = current.parameters.length;
11550
+ return {
11551
+ signatures,
11552
+ activeSignature,
11553
+ activeParameter: count > 0 ? Math.min(Math.max(0, rawActive), count - 1) : 0
11554
+ };
11555
+ }
11556
+ /** hover contents → 文本行(MarkupContent | MarkedString | MarkedString[])。 */ function stringifyHoverContents(contents) {
10338
11557
  if (typeof contents === "string") return [contents];
10339
11558
  if (Array.isArray(contents)) return contents.map(stringifyHoverContents).flat().filter(Boolean);
10340
11559
  if (contents && typeof contents === "object") {
@@ -12243,6 +13462,99 @@ function createLspRpc(deps) {
12243
13462
  };
12244
13463
  }
12245
13464
  },
13465
+ "edrv.lsp.completion": async (args) => {
13466
+ const lang = langOfPath(args.path);
13467
+ if (!lang) return {
13468
+ ok: true,
13469
+ completions: void 0
13470
+ };
13471
+ const sc = await rootOf(args.sessionId);
13472
+ if ("err" in sc) return {
13473
+ ok: false,
13474
+ error: sc.err
13475
+ };
13476
+ try {
13477
+ const server = serverOf(sc.root, lang);
13478
+ if (!server) return {
13479
+ ok: true,
13480
+ completions: void 0
13481
+ };
13482
+ const completions = await server.completion(wsPath(sc.root, args.path), args.position.line, args.position.character, args.context);
13483
+ if (!completions) return {
13484
+ ok: true,
13485
+ completions: void 0
13486
+ };
13487
+ return {
13488
+ ok: true,
13489
+ completions: completions.items.length > 500 ? {
13490
+ ...completions,
13491
+ items: completions.items.slice(0, 500),
13492
+ truncated: true
13493
+ } : completions
13494
+ };
13495
+ } catch (error) {
13496
+ return {
13497
+ ok: false,
13498
+ error: "LSP 补全查询失败:" + String(error)
13499
+ };
13500
+ }
13501
+ },
13502
+ "edrv.lsp.resolveCompletion": async (args) => {
13503
+ const lang = langOfPath(args.path);
13504
+ if (!lang) return {
13505
+ ok: true,
13506
+ item: void 0
13507
+ };
13508
+ const sc = await rootOf(args.sessionId);
13509
+ if ("err" in sc) return {
13510
+ ok: false,
13511
+ error: sc.err
13512
+ };
13513
+ try {
13514
+ const server = serverOf(sc.root, lang);
13515
+ if (!server) return {
13516
+ ok: true,
13517
+ item: args.item
13518
+ };
13519
+ return {
13520
+ ok: true,
13521
+ item: await server.resolveCompletion(wsPath(sc.root, args.path), args.item) ?? void 0
13522
+ };
13523
+ } catch (error) {
13524
+ return {
13525
+ ok: false,
13526
+ error: "LSP 补全解析失败:" + String(error)
13527
+ };
13528
+ }
13529
+ },
13530
+ "edrv.lsp.signatureHelp": async (args) => {
13531
+ const lang = langOfPath(args.path);
13532
+ if (!lang) return {
13533
+ ok: true,
13534
+ signatureHelp: void 0
13535
+ };
13536
+ const sc = await rootOf(args.sessionId);
13537
+ if ("err" in sc) return {
13538
+ ok: false,
13539
+ error: sc.err
13540
+ };
13541
+ try {
13542
+ const server = serverOf(sc.root, lang);
13543
+ if (!server) return {
13544
+ ok: true,
13545
+ signatureHelp: void 0
13546
+ };
13547
+ return {
13548
+ ok: true,
13549
+ signatureHelp: await server.signatureHelp(wsPath(sc.root, args.path), args.position.line, args.position.character) ?? void 0
13550
+ };
13551
+ } catch (error) {
13552
+ return {
13553
+ ok: false,
13554
+ error: "LSP 签名帮助查询失败:" + String(error)
13555
+ };
13556
+ }
13557
+ },
12246
13558
  "edrv.lsp.semanticTokens": async (args) => {
12247
13559
  const lang = langOfPath(args.path);
12248
13560
  if (!lang) return {
@@ -12489,6 +13801,29 @@ async function routeOf(llm, cfg) {
12489
13801
  };
12490
13802
  }
12491
13803
  /**
13804
+ * AI 任务模型路由(非补全场景如 AI 智能整理):任务配置优先,空则回落补全配置链。
13805
+ * 回落序:taskProvider/taskModel 双非空直用 → routeOf(补全配置 → 自动首个);
13806
+ * effort 同序:taskEffort 非空用之,否则 cfg.effort(空串 = 不携带)。
13807
+ * @author ddj 2026年09月23号
13808
+ * @param llm llm 运行时
13809
+ * @param cfg AI 配置(含可选任务模型字段)
13810
+ * @returns 路由(provider/model/effort);不可路由返回 null
13811
+ */
13812
+ async function taskRouteOf(llm, cfg) {
13813
+ const effort = cfg.taskEffort || cfg.effort || "";
13814
+ if (cfg.taskProvider && cfg.taskModel) return {
13815
+ provider: cfg.taskProvider,
13816
+ model: cfg.taskModel,
13817
+ effort
13818
+ };
13819
+ const base = await routeOf(llm, cfg);
13820
+ if (!base) return null;
13821
+ return {
13822
+ ...base,
13823
+ effort
13824
+ };
13825
+ }
13826
+ /**
12492
13827
  * 单次 AI 内联补全。
12493
13828
  * @author ddj
12494
13829
  * @param deps 服务依赖
@@ -12774,6 +14109,126 @@ function createFileVersions(ctx) {
12774
14109
  };
12775
14110
  }
12776
14111
  //#endregion
14112
+ //#region src/ai/svnTriage.ts
14113
+ /** 方案生成最大输出 token(三段 JSON 上限;再大是幻觉温床)。 */
14114
+ const AI_PLAN_MAX_TOKENS = 8192;
14115
+ /** 系统指令:输出唯一 JSON + 三段判据 + 硬约束(不虚构路径/一路径一类)。 */
14116
+ const AI_PLAN_SYSTEM = [
14117
+ "你是 SVN 变更整理助手。根据给出的工作副本变更清单(可能附 diff),产出整理方案,",
14118
+ "输出唯一 JSON 对象,除 JSON 外不得输出任何文本(不要围栏、不要解释):",
14119
+ "{",
14120
+ " \"groups\": [{ \"name\": \"英文短名\", \"paths\": [\"路径1\"], \"reason\": \"分组理由\" }],",
14121
+ " \"reverts\": [{ \"path\": \"路径\", \"reason\": \"还原理由\" }],",
14122
+ " \"ignores\": [{ \"path\": \"路径\", \"reason\": \"忽略理由\" }]",
14123
+ "}",
14124
+ "规则:",
14125
+ "1. 只可使用清单中出现的路径,绝不虚构或改写路径。",
14126
+ "2. 每个路径至多归入一段(groups/reverts/ignores 互斥),拿不准的路径三段都不列。",
14127
+ "3. groups:按功能主题把待提交改动分入 changelist 组;name 用简短英文(不以 - 开头),",
14128
+ " 同一主题合并为一组,组数尽量少。",
14129
+ "4. reverts:仅针对受版本控制的改动建议还原——行尾/空白噪音、误改、试验性改动等。",
14130
+ "5. ignores:仅针对未版本化的条目建议忽略——构建产物、缓存、日志、临时文件等生成物。",
14131
+ "6. reason 用简短中文一句话说明判定依据。"
14132
+ ].join("\n");
14133
+ /**
14134
+ * 构造分析用户段:变更清单行(status | path | changelist)+ 可选 diff 上下文。
14135
+ * @author ddj 2026年09月23号
14136
+ * @param entries 变更清单(调用方已按 AI_PLAN_PATHS_CAP 截断)
14137
+ * @param diffText diff 文本(null = paths-only 降级)
14138
+ * @param truncated 清单是否已截断(prompt 注明,防 AI 误以为是全集)
14139
+ * @returns 用户消息文本
14140
+ */
14141
+ function buildAiPrompt(entries, diffText, truncated = false) {
14142
+ const lines = entries.map((entry) => entry.status + " | " + entry.path + (entry.changelist ? " | " + entry.changelist : ""));
14143
+ let text = (truncated ? "变更清单(已截断,仅分析所列条目;status | path | changelist):" : "变更清单(status | path | changelist):") + "\n" + lines.join("\n");
14144
+ if (diffText) text += "\n\n变更 diff(unified,0 上下文行):\n" + diffText;
14145
+ else text += "\n\n(无 diff 上下文,仅按路径与状态判断)";
14146
+ return text;
14147
+ }
14148
+ /**
14149
+ * 解析 AI 输出 JSON:剥 ``` 围栏 → 取首个 `{` 至末个 `}` → JSON.parse。
14150
+ * 失败抛带原文摘要的错误(调用方整体报错零执行,不静默降级)。
14151
+ * @author ddj 2026年09月23号
14152
+ * @param text 模型输出原文
14153
+ * @returns 解析出的对象(形状由 normalizeAiPlan 容错)
14154
+ */
14155
+ function parseAiPlanJson(text) {
14156
+ const raw = String(text ?? "");
14157
+ const start = raw.indexOf("{");
14158
+ const end = raw.lastIndexOf("}");
14159
+ if (start < 0 || end <= start) throw new Error("AI 输出不含 JSON 对象:" + raw.slice(0, 200));
14160
+ const body = raw.slice(start, end + 1);
14161
+ try {
14162
+ return JSON.parse(body);
14163
+ } catch (error) {
14164
+ throw new Error("AI 输出无法解析为 JSON:" + String(error) + "(原文摘要:" + body.slice(0, 200) + ")");
14165
+ }
14166
+ }
14167
+ /**
14168
+ * 一次 AI 智能整理分析(一次性非会话流式调用)。
14169
+ * llm 缺失/不可路由/流失败/解析失败一律抛错,由 handler 转 `{ok:false, error}`(零执行)。
14170
+ * @author ddj 2026年09月23号
14171
+ * @param ctx DSH 上下文(取 llm)
14172
+ * @param cfg AI 配置(任务模型路由优先,回落补全配置)
14173
+ * @param prompt 用户段(buildAiPrompt 产物)
14174
+ * @returns 原始方案对象与模型标识
14175
+ */
14176
+ async function svnAiPlanOf(ctx, cfg, prompt) {
14177
+ const llm = llmOf(ctx);
14178
+ if (!llm) throw new Error("llm 服务不可用(请检查 DSH 模型配置)");
14179
+ const route = await taskRouteOf(llm, cfg);
14180
+ if (!route) throw new Error("未找到可用模型(请检查 DSH 模型配置)");
14181
+ const controller = new AbortController();
14182
+ let timedOut = false;
14183
+ const timer = setTimeout(() => {
14184
+ timedOut = true;
14185
+ controller.abort(/* @__PURE__ */ new Error("timeout"));
14186
+ }, AI_PLAN_TIMEOUT_MS);
14187
+ try {
14188
+ const stream = llm.stream({
14189
+ provider: route.provider,
14190
+ model: route.model,
14191
+ ...route.effort ? { reasoningEffort: route.effort } : {},
14192
+ messages: [{
14193
+ role: "user",
14194
+ content: [{
14195
+ type: "text",
14196
+ text: prompt
14197
+ }],
14198
+ source: { kind: "user" }
14199
+ }],
14200
+ system: AI_PLAN_SYSTEM,
14201
+ temperature: .2,
14202
+ maxTokens: AI_PLAN_MAX_TOKENS,
14203
+ signal: controller.signal
14204
+ });
14205
+ let text = "";
14206
+ let failNote = "";
14207
+ for await (const chunk of stream) {
14208
+ const c = chunk;
14209
+ if (c.type === "text-delta") text += c.text;
14210
+ else if (c.type === "finish") {
14211
+ const kind = c.reason?.kind;
14212
+ if (kind === "error" || kind === "aborted") {
14213
+ const failure = c.reason.failure;
14214
+ failNote = "模型流失败 " + (failure?.code ?? kind) + ": " + (failure?.message ?? "模型调用失败");
14215
+ }
14216
+ break;
14217
+ }
14218
+ }
14219
+ if (failNote) throw new Error(failNote);
14220
+ return {
14221
+ raw: parseAiPlanJson(text),
14222
+ model: route.provider + "/" + route.model
14223
+ };
14224
+ } catch (error) {
14225
+ if (timedOut) throw new Error("AI 分析超时(" + Math.round(AI_PLAN_TIMEOUT_MS / 1e3) + "s):可在设置页调低任务模型思考强度或换更快模型");
14226
+ throw error;
14227
+ } finally {
14228
+ clearTimeout(timer);
14229
+ }
14230
+ }
14231
+ //#endregion
12777
14232
  //#region src/svnXml.ts
12778
14233
  /**
12779
14234
  * dsh-vscode-mode host — SVN XML 解析基元(零第三方依赖,可单测)。
@@ -13338,6 +14793,22 @@ async function findTortoiseProc(dirSetting, exists = defaultExists, platform = p
13338
14793
  return null;
13339
14794
  }
13340
14795
  /**
14796
+ * svn:ignore 名称合法性校验(host 权威;防换行注入 propset 值与路径穿越)。
14797
+ * 规则:去空白后非空、无换行、无路径分隔符、不以 `-` 开头(防被 svn 当选项解析)。
14798
+ * @author ddj 2026年09月23号
14799
+ * @param name 待写入的忽略名(basename / 通配模式)
14800
+ * @returns 错误文案;合法为 null
14801
+ */
14802
+ function ignoreNameErrorOf(name) {
14803
+ if (typeof name !== "string") return "忽略名必须是字符串";
14804
+ const trimmed = name.trim();
14805
+ if (!trimmed) return "忽略名不能为空";
14806
+ if (/[\r\n]/.test(trimmed)) return "忽略名不能包含换行";
14807
+ if (trimmed.includes("/") || trimmed.includes("\\")) return "忽略名不能包含路径分隔符";
14808
+ if (trimmed.startsWith("-")) return "忽略名不能以 - 开头";
14809
+ return null;
14810
+ }
14811
+ /**
13341
14812
  * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
13342
14813
  *
13343
14814
  * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
@@ -13642,6 +15113,8 @@ function createSvnRpc(deps) {
13642
15113
  const statusCache = /* @__PURE__ */ new Map();
13643
15114
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
13644
15115
  const changesCache = /* @__PURE__ */ new Map();
15116
+ /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
15117
+ const planInbox = /* @__PURE__ */ new Map();
13645
15118
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
13646
15119
  const urlCache = /* @__PURE__ */ new Map();
13647
15120
  let cliProbe = null;
@@ -14071,6 +15544,63 @@ function createSvnRpc(deps) {
14071
15544
  };
14072
15545
  return merged.binary || merged.encodingHint ? merged : {};
14073
15546
  };
15547
+ /**
15548
+ * svn:ignore 写入执行(AI 整理执行段③):逐目录 `propget` 读既有值 → 合并去重 → `propset`。
15549
+ * 不整体覆盖(保留团队既有 ignore);逐目录失败收集不中断,全部失败才 ok:false。
15550
+ * @author ddj 2026年09月23号
15551
+ * @param wcRoot 工作副本根
15552
+ * @param items 目录聚合写入项(dir 已归一,根为 '.')
15553
+ * @returns 新增计数/摘要/原文
15554
+ */
15555
+ const ignoreApplyOf = async (wcRoot, items) => {
15556
+ let count = 0;
15557
+ const notes = [];
15558
+ const failures = [];
15559
+ for (const item of items) {
15560
+ const getOutcome = await runSvn(wcRoot, [
15561
+ "propget",
15562
+ "svn:ignore",
15563
+ "--",
15564
+ item.dir
15565
+ ], READ_GRACE_MS);
15566
+ if (getOutcome.code !== 0) {
15567
+ failures.push(item.dir + " 读取失败:" + tailOfText(getOutcome.stderr || getOutcome.stdout));
15568
+ continue;
15569
+ }
15570
+ const merged = [...getOutcome.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)];
15571
+ let added = 0;
15572
+ for (const name of item.names) {
15573
+ if (merged.includes(name)) continue;
15574
+ merged.push(name);
15575
+ added++;
15576
+ }
15577
+ if (!added) {
15578
+ notes.push(item.dir + ":无新增(已在忽略列表)");
15579
+ continue;
15580
+ }
15581
+ const setOutcome = await runSvn(wcRoot, [
15582
+ "propset",
15583
+ "svn:ignore",
15584
+ merged.join("\n"),
15585
+ "--",
15586
+ item.dir
15587
+ ], MUTATE_GRACE_MS);
15588
+ if (setOutcome.code !== 0) {
15589
+ failures.push(item.dir + " 写入失败:" + tailOfText(setOutcome.stderr || setOutcome.stdout));
15590
+ continue;
15591
+ }
15592
+ count += added;
15593
+ notes.push(item.dir + ":新增 " + added + " 项");
15594
+ }
15595
+ const ok = failures.length < items.length;
15596
+ const summary = failures.length ? "svn:ignore 写入 " + count + " 项;失败 " + failures.length + " 个目录" : count > 0 ? "svn:ignore 写入 " + count + " 项" : "无新增忽略项";
15597
+ return {
15598
+ ok,
15599
+ count,
15600
+ summary,
15601
+ output: [...notes, ...failures].join("\n")
15602
+ };
15603
+ };
14074
15604
  return { handlers: {
14075
15605
  "svn.status": async (args) => {
14076
15606
  const sc = await requireSvnSession(ctx, args.sessionId);
@@ -14186,6 +15716,26 @@ function createSvnRpc(deps) {
14186
15716
  "svn.add": async (args) => {
14187
15717
  return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
14188
15718
  },
15719
+ /**
15720
+ * 分区(changelist)批量操作:关联或解除工作副本条目的 changelist 登记。
15721
+ * 复用 mutate 全套护栏(路径白名单 / BATCH_PATHS_CAP / 超时 / 变更缓存作废);
15722
+ * 分区名 host 权威校验(`-` 开头会被 svn 当成选项解析),remove 分支不需要名字。
15723
+ * @author ddj 2026年09月23号
15724
+ * @param args.paths 工作区相对路径列表
15725
+ * @param args.name 目标分区名(remove 时忽略)
15726
+ * @param args.remove true = 移出分区(`changelist --remove`)
15727
+ * @returns 批量动作结果
15728
+ */
15729
+ "svn.changelist": async (args) => {
15730
+ if (args.remove !== true) {
15731
+ const nameError = svnChangelistNameErrorOf(String(args.name ?? ""));
15732
+ if (nameError) return {
15733
+ ok: false,
15734
+ error: nameError
15735
+ };
15736
+ }
15737
+ return mutate(args.remove === true ? "移出分区" : "分区", args.remove === true ? ["changelist", "--remove"] : ["changelist", String(args.name ?? "").trim()], args.sessionId, args.paths);
15738
+ },
14189
15739
  "svn.log": async (args) => {
14190
15740
  const sc = await requireSvnSession(ctx, args.sessionId);
14191
15741
  if ("err" in sc) return {
@@ -14615,35 +16165,255 @@ function createSvnRpc(deps) {
14615
16165
  ok: false,
14616
16166
  error: "缺少 fs"
14617
16167
  };
14618
- const sizes = [];
14619
- for (const raw of list) {
16168
+ const sizeOne = async (raw) => {
14620
16169
  const rel = safeRel(raw);
14621
- if (rel === null || rel === "") {
14622
- sizes.push({
14623
- path: String(raw ?? ""),
14624
- size: null
14625
- });
14626
- continue;
14627
- }
16170
+ if (rel === null || rel === "") return {
16171
+ path: String(raw ?? ""),
16172
+ size: null
16173
+ };
14628
16174
  try {
14629
16175
  const resolved = await fs.resolve(rel, { cwd: sc.cwd });
14630
16176
  const info = await fs.stat(resolved);
14631
- sizes.push({
16177
+ return {
14632
16178
  path: rel,
14633
16179
  size: info && info.type === "file" ? info.size ?? null : null
14634
- });
16180
+ };
14635
16181
  } catch {
14636
- sizes.push({
16182
+ return {
14637
16183
  path: rel,
14638
16184
  size: null
14639
- });
16185
+ };
14640
16186
  }
14641
- }
16187
+ };
16188
+ const sizes = [];
16189
+ const BATCH = 16;
16190
+ for (let start = 0; start < list.length; start += BATCH) sizes.push(...await Promise.all(list.slice(start, start + BATCH).map(sizeOne)));
14642
16191
  return {
14643
16192
  ok: true,
14644
16193
  sizes
14645
16194
  };
14646
16195
  },
16196
+ /**
16197
+ * AI 智能整理分析(只读):拉最新变更 → 附 diff 上下文(超限降级 paths-only)→
16198
+ * LLM 流式产出 JSON → normalizeAiPlan 以真实清单白名单归一。任何失败零执行只报错。
16199
+ * @author ddj 2026年09月23号
16200
+ * @param args.sessionId 会话 id
16201
+ * @returns 三段方案 + 分析元信息(条目数/是否含 diff/丢弃数/模型)
16202
+ */
16203
+ "svn.aiPlan": async (args) => {
16204
+ const sc = await requireSvnSession(ctx, args.sessionId);
16205
+ if ("err" in sc) return {
16206
+ ok: false,
16207
+ error: sc.err
16208
+ };
16209
+ const status = await statusOf(sc.cwd);
16210
+ if (!status.managed || !status.wcRoot) return {
16211
+ ok: false,
16212
+ error: "当前工作区不受 SVN 管理"
16213
+ };
16214
+ if (!status.svnCli) return {
16215
+ ok: false,
16216
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16217
+ };
16218
+ try {
16219
+ const { entries } = await changesOf(status.wcRoot, true);
16220
+ let diffText = null;
16221
+ try {
16222
+ const outcome = await runSvn(status.wcRoot, [
16223
+ "diff",
16224
+ "-x",
16225
+ "-U0"
16226
+ ], READ_GRACE_MS, AI_PLAN_DIFF_CAP);
16227
+ if (outcome.code === 0 && outcome.stdout.trim() && outcome.stdout.length < 2097152) diffText = outcome.stdout;
16228
+ } catch {}
16229
+ const capped = entries.length > AI_PLAN_PATHS_CAP;
16230
+ const used = capped ? entries.slice(0, AI_PLAN_PATHS_CAP) : entries;
16231
+ const diffIncluded = diffText !== null && !capped;
16232
+ const prompt = buildAiPrompt(used, diffIncluded ? diffText : null, capped);
16233
+ const cfg = settings.ai ? settings.ai() : {
16234
+ enabled: true,
16235
+ provider: "",
16236
+ model: "",
16237
+ effort: ""
16238
+ };
16239
+ const result = await svnAiPlanOf(ctx, cfg, prompt);
16240
+ const { plan, dropped } = normalizeAiPlan(result.raw, entries);
16241
+ return {
16242
+ ok: true,
16243
+ plan,
16244
+ entriesCount: entries.length,
16245
+ diffIncluded,
16246
+ dropped,
16247
+ model: result.model
16248
+ };
16249
+ } catch (error) {
16250
+ return {
16251
+ ok: false,
16252
+ error: String(error instanceof Error ? error.message : error)
16253
+ };
16254
+ }
16255
+ },
16256
+ /**
16257
+ * AI 智能整理执行段③:按目录合并写入 svn:ignore 属性(propget 合并,不整体覆盖)。
16258
+ * 复用受管理/svnCli 校验与变更缓存作废语义(对齐 mutate)。
16259
+ * @author ddj 2026年09月23号
16260
+ * @param args.items 目录聚合写入项(dir + 该目录下新增忽略名)
16261
+ * @returns 新增计数/摘要/原文
16262
+ */
16263
+ "svn.ignore": async (args) => {
16264
+ const sc = await requireSvnSession(ctx, args.sessionId);
16265
+ if ("err" in sc) return {
16266
+ ok: false,
16267
+ error: sc.err
16268
+ };
16269
+ const items = Array.isArray(args.items) ? args.items : [];
16270
+ if (!items.length) return {
16271
+ ok: false,
16272
+ error: "未选择任何路径"
16273
+ };
16274
+ const clean = [];
16275
+ let total = 0;
16276
+ for (const item of items) {
16277
+ const rel = safeRel(item?.dir);
16278
+ if (rel === null) return {
16279
+ ok: false,
16280
+ error: "路径不合法"
16281
+ };
16282
+ const names = Array.isArray(item?.names) ? item.names : [];
16283
+ if (!names.length) return {
16284
+ ok: false,
16285
+ error: "忽略名列表为空"
16286
+ };
16287
+ const checked = [];
16288
+ for (const name of names) {
16289
+ const nameError = ignoreNameErrorOf(name);
16290
+ if (nameError) return {
16291
+ ok: false,
16292
+ error: nameError
16293
+ };
16294
+ checked.push(String(name).trim());
16295
+ }
16296
+ total += checked.length;
16297
+ if (total > 64) return {
16298
+ ok: false,
16299
+ error: "一次最多处理 64 个路径"
16300
+ };
16301
+ clean.push({
16302
+ dir: rel === "" ? "." : rel,
16303
+ names: checked
16304
+ });
16305
+ }
16306
+ const status = await statusOf(sc.cwd);
16307
+ if (!status.managed || !status.wcRoot) return {
16308
+ ok: false,
16309
+ error: "当前工作区不受 SVN 管理"
16310
+ };
16311
+ if (!status.svnCli) return {
16312
+ ok: false,
16313
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16314
+ };
16315
+ try {
16316
+ const result = await ignoreApplyOf(status.wcRoot, clean);
16317
+ changesCache.delete(status.wcRoot);
16318
+ if (!result.ok) return {
16319
+ ok: false,
16320
+ error: result.summary + (result.output ? "\n" + tailOfText(result.output) : "")
16321
+ };
16322
+ return {
16323
+ ok: true,
16324
+ count: result.count,
16325
+ summary: result.summary,
16326
+ output: result.output
16327
+ };
16328
+ } catch (error) {
16329
+ return {
16330
+ ok: false,
16331
+ error: "svn:ignore 写入失败:" + String(error)
16332
+ };
16333
+ }
16334
+ },
16335
+ /**
16336
+ * 混合通道投递口(01-hybrid-deep-analysis):会话 agent 深度分析后 POST 方案入收件箱。
16337
+ * 只读 + 内存态:经 normalizeAiPlan 以最新变更清单白名单归一(幻觉路径丢弃计数),
16338
+ * 零接受拒收(防 agent 空转误报成功);覆盖式入箱(新投递顶掉旧件)。
16339
+ * @author ddj 2026年09月23号
16340
+ * @param args.plan agent 产出的方案 JSON(形状宽松,归一兜底)
16341
+ * @returns 接受路径数与丢弃数
16342
+ */
16343
+ "svn.aiPlanSubmit": async (args) => {
16344
+ const sc = await requireSvnSession(ctx, args.sessionId);
16345
+ if ("err" in sc) return {
16346
+ ok: false,
16347
+ error: sc.err
16348
+ };
16349
+ const status = await statusOf(sc.cwd);
16350
+ if (!status.managed || !status.wcRoot) return {
16351
+ ok: false,
16352
+ error: "当前工作区不受 SVN 管理"
16353
+ };
16354
+ if (!status.svnCli) return {
16355
+ ok: false,
16356
+ error: "svn 命令不可用(可在设置页配置 svn 路径)"
16357
+ };
16358
+ try {
16359
+ const { entries } = await changesOf(status.wcRoot, true);
16360
+ const { plan, dropped } = normalizeAiPlan(args.plan, entries);
16361
+ const accepted = plan.reverts.length + plan.ignores.length + plan.groups.reduce((n, group) => n + group.paths.length, 0);
16362
+ if (!accepted) return {
16363
+ ok: false,
16364
+ error: "方案为空:无任何路径通过白名单校验(丢弃 " + dropped + " 项)"
16365
+ };
16366
+ planInbox.set(status.wcRoot, {
16367
+ plan,
16368
+ dropped,
16369
+ at: now()
16370
+ });
16371
+ return {
16372
+ ok: true,
16373
+ accepted,
16374
+ dropped
16375
+ };
16376
+ } catch (error) {
16377
+ return {
16378
+ ok: false,
16379
+ error: "方案投递失败:" + String(error)
16380
+ };
16381
+ }
16382
+ },
16383
+ /**
16384
+ * 混合通道取件口:面板轮询取收件箱方案。
16385
+ * since = 注入时刻——早于它的旧投递不算新件(防上一轮残留被误认为本轮结果);
16386
+ * TTL 过期返回 plan:null。非破坏性读取(重复取到同一份由 client 侧取件即停消化)。
16387
+ * @author ddj 2026年09月23号
16388
+ * @param args.since 注入时刻时间戳(缺省 0 = 取任意件)
16389
+ * @returns 方案与元信息(无新件 plan 为 null)
16390
+ */
16391
+ "svn.aiPlanPending": async (args) => {
16392
+ const sc = await requireSvnSession(ctx, args.sessionId);
16393
+ if ("err" in sc) return {
16394
+ ok: false,
16395
+ error: sc.err
16396
+ };
16397
+ const status = await statusOf(sc.cwd);
16398
+ if (!status.managed || !status.wcRoot) return {
16399
+ ok: false,
16400
+ error: "当前工作区不受 SVN 管理"
16401
+ };
16402
+ const hit = planInbox.get(status.wcRoot);
16403
+ const since = Number(args.since) || 0;
16404
+ if (!hit || hit.at < since || now() - hit.at > 18e5) return {
16405
+ ok: true,
16406
+ plan: null,
16407
+ dropped: 0,
16408
+ at: 0
16409
+ };
16410
+ return {
16411
+ ok: true,
16412
+ plan: hit.plan,
16413
+ dropped: hit.dropped,
16414
+ at: hit.at
16415
+ };
16416
+ },
14647
16417
  "svn.wcRev": async (args) => {
14648
16418
  const sc = await requireSvnSession(ctx, args.sessionId);
14649
16419
  if ("err" in sc) return {
@@ -16428,6 +18198,20 @@ const inject = [
16428
18198
  "agents"
16429
18199
  ];
16430
18200
  /**
18201
+ * 插件 Config schema(cordis 从 module 导出读 `plugin.Config` 做启动校验;
18202
+ * DSH 0.1.7 起设置页按此 schema 自动生成表单,设置值 = profile 插件配置)。
18203
+ * 字段集与 installOpenSettingsSection 的 section schema 同源(buildSettingsSchema),
18204
+ * 全字段带默认值——undefined/空配置经 schemastery 校验自动填充(rc/alpha 两代
18205
+ * cordis 均读该导出,实测 4.0.0-rc.8 与 0.1.7 行为一致,校验必过)。
18206
+ * volatile: true —— 0.1.7 的 SettingsForms.describe 只下发含 volatile 字段的 entry
18207
+ * (volatileForm 门槛),漏标则 ns 不出现 → client configForms 恒 unavailable →
18208
+ * 设置页「设置服务暂不可用」;同时 volatile 字段由 Loader 就地提交免重载。
18209
+ * 运行时解析:@deepseek-ai/schemastery 经插件自身 dependencies(link/dev/npm 安装
18210
+ * 三形态一致,钉 ~3.18.4 —— 3.18.1 起才有 .volatile(),低版本经 markVolatile 守卫降级)。
18211
+ * @author ddj 2026年09月22号(2026年09月23号 增补 volatile 标记)
18212
+ */
18213
+ const Config = buildSettingsSchema(z, { volatile: true });
18214
+ /**
16431
18215
  * 装配插件:挂事件监听、注册路由、安装兼容层。
16432
18216
  * @author ddj 2026年08月20号
16433
18217
  * @param ctx DSH 上下文(sessions/fs/webServer 由 inject 提供;sandboxPolicy/subprocess 惰性获取)
@@ -16517,6 +18301,6 @@ async function logCompatSummary(ctx, warnings) {
16517
18301
  }
16518
18302
  }
16519
18303
  //#endregion
16520
- export { apply, inject, name };
18304
+ export { Config, apply, inject, name };
16521
18305
 
16522
18306
  //# sourceMappingURL=index.js.map