@xiaoyuyu6420/dsh-backup 0.13.1 → 0.13.3

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
@@ -47,6 +47,7 @@ import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fs
47
47
  import { createRequire } from 'node:module';
48
48
  import { hostname } from 'node:os';
49
49
  import { basename, dirname, join } from 'node:path';
50
+ import { StringDecoder } from 'node:string_decoder';
50
51
  import { fileURLToPath } from 'node:url';
51
52
  import * as zlib from 'node:zlib';
52
53
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -59,8 +60,18 @@ export const inject = ['subprocess', 'commands', 'timer', 'tools'];
59
60
 
60
61
  /** Windows 无 POSIX chmod 语义(用户目录 ACL 默认私有)。 */
61
62
  const IS_WIN = process.platform === 'win32';
62
- /** sha256 回退路径(node:fs 读取 + node:crypto)的内存上限。 */
63
+ /** 深度校验(会话日志整读入内存)的体积上限;sha256 流式回退不适用此限。 */
63
64
  const HASH_MAX_BYTES = 256 * 1024 * 1024;
65
+ /** 流式 sha256 的分块大小(4MB ≈ 每块几毫秒 CPU,块间让出事件循环)。 */
66
+ const HASH_CHUNK_BYTES = 4 * 1024 * 1024;
67
+ /**
68
+ * 启动宽限期:插件装配后头 45 秒不启动任何重活(全量 tar/gzip + 哈希要几分钟)。
69
+ * 宿主自己的 boot 预算按 30s 计(#103:DSH Desktop 的 HostRpc 30s 超时),
70
+ * 启动期补跑全量备份会抢 CPU/IO 把宿主 boot 拖超时,进而被判失败强杀。
71
+ */
72
+ const AUTO_BOOT_GRACE_MS = 45_000;
73
+ /** 自动备份失败后同会话内的重试间隔(只重试一次,避免失败热循环烧 CPU)。 */
74
+ const AUTO_RETRY_MS = 30 * 60_000;
64
75
  /** GitHub 单个文件 100MB 上限,留 10MB 余量。 */
65
76
  const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
66
77
  /** 同步工作树目录名(位于备份目录下)。 */
@@ -77,6 +88,8 @@ const DOWNLOAD_PREFIX = '/backup-download';
77
88
  const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
78
89
  /** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
79
90
  const VAULT_DIR = 'vault';
91
+ /** vault 下存放「凭据旧布局存底」的子目录:跨升级的长效安全网,备份刷新时不得删除。 */
92
+ const PRESERVED_DIR = 'preserved';
80
93
 
81
94
  /**
82
95
  * 分类型备份的内容类型表(相对 ~/.dsh 根的 POSIX 路径)。
@@ -94,7 +107,6 @@ const BACKUP_TYPES = Object.freeze({
94
107
  settings: { label: '设置', paths: ['settings.yaml'] },
95
108
  profiles: { label: '插件 profiles', paths: ['profiles'] },
96
109
  });
97
- const BACKUP_TYPE_KEYS = Object.freeze(Object.keys(BACKUP_TYPES));
98
110
  /** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
99
111
  const TYPED_PREFIX = 'dsh-t-';
100
112
 
@@ -297,17 +309,36 @@ const DEFAULTS = Object.freeze({
297
309
  /**
298
310
  * 解析 settings 命名空间的 schema,同时定义默认值。
299
311
  * Base 层:pluginConfig(cordis.patch.yml,插件级或 profile 级)。
300
- * User 层:Settings 面板运行时编辑,持久化到 settings.yaml。
312
+ * User 层:Settings 面板运行时编辑(0.1.6 及更早持久化到 settings.yaml;
313
+ * 0.1.7 起由宿主写入 profile 覆盖层)。
314
+ *
315
+ * **两代模型共用这一份**:
316
+ * - ≤0.1.6-alpha.1:`ctx.settings.register(NS, SCHEMA)` 自注册命名空间;
317
+ * - ≥0.1.7(含 0.2.0):`register` 被移除,宿主改从「条目的 Config schema」
318
+ * 派生表单,且只认**标了 volatile 的字段**(宿主 dsh-settings 的
319
+ * `volatileForm` 仅当 `meta.volatile` 存在才纳入表单)。故此处导出 Config
320
+ * 并逐字段标注 volatile。
321
+ *
322
+ * `volatileField` 做运行时探测:老载体(@deepseek-ai/schemastery < 3.18.4)
323
+ * 没有 `.volatile()`,探测失败就原样返回——老宿主走 register 路径,不需要它。
324
+ * 这样同一份代码在 0.1.1 → 0.2.0 全列车都能装配,不会因新 API 缺失而崩在装载期。
301
325
  */
326
+ const volatileField = (schema) => (typeof schema.volatile === 'function' ? schema.volatile() : schema);
327
+
302
328
  const SCHEMA = z.object({
303
- destination: z.string().default(DEFAULTS.destination),
304
- keep: z.number().min(0).default(0),
305
- exclude: z.array(z.string()).default(DEFAULTS.exclude),
306
- redact: z.union([z.array(z.string()), 'off']).default(DEFAULTS.redact),
307
- githubRepo: z.string().default(DEFAULTS.githubRepo),
308
- updateCheck: z.boolean().default(DEFAULTS.updateCheck),
329
+ destination: volatileField(z.string().default(DEFAULTS.destination)),
330
+ keep: volatileField(z.number().min(0).default(0)),
331
+ exclude: volatileField(z.array(z.string()).default(DEFAULTS.exclude)),
332
+ // 'none' 是 v0.6 时代写法(代码里映射为 'off'):schema 收下它,别让老配置
333
+ // 在新宿主的条目校验里被拒。
334
+ redact: volatileField(z.union([z.array(z.string()), 'off', 'none']).default(DEFAULTS.redact)),
335
+ githubRepo: volatileField(z.string().default(DEFAULTS.githubRepo)),
336
+ updateCheck: volatileField(z.boolean().default(DEFAULTS.updateCheck)),
309
337
  });
310
338
 
339
+ /** 宿主装载条目时读取的 Config schema(0.1.7+ 的设置表单来源)。 */
340
+ export const Config = SCHEMA;
341
+
311
342
  const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck'];
312
343
 
313
344
  /** 校验并归一化单个字段到 `out`;非法值忽略。 */
@@ -488,7 +519,7 @@ class BackupPanelService extends TypertRemoteService {
488
519
  return this.ops.doctorRepair(selector, signal);
489
520
  }
490
521
 
491
- /** 迁移预检:只读扫描全部会话日志(含 v1/v2/v3 各代),预测升级后哪些会话打不开。 */
522
+ /** 迁移预检:只读扫描全部会话日志(v0 起各代,按文件名代判定),预测升级后哪些会话打不开。 */
492
523
  migrateCheck(signal) {
493
524
  return this.ops.migrateCheck(signal);
494
525
  }
