dsh-vscode-mode 0.21.0 → 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,1434 +464,1467 @@ 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";
477
- }
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
- });
493
- }
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
- }
659
- }
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;
667
- }
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
- }
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
- });
743
- }
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
- });
759
- }
760
- //#endregion
761
- //#region src/shared/diff.ts
762
- /** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
763
- function preciseHunk(record, idx) {
764
- return hunkOf(record, idx);
765
- }
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;
775
- }
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;
782
- }
783
- /** hunk 是否表示没有实际内容变化。 */
784
- function isNoopHunk(hunk) {
785
- return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText;
786
- }
787
- /**
788
- * 差异比较口径归一化:剥离 UTF-8 BOM、统一 CRLF 为 LF。
789
- * 背景:外部工具(pwsh/编辑器)可能改变 BOM/行尾,导致 after 指纹与 hunk
790
- * 定位在内容语义未变的情况下误报 conflict,把待确认修改从 UI 排除。
791
- * @author ddj 2026年09月09号
792
- */
793
- function normalizeForCompare(text) {
794
- return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
795
- }
796
- /** 归一化一个 hunk 的两侧文本(与 normalizeForCompare 同口径)。 */
797
- function normalizeHunk(hunk) {
798
- return {
799
- oldText: hunk.oldText === null ? null : normalizeForCompare(hunk.oldText),
800
- newText: normalizeForCompare(hunk.newText)
801
- };
802
- }
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
- };
817
- }
818
- return hunk;
819
- }
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;
847
- }
467
+ //#region src/shared/rpc.ts
468
+ /** webServer 精确路由。 */
469
+ const RPC_PATH = "/edrv/rpc";
848
470
  /**
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 与完整候选列表双轮查找相同的匹配
471
+ * 图片扩展名(小写、无点)→ MIME。edrv.read base64 响应与 client 图片预览共用,
472
+ * 收敛在此避免 host/client 两处漂移。
473
+ * @author ddj 2026年09月08号
857
474
  */
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;
866
- }
867
- while (!state.done) {
868
- const at = content.indexOf(needle, state.next);
869
- if (at < 0) {
870
- state.done = true;
871
- break;
872
- }
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;
882
- }
883
- return fallback;
884
- }
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
+ };
885
486
  /**
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
487
+ * 路径 → 图片 MIME:按 basename 扩展名小写查 {@link IMAGE_MIME},未命中回退 application/octet-stream。
488
+ * @author ddj 2026年09月08号
489
+ * @param path 文件路径(`/` 或 `\` 分隔均可)
490
+ * @returns MIME 字符串
892
491
  */
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;
957
- }
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;
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";
973
497
  }
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);
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);
979
520
  }
521
+ //#endregion
522
+ //#region src/paths.ts
980
523
  /**
981
- * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
982
- * @author ddj 2026年09月29号
983
- * @param content 当前文件内容
984
- * @param locations 已定位区间
985
- * @param reverse 是否反向应用
986
- * @returns 合成内容及失效块索引
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
987
533
  */
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)
1014
- };
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);
1015
555
  }
1016
556
  /**
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 更新后的记录(原对象就地修改)
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)
1024
563
  */
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;
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");
1049
574
  }
1050
- return true;
575
+ return out;
1051
576
  }
1052
577
  /**
1053
- * 记录决策摘要(采纳/拒绝/待处理计数,superseded 标记)。
1054
- * @author ddj 2026年08月20号
1055
- * @param record 记录
1056
- * @returns 摘要
578
+ * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
579
+ * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
580
+ * @author ddj 2026年09月02号
581
+ * @param cwd 会话工作区绝对路径(非空)
582
+ * @returns `~/.dsh/sessions` 下的工作区目录名
1057
583
  */
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++;
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;
600
+ }
1072
601
  }
1073
- return {
1074
- accepted,
1075
- rejected,
1076
- pending,
1077
- superseded: record.superseded === true
1078
- };
602
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
603
+ }
604
+ /** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
605
+ function sessionIdSegment(id) {
606
+ return encodeSegment(id);
607
+ }
608
+ /** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
609
+ function sessionsRoot(home = dshHome()) {
610
+ return join(home, "sessions");
1079
611
  }
1080
612
  /**
1081
- * 重建"本批次修改前"内容:把仍待处理(pending)的差异块按 新→旧 顺序从当前内容反解
1082
- * (newText → oldText)。已采纳/已拒绝的块跳过。反解失败的块标记为 stale。
1083
- * @author ddj 2026年08月20号
1084
- * @param records 该文件的活动记录
1085
- * @param content 当前磁盘内容
1086
- * @returns 重建内容与 stale 定位
613
+ * 解析 DSH home:空白环境值视为未设置,展开 ~ 并解析绝对路径。
614
+ * @public
615
+ * @author ddj 2026年09月28号
616
+ * @param env 环境映射(缺省 process.env;测试可注入)
617
+ * @returns 归一化的 DSH home 绝对路径
1087
618
  */
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);
1101
- return {
1102
- content: rebuilt.content,
1103
- stale: rebuilt.stale.map((idx) => ({
1104
- callId: pending[idx].rec.callId,
1105
- idx: pending[idx].idx
1106
- }))
1107
- };
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);
625
+ }
626
+ /** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
627
+ function pluginCacheRoot(home = dshHome()) {
628
+ return join(home, PLUGIN_ID, "cache");
629
+ }
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");
1108
649
  }
1109
650
  /**
1110
- * @public
1111
- * @author ddj 2026年10月10号
1112
- * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
1113
- * @param record 审查记录
1114
- * @returns 未归档、未覆盖且没有用户决策时为 true
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 状态镜像文件绝对路径
1115
658
  */
