dsh-vscode-mode 0.8.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.
package/lib/index.js CHANGED
@@ -191,6 +191,17 @@ function tortoiseLaunchArgv(exe, action, absPath) {
191
191
  "/path:" + absPath
192
192
  ];
193
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
+ }
194
205
  /** W2-6 内容护栏嗅探窗(字符数;NUL/U+FFFD 只看前 8K,全量扫描大文本得不偿失)。 */
195
206
  const GUARD_SNIFF_CHARS = 8192;
196
207
  /**
@@ -208,6 +219,148 @@ function contentGuardOf(text) {
208
219
  if (window.includes("�")) return { encodingHint: true };
209
220
  return {};
210
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
+ }
211
364
  //#endregion
212
365
  //#region src/shared/nativeOpen.ts
213
366
  /**
@@ -424,16 +577,19 @@ function bindHostLog(ctx) {
424
577
  * 插件 Config schema,见 runSettingsInstall 四策略:legacy/service/forms/none)。
425
578
  * 缺失/任一策略失败时插件仍可加载(fileOpenTool 降级为配置值,compat 报告可见),
426
579
  * 全程 try/catch,不产生未捕获 rejection。
427
- * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号 / 2026年09月22号
580
+ * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号 / 2026年09月22号 / 2026年09月23号
428
581
  */
429
582
  const FILE_OPEN_SETTINGS_NS = "dsh-vscode-mode";
430
583
  const FILE_OPEN_DEFAULT = "auto";
431
- /** AI 内联补全配置默认值(默认关闭;路由空 = 自动;档位空 = 跟随模型默认)。 */
584
+ /** AI 配置默认值(补全默认关;路由空 = 自动;档位空 = 跟随模型默认;任务模型空 = 跟随补全配置)。 */
432
585
  const AI_CONFIG_DEFAULT = {
433
586
  enabled: false,
434
587
  provider: "",
435
588
  model: "",
436
- effort: ""
589
+ effort: "",
590
+ taskProvider: "",
591
+ taskModel: "",
592
+ taskEffort: ""
437
593
  };
438
594
  let depsPromise;
439
595
  /**
@@ -745,46 +901,132 @@ async function updateSection(provider, ns, patch, expectedRevision) {
745
901
  * 字段集 = fileOpenTool/keybindings/sidebarMinWidth/maxOpenEditors/integrationBaseUrl/
746
902
  * aiInline/aiProvider/aiModel/aiEffort/svnPath/tortoisePath/nativeOpenExts,全部带默认值
747
903
  * (Config 启动校验在 undefined/空配置下自动填充,rc/alpha 两代 cordis 均通过)。
748
- * @author ddj 2026年09月22号
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 与观测)
749
912
  * @param z schemastery 命名空间(静态 import 或动态加载均可)
913
+ * @param options volatile:是否按 0.1.7 Config 语义标记字段
750
914
  * @returns schemastery object schema
751
915
  */
752
- function buildSettingsSchema(z) {
753
- return z.object({
754
- fileOpenTool: z.string().default(FILE_OPEN_DEFAULT),
755
- keybindings: z.object(keybindingsShape(z)).default({ ...KEYBINDING_DEFAULTS }),
756
- sidebarMinWidth: z.number().default(300),
757
- maxOpenEditors: z.number().default(10),
758
- integrationBaseUrl: z.string().default(INTEGRATION_BASE_DEFAULT),
759
- aiInline: z.boolean().default(AI_CONFIG_DEFAULT.enabled),
760
- aiProvider: z.string().default(AI_CONFIG_DEFAULT.provider),
761
- aiModel: z.string().default(AI_CONFIG_DEFAULT.model),
762
- aiEffort: z.string().default(AI_CONFIG_DEFAULT.effort),
763
- svnPath: z.string().default(""),
764
- tortoisePath: z.string().default(TORTOISE_DIR_DEFAULT),
765
- nativeOpenExts: z.string().default(DEFAULT_NATIVE_CSV)
766
- });
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]);
767
1009
  }
768
1010
  function normalizeValue(value) {
769
1011
  if (typeof value !== "string" || value.trim() === "") return FILE_OPEN_DEFAULT;
770
1012
  return value.trim();
771
1013
  }