@@ -515,7 +546,13 @@ export function apply(ctx, pluginConfig) {
515
546
  let settingsService;
516
547
  ctx.inject(['settings'], (sctx) => {
517
548
  settingsService = sctx.settings;
518
- settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
549
+ // 0.1.7 起宿主移除了 settings.register(新模型改由「profile 条目自带的
550
+ // Config schema」派生表单);旧宿主(≤0.1.6-alpha.1)仍走自注册命名空间。
551
+ // 两级都留:有 register 就注册,没有就跳过——请求侧按「命名空间缺失」显式
552
+ // 降级,而不是在装配期抛错(旧实现在新宿主上是静默 TypeError)。
553
+ if (typeof settingsService.register === 'function') {
554
+ settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
555
+ }
519
556
  // detach 清理:服务中途 dispose 时置空,resolvedConfig() 自动降级到 pluginConfig
520
557
  return () => { settingsService = undefined; };
521
558
  });
@@ -574,10 +611,16 @@ export function apply(ctx, pluginConfig) {
574
611
 
575
612
  // ---------- 工具函数 ----------
576
613
  /**
577
- * 读取 subprocess 收集的输出流。dsh-subprocess 的收集契约:输出超过
578
- * maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
579
- * 完整输出 spill 到 spillPath——restore 的 `tar -tvzf` 校验清单必须看
580
- * 全量,否则大归档的校验可被清单前段的恶意条目绕过(8KB 尾窗看不到)。
614
+ * 读取 subprocess 收集的输出流(诊断/小输出用)。dsh-subprocess 的收集契约:
615
+ * 输出超过 maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
616
+ * 完整输出 spill 到 spillPath。
617
+ *
618
+ * ⚠️ **不要用它读体积不可控、且结果参与安全判定的输出**(#119):spill 自身还有
619
+ * 上限,宿主对超过上限的流会**丢弃 spill 文件**(`discardSpill()`),此时
620
+ * `readFrom` 只给内存尾窗、`lossy: true` 且**没有 spillPath**——旧实现把这段
621
+ * 截断文本原样返回,`tar -tvzf` 校验清单因此把"断在文件名中间的碎片"当成不安全
622
+ * 条目,整包被拒绝恢复(备份成功、verify 报完整、面板正常列出,只在恢复时炸)。
623
+ * 这类输出请走 {@link streamListingLines}(原始流逐行消费,无上限)。
581
624
  */
582
625
  async function collectOutput(col) {
583
626
  const read = col?.readFrom(0);
@@ -592,6 +635,65 @@ export function apply(ctx, pluginConfig) {
592
635
  return read.text ?? '';
593
636
  }
594
637
 
638
+ /**
639
+ * 逐行消费一条命令的 stdout(`stdio: 'pipe'` 原始流形态),把每一行交给
640
+ * `onLine`;`onLine` 返回 false 时立即终止子进程并提前返回("发现违规就不必读完")。
641
+ *
642
+ * 为什么不用有界收集(#119):见 {@link collectOutput} 的 ⚠️。这里内存只保留
643
+ * "尚未收尾的一行",清单多大都不截断,也不会把碎片当成一行;stderr 仍走有界
644
+ * 收集(只作诊断,几 KB 足够)。
645
+ */
646
+ async function streamListingLines(argv, cwd, signal, onLine) {
647
+ const proc = ctx.subprocess.spawn({
648
+ argv,
649
+ cwd,
650
+ graceMs: 5000,
651
+ signal,
652
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } } },
653
+ });
654
+ const stream = proc.stdout;
655
+ if (!stream) throw new Error('宿主未提供原始输出流(subprocess stdout:"pipe" 不可用)——请升级宿主后重试');
656
+ // 单行长度的护栏:tar -tv 的一行正常不过几百字节;给足余量后仍设上限,
657
+ // 避免被构造出的超长名字把 carry 撑成内存炸弹。
658
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
659
+ const decoder = new StringDecoder('utf8');
660
+ let bytes = 0;
661
+ let lines = 0;
662
+ let carry = '';
663
+ let stopped = false;
664
+ for await (const chunk of stream) {
665
+ bytes += chunk.length;
666
+ carry += decoder.write(chunk);
667
+ let idx;
668
+ while ((idx = carry.indexOf('\n')) >= 0) {
669
+ const line = carry.slice(0, idx);
670
+ carry = carry.slice(idx + 1);
671
+ lines += 1;
672
+ if (onLine(line) === false) { stopped = true; break; }
673
+ }
674
+ if (stopped) break;
675
+ if (carry.length > MAX_LINE_BYTES) throw new Error(`输出单行超过 ${MAX_LINE_BYTES} 字节上限——拒绝继续解析`);
676
+ }
677
+ if (stopped) {
678
+ proc.terminate();
679
+ } else {
680
+ carry += decoder.end();
681
+ if (carry.length) { lines += 1; onLine(carry); }
682
+ }
683
+ let err = '';
684
+ try {
685
+ err = await collectOutput(proc.collected?.stderr);
686
+ } catch { /* 诊断文本尽力而为 */ }
687
+ const outcome = await proc.done;
688
+ // 取消优先于一切结果分类(与 spawnRun 同款取舍)
689
+ if (signal?.aborted) throw new Error('操作已取消');
690
+ if (!stopped) {
691
+ if (outcome.signal != null) throw new Error(`命令被终止(signal=${outcome.signal})`);
692
+ if (outcome.exitCode !== 0) throw new Error(`命令失败 exit=${outcome.exitCode}: ${err}`);
693
+ }
694
+ return { bytes, lines, stopped };
695
+ }
696
+
595
697
  async function spawnRun(argv, cwd, signal) {
596
698
  const proc = ctx.subprocess.spawn({
597
699
  argv,
@@ -678,6 +780,9 @@ export function apply(ctx, pluginConfig) {
678
780
  // ('D:' + '/' + name 仍是合法路径,renameBeside 等不受影响)。
679
781
  const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
680
782
 
783
+ /** 仓库地址:CLI 首次备份的 star 提示用(面板侧另有常驻入口,见 src/client.js)。 */
784
+ const STAR_URL = 'https://github.com/xiaoyuyu6420/dsh-backup';
785
+
681
786
  /** semver 比较(常见形态,含 prerelease):a > b 才返回 true;解析失败保守降为 false。 */
682
787
  function semverGt(a, b) {
683
788
  const parse = (s) => {
@@ -723,17 +828,33 @@ export function apply(ctx, pluginConfig) {
723
828
  }
724
829
  }
725
830
 
831
+ /** 读回 profile 内实际解析到的本插件版本(pnpm 把直接依赖链接在 profile 的
832
+ * node_modules 下);路径异常或读不到时返回 null,调用方按「无法核对」处理。 */
833
+ async function installedVersion(profile) {
834
+ try {
835
+ const meta = JSON.parse(await readFile(join(paths().dshHome, 'profiles', profile, 'node_modules', SELF_META.name, 'package.json'), 'utf8'));
836
+ return typeof meta?.version === 'string' && meta.version ? meta.version : null;
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
841
+
726
842
  /**
727
843
  * 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
728
- * --profile <p> update <pkg>`(pnpm 语义——profile 是 pnpm 项目,插件绝不
729
- * 自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主在启动
730
- * 时结算 Loader 树,Node 半不可在运行中热替换。
844
+ * --profile <p> update --latest <pkg>`(pnpm 语义——profile 是 pnpm 项目,
845
+ * 插件绝不自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主
846
+ * 在启动时结算 Loader 树,Node 半不可在运行中热替换。
847
+ *
848
+ * `--latest` 不可省:pnpm update <pkg> 只在 profile 声明的版本范围内升级
849
+ * (安装时写入的是 ^x.y.z),跨 minor 会停在旧 minor 上——而升级按钮承诺的
850
+ * 目标通常正是新 minor,少了它就是「点了更新、版本没动」。命令跑完再读回
851
+ * profile 内的真实版本核对,不拿 registry 的 latest 冒充结果。
731
852
  */
732
853
  async function runUpdate(signal, profileArg) {
733
854
  const profile = profileArg || 'web';
734
855
  const chk = await checkUpdate(signal);
735
856
  if (chk.error) {
736
- return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}` };
857
+ return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}` };
737
858
  }
738
859
  if (!chk.update) {
739
860
  return { ok: true, updated: false, current: chk.current, latest: chk.latest, summary: `已是最新版本 ${chk.current}` };
@@ -749,12 +870,12 @@ export function apply(ctx, pluginConfig) {
749
870
  // detectHostTrain 同款定位——`node <入口> plugin ...` 与运行中的宿主同源)
750
871
  let argv = null;
751
872
  try {
752
- argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', SELF_META.name];
873
+ argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
753
874
  } catch {
754
875
  try {
755
- argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', SELF_META.name];
876
+ argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
756
877
  } catch {
757
- return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
878
+ return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
758
879
  }
759
880
  }
760
881
  try {
@@ -763,12 +884,26 @@ export function apply(ctx, pluginConfig) {
763
884
  } catch (err) {
764
885
  return { ok: false, updated: false, summary: `更新失败:${friendlyReason(err)}${snap ? `\n已有升级前快照 ${snap},可 /backup restore ${snap} 回滚。` : ''}` };
765
886
  }
887
+ // 命令跑完 ≠ 版本已变:profile 里锁着精确版本、pnpm 配置忽略 --latest 等情况
888
+ // 都会让结果停在旧版,读回真实版本再回执(否则用户重启后仍是旧版,无从解释)。
889
+ const after = await installedVersion(profile);
890
+ if (after && after !== chk.latest) {
891
+ return {
892
+ ok: false,
893
+ updated: false,
894
+ current: after,
895
+ latest: chk.latest,
896
+ summary: `更新命令已执行,但 profile 内解析到的仍是 ${after}(目标 ${chk.latest})。`
897
+ + `\n请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}`
898
+ + (snap ? `\n更新前已留升级前快照 ${snap}。` : ''),
899
+ };
900
+ }
766
901
  return {
767
902
  ok: true,
768
903
  updated: true,
769
- current: chk.latest,
904
+ current: after || chk.latest,
770
905
  latest: chk.latest,
771
- summary: `已更新到 ${chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
906
+ summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
772
907
  };
773
908
  }
774
909
 
@@ -816,6 +951,18 @@ export function apply(ctx, pluginConfig) {
816
951
  return out;
817
952
  }
818
953
 
954
+ /**
955
+ * vault 串行锁:同一进程内「备份刷新镜像」与「凭据存底」(启动钩子 / migrate
956
+ * 预检)都写同一个 vault 目录。旧实现是"整目录 rm -rf 再重写",两路并发时
957
+ * 互相踩——Linux 上实测 ENOTEMPTY 直接让整个备份失败(CI 稳定复现)。
958
+ */
959
+ let vaultChain = Promise.resolve();
960
+ function withVaultLock(task) {
961
+ const next = vaultChain.then(task, task);
962
+ vaultChain = next.then(() => {}, () => {});
963
+ return next;
964
+ }
965
+
819
966
  /**
820
967
  * 刷新本机 vault:把 ~/.dsh 中现存的敏感文件按相对路径镜像到
821
968
  * `<备份目录>/vault/`(先清空旧镜像,vault 永远对应最新一次备份)。
@@ -827,29 +974,41 @@ export function apply(ctx, pluginConfig) {
827
974
  const vaultDir = `${root}/${VAULT_DIR}`;
828
975
  if (!rels.length) return [];
829
976
  if (signal?.aborted) throw new Error('操作已取消');
830
- await rm(vaultDir, { recursive: true, force: true });
831
- const stored = [];
832
- for (const rel of rels) {
833
- const src = `${dshHome}/${rel}`;
834
- const ok = await fsStat(src).then(() => true, (err) => {
835
- if (err && err.code === 'ENOENT') return false;
836
- throw err;
837
- });
838
- if (!ok) continue;
839
- const dst = `${vaultDir}/${rel}`;
840
- await mkdir(dirname(dst), { recursive: true });
841
- await copyFile(src, dst);
842
- stored.push(rel);
843
- }
844
- if (stored.length && !IS_WIN) {
845
- const chmod = await ctx.subprocess.resolveExecutable('chmod');
977
+ return withVaultLock(async () => {
978
+ // 只清 vault 里的**镜像条目**:`preserved/`(凭据存底)绝不能被下一次备份
979
+ // 抹掉——它正是为"升级后想回退"留的后路。镜像文件随后会被 copyFile 覆盖成
980
+ // 最新内容,所以清理失败(另一实例正在写)一律容忍,不值得让备份失败。
981
+ let names = [];
846
982
  try {
847
- await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
848
- } catch {
849
- // chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
983
+ names = await readdir(vaultDir);
984
+ } catch { /* vault 尚不存在:下面逐文件 mkdir 会建 */ }
985
+ for (const n of names) {
986
+ if (n === PRESERVED_DIR) continue;
987
+ await rm(`${vaultDir}/${n}`, { recursive: true, force: true }).catch(() => {});
850
988
  }
851
- }
852
- return stored;
989
+ const stored = [];
990
+ for (const rel of rels) {
991
+ const src = `${dshHome}/${rel}`;
992
+ const ok = await fsStat(src).then(() => true, (err) => {
993
+ if (err && err.code === 'ENOENT') return false;
994
+ throw err;
995
+ });
996
+ if (!ok) continue;
997
+ const dst = `${vaultDir}/${rel}`;
998
+ await mkdir(dirname(dst), { recursive: true });
999
+ await copyFile(src, dst);
1000
+ stored.push(rel);
1001
+ }
1002
+ if (stored.length && !IS_WIN) {
1003
+ const chmod = await ctx.subprocess.resolveExecutable('chmod');
1004
+ try {
1005
+ await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
1006
+ } catch {
1007
+ // chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
1008
+ }
1009
+ }
1010
+ return stored;
1011
+ });
853
1012
  }
854
1013
 
855
1014
  function stampNow() {
@@ -997,7 +1156,11 @@ export function apply(ctx, pluginConfig) {
997
1156
  }
998
1157
 
999
1158
  // 校验和:POSIX 优先 sha256sum(macOS 回退 shasum)流式计算;
1000
- // 都不可用时(Windows)回退 node:fs 读取 + node:crypto 内存哈希。
1159
+ // 都不可用时(Windows 典型)回退 node:crypto 流式哈希。
1160
+ // #103:旧实现是 readFile 整包进内存 + 同步 update,且 >256MB 直接抛错——
1161
+ // Windows 上 3GB 级归档因此**必然失败**,备份写完 tar 却永远收不了尾,
1162
+ // 锚点不前进 → 每次启动重跑一次全量 tar → 宿主 boot 窗口被拖死。
1163
+ // 改为 createReadStream 分块喂哈希:不整包入内存、每块之间让出事件循环、无体积上限。
1001
1164
  async function sha256File(absPath, home, signal) {
1002
1165
  for (const candidate of [['sha256sum', []], ['shasum', ['-a', '256']]]) {
1003
1166
  try {
@@ -1010,12 +1173,18 @@ export function apply(ctx, pluginConfig) {
1010
1173
  // 尝试下一个
1011
1174
  }
1012
1175
  }
1013
- const info = await fsStat(absPath);
1014
- if (info.size > HASH_MAX_BYTES) {
1015
- throw new Error(`计算 sha256 失败:文件 ${Math.floor(info.size / 1048576)}MB 超过 ${Math.floor(HASH_MAX_BYTES / 1048576)}MB 回退上限`);
1176
+ const hash = createHash('sha256');
1177
+ const stream = createReadStream(absPath, { highWaterMark: HASH_CHUNK_BYTES });
1178
+ try {
1179
+ for await (const chunk of stream) {
1180
+ if (signal?.aborted) throw new Error('操作已取消');
1181
+ hash.update(chunk);
1182
+ }
1183
+ } catch (err) {
1184
+ stream.destroy();
1185
+ throw err;
1016
1186
  }
1017
- const bytes = await readFile(absPath);
1018
- return createHash('sha256').update(bytes).digest('hex');
1187
+ return hash.digest('hex');
1019
1188
  }
1020
1189
 
1021
1190
  async function existsStat(p) {
@@ -1638,7 +1807,29 @@ export function apply(ctx, pluginConfig) {
1638
1807
  }
1639
1808
  }
1640
1809
  if (nameIdx < 0 || nameIdx >= f.length) return null;
1641
- return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
1810
+ const rest = f.slice(nameIdx).join(' ');
1811
+ // tar -tv 的链接形态:硬链接 `<名> link to <目标>`、符号链接 `<名> -> <目标>`。
1812
+ // 必须把目标摘出来单独校验:旧实现把它并进 name,于是硬链接条目的"名字"
1813
+ // 天然含空格与目标路径,必然被判非法而整体拒绝(#112),错误信息里也把
1814
+ // 目标一起印出来。
1815
+ let name = rest;
1816
+ let link = null;
1817
+ const hardAt = rest.indexOf(' link to ');
1818
+ if (hardAt >= 0) {
1819
+ name = rest.slice(0, hardAt);
1820
+ link = rest.slice(hardAt + ' link to '.length);
1821
+ } else {
1822
+ const symAt = rest.lastIndexOf(' -> ');
1823
+ if (symAt >= 0) {
1824
+ name = rest.slice(0, symAt);
1825
+ link = rest.slice(symAt + 4);
1826
+ }
1827
+ }
1828
+ return {
1829
+ type: f[0][0],
1830
+ name: name.replace(/\/$/, ''),
1831
+ link: link === null ? null : link.replace(/\/$/, ''),
1832
+ };
1642
1833
  }
1643
1834
 
1644
1835
  /**
@@ -1699,33 +1890,40 @@ export function apply(ctx, pluginConfig) {
1699
1890
  const { root, dshHome } = paths();
1700
1891
  const base = dshHome.split('/').pop();
1701
1892
  const tar = await ctx.subprocess.resolveExecutable('tar');
1702
- const listed = await spawnRun([tar, '-tvzf', pickedName], root, signal);
1703
- // tar 路径穿越防护:逐行解析 -tvzf 输出(双布局,见 parseTarEntry),
1704
- // 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
1705
- // 即整体拒绝,恢复绝不触碰备份根之外的文件。
1893
+ // 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
1894
+ // 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
1706
1895
  const entries = [];
1707
1896
  const bad = [];
1708
- for (const line of listed.out.split('\n')) {
1897
+ // 路径检查(反斜杠归一化后再判):`.dsh/..\..\escape.txt` 与 `/` 分隔的
1898
+ // `..` 段同样拒绝(Windows 风格路径变体)。
1899
+ const insideBase = (p) => Boolean(p)
1900
+ && !p.startsWith('/') && !/^[A-Za-z]:/.test(p)
1901
+ && !p.split('/').includes('..')
1902
+ && (p === base || p.startsWith(`${base}/`));
1903
+ const onLine = (line) => {
1709
1904
  const s = line.trim();
1710
- if (!s) continue;
1905
+ if (!s) return true;
1711
1906
  const parsed = parseTarEntry(s);
1712
- // 反斜杠归一化后再做全部路径检查:`.dsh/..\..\escape.txt` 与
1713
- // `/` 分隔的 `..` 段同样拒绝(Windows 风格路径变体)
1714
1907
  const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
1715
- if (
1716
- !parsed || !name
1717
- || (parsed.type !== '-' && parsed.type !== 'd')
1718
- || name.startsWith('/') || /^[A-Za-z]:/.test(name)
1719
- || name.split('/').includes('..')
1720
- || (name !== base && !name.startsWith(`${base}/`))
1721
- ) {
1908
+ const link = parsed && parsed.link ? parsed.link.replace(/\\/g, '/').replace(/\/$/, '') : null;
1909
+ // 允许的类型:普通文件 '-'、目录 'd',以及**目标也在备份根内**的硬链接 'h'。
1910
+ // 硬链接是 DSH 附件库(attachments/v1:files/ 与 file-objects/ 同一 inode)的
1911
+ // 正常形态,tar 会把第二个路径写成 'h' 条目——旧实现只放行 -/d,于是**带附件的
1912
+ // 归档一律被判"不安全条目"整体拒绝**,完整恢复/dry-run/分类型合并/doctor 修复/
1913
+ // 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
1914
+ // 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
1915
+ const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
1916
+ const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
1917
+ if (!plainOk && !hardOk) {
1722
1918
  bad.push(parsed ? name : s);
1723
1919
  } else {
1724
1920
  entries.push(name);
1725
1921
  }
1726
- }
1922
+ return bad.length === 0; // 发现违规立即停读并终止 tar,不必读完
1923
+ };
1924
+ const listed = await streamListingLines([tar, '-tvzf', pickedName], root, signal, onLine);
1727
1925
  if (bad.length) {
1728
- throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
1926
+ throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}(已读清单 ${listed.lines} 行 / ${listed.bytes} 字节),恢复已中止`);
1729
1927
  }
1730
1928
  return entries;
1731
1929
  }
@@ -1843,17 +2041,23 @@ export function apply(ctx, pluginConfig) {
1843
2041
  if (mergedProfiles.length) {
1844
2042
  try {
1845
2043
  const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
2044
+ // 单个 profile 装失败记入 failed 并继续(与全量恢复路径同语义);
2045
+ // pnpm 本身缺失才落到外层 note。
1846
2046
  for (const p of mergedProfiles) {
1847
2047
  try {
1848
2048
  await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
1849
2049
  deps.installed.push(p);
1850
2050
  } catch {
1851
- await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
1852
- deps.installed.push(p);
2051
+ try {
2052
+ await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
2053
+ deps.installed.push(p);
2054
+ } catch {
2055
+ deps.failed.push(p);
2056
+ }
1853
2057
  }
1854
2058
  }
1855
2059
  } catch {
1856
- deps.note = 'pnpm 不可用,跳过依赖重装(配置已恢复)';
2060
+ deps.note = '未找到 pnpm,请在各 profile 目录手动执行 pnpm install';
1857
2061
  }
1858
2062
  }
1859
2063
  }
@@ -2049,7 +2253,17 @@ export function apply(ctx, pluginConfig) {
2049
2253
  // (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
2050
2254
  // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
2051
2255
  const ZSTD_MAGIC = 0xfd2fb528;
2052
- const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
2256
+ // 会话日志文件名 = 宿主 sessionFormatLogFilename 的形态:session[.v<N>].jsonl[.zstd]
2257
+ // (N≥1 可多位;无 .vN 后缀即第 0 代)。**代数是文件名的一部分、会随格式换代前进**,
2258
+ // 写死具体名字会在换代当天整体失明(#117:v4 机器上 doctor/migrate 一份都看不见),
2259
+ // 故按形态匹配,让新代文件至少进入校验/预检流程(代的判定另见迁移预检)。
2260
+ const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
2261
+ const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
2262
+ /** 从会话日志文件名取格式代;非会话日志名返回 null(无 .vN 后缀即 v0)。 */
2263
+ function sessionLogVersion(name) {
2264
+ const m = SESSION_LOG_RE.exec(name);
2265
+ return m ? Number(m[1] ?? 0) : null;
2266
+ }
2053
2267
  const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
2054
2268
  const SCAN_MAX_FILES = 4000;
2055
2269
  const SCAN_MAX_DEPTH = 8;
@@ -2194,7 +2408,7 @@ export function apply(ctx, pluginConfig) {
2194
2408
  }
2195
2409
 
2196
2410
  /** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
2197
- async function collectSessionLogs(dir, relPrefix, depth, out, names = SESSION_LOG_NAMES) {
2411
+ async function collectSessionLogs(dir, relPrefix, depth, out, isLogName = isSessionLogName) {
2198
2412
  if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
2199
2413
  let dirents;
2200
2414
  try {
@@ -2207,8 +2421,8 @@ export function apply(ctx, pluginConfig) {
2207
2421
  const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
2208
2422
  if (d.isDirectory()) {
2209
2423
  if (SCAN_SKIP_DIRS.has(d.name)) continue;
2210
- await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, names);
2211
- } else if (names.has(d.name)) {
2424
+ await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, isLogName);
2425
+ } else if (isLogName(d.name)) {
2212
2426
  out.push({ abs: `${dir}/${d.name}`, rel });
2213
2427
  }
2214
2428
  }
@@ -2292,16 +2506,13 @@ export function apply(ctx, pluginConfig) {
2292
2506
  // (0.1.5-rc.2 校准,与 0.11.2 对齐 isHeaderLine 同一套路),覆盖"解析即判"
2293
2507
  // 的规则;需要重放迁移过程的规则(chunk 折叠溯源、引用重映射、worker 复核)
2294
2508
  // 不在覆盖范围,结果里如实标注。三层判定:fail=会话将打不开;migratable=
2295
- // 旧代但静态规则全过(首次写开会发布 v3 新代);ok=已是当前代。
2296
- const MIGRATE_CURRENT_VERSION = 3;
2297
- /** 物理代文件名 ↔ 格式代(宿主 sessionFormatLogFilename;文件名与 header 不一致宿主拒载)。 */
2298
- const MIGRATE_LOG_NAMES = [
2299
- { name: 'session.jsonl', version: 0 },
2300
- { name: 'session.v1.jsonl', version: 1 },
2301
- { name: 'session.v2.jsonl', version: 2 },
2302
- { name: 'session.v3.jsonl', version: 3 },
2303
- ];
2304
- const MIGRATE_LOG_SET = new Set(MIGRATE_LOG_NAMES.flatMap((g) => [g.name, `${g.name}.zstd`]));
2509
+ // 旧代但静态规则全过(首次写开会发布当前代新文件);ok=已是当前代。
2510
+ //
2511
+ // **校准代(#117)**:宿主会随格式换代推进文件名与 header 的 version(0.1.7 起
2512
+ // 为 v4——迁移包只到 `v3-to-v4`、无 `v4-to-v5`)。此常数是本插件**规则**覆盖到的
2513
+ // 最高代,换代时须同步更新;比它更新的日志只提示、不判"打不开"(见
2514
+ // newer-generation)——"宿主读不读得了"取决于宿主版本,不是我们这张注定滞后的表。
2515
+ const MIGRATE_CALIBRATED_VERSION = 4;
2305
2516
  // 冻结事件清单(RELEASED_V0_EVENT_TYPES / RELEASED_V2_EVENT_TYPES,宿主
2306
2517
  // 按契约不增长旧代清单);migration 对清单外类型连 ignorable 也拒(#6355)。
2307
2518
  const MIGRATE_V0_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
@@ -2309,14 +2520,26 @@ export function apply(ctx, pluginConfig) {
2309
2520
  /** v0 侧退役类型(pre-react-loop 预发布形态;宿主点名拒绝)。 */
2310
2521
  const MIGRATE_V0_RETIRED_TYPES = new Set(['request/header-delta', 'mode/set']);
2311
2522
  /** 消息来源 kind 白名单(v3 侧 15 类;未知 kind 在 v2→v3 边被拒——#6355 主症状)。 */
2312
- const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message']);
2523
+ /**
2524
+ * 「消息来源 kind」的**已知**集合(#113)。注意语义:宿主的 `source.kind` 是
2525
+ * **可合并扩展的联合类型**——每个生产者在自己的模块里声明自己的 kind,没有
2526
+ * 统一的兜底值,所以任何硬编码清单都**不可能完整**。这里的集合因此只当"已知
2527
+ * 名单"用:命中的静默通过,没命中的记一条**提示**(不再判为"打不开",见
2528
+ * 下方 unknown-source-kind)——真正会拒载的是旧代**事件类型**的冻结清单。
2529
+ *
2530
+ * 内容来自宿主 `@deepseek-ai/dsh-session-format-v3-to-v4` 自带的
2531
+ * `RENAMED_PRODUCERS` 取值与 `RELEASED_SAME_NAME_PRODUCERS` 名单
2532
+ * (2026-09-29 从 0.2.0-rc.1 实装包逐行核对),外加 `producerKind()` 的兜底
2533
+ * 形态 `plugin:<插件名>`(用前缀匹配,见 MIGRATE_KIND_KNOWN)。
2534
+ */
2535
+ const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'runtime-context', 'system-prompt', 'model-selection', 'plan-mode', 'time-context', 'tmux-context', 'user-approval', 'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal', 'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule', 'dsh-session-title-llm', 'fallback', 'provider']);
2313
2536
  const MIGRATE_MAX_FINDINGS = 3;
2537
+ /** kind 是否在已知名单内:精确命中,或宿主兜底形态 `plugin:<插件名>`(可任意长尾,故用前缀判定)。 */
2538
+ const MIGRATE_KIND_KNOWN = (kind) => MIGRATE_SOURCE_KINDS.has(kind) || kind.startsWith('plugin:');
2314
2539
 
2315
- /** 从物理文件名推格式代;非代文件名(未来扩展)返回 null。 */
2540
+ /** 非代文件名(未来扩展)返回 null;代的判定与 doctor 共用 SESSION_LOG_RE。 */
2316
2541
  function migrateFilenameVersion(fileName) {
2317
- const base = fileName.replace(/\.zstd$/, '');
2318
- const g = MIGRATE_LOG_NAMES.find((x) => x.name === base);
2319
- return g ? g.version : null;
2542
+ return sessionLogVersion(fileName);
2320
2543
  }
2321
2544
 
2322
2545
  /**
@@ -2334,18 +2557,26 @@ export function apply(ctx, pluginConfig) {
2334
2557
  if (!header || typeof header !== 'object' || typeof header.version !== 'number') return null;
2335
2558
  const sourceVersion = header.version;
2336
2559
  const findings = [];
2560
+ const warnings = [];
2337
2561
  let total = 0;
2338
2562
  const fail = (rule, detail) => {
2339
2563
  total += 1;
2340
2564
  if (findings.length < MIGRATE_MAX_FINDINGS) findings.push({ rule, detail });
2341
2565
  };
2342
- if (sourceVersion > MIGRATE_CURRENT_VERSION) {
2343
- fail('newer-than-host', `header version=${sourceVersion} 高于本宿主可读的 v${MIGRATE_CURRENT_VERSION}——日志由更新版本的宿主写出,读/写都会被拒(提示语会要求升级宿主)`);
2344
- return { sourceVersion, findings, total };
2566
+ // 提示(不判"打不开"):用于**开放集合**上的命中失败——最典型的是
2567
+ // source.kind(可合并扩展的联合类型,硬编码清单天生补不全,#113)。
2568
+ const warn = (rule, detail) => {
2569
+ if (warnings.length < MIGRATE_MAX_FINDINGS) warnings.push({ rule, detail });
2570
+ };
2571
+ if (sourceVersion > MIGRATE_CALIBRATED_VERSION) {
2572
+ // 更新的格式代:本插件的规则表对它是盲区,但"能不能读"由宿主决定(同代/更新可读、
2573
+ // 降级运行被拒)——按开放集合纪律只提示、不替宿主下"打不开"的判决(#117/#113)。
2574
+ warn('newer-generation', `header version=${sourceVersion} 高于本插件校准到的 v${MIGRATE_CALIBRATED_VERSION}——日志由更新的格式代写出。能否打开取决于宿主:宿主同代或更新即可读,**降级运行会被拒**;若你的宿主已是这一代,说明本插件校准滞后,欢迎反馈。仅提示,不判为打不开`);
2575
+ return { sourceVersion, findings, warnings, total };
2345
2576
  }
2346
2577
  if (fileVersion !== null && fileVersion !== sourceVersion) {
2347
2578
  fail('name-version-mismatch', `文件名标 v${fileVersion},header 标 v${sourceVersion}——宿主按不一致直接拒载`);
2348
- return { sourceVersion, findings, total };
2579
+ return { sourceVersion, findings, warnings, total };
2349
2580
  }
2350
2581
  // 事件行静态规则。seq 游标语义与 validateSessionLines 一致:v0 有 packed 行
2351
2582
  //(一条行多个 chunk),v1 起一行一事件。
@@ -2383,14 +2614,22 @@ export function apply(ctx, pluginConfig) {
2383
2614
  if (sourceVersion === 2 && (type === 'tool/code-dispatch' || type === 'tool/code-dispatch-start')) fail('ptc-reserved-v2', `第 ${i + 1} 行 ${type} 是 v3 保留 PTC 标签,v2→v3 拒绝(ignorable 也拒)`);
2384
2615
  if ((sourceVersion === 2 || sourceVersion === 3) && type === 'request/header' && rec.data?.header?.system !== undefined) fail('retired-header-system', `第 ${i + 1} 行 request/header 携带退役的 header.system——v2→v3 无法保留时序地提升它${sourceVersion === 3 ? ',当前代读取同样拒绝' : ''}`);
2385
2616
  const kind = rec?.data?.source?.kind;
2386
- if (typeof kind === 'string' && !MIGRATE_SOURCE_KINDS.has(kind)) fail('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在白名单——v2→v3 边拒绝(社区插件自定义 kind,#6355)`);
2617
+ // kind 只作提示,不作判据(#113):宿主把它设计成可合并扩展的联合类型,
2618
+ // 每个生产者(含任意社区插件)都能加自己的 kind,且生产端对未知 kind 照读
2619
+ // ——旧实现把"不在白名单"直接判成"打不开",在一台真实机器上把 81/82 份
2620
+ // 完全健康的日志报成不可打开。
2621
+ if (typeof kind === 'string' && !MIGRATE_KIND_KNOWN(kind)) {
2622
+ warn('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在已知名单(宿主为可合并扩展的联合类型,官方/社区插件都能新增)——仅提示,不判为打不开;真正会拒载的是旧代事件类型不在冻结清单内`);
2623
+ }
2387
2624
  }
2388
- return { sourceVersion, findings, total };
2625
+ return { sourceVersion, findings, warnings, total };
2389
2626
  }
2390
2627
 
2391
2628
  /**
2392
2629
  * 单文件迁移预检:解码(复用 doctor 的帧走查)→ 判代 → 静态规则。
2393
- * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, total, reason }。
2630
+ * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, warnings, total, reason }——
2631
+ * findings 是"判为打不开"的硬伤,warnings 是开放集合上的命中失败(如未知
2632
+ * source.kind),只提示不改变 verdict(#113)。
2394
2633
  */
2395
2634
  async function migrateAnalyzeFile(f) {
2396
2635
  const fileVersion = migrateFilenameVersion(f.abs.split('/').pop());
@@ -2405,8 +2644,8 @@ export function apply(ctx, pluginConfig) {
2405
2644
  if (!lines.length) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '解出的逻辑行为空' }], total: 1 };
2406
2645
  const r = migrateAnalyzeLines(lines, fileVersion);
2407
2646
  if (!r) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: 'header 行不可解析——按损坏处理(doctor 体检覆盖),迁移预检跳过' };
2408
- if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, total: r.total };
2409
- return { verdict: r.sourceVersion < MIGRATE_CURRENT_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], total: 0 };
2647
+ if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, warnings: r.warnings, total: r.total };
2648
+ return { verdict: r.sourceVersion < MIGRATE_CALIBRATED_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], warnings: r.warnings, total: 0 };
2410
2649
  } catch (err) {
2411
2650
  return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `解码失败(${String(err && err.message ? err.message : err)})——doctor 体检会按损坏处理` };