1116
- function fullyPending(record) {
1117
- return !record.archived && !record.superseded && record.decisions.call === "pending" && record.decisions.perHunk.every((value) => value === "pending");
659
+ function uiStateFile(cwd, home = dshHome(), schema = 1) {
660
+ return join(workspaceCacheDir(hashOf(cwd), home), "ui.v" + schema + ".json");
1118
661
  }
1119
662
  /**
1120
- * @public
1121
- * @author ddj 2026年10月10号
1122
- * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
1123
- * @param prior 先前修改的差异块
1124
- * @param later 后续修改的差异块
1125
- * @returns 文本归一化后整组逆反且包含真实修改时为 true
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 状态镜像文件绝对路径
1126
669
  */
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;
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");
1137
676
  }
1138
677
  /**
1139
- * @private
1140
- * @author ddj 2026年10月10号
1141
- * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
1142
- * @param a 第一条记录
1143
- * @param b 第二条记录
1144
- * @returns 负数表示 a 更早,零表示先后无法区分
678
+ * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
679
+ * @author ddj 2026年09月30号
680
+ * @param cwd 工作区绝对路径
681
+ * @param home DSH home
682
+ * @returns 归档数据库绝对路径
1145
683
  */
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;
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");
1151
694
  }
1152
695
  /**
1153
- * @private
1154
- * @author ddj 2026年10月10号
1155
- * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
1156
- * @param prior 先前记录
1157
- * @param later 后续记录
1158
- * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
696
+ * 插件自带技能组根目录(包根 skills/,与 assets/ 同级;随包发布)。
697
+ * 布局:<根>/<技能名>/SKILL.md(目录式)或 <根>/<技能名>.md(扁平式)。
698
+ * @author ddj 2026年09月11号
699
+ * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
700
+ * @returns 技能组根目录绝对路径
1159
701
  */
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);
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);
1164
710
  }
1165
711
  /**
1166
- * @public
1167
- * @author ddj 2026年10月10号
1168
- * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
1169
- * @param records 工作区记录(不修改输入)
1170
- * @param record 新记录
1171
- * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
712
+ * 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
713
+ * 新增缓存文件必须登记在此,否则被当作未知残留清理。
1172
714
  */
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;
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;
727
+ return {
728
+ kind,
729
+ schema: Number(m[2])
730
+ };
1183
731
  }
1184
732
  /**
1185
- * @public
1186
- * @author ddj 2026年10月10号
1187
- * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
1188
- * @param records 工作区记录(不修改输入或记录状态)
1189
- * @returns 可以整对归档的记录对
733
+ * 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
734
+ * @author ddj 2026年09月28号
735
+ * @param filename 缓存文件名(不含目录)
736
+ * @returns 已登记种类且版本为当前版本
1190
737
  */
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;
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;
754
+ }
1200
755
  }
1201
- pairs.push([prior, record]);
1202
- available.splice(available.indexOf(prior), 1);
756
+ if (!drop) continue;
757
+ try {
758
+ await rm(full, { force: true });
759
+ removed.push(name);
760
+ } catch (error) {}
1203
761
  }
1204
- return pairs;
1205
762
  }
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;
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(() => {});
773
+ }
1211
774
  }
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);
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) {}
791
+ }
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) {}
1219
803
  }
1220
804
  }
1221
805
  /**
1222
- * 归档条目构造(一条批次一条)。
1223
- * @author ddj 2026年08月20号
1224
- * @param recs 同批次记录(非空)
1225
- * @param cwd 工作区
1226
- * @param reason 归档原因
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 删除的文件相对路径清单
1227
816
  */
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
- }))
1256
- };
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);
839
+ }
840
+ } catch (error) {}
841
+ }));
842
+ await sweepByTotalCap(dir, removed, cap);
843
+ return removed;
1257
844
  }
1258
845
  //#endregion
1259
- //#region src/shared/rpc.ts
1260
- /** webServer 精确路由。 */
1261
- const RPC_PATH = "/edrv/rpc";
846
+ //#region src/shared/logger.ts
1262
847
  /**
1263
- * 图片扩展名(小写、无点)→ MIME。edrv.read base64 响应与 client 图片预览共用,
1264
- * 收敛在此避免 host/client 两处漂移。
1265
- * @author ddj 2026年09月08号
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
1266
857
  */
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"
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"
1277
866
  };