772
1014
  function configValue(config) {
773
- return normalizeValue(config?.fileOpenTool);
1015
+ return normalizeValue(configField(config, "fileOpenTool"));
774
1016
  }
775
1017
  /** 配置/设置里的深链基址(缺省/非法回退默认值)。 */
776
1018
  function baseValueOf(config) {
777
- const raw = config?.integrationBaseUrl;
1019
+ const raw = configField(config, "integrationBaseUrl");
778
1020
  return typeof raw === "string" && raw.trim() ? raw.trim() : INTEGRATION_BASE_DEFAULT;
779
1021
  }
780
1022
  /** 配置/设置里的 svn CLI 覆盖(空 = 从 PATH 解析 'svn')。 */
781
1023
  function svnPathValueOf(config) {
782
- const raw = config?.svnPath;
1024
+ const raw = configField(config, "svnPath");
783
1025
  return typeof raw === "string" ? raw.trim() : "";
784
1026
  }
785
1027
  /** 配置/设置里的 TortoiseSVN 目录(空 = 默认安装目录)。 */
786
1028
  function tortoiseDirValueOf(config) {
787
- const raw = config?.tortoisePath;
1029
+ const raw = configField(config, "tortoisePath");
788
1030
  return typeof raw === "string" && raw.trim() ? raw.trim() : TORTOISE_DIR_DEFAULT;
789
1031
  }
790
1032
  /**
@@ -816,7 +1058,10 @@ function aiValueOf(stored) {
816
1058
  enabled: raw.aiInline === true,
817
1059
  provider: typeof raw.aiProvider === "string" ? raw.aiProvider : AI_CONFIG_DEFAULT.provider,
818
1060
  model: typeof raw.aiModel === "string" ? raw.aiModel : AI_CONFIG_DEFAULT.model,
819
- 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
820
1065
  };
821
1066
  }
822
1067
  /**
@@ -876,8 +1121,11 @@ function setupOpenSettings(ctx, config, onChange) {
876
1121
  });
877
1122
  ctx.inject?.(["settings"], (settingsCtx) => {
878
1123
  provider = settingsCtx.get("settings");
879
- aiSync();
880
- 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);
881
1129
  syncRevision();
882
1130
  });
883
1131
  return {
@@ -916,6 +1164,18 @@ function setupOpenSettings(ctx, config, onChange) {
916
1164
  stored.effort = patch.effort;
917
1165
  body.aiEffort = patch.effort;
918
1166
  }
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
+ }
919
1179
  if (!(await updateSection(provider, "dsh-vscode-mode", body, expectedRevision)).ok) {
920
1180
  aiCurrent = stored;
921
1181
  return aiCurrent;
@@ -2082,8 +2342,10 @@ function detectGuards(ctx) {
2082
2342
  }];
2083
2343
  }
2084
2344
  /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
2085
- const TESTED_DSH_MAX = "0.1.7-alpha.1";
2086
- /** 版本适配机制状态行: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 标记。 */
2087
2349
  function versionAdapters(dshVersion) {
2088
2350
  const adapters = [];
2089
2351
  const parsed = parseDshVersion(dshVersion);
@@ -2108,11 +2370,20 @@ function versionAdapters(dshVersion) {
2108
2370
  active: strategy === "legacy" || strategy === "service" || strategy === "forms",
2109
2371
  note: settingsInstallNote()
2110
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
+ }
2111
2382
  return adapters;
2112
2383
  }
