dsh-autorestart 0.8.4 → 0.8.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
package/client.js CHANGED
@@ -192,8 +192,8 @@
192
192
  panelParkedGoal: '会话 {session} · {goal}',
193
193
  panelWaiting: '已等待 {duration}',
194
194
  panelResume: '重启后本会话会自动继续进度',
195
- // The long wait: the restart passed the hard deadline while a conversation
196
- // was still running. The capsule is ONE clipped line of 22px: it holds a
195
+ // The long wait: the restart is still waiting while a conversation runs on.
196
+ // There is no deadline and nothing expires. The capsule is ONE clipped line: it holds a
197
197
  // number and a duration, NEVER a name. Every name goes into the tooltip,
198
198
  // where there is room for all of them.
199
199
  longWaitPill: ' · 已等 {duration} · {n} 项任务在跑',
@@ -209,6 +209,14 @@
209
209
  blockerMore: '…等 {n} 项',
210
210
  blockersTitleLine: '占用会话:{blockers}',
211
211
  durationSeconds: '{n} 秒',
212
+ // A CANCELLED QUEUED RESTART, IN PLAIN WORDS (user report, 2026-11-03): the request
213
+ // waited over two hours, then a host restart cancelled it and the page said nothing.
214
+ // No internal identifier may reach the screen.
215
+ 'settings.restart.cancelledNote': '上次要的重启没有做成:你在 {at} 请求重启,等了 {waited},它一直没有执行;这次宿主重启之后,那个请求已经被取消了。想重启的话,再点一次就行。',
216
+ 'restart.waitedSeconds': '几秒',
217
+ 'restart.waitedMinutes': '约 {n} 分钟',
218
+ 'restart.waitedHours': '约 {n} 小时 {m} 分',
219
+ 'restart.waitedHoursExact': '约 {n} 小时',
212
220
  durationMinutes: '{n} 分 {m} 秒',
213
221
  durationHours: '{n} 小时 {m} 分',
214
222
  // The COMPACT duration of the capsule: it can afford "16 分钟", not
@@ -291,9 +299,13 @@
291
299
  'settings.restartGrace.value': '{n} 秒',
292
300
  'settings.restartGrace.immediate': '不等(立即重启)',
293
301
  'settings.backup.label': '重启前备份',
294
- 'settings.backup.hint': '每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。',
302
+ 'settings.backup.details': '详情',
303
+ 'settings.backup.section': '备份说明及手动备份操作',
304
+ 'settings.backup.hint': '重启前自动存一份,重启后起不来时能退回去。存的是:你的设置(一定);你自己写的、直接链接到源码目录的插件(连文件一起拷);从网上下载装的插件(只记下装过哪些、什么版本,不拷文件,真出事时按清单重装)。',
295
305
  'settings.backupPlugins.label': '备份包含已安装插件',
296
306
  'settings.backupPlugins.hint': '关闭后只备份 DSH 自身状态,不含 profile 里的插件目录。',
307
+ 'settings.restartBanner.title': '这台机器现在还没有重启能力:本插件不会重启,也不会在重启前备份。点一下「帮我配好」,我自动推导并写入;配错了可以撤销。',
308
+ 'settings.restartBanner.fix': '帮我配好',
297
309
  'settings.groups.supervisor': '开机自启',
298
310
  'settings.supervisor.status': '当前由',
299
311
  'settings.supervisor.installed': '插件守护',
@@ -353,6 +365,7 @@
353
365
  'settings.autostartKind.systemd-system': 'systemd 系统服务',
354
366
  'settings.autostartKind.wsl-systemd': 'WSL 里的 systemd',
355
367
  'settings.autostartKind.wsl-no-systemd': 'WSL(没有 systemd)',
368
+ 'settings.autostartKind.linux-no-systemd': 'Linux(没有 systemd)',
356
369
  'settings.autostartKind.container': '容器',
357
370
  'settings.autostartKind.desktop': '桌面版自带重启',
358
371
  'settings.autostartKind.unknown': '认不出',
@@ -368,19 +381,102 @@
368
381
  'meta.description': '让 DeepSeek Harness 重启后接着干活:落盘的检查点、启动时重新武装 goal 或给一条续接提示,以及一次等人闲下来的优雅重启。',
369
382
  'text.missing': '(这条文案还没写)',
370
383
  'settings.backup.list': '现在备份了什么',
371
- 'settings.backup.count': '当前 {n} 份',
384
+ // TWO LINES, NOT FIVE (user report, 2026-11-02). One line says how many
385
+ // copies there are and how much disk they take together; the next one says
386
+ // the rule that keeps their number bounded. Everything else about the folder
387
+ // was a restatement of one of those two — or a number written into a
388
+ // sentence, where it went stale the moment the dropdown changed it.
389
+ 'settings.backup.count': '当前 {n} 份,实际占用 {size}。',
390
+ // NO "内含:…" LINE (user decision, 2026-11-02): its only job was to point at
391
+ // the per-plugin recovery while the fold was closed, and that entry is gone —
392
+ // a note advertising a deleted feature is worse than no note.
393
+ // MORE THAN ONE PROFILE (2026-11-02): the host cannot tell which profile to
394
+ // back up, so backups and rollbacks stop. Saying nothing would look like
395
+ // "everything is fine".
396
+ 'settings.backup.multiProfile': '检测到多个 profile,备份和回滚已暂停:它不知道该备份哪一个。解决办法:启动时指定 DSH_PROFILE,或者只保留一个 profile。',
372
397
  'settings.backup.keep': '最多保留 {n} 份,超出自动删最旧的那份。',
398
+ // THE SECOND SENTENCE THE USER SAW TWICE IS GONE (user report, 2026-11-02):
399
+ // "最多保留 2 份" said the same thing as the line above, and the long
400
+ // paragraph about what the folder is for repeated the number a third time.
401
+ // Two lines say everything the page can promise; the dropdown below changes
402
+ // the number, so no sentence carries a frozen copy of it.
403
+ 'settings.backup.kindsLocal': '链接安装(自己写的)和本地化过的插件(目录里有 .git)都已完整备份,可以回档。',
404
+ 'settings.backup.kindsThirdParty': '第三方插件由插件作者维护,有异常建议重装',
405
+ 'settings.backup.limitLabel': '保留份数',
406
+ // THE DISK WARNING SITS NEXT TO THE DROPDOWN (user report, 2026-11-02): it
407
+ // used to be a line BELOW the control, so the warning arrived after the
408
+ // choice was made. Same place in the tree, right of the select.
409
+ 'settings.backup.limitHint': '备份会占用磁盘空间,建议尽量选更少的备份数。',
410
+ 'settings.backup.limitAsk': '将删除 {remove} 份最旧的备份,删除后无法恢复。当前 {from} 份 → 保留 {to} 份。',
411
+ 'settings.backup.limitConfirm': '确认删除并改为保留 {to} 份',
412
+ 'settings.backup.limitCancel': '取消',
413
+ 'settings.backup.limitDeleted': '已删除 {removed} 份,现在保留 {kept} 份。',
414
+ 'settings.backup.limitMore': '已选 {requested} 份,实际保留 {kept} 份。',
415
+ 'settings.backup.limitFailed': '没能改成功,份数保持原样。',
373
416
  'settings.backup.empty': '还没有备份;每次重启前会自动生成一份。',
374
417
  'settings.backup.path': '存放路径',
375
418
  'settings.backup.copyPath': '复制路径',
376
419
  'settings.backup.copied': '路径已复制',
377
420
  'settings.backup.copyFailed': '复制失败,请手动选中上面的路径',
378
421
  'settings.backup.time': '备份时间',
379
- 'settings.backup.size': '备份大小',
422
+ // THE SIZE OF ONE COPY IS NOT SHOWN, so its label is gone as well (user
423
+ // decision, 2026-11-02): a row is a time and a button, and the disk a copy
424
+ // needs is only stated once for the whole folder (`settings.backup.count`).
425
+ // A key nothing renders is a promise the page does not keep — the same reason
426
+ // the identity keys went.
380
427
  'settings.backup.withPlugins': '(含已装插件)',
381
428
  'settings.backup.withoutPlugins': '(不含已装插件)',
382
- 'settings.groups.report': '报告问题',
383
- 'settings.groups.rescue': '出事了怎么办',
429
+ // THE ONE LINE A COPY GETS WHEN IT IS NOT THE ONE (user decision,
430
+ // 2026-11-02): a copy automatic recovery would never use is still listed,
431
+ // but as a sentence with its time and its size — never as a card with a
432
+ // button next to it. Two wordings, because a seed and a restart snapshot are
433
+ // two different things to promise about.
434
+ 'settings.backup.fallback.seed': '另有一份首装备份(只含设置、未验证,仅在前一份不可用时才用),{time},{size}',
435
+ 'settings.backup.fallback.other': '另有一份未验证的备份(不会被自动还原,仅在前一份不可用时才用),{time},{size}',
436
+ // THE FOLD (user report, 2026-11-02): fifteen copies used to be fifteen
437
+ // sentences. The first two stay; the rest are behind this one line, and the
438
+ // line says how many and what they are worth.
439
+ 'settings.backup.othersSummary': '另有 {n} 份重启前自动拍的备份(未验证,不会自动还原)',
440
+ // THE CERTIFICATE VOCABULARY IS GONE (user decision, 2026-11-02). Two rounds
441
+ // of deletion ended here: first the rendering stopped grading copies, then the
442
+ // labels and now the evidence sentences themselves. Nothing draws them any
443
+ // more — a copy is a time and a button — and a dictionary that still carried
444
+ // them could be picked up again by a later change without anyone noticing that
445
+ // it re-introduces a verdict the plugin cannot support.
446
+ 'settings.backup.scopeLabel': '内容范围',
447
+ 'settings.backup.scope.config-only': '只含配置',
448
+ 'settings.backup.scope.config+plugins': '配置 + 已装插件',
449
+ 'settings.backup.scope.config+links': '配置 + 链接插件',
450
+ 'settings.backup.scope.slim': '精简份(配置 + 手装插件)',
451
+ 'settings.backup.scopeMissing': '(没有记录)',
452
+ 'settings.backup.interventionLabel': '这次启动有没有被系统介入',
453
+ 'settings.backup.intervention.true': '有(自动救援动过手)',
454
+ 'settings.backup.intervention.false': '没有',
455
+ 'settings.backup.intervention.unknown': '没有记录',
456
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30): the user may know better
457
+ // than "the newest proven copy", so ANY copy can be restored — but only
458
+ // after a card that says which one, what it replaces, and that a restart
459
+ // follows.
460
+ 'settings.backup.rollback': '回退到这一份',
461
+ // THE ONE SENTENCE THE PAGE MAY SAY ABOUT A COPY (user decision, 2026-11-02).
462
+ //
463
+ // It hangs on exactly one copy — the one taken at the most recent startup —
464
+ // and it says only WHAT THAT COPY IS: the state the host last started from.
465
+ // It does not say the copy is good, because the plugin cannot know that: a
466
+ // plugin that fails to load is dropped and the host comes up anyway. Which
467
+ // copy to roll back to is the reader's call, and the sentence ends by saying
468
+ // so. Verbatim — do not reword.
469
+ 'settings.backup.startupHint': '这是最近一次启动时的状态。DSH 能启动不代表插件都正常——坏掉的插件会被排除,主机照样起来。要回退到哪一份,请自己判断。',
470
+ 'settings.backup.rollbackConfirmTitle': '确定回退到这一份?',
471
+ 'settings.backup.rollbackConfirmBody': '会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。',
472
+ 'settings.backup.rollbackConfirmRestart': '还原完成后会自动重启一次:回退前已自动存了一份可撤回的现场;回退后的这次重启不会再额外备份。重启期间这个页面会断开,回来后接着用。',
473
+ 'settings.backup.rollbackConfirmYes': '确认回退',
474
+ 'settings.backup.rollbackConfirmNo': '取消',
475
+ 'settings.backup.rollbackBusy': '正在回退…',
476
+ 'settings.backup.rollbackDone': '已回退,正在重启…',
477
+ 'settings.backup.rollbackFailed': '回退没成功:{message}',
478
+ 'settings.groups.report': '遇到问题',
479
+ 'settings.groups.rescue': '桌面救援入口',
384
480
  'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
385
481
  'settings.rescue.restoreProfile': '回到上一次好状态',
386
482
  'settings.rescue.emergencyDisable': '只把这个插件摘掉',
@@ -390,12 +486,14 @@
390
486
  'settings.rescue.copyFailed': '复制失败,请手动选中上面的路径',
391
487
  'settings.rescue.reveal': '打开所在文件夹',
392
488
  'settings.rescue.which': '怎么选:整个环境坏了(改了配置、装了别的插件、升级崩了)→ 用「回到上一次好状态」;只怀疑这个插件 → 用「只把这个插件摘掉」。',
393
- 'settings.rescue.warning': '重要:这两个脚本是在 DSH 打不开的时候用的。DSH 正开着的时候用「回到上一次好状态」,会把你现在正在用的配置覆盖掉。',
489
+ 'settings.rescue.warning': '重要:这个桌面入口和上面两个脚本,都只在你打不开 DSH 的时候用;DSH 正常开着的时候不要点它——用「回到上一次好状态」会把你现在正在用的配置覆盖掉。',
394
490
  'settings.rescue.rollbackOk': '自动回滚:可用(重启后起不来会自动回到上一个好状态)',
395
491
  'settings.rescue.rollbackBlocked': '自动回滚:**不可用** —— 这台电脑不允许创建计划任务。**会丢失:重启后 DSH 起不来时不会自动恢复**;出问题时请双击「出事了怎么办」里的还原脚本。',
396
492
  'settings.rescue.rollbackUnknown': '自动回滚:还不知道(这台机器的能力还没检查过)',
397
493
  'settings.rescue.watchdogTier': '看门狗的触发方式:{tier}',
398
494
  'settings.rescue.watchdogLast': '上一次看门狗的结果:{result}',
495
+ 'settings.rescue.watchdogStrict': '上一次自动救援没有动手:找不到同时满足两个条件的备份——那份备份有过启动记录,而且那次启动没有系统介入过。请手动处理。',
496
+ 'settings.rescue.watchdogRestoredFrom': "上一次自动救援还原的这一份:{dir}",
399
497
  'settings.rescue.tierScheduledTask': 'Windows 计划任务',
400
498
  'settings.rescue.tierSystemdRun': 'systemd 定时器',
401
499
  'settings.rescue.tierDetachedProcess': '自建后台进程',
@@ -403,6 +501,16 @@
403
501
  'settings.rescue.shortcut': '在桌面放一个快捷方式',
404
502
  'settings.rescue.shortcutDone': '已在桌面',
405
503
  'settings.rescue.noDesktop': '这台机器上没有桌面目录,所以放不了快捷方式(脚本的路径在上面,可以手动复制过去)。',
504
+ 'settings.rescue.noDesktopPlatform': '网页版 / 服务器版没有桌面,所以没有这个一键入口;请用上面两个 .sh 脚本。',
505
+ // PLAN B (user decision, 2026-11-02): a host without a desktop shows the
506
+ // SAME rescue entry as a copyable command, not a dead-end explanation.
507
+ 'settings.rescue.commandLabel': '救援入口(命令)',
508
+ 'settings.rescue.copyCommand': '复制命令',
509
+ 'settings.rescue.commandHint': '复制这条命令执行,效果和桌面上双击那个入口一样。',
510
+ 'settings.rescue.commandUnavailable': '这台机器上没有找到可用的救援命令。',
511
+ 'settings.rescue.shortcutScope': '它只能把整个环境退回上一次好状态,不能只摘掉某一个插件。',
512
+ 'settings.rescue.shortcutBlocked': '系统策略不允许创建快捷方式(比如组策略或权限限制)。把下面的脚本路径复制出来,手动放到桌面:',
513
+ 'settings.rescue.shortcutWriteFailed': '快捷方式没写成功:{message}。把下面的脚本路径复制出来,手动放到桌面:',
406
514
  'settings.rescue.shortcutFailed': '快捷方式没放成:{message}',
407
515
  'settings.rescue.revealFailed': '文件夹没打开:{message}',
408
516
  'settings.report.label': '遇到问题了?',
@@ -478,6 +586,50 @@
478
586
  'announcement.disabled': '按钮目前关闭,可在设置里打开。',
479
587
  'announcement.button': '知道了',
480
588
  'announcement.error': '没能记下来:{message}',
589
+ // THE PLUGIN THAT DID NOT LOAD (用户拍板 2026-11-01):宿主照常起来,只是某个
590
+ // 插件这次没激活 —— 把这件事搬到前台,并且只给一个动作:只恢复这一个。
591
+ 'pluginFailure.title': "有两件事要你看一下",
592
+ 'watchdog.hint': "后面想改主意,可以到「插件」页里找 dsh-autorestart 的设置。",
593
+ 'pluginFailure.hint': "这些功能已经记下来了,之后可以在「插件」页里再恢复。",
594
+ 'settings.description': "自动重启 DSH、守护插件,出问题能一键恢复。",
595
+ 'settings.restore': "恢复",
596
+ // THE TWO SENTENCES THAT REPLACED THE PER-PLUGIN THIRD-PARTY LINE (user
597
+ // correction, 2026-11-02). The old copy said "the backup holds only its name
598
+ // and version", which reads as "there is something in there" — and there is
599
+ // nothing: third-party plugins are NOT backed up. Built-in ones are shipped
600
+ // with DSH, so they need no backup and must not be reinstalled.
601
+ 'settings.thirdParty': "这个插件不是你自己开发的,没法从备份恢复,需要重新安装。",
602
+ 'settings.version': "版本 {version}",
603
+ 'pluginFailure.dismiss': "知道了",
604
+ 'pluginFailure.dismissLabel': "关掉这个提示(这次启动不再显示,下次启动会重新出现)",
605
+ 'pluginFailure.certainTitle': "这些功能这次没起来",
606
+ 'pluginFailure.certainIntro': "上次启动时这些功能还在,这次没有。这是已经发生的事,不是猜测。",
607
+ 'pluginFailure.clueTitle': "这些插件跟备份不一样",
608
+ 'pluginFailure.clueIntro': "这些插件的文件和最近一次备份不同。可能只是升级过,也可能有别的原因,所以这里只列出差异,不下结论。",
609
+ 'pluginFailure.uiBlindSpot': "另外有 {count} 个界面模块这次没出现(它们没有对应到你安装的插件,所以不在这里列出)。",
610
+ 'pluginFailure.restoreAll': "全部恢复({count} 个)",
611
+ 'pluginFailure.restoreAllDone': "恢复完成:{done} 个成功,{failed} 个没成功。",
612
+ 'pluginFailure.close': "关掉这个提示",
613
+ 'pluginFailure.linkedHint': "这个插件是你自己写在本地、以链接方式安装的,可以直接恢复到备份里的版本。",
614
+ 'pluginFailure.thirdPartyHint': "这个插件是第三方安装的,备份里没有它的文件。要还原就用下面这条命令,它会装回原来的那个版本(不是最新版):",
615
+ 'pluginFailure.commandLabel': "复制这条命令",
616
+ 'pluginFailure.noVersion': "备份里查不到它的精确版本,只能按插件名重新安装。",
617
+ 'pluginFailure.restore': "只恢复 {plugin}",
618
+ 'pluginFailure.restoring': "正在恢复 {plugin}…",
619
+ 'pluginFailure.restored': "已经把 {plugin} 恢复到备份版本(备份时间 {at})。",
620
+ 'pluginFailure.failed': "{plugin} 恢复没有完成:{reason}",
621
+ 'pluginFailure.noBackup': "这台机器没有可用备份,只能手动处理。",
622
+ 'watchdog.title': "重启失败时,会自动帮你救回来",
623
+ 'watchdog.explain': "这个插件自带自动救援:重启之后新版本一直起不来时,它会拿一份它自己认为可用的备份帮你恢复(判据是内容指纹一致、且那次启动起来了——这不代表里面的插件都正常),再把程序重新拉起来。它默认就是开着的。",
624
+ 'watchdog.whereOff': "不想要的话,到设置页把「自动救援」的开关关掉就行——关掉之后,新版本起不来就只能手动处理了。",
625
+ 'watchdog.gotIt': "知道了",
626
+ 'watchdog.noticeSaved': "好的,就这一条提示,以后不会再弹了。",
627
+ 'watchdog.warnOff': "关掉之后,万一新版本起不来,就只能手动处理了。",
628
+ 'watchdog.keepOn': "开着(推荐)",
629
+ 'watchdog.turnOff': "不要,我自己处理",
630
+ 'watchdog.savedOn': "已开启:下次重启如果起不来,会自动帮你救回来。",
631
+ 'watchdog.savedOff': "已关闭:下次重启如果起不来,不会自动救。",
632
+ 'watchdog.saveFailed': "没能保存你的选择,设置没有变。请再点一次,或到设置页里改。",
481
633
  // THE COPY OF THE COMPOSER ROW. It is one SHORT line and never a sentence:
482
634
  // the long stage sentence was clipped away there (user report,
483
635
  // 2026-10-29). While the gate waits the pill keeps its own composed line
@@ -497,6 +649,12 @@
497
649
  'restart.overlay.size': '{done} / {total}',
498
650
  'restart.overlay.sizeDone': '{done}',
499
651
  'restart.overlay.restarting': '正在重启…',
652
+ // THE HONEST CARD (user report, 2026-10-30): a restart that was interrupted
653
+ // by a new conversation said "正在重启…" for 25 s, which was not true. It now
654
+ // says what actually happened — and it may stay, because it IS the truth.
655
+ 'restart.overlay.deferred': '有新会话在跑,重启已暂缓',
656
+ 'restart.overlay.deferredNote': '等它结束会自动继续 · 已等 {count} 个会话',
657
+ 'restart.overlay.deferredNoteZero': '等它结束会自动继续',
500
658
  'restart.overlay.back': '已回来',
501
659
  }
502
660
 
@@ -542,8 +700,8 @@
542
700
  panelParkedGoal: 'session {session} · {goal}',
543
701
  panelWaiting: 'Waiting for {duration}',
544
702
  panelResume: 'This session resumes its progress automatically after the restart.',
545
- // The long wait: the restart passed the hard deadline while a conversation
546
- // was still running. The capsule is ONE clipped line of 22px: it holds a
703
+ // The long wait: the restart is still waiting while a conversation runs on.
704
+ // There is no deadline and nothing expires. The capsule is ONE clipped line: it holds a
547
705
  // number and a duration, NEVER a name. Every name goes into the tooltip,
548
706
  // where there is room for all of them.
549
707
  longWaitPill: ' · waiting for {duration} · {n} task(s) running',
@@ -558,6 +716,11 @@
558
716
  blockerMore: '… and {n} more',
559
717
  blockersTitleLine: 'Blocking sessions: {blockers}',
560
718
  durationSeconds: '{n}s',
719
+ 'settings.restart.cancelledNote': 'The restart you asked for did not happen: you requested it at {at}, waited {waited}, and it was never carried out; after the host restarted, that request was cancelled. If you still want it, just ask once more.',
720
+ 'restart.waitedSeconds': 'a few seconds',
721
+ 'restart.waitedMinutes': 'about {n} min',
722
+ 'restart.waitedHours': 'about {n}h {m}m',
723
+ 'restart.waitedHoursExact': 'about {n}h',
561
724
  durationMinutes: '{n}m {m}s',
562
725
  durationHours: '{n}h {m}m',
563
726
  // The COMPACT duration of the capsule: it can afford "16 min", not
@@ -640,11 +803,14 @@
640
803
  'settings.restartGrace.value': '{n} seconds',
641
804
  'settings.restartGrace.immediate': 'Do not wait (restart right away)',
642
805
  'settings.backup.label': 'Backup before a restart',
643
- 'settings.backup.hint': 'Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically.',
806
+ 'settings.backup.details': 'Details',
807
+ 'settings.backup.section': 'What is backed up, and the manual backup steps',
808
+ 'settings.backup.hint': 'A copy is taken before every restart, so a host that cannot boot again is rolled back. What it holds: your settings (always); plugins you wrote and linked from a source directory (copied file by file); plugins downloaded from the internet (only which ones and which version are recorded — never copied; they are reinstalled from that list).',
644
809
  'settings.backupPlugins.label': 'Include the installed plugins in the backup',
645
810
  'settings.backupPlugins.hint': 'Off: only the state of DSH itself is backed up — the plugin directory of the profile is left out.',
646
- 'settings.groups.supervisor': 'Start at login',
647
- 'settings.supervisor.status': 'Started by',
811
+ 'settings.restartBanner.title': 'This machine has no restart capability right now: this plugin will not restart, and will not take a backup before a restart. Press "Set it up for me" and I derive and write it — and that can be undone.',
812
+ 'settings.restartBanner.fix': 'Set it up for me',
813
+ 'settings.groups.supervisor': 'Start at login', 'settings.supervisor.status': 'Started by',
648
814
  'settings.supervisor.installed': 'the supervisor of this plugin',