1278
867
  /**
1279
- * 路径 → 图片 MIME:按 basename 扩展名小写查 {@link IMAGE_MIME},未命中回退 application/octet-stream。
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);
876
+ };
877
+ }
878
+ /**
879
+ * DSH host 出口工厂:转接 ctx.logger(方法级可选调用,缺服务/缺方法回退 console)。
880
+ * @author ddj 2026年09月08号
881
+ * @param ctx DSH host 上下文
882
+ * @returns 日志出口
883
+ */
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
+ };
892
+ }
893
+ /**
894
+ * 拼一行日志:`[dsh-vscode-mode][:scope] 消息`(scope 为空省略冒号段)。
1280
895
  * @author ddj 2026年09月08号
1281
- * @param path 文件路径(`/` 或 `\` 分隔均可)
1282
- * @returns MIME 字符串
896
+ * @param scope 子域(可空)
897
+ * @param message 消息文本
898
+ * @returns 整行文本
1283
899
  */
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";
900
+ function formatLine(scope, message) {
901
+ const text = String(message);
902
+ return scope ? LOG_PREFIX + ":" + scope + " " + text : LOG_PREFIX + " " + text;
1289
903
  }
1290
- /** PDF 统一 MIME(host base64 响应与 client PDF 面板同源,避免两处漂移)。 */
1291
- const PDF_MIME = "application/pdf";
1292
904
  /**
1293
- * 会话级前置错误的规范文案:host requireSession 系与 client 错误识别共用同一常量,
1294
- * 避免文案漂移导致 client 端分类失效。跨端场景(桌面端/Web 端各自运行独立 DSH 实例、
1295
- * 会话列表互通但会话仅在一端激活)下,本端 host 取不到会话即返回「会话不存在」。
1296
- * @author ddj 2026年10月16号
905
+ * 日志器工厂(内部):emit 时读共享出口状态,保证父 bind 后子实例跟随。
906
+ * @author ddj 2026年09月08号
907
+ * @param state 共享出口状态
908
+ * @param scope 子域文本(可空)
909
+ * @returns 日志器
1297
910
  */
1298
- const SESSION_MISSING_ERROR = "会话不存在";
1299
- /** 会话存在但无工作区 cwd 时的规范文案(与 SESSION_MISSING_ERROR 同源共用)。 */
1300
- const SESSION_NO_WORKSPACE_ERROR = "会话无工作区";
911
+ function makeLogger(state, scope) {
912
+ const emit = (level, message) => state.sink(level, formatLine(scope, message));
913
+ return {
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
+ }
924
+ };
925
+ }
1301
926
  /**
1302
- * 路径 → 二进制预览 MIME:PDF 特判 {@link PDF_MIME},其余走图片表回退 octet-stream。
1303
- * @author ddj 2026年09月22号
1304
- * @param path 文件路径(`/` 或 `\` 分隔均可)
1305
- * @returns MIME 字符串
927
+ * 创建插件日志器(默认 console 出口;host 面单例由 bindHostLog 换到 ctx.logger)。
928
+ * @author ddj 2026年09月08号
929
+ * @param sink 初始出口(缺省 console)
930
+ * @param scope 初始子域(业务方一般经 child 派生,不直接传)
931
+ * @returns 日志器
1306
932
  */
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);
933
+ function createLogger(sink = consoleSink(), scope = "") {
934
+ return makeLogger({ sink }, scope);
1312
935
  }
1313
936
  //#endregion
1314
- //#region src/paths.ts
937
+ //#region src/log.ts
1315
938
  /**
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
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
1325
943
  */
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);
1347
- }
944
+ /** host 面插件日志单例(格式/级别语义见 shared/logger.ts 头注释)。 */
945
+ const log = createLogger();
1348
946
  /**
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)
947
+ * 装配期绑定 host 日志出口:ctx.logger 可用时全部日志转经 DSH 日志系统。
948
+ * @author ddj 2026年09月08号
949
+ * @param ctx DSH host 上下文(logger 服务缺失时保持 console 出口)
1355
950
  */
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");
1366
- }
1367
- return out;
951
+ function bindHostLog(ctx) {
952
+ log.bind(ctxLogSink(ctx));
1368
953
  }
954
+ //#endregion
955
+ //#region src/debugLog.ts
1369
956
  /**
1370
- * DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
1371
- * 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
1372
- * @author ddj 2026年09月02号
1373
- * @param cwd 会话工作区绝对路径(非空)
1374
- * @returns `~/.dsh/sessions` 下的工作区目录名
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
1375
964
  */
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;
1392
- }
1393
- }
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
- }
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$/;
1404
981
  /**
1405
- * 解析 DSH home:空白环境值视为未设置,展开 ~ 并解析绝对路径。
1406
- * @public
1407
- * @author ddj 2026年09月28号
1408
- * @param env 环境映射(缺省 process.env;测试可注入)
1409
- * @returns 归一化的 DSH home 绝对路径
982
+ * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
983
+ * @author ddj 2026年09月17号
984
+ * @param name 请求回传的文件名(可为空)
985
+ * @returns 是否合法
1410
986
  */
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");
987
+ function isDebugLogName(name) {
988
+ return typeof name === "string" && DEBUG_LOG_NAME.test(name);
1441
989
  }
1442
990
  /**
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 状态镜像文件绝对路径
991
+ * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
992
+ * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
993
+ * @author ddj 2026年09月08号
994
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
995
+ * @param batch 本批日志行
1450
996
  */