2412
2651
  }
@@ -2428,14 +2667,17 @@ export function apply(ctx, pluginConfig) {
2428
2667
  async function preserveFlatCredentials() {
2429
2668
  const { dshHome, root } = paths();
2430
2669
  if (!(await credentialsIsFlatLayout(dshHome))) return null;
2431
- const dir = `${root}/${VAULT_DIR}/preserved`;
2432
- await mkdir(dir, { recursive: true });
2433
- const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
2434
- await copyFile(`${dshHome}/.credentials.yaml`, dest);
2435
- // 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
2436
- const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
2437
- for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
2438
- return dest;
2670
+ // 与备份侧的 vault 刷新共用串行锁:并发写入同一 vault 目录互相踩(ENOTEMPTY)
2671
+ return withVaultLock(async () => {
2672
+ const dir = `${root}/${VAULT_DIR}/${PRESERVED_DIR}`;
2673
+ await mkdir(dir, { recursive: true });
2674
+ const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
2675
+ await copyFile(`${dshHome}/.credentials.yaml`, dest);
2676
+ // 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
2677
+ const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
2678
+ for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
2679
+ return dest;
2680
+ });
2439
2681
  }
2440
2682
 
2441
2683
  /** 环境级探测:硬链接支持(v3 发布用 fs.link,exFAT 等会 ENOTSUP)+ 凭据布局。 */
@@ -2469,7 +2711,7 @@ export function apply(ctx, pluginConfig) {
2469
2711
  const { dshHome, root } = paths();
2470
2712
  if (signal?.aborted) throw new Error('操作已取消');
2471
2713
  const found = [];
2472
- await collectSessionLogs(dshHome, '', 0, found, MIGRATE_LOG_SET);
2714
+ await collectSessionLogs(dshHome, '', 0, found);
2473
2715
  const sessions = [];
2474
2716
  for (const f of found) {
2475
2717
  if (signal?.aborted) throw new Error('操作已取消');
@@ -2479,8 +2721,9 @@ export function apply(ctx, pluginConfig) {
2479
2721
  const fails = sessions.filter((s) => s.verdict === 'fail');
2480
2722
  const migratable = sessions.filter((s) => s.verdict === 'migratable');
2481
2723
  const skipped = sessions.filter((s) => s.verdict === 'skipped');
2724
+ const warned = sessions.filter((s) => (s.warnings?.length ?? 0) > 0);
2482
2725
  const lines = [];
2483
- lines.push(`扫描 ${sessions.length} 个会话日志(含 v1/v2/v3 代):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定`);
2726
+ lines.push(`扫描 ${sessions.length} 个会话日志(v0 起各代,按文件名代判定):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定${warned.length ? `、${warned.length} 个带提示(不影响打开)` : ''}`);
2484
2727
  if (fails.length) {
2485
2728
  lines.push('');
2486
2729
  for (const s of fails.slice(0, 15)) {
@@ -2492,24 +2735,33 @@ export function apply(ctx, pluginConfig) {
2492
2735
  lines.push('');
2493
2736
  lines.push('升级前建议:先拍一份升级前快照(/backup),坏的会话可从更早归档定点修复(/backup doctor --repair)。');
2494
2737
  }
2738
+ if (warned.length) {
2739
+ lines.push('');
2740
+ lines.push(`ℹ️ ${warned.length} 个日志带提示(**不计入"打不开"**,逐条看原因):`);
2741
+ for (const s of warned.slice(0, 5)) {
2742
+ for (const w of s.warnings) lines.push(` · ${s.rel}:${w.detail}`);
2743
+ }
2744
+ if (warned.length > 5) lines.push(` …另有 ${warned.length - 5} 个文件`);
2745
+ }
2495
2746
  if (!env.hardlink) {
2496
2747
  lines.push('');
2497
- lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布 v3 新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2748
+ lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2498
2749
  }
2499
2750
  if (env.credentialsFlat) {
2500
2751
  lines.push('');
2501
2752
  lines.push(`⚠️ .credentials.yaml 还是旧扁平布局——宿主下次读档会把它原子替换成 version:1(不可逆,旧宿主将读不回)。${env.credentialsPreserved ? `已自动存底:${env.credentialsPreserved.replace(`${root}/`, '')}` : '自动存底失败(vault 不可写?),建议立即手动 /backup 一次。'}`);
2502
2753
  }
2503
2754
  lines.push('');
2504
- lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准)。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2755
+ lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准;格式代校准到 v${MIGRATE_CALIBRATED_VERSION})。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2505
2756
  const head = fails.length || !env.hardlink || env.credentialsFlat ? '⚠️' : '✅';
2506
2757
  return {
2507
2758
  scanned: sessions.length,
2508
- sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.reason ? { reason: s.reason } : {}) })),
2759
+ sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}), ...(s.reason ? { reason: s.reason } : {}) })),
2509
2760
  env,