649
815
  'settings.supervisor.external': 'your own way',
650
816
  'settings.supervisor.unknown': 'nobody knows',
@@ -702,6 +868,7 @@
702
868
  'settings.autostartKind.systemd-system': 'systemd system service',
703
869
  'settings.autostartKind.wsl-systemd': 'systemd inside WSL',
704
870
  'settings.autostartKind.wsl-no-systemd': 'WSL without systemd',
871
+ 'settings.autostartKind.linux-no-systemd': 'Linux without systemd',
705
872
  'settings.autostartKind.container': 'a container',
706
873
  'settings.autostartKind.desktop': 'the desktop app itself',
707
874
  'settings.autostartKind.unknown': 'cannot tell',
@@ -717,19 +884,77 @@
717
884
  'meta.description': 'Keeps agent work alive across a DeepSeek Harness restart: a durable checkpoint, goal re-arm or a continuation message on boot, and an idle-aware graceful restart the agent can request itself.',
718
885
  'text.missing': '(this text is missing yet)',
719
886
  'settings.backup.list': 'What is backed up right now',
720
- 'settings.backup.count': '{n} copies right now',
887
+ 'settings.backup.count': '{n} copies right now, using {size}.',
888
+ 'settings.backup.multiProfile': 'More than one profile was found, so backups and rollbacks are paused: it cannot tell which one to back up. Fix it by starting with DSH_PROFILE set, or by keeping a single profile.',
721
889
  'settings.backup.keep': 'At most {n} copies are kept; the oldest one is deleted when there are more.',
890
+ 'settings.backup.kindsLocal': 'Both linked plugins (written by hand) and localized ones (their directory has a .git) are in the copy in full, so they can be rolled back.',
891
+ 'settings.backup.kindsThirdParty': 'Third-party plugins are maintained by their authors; if something is wrong, reinstall them.',
892
+ 'settings.backup.limitLabel': 'Copies kept',
893
+ 'settings.backup.limitHint': 'Backups take disk space, so it is best to keep as few copies as possible.',
894
+ 'settings.backup.limitAsk': 'This deletes the {remove} oldest copies and cannot be undone. {from} copies now → {to} kept.',
895
+ 'settings.backup.limitConfirm': 'Delete and keep {to}',
896
+ 'settings.backup.limitCancel': 'Cancel',
897
+ 'settings.backup.limitDeleted': 'Deleted {removed} copies; {kept} are kept now.',
898
+ 'settings.backup.limitMore': 'You picked {requested}; {kept} are kept.',
899
+ 'settings.backup.limitFailed': 'The change did not go through; the copy limit is unchanged.',
722
900
  'settings.backup.empty': 'No backup yet; one is written before every restart.',
723
901
  'settings.backup.path': 'Where they live',
724
902
  'settings.backup.copyPath': 'Copy the path',
725
903
  'settings.backup.copied': 'The path is on the clipboard',
726
904
  'settings.backup.copyFailed': 'The copy failed — please select the path above by hand',
727
905
  'settings.backup.time': 'Backup time',
728
- 'settings.backup.size': 'Backup size',
906
+ // THE SIZE OF ONE COPY IS NOT SHOWN, so its label is gone as well (user
907
+ // decision, 2026-11-02): a row is a time and a button, and the disk a copy
908
+ // needs is only stated once for the whole folder (`settings.backup.count`).
909
+ // A key nothing renders is a promise the page does not keep — the same reason
910
+ // the identity keys went.
729
911
  'settings.backup.withPlugins': '(plugins included)',
730
912
  'settings.backup.withoutPlugins': '(plugins excluded)',
731
- 'settings.groups.report': 'Report a problem',
732
- 'settings.groups.rescue': 'If DSH will not start',
913
+ // THE ONE LINE A COPY GETS WHEN IT IS NOT THE ONE (user decision,
914
+ // 2026-11-02): listed, but as a sentence with its time and its size — never
915
+ // as a card with a button next to it.
916
+ 'settings.backup.fallback.seed': 'There is also a first-install seed copy (settings only, not verified, used only when the copy above is unavailable), {time}, {size}',
917
+ 'settings.backup.fallback.other': 'There is also an unverified copy (never restored automatically, used only when the copy above is unavailable), {time}, {size}',
918
+ 'settings.backup.othersSummary': '{n} more pre-restart snapshots (unverified, never restored automatically)',
919
+ // THE CERTIFICATE VOCABULARY IS GONE (user decision, 2026-11-02). Two rounds
920
+ // of deletion ended here: first the rendering stopped grading copies, then the
921
+ // labels and now the evidence sentences themselves. Nothing draws them any
922
+ // more — a copy is a time and a button — and a dictionary that still carried
923
+ // them could be picked up again by a later change without anyone noticing that
924
+ // it re-introduces a verdict the plugin cannot support.
925
+ 'settings.backup.scopeLabel': 'What it holds',
926
+ 'settings.backup.scope.config-only': 'configuration only',
927
+ 'settings.backup.scope.config+plugins': 'configuration and installed plugins',
928
+ 'settings.backup.scope.config+links': 'configuration and linked plugins',
929
+ 'settings.backup.scope.slim': 'slim copy (configuration and by-hand plugins)',
930
+ 'settings.backup.scopeMissing': '(not recorded)',
931
+ 'settings.backup.interventionLabel': 'Was this boot rescued by the system',
932
+ 'settings.backup.intervention.true': 'yes, the watchdog restored a copy',
933
+ 'settings.backup.intervention.false': 'no, nothing had to step in',
934
+ 'settings.backup.intervention.unknown': 'not recorded',
935
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30): any copy can be restored
936
+ // — but only after a card that says which one, what it replaces, and that
937
+ // a restart follows.
938
+ 'settings.backup.rollback': 'Roll back to this copy',
939
+ // THE ONE SENTENCE THE PAGE MAY SAY ABOUT A COPY (user decision, 2026-11-02).
940
+ //
941
+ // It hangs on exactly one copy — the one taken at the most recent startup —
942
+ // and it says only WHAT THAT COPY IS: the state the host last started from.
943
+ // It does not say the copy is good, because the plugin cannot know that: a
944
+ // plugin that fails to load is dropped and the host comes up anyway. Which
945
+ // copy to roll back to is the reader's call, and the sentence ends by saying
946
+ // so. Verbatim — do not reword.
947
+ 'settings.backup.startupHint': 'This is the state at the most recent startup. DSH starting does not mean every plugin is healthy — a broken plugin is excluded and the host still boots. Which copy to roll back to is your call.',
948
+ 'settings.backup.rollbackConfirmTitle': 'Roll back to this copy?',
949
+ 'settings.backup.rollbackConfirmBody': 'The backup from {time} will be restored over the current state — **what you have now is replaced** (a snapshot of it is taken first, so this rollback can be undone).',
950
+ 'settings.backup.rollbackConfirmRestart': 'A restart follows automatically: a restorable copy of what was here before the rollback was already saved; this restart takes no further backup. This page disconnects during the restart and works again afterwards.',
951
+ 'settings.backup.rollbackConfirmYes': 'Roll back',
952
+ 'settings.backup.rollbackConfirmNo': 'Cancel',
953
+ 'settings.backup.rollbackBusy': 'Rolling back…',
954
+ 'settings.backup.rollbackDone': 'Rolled back — restarting…',
955
+ 'settings.backup.rollbackFailed': 'The rollback did not work: {message}',
956
+ 'settings.groups.report': 'Something is wrong',
957
+ 'settings.groups.rescue': 'Desktop rescue',
733
958
  'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
734
959
  'settings.rescue.restoreProfile': 'Back to the last good state',
735
960
  'settings.rescue.emergencyDisable': 'Take this plugin out only',
@@ -739,12 +964,14 @@
739
964
  'settings.rescue.copyFailed': 'The copy failed — please select the path above by hand',
740
965
  'settings.rescue.reveal': 'Open the folder',
741
966
  'settings.rescue.which': 'Which one: the whole environment is broken (a changed configuration, another plugin, a bad upgrade) → "Back to the last good state"; only this plugin is suspect → "Take this plugin out only".',
742
- 'settings.rescue.warning': 'Important: these two scripts are for when DSH does not come up. Using "Back to the last good state" while DSH is running overwrites the configuration you are working with right now.',
967
+ 'settings.rescue.warning': 'Important: this desktop entry and the two scripts above are for when DSH does not come up. Do not click it while DSH is running — "Back to the last good state" overwrites the configuration you are working with right now.',
743
968
  'settings.rescue.rollbackOk': 'Automatic rollback: available (a host that does not come up after a restart goes back to the last good state)',
744
969
  'settings.rescue.rollbackBlocked': 'Automatic rollback: **not available** — this computer does not allow creating scheduled tasks. **What you lose: DSH will NOT recover by itself when it does not come up after a restart**; when it breaks, double-click the restore script in "If DSH will not start".',
745
970
  'settings.rescue.rollbackUnknown': 'Automatic rollback: not known yet (this machine has not been checked)',
746
971
  'settings.rescue.watchdogTier': 'How the watchdog is triggered: {tier}',
747
972
  'settings.rescue.watchdogLast': 'The last watchdog run: {result}',
973
+ 'settings.rescue.watchdogStrict': 'The last watchdog run did NOT act: no backup satisfies both conditions — it records a startup, and that startup had no system intervention. Please handle it by hand.',
974
+ 'settings.rescue.watchdogRestoredFrom': "The copy the last watchdog run restored: {dir}",
748
975
  'settings.rescue.tierScheduledTask': 'a Windows scheduled task',
749
976
  'settings.rescue.tierSystemdRun': 'a systemd timer',
750
977
  'settings.rescue.tierDetachedProcess': 'a detached background process',
@@ -752,6 +979,14 @@
752
979
  'settings.rescue.shortcut': 'Put a shortcut on the desktop',
753
980
  'settings.rescue.shortcutDone': 'It is on the desktop',
754
981
  'settings.rescue.noDesktop': 'This machine has no desktop directory, so no shortcut can be placed (the paths are above — copy them by hand).',
982
+ 'settings.rescue.noDesktopPlatform': 'The web / server version has no desktop, so there is no one-click entry here; use the two .sh scripts above.',
983
+ 'settings.rescue.commandLabel': 'Rescue entry (command)',
984
+ 'settings.rescue.copyCommand': 'Copy the command',
985
+ 'settings.rescue.commandHint': 'Copy this command and run it — the same effect as double-clicking the desktop entry.',
986
+ 'settings.rescue.commandUnavailable': 'No usable rescue command was found on this machine.',
987
+ 'settings.rescue.shortcutScope': 'It can only put the WHOLE environment back to the last good state — it cannot take one plugin out.',
988
+ 'settings.rescue.shortcutBlocked': 'A system policy does not allow creating the shortcut (a group policy or a permission limit). Copy the script path below and put it on the desktop by hand:',
989
+ 'settings.rescue.shortcutWriteFailed': 'The shortcut was not written: {message}. Copy the script path below and put it on the desktop by hand:',
755
990
  'settings.rescue.shortcutFailed': 'The shortcut was not placed: {message}',
756
991
  'settings.rescue.revealFailed': 'The folder did not open: {message}',
757
992
  'settings.report.label': 'Something wrong?',
@@ -827,6 +1062,46 @@
827
1062
  'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
828
1063
  'announcement.button': 'Got it',
829
1064
  'announcement.error': 'Could not record it: {message}',
1065
+ // THE PLUGIN THAT DID NOT LOAD (user decision 2026-11-01): the host came up,
1066
+ // one plugin simply did not activate this time. Say so, and offer exactly
1067
+ // one action: put THIS one plugin back.
1068
+ 'pluginFailure.title': "Two things worth a look",
1069
+ 'watchdog.hint': "Changed your mind later? Look for the dsh-autorestart settings on the Plugins page.",
1070
+ 'pluginFailure.hint': "These are on record - you can restore them later from the Plugins page.",
1071
+ 'settings.description': "Restarts DSH, watches your plugins, and puts back the ones it can.",
1072
+ 'settings.restore': "Restore",
1073
+ 'settings.thirdParty': "You did not write this plugin yourself, so it cannot be restored from the backup - it has to be reinstalled.",
1074
+ 'settings.version': "version {version}",
1075
+ 'pluginFailure.dismiss': "Got it",
1076
+ 'pluginFailure.dismissLabel': "Hide this for now (it comes back on the next start)",
1077
+ 'pluginFailure.certainTitle': "These features did not come up this time",
1078
+ 'pluginFailure.certainIntro': "They were there on the previous start and are not here now. That is something that happened, not a guess.",
1079
+ 'pluginFailure.clueTitle': "These plugins differ from the backup",
1080
+ 'pluginFailure.clueIntro': "The files of these plugins are not what the latest backup holds. They may simply have been upgraded - this block lists a difference, not a conclusion.",
1081
+ 'pluginFailure.uiBlindSpot': "Also {count} interface module(s) did not appear this time (they do not map to a plugin you installed, so they are not listed here).",
1082
+ 'pluginFailure.restoreAll': "Restore all ({count})",
1083
+ 'pluginFailure.restoreAllDone': "Done: {done} restored, {failed} not restored.",
1084
+ 'pluginFailure.close': "Dismiss this notice",
1085
+ 'pluginFailure.linkedHint': "You wrote this plugin yourself and installed it as a link, so it can be put back from the backup directly.",
1086
+ 'pluginFailure.thirdPartyHint': "This plugin was installed from elsewhere and the backup does not hold its files. To put it back, use the command below: it reinstalls the same version (not the latest one):",
1087
+ 'pluginFailure.commandLabel': "Copy this command",
1088
+ 'pluginFailure.noVersion': "The backup does not record its exact version, so it can only be reinstalled by name.",
1089
+ 'pluginFailure.restore': "Restore {plugin} only",
1090
+ 'pluginFailure.restoring': "Restoring {plugin}…",
1091
+ 'pluginFailure.restored': "{plugin} was put back to the backup version (backup taken {at}).",
1092
+ 'pluginFailure.failed': "{plugin} was not restored: {reason}",
1093
+ 'pluginFailure.noBackup': "There is no usable backup on this machine; it has to be handled by hand.",
1094
+ 'watchdog.title': "If a restart fails, it can rescue you automatically",
1095
+ 'watchdog.explain': "This plugin ships with an automatic rescue: when a newly restarted version never comes up, it restores a backup it considers usable (the criterion is that the content fingerprint matched and that boot came up — it does not mean the plugins inside are all fine), then starts the program again. It is on by default.",
1096
+ 'watchdog.whereOff': "If you do not want it, turn it off on the settings page — with it off, a new version that does not start has to be handled by hand.",
1097
+ 'watchdog.gotIt': "Got it",
1098
+ 'watchdog.noticeSaved': "That is the only notice; it will not appear again.",
1099
+ 'watchdog.warnOff': "With it off, a new version that does not start has to be handled by hand.",
1100
+ 'watchdog.keepOn': "Keep it on (recommended)",
1101
+ 'watchdog.turnOff': "No, I will handle it",
1102
+ 'watchdog.savedOn': "The watchdog is on: the next restart is protected.",
1103
+ 'watchdog.savedOff': "The watchdog is off: the next restart will not roll back on its own.",
1104
+ 'watchdog.saveFailed': "Your choice was not saved, so the watchdog setting is unchanged. Please try again, or change it on the settings page.",
830
1105
  // THE COPY OF THE COMPOSER ROW (see the Chinese block): one short line,
831
1106
  // never a sentence.
832
1107
  'restart.stage.snapshotting': 'Backing up…',
@@ -837,6 +1112,9 @@
837
1112
  'restart.overlay.size': '{done} / {total}',
838
1113
  'restart.overlay.sizeDone': '{done}',
839
1114
  'restart.overlay.restarting': 'Restarting…',
1115
+ 'restart.overlay.deferred': 'A conversation just started — restart postponed',
1116
+ 'restart.overlay.deferredNote': 'It will retry automatically when idle · {count} waiting',
1117
+ 'restart.overlay.deferredNoteZero': 'It will retry automatically when idle',
840
1118
  'restart.overlay.back': 'Back again',
841
1119
  }
842
1120
 
@@ -906,20 +1184,49 @@
906
1184
  * ever sees a key name, in any language.
907
1185
  *
908
1186
  * @param {Function|null|undefined} t the Host's translator (may be missing)
909
- * @returns {(key: string) => string}
1187
+ * @returns {(key: string, params?: object) => string}
910
1188
  */
911
1189
  function makeTranslate(t) {
912
- return (key) => {
1190
+ /**
1191
+ * `{name}` ANSWERED FROM `params` (user report, 2026-11-02).
1192
+ *
1193
+ * THE BUG THIS FIXES: this translator took ONE argument, so every call that
1194
+ * passed replacement values had them silently dropped and the page printed
1195
+ * the placeholder itself ("看门狗的触发方式:{tier}"). Three call sites did
1196
+ * exactly that; the ones that substitute by hand were never affected.
1197
+ *
1198
+ * THE RULES, each one a decision:
1199
+ * - a placeholder with NO value stays as it is: blanking it would delete
1200
+ * information the sentence was written to carry;
1201
+ * - the value is inserted as literal text, so `$&` / `$1` are characters
1202
+ * and not replace patterns (hence split + join, never `replace`);
1203
+ * - every occurrence is replaced, not only the first;
1204
+ * - a `null` / `undefined` value counts as "no value", never as the word
1205
+ * `null` printed on screen.
1206
+ */
1207
+ const fillPlaceholders = (text, params) => {
1208
+ if (typeof text !== 'string' || text === '') return text
1209
+ if (params === null || params === undefined || typeof params !== 'object') return text
1210
+ let out = text
1211
+ for (const [name, value] of Object.entries(params)) {
1212
+ if (value === null || value === undefined) continue
1213
+ const placeholder = '{' + name + '}'
1214
+ if (out.includes(placeholder) !== true) continue
1215
+ out = out.split(placeholder).join(String(value))
1216
+ }
1217
+ return out
1218
+ }
1219
+ return (key, params) => {
913
1220
  const name = typeof key === 'string' ? key : String(key ?? '')
914
1221
  if (typeof t === 'function') {
915
1222
  try {
916
1223
  const value = t(name)
917
- if (typeof value === 'string' && value !== '' && value !== name) return value
1224
+ if (typeof value === 'string' && value !== '' && value !== name) return fillPlaceholders(value, params)
918
1225
  } catch {
919
1226
  // A broken translator must not break the surface.
920
1227
  }
921
1228
  }
922
- return dictionaryText(name, currentLanguage())
1229
+ return fillPlaceholders(dictionaryText(name, currentLanguage()), params)
923
1230
  }
924
1231
  }
925
1232
 
@@ -1105,7 +1412,58 @@
1105
1412
  : fillTemplate(translate('durationHoursMinutesShort'), { n: hours, m: rest })
1106
1413
  }
1107
1414
 
1415
+ /**
1416
+ * A WAIT, SAID THE WAY A PERSON SAYS IT (user report, 2026-11-03).
1417
+ *
1418
+ * Precision is not the point, recognition is: "约 2 小时 21 分" is what makes a
1419
+ * user believe the page looked at THEIR request. Seconds are rounded to words on
1420
+ * purpose — "47 秒" invites a precision nobody needs.
1421
+ */
1422
+ function humanWait(ms, translate) {
1423
+ const t = makeTranslate(translate)
1424
+ const value = Number(ms)
1425
+ if (Number.isFinite(value) !== true || value < 0) return ''
1426
+ if (value < 60_000) return t('restart.waitedSeconds')
1427
+ const totalMinutes = Math.floor(value / 60_000)
1428
+ if (totalMinutes < 60) return fillTemplate(t('restart.waitedMinutes'), { n: totalMinutes })
1429
+ const hours = Math.floor(totalMinutes / 60)
1430
+ const rest = totalMinutes % 60
1431
+ return rest === 0
1432
+ ? fillTemplate(t('restart.waitedHoursExact'), { n: hours })
1433
+ : fillTemplate(t('restart.waitedHours'), { n: hours, m: rest })
1434
+ }
1435
+
1436
+ /** `2026-11-02T23:04:38+08:00` -> `2026-11-02 23:04:38` (the offset is the host's; no Date parsing). */
1437
+ function humanRequestedAt(raw) {
1438
+ const text = typeof raw === 'string' ? raw.trim() : ''
1439
+ if (text.length < 19) return ''
1440
+ return text.slice(0, 10) + ' ' + text.slice(11, 19)
1441
+ }
1442
+
1443
+ /**
1444
+ * THE ONE SENTENCE ABOUT A REQUEST THAT DIED (user decision, 2026-11-03).
1445
+ *
1446
+ * ONLY the newest request is looked at, and only the outcome "the host came back
1447
+ * and settled it" is spoken about. A stale record must not follow the user around,
1448
+ * and a request that simply completed is not news.
1449
+ *
1450
+ * @returns {{ text: string, at: string, waited: string }|null}
1451
+ */
1452
+ function cancelledRestartNote(info, translate) {
1453
+ const request = info && typeof info === 'object' && info.restartRequest && typeof info.restartRequest === 'object'
1454
+ ? info.restartRequest
1455
+ : null
1456
+ if (request === null) return null
1457
+ if (request.state !== 'cancelled' || request.by !== 'boot-recovery') return null
1458
+ const at = humanRequestedAt(request.requestedAtLocal)
1459
+ const waited = humanWait(request.waitedMs, translate)
1460
+ if (at === '' || waited === '') return null
1461
+ const t = makeTranslate(translate)
1462
+ return { text: fillTemplate(t('settings.restart.cancelledNote'), { at, waited }), at, waited }
1463
+ }
1464
+
1108
1465
  /* ------------------------------------------------------------------ *
1466
+
1109
1467
  * The blocker lists: ONE rule for every surface that names them.
1110
1468
  *
1111
1469
  * The defect the real machine showed: the pending-restart bubble grew a
@@ -1653,8 +2011,8 @@
1653
2011
  /**
1654
2012
  * The note of a LONG wait.
1655
2013
  *
1656
- * The Host says that the pending restart passed the hard deadline while a
1657
- * conversation was still running. That is not "a little longer" any more, so
2014
+ * The Host says that the pending restart is still waiting while a conversation
2015
+ * runs on (there is no deadline). That is not "a little longer", so
1658
2016
  * the bubble says it out loud: how long the restart has been waiting and who
1659
2017
  * holds it. The note is empty when there is no long wait.
1660
2018
  *
@@ -1916,7 +2274,7 @@
1916
2274
  return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody);
1917
2275
  }
1918
2276
 
1919
-
2277
+
1920
2278
 
1921
2279
  /**
1922
2280
  * Copy a text to the clipboard — with a FALLBACK.
@@ -2090,6 +2448,117 @@
2090
2448
  return Math.round(value) + ' B'
2091
2449
  }
2092
2450
 
2451
+ /**
2452
+ * THE GRADING OF COPIES IS GONE (user decision, 2026-11-02).
2453
+ *
2454
+ * The page used to sort every copy into an identity and let only one of them
2455
+ * carry a verdict — a source plus a criterion ("full-checks" AND
2456
+ * "fingerprint-match") had to agree before a row was allowed to look better
2457
+ * than the others. Two things were wrong with that and the user ruled both
2458
+ * out: the verdict was read as "the plugins in it are fine", which a boot
2459
+ * cannot establish (a plugin that fails to load is dropped and the host comes
2460
+ * up anyway), and on a hosted instance nothing ever qualified, so the good tier
2461
+ * stayed empty while every real copy sat in the demoted row.
2462
+ *
2463
+ * So there is no `isSafeBackup`, no identity key and no per-copy label any
2464
+ * more: a row is a time and a button. What replaces the question is one fact
2465
+ * the page can state honestly — WHICH COPY IS THE STATE THE HOST LAST STARTED
2466
+ * FROM — and that is `startupCopyName` below.
2467
+ */
2468
+
2469
+ /**
2470
+ * The moment a copy says it was taken, read from the copy's OWN record.
2471
+ *
2472
+ * `takenAt` is the take time under the name the Host records it with; `at` is
2473
+ * the number the same manifest has carried for longer. Either may arrive as a
2474
+ * number of milliseconds or as an ISO string. A copy that says neither cannot
2475
+ * answer the question, and gets no sentence at all — silence beats a guess.
2476
+ *
2477
+ * @param {object|null|undefined} item one backup of the state payload
2478
+ * @returns {number|null}
2479
+ */
2480
+ function copyTakeTime(item) {
2481
+ if (!item) return null
2482
+ for (const value of [item.takenAt, item.at]) {
2483
+ if (typeof value === 'number' && Number.isFinite(value)) return value
2484
+ if (typeof value === 'string' && value !== '') {
2485
+ const parsed = Date.parse(value)
2486
+ if (Number.isFinite(parsed)) return parsed
2487
+ }
2488
+ }
2489
+ return null
2490
+ }
2491
+
2492
+ /**
2493
+ * WHICH COPY WAS TAKEN AT THE MOST RECENT STARTUP? (user decision, 2026-11-02)
2494
+ *
2495
+ * The answer is the copy whose own record carries the latest take time: that is
2496
+ * the state the host was started from the last time it came up. It is
2497
+ * deliberately NOT "the first row of the list" — the row order is a rendering
2498
+ * detail, and a page that read its fact from the row order would move the one
2499
+ * sentence onto a different copy the day the order changes (or silently onto
2500
+ * the wrong copy when the list is not sorted).
2501
+ *
2502
+ * Nothing here judges the copy: the sentence this name selects says what the
2503
+ * copy IS, never that it is healthy.
2504
+ *
2505
+ * @param {Array<object>|null|undefined} items the copies as the Host reports them
2506
+ * @returns {string|null} the name of that copy, or null when no copy can say
2507
+ */
2508
+ function startupCopyName(items) {
2509
+ if (!Array.isArray(items)) return null
2510
+ let best = null
2511
+ for (const item of items) {
2512
+ if (!item || typeof item.name !== 'string' || item.name === '') continue
2513
+ const at = copyTakeTime(item)
2514
+ if (at === null) continue
2515
+ if (best === null || at > best.at) best = { name: item.name, at }
2516
+ }
2517
+ return best === null ? null : best.name
2518
+ }
2519
+
2520
+ /**
2521
+ * THE TWO KEY MAPPERS OF THE CERTIFICATE VOCABULARY ARE GONE (user decision,
2522
+ * 2026-11-02). One named the source that "signed" a copy, the other the
2523
+ * criterion that backed it; both existed only to pick a per-copy sentence out
2524
+ * of the dictionary. No row grades a copy any more, so once the list stopped
2525
+ * rendering identities they had no caller left — deleted rather than kept as a
2526
+ * hook a later change could quietly revive. Their dictionary entries went with
2527
+ * them: the whole "who certified it" family is not in this file any more.
2528
+ */
2529
+
2530
+ /** Which dictionary entry describes what a copy holds. */
2531
+ function backupScopeKey(contentScope) {
2532
+ if (contentScope === 'config-only') return 'config-only'
2533
+ if (contentScope === 'config+plugins') return 'config+plugins'
2534
+ if (contentScope === 'config+links') return 'config+links'
2535
+ if (contentScope === 'slim') return 'slim'
2536
+ return ''
2537
+ }
2538
+
2539
+ /**
2540
+ * Whether this boot was rescued by the system, in three answers.
2541
+ *
2542
+ * `null` is NOT "no": the Host says `null` when nobody recorded anything, and
2543
+ * the two must not be merged — "it did not happen" and "we do not know" lead a
2544
+ * reader to different conclusions.
2545
+ */
2546
+ function backupInterventionKey(intervention) {
2547
+ if (intervention === true) return 'true'
2548
+ if (intervention === false) return 'false'
2549
+ return 'unknown'
2550
+ }
2551
+
2552
+ /**
2553
+ * The version a copy was proven with, or an empty string when it carries none.
2554
+ *
2555
+ * @param {object|null|undefined} item one backup of the state payload
2556
+ * @returns {string}
2557
+ */
2558
+ function backupVersionOf(item) {
2559
+ return item && typeof item.version === 'string' && item.version !== '' ? item.version : ''
2560
+ }
2561
+
2093
2562
  /**
2094
2563
  * The local time of one backup, to the minute.
2095
2564
  *
@@ -2192,6 +2661,134 @@
2192
2661
  * @param {Function} [translator]
2193
2662
  * @returns {string}
2194
2663
  */