1451
- function uiStateFile(cwd, home = dshHome(), schema = 1) {
1452
- return join(workspaceCacheDir(hashOf(cwd), home), "ui.v" + schema + ".json");
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;
1453
1013
  }
1454
1014
  /**
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 状态镜像文件绝对路径
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;落盘行与终端回显均带此级别)
1461
1022
  */
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");
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);
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);
1468
1045
  }
1469
1046
  /**
1470
- * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
1471
- * @author ddj 2026年09月30号
1472
- * @param cwd 工作区绝对路径
1473
- * @param home DSH home
1474
- * @returns 归档数据库绝对路径
1047
+ * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
1048
+ * @author ddj 2026年09月17号
1049
+ * @param home DSH home(测试可注入;缺省按环境解析)
1050
+ * @returns 清单项数组
1475
1051
  */
1476
- function archiveDbFile(cwd, home = dshHome()) {
1477
- return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
1478
- }
1479
- /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
1480
- function assetsDirOf(moduleUrl) {
1481
- return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
1482
- }
1483
- /** Monaco AMD 静态目录(随包发布)。 */
1484
- function vendorDirOf(moduleUrl) {
1485
- return join(assetsDirOf(moduleUrl), "vendor");
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;
1486
1067
  }
1487
1068
  /**
1488
- * 插件自带技能组根目录(包根 skills/,与 assets/ 同级;随包发布)。
1489
- * 布局:<根>/<技能名>/SKILL.md(目录式)或 <根>/<技能名>.md(扁平式)。
1490
- * @author ddj 2026年09月11号
1491
- * @param moduleUrl 模块 URL(缺省 import.meta.url;测试注入)
1492
- * @returns 技能组根目录绝对路径
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
1493
1076
  */
1494
- function skillsDirOf(moduleUrl) {
1495
- return join(dirname(fileURLToPath(moduleUrl)), "..", "skills");
1496
- }
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);
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
+ };
1502
1092
  }
1503
1093
  /**
1504
- * 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
1505
- * 新增缓存文件必须登记在此,否则被当作未知残留清理。
1094
+ * 清空一份诊断日志(truncate 到 0,保留文件本身)。
1095
+ * @author ddj 2026年09月17号
1096
+ * @param name 日志文件名(isDebugLogName 白名单校验)
1097
+ * @param home DSH home(测试可注入)
1098
+ * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
1506
1099
  */
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;
1519
- return {
1520
- kind,
1521
- schema: Number(m[2])
1522
- };
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);
1523
1103
  }
1104
+ //#endregion
1105
+ //#region src/mcpDiagnostic.ts
1524
1106
  /**
1525
- * 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
1526
- * @author ddj 2026年09月28号
1527
- * @param filename 缓存文件名(不含目录)
1528
- * @returns 已登记种类且版本为当前版本
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.
1529
1112
  */
1530
- function isCurrentCacheFile(filename) {
1531
- const parsed = parseCacheName(filename);
1532
- return parsed !== null && parsed.schema === CACHE_SCHEMAS[parsed.kind];
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);
1533
1135
  }
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;
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);
1546
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);
1547
1263
  }
1548
- if (!drop) continue;
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
+ });
1549
1277
  try {
1550
- await rm(full, { force: true });
1551
- removed.push(name);
1552
- } catch (error) {}
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
+ }
1553
1298
  }
1554
- }
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(() => {});
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 插件未完成激活");
1565
1314
  }
1566
- }
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
1581
- });
1582
- } catch (error) {}
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;
1329
+ }
1330
+ }
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;
1583
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);
1584
1387
  };
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) {}
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
+ });
1414
+ }
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
+ });
1430
+ }
1431
+ //#endregion
1432
+ //#region src/shared/diff.ts
1433
+ /** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
1434
+ function preciseHunk(record, idx) {
1435
+ return hunkOf(record, idx);
1436
+ }
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);
1595
1444
  }
1445
+ return "fnv1a:" + (hash >>> 0).toString(16) + ":" + text.length;
1446
+ }
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;
1453
+ }
1454
+ /** hunk 是否表示没有实际内容变化。 */
1455
+ function isNoopHunk(hunk) {
1456
+ return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText;
1596
1457
  }
1597
1458
  /**
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 删除的文件相对路径清单
1459
+ * 差异比较口径归一化:剥离 UTF-8 BOM、统一 CRLF 为 LF。
1460
+ * 背景:外部工具(pwsh/编辑器)可能改变 BOM/行尾,导致 after 指纹与 hunk
1461
+ * 定位在内容语义未变的情况下误报 conflict,把待确认修改从 UI 排除。
1462
+ * @author ddj 2026年09月09号
1608
1463
  */
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);
1464
+ function normalizeForCompare(text) {
1465
+ return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
1466
+ }
1467
+ /** 归一化一个 hunk 的两侧文本(与 normalizeForCompare 同口径)。 */
1468
+ function normalizeHunk(hunk) {
1469
+ return {
1470
+ oldText: hunk.oldText === null ? null : normalizeForCompare(hunk.oldText),
1471
+ newText: normalizeForCompare(hunk.newText)
1472
+ };
1473
+ }
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
+ };
1631
1488
  }
