dsh-m 0.9.21 → 0.9.23

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.23 变更:已装页两段加载——列表秒开 + 更新提示就地补 ⬆(ADR-0008)
12
+
13
+ - **问题(实机 2026-10-03 实证)**:更新探测焊在已装列表接口里且带 60min 内存 TTL——发版后重开面板吃到陈旧缓存,升级提示迟迟不出现(quota-watch 新版发布 17 分钟后面板仍无提示,dshmarket 同刻已见)。
14
+ - **两段并行(零新增按钮)**:第一段 `installed {probe:false}` 立即返回已装列表(registry 匹配与开关相位不受影响);第二段新方法 `installedUpdates`(`probeMode: 'only'`,**TTL=0 每次挂载真实探测**)完成后把 ⬆ 徽标 / tab 红点 / 「全部升级 (N)」就地补上;探测失败仍逐项走「检查未完成」,不冒充「没得更新」。
15
+ - **边界与副作用(ADR-0008 如实留痕)**:范围仅已装页——市场浏览页探测、`dshm_list`/`dshm_outdated` 工具、CLI 行为不变(缺省 `probeMode: 'full'`);ttl=0 会先删 host namespace 共享缓存条目再重探,浏览页/工具的 TTL 内命中被刷新为更新值(数据只更新鲜、代码路径与自身 TTL 制度不变),GitHub 被动预算消耗速率上升(上限 25/req、50/h 滚动不变),超限走 latestError 优雅降级。
16
+ - **文档**:决策全文 `docs/adr/0008-installed-two-phase-probe.md`;DESIGN「已装页/缓存语义」补句;GLOSSARY 新词「两段加载」。
17
+
18
+ ### 0.9.22 变更:升级生效判定——tarball 差异三态分类 + 三端重启提示分流(ADR-0007)
19
+
20
+ - **问题(0.9.21 实证)**:`@iasiv5/dsh-skins` 1.2.3→1.3.0 升级后未重启即已生效(客户端 bundle rev 热更),三端仍无条件提示「需要重启」——警报疲劳会侵蚀提示的权威性,重启本身也有真实成本(web 服务瞬断 / desktop 手动重开)。
21
+ - **生效判定(GLOSSARY「生效判定/纯客户端更新」)**:npm 源升级成功点拉取新旧两版 tarball(并行、总 deadline 10s、单包 8MiB、无缓存),零依赖 ustar 只读解析(支持 pax 长名)+ 逐文件 sha256 diff,分类 `activation: 'client-only' | 'restart-required' | 'unknown'`。规则五条:client 集合 = `exports['./client']` 目标;`dsh.bundle.patch` 声明的补丁目标变更 → 宿主;`package.json` 忽略顶层 version 后语义比较(dependencies 等字段变化照常算宿主);其余差异按路径归属;client 指向变化保守判宿主。**fail-open**:一切异常 → unknown → 现状提示,绝不影响升级成功态。
22
+ - **三端分流**:agent 工具消息 client-only 明示「刷新页面即可生效,不要询问 dshm_restart」、unknown 保守建议重启;GUI toast 后缀 + `needsRestart` 门(client-only 不亮重启横幅;纯函数 `upgradeNotify` 可单测);CLI 行文案换挡。`needsRestart` 源头放宽 boolean(TS2430 规避),`UpgradeResult`/desktop 升级结果新增 `activation` 字段;接线点 `upgradePluginLocked` + `desktopUpgradeLocked`,selfUpgrade/install/uninstall/github 源维持现状。
23
+ - **测试与文档**:新增 ustar 解析 11 例、分类规则+fail-open 18 例、升级接线 7 例(npm/github/selfUpgrade/desktop)、renderUpgrade 4 例、upgradeNotify 3 例;既有升级替身统一补 `classifyActivation` 缝(防隐式出网)。决策与已知局限全文见 `docs/adr/0007-activation-classification.md`(client chunk require 图不追踪;docs 类随版差异保守判宿主侧)。
24
+
11
25
  ### 0.9.21 变更:设置页「强制刷新」显示连带修复 + registry.json 变更自动清 jsDelivr 缓存
12
26
 
13
27
  - **设置页强刷显示修复(实机 2026-10-03 实证)**:设置页「强制刷新」只重载 `registry` 接口(force 同步强刷全链路),但面板展示的 registryState **优先读 `registry-config` 的挂载时快照**——于是出现「toast 报已强制刷新、生效来源/更新时间/条目数纹丝不动」的假死,重开设置页才对齐。现在强刷成功后连带重载 `registry-config`(force 已更新 controller 内存快照,零成本取新值),显示即时跟随生效数据。
@@ -235,6 +249,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
235
249
 
236
250
  ## English
237
251
 
252
+ ### Changed in 0.9.23 — two-phase Installed tab: instant list + update hints filled in place (ADR-0008)
253
+
254
+ - **Problem (observed live 2026-10-03)**: update probes were welded into the installed-list endpoint behind a 60-min in-memory TTL — after publishing a release, reopening the panel served the stale cache and update hints lagged (quota-watch's new version stayed invisible 17 minutes after publish while dsh-market already showed it).
255
+ - **Two parallel phases (zero new buttons)**: phase 1 `installed {probe:false}` returns the installed list immediately (registry matching and toggle phase untouched); phase 2 is a new `installedUpdates` method (`probeMode: 'only'`, **TTL=0 — a real probe on every panel mount**) that fills in ⬆ badges / the tab dot / "Upgrade all (N)" in place; failed checks still surface per item as "check incomplete" instead of pretending "up to date".
256
+ - **Scope & side effects (documented in ADR-0008)**: installed tab only — browse-page probes, `dshm_list`/`dshm_outdated` tools and CLI are unchanged (default `probeMode: 'full'`); ttl=0 evicts shared host-namespace cache entries before re-probing, so browse/tools TTL hits receive fresher write-backs (data only gets fresher; code paths and their own TTL regimes unchanged); GitHub passive budget consumption rate rises (limits unchanged: 25/req, 50/h rolling) with graceful `latestError` degradation.
257
+ - **Docs**: full decision record in `docs/adr/0008-installed-two-phase-probe.md`; DESIGN notes on the installed page / cache semantics; GLOSSARY term "Two-phase Load".
258
+
259
+ ### Changed in 0.9.22 — upgrade activation classification (tarball diff, three states) + three-surface restart-hint split (ADR-0007)
260
+
261
+ - **Problem (observed in 0.9.21)**: after upgrading `@iasiv5/dsh-skins` 1.2.3→1.3.0 the plugin was already live without a restart (client bundle rev hot-reload), yet all three surfaces still said "restart required" — the noise trains users to ignore the hint, and restarts have real costs (web service blip / manual desktop reopen).
262
+ - **Activation classification** (GLOSSARY「生效判定 / 纯客户端更新」): npm-source upgrades now fetch both tarballs at the success point (parallel, 10s total deadline, 8MiB per tarball, no cache), parse them with a zero-dependency read-only ustar reader (pax long names supported) and classify by per-file sha256 diff into `activation: 'client-only' | 'restart-required' | 'unknown'`. Five rules: client set = `exports['./client']` target; a changed `dsh.bundle.patch`-declared patch file → host; `package.json` compared semantically with the top-level `version` ignored (dependency changes still count as host); every other added/removed/changed file attributed by path; a changed client pointer conservatively counts as host. **Fail-open**: any error → unknown → current conservative hint, never affecting the upgrade's success.
263
+ - **Three-surface split**: the agent tool message for `client-only` says a page refresh suffices and explicitly forbids offering `dshm_restart`; the GUI toast gains a suffix and its restart banner gate follows `needsRestart` (pure helper `upgradeNotify`, unit-tested); the CLI line switches accordingly. `needsRestart` widened to boolean at the source (TS2430 avoidance); `UpgradeResult` and the desktop upgrade result carry the new `activation` field; wired at `upgradePluginLocked` + `desktopUpgradeLocked`; selfUpgrade/install/uninstall/github sources unchanged.
264
+ - **Tests & docs**: 11 new ustar parser cases, 18 classifier rule/fail-open cases, 7 upgrade-wiring cases (npm/github/selfUpgrade/desktop), 4 renderUpgrade cases, 3 upgradeNotify cases; existing upgrade fixtures gained a `classifyActivation` seam (no implicit network). Full decision record and known limitations in `docs/adr/0007-activation-classification.md` (no client chunk require-graph tracking; docs-only diffs conservatively count as host).
265
+
238
266
  ### Changed in 0.9.21 — Settings force-refresh display fix + automatic jsDelivr purge on registry.json changes
239
267
 
240
268
  - **Settings force-refresh display fix (observed live 2026-10-03)**: the Settings tab's "Force refresh" only reloaded the `registry` API (synchronous full-chain force fetch), but the panel renders the registryState **from the `registry-config` snapshot taken at mount** — so the toast said "Registry force-refreshed" while source / fetched-at / entry-count stayed frozen until the settings tab was remounted. A successful force refresh now also reloads `registry-config` (force already updated the controller's in-memory snapshot, so this costs nothing) and the display follows the live data immediately.
