dsh-vscode-mode 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
- import { readFile, stat } from "node:fs/promises";
3
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
4
- import { fileURLToPath } from "node:url";
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
5
4
  import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
6
  import { homedir } from "node:os";
7
7
  import { createHash } from "node:crypto";
8
8
  //#region src/mcp.ts
@@ -368,6 +368,258 @@ async function runPnpmInstall(ctx, profileDir) {
368
368
  }
369
369
  }
370
370
  //#endregion
371
+ //#region src/shared/rpc.ts
372
+ /** webServer 精确路由。 */
373
+ const RPC_PATH = "/edrv/rpc";
374
+ //#endregion
375
+ //#region src/paths.ts
376
+ /**
377
+ * dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
378
+ * 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
379
+ * - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
380
+ * user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
381
+ * - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
382
+ * (DSH_HOME → ~/.dsh)
383
+ * - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
384
+ * 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
385
+ * 作者 ddj 2026-09-01
386
+ */
387
+ /** 插件 id(也是 .dsh 下插件工作区目录名)。 */
388
+ const PLUGIN_ID = "dsh-vscode-mode";
389
+ /** DSH 工作区目录名(默认 home 下)。 */
390
+ const DSH_DIR_NAME = ".dsh";
391
+ /** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
392
+ const DSH_HOME_ENV = "DSH_HOME";
393
+ /** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
394
+ const CACHE_TOTAL_CAP = 209715200;
395
+ /** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
396
+ const SIDECAR = ".dsh-edit-review.json";
397
+ /** 工作区根侧车:归档(用户数据)。 */
398
+ const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
399
+ /** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
400
+ const DEBUG_LOG = ".dsh-edit-review-debug.log";
401
+ /** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
402
+ const LEGACY_TREE_SIDECARS = [".dsh-edit-review-tree.json", ".dsh-vscode-mode-tree.json"];
403
+ /** 路由前缀(兼容性检查用)。 */
404
+ const ROUTE_PREFIX = "/edrv";
405
+ /** Monaco 静态资源前缀。 */
406
+ const VENDOR_PREFIX = "/edrv/vendor";
407
+ /** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
408
+ function hashOf(text) {
409
+ return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
410
+ }
411
+ /**
412
+ * 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
413
+ * 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
414
+ * 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
415
+ * @author ddj 2026年09月02号
416
+ * @param raw 原始路径段(非空)
417
+ * @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
418
+ */
419
+ function encodeSegment(raw) {
420
+ if (raw.length === 0) throw new Error("cannot encode an empty path segment");
421
+ if (raw === ".") return "~002E";
422
+ if (raw === "..") return "~002E~002E";
423
+ let out = "";
424
+ for (let i = 0; i < raw.length; i++) {
425
+ const code = raw.charCodeAt(i);
426
+ const ch = String.fromCharCode(code);
427
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
428
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
429
+ }
430
+ return out;
431
+ }
432
+ /**
433
+ * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
434
+ * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
435
+ * @author ddj 2026年09月02号
436
+ * @param cwd 会话工作区绝对路径(非空)
437
+ * @returns `~/.dsh/sessions` 下的工作区目录名
438
+ */
439
+ function sessionWorkspaceKey(cwd) {
440
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
441
+ let readable = "";
442
+ let separatorRun = false;
443
+ for (let i = 0; i < cwd.length; i++) {
444
+ const code = cwd.charCodeAt(i);
445
+ const ch = String.fromCharCode(code);
446
+ if (ch === "/" || ch === "\\" || ch === ":") {
447
+ if (!separatorRun) readable += "-";
448
+ separatorRun = true;
449
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
450
+ readable += ch;
451
+ separatorRun = false;
452
+ } else {
453
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
454
+ separatorRun = false;
455
+ }
456
+ }
457
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
458
+ }
459
+ /** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
460
+ function sessionIdSegment(id) {
461
+ return encodeSegment(id);
462
+ }
463
+ /** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
464
+ function sessionsRoot(home = dshHome()) {
465
+ return join(home, "sessions");
466
+ }
467
+ /**
468
+ * 解析 DSH home:DSH_HOME(去空白)→ ~/.dsh。
469
+ * @author ddj 2026年09月01号
470
+ * @param env 环境映射(缺省 process.env;测试可注入)
471
+ * @returns 归一化的 DSH home 绝对路径
472
+ */
473
+ function dshHome(env = process.env) {
474
+ const value = env[DSH_HOME_ENV];
475
+ if (value && value.trim()) return value;
476
+ return join(homedir(), DSH_DIR_NAME);
477
+ }
478
+ /** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
479
+ function pluginCacheRoot(home = dshHome()) {
480
+ return join(home, PLUGIN_ID, "cache");
481
+ }
482
+ /** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
483
+ function workspaceCacheDir(workspaceId, home = dshHome()) {
484
+ return join(pluginCacheRoot(home), "workspace", workspaceId);
485
+ }
486
+ /** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
487
+ function pluginLogRoot(home = dshHome()) {
488
+ return join(home, PLUGIN_ID, "logs");
489
+ }
490
+ /** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
491
+ function treeCacheFile(cwd, home = dshHome(), schema = 1) {
492
+ return join(workspaceCacheDir(hashOf(cwd), home), "tree.v" + schema + ".json");
493
+ }
494
+ /** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
495
+ function debugLogFile(cwd, home = dshHome()) {
496
+ return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
497
+ }
498
+ /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
499
+ function assetsDirOf(moduleUrl) {
500
+ return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
501
+ }
502
+ /** Monaco AMD 静态目录(随包发布)。 */
503
+ function vendorDirOf(moduleUrl) {
504
+ return join(assetsDirOf(moduleUrl), "vendor");
505
+ }
506
+ /** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
507
+ function imageDirOf(config, moduleUrl) {
508
+ const cfg = config;
509
+ if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
510
+ return assetsDirOf(moduleUrl);
511
+ }
512
+ /** 树缓存文件名 → schema 版本(不匹配返回 null)。 */
513
+ function treeSchemaOf(filename) {
514
+ const m = /^tree\.v(\d+)\./.exec(filename);
515
+ return m ? Number(m[1]) : null;
516
+ }
517
+ /** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
518
+ async function sweepDirFiles(dir, removed, now, dropAll) {
519
+ const names = await readdir(dir).catch(() => []);
520
+ for (const name of names) {
521
+ const full = join(dir, name);
522
+ let drop = dropAll;
523
+ if (!drop) {
524
+ const schema = treeSchemaOf(name);
525
+ if (schema === null) drop = true;
526
+ else if (schema !== 1) drop = true;
527
+ else try {
528
+ if (now - (await stat(full)).mtimeMs > 2592e6) drop = true;
529
+ } catch (error) {
530
+ drop = true;
531
+ }
532
+ }
533
+ if (!drop) continue;
534
+ try {
535
+ await rm(full, { force: true });
536
+ removed.push(name);
537
+ } catch (error) {}
538
+ }
539
+ }
540
+ /** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
541
+ async function sweepWorkspaceTier(dir, removed, now) {
542
+ const ids = await readdir(dir).catch(() => []);
543
+ for (const id of ids) {
544
+ const wsDir = join(dir, id);
545
+ await sweepDirFiles(wsDir, removed, now, false);
546
+ if (!(await readdir(wsDir).catch(() => [])).length) await rm(wsDir, {
547
+ recursive: true,
548
+ force: true
549
+ }).catch(() => {});
550
+ }
551
+ }
552
+ /** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
553
+ async function sweepByTotalCap(root, removed, cap) {
554
+ const files = [];
555
+ const walk = async (dir) => {
556
+ const names = await readdir(dir).catch(() => []);
557
+ for (const name of names) {
558
+ const full = join(dir, name);
559
+ try {
560
+ const info = await stat(full);
561
+ if (info.isDirectory()) await walk(full);
562
+ else files.push({
563
+ path: full,
564
+ mtime: info.mtimeMs,
565
+ size: info.size
566
+ });
567
+ } catch (error) {}
568
+ }
569
+ };
570
+ await walk(root);
571
+ let total = files.reduce((sum, f) => sum + f.size, 0);
572
+ files.sort((a, b) => a.mtime - b.mtime);
573
+ for (const f of files) {
574
+ if (total <= cap) break;
575
+ try {
576
+ await rm(f.path, { force: true });
577
+ total -= f.size;
578
+ removed.push(f.path.slice(root.length + 1));
579
+ } catch (error) {}
580
+ }
581
+ }
582
+ /**
583
+ * 清理插件缓存根(分级 + 总预算兜底):
584
+ * - 根级残留文件(旧扁平格式/未知)→ 删
585
+ * - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
586
+ * - user/ 用户级 → 按「schema 版本 + 保留期」清文件
587
+ * - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
588
+ * best-effort 静默。
589
+ * @author ddj 2026年09月01号
590
+ * @param home DSH home(测试可注入)
591
+ * @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
592
+ * @returns 删除的文件相对路径清单
593
+ */
594
+ async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP) {
595
+ const dir = pluginCacheRoot(home);
596
+ const names = await readdir(dir).catch(() => []);
597
+ if (!names.length) return [];
598
+ const removed = [];
599
+ const now = Date.now();
600
+ await Promise.all(names.map(async (name) => {
601
+ const full = join(dir, name);
602
+ try {
603
+ if ((await stat(full)).isDirectory()) {
604
+ if (name === "workspace") await sweepWorkspaceTier(full, removed, now);
605
+ else if (name === "user") await sweepDirFiles(full, removed, now, false);
606
+ else {
607
+ await rm(full, {
608
+ recursive: true,
609
+ force: true
610
+ });
611
+ removed.push(name);
612
+ }
613
+ } else {
614
+ await rm(full, { force: true });
615
+ removed.push(name);
616
+ }
617
+ } catch (error) {}
618
+ }));
619
+ await sweepByTotalCap(dir, removed, cap);
620
+ return removed;
621
+ }
622
+ //#endregion
371
623
  //#region src/compat.ts
