dsh-m 0.9.78 → 0.9.80

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/CHANGELOG.md CHANGED
@@ -8,6 +8,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.80 修复:已装页开关抖动根治 + 社区开关定位恒定 + 「已实测」徽章退役(三项 UX 修复,client-only)
12
+
13
+ - **① 已装页开关抖动根治(主人实拍:点开关整页抖、开关原地失踪、鼠标不再命中)**:CDP 实测每次点击开关发生 47px×2 卡片栅格位移 + 0.6–1.3s 命中丢失窗口(原点落到上方卡片 `.dshm-links` 外链行,误触即开浏览器标签)。根因三件:`ProgressLine`(install 专用 pnpm 进度条)对 toggle 也挂载(35px 行 + body 12px gap,插入/拔出即位移);toast/重启横幅是面板 column flex 的流内子元素(出现/消失压缩 body 39px×2);开关视觉翻转要等 5 个刷新请求落地(1.5–2.3s「点了没反应」真空期,且期间开关 `locked` 灰态)。修复:新增 `progressTarget`(仅 install/upgrade/self-upgrade 挂进度条;`activeMutateTarget` 语义不动,仍管逐行禁用);toast/横幅包进 `.dshm-banner-stack`(absolute 底部浮层,pointer-events 穿透);`pendingToggles` 乐观翻转(点击 42ms 即变,`finally` 摘除=成功收敛/失败回滚);toggle 只重取已装视图(installed+market×2+updates+external-operations → installed×1,`view-refresh.js` 未动、单测原样);`.dshm-switch::before` 热区 34×19 → 46×31(视觉不变)。CDP 回测四断言全过:位移 0、命中零丢失、翻转 42ms、body 底缘 0 位移。
14
+ - **② 社区目录开关钉进标题行(主人实拍:开/关后开关位置高低不同)**:开关行原是卡片 body 末行,关态 5 行元信息消失即被上提约 5 行高。移入 `.dshm-section-title` 右侧(flex 现成 + 开关自带 `margin-left:auto`;与已装卡片右上角开关同一交互语言),CDP 实测 ON/OFF 两态开关 y 恒等(135.2px,漂移 0.0);冗余「社区目录开关」行退役,语义并入开关 tooltip。
15
+ - **③ 「已实测」徽章退役(B 方案:byline 灰字承接)**:verified 仅覆盖精选 4/21 条且全为作者自家插件——徽章实为署名代理、区分度趋零,真载荷(实测过的 DSH 运行时清单)藏在 hover tooltip 里触屏不可达;按「卡片徽章只放例外信号(弃用/自用/可升级)」原则退役。市场卡/收藏快照卡/弹窗头三处渲染退役;市场卡 tag 行尾新增灰字 `实测 ≤{最新复验版本}`(tooltip 全量清单);详情弹窗「实测版本」行保留为唯一权威呈现;`badge.verified` 键按 0.9.76「版本无关徽章退役」先例退役;registry 数据、snapshotOf 透传与安装期 peer 兼容弹窗零触碰。
16
+ - **验证**:1425 项测试全绿(0.9.79 基线 1423 + 退役断言 2;U12 收藏卡契约随 ③ 改写为新契约)、typecheck 零错误;三项均 client-only,刷新页面即生效;①② 经 CDP 全程量化回测(位移/命中/翻转延迟/几何恒定)。
17
+
18
+ ### 0.9.79 功能:外部操作记录——非面板通道(CLI / Agent 工具)变更直落审计日志进面板「已结束」(ADR-0018)
19
+
20
+ - **问题(审计面脱节实证)**:全局操作记录的口径 = 面板发起——quota-watch 0.1.28 经 `dshm_upgrade`(Agent 工具)升级装机成功后,面板「已结束」列表零记录,操作审计面与真实系统状态脱节。CLI 与 Agent 工具是独立进程,写不进浏览器 localStorage 的操作泵。
21
+ - **方案(ADR-0018:机器本地直落日志,与操作泵分离)**:新增 `$DSH_HOME/dshm/external-operations.json`(`DSHM_EXTERNAL_JOURNAL_FILE` 可覆盖;机器本地、不跨机同步)——九个公共变更入口增 `channel` 参数(`'panel'|'cli'|'host-tool'`,缺省 `'external'` 兜底),非面板通道的操作终态后直落一条记录:**成功与失败都记**、滚动 200 条、tmp+rename 原子写、损坏文件改名隔离、append 任何失败被钩子吞掉(至多 stderr 一行,绝不反噬操作终态,R1-1);reason 截断 ≤300。desktop 口径(R1-2):desktopToggle 透传 togglePlugin(唯一嵌套对,单笔事务一条记录);desktopInstall/Uninstall/Upgrade 自记(call-graph audit 证实不经已插桩入口);服务缺席拒绝自记 `desktop-toggle` failed。
22
+ - **面板消费(内存 overlay 并读,不写 localStorage)**:host-api 新增 `external-operations`(list)与 `external-operations-clear` 两 method(鉴权沿既有宿主信任检查);「已结束」列表 = 本地操作泵(localStorage,现状不动)+ 外部记录 overlay(服务端为唯一事实源,persist 路径零触碰——防服务端淘汰后被本地副本复活,R1-3);渠道徽标 **Agent 工具** / **命令行** / 兜底**外部**(Q2b/R1-4);失败记录点亮红点(Q4);外部行不渲染单条 ✕(R2-3);「清除已结束」连带清除服务端日志 + overlay 同步清空;fetch 时机 = 面板初始化 + 每次操作完成后 + 打开操作记录卡(不轮询,R1-6);「已结束」计数改用合并后全量,最近 50 条渲染截断照旧(R1-7)。
23
+ - **验证**:1423 项测试全绿(0.9.78 基线 1389 + 新增 34:journal 存储 8(滚动/损坏容错/panel 拒绝/无凭据/路径上抛/截断)、通道插桩 8(panel 零记录/cli、host-tool 各一条/嵌套单条/失败 failed/append 注入抛错不反噬)、host-api 4(list/clear/鉴权/注入位)、client 模型+视图 14(overlay 升序并读/persist 纯净/幂等/徽标/✕ 出口/计数/接线锚))、typecheck 零错误;测试全程零触碰真实 `~/.dsh`(10 个既有测试文件补 journal 隔离,另新增 1 个通道插桩测试文件,沿用 cacheRoot 纪律)。实施计划经独立评审 Agent 三轮评审收敛;npm 发版与 dsh-m 自升级另行经用户确认。
24
+
11
25
  ### 0.9.78 性能:收藏 stale 检查零网络 + 市场页零增量提交守卫(钢人 C3)
12
26
 
13
27
  - **问题(钢人报告 C3 节)**:收藏 stale 判定只需要「id 还在不在目录」的成员布尔,却穿透富 market interface 的探测段——每次收藏区访问/切换(800ms debounce 合并后)仍是 N 条收藏 × full 查询,含每命中条 npm/github latest **网络扇出**且产物全部丢弃;叠加 toggle 后 refreshViews 四连响应无条件 `setData`——数据层精心维持的引用稳定性一次 setState 即蒸发。
@@ -565,6 +579,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
565
579
 
566
580
  ## English
567
581
 
582
+ ### Fixes in 0.9.80 — installed-tab toggle jitter eliminated + community switch pinned to its title row + 已实测 badge retired (three UX fixes, client-only)
583
+
584
+ - **① Installed-tab toggle jitter eliminated (user report: clicking the switch shook the whole page, the switch vanished in place, and the cursor no longer hit it)**: CDP measurement showed a 47px×2 card-grid shift per click plus a 0.6–1.3s hit-test loss window (the cursor's fixed point landed on the card above's `.dshm-links` external-links row — a mis-click opens a browser tab). Three root causes: `ProgressLine` (the install-only pnpm progress bar) mounted for toggles too (35px row + 12px body gap — inserting/unmounting it is the shift itself); the toast/restart banners were in-flow flex children of the panel column (mount/dismiss compressed the body by 39px×2); the switch visually flipped only after five refresh requests landed (a 1.5–2.3s "nothing happened" dead window, with the switch greyed to `locked`). Fixes: a new `progressTarget` gates ProgressLine to install/upgrade/self-upgrade (`activeMutateTarget` semantics untouched — it still drives per-row disabling); toasts/banners are wrapped in `.dshm-banner-stack` (absolute bottom overlay, pointer-events pass-through); optimistic `pendingToggles` flips the switch in 42ms (cleared in `finally` — converges on success, reverts to server state on failure); toggle refreshes the installed view only (installed+market×2+updates+external-operations → installed×1; `view-refresh.js` untouched, its unit tests unchanged); `.dshm-switch::before` grows the hit area 34×19 → 46×31 (visuals unchanged). CDP re-probe passes all four assertions: zero drift, zero hit-loss, 42ms flip, stable body bottom edge.
585
+ - **② Community catalog switch pinned to its section title row (user report: the switch sat at different heights when on vs off)**: the switch row used to be the last child of the card body — toggling off dropped five metadata rows and yanked the switch up ~5 row heights. It now lives at the right edge of `.dshm-section-title` (flex already in place + the switch's own `margin-left:auto`; same interaction language as the installed-page card switches). CDP-measured identical Y in both states (135.2px, 0.0 drift); the redundant "社区目录开关" row is retired into the switch tooltip.
586
+ - **③ 已实测 (verified) badge retired (plan B: byline gray text)**: verified covers only 4/21 curated entries — all the author's own plugins — making the badge an authorship proxy with near-zero discriminating power, while its real payload (the tested DSH runtime list) lived in a hover-only tooltip. Per the "card badges only carry exceptions (deprecated / internal / outdated)" principle it is retired from the market card, the favorite snapshot card, and the modal header; the market card tag line gains a low-emphasis `verified ≤{latest re-verified runtime}` suffix (full list on hover); the detail modal's "Verified runtimes" row remains the single authoritative presentation; the `badge.verified` i18n key is retired following the 0.9.76 version-independent-badge precedent. registry data, snapshotOf passthrough, and the install-time peer-compat dialog are untouched.
587
+ - **Verification**: 1425 tests green (0.9.79 baseline 1423 + 2 retirement assertions; the U12 favorites-card contract rewritten for ③), typecheck clean; all three fixes are client-only — a page refresh applies them; ① and ② were quantified end-to-end with CDP probes (drift / hit-testing / flip latency / geometry stability).
588
+
589
+ ### Feature in 0.9.79 — external operation records: non-panel channels (CLI / agent tools) land terminal-state audit entries into the panel's Ended list (ADR-0018)
590
+
591
+ - **Problem (audit-surface disconnect, real incident)**: the global operation records only covered panel-initiated changes — the quota-watch 0.1.28 upgrade via `dshm_upgrade` (an agent tool) installed successfully yet left zero rows in the panel's Ended list. CLI and agent tools are separate processes and cannot write the browser-localStorage operations pump.
592
+ - **Solution (ADR-0018: machine-local direct-write journal, decoupled from the pump)**: a new `$DSH_HOME/dshm/external-operations.json` (overridable via `DSHM_EXTERNAL_JOURNAL_FILE`; machine-local, never synced across machines) — the nine public mutation entries gain a `channel` parameter (`'panel'|'cli'|'host-tool'`, defaulting to `'external'` as the audit-safe fallback). Non-panel operations append one terminal-state record after completion: **both success and failure are recorded**, rolling cap of 200, tmp+rename atomic writes, corrupted files quarantined by rename, and any append failure swallowed by the hook (at most one stderr line — it never back-fires onto the operation result, R1-1); `reason` truncated to 300 chars. Desktop accounting (R1-2): desktopToggle passes through to togglePlugin (the only nested pair — one transaction, one record); desktopInstall/Uninstall/Upgrade self-record (call-graph audit confirms they bypass instrumented entries); the service-absent structured refusal self-records `desktop-toggle` failed.
593
+ - **Panel consumption (in-memory overlay, never localStorage)**: host-api gains `external-operations` (list) and `external-operations-clear` methods (auth rides the existing host trust check); the Ended list = local operations pump (localStorage, untouched) + external-records overlay (the server side is the single source of truth, persist path untouched — prevents local resurrection after server-side eviction, R1-3); channel badges **Agent tool** / **CLI** / fallback **External** (Q2b/R1-4); failed external entries light the red dot (Q4); external rows render no per-row ✕ (R2-3); Clear ended also clears the server journal plus the overlay; fetch timing = panel init + after each operation + opening the operations card (no polling, R1-6); the Ended count now uses the merged total while the last-50 render cap stays (R1-7).
594
+ - **Verification**: 1423 tests green (0.9.78 baseline 1389 + 34 added: journal storage 8 (rolling / corruption quarantine / panel rejection / no-credentials / path error propagation / truncation), channel wiring 8 (panel zero-record / cli and host-tool one each / nested single record / failure recorded as failed / injected append failure never back-fires), host-api 4 (list / clear / auth / override slots), client model+view 14 (overlay ascending merge / persist purity / idempotence / badge / ✕ exit / count / wiring anchors)), typecheck clean; tests never touch the real `~/.dsh` (10 existing test files gained journal isolation, plus 1 new channel-wiring test file, following the cacheRoot discipline). The implementation plan was closed over three independent review rounds; npm publish and dsh-m self-upgrade remain subject to separate user confirmation.
595
+
568
596
  ### Performance in 0.9.78 — favorites stale checks go cache-only: zero-network favorites access + zero-increment commit guard (steelman C3)
569
597
 
570
598
  - **Problem (steelman report, card C3)**: the favorites stale verdict only needs a "is this id still in the catalog" membership boolean, yet it pierces the rich market interface's probe segment - every favorites visit/toggle (after the 800ms debounce merge) still fires N favorites × full queries including a per-hit npm/github latest **network fan-out** whose products are entirely discarded. On top of that, the unconditional `setData` after refreshViews' four-in-a-row responses (the post-toggle norm) evaporates the reference stability the data layer carefully maintains.
package/lib/cli.js CHANGED
@@ -337,6 +337,7 @@ async function runCliDispatch(argv, deps, io) {
337
337
  version,
338
338
  forceIncompatible: flags.force === true,
339
339
  namespace: 'cli',
340
+ channel: 'cli',
340
341
  });