2113
2384
  /**
2114
2385
  * 构建完整兼容性报告(RPC 与启动日志共用)。
2115
- * @author ddj 2026年08月24号 / 2026年09月02号
2386
+ * @author ddj 2026年08月24号 / 2026年09月02号 / 2026年09月23号
2116
2387
  * @param ctx DSH host 上下文
2117
2388
  * @param options 测试注入:depsAvailable 跳过动态导入、version 固定插件版本、dshVersion 固定 DSH 版本
2118
2389
  * @returns 兼容性报告
@@ -2128,7 +2399,10 @@ async function buildReport(ctx, options) {
2128
2399
  if (settingsInstallStrategy() === "none") warnings.push("设置 section 安装不可用:" + settingsInstallNote());
2129
2400
  const parsed = parseDshVersion(dshVersion);
2130
2401
  const testedMax = parseDshVersion(TESTED_DSH_MAX);
2131
- if (parsed && testedMax && compareDshVersions(parsed, testedMax) > 0) warnings.push("DSH " + dshVersion + " 高于已实测版本(0.1.7-alpha.1):设置 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)");
2132
2406
  for (const guard of guards) if (!guard.active && guard.note) warnings.push(guard.note);
2133
2407
  return {
2134
2408
  pluginVersion: options?.version ?? pluginVersionOf(),
@@ -2530,7 +2804,7 @@ function normalizeRecord(raw) {
2530
2804
  ...Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd } : {}
2531
2805
  };
2532
2806
  }) : [];
2533
- const toolName = r.toolName === "write" ? "write" : "edit";
2807
+ const toolName = r.toolName === "write" || r.toolName === "remote_ssh_write" ? r.toolName : "edit";
2534
2808
  const rawDecisions = r.decisions && typeof r.decisions === "object" ? r.decisions : {};
2535
2809
  const call = rawDecisions.call === "accepted" || rawDecisions.call === "rejected" ? rawDecisions.call : "pending";
2536
2810
  const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : [];
@@ -2989,6 +3263,149 @@ function bucketOf(registry, ctx, cwd) {
2989
3263
  });
2990
3264
  }
2991
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
2992
3409
  //#region src/tree.ts
2993
3410
  /** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
2994
3411
  function entryOrder(a, b) {
@@ -3443,6 +3860,16 @@ async function listDirCached(ctx, cwd, rel, force) {
3443
3860
  }
3444
3861
  //#endregion
3445
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
+ */
3446
3873
  /** 校验并提取工具 result meta 中的文件 hunk。 */
3447
3874
  function validHunks(raw) {
3448
3875
  if (!Array.isArray(raw)) return [];
@@ -3480,8 +3907,190 @@ function fallbackHunks(toolName, args, before, after, create) {
3480
3907
  return [];
3481
3908
  }
3482
3909
  /**
3483
- * 捕获一次工具执行结果(tools/result):edit/write 成功时构造记录、
3484
- * 递增文件批次、融合归档被后续修改取代的旧差异、落盘。
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)。
3485
4094
  * @author ddj 2026年08月20号
3486
4095
  * @param ctx DSH 上下文
3487
4096
  * @param registry 工作区记录桶注册表
@@ -3490,58 +4099,17 @@ function fallbackHunks(toolName, args, before, after, create) {
3490
4099
  */
3491
4100
  async function captureToolResult(ctx, registry, exec, result) {
3492
4101
  const session = exec?.agent?.session;
3493
- if (!session || exec?.name !== "edit" && exec?.name !== "write") return;
3494
- 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;
3495
4105
  try {
3496
4106
  const cwd = cwdOf(session);
3497
4107
  if (!cwd) return;
3498
- const value = result.value;
3499
4108
  const args = exec.arguments || {};
3500
- const path = typeof value.path === "string" ? value.path : args.file_path;
3501
- if (typeof path !== "string" || !path) return;
3502
- const before = typeof value.before === "string" ? value.before : null;
3503
- const after = typeof value.after === "string" ? value.after : typeof args.content === "string" ? args.content : null;
3504
- const create = exec.name === "write" && value.before === null;
3505
- const callHunk = exec.name === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string" ? {
3506
- oldText: args.old_string,
3507
- newText: args.new_string
3508
- } : null;
3509
- const metaHunks = validHunks(result.meta?.diffs);
3510
- const hunks = annotateHunks(metaHunks.length ? metaHunks : fallbackHunks(exec.name, args, before, after, create), before, after);
3511
- if (!hunks.length || typeof exec.callId !== "string") return;
3512
- const record = {
3513
- callId: exec.callId,
3514
- toolName: exec.name,
3515
- path,
3516
- before,
3517
- after,
3518
- baseFingerprint: fingerprint(before),
3519
- afterFingerprint: fingerprint(after),
3520
- legacy: false,
3521
- conflict: false,
3522
- create,
3523
- callHunk,
3524
- hunks,
3525
- decisions: {
3526
- call: "pending",
3527
- perHunk: hunks.map(() => "pending")
3528
- },
3529
- note: create ? "新建文件:全部拒绝将删除该文件" : before === null ? "未捕获修改前内容(大文件)" : null,
3530
- superseded: false,
3531
- archived: false,
3532
- batch: 0,
3533
- at: (/* @__PURE__ */ new Date()).toISOString()
3534
- };
3535
- let bucket = registry.get(cwd);
3536
- if (!bucket) {
3537
- bucket = await loadBucket(ctx, cwd);
3538
- registry.set(cwd, bucket);
3539
- }
3540
- record.batch = fileMaxBatch(bucket, path) + 1;
3541
- bucket.set(exec.callId, record);
3542
- prune(bucket);
3543
- await saveBucket(ctx, cwd, bucket, session);
3544
- 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);
3545
4113
  } catch (error) {
3546
4114
  log.error("capture failed: " + String(error));
3547
4115
  }