372
624
  /**
373
625
  * dsh-vscode-mode host — 兼容层:与其他插件 / DSH 版本的统一探测、适配、护栏与自诊断。
@@ -381,8 +633,6 @@ const PLUGIN_NAME = "dsh-vscode-mode";
381
633
  const MCP_PACKAGE = "@deepseek-ai/dsh-mcp-client";
382
634
  /** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;loader id 不能用 ':',故用 '.')。 */
383
635
  const PROJECT_ENTRY_PREFIX = "vsm-mcp.";
384
- /** webServer 路由前缀(RPC 精确路由 + 静态资源前缀共用)。 */
385
- const ROUTE_PREFIX = "/edrv";
386
636
  /** 判断新旧格式项目 MCP 条目 id(旧残留不被视为全局 MCP)。 */
387
637
  function isProjectEntryId(id) {
388
638
  return id.startsWith("vsm-mcp.") || id.startsWith("vsm-mcp:");
@@ -508,10 +758,6 @@ async function buildReport(ctx, options) {
508
758
  };
509
759
  }
510
760
  //#endregion
511
- //#region src/shared/rpc.ts
512
- /** webServer 精确路由。 */
513
- const RPC_PATH = "/edrv/rpc";
514
- //#endregion
515
761
  //#region src/routes.ts
516
762
  /**
517
763
  * dsh-vscode-mode host — webServer 路由注册(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)。
@@ -526,7 +772,6 @@ const IMG_ROUTES = [{
526
772
  file: "compare_select.png"
527
773
  }];
528
774
  /** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
529
- const VENDOR_PREFIX = "/edrv/vendor";
530
775
  const VENDOR_MIME = {
531
776
  ".js": "text/javascript; charset=utf-8",
532
777
  ".css": "text/css; charset=utf-8",
@@ -543,14 +788,10 @@ const VENDOR_MIME = {
543
788
  ".html": "text/html; charset=utf-8",
544
789
  ".md": "text/markdown"
545
790
  };
546
- /** 图标目录:默认插件包内 assets/(随包发布);config.imageDir 可覆盖(用于自定义换图)。 */
547
- function imageDirOf(config) {
548
- const cfg = config;
549
- if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
550
- return join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
551
- }
552
- /** Monaco AMD 静态目录(随包发布)。 */
553
- const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "vendor");
791
+ /** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
792
+ const imgDirOf = (config) => imageDirOf(config, import.meta.url);
793
+ /** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
794
+ const vendorDir = vendorDirOf(import.meta.url);
554
795
  /**
555
796
  * 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
556
797
  * @author ddj 2026年08月20号
@@ -560,7 +801,7 @@ const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets",
560
801
  * @param onWarning 兼容性警告收集(可选)
561
802
  */
562
803
  function registerRoutes(ctx, config, handleRpc, onWarning) {
563
- const imgDir = imageDirOf(config);
804
+ const imgDir = imgDirOf(config);
564
805
  const web = ctx.get("webServer");
565
806
  if (!web) return;
566
807
  resetOwnRoutes();
@@ -1068,10 +1309,6 @@ function archiveEntryFor(recs, cwd, reason) {
1068
1309
  }))
1069
1310
  };
1070
1311
  }