2664
+ /**
2665
+ * HOW LONG THE "RESTART POSTPONED" CARD STAYS ON SCREEN (milliseconds).
2666
+ *
2667
+ * THE REPORT (user, 2026-10-30): "after the interruption I still have a lot of
2668
+ * work to do — a card hovering for ever is in the way". The postponed card is an
2669
+ * ANNOUNCEMENT, not a progress bar: it says what happened for four seconds and
2670
+ * then folds away, while the capsule keeps saying "pending restart · waiting for
2671
+ * N sessions". A NEW postponement (a new stage timestamp) announces itself again.
2672
+ *
2673
+ * The stages that really are running ('snapshotting', 'restarting') are not
2674
+ * affected: they stay while the work lasts.
2675
+ */
2676
+ const RESTART_DEFERRED_CARD_MS = 4000
2677
+
2678
+ /**
2679
+ * HOW LONG A STAGE MAY GO UNMENTIONED BEFORE THE CARD STOPS TRUSTING IT.
2680
+ *
2681
+ * WHY (user report, 2026-10-29): a restart was postponed (a conversation
2682
+ * became active again) right after the copy, and the card kept saying
2683
+ * "DSH 重启前备份中" for ever — the stage on disk had not been brought back to
2684
+ * 'waiting'. The Host is fixed, but the interface must not be able to hang on
2685
+ * a stale stage either: a stage is evidence only while it keeps being
2686
+ * refreshed.
2687
+ */
2688
+ const RESTART_STAGE_STALE_MS = 15000
2689
+ // While files are being copied the Host refreshes the stage on every progress
2690
+ // tick, so ten silent seconds mean the copy is no longer reporting — either it
2691
+ // is over or nobody is copying any more. A 'restarting' stage has no progress
2692
+ // of its own and only has to stay inside the stale window above.
2693
+ const RESTART_SNAPSHOT_FRESH_MS = 10000
2694
+
2695
+ /**
2696
+ * THE TWO CADENCES OF THE SHARED POLL (milliseconds).
2697
+ *
2698
+ * 2 s is right for a page that merely shows whether a restart is pending: a
2699
+ * pending restart sits there for minutes. While a restart is MOVING, though,
2700
+ * the stage can flip at any moment (a conversation starts, the host postpones
2701
+ * the restart, the card has to change its words) — and 2 s of the wrong word is
2702
+ * exactly what the owner complained about. So the cadence follows the state.
2703
+ */
2704
+ const POLL_MS = 2000
2705
+ const POLL_FAST_MS = 500
2706
+
2707
+ /**
2708
+ * IS A RESTART MOVING RIGHT NOW?
2709
+ *
2710
+ * 'waiting' counts as well: the restart is requested and the gate may open (or
2711
+ * a conversation may appear) at any moment — that is exactly when the card must
2712
+ * change its words within a second.
2713
+ *
2714
+ * @param {object|null} info the state payload of the Host
2715
+ * @returns {boolean}
2716
+ */
2717
+ function isRestartMoving(info) {
2718
+ if (!info || typeof info !== 'object') return false
2719
+ if (info.pendingRestart !== true) return false
2720
+ const stage = info.restartPhase
2721
+ const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
2722
+ return phase === 'waiting' || phase === 'snapshotting' || phase === 'restarting'
2723
+ }
2724
+
2725
+ /**
2726
+ * The cadence the CURRENT state asks for.
2727
+ *
2728
+ * @param {object|null} info the state payload of the Host
2729
+ * @returns {number} milliseconds
2730
+ */
2731
+ function pollIntervalFor(info) {
2732
+ return isRestartMoving(info) ? POLL_FAST_MS : POLL_MS
2733
+ }
2734
+
2735
+ /**
2736
+ * Is the page on screen? A hidden tab has nobody to tell, so it must not spin
2737
+ * (and it asks once more as soon as it is visible again).
2738
+ *
2739
+ * @returns {boolean}
2740
+ */
2741
+ function isPageVisible() {
2742
+ if (typeof document === 'undefined' || !document) return true
2743
+ if (document.hidden === true) return false
2744
+ if (typeof document.visibilityState === 'string') return document.visibilityState !== 'hidden'
2745
+ return true
2746
+ }
2747
+
2748
+ /**
2749
+ * IS THE RESTART POSTPONED BY A CONVERSATION? (the Host says so in `reason`)
2750
+ *
2751
+ * A postponed restart is a stage of 'waiting' with a reason that starts with
2752
+ * "deferred:" (see the Host's deferRestartStage). That state is NOT an activity,
2753
+ * so it is drawn differently: the card names the interruption and promises the
2754
+ * retry, and the word "正在重启" never appears for it.
2755
+ *
2756
+ * @param {object|null} stage the `restartPhase` of the state route
2757
+ * @returns {boolean}
2758
+ */
2759
+ function isRestartStageDeferred(stage) {
2760
+ if (!stage || typeof stage !== 'object') return false
2761
+ if (stage.phase !== 'waiting') return false
2762
+ return typeof stage.reason === 'string' && stage.reason.indexOf('deferred:') === 0
2763
+ }
2764
+
2765
+ /**
2766
+ * Is the reported stage still something the card may draw?
2767
+ *
2768
+ * The card is an ALARM about something happening right now: 'snapshotting'
2769
+ * (files are being copied) or 'restarting' (the host is going down). A stage
2770
+ * that nobody refreshed for 15 s (10 s for a copy, which refreshes on every
2771
+ * file) describes something that is not happening any more, and drawing it
2772
+ * would leave a card hanging — exactly the bug to avoid. The pill keeps
2773
+ * telling the user the restart is still pending; the card simply stops
2774
+ * claiming progress.
2775
+ *
2776
+ * @param {object|null} stage the `restartPhase` of the state route
2777
+ * @param {number} now
2778
+ * @returns {boolean}
2779
+ */
2780
+ function isRestartStageLive(stage, now) {
2781
+ const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
2782
+ if (phase !== 'snapshotting' && phase !== 'restarting') return false
2783
+ const at = stage && Number.isFinite(Number(stage.at)) ? Number(stage.at) : null
2784
+ // No timestamp at all: the stage cannot be trusted to describe "now".
2785
+ if (at === null || at <= 0) return false
2786
+ const age = Math.max(0, now - at)
2787
+ if (age > RESTART_STAGE_STALE_MS) return false
2788
+ if (phase === 'restarting') return true
2789
+ return age <= RESTART_SNAPSHOT_FRESH_MS
2790
+ }
2791
+
2195
2792
  function restartOverlayTitle(stage, translator) {
2196
2793
  const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
2197
2794
  if (!isRestartOverlayPhase(phase)) return ''
@@ -2275,6 +2872,19 @@
2275
2872
  // The stage of a restart on the two surfaces that have room for it: the
2276
2873
  // composer row (one short line) and the centred overlay (the detail).
2277
2874
  isRestartOverlayPhase,
2875
+ // Is the reported stage still worth a card? (a stale stage cannot hang it)
2876
+ isRestartStageLive,
2877
+ isRestartStageDeferred,
2878
+ RESTART_DEFERRED_CARD_MS,
2879
+ // The cadence of the shared poll: fast while a restart is moving, and no
2880
+ // polling at all in a hidden tab.
2881
+ isRestartMoving,
2882
+ pollIntervalFor,
2883
+ isPageVisible,
2884
+ POLL_MS,
2885
+ POLL_FAST_MS,
2886
+ RESTART_STAGE_STALE_MS,
2887
+ RESTART_SNAPSHOT_FRESH_MS,
2278
2888
  restartPillStageText,
2279
2889
  restartOverlayTitle,
2280
2890
  // The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
@@ -2316,6 +2926,12 @@
2316
2926
  const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
2317
2927
  const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
2318
2928
  const DEFAULT_CANCEL_URL = ROUTE_PREFIX + '/cancel'
2929
+ // The route of the MANUAL ROLLBACK (restore ONE copy the user picked).
2930
+ const DEFAULT_ROLLBACK_URL = ROUTE_PREFIX + '/rollback-to'
2931
+ // The route that writes the copy limit (how many backups to keep). It has a
2932
+ // default for the same reason the rollback does: this page must not depend
2933
+ // on an optional field of the state payload to be able to send a write.
2934
+ const DEFAULT_BACKUP_LIMIT_URL = ROUTE_PREFIX + '/backup-limit'
2319
2935
  // The route that persists the one-time install choice.
2320
2936
  const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
2321
2937
  // The browser-side record of the one-time install choice. The prompt is
@@ -2329,7 +2945,8 @@
2329
2945
  * the fallback for a Host that is older than the supervisor.
2330
2946
  */
2331
2947
  const DEFAULT_SUPERVISOR_URL = '/__dsh_autorestart/supervisor'
2332
- const POLL_MS = 2000
2948
+ // The cadence lives with the state it follows (see POLL_MS/POLL_FAST_MS
2949
+ // above): the shared poll speeds up while a restart is moving.
2333
2950
  // The brand accent (blue-purple). The pending-restart marker is a brand
2334
2951
  // signal, not a warning: no warning/yellow token is used here.
2335
2952
  const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
@@ -2405,11 +3022,15 @@
2405
3022
  info: null,
2406
3023
  listeners: new Set(),
2407
3024
  timer: null,
3025
+ // The cadence the current timer was armed with (0 = not armed).
3026
+ interval: 0,
2408
3027
  inFlight: false,
2409
3028
  controller: null,
2410
3029
  // When the pending state was FIRST observed, so the details panel can
2411
3030
  // say how long this session has been waiting.
2412
3031
  pendingSince: null,
3032
+ // The visibility listener is bound once, on the first subscriber.
3033
+ visibilityBound: false,
2413
3034
  }
2414
3035
 
2415
3036
  function notifyListeners() {
@@ -2442,8 +3063,63 @@
2442
3063
  }
2443
3064
  }
2444
3065
 