package/docs/DESIGN.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
22
22
  - **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
23
23
  - **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
24
- - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
24
+ - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。已装面板第二段探测为 TTL=0 例外(ADR-0008)。
25
25
  - **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
26
26
 
27
27
  ### 2.2 Schema(v1 定稿,严格校验)
@@ -120,6 +120,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
120
120
  - **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
121
121
  - **安装(Desktop profile 委派,0.9.0+)**:官方 pluginManager `installBundle(enabled: true)` → ChangeResult 以 application/stage 为准判定:cancelled 拒绝;build-blocked 走结构化审批重试(pending 名单原样回传);minimumReleaseAge 翻译成诚实等待指引(策略正确工作非故障);enable 失败按 `packageResult` 如实区分「包已写入 / 未能写入 / 官方未回传(未知)」——enable 先行失败(pnpm 未跑)时不得断言「包已写入」→ `listBundles` 复读在装(不冒充成功)。**enable 阶段失败自动重试一次**(本地增强 2026-10-02,未发版):应用侧 enable 与高频装卸/插件树重载竞速返回 `operation-error`(实机实证 dsh-copilot-auth 三装卸两败,失败 operation 的 pnpm.log 均为 0 字节),单次退避重试即愈;其余失败形态不重试。
122
122
  - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
123
+ - **生效判定(0.9.22,[ADR-0007](./adr/0007-activation-classification.md),术语见 GLOSSARY「生效判定/纯客户端更新」)**:npm 源升级成功点后拉取新旧两版 tarball(并行、总 deadline 10s、单包 8MiB、无缓存),零依赖 ustar 只读解析 + 逐文件 sha256 diff,分类 `activation: 'client-only' | 'restart-required' | 'unknown'`——规则:client 集合 = `exports['./client']` 目标;`dsh.bundle.patch` 声明的补丁目标变更 → 宿主;`package.json` 忽略顶层 version 后语义比较;其余差异按路径归属;client 指向变化保守判宿主。`needsRestart` 仅 client-only 覆写 false(`InstallResult.needsRestart` 源头放宽 boolean,TS2430 规避)。三端分流:工具消息 client-only 明示「不要询问 dshm_restart」、GUI toast 后缀 + 横幅门(`needsRestart=false` 不亮)、CLI 行文案换挡。fail-open:一切异常 → unknown → 现状提示。接线点 `upgradePluginLocked` + `desktopUpgradeLocked`;selfUpgrade/install/uninstall/github 源不产出。已知局限(保守方向误判,安全):client chunk require 图不追踪、docs 类随版差异判宿主侧。
123
124
  - **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
124
125
  - **开关(0.4.0,术语见 GLOSSARY.md「开关/行覆盖/Bundle 选择/委派降级」)**:已装插件运行状态的可逆切换,**不经 Profile 变更事务**。粒度自动路由——包内补丁层单 insert 行且无配置补丁行 → 行覆盖(`cordis.patch.yml` 保注释 `disabled` 编辑,官方 `writePluginEnabled` 同款语义,启用写显式 `disabled: false`);多行/不可解析 → Bundle 选择(`dsh.profile.bundles` 数组增删)。写路径委派优先:运行时探测官方 `pluginManager` 服务(`ctx.get`,按存在性不判版本号)→ `setPluginEnabled`/`setBundleEnabled`(白拿保护判定、unaddressable 识别、hmr 活体重组;applied 以复读 `listPlugins()` 为准);服务缺席降级 loader `entry.update()` 直操作 + 锁内文件编辑(bundle 级恒 restart-required)。**必须持官方同款锁**(`withFileLock(<profile>/package.json)`)防与官方 UI 并发丢更新。架构决策见 [ADR-0001](./adr/0001-delegate-with-fallback-for-plugin-manager.md)。
125
126
  - **运行相位(0.4.0)**:loader fiber 状态投影(active/failed/pending/loading/unloading;fiber 缺失即已停用),读路径始终自读 loader entries,读路径零服务依赖。
@@ -139,6 +140,18 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
139
140
  4. pnpm 构建脚本被拦时,先按 pnpm 待决名单**逐键精确放行**(ADR-0002),名单不可读才 `dangerouslyAllowAllBuilds` 全量兜底,且**必须明确报告**放行了哪些包/是否兜底;
140
141
  5. 不做签名/验签体系(自用,明确不做)。
141
142
 
