dsh-m 0.9.77 → 0.9.79

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,19 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.79 功能:外部操作记录——非面板通道(CLI / Agent 工具)变更直落审计日志进面板「已结束」(ADR-0018)
12
+
13
+ - **问题(审计面脱节实证)**:全局操作记录的口径 = 面板发起——quota-watch 0.1.28 经 `dshm_upgrade`(Agent 工具)升级装机成功后,面板「已结束」列表零记录,操作审计面与真实系统状态脱节。CLI 与 Agent 工具是独立进程,写不进浏览器 localStorage 的操作泵。
14
+ - **方案(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。
15
+ - **面板消费(内存 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)。
16
+ - **验证**: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 自升级另行经用户确认。
17
+
18
+ ### 0.9.78 性能:收藏 stale 检查零网络 + 市场页零增量提交守卫(钢人 C3)
19
+
20
+ - **问题(钢人报告 C3 节)**:收藏 stale 判定只需要「id 还在不在目录」的成员布尔,却穿透富 market interface 的探测段——每次收藏区访问/切换(800ms debounce 合并后)仍是 N 条收藏 × full 查询,含每命中条 npm/github latest **网络扇出**且产物全部丢弃;叠加 toggle 后 refreshViews 四连响应无条件 `setData`——数据层精心维持的引用稳定性一次 setState 即蒸发。
21
+ - **修复**:`lookupLive` 补 `probeMode:"cache-only"`(ADR-0013 第一段模式,零网络零探测;existence = items 成员判定,语义零变化);`fetchPage` 增设**零增量提交守卫**——新增 `marketResponseEquivalent` 纯函数(JSON 值语义递归比较,ZoneChips setGeom 同款纪律),内容等价则跳过 `setData`(dataRef/loading 语义不动)。报告可选项「客户端 stale TTL 缓存」明确不做:C1 物化后 cache-only 查询已是内存级。
22
+ - **验证**:1389 项测试全绿(执行基线 1382 + 新增 7)、typecheck 零错误;真机差分冒烟 5 组用例(npm 主清单/自身/社区条目/不存在 id):cache-only 与 full 的 id 集合与 total 逐项一致(红点语义不变的充分条件),cache-only ~30ms vs full 冷跑 ~250ms(零探测指纹);实施计划经独立评审两轮闭环(F1-1 计划基线勘误)。
23
+
11
24
  ### 0.9.76 修复:三项展示层退役与吸顶跳动根治——「对话」徽章/「版本无关」徽章退役 + 分类行滚动锚定自激断路
12
25
 
13
26
  - **① 「对话」来源徽章退役(主人实拍:界面全点按钮却全员误标「对话」)**:`meta.session` 是 0.7.0 终审·新伤1 的**泵所有权标记**(restore 跳过 / replaceAll 不回卷 / persist 时剥离),GUI 的 `runOp` 与对话区镜像两路无条件同打同一标——它区分不了来源,且重载后徽章整体蒸发。修复:opRowVm 删 session 派生、行渲染删徽章、CSS 与 i18n 键退役;`meta.session` 本职不动(数据模型零变更),来源区分留待未来 `meta.origin` 承载。
@@ -559,6 +572,19 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
559
572
 
560
573
  ## English
561
574
 
575
+ ### 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)
576
+
577
+ - **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.
578
+ - **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.
579
+ - **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).
580
+ - **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.
581
+
582
+ ### Performance in 0.9.78 — favorites stale checks go cache-only: zero-network favorites access + zero-increment commit guard (steelman C3)
583
+
584
+ - **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.
585
+ - **Fix**: `lookupLive` now passes `probeMode:"cache-only"` (ADR-0013 first-phase mode: zero network, zero probing; existence = items membership, semantics unchanged); `fetchPage` gains a **zero-increment commit guard** - a new `marketResponseEquivalent` pure function (recursive JSON-value comparison, ZoneChips setGeom discipline) skips `setData` when content is equivalent (dataRef/loading semantics untouched). The report's optional "client-side stale TTL cache" is explicitly not done: after C1 materialization, cache-only queries are already memory-grade.
586
+ - **Verification**: 1389 tests green (execution baseline 1382 + 7 added), typecheck clean; live-instance differential smoke across 5 cases (npm primary / self / community entry / nonexistent id): id sets and totals identical between cache-only and full (the sufficient condition for unchanged red-dot semantics), cache-only ~30ms vs cold full ~250ms (the zero-probe fingerprint); the implementation plan was closed over two independent review rounds (F1-1 plan-baseline correction).
587
+
562
588
  ### Fix in 0.9.76 — three presentation-layer retirements and sticky-chips jitter fixed at the root: "Chat" badge / "Version-independent" badge retired + scroll-anchoring self-excitation cut on the category row