3066
+ /**
3067
+ * (Re-)arm the shared timer with the cadence the state asks for.
3068
+ *
3069
+ * THE CADENCE IS RE-DECIDED AFTER EVERY ANSWER: a restart that starts makes
3070
+ * the next tick 500 ms away, a restart that ends puts it back to 2 s, and a
3071
+ * hidden page is not polled at all (an interval in a background tab only
3072
+ * spends battery — it asks once as soon as it is visible again).
3073
+ *
3074
+ * Re-arming only happens when the cadence really changed, so the ticks stay
3075
+ * evenly spaced and never pile up.
3076
+ */
3077
+ function armPoll() {
3078
+ if (stateStore.listeners.size === 0) return
3079
+ const wanted = isPageVisible() ? pollIntervalFor(stateStore.info) : 0
3080
+ if (wanted === 0) {
3081
+ if (stateStore.timer !== null) {
3082
+ clearInterval(stateStore.timer)
3083
+ stateStore.timer = null
3084
+ }
3085
+ stateStore.interval = 0
3086
+ return
3087
+ }
3088
+ if (stateStore.timer !== null && stateStore.interval === wanted) return
3089
+ if (stateStore.timer !== null) clearInterval(stateStore.timer)
3090
+ stateStore.interval = wanted
3091
+ stateStore.timer = setInterval(() => {
3092
+ pollOnce().then(armPoll)
3093
+ }, wanted)
3094
+ }
3095
+
3096
+ /**
3097
+ * Ask once more as soon as the page is back on screen.
3098
+ *
3099
+ * A hidden tab stops polling; coming back must not wait for the next tick of
3100
+ * a timer that was stopped, so the visibility event polls immediately and
3101
+ * then re-arms the cadence.
3102
+ */
3103
+ function bindVisibility() {
3104
+ if (stateStore.visibilityBound) return
3105
+ if (typeof document === 'undefined' || !document || typeof document.addEventListener !== 'function') return
3106
+ stateStore.visibilityBound = true
3107
+ document.addEventListener('visibilitychange', () => {
3108
+ if (stateStore.listeners.size === 0) return
3109
+ if (!isPageVisible()) {
3110
+ armPoll()
3111
+ return
3112
+ }
3113
+ pollOnce().then(armPoll)
3114
+ armPoll()
3115
+ })
3116
+ }
3117
+
2445
3118
  async function pollOnce() {
3119
+ // ONE REQUEST AT A TIME: a slow answer must never stack up a queue.
2446
3120
  if (stateStore.inFlight) return
3121
+ // Nobody is looking at a hidden tab; the next visible moment asks.
3122
+ if (!isPageVisible()) return
2447
3123
  stateStore.inFlight = true
2448
3124
  try {
2449
3125
  const response = await fetch(HEALTH_URL, {
@@ -2482,8 +3158,9 @@
2482
3158
  stateStore.listeners.add(listener)
2483
3159
  if (stateStore.listeners.size === 1) {
2484
3160
  stateStore.controller = new AbortController()
2485
- pollOnce()
2486
- stateStore.timer = setInterval(pollOnce, POLL_MS)
3161
+ bindVisibility()
3162
+ pollOnce().then(armPoll)
3163
+ armPoll()
2487
3164
  }
2488
3165
  return () => {
2489
3166
  stateStore.listeners.delete(listener)
@@ -2492,6 +3169,7 @@
2492
3169
  clearInterval(stateStore.timer)
2493
3170
  stateStore.timer = null
2494
3171
  }
3172
+ stateStore.interval = 0
2495
3173
  if (stateStore.controller) {
2496
3174
  stateStore.controller.abort()
2497
3175
  stateStore.controller = null
@@ -2688,6 +3366,59 @@
2688
3366
  return DEFAULT_CANCEL_URL
2689
3367
  }
2690
3368
 
3369
+ /**
3370
+ * The route that restores ONE backup the user picked (the manual
3371
+ * rollback): the one the Host sent with the state payload, or the derived
3372
+ * default. It never carries a path — only a backup NAME goes in the body.
3373
+ */
3374
+ function rollbackUrlFor(info) {
3375
+ const sent = info && typeof info.rollbackUrl === 'string' ? info.rollbackUrl.trim() : ''
3376
+ if (sent.startsWith('/')) return sent
3377
+ return DEFAULT_ROLLBACK_URL
3378
+ }
3379
+
3380
+ /**
3381
+ * The route that writes the backup copy limit (user decision, 2026-11-02).
3382
+ *
3383
+ * THE BUG THIS FIXES (user report, 2026-11-02: "2 份改 3 份不生效"): the
3384
+ * Host sends `healthUrl` as a FULL URL (`http://127.0.0.1:<port>/…/health`),
3385
+ * and this function only accepted a value starting with `/` — so it fell
3386
+ * through to `''`, and `submitBackupLimit` returned before it ever called
3387
+ * `fetch`. Nothing was sent, nothing was said, and the number did not move.
3388
+ * The sibling of health is the same path with its last segment replaced,
3389
+ * whether the Host spelled it as a path or as an absolute URL; when it
3390
+ * sends neither, the derived default is used — exactly like the rollback
3391
+ * route, which never had this problem.
3392
+ */
3393
+ function backupLimitUrlFor(info) {
3394
+ const sent = info && typeof info.backupLimitUrl === 'string' ? info.backupLimitUrl.trim() : ''
3395
+ if (sent.startsWith('/')) return sent
3396
+ const health = info && typeof info.healthUrl === 'string' ? info.healthUrl.trim() : ''
3397
+ const asSibling = health.replace(/\/[^/?]+(\?.*)?$/, '/backup-limit')
3398
+ // An absolute URL keeps only its path: every write in this client is a
3399
+ // same-origin path, and the page's own origin is the instance it talks to.
3400
+ if (/^https?:\/\//i.test(health)) return asSibling.replace(/^https?:\/\/[^/]+/i, '')
3401
+ if (health.startsWith('/')) return asSibling
3402
+ return DEFAULT_BACKUP_LIMIT_URL
3403
+ }
3404
+
3405
+ /**
3406
+ * The two READ-ONLY sources the failure panel merges (2026-11-01):
3407
+ * what the loaded plugins stopped providing, and how each plugin compares
3408
+ * with the newest backup. Both are plain GET routes.
3409
+ */
3410
+ function pluginWatchUrlFor(info) {
3411
+ const sent = info && typeof info.pluginWatchUrl === 'string' ? info.pluginWatchUrl.trim() : ''
3412
+ if (sent.startsWith('/')) return sent
3413
+ return '/__dsh_autorestart/plugin-watch'
3414
+ }
3415
+
3416
+ function pluginInventoryUrlFor(info) {
3417
+ const sent = info && typeof info.pluginInventoryUrl === 'string' ? info.pluginInventoryUrl.trim() : ''
3418
+ if (sent.startsWith('/')) return sent
3419
+ return '/__dsh_autorestart/plugin-inventory'
3420
+ }
3421
+
2691
3422
  /**
2692
3423
  * Whether the one-time install prompt may be shown.
2693
3424
  *
@@ -3314,8 +4045,8 @@
3314
4045
  // waiting for the quiet window — not for a conversation.
3315
4046
  const gateReason = info && typeof info.reason === 'string' ? info.reason : ''
3316
4047
 
3317
- // The long wait: the Host says that the restart passed the hard
3318
- // deadline while a conversation was still running. The capsule shows one
4048
+ // The long wait: the Host says that the restart is still waiting while a
4049
+ // conversation runs on. The capsule shows one
3319
4050
  // more line about it — how long it has been waiting and how many tasks
3320
4051
  // are running.
3321
4052
  const longWait = formatLongWaitNoteParts(info, t)
@@ -3817,13 +4548,58 @@
3817
4548
  // on right now", the state file alone does not.
3818
4549
  const pending = Boolean(info && info.pendingRestart === true)
3819
4550
  // THE ONLY TWO STAGES THAT ARE "SOMETHING IS REALLY HAPPENING": the
3820
- // snapshot is being written, or the host is on its way out.
3821
- const running = pending && (phase === 'snapshotting' || phase === 'restarting')
4551
+ // snapshot is being written, or the host is on its way out — AND the
4552
+ // stage must still be fresh. A stage nobody refreshed (the restart was
4553
+ // postponed and the Host did not say so) cannot leave a card on screen
4554
+ // for ever; the pill still says the restart is pending.
4555
+ const running = pending && isRestartStageLive(stage, Date.now())
3822
4556
 
3823
4557
  useEffect(() => {
3824
4558
  if (running) setSawRunning(true)
3825
4559
  }, [running])
3826
4560
 
4561
+ // THE POSTPONED CARD IS AN ANNOUNCEMENT, NOT A PROGRESS BAR (user report,
4562
+ // 2026-10-30): "after the interruption I still have a lot of work to do — a
4563
+ // card hovering for ever is in the way". It says what happened for four
4564
+ // seconds and then folds away; the capsule keeps saying the restart is
4565
+ // pending. A NEW postponement (a new stage timestamp) announces itself
4566
+ // again. Hooks live up here, with the others, because they must run on
4567
+ // every render — including the renders that draw nothing.
4568
+ const deferredKey = pending && isRestartStageDeferred(stage)
4569
+ ? (Number.isFinite(Number(stage.at)) ? Number(stage.at) : 0)
4570
+ : null
4571
+ const [announcedKey, setAnnouncedKey] = useState(null)
4572
+ const [folded, setFolded] = useState(false)
4573
+
4574
+ useEffect(() => {
4575
+ if (deferredKey === null) {
4576
+ if (announcedKey !== null) {
4577
+ setAnnouncedKey(null)
4578
+ setFolded(false)
4579
+ }
4580
+ return undefined
4581
+ }
4582
+ if (announcedKey === deferredKey) return undefined
4583
+ setAnnouncedKey(deferredKey)
4584
+ setFolded(false)
4585
+ return undefined
4586
+ }, [deferredKey, announcedKey])
4587
+
4588
+ // The four seconds of THIS announcement.
4589
+ useEffect(() => {
4590
+ if (announcedKey === null || folded) return undefined
4591
+ const timer = setTimeout(() => setFolded(true), RESTART_DEFERRED_CARD_MS)
4592
+ return () => clearTimeout(timer)
4593
+ }, [announcedKey, folded])
4594
+
4595
+ // A POSTPONED RESTART IS NOT A RUNNING ONE (user report, 2026-10-30): when
4596
+ // the Host says the restart went back to waiting because a conversation
4597
+ // started, the card says exactly that. It is drawn AT ONCE (a fresh
4598
+ // postponement must not wait for a second render) and the hook above
4599
+ // records which postponement has been announced, so its four seconds can
4600
+ // run out; every new stage timestamp resets the clock.
4601
+ const postponed = deferredKey !== null && !folded
4602
+
3827
4603
  // "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
3828
4604
  useEffect(() => {
3829
4605
  if (phase !== 'back') {
@@ -3834,8 +4610,9 @@
3834
4610
  return () => clearTimeout(timer)
3835
4611
  }, [phase])
3836
4612
 
3837
- // THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS.
3838
- const visible = running || (phase === 'back' && sawRunning && !backGone)
4613
+ // THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS — or while
4614
+ // the restart is honestly postponed.
4615
+ const visible = running || postponed || (phase === 'back' && sawRunning && !backGone)
3839
4616
  if (!visible) return null
3840
4617
 
3841
4618
  const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
@@ -3845,11 +4622,32 @@
3845
4622
  ? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
3846
4623
  : null
3847
4624
 
3848
- const title = restartOverlayTitle(stage, t)
4625
+ // The postponed card wins over everything else: even a stage that still
4626
+ // says 'restarting' in the same render is downgraded here, so the old word
4627
+ // can never survive a single frame once the Host said "deferred".
4628
+ const deferredCount = postponed ? waitingTaskCount(info, info && Array.isArray(info.blockedBy) ? info.blockedBy : []) : 0
4629
+ const title = postponed
4630
+ ? t('restart.overlay.deferred')
4631
+ : restartOverlayTitle(stage, t)
3849
4632
 
3850
4633
  let bar = null
3851
4634
  let detail = null
3852
- if (phase === 'snapshotting') {
4635
+ if (postponed) {
4636
+ // A second, smaller line: what happens next, and how many conversations
4637
+ // are in the way (the same number the capsule shows).
4638
+ detail = h(
4639
+ 'p',
4640
+ { className: 'dsh-autorestart-restart-overlay__size' },
4641
+ h(
4642
+ 'span',
4643
+ { 'data-dsh-autorestart-restart-overlay-deferred': 'true' },
4644
+ deferredCount > 0
4645
+ ? fill(t('restart.overlay.deferredNote'), { count: deferredCount })
4646
+ : t('restart.overlay.deferredNoteZero'),
4647
+ ),
4648
+ )
4649
+ }
4650
+ if (!postponed && phase === 'snapshotting') {
3853
4651
  // The numbers are the ones the copy loop really measured; when there is
3854
4652
  // no measurement yet, the card shows no number at all (an invented
3855
4653
  // progress bar is worse than none). The unit fits the size, so a 92 KB
@@ -3894,6 +4692,9 @@
3894
4692
  'aria-live': 'polite',
3895
4693
  'data-dsh-autorestart-restart-overlay': 'true',
3896
4694
  'data-dsh-autorestart-restart-overlay-phase': phase,
4695
+ // WHAT THE CARD SAYS, independent of the Host phase: a postponed
4696
+ // restart is drawn from 'waiting'.
4697
+ 'data-dsh-autorestart-restart-overlay-card': postponed ? 'deferred' : phase,
3897
4698
  },
3898
4699
  h(
3899
4700
  'p',
@@ -4906,6 +5707,190 @@
4906
5707
  ' line-height:18px;',
4907
5708
  ' color:var(--dsw-alias-label-secondary);',
4908
5709
  '}',
5710
+ // WHAT a copy is (user decision, 2026-10-30): the identity is the first
5711
+ // thing read on a row, so it sits on its own line and is not muted.
5712
+ '.dsh-autorestart-settings__backup-identity{',
5713
+ ' display:flex;',
5714
+ ' flex-wrap:wrap;',
5715
+ ' gap:8px;',
5716
+ ' align-items:baseline;',
5717
+ '}',
5718
+ '.dsh-autorestart-settings__backup-badge{',
5719
+ ' font-size:13px;',
5720
+ ' line-height:18px;',
5721
+ ' font-weight:600;',
5722
+ ' color:var(--dsw-alias-label-primary);',
5723
+ '}',
5724
+ '.dsh-autorestart-settings__backup-actions{',
5725
+ ' display:flex;',
5726
+ ' flex-wrap:wrap;',
5727
+ ' gap:8px;',
5728
+ ' align-items:baseline;',
5729
+ '}',
5730
+ // EVERY COPY THAT IS NOT THE ONE (user decision, 2026-11-02): one small
5731
+ // line each, clearly quieter than the card above it, with no button.
5732
+ '.dsh-autorestart-settings__backup-other{',
5733
+ ' display:flex;',
5734
+ ' flex-wrap:wrap;',
5735
+ ' gap:6px;',
5736
+ ' margin-top:8px;',
5737
+ ' align-items:baseline;',
5738
+ '}',
5739
+ // THE PER-PLUGIN RECOVERY: a plain list of one row per plugin, the name
5740
+ // in monospace so a package name is recognisable at a glance.
5741
+ '.dsh-autorestart-settings__plugins{',
5742
+ ' display:flex;',
5743
+ ' flex-direction:column;',
5744
+ ' gap:6px;',
5745
+ ' margin:0;',
5746
+ ' padding:0;',
5747
+ ' list-style:none;',
5748
+ '}',
5749
+ '.dsh-autorestart-settings__plugin-row{',
5750
+ ' display:grid;',
5751
+ ' grid-template-columns:1fr auto auto;',
5752
+ ' align-items:center;',
5753
+ ' gap:10px;',
5754
+ ' padding:6px 8px;',
5755
+ ' border-radius:8px;',
5756
+ ' background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,0.04));',
5757
+ '}',
5758
+ '.dsh-autorestart-settings__plugin-name{',
5759
+ ' font-family:monospace;',
5760
+ ' font-size:12px;',
5761
+ ' word-break:break-all;',
5762
+ '}',
5763
+ '.dsh-autorestart-settings__plugin-status{',
5764
+ ' font-size:12px;',
5765
+ ' color:var(--dsw-alias-label-secondary);',
5766
+ ' white-space:nowrap;',
5767
+ '}',
5768
+ '.dsh-autorestart-settings__plugin-hint{',
5769
+ ' grid-column:1 / -1;',
5770
+ ' font-size:11px;',
5771
+ ' line-height:1.4;',
5772
+ ' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
5773
+ '}',
5774
+ // A HOST THAT CANNOT RESTART (user decision, 2026-11-02): a permanent
5775
+ // strip at the top of the page. It borrows the error colour of the host
5776
+ // theme, so it reads as a warning in both light and dark themes.
5777
+ '.dsh-autorestart-settings__banner{',
5778
+ ' display:flex;',
5779
+ ' flex-direction:column;',
5780
+ ' gap:8px;',
5781
+ ' margin:0 0 12px;',
5782
+ ' padding:10px 12px;',
5783
+ ' border:1px solid var(--dsw-alias-state-error-border,var(--dsw-alias-border-l1));',
5784
+ ' border-left:3px solid var(--dsw-alias-state-error-fg,#c62828);',
5785
+ ' border-radius:8px;',
5786
+ ' background:var(--dsw-alias-bg-layer-1);',
5787
+ '}',
5788
+ '.dsh-autorestart-settings__banner-text{',
5789
+ ' color:var(--dsw-alias-label-primary);',
5790
+ ' font-size:13px;',
5791
+ ' line-height:19px;',
5792
+ '}',
5793
+ '.dsh-autorestart-settings__banner-actions{display:flex;flex-wrap:wrap;gap:8px;}',
5794
+ '.dsh-autorestart-settings__banner-note,.dsh-autorestart-settings__card-note{',
5795
+ ' color:var(--dsw-alias-state-error-fg,var(--dsw-alias-label-secondary));',
5796
+ ' font-size:12px;',
5797
+ ' line-height:18px;',
5798
+ '}',
5799
+ // THE ONE-TIME CARD: the same shape as the install prompt, so the user
5800
+ // recognises "a question, then it is gone".
5801
+ '.dsh-autorestart-settings__card{',
5802
+ ' display:flex;',
5803
+ ' flex-direction:column;',
5804
+ ' gap:8px;',
5805
+ ' margin:0 0 12px;',
5806
+ ' padding:14px 16px;',
5807
+ ' border:1px solid var(--dsw-alias-border-l1);',
5808
+ ' border-radius:12px;',
5809
+ ' background:var(--dsw-alias-bg-layer-1);',
5810
+ ' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
5811
+ '}',
5812
+ '.dsh-autorestart-settings__card-title{',
5813
+ ' margin:0;',
5814
+ ' font-size:14px;',
5815
+ ' font-weight:600;',
5816
+ ' line-height:20px;',
5817
+ ' color:var(--dsw-alias-label-primary);',
5818
+ '}',
5819
+ '.dsh-autorestart-settings__card-actions{display:flex;flex-wrap:wrap;gap:8px;}',
5820
+ // THE LINE UNDER THE FOLD TITLE: small, secondary, never a substitute for
5821
+ // the title itself.
5822
+ '.dsh-autorestart-settings__summary-note{',
5823
+ ' display:block;',
5824
+ ' margin-top:2px;',
5825
+ ' font-size:12px;',
5826
+ ' line-height:16px;',
5827
+ ' font-weight:400;',
5828
+ ' color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));',
5829
+ '}',
5830
+ // ONE RESCUE SCRIPT: its name, its real path, and a copy button — the
5831
+ // path is the only thing that helps when the host will not start.
5832
+ '.dsh-autorestart-settings__rescue-path{',
5833
+ ' display:flex;',
5834
+ ' flex-wrap:wrap;',
5835
+ ' gap:8px;',
5836
+ ' align-items:baseline;',
5837
+ ' padding:6px 0;',
5838
+ '}',
5839
+ // THE DESKTOP ENTRY, DRAWN AS IT WILL LOOK (user report, 2026-11-02):
5840
+ // a tile with DSH on it and the file name next to it, so "what am I
5841
+ // looking for on my desktop?" is answered before the button is pressed.
5842
+ '.dsh-autorestart-settings__shortcut{',
5843
+ ' display:flex;',
5844
+ ' align-items:center;',
5845
+ ' gap:8px;',
5846
+ '}',
5847
+ '.dsh-autorestart-settings__shortcut-icon{',
5848
+ ' display:inline-flex;',
5849
+ ' align-items:center;',
5850
+ ' justify-content:center;',
5851
+ ' width:26px;',
5852
+ ' height:26px;',
5853
+ ' flex:none;',
5854
+ ' border-radius:6px;',
5855
+ ' background:var(--dsw-alias-brand-primary,#4d6bfe);',
5856
+ ' color:#fff;',
5857
+ ' font-size:9px;',
5858
+ ' font-weight:700;',
5859
+ ' letter-spacing:.02em;',
5860
+ '}',
5861
+ '.dsh-autorestart-settings__shortcut-name{',
5862
+ ' font-size:12.5px;',
5863
+ ' line-height:18px;',
5864
+ ' color:var(--dsw-alias-label-primary);',
5865
+ ' word-break:break-all;',
5866
+ '}',
5867
+ // THE REAL PICTURE, WHEN THE HOST SENT ONE (2026-11-02): the tile stops
5868
+ // being a stand-in — no brand background, no 26px box — and shows the
5869
+ // bytes the desktop shortcut carries. 16px keeps it on the text baseline.
5870
+ '.dsh-autorestart-settings__shortcut-icon[data-icon-real="true"]{',
5871
+ ' background:transparent;',
5872
+ ' width:16px;',
5873
+ ' height:16px;',
5874
+ ' border-radius:2px;',
5875
+ '}',
5876
+ '.dsh-autorestart-settings__shortcut-icon-img{',
5877
+ ' display:block;',
5878
+ ' width:16px;',
5879
+ ' height:16px;',
5880
+ ' border-radius:2px;',
5881
+ ' image-rendering:auto;',
5882
+ '}',
5883
+ // THE SECOND CONFIRMATION of a manual rollback: a block that stands
5884
+ // apart from the list, because it is a question, not a fact.
5885
+ '.dsh-autorestart-settings__confirm{',
5886
+ ' display:flex;',
5887
+ ' flex-direction:column;',
5888
+ ' gap:6px;',
5889
+ ' padding:10px 12px;',
5890
+ ' border:1px solid var(--dsw-alias-border-l1);',
5891
+ ' border-radius:8px;',
5892
+ ' background:var(--dsw-alias-bg-l1);',
5893
+ '}',
4909
5894
  // The folded "technical details": the command itself stays out of the
4910
5895
  // way until somebody asks for it.
4911
5896
  '.dsh-autorestart-settings__details{',
@@ -5417,9 +6402,110 @@
5417
6402
  // a fresh page starts closed, so nobody leaves technical text open by
5418
6403
  // accident.
5419
6404
  const [detailsOpen, setDetailsOpen] = useState(false)
5420
- const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
5421
- // THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
5422
- // THIS panel (it is not a window overlay), so it is read here and
6405
+ // The two folds of the BACKUP group (user decision, 2026-11-02): the long
6406
+ // explanation and the per-copy technical rows are collapsed by default,
6407
+ // while every switch stays visible. Both use the neutral label
6408
+ // `settings.backup.details` (added with the user's approval).
6409
+ const [backupDetailsOpen, setBackupDetailsOpen] = useState(false)
6410
+ const [backupListDetailsOpen, setBackupListDetailsOpen] = useState(false)
6411
+ // THE WHOLE BACKUP BLOCK IS ONE FOLD (user decision, 2026-11-02): the
6412
+ // four switches stay visible, everything that only EXPLAINS the backups
6413
+ // (the count, the rules, the copy cards, the path and the per-plugin
6414
+ // recovery) lives behind `settings.backup.section`. Closed by default —
6415
+ // except on a boot the watchdog had to rescue, where the one copy that
6416
+ // matters must not be hidden (see the fold below).
6417
+ const [backupSectionOpen, setBackupSectionOpen] = useState(false)
6418
+ // The RESCUE script paths on Windows: behind 详情, because the desktop
6419
+ // entry is the main door there (audit finding G1, 2026-11-02). Never
6420
+ // folded on the other platforms and never folded when there is no
6421
+ // desktop entry to press.
6422
+ const [rescuePathsOpen, setRescuePathsOpen] = useState(false)
6423
+ // THE ONE-TIME "this host cannot restart" CARD (user decision,
6424
+ // 2026-11-02). `restartCardPending` is the Host's answer ("read once per
6425
+ // host"), `restartCardDone` only hides it in this session right after
6426
+ // the click, and `restartFixNote` carries the reason of a failed
6427
+ // attempt. Nothing is ever written without the click.
6428
+ const [restartCardPending, setRestartCardPending] = useState(false)
6429
+ const [restartCardDone, setRestartCardDone] = useState(false)
6430
+ const [restartFixNote, setRestartFixNote] = useState('')
6431
+ // The command this page just wrote ('' = none). It hides the strip at
6432
+ // once and shows what is configured NOW, instead of waiting for the next
6433
+ // health poll — the user clicked a button and must see the result.
6434
+ const [restartApplied, setRestartApplied] = useState('')
6435
+ // THE MANUAL ROLLBACK (user decision, 2026-10-30). Asking is a STATE,
6436
+ // not a `window.confirm`: the card has to say WHICH copy, that the
6437
+ // current state is replaced, and that a restart follows. `null` = nobody
6438
+ // is asking, and only a confirmation ever calls the route.
6439
+ // THE COPY LIMIT (user decision, 2026-11-02): the number the user picks,
6440
+ // the confirmation that stands between a smaller number and deleted
6441
+ // copies, and what the route reported back afterwards.
6442
+ const [limitBusy, setLimitBusy] = useState(false)
6443
+ const [limitNote, setLimitNote] = useState('')
6444
+ const [limitAsk, setLimitAsk] = useState(null)
6445
+ // The value the select SHOWS: seeded from the payload, replaced by the
6446
+ // answer, so the number on screen is the number in force right after the
6447
+ // write instead of waiting for the next poll.
6448
+ const [limitValue, setLimitValue] = useState(null)
6449
+
6450
+ /**
6451
+ * WRITE THE COPY LIMIT (user decision, 2026-11-02).
6452
+ *
6453
+ * The route answers with the copies it really deleted and with
6454
+ * `keptWillExceedRequest`, so this reports the truth instead of echoing
6455
+ * the number the user asked for. A refusal carries the Host's `reason`
6456
+ * and is shown as-is — rewriting it would hide which value was refused.
6457
+ */
6458
+ const submitBackupLimit = async (wanted) => {
6459
+ const url = backupLimitUrlFor(info)
6460
+ // A WRITE THAT CANNOT BE ADDRESSED MUST SAY SO (user report,
6461
+ // 2026-11-02): this used to `return` silently, which is exactly how
6462
+ // "2 份改 3 份不生效" looked from the outside — no request, no message,
6463
+ // no change. The default route makes this branch unreachable, and if a
6464
+ // future change makes it reachable again the page says it failed
6465
+ // instead of pretending nothing happened.
6466
+ if (url === '') {
6467
+ setLimitNote(t('settings.backup.limitFailed'))
6468
+ return
6469
+ }
6470
+ setLimitBusy(true)
6471
+ setLimitNote('')
6472
+ try {
6473
+ const response = await fetch(url, {
6474
+ method: 'POST',
6475
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
6476
+ body: JSON.stringify({ maxBackups: wanted }),
6477
+ })
6478
+ const answer = await response.json().catch(() => null)
6479
+ setLimitAsk(null)
6480
+ if (response.ok !== true || answer === null || answer.ok !== true) {
6481
+ setLimitNote(answer && typeof answer.reason === 'string' && answer.reason !== ''
6482
+ ? answer.reason
6483
+ : t('settings.backup.limitFailed'))
6484
+ return
6485
+ }
6486
+ const removed = Array.isArray(answer.removed) ? answer.removed.length : 0
6487
+ const kept = Number.isFinite(Number(answer.kept)) ? Number(answer.kept) : wanted
6488
+ setLimitValue(Number.isFinite(Number(answer.maxBackups)) ? Number(answer.maxBackups) : wanted)
6489
+ // BOTH READINGS MOVE TOGETHER (user report, 2026-11-02): the select
6490
+ // shows the confirmed number at once, and this poll brings the two
6491
+ // lines above it to the same value instead of leaving them saying the
6492
+ // old limit until the next 2 s tick.
6493
+ void pollOnce()
6494
+ setLimitNote(answer.keptWillExceedRequest === true
6495
+ ? fill(t('settings.backup.limitMore'), { requested: String(wanted), kept: String(kept) })
6496
+ : fill(t('settings.backup.limitDeleted'), { removed: String(removed), kept: String(kept) }))
6497
+ } catch {
6498
+ setLimitNote(t('settings.backup.limitFailed'))
6499
+ } finally {
6500
+ setLimitBusy(false)
6501
+ }
6502
+ }
6503
+ const [rollbackAsk, setRollbackAsk] = useState(null)
6504
+ const [rollbackBusy, setRollbackBusy] = useState(false)
6505
+ const [rollbackNote, setRollbackNote] = useState('')
6506
+ const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
6507
+ // THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
6508
+ // THIS panel (it is not a window overlay), so it is read here and
5423
6509
  // drawn here.
5424
6510
  const saveNotice = useSaveNotice()
5425
6511
 
@@ -5506,12 +6592,55 @@
5506
6592
  const restartInfo = info && typeof info === 'object' && info.restart && typeof info.restart === 'object'
5507
6593
  ? info.restart
5508
6594
  : { mode: '', command: '', written: false, canRevert: false, previous: null }
6595
+ // CAN THIS HOST RESTART AT ALL? (user report, 2026-11-02)
6596
+ //
6597
+ // A hosted `dsh web` with no `command` restart mode is a host this
6598
+ // plugin cannot restart and will not back up before a restart — i.e. it
6599
+ // does nothing for the user. That used to be silent: the page looked
6600
+ // perfectly healthy. Both halves are required (`mode: 'command'` AND a
6601
+ // command), and the desktop app is never told this: it relaunches
6602
+ // itself.
6603
+ const restartModeValue = typeof restartInfo.mode === 'string' ? restartInfo.mode : ''
6604
+ const restartCommandValue = typeof restartInfo.command === 'string' ? restartInfo.command : ''
6605
+ const restartCapable = restartModeValue === 'command' && restartCommandValue.trim() !== ''
6606
+ // Only a Host that TOLD us about its restart settings can be accused of
6607
+ // having none: an older build (or the very first paint, before the state
6608
+ // arrived) must not produce a red strip.
6609
+ const restartKnown = Boolean(info && typeof info === 'object' && info.restart && typeof info.restart === 'object')
6610
+ const restartMissing = restartKnown && hostIsDesktop !== true && !restartCapable && restartApplied === ''
6611
+ // WHICH PROFILE WOULD BE BACKED UP (2026-11-02): the Host resolves it,
6612
+ // and `dir: null` means "more than one profile matched / none did" — so
6613
+ // backups and rollbacks are paused. The browser cannot look at the disk,
6614
+ // so this is only a DISPLAY of the Host's answer; when the Host does not
6615
+ // send it (older builds), nothing is claimed.
6616
+ const profileInfo = (() => {
6617
+ const source = info && typeof info === 'object' ? info.profile : null
6618
+ if (source && typeof source === 'object') return source
6619
+ if (typeof source === 'string') return { dir: source }
6620
+ const flat = info && typeof info === 'object' ? info.profileDir : null
6621
+ if (flat && typeof flat === 'object') return flat
6622
+ if (typeof flat === 'string') return { dir: flat }
6623
+ const fromRestart = info && typeof info === 'object' && info.lastRestart
6624
+ && typeof info.lastRestart === 'object' && info.lastRestart.snapshot
6625
+ && typeof info.lastRestart.snapshot === 'object'
6626
+ ? info.lastRestart.snapshot.profile
6627
+ : null
6628
+ return fromRestart && typeof fromRestart === 'object' ? fromRestart : null
6629
+ })()
6630
+ const profileUnknown = profileInfo !== null
6631
+ && (profileInfo.dir === null || profileInfo.dir === undefined || String(profileInfo.dir) === '')
5509
6632
  // THE BACKUPS ON DISK: the count, the local time, the size, whether the
5510
6633
  // plugins are in it and where it lives. The Host reads the directory.
5511
6634
  const backupsInfo = info && typeof info === 'object' && info.backups && typeof info.backups === 'object'
5512
6635
  ? info.backups
5513
6636
  : { dir: '', max: 0, count: 0, items: [] }
6637
+ // WHAT HAPPENED TO THE LAST RESTART REQUEST, computed once (2026-11-03).
6638
+ const cancelledRestart = cancelledRestartNote(info, props && typeof props.t === 'function' ? props.t : translate)
5514
6639
  const backupItems = Array.isArray(backupsInfo.items) ? backupsInfo.items : []
6640
+ // WHICH COPY THE ONE SENTENCE BELONGS TO (user decision, 2026-11-02):
6641
+ // resolved ONCE from the copies' own records, so the sentence follows the
6642
+ // copy and not the row it happens to be drawn in.
6643
+ const startupCopy = startupCopyName(backupItems)
5515
6644
  // THE RESCUE KIT: the two scripts and where they are, as the Host
5516
6645
  // resolved them. The browser never guesses a path.
5517
6646
  const rescuePaths = rescuePathsFor(info)
@@ -5520,6 +6649,54 @@
5520
6649
  : {}
5521
6650
  // Where a desktop shortcut could go ('' = there is no desktop here).
5522
6651
  const rescueDesktop = typeof rescueKit.desktopDir === 'string' ? rescueKit.desktopDir.trim() : ''
6652
+ // WHICH PLATFORM the rescue kit was resolved for, so the page can SAY
6653
+ // "there is no desktop on a hosted web / server install" instead of
6654
+ // hiding the whole block (user report, 2026-11-02).
6655
+ const rescuePlatform = typeof rescueKit.platform === 'string' ? rescueKit.platform : ''
6656
+ const rescueIsWindows = rescuePlatform === 'win32'
6657
+ // THE RESCUE ENTRY ON A HOST WITHOUT A DESKTOP (plan B, user decision
6658
+ // 2026-11-02). The desktop build offers a `.lnk` on the desktop; a hosted
6659
+ // web / server host has no desktop, so the same entry is a COPYABLE
6660
+ // COMMAND — the restore script the Host already resolved. '' means the
6661
+ // Host has none, and the page says so instead of inventing a path.
6662
+ const rescueCommand = (() => {
6663
+ const entry = rescuePaths.find((item) => item && item.key === 'restoreProfile')
6664
+ return entry && typeof entry.path === 'string' ? entry.path.trim() : ''
6665
+ })()
6666
+ // The file name of the shortcut on the desktop (a `.lnk` since
6667
+ // 2026-11-02 — the host names it, so page and disk cannot disagree).
6668
+ const rescueShortcutName = typeof rescueKit.shortcutName === 'string' && rescueKit.shortcutName !== ''
6669
+ ? rescueKit.shortcutName
6670
+ : 'DSH 出事了双击这里.lnk'
6671
+ // The icon the shortcut carries: THIS PLUGIN'S OWN `assets/icon.ico`.
6672
+ // It used to be DSH's executable, which put the face of the very program
6673
+ // that was broken on the rescue shortcut. '' = this install ships no icon
6674
+ // of its own: the `.lnk` is still created, it just gets no IconLocation —
6675
+ // never somebody else's face, and nothing is written at runtime.
6676
+ const rescueIconPath = typeof rescueKit.iconPath === 'string' ? rescueKit.iconPath.trim() : ''
6677
+ // THE SAME PICTURE THE DESKTOP CARRIES, DRAWN HERE (user requirement,
6678
+ // 2026-11-02): the row used to show a made-up "DSH" tile, so the page and
6679
+ // the `.lnk` on the desktop could look like two different things. The Host
6680
+ // now sends the icon as a data URI built from THE SAME BYTES as the file
6681
+ // it points `IconLocation` at, and this page renders that string as-is —
6682
+ // no base64 lives in this bundle, so the two copies cannot drift apart.
6683
+ //
6684
+ // ONLY A PNG DATA URI IS ACCEPTED. Anything else (another media type, a
6685
+ // remote URL, a missing field on an older Host) is treated as "no picture"
6686
+ // and the row falls back to its text tile: a wrong `src` would render a
6687
+ // broken-image marker, which is worse than saying nothing.
6688
+ const rescueIconDataUri = (() => {
6689
+ const raw = typeof rescueKit.iconDataUri === 'string' ? rescueKit.iconDataUri.trim() : ''
6690
+ return raw.startsWith('data:image/png;base64,') ? raw : ''
6691
+ })()
6692
+ // ONE SOURCE FOR THE ICON (user decision, 2026-11-02): the desktop `.lnk`
6693
+ // and this tile must come from the SAME host answer, and both must say
6694
+ // the same thing when there is none. The name travels with the path so
6695
+ // the page can prove which file it used — it never invents an icon.
6696
+ const rescueIconName = typeof rescueKit.iconName === 'string' && rescueKit.iconName !== ''
6697
+ ? rescueKit.iconName
6698
+ : (rescueIconPath === '' ? '' : rescueIconPath.split(/[\\/]/).pop())
6699
+ const rescueIconAvailable = rescueKit.iconAvailable === true || rescueIconPath !== ''
5523
6700
  // Whether it is already there (then the button says so).
5524
6701
  const rescueShortcutExists = rescueKit.shortcutExists === true
5525
6702
  // WHAT THIS MACHINE CAN DO: the automatic rollback needs a watchdog,
@@ -5544,6 +6721,18 @@
5544
6721
  const watchdogLast = watchdog.report && typeof watchdog.report === 'object' && typeof watchdog.report.result === 'string'
5545
6722
  ? watchdog.report.result
5546
6723
  : ''
6724
+ // DID THE WATCHDOG ACT, OR DID IT STAND DOWN FOR WANT OF A TRUSTED COPY?
6725
+ // (2026-11-01) `failed` alone is not enough for the user: the strict mode
6726
+ // deliberately does nothing when no copy qualifies, and that has to be
6727
+ // visible here — including WHICH copies were refused — otherwise "it did
6728
+ // not rescue me" and "it had nothing to rescue me with" look the same.
6729
+ const watchdogStrict = Boolean(
6730
+ watchdog.report && typeof watchdog.report === 'object' && watchdog.report.strictMode === true,
6731
+ )
6732
+ const watchdogRestoredFrom = watchdog.report && typeof watchdog.report === 'object'
6733
+ && typeof watchdog.report.restoredFrom === 'string' && watchdog.report.restoredFrom !== ''
6734
+ ? watchdog.report.restoredFrom
6735
+ : ''
5547
6736
  useEffect(() => {
5548
6737
  if (!schedulerNotice) return
5549
6738
  // The user has read it: the Host remembers that and stops asking.
@@ -5555,6 +6744,21 @@
5555
6744
  // The Host will ask again next time — no harm.
5556
6745
  })
5557
6746
  }, [schedulerNotice])
6747
+ // IS THE ONE-TIME "cannot restart" CARD STILL UNREAD? (2026-11-02)
6748
+ //
6749
+ // Same ledger as the feature announcement (same route, same key style,
6750
+ // same "answered once per host" rule) — the card is a reminder, not a
6751
+ // new mechanism.
6752
+ useEffect(() => {
6753
+ if (!restartMissing) return undefined
6754
+ let alive = true
6755
+ void (async () => {
6756
+ const pending = await fetchAnnouncements().catch(() => [])
6757
+ if (!alive) return
6758
+ setRestartCardPending(pending.some((entry) => entry.feature === ANNOUNCED_RESTART))
6759
+ })()
6760
+ return () => { alive = false }
6761
+ }, [restartMissing])
5558
6762
  const supervisorPending = supervisorStatus !== null
5559
6763
  && supervisorStatus.detail !== null
5560
6764
  && typeof supervisorStatus.detail === 'object'
@@ -5633,6 +6837,81 @@
5633
6837
  }
5634
6838
  }
5635
6839
 
6840
+ /**
6841
+ * THE ONE-CLICK REPAIR of a host that cannot restart (user decision,
6842
+ * 2026-11-02 — option A).
6843
+ *
6844
+ * THREE STEPS, and the click is the only reason anything is written:
6845
+ * 1. `dry-run`: the Host derives the command that starts this host
6846
+ * again and validates it. NOTHING is written;
6847
+ * 2. `apply` with `confirmed: true`: NOW the command is written into
6848
+ * the profile configuration (the Host refuses without it);
6849
+ * 3. the announcement is marked as read, so the card does not come
6850
+ * back, and the undo stays available (`revert`).
6851
+ *
6852
+ * A failure is never silent: the Host's own reason is shown in the
6853
+ * user's words, and the manual path (the supervisor group's
6854
+ * "derive / use this command") stays where it is.
6855
+ */
6856
+ const fixRestartCommand = async () => {
6857
+ if (busy) return
6858
+ setBusy(true)
6859
+ setError('')
6860
+ setRestartFixNote('')
6861
+ try {
6862
+ const dryResponse = await fetch(supervisorUrlFor(info), {
6863
+ method: 'POST',
6864
+ headers: { 'content-type': 'application/json' },
6865
+ body: JSON.stringify({ action: 'dry-run' }),
6866
+ })
6867
+ const dryBody = await dryResponse.json().catch(() => null)
6868
+ const plan = dryBody && typeof dryBody === 'object' && dryBody.plan && typeof dryBody.plan === 'object'
6869
+ ? dryBody.plan
6870
+ : null
6871
+ if (!dryResponse.ok || !plan || plan.ok !== true) {
6872
+ const reason = plan && typeof plan.reason === 'string' ? plan.reason.trim() : ''
6873
+ setRestartFixNote(reason === ''
6874
+ ? t('settings.supervisor.noCommand')
6875
+ : t('settings.supervisor.reason.' + reason))
6876
+ return
6877
+ }
6878
+ const applyResponse = await fetch(supervisorUrlFor(info), {
6879
+ method: 'POST',
6880
+ headers: { 'content-type': 'application/json' },
6881
+ body: JSON.stringify({ action: 'apply', confirmed: true }),
6882
+ })
6883
+ const applied = await applyResponse.json().catch(() => null)
6884
+ if (!applyResponse.ok || !applied || typeof applied !== 'object' || applied.ok !== true) {
6885
+ const message = applied && typeof applied === 'object' && applied.error
6886
+ ? String(applied.error)
6887
+ : String(applyResponse.status)
6888
+ setRestartFixNote(fill(t('supervisor.error'), { message }))
6889
+ return
6890
+ }
6891
+ await acknowledgeAnnouncement(ANNOUNCED_RESTART).catch(() => {})
6892
+ setRestartCardPending(false)
6893
+ setRestartCardDone(true)
6894
+ // WHAT IS CONFIGURED NOW, straight from the Host's answer (never
6895
+ // from what we hoped it wrote).
6896
+ const written = applied.writes && typeof applied.writes === 'object'
6897
+ && typeof applied.writes.restartCommand === 'string'
6898
+ ? applied.writes.restartCommand
6899
+ : ''
6900
+ setRestartApplied(written === '' ? t('settings.supervisor.noCommand') : written)
6901
+ } catch (failure) {
6902
+ setRestartFixNote(fill(t('supervisor.error'), { message: describeError(failure) }))
6903
+ } finally {
6904
+ setBusy(false)
6905
+ }
6906
+ }
6907
+
6908
+ /** "Later": the card does not come back (the strip stays). */
6909
+ const dismissRestartCard = () => {
6910
+ setRestartCardPending(false)
6911
+ setRestartCardDone(true)
6912
+ void acknowledgeAnnouncement(ANNOUNCED_RESTART).catch(() => {})
6913
+ }
6914
+
5636
6915
  /**
5637
6916
  * Write one change into the profile configuration.
5638
6917
  *
@@ -5645,6 +6924,46 @@
5645
6924
  *
5646
6925
  * @param {object} changes the keys to write
5647
6926
  */
6927
+ /**
6928
+ * THE MANUAL ROLLBACK (user decision, 2026-10-30).
6929
+ *
6930
+ * It is ONLY ever called from the confirmation card: the button on a row
6931
+ * opens the question, and nothing is posted until the user says yes. The
6932
+ * route takes a backup NAME (never a path) and does the rest on the Host
6933
+ * — save the current state, restore that copy, request a restart.
6934
+ *
6935
+ * @param {{ name?: string, atLocal?: string|null }|null} ask what the user picked
6936
+ */
6937
+ const rollbackTo = async (ask) => {
6938
+ const name = ask && typeof ask.name === 'string' ? ask.name : ''
6939
+ if (name === '' || rollbackBusy === true) return
6940
+ setRollbackBusy(true)
6941
+ setRollbackNote('')
6942
+ try {
6943
+ const response = await fetch(rollbackUrlFor(info), {
6944
+ method: 'POST',
6945
+ headers: { 'content-type': 'application/json' },
6946
+ body: JSON.stringify({ name }),
6947
+ })
6948
+ const body = await response.json().catch(() => null)
6949
+ if (!response.ok || !body || typeof body !== 'object' || body.ok !== true) {
6950
+ const message = body && typeof body === 'object'
6951
+ ? String(body.error || body.message || response.status)
6952
+ : String(response.status)
6953
+ setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message }))
6954
+ return
6955
+ }
6956
+ // The copy is back and a restart has been requested: the card goes
6957
+ // away, and the panel says what is happening right now.
6958
+ setRollbackAsk(null)
6959
+ setRollbackNote(t('settings.backup.rollbackDone'))
6960
+ } catch (failure) {
6961
+ setRollbackNote(fill(t('settings.backup.rollbackFailed'), { message: describeError(failure) }))
6962
+ } finally {
6963
+ setRollbackBusy(false)
6964
+ }
6965
+ }
6966
+
5648
6967
  const save = async (changes) => {
5649
6968
  if (busy) return
5650
6969
  setBusy(true)
@@ -5757,11 +7076,29 @@
5757
7076
  *
5758
7077
  * ONLY because the user pressed the button: the settings page never
5759
7078
  * writes anything to the desktop by itself.
7079
+ *
7080
+ * THE FAILURE IS CLASSIFIED (2026-11-02): "the shortcut was not placed"
7081
+ * is not enough — a policy that forbids it, a desktop that does not
7082
+ * exist and a write that broke are three different situations, and only
7083
+ * one of them is worth trying again. All three fall back to the manual
7084
+ * way, and the page says so with the real script path.
5760
7085
  */
5761
7086
  const createRescueShortcut = async () => {
5762
7087
  if (busy) return
5763
7088
  setBusy(true)
5764
7089
  setRescueNote('')
7090
+ const manual = typeof rescueKit.restoreProfile === 'string' ? rescueKit.restoreProfile.trim() : ''
7091
+ const failureText = (kind, message) => {
7092
+ const kindKey = kind === 'no-desktop'
7093
+ ? 'settings.rescue.noDesktop'
7094
+ : kind === 'blocked'
7095
+ ? 'settings.rescue.shortcutBlocked'
7096
+ : 'settings.rescue.shortcutWriteFailed'
7097
+ const head = kindKey === 'settings.rescue.shortcutWriteFailed'
7098
+ ? fill(t(kindKey), { message })
7099
+ : t(kindKey)
7100
+ return manual === '' ? head : head + ' ' + manual
7101
+ }
5765
7102
  try {
5766
7103
  const response = await fetch(rescueUrlFor(info), {
5767
7104
  method: 'POST',
@@ -5770,8 +7107,9 @@
5770
7107
  })
5771
7108
  const body = await response.json().catch(() => null)
5772
7109
  if (!response.ok) {
7110
+ const kind = body && typeof body === 'object' && typeof body.errorKind === 'string' ? body.errorKind : ''
5773
7111
  const message = body && typeof body === 'object' && body.error ? body.error : String(response.status)
5774
- setRescueNote(fill(t('settings.rescue.shortcutFailed'), { message }))
7112
+ setRescueNote(kind === '' ? fill(t('settings.rescue.shortcutFailed'), { message }) : failureText(kind, message))
5775
7113
  return
5776
7114
  }
5777
7115
  setRescueNote(t('settings.rescue.shortcutDone'))
@@ -5812,10 +7150,136 @@
5812
7150
  'div',
5813
7151
  { className: 'dsh-autorestart-settings' },
5814
7152
  // THE RECEIPT: a `sticky` strip at the TOP of this panel. It is a
7153
+ // WHAT HAPPENED TO THE RESTART YOU ASKED FOR (user report, 2026-11-03): it waited
7154
+ // hours, a host restart cancelled it, and the page looked as if nothing had ever
7155
+ // been requested. It comes first, because the next thing the user wants is to ask
7156
+ // again — and that button is right below.
7157
+ cancelledRestart ? h(
7158
+ 'div',
7159
+ {
7160
+ className: 'dsh-autorestart-settings__banner',
7161
+ 'data-dsh-autorestart-restart': 'cancelled-note',
7162
+ role: 'status',
7163
+ },
7164
+ h(
7165
+ 'span',
7166
+ { className: 'dsh-autorestart-settings__banner-text', 'data-dsh-autorestart-restart': 'cancelled-text' },
7167
+ cancelledRestart.text,
7168
+ ),
7169
+ ) : null,
5815
7170
  // child of the panel (the DOM ancestor of the message IS the
5816
7171
  // settings section), so it can not float over the conversation area
5817
7172
  // again — and it stays visible while the settings are scrolled.
5818
7173
  saveNoticeBar(t, saveNotice),
7174
+ // ③ THE RESULT OF THE CLICK: the command that is configured NOW. It is
7175
+ // shown where the strip was, so "it worked" is visible without opening
7176
+ // a fold and without waiting for the next poll.
7177
+ restartApplied !== ''
7178
+ ? h(
7179
+ 'div',
7180
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-restart-fixed': 'true' },
7181
+ h(
7182
+ 'span',
7183
+ { className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-restart-fixed': 'label' },
7184
+ t('settings.supervisor.command'),
7185
+ ),
7186
+ h(
7187
+ 'code',
7188
+ { className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-restart-fixed': 'command' },
7189
+ restartApplied,
7190
+ ),
7191
+ )
7192
+ : null,
7193
+ // ① A HOST THAT CANNOT RESTART, SAID AT THE TOP OF THE PAGE (user
7194
+ // decision, 2026-11-02 — option A).
7195
+ //
7196
+ // Without a `command` restart mode the plugin does nothing at all, and
7197
+ // that used to look exactly like a healthy install. The strip stays
7198
+ // while it is true, and it carries the ONE action that fixes it. It
7199
+ // never writes anything by itself: the button is the confirmation.
7200
+ restartMissing
7201
+ ? h(
7202
+ 'div',
7203
+ {
7204
+ className: 'dsh-autorestart-settings__banner',
7205
+ role: 'status',
7206
+ 'data-dsh-autorestart-restart-missing': 'true',
7207
+ },
7208
+ h(
7209
+ 'span',
7210
+ { className: 'dsh-autorestart-settings__banner-text', 'data-dsh-autorestart-restart-missing': 'text' },
7211
+ t('settings.restartBanner.title'),
7212
+ ),
7213
+ h(
7214
+ 'div',
7215
+ { className: 'dsh-autorestart-settings__banner-actions' },
7216
+ h(
7217
+ 'button',
7218
+ {
7219
+ type: 'button',
7220
+ className: 'dsh-autorestart-settings__button',
7221
+ disabled: busy,
7222
+ 'data-dsh-autorestart-restart-missing': 'fix',
7223
+ onClick: () => void fixRestartCommand(),
7224
+ },
7225
+ t('settings.restartBanner.fix'),
7226
+ ),
7227
+ ),
7228
+ restartFixNote === ''
7229
+ ? null
7230
+ : h(
7231
+ 'span',
7232
+ { className: 'dsh-autorestart-settings__banner-note', 'data-dsh-autorestart-restart-missing': 'note' },
7233
+ restartFixNote,
7234
+ ),
7235
+ )
7236
+ : null,
7237
+ // ② THE ONE-TIME CARD: same ledger as the feature announcement, so it
7238
+ // is shown once per host and never again after the user answered it.
7239
+ restartMissing && restartCardPending && !restartCardDone
7240
+ ? h(
7241
+ 'div',
7242
+ { className: 'dsh-autorestart-settings__card', 'data-dsh-autorestart-restart-card': 'true' },
7243
+ h(
7244
+ 'h3',
7245
+ { className: 'dsh-autorestart-settings__card-title', 'data-dsh-autorestart-restart-card': 'title' },
7246
+ t('settings.restartBanner.title'),
7247
+ ),
7248
+ h(
7249
+ 'div',
7250
+ { className: 'dsh-autorestart-settings__card-actions' },
7251
+ h(
7252
+ 'button',
7253
+ {
7254
+ type: 'button',
7255
+ className: 'dsh-autorestart-settings__button',
7256
+ disabled: busy,
7257
+ 'data-dsh-autorestart-restart-card': 'fix',
7258
+ onClick: () => void fixRestartCommand(),
7259
+ },
7260
+ t('settings.restartBanner.fix'),
7261
+ ),
7262
+ h(
7263
+ 'button',
7264
+ {
7265
+ type: 'button',
7266
+ className: 'dsh-autorestart-settings__button',
7267
+ disabled: busy,
7268
+ 'data-dsh-autorestart-restart-card': 'later',
7269
+ onClick: () => dismissRestartCard(),
7270
+ },
7271
+ t('supervisor.later'),
7272
+ ),
7273
+ ),
7274
+ restartFixNote === ''
7275
+ ? null
7276
+ : h(
7277
+ 'span',
7278
+ { className: 'dsh-autorestart-settings__card-note', 'data-dsh-autorestart-restart-card': 'note' },
7279
+ restartFixNote,
7280
+ ),
7281
+ )
7282
+ : null,
5819
7283
  // The panel is split into two titled groups: the RESTART settings
5820
7284
  // come first, the SHUTDOWN settings after them. The two situations
5821
7285
  // are different, so they do not share one flat list.
@@ -5898,7 +7362,26 @@
5898
7362
  },
5899
7363
  },
5900
7364
  ),
5901
- h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backup.hint')),
7365
+ // THE EXPLANATION IS FOLDED (user decision, 2026-11-02): the switch
7366
+ // above stays on screen — only the words fold.
7367
+ h(
7368
+ 'details',
7369
+ {
7370
+ className: 'dsh-autorestart-settings__details',
7371
+ 'data-dsh-autorestart-backup': 'details',
7372
+ onToggle: (event) => {
7373
+ setBackupDetailsOpen(Boolean(event && event.target ? event.target.open : false))
7374
+ },
7375
+ },
7376
+ h('summary', { className: 'dsh-autorestart-settings__summary' }, t('settings.backup.details')),
7377
+ backupDetailsOpen
7378
+ ? h(
7379
+ 'span',
7380
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'content-hint' },
7381
+ t('settings.backup.hint'),
7382
+ )
7383
+ : null,
7384
+ ),
5902
7385
  ),