2510
2761
  failCount: fails.length,
2511
2762
  migratableCount: migratable.length,
2512
2763
  skippedCount: skipped.length,
2764
+ warningCount: warned.length,
2513
2765
  summary: `${head} ${lines.join('\n')}`,
2514
2766
  };
2515
2767
  }
@@ -2598,6 +2850,30 @@ export function apply(ctx, pluginConfig) {
2598
2850
  // 上次见到的宿主列车(detectHostTrain 代理值):null=尚未记录(首启动只记
2599
2851
  // 录不拍快照),非 null 且变化 → 升/降级 → 自动拍 dsh-pre-upgrade- 快照。
2600
2852
  let lastTrain = null;
2853
+ // 增长:CLI 回执的 star 提示只出现一次(持久化在 auto.json;旧版读未知字段安全忽略)
2854
+ let starHinted = false;
2855
+ // #103 循环防护三件套:
2856
+ // - lastAutoAttemptAt:每次自动备份**起跑即记**(不等到成功)。写盘后即使进程
2857
+ // 被强杀(重活干到一半被杀正是本 issue 的形态),下次启动也知道"刚试过",
2858
+ // 不会以 delay≤0 立刻重跑;旧版读未知字段安全忽略。
2859
+ // - newestArchiveAt:备份目录里最新归档的 mtime,调度锚点的最终兜底——磁盘上
2860
+ // 躺着新归档就说明不必马上再打一份,避免锚点丢失导致的重跑。
2861
+ // - lastAutoError:上次失败原因(成功即清),让"备份一直在失败"这件事在
2862
+ // /backup 与面板上可见,而不是只躺在没人看的宿主日志里。
2863
+ let lastAutoAttemptAt = null;
2864
+ let newestArchiveAt = 0;
2865
+ let lastAutoError = null;
2866
+ // 连续失败次数(内存态):失败重试按 30min × 2^(n-1) 退避、封顶一个周期,
2867
+ // 避免确定性失败(磁盘满/权限)变成每 30 分钟烧一次 CPU 的热循环。
2868
+ let autoFailStreak = 0;
2869
+ // 无人值守的重活串行队列:开机补跑与列车快照可能同时到点,两份 GB 级 tar
2870
+ // 并行会把机器彻底压死。手动备份/恢复快照是用户在等结果的显式操作,不入队。
2871
+ let heavyChain = Promise.resolve();
2872
+ function enqueueHeavy(task) {
2873
+ const next = heavyChain.then(task, task);
2874
+ heavyChain = next.then(() => {}, () => {});
2875
+ return next;
2876
+ }
2601
2877
 
2602
2878
  /**
2603
2879
  * 等待 settings 用户层加载完成(describe 出现 revision)。启动钩子若在
@@ -2640,7 +2916,7 @@ export function apply(ctx, pluginConfig) {
2640
2916
  // (先到者 rename 走,后到者 ENOENT),UUID 跨实例唯一
2641
2917
  const tmp = `${target}.${randomUUID()}.tmp`;
2642
2918
  try {
2643
- await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain })}\n`);
2919
+ await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain, starHinted, lastAutoAttemptAt, lastAutoError })}\n`);
2644
2920
  await rename(tmp, target);
2645
2921
  } catch (err) {
2646
2922
  await rm(tmp, { force: true }).catch(() => {});
@@ -2648,6 +2924,32 @@ export function apply(ctx, pluginConfig) {
2648
2924
  }
2649
2925
  }
2650
2926
 
2927
+ /**
2928
+ * 磁盘上最新全量归档的 mtime(#103 兜底锚点):锚点丢失/不前进时,只要备份
2929
+ * 目录里躺着新归档就不该马上再打一份——这是"失败→重跑→再失败"死循环的
2930
+ * 第二道断路(第一道是 lastAutoAttemptAt)。口径与 listBackups 一致:
2931
+ * 只看用户可见的全量归档,不含内部快照与分类型归档。
2932
+ */
2933
+ async function refreshNewestArchiveAt() {
2934
+ const { root } = paths();
2935
+ let dirents;
2936
+ try {
2937
+ dirents = await readdir(root, { withFileTypes: true });
2938
+ } catch {
2939
+ return; // 备份目录还没有/不可读:锚点保持 0
2940
+ }
2941
+ let newest = 0;
2942
+ for (const d of dirents) {
2943
+ if (!d.name.startsWith('dsh-') || !d.name.endsWith('.tar.gz')) continue;
2944
+ if (SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || d.name.startsWith(TYPED_PREFIX)) continue;
2945
+ try {
2946
+ const st = await fsStat(`${root}/${d.name}`);
2947
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
2948
+ } catch { /* 与轮换/删除竞态:忽略 */ }
2949
+ }
2950
+ newestArchiveAt = newest;
2951
+ }
2952
+
2651
2953
  async function loadAutoState() {
2652
2954
  try {
2653
2955
  const { root } = paths();
@@ -2665,7 +2967,16 @@ export function apply(ctx, pluginConfig) {
2665
2967
  // 故一律视为非法 → null,从当前时间起算 catch-up。
2666
2968
  const lastTs = typeof parsed?.lastAutoAt === 'string' ? Date.parse(parsed.lastAutoAt) : NaN;
2667
2969
  lastAutoAt = Number.isNaN(lastTs) || lastTs > Date.now() ? null : parsed.lastAutoAt;
2970
+ const attTs = typeof parsed?.lastAutoAttemptAt === 'string' ? Date.parse(parsed.lastAutoAttemptAt) : NaN;
2971
+ lastAutoAttemptAt = Number.isNaN(attTs) || attTs > Date.now() ? null : parsed.lastAutoAttemptAt;
2668
2972
  lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
2973
+ starHinted = parsed?.starHinted === true;
2974
+ const e = parsed?.lastAutoError;
2975
+ lastAutoError = e && typeof e === 'object' && typeof e.message === 'string'
2976
+ ? { at: typeof e.at === 'string' ? e.at : '', message: e.message }
2977
+ : null;
2978
+ // 旧版写的 auto.json 没有这些字段 → 全部按缺失处理,行为与旧版一致
2979
+ await refreshNewestArchiveAt();
2669
2980
  return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
2670
2981
  } catch (err) {
2671
2982
  // ENOENT = 首次使用还没有 auto.json,是正常初始状态,静默按未配置处理;
@@ -2673,16 +2984,20 @@ export function apply(ctx, pluginConfig) {
2673
2984
  if (err && err.code !== 'ENOENT') {
2674
2985
  console.warn(`[dsh-backup] 自动备份计划文件读取失败,定时备份已暂停(不影响已有备份):执行 /backup auto <小时数> 可重新开启(${String(err && err.message ? err.message : err)})`);
2675
2986
  }
2987
+ await refreshNewestArchiveAt();
2676
2988
  return 0;
2677
2989
  }
2678
2990
  }
2679
2991
 
2680
- /** 下次自动备份触发时间(毫秒):上次执行 + 周期;无锚点从当前时间起算。 */
2992
+ /** 调度锚点(毫秒):上次成功 / 磁盘最新归档,取最晚者;皆无则 0。 */
2993
+ function autoAnchorAt() {
2994
+ return Math.max(Number(new Date(lastAutoAt)) || 0, newestArchiveAt || 0);
2995
+ }
2996
+
2997
+ /** 下次自动备份触发时间(毫秒):锚点 + 周期;无锚点从当前时间起算。 */
2681
2998
  function nextAutoAt() {
2682
2999
  const cycle = autoHours * 3600 * 1000;
2683
- // 非法/缺失 lastAutoAt 按 0 处理:从当前时间起算,避免 NaN 传染延迟计算
2684
- const anchor = (Number(new Date(lastAutoAt)) || 0) + cycle;
2685
- return Math.max(anchor, Date.now());
3000
+ return Math.max(autoAnchorAt() + cycle, Date.now());
2686
3001
  }
2687
3002
 
2688
3003
  /** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
@@ -2692,43 +3007,82 @@ export function apply(ctx, pluginConfig) {
2692
3007
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
2693
3008
  }
2694
3009
 
3010
+ /** 失败提示(若有):让"备份一直在失败"在 /backup 与面板上可见,而不是只躺在宿主日志里。 */
3011
+ function autoErrorNote() {
3012
+ if (!lastAutoError) return '';
3013
+ const when = lastAutoError.at ? formatWhen(Date.parse(lastAutoError.at)) : '';
3014
+ const msg = lastAutoError.message.length > 200 ? `${lastAutoError.message.slice(0, 200)}…` : lastAutoError.message;
3015
+ return `\n⚠️ 上次自动备份未完成${when ? `(${when})` : ''}:${msg}`;
3016
+ }
3017
+
2695
3018
  function autoSummary() {
2696
- if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
2697
- // 绝对节奏:下次 = 上次执行 + 周期;错过(重启间隔超周期)则按现在显示,调度会立即补跑。
2698
- return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
3019
+ if (!autoDispose) return `自动备份未开启(/backup auto <N小时> 开启)${autoErrorNote()}`;
3020
+ // 绝对节奏:下次 = 锚点 + 周期;错过(重启间隔超周期)则按现在显示,调度会在启动宽限后补跑。
3021
+ return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}${autoErrorNote()}`;
2699
3022
  }
2700
3023
 
2701
- /** 链式 timeout 调度:每次触发后按上次执行时间推算下一次,重启后节奏不重置。 */
2702
- function scheduleAuto() {
3024
+ /**
3025
+ * 链式 timeout 调度:每次触发后按锚点推算下一次,重启后节奏不重置。
3026
+ * #103 两条断路,缺一不可:
3027
+ * 1. boot 宽限——启动路径的首次触发绝不落在宿主 boot 窗口内(delay≤0 的补跑
3028
+ * 正是把 GB 级 tar 塞进 boot 窗口、拖垮 HostRpc 的元凶);
3029
+ * 2. 尝试冷却——上次**起跑**(不要求成功)之后 30 分钟(失败按 2^n 退避)内
3030
+ * 不再起跑。锚点落盘,所以"干到一半被强杀 → 下次启动又立刻重跑"的循环断开。
3031
+ */
3032
+ function scheduleAuto(opts = {}) {
2703
3033
  // 关闭竞态保护:auto off 时 in-flight 备份完成后的续链会以 autoHours=0
2704
3034
  // 算出 delay=0,形成无限备份循环——关闭后绝不续链。
2705
3035
  if (autoHours <= 0) return;
2706
- const delay = nextAutoAt() - Date.now();
3036
+ const cycle = autoHours * 3600 * 1000;
3037
+ let delay = nextAutoAt() - Date.now();
3038
+ const attemptAt = Number(new Date(lastAutoAttemptAt)) || 0;
3039
+ const cooldown = autoFailStreak > 0
3040
+ ? Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), cycle)
3041
+ : AUTO_RETRY_MS;
3042
+ if (attemptAt) delay = Math.max(delay, attemptAt + cooldown - Date.now());
3043
+ if (opts.boot) delay = Math.max(delay, AUTO_BOOT_GRACE_MS);
2707
3044
  autoDispose = ctx.timeout(async () => {
2708
- await runAutoBackup();
3045
+ // 无人值守重活串行:与列车快照等其它无人值守任务排队,不并行抢机器
3046
+ await enqueueHeavy(runAutoBackup);
2709
3047
  scheduleAuto();
2710
3048
  }, delay);
2711
3049
  }
2712
3050
 
2713
3051
  async function runAutoBackup() {
3052
+ // 起跑留痕(先写盘再干活):进程被强杀也不丢"刚试过"这个事实,
3053
+ // 下次启动据此冷却,不会在 boot 窗口里重跑(#103)。
3054
+ lastAutoAttemptAt = new Date().toISOString();
3055
+ await saveAutoState().catch(() => { /* 记录失败不阻断备份本身 */ });
2714
3056
  try {
2715
3057
  const r = await doBackup(autoKeep());
2716
3058
  lastAuto = r.path.split('/').pop();
2717
3059
  lastAutoAt = new Date().toISOString();
3060
+ lastAutoError = null;
3061
+ autoFailStreak = 0;
3062
+ newestArchiveAt = Date.now();
2718
3063
  await saveAutoState();
2719
3064
  console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
2720
3065
  } catch (err) {
2721
- // 失败也推进内存锚点:否则下次 delay=0 立即重试形成热循环。不落盘
2722
- // (失败路径 auto.json 大概率也写失败),下次成功备份会持久化。
2723
- lastAutoAt = new Date().toISOString();
2724
- console.error(`[dsh-backup] 自动备份失败: ${String(err && err.message ? err.message : err)}`);
3066
+ // 失败不再"只推进内存锚点"(旧实现不落盘 → 每次启动 delay≤0 重跑,
3067
+ // 3GB 归档必然失败时就是无限重跑;#103)。改记失败原因 + 退避重试,
3068
+ // 由 lastAutoAttemptAt(已落盘)负责跨重启冷却。
3069
+ autoFailStreak += 1;
3070
+ const message = String(err && err.message ? err.message : err);
3071
+ lastAutoError = { at: new Date().toISOString(), message };
3072
+ await saveAutoState().catch(() => { /* 失败路径写盘也可能失败,不影响内存退避 */ });
3073
+ console.error(`[dsh-backup] 自动备份失败(第 ${autoFailStreak} 次,${Math.round(Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), autoHours * 3600 * 1000) / 60000)} 分钟后重试): ${message}`);
2725
3074
  }
2726
3075
  }
2727
3076
 
2728
3077
  async function setAuto(h) {
2729
3078
  if (autoDispose) { autoDispose(); autoDispose = null; }
2730
3079
  autoHours = h;
3080
+ // 开启/重设 = 用户要一次基线备份:清掉旧锚点与失败退避,让下次触发落在"现在"
2731
3081
  lastAutoAt = null;
3082
+ lastAutoAttemptAt = null;
3083
+ newestArchiveAt = 0;
3084
+ autoFailStreak = 0;
3085
+ lastAutoError = null;
2732
3086
  if (h > 0) scheduleAuto();
2733
3087
  await saveAutoState();
2734
3088
  }
@@ -2918,9 +3272,17 @@ export function apply(ctx, pluginConfig) {
2918
3272
  const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
2919
3273
  const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
2920
3274
  const r = await doBackup(keep, invocation.signal, types ? { types } : {});
3275
+ // 增长(每周增长仪表盘配套):首次手动备份成功后给一次 star 提示,此后永不再现;
3276
+ // 自动备份的日志不加(噪音),面板有常驻入口。
3277
+ let starNote = '';
3278
+ if (!starHinted) {
3279
+ starNote = `\n\n💡 觉得好用的话,给个 Star 是对我们最大的支持: ${STAR_URL}`;
3280
+ starHinted = true;
3281
+ await saveAutoState().catch(() => { /* 记录失败不影响回执内容 */ });
3282
+ }
2921
3283
  return {
2922
3284
  kind: 'success',
2923
- 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()}`,
3285
+ 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()}${starNote}`,
2924
3286
  };
2925
3287
  } catch (err) {
2926
3288
  return { kind: 'error', text: `备份失败: ${friendlyReason(err)}` };
@@ -2931,7 +3293,7 @@ export function apply(ctx, pluginConfig) {
2931
3293
  // ---------- backup_dsh 模型工具 ----------
2932
3294
  ctx.tools.register(defineTool({
2933
3295
  name: 'backup_dsh',
2934
- description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(含 v1/v2/v3 代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
3296
+ description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(v0 起各代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
2935
3297
  parameters: {
2936
3298
  mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor', 'migrate'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检,migrate=升级迁移预检' },
2937
3299
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
@@ -2996,8 +3358,11 @@ export function apply(ctx, pluginConfig) {
2996
3358
  failCount: r.failCount,
2997
3359
  migratableCount: r.migratableCount,
2998
3360
  skippedCount: r.skippedCount,
3361
+ warningCount: r.warningCount ?? 0,
2999
3362
  summary: r.summary,
3000
- sessions: r.sessions.filter((s) => s.verdict === 'fail').map((s) => ({ path: s.rel, version: s.version, findings: s.findings })),
3363
+ // fail 项照旧全带;带提示(不判打不开)的条目也带上 warnings——否则
3364
+ // UI 只能看到"没问题",用户无从知道有哪些开放集合上的命中失败(#113)。
3365
+ sessions: r.sessions.filter((s) => s.verdict === 'fail' || s.warnings?.length).map((s) => ({ path: s.rel, version: s.version, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}) })),
3001
3366
  env: r.env,
3002
3367
  };
3003
3368
  }
@@ -3306,8 +3671,22 @@ export function apply(ctx, pluginConfig) {
3306
3671
  sendJson(res, 503, { error: 'settings-unavailable' });
3307
3672
  return;
3308
3673
  }
3674
+ // 0.1.7 起宿主换了 settings 模型:插件的 register(ns, schema) 被移除,
3675
+ // 描述符改由「profile 条目自带的 Config schema」派生——我们自注册的
3676
+ // 命名空间因此不在 describe() 里(0.1.6-alpha.1 及更早仍是旧模型)。
3677
+ // 旧实现在这里直接抛 TypeError(读 undefined.value),面板只看到一句
3678
+ // 原始报错、编辑还静默失败。改为显式降级:如实说明并指向配置出路。
3679
+ const desc = describeOwn(settingsService, NS);
3680
+ if (!desc) {
3681
+ sendJson(res, 200, {
3682
+ unavailable: true,
3683
+ reason: 'settings-namespace-missing',
3684
+ note: '此宿主版本的设置面板由宿主自带的配置表单接管(0.1.7 起插件自注册的设置命名空间不再生效);本插件的选项请写在 profile 的 cordis.yml / cordis.patch.yml 的插件条目 config 下。',
3685
+ });
3686
+ return;
3687
+ }
3309
3688
  if (req.method === 'GET' || req.method === 'HEAD') {
3310
- sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
3689
+ sendJson(res, 200, describeResponse(desc));
3311
3690
  return;
3312
3691
  }
3313
3692
  if (req.method !== 'POST') {
@@ -3340,7 +3719,7 @@ export function apply(ctx, pluginConfig) {
3340
3719
  }
3341
3720
  throw err;
3342
3721
  }
3343
- sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
3722
+ sendJson(res, 200, describeResponse(describeOwn(settingsService, NS) ?? desc));
3344
3723
  } catch (err) {
3345
3724
  sendJson(res, 400, { error: String(err && err.message ? err.message : err) });
3346
3725
  }
@@ -3348,7 +3727,8 @@ export function apply(ctx, pluginConfig) {
3348
3727
  }), 'dsh-backup: settings route');
3349
3728
  });
3350
3729
 
3351
- // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则 delay=0 立即补跑。
3730
+ // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则在**启动宽限期之后**
3731
+ // 补跑——绝不在宿主 boot 窗口内启动 GB 级 tar(#103)。
3352
3732
  // 同处做升级前快照:宿主列车变化(升/降级)先拍一份 dsh-pre-upgrade- 安全网
3353
3733
  // 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
3354
3734
  void (async () => {
@@ -3357,7 +3737,7 @@ export function apply(ctx, pluginConfig) {
3357
3737
  const h = await loadAutoState();
3358
3738
  if (h > 0 && !autoDispose) {
3359
3739
  autoHours = h;
3360
- scheduleAuto();
3740
+ scheduleAuto({ boot: true });
3361
3741
  }
3362
3742
  // 凭据哨兵:宿主首次读档会把扁平布局的 .credentials.yaml 原子替换成
3363
3743
  // version:1(不可逆——旧宿主读不回 versioned,官方 #6358)。启动即检测,
@@ -3379,14 +3759,21 @@ export function apply(ctx, pluginConfig) {
3379
3759
  }
3380
3760
  if (lastTrain === train) return;
3381
3761
  const oldTrain = lastTrain;
3382
- try {
3383
- const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
3384
- await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
3385
- lastTrain = train;
3386
- await saveAutoState();
3387
- console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
3388
- } catch (err) {
3389
- console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
3390
- }
3762
+ // 列车快照同样是 GB 级重活:与自动补跑一样推迟到 boot 窗口之外,并走同一条
3763
+ // 无人值守队列(两者同时到点时串行而非并行)。失败时不更新 lastTrain,
3764
+ // 下次启动重试——语义与旧版一致。
3765
+ ctx.timeout(() => {
3766
+ void enqueueHeavy(async () => {
3767
+ try {
3768
+ const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
3769
+ await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
3770
+ lastTrain = train;
3771
+ await saveAutoState();
3772
+ console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
3773
+ } catch (err) {
3774
+ console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
3775
+ }
3776
+ });
3777
+ }, AUTO_BOOT_GRACE_MS);
3391
3778
  })();
3392
3779
  }