143
+ ### 3.3 生效判定三态分流(0.9.22,[ADR-0007](./adr/0007-activation-classification.md))
144
+
145
+ 判定本体(五条规则、10s/8MiB 预算、fail-open、已知局限)见 §3.2「生效判定」条目与 ADR-0007,此处只列三端分流表:
146
+
147
+ | activation | agent 工具消息 | GUI | CLI |
148
+ |---|---|---|---|
149
+ | `client-only` | 明示刷新生效,**不询问** dshm_restart | toast 后缀「刷新页面即可生效」;重启横幅不亮(`needsRestart=false`) | 「纯客户端更新:刷新页面即生效,无需重启」 |
150
+ | `unknown` | 保守建议重启并询问 dshm_restart | 现状重启横幅 + toast 后缀「判定未完成」 | 「生效判定未完成;建议重启」 |
151
+ | `restart-required` / 字段缺席(github 源等) | 现状「需要重启」并询问 | 现状重启横幅 | 现状行 |
152
+
153
+ 与 `application`(Profile 变更事务词汇)的边界:`activation` 只是「新版本如何生效」的操作建议,不是事务状态——事务四态(applied/restart-required/overridden/failed)描述**变更本身**,`activation` 描述**生效方式**;两者不互相推导(GLOSSARY Avoid 行同源)。
154
+
142
155
  ## 4. GUI(旗舰,v1 必须做好)
143
156
 
144
157
  0.4.0 增补:已装卡右上 `dshm-switch` 开关(受 `toggleable` 控制,锁因 title 提示);sub 行相位点 `● active · v1.0.8 · npm`(相位点只映射 phase 五值,「已停用」归 Switch,两输入源各管各的);开关结果通知按 `applied` 分流(live → 绿 toast「即时生效」;restart-required → 沿用重启横幅 + 一键重启);安装遇兼容拦截(409 + issue)弹「仍要安装」确认(红字风险 + peers 清单 → `forceIncompatible` 重发)。
@@ -149,7 +162,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
149
162
  3 个视图,**中文优先**,跟随 DSH Web 深色主题(0.7.0:卡片展开式详情已由 Modal 取代):
150
163
 
151
164
  1. **市场页**(默认):**分区制**(0.7.0 §2.6)——社区/精选/收藏三分区,各分区独立分类/搜索/排序/分页状态;搜索/分类/排序为**服务端处理**(Host 强制 `withLatest=true`、limit clamp 1..96 缺省 24,探测仅当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController 丢弃旧响应);安装/卸载/升级完成后通过统一协调器同时刷新市场与已装快照,不要求关闭并重新打开面板;页码窗口化分页(24/48/96)+ 默认/自定义/缓存/不可用短提示(不含本地路径;0.5.0 的「超过 200 条性能提示」随分区退役);详情 Modal 保留 README markdown 预览与 npm/GitHub 官方外链。
152
- 2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。
165
+ 2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。两段加载(ADR-0008):列表不等待探测(`probe:false`),更新提示由 `installedUpdates`(TTL=0)就地补上。
153
166
  3. **设置页**:registry 地址草稿 +「校验并应用」(先校验候选再写 settings,失败不落盘)/「恢复默认」/「下载默认 registry.json」(不改当前配置)/「检查条目可达性」(probe 统计 + 最多 100 条 issue,只读不改配置);同时展示配置地址、当前生效配置、生效来源与 configStatus(含 rejected/回滚原因与维护性 warnings);完整本地路径仅在此页显示。registry 配置为 **live 生效,不出现安装类重启横幅**;安装/卸载/升级仍保留重启横幅。
154
167
 
155
168
  技术:`src/client.js` 经 **esbuild** 打包为 `lib/client.js`;`window.__ModuleLoader__.load({ id: "dsh-m", factory })` 注册;**React 从 module loader require**(零额外运行时依赖);manifest 注入 `@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-settings`。
package/lib/cli.js CHANGED
@@ -10,6 +10,7 @@ import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
10
10
  import { loadRegistry } from './core/registry.js';
11
11
  import { COMMUNITY_CATEGORY_LABELS } from './core/community.js';
12
12
  import { scheduleRestart } from './core/restart.js';
13
+ import { upgradeEffectLine } from './core/activation.js';
13
14
  import { pathToFileURL } from 'node:url';
14
15
  import { resolve } from 'node:path';
15
16
  /** 分类中文标签(0.7.0 Task 6;0.9.16 策展五桶):精选策展桶本地表 + 社区已知标签单一事实源;未知 slug 原样。 */
@@ -366,7 +367,7 @@ async function runCliDispatch(argv, deps, io) {
366
367
  const to = res.version ? `v${res.version}` : res.sha ? res.sha.slice(0, 7) : '最新';
367
368
  out(`✅ 已升级 ${res.pkg}(${from}${to})`);
368
369
  outBuildsNote(res, out);
369
- out('需要重启 DSH Web 生效:dshm restart --yes');
370
+ out(upgradeEffectLine(res.activation));
370
371
  return 0;
371
372
  }