5903
7386
  h(
5904
7387
  'label',
@@ -5923,28 +7406,188 @@
5923
7406
  ),
5924
7407
  h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
5925
7408
  ),
5926
- // WHAT IS BACKED UP RIGHT NOW. The Host reads the directory (the
5927
- // browser cannot see the disk), so the numbers are real: the count,
5928
- // the local time of every copy, its size and whether the plugins
5929
- // are in it.
5930
- h(
5931
- 'div',
5932
- { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-backup': 'list' },
7409
+ // MORE THAN ONE PROFILE = NO BACKUPS AT ALL (2026-11-02): the Host
7410
+ // cannot tell which profile to copy, so backups and rollbacks are
7411
+ // paused. That is a fact about the whole block, so it sits ABOVE
7412
+ // the fold — and it is only ever shown when the Host actually said
7413
+ // so (`dir: null`); the browser never guesses.
7414
+ profileUnknown
7415
+ ? h(
7416
+ 'div',
7417
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-backup': 'multi-profile' },
7418
+ h(
7419
+ 'span',
7420
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'multi-profile-text' },
7421
+ t('settings.backup.multiProfile'),
7422
+ ),
7423
+ )
7424
+ : null,
7425
+ // WHAT IS BACKED UP RIGHT NOW — one FOLD (user decision, 2026-11-02).
7426
+ //
7427
+ // The words that only EXPLAIN the backups (the count, the rules, the
7428
+ // copy list, the path and the per-plugin recovery) made the page
7429
+ // long enough that the switches were pushed out of sight. They are
7430
+ // all behind `settings.backup.section` now, closed by default — but
7431
+ // NOT closed on a boot the watchdog had to rescue: an intervention
7432
+ // is the one fact that must not be hidden, so the fold opens itself
7433
+ // and says so on its summary.
7434
+ (() => {
7435
+ const anyIntervened = backupItems.some((item) => Boolean(item && item.intervention === true))
7436
+ const sectionOpen = backupSectionOpen || anyIntervened
7437
+ return h(
7438
+ 'details',
7439
+ {
7440
+ className: 'dsh-autorestart-settings__details',
7441
+ 'data-dsh-autorestart-backup': 'section',
7442
+ ...(sectionOpen ? { open: true } : {}),
7443
+ onToggle: (event) => {
7444
+ setBackupSectionOpen(Boolean(event && event.target ? event.target.open : false))
7445
+ },
7446
+ },
7447
+ h(
7448
+ 'summary',
7449
+ {
7450
+ className: 'dsh-autorestart-settings__summary',
7451
+ 'data-dsh-autorestart-backup': 'section-summary',
7452
+ },
7453
+ anyIntervened
7454
+ ? t('settings.backup.section') + ' · ' + t('settings.backup.intervention.true')
7455
+ : t('settings.backup.section'),
7456
+ ),
7457
+ // THE CONTENT IS ALWAYS IN THE TREE (only `<details>` hides it):
7458
+ // the panel is a few dozen nodes, and rendering them once keeps
7459
+ // the fold from re-mounting the buttons (and their state) every
7460
+ // time somebody opens it.
7461
+ h(
7462
+ 'div',
7463
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-backup': 'list' },
5933
7464
  h(
5934
7465
  'span',
5935
7466
  { className: 'dsh-autorestart-settings__label' },
5936
7467
  t('settings.backup.list'),
5937
7468
  ),
7469
+ // TWO LINES, AND ONLY TWO (user report, 2026-11-02). This block used
7470
+ // to spend five lines on the same three facts — twice on the
7471
+ // retention limit, and once with the number spelled out inside a
7472
+ // sentence, which went stale the moment the dropdown changed it.
7473
+ // Line 1: how many copies there are and what they take on disk.
5938
7474
  h(
5939
7475
  'span',
5940
7476
  { className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-backup': 'count' },
5941
- fill(t('settings.backup.count'), { n: backupItems.length }),
7477
+ fill(t('settings.backup.count'), {
7478
+ n: backupItems.length,
7479
+ size: formatBackupSize(
7480
+ backupItems.reduce((total, item) => total + (Number(item && item.bytes) || 0), 0),
7481
+ ),
7482
+ }),
5942
7483
  ),
7484
+ // Line 2: the rule that bounds them, with the limit in force — the
7485
+ // dropdown below is what changes that number.
5943
7486
  h(
5944
7487
  'span',
5945
7488
  { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'keep' },
5946
7489
  fill(t('settings.backup.keep'), { n: backupsInfo.max }),
5947
7490
  ),
7491
+ h(
7492
+ 'span',
7493
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'kinds-local' },
7494
+ t('settings.backup.kindsLocal'),
7495
+ ),
7496
+ h(
7497
+ 'span',
7498
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'kinds-third-party' },
7499
+ t('settings.backup.kindsThirdParty'),
7500
+ ),
7501
+ // THE COPY LIMIT IS EDITABLE HERE (user decision, 2026-11-02): a
7502
+ // dropdown of 1..10, defaulting to the value the Host reports. It
7503
+ // writes through the backup-limit route and the page re-reads the
7504
+ // state, so "the number shown" is always "the number in force".
7505
+ h(
7506
+ 'div',
7507
+ { className: 'dsh-autorestart-settings__backup-row', key: 'backup-limit' },
7508
+ h(
7509
+ 'span',
7510
+ { className: 'dsh-autorestart-settings__backup-label' },
7511
+ t('settings.backup.limitLabel'),
7512
+ ),
7513
+ h(
7514
+ 'select',
7515
+ {
7516
+ className: 'dsh-autorestart-settings__select',
7517
+ 'data-dsh-autorestart-backup': 'limit-select',
7518
+ 'data-dsh-autorestart-setting': 'maxBackups',
7519
+ value: String(limitValue === null ? backupsInfo.max : limitValue),
7520
+ disabled: limitBusy,
7521
+ onChange: (event) => {
7522
+ const wanted = Number(event.target.value)
7523
+ if (Number.isFinite(wanted) !== true) return
7524
+ // SHRINKING IS THE ONLY DANGEROUS DIRECTION: nothing is
7525
+ // written until the user confirms how many copies die.
7526
+ if (wanted < backupItems.length) {
7527
+ setLimitNote('')
7528
+ setLimitAsk({ wanted, from: backupItems.length, remove: backupItems.length - wanted })
7529
+ return
7530
+ }
7531
+ void submitBackupLimit(wanted)
7532
+ },
7533
+ },
7534
+ [2, 3, 4, 5, 6, 7, 8, 9, 10].map((value) =>
7535
+ h('option', { key: 'limit-' + value, value: String(value) }, String(value))),
7536
+ ),
7537
+ // THE DISK WARNING SITS TO THE RIGHT OF THE DROPDOWN (user
7538
+ // report, 2026-11-02): as its own line BELOW the control it
7539
+ // arrived after the choice had already been made.
7540
+ h(
7541
+ 'span',
7542
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'limit-hint' },
7543
+ t('settings.backup.limitHint'),
7544
+ ),
7545
+ ),
7546
+ limitAsk === null
7547
+ ? null
7548
+ : h(
7549
+ 'div',
7550
+ { className: 'dsh-autorestart-settings__backup-row', key: 'limit-ask' },
7551
+ h(
7552
+ 'span',
7553
+ { className: 'dsh-autorestart-settings__backup-label', 'data-dsh-autorestart-backup': 'limit-ask-body' },
7554
+ fill(t('settings.backup.limitAsk'), {
7555
+ remove: String(limitAsk.remove),
7556
+ from: String(limitAsk.from),
7557
+ to: String(limitAsk.wanted),
7558
+ }),
7559
+ ),
7560
+ h('button', {
7561
+ type: 'button',
7562
+ className: 'dsh-autorestart-announcement__cta',
7563
+ 'data-dsh-autorestart-backup': 'limit-confirm',
7564
+ disabled: limitBusy,
7565
+ onClick: () => { void submitBackupLimit(limitAsk.wanted) },
7566
+ }, t('settings.backup.limitConfirm')),
7567
+ h('button', {
7568
+ type: 'button',
7569
+ className: 'dsh-autorestart-announcement__cta',
7570
+ 'data-dsh-autorestart-backup': 'limit-cancel',
7571
+ // CANCELLING SENDS NOTHING: no route call, no write, no delete.
7572
+ onClick: () => { setLimitAsk(null); setLimitNote('') },
7573
+ }, t('settings.backup.limitCancel')),
7574
+ ),
7575
+ limitNote === ''
7576
+ ? null
7577
+ : h(
7578
+ 'span',
7579
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'limit-note' },
7580
+ limitNote,
7581
+ ),
7582
+ // THE TWO EXPLAINING PARAGRAPHS ARE GONE (user report, 2026-11-02).
7583
+ //
7584
+ // They said a backup has "one of two identities" and that only a
7585
+ // "safe" copy is ever rolled back — but since 2026-11-02 the list
7586
+ // has no tier at all: every copy is one row with its own rollback
7587
+ // button. Keeping the paragraph made the page contradict itself,
7588
+ // and a user who believes it will not dare press the button on an
7589
+ // older copy. What is left on this page is what is TRUE: the count,
7590
+ // the rules in force (`keep`), the rows themselves, the path.
5948
7591
  backupItems.length === 0
5949
7592
  ? h(
5950
7593
  'span',
@@ -5952,53 +7595,144 @@
5952
7595
  t('settings.backup.empty'),
5953
7596
  )
5954
7597
  : null,
7598
+ // ONE ROW PER COPY, AND ONE SENTENCE ON ONE COPY
7599
+ // (user decision, 2026-11-02).
7600
+ //
7601
+ // The page used to split the list into "the good copy" and
7602
+ // "everything else". That split was a promise the plugin cannot
7603
+ // keep: on a hosted instance nothing ever qualified, so the good
7604
+ // tier was empty forever and every real copy sat in the demoted
7605
+ // row. So there is no tier any more, and nothing is read from
7606
+ // `certifiedBy` / `kind` / `certificationEvidence` to draw a row.
7607
+ //
7608
+ // What is left is what can be said without judging: one row per
7609
+ // copy with its time and its own rollback button, plus the single
7610
+ // sentence that describes WHICH copy this is — the state the host
7611
+ // last started from. `startupCopy` is resolved from the copies' own
7612
+ // records (`startupCopyName`), never from the row order: a sentence
7613
+ // that followed the position would walk onto an older copy the day
7614
+ // the Host returns them in another order.
5955
7615
  backupItems.map((item, index) => h(
5956
7616
  'div',
5957
7617
  {
5958
7618
  className: 'dsh-autorestart-settings__backup',
5959
7619
  'data-dsh-autorestart-backup': 'item',
7620
+ 'data-backup-index': String(index),
5960
7621
  key: 'backup-' + index,
5961
7622
  },
5962
- // WHEN it was taken. No timezone offset: what is shown is
5963
- // already the local clock of the machine that took the copy.
5964
7623
  h(
5965
7624
  'div',
5966
7625
  { className: 'dsh-autorestart-settings__backup-row' },
5967
7626
  h(
5968
7627
  'span',
5969
- { className: 'dsh-autorestart-settings__backup-label' },
5970
- t('settings.backup.time'),
7628
+ {
7629
+ className: 'dsh-autorestart-settings__backup-value',
7630
+ 'data-dsh-autorestart-backup': 'time',
7631
+ },
7632
+ formatBackupTime(item && item.atLocal),
5971
7633
  ),
7634
+ // ROLL BACK TO THIS COPY. The button only OPENS the question —
7635
+ // the route is called from the confirmation card and nowhere
7636
+ // else, so a click alone never restores anything.
5972
7637
  h(
5973
- 'span',
5974
- { className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'time' },
5975
- formatBackupTime(item && item.atLocal),
7638
+ 'button',
7639
+ {
7640
+ type: 'button',
7641
+ className: 'dsh-autorestart-settings__button',
7642
+ disabled: busy === true || rollbackBusy === true,
7643
+ 'data-dsh-autorestart-backup': 'rollback',
7644
+ 'data-backup-name': item && typeof item.name === 'string' ? item.name : '',
7645
+ onClick: () => {
7646
+ setRollbackNote('')
7647
+ setRollbackAsk({
7648
+ name: item && typeof item.name === 'string' ? item.name : '',
7649
+ atLocal: item && typeof item.atLocal === 'string' ? item.atLocal : null,
7650
+ })
7651
+ },
7652
+ },
7653
+ t('settings.backup.rollback'),
5976
7654
  ),
5977
7655
  ),
5978
- // HOW BIG it is — and whether the plugins are in it, because
5979
- // that is WHY it is that big: the note belongs to the size.
5980
- h(
7656
+ // THE ONE SENTENCE, ON THE ONE COPY IT IS TRUE OF — matched by
7657
+ // the copy's own name, never by `index === 0`.
7658
+ startupCopy !== null && item && item.name === startupCopy
7659
+ ? h(
7660
+ 'div',
7661
+ {
7662
+ className: 'dsh-autorestart-settings__backup-note',
7663
+ 'data-dsh-autorestart-backup': 'startup-hint',
7664
+ },
7665
+ t('settings.backup.startupHint'),
7666
+ )
7667
+ : null,
7668
+ )),
7669
+ // THE SECOND CONFIRMATION OF A MANUAL ROLLBACK (user decision,
7670
+ // 2026-10-30): it names the copy, says that the current state is
7671
+ // replaced (and that it is saved first), and says that a restart
7672
+ // follows. Nothing is posted until the confirm button is pressed.
7673
+ rollbackAsk === null
7674
+ ? null
7675
+ : h(
5981
7676
  'div',
5982
- { className: 'dsh-autorestart-settings__backup-row' },
7677
+ {
7678
+ className: 'dsh-autorestart-settings__confirm',
7679
+ 'data-dsh-autorestart-backup': 'rollback-confirm',
7680
+ },
5983
7681
  h(
5984
7682
  'span',
5985
- { className: 'dsh-autorestart-settings__backup-label' },
5986
- t('settings.backup.size'),
7683
+ { className: 'dsh-autorestart-settings__label' },
7684
+ t('settings.backup.rollbackConfirmTitle'),
5987
7685
  ),
5988
7686
  h(
5989
7687
  'span',
5990
- { className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'size' },
5991
- formatBackupSize(item && item.bytes),
7688
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-body' },
7689
+ fill(t('settings.backup.rollbackConfirmBody'), { time: formatBackupTime(rollbackAsk.atLocal) }),
5992
7690
  ),
5993
7691
  h(
5994
7692
  'span',
5995
- { className: 'dsh-autorestart-settings__backup-note', 'data-dsh-autorestart-backup': 'plugins' },
5996
- item && item.includePlugins === true
5997
- ? t('settings.backup.withPlugins')
5998
- : t('settings.backup.withoutPlugins'),
7693
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-confirm-restart' },
7694
+ t('settings.backup.rollbackConfirmRestart'),
7695
+ ),
7696
+ h(
7697
+ 'div',
7698
+ { className: 'dsh-autorestart-settings__backup-actions' },
7699
+ h(
7700
+ 'button',
7701
+ {
7702
+ type: 'button',
7703
+ className: 'dsh-autorestart-settings__button',
7704
+ disabled: rollbackBusy === true,
7705
+ 'data-dsh-autorestart-backup': 'rollback-confirm-yes',
7706
+ onClick: () => void rollbackTo(rollbackAsk),
7707
+ },
7708
+ rollbackBusy === true
7709
+ ? t('settings.backup.rollbackBusy')
7710
+ : t('settings.backup.rollbackConfirmYes'),
7711
+ ),
7712
+ h(
7713
+ 'button',
7714
+ {
7715
+ type: 'button',
7716
+ className: 'dsh-autorestart-settings__button',
7717
+ disabled: rollbackBusy === true,
7718
+ 'data-dsh-autorestart-backup': 'rollback-confirm-no',
7719
+ onClick: () => {
7720
+ // Cancel: nothing was posted, nothing is restored.
7721
+ setRollbackAsk(null)
7722
+ setRollbackNote('')
7723
+ },
7724
+ },
7725
+ t('settings.backup.rollbackConfirmNo'),
7726
+ ),
5999
7727
  ),
6000
7728
  ),
6001
- )),
7729
+ rollbackNote === ''
7730
+ ? null
7731
+ : h(
7732
+ 'span',
7733
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-backup': 'rollback-note' },
7734
+ rollbackNote,
7735
+ ),
6002
7736
  backupsInfo.dir === ''
6003
7737
  ? null
6004
7738
  : h(
@@ -6033,7 +7767,9 @@
6033
7767
  pathNote,
6034
7768
  ),
6035
7769
  ),
