dsh-vscode-mode 0.21.1 → 0.21.2

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
@@ -464,1435 +464,1468 @@ var McpBridge = class McpBridge {
464
464
  }
465
465
  };
466
466
  //#endregion
467
- //#region src/mcpPool.ts
468
- /** Connection ownership, bounded cold starts and workspace leases. @author ddj 2026年10月20号 */
469
- /** @private @author ddj 2026年10月20号 @param input Plain effective configuration. @returns Stable secret-bearing identity used only as digest input. */
470
- function stableConfig(input) {
471
- if (Array.isArray(input)) return "[" + input.map(stableConfig).join(",") + "]";
472
- if (input && typeof input === "object") {
473
- const config = input;
474
- return "{" + Object.keys(config).sort().map((key) => JSON.stringify(key) + ":" + stableConfig(config[key])).join(",") + "}";
475
- }
476
- return JSON.stringify(input) ?? "null";
467
+ //#region src/shared/rpc.ts
468
+ /** webServer 精确路由。 */
469
+ const RPC_PATH = "/edrv/rpc";
470
+ /**
471
+ * 图片扩展名(小写、无点)→ MIME。edrv.read base64 响应与 client 图片预览共用,
472
+ * 收敛在此避免 host/client 两处漂移。
473
+ * @author ddj 2026年09月08号
474
+ */
475
+ const IMAGE_MIME = {
476
+ png: "image/png",
477
+ jpg: "image/jpeg",
478
+ jpeg: "image/jpeg",
479
+ gif: "image/gif",
480
+ webp: "image/webp",
481
+ bmp: "image/bmp",
482
+ ico: "image/x-icon",
483
+ avif: "image/avif",
484
+ svg: "image/svg+xml"
485
+ };
486
+ /**
487
+ * 路径 → 图片 MIME:按 basename 扩展名小写查 {@link IMAGE_MIME},未命中回退 application/octet-stream。
488
+ * @author ddj 2026年09月08号
489
+ * @param path 文件路径(`/` 或 `\` 分隔均可)
490
+ * @returns MIME 字符串
491
+ */
492
+ function imageMimeOf(path) {
493
+ const base = String(path || "").split(/[\\/]/).pop() || "";
494
+ const dot = base.lastIndexOf(".");
495
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
496
+ return IMAGE_MIME[ext] ?? "application/octet-stream";
477
497
  }
478
- /** @public @author ddj 2026年10月20号 @param task Shared work that must remain alive for other waiters. @param signal One waiter's cancellation. @returns Caller-local wait without abandoning owned pool work. */
479
- function waitMcp(task, signal) {
480
- if (!signal) return task;
481
- return new Promise((resolve, reject) => {
482
- /** @author ddj 2026年10月20号 Rejects only this waiter. */
483
- const abort = () => {
484
- reject(/* @__PURE__ */ new Error("MCP 等待已取消"));
485
- };
486
- if (signal.aborted) {
487
- abort();
488
- return;
489
- }
490
- signal.addEventListener("abort", abort, { once: true });
491
- task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
492
- });
498
+ /** PDF 统一 MIME(host base64 响应与 client PDF 面板同源,避免两处漂移)。 */
499
+ const PDF_MIME = "application/pdf";
500
+ /**
501
+ * 会话级前置错误的规范文案:host requireSession 系与 client 错误识别共用同一常量,
502
+ * 避免文案漂移导致 client 端分类失效。跨端场景(桌面端/Web 端各自运行独立 DSH 实例、
503
+ * 会话列表互通但会话仅在一端激活)下,本端 host 取不到会话即返回「会话不存在」。
504
+ * @author ddj 2026年10月16号
505
+ */
506
+ const SESSION_MISSING_ERROR = "会话不存在";
507
+ /** 会话存在但无工作区 cwd 时的规范文案(与 SESSION_MISSING_ERROR 同源共用)。 */
508
+ const SESSION_NO_WORKSPACE_ERROR = "会话无工作区";
509
+ /**
510
+ * 路径 → 二进制预览 MIME:PDF 特判 {@link PDF_MIME},其余走图片表回退 octet-stream。
511
+ * @author ddj 2026年09月22号
512
+ * @param path 文件路径(`/` 或 `\` 分隔均可)
513
+ * @returns MIME 字符串
514
+ */
515
+ function binaryMimeOf(path) {
516
+ const base = String(path || "").split(/[\\/]/).pop() || "";
517
+ const dot = base.lastIndexOf(".");
518
+ if (dot > 0 && base.slice(dot + 1).toLowerCase() === "pdf") return PDF_MIME;
519
+ return imageMimeOf(path);
493
520
  }
494
- /** The pool owns private scopes independently from any first subscriber. */
495
- var McpPool = class {
496
- ctx;
497
- load;
498
- make;
499
- entries = /* @__PURE__ */ new Map();
500
- owners = /* @__PURE__ */ new WeakMap();
501
- ownerSeq = 0;
502
- running = 0;
503
- queue = [];
504
- jobs = /* @__PURE__ */ new Set();
505
- stopped = false;
506
- /** @public @author ddj 2026年10月20号 @param ctx Host lifecycle owner. @param load Official plugin loader. @param make Private scope factory. */
507
- constructor(ctx, load, make = () => McpBridge.create(ctx)) {
508
- this.ctx = ctx;
509
- this.load = load;
510
- this.make = make;
511
- }
512
- /** @private @author ddj 2026年10月20号 @param spec Effective request. @returns Private digest; never emit configuration or secrets. */
513
- keyOf(spec) {
514
- let owner = 0;
515
- if (spec.policy.sharing === "agent") {
516
- if (!this.owners.has(spec.owner)) this.owners.set(spec.owner, ++this.ownerSeq);
517
- owner = this.owners.get(spec.owner);
518
- }
519
- const workspace = process.platform === "win32" ? spec.workspace.replace(/\\/g, "/").toLowerCase() : spec.workspace;
520
- return createHash("sha256").update(stableConfig([
521
- workspace,
522
- owner,
523
- spec.config,
524
- spec.policy
525
- ])).digest("hex");
526
- }
527
- /** @public @author ddj 2026年10月20号 @param spec Authorized connection request. @returns Reference-counted single-flight lease. */
528
- acquire(spec) {
529
- if (this.stopped) throw new Error("MCP runtime is disposed");
530
- const key = this.keyOf(spec);
531
- let entry = this.entries.get(key);
532
- if (entry?.phase === "error" && entry.retryAt !== void 0 && Date.now() >= entry.retryAt && !entry.close) {
533
- this.entries.delete(key);
534
- entry = void 0;
535
- }
536
- if (entry && !entry.live) throw new Error(entry.error ?? "MCP 旧连接仍在释放,暂不能创建替代实例");
537
- if (!entry) {
538
- entry = {
539
- key,
540
- spec,
541
- refs: 0,
542
- phase: "queued",
543
- queuedAt: Date.now(),
544
- phaseAt: Date.now(),
545
- ready: Promise.resolve(),
546
- live: true,
547
- calls: Promise.resolve()
548
- };
549
- this.entries.set(key, entry);
550
- entry.ready = this.schedule(entry);
551
- entry.ready.catch(() => void 0);
552
- }
553
- if (entry.idle) clearTimeout(entry.idle);
554
- entry.idle = void 0;
555
- entry.refs += 1;
556
- let released = false;
557
- return {
558
- entry,
559
- release: () => {
560
- if (released) return;
561
- released = true;
562
- this.release(entry);
563
- }
564
- };
565
- }
566
- /** @private @author ddj 2026年10月20号 @param entry New entry. @returns Queued initialization, bounded to two concurrent starts. */
567
- schedule(entry) {
568
- return new Promise((resolve, reject) => {
569
- /** @author ddj 2026年10月20号 Initializes a still-owned queue item. */
570
- const run = async () => {
571
- try {
572
- await this.start(entry);
573
- resolve();
574
- } catch (error) {
575
- reject(error);
576
- }
577
- };
578
- this.queue.push(run);
579
- this.pump();
580
- });
581
- }
582
- /** @private @author ddj 2026年10月20号 Advances FIFO work; each task holds its slot until startup or cleanup quiesces. */
583
- pump() {
584
- while (this.running < 2 && this.queue.length) {
585
- const run = this.queue.shift();
586
- this.running += 1;
587
- const job = run().finally(() => {
588
- this.running -= 1;
589
- this.jobs.delete(job);
590
- this.pump();
591
- });
592
- this.jobs.add(job);
593
- }
594
- }
595
- /** @private @author ddj 2026年10月20号 @param entry Connection to initialize. @returns Initialization completion, or failure after owned cleanup. */
596
- async start(entry) {
597
- if (!entry.live || this.stopped) throw new Error("MCP 启动已取消");
598
- entry.phase = "loading";
599
- entry.phaseAt = Date.now();
600
- entry.startAt = entry.phaseAt;
601
- let timer;
602
- const work = this.mount(entry);
603
- const deadline = new Promise((_resolve, reject) => {
604
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error("MCP 初始化超时")), entry.spec.policy.startupTimeoutMs);
605
- timer.unref?.();
606
- });
607
- try {
608
- await Promise.race([work, deadline]);
609
- if (!entry.live) throw new Error("MCP 启动已取消");
610
- entry.phase = "ready";
611
- entry.finishedAt = Date.now();
612
- entry.connectedAt = entry.finishedAt;
613
- } catch (error) {
614
- entry.error = "MCP 初始化失败或超时,请查看 Host 日志";
615
- this.ctx.logger?.warn?.("MCP initialization failed: " + String(error));
616
- entry.live = false;
617
- entry.phase = "stopping";
618
- const closing = this.closeBridge(entry);
619
- await Promise.allSettled([closing, work]);
620
- entry.phase = "error";
621
- entry.finishedAt = Date.now();
622
- entry.retryAt = Date.now() + 5e3;
623
- throw new Error(entry.error);
624
- } finally {
625
- if (timer) clearTimeout(timer);
626
- }
627
- }
628
- /** @private @author ddj 2026年10月20号 @param entry Owned generation. @returns Official initial sync; never publishes after invalidation. */
629
- async mount(entry) {
630
- const official = await this.load();
631
- if (!entry.live || this.stopped) return;
632
- const bridge = await this.make();
633
- if (!entry.live || this.stopped) {
634
- await bridge.dispose();
635
- return;
636
- }
637
- entry.bridge = bridge;
638
- entry.phase = "starting";
639
- entry.phaseAt = Date.now();
640
- entry.fiber = bridge.scope.ctx.plugin(official, entry.spec.config);
641
- await entry.fiber;
642
- if (entry.fiber?.state !== void 0 && entry.fiber.state !== 2) throw new Error("MCP 插件未完成激活");
643
- }
644
- /** @private @author ddj 2026年10月20号 @param entry Connection to close. @returns Shared closure barrier; rejection retains a fenced entry. */
645
- async closeBridge(entry) {
646
- if (entry.close) return entry.close;
647
- const close = entry.bridge?.dispose() ?? Promise.resolve();
648
- entry.close = close;
649
- try {
650
- await close;
651
- entry.bridge = void 0;
652
- entry.fiber = void 0;
653
- entry.close = void 0;
654
- } catch (error) {
655
- entry.error = "MCP 释放未确认,禁止重建,请查看 Host 日志";
656
- this.ctx.logger?.warn?.("MCP cleanup failed: " + String(error));
657
- throw error;
658
- }
521
+ //#endregion
522
+ //#region src/paths.ts
523
+ /**
524
+ * dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
525
+ * 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
526
+ * - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
527
+ * user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
528
+ * - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
529
+ * (DSH_HOME → ~/.dsh)
530
+ * - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
531
+ * 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
532
+ * 作者 ddj 2026-09-01
533
+ */
534
+ /** 插件 id(也是 .dsh 下插件工作区目录名)。 */
535
+ const PLUGIN_ID = "dsh-vscode-mode";
536
+ /** DSH 工作区目录名(默认 home 下)。 */
537
+ const DSH_DIR_NAME = ".dsh";
538
+ /** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
539
+ const DSH_HOME_ENV = "DSH_HOME";
540
+ /** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
541
+ const CACHE_TOTAL_CAP = 209715200;
542
+ /** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
543
+ const SIDECAR = ".dsh-edit-review.json";
544
+ /** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
545
+ const DEBUG_LOG = ".dsh-edit-review-debug.log";
546
+ /** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
547
+ const LEGACY_TREE_SIDECARS = [".dsh-edit-review-tree.json", ".dsh-vscode-mode-tree.json"];
548
+ /** 路由前缀(兼容性检查用)。 */
549
+ const ROUTE_PREFIX = "/edrv";
550
+ /** Monaco 静态资源前缀。 */
551
+ const VENDOR_PREFIX = "/edrv/vendor";
552
+ /** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
553
+ function hashOf(text) {
554
+ return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
555
+ }
556
+ /**
557
+ * 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
558
+ * 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
559
+ * 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
560
+ * @author ddj 2026年09月02号
561
+ * @param raw 原始路径段(非空)
562
+ * @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
563
+ */
564
+ function encodeSegment(raw) {
565
+ if (raw.length === 0) throw new Error("cannot encode an empty path segment");
566
+ if (raw === ".") return "~002E";
567
+ if (raw === "..") return "~002E~002E";
568
+ let out = "";
569
+ for (let i = 0; i < raw.length; i++) {
570
+ const code = raw.charCodeAt(i);
571
+ const ch = String.fromCharCode(code);
572
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
573
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
659
574
  }
660
- /** @private @author ddj 2026年10月20号 @param entry One lease's owner. Arms workspace-only warm retention. */
661
- release(entry) {
662
- entry.refs = Math.max(0, entry.refs - 1);
663
- if (entry.refs || !entry.live) return;
664
- if (entry.spec.policy.sharing === "agent") {
665
- this.retire(entry).catch(() => void 0);
666
- return;
575
+ return out;
576
+ }
577
+ /**
578
+ * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
579
+ * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
580
+ * @author ddj 2026年09月02号
581
+ * @param cwd 会话工作区绝对路径(非空)
582
+ * @returns `~/.dsh/sessions` 下的工作区目录名
583
+ */
584
+ function sessionWorkspaceKey(cwd) {
585
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
586
+ let readable = "";
587
+ let separatorRun = false;
588
+ for (let i = 0; i < cwd.length; i++) {
589
+ const code = cwd.charCodeAt(i);
590
+ const ch = String.fromCharCode(code);
591
+ if (ch === "/" || ch === "\\" || ch === ":") {
592
+ if (!separatorRun) readable += "-";
593
+ separatorRun = true;
594
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
595
+ readable += ch;
596
+ separatorRun = false;
597
+ } else {
598
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
599
+ separatorRun = false;
667
600
  }
668
- entry.idle = setTimeout(() => {
669
- this.retire(entry).catch(() => void 0);
670
- }, 6e4);
671
- entry.idle.unref?.();
672
- }
673
- /** @public @author ddj 2026年10月20号 @param entry Invalid generation. @returns Quiescent close; callers may await before starting a replacement. */
674
- async retire(entry) {
675
- entry.live = false;
676
- entry.phase = "stopping";
677
- if (entry.idle) clearTimeout(entry.idle);
678
- await this.closeBridge(entry);
679
- await entry.ready.catch(() => void 0);
680
- await entry.calls.catch(() => void 0);
681
- if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
682
- }
683
- /** @public @author ddj 2026年10月20号 @param entry Authorized connection. @param action Per-call work retaining original execution identity. @param signal Caller cancellation. @returns Serialized shared result; no direct registry bypass is introduced here. */
684
- run(entry, action, signal) {
685
- /** @author ddj 2026年10月20号 Checks cancellation and generation again at actual dispatch. */
686
- const invoke = async () => {
687
- await waitMcp(entry.ready, signal);
688
- if (signal?.aborted || !entry.live || entry.phase !== "ready") throw new Error("MCP 连接不可用或调用已取消");
689
- return action();
690
- };
691
- const result = entry.calls.then(invoke);
692
- entry.calls = result.catch(() => void 0);
693
- return result;
694
- }
695
- /** @public @author ddj 2026年10月20号 @returns Full disposal of entries, queue, timers and owned work. */
696
- async dispose() {
697
- this.stopped = true;
698
- await Promise.all([...this.entries.values()].map((entry) => this.retire(entry)));
699
- await Promise.all([...this.jobs]);
700
601
  }
701
- };
702
- //#endregion
703
- //#region src/mcpDiscover.ts
704
- /** @public @author ddj 2026年10月20号 @param runtime Authorized runtime. @param ctx Host registration context. @returns Global discovery cleanup; normal capability filters remain authoritative. */
705
- function installDiscover(runtime, ctx) {
706
- const tools = ctx.get("tools");
707
- /** @author ddj 2026年10月20号 @param args Validated operation. @param execution Registry-owned caller identity. @returns Safe capability catalogue or real connected schemas. */
708
- const execute = async (args, execution) => {
709
- const agent = execution.agent;
710
- if (!agent || !runtime.agents.has(agent)) throw new Error("会话未激活,不能使用项目 MCP 发现入口");
711
- if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some((key) => !["action", "serverName"].includes(key))) throw new Error("发现入口仅接受 action 和 serverName");
712
- if (!["list", "connect"].includes(args.action)) throw new Error("action 必须为 list 或 connect");
713
- if (args.action === "list") return runtime.catalogue(agent);
714
- if (typeof args.serverName !== "string") throw new Error("请指定已配置的 serverName");
715
- return runtime.connect(agent, args.serverName, execution.signal);
716
- };
717
- /** @author ddj 2026年10月20号 @param _args Unused. @param result Safe canonical JSON. @returns Native content without bypassing output validation. */
718
- const render = (_args, result) => [{
719
- type: "text",
720
- text: JSON.stringify(result)
721
- }];
722
- return tools.register({
723
- name: "discover_project_mcp",
724
- description: "List this workspace’s configured project MCP services without starting them, or connect one to discover its real tools. After connecting, call discovered tools in the NEXT request/program; never guess schemas. Global/preset MCPs are not included.",
725
- parameters: {
726
- type: "object",
727
- properties: {
728
- action: {
729
- type: "string",
730
- enum: ["list", "connect"]
731
- },
732
- serverName: { type: "string" }
733
- },
734
- required: ["action"],
735
- additionalProperties: false
736
- },
737
- output: {
738
- schema: {},
739
- render
740
- },
741
- execute
742
- });
602
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
743
603
  }
744
- /** @public @author ddj 2026年10月20号 @param runtime Active owners. @param agent Scoped catalogue owner. @returns Scoped instruction cleanup; does not register a tool override. */
745
- function discoverPrompt(runtime, agent) {
746
- const prompt = agent.ctx.get("systemPrompt");
747
- /** @author ddj 2026年10月20号 @returns Current enabled names only; no credential-bearing configuration. */
748
- const text = () => {
749
- const names = runtime.catalogue(agent).map((server) => server.serverName);
750
- if (!names.length) return "";
751
- return "Project MCP capabilities (may not be started): " + JSON.stringify(names) + ". Use discover_project_mcp action=list, then action=connect with serverName when needed. Only use returned real tool schemas. Native tools appear on the next model request; PTC must finish the current program and start a new program to refresh its SDK. Do not eval or bypass tool approval.";
752
- };
753
- return prompt.section({
754
- name: "vscode-mode:project-mcp",
755
- order: 3090,
756
- text,
757
- interpolate: false
758
- });
604
+ /** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
605
+ function sessionIdSegment(id) {
606
+ return encodeSegment(id);
759
607
  }
760
- //#endregion
761
- //#region src/shared/diff.ts
762
- /** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
763
- function preciseHunk(record, idx) {
764
- return hunkOf(record, idx);
608
+ /** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
609
+ function sessionsRoot(home = dshHome()) {
610
+ return join(home, "sessions");
765
611
  }
766
- /** 轻量、确定性的字符串指纹;不引入 Node crypto。 */
767
- function fingerprint(text) {
768
- if (typeof text !== "string") return null;
769
- let hash = 2166136261;
770
- for (let i = 0; i < text.length; i++) {
771
- hash ^= text.charCodeAt(i);
772
- hash = Math.imul(hash, 16777619);
773
- }
774
- return "fnv1a:" + (hash >>> 0).toString(16) + ":" + text.length;
612
+ /**
613
+ * 解析 DSH home:空白环境值视为未设置,展开 ~ 并解析绝对路径。
614
+ * @public
615
+ * @author ddj 2026年09月28号
616
+ * @param env 环境映射(缺省 process.env;测试可注入)
617
+ * @returns 归一化的 DSH home 绝对路径
618
+ */
619
+ function dshHome(env = process.env) {
620
+ const value = env[DSH_HOME_ENV];
621
+ const path = value && value.trim() ? value : join(homedir(), DSH_DIR_NAME);
622
+ if (path === "~") return resolve(homedir());
623
+ if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(join(homedir(), path.slice(2)));
624
+ return resolve(path);
775
625
  }
776
- /** 取记录第 idx 个有效 hunk;只有记录缺 hunk 时才回退 callHunk。 */
777
- function hunkOf(record, idx) {
778
- const hunk = Array.isArray(record.hunks) ? record.hunks[idx] : void 0;
779
- if (hunk && typeof hunk.newText === "string") return hunk;
780
- if (idx === 0 && record.callHunk && typeof record.callHunk.newText === "string") return record.callHunk;
781
- return null;
626
+ /** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
627
+ function pluginCacheRoot(home = dshHome()) {
628
+ return join(home, PLUGIN_ID, "cache");
782
629
  }
783
- /** hunk 是否表示没有实际内容变化。 */
784
- function isNoopHunk(hunk) {
785
- return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText;
630
+ /** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
631
+ function workspaceCacheDir(workspaceId, home = dshHome()) {
632
+ return join(pluginCacheRoot(home), "workspace", workspaceId);
633
+ }
634
+ /** 用户级缓存目录(跨工作区共享;当前无实例,预留供全局偏好类缓存使用)。 */
635
+ function userCacheDir(home = dshHome()) {
636
+ return join(pluginCacheRoot(home), "user");
637
+ }
638
+ /** LSP provider 发现结果缓存文件(用户级:user/lsp-specs.v<schema>.json,跨会话/跨重启共享)。 */
639
+ function lspSpecCacheFile(home = dshHome(), schema = 4) {
640
+ return join(userCacheDir(home), "lsp-specs.v" + schema + ".json");
641
+ }
642
+ /** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
643
+ function pluginLogRoot(home = dshHome()) {
644
+ return join(home, PLUGIN_ID, "logs");
645
+ }
646
+ /** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
647
+ function treeCacheFile(cwd, home = dshHome(), schema = 1) {
648
+ return join(workspaceCacheDir(hashOf(cwd), home), "tree.v" + schema + ".json");
786
649
  }
787
650
  /**
788
- * 差异比较口径归一化:剥离 UTF-8 BOM、统一 CRLF 为 LF。
789
- * 背景:外部工具(pwsh/编辑器)可能改变 BOM/行尾,导致 after 指纹与 hunk
790
- * 定位在内容语义未变的情况下误报 conflict,把待确认修改从 UI 排除。
791
- * @author ddj 2026年09月09号
651
+ * 每工作区 UI 状态镜像文件(工作区级:workspace/<id>/ui.v<schema>.json)。
652
+ * 存编辑器页签/侧栏/预览态等界面状态的 localStorage 原值副本,供换 origin 后回填。
653
+ * @author ddj 2026年09月28号
654
+ * @param cwd 工作区绝对路径
655
+ * @param home DSH home(测试可注入)
656
+ * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
657
+ * @returns UI 状态镜像文件绝对路径
792
658
  */
793
- function normalizeForCompare(text) {
794
- return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
659
+ function uiStateFile(cwd, home = dshHome(), schema = 1) {
660
+ return join(workspaceCacheDir(hashOf(cwd), home), "ui.v" + schema + ".json");
661
+ }
662
+ /**
663
+ * 用户级 UI 状态镜像文件(跨工作区共享:user/ui-globals.v<schema>.json)。
664
+ * 存与工作区无关的全局键(侧栏提示已忽略、工作区折叠态等)。
665
+ * @author ddj 2026年09月28号
666
+ * @param home DSH home(测试可注入)
667
+ * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
668
+ * @returns 全局 UI 状态镜像文件绝对路径
669
+ */
670
+ function uiGlobalsFile(home = dshHome(), schema = 1) {
671
+ return join(userCacheDir(home), "ui-globals.v" + schema + ".json");
672
+ }
673
+ /** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
674
+ function debugLogFile(cwd, home = dshHome()) {
675
+ return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
676
+ }
677
+ /**
678
+ * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
679
+ * @author ddj 2026年09月30号
680
+ * @param cwd 工作区绝对路径
681
+ * @param home DSH home
682
+ * @returns 归档数据库绝对路径
683
+ */
684
+ function archiveDbFile(cwd, home = dshHome()) {
685
+ return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
686
+ }
687
+ /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
688
+ function assetsDirOf(moduleUrl) {
689
+ return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
690
+ }
691
+ /** Monaco AMD 静态目录(随包发布)。 */
692
+ function vendorDirOf(moduleUrl) {
693
+ return join(assetsDirOf(moduleUrl), "vendor");
694
+ }
695
+ /**
696
+ * 插件自带技能组根目录(包根 skills/,与 assets/ 同级;随包发布)。
697
+ * 布局:<根>/<技能名>/SKILL.md(目录式)或 <根>/<技能名>.md(扁平式)。
698
+ * @author ddj 2026年09月11号
699
+ * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
700
+ * @returns 技能组根目录绝对路径
701
+ */
702
+ function skillsDirOf(moduleUrl) {
703
+ return join(dirname(fileURLToPath(moduleUrl)), "..", "skills");
704
+ }
705
+ /** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
706
+ function imageDirOf(config, moduleUrl) {
707
+ const cfg = config;
708
+ if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
709
+ return assetsDirOf(moduleUrl);
795
710
  }
796
- /** 归一化一个 hunk 的两侧文本(与 normalizeForCompare 同口径)。 */
797
- function normalizeHunk(hunk) {
711
+ /**
712
+ * 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
713
+ * 新增缓存文件必须登记在此,否则被当作未知残留清理。
714
+ */
715
+ const CACHE_SCHEMAS = {
716
+ tree: 1,
717
+ "lsp-specs": 4,
718
+ ui: 1,
719
+ "ui-globals": 1
720
+ };
721
+ /** 缓存文件名 → 种类与 schema 版本(`<kind>.v<schema>.json`;未登记种类/命名不合规 → null)。 */
722
+ function parseCacheName(filename) {
723
+ const m = /^([a-z-]+)\.v(\d+)\.json$/.exec(filename);
724
+ if (!m) return null;
725
+ const kind = m[1];
726
+ if (CACHE_SCHEMAS[kind] === void 0) return null;
798
727
  return {
799
- oldText: hunk.oldText === null ? null : normalizeForCompare(hunk.oldText),
800
- newText: normalizeForCompare(hunk.newText)
728
+ kind,
729
+ schema: Number(m[2])
801
730
  };
802
731
  }
803
- /** 按文件顺序为多 hunk 写入不重复的 after 坐标。 */
804
- function annotateHunks(hunks, before, after) {
805
- if (typeof after !== "string") return hunks;
806
- let cursor = 0;
807
- return hunks.map((hunk) => {
808
- if (hunk.newText.length > 0) {
809
- const start = after.indexOf(hunk.newText, cursor);
810
- if (start >= 0) {
811
- cursor = start + hunk.newText.length;
812
- return {
813
- ...hunk,
814
- afterStart: start,
815
- afterEnd: cursor
816
- };
732
+ /**
733
+ * 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
734
+ * @author ddj 2026年09月28号
735
+ * @param filename 缓存文件名(不含目录)
736
+ * @returns 已登记种类且版本为当前版本
737
+ */
738
+ function isCurrentCacheFile(filename) {
739
+ const parsed = parseCacheName(filename);
740
+ return parsed !== null && parsed.schema === CACHE_SCHEMAS[parsed.kind];
741
+ }
742
+ /** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
743
+ async function sweepDirFiles(dir, removed, now, dropAll) {
744
+ const names = await readdir(dir).catch(() => []);
745
+ for (const name of names) {
746
+ const full = join(dir, name);
747
+ let drop = dropAll;
748
+ if (!drop) {
749
+ if (!isCurrentCacheFile(name)) drop = true;
750
+ else try {
751
+ if (now - (await stat(full)).mtimeMs > 2592e6) drop = true;
752
+ } catch (error) {
753
+ drop = true;
817
754
  }
818
- return hunk;
819
755
  }
820
- if (hunks.length !== 1 || typeof before !== "string" || before === after || hunk.oldText === null) return hunk;
821
- const prefix = commonPrefix(before, after);
822
- const suffix = commonSuffix(before, after, prefix);
823
- const removedStart = prefix;
824
- const removedEnd = Math.max(removedStart, before.length - suffix);
825
- return {
826
- ...hunk,
827
- afterStart: removedStart,
828
- afterEnd: removedStart,
829
- beforeStart: removedStart,
830
- beforeEnd: removedEnd
831
- };
832
- });
833
- }
834
- /** 计算两个文本的最长公共前缀长度。 */
835
- function commonPrefix(left, right) {
836
- const limit = Math.min(left.length, right.length);
837
- let i = 0;
838
- while (i < limit && left.charCodeAt(i) === right.charCodeAt(i)) i++;
839
- return i;
840
- }
841
- /** 计算不与 prefix 重叠的最长公共后缀长度。 */
842
- function commonSuffix(left, right, prefix) {
843
- const limit = Math.min(left.length, right.length) - prefix;
844
- let i = 0;
845
- while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++;
846
- return i;
756
+ if (!drop) continue;
757
+ try {
758
+ await rm(full, { force: true });
759
+ removed.push(name);
760
+ } catch (error) {}
761
+ }
847
762
  }
848
- /**
849
- * 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
850
- * @author ddj 2026年09月29号
851
- * @param content 当前内容
852
- * @param needle 搜索串
853
- * @param cursor 前一块的结束位置
854
- * @param occupied 已选区间
855
- * @param state 此搜索串已扫描的候选
856
- * @returns 与完整候选列表双轮查找相同的匹配
857
- */
858
- function findCandidate(content, needle, cursor, occupied, state) {
859
- if (!needle.length) return void 0;
860
- let fallback;
861
- for (let i = 0; i < state.ranges.length; i++) {
862
- const range = state.ranges[i];
863
- if (occupied.some((used) => overlaps(range, used))) continue;
864
- if (range.start >= cursor) return range;
865
- if (!fallback) fallback = range;
763
+ /** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
764
+ async function sweepWorkspaceTier(dir, removed, now) {
765
+ const ids = await readdir(dir).catch(() => []);
766
+ for (const id of ids) {
767
+ const wsDir = join(dir, id);
768
+ await sweepDirFiles(wsDir, removed, now, false);
769
+ if (!(await readdir(wsDir).catch(() => [])).length) await rm(wsDir, {
770
+ recursive: true,
771
+ force: true
772
+ }).catch(() => {});
866
773
  }
867
- while (!state.done) {
868
- const at = content.indexOf(needle, state.next);
869
- if (at < 0) {
870
- state.done = true;
871
- break;
774
+ }
775
+ /** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
776
+ async function sweepByTotalCap(root, removed, cap) {
777
+ const files = [];
778
+ const walk = async (dir) => {
779
+ const names = await readdir(dir).catch(() => []);
780
+ for (const name of names) {
781
+ const full = join(dir, name);
782
+ try {
783
+ const info = await stat(full);
784
+ if (info.isDirectory()) await walk(full);
785
+ else files.push({
786
+ path: full,
787
+ mtime: info.mtimeMs,
788
+ size: info.size
789
+ });
790
+ } catch (error) {}
872
791
  }
873
- const range = {
874
- start: at,
875
- end: at + needle.length
876
- };
877
- state.ranges.push(range);
878
- state.next = range.end;
879
- if (occupied.some((used) => overlaps(range, used))) continue;
880
- if (range.start >= cursor) return range;
881
- if (!fallback) fallback = range;
792
+ };
793
+ await walk(root);
794
+ let total = files.reduce((sum, f) => sum + f.size, 0);
795
+ files.sort((a, b) => a.mtime - b.mtime);
796
+ for (const f of files) {
797
+ if (total <= cap) break;
798
+ try {
799
+ await rm(f.path, { force: true });
800
+ total -= f.size;
801
+ removed.push(f.path.slice(root.length + 1));
802
+ } catch (error) {}
882
803
  }
883
- return fallback;
884
804
  }
885
805
  /**
886
- * 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
887
- * 若 hunk 带 afterStart/afterEnd,则优先验证快照坐标,避免重复文本错配。
888
- * @author ddj 2026年08月24号
889
- * @param content 当前文件内容
890
- * @param hunks 待定位 hunk
891
- * @returns 每个 hunk 一个结果,matched=false 表示冲突/stale
806
+ * 清理插件缓存根(分级 + 总预算兜底):
807
+ * - 根级残留文件(旧扁平格式/未知)→ 删
808
+ * - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
809
+ * - user/ 用户级 → 按「schema 版本 + 保留期」清文件
810
+ * - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
811
+ * best-effort 静默。
812
+ * @author ddj 2026年09月01号
813
+ * @param home DSH home(测试可注入)
814
+ * @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
815
+ * @returns 删除的文件相对路径清单
892
816
  */
893
- function locateHunks(content, hunks) {
894
- const locations = [];
895
- let cursor = 0;
896
- const occupied = [];
897
- const matches = /* @__PURE__ */ new Map();
898
- for (let idx = 0; idx < hunks.length; idx++) {
899
- const hunk = hunks[idx];
900
- const newText = hunk.newText;
901
- const snapshot = Number.isInteger(hunk.afterStart) && Number.isInteger(hunk.afterEnd) ? {
902
- start: hunk.afterStart,
903
- end: hunk.afterEnd
904
- } : null;
905
- const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText) ? snapshot : null;
906
- let candidate;
907
- if (snapshotMatches) {
908
- if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches;
909
- } else if (newText.length) {
910
- let state = matches.get(newText);
911
- if (!state) {
912
- state = {
913
- ranges: [],
914
- next: 0,
915
- done: false
916
- };
917
- matches.set(newText, state);
918
- }
919
- candidate = findCandidate(content, newText, cursor, occupied, state);
920
- }
921
- if (candidate) {
922
- locations.push({
923
- idx,
924
- hunk,
925
- ...candidate,
926
- matched: true
927
- });
928
- occupied.push(candidate);
929
- cursor = Math.max(cursor, candidate.end);
930
- continue;
931
- }
932
- if (newText.length === 0 && hunk.afterStart === void 0 && hunk.afterEnd === void 0 && content.length === 0) {
933
- locations.push({
934
- idx,
935
- hunk,
936
- start: 0,
937
- end: 0,
938
- matched: true
939
- });
940
- continue;
941
- }
942
- if (newText.length === 0 && hunk.afterStart !== void 0 && hunk.afterEnd !== void 0 && hunk.afterStart === hunk.afterEnd && hunk.afterStart >= 0 && hunk.afterStart <= content.length) {
943
- const point = {
944
- start: hunk.afterStart,
945
- end: hunk.afterEnd
946
- };
947
- if (!occupied.some((used) => overlaps(point, used))) {
948
- locations.push({
949
- idx,
950
- hunk,
951
- ...point,
952
- matched: true
953
- });
954
- occupied.push(point);
955
- cursor = Math.max(cursor, point.end);
956
- continue;
817
+ async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP) {
818
+ const dir = pluginCacheRoot(home);
819
+ const names = await readdir(dir).catch(() => []);
820
+ if (!names.length) return [];
821
+ const removed = [];
822
+ const now = Date.now();
823
+ await Promise.all(names.map(async (name) => {
824
+ const full = join(dir, name);
825
+ try {
826
+ if ((await stat(full)).isDirectory()) {
827
+ if (name === "workspace") await sweepWorkspaceTier(full, removed, now);
828
+ else if (name === "user") await sweepDirFiles(full, removed, now, false);
829
+ else {
830
+ await rm(full, {
831
+ recursive: true,
832
+ force: true
833
+ });
834
+ removed.push(name);
835
+ }
836
+ } else {
837
+ await rm(full, { force: true });
838
+ removed.push(name);
957
839
  }
958
- }
959
- locations.push({
960
- idx,
961
- hunk,
962
- start: -1,
963
- end: -1,
964
- matched: false
965
- });
966
- }
967
- return locations;
968
- }
969
- /** 判断两个半开区间是否重叠。零长度区间只与同一位置的零长度区间重叠。 */
970
- function overlaps(left, right) {
971
- if (left.start === left.end || right.start === right.end) return left.start === right.start && left.end === right.end;
972
- return left.start < right.end && right.start < left.end;
973
- }
974
- /** 在可定位区间上执行一次 hunk 反向替换。 */
975
- function replaceRange(content, location, reverse = false) {
976
- if (!location.matched || location.start < 0) return null;
977
- const replacement = reverse ? location.hunk.oldText ?? "" : location.hunk.newText;
978
- return content.slice(0, location.start) + replacement + content.slice(location.end);
840
+ } catch (error) {}
841
+ }));
842
+ await sweepByTotalCap(dir, removed, cap);
843
+ return removed;
979
844
  }
845
+ //#endregion
846
+ //#region src/shared/logger.ts
980
847
  /**
981
- * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
982
- * @author ddj 2026年09月29号
983
- * @param content 当前文件内容
984
- * @param locations 已定位区间
985
- * @param reverse 是否反向应用
986
- * @returns 合成内容及失效块索引
848
+ * dsh-vscode-mode — 统一日志核心(双面共享,平台中立)。
849
+ * 输出规范:
850
+ * - 格式:`[dsh-vscode-mode][:scope] 消息`;前缀只在模块内部拼接,业务代码禁止手写前缀
851
+ * - 级别语义:debug=诊断明细 / info=装配与路由里程碑 / warn=降级与兼容回退 / error=失败
852
+ * - 出口:host 面经 bindHostLog(ctx) 绑定 ctx.logger(缺失回退 console);client 面固定 console
853
+ * - 诊断文件通道(edrv.debug → ~/.dsh/dsh-vscode-mode/logs/)见 debugLog.ts,与本模块前缀共用
854
+ * 新增日志一律 `import { log } from '../log.js'`(client 为 './log.js')后调 log.debug/info/warn/error;
855
+ * 子域日志用 log.child('scope'),禁止另起 console。
856
+ * 作者 ddj 2026-09-08
987
857
  */
988
- function applyLocations(content, locations, reverse = false) {
989
- const stale = locations.filter((location) => !location.matched).map((location) => location.idx);
990
- const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start);
991
- const parts = [];
992
- let end = content.length;
993
- for (const location of matched) {
994
- if (location.start < 0 || location.end > end) {
995
- let out = content;
996
- for (const item of matched) {
997
- const next = replaceRange(out, item, reverse);
998
- if (next === null) stale.push(item.idx);
999
- else out = next;
1000
- }
1001
- return {
1002
- content: out,
1003
- stale: stale.sort((a, b) => a - b)
1004
- };
1005
- }
1006
- parts.push(content.slice(location.end, end), reverse ? location.hunk.oldText ?? "" : location.hunk.newText);
1007
- end = location.start;
1008
- }
1009
- parts.push(content.slice(0, end));
1010
- parts.reverse();
1011
- return {
1012
- content: parts.join(""),
1013
- stale: stale.sort((a, b) => a - b)
858
+ /** 插件统一日志前缀(与包安装名一致)。 */
859
+ const LOG_PREFIX = "[dsh-vscode-mode]";
860
+ /** 默认级别到 console 方法名的映射。 */
861
+ const CONSOLE_METHODS = {
862
+ debug: "debug",
863
+ info: "info",
864
+ warn: "warn",
865
+ error: "error"
866
+ };
867
+ /**
868
+ * 默认出口:console(级别直映方法;方法缺失时静默丢弃,不抛错)。
869
+ * @author ddj 2026年09月08号
870
+ * @returns console 出口
871
+ */
872
+ function consoleSink() {
873
+ return (level, line) => {
874
+ const emit = console[CONSOLE_METHODS[level]];
875
+ if (typeof emit === "function") emit.call(console, line);
1014
876
  };
1015
877
  }
1016
878
  /**
1017
- * 写决策到记录(scope=call 整体 / hunk 单个块)。
1018
- * @author ddj 2026年08月20号
1019
- * @param record 记录
1020
- * @param scope 作用域
1021
- * @param idx hunk 下标(仅 scope=hunk 时生效)
1022
- * @param value 决策值
1023
- * @returns 更新后的记录(原对象就地修改)
879
+ * DSH host 出口工厂:转接 ctx.logger(方法级可选调用,缺服务/缺方法回退 console)。
880
+ * @author ddj 2026年09月08号
881
+ * @param ctx DSH host 上下文
882
+ * @returns 日志出口
1024
883
  */
1025
- function markDecision(record, scope, idx, value) {
1026
- const dec = record.decisions;
1027
- if (scope === "hunk" && Array.isArray(dec.perHunk) && Number.isInteger(idx)) {
1028
- const i = idx;
1029
- if (i >= 0 && i < dec.perHunk.length) dec.perHunk[i] = value;
1030
- } else {
1031
- dec.call = value;
1032
- if (Array.isArray(dec.perHunk)) dec.perHunk = dec.perHunk.map(() => value);
1033
- }
1034
- record.at = (/* @__PURE__ */ new Date()).toISOString();
1035
- return record;
1036
- }
1037
- /** 记录是否已无任何待决策差异(superseded 或全部已决策)。 */
1038
- function recordResolved(record) {
1039
- if (record.superseded === true) return true;
1040
- const hunks = Array.isArray(record.hunks) ? record.hunks : [];
1041
- const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
1042
- const count = Math.max(hunks.length, perHunk.length);
1043
- if (!count) return record.decisions.call === "accepted" || record.decisions.call === "rejected";
1044
- for (let idx = 0; idx < count; idx++) {
1045
- const precise = preciseHunk(record, idx);
1046
- const value = perHunk[idx] ?? record.decisions.call;
1047
- if (isNoopHunk(precise) && value === "pending") return false;
1048
- if (value !== "accepted" && value !== "rejected") return false;
1049
- }
1050
- return true;
884
+ function ctxLogSink(ctx) {
885
+ const base = consoleSink();
886
+ return (level, line) => {
887
+ const logger = ctx?.logger;
888
+ const emit = logger?.[level];
889
+ if (typeof emit === "function") emit.call(logger, line);
890
+ else base(level, line);
891
+ };
1051
892
  }
1052
893
  /**
1053
- * 记录决策摘要(采纳/拒绝/待处理计数,superseded 标记)。
1054
- * @author ddj 2026年08月20号
1055
- * @param record 记录
1056
- * @returns 摘要
894
+ * 拼一行日志:`[dsh-vscode-mode][:scope] 消息`(scope 为空省略冒号段)。
895
+ * @author ddj 2026年09月08号
896
+ * @param scope 子域(可空)
897
+ * @param message 消息文本
898
+ * @returns 整行文本
1057
899
  */
1058
- function recSummary(record) {
1059
- const hunks = Array.isArray(record.hunks) ? record.hunks : [];
1060
- const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
1061
- const n = Math.max(1, hunks.length, perHunk.length);
1062
- let accepted = 0;
1063
- let rejected = 0;
1064
- let pending = 0;
1065
- for (let i = 0; i < n; i++) {
1066
- const hunk = preciseHunk(record, i);
1067
- const value = perHunk[i] ?? record.decisions.call;
1068
- if (i < hunks.length && isNoopHunk(hunk) && value === "pending") continue;
1069
- if (value === "accepted") accepted++;
1070
- else if (value === "rejected") rejected++;
1071
- else pending++;
1072
- }
1073
- return {
1074
- accepted,
1075
- rejected,
1076
- pending,
1077
- superseded: record.superseded === true
1078
- };
900
+ function formatLine(scope, message) {
901
+ const text = String(message);
902
+ return scope ? LOG_PREFIX + ":" + scope + " " + text : LOG_PREFIX + " " + text;
1079
903
  }
1080
904
  /**
1081
- * 重建"本批次修改前"内容:把仍待处理(pending)的差异块按 新→旧 顺序从当前内容反解
1082
- * (newText → oldText)。已采纳/已拒绝的块跳过。反解失败的块标记为 stale。
1083
- * @author ddj 2026年08月20号
1084
- * @param records 该文件的活动记录
1085
- * @param content 当前磁盘内容
1086
- * @returns 重建内容与 stale 定位
905
+ * 日志器工厂(内部):emit 时读共享出口状态,保证父 bind 后子实例跟随。
906
+ * @author ddj 2026年09月08号
907
+ * @param state 共享出口状态
908
+ * @param scope 子域文本(可空)
909
+ * @returns 日志器
1087
910
  */
1088
- function reconstructOriginal(records, content) {
1089
- const pending = [];
1090
- for (const rec of records) for (let i = 0; i < rec.hunks.length; i++) {
1091
- const st = rec.decisions.perHunk[i] ?? rec.decisions.call;
1092
- const hunk = preciseHunk(rec, i);
1093
- if (st === "pending" && hunk && !isNoopHunk(hunk)) pending.push({
1094
- rec,
1095
- idx: i,
1096
- hunk
1097
- });
1098
- }
1099
- pending.sort((a, b) => a.rec.at < b.rec.at ? 1 : a.rec.at > b.rec.at ? -1 : 0);
1100
- const rebuilt = applyLocations(content, locateHunks(content, pending.map((item) => item.hunk)), true);
911
+ function makeLogger(state, scope) {
912
+ const emit = (level, message) => state.sink(level, formatLine(scope, message));
1101
913
  return {
1102
- content: rebuilt.content,
1103
- stale: rebuilt.stale.map((idx) => ({
1104
- callId: pending[idx].rec.callId,
1105
- idx: pending[idx].idx
1106
- }))
914
+ debug: (message) => emit("debug", message),
915
+ info: (message) => emit("info", message),
916
+ warn: (message) => emit("warn", message),
917
+ error: (message) => emit("error", message),
918
+ bind(next) {
919
+ state.sink = next;
920
+ },
921
+ child(next) {
922
+ return makeLogger(state, scope ? scope + "." + next : next);
923
+ }
1107
924
  };
1108
925
  }
1109
926
  /**
1110
- * @public
1111
- * @author ddj 2026年10月10号
1112
- * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
1113
- * @param record 审查记录
1114
- * @returns 未归档、未覆盖且没有用户决策时为 true
927
+ * 创建插件日志器(默认 console 出口;host 面单例由 bindHostLog 换到 ctx.logger)。
928
+ * @author ddj 2026年09月08号
929
+ * @param sink 初始出口(缺省 console)
930
+ * @param scope 初始子域(业务方一般经 child 派生,不直接传)
931
+ * @returns 日志器
1115
932
  */
1116
- function fullyPending(record) {
1117
- return !record.archived && !record.superseded && record.decisions.call === "pending" && record.decisions.perHunk.every((value) => value === "pending");
933
+ function createLogger(sink = consoleSink(), scope = "") {
934
+ return makeLogger({ sink }, scope);
1118
935
  }
936
+ //#endregion
937
+ //#region src/log.ts
1119
938
  /**
1120
- * @public
1121
- * @author ddj 2026年10月10号
1122
- * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
1123
- * @param prior 先前修改的差异块
1124
- * @param later 后续修改的差异块
1125
- * @returns 文本归一化后整组逆反且包含真实修改时为 true
939
+ * dsh-vscode-mode host — 日志单例(全 host 面唯一日志入口)。
940
+ * 默认 console 出口;apply() 首行调 bindHostLog(ctx) 切到 ctx.logger(缺失回退 console)。
941
+ * 用法:`import { log } from './log.js'` → `log.debug/info/warn/error(msg)`;子域用 `log.child('scope')`。
942
+ * 作者 ddj 2026-09-08
1126
943
  */
1127
- function hunksInverse(prior, later) {
1128
- if (!prior.length || prior.length !== later.length) return false;
1129
- let changed = false;
1130
- for (let idx = 0; idx < prior.length; idx++) {
1131
- const a = normalizeHunk(prior[idx]);
1132
- const b = normalizeHunk(later[idx]);
1133
- if (a.oldText !== b.newText || a.newText !== b.oldText) return false;
1134
- if (!isNoopHunk(a)) changed = true;
1135
- }
1136
- return changed;
1137
- }
944
+ /** host 面插件日志单例(格式/级别语义见 shared/logger.ts 头注释)。 */
945
+ const log = createLogger();
1138
946
  /**
1139
- * @private
1140
- * @author ddj 2026年10月10号
1141
- * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
1142
- * @param a 第一条记录
1143
- * @param b 第二条记录
1144
- * @returns 负数表示 a 更早,零表示先后无法区分
947
+ * 装配期绑定 host 日志出口:ctx.logger 可用时全部日志转经 DSH 日志系统。
948
+ * @author ddj 2026年09月08号
949
+ * @param ctx DSH host 上下文(logger 服务缺失时保持 console 出口)
1145
950
  */
1146
- function recordOrder(a, b) {
1147
- if (a.path !== b.path) return a.path.localeCompare(b.path);
1148
- if (Number.isInteger(a.batch) && Number.isInteger(b.batch) && a.batch > 0 && b.batch > 0 && a.batch !== b.batch) return a.batch - b.batch;
1149
- const delta = Date.parse(a.at) - Date.parse(b.at);
1150
- return Number.isFinite(delta) ? delta : 0;
951
+ function bindHostLog(ctx) {
952
+ log.bind(ctxLogSink(ctx));
1151
953
  }
954
+ //#endregion
955
+ //#region src/debugLog.ts
1152
956
  /**
1153
- * @private
1154
- * @author ddj 2026年10月10号
1155
- * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
1156
- * @param prior 先前记录
1157
- * @param later 后续记录
1158
- * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
957
+ * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
958
+ * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
959
+ * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
960
+ * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
961
+ * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
962
+ * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
963
+ * 作者 ddj 2026-09-08 / 2026-09-17
1159
964
  */
1160
- function snapshotsRestore(prior, later) {
1161
- if (prior.create || later.create) return false;
1162
- if (typeof prior.before !== "string" || typeof prior.after !== "string" || typeof later.before !== "string" || typeof later.after !== "string") return false;
1163
- return normalizeForCompare(prior.before) === normalizeForCompare(later.after) && normalizeForCompare(prior.after) === normalizeForCompare(later.before);
1164
- }
965
+ /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
966
+ const debugBuffers = /* @__PURE__ */ new Map();
967
+ /** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
968
+ const debugWriteQueues = /* @__PURE__ */ new Map();
969
+ /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
970
+ const DEBUG_LOG_CAP = 524288;
971
+ /** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
972
+ const DEBUG_BUF_CAP = 32768;
973
+ /** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
974
+ const DEBUG_FLUSH_IDLE_MS = 1e3;
975
+ /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
976
+ const debugLegacyCleaned = /* @__PURE__ */ new Set();
977
+ /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
978
+ const debugLog = log.child("debug");
979
+ /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
980
+ const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
1165
981
  /**
1166
- * @public
1167
- * @author ddj 2026年10月10号
1168
- * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
1169
- * @param records 工作区记录(不修改输入)
1170
- * @param record 新记录
1171
- * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
982
+ * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
983
+ * @author ddj 2026年09月17号
984
+ * @param name 请求回传的文件名(可为空)
985
+ * @returns 是否合法
1172
986
  */
1173
- function findInversePrior(records, record) {
1174
- if (!fullyPending(record)) return null;
1175
- let found = null;
1176
- for (const prior of records) {
1177
- if (prior.callId === record.callId || prior.path !== record.path || !fullyPending(prior)) continue;
1178
- if (recordOrder(prior, record) >= 0 || !hunksInverse(prior.hunks, record.hunks)) continue;
1179
- if (!snapshotsRestore(prior, record)) continue;
1180
- if (!found || recordOrder(found, prior) < 0) found = prior;
1181
- }
1182
- return found;
987
+ function isDebugLogName(name) {
988
+ return typeof name === "string" && DEBUG_LOG_NAME.test(name);
1183
989
  }
1184
990
  /**
1185
- * @public
1186
- * @author ddj 2026年10月10号
1187
- * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
1188
- * @param records 工作区记录(不修改输入或记录状态)
1189
- * @returns 可以整对归档的记录对
991
+ * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
992
+ * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
993
+ * @author ddj 2026年09月08号
994
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
995
+ * @param batch 本批日志行
1190
996
  */
1191
- function findCanceledPairs(records) {
1192
- const available = [];
1193
- const pairs = [];
1194
- const ordered = [...records].sort(recordOrder);
1195
- for (const record of ordered) {
1196
- const prior = findInversePrior(available, record);
1197
- if (!prior) {
1198
- if (fullyPending(record)) available.push(record);
1199
- continue;
1200
- }
1201
- pairs.push([prior, record]);
1202
- available.splice(available.indexOf(prior), 1);
1203
- }
1204
- return pairs;
1205
- }
1206
- /** 文件当前最大批次号(无记录为 0)。 */
1207
- function fileMaxBatch(records, path) {
1208
- let m = 0;
1209
- for (const r of records.values()) if (r.path === path && Number.isInteger(r.batch) && r.batch > m) m = r.batch;
1210
- return m;
1211
- }
1212
- /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
1213
- function prune(map, max = 200) {
1214
- if (map.size <= max) return;
1215
- const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record)).sort((a, b) => a.at < b.at ? -1 : 1);
1216
- for (const record of sorted) {
1217
- if (map.size <= max) break;
1218
- map.delete(record.callId);
1219
- }
997
+ function flushDebug(cwd, batch) {
998
+ const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
999
+ try {
1000
+ await mkdir(pluginLogRoot(), { recursive: true });
1001
+ const target = debugLogFile(cwd);
1002
+ const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
1003
+ const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
1004
+ await writeFile(target, next, "utf8");
1005
+ if (!debugLegacyCleaned.has(cwd)) {
1006
+ debugLegacyCleaned.add(cwd);
1007
+ await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
1008
+ }
1009
+ } catch (e) {}
1010
+ });
1011
+ debugWriteQueues.set(cwd, task);
1012
+ return task;
1220
1013
  }
1221
1014
  /**
1222
- * 归档条目构造(一条批次一条)。
1223
- * @author ddj 2026年08月20号
1224
- * @param recs 同批次记录(非空)
1225
- * @param cwd 工作区
1226
- * @param reason 归档原因
1015
+ * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
1016
+ * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
1017
+ * @author ddj 2026年09月08号 / 2026年09月17号
1018
+ * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
1019
+ * @param cwd 工作区(日志文件按 cwd hash 隔离)
1020
+ * @param text 单条日志文本(不含换行)
1021
+ * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
1227
1022
  */
1228
- function archiveEntryFor(recs, cwd, reason) {
1229
- const first = recs[0];
1230
- return {
1231
- at: (/* @__PURE__ */ new Date()).toISOString(),
1232
- cwd,
1233
- path: first.path,
1234
- batch: Number.isInteger(first.batch) ? first.batch : null,
1235
- reason,
1236
- records: recs.map((r) => ({
1237
- callId: r.callId,
1238
- toolName: r.toolName,
1239
- path: r.path,
1240
- create: r.create === true,
1241
- callHunk: r.callHunk,
1242
- hunks: r.hunks,
1243
- decisions: r.decisions,
1244
- note: r.note ?? null,
1245
- before: typeof r.before === "string" ? r.before : null,
1246
- after: typeof r.after === "string" ? r.after : null,
1247
- baseFingerprint: r.baseFingerprint ?? null,
1248
- afterFingerprint: r.afterFingerprint ?? null,
1249
- conflict: r.conflict === true,
1250
- legacy: r.legacy === true,
1251
- superseded: r.superseded === true,
1252
- batch: Number.isInteger(r.batch) ? r.batch : null,
1253
- at: r.at,
1254
- summary: recSummary(r)
1255
- }))
1023
+ function debugRecord(_ctx, cwd, text, level = "debug") {
1024
+ const tagged = "[" + level + "] " + text;
1025
+ const st = debugBuffers.get(cwd) ?? {
1026
+ lines: [],
1027
+ len: 0,
1028
+ timer: null
1029
+ };
1030
+ st.lines.push(tagged);
1031
+ st.len += tagged.length;
1032
+ const flush = () => {
1033
+ st.timer = null;
1034
+ const batch = st.lines;
1035
+ st.lines = [];
1036
+ st.len = 0;
1037
+ flushDebug(cwd, batch);
1256
1038
  };
1039
+ if (st.len >= DEBUG_BUF_CAP) {
1040
+ if (st.timer) clearTimeout(st.timer);
1041
+ flush();
1042
+ } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
1043
+ debugBuffers.set(cwd, st);
1044
+ debugLog[level](text);
1257
1045
  }
1258
- //#endregion
1259
- //#region src/shared/rpc.ts
1260
- /** webServer 精确路由。 */
1261
- const RPC_PATH = "/edrv/rpc";
1262
1046
  /**
1263
- * 图片扩展名(小写、无点)→ MIME。edrv.read base64 响应与 client 图片预览共用,
1264
- * 收敛在此避免 host/client 两处漂移。
1265
- * @author ddj 2026年09月08号
1047
+ * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
1048
+ * @author ddj 2026年09月17号
1049
+ * @param home DSH home(测试可注入;缺省按环境解析)
1050
+ * @returns 清单项数组
1266
1051
  */
1267
- const IMAGE_MIME = {
1268
- png: "image/png",
1269
- jpg: "image/jpeg",
1270
- jpeg: "image/jpeg",
1271
- gif: "image/gif",
1272
- webp: "image/webp",
1273
- bmp: "image/bmp",
1274
- ico: "image/x-icon",
1275
- avif: "image/avif",
1276
- svg: "image/svg+xml"
1277
- };
1052
+ async function listDebugLogs(home = dshHome()) {
1053
+ const names = await readdir(pluginLogRoot(home)).catch(() => []);
1054
+ const items = [];
1055
+ for (const name of names) {
1056
+ if (!isDebugLogName(name)) continue;
1057
+ const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
1058
+ if (!info?.isFile()) continue;
1059
+ items.push({
1060
+ name,
1061
+ bytes: info.size,
1062
+ mtimeMs: info.mtimeMs
1063
+ });
1064
+ }
1065
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs);
1066
+ return items;
1067
+ }
1278
1068
  /**
1279
- * 路径 → 图片 MIME:按 basename 扩展名小写查 {@link IMAGE_MIME},未命中回退 application/octet-stream。
1280
- * @author ddj 2026年09月08号
1281
- * @param path 文件路径(`/` 或 `\` 分隔均可)
1282
- * @returns MIME 字符串
1069
+ * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
1070
+ * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
1071
+ * @author ddj 2026年09月17号
1072
+ * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
1073
+ * @param maxBytes 尾部保留字节数(缺省 256KB)
1074
+ * @param home DSH home(测试可注入)
1075
+ * @returns 读取结果;文件不存在或名字非法返回 null
1283
1076
  */
1284
- function imageMimeOf(path) {
1285
- const base = String(path || "").split(/[\\/]/).pop() || "";
1286
- const dot = base.lastIndexOf(".");
1287
- const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
1288
- return IMAGE_MIME[ext] ?? "application/octet-stream";
1077
+ async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
1078
+ if (!isDebugLogName(name)) return null;
1079
+ const target = join(pluginLogRoot(home), name);
1080
+ const info = await stat(target).catch(() => null);
1081
+ if (!info?.isFile()) return null;
1082
+ const text = await readFile(target, "utf8").catch(() => null);
1083
+ if (text === null) return null;
1084
+ const cut = Math.max(0, text.length - maxBytes);
1085
+ return {
1086
+ name,
1087
+ path: target,
1088
+ content: cut > 0 ? text.slice(cut) : text,
1089
+ size: info.size,
1090
+ truncated: cut > 0
1091
+ };
1289
1092
  }
1290
- /** PDF 统一 MIME(host base64 响应与 client PDF 面板同源,避免两处漂移)。 */
1291
- const PDF_MIME = "application/pdf";
1292
- /**
1293
- * 会话级前置错误的规范文案:host requireSession 系与 client 错误识别共用同一常量,
1294
- * 避免文案漂移导致 client 端分类失效。跨端场景(桌面端/Web 端各自运行独立 DSH 实例、
1295
- * 会话列表互通但会话仅在一端激活)下,本端 host 取不到会话即返回「会话不存在」。
1296
- * @author ddj 2026年10月16号
1297
- */
1298
- const SESSION_MISSING_ERROR = "会话不存在";
1299
- /** 会话存在但无工作区 cwd 时的规范文案(与 SESSION_MISSING_ERROR 同源共用)。 */
1300
- const SESSION_NO_WORKSPACE_ERROR = "会话无工作区";
1301
1093
  /**
1302
- * 路径 → 二进制预览 MIME:PDF 特判 {@link PDF_MIME},其余走图片表回退 octet-stream。
1303
- * @author ddj 2026年09月22号
1304
- * @param path 文件路径(`/` 或 `\` 分隔均可)
1305
- * @returns MIME 字符串
1094
+ * 清空一份诊断日志(truncate 到 0,保留文件本身)。
1095
+ * @author ddj 2026年09月17号
1096
+ * @param name 日志文件名(isDebugLogName 白名单校验)
1097
+ * @param home DSH home(测试可注入)
1098
+ * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
1306
1099
  */
1307
- function binaryMimeOf(path) {
1308
- const base = String(path || "").split(/[\\/]/).pop() || "";
1309
- const dot = base.lastIndexOf(".");
1310
- if (dot > 0 && base.slice(dot + 1).toLowerCase() === "pdf") return PDF_MIME;
1311
- return imageMimeOf(path);
1100
+ async function clearDebugLog(name, home = dshHome()) {
1101
+ if (!isDebugLogName(name)) return false;
1102
+ return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
1312
1103
  }
1313
1104
  //#endregion
1314
- //#region src/paths.ts
1105
+ //#region src/mcpDiagnostic.ts
1315
1106
  /**
1316
- * dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
1317
- * 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
1318
- * - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
1319
- * user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
1320
- * - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
1321
- * (DSH_HOME → ~/.dsh)
1322
- * - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
1323
- * 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
1324
- * 作者 ddj 2026-09-01
1107
+ * Format an initialization failure without serializing the MCP configuration.
1108
+ * @public @author ddj 2026年10月28号
1109
+ * @param error Original failure and optional cause chain.
1110
+ * @param config Effective configuration, used only to remove known secret values.
1111
+ * @returns Bounded single-line diagnostic; URLs and explicit credentials are removed.
1325
1112
  */
1326
- /** 插件 id(也是 .dsh 下插件工作区目录名)。 */
1327
- const PLUGIN_ID = "dsh-vscode-mode";
1328
- /** DSH 工作区目录名(默认 home 下)。 */
1329
- const DSH_DIR_NAME = ".dsh";
1330
- /** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
1331
- const DSH_HOME_ENV = "DSH_HOME";
1332
- /** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
1333
- const CACHE_TOTAL_CAP = 209715200;
1334
- /** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
1335
- const SIDECAR = ".dsh-edit-review.json";
1336
- /** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
1337
- const DEBUG_LOG = ".dsh-edit-review-debug.log";
1338
- /** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
1339
- const LEGACY_TREE_SIDECARS = [".dsh-edit-review-tree.json", ".dsh-vscode-mode-tree.json"];
1340
- /** 路由前缀(兼容性检查用)。 */
1341
- const ROUTE_PREFIX = "/edrv";
1342
- /** Monaco 静态资源前缀。 */
1343
- const VENDOR_PREFIX = "/edrv/vendor";
1344
- /** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
1345
- function hashOf(text) {
1346
- return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
1113
+ function mcpErrorText(error, config) {
1114
+ const messages = [];
1115
+ const seen = /* @__PURE__ */ new Set();
1116
+ let cause = error;
1117
+ while (cause !== void 0 && !seen.has(cause) && messages.length < 4) {
1118
+ seen.add(cause);
1119
+ if (cause instanceof Error) {
1120
+ const code = cause.code;
1121
+ messages.push(cause.name + ": " + cause.message + (typeof code === "string" ? " [" + code + "]" : ""));
1122
+ cause = cause.cause;
1123
+ } else {
1124
+ messages.push(typeof cause === "string" ? cause : "Unknown MCP failure");
1125
+ break;
1126
+ }
1127
+ }
1128
+ let text = messages.join(" <- ");
1129
+ const secrets = [...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})];
1130
+ for (const argument of config.args ?? []) if (!argument.startsWith("-")) secrets.push(argument);
1131
+ secrets.sort((left, right) => right.length - left.length);
1132
+ for (const secret of secrets) if (secret) text = text.split(secret).join("[REDACTED]");
1133
+ text = text.replace(/https?:\/\/[^\s"'<>]+/gi, "[URL]").replace(/\b(authorization|api[-_]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]").replace(/[\r\n\x00-\x1f\x7f]+/g, " ");
1134
+ return text.slice(0, 2048);
1347
1135
  }
1348
- /**
1349
- * 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
1350
- * 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
1351
- * 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
1352
- * @author ddj 2026年09月02号
1353
- * @param raw 原始路径段(非空)
1354
- * @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
1355
- */
1356
- function encodeSegment(raw) {
1357
- if (raw.length === 0) throw new Error("cannot encode an empty path segment");
1358
- if (raw === ".") return "~002E";
1359
- if (raw === "..") return "~002E~002E";
1360
- let out = "";
1361
- for (let i = 0; i < raw.length; i++) {
1362
- const code = raw.charCodeAt(i);
1363
- const ch = String.fromCharCode(code);
1364
- if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
1365
- else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
1136
+ //#endregion
1137
+ //#region src/mcpPool.ts
1138
+ /** Connection ownership, bounded cold starts and workspace leases. @author ddj 2026年10月20号 */
1139
+ /** @private @author ddj 2026年10月20号 @param input Plain effective configuration. @returns Stable secret-bearing identity used only as digest input. */
1140
+ function stableConfig(input) {
1141
+ if (Array.isArray(input)) return "[" + input.map(stableConfig).join(",") + "]";
1142
+ if (input && typeof input === "object") {
1143
+ const config = input;
1144
+ return "{" + Object.keys(config).sort().map((key) => JSON.stringify(key) + ":" + stableConfig(config[key])).join(",") + "}";
1145
+ }
1146
+ return JSON.stringify(input) ?? "null";
1147
+ }
1148
+ /** @public @author ddj 2026年10月20号 @param task Shared work that must remain alive for other waiters. @param signal One waiter's cancellation. @returns Caller-local wait without abandoning owned pool work. */
1149
+ function waitMcp(task, signal) {
1150
+ if (!signal) return task;
1151
+ return new Promise((resolve, reject) => {
1152
+ /** @author ddj 2026年10月20号 Rejects only this waiter. */
1153
+ const abort = () => {
1154
+ reject(/* @__PURE__ */ new Error("MCP 等待已取消"));
1155
+ };
1156
+ if (signal.aborted) {
1157
+ abort();
1158
+ return;
1159
+ }
1160
+ signal.addEventListener("abort", abort, { once: true });
1161
+ task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
1162
+ });
1163
+ }
1164
+ /** The pool owns private scopes independently from any first subscriber. */
1165
+ var McpPool = class {
1166
+ ctx;
1167
+ load;
1168
+ make;
1169
+ entries = /* @__PURE__ */ new Map();
1170
+ owners = /* @__PURE__ */ new WeakMap();
1171
+ ownerSeq = 0;
1172
+ running = 0;
1173
+ queue = [];
1174
+ jobs = /* @__PURE__ */ new Set();
1175
+ stopped = false;
1176
+ /** @public @author ddj 2026年10月20号 @param ctx Host lifecycle owner. @param load Official plugin loader. @param make Private scope factory. */
1177
+ constructor(ctx, load, make = () => McpBridge.create(ctx)) {
1178
+ this.ctx = ctx;
1179
+ this.load = load;
1180
+ this.make = make;
1181
+ }
1182
+ /** @private @author ddj 2026年10月20号 @param spec Effective request. @returns Private digest; never emit configuration or secrets. */
1183
+ keyOf(spec) {
1184
+ let owner = 0;
1185
+ if (spec.policy.sharing === "agent") {
1186
+ if (!this.owners.has(spec.owner)) this.owners.set(spec.owner, ++this.ownerSeq);
1187
+ owner = this.owners.get(spec.owner);
1188
+ }
1189
+ const workspace = process.platform === "win32" ? spec.workspace.replace(/\\/g, "/").toLowerCase() : spec.workspace;
1190
+ return createHash("sha256").update(stableConfig([
1191
+ workspace,
1192
+ owner,
1193
+ spec.config,
1194
+ spec.policy
1195
+ ])).digest("hex");
1196
+ }
1197
+ /** @public @author ddj 2026年10月20号 @param spec Authorized connection request. @returns Reference-counted single-flight lease. */
1198
+ acquire(spec) {
1199
+ if (this.stopped) throw new Error("MCP runtime is disposed");
1200
+ const key = this.keyOf(spec);
1201
+ let entry = this.entries.get(key);
1202
+ if (entry?.phase === "error" && entry.retryAt !== void 0 && Date.now() >= entry.retryAt && !entry.close) {
1203
+ this.entries.delete(key);
1204
+ entry = void 0;
1205
+ }
1206
+ if (entry && !entry.live) throw new Error(entry.error ?? "MCP 旧连接仍在释放,暂不能创建替代实例");
1207
+ if (!entry) {
1208
+ entry = {
1209
+ key,
1210
+ spec,
1211
+ refs: 0,
1212
+ phase: "queued",
1213
+ queuedAt: Date.now(),
1214
+ phaseAt: Date.now(),
1215
+ ready: Promise.resolve(),
1216
+ live: true,
1217
+ calls: Promise.resolve()
1218
+ };
1219
+ this.entries.set(key, entry);
1220
+ entry.ready = this.schedule(entry);
1221
+ entry.ready.catch(() => void 0);
1222
+ }
1223
+ if (entry.idle) clearTimeout(entry.idle);
1224
+ entry.idle = void 0;
1225
+ entry.refs += 1;
1226
+ let released = false;
1227
+ return {
1228
+ entry,
1229
+ release: () => {
1230
+ if (released) return;
1231
+ released = true;
1232
+ this.release(entry);
1233
+ }
1234
+ };
1235
+ }
1236
+ /** @private @author ddj 2026年10月20号 @param entry New entry. @returns Queued initialization, bounded to two concurrent starts. */
1237
+ schedule(entry) {
1238
+ return new Promise((resolve, reject) => {
1239
+ /** @author ddj 2026年10月20号 Initializes a still-owned queue item. */
1240
+ const run = async () => {
1241
+ try {
1242
+ await this.start(entry);
1243
+ resolve();
1244
+ } catch (error) {
1245
+ reject(error);
1246
+ }
1247
+ };
1248
+ this.queue.push(run);
1249
+ this.pump();
1250
+ });
1251
+ }
1252
+ /** @private @author ddj 2026年10月20号 Advances FIFO work; each task holds its slot until startup or cleanup quiesces. */
1253
+ pump() {
1254
+ while (this.running < 2 && this.queue.length) {
1255
+ const run = this.queue.shift();
1256
+ this.running += 1;
1257
+ const job = run().finally(() => {
1258
+ this.running -= 1;
1259
+ this.jobs.delete(job);
1260
+ this.pump();
1261
+ });
1262
+ this.jobs.add(job);
1263
+ }
1264
+ }
1265
+ /** @private @author ddj 2026年10月20号 @param entry Connection to initialize. @returns Initialization completion, or failure after owned cleanup. */
1266
+ async start(entry) {
1267
+ if (!entry.live || this.stopped) throw new Error("MCP 启动已取消");
1268
+ entry.phase = "loading";
1269
+ entry.phaseAt = Date.now();
1270
+ entry.startAt = entry.phaseAt;
1271
+ let timer;
1272
+ const work = this.mount(entry);
1273
+ const deadline = new Promise((_resolve, reject) => {
1274
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("MCP 初始化超时")), entry.spec.policy.startupTimeoutMs);
1275
+ timer.unref?.();
1276
+ });
1277
+ try {
1278
+ await Promise.race([work, deadline]);
1279
+ if (!entry.live) throw new Error("MCP 启动已取消");
1280
+ entry.phase = "ready";
1281
+ entry.finishedAt = Date.now();
1282
+ entry.connectedAt = entry.finishedAt;
1283
+ } catch (error) {
1284
+ entry.error = "MCP 初始化失败或超时,请查看 Host 日志";
1285
+ const diagnostic = "MCP initialization failed; phase=" + entry.phase + "; transport=" + entry.spec.config.transport + "; elapsedMs=" + (Date.now() - entry.startAt) + "; " + mcpErrorText(error, entry.spec.config);
1286
+ debugRecord(this.ctx, entry.spec.workspace, diagnostic, "warn");
1287
+ entry.live = false;
1288
+ entry.phase = "stopping";
1289
+ const closing = this.closeBridge(entry);
1290
+ await Promise.allSettled([closing, work]);
1291
+ entry.phase = "error";
1292
+ entry.finishedAt = Date.now();
1293
+ entry.retryAt = Date.now() + 5e3;
1294
+ throw new Error(entry.error);
1295
+ } finally {
1296
+ if (timer) clearTimeout(timer);
1297
+ }
1298
+ }
1299
+ /** @private @author ddj 2026年10月20号 @param entry Owned generation. @returns Official initial sync; never publishes after invalidation. */
1300
+ async mount(entry) {
1301
+ const official = await this.load();
1302
+ if (!entry.live || this.stopped) return;
1303
+ const bridge = await this.make();
1304
+ if (!entry.live || this.stopped) {
1305
+ await bridge.dispose();
1306
+ return;
1307
+ }
1308
+ entry.bridge = bridge;
1309
+ entry.phase = "starting";
1310
+ entry.phaseAt = Date.now();
1311
+ entry.fiber = bridge.scope.ctx.plugin(official, entry.spec.config);
1312
+ await entry.fiber;
1313
+ if (entry.fiber?.state !== void 0 && entry.fiber.state !== 2) throw new Error("MCP 插件未完成激活");
1366
1314
  }
1367
- return out;
1368
- }
1369
- /**
1370
- * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
1371
- * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
1372
- * @author ddj 2026年09月02号
1373
- * @param cwd 会话工作区绝对路径(非空)
1374
- * @returns `~/.dsh/sessions` 下的工作区目录名
1375
- */
1376
- function sessionWorkspaceKey(cwd) {
1377
- if (cwd.length === 0) throw new Error("cannot encode an empty project path");
1378
- let readable = "";
1379
- let separatorRun = false;
1380
- for (let i = 0; i < cwd.length; i++) {
1381
- const code = cwd.charCodeAt(i);
1382
- const ch = String.fromCharCode(code);
1383
- if (ch === "/" || ch === "\\" || ch === ":") {
1384
- if (!separatorRun) readable += "-";
1385
- separatorRun = true;
1386
- } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
1387
- readable += ch;
1388
- separatorRun = false;
1389
- } else {
1390
- readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
1391
- separatorRun = false;
1315
+ /** @private @author ddj 2026年10月20号 @param entry Connection to close. @returns Shared closure barrier; rejection retains a fenced entry. */
1316
+ async closeBridge(entry) {
1317
+ if (entry.close) return entry.close;
1318
+ const close = entry.bridge?.dispose() ?? Promise.resolve();
1319
+ entry.close = close;
1320
+ try {
1321
+ await close;
1322
+ entry.bridge = void 0;
1323
+ entry.fiber = void 0;
1324
+ entry.close = void 0;
1325
+ } catch (error) {
1326
+ entry.error = "MCP 释放未确认,禁止重建,请查看 Host 日志";
1327
+ this.ctx.logger?.warn?.("MCP cleanup failed: " + String(error));
1328
+ throw error;
1392
1329
  }
1393
1330
  }
1394
- return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
1395
- }
1396
- /** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
1397
- function sessionIdSegment(id) {
1398
- return encodeSegment(id);
1399
- }
1400
- /** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
1401
- function sessionsRoot(home = dshHome()) {
1402
- return join(home, "sessions");
1403
- }
1404
- /**
1405
- * 解析 DSH home:空白环境值视为未设置,展开 ~ 并解析绝对路径。
1406
- * @public
1407
- * @author ddj 2026年09月28号
1408
- * @param env 环境映射(缺省 process.env;测试可注入)
1409
- * @returns 归一化的 DSH home 绝对路径
1410
- */
1411
- function dshHome(env = process.env) {
1412
- const value = env[DSH_HOME_ENV];
1413
- const path = value && value.trim() ? value : join(homedir(), DSH_DIR_NAME);
1414
- if (path === "~") return resolve(homedir());
1415
- if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(join(homedir(), path.slice(2)));
1416
- return resolve(path);
1417
- }
1418
- /** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
1419
- function pluginCacheRoot(home = dshHome()) {
1420
- return join(home, PLUGIN_ID, "cache");
1421
- }
1422
- /** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
1423
- function workspaceCacheDir(workspaceId, home = dshHome()) {
1424
- return join(pluginCacheRoot(home), "workspace", workspaceId);
1425
- }
1426
- /** 用户级缓存目录(跨工作区共享;当前无实例,预留供全局偏好类缓存使用)。 */
1427
- function userCacheDir(home = dshHome()) {
1428
- return join(pluginCacheRoot(home), "user");
1429
- }
1430
- /** LSP provider 发现结果缓存文件(用户级:user/lsp-specs.v<schema>.json,跨会话/跨重启共享)。 */
1431
- function lspSpecCacheFile(home = dshHome(), schema = 4) {
1432
- return join(userCacheDir(home), "lsp-specs.v" + schema + ".json");
1433
- }
1434
- /** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
1435
- function pluginLogRoot(home = dshHome()) {
1436
- return join(home, PLUGIN_ID, "logs");
1437
- }
1438
- /** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
1439
- function treeCacheFile(cwd, home = dshHome(), schema = 1) {
1440
- return join(workspaceCacheDir(hashOf(cwd), home), "tree.v" + schema + ".json");
1441
- }
1442
- /**
1443
- * 每工作区 UI 状态镜像文件(工作区级:workspace/<id>/ui.v<schema>.json)。
1444
- * 存编辑器页签/侧栏/预览态等界面状态的 localStorage 原值副本,供换 origin 后回填。
1445
- * @author ddj 2026年09月28号
1446
- * @param cwd 工作区绝对路径
1447
- * @param home DSH home(测试可注入)
1448
- * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
1449
- * @returns UI 状态镜像文件绝对路径
1450
- */
1451
- function uiStateFile(cwd, home = dshHome(), schema = 1) {
1452
- return join(workspaceCacheDir(hashOf(cwd), home), "ui.v" + schema + ".json");
1453
- }
1454
- /**
1455
- * 用户级 UI 状态镜像文件(跨工作区共享:user/ui-globals.v<schema>.json)。
1456
- * 存与工作区无关的全局键(侧栏提示已忽略、工作区折叠态等)。
1457
- * @author ddj 2026年09月28号
1458
- * @param home DSH home(测试可注入)
1459
- * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
1460
- * @returns 全局 UI 状态镜像文件绝对路径
1461
- */
1462
- function uiGlobalsFile(home = dshHome(), schema = 1) {
1463
- return join(userCacheDir(home), "ui-globals.v" + schema + ".json");
1464
- }
1465
- /** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
1466
- function debugLogFile(cwd, home = dshHome()) {
1467
- return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
1331
+ /** @private @author ddj 2026年10月20号 @param entry One lease's owner. Arms workspace-only warm retention. */
1332
+ release(entry) {
1333
+ entry.refs = Math.max(0, entry.refs - 1);
1334
+ if (entry.refs || !entry.live) return;
1335
+ if (entry.spec.policy.sharing === "agent") {
1336
+ this.retire(entry).catch(() => void 0);
1337
+ return;
1338
+ }
1339
+ entry.idle = setTimeout(() => {
1340
+ this.retire(entry).catch(() => void 0);
1341
+ }, 6e4);
1342
+ entry.idle.unref?.();
1343
+ }
1344
+ /** @public @author ddj 2026年10月20号 @param entry Invalid generation. @returns Quiescent close; callers may await before starting a replacement. */
1345
+ async retire(entry) {
1346
+ entry.live = false;
1347
+ entry.phase = "stopping";
1348
+ if (entry.idle) clearTimeout(entry.idle);
1349
+ await this.closeBridge(entry);
1350
+ await entry.ready.catch(() => void 0);
1351
+ await entry.calls.catch(() => void 0);
1352
+ if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
1353
+ }
1354
+ /** @public @author ddj 2026年10月20号 @param entry Authorized connection. @param action Per-call work retaining original execution identity. @param signal Caller cancellation. @returns Serialized shared result; no direct registry bypass is introduced here. */
1355
+ run(entry, action, signal) {
1356
+ /** @author ddj 2026年10月20号 Checks cancellation and generation again at actual dispatch. */
1357
+ const invoke = async () => {
1358
+ await waitMcp(entry.ready, signal);
1359
+ if (signal?.aborted || !entry.live || entry.phase !== "ready") throw new Error("MCP 连接不可用或调用已取消");
1360
+ return action();
1361
+ };
1362
+ const result = entry.calls.then(invoke);
1363
+ entry.calls = result.catch(() => void 0);
1364
+ return result;
1365
+ }
1366
+ /** @public @author ddj 2026年10月20号 @returns Full disposal of entries, queue, timers and owned work. */
1367
+ async dispose() {
1368
+ this.stopped = true;
1369
+ await Promise.all([...this.entries.values()].map((entry) => this.retire(entry)));
1370
+ await Promise.all([...this.jobs]);
1371
+ }
1372
+ };
1373
+ //#endregion
1374
+ //#region src/mcpDiscover.ts
1375
+ /** @public @author ddj 2026年10月20号 @param runtime Authorized runtime. @param ctx Host registration context. @returns Global discovery cleanup; normal capability filters remain authoritative. */
1376
+ function installDiscover(runtime, ctx) {
1377
+ const tools = ctx.get("tools");
1378
+ /** @author ddj 2026年10月20号 @param args Validated operation. @param execution Registry-owned caller identity. @returns Safe capability catalogue or real connected schemas. */
1379
+ const execute = async (args, execution) => {
1380
+ const agent = execution.agent;
1381
+ if (!agent || !runtime.agents.has(agent)) throw new Error("会话未激活,不能使用项目 MCP 发现入口");
1382
+ if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some((key) => !["action", "serverName"].includes(key))) throw new Error("发现入口仅接受 action 和 serverName");
1383
+ if (!["list", "connect"].includes(args.action)) throw new Error("action 必须为 list 或 connect");
1384
+ if (args.action === "list") return runtime.catalogue(agent);
1385
+ if (typeof args.serverName !== "string") throw new Error("请指定已配置的 serverName");
1386
+ return runtime.connect(agent, args.serverName, execution.signal);
1387
+ };
1388
+ /** @author ddj 2026年10月20号 @param _args Unused. @param result Safe canonical JSON. @returns Native content without bypassing output validation. */
1389
+ const render = (_args, result) => [{
1390
+ type: "text",
1391
+ text: JSON.stringify(result)
1392
+ }];
1393
+ return tools.register({
1394
+ name: "discover_project_mcp",
1395
+ description: "List this workspace’s configured project MCP services without starting them, or connect one to discover its real tools. After connecting, call discovered tools in the NEXT request/program; never guess schemas. Global/preset MCPs are not included.",
1396
+ parameters: {
1397
+ type: "object",
1398
+ properties: {
1399
+ action: {
1400
+ type: "string",
1401
+ enum: ["list", "connect"]
1402
+ },
1403
+ serverName: { type: "string" }
1404
+ },
1405
+ required: ["action"],
1406
+ additionalProperties: false
1407
+ },
1408
+ output: {
1409
+ schema: {},
1410
+ render
1411
+ },
1412
+ execute
1413
+ });
1468
1414
  }
1469
- /**
1470
- * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
1471
- * @author ddj 2026年09月30号
1472
- * @param cwd 工作区绝对路径
1473
- * @param home DSH home
1474
- * @returns 归档数据库绝对路径
1475
- */
1476
- function archiveDbFile(cwd, home = dshHome()) {
1477
- return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
1415
+ /** @public @author ddj 2026年10月20号 @param runtime Active owners. @param agent Scoped catalogue owner. @returns Scoped instruction cleanup; does not register a tool override. */
1416
+ function discoverPrompt(runtime, agent) {
1417
+ const prompt = agent.ctx.get("systemPrompt");
1418
+ /** @author ddj 2026年10月20号 @returns Current enabled names only; no credential-bearing configuration. */
1419
+ const text = () => {
1420
+ const names = runtime.catalogue(agent).map((server) => server.serverName);
1421
+ if (!names.length) return "";
1422
+ return "Project MCP capabilities (may not be started): " + JSON.stringify(names) + ". Use discover_project_mcp action=list, then action=connect with serverName when needed. Only use returned real tool schemas. Native tools appear on the next model request; PTC must finish the current program and start a new program to refresh its SDK. Do not eval or bypass tool approval.";
1423
+ };
1424
+ return prompt.section({
1425
+ name: "vscode-mode:project-mcp",
1426
+ order: 3090,
1427
+ text,
1428
+ interpolate: false
1429
+ });
1478
1430
  }
1479
- /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
1480
- function assetsDirOf(moduleUrl) {
1481
- return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
1431
+ //#endregion
1432
+ //#region src/shared/diff.ts
1433
+ /** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
1434
+ function preciseHunk(record, idx) {
1435
+ return hunkOf(record, idx);
1482
1436
  }
1483
- /** Monaco AMD 静态目录(随包发布)。 */
1484
- function vendorDirOf(moduleUrl) {
1485
- return join(assetsDirOf(moduleUrl), "vendor");
1437
+ /** 轻量、确定性的字符串指纹;不引入 Node crypto。 */
1438
+ function fingerprint(text) {
1439
+ if (typeof text !== "string") return null;
1440
+ let hash = 2166136261;
1441
+ for (let i = 0; i < text.length; i++) {
1442
+ hash ^= text.charCodeAt(i);
1443
+ hash = Math.imul(hash, 16777619);
1444
+ }
1445
+ return "fnv1a:" + (hash >>> 0).toString(16) + ":" + text.length;
1486
1446
  }
1487
- /**
1488
- * 插件自带技能组根目录(包根 skills/,与 assets/ 同级;随包发布)。
1489
- * 布局:<根>/<技能名>/SKILL.md(目录式)或 <根>/<技能名>.md(扁平式)。
1490
- * @author ddj 2026年09月11号
1491
- * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
1492
- * @returns 技能组根目录绝对路径
1493
- */
1494
- function skillsDirOf(moduleUrl) {
1495
- return join(dirname(fileURLToPath(moduleUrl)), "..", "skills");
1447
+ /** 取记录第 idx 个有效 hunk;只有记录缺 hunk 时才回退 callHunk。 */
1448
+ function hunkOf(record, idx) {
1449
+ const hunk = Array.isArray(record.hunks) ? record.hunks[idx] : void 0;
1450
+ if (hunk && typeof hunk.newText === "string") return hunk;
1451
+ if (idx === 0 && record.callHunk && typeof record.callHunk.newText === "string") return record.callHunk;
1452
+ return null;
1496
1453
  }
1497
- /** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
1498
- function imageDirOf(config, moduleUrl) {
1499
- const cfg = config;
1500
- if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
1501
- return assetsDirOf(moduleUrl);
1454
+ /** hunk 是否表示没有实际内容变化。 */
1455
+ function isNoopHunk(hunk) {
1456
+ return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText;
1502
1457
  }
1503
1458
  /**
1504
- * 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
1505
- * 新增缓存文件必须登记在此,否则被当作未知残留清理。
1459
+ * 差异比较口径归一化:剥离 UTF-8 BOM、统一 CRLF 为 LF。
1460
+ * 背景:外部工具(pwsh/编辑器)可能改变 BOM/行尾,导致 after 指纹与 hunk
1461
+ * 定位在内容语义未变的情况下误报 conflict,把待确认修改从 UI 排除。
1462
+ * @author ddj 2026年09月09号
1506
1463
  */
1507
- const CACHE_SCHEMAS = {
1508
- tree: 1,
1509
- "lsp-specs": 4,
1510
- ui: 1,
1511
- "ui-globals": 1
1512
- };
1513
- /** 缓存文件名 → 种类与 schema 版本(`<kind>.v<schema>.json`;未登记种类/命名不合规 → null)。 */
1514
- function parseCacheName(filename) {
1515
- const m = /^([a-z-]+)\.v(\d+)\.json$/.exec(filename);
1516
- if (!m) return null;
1517
- const kind = m[1];
1518
- if (CACHE_SCHEMAS[kind] === void 0) return null;
1464
+ function normalizeForCompare(text) {
1465
+ return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
1466
+ }
1467
+ /** 归一化一个 hunk 的两侧文本(与 normalizeForCompare 同口径)。 */
1468
+ function normalizeHunk(hunk) {
1519
1469
  return {
1520
- kind,
1521
- schema: Number(m[2])
1470
+ oldText: hunk.oldText === null ? null : normalizeForCompare(hunk.oldText),
1471
+ newText: normalizeForCompare(hunk.newText)
1522
1472
  };
1523
1473
  }
1524
- /**
1525
- * 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
1526
- * @author ddj 2026年09月28号
1527
- * @param filename 缓存文件名(不含目录)
1528
- * @returns 已登记种类且版本为当前版本
1529
- */
1530
- function isCurrentCacheFile(filename) {
1531
- const parsed = parseCacheName(filename);
1532
- return parsed !== null && parsed.schema === CACHE_SCHEMAS[parsed.kind];
1533
- }
1534
- /** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
1535
- async function sweepDirFiles(dir, removed, now, dropAll) {
1536
- const names = await readdir(dir).catch(() => []);
1537
- for (const name of names) {
1538
- const full = join(dir, name);
1539
- let drop = dropAll;
1540
- if (!drop) {
1541
- if (!isCurrentCacheFile(name)) drop = true;
1542
- else try {
1543
- if (now - (await stat(full)).mtimeMs > 2592e6) drop = true;
1544
- } catch (error) {
1545
- drop = true;
1474
+ /** 按文件顺序为多 hunk 写入不重复的 after 坐标。 */
1475
+ function annotateHunks(hunks, before, after) {
1476
+ if (typeof after !== "string") return hunks;
1477
+ let cursor = 0;
1478
+ return hunks.map((hunk) => {
1479
+ if (hunk.newText.length > 0) {
1480
+ const start = after.indexOf(hunk.newText, cursor);
1481
+ if (start >= 0) {
1482
+ cursor = start + hunk.newText.length;
1483
+ return {
1484
+ ...hunk,
1485
+ afterStart: start,
1486
+ afterEnd: cursor
1487
+ };
1546
1488
  }
1489
+ return hunk;
1547
1490
  }
1548
- if (!drop) continue;
1549
- try {
1550
- await rm(full, { force: true });
1551
- removed.push(name);
1552
- } catch (error) {}
1553
- }
1491
+ if (hunks.length !== 1 || typeof before !== "string" || before === after || hunk.oldText === null) return hunk;
1492
+ const prefix = commonPrefix(before, after);
1493
+ const suffix = commonSuffix(before, after, prefix);
1494
+ const removedStart = prefix;
1495
+ const removedEnd = Math.max(removedStart, before.length - suffix);
1496
+ return {
1497
+ ...hunk,
1498
+ afterStart: removedStart,
1499
+ afterEnd: removedStart,
1500
+ beforeStart: removedStart,
1501
+ beforeEnd: removedEnd
1502
+ };
1503
+ });
1554
1504
  }
1555
- /** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
1556
- async function sweepWorkspaceTier(dir, removed, now) {
1557
- const ids = await readdir(dir).catch(() => []);
1558
- for (const id of ids) {
1559
- const wsDir = join(dir, id);
1560
- await sweepDirFiles(wsDir, removed, now, false);
1561
- if (!(await readdir(wsDir).catch(() => [])).length) await rm(wsDir, {
1562
- recursive: true,
1563
- force: true
1564
- }).catch(() => {});
1505
+ /** 计算两个文本的最长公共前缀长度。 */
1506
+ function commonPrefix(left, right) {
1507
+ const limit = Math.min(left.length, right.length);
1508
+ let i = 0;
1509
+ while (i < limit && left.charCodeAt(i) === right.charCodeAt(i)) i++;
1510
+ return i;
1511
+ }
1512
+ /** 计算不与 prefix 重叠的最长公共后缀长度。 */
1513
+ function commonSuffix(left, right, prefix) {
1514
+ const limit = Math.min(left.length, right.length) - prefix;
1515
+ let i = 0;
1516
+ while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++;
1517
+ return i;
1518
+ }
1519
+ /**
1520
+ * 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
1521
+ * @author ddj 2026年09月29号
1522
+ * @param content 当前内容
1523
+ * @param needle 搜索串
1524
+ * @param cursor 前一块的结束位置
1525
+ * @param occupied 已选区间
1526
+ * @param state 此搜索串已扫描的候选
1527
+ * @returns 与完整候选列表双轮查找相同的匹配
1528
+ */
1529
+ function findCandidate(content, needle, cursor, occupied, state) {
1530
+ if (!needle.length) return void 0;
1531
+ let fallback;
1532
+ for (let i = 0; i < state.ranges.length; i++) {
1533
+ const range = state.ranges[i];
1534
+ if (occupied.some((used) => overlaps(range, used))) continue;
1535
+ if (range.start >= cursor) return range;
1536
+ if (!fallback) fallback = range;
1537
+ }
1538
+ while (!state.done) {
1539
+ const at = content.indexOf(needle, state.next);
1540
+ if (at < 0) {
1541
+ state.done = true;
1542
+ break;
1543
+ }
1544
+ const range = {
1545
+ start: at,
1546
+ end: at + needle.length
1547
+ };
1548
+ state.ranges.push(range);
1549
+ state.next = range.end;
1550
+ if (occupied.some((used) => overlaps(range, used))) continue;
1551
+ if (range.start >= cursor) return range;
1552
+ if (!fallback) fallback = range;
1565
1553
  }
1554
+ return fallback;
1566
1555
  }
1567
- /** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
1568
- async function sweepByTotalCap(root, removed, cap) {
1569
- const files = [];
1570
- const walk = async (dir) => {
1571
- const names = await readdir(dir).catch(() => []);
1572
- for (const name of names) {
1573
- const full = join(dir, name);
1574
- try {
1575
- const info = await stat(full);
1576
- if (info.isDirectory()) await walk(full);
1577
- else files.push({
1578
- path: full,
1579
- mtime: info.mtimeMs,
1580
- size: info.size
1556
+ /**
1557
+ * 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
1558
+ * 若 hunk 带 afterStart/afterEnd,则优先验证快照坐标,避免重复文本错配。
1559
+ * @author ddj 2026年08月24号
1560
+ * @param content 当前文件内容
1561
+ * @param hunks 待定位 hunk
1562
+ * @returns 每个 hunk 一个结果,matched=false 表示冲突/stale
1563
+ */
1564
+ function locateHunks(content, hunks) {
1565
+ const locations = [];
1566
+ let cursor = 0;
1567
+ const occupied = [];
1568
+ const matches = /* @__PURE__ */ new Map();
1569
+ for (let idx = 0; idx < hunks.length; idx++) {
1570
+ const hunk = hunks[idx];
1571
+ const newText = hunk.newText;
1572
+ const snapshot = Number.isInteger(hunk.afterStart) && Number.isInteger(hunk.afterEnd) ? {
1573
+ start: hunk.afterStart,
1574
+ end: hunk.afterEnd
1575
+ } : null;
1576
+ const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText) ? snapshot : null;
1577
+ let candidate;
1578
+ if (snapshotMatches) {
1579
+ if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches;
1580
+ } else if (newText.length) {
1581
+ let state = matches.get(newText);
1582
+ if (!state) {
1583
+ state = {
1584
+ ranges: [],
1585
+ next: 0,
1586
+ done: false
1587
+ };
1588
+ matches.set(newText, state);
1589
+ }
1590
+ candidate = findCandidate(content, newText, cursor, occupied, state);
1591
+ }
1592
+ if (candidate) {
1593
+ locations.push({
1594
+ idx,
1595
+ hunk,
1596
+ ...candidate,
1597
+ matched: true
1598
+ });
1599
+ occupied.push(candidate);
1600
+ cursor = Math.max(cursor, candidate.end);
1601
+ continue;
1602
+ }
1603
+ if (newText.length === 0 && hunk.afterStart === void 0 && hunk.afterEnd === void 0 && content.length === 0) {
1604
+ locations.push({
1605
+ idx,
1606
+ hunk,
1607
+ start: 0,
1608
+ end: 0,
1609
+ matched: true
1610
+ });
1611
+ continue;
1612
+ }
1613
+ if (newText.length === 0 && hunk.afterStart !== void 0 && hunk.afterEnd !== void 0 && hunk.afterStart === hunk.afterEnd && hunk.afterStart >= 0 && hunk.afterStart <= content.length) {
1614
+ const point = {
1615
+ start: hunk.afterStart,
1616
+ end: hunk.afterEnd
1617
+ };
1618
+ if (!occupied.some((used) => overlaps(point, used))) {
1619
+ locations.push({
1620
+ idx,
1621
+ hunk,
1622
+ ...point,
1623
+ matched: true
1581
1624
  });
1582
- } catch (error) {}
1625
+ occupied.push(point);
1626
+ cursor = Math.max(cursor, point.end);
1627
+ continue;
1628
+ }
1583
1629
  }
1584
- };
1585
- await walk(root);
1586
- let total = files.reduce((sum, f) => sum + f.size, 0);
1587
- files.sort((a, b) => a.mtime - b.mtime);
1588
- for (const f of files) {
1589
- if (total <= cap) break;
1590
- try {
1591
- await rm(f.path, { force: true });
1592
- total -= f.size;
1593
- removed.push(f.path.slice(root.length + 1));
1594
- } catch (error) {}
1630
+ locations.push({
1631
+ idx,
1632
+ hunk,
1633
+ start: -1,
1634
+ end: -1,
1635
+ matched: false
1636
+ });
1595
1637
  }
1638
+ return locations;
1596
1639
  }
1597
- /**
1598
- * 清理插件缓存根(分级 + 总预算兜底):
1599
- * - 根级残留文件(旧扁平格式/未知)→ 删
1600
- * - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
1601
- * - user/ 用户级 → 按「schema 版本 + 保留期」清文件
1602
- * - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
1603
- * best-effort 静默。
1604
- * @author ddj 2026年09月01号
1605
- * @param home DSH home(测试可注入)
1606
- * @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
1607
- * @returns 删除的文件相对路径清单
1608
- */
1609
- async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP) {
1610
- const dir = pluginCacheRoot(home);
1611
- const names = await readdir(dir).catch(() => []);
1612
- if (!names.length) return [];
1613
- const removed = [];
1614
- const now = Date.now();
1615
- await Promise.all(names.map(async (name) => {
1616
- const full = join(dir, name);
1617
- try {
1618
- if ((await stat(full)).isDirectory()) {
1619
- if (name === "workspace") await sweepWorkspaceTier(full, removed, now);
1620
- else if (name === "user") await sweepDirFiles(full, removed, now, false);
1621
- else {
1622
- await rm(full, {
1623
- recursive: true,
1624
- force: true
1625
- });
1626
- removed.push(name);
1627
- }
1628
- } else {
1629
- await rm(full, { force: true });
1630
- removed.push(name);
1631
- }
1632
- } catch (error) {}
1633
- }));
1634
- await sweepByTotalCap(dir, removed, cap);
1635
- return removed;
1640
+ /** 判断两个半开区间是否重叠。零长度区间只与同一位置的零长度区间重叠。 */
1641
+ function overlaps(left, right) {
1642
+ if (left.start === left.end || right.start === right.end) return left.start === right.start && left.end === right.end;
1643
+ return left.start < right.end && right.start < left.end;
1636
1644
  }
1637
- //#endregion
1638
- //#region src/shared/logger.ts
1639
- /**
1640
- * dsh-vscode-mode — 统一日志核心(双面共享,平台中立)。
1641
- * 输出规范:
1642
- * - 格式:`[dsh-vscode-mode][:scope] 消息`;前缀只在模块内部拼接,业务代码禁止手写前缀
1643
- * - 级别语义:debug=诊断明细 / info=装配与路由里程碑 / warn=降级与兼容回退 / error=失败
1644
- * - 出口:host 面经 bindHostLog(ctx) 绑定 ctx.logger(缺失回退 console);client 面固定 console
1645
- * - 诊断文件通道(edrv.debug → ~/.dsh/dsh-vscode-mode/logs/)见 debugLog.ts,与本模块前缀共用
1646
- * 新增日志一律 `import { log } from '../log.js'`(client 为 './log.js')后调 log.debug/info/warn/error;
1647
- * 子域日志用 log.child('scope'),禁止另起 console。
1648
- * 作者 ddj 2026-09-08
1649
- */
1650
- /** 插件统一日志前缀(与包安装名一致)。 */
1651
- const LOG_PREFIX = "[dsh-vscode-mode]";
1652
- /** 默认级别到 console 方法名的映射。 */
1653
- const CONSOLE_METHODS = {
1654
- debug: "debug",
1655
- info: "info",
1656
- warn: "warn",
1657
- error: "error"
1658
- };
1659
- /**
1660
- * 默认出口:console(级别直映方法;方法缺失时静默丢弃,不抛错)。
1661
- * @author ddj 2026年09月08号
1662
- * @returns console 出口
1663
- */
1664
- function consoleSink() {
1665
- return (level, line) => {
1666
- const emit = console[CONSOLE_METHODS[level]];
1667
- if (typeof emit === "function") emit.call(console, line);
1668
- };
1645
+ /** 在可定位区间上执行一次 hunk 反向替换。 */
1646
+ function replaceRange(content, location, reverse = false) {
1647
+ if (!location.matched || location.start < 0) return null;
1648
+ const replacement = reverse ? location.hunk.oldText ?? "" : location.hunk.newText;
1649
+ return content.slice(0, location.start) + replacement + content.slice(location.end);
1669
1650
  }
1670
1651
  /**
1671
- * DSH host 出口工厂:转接 ctx.logger(方法级可选调用,缺服务/缺方法回退 console)。
1672
- * @author ddj 2026年09月08号
1673
- * @param ctx DSH host 上下文
1674
- * @returns 日志出口
1652
+ * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
1653
+ * @author ddj 2026年09月29号
1654
+ * @param content 当前文件内容
1655
+ * @param locations 已定位区间
1656
+ * @param reverse 是否反向应用
1657
+ * @returns 合成内容及失效块索引
1675
1658
  */
1676
- function ctxLogSink(ctx) {
1677
- const base = consoleSink();
1678
- return (level, line) => {
1679
- const logger = ctx?.logger;
1680
- const emit = logger?.[level];
1681
- if (typeof emit === "function") emit.call(logger, line);
1682
- else base(level, line);
1659
+ function applyLocations(content, locations, reverse = false) {
1660
+ const stale = locations.filter((location) => !location.matched).map((location) => location.idx);
1661
+ const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start);
1662
+ const parts = [];
1663
+ let end = content.length;
1664
+ for (const location of matched) {
1665
+ if (location.start < 0 || location.end > end) {
1666
+ let out = content;
1667
+ for (const item of matched) {
1668
+ const next = replaceRange(out, item, reverse);
1669
+ if (next === null) stale.push(item.idx);
1670
+ else out = next;
1671
+ }
1672
+ return {
1673
+ content: out,
1674
+ stale: stale.sort((a, b) => a - b)
1675
+ };
1676
+ }
1677
+ parts.push(content.slice(location.end, end), reverse ? location.hunk.oldText ?? "" : location.hunk.newText);
1678
+ end = location.start;
1679
+ }
1680
+ parts.push(content.slice(0, end));
1681
+ parts.reverse();
1682
+ return {
1683
+ content: parts.join(""),
1684
+ stale: stale.sort((a, b) => a - b)
1683
1685
  };
1684
1686
  }
1685
1687
  /**
1686
- * 拼一行日志:`[dsh-vscode-mode][:scope] 消息`(scope 为空省略冒号段)。
1687
- * @author ddj 2026年09月08号
1688
- * @param scope 子域(可空)
1689
- * @param message 消息文本
1690
- * @returns 整行文本
1688
+ * 写决策到记录(scope=call 整体 / hunk 单个块)。
1689
+ * @author ddj 2026年08月20号
1690
+ * @param record 记录
1691
+ * @param scope 作用域
1692
+ * @param idx hunk 下标(仅 scope=hunk 时生效)
1693
+ * @param value 决策值
1694
+ * @returns 更新后的记录(原对象就地修改)
1691
1695
  */
1692
- function formatLine(scope, message) {
1693
- const text = String(message);
1694
- return scope ? LOG_PREFIX + ":" + scope + " " + text : LOG_PREFIX + " " + text;
1696
+ function markDecision(record, scope, idx, value) {
1697
+ const dec = record.decisions;
1698
+ if (scope === "hunk" && Array.isArray(dec.perHunk) && Number.isInteger(idx)) {
1699
+ const i = idx;
1700
+ if (i >= 0 && i < dec.perHunk.length) dec.perHunk[i] = value;
1701
+ } else {
1702
+ dec.call = value;
1703
+ if (Array.isArray(dec.perHunk)) dec.perHunk = dec.perHunk.map(() => value);
1704
+ }
1705
+ record.at = (/* @__PURE__ */ new Date()).toISOString();
1706
+ return record;
1707
+ }
1708
+ /** 记录是否已无任何待决策差异(superseded 或全部已决策)。 */
1709
+ function recordResolved(record) {
1710
+ if (record.superseded === true) return true;
1711
+ const hunks = Array.isArray(record.hunks) ? record.hunks : [];
1712
+ const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
1713
+ const count = Math.max(hunks.length, perHunk.length);
1714
+ if (!count) return record.decisions.call === "accepted" || record.decisions.call === "rejected";
1715
+ for (let idx = 0; idx < count; idx++) {
1716
+ const precise = preciseHunk(record, idx);
1717
+ const value = perHunk[idx] ?? record.decisions.call;
1718
+ if (isNoopHunk(precise) && value === "pending") return false;
1719
+ if (value !== "accepted" && value !== "rejected") return false;
1720
+ }
1721
+ return true;
1695
1722
  }
1696
1723
  /**
1697
- * 日志器工厂(内部):emit 时读共享出口状态,保证父 bind 后子实例跟随。
1698
- * @author ddj 2026年09月08号
1699
- * @param state 共享出口状态
1700
- * @param scope 子域文本(可空)
1701
- * @returns 日志器
1724
+ * 记录决策摘要(采纳/拒绝/待处理计数,superseded 标记)。
1725
+ * @author ddj 2026年08月20号
1726
+ * @param record 记录
1727
+ * @returns 摘要
1702
1728
  */
1703
- function makeLogger(state, scope) {
1704
- const emit = (level, message) => state.sink(level, formatLine(scope, message));
1729
+ function recSummary(record) {
1730
+ const hunks = Array.isArray(record.hunks) ? record.hunks : [];
1731
+ const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : [];
1732
+ const n = Math.max(1, hunks.length, perHunk.length);
1733
+ let accepted = 0;
1734
+ let rejected = 0;
1735
+ let pending = 0;
1736
+ for (let i = 0; i < n; i++) {
1737
+ const hunk = preciseHunk(record, i);
1738
+ const value = perHunk[i] ?? record.decisions.call;
1739
+ if (i < hunks.length && isNoopHunk(hunk) && value === "pending") continue;
1740
+ if (value === "accepted") accepted++;
1741
+ else if (value === "rejected") rejected++;
1742
+ else pending++;
1743
+ }
1705
1744
  return {
1706
- debug: (message) => emit("debug", message),
1707
- info: (message) => emit("info", message),
1708
- warn: (message) => emit("warn", message),
1709
- error: (message) => emit("error", message),
1710
- bind(next) {
1711
- state.sink = next;
1712
- },
1713
- child(next) {
1714
- return makeLogger(state, scope ? scope + "." + next : next);
1715
- }
1745
+ accepted,
1746
+ rejected,
1747
+ pending,
1748
+ superseded: record.superseded === true
1716
1749
  };
1717
1750
  }
1718
1751
  /**
1719
- * 创建插件日志器(默认 console 出口;host 面单例由 bindHostLog 换到 ctx.logger)。
1720
- * @author ddj 2026年09月08号
1721
- * @param sink 初始出口(缺省 console)
1722
- * @param scope 初始子域(业务方一般经 child 派生,不直接传)
1723
- * @returns 日志器
1752
+ * 重建"本批次修改前"内容:把仍待处理(pending)的差异块按 新→旧 顺序从当前内容反解
1753
+ * (newText → oldText)。已采纳/已拒绝的块跳过。反解失败的块标记为 stale。
1754
+ * @author ddj 2026年08月20号
1755
+ * @param records 该文件的活动记录
1756
+ * @param content 当前磁盘内容
1757
+ * @returns 重建内容与 stale 定位
1724
1758
  */
1725
- function createLogger(sink = consoleSink(), scope = "") {
1726
- return makeLogger({ sink }, scope);
1759
+ function reconstructOriginal(records, content) {
1760
+ const pending = [];
1761
+ for (const rec of records) for (let i = 0; i < rec.hunks.length; i++) {
1762
+ const st = rec.decisions.perHunk[i] ?? rec.decisions.call;
1763
+ const hunk = preciseHunk(rec, i);
1764
+ if (st === "pending" && hunk && !isNoopHunk(hunk)) pending.push({
1765
+ rec,
1766
+ idx: i,
1767
+ hunk
1768
+ });
1769
+ }
1770
+ pending.sort((a, b) => a.rec.at < b.rec.at ? 1 : a.rec.at > b.rec.at ? -1 : 0);
1771
+ const rebuilt = applyLocations(content, locateHunks(content, pending.map((item) => item.hunk)), true);
1772
+ return {
1773
+ content: rebuilt.content,
1774
+ stale: rebuilt.stale.map((idx) => ({
1775
+ callId: pending[idx].rec.callId,
1776
+ idx: pending[idx].idx
1777
+ }))
1778
+ };
1727
1779
  }
1728
- //#endregion
1729
- //#region src/log.ts
1730
- /**
1731
- * dsh-vscode-mode host — 日志单例(全 host 面唯一日志入口)。
1732
- * 默认 console 出口;apply() 首行调 bindHostLog(ctx) 切到 ctx.logger(缺失回退 console)。
1733
- * 用法:`import { log } from './log.js'` → `log.debug/info/warn/error(msg)`;子域用 `log.child('scope')`。
1734
- * 作者 ddj 2026-09-08
1735
- */
1736
- /** host 面插件日志单例(格式/级别语义见 shared/logger.ts 头注释)。 */
1737
- const log = createLogger();
1738
1780
  /**
1739
- * 装配期绑定 host 日志出口:ctx.logger 可用时全部日志转经 DSH 日志系统。
1740
- * @author ddj 2026年09月08号
1741
- * @param ctx DSH host 上下文(logger 服务缺失时保持 console 出口)
1781
+ * @public
1782
+ * @author ddj 2026年10月10号
1783
+ * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
1784
+ * @param record 审查记录
1785
+ * @returns 未归档、未覆盖且没有用户决策时为 true
1742
1786
  */
1743
- function bindHostLog(ctx) {
1744
- log.bind(ctxLogSink(ctx));
1787
+ function fullyPending(record) {
1788
+ return !record.archived && !record.superseded && record.decisions.call === "pending" && record.decisions.perHunk.every((value) => value === "pending");
1745
1789
  }
1746
- //#endregion
1747
- //#region src/debugLog.ts
1748
1790
  /**
1749
- * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
1750
- * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
1751
- * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
1752
- * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
1753
- * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
1754
- * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
1755
- * 作者 ddj 2026-09-08 / 2026-09-17
1791
+ * @public
1792
+ * @author ddj 2026年10月10号
1793
+ * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
1794
+ * @param prior 先前修改的差异块
1795
+ * @param later 后续修改的差异块
1796
+ * @returns 文本归一化后整组逆反且包含真实修改时为 true
1756
1797
  */
1757
- /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
1758
- const debugBuffers = /* @__PURE__ */ new Map();
1759
- /** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
1760
- const debugWriteQueues = /* @__PURE__ */ new Map();
1761
- /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
1762
- const DEBUG_LOG_CAP = 524288;
1763
- /** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
1764
- const DEBUG_BUF_CAP = 32768;
1765
- /** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
1766
- const DEBUG_FLUSH_IDLE_MS = 1e3;
1767
- /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
1768
- const debugLegacyCleaned = /* @__PURE__ */ new Set();
1769
- /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
1770
- const debugLog = log.child("debug");
1771
- /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
1772
- const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
1798
+ function hunksInverse(prior, later) {
1799
+ if (!prior.length || prior.length !== later.length) return false;
1800
+ let changed = false;
1801
+ for (let idx = 0; idx < prior.length; idx++) {
1802
+ const a = normalizeHunk(prior[idx]);
1803
+ const b = normalizeHunk(later[idx]);
1804
+ if (a.oldText !== b.newText || a.newText !== b.oldText) return false;
1805
+ if (!isNoopHunk(a)) changed = true;
1806
+ }
1807
+ return changed;
1808
+ }
1773
1809
  /**
1774
- * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
1775
- * @author ddj 2026年09月17号
1776
- * @param name 请求回传的文件名(可为空)
1777
- * @returns 是否合法
1810
+ * @private
1811
+ * @author ddj 2026年10月10号
1812
+ * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
1813
+ * @param a 第一条记录
1814
+ * @param b 第二条记录
1815
+ * @returns 负数表示 a 更早,零表示先后无法区分
1778
1816
  */
1779
- function isDebugLogName(name) {
1780
- return typeof name === "string" && DEBUG_LOG_NAME.test(name);
1817
+ function recordOrder(a, b) {
1818
+ if (a.path !== b.path) return a.path.localeCompare(b.path);
1819
+ if (Number.isInteger(a.batch) && Number.isInteger(b.batch) && a.batch > 0 && b.batch > 0 && a.batch !== b.batch) return a.batch - b.batch;
1820
+ const delta = Date.parse(a.at) - Date.parse(b.at);
1821
+ return Number.isFinite(delta) ? delta : 0;
1781
1822
  }
1782
1823
  /**
1783
- * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
1784
- * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
1785
- * @author ddj 2026年09月08号
1786
- * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
1787
- * @param batch 本批日志行
1824
+ * @private
1825
+ * @author ddj 2026年10月10号
1826
+ * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
1827
+ * @param prior 先前记录
1828
+ * @param later 后续记录
1829
+ * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
1788
1830
  */
1789
- function flushDebug(cwd, batch) {
1790
- const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
1791
- try {
1792
- await mkdir(pluginLogRoot(), { recursive: true });
1793
- const target = debugLogFile(cwd);
1794
- const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
1795
- const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
1796
- await writeFile(target, next, "utf8");
1797
- if (!debugLegacyCleaned.has(cwd)) {
1798
- debugLegacyCleaned.add(cwd);
1799
- await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
1800
- }
1801
- } catch (e) {}
1802
- });
1803
- debugWriteQueues.set(cwd, task);
1804
- return task;
1831
+ function snapshotsRestore(prior, later) {
1832
+ if (prior.create || later.create) return false;
1833
+ if (typeof prior.before !== "string" || typeof prior.after !== "string" || typeof later.before !== "string" || typeof later.after !== "string") return false;
1834
+ return normalizeForCompare(prior.before) === normalizeForCompare(later.after) && normalizeForCompare(prior.after) === normalizeForCompare(later.before);
1805
1835
  }
1806
1836
  /**
1807
- * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
1808
- * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
1809
- * @author ddj 2026年09月08号 / 2026年09月17号
1810
- * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
1811
- * @param cwd 工作区(日志文件按 cwd hash 隔离)
1812
- * @param text 单条日志文本(不含换行)
1813
- * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
1837
+ * @public
1838
+ * @author ddj 2026年10月10号
1839
+ * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
1840
+ * @param records 工作区记录(不修改输入)
1841
+ * @param record 新记录
1842
+ * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
1814
1843
  */
1815
- function debugRecord(_ctx, cwd, text, level = "debug") {
1816
- const tagged = "[" + level + "] " + text;
1817
- const st = debugBuffers.get(cwd) ?? {
1818
- lines: [],
1819
- len: 0,
1820
- timer: null
1821
- };
1822
- st.lines.push(tagged);
1823
- st.len += tagged.length;
1824
- const flush = () => {
1825
- st.timer = null;
1826
- const batch = st.lines;
1827
- st.lines = [];
1828
- st.len = 0;
1829
- flushDebug(cwd, batch);
1830
- };
1831
- if (st.len >= DEBUG_BUF_CAP) {
1832
- if (st.timer) clearTimeout(st.timer);
1833
- flush();
1834
- } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
1835
- debugBuffers.set(cwd, st);
1836
- debugLog[level](text);
1844
+ function findInversePrior(records, record) {
1845
+ if (!fullyPending(record)) return null;
1846
+ let found = null;
1847
+ for (const prior of records) {
1848
+ if (prior.callId === record.callId || prior.path !== record.path || !fullyPending(prior)) continue;
1849
+ if (recordOrder(prior, record) >= 0 || !hunksInverse(prior.hunks, record.hunks)) continue;
1850
+ if (!snapshotsRestore(prior, record)) continue;
1851
+ if (!found || recordOrder(found, prior) < 0) found = prior;
1852
+ }
1853
+ return found;
1837
1854
  }
1838
1855
  /**
1839
- * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
1840
- * @author ddj 2026年09月17号
1841
- * @param home DSH home(测试可注入;缺省按环境解析)
1842
- * @returns 清单项数组
1856
+ * @public
1857
+ * @author ddj 2026年10月10号
1858
+ * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
1859
+ * @param records 工作区记录(不修改输入或记录状态)
1860
+ * @returns 可以整对归档的记录对
1843
1861
  */
1844
- async function listDebugLogs(home = dshHome()) {
1845
- const names = await readdir(pluginLogRoot(home)).catch(() => []);
1846
- const items = [];
1847
- for (const name of names) {
1848
- if (!isDebugLogName(name)) continue;
1849
- const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
1850
- if (!info?.isFile()) continue;
1851
- items.push({
1852
- name,
1853
- bytes: info.size,
1854
- mtimeMs: info.mtimeMs
1855
- });
1862
+ function findCanceledPairs(records) {
1863
+ const available = [];
1864
+ const pairs = [];
1865
+ const ordered = [...records].sort(recordOrder);
1866
+ for (const record of ordered) {
1867
+ const prior = findInversePrior(available, record);
1868
+ if (!prior) {
1869
+ if (fullyPending(record)) available.push(record);
1870
+ continue;
1871
+ }
1872
+ pairs.push([prior, record]);
1873
+ available.splice(available.indexOf(prior), 1);
1874
+ }
1875
+ return pairs;
1876
+ }
1877
+ /** 文件当前最大批次号(无记录为 0)。 */
1878
+ function fileMaxBatch(records, path) {
1879
+ let m = 0;
1880
+ for (const r of records.values()) if (r.path === path && Number.isInteger(r.batch) && r.batch > m) m = r.batch;
1881
+ return m;
1882
+ }
1883
+ /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
1884
+ function prune(map, max = 200) {
1885
+ if (map.size <= max) return;
1886
+ const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record)).sort((a, b) => a.at < b.at ? -1 : 1);
1887
+ for (const record of sorted) {
1888
+ if (map.size <= max) break;
1889
+ map.delete(record.callId);
1856
1890
  }
1857
- items.sort((a, b) => b.mtimeMs - a.mtimeMs);
1858
- return items;
1859
1891
  }
1860
1892
  /**
1861
- * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
1862
- * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
1863
- * @author ddj 2026年09月17号
1864
- * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
1865
- * @param maxBytes 尾部保留字节数(缺省 256KB)
1866
- * @param home DSH home(测试可注入)
1867
- * @returns 读取结果;文件不存在或名字非法返回 null
1893
+ * 归档条目构造(一条批次一条)。
1894
+ * @author ddj 2026年08月20号
1895
+ * @param recs 同批次记录(非空)
1896
+ * @param cwd 工作区
1897
+ * @param reason 归档原因
1868
1898
  */
1869
- async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
1870
- if (!isDebugLogName(name)) return null;
1871
- const target = join(pluginLogRoot(home), name);
1872
- const info = await stat(target).catch(() => null);
1873
- if (!info?.isFile()) return null;
1874
- const text = await readFile(target, "utf8").catch(() => null);
1875
- if (text === null) return null;
1876
- const cut = Math.max(0, text.length - maxBytes);
1899
+ function archiveEntryFor(recs, cwd, reason) {
1900
+ const first = recs[0];
1877
1901
  return {
1878
- name,
1879
- path: target,
1880
- content: cut > 0 ? text.slice(cut) : text,
1881
- size: info.size,
1882
- truncated: cut > 0
1902
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1903
+ cwd,
1904
+ path: first.path,
1905
+ batch: Number.isInteger(first.batch) ? first.batch : null,
1906
+ reason,
1907
+ records: recs.map((r) => ({
1908
+ callId: r.callId,
1909
+ toolName: r.toolName,
1910
+ path: r.path,
1911
+ create: r.create === true,
1912
+ callHunk: r.callHunk,
1913
+ hunks: r.hunks,
1914
+ decisions: r.decisions,
1915
+ note: r.note ?? null,
1916
+ before: typeof r.before === "string" ? r.before : null,
1917
+ after: typeof r.after === "string" ? r.after : null,
1918
+ baseFingerprint: r.baseFingerprint ?? null,
1919
+ afterFingerprint: r.afterFingerprint ?? null,
1920
+ conflict: r.conflict === true,
1921
+ legacy: r.legacy === true,
1922
+ superseded: r.superseded === true,
1923
+ batch: Number.isInteger(r.batch) ? r.batch : null,
1924
+ at: r.at,
1925
+ summary: recSummary(r)
1926
+ }))
1883
1927
  };
1884
1928
  }
1885
- /**
1886
- * 清空一份诊断日志(truncate 到 0,保留文件本身)。
1887
- * @author ddj 2026年09月17号
1888
- * @param name 日志文件名(isDebugLogName 白名单校验)
1889
- * @param home DSH home(测试可注入)
1890
- * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
1891
- */
1892
- async function clearDebugLog(name, home = dshHome()) {
1893
- if (!isDebugLogName(name)) return false;
1894
- return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
1895
- }
1896
1929
  //#endregion
1897
1930
  //#region src/archiveDb.ts
1898
1931
  /**
@@ -2986,10 +3019,14 @@ var McpRuntime = class {
2986
3019
  instance ??= this.reserve(agent, record, path, name, def);
2987
3020
  await waitMcp(instance.ready, signal);
2988
3021
  if (!this.valid(agent, record, name, instance)) throw new Error("项目 MCP 配置已失效");
2989
- const visible = agent.ctx.get("tools")?.view?.(agent)?.visible;
3022
+ const registry = agent.ctx.get("tools");
3023
+ let visible;
3024
+ if (typeof registry?.definitions === "function") visible = new Set(registry.definitions(agent).map((tool) => tool.name));
3025
+ else if (typeof registry?.view === "function") visible = new Set(registry.view(agent).visible.keys());
3026
+ else throw new Error("项目 MCP 工具目录不可用:缺少公开 definitions 或旧版 view 接口");
2990
3027
  return {
2991
3028
  serverName: name,
2992
- tools: [...instance.lease.entry.bridge.tools.values()].filter((tool) => visible?.has(tool.name)).map((tool) => ({
3029
+ tools: [...instance.lease.entry.bridge.tools.values()].filter((tool) => visible.has(tool.name)).map((tool) => ({
2993
3030
  name: tool.name,
2994
3031
  description: tool.description,
2995
3032
  parameters: tool.parameters