1071
- //#endregion
1072
- //#region src/store.ts
1073
- const SIDECAR = ".dsh-edit-review.json";
1074
- const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
1075
1312
  /** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
1076
1313
  function policyOf(ctx, session) {
1077
1314
  const svc = ctx.get("sandboxPolicy");
@@ -1333,6 +1570,459 @@ function bucketOf(registry, ctx, cwd) {
1333
1570
  });
1334
1571
  }
1335
1572
  //#endregion
1573
+ //#region src/tree.ts
1574
+ /** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
1575
+ function entryOrder(a, b) {
1576
+ const aDir = a.type === "directory" ? 0 : 1;
1577
+ const bDir = b.type === "directory" ? 0 : 1;
1578
+ if (aDir !== bDir) return aDir - bDir;
1579
+ const la = a.name.toLocaleLowerCase("en-US");
1580
+ const lb = b.name.toLocaleLowerCase("en-US");
1581
+ return la < lb ? -1 : la > lb ? 1 : 0;
1582
+ }
1583
+ /**
1584
+ * 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
1585
+ * 任一路径段为 `..` 返回 null(拒绝越界)。
1586
+ * @author ddj 2026年08月26号
1587
+ * @param path 原始相对路径('' 表示根目录)
1588
+ * @returns 归一化相对路径('' = 根),非法返回 null
1589
+ */
1590
+ function normalizeRel(path) {
1591
+ const rel = String(path ?? "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
1592
+ if (!rel) return "";
1593
+ const segs = rel.split("/").filter((seg) => seg !== ".");
1594
+ if (!segs.length) return "";
1595
+ for (const seg of segs) if (seg === "..") return null;
1596
+ return segs.join("/");
1597
+ }
1598
+ /**
1599
+ * fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
1600
+ * @author ddj 2026年08月26号
1601
+ * @param rel 父目录相对路径('' = 根)
1602
+ * @param children 目录子项
1603
+ * @returns 排序后的树条目
1604
+ */
1605
+ function toTreeEntries(rel, children) {
1606
+ const out = [];
1607
+ for (const child of children) {
1608
+ if (!child || typeof child.name !== "string" || !child.name) continue;
1609
+ out.push({
1610
+ name: child.name,
1611
+ path: rel ? rel + "/" + child.name : child.name,
1612
+ type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
1613
+ size: typeof child.size === "number" ? child.size : void 0
1614
+ });
1615
+ }
1616
+ return out.sort(entryOrder);
1617
+ }
1618
+ /** 列取失败(带稳定语义 code,调用方转文案)。 */
1619
+ var DirListError = class extends Error {
1620
+ code;
1621
+ constructor(code, message) {
1622
+ super(message);
1623
+ this.code = code;
1624
+ }
1625
+ };
1626
+ /** node fs 错误码 → 稳定语义错误。 */
1627
+ function codeOf(err) {
1628
+ const code = err?.code;
1629
+ if (code === "ENOENT") return new DirListError("not-found", "目录不存在");
1630
+ if (code === "ENOTDIR") return new DirListError("not-dir", "不是目录");
1631
+ if (code === "EACCES" || code === "EPERM" || code === "EBUSY") return new DirListError("permission", "权限不足");
1632
+ return new DirListError("io", String(err?.message ?? err));
1633
+ }
1634
+ /** 并行 stat 一批绝对路径 → 目录/文件/other(follow symlink,失败按 other)。 */
1635
+ async function statTypes(paths, out) {
1636
+ const cap = Math.min(paths.length, 256);
1637
+ for (let i = 0; i < cap; i += 32) {
1638
+ const chunk = paths.slice(i, i + 32);
1639
+ await Promise.all(chunk.map(async (p) => {
1640
+ try {
1641
+ const info = await stat(p);
1642
+ out.set(p, info.isDirectory() ? "directory" : info.isFile() ? "file" : "other");
1643
+ } catch (error) {
1644
+ out.set(p, "other");
1645
+ }
1646
+ }));
1647
+ }
1648
+ }
1649
+ /**
1650
+ * 快速列取目录:一次 readdir(withFileTypes) 判型;symlink 并行 follow-stat。
1651
+ * @author ddj 2026年08月31号
1652
+ * @param absDir 绝对目录路径(fs.resolve 后的 targetKey)
1653
+ * @returns 子项列表(name/type;size 缺省,树界面不展示大小)
1654
+ * @throws DirListError 目录不存在/不是目录/权限不足/IO 失败
1655
+ */
1656
+ async function listDirCheap(absDir) {
1657
+ let dirents;
1658
+ try {
1659
+ dirents = await readdir(absDir, {
1660
+ withFileTypes: true,
1661
+ encoding: "utf8"
1662
+ });
1663
+ } catch (error) {
1664
+ throw codeOf(error);
1665
+ }
1666
+ const out = [];
1667
+ const syms = [];
1668
+ for (const d of dirents) if (d.isDirectory()) out.push({
1669
+ name: d.name,
1670
+ type: "directory"
1671
+ });
1672
+ else if (d.isFile()) out.push({
1673
+ name: d.name,
1674
+ type: "file"
1675
+ });
1676
+ else if (d.isSymbolicLink()) syms.push(d.name);
1677
+ else out.push({
1678
+ name: d.name,
1679
+ type: "other"
1680
+ });
1681
+ if (syms.length) {
1682
+ const symAbs = syms.map((name) => join(absDir, name));
1683
+ const types = /* @__PURE__ */ new Map();
1684
+ await statTypes(symAbs, types);
1685
+ for (let i = 0; i < syms.length; i++) out.push({
1686
+ name: syms[i],
1687
+ type: types.get(symAbs[i]) ?? "other"
1688
+ });
1689
+ }
1690
+ return out;
1691
+ }
1692
+ /** 落盘单目录条目上限:超出截断并标 full=false(截断仍可即时渲染,客户端后台刷新补全)。 */
1693
+ const PERSIST_DIR_ENTRIES = 1500;
1694
+ /** 落盘防抖:变更后静默 10s 再写一次。 */
1695
+ const PERSIST_DEBOUNCE_MS = 1e4;
1696
+ /** re-heal 防抖:写入密集期合并为 3s 后一轮。 */
1697
+ const HEAL_DEBOUNCE_MS = 3e3;
1698
+ /** cwd → 索引状态(随会话销毁清理)。 */
1699
+ const states = /* @__PURE__ */ new Map();
1700
+ /** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出/裁剪顺序可预期)。 */
1701
+ let clock = 0;
1702
+ /** 取下一时间戳(不小于真实时间且严格递增)。 */
1703
+ function nextTs() {
1704
+ clock = Math.max(Date.now(), clock + 1);
1705
+ return clock;
1706
+ }
1707
+ /** 条目原始值 → 合法条目(非法返回 null)。 */
1708
+ function entryOf(raw) {
1709
+ if (!raw || typeof raw !== "object") return null;
1710
+ const e = raw;
1711
+ if (typeof e.name !== "string" || !e.name) return null;
1712
+ const type = e.type === "directory" ? "directory" : e.type === "file" ? "file" : "other";
1713
+ return {
1714
+ name: e.name,
1715
+ type
1716
+ };
1717
+ }
1718
+ /** 写入路径 → 相对工作区根的 rel(绝对路径按 cwd 前缀裁剪;越界/非法 → null)。 */
1719
+ function relOf(cwd, path) {
1720
+ let rel = String(path ?? "").trim().replace(/\\/g, "/");
1721
+ const cl = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1722
+ if (rel.toLowerCase().startsWith(cl + "/")) rel = rel.slice(cl.length + 1);
1723
+ else if (rel.startsWith("/") || /^[a-z]:\//i.test(rel)) return null;
1724
+ rel = rel.replace(/^\/+|\/+$/g, "");
1725
+ if (!rel) return null;
1726
+ const segs = rel.split("/").filter((s) => s !== "" && s !== ".");
1727
+ if (!segs.length) return null;
1728
+ for (const s of segs) if (s === "..") return null;
1729
+ return segs.join("/");
1730
+ }
1731
+ /** rel 的父目录 + 全部祖先('a/b/c' → ['a/b','a',''];'a' → [''])。 */
1732
+ function ancestorsOf(rel) {
1733
+ const segs = rel.split("/");
1734
+ const out = [];
1735
+ for (let i = segs.length - 1; i >= 1; i--) out.push(segs.slice(0, i).join("/"));
1736
+ out.push("");
1737
+ return out;
1738
+ }
1739
+ /** 序列化索引数据。 */
1740
+ function indexSerialize(data) {
1741
+ const lean = {
1742
+ v: 1,
1743
+ root: data.root,
1744
+ dirs: {}
1745
+ };
1746
+ for (const [rel, dir] of Object.entries(data.dirs)) lean.dirs[rel] = {
1747
+ ts: dir.ts,
1748
+ full: dir.full,
1749
+ entries: dir.entries.map((e) => ({
1750
+ name: e.name,
1751
+ type: e.type
1752
+ }))
1753
+ };
1754
+ return JSON.stringify(lean);
1755
+ }
1756
+ /** 解析索引文本;损坏/格式不符 → null。 */
1757
+ function indexParse(text) {
1758
+ if (!text) return null;
1759
+ try {
1760
+ const raw = JSON.parse(text);
1761
+ if (!raw || raw.v !== 1 || !raw.dirs || typeof raw.dirs !== "object") return null;
1762
+ const dirs = {};
1763
+ for (const [rel, d] of Object.entries(raw.dirs)) {
1764
+ if (!d || typeof d !== "object") continue;
1765
+ const dir = d;
1766
+ if (!Array.isArray(dir.entries)) continue;
1767
+ const entries = [];
1768
+ for (const rawEntry of dir.entries) {
1769
+ const e = entryOf(rawEntry);
1770
+ if (!e) continue;
1771
+ entries.push({
1772
+ name: e.name,
1773
+ path: rel ? rel + "/" + e.name : e.name,
1774
+ type: e.type
1775
+ });
1776
+ }
1777
+ if (!entries.length) continue;
1778
+ dirs[rel] = {
1779
+ ts: typeof dir.ts === "number" ? dir.ts : 0,
1780
+ full: dir.full === true,
1781
+ entries
1782
+ };
1783
+ }
1784
+ return {
1785
+ v: 1,
1786
+ root: typeof raw.root === "string" ? raw.root : null,
1787
+ dirs
1788
+ };
1789
+ } catch (error) {
1790
+ return null;
1791
+ }
1792
+ }
1793
+ /** 落盘裁剪:目录按 ts 取新裁旧、单目录超限截断标 full=false(内存不受影响)。 */
1794
+ function trimPersist(data, maxDirs = 400, maxEntries = PERSIST_DIR_ENTRIES) {
1795
+ const dirs = {};
1796
+ const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts);
1797
+ for (const [rel, dir] of sorted) {
1798
+ if (Object.keys(dirs).length >= maxDirs) break;
1799
+ const full = dir.entries.length <= maxEntries;
1800
+ dirs[rel] = {
1801
+ ts: dir.ts,
1802
+ full,
1803
+ entries: full ? dir.entries : dir.entries.slice(0, maxEntries)
1804
+ };
1805
+ }
1806
+ return {
1807
+ v: 1,
1808
+ root: data.root,
1809
+ dirs
1810
+ };
1811
+ }
1812
+ /** 取(或建)某 cwd 的索引状态,并惰性加载落盘快照(合并:文件 ts 更新才采纳)。 */
1813
+ function ensureState(ctx, cwd) {
1814
+ const existing = states.get(cwd);
1815
+ if (existing) return existing;
1816
+ const state = {
1817
+ ctx,
1818
+ cwd,
1819
+ data: {
1820
+ v: 1,
1821
+ root: null,
1822
+ dirs: {}
1823
+ },
1824
+ stale: /* @__PURE__ */ new Set(),
1825
+ dirty: false,
1826
+ persistTimer: null,
1827
+ healTimer: null,
1828
+ pending: /* @__PURE__ */ new Map(),
1829
+ loading: false,
1830
+ loadPromise: null
1831
+ };
1832
+ states.set(cwd, state);
1833
+ state.loadPromise = loadIndex(state);
1834
+ removeLegacyTreeSidecars(state.cwd);
1835
+ return state;
1836
+ }
1837
+ /** 清理旧工作区树 sidecar(.dsh-edit-review-tree.json/.dsh-vscode-mode-tree.json)。 */
1838
+ async function removeLegacyTreeSidecars(cwd) {
1839
+ await Promise.all(LEGACY_TREE_SIDECARS.map(async (name) => {
1840
+ try {
1841
+ await rm(join(cwd, name), { force: true });
1842
+ } catch (error) {}
1843
+ }));
1844
+ }
1845
+ /** 惰性加载落盘快照一次(缺失/损坏 → 空索引;失败静默)。 */
1846
+ async function loadIndex(state) {
1847
+ if (state.loading) return;
1848
+ state.loading = true;
1849
+ try {
1850
+ const parsed = indexParse(await readFile(treeCacheFile(state.cwd), "utf8"));
1851
+ if (!parsed) return;
1852
+ for (const [rel, dir] of Object.entries(parsed.dirs)) {
1853
+ const cur = state.data.dirs[rel];
1854
+ if (!cur || dir.ts > cur.ts) state.data.dirs[rel] = dir;
1855
+ }
1856
+ if (parsed.root && !state.data.root) state.data.root = parsed.root;
1857
+ } catch (error) {} finally {
1858
+ state.loading = false;
1859
+ }
1860
+ }
1861
+ /** 命中:新鲜且未失效 → 条目(并刷新 ts 保活);否则 null。 */
1862
+ function hitIndex(cwd, rel) {
1863
+ const state = states.get(cwd);
1864
+ if (!state) return null;
1865
+ const dir = state.data.dirs[rel];
1866
+ if (!dir || state.stale.has(rel)) return null;
1867
+ const now = Date.now();
1868
+ if (now - dir.ts > 6e4) return null;
1869
+ dir.ts = now;
1870
+ return dir.entries;
1871
+ }
1872
+ /** 更新某目录条目:写内存 + 打脏 + 防抖落盘;stale 撤销。 */
1873
+ function putIndex(ctx, cwd, rel, entries) {
1874
+ const state = ensureState(ctx, cwd);
1875
+ const dirs = state.data.dirs;
1876
+ if (!dirs[rel] && Object.keys(dirs).length >= 2e3) {
1877
+ let oldest = rel;
1878
+ let oldestTs = Infinity;
1879
+ for (const [k, v] of Object.entries(dirs)) if (v.ts < oldestTs) {
1880
+ oldestTs = v.ts;
1881
+ oldest = k;
1882
+ }
1883
+ delete dirs[oldest];
1884
+ }
1885
+ dirs[rel] = {
1886
+ ts: nextTs(),
1887
+ full: entries.length <= PERSIST_DIR_ENTRIES,
1888
+ entries
1889
+ };
1890
+ state.stale.delete(rel);
1891
+ state.dirty = true;
1892
+ schedulePersist(state);
1893
+ }
1894
+ /** 写入路径失效:父目录+祖先进 stale,并调度后台 re-heal。 */
1895
+ function invalidateIndex(ctx, cwd, path) {
1896
+ const rel = relOf(cwd, path);
1897
+ if (rel === null) return;
1898
+ const state = ensureState(ctx, cwd);
1899
+ for (const anc of ancestorsOf(rel)) state.stale.add(anc);
1900
+ scheduleHeal(state);
1901
+ }
1902
+ /** 调度 re-heal(防抖合并;已有定时器则复用)。 */
1903
+ function scheduleHeal(state) {
1904
+ if (state.healTimer) return;
1905
+ state.healTimer = setTimeout(() => {
1906
+ state.healTimer = null;
1907
+ healRound(state);
1908
+ }, HEAL_DEBOUNCE_MS);
1909
+ }
1910
+ /** 后台自愈一轮:并行重列 ≤HEAL_ROUND_MAX 个 stale 目录;失败移出(交给 TTL/展开路径自愈)。 */
1911
+ async function healRound(state) {
1912
+ if (!state.stale.size) return;
1913
+ const targets = [...state.stale].slice(0, 20);
1914
+ await Promise.all(targets.map(async (rel) => {
1915
+ if ("error" in await listDirCached(state.ctx, state.cwd, rel, true)) state.stale.delete(rel);
1916
+ }));
1917
+ if (state.stale.size) scheduleHeal(state);
1918
+ }
1919
+ /** 调度落盘(防抖;已有定时器则重置)。 */
1920
+ function schedulePersist(state) {
1921
+ if (state.persistTimer) clearTimeout(state.persistTimer);
1922
+ state.persistTimer = setTimeout(() => {
1923
+ state.persistTimer = null;
1924
+ persistNow(state);
1925
+ }, PERSIST_DEBOUNCE_MS);
1926
+ }
1927
+ /** 落盘快照(裁剪 + 序列化 + 超限跳过;raw fs 写 ~/.dsh 缓存根,失败静默)。 */
1928
+ async function persistNow(state) {
1929
+ if (!state.dirty) return;
1930
+ try {
1931
+ const json = indexSerialize(trimPersist(state.data));
1932
+ if (json.length > 8388608) {
1933
+ state.dirty = false;
1934
+ return;
1935
+ }
1936
+ await mkdir(dirname(treeCacheFile(state.cwd)), { recursive: true });
1937
+ await writeFile(treeCacheFile(state.cwd), json, "utf8");
1938
+ state.dirty = false;
1939
+ } catch (error) {}
1940
+ }
1941
+ /** 清理某 cwd 索引(会话销毁时):清定时器,dirty 则尽力补写一次。 */
1942
+ function disposeIndex(cwd) {
1943
+ const state = states.get(cwd);
1944
+ if (!state) return;
1945
+ if (state.persistTimer) {
1946
+ clearTimeout(state.persistTimer);
1947
+ state.persistTimer = null;
1948
+ }
1949
+ if (state.healTimer) {
1950
+ clearTimeout(state.healTimer);
1951
+ state.healTimer = null;
1952
+ }
1953
+ if (state.dirty) persistNow(state);
1954
+ states.delete(cwd);
1955
+ }
1956
+ /** 错误 → 用户文案(DirListError 用自身 message,其余带上下文)。 */
1957
+ function errorText(error) {
1958
+ if (error instanceof DirListError) return error.message;
1959
+ return "读取目录失败:" + String(error);
1960
+ }
1961
+ /**
1962
+ * 目录列取统一入口(RPC handler 用):命中索引直出;失效/force 走快路径
1963
+ * (resolve + listDirCheap + putIndex);同 (cwd,rel) 在途请求复用同一 Promise。
1964
+ * @author ddj 2026年08月31号 / 2026年09月01号
1965
+ * @param ctx DSH 上下文(仅用于工作区 IO)
1966
+ * @param cwd 工作区根
1967
+ * @param rel 归一化相对路径('' = 根)
1968
+ * @param force 跳过索引命中,强制实列
1969
+ * @returns 目录条目与工作区根,或错误文案
1970
+ */
1971
+ async function listDirCached(ctx, cwd, rel, force) {
1972
+ const state = ensureState(ctx, cwd);
1973
+ if (state.loadPromise) await state.loadPromise;
1974
+ const fs = ctx.get("fs");
1975
+ if (!fs) return { error: "缺少 fs" };
1976
+ if (!state.data.root) try {
1977
+ state.data.root = String(fs.processPath(await fs.resolve(".", { cwd })));
1978
+ } catch (error) {
1979
+ return { error: errorText(error) };
1980
+ }
1981
+ const root = state.data.root;
1982
+ if (!force) {
1983
+ const hit = hitIndex(cwd, rel);
1984
+ if (hit) return {
1985
+ entries: hit,
1986
+ root
1987
+ };
1988
+ }
1989
+ const inflight = state.pending.get(rel);
1990
+ if (inflight) {
1991
+ const res = await inflight;
1992
+ return "entries" in res ? {
1993
+ entries: res.entries,
1994
+ root
1995
+ } : res;
1996
+ }
1997
+ const task = (async () => {
1998
+ try {
1999
+ const entries = toTreeEntries(rel, await listDirCheap((await fs.resolve(rel || ".", { cwd })).targetKey));
2000
+ putIndex(ctx, cwd, rel, entries);
2001
+ return { entries };
2002
+ } catch (error) {
2003
+ if (error instanceof DirListError && error.code === "not-found") {
2004
+ if (state.data.dirs[rel]) {
2005
+ delete state.data.dirs[rel];
2006
+ state.dirty = true;
2007
+ schedulePersist(state);
2008
+ }
2009
+ state.stale.delete(rel);
2010
+ }
2011
+ return { error: errorText(error) };
2012
+ }
2013
+ })();
2014
+ state.pending.set(rel, task);
2015
+ try {
2016
+ const res = await task;
2017
+ return "entries" in res ? {
2018
+ entries: res.entries,
2019
+ root
2020
+ } : res;
2021
+ } finally {
2022
+ if (state.pending.get(rel) === task) state.pending.delete(rel);
2023
+ }
2024
+ }
2025
+ //#endregion
1336
2026
  //#region src/capture.ts
1337
2027
  /** 校验并提取工具 result meta 中的文件 hunk。 */
1338
2028
  function validHunks(raw) {
@@ -1432,6 +2122,7 @@ async function captureToolResult(ctx, registry, exec, result) {
1432
2122
  bucket.set(exec.callId, record);
1433
2123
  prune(bucket);
1434
2124
  await saveBucket(ctx, cwd, bucket, session);
2125
+ invalidateIndex(ctx, cwd, path);
1435
2126
  } catch (error) {
1436
2127
  console.error("edrv capture failed", error);
1437
2128
  }
@@ -1579,6 +2270,65 @@ function parseOutput(handle, maxResults) {
1579
2270
  };
1580
2271
  }
1581
2272
  /**
2273
+ * 读取已收集的 stderr(无收集器 → 空文本)。
2274
+ * @author ddj 2026年08月27号
2275
+ * @param handle subprocess handle
2276
+ * @returns stderr 文本
2277
+ */
2278
+ function readStderr(handle) {
2279
+ const reader = handle.collected?.stderr;
2280
+ return reader ? reader.readFrom(0) : { text: "" };
2281
+ }
2282
+ /**
2283
+ * 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
2284
+ * @author ddj 2026年08月27号
2285
+ * @param text stderr 全文
2286
+ * @returns 首条诊断行(无则空串)
2287
+ */
2288
+ function firstStderrLine(text) {
2289
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
2290
+ const line = raw.trim();
2291
+ if (!line) continue;
2292
+ return line.length > 300 ? line.slice(0, 300) + "…" : line;
2293
+ }
2294
+ return "";
2295
+ }
2296
+ /**
2297
+ * 分类 rg 非 0/1 退出:pattern=模式错误(正则/glob 无效,无结果),
2298
+ * partial=遍历错误(rg 已输出的命中仍有效,结果不完整),hard=硬失败(其他退出码/超时被杀)。
2299
+ * @author ddj 2026年08月27号
2300
+ * @param code 退出码(null/undefined = 被终止,通常为 20s 超时)
2301
+ * @param stderrText stderr 全文
2302
+ * @returns 分类与中文提示文案(partial 的 message 即 warning 内容)
2303
+ */
2304
+ function rgExitFailure(code, stderrText) {
2305
+ const detail = firstStderrLine(stderrText);
2306
+ if (/regex parse error/i.test(detail)) return {
2307
+ kind: "pattern",
2308
+ message: "正则表达式无效:" + detail
2309
+ };
2310
+ if (/error parsing glob/i.test(detail)) return {
2311
+ kind: "pattern",
2312
+ message: "文件过滤模式无效:" + detail
2313
+ };
2314
+ if (code === 2) {
2315
+ const count = String(stderrText ?? "").split(/\r?\n/).filter((line) => line.trim()).length;
2316
+ const suffix = count > 1 ? "(共 " + count + " 处)" : "";
2317
+ return {
2318
+ kind: "partial",
2319
+ message: detail ? "部分路径无法访问,结果可能不完整:" + detail + suffix : "部分路径无法访问,结果可能不完整"
2320
+ };
2321
+ }
2322
+ if (code === null || code === void 0) return {
2323
+ kind: "hard",
2324
+ message: "搜索超时(20 秒内未完成,已终止)"
2325
+ };
2326
+ return {
2327
+ kind: "hard",
2328
+ message: "ripgrep 退出码:" + String(code) + (detail ? "(" + detail + ")" : "")
2329
+ };
2330
+ }
2331
+ /**
1582
2332
  * 使用打包 ripgrep 搜索工作区文件。
1583
2333
  * @author ddj 2026年08月24号
1584
2334
  * @param input provider 输入
@@ -1621,7 +2371,16 @@ async function searchRipgrep(input) {
1621
2371
  throw new Error("ripgrep 启动失败:" + String(error));
1622
2372
  }
1623
2373
  const code = outcome.exitCode ?? outcome.code;
1624
- if (code !== 0 && code !== 1) throw new Error("ripgrep 退出码:" + String(code));
2374
+ if (code !== 0 && code !== 1) {
2375
+ const failure = rgExitFailure(code, readStderr(handle).text);
2376
+ if (failure.kind !== "partial") throw new Error(failure.message);
2377
+ return {
2378
+ ...parseOutput(handle, input.maxResults),
2379
+ warning: failure.message,
2380
+ complete: false,
2381
+ source: "ripgrep"
2382
+ };
2383
+ }
1625
2384
  const parsed = parseOutput(handle, input.maxResults);
1626
2385
  if (!handle.collected?.stdout) throw new Error("ripgrep stdout 不可用");
1627
2386
  return {
@@ -2239,17 +2998,23 @@ async function searchRipgrepContent(input) {
2239
2998
  throw new Error("ripgrep 启动失败:" + String(error));
2240
2999
  }
2241
3000
  const code = outcome.exitCode ?? outcome.code;
2242
- if (code !== 0 && code !== 1) throw new Error("ripgrep 退出码:" + String(code));
3001
+ const failure = code !== 0 && code !== 1 ? rgExitFailure(code, readStderr(handle).text) : null;
3002
+ if (failure && failure.kind !== "partial") throw new Error(failure.message);
2243
3003
  const reader = handle.collected?.stdout;
2244
3004
  if (!reader) throw new Error("ripgrep stdout 不可用");
2245
3005
  const output = reader.readFrom(0);
2246
3006
  const capped = applyCaps(parseRgJsonLines(output.text, root), Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES)), DEFAULT_MAX_FILES);
2247
3007
  const truncated = Boolean(output.lossy) || capped.truncated;
2248
- return {
3008
+ const result = {
2249
3009
  ...capped,
2250
3010
  complete: !truncated,
2251
3011
  source: "ripgrep"
2252
3012
  };
3013
+ if (failure) {
3014
+ result.complete = false;
3015
+ result.warning = failure.message;
3016
+ }
3017
+ return result;
2253
3018
  }
2254
3019
  /**
2255
3020
  * 内容搜索编排器:短期缓存 + 同根在途取消 + 会话清理。
@@ -2314,7 +3079,8 @@ var ContentSearcher = class {
2314
3079
  const cached = this.cache.get(key);
2315
3080
  if (cached) return {
2316
3081
  matches: cached.matches,
2317
- truncated: cached.truncated
3082
+ truncated: cached.truncated,
3083
+ warning: cached.warning
2318
3084
  };
2319
3085
  this.inflight.get(root)?.abort();
2320
3086
  const controller = new AbortController();
@@ -2341,7 +3107,8 @@ var ContentSearcher = class {
2341
3107
  this.cache.set(key, result);
2342
3108
  return {
2343
3109
  matches: result.matches,
2344
- truncated: result.truncated
3110
+ truncated: result.truncated,
3111
+ warning: result.warning
2345
3112
  };
2346
3113
  } catch (error) {
2347
3114
  if (controller.signal.aborted) return {
@@ -2813,53 +3580,7 @@ async function projectRefresh(ctx, workspacePath, serverName) {
2813
3580
  return projectOf(ctx, workspacePath, workspace.title ?? "");
2814
3581
  }
2815
3582
  //#endregion
2816
- //#region src/tree.ts
2817
- /** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
2818
- function entryOrder(a, b) {
2819
- const aDir = a.type === "directory" ? 0 : 1;
2820
- const bDir = b.type === "directory" ? 0 : 1;
2821
- if (aDir !== bDir) return aDir - bDir;
2822
- const la = a.name.toLocaleLowerCase("en-US");
2823
- const lb = b.name.toLocaleLowerCase("en-US");
2824
- return la < lb ? -1 : la > lb ? 1 : 0;
2825
- }
2826
- /**
2827
- * 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
2828
- * 任一路径段为 `..` 返回 null(拒绝越界)。
2829
- * @author ddj 2026年08月26号
2830
- * @param path 原始相对路径('' 表示根目录)
2831
- * @returns 归一化相对路径('' = 根),非法返回 null
2832
- */
2833
- function normalizeRel(path) {
2834
- const rel = String(path ?? "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
2835
- if (!rel) return "";
2836
- const segs = rel.split("/").filter((seg) => seg !== ".");
2837
- if (!segs.length) return "";
2838
- for (const seg of segs) if (seg === "..") return null;
2839
- return segs.join("/");
2840
- }
2841
- /**
2842
- * fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
2843
- * @author ddj 2026年08月26号
2844
- * @param rel 父目录相对路径('' = 根)
2845
- * @param children 目录子项
2846
- * @returns 排序后的树条目
2847
- */
2848
- function toTreeEntries(rel, children) {
2849
- const out = [];
2850
- for (const child of children) {
2851
- if (!child || typeof child.name !== "string" || !child.name) continue;
2852
- out.push({
2853
- name: child.name,
2854
- path: rel ? rel + "/" + child.name : child.name,
2855
- type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
2856
- size: typeof child.size === "number" ? child.size : void 0
2857
- });
2858
- }
2859
- return out.sort(entryOrder);
2860
- }
2861
- //#endregion
2862
- //#region src/reveal.ts
3583
+ //#region src/reveal.ts
2863
3584
  /**
2864
3585
  * dsh-vscode-mode host — 在 OS 文件浏览器中打开/定位路径(reveal 能力)。
2865
3586
  * 纯函数 revealCommand 平台分发 + revealInExplorer 经 ctx.subprocess.spawn 发射
@@ -2926,6 +3647,462 @@ async function revealInExplorer(ctx, absPath, isDir) {
2926
3647
  }
2927
3648
  }
2928
3649
  //#endregion
3650
+ //#region src/perf.ts
3651
+ /**
3652
+ * dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
3653
+ * 职责:
3654
+ * - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
3655
+ * - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
3656
+ * - 当前会话体积 stat(对话头部指示器用,纯 stat);
3657
+ * - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
3658
+ * 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
3659
+ * 作者 ddj 2026-09-02
3660
+ */
3661
+ /** 会话日志文件名(zstd 优先,降级纯 jsonl)。 */
3662
+ const LOG_NAMES = ["session.jsonl.zstd", "session.jsonl"];
3663
+ /** 归档根(~/.dsh/sessions-archive,与 sessions 同级,可逆移出的落点)。 */
3664
+ function sessionsArchiveRoot(home = dshHome()) {
3665
+ return join(home, "sessions-archive");
3666
+ }
3667
+ /** 路径段校验:非空、非 . / ..、不含分隔符与 Windows 非法字符(防 join 逃逸)。 */
3668
+ function validDirName(name) {
3669
+ return typeof name === "string" && name.length > 0 && name !== "." && name !== ".." && !/[/\\]/.test(name) && !/[:*?"<>|]/.test(name);
3670
+ }
3671
+ /** target 必须落在 root 内(自身或其子路径),防路径穿越。 */
3672
+ function withinDir(target, root) {
3673
+ const rel = resolve(target);
3674
+ const base = resolve(root);
3675
+ return rel === base || rel.startsWith(base + sep);
3676
+ }
3677
+ /** stat 一个会话目录里的日志文件(首个存在的),缺失 → null。 */
3678
+ async function statSessionLog(sessionDir) {
3679
+ for (const name of LOG_NAMES) try {
3680
+ const info = await stat(join(sessionDir, name));
3681
+ if (info.isFile()) return {
3682
+ size: info.size,
3683
+ mtimeMs: info.mtimeMs
3684
+ };
3685
+ } catch (error) {}
3686
+ return null;
3687
+ }
3688
+ /**
3689
+ * 盘点 ~/.dsh/sessions:按工作区分组,每会话仅 stat 日志文件体积与 mtime。
3690
+ * 不解压日志(V8 展开成本高),活跃标记由调用方传入后置。
3691
+ * @author ddj 2026年09月02号
3692
+ * @param home DSH home(测试可注入)
3693
+ * @param archive 归档根(默认 sessions-archive;测试可注入)
3694
+ * @returns 工作区聚合(按体积降序)+ 会话明细(按体积降序)+ 汇总
3695
+ */
3696
+ async function scanSessionInventory(home = dshHome(), archive = sessionsArchiveRoot(home)) {
3697
+ const root = sessionsRoot(home);
3698
+ const workspaces = [];
3699
+ const sessions = [];
3700
+ let totalBytes = 0;
3701
+ let totalCount = 0;
3702
+ const wsDirs = await readdir(root, { withFileTypes: true }).catch(() => []);
3703
+ for (const ws of wsDirs) {
3704
+ if (!ws.isDirectory() || !validDirName(ws.name)) continue;
3705
+ const wsDir = join(root, ws.name);
3706
+ const wsSessions = [];
3707
+ let wsBytes = 0;
3708
+ let minMtime = Number.POSITIVE_INFINITY;
3709
+ let maxMtime = 0;
3710
+ const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
3711
+ for (const sd of sessionDirs) {
3712
+ if (!sd.isDirectory()) continue;
3713
+ const info = await statSessionLog(join(wsDir, sd.name));
3714
+ if (!info) continue;
3715
+ const row = {
3716
+ sessionId: sd.name,
3717
+ workspaceKey: ws.name,
3718
+ bytes: info.size,
3719
+ mtime: info.mtimeMs,
3720
+ active: false
3721
+ };
3722
+ wsSessions.push(row);
3723
+ wsBytes += info.size;
3724
+ totalBytes += info.size;
3725
+ totalCount += 1;
3726
+ if (info.mtimeMs < minMtime) minMtime = info.mtimeMs;
3727
+ if (info.mtimeMs > maxMtime) maxMtime = info.mtimeMs;
3728
+ }
3729
+ wsSessions.sort((a, b) => b.bytes - a.bytes);
3730
+ sessions.push(...wsSessions);
3731
+ workspaces.push({
3732
+ workspaceKey: ws.name,
3733
+ sessionCount: wsSessions.length,
3734
+ totalBytes: wsBytes,
3735
+ minMtime: Number.isFinite(minMtime) ? minMtime : 0,
3736
+ maxMtime
3737
+ });
3738
+ }
3739
+ workspaces.sort((a, b) => b.totalBytes - a.totalBytes);
3740
+ return {
3741
+ workspaces,
3742
+ sessions,
3743
+ totals: {
3744
+ workspaces: workspaces.length,
3745
+ sessions: totalCount,
3746
+ totalBytes
3747
+ }
3748
+ };
3749
+ }
3750
+ /** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
3751
+ function markActiveSessions(sessions, activeIds) {
3752
+ const raw = new Set(activeIds);
3753
+ const encoded = /* @__PURE__ */ new Set();
3754
+ for (const id of raw) encoded.add(sessionIdSegment(id));
3755
+ for (const s of sessions) if (raw.has(s.sessionId) || encoded.has(s.sessionId)) s.active = true;
3756
+ }
3757
+ /**
3758
+ * 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
3759
+ * 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
3760
+ * @author ddj 2026年09月02号
3761
+ * @param inventory 盘点结果
3762
+ * @param criteria 圈选条件
3763
+ * @returns 待移出清单与释放字节
3764
+ */
3765
+ function planMoveOut(inventory, criteria) {
3766
+ const items = [];
3767
+ const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0;
3768
+ const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1e3 : 0;
3769
+ for (const s of inventory.sessions) {
3770
+ if (s.active) continue;
3771
+ if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue;
3772
+ if (explicit) {
3773
+ if (!criteria.sessionIds.includes(s.sessionId)) continue;
3774
+ } else {
3775
+ if (criteria.minBytes && s.bytes < criteria.minBytes) continue;
3776
+ if (criteria.olderThanDays && s.mtime > cutoff) continue;
3777
+ if (!criteria.minBytes && !criteria.olderThanDays) continue;
3778
+ }
3779
+ items.push({
3780
+ workspaceKey: s.workspaceKey,
3781
+ sessionId: s.sessionId,
3782
+ bytes: s.bytes
3783
+ });
3784
+ }
3785
+ return {
3786
+ items,
3787
+ reclaimedBytes: items.reduce((sum, i) => sum + i.bytes, 0)
3788
+ };
3789
+ }
3790
+ /**
3791
+ * 移出会话目录到归档区(同卷 rename,可逆)。活跃会话拒绝;路径越界拒绝;
3792
+ * 逐项 best-effort,成功项写归档 manifest。默认不 dry-run(dryRun 时只校验不搬迁)。
3793
+ * @author ddj 2026年09月02号
3794
+ * @param home DSH home
3795
+ * @param archive 归档根
3796
+ * @param items 待移出清单(来自 planMoveOut 或用户确认)
3797
+ * @param activeIds 活跃会话 id 集合(执行期再校验一次)
3798
+ * @param dryRun 仅校验不搬迁
3799
+ */
3800
+ async function moveOutSessions(home, archive, items, activeIds, dryRun = false) {
3801
+ const root = sessionsRoot(home);
3802
+ const rawActive = new Set(activeIds);
3803
+ const encodedActive = /* @__PURE__ */ new Set();
3804
+ for (const id of rawActive) encodedActive.add(sessionIdSegment(id));
3805
+ const isActive = (id) => rawActive.has(id) || encodedActive.has(id);
3806
+ const moved = [];
3807
+ const failures = [];
3808
+ for (const item of items) {
3809
+ if (!validDirName(item.workspaceKey)) {
3810
+ failures.push({
3811
+ workspaceKey: item.workspaceKey,
3812
+ sessionId: item.sessionId,
3813
+ error: "工作区键不合法"
3814
+ });
3815
+ continue;
3816
+ }
3817
+ if (isActive(item.sessionId)) {
3818
+ failures.push({
3819
+ workspaceKey: item.workspaceKey,
3820
+ sessionId: item.sessionId,
3821
+ error: "活跃会话,拒绝移出"
3822
+ });
3823
+ continue;
3824
+ }
3825
+ const seg = sessionIdSegment(item.sessionId);
3826
+ const src = join(root, item.workspaceKey, seg);
3827
+ const dst = join(archive, item.workspaceKey, seg);
3828
+ if (!withinDir(src, root) || !withinDir(dst, archive)) {
3829
+ failures.push({
3830
+ workspaceKey: item.workspaceKey,
3831
+ sessionId: item.sessionId,
3832
+ error: "路径越界,拒绝执行"
3833
+ });
3834
+ continue;
3835
+ }
3836
+ if (dryRun) {
3837
+ try {
3838
+ if ((await stat(src)).isDirectory()) moved.push({
3839
+ ...item,
3840
+ bytes: (await statSessionLog(src))?.size ?? item.bytes
3841
+ });
3842
+ else failures.push({
3843
+ workspaceKey: item.workspaceKey,
3844
+ sessionId: item.sessionId,
3845
+ error: "源目录不存在"
3846
+ });
3847
+ } catch (error) {
3848
+ failures.push({
3849
+ workspaceKey: item.workspaceKey,
3850
+ sessionId: item.sessionId,
3851
+ error: "源目录不存在"
3852
+ });
3853
+ }
3854
+ continue;
3855
+ }
3856
+ try {
3857
+ await mkdir(dirname(dst), { recursive: true });
3858
+ await rename(src, dst);
3859
+ const bytes = (await statSessionLog(dst))?.size ?? item.bytes;
3860
+ moved.push({
3861
+ ...item,
3862
+ bytes
3863
+ });
3864
+ } catch (error) {
3865
+ failures.push({
3866
+ workspaceKey: item.workspaceKey,
3867
+ sessionId: item.sessionId,
3868
+ error: String(error)
3869
+ });
3870
+ }
3871
+ }
3872
+ if (moved.length && !dryRun) await appendMoveManifest(archive, moved);
3873
+ return {
3874
+ moved,
3875
+ failures,
3876
+ reclaimedBytes: moved.reduce((sum, i) => sum + i.bytes, 0)
3877
+ };
3878
+ }
3879
+ /** 恢复单个会话:归档 → sessions。目标已存在则拒绝(不覆盖)。 */
3880
+ async function restoreSession(home, archive, workspaceKey, sessionId) {
3881
+ if (!validDirName(workspaceKey)) return {
3882
+ ok: false,
3883
+ error: "工作区键不合法"
3884
+ };
3885
+ const seg = sessionIdSegment(sessionId);
3886
+ const root = sessionsRoot(home);
3887
+ const src = join(archive, workspaceKey, seg);
3888
+ const dst = join(root, workspaceKey, seg);
3889
+ if (!withinDir(src, archive) || !withinDir(dst, root)) return {
3890
+ ok: false,
3891
+ error: "路径越界,拒绝执行"
3892
+ };
3893
+ try {
3894
+ await stat(src);
3895
+ } catch (error) {
3896
+ return {
3897
+ ok: false,
3898
+ error: "归档中不存在该会话"
3899
+ };
3900
+ }
3901
+ try {
3902
+ await stat(dst);
3903
+ return {
3904
+ ok: false,
3905
+ error: "目标位置已存在同名会话"
3906
+ };
3907
+ } catch (error) {}
3908
+ try {
3909
+ await mkdir(dirname(dst), { recursive: true });
3910
+ await rename(src, dst);
3911
+ return { ok: true };
3912
+ } catch (error) {
3913
+ return {
3914
+ ok: false,
3915
+ error: String(error)
3916
+ };
3917
+ }
3918
+ }
3919
+ /** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
3920
+ async function purgeArchive(archive, olderThanDays) {
3921
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1e3;
3922
+ let removed = 0;
3923
+ let reclaimedBytes = 0;
3924
+ const wsDirs = await readdir(archive, { withFileTypes: true }).catch(() => []);
3925
+ for (const ws of wsDirs) {
3926
+ if (!ws.isDirectory()) continue;
3927
+ const wsDir = join(archive, ws.name);
3928
+ const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
3929
+ for (const sd of sessionDirs) {
3930
+ if (!sd.isDirectory()) continue;
3931
+ const info = await statSessionLog(join(wsDir, sd.name));
3932
+ if (!info || info.mtimeMs > cutoff) continue;
3933
+ try {
3934
+ await rm(join(wsDir, sd.name), {
3935
+ recursive: true,
3936
+ force: true
3937
+ });
3938
+ removed += 1;
3939
+ reclaimedBytes += info.size;
3940
+ } catch (error) {}
3941
+ }
3942
+ }
3943
+ return {
3944
+ removed,
3945
+ reclaimedBytes
3946
+ };
3947
+ }
3948
+ /** 当前会话持久化体积(对话头部指示器用):仅 stat,近零成本。 */
3949
+ async function sessionSizeOf(home, cwd, sessionId) {
3950
+ const dir = join(sessionsRoot(home), sessionWorkspaceKey(cwd), sessionIdSegment(sessionId));
3951
+ for (const name of LOG_NAMES) try {
3952
+ const info = await stat(join(dir, name));
3953
+ if (info.isFile()) return {
3954
+ bytes: info.size,
3955
+ exists: true
3956
+ };
3957
+ } catch (error) {}
3958
+ return {
3959
+ bytes: 0,
3960
+ exists: false
3961
+ };
3962
+ }
3963
+ /**
3964
+ * 侧车摘要:返回活跃记录数、每文件 pending 计数、归档体积。
3965
+ * agent/脚本取关键字段即可,不必整份读 .dsh-edit-review.json(避免大对象进对话)。
3966
+ * @author ddj 2026年09月02号
3967
+ * @param ctx DSH host 上下文(fs 服务)
3968
+ * @param cwd 工作区路径(null 时返回空摘要)
3969
+ */
3970
+ async function sidecarSummaryOf(ctx, cwd) {
3971
+ const fs = ctx.get("fs");
3972
+ const summary = {
3973
+ active: 0,
3974
+ pendingByFile: [],
3975
+ archiveBytes: 0
3976
+ };
3977
+ if (!fs || !cwd) return summary;
3978
+ try {
3979
+ const recs = parseSidecar(await fs.readText(await fs.resolve(SIDECAR, { cwd })))?.workspaces?.[cwd]?.records;
3980
+ if (recs) {
3981
+ const byFile = /* @__PURE__ */ new Map();
3982
+ let active = 0;
3983
+ for (const rec of Object.values(recs)) {
3984
+ if (rec.archived) continue;
3985
+ active += 1;
3986
+ const sm = recSummary(rec);
3987
+ if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending);
3988
+ }
3989
+ summary.active = active;
3990
+ summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({
3991
+ path,
3992
+ pending
3993
+ })).sort((a, b) => b.pending - a.pending);
3994
+ }
3995
+ try {
3996
+ summary.archiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
3997
+ } catch (error) {}
3998
+ } catch (error) {}
3999
+ return summary;
4000
+ }
4001
+ /** 解析归档 manifest(损坏/缺失 → 空)。 */
4002
+ function parseMoveManifest(text) {
4003
+ if (!text) return {
4004
+ version: 1,
4005
+ items: []
4006
+ };
4007
+ try {
4008
+ const data = JSON.parse(text);
4009
+ return {
4010
+ version: 1,
4011
+ items: Array.isArray(data.items) ? data.items : []
4012
+ };
4013
+ } catch (error) {
4014
+ return {
4015
+ version: 1,
4016
+ items: []
4017
+ };
4018
+ }
4019
+ }
4020
+ /** 追加移出记录到归档 manifest(best-effort,失败不影响主流程)。 */
4021
+ async function appendMoveManifest(archive, items) {
4022
+ try {
4023
+ const file = join(archive, ".manifest.json");
4024
+ const prev = parseMoveManifest(await readFile(file, "utf8").catch(() => null));
4025
+ const at = (/* @__PURE__ */ new Date()).toISOString();
4026
+ for (const item of items) prev.items.push({
4027
+ at,
4028
+ workspaceKey: item.workspaceKey,
4029
+ sessionId: item.sessionId
4030
+ });
4031
+ await mkdir(archive, { recursive: true });
4032
+ await writeFile(file, JSON.stringify(prev, null, 2), "utf8");
4033
+ } catch (error) {}
4034
+ }
4035
+ //#endregion
4036
+ //#region src/perfPatch.ts
4037
+ /**
4038
+ * dsh-vscode-mode host — profile patch 压缩调优(文本行级,零 YAML 依赖)。
4039
+ * 在 ~/.dsh/profiles/<profile>/cordis.patch.yml 末尾追加/移除一段带标记注释的
4040
+ * id 定向覆盖块(compaction-basic / tool-result-pruner 更低阈值)。
4041
+ * 纯文本函数可单测:幂等追加、整段移除、无块原样返回。
4042
+ * 作者 ddj 2026-09-02
4043
+ */
4044
+ /** 性能块起始标记注释(文件内唯一锚点)。 */
4045
+ const PERF_MARK_START = "# dsh-vscode-mode perf tuning (start)";
4046
+ /** 性能块结束标记注释。 */
4047
+ const PERF_MARK_END = "# dsh-vscode-mode perf tuning (end)";
4048
+ /**
4049
+ * 建议的压缩调优 YAML 块(顶层 loader patch 条目,id 定向覆盖)。
4050
+ * - compaction-basic:thresholdRatio 0.8→0.6、retainRatio 0.16→0.12(更早触发压缩);
4051
+ * - tool-result-pruner:thresholdChars 8192→4096、head/tail 同步收窄(大 tool result 提前截断)。
4052
+ * 注意:压缩只降低模型可见上下文压力,不重写已持久化会话日志(append-only)。
4053
+ * @author ddj 2026年09月02号
4054
+ * @returns 带前后标记注释与尾换行的 YAML 块
4055
+ */
4056
+ function perfConfigBlock() {
4057
+ return [
4058
+ PERF_MARK_START,
4059
+ "# 更早触发会话内压缩:降低模型可见上下文压力(仅影响后续轮次,不缩小已持久化日志)。",
4060
+ "- id: compaction-basic",
4061
+ " config:",
4062
+ " thresholdRatio: 0.6",
4063
+ " retainRatio: 0.12",
4064
+ "- id: tool-result-pruner",
4065
+ " config:",
4066
+ " thresholdChars: 4096",
4067
+ " headChars: 2048",
4068
+ " tailChars: 512",
4069
+ PERF_MARK_END,
4070
+ ""
4071
+ ].join("\n");
4072
+ }
4073
+ /** 当前文本是否已包含性能块。 */
4074
+ function patchHasPerfConfig(text) {
4075
+ return text.includes(PERF_MARK_START);
4076
+ }
4077
+ /**
4078
+ * 移除性能块(起止标记注释之间的整段,含所在行),无块时原样返回。
4079
+ * 移除后压缩多余空行、确保以单个换行结尾。
4080
+ * @author ddj 2026年09月02号
4081
+ * @param text 原 patch 文本
4082
+ * @returns 移除后的文本
4083
+ */
4084
+ function patchRemovePerfConfig(text) {
4085
+ const start = text.indexOf(PERF_MARK_START);
4086
+ if (start === -1) return text;
4087
+ const end = text.indexOf(PERF_MARK_END, start);
4088
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
4089
+ if (end === -1) return text.slice(0, lineStart).replace(/\n+$/, "\n");
4090
+ const after = (() => {
4091
+ const nl = text.indexOf("\n", end);
4092
+ return nl === -1 ? text.length : nl + 1;
4093
+ })();
4094
+ return (text.slice(0, lineStart) + text.slice(after)).replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
4095
+ }
4096
+ /**
4097
+ * 幂等追加性能块:已有块先整段移除再追加到文件末尾(保证全文件只有一份、位置固定)。
4098
+ * @author ddj 2026年09月02号
4099
+ * @param text 原 patch 文本
4100
+ * @returns 追加后的文本
4101
+ */
4102
+ function patchInsertPerfConfig(text) {
4103
+ return patchRemovePerfConfig(text).replace(/\n+$/, "") + "\n" + perfConfigBlock();
4104
+ }
4105
+ //#endregion
2929
4106
  //#region src/rpc.ts