6036
- ),
7770
+ ),
7771
+ )
7772
+ })(),
6037
7773
  ),
6038
7774
  h(
6039
7775
  'div',
@@ -6073,8 +7809,10 @@
6073
7809
  ),
6074
7810
  ),
6075
7811
  // The SUPERVISOR group: who starts this host, and whether the restart
6076
- // can work at all.
6077
- h(
7812
+ // can work at all. HIDDEN ON THE DESKTOP APP (user decision, 2026-11-02):
7813
+ // the desktop client relaunches itself, so there is no supervisor to
7814
+ // install and nothing in this group can be acted on.
7815
+ hostIsDesktop === true ? null : h(
6078
7816
  'div',
6079
7817
  {
6080
7818
  className: 'dsh-autorestart-settings__group',
@@ -6371,10 +8109,16 @@
6371
8109
  )
6372
8110
  : null,
6373
8111
  ),
6374
- // "IF DSH WILL NOT START": the two rescue scripts, where they are, and
6375
- // the way to get them onto the desktop. A user whose host is down
6376
- // cannot open a README — they need the path, and they need to be
6377
- // able to copy it.
8112
+ // "IF DSH WILL NOT START": the rescue scripts, where they are, and the
8113
+ // way to reach them. A user whose host is down cannot open a README —
8114
+ // they need the path, and they need to be able to copy it.
8115
+ //
8116
+ // BOTH ENDS SHOW IT (user decision, 2026-11-02, plan B): the desktop
8117
+ // build keeps its rescue entry, and the hosted / web page shows the
8118
+ // SAME group with the SAME title and the SAME entries — the only thing
8119
+ // that changes is that a hosted host has no desktop, so the
8120
+ // "double-click this .lnk" row becomes a COPYABLE COMMAND instead of
8121
+ // being explained away with "the web version has no desktop".
6378
8122
  h(
6379
8123
  'div',
6380
8124
  {
@@ -6386,71 +8130,231 @@
6386
8130
  { className: 'dsh-autorestart-settings__group-title' },
6387
8131
  t('settings.groups.rescue'),
6388
8132
  ),
8133
+ // The rescue group says ONE plain thing (user decision, 2026-11-02):
8134
+ // whether the automatic rollback can work here. The script paths,
8135
+ // "which one do I pick" and the overwrite warning are gone — a
8136
+ // worried user needs a status line and a button, not a manual.
8137
+ // THE TWO SCRIPTS THEMSELVES (audit finding G1, 2026-11-02).
8138
+ //
8139
+ // The page used to say "use the two .sh scripts above" while
8140
+ // rendering NOTHING — the worst possible dead end, because the user
8141
+ // reading it is exactly the one whose host will not start. The paths
8142
+ // come from the Host (the browser never guesses them) and each one
8143
+ // has a copy button.
8144
+ //
8145
+ // Where there IS a desktop entry, the paths sit behind 详情 (the
8146
+ // entry is the main door). Where there is NO desktop (a hosted host,
8147
+ // or a desktop app whose desktop directory could not be found) the
8148
+ // paths are the only way, so they are never folded — same shape on
8149
+ // both ends, keyed on "is there a desktop entry here", not on the OS.
8150
+ rescuePaths.length === 0
8151
+ ? null
8152
+ : (() => {
8153
+ const list = h(
8154
+ 'div',
8155
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'paths' },
8156
+ h(
8157
+ 'span',
8158
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'paths-hint' },
8159
+ t('settings.rescue.hint'),
8160
+ ),
8161
+ rescuePaths.map((entry) => h(
8162
+ 'div',
8163
+ { key: 'rescue-path-' + entry.key, className: 'dsh-autorestart-settings__rescue-path' },
8164
+ h('span', {
8165
+ className: 'dsh-autorestart-settings__label',
8166
+ 'data-dsh-autorestart-rescue': 'path-label',
8167
+ }, entry.key === 'restoreProfile' ? t('settings.rescue.restoreProfile') : t('settings.rescue.emergencyDisable')),
8168
+ h('code', {
8169
+ className: 'dsh-autorestart-settings__value',
8170
+ 'data-dsh-autorestart-rescue': 'path',
8171
+ 'data-rescue-key': entry.key,
8172
+ }, entry.path),
8173
+ h('button', {
8174
+ type: 'button',
8175
+ className: 'dsh-autorestart-settings__button',
8176
+ disabled: busy,
8177
+ 'data-dsh-autorestart-rescue': 'copy-path',
8178
+ 'data-rescue-key': entry.key,
8179
+ onClick: () => void copyRescuePath(entry.path),
8180
+ }, t('settings.rescue.copyPath')),
8181
+ )),
8182
+ )
8183
+ if (rescueDesktop === '') return list
8184
+ const pathsOpen = rescuePathsOpen || rescueDesktop === ''
8185
+ return h(
8186
+ 'details',
8187
+ {
8188
+ className: 'dsh-autorestart-settings__details',
8189
+ 'data-dsh-autorestart-rescue': 'paths-details',
8190
+ ...(pathsOpen ? { open: true } : {}),
8191
+ onToggle: (event) => setRescuePathsOpen(Boolean(event && event.target ? event.target.open : false)),
8192
+ },
8193
+ h(
8194
+ 'summary',
8195
+ { className: 'dsh-autorestart-settings__summary', 'data-dsh-autorestart-rescue': 'paths-summary' },
8196
+ t('settings.rescue.path'),
8197
+ ),
8198
+ list,
8199
+ )
8200
+ })(),
8201
+ // THE RESCUE ENTRY, SHOWN THE SAME WAY ON BOTH ENDS (plan B, user
8202
+ // decision 2026-11-02). On a machine WITH a desktop this is the
8203
+ // `.lnk` the user is looking for — its icon and its file name, so
8204
+ // "what am I looking for on the desktop?" is answered on screen, and
8205
+ // one button puts it there. On a host WITHOUT a desktop (a hosted web
8206
+ // / server install) the SAME entry is a COPYABLE COMMAND: copy it,
8207
+ // run it, and it does what the double-click would have done. The
8208
+ // group is never removed and never explained away — only the shape of
8209
+ // this one entry changes, and when the Host has no path at all the
8210
+ // page says so instead of inventing one.
6389
8211
  h(
6390
8212
  'div',
6391
- { className: 'dsh-autorestart-settings__field' },
6392
- h(
6393
- 'span',
6394
- { className: 'dsh-autorestart-settings__hint' },
6395
- t('settings.rescue.hint'),
6396
- ),
6397
- ),
6398
- rescuePaths.map((entry) => h(
6399
- 'div',
6400
- {
6401
- className: 'dsh-autorestart-settings__field',
6402
- 'data-dsh-autorestart-rescue': entry.key,
6403
- },
6404
- h(
6405
- 'span',
6406
- { className: 'dsh-autorestart-settings__label' },
6407
- t(entry.key === 'restoreProfile' ? 'settings.rescue.restoreProfile' : 'settings.rescue.emergencyDisable'),
6408
- ),
6409
- h(
6410
- 'code',
6411
- { className: 'dsh-autorestart-settings__value', 'data-dsh-autorestart-rescue': 'path' },
6412
- entry.path,
6413
- ),
6414
- h(
6415
- 'button',
6416
- {
6417
- type: 'button',
6418
- className: 'dsh-autorestart-settings__button',
6419
- disabled: busy,
6420
- 'data-dsh-autorestart-rescue': 'copy-path',
6421
- onClick: () => void copyRescuePath(entry.path),
6422
- },
6423
- t('settings.rescue.copyPath'),
6424
- ),
6425
- h(
6426
- 'button',
6427
- {
6428
- type: 'button',
6429
- className: 'dsh-autorestart-settings__button',
6430
- disabled: busy,
6431
- 'data-dsh-autorestart-rescue': 'reveal',
6432
- onClick: () => void revealRescueFolder(),
6433
- },
6434
- t('settings.rescue.reveal'),
6435
- ),
6436
- )),
6437
- h(
6438
- 'div',
6439
- { className: 'dsh-autorestart-settings__field' },
8213
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'shortcut-block' },
8214
+ rescueDesktop === ''
8215
+ ? h(
8216
+ 'div',
8217
+ { className: 'dsh-autorestart-settings__field', 'data-dsh-autorestart-rescue': 'rescue-command' },
8218
+ h(
8219
+ 'span',
8220
+ { className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'command-label' },
8221
+ t('settings.rescue.commandLabel'),
8222
+ ),
8223
+ rescueCommand === ''
8224
+ ? h(
8225
+ 'span',
8226
+ {
8227
+ className: 'dsh-autorestart-settings__hint',
8228
+ 'data-dsh-autorestart-rescue': 'command-unavailable',
8229
+ },
8230
+ t('settings.rescue.commandUnavailable'),
8231
+ )
8232
+ : [
8233
+ h(
8234
+ 'code',
8235
+ {
8236
+ className: 'dsh-autorestart-settings__value',
8237
+ 'data-dsh-autorestart-rescue': 'command',
8238
+ key: 'rescue-command-value',
8239
+ },
8240
+ rescueCommand,
8241
+ ),
8242
+ h(
8243
+ 'button',
8244
+ {
8245
+ type: 'button',
8246
+ className: 'dsh-autorestart-settings__button',
8247
+ disabled: busy,
8248
+ 'data-dsh-autorestart-rescue': 'copy-command',
8249
+ key: 'rescue-command-copy',
8250
+ onClick: () => void copyRescuePath(rescueCommand),
8251
+ },
8252
+ t('settings.rescue.copyCommand'),
8253
+ ),
8254
+ h(
8255
+ 'span',
8256
+ {
8257
+ className: 'dsh-autorestart-settings__hint',
8258
+ 'data-dsh-autorestart-rescue': 'command-hint',
8259
+ key: 'rescue-command-hint',
8260
+ },
8261
+ t('settings.rescue.commandHint'),
8262
+ ),
8263
+ ],
8264
+ )
8265
+ : [
8266
+ h(
8267
+ 'div',
8268
+ { className: 'dsh-autorestart-settings__shortcut', key: 'rescue-shortcut-row' },
8269
+ h(
8270
+ 'span',
8271
+ {
8272
+ className: 'dsh-autorestart-settings__shortcut-icon',
8273
+ 'data-dsh-autorestart-rescue': 'shortcut-icon',
8274
+ // WHICH FILE THIS ICON IS: the same one the `.lnk`
8275
+ // gets, or nothing at all — never a made-up picture.
8276
+ 'data-icon-source': rescueIconName,
8277
+ 'data-icon-available': rescueIconAvailable ? 'true' : 'false',
8278
+ // WHETHER THE PICTURE ON SCREEN IS THE REAL ONE
8279
+ // (2026-11-02): true means the bytes below came from
8280
+ // the Host, so the tile drops its stand-in background
8281
+ // and shows the actual image.
8282
+ 'data-icon-real': rescueIconDataUri === '' ? 'false' : 'true',
8283
+ 'aria-hidden': 'true',
8284
+ title: rescueIconPath,
8285
+ },
8286
+ // THE REAL PICTURE, or the text tile when the Host sent
8287
+ // none. Never both: two icons on one row would look like
8288
+ // two different shortcuts.
8289
+ rescueIconDataUri === ''
8290
+ ? 'DSH'
8291
+ : h('img', {
8292
+ className: 'dsh-autorestart-settings__shortcut-icon-img',
8293
+ 'data-dsh-autorestart-rescue': 'rescue-icon',
8294
+ src: rescueIconDataUri,
8295
+ alt: rescueShortcutName,
8296
+ width: 16,
8297
+ height: 16,
8298
+ draggable: 'false',
8299
+ }),
8300
+ ),
8301
+ h(
8302
+ 'span',
8303
+ {
8304
+ className: 'dsh-autorestart-settings__shortcut-name',
8305
+ 'data-dsh-autorestart-rescue': 'shortcut-name',
8306
+ },
8307
+ rescueShortcutName,
8308
+ ),
8309
+ ),
8310
+ // The button lives only where it can work: a Windows machine
8311
+ // with a desktop.
8312
+ rescueIsWindows
8313
+ ? h(
8314
+ 'button',
8315
+ {
8316
+ type: 'button',
8317
+ className: 'dsh-autorestart-settings__button',
8318
+ disabled: busy,
8319
+ 'data-dsh-autorestart-rescue': 'shortcut',
8320
+ key: 'rescue-shortcut-button',
8321
+ onClick: () => void createRescueShortcut(),
8322
+ },
8323
+ rescueShortcutExists ? t('settings.rescue.shortcutDone') : t('settings.rescue.shortcut'),
8324
+ )
8325
+ : null,
8326
+ ],
8327
+ rescueNote === ''
8328
+ ? null
8329
+ : h(
8330
+ 'span',
8331
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'note' },
8332
+ rescueNote,
8333
+ ),
8334
+ // REMINDER ONE: this is for a host that does NOT start. Using it
8335
+ // while DSH runs overwrites the configuration in use.
6440
8336
  h(
6441
8337
  'span',
6442
- { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'which' },
6443
- t('settings.rescue.which'),
8338
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'shortcut-when' },
8339
+ t('settings.rescue.warning'),
6444
8340
  ),
6445
- ),
6446
- h(
6447
- 'div',
6448
- { className: 'dsh-autorestart-settings__field' },
8341
+ // REMINDER TWO: it puts the WHOLE environment back. Taking one
8342
+ // plugin out is a different button, and the wrong one here costs
8343
+ // the user their working configuration.
6449
8344
  h(
6450
8345
  'span',
6451
- { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'warning' },
6452
- t('settings.rescue.warning'),
8346
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'shortcut-scope' },
8347
+ t('settings.rescue.shortcutScope'),
6453
8348
  ),
8349
+ // A Windows desktop app whose desktop directory could not be found:
8350
+ // no shortcut can be placed, and the paths above are the way out.
8351
+ rescueIsWindows && rescueDesktop === ''
8352
+ ? h(
8353
+ 'span',
8354
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'no-desktop' },
8355
+ t('settings.rescue.noDesktop'),
8356
+ )
8357
+ : null,
6454
8358
  ),
6455
8359
  // WHAT THIS MACHINE CAN DO: the automatic rollback (and what is
6456
8360
  // lost when the machine refuses a watchdog).
@@ -6472,6 +8376,18 @@
6472
8376
  { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-last' },
6473
8377
  t('settings.rescue.watchdogLast', { result: watchdogLast }),
6474
8378
  ),
8379
+ // The strict-mode answer, in the words the user asked for: nothing
8380
+ // was touched because there is nothing trustworthy to put back.
8381
+ watchdogStrict ? h(
8382
+ 'span',
8383
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-strict' },
8384
+ t('settings.rescue.watchdogStrict'),
8385
+ ) : null,
8386
+ watchdogRestoredFrom === '' ? null : h(
8387
+ 'span',
8388
+ { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'watchdog-restored-from' },
8389
+ t('settings.rescue.watchdogRestoredFrom', { dir: watchdogRestoredFrom }),
8390
+ ),
6475
8391
  ),