1632
- } catch (error) {}
1633
- }));
1634
- await sweepByTotalCap(dir, removed, cap);
1635
- return removed;
1489
+ return hunk;
1490
+ }
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
+ });
1504
+ }
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;
1636
1518
  }
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
1519
  /**
1660
- * 默认出口:console(级别直映方法;方法缺失时静默丢弃,不抛错)。
1661
- * @author ddj 2026年09月08号
1662
- * @returns console 出口
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 与完整候选列表双轮查找相同的匹配
1663
1528
  */
1664
- function consoleSink() {
1665
- return (level, line) => {
1666
- const emit = console[CONSOLE_METHODS[level]];
1667
- if (typeof emit === "function") emit.call(console, line);
1668
- };
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;
1553
+ }
1554
+ return fallback;
1669
1555
  }
1670
1556
  /**
1671
- * DSH host 出口工厂:转接 ctx.logger(方法级可选调用,缺服务/缺方法回退 console)。
1672
- * @author ddj 2026年09月08号
1673
- * @param ctx DSH host 上下文
1674
- * @returns 日志出口
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
1675
1563
  */
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);
1683
- };
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
1624
+ });
1625
+ occupied.push(point);
1626
+ cursor = Math.max(cursor, point.end);
1627
+ continue;
1628
+ }
1629
+ }
1630
+ locations.push({
1631
+ idx,
1632
+ hunk,
1633
+ start: -1,
1634
+ end: -1,
1635
+ matched: false
1636
+ });
1637
+ }
1638
+ return locations;
1639
+ }
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;
1684
1644
  }
1685
- /**
1686
- * 拼一行日志:`[dsh-vscode-mode][:scope] 消息`(scope 为空省略冒号段)。
1687
- * @author ddj 2026年09月08号
1688
- * @param scope 子域(可空)
1689
- * @param message 消息文本
1690
- * @returns 整行文本
1691
- */
1692
- function formatLine(scope, message) {
1693
- const text = String(message);
1694
- return scope ? LOG_PREFIX + ":" + scope + " " + text : LOG_PREFIX + " " + text;
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);
1695
1650
  }
1696
1651
  /**
1697
- * 日志器工厂(内部):emit 时读共享出口状态,保证父 bind 后子实例跟随。
1698
- * @author ddj 2026年09月08号
1699
- * @param state 共享出口状态
1700
- * @param scope 子域文本(可空)
1701
- * @returns 日志器
1652
+ * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
1653
+ * @author ddj 2026年09月29号
1654
+ * @param content 当前文件内容
1655
+ * @param locations 已定位区间
1656
+ * @param reverse 是否反向应用
1657
+ * @returns 合成内容及失效块索引
1702
1658
  */
1703
- function makeLogger(state, scope) {
1704
- const emit = (level, message) => state.sink(level, formatLine(scope, message));
1705
- 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);
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
+ };
1715
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)
1716
1685
  };
1717
1686
  }
1718
1687
  /**
1719
- * 创建插件日志器(默认 console 出口;host 面单例由 bindHostLog 换到 ctx.logger)。
1720
- * @author ddj 2026年09月08号
1721
- * @param sink 初始出口(缺省 console)
1722
- * @param scope 初始子域(业务方一般经 child 派生,不直接传)
1723
- * @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 更新后的记录(原对象就地修改)
1724
1695
  */
1725
- function createLogger(sink = consoleSink(), scope = "") {
1726
- return makeLogger({ sink }, scope);
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;
1727
1722
  }
1728
- //#endregion
1729
- //#region src/log.ts
1730
1723
  /**
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
1724
+ * 记录决策摘要(采纳/拒绝/待处理计数,superseded 标记)。
1725
+ * @author ddj 2026年08月20号
1726
+ * @param record 记录
1727
+ * @returns 摘要
1735
1728
  */
1736
- /** host 面插件日志单例(格式/级别语义见 shared/logger.ts 头注释)。 */
1737
- const log = createLogger();
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
+ }
1744
+ return {
1745
+ accepted,
1746
+ rejected,
1747
+ pending,
1748
+ superseded: record.superseded === true
1749
+ };
1750
+ }
1738
1751
  /**
1739
- * 装配期绑定 host 日志出口:ctx.logger 可用时全部日志转经 DSH 日志系统。
1740
- * @author ddj 2026年09月08号
1741
- * @param ctx DSH host 上下文(logger 服务缺失时保持 console 出口)
1752
+ * 重建"本批次修改前"内容:把仍待处理(pending)的差异块按 新→旧 顺序从当前内容反解
1753
+ * (newText → oldText)。已采纳/已拒绝的块跳过。反解失败的块标记为 stale。
1754
+ * @author ddj 2026年08月20号
1755
+ * @param records 该文件的活动记录
1756
+ * @param content 当前磁盘内容
1757
+ * @returns 重建内容与 stale 定位
1742
1758
  */
1743
- function bindHostLog(ctx) {
1744
- log.bind(ctxLogSink(ctx));
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
+ };
1745
1779
  }