@@ -7772,8 +8340,8 @@ function patchInsertPerfConfig(text) {
7772
8340
  const staleCheckedAt = /* @__PURE__ */ new Map();
7773
8341
  /** stale 自动清理最小间隔。 */
7774
8342
  const STALE_CHECK_MIN_MS = 1e4;
7775
- /** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
7776
- function recView(record) {
8343
+ /** 记录 → 客户端视图(不含 before 全文,仅长度);远程工作区附远端展示路径(issue #7)。 */
8344
+ function recView(record, ws = null) {
7777
8345
  return {
7778
8346
  callId: record.callId,
7779
8347
  toolName: record.toolName,
@@ -7790,7 +8358,8 @@ function recView(record) {
7790
8358
  afterFingerprint: record.afterFingerprint ?? null,
7791
8359
  conflict: record.conflict === true,
7792
8360
  legacy: record.legacy === true,
7793
- at: record.at
8361
+ at: record.at,
8362
+ ...ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? void 0 } : {}
7794
8363
  };
7795
8364
  }
7796
8365
  /** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
@@ -7920,6 +8489,7 @@ async function afterManualSave(ctx, registry, sc, path) {
7920
8489
  async function applyDecisions(ctx, session, cwd, bucket, items) {
7921
8490
  const results = [];
7922
8491
  const resolved = [];
8492
+ const ws = findRemoteWs(cwd);
7923
8493
  let changed = false;
7924
8494
  for (const item of items) {
7925
8495
  const record = bucket.get(item.callId);
@@ -7954,7 +8524,7 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
7954
8524
  const result = {
7955
8525
  callId: item.callId,
7956
8526
  ok: true,
7957
- record: recView(record)
8527
+ record: recView(record, ws)
7958
8528
  };
7959
8529
  if (revertedStale) result.stale = true;
7960
8530
  results.push(result);
@@ -8109,10 +8679,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8109
8679
  }
8110
8680
  }
8111
8681
  const out = [];
8682
+ const ws = findRemoteWs(sc.cwd);
8112
8683
  for (const rec of bucket.values()) {
8113
8684
  if (!want && rec.archived) continue;
8114
8685
  if (want && !want.has(rec.callId)) continue;
8115
- out.push(recView(rec));
8686
+ out.push(recView(rec, ws));
8116
8687
  }
8117
8688
  out.sort((a, b) => a.at < b.at ? -1 : 1);
8118
8689
  return {
@@ -8418,7 +8989,9 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8418
8989
  ok: false,
8419
8990
  error: sc.err
8420
8991
  };
8421
- 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) => {
8422
8995
  const recs = Array.isArray(b.records) ? b.records : [];
8423
8996
  const sum = recs.reduce((s, r) => {
8424
8997
  const sm = r.summary || {
@@ -8442,6 +9015,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
8442
9015
  at: b.at,
8443
9016
  lastAt: b.lastAt || b.at,
8444
9017
  path: b.path,
9018
+ ...ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? void 0 } : {},
8445
9019
  batch: b.batch ?? null,
8446
9020
  reason: b.reason ?? null,
8447
9021
  nRecords: recs.length,
@@ -13227,6 +13801,29 @@ async function routeOf(llm, cfg) {
13227
13801
  };
13228
13802
  }
13229
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
+ /**
13230
13827
  * 单次 AI 内联补全。
13231
13828
  * @author ddj
13232
13829
  * @param deps 服务依赖
@@ -13512,6 +14109,126 @@ function createFileVersions(ctx) {
13512
14109
  };
13513
14110
  }
13514
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
13515
14232
  //#region src/svnXml.ts
13516
14233
  /**
13517
14234
  * dsh-vscode-mode host — SVN XML 解析基元(零第三方依赖,可单测)。
@@ -14076,6 +14793,22 @@ async function findTortoiseProc(dirSetting, exists = defaultExists, platform = p
14076
14793
  return null;
14077
14794
  }
14078
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
+ /**
14079
14812
  * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
14080
14813
  *
14081
14814
  * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
@@ -14380,6 +15113,8 @@ function createSvnRpc(deps) {
14380
15113
  const statusCache = /* @__PURE__ */ new Map();
14381
15114
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
14382
15115
  const changesCache = /* @__PURE__ */ new Map();
15116
+ /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
15117
+ const planInbox = /* @__PURE__ */ new Map();
14383
15118
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
14384
15119
  const urlCache = /* @__PURE__ */ new Map();
14385
15120
  let cliProbe = null;
@@ -14809,6 +15544,63 @@ function createSvnRpc(deps) {
14809
15544
  };
14810
15545
  return merged.binary || merged.encodingHint ? merged : {};
14811
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
+ };
14812
15604
  return { handlers: {
14813
15605
  "svn.status": async (args) => {
14814
15606
  const sc = await requireSvnSession(ctx, args.sessionId);
@@ -14924,6 +15716,26 @@ function createSvnRpc(deps) {
14924
15716
  "svn.add": async (args) => {
14925
15717
  return mutate("加入版本控制", ["add"], args.sessionId, args.paths);
14926
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
+ },
14927
15739
  "svn.log": async (args) => {
14928
15740
  const sc = await requireSvnSession(ctx, args.sessionId);
14929
15741
  if ("err" in sc) return {
@@ -15353,35 +16165,255 @@ function createSvnRpc(deps) {
15353
16165
  ok: false,
15354
16166
  error: "缺少 fs"
15355
16167
  };
15356
- const sizes = [];
15357
- for (const raw of list) {
16168
+ const sizeOne = async (raw) => {
15358
16169
  const rel = safeRel(raw);
15359
- if (rel === null || rel === "") {
15360
- sizes.push({
15361
- path: String(raw ?? ""),
15362
- size: null
15363
- });
15364
- continue;
15365
- }
16170
+ if (rel === null || rel === "") return {
16171
+ path: String(raw ?? ""),
16172
+ size: null
16173
+ };
15366
16174
  try {
15367
16175
  const resolved = await fs.resolve(rel, { cwd: sc.cwd });
15368
16176
  const info = await fs.stat(resolved);
15369
- sizes.push({
16177
+ return {
15370
16178
  path: rel,
15371
16179
  size: info && info.type === "file" ? info.size ?? null : null
15372
- });
16180
+ };
15373
16181
  } catch {
15374
- sizes.push({
16182
+ return {
15375
16183
  path: rel,
15376
16184
  size: null
15377
- });
16185
+ };
15378
16186
  }
15379
- }
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)));
15380
16191
  return {
15381
16192
  ok: true,
15382
16193
  sizes
15383
16194
  };
15384
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
+ },
15385
16417
  "svn.wcRev": async (args) => {
15386
16418
  const sc = await requireSvnSession(ctx, args.sessionId);
15387
16419
  if ("err" in sc) return {
@@ -17171,11 +18203,14 @@ const inject = [
17171
18203
  * 字段集与 installOpenSettingsSection 的 section schema 同源(buildSettingsSchema),
17172
18204
  * 全字段带默认值——undefined/空配置经 schemastery 校验自动填充(rc/alpha 两代
17173
18205
  * cordis 均读该导出,实测 4.0.0-rc.8 与 0.1.7 行为一致,校验必过)。
17174
- * 运行时解析:@deepseek-ai/schemastery 经插件自身 node_modules(link/dev/npm 安装
17175
- * 三形态均在 peer/dev 依赖面内,先于宿主树命中)。
17176
- * @author ddj 2026年09月22号
17177
- */
17178
- const Config = buildSettingsSchema(z);
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 });
17179
18214
  /**
17180
18215
  * 装配插件:挂事件监听、注册路由、安装兼容层。
17181
18216
  * @author ddj 2026年08月20号