6476
8392
  // The notice is shown ONCE: the Host remembers that the user has
6477
8393
  // read it.
@@ -6481,45 +8397,14 @@
6481
8397
  { className: 'dsh-autorestart-settings__field' },
6482
8398
  h(
6483
8399
  'span',
6484
- { className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'rollback-notice' },
6485
- t('settings.rescue.rollbackBlocked'),
6486
- ),
6487
- )
6488
- : null,
6489
- // The desktop shortcut is a BUTTON, never a side effect: loading
6490
- // this page writes nothing to the desktop.
6491
- rescueDesktop === ''
6492
- ? h(
6493
- 'div',
6494
- { className: 'dsh-autorestart-settings__field' },
6495
- h(
6496
- 'span',
6497
- { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'no-desktop' },
6498
- t('settings.rescue.noDesktop'),
8400
+ { className: 'dsh-autorestart-settings__label', 'data-dsh-autorestart-rescue': 'rollback-notice' },
8401
+ t('settings.rescue.rollbackBlocked'),
6499
8402
  ),
6500
8403
  )
6501
- : h(
6502
- 'div',
6503
- { className: 'dsh-autorestart-settings__field' },
6504
- h(
6505
- 'button',
6506
- {
6507
- type: 'button',
6508
- className: 'dsh-autorestart-settings__button',
6509
- disabled: busy,
6510
- 'data-dsh-autorestart-rescue': 'shortcut',
6511
- onClick: () => void createRescueShortcut(),
6512
- },
6513
- rescueShortcutExists ? t('settings.rescue.shortcutDone') : t('settings.rescue.shortcut'),
6514
- ),
6515
- rescueNote === ''
6516
- ? null
6517
- : h(
6518
- 'span',
6519
- { className: 'dsh-autorestart-settings__hint', 'data-dsh-autorestart-rescue': 'note' },
6520
- rescueNote,
6521
- ),
6522
- ),
8404
+ : null,
8405
+ // The desktop shortcut is a BUTTON, never a side effect: loading
8406
+ // this page writes nothing to the desktop. That button now lives in
8407
+ // the block above, together with the icon and the two reminders.
6523
8408
  ),
6524
8409
  // "REPORT A PROBLEM": copy the diagnostics and open a pre-filled
6525
8410
  // issue page. The email is the way out when there is no GitHub
@@ -7315,6 +9200,11 @@
7315
9200
  // features, but only this one has a card here.
7316
9201
  const ANNOUNCED_FEATURE = 'sleep'
7317
9202
 
9203
+ // A HOST THAT CANNOT RESTART (2026-11-02): its own one-time card, in the
9204
+ // same announcement ledger as the feature above — same route, same
9205
+ // "answered once per host" rule, no second mechanism.
9206
+ const ANNOUNCED_RESTART = 'restart-command'
9207
+
7318
9208
  /**
7319
9209
  * The announcements the user has not seen yet.
7320
9210
  *
@@ -7564,6 +9454,632 @@
7564
9454
  )
7565
9455
  }
7566
9456
 
9457
+ /**
9458
+ * The single-plugin restore: the button in the plugin-failure panel.
9459
+ *
9460
+ * `POST /plugin-restore` with `{ "plugin": "<name>" }` puts THAT ONE
9461
+ * plugin back from the last known-good backup. The Host answers with
9462
+ * `ok:false` + a reason for "no backup" / "could not copy" — and it says
9463
+ * so with HTTP 200, because that is an answer, not a server error.
9464
+ */
9465
+ const DEFAULT_PLUGIN_RESTORE_URL = ROUTE_PREFIX + '/plugin-restore'
9466
+
9467
+ /**
9468
+ * WHERE "the user said 知道了" IS REMEMBERED.
9469
+ *
9470
+ * One key, holding the FINGERPRINT of the report that was dismissed — not
9471
+ * a boolean. That is the whole point: a boolean would silence a real
9472
+ * failure forever, while a fingerprint only silences the boot that wrote
9473
+ * that report. The next start writes a new report (new `at`) and the panel
9474
+ * comes back by itself.
9475
+ */
9476
+ const PLUGIN_FAILURE_DISMISS_KEY = 'dsh-autorestart.plugin-failure.dismissed'
9477
+
9478
+ async function restorePluginOnce(plugin) {
9479
+ const response = await fetch(DEFAULT_PLUGIN_RESTORE_URL, {
9480
+ method: 'POST',
9481
+ headers: { 'content-type': 'application/json' },
9482
+ body: JSON.stringify({ plugin }),
9483
+ cache: 'no-store',
9484
+ })
9485
+ if (!response.ok) throw new Error('HTTP ' + response.status)
9486
+ const body = await response.json()
9487
+ if (!body || typeof body !== 'object') throw new Error('the Host sent no answer')
9488
+ return body
9489
+ }
9490
+
9491
+ /**
9492
+ * THE PLUGIN THAT DID NOT LOAD — the popup the user asked for.
9493
+ *
9494
+ * WHY IT EXISTS: the host ALREADY survives a broken plugin (measured: 22 s
9495
+ * on the desktop, 2.0–2.6 s on hosted `dsh web`, profile untouched, no
9496
+ * repeated restarts under the supervisor), so the plugin no longer
9497
+ * restores anything by itself at boot. What was missing was the OTHER
9498
+ * half: the user finding out. This panel says which plugin did not
9499
+ * activate and what error it reported, and offers exactly one action —
9500
+ * "put THIS one plugin back from the last backup".
9501
+ *
9502
+ * It is an ordinary block in the composer dock (the same place as the
9503
+ * install prompt and the announcement): it never covers the interface and
9504
+ * never touches the countdown. It is not dismissible by hiding a problem:
9505
+ * it disappears when the plugin loads again (a new boot with a clean log,
9506
+ * which clears the report).
9507
+ */
9508
+ /**
9509
+ * WHAT THE PANEL MAY SAY, AND NOTHING MORE.
9510
+ *
9511
+ * Two facts, kept apart on purpose:
9512
+ * - "these functions did not come up this time" - the watch compared this
9513
+ * boot with the previous one and something is gone. A hard fact.
9514
+ * - "these plugins differ from the backup" - a lead, not a verdict: the
9515
+ * files changed, which may simply mean an upgrade.
9516
+ *
9517
+ * It never says a plugin is broken or failed to load: nothing here can know
9518
+ * that. Interface module names are reported as ONE number and never as
9519
+ * plugin names (they are things like \`batches\`, not plugins).
9520
+ */
9521
+
9522
+ /**
9523
+ * ASK ONCE WHETHER THE WATCHDOG STAYS ON.
9524
+ *
9525
+ * The watchdog already works; what was missing is that nobody ever asked.
9526
+ * The wording says what it DOES (put the last working version back when the
9527
+ * new one does not start) and what turning it off COSTS (you fix it by
9528
+ * hand), because the person reading this is not a programmer.
9529
+ *
9530
+ * The decision is made on the host by shouldAskWatchdog() and arrives as
9531
+ * info.watchdogAsk; this panel only renders it.
9532
+ * Hidden unless the host says it is time to ask: never answered, or the
9533
+ * plugin changed version since the answer.
9534
+ */
9535
+ function WatchdogAskPanel() {
9536
+ const info = useSharedInfo(true)
9537
+ const [busy, setBusy] = useState('')
9538
+ const [answered, setAnswered] = useState(null)
9539
+ const t = makeTranslate(translate)
9540
+ ensureAnnouncementStyle()
9541
+
9542
+ // Nothing to ask: no answer yet is not the same as "we should ask".
9543
+ // The host decides, and it only says yes when there is no answer or the
9544
+ // plugin changed version since the last one.
9545
+ if (answered === 'dismissed') return null
9546
+ if (info === null || info === undefined || info.watchdogAsk !== true) return null
9547
+
9548
+ const answer = (keepOn) => {
9549
+ // THE BUTTON NO LONGER ANSWERS A QUESTION, IT ACKNOWLEDGES A NOTICE
9550
+ // (2026-11-01, user decision): the watchdog ships with the plugin, so
9551
+ // "should it stay on?" was a choice the user does not actually have.
9552
+ // The single button records "the user was told" and nothing else.
9553
+ if (busy !== '') return
9554
+ setBusy('notify')
9555
+ void (async () => {
9556
+ // "SAVED" IS A PROMISE, NOT A GUESS. The request is a success only
9557
+ // when the host really answered 2xx: a 400 or a dead connection means
9558
+ // NOTHING was written, and saying "saved" then would leave the user
9559
+ // believing a record that does not exist on disk.
9560
+ let saved = false
9561
+ try {
9562
+ const response = await fetch(choiceUrlFor(info), {
9563
+ method: 'POST',
9564
+ headers: { 'content-type': 'application/json' },
9565
+ body: JSON.stringify({
9566
+ watchdogNotified: true,
9567
+ askedForPluginVersion: typeof info.pluginVersion === 'string' ? info.pluginVersion : '',
9568
+ }),
9569
+ })
9570
+ saved = response !== null && response !== undefined && response.ok === true
9571
+ } catch {
9572
+ saved = false
9573
+ }
9574
+ setAnswered(saved === true ? 'notified' : 'failed')
9575
+ setBusy('')
9576
+ })()
9577
+ }
9578
+
9579
+ /**
9580
+ * PUT EVERY BROKEN PLUGIN BACK, one at a time, never in parallel: the host
9581
+ * writes into the profile, and two restores racing on the same tree is not
9582
+ * worth the risk. Each plugin is awaited before the next one starts.
9583
+ */
9584
+ const restoreAll = (entries) => {
9585
+ if (busy !== '') return
9586
+ setBusy('all')
9587
+ setNote('')
9588
+ void (async () => {
9589
+ let done = 0
9590
+ let failed = 0
9591
+ for (const entry of entries) {
9592
+ try {
9593
+ const answer = await restorePluginOnce(entry.name)
9594
+ if (answer && answer.ok === true) done += 1
9595
+ else failed += 1
9596
+ } catch {
9597
+ failed += 1
9598
+ }
9599
+ }
9600
+ setNote(fill(t('pluginFailure.restoreAllDone'), { done: String(done), failed: String(failed) }))
9601
+ setBusy('')
9602
+ })()
9603
+ }
9604
+
9605
+ const rows = []
9606
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'title' }, t('watchdog.title')))
9607
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key: 'explain' }, t('watchdog.explain')))
9608
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'off' }, t('watchdog.whereOff')))
9609
+ if (answered !== null) {
9610
+ rows.push(h('p', {
9611
+ className: 'dsh-autorestart-announcement__note',
9612
+ key: 'saved',
9613
+ }, t(answered === 'failed' ? 'watchdog.saveFailed' : 'watchdog.noticeSaved')))
9614
+ }
9615
+ // ONE BUTTON, BECAUSE THERE IS NO CHOICE TO MAKE: the watchdog ships with
9616
+ // the plugin. Turning it off is a settings-page decision, not a question
9617
+ // asked once at install time.
9618
+ rows.push(h('button', {
9619
+ type: 'button',
9620
+ className: 'dsh-autorestart-announcement__cta',
9621
+ key: 'got-it',
9622
+ disabled: busy !== '',
9623
+ onClick: () => answer(true),
9624
+ }, t('watchdog.gotIt')))
9625
+ rows.push(overlayHint(t('watchdog.hint')))
9626
+ return overlayCard('watchdog-ask', t('watchdog.title'), () => setAnswered('dismissed'), rows)
9627
+ }
9628
+
9629
+ /**
9630
+ * THE FLOATING CARD.
9631
+ *
9632
+ * The panels used to sit in `conversation.input.dock`, which is part of the
9633
+ * layout flow: the message column was pushed up and the text ran straight
9634
+ * into the conversation with no edge of its own. Declaring the frame here
9635
+ * means every panel draws the same card, whatever the page CSS does.
9636
+ */
9637
+ // THE CARD'S OWN PAINT (2026-11-01).
9638
+ //
9639
+ // Hard-coded, OPAQUE colours on purpose. The first version used
9640
+ // `var(--dsh-surface, #fff)`, and when the theme does not define that
9641
+ // variable the card was drawn with no background at all - on the real
9642
+ // machine the text simply sat in the conversation, indistinguishable from
9643
+ // the messages around it.
9644
+ // THE ANCHOR THAT TAKES NO SPACE.
9645
+ //
9646
+ // `position: fixed` does NOT mean "relative to the viewport" when any
9647
+ // ancestor has `transform`, `filter`, `perspective` or `will-change`: the
9648
+ // card then anchors to that ancestor and stays in its layout, which is why
9649
+ // the conversation was pushed up. This wrapper is ZERO-sized and the card
9650
+ // hangs out of it with `position: absolute`, so the flow never sees it, no
9651
+ // matter what the host puts above us.
9652
+ /**
9653
+ * THE CARD, AS A REAL COMPONENT.
9654
+ *
9655
+ * It has to be a component (not a plain helper) because it now MEASURES
9656
+ * itself: the card renders a line with its own computed style, so a single
9657
+ * screenshot says which layer is broken - the inline style not applied, an
9658
+ * ancestor overriding it, or the portal never happening.
9659
+ *
9660
+ * Every painting property is inline and hard-coded: no className, no CSS
9661
+ * variable. Inline wins over every stylesheet rule, so if the card still
9662
+ * comes out unstyled the reason is not CSS specificity.
9663
+ */
9664
+ // A quiet line under the buttons. The panel is the door; the settings page
9665
+ // is where you come back later if you change your mind.
9666
+ function overlayHint(text) {
9667
+ return h('p', {
9668
+ key: 'overlay-hint',
9669
+ 'data-dsh-autorestart-hint': '1',
9670
+ style: {
9671
+ margin: '2px 0 0',
9672
+ color: 'var(--dsw-alias-label-tertiary, #888888)',
9673
+ fontSize: '11px',
9674
+ lineHeight: '1.4',
9675
+ },
9676
+ }, text)
9677
+ }
9678
+
9679
+ function OverlayCard(props) {
9680
+ const id = props ? props.id : 'overlay'
9681
+ const title = props ? props.title : ''
9682
+ const onClose = props && typeof props.onClose === 'function' ? props.onClose : () => {}
9683
+ const rows = props && Array.isArray(props.rows) ? props.rows : []
9684
+ // CARD-LEVEL ACTIONS LIVE IN THE HEADER (user report, 2026-11-02): buttons
9685
+ // that apply to the WHOLE card used to sit at the bottom of the list, so
9686
+ // they looked like they belonged to the last item in it. The header owns
9687
+ // them now — title on the left (it truncates), actions on the right.
9688
+ const actions = props && Array.isArray(props.actions) ? props.actions : []
9689
+ const header = h('div', {
9690
+ key: 'overlay-header',
9691
+ 'data-dsh-autorestart-overlay-header': id,
9692
+ style: {
9693
+ display: 'flex',
9694
+ alignItems: 'center',
9695
+ justifyContent: 'space-between',
9696
+ gap: '8px',
9697
+ flexWrap: 'nowrap',
9698
+ },
9699
+ },
9700
+ h('strong', {
9701
+ key: 'overlay-title',
9702
+ style: {
9703
+ flex: '1 1 auto',
9704
+ minWidth: '0',
9705
+ overflow: 'hidden',
9706
+ textOverflow: 'ellipsis',
9707
+ whiteSpace: 'nowrap',
9708
+ fontSize: '14px',
9709
+ lineHeight: '1.4',
9710
+ color: 'var(--dsw-alias-label-primary, #111111)',
9711
+ },
9712
+ }, title),
9713
+ h('div', {
9714
+ key: 'overlay-actions',
9715
+ 'data-dsh-autorestart-overlay-actions': id,
9716
+ style: {
9717
+ flex: '0 0 auto',
9718
+ display: 'flex',
9719
+ alignItems: 'center',
9720
+ gap: '6px',
9721
+ flexWrap: 'nowrap',
9722
+ },
9723
+ },
9724
+ actions,
9725
+ h('button', {
9726
+ type: 'button',
9727
+ key: 'overlay-close',
9728
+ title: translate('pluginFailure.close'),
9729
+ 'aria-label': translate('pluginFailure.close'),
9730
+ onClick: onClose,
9731
+ style: {
9732
+ flex: '0 0 auto',
9733
+ cursor: 'pointer',
9734
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
9735
+ borderRadius: '8px',
9736
+ background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
9737
+ color: 'var(--dsw-alias-label-primary, #111111)',
9738
+ fontSize: '16px',
9739
+ lineHeight: '1',
9740
+ padding: '2px 8px',
9741
+ },
9742
+ }, '\u00d7')))
9743
+
9744
+ const body = [header, ...rows]
9745
+
9746
+ const card = h('div', {
9747
+ key: id,
9748
+ 'data-dsh-autorestart-overlay': id,
9749
+ style: {
9750
+ position: "fixed",
9751
+ left: '24px',
9752
+ bottom: '24px',
9753
+ width: 'min(560px, calc(100vw - 48px))',
9754
+ maxHeight: 'min(70vh, 640px)',
9755
+ overflowY: 'auto',
9756
+ boxSizing: 'border-box',
9757
+ background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
9758
+ backgroundColor: 'var(--dsw-alias-bg-layer-1, #ffffff)',
9759
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
9760
+ borderRadius: '12px',
9761
+ boxShadow: '0 12px 32px rgba(0,0,0,0.18)',
9762
+ padding: '14px 16px',
9763
+ zIndex: 2147483000,
9764
+ color: 'var(--dsw-alias-label-primary, #111111)',
9765
+ font: 'inherit',
9766
+ textAlign: 'left',
9767
+ lineHeight: '1.5',
9768
+ fontSize: '13px',
9769
+ },
9770
+ }, body)
9771
+
9772
+ return card
9773
+ }
9774
+
9775
+ function overlayCard(id, title, onClose, rows) {
9776
+ return h(OverlayCard, { key: 'overlay-' + id, id, title, onClose, rows })
9777
+ }
9778
+
9779
+ const SETTINGS_STYLE = {
9780
+ section: {
9781
+ display: 'flex',
9782
+ flexDirection: 'column',
9783
+ gap: '10px',
9784
+ padding: '14px 16px',
9785
+ background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
9786
+ color: 'var(--dsw-alias-label-primary, #111111)',
9787
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.18))',
9788
+ borderRadius: '12px',
9789
+ font: 'inherit',
9790
+ textAlign: 'left',
9791
+ lineHeight: '1.5',
9792
+ fontSize: '13px',
9793
+ },
9794
+ description: { color: 'var(--dsw-alias-label-secondary, #666666)', fontSize: '12px' },
9795
+ button: {
9796
+ alignSelf: 'flex-start',
9797
+ cursor: 'pointer',
9798
+ padding: '6px 14px',
9799
+ borderRadius: '8px',
9800
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
9801
+ background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
9802
+ color: 'var(--dsw-alias-label-primary, #111111)',
9803
+ font: 'inherit',
9804
+ fontSize: '13px',
9805
+ },
9806
+ restoreButton: {
9807
+ cursor: 'pointer',
9808
+ padding: '3px 10px',
9809
+ borderRadius: '6px',
9810
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.22))',
9811
+ background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
9812
+ color: 'var(--dsw-alias-label-primary, #111111)',
9813
+ font: 'inherit',
9814
+ fontSize: '12px',
9815
+ },
9816
+ list: {
9817
+ display: 'flex',
9818
+ flexDirection: 'column',
9819
+ gap: '6px',
9820
+ margin: 0,
9821
+ padding: 0,
9822
+ listStyle: 'none',
9823
+ },
9824
+ row: {
9825
+ display: 'grid',
9826
+ gridTemplateColumns: '1fr auto auto',
9827
+ alignItems: 'center',
9828
+ gap: '10px',
9829
+ padding: '6px 8px',
9830
+ borderRadius: '8px',
9831
+ background: 'var(--dsw-alias-bg-layer-2, #f7f7f7)',
9832
+ },
9833
+ name: { fontFamily: 'monospace', fontSize: '12px', wordBreak: 'break-all' },
9834
+ status: { color: 'var(--dsw-alias-label-secondary, #666666)', fontSize: '12px', whiteSpace: 'nowrap' },
9835
+ hint: {
9836
+ gridColumn: '1 / -1',
9837
+ color: 'var(--dsw-alias-label-tertiary, #888888)',
9838
+ fontSize: '11px',
9839
+ lineHeight: '1.4',
9840
+ },
9841
+ note: { color: 'var(--dsw-alias-state-warn-label, #a15c00)', fontSize: '12px' },
9842
+ }
9843
+
9844
+ /**
9845
+ * THE SETTINGS PAGE (plugins.bundle.config).
9846
+ *
9847
+ * Everything here is on demand: nothing talks to the inventory route until
9848
+ * the user presses the button. "Check now" really re-requests the route -
9849
+ * it does not re-render whatever the last reading was.
9850
+ */
9851
+ function SettingsPanel() {
9852
+ const info = useSharedInfo(true)
9853
+ const t = makeTranslate(translate)
9854
+
9855
+ const items = []
9856
+ items.push(h('strong', { key: 'settings-title' }, 'dsh-autorestart'))
9857
+ items.push(h('p', { key: 'settings-description', style: SETTINGS_STYLE.description }, t('settings.description')))
9858
+
9859
+ return h('section', { 'data-dsh-autorestart-settings': 'dsh-autorestart', style: SETTINGS_STYLE.section }, items)
9860
+ }
9861
+
9862
+ function PluginFailurePanel() {
9863
+ const info = useSharedInfo(true)
9864
+ const [loaded, setLoaded] = useState(null)
9865
+ const [judgedHere, setJudgedHere] = useState(false)
9866
+ const [busy, setBusy] = useState('')
9867
+ const [note, setNote] = useState('')
9868
+ const [dismissed, setDismissed] = useState(() => {
9869
+ try {
9870
+ return window.localStorage.getItem(PLUGIN_FAILURE_DISMISS_KEY) || ''
9871
+ } catch {
9872
+ return ''
9873
+ }
9874
+ })
9875
+ const t = makeTranslate(translate)
9876
+
9877
+ // The pure helpers live in the second module and are reachable through
9878
+ // the global namespace, like `buildPluginFailureModel` above.
9879
+ const helpers = globalThis.__dshAutorestartClient
9880
+ const build = helpers && typeof helpers.buildPluginFailureModel === 'function'
9881
+ ? helpers.buildPluginFailureModel
9882
+ : null
9883
+ const bootIdOf = helpers && typeof helpers.pluginFailureBootId === 'function'
9884
+ ? helpers.pluginFailureBootId
9885
+ : () => ''
9886
+ const judgedKeyOf = helpers && typeof helpers.pluginFailureJudgedKey === 'function'
9887
+ ? helpers.pluginFailureJudgedKey
9888
+ : () => PLUGIN_FAILURE_DISMISS_KEY
9889
+ const fingerprintOf = helpers && typeof helpers.pluginFailureFingerprint === 'function'
9890
+ ? helpers.pluginFailureFingerprint
9891
+ : () => ''
9892
+
9893
+ // WHICH BOOT IS THIS? (user decision, 2026-11-02.) This card reports the
9894
+ // RESULT of a restart, so the boot identity — not the passing time —
9895
+ // decides when it may appear: `bootId` when the Host sends one,
9896
+ // `generation` when it does not (see pluginFailureBootId).
9897
+ const bootId = bootIdOf(info)
9898
+ const judgedKey = judgedKeyOf(bootId)
9899
+
9900
+ /** The record an earlier judgement of a boot left behind, or null. */
9901
+ const judgedRecord = (key) => {
9902
+ try {
9903
+ const stored = window.localStorage.getItem(key)
9904
+ return stored === null || stored === '' ? null : stored
9905
+ } catch {
9906
+ return null
9907
+ }
9908
+ }
9909
+
9910
+ // THE JUDGEMENT IS THE RESTART EVENT, NOT A LIVE READING. Two requests,
9911
+ // once, on the first mount of this boot — no polling, and nothing that
9912
+ // re-runs when the shared payload refreshes. The effect is keyed on the
9913
+ // boot identity alone, so a page refresh inside the same boot does not
9914
+ // judge again, and the next boot does.
9915
+ useEffect(() => {
9916
+ if (judgedRecord(judgedKey) !== null) return undefined
9917
+ let alive = true
9918
+ const get = async (url) => {
9919
+ try {
9920
+ const response = await fetch(url, { headers: { accept: 'application/json' } })
9921
+ if (!response || response.ok !== true) return null
9922
+ return await response.json()
9923
+ } catch {
9924
+ return null
9925
+ }
9926
+ }
9927
+ void (async () => {
9928
+ const watch = await get(pluginWatchUrlFor(info))
9929
+ const inventory = await get(pluginInventoryUrlFor(info))
9930
+ if (alive !== true) return
9931
+ // THE RECORD IS WRITTEN EVEN WHEN THERE IS NOTHING TO REPORT: "this
9932
+ // boot was judged" is the whole point, so a file that changes later
9933
+ // in the same boot cannot make the card appear out of nowhere.
9934
+ const modelNow = build === null ? null : build({ watch, inventory })
9935
+ try {
9936
+ window.localStorage.setItem(judgedKey, JSON.stringify({
9937
+ at: Date.now(),
9938
+ boot: bootId,
9939
+ fingerprint: fingerprintOf(modelNow),
9940
+ }))
9941
+ } catch {}
9942
+ setJudgedHere(true)
9943
+ setLoaded({ watch, inventory })
9944
+ })()
9945
+ return () => { alive = false }
9946
+ }, [judgedKey])
9947
+
9948
+ // AN EARLIER MOUNT OF THIS BOOT ALREADY JUDGED: nothing to show, and
9949
+ // nothing to ask the Host either (the effect above returned early).
9950
+ if (!judgedHere && judgedRecord(judgedKey) !== null) return null
9951
+
9952
+ if (loaded === null || build === null) return null
9953
+ const model = build({ watch: loaded.watch, inventory: loaded.inventory })
9954
+ if (model === null) return null
9955
+
9956
+ // The fingerprint is WHICH report this is (which plugins did not come up),
9957
+ // so dismissing silences this one for the rest of the boot; the boot record
9958
+ // keeps it silent even after a page refresh.
9959
+ const fingerprint = (model.certain || []).map((entry) => entry.name).sort().join(',')
9960
+ if (dismissed !== '' && dismissed === fingerprint) return null
9961
+ ensureAnnouncementStyle()
9962
+
9963
+ const restore = (plugin) => {
9964
+ if (busy !== '') return
9965
+ setBusy(plugin)
9966
+ setNote('')
9967
+ void (async () => {
9968
+ try {
9969
+ const restored = await restorePluginOnce(plugin)
9970
+ setNote(fill(t('pluginFailure.restored'), { plugin, at: '' }))
9971
+ if (restored && restored.ok !== true) setNote(fill(t('pluginFailure.failed'), { plugin, reason: '' }))
9972
+ } catch (failure) {
9973
+ setNote(fill(t('pluginFailure.failed'), { plugin, reason: describeError(failure) }))
9974
+ }
9975
+ setBusy('')
9976
+ })()
9977
+ }
9978
+ // DISMISS IS BOOT-SCOPED TOO (user decision, 2026-11-02): the record for
9979
+ // this boot already exists (it is written at judgement time), so the card
9980
+ // stays away for the rest of the boot — including after a page refresh —
9981
+ // and the next boot judges again. The fingerprint is stored as well so a
9982
+ // single report can be silenced without silencing the boot.
9983
+ // «全部恢复» needs no extra work for the same reason: it acts on the
9984
+ // judgement that this boot already made.
9985
+ const dismiss = () => {
9986
+ setDismissed(fingerprint)
9987
+ try { window.localStorage.setItem(PLUGIN_FAILURE_DISMISS_KEY, fingerprint) } catch {}
9988
+ try {
9989
+ if (judgedRecord(judgedKey) === null) {
9990
+ window.localStorage.setItem(judgedKey, JSON.stringify({ at: Date.now(), boot: bootId, fingerprint }))
9991
+ }
9992
+ } catch {}
9993
+ }
9994
+
9995
+ const rows = []
9996
+ const line = (key, text) => rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key }, text))
9997
+
9998
+ // NO SECOND TITLE LINE (user report, 2026-11-02): the card header already
9999
+ // says «有两件事要你看一下», and repeating it as the first paragraph made
10000
+ // the panel look like it had two headings.
10001
+
10002
+ // WHICH PLUGINS DID NOT COME UP. There is exactly ONE list on this card
10003
+ // now (user decision, 2026-11-02): a plugin that is declared in the
10004
+ // profile and did not load this time. A file that merely differs from the
10005
+ // backup is NOT on this card — it belongs to the settings page, without a
10006
+ // verdict attached to it.
10007
+ const certain = Array.isArray(model.certain) ? model.certain : []
10008
+ if (certain.length > 0) {
10009
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'certain-title' }, t('pluginFailure.certainTitle')))
10010
+ line('certain-intro', t('pluginFailure.certainIntro'))
10011
+ for (const entry of certain) {
10012
+ line('certain:' + entry.name, entry.name)
10013
+ const action = entry.action && typeof entry.action === 'object' ? entry.action : {}
10014
+ if (entry.kind === 'linked') {
10015
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'hint:' + entry.name }, t('pluginFailure.linkedHint')))
10016
+ rows.push(h('button', {
10017
+ type: 'button',
10018
+ className: 'dsh-autorestart-announcement__cta',
10019
+ key: 'cta:' + entry.name,
10020
+ disabled: busy !== '',
10021
+ onClick: () => restore(entry.name),
10022
+ }, busy === entry.name
10023
+ ? fill(t('pluginFailure.restoring'), { plugin: entry.name })
10024
+ : fill(t('pluginFailure.restore'), { plugin: entry.name })))
10025
+ } else {
10026
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'hint:' + entry.name }, t('pluginFailure.thirdPartyHint')))
10027
+ if (typeof action.command === 'string' && action.command !== '') {
10028
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__line', key: 'cmd:' + entry.name }, action.command))
10029
+ } else {
10030
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'nocmd:' + entry.name }, t('pluginFailure.noVersion')))
10031
+ }
10032
+ }
10033
+ }
10034
+ }
10035
+
10036
+ // ONE BUTTON FOR ALL OF THEM. Only plugins we wrote ourselves are in it:
10037
+ // a third-party plugin has no files in the backup, so "restore" cannot be
10038
+ // offered for it - those keep their reinstall hint instead.
10039
+ //
10040
+ // IT BELONGS TO THE CARD, SO IT SITS IN THE HEADER (user report,
10041
+ // 2026-11-02): at the bottom of the list it read like "restore all" applied
10042
+ // to the last plugin only. Same for «知道了».
10043
+ const linkedClues = certain.filter((entry) => entry.kind === 'linked')
10044
+ const actions = []
10045
+ if (linkedClues.length > 1) {
10046
+ actions.push(h('button', {
10047
+ type: 'button',
10048
+ className: 'dsh-autorestart-announcement__cta',
10049
+ 'data-dsh-autorestart-plugin-failure': 'restore-all',
10050
+ key: 'restore-all',
10051
+ disabled: busy !== '',
10052
+ onClick: () => restoreAll(linkedClues),
10053
+ }, fill(t('pluginFailure.restoreAll'), { count: String(linkedClues.length) })))
10054
+ }
10055
+
10056
+ // A blind spot is a NUMBER: those module names are not plugin names.
10057
+ const blind = Number(model.uiBlindSpot)
10058
+ if (Number.isFinite(blind) && blind > 0) {
10059
+ rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'blind' },
10060
+ fill(t('pluginFailure.uiBlindSpot'), { count: String(blind) })))
10061
+ }
10062
+ if (note !== '') rows.push(h('p', { className: 'dsh-autorestart-announcement__note', key: 'note' }, note))
10063
+
10064
+ actions.push(h('button', {
10065
+ type: 'button',
10066
+ className: 'dsh-autorestart-announcement__cta',
10067
+ 'data-dsh-autorestart-plugin-failure': 'dismiss',
10068
+ key: 'dismiss',
10069
+ title: t('pluginFailure.dismissLabel'),
10070
+ onClick: dismiss,
10071
+ }, t('pluginFailure.dismiss')))
10072
+
10073
+ return h(OverlayCard, {
10074
+ key: 'overlay-plugin-failure',
10075
+ id: 'plugin-failure',
10076
+ title: t('pluginFailure.title'),
10077
+ onClose: dismiss,
10078
+ rows: rows.concat([overlayHint(t('pluginFailure.hint'))]),
10079
+ actions,
10080
+ })
10081
+ }
10082
+
7567
10083
  return {
7568
10084
  inject: ['slots', 'locale'],
7569
10085
  apply(ctx) {
@@ -7586,6 +10102,10 @@
7586
10102
  // same dock (it never covers the interface and never touches the
7587
10103
  // countdown) and disappears for good once the user answered it.
7588
10104
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-feature-announcement', order: 8, locale: NS }, FeatureAnnouncementPanel))
10105
+ // THE PLUGIN THAT DID NOT LOAD (user decision, 2026-11-01): the boot
10106
+ // pass only REPORTS now, so this panel is where the user finds out
10107
+ // which plugin did not activate — and where the ONE action lives:
10108
+ // "put this one plugin back from the last backup".
7589
10109
  // The graceful-shutdown button: a LIST slot at the foot of the left
7590
10110
  // sidebar, beside the Settings row. The slot is a ROOT slot, so the
7591
10111
  // button is there even when no conversation is open — and the
@@ -7627,6 +10147,20 @@
7627
10147
  // back) in the middle of the interface, so the composer row can stay
7628
10148
  // one short clipped line. It draws NOTHING when no restart is staged.
7629
10149
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-restart-overlay', order: 55, locale: NS }, RestartOverlay))
10150
+ // THESE TWO COME LAST ON PURPOSE: the countdown and the restart notice are
10151
+ // registered above, and the slot service keeps registrations in call order.
10152
+ // The plugin-failure and watchdog cards join them in the same floating layer.
10153
+ // THE DOCK, ON PURPOSE (2026-11-01). These two cards were registered on
10154
+ // `shell.overlay` first, and on the real machine NOTHING rendered at all -
10155
+ // not even the diagnostic skeleton - so that slot never reached this plugin.
10156
+ // `conversation.input.dock` is the slot this plugin has always rendered in.
10157
+ // The card is taken OUT of the layout flow by its own `position: fixed`, so
10158
+ // it floats in the corner instead of pushing the conversation up.
10159
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-plugin-failure', order: 9, label: () => 'dsh-autorestart', locale: NS }, PluginFailurePanel))
10160
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-watchdog-ask', order: 8, label: () => 'dsh-autorestart', locale: NS }, WatchdogAskPanel))
10161
+ // THE SETTINGS PAGE lives on its OWN slot, keyed to this package.
10162
+ // Name + key + locale is exactly how dsh-mnemon registers there.
10163
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-autorestart', locale: NS }, SettingsPanel))
7630
10164
  // NOTE: the receipt of a settings change is NOT registered here. It
7631
10165
  // is a `sticky` strip INSIDE the settings panel (see
7632
10166
  // `saveNoticeBar`), because a window overlay put it on top of the
@@ -7643,3 +10177,301 @@
7643
10177
  })