1746
- //#endregion
1747
- //#region src/debugLog.ts
1748
1780
  /**
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
1781
+ * @public
1782
+ * @author ddj 2026年10月10号
1783
+ * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
1784
+ * @param record 审查记录
1785
+ * @returns 未归档、未覆盖且没有用户决策时为 true
1756
1786
  */
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$/;
1787
+ function fullyPending(record) {
1788
+ return !record.archived && !record.superseded && record.decisions.call === "pending" && record.decisions.perHunk.every((value) => value === "pending");
1789
+ }
1773
1790
  /**
1774
- * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
1775
- * @author ddj 2026年09月17号
1776
- * @param name 请求回传的文件名(可为空)
1777
- * @returns 是否合法
1791
+ * @public
1792
+ * @author ddj 2026年10月10号
1793
+ * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
1794
+ * @param prior 先前修改的差异块
1795
+ * @param later 后续修改的差异块
1796
+ * @returns 文本归一化后整组逆反且包含真实修改时为 true
1778
1797
  */
1779
- function isDebugLogName(name) {
1780
- return typeof name === "string" && DEBUG_LOG_NAME.test(name);
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;
1781
1808
  }
1782
1809
  /**
1783
- * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
1784
- * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
1785
- * @author ddj 2026年09月08号
1786
- * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
1787
- * @param batch 本批日志行
1810
+ * @private
1811
+ * @author ddj 2026年10月10号
1812
+ * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
1813
+ * @param a 第一条记录
1814
+ * @param b 第二条记录
1815
+ * @returns 负数表示 a 更早,零表示先后无法区分
1788
1816
  */
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;
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;
1805
1822
  }
1806
1823
  /**
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;落盘行与终端回显均带此级别)
1824
+ * @private
1825
+ * @author ddj 2026年10月10号
1826
+ * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
1827
+ * @param prior 先前记录
1828
+ * @param later 后续记录
1829
+ * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
1814
1830
  */
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);
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);
1837
1835
  }
1838
1836
  /**
1839
- * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
1840
- * @author ddj 2026年09月17号
1841
- * @param home DSH home(测试可注入;缺省按环境解析)
1842
- * @returns 清单项数组
1837
+ * @public
1838
+ * @author ddj 2026年10月10号
1839
+ * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
1840
+ * @param records 工作区记录(不修改输入)
1841
+ * @param record 新记录
1842
+ * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
1843
1843
  */
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
- });
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;
1856
1852
  }
1857
- items.sort((a, b) => b.mtimeMs - a.mtimeMs);
1858
- return items;
1853
+ return found;
1859
1854
  }
1860
1855
  /**
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
1856
+ * @public
1857
+ * @author ddj 2026年10月10号
1858
+ * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
1859
+ * @param records 工作区记录(不修改输入或记录状态)
1860
+ * @returns 可以整对归档的记录对
1868
1861
  */
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);
1877
- return {
1878
- name,
1879
- path: target,
1880
- content: cut > 0 ? text.slice(cut) : text,
1881
- size: info.size,
1882
- truncated: cut > 0
1883
- };
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);
1890
+ }
1884
1891
  }
1885
1892
  /**
1886
- * 清空一份诊断日志(truncate 到 0,保留文件本身)。
1887
- * @author ddj 2026年09月17号
1888
- * @param name 日志文件名(isDebugLogName 白名单校验)
1889
- * @param home DSH home(测试可注入)
1890
- * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
1893
+ * 归档条目构造(一条批次一条)。
1894
+ * @author ddj 2026年08月20号
1895
+ * @param recs 同批次记录(非空)
1896
+ * @param cwd 工作区
1897
+ * @param reason 归档原因
1891
1898
  */
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);
1899
+ function archiveEntryFor(recs, cwd, reason) {
1900
+ const first = recs[0];
1901
+ return {
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
+ }))
1927
+ };
1895
1928
  }
1896
1929
  //#endregion
1897
1930
  //#region src/archiveDb.ts
@@ -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
@@ -13114,6 +13151,147 @@ function reviewHandlers(ctx, registry) {
13114
13151
  };
13115
13152
  }
13116
13153
  //#endregion