341
342
  }
342
343
  catch (e) {
@@ -361,6 +362,7 @@ async function runCliDispatch(argv, deps, io) {
361
362
  res = await d.upgradePlugin(target, cfg, {
362
363
  forceIncompatible: flags.force === true,
363
364
  namespace: 'cli',
365
+ channel: 'cli',
364
366
  });
365
367
  }
366
368
  catch (e) {
@@ -386,7 +388,7 @@ async function runCliDispatch(argv, deps, io) {
386
388
  if (onFlag === offFlag)
387
389
  throw new Error('开关命令需要恰好一个 --on 或 --off');
388
390
  requireYes(flags, '切换插件运行状态');
389
- const res = await d.togglePlugin(target, onFlag);
391
+ const res = await d.togglePlugin(target, onFlag, { channel: 'cli' });
390
392
  out(`✅ 已${onFlag ? '启用' : '停用'} ${res.pkg}(profile 文件已更新)`);
391
393
  if (res.applied === 'live')
392
394
  out('本次进程内已即时生效;重启后状态保持。');
@@ -397,7 +399,7 @@ async function runCliDispatch(argv, deps, io) {
397
399
  case 'uninstall': {
398
400
  const target = needFlag(flags, 'pkg');
399
401
  requireYes(flags, '卸载');
400
- const res = await d.uninstallPlugin(target, cfg, { namespace: 'cli' });
402
+ const res = await d.uninstallPlugin(target, cfg, { namespace: 'cli', channel: 'cli' });
401
403
  out(`✅ 已卸载 ${res.pkg}${res.liveDisabled ? '(运行中的界面已先下线)' : ''}`);
402
404
  if (res.leftovers.length)
403
405
  out(`ℹ️ 疑似残留数据(未删除):${res.leftovers.join('、')}`);
package/lib/client.js CHANGED
@@ -1344,10 +1344,28 @@ __export(operations_exports, {
1344
1344
  createOperationsStore: () => createOperationsStore,
1345
1345
  createOpsPump: () => createOpsPump,
1346
1346
  drainRestored: () => drainRestored,
1347
+ externalToRecord: () => externalToRecord,
1347
1348
  opAppliesTo: () => opAppliesTo,
1348
1349
  restoreRecords: () => restoreRecords,
1349
1350
  upgradeNotify: () => upgradeNotify
1350
1351
  });
1352
+ function externalToRecord(er) {
1353
+ if (!er || typeof er !== "object") return null;
1354
+ const meta = {};
1355
+ if (typeof er.fromVersion === "string" && er.fromVersion !== "") meta.from = er.fromVersion;
1356
+ if (typeof er.toVersion === "string" && er.toVersion !== "") meta.to = er.toVersion;
1357
+ return {
1358
+ id: typeof er.id === "string" && er.id !== "" ? er.id : `ext-${er.at ?? "unknown"}`,
1359
+ kind: er.kind,
1360
+ target: typeof er.target === "string" ? er.target : "",
1361
+ status: er.result === "failed" ? "failed" : "done",
1362
+ createdAt: Number.isFinite(er.at) ? er.at : 0,
1363
+ updatedAt: Number.isFinite(er.at) ? er.at : 0,
1364
+ channel: typeof er.channel === "string" && er.channel !== "" ? er.channel : "external",
1365
+ ...er.reason ? { error: String(er.reason) } : {},
1366
+ ...Object.keys(meta).length > 0 ? { meta } : {}
1367
+ };
1368
+ }
1351
1369
  function upgradeNotify(activation) {
1352
1370
  if (activation === "client-only") return { needsRestart: false, suffixKey: "notify.upgraded.clientonly" };
1353
1371
  if (activation === "unknown") return { needsRestart: true, suffixKey: "notify.upgraded.activationUnknown" };
@@ -1385,6 +1403,7 @@ function createOperationsStore(storage) {
1385
1403
  const s = storageOf(storage);
1386
1404
  let records = safeParse(s.read());
1387
1405
  let persistDegraded = false;
1406
+ let externalOverlay = [];
1388
1407
  const persist = () => {
1389
1408
  if (persistDegraded) return;
1390
1409
  try {
@@ -1398,6 +1417,28 @@ function createOperationsStore(storage) {
1398
1417
  list() {
1399
1418
  return records.map((r) => ({ ...r }));
1400
1419
  },
1420
+ /** 外部记录 overlay(内存):id 幂等合并(后到值覆盖),返回合并视图。 */
1421
+ setExternal(next) {
1422
+ const incoming = (Array.isArray(next) ? next : []).filter((r) => r && typeof r === "object" && typeof r.id === "string");
1423
+ const byId = new Map(externalOverlay.map((r) => [r.id, r]));
1424
+ for (const r of incoming) byId.set(r.id, { ...byId.get(r.id) || {}, ...r });
1425
+ externalOverlay = [...byId.values()];
1426
+ return this.listMerged();
1427
+ },
1428
+ /** 清空外部 overlay(「清除已结束」联动 external-operations-clear 成功后调用)。 */
1429
+ clearExternal() {
1430
+ externalOverlay = [];
1431
+ return this.listMerged();
1432
+ },
1433
+ /** @returns {OperationRecord[]} 外部 overlay 当前快照(原始形态,浅拷贝)。 */
1434
+ externalList() {
1435
+ return externalOverlay.map((r) => ({ ...r }));
1436
+ },
1437
+ /** 本地 + 外部合并视图:外部映射为渲染记录后与本地按 createdAt 升序(groupOpsByDay 假定升序输入)。 */
1438
+ listMerged() {
1439
+ const external = externalOverlay.map(externalToRecord).filter(Boolean);
1440
+ return [...records.map((r) => ({ ...r })), ...external].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
1441
+ },
1401
1442
  get persistDegraded() {
1402
1443
  return persistDegraded;
1403
1444
  },
@@ -1616,6 +1657,8 @@ var init_operations = __esm({
1616
1657
  // src/client/operations-view.js
1617
1658
  var operations_view_exports = {};
1618
1659
  __export(operations_view_exports, {
1660
+ canRemoveRow: () => canRemoveRow,
1661
+ channelLabelKey: () => channelLabelKey,
1619
1662
  fmtOpClock: () => fmtOpClock,
1620
1663
  fmtOpDuration: () => fmtOpDuration,
1621
1664
  fmtOpFull: () => fmtOpFull,
@@ -1686,9 +1729,19 @@ function opRowVm(r) {
1686
1729
  durText: durMs === null ? "" : fmtOpDuration(durMs),
1687
1730
  dir: isToggle && typeof meta.on === "boolean" ? meta.on ? "on" : "off" : null,
1688
1731
  version,
1689
- target: typeof rec.target === "string" ? rec.target : ""
1732
+ target: typeof rec.target === "string" ? rec.target : "",
1733
+ // 外部记录渠道标记(0.9.79 ADR-0018):非空 = 渠道徽标渲染 + 无单条 ✕ 出口
1734
+ channel: typeof rec.channel === "string" && rec.channel !== "" ? rec.channel : null
1690
1735
  };
1691
1736
  }
1737
+ function canRemoveRow(r) {
1738
+ if (!r || typeof r !== "object") return false;
1739
+ if (r.channel) return false;
1740
+ return r.status === "input" || r.status === "failed" || r.status === "superseded";
1741
+ }
1742
+ function channelLabelKey(channel) {
1743
+ return `op.channel.${channel === "host-tool" || channel === "cli" ? channel : "external"}`;
1744
+ }
1692
1745
  var init_operations_view = __esm({
1693
1746
  "src/client/operations-view.js"() {
1694
1747
  "use strict";
@@ -1902,7 +1955,7 @@ var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_M
1902
1955
  var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
1903
1956
  var { applyInstalledUpdates: applyInstalledUpdates2, installedUpdateStats: installedUpdateStats2 } = (init_installed_updates(), __toCommonJS(installed_updates_exports));
1904
1957
  var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2, upgradeNotify: upgradeNotify2, REGISTRY_DEFAULT_TARGET: REGISTRY_DEFAULT_TARGET2 } = (init_operations(), __toCommonJS(operations_exports));
1905
- var { groupOpsByDay: groupOpsByDay2, opRowVm: opRowVm2, opUpgradeMeta: opUpgradeMeta2 } = (init_operations_view(), __toCommonJS(operations_view_exports));
1958
+ var { groupOpsByDay: groupOpsByDay2, opRowVm: opRowVm2, opUpgradeMeta: opUpgradeMeta2, canRemoveRow: canRemoveRow2, channelLabelKey: channelLabelKey2 } = (init_operations_view(), __toCommonJS(operations_view_exports));
1906
1959
  var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
1907
1960
  var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
1908
1961
  var { shouldShowInstallCmd: shouldShowInstallCmd2 } = (init_install_cmd(), __toCommonJS(install_cmd_exports));
@@ -1922,8 +1975,8 @@ var ZH = {
1922
1975
  "zone.favorites": "\u6536\u85CF",
1923
1976
  "badge.deprecated": "\u5DF2\u5F03\u7528",
1924
1977
  "sub.snapshot": "v{v}\uFF08\u76EE\u5F55\u5FEB\u7167\uFF09",
1925
- "badge.verified": "\u5DF2\u5B9E\u6D4B",
1926
1978
  "badge.internal": "\u4F5C\u8005\u81EA\u7528",
1979
+ "byline.verified": "\u5B9E\u6D4B \u2264{v}",
1927
1980
  "modal.category": "\u5206\u7C7B",
1928
1981
  "modal.added": "\u6536\u5F55\u65E5\u671F",
1929
1982
  "modal.dlwindow": "\u4E0B\u8F7D\u91CF\uFF0830 \u5929\u7A97\u53E3\uFF09",
@@ -1951,6 +2004,14 @@ var ZH = {
1951
2004
  "op.kind.toggle": "\u5F00\u5173",
1952
2005
  "op.kind.community-toggle": "\u793E\u533A\u76EE\u5F55\u5F00\u5173",
1953
2006
  "op.kind.registry-url": "\u6E05\u5355\u6E90\u53D8\u66F4",
2007
+ "op.kind.self-upgrade": "\u81EA\u5347\u7EA7",
2008
+ "op.kind.desktop-install": "\u684C\u9762\u5B89\u88C5",
2009
+ "op.kind.desktop-uninstall": "\u684C\u9762\u5378\u8F7D",
2010
+ "op.kind.desktop-upgrade": "\u684C\u9762\u5347\u7EA7",
2011
+ "op.kind.desktop-toggle": "\u684C\u9762\u5F00\u5173",
2012
+ "op.channel.host-tool": "Agent \u5DE5\u5177",
2013
+ "op.channel.cli": "\u547D\u4EE4\u884C",
2014
+ "op.channel.external": "\u5916\u90E8",
1954
2015
  "op.state.on": "\u5F00",
1955
2016
  "op.state.off": "\u5173",
1956
2017
  "op.status.queued": "\u6392\u961F\u4E2D",
@@ -2213,8 +2274,8 @@ var EN = {
2213
2274
  "zone.favorites": "Favorites",
2214
2275
  "badge.deprecated": "Deprecated",
2215
2276
  "sub.snapshot": "v{v} (catalog snapshot)",
2216
- "badge.verified": "Verified",
2217
2277
  "badge.internal": "Author's own",
2278
+ "byline.verified": "verified \u2264{v}",
2218
2279
  "modal.category": "Category",
2219
2280
  "modal.added": "Added",
2220
2281
  "modal.dlwindow": "Downloads (30-day window)",
@@ -2242,6 +2303,14 @@ var EN = {
2242
2303
  "op.kind.toggle": "Toggle",
2243
2304
  "op.kind.community-toggle": "Community catalog toggle",
2244
2305
  "op.kind.registry-url": "Registry source change",
2306
+ "op.kind.self-upgrade": "Self-upgrade",
2307
+ "op.kind.desktop-install": "Desktop install",
2308
+ "op.kind.desktop-uninstall": "Desktop uninstall",
2309
+ "op.kind.desktop-upgrade": "Desktop upgrade",
2310
+ "op.kind.desktop-toggle": "Desktop toggle",
2311
+ "op.channel.host-tool": "Agent tool",
2312
+ "op.channel.cli": "CLI",
2313
+ "op.channel.external": "External",
2245
2314
  "op.state.on": "On",
2246
2315
  "op.state.off": "Off",
2247
2316
  "op.status.queued": "Queued",
@@ -2509,7 +2578,7 @@ function communityLabels(data) {
2509
2578
  }
2510
2579
  var CSS = `
2511
2580
  .dshm-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-mask-3,rgba(15,23,42,.48));display:flex;align-items:center;justify-content:center;padding:max(24px,var(--dsh-windows-titlebar-height,0px)) 16px 24px;box-sizing:border-box}
2512
- .dshm-panel{width:min(920px,100%);height:min(680px,86vh);display:flex;flex-direction:column;background:var(--dsw-alias-bg-elevated,var(--dsw-alias-bg-base,#fff));background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 86%,transparent);backdrop-filter:blur(14px) saturate(1.3);-webkit-backdrop-filter:blur(14px) saturate(1.3);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:14px;box-shadow:0 18px 48px rgba(2,6,23,.25);overflow:hidden;font-family:inherit;color:var(--dsw-alias-label-primary,inherit)}
2581
+ .dshm-panel{position:relative;width:min(920px,100%);height:min(680px,86vh);display:flex;flex-direction:column;background:var(--dsw-alias-bg-elevated,var(--dsw-alias-bg-base,#fff));background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 86%,transparent);backdrop-filter:blur(14px) saturate(1.3);-webkit-backdrop-filter:blur(14px) saturate(1.3);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:14px;box-shadow:0 18px 48px rgba(2,6,23,.25);overflow:hidden;font-family:inherit;color:var(--dsw-alias-label-primary,inherit)}
2513
2582
  /* 0.9.70 \u8FD8\u539F\u6001\u62D6\u62FD\uFF1A.abs = \u6709\u4F4D\u7F6E\u8BB0\u5FC6\u65F6\u7684\u7EDD\u5BF9\u5B9A\u4F4D\u5F62\u6001\uFF08overlay \u4E3A inset:0 fixed\uFF0C
2514
2583
  \u7EDD\u5BF9\u5B9A\u4F4D\u5750\u6807\u5373\u89C6\u53E3\u5750\u6807\uFF09\uFF1B\u65E0\u8BB0\u5F55\u65F6\u9762\u677F\u4ECD\u8D70 overlay flex \u5C45\u4E2D\uFF0C\u6E32\u67D3\u8DEF\u5F84\u4E0E\u65E7\u7248\u9010\u5B57\u8282\u4E00\u81F4\u3002
2515
2584
  \u62D6\u62FD\u89C2\u611F\u4E0D\u53D8\u91CF\uFF080.9.70 \u4FEE\u8BA2\uFF0CADR-0017 v2\uFF09\uFF1A\u62D6\u62FD\u6001\u4E0E\u9759\u6B62\u6001\u540C\u73BB\u7483\u540C blur\uFF0C\u5916\u89C2\u9010\u50CF\u7D20\u4E00\u81F4
@@ -2733,6 +2802,8 @@ var CSS = `
2733
2802
  .dsvm-opdir{font-size:11px;line-height:16px;padding:0 6px;border-radius:8px;border:1px solid currentColor}
2734
2803
  .dsvm-opdir.on{color:#15803d}
2735
2804
  .dsvm-opdir.off{color:var(--dsw-alias-label-secondary,#4b5563)}
2805
+ /* 0.9.79 \u5916\u90E8\u8BB0\u5F55\u6E20\u9053\u5FBD\u6807\uFF08Agent \u5DE5\u5177/\u547D\u4EE4\u884C/\u5916\u90E8\uFF09\uFF1A\u4E2D\u6027\u63CF\u8FB9\u80F6\u56CA\uFF0C\u4E0E\u65B9\u5411\u5FBD\u7AE0\u540C\u5F62\u4E0D\u540C\u8272 */
2806
+ .dsvm-opchan{font-size:11px;line-height:16px;padding:0 6px;border-radius:8px;border:1px solid currentColor;color:var(--dsw-alias-label-caption,#9ca3af)}
2736
2807
  .dsvm-opver{font-size:11px;color:var(--dsw-alias-label-secondary,#4b5563);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
2737
2808
  .dsvm-favbtn{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-caption,#9ca3af);font-size:15px;line-height:1;cursor:pointer;padding:0 2px;margin-left:auto}
2738
2809
  .dsvm-favbtn:hover{color:var(--dsw-alias-state-business-primary,#4d6bfe)}
@@ -2760,6 +2831,8 @@ button.dshm-badge:hover{filter:brightness(.95)}
2760
2831
  .dshm-sub{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dsw-alias-label-caption,#6b7280)}
2761
2832
  .dshm-detail{margin-top:8px;border-top:1px dashed var(--dsw-alias-border-l2,#e5e7eb);padding-top:8px;display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary,#4b5563)}
2762
2833
  .dshm-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
2834
+ .dshm-banner-stack{position:absolute;left:0;right:0;bottom:0;z-index:30;display:flex;flex-direction:column;pointer-events:none}
2835
+ .dshm-banner-stack .dshm-banner{pointer-events:auto}
2763
2836
  .dshm-banner{display:flex;align-items:center;gap:10px;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-state-warn-tertiary,#fffbeb);color:var(--dsw-alias-state-warn-primary,#b45309);font-size:12px}
2764
2837
  .dshm-banner.err{background:rgba(239,68,68,.13);color:var(--dsw-alias-state-error-primary,#b91c1c)}
2765
2838
  /* 0.9.72 \u540C\u6B3E\u6EDA\u8F6E\u6CBB\u7406\uFF08\u89C1 dshm-ops-scroll \u6CE8\uFF09\uFF1A\u6A2A\u5E45\u6587\u672C\u4E0D\u6EE1 140px \u65F6\u4E0D\u5F97\u541E\u6EDA\u8F6E */
@@ -2857,6 +2930,9 @@ button.dshm-dshchip:disabled{cursor:default;opacity:.75}
2857
2930
  .dshm-dot.idle{color:#9ca3af}
2858
2931
  .dshm-dot.busy{color:#f59e0b}
2859
2932
  .dshm-switch{position:relative;display:inline-flex;align-items:center;width:34px;height:19px;border-radius:10px;background:var(--dsw-alias-fill-secondary,#d1d5db);cursor:pointer;transition:background .15s;flex:none;margin-left:auto}
2933
+ /* \u70ED\u533A\u6269\u5C55\uFF082026-10-13 UX \u4FEE\u590D\uFF09\uFF1A\u89C6\u89C9 34\xD719 \u4E0D\u53D8\uFF0C\u53EF\u70B9\u9762\u79EF\u7ECF\u4F2A\u5143\u7D20\u6269\u5230 46\xD731\u2014\u2014
2934
+ \u5C0F\u76EE\u6807 + \u6574\u5361\u5927\u76EE\u6807\u76F8\u90BB\u7684\u8BEF\u89E6\u534A\u5F84\u6536\u655B\uFF1B\u5F00\u5173\u8DDD\u5361\u8FB9 12px\uFF0C-6px \u5916\u6EA2\u4E0D\u8D8A\u51FA\u81EA\u8EAB\u5361\u7247 */
2935
+ .dshm-switch::before{content:"";position:absolute;inset:-6px;border-radius:14px}
2860
2936
  .dshm-switch input{display:none}
2861
2937
  .dshm-switch.on{background:#22c55e}
2862
2938
  .dshm-switch.locked{opacity:.45;cursor:not-allowed}
@@ -3675,7 +3751,6 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3675
3751
  h(Icon, { entry: it }),
3676
3752
  h("span", { className: "dshm-name" }, it.name),
3677
3753
  it.deprecated === true ? h("span", { className: "dshm-badge warn" }, lookup("badge.deprecated")) : null,
3678
- it.community !== true && Array.isArray(it.verified) && it.verified.length ? h("span", { className: "dshm-badge", title: it.verified.join("\u3001") }, lookup("badge.verified")) : null,
3679
3754
  it.audience === "internal" ? h("span", { className: "dshm-badge" }, lookup("badge.internal")) : null,
3680
3755
  it.installed ? h("span", { className: "dshm-badge" }, lookup("badge.installed")) : null,
3681
3756
  it.community === true ? h("span", { className: "dshm-badge info" }, lookup("badge.community")) : null,
@@ -3772,6 +3847,7 @@ var ZONE_TABS = [
3772
3847
  { id: "primary", labelKey: "zone.primary" },
3773
3848
  { id: "favorites", labelKey: "zone.favorites" }
3774
3849
  ];
3850
+ var verifiedByline = (it) => it.community !== true && Array.isArray(it.verified) && it.verified.length ? { text: lookup("byline.verified", { v: it.verified[it.verified.length - 1] }), title: it.verified.join("\u3001") } : null;
3775
3851
  function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind, onOpenOps }) {
3776
3852
  const [zone, setZone] = useState("community");
3777
3853
  const market = zone === "favorites" ? null : markets[zone];
@@ -4072,7 +4148,6 @@ function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind, o
4072
4148
  lookup("op.status.running")
4073
4149
  ) : null,
4074
4150
  it.deprecated === true ? h("span", { className: "dshm-badge warn", key: "dep" }, lookup("badge.deprecated")) : null,
4075
- it.community !== true && Array.isArray(it.verified) && it.verified.length ? h("span", { className: "dshm-badge", key: "v", title: it.verified.join("\u3001") }, lookup("badge.verified")) : null,
4076
4151
  it.audience === "internal" ? h("span", { className: "dshm-badge dshm-badge-internal", key: "internal" }, lookup("badge.internal")) : null,
4077
4152
  it.outdated ? h("span", { className: "dshm-badge warn", key: "u" }, lookup("badge.update")) : null,
4078
4153
  it.installed ? h("span", { className: "dshm-badge", key: "i" }, lookup("badge.installed")) : null,
@@ -4084,7 +4159,10 @@ function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind, o
4084
4159
  it.owner ? { text: `by ${it.owner}` } : null,
4085
4160
  typeof it.downloads === "number" ? { text: `${compactCount(it.downloads)} \u2193`, title: String(it.downloads) } : null,
4086
4161
  typeof it.stars === "number" ? { text: `${compactCount(it.stars)} \u2605`, title: String(it.stars) } : null
4087
- ].filter(Boolean) : (it.tags || []).map((t) => ({ text: `#${t}` })),
4162
+ ].filter(Boolean) : [
4163
+ ...(it.tags || []).map((t) => ({ text: `#${t}` })),
4164
+ verifiedByline(it)
4165
+ ].filter(Boolean),
4088
4166
  desc: browserLang() === "en" && typeof it.descriptionEn === "string" && it.descriptionEn !== "" ? it.descriptionEn : it.description,
4089
4167
  clampLines: it.community === true ? 5 : 2,
4090
4168
  sub: [
@@ -4254,8 +4332,11 @@ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
4254
4332
  const updateRows = updates && updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : [];
4255
4333
  const [openPkg, setOpenPkg] = useState(null);
4256
4334
  const [readmePkg, setReadmePkg] = useState(null);
4335
+ const [pendingToggles, setPendingToggles] = useState({});
4257
4336
  const activeMutateTarget = (ops.records.find((r) => r.kind !== "install" && (r.status === "running" || r.status === "queued" || r.status === "input")) || {}).target || null;
4337
+ const progressTarget = (ops.records.find((r) => (r.kind === "install" || r.kind === "upgrade" || r.kind === "self-upgrade") && (r.status === "running" || r.status === "queued" || r.status === "input")) || {}).target || null;
4258
4338
  const doToggle = async (it, enabled) => {
4339
+ setPendingToggles((m) => ({ ...m, [it.pkg]: enabled }));
4259
4340
  const call = () => api("set-enabled", { pkg: it.pkg, enabled });
4260
4341
  try {
4261
4342
  let res;
@@ -4269,13 +4350,19 @@ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
4269
4350
  const note = toggleNoticeKeys2(res);
4270
4351
  const extra = res.warnings && res.warnings.length ? `\uFF08${res.warnings.join("\uFF1B")}\uFF09` : "";
4271
4352
  notify({ kind: "ok", needsRestart: note.needsRestart, text: lookup(note.textKey, note.params) + extra });
4272
- await (onMutation ? onMutation() : reload());
4353
+ await reload();
4273
4354
  } catch (e) {
4274
4355
  if (e && e.opSuperseded) {
4275
4356
  notify({ kind: "ok", text: lookup("op.superseded.note", { target: it.pkg }) });
4276
4357
  } else {
4277
4358
  notify({ kind: "err", text: lookup("toggle.failed", { err: e && e.message || e }) });
4278
4359
  }
4360
+ } finally {
4361
+ setPendingToggles((m) => {
4362
+ if (!(it.pkg in m)) return m;
4363
+ const { [it.pkg]: _drop, ...rest } = m;
4364
+ return rest;
4365
+ });
4279
4366
  }
4280
4367
  };
4281
4368
  const doUninstall = async (it) => {
@@ -4356,13 +4443,15 @@ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
4356
4443
  lookup("installed.upgradeAll", { n: items.filter((x) => x && x.outdated).length })
4357
4444
  )
4358
4445
  ) : null,
4359
- activeMutateTarget ? h(ProgressLine, { key: "prog" }) : null,
4446
+ progressTarget ? h(ProgressLine, { key: "prog" }) : null,
4360
4447
  h(
4361
4448
  "div",
4362
4449
  { className: "dshm-cards" },
4363
4450
  items.map((it) => {
4364
4451
  const vm = installedViewModel2(it);
4365
- const tvm = toggleViewModel2(it);
4452
+ const pendingOn = pendingToggles[it.pkg];
4453
+ const tvm0 = toggleViewModel2(it);
4454
+ const tvm = pendingOn === void 0 ? tvm0 : { ...tvm0, switchOn: pendingOn, ...pendingOn === false ? { phaseDotClass: "", phaseLabelKey: null } : {} };
4366
4455
  return Card({
4367
4456
  key: it.pkg,
4368
4457
  icon: h(Icon, { entry: { name: it.name, github: vm.githubRepo, icon: null } }),
@@ -4379,7 +4468,7 @@ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
4379
4468
  desc: it.description || "\uFF08\u65E0\u63CF\u8FF0\uFF09",
4380
4469
  sub: [
4381
4470
  h(PhaseDot, { key: "dot", vm: tvm }),
4382
- vm.phaseKey ? lookup(vm.phaseKey) : lookup(vm.enabledLabelKey),
4471
+ pendingOn === void 0 ? vm.phaseKey ? lookup(vm.phaseKey) : lookup(vm.enabledLabelKey) : lookup(pendingOn ? "toggle.state.on" : "toggle.state.off"),
4383
4472
  `v${it.version || "?"}`,
4384
4473
  vm.sourceLabelKey ? lookup(vm.sourceLabelKey) : it.source,
4385
4474
  vm.latestIssue ? lookup("installed.check.incomplete") : null
@@ -4557,17 +4646,10 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4557
4646
  }),
4558
4647
  h("span", { className: "dshm-switch-slider" })
4559
4648
  );
4560
- const communitySwitchRow = h(
4561
- "div",
4562
- { className: "dshm-kvline-row" },
4563
- h("span", { className: "rl" }, lookup("settings.community.toggle")),
4564
- communitySwitch
4565
- );
4566
4649
  const communityBody = communityOn === false ? h(
4567
4650
  React.Fragment,
4568
4651
  null,
4569
- h("div", { className: "dshm-hint" }, lookup("settings.community.off")),
4570
- communitySwitchRow
4652
+ h("div", { className: "dshm-hint" }, lookup("settings.community.off"))
4571
4653
  ) : h(
4572
4654
  "div",
4573
4655
  { className: "dshm-kvlines" },
@@ -4580,17 +4662,16 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4580
4662
  kvlineRow("settings.community.version", communityLive ? communitySummary.version || "\u2014" : "\u2014"),
4581
4663
  kvlineRow("settings.community.route", communityLive ? communitySummary.route || "\u2014" : "\u2014"),
4582
4664
  kvlineRow("settings.community.count", communityLive ? lookup("settings.community.count.v", { n: communitySummary.acceptedCount ?? 0, up: communitySummary.upstreamCount ?? "\u2014" }) : "\u2014"),
4583
- kvlineRow("settings.community.displaced", communityLive ? lookup("settings.community.displaced.v", { n: communitySummary.displaced ?? 0 }) : "\u2014"),
4584
- communitySwitchRow
4665
+ kvlineRow("settings.community.displaced", communityLive ? lookup("settings.community.displaced.v", { n: communitySummary.displaced ?? 0 }) : "\u2014")
4585
4666
  );
4586
4667
  return h(
4587
4668
  "div",
4588
4669
  { className: "dshm-settings-grid" },
4589
- // ① 社区清单(awesome-dsh-plugin):左卡;清爽列表 + 发丝分隔线,开关为列表末行
4670
+ // ① 社区清单(awesome-dsh-plugin):左卡;开关在标题行右侧(位置恒定,2026-10-13 方案A)
4590
4671
  h(
4591
4672
  "div",
4592
4673
  { className: "dshm-section" },
4593
- h("div", { className: "dshm-section-title" }, lookup("settings.community")),
4674
+ h("div", { className: "dshm-section-title" }, lookup("settings.community"), communitySwitch),
4594
4675
  communityBody,
4595
4676
  communitySummary && Array.isArray(communitySummary.errors) && communitySummary.errors.length && communityOn !== false ? h("div", { className: "dshm-err" }, communitySummary.errors.join("\uFF1B")) : null
4596
4677
  ),
@@ -4794,10 +4875,10 @@ function FavoriteZone({ favorites, onOpen }) {
4794
4875
  name: s.name,
4795
4876
  badges: [
4796
4877
  s.deprecated === true ? h("span", { className: "dshm-badge warn", key: "dep" }, lookup("badge.deprecated")) : null,
4797
- // 0.9.45 U12(R7 收窄):无 owner = 精选条目——补「精选」身份徽标;verified 质量徽标恢复;
4878
+ // 0.9.45 U12(R7 收窄):无 owner = 精选条目——补「精选」身份徽标;
4879
+ // verified 徽标已移除(2026-10-13 B 方案:卡面只留例外信号,实测信息在详情弹窗「实测版本」行);
4798
4880
  // 受众/解耦徽标不进收藏区(DESIGN §2.7 裁决⑤「已装视图与收藏页不打标」),快照仅存字段
4799
4881
  s.owner ? h("span", { className: "dshm-badge info", key: "c" }, lookup("badge.community")) : h("span", { className: "dshm-badge", key: "cz" }, lookup("zone.primary")),
4800
- Array.isArray(s.verified) && s.verified.length && s.community !== true ? h("span", { className: "dshm-badge", key: "v", title: s.verified.join("\u3001") }, lookup("badge.verified")) : null,
4801
4882
  h("span", { className: "dshm-badge info", key: "s" }, s.source === "npm" ? "npm" : "github"),
4802
4883
  isStale ? h("span", { className: "dshm-badge warn", key: "st" }, lookup("favorites.stalebadge")) : null
4803
4884
  ],
@@ -4860,7 +4941,8 @@ function useScrollableContain(dep) {
4860
4941
  }
4861
4942
  function OperationsCard({ records, onClearFinished, onRemove }) {
4862
4943
  const active = records.filter((r) => r.status === "queued" || r.status === "running" || r.status === "input");
4863
- const finished = records.filter((r) => active.indexOf(r) < 0).slice(-50);
4944
+ const finishedAll = records.filter((r) => active.indexOf(r) < 0);
4945
+ const finished = finishedAll.slice(-50);
4864
4946
  const clearable = records.some((r) => TERMINAL_CLEARABLE2.has(r.status));
4865
4947
  const [scrollRef, scrollCls] = useScrollableContain(finished.length);
4866
4948
  const dayGroups = useMemo(() => groupOpsByDay2(finished), [records]);
@@ -4873,6 +4955,7 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4873
4955
  vm.clock ? h("span", { className: "dsvm-optime", title: vm.full }, vm.clock) : null,
4874
4956
  h("span", { className: "dsvm-opstatus" }, lookup("op.status." + r.status)),
4875
4957
  h("span", { className: "dsvm-opkind" }, lookup("op.kind." + r.kind)),
4958
+ vm.channel ? h("span", { className: "dsvm-opchan", title: lookup(channelLabelKey2(vm.channel)) }, lookup(channelLabelKey2(vm.channel))) : null,
4876
4959
  vm.dir ? h("span", { className: `dsvm-opdir ${vm.dir}` }, lookup(vm.dir === "on" ? "op.state.on" : "op.state.off")) : null,
4877
4960
  h("span", { className: "dsvm-optarget", title: tip || void 0 }, vm.target),
4878
4961
  vm.version ? h("span", { className: "dsvm-opver", title: tip || void 0 }, vm.version) : null,
@@ -4880,8 +4963,10 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4880
4963
  r.status === "running" ? Spin() : null,
4881
4964
  r.error ? h("span", { className: "dsvm-opnote" }, r.error) : null,
4882
4965
  r.warning ? h("span", { className: "dsvm-opnote" }, r.warning) : null,
4883
- // 单条移除(终审·新伤2):input/failed/superseded 行给出口(对话区来源的 input 不再永挂)
4884
- (r.status === "input" || r.status === "failed" || r.status === "superseded") && onRemove ? h("button", { className: "dshm-xbtn", title: lookup("op.remove"), onClick: () => onRemove(r.id) }, h(XIcon)) : null
4966
+ // 单条移除(终审·新伤2 → 0.9.79 R3-1/R2-3):出口判定下沉 canRemoveRow——
4967
+ // 本地 input/failed/superseded 保留 ✕;外部记录(channel 非空)不给单条 ✕(防按 id 复活),
4968
+ // 移除出口仅「清除已结束」。
4969
+ canRemoveRow2(r) && onRemove ? h("button", { className: "dshm-xbtn", title: lookup("op.remove"), onClick: () => onRemove(r.id) }, h(XIcon)) : null
4885
4970
  );
4886
4971
  };
4887
4972
  return h(
@@ -4893,7 +4978,7 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4893
4978
  finished.length ? h(
4894
4979
  "div",
4895
4980
  { className: "dsvm-ops-caption", style: { marginTop: active.length ? "7px" : "0" } },
4896
- lookup("settings.ops.group.done.n", { n: finished.length })
4981
+ lookup("settings.ops.group.done.n", { n: finishedAll.length })
4897
4982
  ) : null,
4898
4983
  finished.length ? h(
4899
4984
  "div",
@@ -5211,8 +5296,24 @@ function MarketPanel({ onClose }) {
5211
5296
  const markets = { community: marketCommunity, primary: marketPrimary };
5212
5297
  const installed = useAsync(() => api("installed", { probe: false }), []);
5213
5298
  const updates = useAsync(() => api("installedUpdates"), []);
5214
- const [opRecords, setOpRecords] = useState(() => opsStore.list().map((r) => ({ ...r })));
5215
- const syncOps = useCallback(() => setOpRecords(opsStore.list().map((r) => ({ ...r }))), []);
5299
+ const [opRecords, setOpRecords] = useState(() => opsStore.listMerged().map((r) => ({ ...r })));
5300
+ const syncOps = useCallback(() => setOpRecords(opsStore.listMerged().map((r) => ({ ...r }))), []);
5301
+ const fetchExternalOps = useCallback(async () => {
5302
+ try {
5303
+ const r = await api("external-operations");
5304
+ if (r && Array.isArray(r.records)) {
5305
+ opsStore.setExternal(r.records);
5306
+ syncOps();
5307
+ }
5308
+ } catch {
5309
+ }
5310
+ }, [syncOps]);
5311
+ useEffect(() => {
5312
+ void fetchExternalOps();
5313
+ }, [fetchExternalOps]);
5314
+ useEffect(() => {
5315
+ if (tab === "settings") void fetchExternalOps();
5316
+ }, [tab, fetchExternalOps]);
5216
5317
  const openOps = useCallback(() => {
5217
5318
  setTab("settings");
5218
5319
  if (typeof document !== "undefined") {
@@ -5227,9 +5328,13 @@ function MarketPanel({ onClose }) {
5227
5328
  (kind, target, exec, meta = {}, reuseId) => (
5228
5329
  // session:true = 活会话标记(终审·新伤1):重挂载的 restore 跳过(泵仍拥有);persist 时剥离。
5229
5330
  // 入队与 waiters/原始错误保真都在 createOpsPump(评审 P4:实现与测试同源)。
5230
- opsPump.enqueue({ ...reuseId ? { id: reuseId } : {}, kind, target, status: "queued", meta: { ...meta, session: true } }, exec)
5331
+ // 0.9.79(R1-6):操作终态后连带拉取一次外部记录——他终端(CLI/Agent 工具)的并发变更
5332
+ // 尽快可见;拉取失败静默。
5333
+ opsPump.enqueue({ ...reuseId ? { id: reuseId } : {}, kind, target, status: "queued", meta: { ...meta, session: true } }, exec).finally(() => {
5334
+ void fetchExternalOps();
5335
+ })
5231
5336
  ),
5232
- []
5337
+ [fetchExternalOps]
5233
5338
  );
5234
5339
  const disposeOp = useCallback((id, note) => {
5235
5340
  opsStore.upsert({ id, status: "superseded", error: note });
@@ -5443,7 +5548,10 @@ function MarketPanel({ onClose }) {
5443
5548
  opsRecords: opRecords,
5444
5549
  onOpsClearFinished: () => {
5445
5550
  opsStore.clearFinished();
5551
+ opsStore.clearExternal();
5446
5552
  syncOps();
5553
+ void api("external-operations-clear").catch(() => {
5554
+ });
5447
5555
  },
5448
5556
  onOpsRemove: (id) => {
5449
5557
  opsStore.remove(id);
@@ -5452,12 +5560,19 @@ function MarketPanel({ onClose }) {
5452
5560
  }) : null
5453
5561
  // 操作记录已整体搬迁至设置 tab 末位通宽卡(0.9.x 布局重构·第二批):底部不再常驻
5454
5562
  ),
5455
- toast ? h(
5563
+ // toast/横幅 overlay 化(2026-10-13 UX 修复):原先是面板 column flex 的流内子元素,
5564
+ // 出现/消失会压缩 body(flex:1) 造成二次抖动;现挂 absolute 底部浮层(.dshm-banner-stack),
5565
+ // 零布局参与。stack 用 pointer-events:none 放行下层点击,banner 自身恢复 auto。
5566
+ toast || banner ? h(
5456
5567
  "div",
5457
- { className: toast.kind === "err" ? "dshm-banner err" : "dshm-banner" },
5458
- h("span", { className: `dshm-banner-text${toastScrollCls}`, ref: toastTextRef }, toast.text)
5459
- ) : null,
5460
- banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null), desktop: banner.desktop === true, onRestarted: reloadPing }) : null
5568
+ { className: "dshm-banner-stack" },
5569
+ toast ? h(
5570
+ "div",
5571
+ { className: toast.kind === "err" ? "dshm-banner err" : "dshm-banner" },
5572
+ h("span", { className: `dshm-banner-text${toastScrollCls}`, ref: toastTextRef }, toast.text)
5573
+ ) : null,
5574
+ banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null), desktop: banner.desktop === true, onRestarted: reloadPing }) : null
5575
+ ) : null
5461
5576
  )
5462
5577
  );
5463
5578
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 机器本地的外部操作日志(0.9.79,ADR-0018):外部通道(dshm CLI / Agent 工具)发起的
3
+ * Profile 变更以终态直落记录进入本文件——不入操作泵、永不 queued/running、不参与恢复重放、
4
+ * 宿主重启后原样保留;记录机器本地、不跨机同步;不含凭据与原始命令输出。
5
+ *
6
+ * - 存储文件:`$DSH_HOME/dshm/external-operations.json`(`DSHM_EXTERNAL_JOURNAL_FILE` 可整文件
7
+ * 覆盖供测试;与 dshm/cache 同基址、不带 profile 维度、不被缓存清理波及)。
8
+ * - append:mkdir recursive 先行(R2-4:全新机器上 `dshm/` 目录可能尚不存在,首条审计记录
9
+ * 恰是最有价值的一条,不能静默丢失)→ tmp+rename 原子替换(community.ts 同款惯例)→
10
+ * 滚动保留最新 200 条。channel='panel' 拒绝(防御性不变量:面板通道不走外部日志,
11
+ * 面板调用点应在钩子层被跳过)。错误一律上抛——吞错是上层钩子的职责(R1-1)。
12
+ * - list:按 `at` 降序(新→旧);文件缺失 = 空列表;损坏文件不抛——改名
13
+ * `<file>.corrupt-<ts>` 隔离留证后从空开始(best-effort,隔离失败同样不抛)。
14
+ * - clear:写空数组,文件保留(面板「清除已结束」联动出口)。
15
+ */
16
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
17
+ import { dirname, join } from 'node:path';
18
+ import { dshHome } from './env.js';
19
+ const MAX_RECORDS = 200;
20
+ const REASON_MAX = 300;
21
+ /** 日志文件路径:env 整文件覆盖优先(测试隔离),缺省 $DSH_HOME/dshm/external-operations.json。 */
22
+ export function journalFile() {
23
+ return process.env.DSHM_EXTERNAL_JOURNAL_FILE || join(dshHome(), 'dshm', 'external-operations.json');
24
+ }
25
+ /** 显式字段白名单重组(超集字段一律不落盘——「记录不含凭据」的形状级保证);reason 截断 ≤300。 */
26
+ function normalizeRecord(record) {
27
+ const reason = typeof record.reason === 'string' ? record.reason.slice(0, REASON_MAX) : undefined;
28
+ return {
29
+ id: record.id,
30
+ at: record.at,
31
+ channel: record.channel,
32
+ kind: record.kind,
33
+ target: record.target,
34
+ ...(record.fromVersion !== undefined ? { fromVersion: record.fromVersion } : {}),
35
+ ...(record.toVersion !== undefined ? { toVersion: record.toVersion } : {}),
36
+ result: record.result,
37
+ ...(reason !== undefined && reason !== '' ? { reason } : {}),
38
+ };
39
+ }
40
+ async function atomicWrite(path, text) {
41
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
42
+ await writeFile(tmp, text);
43
+ await rename(tmp, path);
44
+ }
45
+ /** 读全量记录(插入序 = 时间升序);损坏 → 隔离改名后按空处理;文件缺失 → []。 */
46
+ async function readAll(file) {
47
+ let text;
48
+ try {
49
+ text = await readFile(file, 'utf8');
50
+ }
51
+ catch (err) {
52
+ if (err.code === 'ENOENT')
53
+ return [];
54
+ throw err;
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ return Array.isArray(parsed) ? parsed.filter((r) => r && typeof r === 'object') : [];
59
+ }
60
+ catch {
61
+ // 损坏容错:整文件改名隔离留证(best-effort),从空开始;不抛——读路径永不炸
62
+ const quarantined = `${file}.corrupt-${Date.now()}`;
63
+ try {
64
+ await rename(file, quarantined);
65
+ }
66
+ catch {
67
+ try {
68
+ // 同毫秒重入等极小概率命名冲突:换一个带随机尾缀的名字再试一次;仍败则放弃隔离
69
+ const fallback = `${file}.corrupt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
70
+ await rename(file, fallback);
71
+ }
72
+ catch {
73
+ /* 放弃隔离:按空继续 */
74
+ }
75
+ }
76
+ return [];
77
+ }
78
+ }
79
+ /**
80
+ * 追加一条终态记录(成功与失败都记)。channel='panel' 抛错(防御性不变量);
81
+ * 不可创建路径/磁盘错误等存储失败原样上抛,由上层钩子吞(R1-1:绝不反噬操作终态)。
82
+ */
83
+ export async function appendExternalOperation(record) {
84
+ if (record.channel === 'panel') {
85
+ throw new Error('panel 通道不入外部操作日志(防御性不变量:面板调用点应在钩子层跳过 append)');
86
+ }
87
+ const file = journalFile();
88
+ await mkdir(dirname(file), { recursive: true });
89
+ const records = await readAll(file);
90
+ records.push(normalizeRecord(record));
91
+ const kept = records.slice(-MAX_RECORDS);
92
+ await atomicWrite(file, `${JSON.stringify(kept, null, 2)}\n`);
93
+ }
94
+ /** 全量外部记录,按 `at` 降序(新→旧)。 */
95
+ export async function listExternalOperations() {
96
+ const records = await readAll(journalFile());
97
+ return records
98
+ .slice()
99
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
100
+ .map((r) => ({ ...r }));
101
+ }
102
+ /** 清空外部记录:文件保留(空数组),面板「清除已结束」联动出口。 */
103
+ export async function clearExternalOperations() {
104
+ const file = journalFile();
105
+ await mkdir(dirname(file), { recursive: true });
106
+ await atomicWrite(file, '[]\n');
107
+ }
108
+ /**
109
+ * 九个公共变更入口共用的终态直落钩子(0.9.79,ADR-0018):
110
+ * - channel 缺省 'external'(审计上漏报更糟,未知调用方落兜底而非丢弃);
111
+ * - channel='panel' 在钩子层**跳过**(面板通道零记录;append 侧的 panel 抛错仅为防御性不变量);
112
+ * - append 任何失败被吞(至多 stderr 一行)——**绝不反噬操作终态与返回值**(R1-1);
113
+ * - reason 截断 ≤300 字符(R1-8)。
114
+ * 调用时机 = 操作终态之后(成功与失败都记)。
115
+ */
116
+ export async function journalExternalOperation(channel, record, result, deps = {}) {
117
+ const ch = channel ?? 'external';
118
+ if (ch === 'panel')
119
+ return;
120
+ try {
121
+ const reason = typeof deps.reason === 'string' ? deps.reason.slice(0, REASON_MAX) : undefined;
122
+ await (deps.appendExternalOperation ?? appendExternalOperation)({
123
+ id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
124
+ at: Date.now(),
125
+ channel: ch,
126
+ kind: record.kind,
127
+ target: record.target,
128
+ ...(record.fromVersion !== undefined ? { fromVersion: record.fromVersion } : {}),
129
+ ...(record.toVersion !== undefined ? { toVersion: record.toVersion } : {}),
130
+ result,
131
+ ...(reason !== undefined && reason !== '' ? { reason } : {}),
132
+ });
133
+ }
134
+ catch (err) {
135
+ console.error(`[dsh-m] 外部操作记录落盘失败(不影响操作结果): ${err instanceof Error ? err.message : String(err)}`);
136
+ }
137
+ }
@@ -12,6 +12,7 @@ import { resolveDshVersion, readLauncherPackageVersion } from './dsh-version.js'
12
12
  import { runDoctor } from './doctor.js';
13
13
  import { listInstalledWithMeta, listMarket, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
14
14
  import { runProfileTransaction, TransactionError } from './profile-transaction.js';
15
+ import { listExternalOperations, clearExternalOperations } from './external-journal.js';
15
16
  import { readInstalledPluginReadme } from './installed.js';
16
17
  import { loadPrimaryScreenshots } from './primary-screenshots.js';
17
18
  import { isNewerVersion, npmLatest, npmPackumentReadme } from './versions.js';
@@ -231,6 +232,8 @@ export function createApiDispatcher(ctx) {
231
232
  desktopUninstall: ctx.deps?.desktopUninstall ?? desktopUninstall,
232
233
  desktopUpgrade: ctx.deps?.desktopUpgrade ?? desktopUpgradeFromRegistry,
233
234
  ensureService: ctx.deps?.ensureService,
235
+ listExternalOperations: ctx.deps?.listExternalOperations ?? listExternalOperations,
236
+ clearExternalOperations: ctx.deps?.clearExternalOperations ?? clearExternalOperations,
234
237
  };
235
238
  const cfg = () => ctx.controller.config;
236
239
  // 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
@@ -284,6 +287,7 @@ export function createApiDispatcher(ctx) {
284
287
  profile: profile.name,
285
288
  profileDir: profile.dir,
286
289
  signal,
290
+ channel: 'panel', // 0.9.79:面板自升级零外部记录(漏标 = 落 external,违反面板零记录不变量)
287
291
  }, { getService: d.getService, ensureService: d.ensureService });
288
292
  if ('needsBuildApproval' in managed) {
289
293
  // build-blocked(自升级罕见):结构化透出,不冒充成功
@@ -300,7 +304,7 @@ export function createApiDispatcher(ctx) {
300
304
  break;
301
305
  }
302
306
  // M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
303
- const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
307
+ const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal, channel: 'panel' });
304
308
  payload = {
305
309
  pkg: result.pkg,
306
310
  version: result.version,
@@ -502,6 +506,7 @@ export function createApiDispatcher(ctx) {
502
506
  profile: profile.name,
503
507
  profileDir: profile.dir,
504
508
  signal,
509
+ channel: 'panel',
505
510
  }, { getService: d.getService, ensureService: d.ensureService })) };
506
511
  break;
507
512
  }
@@ -511,6 +516,7 @@ export function createApiDispatcher(ctx) {
511
516
  forceIncompatible: boolArg(body.forceIncompatible),
512
517
  namespace: 'host',
513
518
  signal,
519
+ channel: 'panel',
514
520
  });
515
521
  payload = { ...result };
516
522
  break;
@@ -522,10 +528,10 @@ export function createApiDispatcher(ctx) {
522
528
  assertWriteAllowed(profile, 'uninstall');
523
529
  // 0.9.8:desktop → 官方管理器 removeBundle(dsh-market 同策略;此前结构化拒绝)
524
530
  if (profile.kind !== 'web') {
525
- payload = { ...(await d.desktopUninstall(target, { getService: d.getService, ensureService: d.ensureService, profileDir: profile.dir })) };
531
+ payload = { ...(await d.desktopUninstall(target, { getService: d.getService, ensureService: d.ensureService, profileDir: profile.dir, channel: 'panel' })) };
526
532
  break;
527
533
  }
528
- const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
534
+ const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal, channel: 'panel' });
529
535
  payload = { ...result };
530
536
  break;
531
537
  }
@@ -538,10 +544,10 @@ export function createApiDispatcher(ctx) {
538
544
  // 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝,禁 fallback 文件写)
539
545
  if (profile.kind !== 'web') {
540
546
  assertWriteAllowed(profile, 'set-enabled');
541
- payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir })) };
547
+ payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir, channel: 'panel' })) };
542
548
  break;
543
549
  }
544
- const result = await d.toggle(target, body.enabled, { getService: d.getService });
550
+ const result = await d.toggle(target, body.enabled, { getService: d.getService, channel: 'panel' });
545
551
  payload = { ...result };
546
552
  break;
547
553
  }
@@ -558,6 +564,7 @@ export function createApiDispatcher(ctx) {
558
564
  profile: profile.name,
559
565
  profileDir: profile.dir,
560
566
  signal,
567
+ channel: 'panel',
561
568
  }, { getService: d.getService, ensureService: d.ensureService })) };
562
569
  break;
563
570
  }
@@ -565,10 +572,23 @@ export function createApiDispatcher(ctx) {
565
572
  forceIncompatible: boolArg(body.forceIncompatible),
566
573
  namespace: 'host',
567
574
  signal,
575
+ channel: 'panel',
568
576
  });
569
577
  payload = { ...result };
570
578
  break;
571
579
  }
580
+ case 'external-operations': {
581
+ // 0.9.79 外部操作记录(ADR-0018):面板「已结束」overlay 数据源;鉴权走方法分发前的
582
+ // 既有宿主信任检查(零新增特例)
583
+ const records = await d.listExternalOperations();
584
+ payload = { records };
585
+ break;
586
+ }
587
+ case 'external-operations-clear': {
588
+ await d.clearExternalOperations();
589
+ payload = {};
590
+ break;
591
+ }
572
592
  case 'restart': {
573
593
  // 0.9.0:Desktop 不触发旧 helper(Electron 生命周期归官方;杀子进程=红线)
574
594
  assertWriteAllowed(profile, 'restart');
@@ -30,6 +30,7 @@ import { classifyUpgradeActivation } from './activation.js';
30
30
  import { HttpError } from './httpx.js';
31
31
  import { ensureLatestCacheSwept, invalidateLatestCache, latestCacheKey, latestItemId, peekLatestCache, readLatestCache, writeLatestCache } from './latest-cache.js';
32
32
  import { verifyInstalledAdditions } from './install-guard.js';
33
+ import { journalExternalOperation } from './external-journal.js';
33
34
  import { fetchTextLimited } from './httpx.js';
34
35
  import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
35
36
  /** profile package.json 的 dsh.profile.bundles 数组(读不到 → [];Task 13)。 */
@@ -672,8 +673,17 @@ export async function resolveRegistryEntry(id, cfg = {}, opts = {}, deps) {
672
673
  throw new Error(`registry 中没有该条目: ${id}`);
673
674
  }
674
675
  export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
675
- const entry = await resolveRegistryEntry(id, cfg, opts, deps);
676
- return installEntry(entry, cfg, opts, deps);
676
+ // 0.9.79 外部操作直落(ADR-0018):终态后挂钩,成功与失败都记;append 失败被钩子吞(R1-1)
677
+ try {
678
+ const entry = await resolveRegistryEntry(id, cfg, opts, deps);
679
+ const result = await installEntry(entry, cfg, opts, deps);
680
+ await journalExternalOperation(opts.channel, { kind: 'install', target: id, ...(result.version ? { toVersion: result.version } : {}) }, 'ok', { appendExternalOperation: deps?.appendExternalOperation });
681
+ return result;
682
+ }
683
+ catch (err) {
684
+ await journalExternalOperation(opts.channel, { kind: 'install', target: id }, 'failed', { reason: errText(err), appendExternalOperation: deps?.appendExternalOperation });
685
+ throw err;
686
+ }
677
687
  }
678
688
  // ---------- M2 Task 3:统一 mutation session + 装后守卫 + 补偿接线 ----------
679
689
  let sessionTail = Promise.resolve();
@@ -1196,7 +1206,23 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
1196
1206
  }
1197
1207
  /** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
1198
1208
  export function uninstallPlugin(pkg, cfg = {}, opts = {}, deps = {}) {
1199
- return withMutationSession(() => uninstallPluginLocked(pkg, cfg, opts, deps));
1209
+ // 0.9.79 外部操作直落(ADR-0018):终态后挂钩,session 区间内保持单写者
1210
+ return withMutationSession(async () => {
1211
+ try {
1212
+ const result = await uninstallPluginLocked(pkg, cfg, opts, deps);
1213
+ await journalExternalOperation(opts.channel, { kind: 'uninstall', target: pkg }, 'ok', {
1214
+ appendExternalOperation: deps.appendExternalOperation,
1215
+ });
1216
+ return result;
1217
+ }
1218
+ catch (err) {
1219
+ await journalExternalOperation(opts.channel, { kind: 'uninstall', target: pkg }, 'failed', {
1220
+ reason: errText(err),
1221
+ appendExternalOperation: deps.appendExternalOperation,
1222
+ });
1223
+ throw err;
1224
+ }
1225
+ });
1200
1226
  }
1201
1227
  async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
1202
1228
  // 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
@@ -1221,7 +1247,26 @@ async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
1221
1247
  }
1222
1248
  /** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
1223
1249
  export function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
1224
- return withMutationSession(() => upgradePluginLocked(pkg, cfg, opts, deps));
1250
+ // 0.9.79 外部操作直落(ADR-0018):终态后挂钩,session 区间内保持单写者
1251
+ return withMutationSession(async () => {
1252
+ try {
1253
+ const result = await upgradePluginLocked(pkg, cfg, opts, deps);
1254
+ await journalExternalOperation(opts.channel, {
1255
+ kind: 'upgrade',
1256
+ target: pkg,
1257
+ ...(result.fromVersion ? { fromVersion: result.fromVersion } : {}),
1258
+ ...(result.version ? { toVersion: result.version } : {}),
1259
+ }, 'ok', { appendExternalOperation: deps?.appendExternalOperation });
1260
+ return result;
1261
+ }
1262
+ catch (err) {
1263
+ await journalExternalOperation(opts.channel, { kind: 'upgrade', target: pkg }, 'failed', {
1264
+ reason: errText(err),
1265
+ appendExternalOperation: deps?.appendExternalOperation,
1266
+ });
1267
+ throw err;
1268
+ }
1269
+ });
1225
1270
  }
1226
1271
  async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1227
1272
  const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
@@ -1278,29 +1323,42 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1278
1323
  * (owner--name 等),主清单 miss 时按同一 id 查社区目录。社区清单未启用/不可用/加载
1279
1324
  * 异常/未命中 → undefined,由调用方统一报「registry 中没有该条目」。
1280
1325
  */
1281
- /** 自升级(0.5.0 收编:host-api 直调事务的旁路封死):npmLatest + integrity fail-closed + installEntryLocked(session/守卫经 installEntry)。 */
1326
+ /** 自升级(0.5.0 收编:host-api 直调事务的旁路封死):npmLatest + integrity fail-closed;函数体直调事务(call-graph audit:不经 installEntry,无嵌套双记)。 */
1282
1327
  export async function selfUpgrade(pkgName, currentVersion, cfg = {}, opts = {}, deps) {
1283
1328
  return withMutationSession(async () => {
1284
- const timeoutMs = cfg.timeoutMs ?? 20_000;
1285
- const latest = await (deps?.npmLatest ?? defaultNpmLatest)(pkgName, timeoutMs, opts.signal);
1286
- if (!latest.integrity) {
1287
- throw new Error(`npm metadata 缺少 dist integrity:${pkgName}@${latest.version},拒绝升级`);
1329
+ try {
1330
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
1331
+ const latest = await (deps?.npmLatest ?? defaultNpmLatest)(pkgName, timeoutMs, opts.signal);
1332
+ if (!latest.integrity) {
1333
+ throw new Error(`npm metadata 缺少 dist integrity:${pkgName}@${latest.version},拒绝升级`);
1334
+ }
1335
+ const result = await runProfileTransaction({ kind: 'install-npm', pkg: pkgName, version: latest.version, integrity: latest.integrity, signal: opts.signal }, { ...(deps?.transaction ?? {}), warmPackument: deps?.transaction?.warmPackument ?? makeNpmWarmPackument(timeoutMs) });
1336
+ if (!result.ok)
1337
+ throw new TransactionError(result);
1338
+ const payload = {
1339
+ id: pkgName,
1340
+ pkg: result.pkg ?? pkgName,
1341
+ spec: result.spec ?? `${pkgName}@${latest.version}`,
1342
+ version: result.version ?? latest.version,
1343
+ buildApprovals: result.buildApprovals ?? [],
1344
+ fallbackAllBuilds: result.fallbackAllBuilds === true,
1345
+ ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
1346
+ needsRestart: true,
1347
+ output: result.output,
1348
+ healActions: result.healActions,
1349
+ };
1350
+ await journalExternalOperation(opts.channel, {
1351
+ kind: 'self-upgrade',
1352
+ target: pkgName,
1353
+ ...(currentVersion ? { fromVersion: currentVersion } : {}),
1354
+ ...(payload.version ? { toVersion: payload.version } : {}),
1355
+ }, 'ok', { appendExternalOperation: deps?.appendExternalOperation });
1356
+ return payload;
1357
+ }
1358
+ catch (err) {
1359
+ await journalExternalOperation(opts.channel, { kind: 'self-upgrade', target: pkgName, ...(currentVersion ? { fromVersion: currentVersion } : {}) }, 'failed', { reason: errText(err), appendExternalOperation: deps?.appendExternalOperation });
1360
+ throw err;
1288
1361
  }
1289
- const result = await runProfileTransaction({ kind: 'install-npm', pkg: pkgName, version: latest.version, integrity: latest.integrity, signal: opts.signal }, { ...(deps?.transaction ?? {}), warmPackument: deps?.transaction?.warmPackument ?? makeNpmWarmPackument(timeoutMs) });
1290
- if (!result.ok)
1291
- throw new TransactionError(result);
1292
- return {
1293
- id: pkgName,
1294
- pkg: result.pkg ?? pkgName,
1295
- spec: result.spec ?? `${pkgName}@${latest.version}`,
1296
- version: result.version ?? latest.version,
1297
- buildApprovals: result.buildApprovals ?? [],
1298
- fallbackAllBuilds: result.fallbackAllBuilds === true,
1299
- ...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
1300
- needsRestart: true,
1301
- output: result.output,
1302
- healActions: result.healActions,
1303
- };
1304
1362
  });
1305
1363
  }
1306
1364
  /** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
@@ -33,6 +33,7 @@ import { classifyUpgradeActivation } from './activation.js';
33
33
  import { togglePlugin, ToggleError } from './toggle.js';
34
34
  import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
35
35
  import { governExcludeBlock, registerExclusion } from './exclude-governance.js';
36
+ import { journalExternalOperation } from './external-journal.js';
36
37
  /** desktop 升级社区兜底的等待上限(对齐 market.ts DEFAULT_DEADLINE_MS 量级;仅本路径使用)。 */
37
38
  const DEFAULT_COMMUNITY_DEADLINE_MS = 60_000;
38
39
  // 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
@@ -77,7 +78,27 @@ function changeOf(raw) {
77
78
  * 全程 within mutation session(与 web 五入口同一 FIFO)。
78
79
  */
79
80
  export async function desktopInstallFromRegistry(id, cfg = {}, opts = {}, deps = {}) {
80
- return withMutationSession(() => desktopInstallLocked(id, cfg, opts, deps));
81
+ // 0.9.79 外部操作直落(ADR-0018):自记(call-graph audit:内部走 resolveEntry + service.installBundle,
82
+ // 不经 market.ts 已插桩入口);成功/失败/needsBuildApproval(结构化未遂)都记;append 失败被钩子吞
83
+ try {
84
+ const result = await withMutationSession(() => desktopInstallLocked(id, cfg, opts, deps));
85
+ if ('needsBuildApproval' in result) {
86
+ await journalExternalOperation(opts.channel, { kind: 'desktop-install', target: id }, 'failed', {
87
+ reason: result.message,
88
+ appendExternalOperation: deps.appendExternalOperation,
89
+ });
90
+ return result;
91
+ }
92
+ await journalExternalOperation(opts.channel, { kind: 'desktop-install', target: id, ...(result.version ? { toVersion: result.version } : {}) }, 'ok', { appendExternalOperation: deps.appendExternalOperation });
93
+ return result;
94
+ }
95
+ catch (err) {
96
+ await journalExternalOperation(opts.channel, { kind: 'desktop-install', target: id }, 'failed', {
97
+ reason: err instanceof Error ? err.message : String(err),
98
+ appendExternalOperation: deps.appendExternalOperation,
99
+ });
100
+ throw err;
101
+ }
81
102
  }
82
103
  async function desktopInstallLocked(id, cfg, opts, deps) {
83
104
  const service = await resolveManager(deps);
@@ -197,15 +218,27 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
197
218
  };
198
219
  }
199
220
  /**
200
- * Desktop 开关:服务缺席 → 结构化拒绝(ToggleError/unaddressable,禁 fallback 文件写);
201
- * 服务在用 → 既有 togglePlugin 委派路径(installed 校验按 profileDir,与 web 委派同代码)。
221
+ * Desktop 开关:服务缺席 → 结构化拒绝(ToggleError/unaddressable,禁 fallback 文件写;
222
+ * 该拒绝发生在透传前,由本入口自记 desktop-toggle failed——类型契约中该 kind 的唯一可达路径);
223
+ * 服务在用 → 既有 togglePlugin 委派路径(installed 校验按 profileDir,与 web 委派同代码;
224
+ * channel/钩子透传,单笔事务只产生一条 toggle 记录,不双记——R1-2)。
202
225
  */
203
226
  export async function desktopToggle(pkg, enabled, deps = {}) {
204
227
  const service = await resolveManager(deps);
205
228
  if (!service) {
206
- throw new ToggleError('unaddressable', '官方 pluginManager 服务不可用:desktop profile 的开关必须走官方管理器(dsh-m 不做文件级 fallback);请在官方 Desktop 插件页操作');
229
+ const message = '官方 pluginManager 服务不可用:desktop profile 的开关必须走官方管理器(dsh-m 不做文件级 fallback);请在官方 Desktop 插件页操作';
230
+ await journalExternalOperation(deps.channel, { kind: 'desktop-toggle', target: pkg }, 'failed', {
231
+ reason: message,
232
+ appendExternalOperation: deps.appendExternalOperation,
233
+ });
234
+ throw new ToggleError('unaddressable', message);
207
235
  }
208
- return togglePlugin(pkg, enabled, { getService: () => service, profileDir: deps.profileDir });
236
+ return togglePlugin(pkg, enabled, {
237
+ getService: () => service,
238
+ profileDir: deps.profileDir,
239
+ channel: deps.channel,
240
+ appendExternalOperation: deps.appendExternalOperation,
241
+ });
209
242
  }
210
243
  /**
211
244
  * 管理器安装共享核心(0.9.8):installBundle + ChangeResult 判定 + listBundles 复读。
@@ -363,40 +396,78 @@ async function runManagedInstall(service, ctx, deps = {}) {
363
396
  /**
364
397
  * Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
365
398
  * listBundles 复读(可用时必须不见目标在装)。服务缺席 → 结构化拒绝,零 CLI、零文件级 fallback。
399
+ * 0.9.79:自记外部操作(call-graph audit:内部直接 service.removeBundle,不经 market.ts 插桩入口)。
366
400
  */
367
401
  export async function desktopUninstall(pkg, deps = {}) {
368
- const service = await resolveManager(deps);
369
- if (!service || typeof service.removeBundle !== 'function') {
370
- throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的卸载必须由官方管理器执行);拒绝卸载(fail-closed)');
371
- }
372
- const raw = await service.removeBundle(pkg);
373
- const change = changeOf(raw);
374
- if (change.application === 'failed' || change.error) {
375
- throw new DesktopOpsError('remove-failed', `官方管理器卸载失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
376
- }
377
- if (typeof service.listBundles === 'function') {
378
- const bundles = await service.listBundles();
379
- const found = (bundles ?? []).find((b) => b && b.name === (change.bundle ?? pkg));
380
- if (found && found.installed === true) {
381
- throw new DesktopOpsError('verify-failed', `卸载复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 仍见目标在装——不冒充卸载成功`);
402
+ try {
403
+ const service = await resolveManager(deps);
404
+ if (!service || typeof service.removeBundle !== 'function') {
405
+ throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的卸载必须由官方管理器执行);拒绝卸载(fail-closed)');
406
+ }
407
+ const raw = await service.removeBundle(pkg);
408
+ const change = changeOf(raw);
409
+ if (change.application === 'failed' || change.error) {
410
+ throw new DesktopOpsError('remove-failed', `官方管理器卸载失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
411
+ }
412
+ if (typeof service.listBundles === 'function') {
413
+ const bundles = await service.listBundles();
414
+ const found = (bundles ?? []).find((b) => b && b.name === (change.bundle ?? pkg));
415
+ if (found && found.installed === true) {
416
+ throw new DesktopOpsError('verify-failed', `卸载复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 仍见目标在装——不冒充卸载成功`);
417
+ }
382
418
  }
419
+ await journalExternalOperation(deps.channel, { kind: 'desktop-uninstall', target: pkg }, 'ok', {
420
+ appendExternalOperation: deps.appendExternalOperation,
421
+ });
422
+ return {
423
+ pkg,
424
+ liveDisabled: false,
425
+ needsRestart: change.application === 'restart-required',
426
+ leftovers: [],
427
+ via: 'desktop-manager',
428
+ output: `official pluginManager: application=${change.application ?? 'applied'}`,
429
+ };
430
+ }
431
+ catch (err) {
432
+ await journalExternalOperation(deps.channel, { kind: 'desktop-uninstall', target: pkg }, 'failed', {
433
+ reason: err instanceof Error ? err.message : String(err),
434
+ appendExternalOperation: deps.appendExternalOperation,
435
+ });
436
+ throw err;
383
437
  }
384
- return {
385
- pkg,
386
- liveDisabled: false,
387
- needsRestart: change.application === 'restart-required',
388
- leftovers: [],
389
- via: 'desktop-manager',
390
- output: `official pluginManager: application=${change.application ?? 'applied'}`,
391
- };
392
438
  }
393
439
  /**
394
440
  * Desktop 升级(0.9.8,dsh-market 同策略——本机 0.9.3→0.9.4/0.9.5 即此路径实证):
395
441
  * 覆盖安装 = installBundle(latest 精确 spec)。收录匹配(主清单 → 社区目录)与
396
442
  * upgradePluginLocked 同语义;非 dsh-m 收录的插件结构化拒绝(官方插件页负责)。
443
+ * 0.9.79:自记外部操作(call-graph audit:内部走 loadRegistry/mergedOutcome + service.installBundle,
444
+ * 不经 market.ts 已插桩入口);needsBuildApproval(结构化未遂)记 failed。
397
445
  */
398
446
  export async function desktopUpgradeFromRegistry(pkg, cfg = {}, opts = {}, deps = {}) {
399
- return withMutationSession(() => desktopUpgradeLocked(pkg, cfg, opts, deps));
447
+ try {
448
+ const result = await withMutationSession(() => desktopUpgradeLocked(pkg, cfg, opts, deps));
449
+ if ('needsBuildApproval' in result) {
450
+ await journalExternalOperation(opts.channel, { kind: 'desktop-upgrade', target: pkg }, 'failed', {
451
+ reason: result.message,
452
+ appendExternalOperation: deps.appendExternalOperation,
453
+ });
454
+ return result;
455
+ }
456
+ await journalExternalOperation(opts.channel, {
457
+ kind: 'desktop-upgrade',
458
+ target: pkg,
459
+ ...(result.fromVersion ? { fromVersion: result.fromVersion } : {}),
460
+ ...(result.version ? { toVersion: result.version } : {}),
461
+ }, 'ok', { appendExternalOperation: deps.appendExternalOperation });
462
+ return result;
463
+ }
464
+ catch (err) {
465
+ await journalExternalOperation(opts.channel, { kind: 'desktop-upgrade', target: pkg }, 'failed', {
466
+ reason: err instanceof Error ? err.message : String(err),
467
+ appendExternalOperation: deps.appendExternalOperation,
468
+ });
469
+ throw err;
470
+ }
400
471
  }
401
472
  async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
402
473
  const service = await resolveManager(deps);
@@ -24,6 +24,7 @@ import { isSingleRowPlugin, planRowOverride } from './patch-yaml.js';
24
24
  import { PROTECTED_MODULES } from './enablement.js';
25
25
  import { withMutationSession } from './market.js';
26
26
  import { setLivePluginDisabled, loaderHost } from './live-plugin.js';
27
+ import { journalExternalOperation } from './external-journal.js';
27
28
  import { webProfileDir } from './env.js';
28
29
  export class ToggleError extends Error {
29
30
  code;
@@ -55,7 +56,23 @@ async function readPatchText(io, path) {
55
56
  }
56
57
  export function togglePlugin(pkg, enabled, deps = {}) {
57
58
  // M2 Task 3:toggle 纳入统一 mutation session(本体单点获取;自身 file lock 在 session 内层)
58
- return withMutationSession(() => togglePluginLocked(pkg, enabled, deps));
59
+ // 0.9.79 外部操作直落(ADR-0018):终态后挂钩,成功与失败都记;append 失败被钩子吞(R1-1)
60
+ return withMutationSession(async () => {
61
+ try {
62
+ const result = await togglePluginLocked(pkg, enabled, deps);
63
+ await journalExternalOperation(deps.channel, { kind: 'toggle', target: pkg }, 'ok', {
64
+ appendExternalOperation: deps.appendExternalOperation,
65
+ });
66
+ return result;
67
+ }
68
+ catch (err) {
69
+ await journalExternalOperation(deps.channel, { kind: 'toggle', target: pkg }, 'failed', {
70
+ reason: err instanceof Error ? err.message : String(err),
71
+ appendExternalOperation: deps.appendExternalOperation,
72
+ });
73
+ throw err;
74
+ }
75
+ });
59
76
  }
60
77
  async function togglePluginLocked(pkg, enabled, deps = {}) {
61
78
  const key = String(pkg || '').trim();
package/lib/tools.js CHANGED
@@ -257,10 +257,10 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
257
257
  if (profile.kind !== 'web') {
258
258
  assertWriteAllowed(profile, 'install');
259
259
  // 0.9.8:补传服务解析 deps(此前漏传 → desktop 安装恒 no-manager,实机 2026-10-01)
260
- return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir }, { getService: getService, ensureService: ensureService }));
260
+ return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir, channel: 'host-tool' }, { getService: getService, ensureService: ensureService }));
261
261
  }
262
262
  try {
263
- return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
263
+ return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host', channel: 'host-tool' }));
264
264
  }
265
265
  catch (err) {
266
266
  if (err instanceof InstallGuardError) {
@@ -303,9 +303,9 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
303
303
  // 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝);web → 既有委派/降级
304
304
  if (profile.kind !== 'web') {
305
305
  assertWriteAllowed(profile, 'set-enabled');
306
- return cloneJson(await m.desktopToggle(target, args.enabled, { getService: getService, ensureService: ensureService, profileDir: profile.dir }));
306
+ return cloneJson(await m.desktopToggle(target, args.enabled, { getService: getService, ensureService: ensureService, profileDir: profile.dir, channel: 'host-tool' }));
307
307
  }
308
- return cloneJson(await toggle(target, args.enabled, { getService: getService }));
308
+ return cloneJson(await toggle(target, args.enabled, { getService: getService, channel: 'host-tool' }));
309
309
  },
310
310
  }));
311
311
  ctx.tools.register(defineTool({
@@ -333,9 +333,9 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
333
333
  assertWriteAllowed(profile, 'uninstall');
334
334
  // 0.9.8:desktop → 官方管理器 removeBundle(dsh-market 同策略;此前该工具在 desktop 走 web 事务必然错位)
335
335
  if (profile.kind !== 'web') {
336
- return cloneJson(await m.desktopUninstall(target, { getService: getService, ensureService: ensureService, profileDir: profile.dir }));
336
+ return cloneJson(await m.desktopUninstall(target, { getService: getService, ensureService: ensureService, profileDir: profile.dir, channel: 'host-tool' }));
337
337
  }
338
- return cloneJson(await m.uninstallPlugin(target, cfg, { namespace: 'host' }));
338
+ return cloneJson(await m.uninstallPlugin(target, cfg, { namespace: 'host', channel: 'host-tool' }));
339
339
  },
340
340
  }));
341
341
  ctx.tools.register(defineTool({
@@ -420,10 +420,10 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
420
420
  assertWriteAllowed(profile, 'upgrade');
421
421
  // 0.9.8:desktop → 官方管理器覆盖安装(dsh-market 同策略;此前该工具在 desktop 走 web 事务必然错位)
422
422
  if (profile.kind !== 'web') {
423
- return cloneJson(await m.desktopUpgrade(target, cfg, { forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir }, { getService: getService, ensureService: ensureService }));
423
+ return cloneJson(await m.desktopUpgrade(target, cfg, { forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir, channel: 'host-tool' }, { getService: getService, ensureService: ensureService }));
424
424
  }
425
425
  try {
426
- return cloneJson(await m.upgradePlugin(target, cfg, { forceIncompatible: force, namespace: 'host' }));
426
+ return cloneJson(await m.upgradePlugin(target, cfg, { forceIncompatible: force, namespace: 'host', channel: 'host-tool' }));
427
427
  }
428
428
  catch (err) {
429
429
  if (err instanceof InstallGuardError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.78",
3
+ "version": "0.9.80",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",