7644
10178
 
7645
10179
  })()
10180
+
10181
+ ;(function () {
10182
+ // ---------------------------------------------------------------------------
10183
+ // THE PLUGIN-FAILURE MODEL (pure, testable, no React and no fetch)
10184
+ //
10185
+ // Two DIFFERENT facts feed the panel, and they must never be blurred:
10186
+ //
10187
+ // certain - "these functions did not come up this time". A HARD fact: the last
10188
+ // reading had them and this one does not.
10189
+ // clues - "these plugins differ from the backup". A LEAD, not a verdict. A
10190
+ // plugin whose files changed may be perfectly fine.
10191
+ //
10192
+ // Neither of them says a plugin is BROKEN or FAILED to load: we have no way to
10193
+ // know that, and claiming it would be a guess dressed up as a finding.
10194
+ //
10195
+ // The three live layers are not equally good at naming a plugin. Measured on the
10196
+ // real host: tools 140 items, packages 29, clientModules only 4 - and those four
10197
+ // are module names (`batches`, `entries`, `release`), NOT plugin names. So
10198
+ // clientModules never produces a list entry; it becomes one number.
10199
+ // ---------------------------------------------------------------------------
10200
+
10201
+ const PLUGIN_FAILURE_THIRD_PARTY = 'third-party'
10202
+ const PLUGIN_FAILURE_LINKED = 'linked'
10203
+
10204
+ /** `codekg_query` -> `codekg`; a name with no separator is returned as it is. */
10205
+ function pluginFailureToolOwner(name) {
10206
+ const text = typeof name === 'string' ? name.trim() : ''
10207
+ if (text === '') return ''
10208
+ const cut = text.indexOf('_')
10209
+ return cut > 0 ? text.slice(0, cut) : text
10210
+ }
10211
+
10212
+ /** `@scope/name#host` -> `@scope/name`; `plain` stays `plain`. */
10213
+ function pluginFailurePackageName(name) {
10214
+ const text = typeof name === 'string' ? name.trim() : ''
10215
+ if (text === '') return ''
10216
+ const cut = text.indexOf('#')
10217
+ return cut > 0 ? text.slice(0, cut) : text
10218
+ }
10219
+
10220
+ /** Entry name out of one layer item, or '' when the shape is not recognised. */
10221
+ function pluginFailureLayerItemName(item) {
10222
+ if (typeof item === 'string') return item.trim()
10223
+ if (item === null || typeof item !== 'object') return ''
10224
+ for (const key of ['name', 'id', 'key', 'label']) {
10225
+ const value = item[key]
10226
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
10227
+ }
10228
+ return ''
10229
+ }
10230
+
10231
+ /** The items a watch layer reports as gone, whichever shape the host used. */
10232
+ function pluginFailureWatchLayer(watch, layer) {
10233
+ const out = []
10234
+ const push = (item) => {
10235
+ if (typeof item === 'string') { if (item.trim() !== '') out.push(item.trim()); return }
10236
+ if (item === null || typeof item !== 'object') return
10237
+ // `{layer, name}` (listDisappearances) or `{name, ...}`
10238
+ if (item.layer !== undefined && item.layer !== layer) return
10239
+ const name = pluginFailureLayerItemName(item)
10240
+ if (name !== '') out.push(name)
10241
+ }
10242
+ try {
10243
+ if (Array.isArray(watch && watch.disappeared)) {
10244
+ for (const item of watch.disappeared) push(item)
10245
+ }
10246
+ const layers = watch && typeof watch === 'object' ? watch.layers : null
10247
+ const bucket = layers && typeof layers === 'object' ? layers[layer] : null
10248
+ if (bucket && Array.isArray(bucket.removed)) {
10249
+ for (const item of bucket.removed) push(item)
10250
+ }
10251
+ } catch {
10252
+ return []
10253
+ }
10254
+ return out
10255
+ }
10256
+
10257
+ /** The inventory rows, whichever wrapper the route used. */
10258
+ function pluginFailureInventoryRows(inventory) {
10259
+ if (inventory === null || typeof inventory !== 'object') return []
10260
+ if (Array.isArray(inventory.plugins)) return inventory.plugins
10261
+ if (Array.isArray(inventory.items)) return inventory.items
10262
+ if (Array.isArray(inventory.entries)) return inventory.entries
10263
+ return []
10264
+ }
10265
+
10266
+ /** Is this inventory row a plugin the user wrote themselves (a link install)? */
10267
+ function pluginFailureIsLinked(row) {
10268
+ if (row === null || typeof row !== 'object') return false
10269
+ if (row.linked === true) return true
10270
+ if (row.isLink === true) return true
10271
+ for (const key of ['kind', 'installKind', 'source', 'install', 'mode']) {
10272
+ const value = row[key]
10273
+ if (typeof value === 'string' && /^(link|linked|local|dev|development)$/i.test(value.trim())) return true
10274
+ }
10275
+ return false
10276
+ }
10277
+
10278
+ /**
10279
+ * Turn one plugin into the action the panel is allowed to offer.
10280
+ *
10281
+ * A hand-written (link) plugin can be put back from the backup: we hold its
10282
+ * files. A third-party plugin CANNOT - that is the whole point of keeping only
10283
+ * the config - so it gets a hint and a command with the EXACT version, never a
10284
+ * one-click button that would silently install something else.
10285
+ */
10286
+ function pluginFailureActionFor(name, kind, row, profile) {
10287
+ if (kind === PLUGIN_FAILURE_LINKED) {
10288
+ return { type: 'restore', label: name }
10289
+ }
10290
+ const version = row && typeof row === 'object' && typeof row.version === 'string' ? row.version.trim() : ''
10291
+ const profileName = typeof profile === 'string' && profile.trim() !== '' ? profile.trim() : '<profile>'
10292
+ if (version === '') {
10293
+ return {
10294
+ type: 'hint',
10295
+ command: null,
10296
+ note: 'the exact version is unknown, so no install command can be built',
10297
+ }
10298
+ }
10299
+ return {
10300
+ type: 'hint',
10301
+ command: 'dsh plugin --profile ' + profileName + ' add ' + name + '@' + version,
10302
+ note: '',
10303
+ }
10304
+ }
10305
+
10306
+ /**
10307
+ * THE BOOT THIS PAGE BELONGS TO (user decision, 2026-11-02).
10308
+ *
10309
+ * The plugin card reports the RESULT of a restart, so it is judged once per
10310
+ * boot — not monitored. The identity comes from the health payload: `bootId`
10311
+ * when the Host sends one, `generation` when it does not. Several wrappers are
10312
+ * accepted because the payload is flat in some builds and nested in others;
10313
+ * when the Host says nothing at all the identity is '' and the panel falls back
10314
+ * to one judgement per page load (reporting once beats monitoring forever).
10315
+ *
10316
+ * @param {object|null} info the shared health payload
10317
+ * @returns {string} the boot identity, or ''
10318
+ */
10319
+ function pluginFailureBootId(info) {
10320
+ const read = (source, key) => {
10321
+ if (source === null || typeof source !== 'object') return ''
10322
+ const value = source[key]
10323
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
10324
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
10325
+ return ''
10326
+ }
10327
+ const holders = [info]
10328
+ if (info !== null && typeof info === 'object') {
10329
+ for (const key of ['health', 'status', 'instance', 'restart']) {
10330
+ if (info[key] !== null && typeof info[key] === 'object') holders.push(info[key])
10331
+ }
10332
+ }
10333
+ for (const key of ['bootId', 'boot', 'generation']) {
10334
+ for (const holder of holders) {
10335
+ const found = read(holder, key)
10336
+ if (found !== '') return found
10337
+ }
10338
+ }
10339
+ return ''
10340
+ }
10341
+
10342
+ /**
10343
+ * WHERE the "this boot was already judged" record lives: the key carries the
10344
+ * boot identity, so a restart leaves the old record behind and the next boot
10345
+ * starts clean.
10346
+ *
10347
+ * @param {string} bootId the identity from {@link pluginFailureBootId}
10348
+ * @returns {string} the localStorage key
10349
+ */
10350
+ function pluginFailureJudgedKey(bootId) {
10351
+ const id = typeof bootId === 'string' && bootId.trim() !== '' ? bootId.trim() : 'page'
10352
+ return 'dsh-autorestart.plugin-failure.judged.' + id
10353
+ }
10354
+
10355
+ /**
10356
+ * The identity of one judgement: which functions are missing and which plugins
10357
+ * differ. Stored with the boot record so the page can tell "already reported"
10358
+ * from "the same report came back".
10359
+ *
10360
+ * @param {object|null} model a model from {@link buildPluginFailureModel}
10361
+ * @returns {string}
10362
+ */
10363
+ function pluginFailureFingerprint(model) {
10364
+ const names = (list) => (Array.isArray(list) ? list : [])
10365
+ .map((entry) => (entry && typeof entry.name === 'string' ? entry.name : ''))
10366
+ .filter((name) => name !== '')
10367
+ .sort()
10368
+ .join(',')
10369
+ if (model === null || typeof model !== 'object') return ''
10370
+ return names(model.certain)
10371
+ }
10372
+
10373
+ /**
10374
+ * Merge the two data sources into the panel's model.
10375
+ *
10376
+ * @param {{ watch?: object|null, inventory?: object|null, profile?: string }} input
10377
+ * @returns {{ certain: object[], uiBlindSpot: number, watchAt: number|null }|null}
10378
+ */
10379
+ function buildPluginFailureModel(input) {
10380
+ try {
10381
+ const source = input !== null && typeof input === 'object' ? input : {}
10382
+ const watch = source.watch !== null && typeof source.watch === 'object' ? source.watch : null
10383
+ const inventory = source.inventory !== null && typeof source.inventory === 'object' ? source.inventory : null
10384
+ const profile = typeof source.profile === 'string' ? source.profile : ''
10385
+
10386
+ // WHICH PLUGINS DID NOT COME UP (user decision, 2026-11-02).
10387
+ //
10388
+ // The ONLY evidence this card accepts: a plugin PACKAGE that loaded on the
10389
+ // previous start and is not loaded now. That is "it did not come up" — the
10390
+ // whole entry is gone, not just a few of its tools.
10391
+ //
10392
+ // What is deliberately NOT evidence:
10393
+ // * a file difference from the backup (an upgrade, our own edits) — that is
10394
+ // normal work, and the settings page shows it without judging it;
10395
+ // * a FEW capabilities disappearing while the package still loads — an
10396
+ // upgrade renames a tool; that is not a plugin that failed;
10397
+ // * a plugin the user removed from the bundles — that already happened.
10398
+ const notInstalled = new Set()
10399
+ const inventoryRows = pluginFailureInventoryRows(inventory)
10400
+ for (const row of inventoryRows) {
10401
+ if (row === null || typeof row !== 'object') continue
10402
+ if (row.status !== 'not-installed') continue
10403
+ const name = typeof row.name === 'string' ? row.name.trim() : ''
10404
+ if (name !== '') notInstalled.add(name)
10405
+ }
10406
+ const inventoryRowFor = (name) => inventoryRows
10407
+ .find((row) => row !== null && typeof row === 'object'
10408
+ && typeof row.name === 'string' && row.name.trim() === name) || null
10409
+
10410
+ const certain = []
10411
+ const byName = new Map()
10412
+ const addNotLoaded = (value, from) => {
10413
+ // `@scope/pkg@1.2.3` is one package with a version: the card names the
10414
+ // PLUGIN, and the version belongs in the command, not in the name.
10415
+ const text = typeof value === 'string' ? value.trim() : ''
10416
+ const at = text.lastIndexOf('@')
10417
+ const name = at > 0 ? text.slice(0, at) : text
10418
+ if (name === '') return
10419
+ if (notInstalled.has(name)) return
10420
+ if (byName.has(name)) return
10421
+ const row = inventoryRowFor(name)
10422
+ const kind = row !== null && pluginFailureIsLinked(row) ? PLUGIN_FAILURE_LINKED : PLUGIN_FAILURE_THIRD_PARTY
10423
+ const entry = {
10424
+ name,
10425
+ source: from,
10426
+ kind,
10427
+ action: pluginFailureActionFor(name, kind, row, profile),
10428
+ lostTools: [],
10429
+ }
10430
+ byName.set(name, entry)
10431
+ certain.push(entry)
10432
+ }
10433
+ for (const raw of pluginFailureWatchLayer(watch, 'packages')) addNotLoaded(pluginFailurePackageName(raw), 'packages')
10434
+
10435
+ // A vanished TOOL is EVIDENCE, never a trigger: it is attached to a package
10436
+ // that already did not come up. A tool that vanished while its package still
10437
+ // loads is an upgrade and stays out of this card.
10438
+ for (const raw of pluginFailureWatchLayer(watch, 'tools')) {
10439
+ const owner = pluginFailureToolOwner(raw)
10440
+ const entry = byName.get(owner)
10441
+ if (entry === undefined) continue
10442
+ const tool = typeof raw === 'string' ? raw.trim() : ''
10443
+ if (tool !== '' && entry.lostTools.includes(tool) === false) entry.lostTools.push(tool)
10444
+ }
10445
+
10446
+ // clientModules CANNOT name a plugin: its items are module names. It is a
10447
+ // blind spot, and it is reported as a number so nobody mistakes `batches`
10448
+ // for a plugin the user should go and look for.
10449
+ const uiBlindSpot = pluginFailureWatchLayer(watch, 'clientModules').length
10450
+
10451
+ // Nothing to say: no shell, no empty panel.
10452
+ if (certain.length === 0) return null
10453
+
10454
+ const watchAt = watch && Number.isFinite(Number(watch.at)) ? Number(watch.at) : null
10455
+ return { certain, uiBlindSpot, watchAt }
10456
+ } catch {
10457
+ // The panel is a convenience; a malformed payload must never break the page.
10458
+ return null
10459
+ }
10460
+ }
10461
+
10462
+ const PLUGIN_FAILURE_EXPORTS = {
10463
+ buildPluginFailureModel,
10464
+ pluginFailureBootId,
10465
+ pluginFailureJudgedKey,
10466
+ pluginFailureFingerprint,
10467
+ pluginFailureToolOwner,
10468
+ pluginFailurePackageName,
10469
+ }
10470
+ if (typeof globalThis !== 'undefined') {
10471
+ globalThis.__dshAutorestartClient = Object.assign(
10472
+ globalThis.__dshAutorestartClient || {},
10473
+ PLUGIN_FAILURE_EXPORTS,
10474
+ )
10475
+ }
10476
+
10477
+ })()