13154
+ //#region src/missingReview.ts
13155
+ /** Missing historical files: frozen, whole-file deletion reviews. */
13156
+ const frozen = /* @__PURE__ */ new Map();
13157
+ const locks = /* @__PURE__ */ new Set();
13158
+ /** @private @author ddj 2026年10月28号 @param target Resolved absolute path. @returns Platform file identity. */
13159
+ function fileKey(target) {
13160
+ const normalized = resolve(target).replace(/\\/g, "/");
13161
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
13162
+ }
13163
+ /** @private @author ddj 2026年10月28号 @param records Ordered records. @returns Full evidence and decision revision, excluding transient conflict flags. */
13164
+ function revisionOf(records) {
13165
+ const evidence = records.map(({ callId, path, at, after, hunks, decisions, archived, superseded }) => ({
13166
+ callId,
13167
+ path,
13168
+ at,
13169
+ after,
13170
+ hunks,
13171
+ decisions,
13172
+ archived,
13173
+ superseded
13174
+ }));
13175
+ return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
13176
+ }
13177
+ /** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
13178
+ async function fileRecords(ctx, cwd, bucket, target) {
13179
+ const matches = [];
13180
+ const identity = fileKey(target);
13181
+ for (const record of bucket.values()) {
13182
+ if (record.superseded) continue;
13183
+ if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record);
13184
+ }
13185
+ return matches.sort((left, right) => left.at.localeCompare(right.at));
13186
+ }
13187
+ /** @public @author ddj 2026年10月28号 @param ctx Host. @param cwd Workspace. @param path Requested file. @returns True only for confirmed absence; directories and IO failures are not missing files. */
13188
+ async function fileAbsent(ctx, cwd, path) {
13189
+ const fs = ctx.get("fs");
13190
+ if (!fs) throw new Error("缺少 fs");
13191
+ const target = await resolveTargetIn(ctx, cwd, path);
13192
+ try {
13193
+ return !await fs.stat(target);
13194
+ } catch (error) {
13195
+ if (["ENOENT", "FS_NOT_FOUND"].includes(String(error.code))) return true;
13196
+ throw error;
13197
+ }
13198
+ }
13199
+ /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
13200
+ async function missingReview(ctx, session, cwd, bucket, path) {
13201
+ const target = await resolveTargetIn(ctx, cwd, path);
13202
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error("该文件已有官方审查记录,请使用官方审查入口");
13203
+ if (!await fileAbsent(ctx, cwd, target)) throw new Error("文件已重新出现,请刷新");
13204
+ const records = await fileRecords(ctx, cwd, bucket, target);
13205
+ const latest = records.at(-1);
13206
+ if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error("没有待审历史记录");
13207
+ if (!latest || typeof latest.after !== "string") throw new Error("缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件");
13208
+ if (Buffer.byteLength(latest.after, "utf8") > 8388608) throw new Error("历史快照过大,无法整文件预览");
13209
+ for (const [token, item] of frozen) if (item.expires < Date.now()) frozen.delete(token);
13210
+ if (frozen.size >= 256) throw new Error("缺失文件预览过多,请稍后重试");
13211
+ const view = {
13212
+ token: randomUUID(),
13213
+ path,
13214
+ content: latest.after,
13215
+ callIds: records.filter((record) => !record.archived && !recordResolved(record)).map((record) => record.callId),
13216
+ at: latest.at
13217
+ };
13218
+ frozen.set(view.token, {
13219
+ view,
13220
+ cwd,
13221
+ session,
13222
+ revision: revisionOf(records),
13223
+ expires: Date.now() + 18e5
13224
+ });
13225
+ return view;
13226
+ }
13227
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen evidence. @param target Resolved path. @param undo Restore instead of keeping absence. @sideEffects Atomically creates absent file, verifies exact bytes; retries never overwrite an existing file. */
13228
+ async function applyDisk(ctx, item, target, undo) {
13229
+ const fs = ctx.get("fs");
13230
+ const absent = await fileAbsent(ctx, item.cwd, target);
13231
+ if (!undo) {
13232
+ if (!absent) throw new Error("文件已重新出现,未执行 Keep");
13233
+ return;
13234
+ }
13235
+ const operation = item.operation;
13236
+ if (!absent && !operation.restored) throw new Error("文件已重新出现,未执行 Undo");
13237
+ if (absent && !operation.restored) {
13238
+ await fs.writeText(target, item.view.content, { kind: "createIfAbsent" }, void 0, policyOf(ctx, item.session));
13239
+ operation.restored = true;
13240
+ }
13241
+ if (absent && operation.restored && !await fs.stat(target)) throw new Error("恢复后的文件再次缺失,未结清记录");
13242
+ if (await fs.readText(target) !== item.view.content) throw new Error("恢复内容校验失败,未结清记录");
13243
+ }
13244
+ /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
13245
+ async function missingDecide(ctx, session, cwd, bucket, token, operationId, undo) {
13246
+ const item = frozen.get(token);
13247
+ if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error("删除预览已过期,请刷新后重试");
13248
+ if (!operationId || operationId.length > 128) throw new Error("缺少有效操作 ID");
13249
+ const target = await resolveTargetIn(ctx, cwd, item.view.path);
13250
+ const key = JSON.stringify([cwd, fileKey(target)]);
13251
+ if (locks.has(key)) throw new Error("该文件正在处理,请稍后重试");
13252
+ locks.add(key);
13253
+ item.session = session;
13254
+ try {
13255
+ return await commitMissing(ctx, item, bucket, target, operationId, undo);
13256
+ } finally {
13257
+ locks.delete(key);
13258
+ }
13259
+ }
13260
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
13261
+ async function commitMissing(ctx, item, bucket, target, operationId, undo) {
13262
+ const previous = item.operation;
13263
+ if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error("已有不同操作,请刷新后重试");
13264
+ if (previous?.done) return item.view.callIds;
13265
+ if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error("该文件已有官方审查记录,请刷新");
13266
+ if (revisionOf(await fileRecords(ctx, item.cwd, bucket, target)) !== item.revision) throw new Error("历史记录已变化,请刷新删除预览");
13267
+ item.operation ??= {
13268
+ id: operationId,
13269
+ undo,
13270
+ restored: false,
13271
+ done: false
13272
+ };
13273
+ await applyDisk(ctx, item, target, undo);
13274
+ const current = await fileRecords(ctx, item.cwd, bucket, target);
13275
+ if (revisionOf(current) !== item.revision) throw new Error("历史记录已变化,未结清记录,请刷新");
13276
+ const pending = current.filter((record) => item.view.callIds.includes(record.callId));
13277
+ const decisions = pending.map((record) => record.decisions);
13278
+ const decision = undo ? "rejected" : "accepted";
13279
+ for (const record of pending) record.decisions = {
13280
+ call: decision,
13281
+ perHunk: record.hunks.map(() => decision)
13282
+ };
13283
+ try {
13284
+ await archiveRecords(ctx, item.session, item.cwd, bucket, pending, undo ? "已恢复删除文件快照" : "已保留文件删除");
13285
+ } catch (error) {
13286
+ pending.forEach((record, index) => {
13287
+ record.decisions = decisions[index];
13288
+ });
13289
+ throw error;
13290
+ }
13291
+ item.operation.done = true;
13292
+ return item.view.callIds;
13293
+ }
13294
+ //#endregion
13117
13295
  //#region src/rpc.ts