2930
4107
  /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
2931
4108
  const debugBuffers = /* @__PURE__ */ new Map();
@@ -2944,13 +4121,12 @@ const STALE_CHECK_MIN_MS = 1e4;
2944
4121
  /**
2945
4122
  * 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
2946
4123
  * 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
2947
- * @author ddj 2026年08月26号
4124
+ * @author ddj 2026年08月26号 / 2026年09月01号
2948
4125
  * @param ctx DSH 上下文
2949
- * @param cwd 工作区(日志文件按工作区旁车存放)
2950
- * @param policy 会话沙箱策略(入队时刻捕获)
4126
+ * @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
2951
4127
  * @param line 单条日志文本(不含换行)
2952
4128
  */
2953
- function enqueueDebug(ctx, cwd, policy, line) {
4129
+ function enqueueDebug(ctx, cwd, line) {
2954
4130
  const st = debugBuffers.get(cwd) ?? {
2955
4131
  lines: [],
2956
4132
  len: 0,
@@ -2963,7 +4139,7 @@ function enqueueDebug(ctx, cwd, policy, line) {
2963
4139
  const batch = st.lines;
2964
4140
  st.lines = [];
2965
4141
  st.len = 0;
2966
- flushDebug(ctx, cwd, policy, batch);
4142
+ flushDebug(cwd, batch);
2967
4143
  };
2968
4144
  if (st.len >= DEBUG_BUF_CAP) {
2969
4145
  if (st.timer) clearTimeout(st.timer);
@@ -2971,24 +4147,27 @@ function enqueueDebug(ctx, cwd, policy, line) {
2971
4147
  } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
2972
4148
  debugBuffers.set(cwd, st);
2973
4149
  }
4150
+ /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
4151
+ const debugLegacyCleaned = /* @__PURE__ */ new Set();
2974
4152
  /**
2975
4153
  * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
2976
4154
  * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
2977
- * @author ddj 2026年08月26号
2978
- * @param ctx DSH 上下文
2979
- * @param cwd 工作区
2980
- * @param policy 会话沙箱策略(enqueue 时刻捕获)
4155
+ * @author ddj 2026年08月26号 / 2026年09月01号
4156
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
2981
4157
  * @param batch 本批日志行
2982
4158
  */
2983
- function flushDebug(ctx, cwd, policy, batch) {
4159
+ function flushDebug(cwd, batch) {
2984
4160
  const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
2985
4161
  try {
2986
- const fs = ctx.get("fs");
2987
- if (!fs) return;
2988
- const target = await fs.resolve(".dsh-edit-review-debug.log", { cwd });
2989
- const appended = await fs.readText(target).catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
4162
+ await mkdir(pluginLogRoot(), { recursive: true });
4163
+ const target = debugLogFile(cwd);
4164
+ const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
2990
4165
  const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
2991
- await fs.writeText(target, next, void 0, void 0, policy);
4166
+ await writeFile(target, next, "utf8");
4167
+ if (!debugLegacyCleaned.has(cwd)) {
4168
+ debugLegacyCleaned.add(cwd);
4169
+ await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
4170
+ }
2992
4171
  } catch (e) {}
2993
4172
  });
2994
4173
  debugWriteQueues.set(cwd, task);
@@ -3073,6 +4252,27 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
3073
4252
  if (changed) await saveBucket(ctx, cwd, bucket, session);
3074
4253
  return results;
3075
4254
  }
4255
+ /** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
4256
+ function activeSessionIds(ctx) {
4257
+ const sessions = ctx.get("sessions");
4258
+ if (!sessions || typeof sessions.list !== "function") return /* @__PURE__ */ new Set();
4259
+ return new Set(sessions.list().map((s) => s.id).filter(Boolean));
4260
+ }
4261
+ /** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
4262
+ async function patchFileInfo() {
4263
+ const profileDir = findProfileDir();
4264
+ if (!profileDir) return {};
4265
+ return {
4266
+ profileDir,
4267
+ patchPath: join(profileDir, "cordis.patch.yml")
4268
+ };
4269
+ }
4270
+ /** 最新一次压缩配置备份文件名(cordis.patch.yml.bak-<ts>,按名倒序取新)。 */
4271
+ async function latestPatchBackup(patchPath) {
4272
+ const dir = join(patchPath, "..");
4273
+ const base = basename(patchPath);
4274
+ return (await readdir(dir).catch(() => [])).filter((n) => n.startsWith(base + ".bak-")).sort().reverse()[0];
4275
+ }
3076
4276
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
3077
4277
  function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)) {
3078
4278
  return {
@@ -3261,6 +4461,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
3261
4461
  try {
3262
4462
  const target = await resolveTarget(ctx, sc.session, args.path);
3263
4463
  await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session));
4464
+ invalidateIndex(ctx, sc.cwd, args.path);
3264
4465
  const bucket = await bucketOf(registry, ctx, sc.cwd);
3265
4466
  let changed = false;
3266
4467
  for (const rec of bucket.values()) if (rec.path === args.path && rec.superseded !== true) {
@@ -3397,7 +4598,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
3397
4598
  error: sc.err
3398
4599
  };
3399
4600
  const text = String(args.text ?? "");
3400
- enqueueDebug(ctx, sc.cwd, policyOf(ctx, sc.session), text);
4601
+ enqueueDebug(ctx, sc.cwd, text);
3401
4602
  console.error("[edrv-debug] " + text);
3402
4603
  return { ok: true };
3403
4604
  },
