@xiaoyuyu6420/dsh-backup 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -78,6 +78,55 @@ const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'
78
78
  /** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
79
79
  const VAULT_DIR = 'vault';
80
80
 
81
+ /**
82
+ * 分类型备份的内容类型表(相对 ~/.dsh 根的 POSIX 路径)。
83
+ * 每类映射一组路径:文件用具体名、目录用目录名;mcp 用 glob(承载组合
84
+ * 文件,连带其他 cordis 行——MCP 无独立目录,声明嵌在 cordis 组合层里)。
85
+ * credentials 类反转脱敏:明文进归档(跳过 redactExcludeFlags),但这类
86
+ * 归档在 githubSync 里被识别并跳过,永不上一线。旧归档(meta.types 缺省)
87
+ * 视为全量,与分类型归档(dsh-t- 前缀)独立轮换、不进全量列表。
88
+ */
89
+ const BACKUP_TYPES = Object.freeze({
90
+ credentials: { label: '凭据/api key', paths: ['.credentials.yaml', '.env', 'qq-bridge/config.json'], credentials: true },
91
+ mcp: { label: 'MCP 配置', paths: ['cordis.patch.yml', 'profiles/*/cordis.patch.yml', 'profiles/*/cordis.yml'] },
92
+ skills: { label: '技能', paths: ['skills'] },
93
+ sessions: { label: '会话', paths: ['sessions'] },
94
+ settings: { label: '设置', paths: ['settings.yaml'] },
95
+ profiles: { label: '插件 profiles', paths: ['profiles'] },
96
+ });
97
+ const BACKUP_TYPE_KEYS = Object.freeze(Object.keys(BACKUP_TYPES));
98
+ /** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
99
+ const TYPED_PREFIX = 'dsh-t-';
100
+
101
+ /**
102
+ * 按类型 globs 匹配归档内条目(restore 侧):把 BACKUP_TYPES 的相对路径转成
103
+ * 对归档条目(${base}/ 前缀)的匹配——文件精确匹配、目录前缀匹配、含 * 的
104
+ * glob 转正则。返回命中的归档条目数组(去重)。与备份侧 typeTargets(文件
105
+ * 系统展开)对称:备份展开实际路径,恢复匹配归档条目。
106
+ */
107
+ function typeEntries(types, entries, base) {
108
+ const out = [];
109
+ const seen = new Set();
110
+ const esc = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
111
+ for (const key of types) {
112
+ const spec = BACKUP_TYPES[key];
113
+ if (!spec) continue;
114
+ for (const rel of spec.paths) {
115
+ if (rel.includes('*')) {
116
+ const re = new RegExp(`^${esc(base)}/${esc(rel).replace(/\*/g, '[^/]+')}$`);
117
+ for (const e of entries) if (re.test(e) && !seen.has(e)) { seen.add(e); out.push(e); }
118
+ } else {
119
+ const fileEntry = `${base}/${rel}`;
120
+ const dirPrefix = `${base}/${rel}/`;
121
+ for (const e of entries) {
122
+ if ((e === fileEntry || e.startsWith(dirPrefix)) && !seen.has(e)) { seen.add(e); out.push(e); }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
81
130
  /** 随备份落盘的救援说明:DSH 起不来时,用户打开备份目录第一眼能看到的东西。 */
82
131
  const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动)
83
132
  ====================================================
@@ -99,9 +148,12 @@ const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动
99
148
 
100
149
  /** 双击启动器:起救援服务并自动开浏览器。按当前平台生成,避免目录里躺三个系统的文件。 */
101
150
  const RESCUE_LAUNCHERS = Object.freeze({
102
- darwin: ['点我恢复.command', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nexec node rescue.mjs serve --open\n'],
151
+ // 双击 .command GUI PATH 只有 /usr/bin:/bin:...,nvm/Homebrew 装的
152
+ // node 找不到——直接 exec 会静默失败且窗口即关(UX 审查 P1-3)。先依次
153
+ // 探测常见安装位置;全落空时给人话指引并保持窗口不关。
154
+ darwin: ['点我恢复.command', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nNODE_BIN="$(command -v node)"\nif [ -z "$NODE_BIN" ]; then\n for c in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do\n if [ -x "$c" ]; then NODE_BIN="$c"; break; fi\n done\nfi\nif [ -z "$NODE_BIN" ]; then\n echo "没有找到 node。请先安装 Node.js(https://nodejs.org),然后重新双击本文件。"\n echo "也可以在终端进入本目录手动执行: node rescue.mjs serve --open"\n printf "\\n按回车键关闭窗口…"\n read _\n exit 1\nfi\nexec "$NODE_BIN" rescue.mjs serve --open\n'],
103
155
  win32: ['点我恢复.bat', '@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nnode rescue.mjs serve --open\r\npause\r\n'],
104
- linux: ['点我恢复.sh', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nexec node rescue.mjs serve --open\n'],
156
+ linux: ['点我恢复.sh', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nNODE_BIN="$(command -v node)"\nif [ -z "$NODE_BIN" ]; then\n for c in /usr/local/bin/node /opt/node/bin/node "$HOME"/.nvm/versions/node/*/bin/node "$HOME"/.local/bin/node; do\n if [ -x "$c" ]; then NODE_BIN="$c"; break; fi\n done\nfi\nif [ -z "$NODE_BIN" ]; then\n echo "没有找到 node。请先安装 Node.js,然后在终端进入本目录执行: node rescue.mjs serve --open"\n exit 1\nfi\nexec "$NODE_BIN" rescue.mjs serve --open\n'],
105
157
  });
106
158
 
107
159
  /**
@@ -109,7 +161,8 @@ const RESCUE_LAUNCHERS = Object.freeze({
109
161
  * 纯函数、模块级:轮换分层与测试共用。
110
162
  */
111
163
  function archiveDay(name) {
112
- const m = /^dsh-(\d{4})(\d{2})(\d{2})-\d{6}\d{3}\.tar\.gz$/.exec(name);
164
+ // dsh-t-(分类型归档)与 dsh-(全量)共用同一时间戳格式;前缀可选 t-。
165
+ const m = /^dsh-(?:t-)?(\d{4})(\d{2})(\d{2})-\d{6}\d{3}\.tar\.gz$/.exec(name);
113
166
  return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
114
167
  }
115
168
 
@@ -329,9 +382,9 @@ function panelDescriptor(method, parameters, cancellation) {
329
382
 
330
383
  const PANEL_INVOCATIONS = Object.freeze([
331
384
  panelDescriptor('status', []),
332
- panelDescriptor('backup', ['keep'], true),
385
+ panelDescriptor('backup', ['keep', 'types'], true),
333
386
  panelDescriptor('verify', ['selector'], true),
334
- panelDescriptor('restore', ['selector', 'dryRun'], true),
387
+ panelDescriptor('restore', ['selector', 'dryRun', 'types', 'syncDeps'], true),
335
388
  panelDescriptor('setAuto', ['hours']),
336
389
  panelDescriptor('githubStatus', []),
337
390
  panelDescriptor('githubSyncNow', [], true),
@@ -359,9 +412,9 @@ class BackupPanelService extends TypertRemoteService {
359
412
  return this.ops.status();
360
413
  }
361
414
 
362
- /** 立即备份。 */
363
- backup(keep, signal) {
364
- return this.ops.backup(keep, signal);
415
+ /** 立即备份(types 非空时分类型子集备份)。 */
416
+ backup(keep, types, signal) {
417
+ return this.ops.backup(keep, types, signal);
365
418
  }
366
419
 
367
420
  /** 校验(selector=前缀|all|latest)。 */
@@ -369,9 +422,9 @@ class BackupPanelService extends TypertRemoteService {
369
422
  return this.ops.verify(selector, signal);
370
423
  }
371
424
 
372
- /** 恢复(dryRun 仅预览)。 */
373
- restore(selector, dryRun, signal) {
374
- return this.ops.restore(selector, dryRun, signal);
425
+ /** 恢复(dryRun 仅预览;types 非空时分类型 merge 恢复;syncDeps 恢复后重装 profile 依赖)。 */
426
+ restore(selector, dryRun, types, syncDeps, signal) {
427
+ return this.ops.restore(selector, dryRun, types, syncDeps, signal);
375
428
  }
376
429
 
377
430
  /** 设置自动备份(0=关闭)。 */
@@ -529,6 +582,34 @@ export function apply(ctx, pluginConfig) {
529
582
  return { out, err };
530
583
  }
531
584
 
585
+ /**
586
+ * 错误翻译层:把底层错误(fs 错误码、tar/git 的英文 stderr、JS 异常)映射
587
+ * 成「人话原因 + 折叠的技术细节」。所有面向用户的 catch 出口统一经此——
588
+ * 直接拼 err.message 会把 ENOENT/tar stderr 甩给最慌的用户(UX 审查
589
+ * P1-4:19 处直拼的模式级问题),在这里一处收口。
590
+ */
591
+ function friendlyReason(err) {
592
+ const raw = String(err && err.message ? err.message : err);
593
+ // 内层(如 restoreArchive 的解压失败分支)已翻译过——不重复包一层
594
+ if (raw.includes('技术细节: ')) return raw;
595
+ if (/gzip decompression failed|not in gzip format|invalid distance|unexpected end of file|Canto open|damaged|failed to open|cannot open/i.test(raw)) {
596
+ return `归档内容损坏或不是有效的压缩包(技术细节: ${snippet(raw, 160)})`;
597
+ }
598
+ if (/\bENOENT\b/.test(raw)) {
599
+ return `需要的文件不存在——可能已被轮换清理,或路径变动(技术细节: ${snippet(raw, 160)})`;
600
+ }
601
+ if (/\b(ENOSPC|EDQUOT)\b/.test(raw)) {
602
+ return `磁盘空间不足,请释放空间后重试(技术细节: ${snippet(raw, 160)})`;
603
+ }
604
+ if (/\b(EACCES|EPERM|EROFS)\b/.test(raw)) {
605
+ return `权限不足,无法读写目标位置(技术细节: ${snippet(raw, 160)})`;
606
+ }
607
+ if (/Cannot read propert|is not a function|undefined is not/i.test(raw)) {
608
+ return `程序内部错误,请带着下面的细节反馈给作者(技术细节: ${snippet(raw, 160)})`;
609
+ }
610
+ return raw;
611
+ }
612
+
532
613
  function paths() {
533
614
  const env = ctx.get('launchEnvironment');
534
615
  // Windows 上 launchEnvironment 无 HOME 时退回 USERPROFILE。
@@ -679,13 +760,9 @@ export function apply(ctx, pluginConfig) {
679
760
  await rename(`${dir}/${srcName}`, `${dir}/${dstName}`);
680
761
  }
681
762
 
682
- /**
683
- * 清理旧的恢复前快照(dsh-pre-restore-* 归档 + 边车),仅保留 keepName
684
- * 这一份。恢复是低频操作,快照又被 listBackups 排除、用户不可见,故每
685
- * 次恢复后清掉前一次的快照防隐藏累积(aside 目录仍由用户自行管理)。
686
- */
687
- /** 清理内部快照(pre-restore / pre-upgrade 共用):只保留最近 keepN 份。 */
688
- async function prunePrefixedSnapshots(prefix, keepN, keepName, signal) {
763
+ /** 清理内部快照(pre-restore / pre-upgrade 共用):只保留最近 keepN 份;protect 里的归档即使落在窗口外也强制保留。 */
764
+ async function prunePrefixedSnapshots(prefix, keepN, keepNames, signal) {
765
+ const protect = Array.isArray(keepNames) ? keepNames : [keepNames];
689
766
  const { root } = paths();
690
767
  let dirents;
691
768
  try {
@@ -700,13 +777,18 @@ export function apply(ctx, pluginConfig) {
700
777
  .reverse();
701
778
  // 归档名排序即时间序;边车跟宿主同名排序紧邻其后,按"每份归档一组"数份数
702
779
  const archiveCount = names.filter((n) => n.endsWith('.tar.gz'));
703
- const staleArchives = archiveCount.slice(keepN).filter((n) => n !== keepName);
780
+ const staleArchives = archiveCount.slice(keepN).filter((n) => !protect.includes(n));
704
781
  const staleSet = new Set(staleArchives.flatMap((n) => sidecarsFor(n)));
705
782
  if (staleSet.size) await removeFiles([...staleSet], root, signal);
706
783
  }
707
784
 
708
- async function prunePreRestoreSnapshots(keepName, signal) {
709
- await prunePrefixedSnapshots('dsh-pre-restore-', 1, keepName, signal);
785
+ /**
786
+ * 恢复流程收尾的快照清理。keepNames 必须同时含新拍的快照与本次恢复的
787
+ * 目标归档——目标本身可能就是一份 dsh-pre-restore-* 快照,只保护新快照
788
+ * 会把正在恢复的归档清掉,解压必然 ENOENT(UX 审查实测的自毁死路)。
789
+ */
790
+ async function prunePreRestoreSnapshots(keepNames, signal) {
791
+ await prunePrefixedSnapshots('dsh-pre-restore-', 1, keepNames, signal);
710
792
  }
711
793
 
712
794
  /** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
@@ -731,10 +813,15 @@ export function apply(ctx, pluginConfig) {
731
813
  // 内部快照(dsh-pre-restore-* / dsh-pre-upgrade-*)不进用户列表、
732
814
  // 不参与轮换、不被 pickArchive 的 latest 误选;显式前缀仍可选中
733
815
  // (见 pickArchive 的快照回退分支)。
734
- if (!d.name.startsWith('dsh-') || SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || !d.name.endsWith('.tar.gz')) continue;
816
+ if (!d.name.startsWith('dsh-') || SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || d.name.startsWith(TYPED_PREFIX) || !d.name.endsWith('.tar.gz')) continue;
735
817
  let size;
736
818
  try {
737
- size = (await fsStat(`${root}/${d.name}`)).size;
819
+ const st = await fsStat(`${root}/${d.name}`);
820
+ // 正在写入的归档(tar 直写最终文件名,写完前 size=0)先不进列表——
821
+ // 用户刷新撞见 0 字节条目会以为备份坏了(UX 审查 P1-7);写入失败
822
+ // 残留的 0 字节文件 60 秒后仍会显示出来,不会被永久隐藏。
823
+ if (st.size === 0 && Date.now() - st.mtimeMs < 60_000) continue;
824
+ size = st.size;
738
825
  } catch {
739
826
  // 归档在列表与 stat 之间被删除(轮换/删除竞态)——大小显示为未知而非报错
740
827
  size = undefined;
@@ -787,13 +874,90 @@ export function apply(ctx, pluginConfig) {
787
874
  return createHash('sha256').update(bytes).digest('hex');
788
875
  }
789
876
 
877
+ async function existsStat(p) {
878
+ try { await fsStat(p); return true; } catch { return false; }
879
+ }
880
+
881
+ /**
882
+ * 递归展开相对 ~/.dsh 的星号通配(spawnRun 不经 shell,tar 位置参数需字面
883
+ * 路径)。只返回实际存在的路径。mcp 类 profiles 下各 profile 的
884
+ * cordis.yml / cordis.patch.yml 靠它展开。
885
+ */
886
+ async function expandGlob(rel, root) {
887
+ const parts = rel.split('/');
888
+ const out = [];
889
+ async function walk(i, dir, acc) {
890
+ if (i >= parts.length) { if (acc) out.push(acc); return; }
891
+ const seg = parts[i];
892
+ if (seg === '*') {
893
+ let dirents;
894
+ try { dirents = await readdir(dir, { withFileTypes: true }); } catch { return; }
895
+ for (const d of dirents) {
896
+ if (!d.isDirectory()) continue;
897
+ await walk(i + 1, `${dir}/${d.name}`, acc ? `${acc}/${d.name}` : d.name);
898
+ }
899
+ } else {
900
+ const next = `${dir}/${seg}`;
901
+ if (!(await existsStat(next))) return;
902
+ await walk(i + 1, next, acc ? `${acc}/${seg}` : seg);
903
+ }
904
+ }
905
+ await walk(0, root, '');
906
+ return out;
907
+ }
908
+
909
+ /**
910
+ * 把分类型名展开成归档内相对路径(${base}/ 前缀),预检存在性、展开 glob。
911
+ * 返回 { targets, hasCredentials }:targets 供 tar 位置参数(-C parent ...targets);
912
+ * hasCredentials 标记是否含凭据类——凭据明文进归档,githubSync 据此跳过上云。
913
+ * 不存在的路径跳过(tar 对不存在路径会报错中止);glob 展开后同样预检。
914
+ */
915
+ async function typeTargets(types, dshHome, base) {
916
+ const targets = [];
917
+ let hasCredentials = false;
918
+ const seen = new Set();
919
+ for (const key of types) {
920
+ const spec = BACKUP_TYPES[key];
921
+ if (!spec) continue;
922
+ if (spec.credentials) hasCredentials = true;
923
+ for (const rel of spec.paths) {
924
+ const matched = rel.includes('*') ? await expandGlob(rel, dshHome) : ((await existsStat(`${dshHome}/${rel}`)) ? [rel] : []);
925
+ for (const m of matched) {
926
+ const entry = `${base}/${m}`;
927
+ if (!seen.has(entry)) { seen.add(entry); targets.push(entry); }
928
+ }
929
+ }
930
+ }
931
+ return { targets, hasCredentials };
932
+ }
933
+
934
+ /** 列分类型归档(dsh-t-*,新→旧),供独立轮换与列表展示。 */
935
+ async function listTypedBackups() {
936
+ const { root } = paths();
937
+ let dirents;
938
+ try { dirents = await readdir(root, { withFileTypes: true }); } catch { return []; }
939
+ const backups = [];
940
+ for (const d of dirents) {
941
+ if (!d.name.startsWith(TYPED_PREFIX) || !d.name.endsWith('.tar.gz')) continue;
942
+ let size;
943
+ try { size = (await fsStat(`${root}/${d.name}`)).size; } catch { size = undefined; }
944
+ backups.push({ name: d.name, size });
945
+ }
946
+ return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
947
+ }
948
+
790
949
  async function doBackup(keep, signal, opts = {}) {
791
950
  const { home, dshHome, root } = paths();
792
951
  const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
793
952
  // 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
794
953
  await saveAutoState();
795
954
 
796
- const prefix = typeof opts?.namePrefix === 'string' && opts.namePrefix ? opts.namePrefix : 'dsh-';
955
+ // 分类型备份:opts.types 非空时用 dsh-t- 前缀(独立轮换、旧版不可见),
956
+ // tar 按 typeTargets 子集打包而非整个 .dsh。
957
+ const types = Array.isArray(opts?.types) && opts.types.length
958
+ ? [...new Set(opts.types.filter((t) => BACKUP_TYPES[t]))]
959
+ : null;
960
+ const prefix = types ? TYPED_PREFIX : (typeof opts?.namePrefix === 'string' && opts.namePrefix ? opts.namePrefix : 'dsh-');
797
961
  const name = `${prefix}${stampNow()}.tar.gz`;
798
962
  const out = `${root}/${name}`;
799
963
  const base = dshHome.split('/').pop();
@@ -815,19 +979,34 @@ export function apply(ctx, pluginConfig) {
815
979
  // 敏感文件先从归档排除(redactExcludeFlags),其明文副本进本机 vault。
816
980
  // skipVault(pre-restore 快照):数据可能正待恢复/已损,刷新 vault 会把
817
981
  // 仅存的凭据副本清掉——只列清单不拷贝,vault 保持上一次常规备份的状态。
818
- const redacted = opts?.skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
982
+ // 分类型备份:展开选定的类型为 tar 位置参数子集;凭据类要明文进归档,
983
+ // 跳过脱敏排除。types 备份不刷新 vault(归档已自含所选内容,凭据类含明文;
984
+ // 非凭据类子集本不含凭据文件),vault 保持上一次全量备份状态不被干扰。
985
+ let typePaths = null;
986
+ let hasCredentials = false;
987
+ if (types) {
988
+ ({ targets: typePaths, hasCredentials } = await typeTargets(types, dshHome, base));
989
+ if (!typePaths.length) throw new Error('选定的类型在 ~/.dsh 里没有可备份的内容(路径均不存在)');
990
+ }
991
+ const isTyped = !!types;
992
+ const skipVault = opts?.skipVault || isTyped;
993
+ const redacted = skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
819
994
  const tar = await ctx.subprocess.resolveExecutable('tar');
820
- await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactExcludeFlags(base), ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
995
+ const redactFlags = isTyped ? [] : redactExcludeFlags(base);
996
+ await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parent, ...(isTyped ? typePaths : [base])], root, signal);
821
997
 
822
998
  const shaText = await sha256File(out, home, signal);
823
999
  await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
824
- // 脱敏清单边车:恢复侧据其判断归档是否脱敏、需要从 vault 还原哪些文件。
825
- if (sensitivePaths().length) {
1000
+ // types 归档不写 .redacted.json:凭据类明文已在归档内(不靠 vault 还原),
1001
+ // 非凭据类子集本不含凭据。全量归档照旧写脱敏清单供恢复侧从 vault 还原。
1002
+ if (!isTyped && sensitivePaths().length) {
826
1003
  await writeOwned(`${out}.redacted.json`, `${JSON.stringify({ files: redacted })}\n`);
827
1004
  }
828
1005
  // 机器元数据边车:跨机恢复的预检依据(home 不一致 → 绝对路径提示);
829
1006
  // quarantined 记录体检隔离、未入档的损坏会话文件。
830
- await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: redacted.length, ...(quarantined.length ? { quarantined } : {}) })}\n`);
1007
+ // meta types(分类型归档标识;缺省=全量,旧版/旧归档照读不误);types
1008
+ // 归档 redacted 记 0(未脱敏),hasCredentials 供 githubSync 跳过上云判断。
1009
+ await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: isTyped ? 0 : redacted.length, ...(isTyped ? { types, hasCredentials } : {}), ...(quarantined.length ? { quarantined } : {}) })}\n`);
831
1010
 
832
1011
  // 救援通道:每次备份把进程外恢复工具落进备份目录——宿主起不来时
833
1012
  // `node rescue.mjs`(或双击启动器)仍可恢复(DSH 依赖 Node,宿主死了
@@ -857,12 +1036,16 @@ export function apply(ctx, pluginConfig) {
857
1036
  // 的每日首份、再往前 4 个不同 ISO 周的每周首份(今日的档内冗余仍按 keep
858
1037
  // 裁剪)。严格只比旧的"全删"多保留,绝不多删;非标准命名(解析不出日期)
859
1038
  // 保持旧行为不保护。
860
- const all = await listBackups();
861
- const tail = all.slice(keepN);
862
- const stale = tail.filter((b) => !tierProtectedSet(tail, all[0]?.name).has(b.name)).map((b) => b.name);
863
- if (stale.length) {
864
- await removeFiles(stale.flatMap((n) => sidecarsFor(n)), root, signal);
865
- }
1039
+ // 轮换:types 归档与全量归档分桶独立计 keep 窗口(dsh-t- 前缀),互不挤占。
1040
+ // 统一用 rotateBase/rotateStale 变量,使下方 return 在两条路径下都可见。
1041
+ // skipRotate:恢复流程中途拍的快照绝不顺带轮换——目标归档可能正落在
1042
+ // keep 窗口外,轮换会删掉马上要解压的文件(review P1-3)。
1043
+ const rotateBase = isTyped ? await listTypedBackups() : await listBackups();
1044
+ const rotateTail = rotateBase.slice(keepN);
1045
+ const rotateStale = opts.skipRotate
1046
+ ? []
1047
+ : rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name)).map((b) => b.name);
1048
+ if (rotateStale.length) await removeFiles(rotateStale.flatMap((n) => sidecarsFor(n)), root, signal);
866
1049
 
867
1050
  // GitHub 同步(失败不回滚备份;状态记入 auto.json)
868
1051
  let sync = null;
@@ -882,7 +1065,7 @@ export function apply(ctx, pluginConfig) {
882
1065
  await saveAutoState();
883
1066
  }
884
1067
 
885
- return { path: out, name, sha: shaText, total: all.length, stale: stale.length, keep: keepN, quarantined, sync };
1068
+ return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(isTyped ? { types, hasCredentials } : {}) };
886
1069
  }
887
1070
 
888
1071
  /** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
@@ -995,6 +1178,8 @@ export function apply(ctx, pluginConfig) {
995
1178
  // 配置时在 keep 集内保留)与当前备份集(归档+边车),其余文件
996
1179
  // (旧副本、误入杂物)清理——git add -A 因此只会收录归档;轮换
997
1180
  // 删除与误入文件一并同步移除。
1181
+ // keep 集只含全量归档:listBackups 排除 dsh-t-(分类型归档),故类型归档
1182
+ // 不进 GitHub 同步——含 credentials 明文者尤甚,纵深满足"凭据不上云"。
998
1183
  const keep = new Set(['.gitignore']);
999
1184
  if (wt.cfg.token) keep.add('.git-credentials');
1000
1185
  for (const b of await listBackups()) {
@@ -1146,10 +1331,10 @@ export function apply(ctx, pluginConfig) {
1146
1331
  // 边车缺失
1147
1332
  }
1148
1333
  if (!/^[0-9a-f]{64}$/.test(expected)) {
1149
- return { name, ok: false, note: '缺少或无效的 .sha256 边车文件' };
1334
+ return { name, ok: false, note: '缺少或无效的配套校验文件(.sha256)——无法确认这份备份是否完好' };
1150
1335
  }
1151
1336
  const actual = await sha256File(archive, home, signal);
1152
- return { name, ok: actual === expected, note: actual === expected ? '完整' : 'sha256 不匹配(归档已损坏)' };
1337
+ return { name, ok: actual === expected, note: actual === expected ? '完整' : '校验和不匹配(归档或校验文件之一可能损坏)' };
1153
1338
  }
1154
1339
 
1155
1340
  async function pickArchive(selector) {
@@ -1168,6 +1353,14 @@ export function apply(ctx, pluginConfig) {
1168
1353
  if (sHits.length > 1) {
1169
1354
  throw new Error(`"${selector}" 匹配多份快照,请加长前缀:\n${sHits.slice(0, 5).map((n) => ` ${n}`).join('\n')}`);
1170
1355
  }
1356
+ // 分类型归档不在全量列表(旧版视角不可见),显式名/前缀可在此命中
1357
+ const typed = await listTypedBackups();
1358
+ const tExact = typed.filter((b) => b.name === selector);
1359
+ const tHits = tExact.length ? tExact : typed.filter((b) => b.name.startsWith(selector));
1360
+ if (tHits.length === 1) return tHits[0];
1361
+ if (tHits.length > 1) {
1362
+ throw new Error(`"${selector}" 匹配多份分类型归档,请加长前缀:\n${tHits.slice(0, 5).map((n) => ` ${n.name}`).join('\n')}`);
1363
+ }
1171
1364
  throw new Error(`没有匹配 "${selector}" 的备份,/backup list 查看`);
1172
1365
  }
1173
1366
  throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
@@ -1176,7 +1369,17 @@ export function apply(ctx, pluginConfig) {
1176
1369
  /** 删除指定备份(归档 + 全部边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
1177
1370
  async function removeBackup(selector, signal) {
1178
1371
  const { root } = paths();
1179
- const picked = await pickArchive(selector);
1372
+ let picked;
1373
+ try {
1374
+ picked = await pickArchive(selector);
1375
+ } catch (err) {
1376
+ // 分类型归档不在 listBackups(全量视角不可见),删除时按 dsh-t- 名单单独匹配
1377
+ const typed = await listTypedBackups();
1378
+ const exact = typed.filter((b) => b.name === selector);
1379
+ const hits = exact.length ? exact : typed.filter((b) => b.name.startsWith(selector));
1380
+ if (hits.length !== 1) throw err;
1381
+ picked = hits[0];
1382
+ }
1180
1383
  await removeFiles(sidecarsFor(picked.name), root, signal);
1181
1384
  return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
1182
1385
  }
@@ -1256,7 +1459,10 @@ export function apply(ctx, pluginConfig) {
1256
1459
  lines.push(`⚠️ 备份来自另一台机器/用户目录(${preflight.sourceHost ?? '未知主机'},${preflight.sourceHome}),settings 内的绝对路径可能需要调整`);
1257
1460
  }
1258
1461
  if (Array.isArray(preflight.redactedFiles)) {
1259
- lines.push(`🔐 该归档已脱敏:${preflight.redactedFiles.length} 个凭据文件不随归档走,恢复时从本机 vault 还原(跨机恢复需重填)`);
1462
+ // 0 个凭据文件时"已脱敏"是噪音且误导(让人以为有东西被隐去)——如实说明即可
1463
+ lines.push(preflight.redactedFiles.length
1464
+ ? `🔐 该归档已脱敏:${preflight.redactedFiles.length} 个凭据文件不随归档走,恢复时从本机 vault 还原(跨机恢复需重填)`
1465
+ : '🔐 归档不含凭据文件;本机凭据不受恢复影响');
1260
1466
  }
1261
1467
  if (preflight.legacyUnaccountedCredentials) {
1262
1468
  lines.push('⚠️ 归档未携带脱敏清单(v0.6.x 时代格式)且不含凭据文件——恢复后凭据需从本机 vault 或原机器补齐');
@@ -1354,6 +1560,96 @@ export function apply(ctx, pluginConfig) {
1354
1560
  },
1355
1561
  );
1356
1562
 
1563
+ // 安全拦截:分类型归档必须带 --types 走 merge。整包路径(挪旁+解压)会把
1564
+ // ~/.dsh 替换成子集内容,丢失未包含的类型数据——rescue 同语义拦截。
1565
+ if (!opts?.types && meta && Array.isArray(meta.types) && meta.types.length) {
1566
+ throw new Error(`${picked.name} 是分类型归档(${meta.types.join(', ')})。整包恢复会丢失其他类型数据——请带 --types ${meta.types.join(',')} 分类型恢复,或选一份全量归档(dsh- 开头)整包恢复。`);
1567
+ }
1568
+
1569
+ // 分类型恢复(merge):只把选中类型的条目解压进现有 ~/.dsh,不挪旁整包。
1570
+ // 复用 doctorRepair 的"留档+子集解压+回滚"哲学——打破现有"绝不合并、整包
1571
+ // 覆盖"不变量,用留档兜底。凭据类归档已含明文,直接覆盖;非凭据类
1572
+ // mergeTargets 不含凭据文件,不碰 vault(不走下方的 vault 还原)。
1573
+ if (Array.isArray(opts?.types) && opts.types.length) {
1574
+ const mergeTargets = typeEntries(opts.types, entries, base);
1575
+ if (!mergeTargets.length) {
1576
+ throw new Error(`归档 ${picked.name} 里没有选中类型(${opts.types.join(', ')})的条目`);
1577
+ }
1578
+ // 预检将覆盖的现有文件(留档依据);目录条目 tar 解压时 mkdir 无覆盖
1579
+ // 风险,新增项也不算覆盖——只留档现有文件。
1580
+ const willOverwrite = [];
1581
+ for (const entry of mergeTargets) {
1582
+ const rel = entry.slice(`${base}/`.length);
1583
+ const st = await fsStat(`${dshHome}/${rel}`).then((s) => s, () => null);
1584
+ if (st && st.isFile()) willOverwrite.push({ entry, rel });
1585
+ }
1586
+ // 只解压文件条目:目录条目在 GNU tar 下递归消费内容会让显式成员报
1587
+ // "Not found in archive"(exit 2),在 bsdtar 下又不递归——传文件列表在
1588
+ // 两端行为一致(tar -x 文件成员自动补父目录;doctor 定点修复同模式)。
1589
+ const fileTargets = mergeTargets.filter((e) => !mergeTargets.some((o) => o.startsWith(`${e}/`)));
1590
+ if (dryRun) {
1591
+ return {
1592
+ archive, files: fileTargets.length, sample: fileTargets.slice(0, 24), aside: null, snapshotPath: null, dryRun: true,
1593
+ merge: true, types: opts.types, willOverwrite: willOverwrite.map((w) => w.rel),
1594
+ targetExists, preflight: preflightLines(preflight, profileDirs),
1595
+ };
1596
+ }
1597
+ // 留档将覆盖的现有文件(.pre-merge-<时间戳>),失败可逐个还原。
1598
+ const stamp = stampNow();
1599
+ const kept = [];
1600
+ for (const w of willOverwrite) {
1601
+ const keep = `${dshHome}/${w.rel}.pre-merge-${stamp}`;
1602
+ await mkdir(dirname(keep), { recursive: true });
1603
+ await copyFile(`${dshHome}/${w.rel}`, keep);
1604
+ kept.push({ ...w, keep });
1605
+ }
1606
+ try {
1607
+ const tar = await ctx.subprocess.resolveExecutable('tar');
1608
+ await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...fileTargets], root, signal);
1609
+ } catch (err) {
1610
+ // 对齐 #28 / doctorRepair 回滚:从留档还原每个被覆盖项;连还原都失败
1611
+ // 时把留档路径交代清楚,绝不静默停在半截状态。
1612
+ const reason = err && err.message ? err.message : String(err);
1613
+ const restored = new Set();
1614
+ for (const w of willOverwrite) {
1615
+ try { await copyFile(w.keep, `${dshHome}/${w.rel}`); restored.add(w.rel); } catch { /* 逐个失败落入下方报告 */ }
1616
+ }
1617
+ if (restored.size === willOverwrite.length) {
1618
+ throw new Error(`分类型恢复失败(${reason})。已从留档还原全部覆盖现场,数据保持恢复前状态;排查后可重试`);
1619
+ }
1620
+ const missing = willOverwrite.filter((w) => !restored.has(w.rel)).map((w) => w.rel);
1621
+ throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.pre-merge-${stamp} 留档仍在原目录旁,手工拷回即可`);
1622
+ }
1623
+ // 可选依赖重装:merge 含 profiles 时对新解出的 profile 跑 pnpm install。
1624
+ const deps = { installed: [], failed: [], note: null };
1625
+ if (opts?.syncDeps) {
1626
+ const mergedProfiles = [...new Set(mergeTargets
1627
+ .filter((n) => n.startsWith(`${base}/profiles/`) && n.endsWith('/package.json'))
1628
+ .map((n) => n.slice(`${base}/profiles/`.length).split('/')[0]))];
1629
+ if (mergedProfiles.length) {
1630
+ try {
1631
+ const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
1632
+ for (const p of mergedProfiles) {
1633
+ try {
1634
+ await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
1635
+ deps.installed.push(p);
1636
+ } catch {
1637
+ await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
1638
+ deps.installed.push(p);
1639
+ }
1640
+ }
1641
+ } catch {
1642
+ deps.note = 'pnpm 不可用,跳过依赖重装(配置已恢复)';
1643
+ }
1644
+ }
1645
+ }
1646
+ return {
1647
+ archive, files: fileTargets.length, aside: null, snapshotPath: null, dryRun: false,
1648
+ merge: true, types: opts.types, restored: fileTargets.length,
1649
+ kept: kept.map((k) => k.keep), deps, preflight: preflightLines(preflight, profileDirs),
1650
+ };
1651
+ }
1652
+
1357
1653
  if (dryRun) {
1358
1654
  return {
1359
1655
  archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true,
@@ -1366,9 +1662,9 @@ export function apply(ctx, pluginConfig) {
1366
1662
  // 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
1367
1663
  // 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
1368
1664
  const snapshot = targetExists
1369
- ? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true })
1665
+ ? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true, skipRotate: true })
1370
1666
  : null;
1371
- if (snapshot) await prunePreRestoreSnapshots(snapshot.name, signal);
1667
+ if (snapshot) await prunePreRestoreSnapshots([snapshot.name, picked.name], signal);
1372
1668
  const asideName = `${base}.pre-restore-${stampNow()}`;
1373
1669
  let aside = null;
1374
1670
  if (targetExists) {
@@ -1392,11 +1688,11 @@ export function apply(ctx, pluginConfig) {
1392
1688
  // 回滚失败:落入下方的人工指引分支,把位置交代清楚
1393
1689
  }
1394
1690
  if (rolledBack) {
1395
- throw new Error(`解压归档失败(${reason})。已自动还原到恢复前状态,数据未丢失;排查磁盘空间/权限后可重试`);
1691
+ throw new Error(`解压归档失败:${friendlyReason(err)}。已自动还原到恢复前状态,数据未丢失,可处理后重试`);
1396
1692
  }
1397
1693
  throw new Error(aside
1398
- ? `解压归档失败(${reason}),且自动还原未成功。原数据完好保存在 ${aside},把它改名回 "${dshHome}" 即可回到恢复前状态`
1399
- : `解压归档失败(${reason}),且清理未成功。半截解压产物在 ${dshHome},删除后即可重试`);
1694
+ ? `解压归档失败:${friendlyReason(err)},且自动还原未成功。原数据完好保存在 ${aside},把它改名回 "${dshHome}" 即可回到恢复前状态`
1695
+ : `解压归档失败:${friendlyReason(err)},且清理未成功。半截解压产物在 ${dshHome},删除后即可重试`);
1400
1696
  }
1401
1697
 
1402
1698
  // vault 还原:脱敏归档恢复出的 ~/.dsh 不含凭据文件,从本机 vault 拷回。
@@ -1457,6 +1753,28 @@ export function apply(ctx, pluginConfig) {
1457
1753
 
1458
1754
  /** 恢复结果的人类可读汇总(命令、模型工具与面板共用)。 */
1459
1755
  function summarizeRestore(r) {
1756
+ if (r.merge) {
1757
+ const types = (r.types ?? []).join(', ');
1758
+ if (r.dryRun) {
1759
+ const over = r.willOverwrite ?? [];
1760
+ const shown = r.sample.length;
1761
+ const tail = r.files > shown ? `\n … 共 ${r.files} 项` : '';
1762
+ const overLine = over.length
1763
+ ? `\n 其中覆盖现有 ${over.length} 个(先留档 .pre-merge-*): ${over.slice(0, 8).join(', ')}${over.length > 8 ? ' …' : ''}`
1764
+ : '\n 均为新增,不覆盖现有文件';
1765
+ return `📦 分类型恢复预览 —— 只预览,还没动磁盘\n 归档: ${r.archive}\n 类型: ${types}\n 将写入: ${r.files} 项${overLine}\n${(r.preflight ?? []).map((l) => ` ${l}`).join('\n')}\n\n 将写入的条目:\n${r.sample.map((s) => ` ${s}`).join('\n')}${tail}`;
1766
+ }
1767
+ const lines = [
1768
+ '✅ 分类型恢复完成',
1769
+ ` 已合并写入: ${r.restored} 项 ← 来自 ${r.archive.split('/').pop()}(类型: ${types})`,
1770
+ ];
1771
+ if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(.pre-merge-*,确认后可自行删除)`);
1772
+ for (const p of r.preflight ?? []) if (p.startsWith('⚠️')) lines.push(` ${p}`);
1773
+ if (r.deps?.note) lines.push(` 插件依赖: ${r.deps.note}`);
1774
+ else if (r.deps?.installed?.length) lines.push(` 插件依赖: 已装好 ${r.deps.installed.join(', ')}`);
1775
+ lines.push(' ⏸️ 待办: 重启 dsh 让恢复的类型内容生效');
1776
+ return lines.join('\n');
1777
+ }
1460
1778
  if (r.dryRun) {
1461
1779
  // 预览要回答两件事:确认后会发生什么(按目标是否已有数据切换口径)、
1462
1780
  // 凭据/依赖的 preflight 提醒。此时还没动任何文件,标题必须说清。
@@ -1464,7 +1782,7 @@ export function apply(ctx, pluginConfig) {
1464
1782
  ? [' · 目标位置还没有 .dsh(首次恢复):直接写入,不做挪动和快照']
1465
1783
  : [
1466
1784
  ' · 当前的 .dsh 整个挪到一旁,改名为 .dsh.pre-restore-<时间>,不会和新数据混在一起',
1467
- ' · 挪之前自动做一份快照备份;快照只保留最近一次',
1785
+ ' · 挪之前自动做一份快照备份(恢复前快照只保留最近一次)',
1468
1786
  ' · 然后把这份归档的内容原样写回 .dsh',
1469
1787
  ];
1470
1788
  const pre = ['', ...moves, ...(r.preflight ?? []).map((l) => ` ${l}`)].join('\n');
@@ -1487,6 +1805,9 @@ export function apply(ctx, pluginConfig) {
1487
1805
  if (r.vaultMissing?.length) lines.push(` ⚠️ 凭据缺失需手动重填: ${r.vaultMissing.join(', ')}`);
1488
1806
  const installed = r.deps?.installed ?? [];
1489
1807
  const failed = r.deps?.failed ?? [];
1808
+ // 待办必须只有一条且顺序正确:node_modules 不随归档,先重装依赖再重启,
1809
+ // 照"直接重启"执行会得到 cannot resolve profile bundle(UX 审查 P1-2)。
1810
+ const depsPending = Boolean(r.deps?.note) || failed.length > 0 || (!installed.length && r.profiles?.length);
1490
1811
  if (r.deps?.note) {
1491
1812
  lines.push(` 插件依赖: 还没装 —— ${r.deps.note};之后也可用 --sync-deps 补装`);
1492
1813
  } else if (failed.length) {
@@ -1494,9 +1815,11 @@ export function apply(ctx, pluginConfig) {
1494
1815
  } else if (installed.length) {
1495
1816
  lines.push(` 插件依赖: 已装好 ${installed.join(', ')}`);
1496
1817
  } else if (r.profiles?.length) {
1497
- lines.push(' 插件依赖: 还没装 —— 恢复时带 --sync-deps 重装,或进各 profile 目录手动 pnpm install');
1818
+ lines.push(' 插件依赖: 还没装');
1498
1819
  }
1499
- lines.push(' ⏸️ 待办: 重启 dsh,会话和配置才会切换到恢复的内容');
1820
+ lines.push(depsPending
1821
+ ? ' ⏸️ 待办: 先重装插件依赖(重开 dsh 前进各 profile 目录跑 pnpm install),再重启 dsh——顺序不能反,否则起不来'
1822
+ : ' ⏸️ 待办: 重启 dsh,会话和配置才会切换到恢复的内容');
1500
1823
  return lines.join('\n');
1501
1824
  }
1502
1825
 
@@ -1505,9 +1828,12 @@ export function apply(ctx, pluginConfig) {
1505
1828
  // 布局与格式依据宿主内置的 @deepseek-ai/dsh-session-persistence-jsonl:
1506
1829
  // <会话根>/--<项目目录>--/<编码后 id>/session.jsonl.zstd(compression:'none' 时为 .jsonl)
1507
1830
  // zstd 文件是独立帧的拼接:首帧是带校验和的 SessionHeader 行,其后每个追加批一帧。
1831
+ // 两条硬契约都对齐宿主读端:①行级——首行 SessionHeader、seq 连续;②容器级——
1832
+ // 首帧解出来恰好一行 header(宿主 assertZstdHeaderFrame;外部工具把整份日志
1833
+ // 压成单帧时内容完整可解、宿主却拒载,行级校验覆盖不到,必须单独判)。
1508
1834
  // Node 内置 zstd 解码器不跨帧续读,因此先按 RFC 8878 走帧字节边界
1509
1835
  // (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
1510
- // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断。
1836
+ // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
1511
1837
  const ZSTD_MAGIC = 0xfd2fb528;
1512
1838
  const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
1513
1839
  const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
@@ -1525,7 +1851,9 @@ export function apply(ctx, pluginConfig) {
1525
1851
 
1526
1852
  /**
1527
1853
  * 按 RFC 8878 结构走查拼接帧的字节边界(不解压):返回 [start,end] 区间数组;
1528
- * 结构不合法(截断/未知魔数/保留位/越界块)抛错。skippable 帧一并支持。
1854
+ * 结构不合法(截断/未知魔数/保留位/越界块)抛错。
1855
+ * skippable 帧直接判非法——宿主 scanZstdFrames 拒绝一切非数据帧魔数,
1856
+ * 这类文件宿主整体拒载,容忍它们会造成「doctor 报 OK、宿主编译系统读不了」。
1529
1857
  */
1530
1858
  function walkZstdFrames(buf) {
1531
1859
  const frames = [];
@@ -1564,12 +1892,7 @@ export function apply(ctx, pluginConfig) {
1564
1892
  frames.push([off, pos]);
1565
1893
  off = pos;
1566
1894
  } else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
1567
- if (buf.length - off < 8) throw new Error('skippable 帧头截断');
1568
- const size = buf.readUInt32LE(off + 4);
1569
- const end = off + 8 + size;
1570
- if (end > buf.length) throw new Error('skippable 帧数据越界');
1571
- frames.push([off, end]);
1572
- off = end;
1895
+ throw new Error('skippable 帧——宿主读端不支持任何非数据帧魔数,整个文件将被拒载');
1573
1896
  } else {
1574
1897
  throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
1575
1898
  }
@@ -1577,19 +1900,32 @@ export function apply(ctx, pluginConfig) {
1577
1900
  return frames;
1578
1901
  }
1579
1902
 
1580
- /** 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错(错误含偏移信息)。 */
1903
+ /**
1904
+ * 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错(错误含偏移信息)。
1905
+ * 同时对首个数据帧做宿主容器契约判定 headerFrameOk——宿主 assertZstdHeaderFrame
1906
+ * 是字节精确的:首帧解出必须非空、且首个换行符恰在最后一个字节(即「恰好
1907
+ * 一行、以单个 \n 结尾」)。行流拼接校验覆盖不到这条(#66)。
1908
+ */
1581
1909
  function decodeZstdLog(buf) {
1582
1910
  const parts = [];
1583
1911
  let frameNo = 0;
1912
+ let headerFrameOk;
1584
1913
  for (const [s, e] of walkZstdFrames(buf)) {
1585
1914
  frameNo += 1;
1586
1915
  try {
1587
- parts.push(zlib.zstdDecompressSync(buf.subarray(s, e)));
1916
+ const out = zlib.zstdDecompressSync(buf.subarray(s, e));
1917
+ parts.push(out);
1918
+ if (headerFrameOk === undefined) {
1919
+ headerFrameOk = out.length > 0 && out.indexOf(10) === out.length - 1;
1920
+ }
1588
1921
  } catch (err) {
1589
1922
  throw new Error(`第 ${frameNo} 帧解压失败(${e - s}B):${String(err && err.message ? err.message : err)}`);
1590
1923
  }
1591
1924
  }
1592
- return Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0);
1925
+ return {
1926
+ lines: Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0),
1927
+ headerFrameOk,
1928
+ };
1593
1929
  }
1594
1930
 
1595
1931
  /**
@@ -1670,8 +2006,15 @@ export function apply(ctx, pluginConfig) {
1670
2006
  return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限(${Math.floor(HASH_MAX_BYTES / 1048576)}MB),不整读入内存` };
1671
2007
  }
1672
2008
  let lines;
2009
+ let headerFrameOk;
1673
2010
  if (f.abs.endsWith('.zstd')) {
1674
- lines = decodeZstdLog(await readFile(f.abs));
2011
+ ({ lines, headerFrameOk } = decodeZstdLog(await readFile(f.abs)));
2012
+ // 容器契约(#66/#1047):首帧必须解出「恰好一行 header、以单个换行
2013
+ // 结尾」(字节精确,对齐宿主 assertZstdHeaderFrame)。单帧重写、帧内
2014
+ // 多余空行、缺行尾的文件内容完整可解、行级校验全过,宿主却整体拒载。
2015
+ if (headerFrameOk === false) {
2016
+ return { state: 'bad', reason: '首帧不是恰好一行 header——违反容器契约(首帧应解出一行 SessionHeader 且以单个换行结尾;单帧重写/多余空行/缺行尾的典型形态,宿主读端会拒载)' };
2017
+ }
1675
2018
  } else {
1676
2019
  lines = (await readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
1677
2020
  }
@@ -1683,7 +2026,7 @@ export function apply(ctx, pluginConfig) {
1683
2026
  }
1684
2027
  }
1685
2028
 
1686
- function summarizeDoctorScan(r) {
2029
+ function summarizeDoctorScan(r, opts = {}) {
1687
2030
  if (!r.files.length) return '未找到任何会话日志——可能还没有产生过会话,或布局与预期不同。';
1688
2031
  const healthy = r.scanned - r.corruptCount - (r.skippedCount ?? 0);
1689
2032
  let head = `扫描 ${r.scanned} 个会话日志:${healthy} 健康、${r.corruptCount} 损坏`;
@@ -1692,7 +2035,9 @@ export function apply(ctx, pluginConfig) {
1692
2035
  const shown = r.corrupt.slice(0, 15);
1693
2036
  const more = r.corrupt.length > shown.length ? `\n …另有 ${r.corrupt.length - shown.length} 个损坏文件` : '';
1694
2037
  const detail = shown.map((c) => ` ❌ ${c.rel}\n ${c.reason}`).join('\n');
1695
- return `${head}\n${detail}${more}\n\n可从备份定点修复:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*)`;
2038
+ // 修复入口按调用面分流:聊天面指聊天命令;面板没有 doctor UI,指到真实可用路径
2039
+ const hint = opts.repairHint ?? '可从备份定点修复:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*)';
2040
+ return `${head}\n${detail}${more}\n\n${hint}`;
1696
2041
  }
1697
2042
 
1698
2043
  /** 只读扫描 ~/.dsh 下全部会话日志;不写任何文件。 */
@@ -1756,7 +2101,6 @@ export function apply(ctx, pluginConfig) {
1756
2101
  const tar = await ctx.subprocess.resolveExecutable('tar');
1757
2102
  await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root, signal);
1758
2103
  } catch (err) {
1759
- const reason = err && err.message ? err.message : String(err);
1760
2104
  const restored = new Set();
1761
2105
  for (let i = 0; i < targets.length; i++) {
1762
2106
  try {
@@ -1765,10 +2109,10 @@ export function apply(ctx, pluginConfig) {
1765
2109
  } catch { /* 单个还原失败:落入下方如实报告 */ }
1766
2110
  }
1767
2111
  if (restored.size === targets.length) {
1768
- throw new Error(`定点修复失败(${reason})。已从留档还原全部损坏现场,数据保持修复前状态;排查磁盘空间/权限后可重试`);
2112
+ throw new Error(`定点修复失败:${friendlyReason(err)}。已从留档还原全部损坏现场,数据保持修复前状态,可处理后重试`);
1769
2113
  }
1770
2114
  const missing = targets.filter((t) => !restored.has(t.rel)).map((t) => t.rel);
1771
- throw new Error(`定点修复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.corrupt-${stamp} 留档仍在原目录旁,手工拷回原位即可`);
2115
+ throw new Error(`定点修复失败:${friendlyReason(err)},且部分现场未自动还原: ${missing.join(', ')}。对应的 *.corrupt-${stamp} 留档仍在原目录旁,手工拷回原位即可`);
1772
2116
  }
1773
2117
 
1774
2118
  // 修完复检:从归档解出的副本必须健康,否则如实报告(skipped 不算失败——
@@ -1867,7 +2211,11 @@ export function apply(ctx, pluginConfig) {
1867
2211
  lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
1868
2212
  return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
1869
2213
  } catch (err) {
1870
- console.warn(`[dsh-backup] auto.json 无法读取,自动备份计划已重置: ${String(err && err.message ? err.message : err)}`);
2214
+ // ENOENT = 首次使用还没有 auto.json,是正常初始状态,静默按未配置处理;
2215
+ // 其他读取失败(解析错误/权限)才值得告警,且文案要给"影响 + 出路"。
2216
+ if (err && err.code !== 'ENOENT') {
2217
+ console.warn(`[dsh-backup] 自动备份计划文件读取失败,定时备份已暂停(不影响已有备份):执行 /backup auto <小时数> 可重新开启(${String(err && err.message ? err.message : err)})`);
2218
+ }
1871
2219
  return 0;
1872
2220
  }
1873
2221
  }
@@ -1880,11 +2228,17 @@ export function apply(ctx, pluginConfig) {
1880
2228
  return Math.max(anchor, Date.now());
1881
2229
  }
1882
2230
 
2231
+ /** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
2232
+ function formatWhen(ts) {
2233
+ const d = new Date(ts);
2234
+ const p = (n) => String(n).padStart(2, '0');
2235
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
2236
+ }
2237
+
1883
2238
  function autoSummary() {
1884
2239
  if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
1885
2240
  // 绝对节奏:下次 = 上次执行 + 周期;错过(重启间隔超周期)则按现在显示,调度会立即补跑。
1886
- const next = new Date(nextAutoAt()).toLocaleString();
1887
- return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次约 ${next}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
2241
+ return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
1888
2242
  }
1889
2243
 
1890
2244
  /** 链式 timeout 调度:每次触发后按上次执行时间推算下一次,重启后节奏不重置。 */
@@ -1965,7 +2319,10 @@ export function apply(ctx, pluginConfig) {
1965
2319
  const dryRun = parts.includes('--dry-run');
1966
2320
  const syncDeps = parts.includes('--sync-deps');
1967
2321
  const sel = parts.slice(1).find((t) => !t.startsWith('--')) || 'latest';
1968
- const r = await restoreArchive(sel, dryRun, invocation.signal, { syncDeps });
2322
+ // 分类型恢复:--types skills(只把选中类型 merge 进现有 ~/.dsh,不挪旁整包)
2323
+ const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
2324
+ const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
2325
+ const r = await restoreArchive(sel, dryRun, invocation.signal, { syncDeps, ...(types ? { types } : {}) });
1969
2326
  return { kind: 'success', text: summarizeRestore(r) };
1970
2327
  }
1971
2328
 
@@ -2041,7 +2398,7 @@ export function apply(ctx, pluginConfig) {
2041
2398
 
2042
2399
  if (head === 'delete' || head === 'rm') {
2043
2400
  const sel = parts[1];
2044
- if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest>' };
2401
+ if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest>(删除不可恢复,需要时可用 /backup 重新备份)' };
2045
2402
  const r = await removeBackup(sel, invocation.signal);
2046
2403
  return { kind: 'success', text: `🗑️ ${r.summary}` };
2047
2404
  }
@@ -2058,19 +2415,22 @@ export function apply(ctx, pluginConfig) {
2058
2415
  return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12)' };
2059
2416
  }
2060
2417
  await setAuto(h);
2061
- return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n${autoSummary()}` };
2418
+ return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n首次备份正在执行,之后每 ${h} 小时一次。\n${autoSummary()}` };
2062
2419
  }
2063
2420
 
2064
2421
  let keep;
2065
2422
  const m = input.match(/--keep\s+(\d+)/);
2066
2423
  if (m) keep = Number(m[1]);
2067
- const r = await doBackup(keep, invocation.signal);
2424
+ // 分类型备份:--types api,mcp,skills(逗号分隔,可多类;--only 同义)。缺省=全量。
2425
+ const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
2426
+ const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
2427
+ const r = await doBackup(keep, invocation.signal, types ? { types } : {});
2068
2428
  return {
2069
2429
  kind: 'success',
2070
- text: `✅ 备份完成\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
2430
+ text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
2071
2431
  };
2072
2432
  } catch (err) {
2073
- return { kind: 'error', text: `备份失败: ${String(err && err.message ? err.message : err)}` };
2433
+ return { kind: 'error', text: `备份失败: ${friendlyReason(err)}` };
2074
2434
  }
2075
2435
  },
2076
2436
  });
@@ -2087,6 +2447,7 @@ export function apply(ctx, pluginConfig) {
2087
2447
  dryRun: { type: 'boolean', description: 'mode=restore 时仅预览(不写入);真实恢复前必须先预览并向用户展示确认' },
2088
2448
  syncDeps: { type: 'boolean', description: 'mode=restore 时恢复后对各 profile 执行 pnpm install 重装插件依赖' },
2089
2449
  repair: { type: 'boolean', description: 'mode=doctor 时从 selector 归档定点修复损坏的会话日志(缺省仅扫描)' },
2450
+ types: { type: 'string', description: 'mode=backup/restore 时按内容类型子集操作(逗号分隔,如 credentials,mcp,skills)。backup=只打选中类型子集归档;restore=只把选中类型 merge 进现有 ~/.dsh(不挪旁整包,覆盖前留档)' },
2090
2451
  },
2091
2452
  output: {
2092
2453
  schema: { type: 'object', additionalProperties: true },
@@ -2115,7 +2476,8 @@ export function apply(ctx, pluginConfig) {
2115
2476
  return { ok: bad === 0, summary: lines.join('\n') || '暂无备份可校验。' };
2116
2477
  }
2117
2478
  if (mode === 'restore') {
2118
- const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal);
2479
+ const types = typeof args?.types === 'string' && args.types.trim() ? [...new Set(args.types.split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
2480
+ const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal, { syncDeps: Boolean(args && args.syncDeps), ...(types ? { types } : {}) });
2119
2481
  return { ok: true, path: r.archive, summary: summarizeRestore(r) };
2120
2482
  }
2121
2483
  if (mode === 'doctor') {
@@ -2140,14 +2502,17 @@ export function apply(ctx, pluginConfig) {
2140
2502
  await setAuto(0);
2141
2503
  return { ok: true, summary: '自动备份已关闭。' };
2142
2504
  }
2143
- if (!Number.isFinite(h) || h < 1 || h > 720) return { ok: false, summary: 'hours 需为 1~720' };
2505
+ if (!Number.isFinite(h) || h < 1 || h > 720) return { ok: false, summary: 'hours 需为 1~720 的整数(0=关闭)' };
2144
2506
  await setAuto(h);
2145
- return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n${autoSummary()}` };
2507
+ return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n首次备份正在执行,之后每 ${h} 小时一次。\n${autoSummary()}` };
2146
2508
  }
2147
- const r = await doBackup(args && args.keep ? args.keep : undefined, signal);
2148
- return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}` };
2509
+ const types = typeof args?.types === 'string' && args.types.trim()
2510
+ ? [...new Set(args.types.split(',').map((s) => s.trim()).filter(Boolean))]
2511
+ : undefined;
2512
+ const r = await doBackup(args && args.keep ? args.keep : undefined, signal, types ? { types } : {});
2513
+ return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}` };
2149
2514
  } catch (err) {
2150
- return { ok: false, summary: `操作失败: ${String(err && err.message ? err.message : err)}` };
2515
+ return { ok: false, summary: `操作失败: ${friendlyReason(err)}` };
2151
2516
  }
2152
2517
  },
2153
2518
  }));
@@ -2156,7 +2521,15 @@ export function apply(ctx, pluginConfig) {
2156
2521
  const panelOps = {
2157
2522
  status: async () => {
2158
2523
  const all = await listBackups();
2524
+ const typed = await listTypedBackups();
2159
2525
  const { root, dshHome } = paths();
2526
+ // 分类型归档单独列出并带类型清单(读各自 meta.types),供面板分区展示
2527
+ const typedBackups = [];
2528
+ for (const b of typed) {
2529
+ let types = [];
2530
+ try { types = JSON.parse(await readFile(`${root}/${b.name}.meta.json`, 'utf8')).types ?? []; } catch { /* 边车缺失 */ }
2531
+ typedBackups.push({ name: b.name, size: typeof b.size === 'number' ? b.size : null, types });
2532
+ }
2160
2533
  return {
2161
2534
  destination: root,
2162
2535
  dshHome,
@@ -2164,17 +2537,22 @@ export function apply(ctx, pluginConfig) {
2164
2537
  autoHours,
2165
2538
  lastAuto,
2166
2539
  backups: all.map((b) => ({ name: b.name, size: typeof b.size === 'number' ? b.size : null })),
2540
+ typedBackups,
2167
2541
  };
2168
2542
  },
2169
- backup: async (keep, signal) => {
2170
- const r = await doBackup(keep, signal);
2543
+ backup: async (keep, types, signal) => {
2544
+ const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
2545
+ const r = await doBackup(keep, signal, validTypes ? { types: validTypes } : {});
2546
+ // 面板回执给"成了、叫什么、存哪了";64 位 sha 全文是噪音(完整值就在
2547
+ // 归档旁的 .sha256),路径用文件名——目录已显示在上方总览卡
2171
2548
  return {
2172
2549
  ok: true,
2173
- summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}`,
2550
+ summary: `备份完成: ${r.path.split('/').pop()}${validTypes ? `(类型: ${validTypes.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha.slice(0, 16)}…(完整校验值在归档旁的 .sha256 文件)${r.stale ? `\n轮换删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)` : ''}${quarantineNote(r)}`,
2174
2551
  path: r.path,
2175
2552
  sha: r.sha,
2176
2553
  stale: r.stale,
2177
2554
  keep: r.keep,
2555
+ ...(validTypes ? { types: validTypes, hasCredentials: Boolean(r.hasCredentials) } : {}),
2178
2556
  };
2179
2557
  },
2180
2558
  verify: async (selector, signal) => {
@@ -2192,11 +2570,12 @@ export function apply(ctx, pluginConfig) {
2192
2570
  }
2193
2571
  return { ok: bad === 0, summary: results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份可校验。', results };
2194
2572
  },
2195
- restore: async (selector, dryRun, signal) => {
2573
+ restore: async (selector, dryRun, types, syncDeps, signal) => {
2196
2574
  try {
2197
- const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal);
2575
+ const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
2576
+ const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal, { syncDeps: Boolean(syncDeps), ...(validTypes ? { types: validTypes } : {}) });
2198
2577
  if (r.dryRun) {
2199
- return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, summary: summarizeRestore(r) };
2578
+ return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, ...(r.merge ? { merge: true, types: r.types, willOverwrite: r.willOverwrite ?? [] } : {}), summary: summarizeRestore(r) };
2200
2579
  }
2201
2580
  return {
2202
2581
  ok: true,
@@ -2208,10 +2587,12 @@ export function apply(ctx, pluginConfig) {
2208
2587
  preflight: r.preflight ?? [],
2209
2588
  vaultRestored: r.vaultRestored ?? [],
2210
2589
  vaultMissing: r.vaultMissing ?? [],
2590
+ ...(r.merge ? { merge: true, types: r.types, restored: r.restored, kept: r.kept ?? [] } : {}),
2591
+ deps: r.deps,
2211
2592
  summary: summarizeRestore(r),
2212
2593
  };
2213
2594
  } catch (err) {
2214
- return { ok: false, dryRun: Boolean(dryRun), summary: String(err && err.message ? err.message : err) };
2595
+ return { ok: false, dryRun: Boolean(dryRun), summary: friendlyReason(err) };
2215
2596
  }
2216
2597
  },
2217
2598
  setAuto: async (hours) => {
@@ -2220,10 +2601,10 @@ export function apply(ctx, pluginConfig) {
2220
2601
  return { ok: true, hours: 0, summary: '自动备份已关闭。' };
2221
2602
  }
2222
2603
  if (!Number.isFinite(hours) || hours < 1 || hours > 720) {
2223
- return { ok: false, summary: 'hours 需为 1~720(0=关闭)' };
2604
+ return { ok: false, summary: '间隔需为 1~720 的整数小时(填 12 表示每 12 小时一次)' };
2224
2605
  }
2225
2606
  await setAuto(Math.floor(hours));
2226
- return { ok: true, hours: Math.floor(hours), summary: autoSummary() };
2607
+ return { ok: true, hours: Math.floor(hours), summary: `自动备份已开启:每 ${Math.floor(hours)} 小时一次。\n首次备份正在执行,之后每 ${Math.floor(hours)} 小时一次。\n${autoSummary()}` };
2227
2608
  },
2228
2609
  githubStatus: async () => {
2229
2610
  const cfg = githubConfig();
@@ -2276,7 +2657,7 @@ export function apply(ctx, pluginConfig) {
2276
2657
  const r = await removeBackup(selector || 'latest', signal);
2277
2658
  return { ok: true, summary: r.summary };
2278
2659
  } catch (err) {
2279
- return { ok: false, summary: String(err && err.message ? err.message : err) };
2660
+ return { ok: false, summary: friendlyReason(err) };
2280
2661
  }
2281
2662
  },
2282
2663
  setGithubRepo: async (repo) => {
@@ -2320,7 +2701,8 @@ export function apply(ctx, pluginConfig) {
2320
2701
  ok: r.corruptCount === 0,
2321
2702
  scanned: r.scanned,
2322
2703
  corruptCount: r.corruptCount,
2323
- summary: summarizeDoctorScan(r),
2704
+ // 面板没有 doctor UI,指路必须指向真实可用的入口(聊天命令或救援页)
2705
+ summary: summarizeDoctorScan(r, { repairHint: '定点修复目前要在聊天窗执行:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*);DSH 起不来时可用备份目录里的救援页「体检并修复」' }),
2324
2706
  corrupt: r.corrupt.map((c) => ({ path: c.rel, reason: c.reason })),
2325
2707
  };
2326
2708
  } catch (err) {