13118
13296
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
13119
13297
  const readTextPage = createTextPages();
@@ -13202,8 +13380,13 @@ async function readTargetOf(ctx, sc, path) {
13202
13380
  const target = await resolveTargetIn(ctx, sc.cwd, path);
13203
13381
  debugRecord(ctx, sc.cwd, "[DEBUG path.resolve] input=" + String(path ?? "") + " resolved=" + fs.processPath(target), "debug");
13204
13382
  const info = await fs.stat(target);
13205
- if (!info || info.type !== "file") return {
13383
+ if (!info) return {
13206
13384
  err: "文件不存在",
13385
+ resolvedPath: fs.processPath(target),
13386
+ code: "file-missing"
13387
+ };
13388
+ if (info.type !== "file") return {
13389
+ err: "目标不是文件",
13207
13390
  resolvedPath: fs.processPath(target)
13208
13391
  };
13209
13392
  if ((info.size ?? 0) > 33554432) return {
@@ -13216,6 +13399,10 @@ async function readTargetOf(ctx, sc, path) {
13216
13399
  info
13217
13400
  };
13218
13401
  } catch (error) {
13402
+ if (["ENOENT", "FS_NOT_FOUND"].includes(String(error.code))) return {
13403
+ err: "文件不存在",
13404
+ code: "file-missing"
13405
+ };
13219
13406
  return { err: "读取失败:" + String(error) };
13220
13407
  }
13221
13408
  }
@@ -13316,6 +13503,23 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
13316
13503
  });
13317
13504
  continue;
13318
13505
  }
13506
+ try {
13507
+ if (await fileAbsent(ctx, cwd, record.path)) {
13508
+ results.push({
13509
+ callId: item.callId,
13510
+ ok: false,
13511
+ error: "文件已删除,请刷新后按整文件删除预览执行 Keep / Undo"
13512
+ });
13513
+ continue;
13514
+ }
13515
+ } catch (error) {
13516
+ results.push({
13517
+ callId: item.callId,
13518
+ ok: false,
13519
+ error: "无法确认文件状态:" + String(error)
13520
+ });
13521
+ continue;
13522
+ }
13319
13523
  const alreadyDone = (item.scope === "hunk" ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call) === item.decision;
13320
13524
  if (alreadyDone) {
13321
13525
  if (recordResolved(record) && !record.archived) resolved.push(record);
@@ -13735,6 +13939,48 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13735
13939
  record: item.record
13736
13940
  };
13737
13941
  },
13942
+ "edrv.missingReview": async (args) => {
13943
+ const sc = await requireSession(ctx, args.sessionId);
13944
+ if ("err" in sc) return {
13945
+ ok: false,
13946
+ error: sc.err
13947
+ };
13948
+ try {
13949
+ const bucket = await bucketOf(registry, ctx, sc.cwd);
13950
+ if (!await fileAbsent(ctx, sc.cwd, args.path)) return {
13951
+ ok: true,
13952
+ review: null
13953
+ };
13954
+ return {
13955
+ ok: true,
13956
+ review: await missingReview(ctx, sc.session, sc.cwd, bucket, args.path)
13957
+ };
13958
+ } catch (error) {
13959
+ return {
13960
+ ok: false,
13961
+ error: String(error)
13962
+ };
13963
+ }
13964
+ },
13965
+ "edrv.missingDecide": async (args) => {
13966
+ const sc = await requireSession(ctx, args.sessionId);
13967
+ if ("err" in sc) return {
13968
+ ok: false,
13969
+ error: sc.err
13970
+ };
13971
+ try {
13972
+ const bucket = await bucketOf(registry, ctx, sc.cwd);
13973
+ return {
13974
+ ok: true,
13975
+ callIds: await missingDecide(ctx, sc.session, sc.cwd, bucket, args.token, args.operationId, args.undo === true)
13976
+ };
13977
+ } catch (error) {
13978
+ return {
13979
+ ok: false,
13980
+ error: String(error)
13981
+ };
13982
+ }
13983
+ },
13738
13984
  "edrv.decideBatch": async (args) => {
13739
13985
  const sc = await requireSession(ctx, args.sessionId);
13740
13986
  if ("err" in sc) return {