@@ -3454,40 +4655,22 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
3454
4655
  ok: false,
3455
4656
  error: sc.err
3456
4657
  };
3457
- const fs = ctx.get("fs");
3458
- if (!fs) return {
4658
+ const rel = normalizeRel(args.path);
4659
+ if (rel === null) return {
3459
4660
  ok: false,
3460
- error: "缺少 fs"
4661
+ error: "路径不合法"
4662
+ };
4663
+ const res = await listDirCached(ctx, sc.cwd, rel, args.force === true);
4664
+ if ("error" in res) return {
4665
+ ok: false,
4666
+ error: res.error
4667
+ };
4668
+ return {
4669
+ ok: true,
4670
+ root: res.root,
4671
+ path: rel,
4672
+ entries: res.entries
3461
4673
  };
3462
- try {
3463
- const rel = normalizeRel(args.path);
3464
- if (rel === null) return {
3465
- ok: false,
3466
- error: "路径不合法"
3467
- };
3468
- const target = await resolveTarget(ctx, sc.session, rel || ".");
3469
- const info = await fs.stat(target);
3470
- if (!info) return {
3471
- ok: false,
3472
- error: "目录不存在"
3473
- };
3474
- if (info.type !== "directory") return {
3475
- ok: false,
3476
- error: "不是目录"
3477
- };
3478
- const children = await fs.listDir(target);
3479
- return {
3480
- ok: true,
3481
- root: fs.processPath(await fs.resolve(".", { cwd: sc.cwd })),
3482
- path: rel,
3483
- entries: toTreeEntries(rel, children)
3484
- };
3485
- } catch (error) {
3486
- return {
3487
- ok: false,
3488
- error: "读取目录失败:" + String(error)
3489
- };
3490
- }
3491
4674
  },