372
373
  case 'toggle': {
package/lib/client.js CHANGED
@@ -689,10 +689,11 @@ var view_refresh_exports = {};
689
689
  __export(view_refresh_exports, {
690
690
  refreshAfterMutation: () => refreshAfterMutation
691
691
  });
692
- async function refreshAfterMutation({ marketReload, installedReload }) {
692
+ async function refreshAfterMutation({ marketReload, installedReload, updatesReload }) {
693
693
  await Promise.all([
694
694
  marketReload(false),
695
- installedReload()
695
+ installedReload(),
696
+ updatesReload()
696
697
  ]);
697
698
  }
698
699
  var init_view_refresh = __esm({
@@ -701,6 +702,53 @@ var init_view_refresh = __esm({
701
702
  }
702
703
  });
703
704
 
705
+ // src/client/installed-updates.js
706
+ var installed_updates_exports = {};
707
+ __export(installed_updates_exports, {
708
+ applyInstalledUpdates: () => applyInstalledUpdates,
709
+ installedUpdateStats: () => installedUpdateStats
710
+ });
711
+ function applyInstalledUpdates(items, updates) {
712
+ if (!Array.isArray(items)) return [];
713
+ const byPkg = /* @__PURE__ */ new Map();
714
+ if (Array.isArray(updates)) {
715
+ for (const u of updates) {
716
+ if (!u || typeof u !== "object" || typeof u.pkg !== "string" || u.pkg === "") continue;
717
+ byPkg.set(u.pkg, u);
718
+ }
719
+ }
720
+ return items.map((it) => {
721
+ if (!it || typeof it !== "object") return it;
722
+ const u = byPkg.get(it.pkg);
723
+ if (!u) return it;
724
+ return {
725
+ ...it,
726
+ latestVersion: u.latestVersion ?? void 0,
727
+ latestTag: u.latestTag ?? void 0,
728
+ latestSha: u.latestSha ?? void 0,
729
+ outdated: u.outdated === true,
730
+ latestError: u.latestError ?? void 0,
731
+ latestErrorCode: u.latestErrorCode ?? void 0
732
+ };
733
+ });
734
+ }
735
+ function installedUpdateStats(items) {
736
+ const arr = Array.isArray(items) ? items : [];
737
+ let outdatedCount = 0;
738
+ let incompleteCount = 0;
739
+ for (const it of arr) {
740
+ if (!it || typeof it !== "object") continue;
741
+ if (it.outdated === true) outdatedCount += 1;
742
+ if (typeof it.latestError === "string" && it.latestError !== "") incompleteCount += 1;
743
+ }
744
+ return { outdatedCount, incompleteCount };
745
+ }
746
+ var init_installed_updates = __esm({
747
+ "src/client/installed-updates.js"() {
748
+ "use strict";
749
+ }
750
+ });
751
+
704
752
  // src/client/operations.js
705
753
  var operations_exports = {};
706
754
  __export(operations_exports, {
@@ -710,8 +758,14 @@ __export(operations_exports, {
710
758
  createOpsPump: () => createOpsPump,
711
759
  drainRestored: () => drainRestored,
712
760
  opAppliesTo: () => opAppliesTo,
713
- restoreRecords: () => restoreRecords
761
+ restoreRecords: () => restoreRecords,
762
+ upgradeNotify: () => upgradeNotify
714
763
  });
764
+ function upgradeNotify(activation) {
765
+ if (activation === "client-only") return { needsRestart: false, suffixKey: "notify.upgraded.clientonly" };
766
+ if (activation === "unknown") return { needsRestart: true, suffixKey: "notify.upgraded.activationUnknown" };
767
+ return { needsRestart: true, suffixKey: null };
768
+ }
715
769
  function safeParse(raw) {
716
770
  try {
717
771
  const v = JSON.parse(raw);
@@ -1163,7 +1217,8 @@ var { toggleViewModel: toggleViewModel2, toggleNoticeKeys: toggleNoticeKeys2 } =
1163
1217
  var { pickPayload: pickPayload2, parseToolArgs: parseToolArgs2 } = (init_tool_view(), __toCommonJS(tool_view_exports));
1164
1218
  var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2, isAmbiguousRestartRequestError: isAmbiguousRestartRequestError2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
1165
1219
  var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
1166
- var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2 } = (init_operations(), __toCommonJS(operations_exports));
1220
+ var { applyInstalledUpdates: applyInstalledUpdates2, installedUpdateStats: installedUpdateStats2 } = (init_installed_updates(), __toCommonJS(installed_updates_exports));
1221
+ var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2, upgradeNotify: upgradeNotify2 } = (init_operations(), __toCommonJS(operations_exports));
1167
1222
  var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
1168
1223
  var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
1169
1224
  var { shouldShowInstallCmd: shouldShowInstallCmd2 } = (init_install_cmd(), __toCommonJS(install_cmd_exports));
@@ -1390,6 +1445,8 @@ var ZH = {
1390
1445
  "notify.livedisabled": "\uFF08\u5DF2\u5148\u4E0B\u7EBF\u8FD0\u884C\u4E2D\u7684\u754C\u9762\uFF09",
1391
1446
  "notify.leftovers": "\uFF1B\u68C0\u6D4B\u5230\u7591\u4F3C\u6B8B\u7559\u6570\u636E\uFF1A{paths}",
1392
1447
  "notify.upgraded": "\u5DF2\u5347\u7EA7 {pkg}\uFF08{from} \u2192 {to}\uFF09",
1448
+ "notify.upgraded.clientonly": "\u3002\u7EAF\u5BA2\u6237\u7AEF\u66F4\u65B0\uFF1A\u5237\u65B0\u9875\u9762\u5373\u53EF\u751F\u6548\uFF0C\u65E0\u9700\u91CD\u542F",
1449
+ "notify.upgraded.activationUnknown": "\u3002\u751F\u6548\u5224\u5B9A\u672A\u5B8C\u6210\uFF1A\u4E3A\u786E\u4FDD\u751F\u6548\u8BF7\u91CD\u542F DSH Web",
1393
1450
  "notify.upgradehint": "\uFF08\u6CE8\u610F\uFF1A\u8BE5\u63D2\u4EF6\u6267\u884C\u4E86\u6784\u5EFA\u811A\u672C\uFF09",
1394
1451
  "notify.selfupgraded": "dsh-m \u5DF2\u5347\u7EA7\uFF08v{from} \u2192 v{to}\uFF09",
1395
1452
  "notify.selfupgraded.failed": "dsh-m \u5347\u7EA7\u5931\u8D25\uFF1A{err}",
@@ -1650,6 +1707,8 @@ var EN = {
1650
1707
  "notify.livedisabled": " (live UI disabled first)",
1651
1708
  "notify.leftovers": "; possible leftover data: {paths}",
1652
1709
  "notify.upgraded": "Upgraded {pkg} ({from} \u2192 {to})",
1710
+ "notify.upgraded.clientonly": ". Client-only update: refresh the page to take effect \u2014 no restart needed",
1711
+ "notify.upgraded.activationUnknown": ". Activation classification unavailable: restart DSH Web to ensure the new version is live",
1653
1712
  "notify.upgradehint": " (note: this plugin ran build scripts)",
1654
1713
  "notify.selfupgraded": "dsh-m upgraded (v{from} \u2192 v{to})",
1655
1714
  "notify.selfupgraded.failed": "dsh-m upgrade failed: {err}",
@@ -2914,8 +2973,9 @@ function ReadmeBlock({ pkg }) {
2914
2973
  state.truncated ? h("div", { className: "dshm-md-note" }, lookup("readme.truncated")) : null
2915
2974
  );
2916
2975
  }
2917
- function InstalledTab({ notify, installed, onMutation, ops }) {
2976
+ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
2918
2977
  const { loading, data, error, reload } = installed;
2978
+ const updateRows = updates && updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : [];
2919
2979
  const [openPkg, setOpenPkg] = useState(null);
2920
2980
  const [readmePkg, setReadmePkg] = useState(null);
2921
2981
  const activeMutateTarget = (ops.records.find((r) => r.kind !== "install" && (r.status === "running" || r.status === "queued" || r.status === "input")) || {}).target || null;
@@ -2962,14 +3022,15 @@ function InstalledTab({ notify, installed, onMutation, ops }) {
2962
3022
  const doUpgrade = async (it) => {
2963
3023
  try {
2964
3024
  const res = await ops.runOp("upgrade", it.pkg, () => api("upgrade", { pkg: it.pkg }));
3025
+ const note = upgradeNotify2(res.activation);
2965
3026
  notify({
2966
3027
  kind: "ok",
2967
- needsRestart: true,
3028
+ needsRestart: note.needsRestart,
2968
3029
  text: lookup("notify.upgraded", {
2969
3030
  pkg: res.pkg,
2970
3031
  from: res.fromVersion ? `v${res.fromVersion}` : "\u2014",
2971
3032
  to: res.version ? `v${res.version}` : res.sha ? res.sha.slice(0, 7) : "latest"
2972
- }) + (res.buildApprovals && res.buildApprovals.length ? lookup("notify.builds", { names: res.buildApprovals.join(", ") }) : res.fallbackAllBuilds ? lookup("notify.builds.fallback") : "")
3033
+ }) + (res.buildApprovals && res.buildApprovals.length ? lookup("notify.builds", { names: res.buildApprovals.join(", ") }) : res.fallbackAllBuilds ? lookup("notify.builds.fallback") : "") + (note.suffixKey ? lookup(note.suffixKey) : "")
2973
3034
  });
2974
3035
  await (onMutation ? onMutation() : reload());
2975
3036
  } catch (e) {
@@ -2993,7 +3054,7 @@ function InstalledTab({ notify, installed, onMutation, ops }) {
2993
3054
  };
2994
3055
  if (loading && !data) return h("div", { className: "dshm-empty" }, lookup("installed.loading"), Spin());
2995
3056
  if (error) return h("div", { className: "dshm-err" }, lookup("failed.read", { err: error }));
2996
- const items = data && data.items || [];
3057
+ const items = applyInstalledUpdates2(data && data.items || [], updateRows);
2997
3058
  if (!items.length) return h("div", { className: "dshm-empty" }, `${lookup("installed.empty")} (${data.profileDir})`);
2998
3059
  return h(
2999
3060
  React.Fragment,
@@ -3615,7 +3676,8 @@ function MarketPanel({ onClose }) {
3615
3676
  const marketCommunity = useMarketData("community");
3616
3677
  const marketPrimary = useMarketData("primary");
3617
3678
  const markets = { community: marketCommunity, primary: marketPrimary };
3618
- const installed = useAsync(() => api("installed"), []);
3679
+ const installed = useAsync(() => api("installed", { probe: false }), []);
3680
+ const updates = useAsync(() => api("installedUpdates"), []);
3619
3681
  const [opRecords, setOpRecords] = useState(() => opsStore.list().map((r) => ({ ...r })));
3620
3682
  const syncOps = useCallback(() => setOpRecords(opsStore.list().map((r) => ({ ...r }))), []);
3621
3683
  const runOp = useCallback(
@@ -3672,14 +3734,14 @@ function MarketPanel({ onClose }) {
3672
3734
  [marketCommunity.reload, marketPrimary.reload]
3673
3735
  );
3674
3736
  const refreshViews = useCallback(
3675
- () => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload }),
3676
- [marketReloadAll, installed.reload]
3737
+ () => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload, updatesReload: updates.reload }),
3738
+ [marketReloadAll, installed.reload, updates.reload]
3677
3739
  );
3678
3740
  const onRegistryChanged = refreshViews;
3679
3741
  const stillApplies = useCallback(async (r) => {
3680
3742
  let fresh = null;
3681
3743
  try {
3682
- fresh = await api("installed");
3744
+ fresh = await api("installed", { probe: false });
3683
3745
  } catch {
3684
3746
  fresh = null;
3685
3747
  }
@@ -3726,7 +3788,8 @@ function MarketPanel({ onClose }) {
3726
3788
  market: marketCommunity.data ? marketCommunity.data.total : null,
3727
3789
  installed: installed.data ? installed.data.items.length : null
3728
3790
  };
3729
- const outdatedCount = installed.data && Array.isArray(installed.data.items) ? installed.data.items.filter((x) => x && x.outdated).length : 0;
3791
+ const mergedItems = installed.data && Array.isArray(installed.data.items) ? applyInstalledUpdates2(installed.data.items, updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : []) : null;
3792
+ const outdatedCount = installedUpdateStats2(mergedItems || []).outdatedCount;
3730
3793
  const [banner, setBanner] = useState(null);
3731
3794
  const [toast, setToast] = useState(null);
3732
3795
  useEffect(() => {
@@ -3806,7 +3869,7 @@ function MarketPanel({ onClose }) {
3806
3869
  "div",
3807
3870
  { className: "dshm-body" },
3808
3871
  tab === "market" ? h(MarketTab, { notify, markets, onMutation: refreshViews, ops, favorites, profileKind: profile?.kind ?? null }) : null,
3809
- tab === "installed" ? h(InstalledTab, { notify, installed, onMutation: refreshViews, ops }) : null,
3872
+ tab === "installed" ? h(InstalledTab, { notify, installed, updates, onMutation: refreshViews, ops }) : null,
3810
3873
  tab === "settings" ? h(SettingsTab, { notify, onRegistryChanged }) : null,
3811
3874
  h(OperationsPanel, {
3812
3875
  records: opRecords,
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 生效判定(GLOSSARY:生效判定 Activation Classification,0.9.22):
3
+ * 对 npm 源升级的新旧两版 tarball 做逐文件 sha256 diff,把「新版本如何生效」分类为
4
+ * 'client-only'(纯客户端更新:刷新页面即生效)/ 'restart-required'(需重启)/ 'unknown'。
5
+ * 任何异常(元数据缺失、下载失败、超时、超限、tar/JSON 解析)一律 fail-open 到 'unknown',
6
+ * 绝不影响升级成功态。判定预算独立于 pnpm 事务:总 deadline 默认 10s、单 tarball 上限 8MiB、
7
+ * 无缓存(升级是一次性操作,结果随升级返回渲染完即弃——ADR-0006「缓存是优化不是功能」精神)。
8
+ * 分类规则五条与已知局限见 docs/adr/0007-activation-classification.md。
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { gunzipSync } from 'node:zlib';
12
+ import { fetchBytesLimited } from './httpx.js';
13
+ import { npmVersion } from './versions.js';
14
+ import { parseTarEntries } from './ustar.js';
15
+ const DEFAULT_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
17
+ function stripPackagePrefix(name) {
18
+ return name.startsWith('package/') ? name.slice('package/'.length) : name;
19
+ }
20
+ function normalizeTarget(target) {
21
+ return target.replace(/^\.\//, '');
22
+ }
23
+ function sha256(buf) {
24
+ return createHash('sha256').update(buf).digest('hex');
25
+ }
26
+ function fileMap(tarBuf) {
27
+ const raw = parseTarEntries(gunzipSync(tarBuf));
28
+ const map = new Map();
29
+ for (const [name, content] of raw)
30
+ map.set(stripPackagePrefix(name), content);
31
+ return map;
32
+ }
33
+ /** 深比较(键序无关):package.json 忽略顶层 version 后的语义 diff 用。 */
34
+ function deepEqual(a, b) {
35
+ if (a === b)
36
+ return true;
37
+ if (a === null || b === null || typeof a !== typeof b)
38
+ return false;
39
+ if (Array.isArray(a) || Array.isArray(b)) {
40
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
41
+ return false;
42
+ return a.every((v, i) => deepEqual(v, b[i]));
43
+ }
44
+ if (typeof a === 'object') {
45
+ const ka = Object.keys(a).sort();
46
+ const kb = Object.keys(b).sort();
47
+ if (!deepEqual(ka, kb))
48
+ return false;
49
+ return ka.every((k) => deepEqual(a[k], b[k]));
50
+ }
51
+ return false;
52
+ }
53
+ function parsePackageJson(buf) {
54
+ if (!buf)
55
+ throw new Error('tar 缺少 package.json');
56
+ const parsed = JSON.parse(buf.toString('utf8'));
57
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
58
+ throw new Error('package.json 不是对象');
59
+ return parsed;
60
+ }
61
+ /** 规则 3:package.json 忽略顶层 version 后语义比较(依赖条目里的版本串照常计入);非法 JSON 抛错。 */
62
+ function packageJsonMeaningfulDiff(oldBuf, newBuf) {
63
+ const a = parsePackageJson(oldBuf);
64
+ delete a.version;
65
+ const b = parsePackageJson(newBuf);
66
+ delete b.version;
67
+ return !deepEqual(a, b);
68
+ }
69
+ function exportClientTarget(pkg) {
70
+ const exports = pkg.exports;
71
+ const target = exports?.['./client'];
72
+ return typeof target === 'string' ? normalizeTarget(target) : null;
73
+ }
74
+ function patchTarget(pkg) {
75
+ const dsh = pkg.dsh;
76
+ const patch = dsh?.bundle?.patch;
77
+ return typeof patch === 'string' ? normalizeTarget(patch) : null;
78
+ }
79
+ /** 规则引擎(五条,见 ADR-0007);输入已解包归一,异常由调用方 fail-open。 */
80
+ function classifyDiff(oldFiles, newFiles) {
81
+ const oldPkgRaw = oldFiles.get('package.json');
82
+ const newPkgRaw = newFiles.get('package.json');
83
+ const oldPkg = parsePackageJson(oldPkgRaw);
84
+ const newPkg = parsePackageJson(newPkgRaw);
85
+ const clientOld = exportClientTarget(oldPkg);
86
+ const clientNew = exportClientTarget(newPkg);
87
+ if (clientOld !== clientNew)
88
+ return 'restart-required'; // 规则 5:client 指向变化保守判宿主
89
+ const patchOld = patchTarget(oldPkg);
90
+ const patchNew = patchTarget(newPkg);
91
+ const names = new Set([...oldFiles.keys(), ...newFiles.keys()]);
92
+ for (const name of names) {
93
+ const o = oldFiles.get(name);
94
+ const n = newFiles.get(name);
95
+ if (o && n && sha256(o) === sha256(n))
96
+ continue;
97
+ if (name === 'package.json') {
98
+ // 规则 3:仅顶层 version 差异不算宿主侧;其余字段差异算
99
+ if (packageJsonMeaningfulDiff(oldPkgRaw, newPkgRaw))
100
+ return 'restart-required';
101
+ continue;
102
+ }
103
+ if (name === clientNew || name === clientOld)
104
+ continue; // 规则 1/4:client 集合内
105
+ if (name === patchNew || name === patchOld)
106
+ return 'restart-required'; // 规则 2:强调项(被规则 4 覆盖)
107
+ return 'restart-required'; // 规则 4:其余一切差异归宿主侧
108
+ }
109
+ return 'client-only';
110
+ }
111
+ /**
112
+ * 生效判定主入口:拉取 from/to 两版元数据与 tarball(并行),解包 diff 后分类。
113
+ * 任何异常返回 'unknown'(含被 opts.signal 外部中止)——调用方无需 try/catch。
114
+ */
115
+ export async function classifyUpgradeActivation(pkg, fromVersion, toVersion, opts = {}, deps = {}) {
116
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
117
+ const maxBytes = opts.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES;
118
+ const ac = new AbortController();
119
+ const onOuterAbort = () => ac.abort(opts.signal?.reason);
120
+ if (opts.signal) {
121
+ if (opts.signal.aborted)
122
+ onOuterAbort();
123
+ else
124
+ opts.signal.addEventListener('abort', onOuterAbort, { once: true });
125
+ }
126
+ const timer = setTimeout(() => ac.abort(new Error('生效判定超时')), timeoutMs);
127
+ try {
128
+ const fetchVersionMeta = deps.fetchVersionMeta ??
129
+ (async (p, v, signal) => ({ tarball: (await npmVersion(p, v, timeoutMs, signal)).tarball }));
130
+ const fetchTarball = deps.fetchTarball ??
131
+ (async (url, max, signal) => (await fetchBytesLimited(url, { maxBytes: max, timeoutMs, signal })).bytes);
132
+ const [oldMeta, newMeta] = await Promise.all([
133
+ fetchVersionMeta(pkg, fromVersion, ac.signal),
134
+ fetchVersionMeta(pkg, toVersion, ac.signal),
135
+ ]);
136
+ if (!oldMeta?.tarball || !newMeta?.tarball)
137
+ return 'unknown';
138
+ const [oldTar, newTar] = await Promise.all([
139
+ fetchTarball(oldMeta.tarball, maxBytes, ac.signal),
140
+ fetchTarball(newMeta.tarball, maxBytes, ac.signal),
141
+ ]);
142
+ return classifyDiff(fileMap(oldTar), fileMap(newTar));
143
+ }
144
+ catch {
145
+ return 'unknown';
146
+ }
147
+ finally {
148
+ clearTimeout(timer);
149
+ if (opts.signal)
150
+ opts.signal.removeEventListener('abort', onOuterAbort);
151
+ }
152
+ }
153
+ /** CLI 行文案(tools/GUI 有各自的专用措辞;此处只服务 cli.ts upgrade case)。 */
154
+ export function upgradeEffectLine(activation) {
155
+ if (activation === 'client-only')
156
+ return '纯客户端更新:刷新页面即生效,无需重启';
157
+ if (activation === 'unknown')
158
+ return '生效判定未完成;建议重启 DSH Web 生效:dshm restart --yes';
159
+ return '需要重启 DSH Web 生效:dshm restart --yes';
160
+ }
@@ -385,15 +385,43 @@ export function createApiDispatcher(ctx) {
385
385
  // 0.9.5 双 profile 读路径接线(实机回归 2026-10-01):desktop 下已装列表此前
386
386
  // 漏传 profile → 落回 webProfileDir,已装页恒显「web profile 尚未安装任何插件」。
387
387
  // 与 tools.ts dshm_list 同款接线(profile.dir 单一事实源)。
388
+ // 两段加载(ADR-0008):body.probe === false → 跳过探测段(面板第一段快列表);
389
+ // 缺省 'full'——工具/CLI/旧客户端行为不变。
388
390
  const result = await d.listInstalledWithMeta(cfg(), {
389
391
  namespace: 'host',
390
392
  profileDir: profile.dir,
391
393
  profile: profile.name,
394
+ probeMode: body.probe === false ? 'none' : 'full',
392
395
  signal,
393
396
  });
394
397
  payload = { ...result };
395
398
  break;
396
399
  }
400
+ case 'installedUpdates': {
401
+ // 两段加载(ADR-0008)第二段:probeMode 'only' → ttlMin=0 永远新鲜(先删共享缓存条目再重探)。
402
+ // 响应只保留更新字段且含全部已装项(latestError 项也在——client 的「检查未完成」依赖它),
403
+ // 不携带 items/others 全量负载。
404
+ await ctx.controller.ensureReady();
405
+ const result = await d.listInstalledWithMeta(cfg(), {
406
+ namespace: 'host',
407
+ profileDir: profile.dir,
408
+ profile: profile.name,
409
+ probeMode: 'only',
410
+ signal,
411
+ });
412
+ payload = {
413
+ updates: result.items.map((it) => ({
414
+ pkg: it.pkg,
415
+ latestVersion: it.latestVersion ?? null,
416
+ latestTag: it.latestTag ?? null,
417
+ latestSha: it.latestSha ?? null,
418
+ outdated: it.outdated,
419
+ latestError: it.latestError ?? null,
420
+ latestErrorCode: it.latestErrorCode ?? null,
421
+ })),
422
+ };
423
+ break;
424
+ }
397
425
  case 'readme': {
398
426
  const target = strArg(body, 'pkg');
399
427
  if (!target)
@@ -22,6 +22,7 @@ import { COMMUNITY_CATEGORY_LABELS, fetchCommunityCatalog as defaultFetchCommuni
22
22
  import { adaptCommunityCatalog } from './community-adapter.js';
23
23
  import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
24
24
  import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
25
+ import { classifyUpgradeActivation } from './activation.js';
25
26
  import { HttpError } from './httpx.js';
26
27
  import { ensureLatestCacheSwept, invalidateLatestCache, latestCacheKey, latestItemId, readLatestCache, writeLatestCache } from './latest-cache.js';
27
28
  import { verifyInstalledAdditions } from './install-guard.js';
@@ -636,9 +637,14 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
636
637
  }
637
638
  const registryState = stateOf(loaded);
638
639
  matchInstalled(items, community.merged);
639
- // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
640
- await ensureLatestCacheSwept({ namespace, profile: opts.profile });
641
- await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
640
+ // 两段加载(ADR-0008):probeMode 控制探测段——缺省 'full' 行为不变;'none' 面板快列表跳过探测;
641
+ // 'only' 面板第二段 ttlMin=0 永远新鲜(readLatestCache(key,0) 先删共享条目再重探:host ns 下浏览页/
642
+ // 工具的 TTL 命中被刷新为更新值,预算消耗速率上升、上限不变,超限走 latestError 降级)。
643
+ if ((opts.probeMode ?? 'full') !== 'none') {
644
+ // 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
645
+ await ensureLatestCacheSwept({ namespace, profile: opts.profile });
646
+ await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: opts.probeMode === 'only' ? 0 : Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
647
+ }
642
648
  return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
643
649
  }
644
650
  /** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
@@ -1301,7 +1307,20 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1301
1307
  // 0.5.1 修复:直调 installEntryLocked——upgradePlugin 已在 mutation session 区间内,
1302
1308
  // 再经 installEntry 二次获取 session 会自死锁(session 非重入,见 withMutationSession 契约)。
1303
1309
  const result = await installEntryLocked(entry, cfg, opts, deps);
1304
- return { ...result, fromVersion: target.version };
1310
+ // 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
1311
+ let activation;
1312
+ let needsRestart = result.needsRestart;
1313
+ if (entry.source === 'npm' && entry.npm && target.version && result.version) {
1314
+ try {
1315
+ activation = await (deps?.classifyActivation ?? classifyUpgradeActivation)(entry.npm, target.version, result.version);
1316
+ if (activation === 'client-only')
1317
+ needsRestart = false;
1318
+ }
1319
+ catch {
1320
+ activation = 'unknown';
1321
+ }
1322
+ }
1323
+ return { ...result, fromVersion: target.version, needsRestart, ...(activation ? { activation } : {}) };
1305
1324
  }
1306
1325
  /**
1307
1326
  * 社区条目按收录 id 查找(0.7.1 安装修复):合并市场展示的社区条目 id = 社区目录合成 id
@@ -28,6 +28,7 @@ import { adaptCommunityCatalog } from './community-adapter.js';
28
28
  import { listInstalledPlugins as defaultListInstalled } from './installed.js';
29
29
  import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
30
30
  import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
31
+ import { classifyUpgradeActivation } from './activation.js';
31
32
  import { togglePlugin, ToggleError } from './toggle.js';
32
33
  import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
33
34
  // 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
@@ -406,6 +407,19 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
406
407
  }
407
408
  throw err;
408
409
  }
410
+ // 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
411
+ let activation;
412
+ let needsRestart = true;
413
+ if (entry.source === 'npm' && entry.npm && target.version && version !== undefined) {
414
+ try {
415
+ activation = await (deps.classifyActivation ?? classifyUpgradeActivation)(entry.npm, target.version, version);
416
+ if (activation === 'client-only')
417
+ needsRestart = false;
418
+ }
419
+ catch {
420
+ activation = 'unknown';
421
+ }
422
+ }
409
423
  return {
410
424
  id: entry.id,
411
425
  pkg,
@@ -417,10 +431,11 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
417
431
  fallbackAllBuilds: false,
418
432
  ...(compat !== null ? { compat } : {}),
419
433
  ...(compatSkipped ? { compatSkipped } : {}),
420
- needsRestart: true,
434
+ needsRestart,
421
435
  output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
422
436
  via: 'desktop-manager',
423
437
  ...(change.application === 'overridden' ? { overridden: true } : {}),
424
438
  fromVersion: target.version,
439
+ ...(activation ? { activation } : {}),
425
440
  };
426
441
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ustar/pax 只读解析器(0.9.22 生效判定专用):把(已解压的)tar 字节流解析为
3
+ * 「条目路径 → 内容」的 Map。支持:常规文件('0'/NUL)、目录('5',跳过)、
4
+ * pax 扩展头('x',path= 记录应用于下一条件目)、GNU 长名('L')、全局头('g',跳过)。
5
+ * 任何结构异常(截断、坏 size、校验和不符)抛 Error——调用方 fail-open 到 unknown。
6
+ * 只读不落盘;零第三方依赖(npm-integrity 同款纪律)。npm registry 产出的 tar 足够规整,
7
+ * 不支持 base-256 超长 size 编码。
8
+ */
9
+ const BLOCK = 512;
10
+ function isZeroBlock(block) {
11
+ for (const b of block)
12
+ if (b !== 0)
13
+ return false;
14
+ return true;
15
+ }
16
+ /** 八进制字段:容忍 NUL/space 填充;空串返回 0;非八进制返回 null。 */
17
+ function parseOctal(field) {
18
+ const text = field.toString('utf8');
19
+ const cleaned = text.replace(/[\0 ].*$/, '').trim();
20
+ if (cleaned === '') {
21
+ // 全 NUL/space 的 size 视为 0(目录/空文件常态);完全空白的 checksum 视为非法
22
+ return /^[\0 ]*$/.test(text) && text.includes('\0') ? 0 : null;
23
+ }
24
+ if (!/^[0-7]+$/.test(cleaned))
25
+ return null;
26
+ const value = Number.parseInt(cleaned, 8);
27
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
28
+ }
29
+ function cString(field) {
30
+ const end = field.indexOf(0);
31
+ return field.subarray(0, end === -1 ? field.length : end).toString('utf8');
32
+ }
33
+ /** ustar 头:name(100)@0 + prefix(155)@345(magic 为 ustar 时拼接)。 */
34
+ function entryName(header) {
35
+ const name = cString(header.subarray(0, 100));
36
+ const magic = cString(header.subarray(257, 263));
37
+ const prefix = magic === 'ustar' ? cString(header.subarray(345, 500)) : '';
38
+ return prefix !== '' ? `${prefix}/${name}` : name;
39
+ }
40
+ /** pax 'x' 负载:每行 "%d key=value\n";只关心 path= 记录(取最后一次出现的值)。 */
41
+ function paxPathRecord(content) {
42
+ let found = null;
43
+ for (const line of content.toString('utf8').split('\n')) {
44
+ const m = /^\d+ path=(.*)$/.exec(line);
45
+ if (m)
46
+ found = m[1] ?? null;
47
+ }
48
+ return found;
49
+ }
50
+ /**
51
+ * 解析 tar 字节流。键为条目原始路径(保留 npm 的 `package/` 前缀,归一是调用方职责);
52
+ * 仅收录常规文件;目录与元数据条目不进 Map。
53
+ */
54
+ export function parseTarEntries(tar) {
55
+ const entries = new Map();
56
+ let offset = 0;
57
+ let pendingName = null; // 来自 pax 'x' / GNU 'L'
58
+ while (offset + BLOCK <= tar.length) {
59
+ const header = tar.subarray(offset, offset + BLOCK);
60
+ if (isZeroBlock(header))
61
+ break;
62
+ // 校验和:计算时 chksum 字段(148-156)按 8 个空格计
63
+ let sum = 0;
64
+ for (let i = 0; i < BLOCK; i++)
65
+ sum += i >= 148 && i < 156 ? 0x20 : header[i];
66
+ const stored = parseOctal(header.subarray(148, 156));
67
+ if (stored === null || stored !== sum)
68
+ throw new Error('tar 头校验和不符');
69
+ const size = parseOctal(header.subarray(124, 136));
70
+ if (size === null || size < 0)
71
+ throw new Error('tar size 字段非法');
72
+ const typeflag = String.fromCharCode(header[156] ?? 0x30);
73
+ const contentStart = offset + BLOCK;
74
+ const contentEnd = contentStart + size;
75
+ if (contentEnd > tar.length)
76
+ throw new Error('tar 条目内容截断');
77
+ const content = tar.subarray(contentStart, contentEnd);
78
+ if (typeflag === 'x') {
79
+ const path = paxPathRecord(content);
80
+ if (path !== null)
81
+ pendingName = path;
82
+ }
83
+ else if (typeflag === 'L') {
84
+ pendingName = cString(content);
85
+ }
86
+ else if (typeflag === 'g') {
87
+ // 全局扩展头:整段跳过
88
+ }
89
+ else if (typeflag === '0' || typeflag === '\0') {
90
+ const name = pendingName ?? entryName(header);
91
+ pendingName = null;
92
+ entries.set(name, Buffer.from(content)); // 拷贝,避免 subarray 挂住整块底层 buffer
93
+ }
94
+ else {
95
+ // 目录/链接等其他类型:长名只属于紧贴其后的条目,未消费即弃用(防跨条目错名归属)
96
+ pendingName = null;
97
+ }
98
+ offset = contentEnd + ((BLOCK - (size % BLOCK)) % BLOCK);
99
+ }
100
+ return entries;
101
+ }
package/lib/tools.js CHANGED
@@ -392,7 +392,7 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
392
392
  }));
393
393
  ctx.tools.register(defineTool({
394
394
  name: 'dshm_upgrade',
395
- description: 'Upgrade an installed DSH plugin to the latest version (npm 拉最新精确版 / github 重新锁 HEAD)。pkg 来自 dshm_list 或 dshm_outdated。用户确认升级哪一个之后再调用。After success, tell the user it needs a restart, and offer dshm_restart.',
395
+ description: 'Upgrade an installed DSH plugin to the latest version (npm 拉最新精确版 / github 重新锁 HEAD)。pkg 来自 dshm_list 或 dshm_outdated。用户确认升级哪一个之后再调用。After success follow the result\'s activation field: \'client-only\' → tell the user a page refresh suffices and do NOT offer dshm_restart; \'unknown\' or \'restart-required\' (or the field being absent) → tell the user it needs a restart and offer dshm_restart.',
396
396
  parameters: {
397
397
  pkg: { type: 'string', required: true, description: '包名 from dshm_list / dshm_outdated' },
398
398
  force: { type: 'boolean', description: 'peer 兼容预检不通过且用户已确认风险后置 true(等价 forceIncompatible,跳过 @deepseek-ai/dsh(-*) peer 拦截);守卫拦截(guard)无 force 通道,不受此参数影响。' },
@@ -577,7 +577,7 @@ function renderOutdated(out) {
577
577
  head.push('询问用户要升级哪个,确认后调 dshm_upgrade(pkg)。');
578
578
  return head.join('\n');
579
579
  }
580
- function renderUpgrade(out) {
580
+ export function renderUpgrade(out) {
581
581
  // 0.8.5 修复:升级工具会命中装后守卫拦截(复用 installEntry,link/file prior 不可回退等),
582
582
  // execute 返回 guard 形态结果——旧 render 没有该分支,把拦截渲染成「✅ undefined 已升级」假成功。
583
583
  if (out.guard === true) {
@@ -592,7 +592,13 @@ function renderUpgrade(out) {
592
592
  const from = out.fromVersion ? `v${out.fromVersion} → ` : '';
593
593
  const to = out.version ? `v${out.version}` : out.sha ? out.sha.slice(0, 7) : '最新';
594
594
  const extra = buildsNote(out);
595
- return `✅ ${out.pkg} 已升级(${from}${to})。${extra}需要重启生效——询问是否 dshm_restart。`;
595
+ // 0.9.22 生效判定三态分流:client-only 明确不问重启;unknown 保守建议重启;其余现状
596
+ const tail = out.activation === 'client-only'
597
+ ? '纯客户端更新:刷新页面即可生效,无需重启——不要询问 dshm_restart。'
598
+ : out.activation === 'unknown'
599
+ ? '生效判定未完成(网络或解析失败):为确保生效建议重启——询问是否 dshm_restart。'
600
+ : '需要重启生效——询问是否 dshm_restart。';
601
+ return `✅ ${out.pkg} 已升级(${from}${to})。${extra}${tail}`;
596
602
  }
597
603
  function clamp(n, min, max) {
598
604
  return Math.min(max, Math.max(min, n));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",