563
589
 
564
590
  - **① "Chat" source badge retired (owner screenshot: every row flagged despite all-UI clicks)**: `meta.session` is the **pump-ownership marker** from the 0.7.0 final review (restore skip / replaceAll no-rollback / stripped on persist) — both the GUI `runOp` path and the chat-mirror path set it unconditionally, so it cannot distinguish origins, and the badge vanishes entirely after a page reload. Fix: opRowVm drops the session derivation; the row badge, its CSS and i18n keys retire; `meta.session` keeps its day job (zero data-model change); a future `meta.origin` would be the honest carrier for source attribution.
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
@@ -34,6 +34,7 @@ __export(market_state_exports, {
34
34
  countBeyondRows: () => countBeyondRows,
35
35
  createZoneState: () => createZoneState,
36
36
  marketNotice: () => marketNotice,
37
+ marketResponseEquivalent: () => marketResponseEquivalent,
37
38
  mergeLatestFields: () => mergeLatestFields,
38
39
  normalizeMarketQuery: () => normalizeMarketQuery,
39
40
  normalizeMarketResponse: () => normalizeMarketResponse,
@@ -235,6 +236,25 @@ function mergeLatestFields(nextItems, prevItems) {
235
236
  });
236
237
  return touched ? out : nextItems;
237
238
  }