3492
4675
  "edrv.revealInExplorer": async (args) => {
3493
4676
  const sc = await requireSession(ctx, args.sessionId);
@@ -3705,6 +4888,211 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
3705
4888
  error: String(error)
3706
4889
  };
3707
4890
  }
4891
+ },
4892
+ "edrv.perf.inventory": async () => {
4893
+ try {
4894
+ const home = dshHome();
4895
+ const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
4896
+ const active = activeSessionIds(ctx);
4897
+ markActiveSessions(inventory.sessions, active);
4898
+ return {
4899
+ ok: true,
4900
+ ...inventory,
4901
+ activeIds: [...active]
4902
+ };
4903
+ } catch (error) {
4904
+ return {
4905
+ ok: false,
4906
+ error: "盘点失败:" + String(error)
4907
+ };
4908
+ }
4909
+ },
4910
+ "edrv.perf.sessionSize": async (args) => {
4911
+ try {
4912
+ return {
4913
+ ok: true,
4914
+ ...await sessionSizeOf(dshHome(), args.cwd, args.sessionId ?? "")
4915
+ };
4916
+ } catch (error) {
4917
+ return {
4918
+ ok: false,
4919
+ error: "读取会话体积失败:" + String(error)
4920
+ };
4921
+ }
4922
+ },
4923
+ "edrv.perf.movePlan": async (args) => {
4924
+ try {
4925
+ const home = dshHome();
4926
+ const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
4927
+ markActiveSessions(inventory.sessions, activeSessionIds(ctx));
4928
+ return {
4929
+ ok: true,
4930
+ ...planMoveOut(inventory, {
4931
+ workspaceKey: args.workspaceKey,
4932
+ sessionIds: args.sessionIds,
4933
+ minBytes: args.minBytes,
4934
+ olderThanDays: args.olderThanDays
4935
+ })
4936
+ };
4937
+ } catch (error) {
4938
+ return {
4939
+ ok: false,
4940
+ error: "移出规划失败:" + String(error)
4941
+ };
4942
+ }
4943
+ },
4944
+ "edrv.perf.moveOut": async (args) => {
4945
+ try {
4946
+ const home = dshHome();
4947
+ const items = (args.sessionIds ?? []).map((sessionId) => ({
4948
+ workspaceKey: args.workspaceKey,
4949
+ sessionId,
4950
+ bytes: 0
4951
+ }));
4952
+ return {
4953
+ ok: true,
4954
+ ...await moveOutSessions(home, sessionsArchiveRoot(home), items, activeSessionIds(ctx), args.dryRun === true)
4955
+ };
4956
+ } catch (error) {
4957
+ return {
4958
+ ok: false,
4959
+ error: "移出失败:" + String(error)
4960
+ };
4961
+ }
4962
+ },
4963
+ "edrv.perf.restore": async (args) => {
4964
+ try {
4965
+ const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId);
4966
+ return result.ok ? {
4967
+ ok: true,
4968
+ restored: true
4969
+ } : {
4970
+ ok: false,
4971
+ error: result.error ?? "恢复失败"
4972
+ };
4973
+ } catch (error) {
4974
+ return {
4975
+ ok: false,
4976
+ error: "恢复失败:" + String(error)
4977
+ };
4978
+ }
4979
+ },
4980
+ "edrv.perf.purgeArchive": async (args) => {
4981
+ try {
4982
+ return {
4983
+ ok: true,
4984
+ ...await purgeArchive(sessionsArchiveRoot(dshHome()), args.olderThanDays)
4985
+ };
4986
+ } catch (error) {
4987
+ return {
4988
+ ok: false,
4989
+ error: "清除失败:" + String(error)
4990
+ };
4991
+ }
4992
+ },
4993
+ "edrv.perf.sidecarSummary": async (args) => {
4994
+ const sc = await requireSession(ctx, args.sessionId);
4995
+ const cwd = "err" in sc ? null : sc.cwd;
4996
+ try {
4997
+ return {
4998
+ ok: true,
4999
+ ...await sidecarSummaryOf(ctx, cwd)
5000
+ };
5001
+ } catch (error) {
5002
+ return {
5003
+ ok: false,
5004
+ error: "读取侧车摘要失败:" + String(error)
5005
+ };
5006
+ }
5007
+ },
5008
+ "edrv.perf.configGet": async () => {
5009
+ try {
5010
+ const { profileDir, patchPath } = await patchFileInfo();
5011
+ const block = perfConfigBlock();
5012
+ if (!patchPath) return {
5013
+ ok: true,
5014
+ applied: false,
5015
+ block
5016
+ };
5017
+ const text = await readFile(patchPath, "utf8").catch(() => "");
5018
+ const backup = await latestPatchBackup(patchPath);
5019
+ return {
5020
+ ok: true,
5021
+ profileDir,
5022
+ patchPath,
5023
+ applied: patchHasPerfConfig(text),
5024
+ block,
5025
+ backup
5026
+ };
5027
+ } catch (error) {
5028
+ return {
5029
+ ok: false,
5030
+ error: "读取压缩配置失败:" + String(error)
5031
+ };
5032
+ }
5033
+ },
5034
+ "edrv.perf.configApply": async () => {
5035
+ try {
5036
+ const { patchPath } = await patchFileInfo();
5037
+ if (!patchPath) return {
5038
+ ok: false,
5039
+ error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
5040
+ };
5041
+ const text = await readFile(patchPath, "utf8").catch(() => "");
5042
+ const backup = patchPath + ".bak-" + Date.now();
5043
+ if (text) await writeFile(backup, text, "utf8");
5044
+ await mkdir(join(patchPath, ".."), { recursive: true });
5045
+ await writeFile(patchPath, patchInsertPerfConfig(text), "utf8");
5046
+ return {
5047
+ ok: true,
5048
+ applied: true,
5049
+ backup,
5050
+ restart: true
5051
+ };
5052
+ } catch (error) {
5053
+ return {
5054
+ ok: false,
5055
+ error: "写入压缩配置失败:" + String(error)
5056
+ };
5057
+ }
5058
+ },
5059
+ "edrv.perf.configUndo": async () => {
5060
+ try {
5061
+ const { patchPath } = await patchFileInfo();
5062
+ if (!patchPath) return {
5063
+ ok: false,
5064
+ error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
5065
+ };
5066
+ const backup = await latestPatchBackup(patchPath);
5067
+ if (backup) {
5068
+ const backupPath = join(join(patchPath, ".."), backup);
5069
+ const saved = await readFile(backupPath, "utf8").catch(() => null);
5070
+ if (saved !== null) {
5071
+ await writeFile(patchPath, saved, "utf8");
5072
+ await rm(backupPath, { force: true }).catch(() => {});
5073
+ return {
5074
+ ok: true,
5075
+ restored: true,
5076
+ backup
5077
+ };
5078
+ }
5079
+ }
5080
+ const text = await readFile(patchPath, "utf8").catch(() => "");
5081
+ if (!patchHasPerfConfig(text)) return {
5082
+ ok: false,
5083
+ error: "没有可撤销的压缩配置"
5084
+ };
5085
+ await writeFile(patchPath, patchRemovePerfConfig(text), "utf8");
5086
+ return {
5087
+ ok: true,
5088
+ restored: true
5089
+ };
5090
+ } catch (error) {
5091
+ return {
5092
+ ok: false,
5093
+ error: "撤销压缩配置失败:" + String(error)
5094
+ };
5095
+ }
3708
5096
  }
3709
5097
  };
3710
5098
  }
@@ -3898,10 +5286,12 @@ function apply(ctx, config) {
3898
5286
  dropFileIndex(cwd);
3899
5287
  searcher.dispose(cwd);
3900
5288
  contentSearcher.dispose(cwd);
5289
+ disposeIndex(cwd);
3901
5290
  }
3902
5291
  });
3903
5292
  registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher), (warning) => warnings.push(warning));
3904
5293
  installIsolation(ctx);
5294
+ sweepTreeCache();
3905
5295
  logCompatSummary(ctx, warnings);
3906
5296
  ctx.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)");
3907
5297
  }