@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -111,6 +111,20 @@ window.__ModuleLoader__.load({
111
111
  safetyLockRepairPartial: "重建后仍有差距,见下方问题",
112
112
  safetyLockNoRebuild: "当前不需要重建",
113
113
  safetyLockCheckBtn: "体检",
114
+ // 0.5.84 加法(用户第 7 项):磁盘体检 / 两步清理
115
+ diskTitle: "磁盘占用(plugin-console)",
116
+ diskDesc: "只读体检:这个目录谁占了多少、哪些能安全清理、哪些绝不能动(当前回滚点指向的备份一律不许删)",
117
+ diskCheckBtn: "磁盘体检",
118
+ diskSummary: "共 {mb} MB({n} 项)· 可直接清理约 {safe} MB · 可清理(含取舍)约 {reclaim} MB",
119
+ diskSafeHint: "只清理「安全」级(日志 / 旧残留 / 状态 .bak):不影响任何功能",
120
+ diskConfirmHint: "确认清理:这一步才会真的删(服务端只删体检白名单里的条目)",
121
+ diskCleanupBtn: "清理安全项",
122
+ diskConfirmBtn: "确认清理(约 {mb} MB)",
123
+ diskPlanReady: "将清理 {n} 项、释放约 {mb} MB —— 确认后才会真的删",
124
+ diskConfirmBtn2: "确认清理",
125
+ diskDone: "已清理",
126
+ diskFailed: "磁盘体检失败",
127
+ diskNoCandidate: "没有可安全清理的条目(大头通常是当前回滚点指向的框架备份,不能删)",
114
128
  safetyLockRepairBtn: "重建 lock(显式)",
115
129
  // 2026-09-27 加法:安装结果旁的「建议动作」执行框(短句进面板,长解释进悬浮 title)
116
130
  actionBoxTitle: "建议动作",
@@ -127,6 +141,12 @@ window.__ModuleLoader__.load({
127
141
  linksToggleBtn: "开发式链接(link)明细",
128
142
  linksToggleSummary: "开发式链接(link):共 {n} 个,自足 {ready} 个",
129
143
  linksToggleTitle: "展开/收起 link 明细(默认收起)",
144
+ // 0.5.84 加法(用户第 6 项):功能包工具栏里的 link 入口 + 它打开的那块面板
145
+ linksBtn: "开发式链接",
146
+ linksPanelTitle: "开发式链接(link:)—— 直接展示 /plugin-console/links 的只读清单:指向哪、目标还在不在、依赖/peer 齐不齐、垫片在不在",
147
+ linksPanelLoading: "正在读取…",
148
+ linksPanelReload: "重新读取",
149
+ linksPanelEmpty: "没有 link: 钉住的插件(这份清单只列开发式链接)",
130
150
  devRootsTitle: "开发区根(只有这些根里的目录能钉住)",
131
151
  devRootsNone: "(未登记)",
132
152
  devRootAddPlaceholder: "注册新的开发区根(绝对路径,例如 D:\\my-plugins)",
@@ -251,6 +271,8 @@ window.__ModuleLoader__.load({
251
271
  gatePendingNone: "当前没有待适配的行",
252
272
  gateListPending: "待适配明细({n} 行)",
253
273
  gateListAdopted: "已适配解锁({n} 行,历史上被拦过、现已放行)",
274
+ gateHistoryShow: "展开查看 {n} 条历史",
275
+ gateHistoryHide: "收起历史",
254
276
  gateListMore: "展开全部 {n} 行",
255
277
  gateListFold: "收起列表",
256
278
  gateRowReason: "原因:{reason}",
@@ -317,6 +339,8 @@ window.__ModuleLoader__.load({
317
339
  // 0.5.38 加法:三处没齐备(清单 / lock / 挂载行)时**绝不**说"已安装并启用"——
318
340
  // 那种状态下任何一次 pnpm 操作或重启都会把它清掉(真机 dshmarket 现场)。
319
341
  installedNotPersisted: "⚠ 已装上但未持久化(重启/重装会消失)",
342
+ // 0.5.84 判据降噪:只是「口径不一致」时换这句 —— 不再拿"会消失"吓人
343
+ installedNotPersistedWarn: "已装上;清单 / lock 口径还没对齐(不影响重启后加载,可顺手钉一下)",
320
344
  installNote: "HMR 正在生效;若插件带界面,请刷新页面",
321
345
  bundleNote: "该包是 bundle 层插件(如皮肤合集),重启 DSH 服务后生效",
322
346
  // ── 安装诊断短句(2026-09-26 加法):面板只放短句;解释性长文留在服务端字段里,
@@ -324,6 +348,8 @@ window.__ModuleLoader__.load({
324
348
  diagCardTitle: "安装诊断",
325
349
  // 0.5.38 加法:持久化判据(服务端 domain/persist.js)——三处任一没就位就出这张卡
326
350
  diagNotPersisted: "未持久化:清单 / lock / 挂载行有缺口",
351
+ // 0.5.84 判据降噪:降级后的短句(可折叠提示,不再报红)
352
+ diagPersistDowngraded: "口径不一致(不影响重启后加载)",
327
353
  // 分隔符也进字典:英文界面用半角标点,避免中文全角标点混进英文句子
328
354
  diagColon: ":",
329
355
  diagSemi: ";",
@@ -419,6 +445,7 @@ window.__ModuleLoader__.load({
419
445
  stageConfiguring: "写入启用配置",
420
446
  stageRepairing: "本地 AI 接管安装中…",
421
447
  restartService: "重启服务(官方路径)",
448
+ restartHostedNotice: "本实例由桌面端外壳托管 —— 请在桌面端内重启:完全退出 DeepSeek Harness 再打开(控制台不自行 kill / spawn,以免与宿主争端口)。",
422
449
  restarting: "正在按官方路径处理重启请求…",
423
450
  autoReload: "页面即将自动刷新…",
424
451
  aiRepaired: "本地 AI 已接管并完成修复,请刷新页面查看",
@@ -730,6 +757,19 @@ window.__ModuleLoader__.load({
730
757
  safetyLockRepairPartial: "Still out of sync after the rebuild — see the problems below",
731
758
  safetyLockNoRebuild: "No rebuild needed right now",
732
759
  safetyLockCheckBtn: "Check",
760
+ diskTitle: "Disk usage (plugin-console)",
761
+ diskDesc: "Read-only audit: what fills this directory, what can be cleaned safely, and what must never be touched (the backup the current rollback point refers to is off limits)",
762
+ diskCheckBtn: "Audit disk",
763
+ diskSummary: "{mb} MB total ({n} entries) - about {safe} MB safely cleanable, about {reclaim} MB cleanable with a tradeoff",
764
+ diskSafeHint: "Clean only the safe tier (logs / old residue / state .bak files): no functionality is affected",
765
+ diskConfirmHint: "Confirm cleanup: this is the step that actually deletes (the server only deletes entries from the audit whitelist)",
766
+ diskCleanupBtn: "Clean safe items",
767
+ diskConfirmBtn: "Confirm cleanup (~{mb} MB)",
768
+ diskPlanReady: "Will remove {n} items, about {mb} MB freed - nothing is deleted until you confirm",
769
+ diskConfirmBtn2: "Confirm cleanup",
770
+ diskDone: "Cleaned",
771
+ diskFailed: "Disk audit failed",
772
+ diskNoCandidate: "Nothing is safely cleanable (the largest item is usually the framework backup the current rollback point refers to, which must not be deleted)",
733
773
  safetyLockRepairBtn: "Rebuild lock (explicit)",
734
774
  actionBoxTitle: "Suggested action",
735
775
  actionPinLabel: "Pin this dependency (record as link:)",
@@ -745,6 +785,11 @@ window.__ModuleLoader__.load({
745
785
  linksToggleBtn: "Dev link (link:) details",
746
786
  linksToggleSummary: "Dev links (link:): {n} total, {ready} self-sufficient",
747
787
  linksToggleTitle: "Expand/collapse link details (collapsed by default)",
788
+ linksBtn: "Dev links",
789
+ linksPanelTitle: "Dev links (link:) - the read-only list from /plugin-console/links: where it points, whether the target still exists, whether deps/peers are complete, and whether shims are in place",
790
+ linksPanelLoading: "Reading...",
791
+ linksPanelReload: "Reload",
792
+ linksPanelEmpty: "No plugin is pinned with link: (this list only covers dev links)",
748
793
  devRootsTitle: "Dev roots (only directories inside these can be pinned)",
749
794
  devRootsNone: "(none registered)",
750
795
  devRootAddPlaceholder: "Register a dev root (absolute path, e.g. D:\\my-plugins)",
@@ -930,12 +975,14 @@ window.__ModuleLoader__.load({
930
975
  recentFailures: "Recent install failures",
931
976
  installed: "Installed and enabled",
932
977
  installedNotPersisted: "⚠ Installed but NOT persisted (it disappears on restart/reinstall)",
978
+ installedNotPersistedWarn: "Installed; manifest / lock are not fully aligned (this does NOT affect loading after a restart - pin it whenever convenient)",
933
979
  installNote: "HMR is applying; refresh the page if the plugin ships UI",
934
980
  bundleNote: "This package is a bundle-layer plugin (e.g. skin packs); restart the DSH service to activate it",
935
981
  // ── install-diagnosis one-liners (2026-09-26, additive): the panel shows short sentences only;
936
982
  // the long server-side explanations go to the title tooltip, never into the panel body ──
937
983
  diagCardTitle: "Install diagnosis",
938
984
  diagNotPersisted: "Not persisted: a gap remains in manifest / lock / mount row",
985
+ diagPersistDowngraded: "Not aligned (this does not affect loading after a restart)",
939
986
  // separators live in the dictionary too: English uses half-width punctuation
940
987
  diagColon: ": ",
941
988
  diagSemi: "; ",
@@ -1027,6 +1074,7 @@ window.__ModuleLoader__.load({
1027
1074
  stageConfiguring: "Writing enable config",
1028
1075
  stageRepairing: "Local AI is taking over the install…",
1029
1076
  restartService: "Restart service (official path)",
1077
+ restartHostedNotice: "This instance is hosted by the desktop shell - restart it there by fully quitting and reopening DeepSeek Harness.",
1030
1078
  restarting: "Handling the restart request via the official path…",
1031
1079
  autoReload: "Page will auto-reload…",
1032
1080
  aiRepaired: "Local AI took over and finished the repair; refresh the page",
@@ -2311,6 +2359,28 @@ function healthSignature(health) {
2311
2359
  });
2312
2360
  // 门控面板(用户要求:总开关收进「功能包」里的 [门控] 按钮,不再占已安装列表的表头)
2313
2361
  const [gateOpen, setGateOpen] = react.useState(false);
2362
+ // ★ 0.5.84 加法(用户第 6 项):功能包工具栏里的 **link 入口**。
2363
+ // 过去开发式链接(link:)清单只长在「升级安全」面板**内部**的折叠块里 —— 得先找到那个入口、
2364
+ // 再展开才看得到;用户要的是从工具栏直接跳到那份数据。数据源与判据与安全面板**完全同一份**
2365
+ // (`GET /plugin-console/links`,纯只读,判据全在 domain/link-inventory.js);
2366
+ // 这里只负责如实展示,不推断、不美化,也不自己算"自足/缺什么"。
2367
+ // 安全面板里那块折叠展示**保留不动**(老入口照旧),这是加法。
2368
+ const [linksPanelOpen, setLinksPanelOpen] = react.useState(false);
2369
+ const [linksPanelData, setLinksPanelData] = react.useState(null);
2370
+ const [linksPanelBusy, setLinksPanelBusy] = react.useState(false);
2371
+ const linksPanelLoad = async () => {
2372
+ if (linksPanelBusy) return;
2373
+ setLinksPanelBusy(true);
2374
+ try {
2375
+ const data = await call("/plugin-console/links");
2376
+ if (data !== null) setLinksPanelData(data);
2377
+ } catch (error) {
2378
+ // 拿不到就如实说(不静默成"没有 link 依赖")
2379
+ setLinksPanelData({ ok: false, items: [], count: 0, readyCount: 0, error: (error && error.message) || t("safetySwitchFailed") });
2380
+ } finally { setLinksPanelBusy(false); }
2381
+ };
2382
+ // ★ 0.5.80:历史解锁记录默认折叠(用户:新的应覆盖旧的,别一直铺满)
2383
+ const [gateHistoryOpen, setGateHistoryOpen] = react.useState(false);
2314
2384
  // 功能包位置:长按拖动,持久化 localStorage(每次打开沿用上次位置)
2315
2385
  const [toolbarPos, setToolbarPos] = react.useState(() => {
2316
2386
  try {
@@ -3473,7 +3543,13 @@ function healthSignature(health) {
3473
3543
  // 0.5.38 加法(用户诉求「下载下来就不用 lock、重启也不会消失」):
3474
3544
  // 三处没齐备时**绝不报"已安装并启用"** —— 说清它还缺什么、后果是什么,
3475
3545
  // 并把服务端下发的「一键钉住」执行框带出来(下面的 suggestedAction 分支)。
3476
- setMessage(t("installedNotPersisted") + ":" + data.packageName + (typeof data.persistNote === "string" && data.persistNote !== "" ? "。" + data.persistNote : "") + diagMessage(data));
3546
+ // 0.5.84 降噪:只有「会丢插件」那一档才用"重启/重装会消失"这句;只是口径
3547
+ // 不一致(lock 形态、清单与磁盘版本)时用软一点的措辞,原因照样在诊断行里。
3548
+ const sev = data.persist !== null && data.persist !== undefined && typeof data.persist.severity === "object" && data.persist.severity !== null ? data.persist.severity : null;
3549
+ const criticalGap = sev === null ? true : sev.level === "critical";
3550
+ const head = criticalGap ? t("installedNotPersisted") : t("installedNotPersistedWarn");
3551
+ const detail = typeof data.persistNote === "string" && data.persistNote !== "" ? data.persistNote : (typeof sev?.note === "string" ? sev.note : "");
3552
+ setMessage(head + ":" + data.packageName + (detail !== "" ? "。" + detail : "") + diagMessage(data));
3477
3553
  setReloadHint(true);
3478
3554
  } else {
3479
3555
  setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
@@ -3624,15 +3700,27 @@ function healthSignature(health) {
3624
3700
  title: channelNotes.join("\n"),
3625
3701
  });
3626
3702
  }
3627
- // ⑤ 持久化判据(0.5.38 加法,服务端 domain/persist.js:{ missing, actual, note }):
3628
- // 三处(清单 spec / pnpm-lock.yaml 条目 / 挂载行)任一没就位 → error 级诊断行,
3703
+ // ⑤ 持久化判据(0.5.38 加法,服务端 domain/persist.js:{ missing, actual, note, severity }):
3704
+ // 三处(清单 spec / pnpm-lock.yaml 条目 / 挂载行)任一没就位 → 诊断行,
3629
3705
  // 完整缺口清单挂 title。**装成功但没钉住**的作业从此不会只显示"已安装并启用"。
3706
+ // ★ 0.5.84 判据降噪(用户 2026-10 反馈:红字太多,真要紧的那条被淹了):
3707
+ // 服务端 persistSeverity() 已把每个缺口分成「会丢插件 / 只是口径不一致」两档,
3708
+ // 这里**只认那一份结论**,不自己重判:
3709
+ // · critical(清单里没有它 / 重启后挂不上)→ error 级(红),并给完整缺口清单;
3710
+ // · warn(lock 形态/目标、清单形态与磁盘版本不一致)→ warn 级(可折叠提示)+
3711
+ // 明说"不影响重启后加载",不再拿红字吓人。
3712
+ // 老服务端没有 severity 字段 → 按 critical 处理(保持 0.5.38 以来的既有行为,不静默降级)。
3630
3713
  if (view.status === "done" && view.persisted === false) {
3714
+ const severity = view.persist !== null && view.persist !== undefined && typeof view.persist.severity === "object" && view.persist.severity !== null ? view.persist.severity : null;
3715
+ const critical = severity === null ? true : severity.level === "critical";
3716
+ const missingText = Array.isArray(view.persist?.missing) && view.persist.missing.length > 0 ? t("diagColon") + view.persist.missing.join(" / ") : "";
3717
+ const longText = typeof view.persistNote === "string" && view.persistNote !== "" ? view.persistNote
3718
+ : (typeof view.persist?.note === "string" ? view.persist.note : null);
3631
3719
  lines.push({
3632
3720
  key: "persist",
3633
- text: t("diagNotPersisted") + (Array.isArray(view.persist?.missing) && view.persist.missing.length > 0 ? t("diagColon") + view.persist.missing.join(" / ") : ""),
3634
- level: "error",
3635
- title: typeof view.persistNote === "string" && view.persistNote !== "" ? view.persistNote : (typeof view.persist?.note === "string" ? view.persist.note : null),
3721
+ text: critical ? t("diagNotPersisted") + missingText : t("diagPersistDowngraded") + missingText,
3722
+ level: critical ? "error" : "warn",
3723
+ title: !critical && typeof severity?.note === "string" && severity.note !== "" ? severity.note : longText,
3636
3724
  });
3637
3725
  }
3638
3726
  return lines;
@@ -3821,7 +3909,7 @@ function healthSignature(health) {
3821
3909
  };
3822
3910
  // subdir 用于「从这个子目录安装」(0.5.47 加法):服务端按它去读 <subdir>/package.json、
3823
3911
  // 稀疏取该子目录源码、再按既有本地目录来源(link:)装。为空时请求体形状与改动前逐字相同。
3824
- const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null) => {
3912
+ const startJob = (repo, packageName, source = "github", kind = "plugin", update = false, subdir = null, version = null) => {
3825
3913
  // 防并发(与 addLocal 一致):已有安装/更新进行中时忽略新点击(更新按钮此前可绕过,
3826
3914
  // 反复点击会产生几十个并发任务装同一插件)
3827
3915
  // 只拦**同一个目标**:不同插件可以并行更新(服务端是作业模型,天然支持)
@@ -3836,6 +3924,9 @@ function healthSignature(health) {
3836
3924
  setRestartHint(false);
3837
3925
  setMessage(null);
3838
3926
  const body = { repo: repo ?? "", packageName, source, kind, update };
3927
+ // ★ 0.5.81(用户 2026-10-18):目标版本必须传下去 —— 不带版本时 pnpm 按 latest 把旧版本原地重装,
3928
+ // 表现就是"更新了、刷新后还是老版本"。null/空串一律不带(行为与改动前逐字相同)。
3929
+ if (typeof version === "string" && version !== "") body.version = version;
3839
3930
  if (subdir !== null && subdir !== undefined && String(subdir) !== "") body.subdir = String(subdir);
3840
3931
  call("/plugin-console/install", body).then(
3841
3932
  (data) => {
@@ -4121,7 +4212,8 @@ function healthSignature(health) {
4121
4212
  type: "button",
4122
4213
  className: styles.toggle,
4123
4214
  disabled: installing === entry.moduleName,
4124
- onClick: () => startJob("", entry.moduleName, "github", "plugin", true),
4215
+ // ★ 0.5.81:把服务端算出的目标版本一起传下去(传 null 时行为与改动前逐字相同)
4216
+ onClick: () => startJob("", entry.moduleName, "github", "plugin", true, null, updateCheck.target ?? updateCheck.latest ?? null),
4125
4217
  }, installing === entry.moduleName ? t("installing") : (entry.pendingCompat === true ? t("updateAndAdapt") : t("updateNow")))),
4126
4218
  Array.isArray(updateCheck.depsOutdated) && updateCheck.depsOutdated.length > 0
4127
4219
  ? el("p", { className: styles.status, "data-error": "true" },
@@ -4193,13 +4285,14 @@ function healthSignature(health) {
4193
4285
  el("button", {
4194
4286
  type: "button",
4195
4287
  className: styles.toggle,
4288
+ // ★ 强制启用且未运行 ⇒ 这个开关本身是空操作(patch.js#enableEntry 直接 changed:false),藏起来
4196
4289
  disabled: !entry.toggleable || busy === entry.entryId, // ★ 待适配不再禁用启用(用户 2026-10-18):启用=确认启用,服务端会把它标为已采纳
4197
4290
  // 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
4198
4291
  title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
4199
4292
  onClick: () => toggle(entry, !entry.enabled),
4200
4293
  }, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
4201
4294
  // ★ 0.5.76:补丁写了强制开启但运行时没起来 ⇒ 给一个撤销覆盖的入口
4202
- entry.forcedNotLoaded === true ? el("button", {
4295
+ entry.userForced === true ? el("button", {
4203
4296
  type: "button",
4204
4297
  className: styles.toggle,
4205
4298
  title: t("revokeForceHint"),
@@ -4924,7 +5017,44 @@ function healthSignature(health) {
4924
5017
  el("button", { type: "button", className: styles.toggle, onClick: fwStatusRefresh }, t("fwPanelRefreshStatus")),
4925
5018
  el("button", { type: "button", className: styles.toggle, disabled: fwCheckBusy, onClick: () => fwCheckRefresh(true) }, fwCheckBusy ? t("fwPanelChecking") : t("fwPanelRecheck"))),
4926
5019
  el("div", { className: styles.rowTop },
4927
- el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")))))
5020
+ hostedByShell
5021
+ ? el("p", { className: styles.status, "data-hosted": "true" }, t("restartHostedNotice"))
5022
+ : el("button", { type: "button", className: styles.toggle, onClick: () => { restartServiceViaServer(setMessage); } }, t("restartService")))))
5023
+ : null,
5024
+ // ★ 0.5.84(用户第 6 项):功能包 → [开发式链接] 的面板:直接展示 /plugin-console/links
5025
+ // 的同一份数据(与「升级安全」里那块折叠展示同源)。判据来自服务端,这里只如实展示:
5026
+ // 每行 = ✓/• + 包名 + 版本 + 指向 + 缺什么(悬停看完整原因)。
5027
+ linksPanelOpen
5028
+ ? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setLinksPanelOpen(false); } },
5029
+ el("div", { className: styles.modalCard, style: { maxWidth: 620 } },
5030
+ el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
5031
+ el("strong", { className: styles.name }, t("linksBtn")),
5032
+ el("button", { type: "button", className: styles.trashBtn, style: { fontSize: 14, padding: "4px 8px" }, title: t("closeModal"), "aria-label": t("closeModal"), onClick: () => setLinksPanelOpen(false) }, "✕")),
5033
+ el("p", { className: styles.message }, t("linksPanelTitle")),
5034
+ linksPanelData === null
5035
+ ? el("p", { className: styles.status }, t("linksPanelLoading"))
5036
+ : (linksPanelData.ok === false
5037
+ ? el("p", { className: styles.message, "data-warn": "true" }, t("linksFailed") + ":" + String(linksPanelData.error ?? "-"))
5038
+ : el("div", { style: { flexDirection: "column", gap: 4, display: "flex", maxHeight: "52vh", overflow: "auto" } },
5039
+ // ⚠️ 这里**不能**用 fill():它是 PluginSafetyTab 内部的局部助手,
5040
+ // 主面板里出现 fill( 就是 0.5.59 那次「整块空白」事故的根因
5041
+ // (tests/test-ui-contract.mjs ⑨ 与 test-client-scope.mjs ★★ 都把这条钉死了)。
5042
+ // 这里用最朴素的字符串替换,语义与 fill 的两个占位符一致。
5043
+ el("div", { style: { fontSize: 12, fontWeight: 500 } },
5044
+ t("linksTitle").replace("{n}", String(linksPanelData.count ?? 0)).replace("{ready}", String(linksPanelData.readyCount ?? 0))),
5045
+ (Array.isArray(linksPanelData.items) ? linksPanelData.items : []).length === 0
5046
+ ? el("p", { className: styles.status }, t("linksPanelEmpty"))
5047
+ : null,
5048
+ (Array.isArray(linksPanelData.items) ? linksPanelData.items : []).map((x) => el("div", { key: "lp-" + x.packageName, title: x.note ?? undefined, style: { fontSize: 12, opacity: .9, wordBreak: "break-all" } },
5049
+ (x.ready === true ? "✓ " : "• ") + x.packageName + (x.version ? " v" + x.version : "")
5050
+ + (x.target ? " → " + x.target : "")
5051
+ + (Array.isArray(x.missingDeps) && x.missingDeps.length > 0 ? " · 缺依赖 " + x.missingDeps.length : "")
5052
+ + (Array.isArray(x.missingPeers) && x.missingPeers.length > 0 ? " · 缺 peer " + x.missingPeers.length : "")
5053
+ + (x.foreignPeers !== null && x.foreignPeers !== undefined && Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
5054
+ + (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
5055
+ + (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : ""))))),
5056
+ el("div", { className: styles.rowTop, style: { justifyContent: "flex-end", gap: 8, marginTop: 10 } },
5057
+ el("button", { type: "button", className: styles.toolItem, "data-busy": linksPanelBusy ? "true" : "false", onClick: linksPanelLoad }, linksPanelBusy ? t("linksPanelLoading") : t("linksPanelReload")))))
4928
5058
  : null,
4929
5059
  gateOpen
4930
5060
  ? el("div", { className: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget) setGateOpen(false); } },
@@ -4981,9 +5111,17 @@ function healthSignature(health) {
4981
5111
  : null,
4982
5112
  gateRows.adoptedCount > 0
4983
5113
  ? el("div", { style: { flexDirection: "column", gap: 2, display: "flex" } },
4984
- el("strong", { className: styles.name }, t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
4985
- gateRows.adopted.slice(0, 8).map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
4986
- "✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : ""))))
5114
+ // ★ 0.5.80:默认折叠,只留一行摘要(点开才列明细)—— 历史不该把当前态挤下去
5115
+ el("button", {
5116
+ type: "button",
5117
+ className: styles.toggle,
5118
+ onClick: () => setGateHistoryOpen((v) => !v),
5119
+ }, (gateHistoryOpen ? t("gateHistoryHide") : t("gateHistoryShow").replace("{n}", String(gateRows.adoptedCount)))
5120
+ + " · " + t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
5121
+ gateHistoryOpen
5122
+ ? gateRows.adopted.map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
5123
+ "✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : "")))
5124
+ : null)
4987
5125
  : null)
4988
5126
  : null),
4989
5127
  // 0.5.42 加法(用户点名):右侧贴底的 Star 提示 —— 只加这一行 UI,
@@ -5406,13 +5544,14 @@ function healthSignature(health) {
5406
5544
  }, t("toolbarLabel") + (toolbarOpen ? " ▾" : " ▸")),
5407
5545
  toolbarOpen
5408
5546
  ? el("div", { className: styles.toolbarGroup, style: (dragPos ?? toolbarPos) !== null ? { left: (dragPos ?? toolbarPos).left, top: (dragPos ?? toolbarPos).top + 36, right: "auto" } : undefined },
5409
- el("button", {
5410
- type: "button",
5411
- className: styles.toolItem,
5412
- title: t("restartService"),
5413
- onClick: () => { restartServiceViaServer(setMessage); },
5414
- }, t("restartService")),
5415
- el("button", {
5547
+
5548
+ hostedByShell ? null : el("button", { // ★ 0.5.84:桌面端(shell 托管)不显示;web 端保留且可用
5549
+ type: "button",
5550
+ className: styles.toolItem,
5551
+ title: t("restartService"),
5552
+ onClick: () => { restartServiceViaServer(setMessage); },
5553
+ }, t("restartService")),
5554
+ el("button", {
5416
5555
  type: "button",
5417
5556
  className: styles.toolItem,
5418
5557
  "data-active": repoLandOn ? "true" : "false",
@@ -5448,6 +5587,16 @@ function healthSignature(health) {
5448
5587
  "aria-label": t("sourcesBtn"),
5449
5588
  onClick: openSources,
5450
5589
  }, t("sourcesBtn")),
5590
+ el("button", {
5591
+ type: "button",
5592
+ className: styles.toolItem,
5593
+ "data-active": linksPanelOpen ? "true" : "false",
5594
+ "data-busy": linksPanelBusy ? "true" : "false",
5595
+ title: t("linksPanelTitle"),
5596
+ "aria-label": t("linksBtn"),
5597
+ // ★ 0.5.84(用户第 6 项):打开时拉一次真实清单(与安全面板同一个只读接口)
5598
+ onClick: () => setLinksPanelOpen((v) => { const next = !v; if (next) linksPanelLoad(); return next; }),
5599
+ }, t("linksBtn") + (linksPanelData !== null && Array.isArray(linksPanelData.items) ? " " + linksPanelData.items.length : "")),
5451
5600
  el("button", {
5452
5601
  type: "button",
5453
5602
  className: styles.toolItem,
@@ -6281,6 +6430,45 @@ function healthSignature(health) {
6281
6430
  const [pinInput, setPinInput] = react.useState("");
6282
6431
  const [pinNote, setPinNote] = react.useState(null);
6283
6432
  const [allowAgent, setAllowAgent] = react.useState(false);
6433
+ // ★ 0.5.84 加法(用户第 7 项):磁盘体检 / 清理的状态与两个动作。
6434
+ // 判据全在服务端(domain/disk-audit.js):面板只展示服务端给的 level 与体积,
6435
+ // 并且**必须**经过「先要计划(不带 confirm)→ 再确认(confirm:true)」两步。
6436
+ const [diskAudit, setDiskAudit] = react.useState(null);
6437
+ const [diskPlan, setDiskPlan] = react.useState(null);
6438
+ const [diskBusy, setDiskBusy] = react.useState(false);
6439
+ const [diskNote, setDiskNote] = react.useState(null);
6440
+ const diskCheck = async () => {
6441
+ if (diskBusy) return;
6442
+ setDiskBusy(true); setDiskNote(null); setDiskPlan(null);
6443
+ try {
6444
+ const data = await call("/plugin-console/disk-audit"); // 只读:无 body ⇒ GET
6445
+ setDiskAudit(data ?? null);
6446
+ if (data !== null && data.ok === true) setDiskNote({ text: fill(t("diskSummary"), [["mb", String(data.totalMb ?? 0)], ["n", String((data.entries ?? []).length)], ["safe", String(data.safeMb ?? 0)], ["reclaim", String(data.reclaimableMb ?? 0)]]) + (data.truncated === true ? "(统计已截断)" : ""), level: null });
6447
+ } catch (error) { setDiskNote({ text: String((error && error.message) || error), level: "error" }); }
6448
+ finally { setDiskBusy(false); }
6449
+ };
6450
+ // confirm=false ⇒ 服务端**只回计划**(一个文件都不动);confirm=true 才会真删。
6451
+ const diskCleanup = async (confirm) => {
6452
+ if (diskBusy) return;
6453
+ setDiskBusy(true); setDiskNote(null);
6454
+ try {
6455
+ const ids = confirm && diskPlan !== null && Array.isArray(diskPlan.ids) && diskPlan.ids.length > 0
6456
+ ? diskPlan.ids
6457
+ : (diskAudit !== null && Array.isArray(diskAudit.entries) ? diskAudit.entries.filter((x) => x.level === "safe").map((x) => x.id) : []);
6458
+ const data = await call("/plugin-console/disk-cleanup", { ids: ids, confirm: confirm === true });
6459
+ if (data !== null && data.needsConfirm === true) {
6460
+ // 第一步的回报:把计划(哪些项 / 能省多少)留下,等用户点「确认清理」
6461
+ const plan = data.plan ?? {};
6462
+ setDiskPlan({ ids: (plan.plan ?? []).map((p) => p.id), freedMb: plan.freedMb ?? 0 });
6463
+ setDiskNote({ text: fill(t("diskPlanReady"), [["n", String((plan.plan ?? []).length)], ["mb", String(plan.freedMb ?? 0)]]) + ((plan.refused ?? []).length === 0 ? "" : " · 已拒绝 " + plan.refused.length + " 项"), level: null });
6464
+ } else if (data !== null) {
6465
+ setDiskPlan(null);
6466
+ setDiskNote({ text: String(data.note ?? t("diskDone")), level: data.ok === true ? null : "error" });
6467
+ await diskCheck(); // 删完重新体检,让面板显示真实的新体积
6468
+ }
6469
+ } catch (error) { setDiskNote({ text: String((error && error.message) || error), level: "error" }); }
6470
+ finally { setDiskBusy(false); }
6471
+ };
6284
6472
  const load = async () => {
6285
6473
  try {
6286
6474
  // link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
@@ -6530,6 +6718,28 @@ function healthSignature(health) {
6530
6718
  lockFix !== null && lockFix.ok !== true && Array.isArray(lockFix.problems) && lockFix.problems.length > 0
6531
6719
  ? lockFix.problems.map((p) => p.hint).join("\n")
6532
6720
  : undefined),
6721
+ // ★ 0.5.84 加法(用户第 7 项):`<DSH_HOME>/plugin-console` 的**磁盘体检 + 显式确认后清理**。
6722
+ // 两段式(不让人闭眼点):先「磁盘体检」(GET /disk-audit,纯只读)→ 再「清理安全项」
6723
+ // (POST /disk-cleanup,**不带 confirm** ⇒ 服务端只回计划)→ 最后「确认清理」(confirm:true)。
6724
+ // 哪些能删、哪些绝不能删**全由服务端判**(domain/disk-audit.js):真机实测最大的 2.1 GB
6725
+ // 恰恰是当前回滚点指向的备份,面板不自己按体积猜。
6726
+ row("trash", t("diskTitle"),
6727
+ diskAudit === null ? t("diskDesc")
6728
+ : diskAudit.ok !== true ? t("diskFailed") + ":" + String(diskAudit.error ?? "-")
6729
+ : fill(t("diskSummary"), [["mb", String(diskAudit.totalMb ?? 0)], ["n", String((diskAudit.entries ?? []).length)], ["safe", String(diskAudit.safeMb ?? 0)], ["reclaim", String(diskAudit.reclaimableMb ?? 0)]]),
6730
+ el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
6731
+ el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskDesc"), onClick: diskCheck }, t("diskCheckBtn")),
6732
+ diskAudit !== null && diskAudit.ok === true && (diskAudit.safeBytes ?? 0) > 0
6733
+ ? el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskSafeHint"), onClick: () => diskCleanup(false) }, t("diskCleanupBtn"))
6734
+ : null,
6735
+ diskPlan !== null
6736
+ ? el("button", { type: "button", className: styles.toggle, disabled: busy || diskBusy, title: t("diskConfirmHint"), onClick: () => diskCleanup(true) }, fill(t("diskConfirmBtn"), [["mb", String(diskPlan.freedMb ?? 0)]]))
6737
+ : null),
6738
+ diskAudit !== null && diskAudit.ok === true && Array.isArray(diskAudit.entries)
6739
+ ? diskAudit.entries.filter((x) => x.level !== "protected").slice(0, 6)
6740
+ .map((x) => (x.level === "safe" ? "✓ " : "! ") + x.name + " " + x.mb + " MB").join("\n") || t("diskNoCandidate")
6741
+ : undefined),
6742
+ diskNote === null ? null : el("div", { style: { fontSize: "12px", padding: "0 12px 6px", color: diskNote.level === "error" ? "#d33" : "inherit", wordBreak: "break-word" } }, diskNote.text),
6533
6743
  row("pulse", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
6534
6744
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(false) }, t("fwPreflightBtn")),
6535
6745
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(true) }, t("fwPreflightPatch")))),