239
+ function marketResponseEquivalent(a, b) {
240
+ if (a === b) return true;
241
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
242
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
243
+ if (Array.isArray(a)) {
244
+ if (a.length !== b.length) return false;
245
+ for (let i = 0; i < a.length; i++) {
246
+ if (!marketResponseEquivalent(a[i], b[i])) return false;
247
+ }
248
+ return true;
249
+ }
250
+ const ka = Object.keys(a).filter((k) => a[k] !== void 0);
251
+ const kb = Object.keys(b).filter((k) => b[k] !== void 0);
252
+ if (ka.length !== kb.length) return false;
253
+ for (const k of ka) {
254
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !marketResponseEquivalent(a[k], b[k])) return false;
255
+ }
256
+ return true;
257
+ }
238
258
  function chipRows(offsets) {
239
259
  if (!Array.isArray(offsets)) return 0;
240
260
  let rows = 0;
@@ -1324,10 +1344,28 @@ __export(operations_exports, {
1324
1344
  createOperationsStore: () => createOperationsStore,
1325
1345
  createOpsPump: () => createOpsPump,
1326
1346
  drainRestored: () => drainRestored,
1347
+ externalToRecord: () => externalToRecord,
1327
1348
  opAppliesTo: () => opAppliesTo,
1328
1349
  restoreRecords: () => restoreRecords,
1329
1350
  upgradeNotify: () => upgradeNotify
1330
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
+ }
1331
1369
  function upgradeNotify(activation) {
1332
1370
  if (activation === "client-only") return { needsRestart: false, suffixKey: "notify.upgraded.clientonly" };
1333
1371
  if (activation === "unknown") return { needsRestart: true, suffixKey: "notify.upgraded.activationUnknown" };
@@ -1365,6 +1403,7 @@ function createOperationsStore(storage) {
1365
1403
  const s = storageOf(storage);
1366
1404
  let records = safeParse(s.read());
1367
1405
  let persistDegraded = false;
1406
+ let externalOverlay = [];
1368
1407
  const persist = () => {
1369
1408
  if (persistDegraded) return;
1370
1409
  try {
@@ -1378,6 +1417,28 @@ function createOperationsStore(storage) {
1378
1417
  list() {
1379
1418
  return records.map((r) => ({ ...r }));
1380
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
+ },
1381
1442
  get persistDegraded() {
1382
1443
  return persistDegraded;
1383
1444
  },
@@ -1596,6 +1657,8 @@ var init_operations = __esm({
1596
1657
  // src/client/operations-view.js
1597
1658
  var operations_view_exports = {};
1598
1659
  __export(operations_view_exports, {
1660
+ canRemoveRow: () => canRemoveRow,
1661
+ channelLabelKey: () => channelLabelKey,
1599
1662
  fmtOpClock: () => fmtOpClock,
1600
1663
  fmtOpDuration: () => fmtOpDuration,
1601
1664
  fmtOpFull: () => fmtOpFull,
@@ -1666,9 +1729,19 @@ function opRowVm(r) {
1666
1729
  durText: durMs === null ? "" : fmtOpDuration(durMs),
1667
1730
  dir: isToggle && typeof meta.on === "boolean" ? meta.on ? "on" : "off" : null,
1668
1731
  version,
1669
- 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
1670
1735
  };
1671
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
+ }
1672
1745
  var init_operations_view = __esm({
1673
1746
  "src/client/operations-view.js"() {
1674
1747
  "use strict";
@@ -1866,7 +1939,7 @@ var h = React.createElement;
1866
1939
  var { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React;
1867
1940
  var PLUGIN_ID = "dsh-m";
1868
1941
  var API = "/dshm";
1869
- var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2, searchSourceOf: searchSourceOf2, mergeLatestFields: mergeLatestFields2, chipRows: chipRows2, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2 } = (init_market_state(), __toCommonJS(market_state_exports));
1942
+ var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2, searchSourceOf: searchSourceOf2, mergeLatestFields: mergeLatestFields2, chipRows: chipRows2, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2, marketResponseEquivalent: marketResponseEquivalent2 } = (init_market_state(), __toCommonJS(market_state_exports));
1870
1943
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1871
1944
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1872
1945
  var { lbStep: lbStep2, lbNeighbors: lbNeighbors2, swipeDir: swipeDir2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
@@ -1882,7 +1955,7 @@ var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_M
1882
1955
  var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
1883
1956
  var { applyInstalledUpdates: applyInstalledUpdates2, installedUpdateStats: installedUpdateStats2 } = (init_installed_updates(), __toCommonJS(installed_updates_exports));
1884
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));
1885
- 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));
1886
1959
  var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
1887
1960
  var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
1888
1961
  var { shouldShowInstallCmd: shouldShowInstallCmd2 } = (init_install_cmd(), __toCommonJS(install_cmd_exports));
@@ -1931,6 +2004,14 @@ var ZH = {
1931
2004
  "op.kind.toggle": "\u5F00\u5173",
1932
2005
  "op.kind.community-toggle": "\u793E\u533A\u76EE\u5F55\u5F00\u5173",
1933
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",
1934
2015
  "op.state.on": "\u5F00",
1935
2016
  "op.state.off": "\u5173",
1936
2017
  "op.status.queued": "\u6392\u961F\u4E2D",
@@ -2222,6 +2303,14 @@ var EN = {
2222
2303
  "op.kind.toggle": "Toggle",
2223
2304
  "op.kind.community-toggle": "Community catalog toggle",
2224
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",
2225
2314
  "op.state.on": "On",
2226
2315
  "op.state.off": "Off",
2227
2316
  "op.status.queued": "Queued",
@@ -2713,6 +2802,8 @@ var CSS = `
2713
2802
  .dsvm-opdir{font-size:11px;line-height:16px;padding:0 6px;border-radius:8px;border:1px solid currentColor}
2714
2803
  .dsvm-opdir.on{color:#15803d}
2715
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)}
2716
2807
  .dsvm-opver{font-size:11px;color:var(--dsw-alias-label-secondary,#4b5563);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
2717
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}
2718
2809
  .dsvm-favbtn:hover{color:var(--dsw-alias-state-business-primary,#4d6bfe)}
@@ -2944,11 +3035,12 @@ function useMarketData(zone = "community") {
2944
3035
  return api("market", params, ac.signal).then((raw) => {
2945
3036
  if (genRef.current !== gen || ac.signal.aborted) return;
2946
3037
  const next = normalizeMarketResponse2(raw);
2947
- const prevItems = dataRef.current && Array.isArray(dataRef.current.items) ? dataRef.current.items : null;
3038
+ const prevData = dataRef.current;
3039
+ const prevItems = prevData && Array.isArray(prevData.items) ? prevData.items : null;
2948
3040
  const mergedItems = mergeLatestFields2(next.items, prevItems);
2949
3041
  const merged = mergedItems === next.items ? next : { ...next, items: mergedItems };
2950
3042
  dataRef.current = merged;
2951
- setData(merged);
3043
+ if (!marketResponseEquivalent2(prevData, merged)) setData(merged);
2952
3044
  setLoading(false);
2953
3045
  if (probeMode === "cache-only" && next.latestComplete === false) {
2954
3046
  return fetchPage(nextQuery, force, true, "full");
@@ -4730,7 +4822,7 @@ function FavoriteZone({ favorites, onOpen }) {
4730
4822
  void (async () => {
4731
4823
  const lookupLive = async (fav) => {
4732
4824
  try {
4733
- const res = await api("market", { query: fav.id, source: "all", limit: 8 });
4825
+ const res = await api("market", { query: fav.id, source: "all", limit: 8, probeMode: "cache-only" });
4734
4826
  const items = res && Array.isArray(res.items) ? res.items : [];
4735
4827
  return items.some((x) => x && x.id === fav.id);
4736
4828
  } catch {
@@ -4839,7 +4931,8 @@ function useScrollableContain(dep) {
4839
4931
  }
4840
4932
  function OperationsCard({ records, onClearFinished, onRemove }) {
4841
4933
  const active = records.filter((r) => r.status === "queued" || r.status === "running" || r.status === "input");
4842
- const finished = records.filter((r) => active.indexOf(r) < 0).slice(-50);
4934
+ const finishedAll = records.filter((r) => active.indexOf(r) < 0);
4935
+ const finished = finishedAll.slice(-50);
4843
4936
  const clearable = records.some((r) => TERMINAL_CLEARABLE2.has(r.status));
4844
4937
  const [scrollRef, scrollCls] = useScrollableContain(finished.length);
4845
4938
  const dayGroups = useMemo(() => groupOpsByDay2(finished), [records]);
@@ -4852,6 +4945,7 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4852
4945
  vm.clock ? h("span", { className: "dsvm-optime", title: vm.full }, vm.clock) : null,
4853
4946
  h("span", { className: "dsvm-opstatus" }, lookup("op.status." + r.status)),
4854
4947
  h("span", { className: "dsvm-opkind" }, lookup("op.kind." + r.kind)),
4948
+ vm.channel ? h("span", { className: "dsvm-opchan", title: lookup(channelLabelKey2(vm.channel)) }, lookup(channelLabelKey2(vm.channel))) : null,
4855
4949
  vm.dir ? h("span", { className: `dsvm-opdir ${vm.dir}` }, lookup(vm.dir === "on" ? "op.state.on" : "op.state.off")) : null,
4856
4950
  h("span", { className: "dsvm-optarget", title: tip || void 0 }, vm.target),
4857
4951
  vm.version ? h("span", { className: "dsvm-opver", title: tip || void 0 }, vm.version) : null,
@@ -4859,8 +4953,10 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4859
4953
  r.status === "running" ? Spin() : null,
4860
4954
  r.error ? h("span", { className: "dsvm-opnote" }, r.error) : null,
4861
4955
  r.warning ? h("span", { className: "dsvm-opnote" }, r.warning) : null,
4862
- // 单条移除(终审·新伤2):input/failed/superseded 行给出口(对话区来源的 input 不再永挂)
4863
- (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
4956
+ // 单条移除(终审·新伤2 → 0.9.79 R3-1/R2-3):出口判定下沉 canRemoveRow——
4957
+ // 本地 input/failed/superseded 保留 ✕;外部记录(channel 非空)不给单条 ✕(防按 id 复活),
4958
+ // 移除出口仅「清除已结束」。
4959
+ canRemoveRow2(r) && onRemove ? h("button", { className: "dshm-xbtn", title: lookup("op.remove"), onClick: () => onRemove(r.id) }, h(XIcon)) : null
4864
4960
  );
4865
4961
  };
4866
4962
  return h(
@@ -4872,7 +4968,7 @@ function OperationsCard({ records, onClearFinished, onRemove }) {
4872
4968
  finished.length ? h(
4873
4969
  "div",
4874
4970
  { className: "dsvm-ops-caption", style: { marginTop: active.length ? "7px" : "0" } },
4875
- lookup("settings.ops.group.done.n", { n: finished.length })
4971
+ lookup("settings.ops.group.done.n", { n: finishedAll.length })
4876
4972
  ) : null,
4877
4973
  finished.length ? h(
4878
4974
  "div",
@@ -5190,8 +5286,24 @@ function MarketPanel({ onClose }) {
5190
5286
  const markets = { community: marketCommunity, primary: marketPrimary };
5191
5287
  const installed = useAsync(() => api("installed", { probe: false }), []);
5192
5288
  const updates = useAsync(() => api("installedUpdates"), []);
5193
- const [opRecords, setOpRecords] = useState(() => opsStore.list().map((r) => ({ ...r })));
5194
- const syncOps = useCallback(() => setOpRecords(opsStore.list().map((r) => ({ ...r }))), []);
5289
+ const [opRecords, setOpRecords] = useState(() => opsStore.listMerged().map((r) => ({ ...r })));
5290
+ const syncOps = useCallback(() => setOpRecords(opsStore.listMerged().map((r) => ({ ...r }))), []);
5291
+ const fetchExternalOps = useCallback(async () => {
5292
+ try {
5293
+ const r = await api("external-operations");
5294
+ if (r && Array.isArray(r.records)) {
5295
+ opsStore.setExternal(r.records);
5296
+ syncOps();
5297
+ }
5298
+ } catch {
5299
+ }
5300
+ }, [syncOps]);
5301
+ useEffect(() => {
5302
+ void fetchExternalOps();
5303
+ }, [fetchExternalOps]);
5304
+ useEffect(() => {
5305
+ if (tab === "settings") void fetchExternalOps();
5306
+ }, [tab, fetchExternalOps]);
5195
5307
  const openOps = useCallback(() => {
5196
5308
  setTab("settings");
5197
5309
  if (typeof document !== "undefined") {
@@ -5206,9 +5318,13 @@ function MarketPanel({ onClose }) {
5206
5318
  (kind, target, exec, meta = {}, reuseId) => (
5207
5319
  // session:true = 活会话标记(终审·新伤1):重挂载的 restore 跳过(泵仍拥有);persist 时剥离。
5208
5320
  // 入队与 waiters/原始错误保真都在 createOpsPump(评审 P4:实现与测试同源)。
5209
- opsPump.enqueue({ ...reuseId ? { id: reuseId } : {}, kind, target, status: "queued", meta: { ...meta, session: true } }, exec)
5321
+ // 0.9.79(R1-6):操作终态后连带拉取一次外部记录——他终端(CLI/Agent 工具)的并发变更
5322
+ // 尽快可见;拉取失败静默。
5323
+ opsPump.enqueue({ ...reuseId ? { id: reuseId } : {}, kind, target, status: "queued", meta: { ...meta, session: true } }, exec).finally(() => {
5324
+ void fetchExternalOps();
5325
+ })
5210
5326
  ),
5211
- []
5327
+ [fetchExternalOps]
5212
5328
  );
5213
5329
  const disposeOp = useCallback((id, note) => {
5214
5330
  opsStore.upsert({ id, status: "superseded", error: note });
@@ -5422,7 +5538,10 @@ function MarketPanel({ onClose }) {
5422
5538
  opsRecords: opRecords,
5423
5539
  onOpsClearFinished: () => {
5424
5540
  opsStore.clearFinished();
5541
+ opsStore.clearExternal();
5425
5542
  syncOps();
5543
+ void api("external-operations-clear").catch(() => {
5544
+ });
5426
5545
  },
5427
5546
  onOpsRemove: (id) => {
5428
5547
  opsStore.remove(id);
@@ -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.77",
3
+ "version": "0.9.79",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",