dsh-my-guardian 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.6] - 2026-09-04
9
+
10
+ ### 变更
11
+
12
+ - fix(guardian): 事件日志不再访问 entry.id getter——构造期 parent.tree 未就绪会抛错炸掉 DSH 启动(隔离实例复现)
13
+ - fix(guardian): 诊断面板可读性 + entry 标识修复——插件失败时不再"看不懂/兜不住"
14
+ - fix(guardian): #86 发版一致性修复——package.json 0.3.5 + CHANGELOG 0.3.5 段(rebase 冲突还原恢复)
15
+ - chore(release): dsh-my-guardian v0.3.5(#86 依赖预检 + 验证清单)
16
+ - feat(guardian): #86 候选区插件依赖预检 + 失败分类 (#124)
17
+ - docs: #106 安装命令统一加 --trust-lockfile (#113)
18
+
19
+ ## [0.3.5] - 2026-09-04
20
+
21
+ ### 新增
22
+
23
+ - [#86](https://github.com/baosfeng/my-dsh-plugins/issues/86) 候选区插件依赖预检 + 失败分类:热挂载前校验依赖就绪,失败按类别诊断(缺失依赖/锁定冲突/加载错误),诊断面板展示分类详情
24
+
25
+ ### 变更
26
+
27
+ - 验证清单与效果图同步(发版前功能级验证归档)
28
+
8
29
  ## [0.3.4] - 2026-09-02
9
30
 
10
31
  ### 变更
package/README.md CHANGED
@@ -13,23 +13,24 @@ cordis.patch.yml(核心区:只放稳定插件,守护插件自身)
13
13
  ↓ 新插件写进
14
14
  cordis.staged.json(候选区)
15
15
  ↓ DSH 启动完成后
16
- 守护插件逐个热挂载
16
+ 守护插件逐个依赖预检 + 热挂载
17
17
  ├─ 成功 → 自动转正(进入持久化清单,每次启动自动恢复)
18
- └─ 失败 → 自动隔离(记录次数+错误,连续 3 次冻结)
18
+ └─ 失败 → 自动隔离(记录次数 + 失败类型/错误,连续 3 次冻结)
19
19
  ```
20
20
 
21
21
  ## 功能
22
22
 
23
23
  - **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
24
- - **失败自动隔离**:挂载失败自动记录(尝试次数 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
24
+ - **失败自动隔离**:挂载失败自动记录(尝试次数 + 失败类型 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
25
+ - **挂载前依赖预检**:候选插件挂载前检查 `peerDependencies`——仓库内 `dsh-*` 依赖是否安装、官方依赖版本是否满足;缺失/不满足即标记「依赖缺失」并给出安装建议(如 `dsh plugin add <依赖>`),不进入挂载。
25
26
  - **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
26
27
  - **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
27
- - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关 / 最近事件。
28
+ - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
28
29
  - **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
29
30
 
30
31
  ## 安装
31
32
 
32
- > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
33
+ > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
33
34
 
34
35
  ### 方式一:dsh plugin(推荐)
35
36
 
@@ -83,6 +84,8 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
83
84
  | 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
84
85
  | 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
85
86
 
87
+ 失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
88
+
86
89
  ### 效果截图(真实 DSH 实例验证)
87
90
 
88
91
  侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
@@ -91,7 +94,7 @@ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
91
94
 
92
95
  ![失败自动隔离:错误详情可查](https://unpkg.com/dsh-my-guardian/assets/panel-error-detail.png)
93
96
 
94
- > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3081`,端口 3081)。候选区同时写入 `demo-plugin`(挂载成功 → 自动转正"运行中")与 `dsh-no-such-plugin-xyz`(包不存在 → 挂载失败自动隔离 ×1,错误详情保留可查)。
97
+ > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3085`,端口 3085)。候选区写入 `fake-needy`(peer 依赖 `fake-never-installed-dep` 缺失 → 依赖预检拦截,分类徽标「依赖缺失」+ 安装建议 `dsh plugin add fake-never-installed-dep` + 自动隔离冻结)与 `fake-simple`(无依赖 → 热挂载成功自动转正「运行中」)。
95
98
 
96
99
  ## 配置
97
100
 
package/lib/api.js CHANGED
@@ -155,45 +155,37 @@ async function handleSafemodePost(ctx, shared, request, response) {
155
155
  writeJson(response, 200, { ok: true, value: shared.snapshot() })
156
156
  }
157
157
 
158
+ /** One row for the panel: status + failure-classification fields (issue #86). */
159
+ function entrySnapshot(shared, id, record, isStaged) {
160
+ const status = shared.mounted.has(id)
161
+ ? 'running'
162
+ : record.frozen
163
+ ? 'frozen'
164
+ : record.attempts > 0
165
+ ? 'failed'
166
+ : 'pending'
167
+ const item = {
168
+ id,
169
+ name: record.name,
170
+ attempts: record.attempts,
171
+ frozen: record.frozen,
172
+ lastError: record.lastError,
173
+ lastFailedAt: record.lastFailedAt,
174
+ failureType: record.failureType ?? null,
175
+ missingDeps: record.missingDeps ?? [],
176
+ installHint: record.installHint ?? null,
177
+ status,
178
+ }
179
+ if (!isStaged) item.promotedAt = record.promotedAt
180
+ return item
181
+ }
182
+
158
183
  /** Snapshot for the panel (leaf values only). */
159
184
  function snapshot(shared) {
160
- const stagedList = []
161
- for (const [id, record] of Object.entries(shared.state.staged)) {
162
- stagedList.push({
163
- id,
164
- name: record.name,
165
- attempts: record.attempts,
166
- frozen: record.frozen,
167
- lastError: record.lastError,
168
- lastFailedAt: record.lastFailedAt,
169
- status: shared.mounted.has(id)
170
- ? 'running'
171
- : record.frozen
172
- ? 'frozen'
173
- : record.attempts > 0
174
- ? 'failed'
175
- : 'pending',
176
- })
177
- }
178
- const promotedList = []
179
- for (const [id, record] of Object.entries(shared.state.promoted)) {
180
- promotedList.push({
181
- id,
182
- name: record.name,
183
- attempts: record.attempts,
184
- frozen: record.frozen,
185
- lastError: record.lastError,
186
- lastFailedAt: record.lastFailedAt,
187
- promotedAt: record.promotedAt,
188
- status: shared.mounted.has(id)
189
- ? 'running'
190
- : record.frozen
191
- ? 'frozen'
192
- : record.attempts > 0
193
- ? 'failed'
194
- : 'pending',
195
- })
196
- }
185
+ const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true))
186
+ const promotedList = Object.entries(shared.state.promoted).map(([id, record]) =>
187
+ entrySnapshot(shared, id, record, false),
188
+ )
197
189
  return {
198
190
  safeMode: shared.state.safeMode,
199
191
  staged: stagedList,
package/lib/client.js CHANGED
@@ -107,8 +107,22 @@ const STYLES = `
107
107
  .dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
108
108
  .dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
109
109
  .dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
110
+ /* failure-classification badge chips (issue #86): dependency / code / other */
111
+ .dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
112
+ font:var(--dsw-font-xxxs-strong-11); }
113
+ .dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
114
+ .dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
115
+ .dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
116
+ /* install-suggestion line for dependency failures */
117
+ .dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
118
+ background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
119
+ font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
120
+ .dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
121
+ .dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
122
+ color:var(--dsw-alias-state-success-primary); word-break:break-all; }
110
123
  .dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
111
124
  .dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
125
+ .dsh-my-guardian-freeze-hint { font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-state-warn-primary); line-height:1.6; }
112
126
  .dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
113
127
  font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
114
128
  transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
@@ -186,8 +200,8 @@ const strings = {
186
200
  safeMode: () => (isZh() ? '安全模式' : 'Safe mode'),
187
201
  safeModeDesc: () =>
188
202
  isZh()
189
- ? '开启后所有候选/已转正插件都不再加载,用于快速恢复环境'
190
- : 'Skips every staged/promoted plugin mount — fast recovery',
203
+ ? '开启后所有候选/已转正插件(候选区 cordis.staged.json 中的条目)都不再加载,用于快速恢复环境。注意:安全模式只作用于启动后挂载的候选插件;若插件直接写进启动名册(cordis.patch.yml / profile),DSH 启动时仍是 all-or-nothing——请把新插件先放进候选区。'
204
+ : 'Skips every staged/promoted plugin mount — fast recovery. Note: this only covers entries mounted after boot (cordis.staged.json); plugins in the boot roster (cordis.patch.yml / profile) are still all-or-nothing — put new plugins in the staged file first.',
191
205
  staged: () => (isZh() ? '候选' : 'staged'),
192
206
  promoted: () => (isZh() ? '转正' : 'promoted'),
193
207
  entries: () => (isZh() ? '插件条目' : 'Plugin entries'),
@@ -215,6 +229,14 @@ const strings = {
215
229
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
216
230
  events: () => (isZh() ? '最近事件' : 'Recent events'),
217
231
  attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
232
+ frozenHint: () =>
233
+ isZh()
234
+ ? '已冻结:连续失败停止自动重试——点击刷新按钮手动重试,或移除该条目'
235
+ : 'Frozen: auto-retry stopped after repeated failures — retry manually or remove the entry',
236
+ failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
237
+ failureCode: () => (isZh() ? '代码错误' : 'Code error'),
238
+ failureOther: () => (isZh() ? '其他' : 'Other'),
239
+ installHint: () => (isZh() ? '安装建议' : 'Install'),
218
240
  }
219
241
 
220
242
  // ── api ───────────────────────────────────────────────────────────────
@@ -256,6 +278,20 @@ function statusLabel(status) {
256
278
  }
257
279
  }
258
280
 
281
+ /** Failure-classification badge label (issue #86): dependency / code / other. */
282
+ function failureTypeLabel(type) {
283
+ switch (type) {
284
+ case 'dependency':
285
+ return strings.failureDependency()
286
+ case 'code':
287
+ return strings.failureCode()
288
+ case 'other':
289
+ return strings.failureOther()
290
+ default:
291
+ return type
292
+ }
293
+ }
294
+
259
295
  // ── event log ─────────────────────────────────────────────────────────
260
296
  // Event type → badge label + color variant (mirrors the dfa-op chip style).
261
297
  const EVENT_LABELS = {
@@ -442,6 +478,27 @@ const icon = {
442
478
  ],
443
479
  size,
444
480
  ),
481
+ // 下载(issue #85 新增):箭头入托盘,图表导出按钮(dsh-mermaid-render
482
+ // 卡片下载 PNG/SVG),stroke=currentColor 风格与其余图标一致。
483
+ download: (size = 16) =>
484
+ iconSvg(
485
+ [
486
+ createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
487
+ createElement('polyline', { points: '7 10 12 15 17 10' }),
488
+ createElement('line', { x1: 12, y1: 15, x2: 12, y2: 3 }),
489
+ ],
490
+ size,
491
+ ),
492
+ // 复制(issue #85 新增):双层矩形,复制源码按钮(dsh-mermaid-render
493
+ // 卡片复制代码),stroke=currentColor 风格与其余图标一致。
494
+ copy: (size = 16) =>
495
+ iconSvg(
496
+ [
497
+ createElement('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
498
+ createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
499
+ ],
500
+ size,
501
+ ),
445
502
  }
446
503
 
447
504
  // Common-language / file-type badges (issue #24): brand fill + contrast
@@ -599,7 +656,7 @@ const fileIconByExt = (ext, size = 14) => {
599
656
  }
600
657
 
601
658
  // ── row ────────────────────────────────────────────────────────────────
602
- /** Row head: source chip + name + status badge. */
659
+ /** Row head: source chip + name + status badge + failure-category badge. */
603
660
  function RowHead({ entry, source }) {
604
661
  return createElement(
605
662
  'div',
@@ -615,6 +672,16 @@ function RowHead({ entry, source }) {
615
672
  { className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
616
673
  statusLabel(entry.status),
617
674
  ),
675
+ typeof entry.failureType === 'string' && entry.failureType !== ''
676
+ ? createElement(
677
+ 'span',
678
+ {
679
+ className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
680
+ title: entry.failureType,
681
+ },
682
+ failureTypeLabel(entry.failureType),
683
+ )
684
+ : null,
618
685
  )
619
686
  }
620
687
 
@@ -722,11 +789,26 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
722
789
  )
723
790
  }
724
791
 
792
+ /** 冻结行提示(连败停止自动重试,需手动操作)。 */
793
+ function FrozenHint({ status }) {
794
+ if (status !== 'frozen') return null
795
+ return createElement('div', { className: 'dsh-my-guardian-freeze-hint' }, strings.frozenHint())
796
+ }
797
+
725
798
  function EntryRow({ entry, source, onAction }) {
726
- const [expanded, setExpanded] = useState(false)
799
+ // 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
800
+ const [expanded, setExpanded] = useState(
801
+ () =>
802
+ typeof entry.lastError === 'string' &&
803
+ entry.lastError !== '' &&
804
+ (entry.status === 'failed' || entry.status === 'frozen'),
805
+ )
727
806
  const [confirming, setConfirming] = useState(false)
728
807
  const [busy, setBusy] = useState(false)
729
808
  const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
809
+ const isDepFailure = entry.failureType === 'dependency'
810
+ const installHint =
811
+ isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
730
812
 
731
813
  const run = (kind) => {
732
814
  setBusy(true)
@@ -738,6 +820,15 @@ function EntryRow({ entry, source, onAction }) {
738
820
  { className: 'dsh-my-guardian-row' },
739
821
  createElement(RowHead, { entry, source }),
740
822
  createElement(RowMeta, { entry }),
823
+ createElement(FrozenHint, { status: entry.status }),
824
+ installHint
825
+ ? createElement(
826
+ 'div',
827
+ { className: 'dsh-my-guardian-install-hint' },
828
+ createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
829
+ createElement('code', null, installHint),
830
+ )
831
+ : null,
741
832
  hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
742
833
  expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
743
834
  confirming
@@ -884,14 +975,19 @@ function EntryList({ rows, onAction }) {
884
975
  )
885
976
  }
886
977
 
887
- /** Recent guardian event log: badge + key info + time per entry. */
978
+ /** 高频噪音事件:每次启动/热重载都会大量产生,挤掉真正重要的诊断信息。 */
979
+ const EVENT_NOISE = new Set(['entry-init', 'entry-dispose'])
980
+
981
+ /** Recent guardian event log: badge + key info + time per entry.
982
+ * 过滤 entry-init/entry-dispose 噪音,优先展示隔离/冻结/更新失败等关键事件。 */
888
983
  function EventList({ events }) {
889
- if (events.length === 0) return null
984
+ const important = events.filter((event) => !EVENT_NOISE.has(event.type))
985
+ if (important.length === 0) return null
890
986
  return createElement(
891
987
  'div',
892
988
  { className: 'dsh-my-guardian-events' },
893
989
  createElement('div', { className: 'dsh-my-guardian-events-title' }, icon.clock(14), strings.events()),
894
- events.map((event, index) =>
990
+ important.map((event, index) =>
895
991
  createElement(
896
992
  'div',
897
993
  {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * dsh-my-guardian — dependency pre-check for the candidate mount pipeline.
3
+ *
4
+ * Reads a candidate plugin's package.json peerDependencies (from the profile
5
+ * node_modules) and verifies each dependency is installed and version-satisfying
6
+ * BEFORE the plugin is mounted. A hard-missing dependency is reported as a
7
+ * pre-check failure (failureType 'dependency') with an install suggestion, and
8
+ * the mount is skipped — the plugin never enters the runtime load path with a
9
+ * hole in its dependency graph (issue #72: dsh-shared was not published).
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { satisfies } from './dep-version.js'
14
+
15
+ // Locate a package directory below a node_modules root, following symlinks
16
+ // (pnpm store / npm link both expose package.json through the mirrored dir).
17
+ function findModuleDir(nmRoot, packageName) {
18
+ const dir = join(nmRoot, packageName)
19
+ return existsSync(join(dir, 'package.json')) ? dir : null
20
+ }
21
+
22
+ // Resolve a dependency from the plugin's nested node_modules or the profile
23
+ // node_modules (hoisted installs). Returns the dir or null when absent.
24
+ function resolveDependencyDir(profileDir, pluginDir, dep) {
25
+ const nested = pluginDir === null ? null : findModuleDir(join(pluginDir, 'node_modules'), dep)
26
+ if (nested !== null) return nested
27
+ return findModuleDir(join(profileDir, 'node_modules'), dep)
28
+ }
29
+
30
+ function readPackageJson(dir) {
31
+ if (dir === null) return null
32
+ try {
33
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ function installedVersion(dir) {
40
+ const pkg = readPackageJson(dir)
41
+ return pkg !== null && typeof pkg.version === 'string' ? pkg.version : null
42
+ }
43
+
44
+ // Inspect a single peer dependency and classify the outcome.
45
+ function examinePeer(dep, range, optional, pluginDir, profileDir) {
46
+ const depDir = resolveDependencyDir(profileDir, pluginDir, dep)
47
+ if (depDir === null) {
48
+ if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 缺失(未安装)` }
49
+ return { kind: 'missing', name: dep }
50
+ }
51
+ const version = installedVersion(depDir)
52
+ if (version !== null && typeof range === 'string' && range.trim() !== '' && !satisfies(version, range)) {
53
+ const issue = { name: dep, expected: range, found: version }
54
+ if (optional) return { kind: 'warn', message: `可选依赖 ${dep} 版本不满足:${range}(当前 ${version})` }
55
+ return { kind: 'mismatch', issue }
56
+ }
57
+ return { kind: 'ok' }
58
+ }
59
+
60
+ function buildSuggestions(missing, mismatched) {
61
+ const suggestions = missing.map((dep) => `dsh plugin add ${dep}`)
62
+ for (const item of mismatched) suggestions.push(`dsh plugin add ${item.name}@${item.expected}`)
63
+ return suggestions
64
+ }
65
+
66
+ function objectOrEmpty(value) {
67
+ return value ?? {}
68
+ }
69
+
70
+ // Group every peer into missing / mismatched / warning buckets.
71
+ function classifyPeers(peers, meta, pluginDir, profileDir) {
72
+ const missing = []
73
+ const mismatched = []
74
+ const warnings = []
75
+ for (const [dep, range] of Object.entries(peers)) {
76
+ const result = examinePeer(dep, range, meta[dep]?.optional === true, pluginDir, profileDir)
77
+ if (result.kind === 'missing') missing.push(result.name)
78
+ else if (result.kind === 'mismatch') mismatched.push(result.issue)
79
+ else if (result.kind === 'warn') warnings.push(result.message)
80
+ }
81
+ return { missing, mismatched, warnings }
82
+ }
83
+
84
+ function skippedResult(reason) {
85
+ return { ok: true, missing: [], mismatched: [], suggestions: [], warnings: [`跳过依赖预检:${reason}`] }
86
+ }
87
+
88
+ /**
89
+ * Pre-check the peer dependencies of a candidate plugin. Returns:
90
+ * { ok, missing, mismatched, suggestions, warnings }
91
+ * - missing: deps required (not optional) but absent from node_modules
92
+ * - mismatched: deps present at a version outside the declared range
93
+ * - suggestions: `dsh plugin add ...` repair commands
94
+ * - warnings: non-blocking notes (plugin unreadable / optional peers missing)
95
+ * When the plugin or its package.json cannot be located the check is skipped
96
+ * (ok: true) so an unusual install layout is never a false block.
97
+ */
98
+ export function checkPeerDependencies({ profileDir, pluginName }) {
99
+ const pluginDir = findModuleDir(join(profileDir, 'node_modules'), pluginName)
100
+ if (pluginDir === null)
101
+ return skippedResult(`无法定位插件 ${pluginName}(未在 profile node_modules 找到 package.json)`)
102
+ const pkg = readPackageJson(pluginDir)
103
+ if (pkg === null) return skippedResult(`无法解析 ${pluginName} 的 package.json`)
104
+ const { missing, mismatched, warnings } = classifyPeers(
105
+ objectOrEmpty(pkg.peerDependencies),
106
+ objectOrEmpty(pkg.peerDependenciesMeta),
107
+ pluginDir,
108
+ profileDir,
109
+ )
110
+ return {
111
+ ok: missing.length === 0 && mismatched.length === 0,
112
+ missing,
113
+ mismatched,
114
+ suggestions: buildSuggestions(missing, mismatched),
115
+ warnings,
116
+ }
117
+ }
118
+
119
+ /** Build the "缺少依赖 X(请先安装)" message recorded for a failed pre-check. */
120
+ export function buildDependencyMessage(result) {
121
+ const names = [...result.missing, ...result.mismatched.map((item) => item.name)]
122
+ if (names.length === 0) return '依赖预检失败'
123
+ return names.map((name) => `缺少依赖 ${name}(请先安装)`).join(';')
124
+ }
125
+
126
+ /** Classify a mount failure for the isolation record (issue #86). */
127
+ export function classifyFailure(error) {
128
+ const message = error instanceof Error ? error.message : String(error)
129
+ if (/Cannot find module|MODULE_NOT_FOUND|Cannot resolve/i.test(message)) return 'dependency'
130
+ if (/already exists|already in use|conflict/i.test(message)) return 'other'
131
+ return 'code'
132
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * dsh-my-guardian — minimal semver helpers for the dependency pre-check.
3
+ *
4
+ * Only the subset needed to validate peerDependency ranges is implemented:
5
+ * parse a version, compare two versions (with prerelease precedence), and test
6
+ * whether a version satisfies a range (`^`, `~`, comparator operators,
7
+ * wildcards, `||` and whitespace-AND). Build metadata (`+build`) is ignored for
8
+ * precedence. Everything is pure and dependency-free.
9
+ */
10
+
11
+ // Parse a version string into { major, minor, patch, prerelease }.
12
+ // Returns null for input that is not a usable version.
13
+ function parseVersion(input) {
14
+ if (typeof input !== 'string') return null
15
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim())
16
+ if (match === null) return null
17
+ return {
18
+ major: Number(match[1]),
19
+ minor: Number(match[2] ?? 0),
20
+ patch: Number(match[3] ?? 0),
21
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
22
+ }
23
+ }
24
+
25
+ // Numeric identifiers have lower precedence than alphanumeric ones.
26
+ function compareIdentifiers(a, b) {
27
+ const aNum = /^\d+$/.test(a)
28
+ const bNum = /^\d+$/.test(b)
29
+ if (aNum && bNum) return Number(a) - Number(b)
30
+ if (aNum !== bNum) return aNum ? -1 : 1
31
+ return a < b ? -1 : a > b ? 1 : 0
32
+ }
33
+
34
+ // Compare two prerelease arrays (a shorter list has lower precedence).
35
+ function comparePrerelease(ap, bp) {
36
+ if (ap.length === 0 && bp.length === 0) return 0
37
+ if (ap.length === 0) return 1
38
+ if (bp.length === 0) return -1
39
+ const len = Math.max(ap.length, bp.length)
40
+ for (let i = 0; i < len; i++) {
41
+ const ai = ap[i]
42
+ const bi = bp[i]
43
+ if (ai === undefined) return -1
44
+ if (bi === undefined) return 1
45
+ const diff = compareIdentifiers(ai, bi)
46
+ if (diff !== 0) return diff
47
+ }
48
+ return 0
49
+ }
50
+
51
+ // Compare two parsed version tuples (major/minor/patch/prerelease).
52
+ // Returns negative/zero/positive like Array#sort.
53
+ function compareParsed(a, b) {
54
+ for (const key of ['major', 'minor', 'patch']) {
55
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
56
+ }
57
+ return comparePrerelease(a.prerelease, b.prerelease)
58
+ }
59
+
60
+ // Parse a range version spec that may contain wildcards (`x`/`*`) and a
61
+ // trailing prerelease (`1.2.3-rc.8`). Missing/padded components are null.
62
+ function parseRangeVersion(spec) {
63
+ const match = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?$/.exec(spec.trim())
64
+ if (match === null) return null
65
+ const toNum = (value) => (value !== undefined && /^\d+$/.test(value) ? Number(value) : null)
66
+ return {
67
+ major: toNum(match[1]),
68
+ minor: match[2] === undefined ? null : toNum(match[2]),
69
+ patch: match[3] === undefined ? null : toNum(match[3]),
70
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
71
+ }
72
+ }
73
+
74
+ // Lower bound tuple for a caret/tilde range base (wildcards → 0).
75
+ function lowerBound(base) {
76
+ return {
77
+ major: base.major ?? 0,
78
+ minor: base.minor ?? 0,
79
+ patch: base.patch ?? 0,
80
+ prerelease: base.prerelease,
81
+ }
82
+ }
83
+
84
+ // Upper bound for a caret range: bump the first non-zero component.
85
+ function caretUpper(lo) {
86
+ if (lo.major > 0) return { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
87
+ if (lo.minor > 0) return { major: 0, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
88
+ if (lo.patch > 0) return { major: 0, minor: 0, patch: lo.patch + 1, prerelease: ['0'] }
89
+ return { major: 0, minor: 1, patch: 0, prerelease: ['0'] }
90
+ }
91
+
92
+ function satisfiesCaret(version, spec) {
93
+ const base = parseRangeVersion(spec)
94
+ const parsed = parseVersion(version)
95
+ if (base === null || parsed === null) return false
96
+ const lo = lowerBound(base)
97
+ if (compareParsed(parsed, lo) < 0) return false
98
+ return compareParsed(parsed, caretUpper(lo)) < 0
99
+ }
100
+
101
+ function satisfiesTilde(version, spec) {
102
+ const base = parseRangeVersion(spec)
103
+ const parsed = parseVersion(version)
104
+ if (base === null || parsed === null) return false
105
+ const lo = lowerBound(base)
106
+ if (compareParsed(parsed, lo) < 0) return false
107
+ const upper =
108
+ base.minor === null
109
+ ? { major: lo.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
110
+ : { major: lo.major, minor: lo.minor + 1, patch: 0, prerelease: ['0'] }
111
+ return compareParsed(parsed, upper) < 0
112
+ }
113
+
114
+ // Compare a single range component against the version; wildcard (null) skips.
115
+ function compareComponent(parsed, base, key) {
116
+ if (base[key] === null) return 0
117
+ if (parsed[key] === base[key]) return 0
118
+ return parsed[key] < base[key] ? -1 : 1
119
+ }
120
+
121
+ // Compare a version against a range spec, only over the components that are
122
+ // concrete numbers. Returns null when either side is not a usable version.
123
+ function compareRangeToVersion(version, spec) {
124
+ const base = parseRangeVersion(spec)
125
+ const parsed = parseVersion(version)
126
+ if (base === null || parsed === null) return null
127
+ for (const key of ['major', 'minor', 'patch']) {
128
+ const diff = compareComponent(parsed, base, key)
129
+ if (diff !== 0) return diff
130
+ }
131
+ return 0
132
+ }
133
+
134
+ function satisfiesComparator(version, token) {
135
+ const match = /^(>=|<=|>|<|=|==)?\s*(.+)$/.exec(token.trim())
136
+ if (match === null) return false
137
+ const op = match[1] ?? '='
138
+ const diff = compareRangeToVersion(version, match[2].trim())
139
+ if (diff === null) return false
140
+ if (op === '>') return diff > 0
141
+ if (op === '<') return diff < 0
142
+ if (op === '>=') return diff >= 0
143
+ if (op === '<=') return diff <= 0
144
+ return diff === 0
145
+ }
146
+
147
+ function satisfiesExactOrWildcard(version, token) {
148
+ const base = parseRangeVersion(token)
149
+ const parsed = parseVersion(version)
150
+ if (base === null || parsed === null) return false
151
+ return compareRangeToVersion(version, token) === 0
152
+ }
153
+
154
+ function satisfiesToken(version, token) {
155
+ if (token === '' || token === '*' || token === 'x' || token === 'X') return true
156
+ if (token.startsWith('^')) return satisfiesCaret(version, token.slice(1))
157
+ if (token.startsWith('~')) return satisfiesTilde(version, token.slice(1))
158
+ if (/^(>=|<=|>|<|=|==)/.test(token)) return satisfiesComparator(version, token)
159
+ return satisfiesExactOrWildcard(version, token)
160
+ }
161
+
162
+ // A single alternative group: whitespace-AND of tokens, minus hyphen ranges.
163
+ function satisfiesAll(version, alt) {
164
+ if (alt === '') return true
165
+ const hyphen = alt.split(/\s+-\s+/)
166
+ if (hyphen.length === 2) {
167
+ return satisfies(version, `>=${hyphen[0].trim()}`) && satisfies(version, `<=${hyphen[1].trim()}`)
168
+ }
169
+ return alt
170
+ .split(/\s+/)
171
+ .filter(Boolean)
172
+ .every((token) => satisfiesToken(version, token))
173
+ }
174
+
175
+ /**
176
+ * Test whether `version` satisfies `range` (supports `||`, whitespace-AND and
177
+ * the common comparators). An empty or `*` range matches anything. A prerelease
178
+ * version only matches when the range itself mentions a prerelease — mirroring
179
+ * the semver rule that prereleases are opt-in.
180
+ */
181
+ export function satisfies(version, range) {
182
+ if (typeof version !== 'string' || typeof range !== 'string') return false
183
+ const trimmed = range.trim()
184
+ if (trimmed === '' || trimmed === '*') return true
185
+ const parsed = parseVersion(version)
186
+ if (parsed !== null && parsed.prerelease.length > 0 && !range.includes('-')) return false
187
+ return trimmed.split('||').some((alt) => satisfiesAll(version, alt.trim()))
188
+ }
package/lib/events.js CHANGED
@@ -17,10 +17,10 @@ export function logEvent(shared, type, message) {
17
17
  /** Register the loader/HMR diagnostic listeners (R9/R10). */
18
18
  export function attachEventListeners(ctx, shared) {
19
19
  ctx.on('loader/entry-init', (entry) => {
20
- logEvent(shared, 'entry-init', `entry ${entry?.options?.id ?? '?'} initialized`)
20
+ logEvent(shared, 'entry-init', `entry ${entryLabelOf(entry)} initialized`)
21
21
  })
22
22
  ctx.on('loader/partial-dispose', (entry) => {
23
- logEvent(shared, 'entry-dispose', `entry ${entry?.options?.id ?? '?'} disposed`)
23
+ logEvent(shared, 'entry-dispose', `entry ${entryLabelOf(entry)} disposed`)
24
24
  })
25
25
  ctx.on('hmr/config-update-failed', (filename, error) => {
26
26
  logEvent(shared, 'update-failed', `${filename}: ${error instanceof Error ? error.message : String(error)}`)
@@ -28,3 +28,22 @@ export function attachEventListeners(ctx, shared) {
28
28
  shared.persistSoon()
29
29
  })
30
30
  }
31
+
32
+ /**
33
+ * entry 可读标识——⚠️ 只读 options 字段,绝不访问 `entry.id` getter:
34
+ * loader 在 Entry **构造函数中** emit `loader/entry-init`,此时
35
+ * `parent.tree` 尚未就绪,访问 getter 会抛 "Cannot read properties of
36
+ * undefined (reading 'tree')" —— 这个异常发生在启动阶段,会让整个
37
+ * DSH 服务起不来(守护插件自己炸启动,实锤隔离实例复现)。
38
+ * (此前读 `entry?.options?.id` 安全但恒为空显示 '?';本次改为
39
+ * options.id → options.name → '?' 的回退,全部属性访问都可安全兜底。)
40
+ */
41
+ function entryLabelOf(entry) {
42
+ if (entry === null || typeof entry !== 'object') return '?'
43
+ const options = entry.options
44
+ if (options !== null && typeof options === 'object') {
45
+ if (options.id !== undefined && options.id !== null) return String(options.id)
46
+ if (typeof options.name === 'string' && options.name !== '') return options.name
47
+ }
48
+ return '?'
49
+ }
package/lib/mount.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { dirname } from 'node:path'
10
10
  import { FREEZE_LIMIT, errorSnip, loadState, readStagedFile, writeStagedFile } from './state.js'
11
11
  import { logEvent } from './events.js'
12
+ import { checkPeerDependencies, buildDependencyMessage, classifyFailure } from './dep-precheck.js'
12
13
 
13
14
  /**
14
15
  * Find the root Include tree of the profile. The loader tree's entries carry
@@ -91,16 +92,40 @@ async function mountWithState(shared, kind, id, entry) {
91
92
  const record = shared.state[recordKey][id] ?? {}
92
93
  if (record.frozen) return 'skipped'
93
94
  const name = typeof entry?.name === 'string' ? entry.name : id
95
+ // Dependency pre-check (issue #86): a plugin whose peer deps are missing or
96
+ // out of range must not enter the runtime load path with a hole in its
97
+ // dependency graph. A failed pre-check is recorded as a 'dependency' failure
98
+ // with the missing deps + an install suggestion, and the mount is skipped.
99
+ const precheck = precheckEntry(shared, name)
100
+ if (precheck !== null) {
101
+ recordFailure(shared, recordKey, id, name, entry, record, {
102
+ failureType: 'dependency',
103
+ message: buildDependencyMessage(precheck),
104
+ missingDeps: [...precheck.missing, ...precheck.mismatched.map((item) => item.name)],
105
+ installHint: precheck.suggestions[0] ?? null,
106
+ })
107
+ return 'failed'
108
+ }
94
109
  try {
95
110
  await mount(shared, id, entry)
96
111
  await promote(shared, recordKey, id, name, entry, record)
97
112
  return 'mounted'
98
113
  } catch (error) {
99
- recordFailure(shared, recordKey, id, name, entry, record, error)
114
+ recordFailure(shared, recordKey, id, name, entry, record, {
115
+ failureType: classifyFailure(error),
116
+ message: error instanceof Error ? error.message : String(error),
117
+ })
100
118
  return 'failed'
101
119
  }
102
120
  }
103
121
 
122
+ /** Run the dependency pre-check; null means it passed (or was skipped). */
123
+ function precheckEntry(shared, name) {
124
+ if (typeof name !== 'string' || name === '') return null
125
+ const result = checkPeerDependencies({ profileDir: shared.profileDir, pluginName: name })
126
+ return result.ok ? null : result
127
+ }
128
+
104
129
  /** Success path: staged moves into the persisted promoted list (R2). */
105
130
  async function promote(shared, recordKey, id, name, entry, record) {
106
131
  if (recordKey === 'staged') {
@@ -111,6 +136,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
111
136
  lastError: null,
112
137
  lastFailedAt: null,
113
138
  frozen: false,
139
+ failureType: null,
140
+ missingDeps: [],
141
+ installHint: null,
114
142
  promotedAt: Date.now(),
115
143
  }
116
144
  delete shared.state.staged[id]
@@ -125,6 +153,9 @@ async function promote(shared, recordKey, id, name, entry, record) {
125
153
  lastError: null,
126
154
  lastFailedAt: null,
127
155
  frozen: false,
156
+ failureType: null,
157
+ missingDeps: [],
158
+ installHint: null,
128
159
  }
129
160
  }
130
161
  logEvent(shared, 'promote', `mounted ${name} (${id})`)
@@ -132,19 +163,23 @@ async function promote(shared, recordKey, id, name, entry, record) {
132
163
  }
133
164
 
134
165
  /** Failure path: attempts counter + error recorded; freeze at the limit. */
135
- function recordFailure(shared, recordKey, id, name, entry, record, error) {
166
+ function recordFailure(shared, recordKey, id, name, entry, record, info) {
136
167
  const attempts = (record.attempts ?? 0) + 1
137
168
  const frozen = attempts >= FREEZE_LIMIT
169
+ const message = typeof info.message === 'string' ? info.message : String(info.message ?? info)
138
170
  shared.state[recordKey][id] = {
139
171
  name,
140
172
  config: entry.config ?? undefined,
141
173
  attempts,
142
- lastError: errorSnip(error),
174
+ lastError: errorSnip(message),
143
175
  lastFailedAt: Date.now(),
144
176
  frozen,
177
+ failureType: info.failureType ?? 'code',
178
+ missingDeps: info.missingDeps ?? [],
179
+ installHint: info.installHint ?? null,
145
180
  ...(recordKey === 'promoted' ? { promotedAt: record.promotedAt } : {}),
146
181
  }
147
- logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${error.message ?? error}`)
182
+ logEvent(shared, frozen ? 'freeze' : 'quarantine', `${name} (${id}) failed ${attempts}x: ${message}`)
148
183
  shared.persistSoon()
149
184
  }
150
185
 
@@ -211,6 +246,9 @@ async function retryEntry(shared, id) {
211
246
  lastError: null,
212
247
  lastFailedAt: null,
213
248
  frozen: false,
249
+ failureType: null,
250
+ missingDeps: [],
251
+ installHint: null,
214
252
  }
215
253
  shared.persistSoon()
216
254
  if (shared.state.safeMode) return 'safe'
@@ -1,5 +1,5 @@
1
1
  // ── row ────────────────────────────────────────────────────────────────
2
- /** Row head: source chip + name + status badge. */
2
+ /** Row head: source chip + name + status badge + failure-category badge. */
3
3
  function RowHead({ entry, source }) {
4
4
  return createElement(
5
5
  'div',
@@ -15,6 +15,16 @@ function RowHead({ entry, source }) {
15
15
  { className: `dsh-my-guardian-badge dsh-my-guardian-badge-${entry.status}` },
16
16
  statusLabel(entry.status),
17
17
  ),
18
+ typeof entry.failureType === 'string' && entry.failureType !== ''
19
+ ? createElement(
20
+ 'span',
21
+ {
22
+ className: `dsh-my-guardian-category dsh-my-guardian-category-${entry.failureType}`,
23
+ title: entry.failureType,
24
+ },
25
+ failureTypeLabel(entry.failureType),
26
+ )
27
+ : null,
18
28
  )
19
29
  }
20
30
 
@@ -122,11 +132,26 @@ function RowActions({ entry, busy, onRetry, onRemove }) {
122
132
  )
123
133
  }
124
134
 
135
+ /** 冻结行提示(连败停止自动重试,需手动操作)。 */
136
+ function FrozenHint({ status }) {
137
+ if (status !== 'frozen') return null
138
+ return createElement('div', { className: 'dsh-my-guardian-freeze-hint' }, strings.frozenHint())
139
+ }
140
+
125
141
  function EntryRow({ entry, source, onAction }) {
126
- const [expanded, setExpanded] = useState(false)
142
+ // 失败/冻结行默认展开错误详情(用户之前必须手动点开才看得到真正报错)。
143
+ const [expanded, setExpanded] = useState(
144
+ () =>
145
+ typeof entry.lastError === 'string' &&
146
+ entry.lastError !== '' &&
147
+ (entry.status === 'failed' || entry.status === 'frozen'),
148
+ )
127
149
  const [confirming, setConfirming] = useState(false)
128
150
  const [busy, setBusy] = useState(false)
129
151
  const hasError = typeof entry.lastError === 'string' && entry.lastError !== ''
152
+ const isDepFailure = entry.failureType === 'dependency'
153
+ const installHint =
154
+ isDepFailure && typeof entry.installHint === 'string' && entry.installHint !== '' ? entry.installHint : null
130
155
 
131
156
  const run = (kind) => {
132
157
  setBusy(true)
@@ -138,6 +163,15 @@ function EntryRow({ entry, source, onAction }) {
138
163
  { className: 'dsh-my-guardian-row' },
139
164
  createElement(RowHead, { entry, source }),
140
165
  createElement(RowMeta, { entry }),
166
+ createElement(FrozenHint, { status: entry.status }),
167
+ installHint
168
+ ? createElement(
169
+ 'div',
170
+ { className: 'dsh-my-guardian-install-hint' },
171
+ createElement('span', { className: 'dsh-my-guardian-install-hint-label' }, strings.installHint()),
172
+ createElement('code', null, installHint),
173
+ )
174
+ : null,
141
175
  hasError ? createElement(ErrorToggle, { expanded, onToggle: () => setExpanded(!expanded) }) : null,
142
176
  expanded && hasError ? createElement('pre', { className: 'dsh-my-guardian-error-detail' }, entry.lastError) : null,
143
177
  confirming
@@ -73,8 +73,22 @@ const STYLES = `
73
73
  .dsh-my-guardian-badge-pending { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
74
74
  .dsh-my-guardian-badge-failed { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
75
75
  .dsh-my-guardian-badge-frozen { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
76
+ /* failure-classification badge chips (issue #86): dependency / code / other */
77
+ .dsh-my-guardian-category { flex:none; display:inline-flex; align-items:center; justify-content:center; height:17px; padding:0 5px; border-radius:4px;
78
+ font:var(--dsw-font-xxxs-strong-11); }
79
+ .dsh-my-guardian-category-dependency { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
80
+ .dsh-my-guardian-category-code { color:var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
81
+ .dsh-my-guardian-category-other { color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }
82
+ /* install-suggestion line for dependency failures */
83
+ .dsh-my-guardian-install-hint { display:flex; align-items:center; gap:5px; padding:3px 6px; border-radius:6px;
84
+ background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 6%, transparent);
85
+ font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
86
+ .dsh-my-guardian-install-hint-label { flex:none; color:var(--dsw-alias-label-tertiary); }
87
+ .dsh-my-guardian-install-hint code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:var(--dsw-font-xxxs-11);
88
+ color:var(--dsw-alias-state-success-primary); word-break:break-all; }
76
89
  .dsh-my-guardian-row-meta { display:flex; align-items:center; gap:6px; font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary); }
77
90
  .dsh-my-guardian-attempts { color:var(--dsw-alias-state-error-primary); }
91
+ .dsh-my-guardian-freeze-hint { font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-state-warn-primary); line-height:1.6; }
78
92
  .dsh-my-guardian-link { display:inline-flex; align-items:center; gap:3px; padding:0; border:none; background:transparent; cursor:pointer;
79
93
  font:var(--dsw-font-xxxs-11); color:var(--dsw-alias-label-tertiary);
80
94
  transition:color var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
@@ -12,8 +12,8 @@ const strings = {
12
12
  safeMode: () => (isZh() ? '安全模式' : 'Safe mode'),
13
13
  safeModeDesc: () =>
14
14
  isZh()
15
- ? '开启后所有候选/已转正插件都不再加载,用于快速恢复环境'
16
- : 'Skips every staged/promoted plugin mount — fast recovery',
15
+ ? '开启后所有候选/已转正插件(候选区 cordis.staged.json 中的条目)都不再加载,用于快速恢复环境。注意:安全模式只作用于启动后挂载的候选插件;若插件直接写进启动名册(cordis.patch.yml / profile),DSH 启动时仍是 all-or-nothing——请把新插件先放进候选区。'
16
+ : 'Skips every staged/promoted plugin mount — fast recovery. Note: this only covers entries mounted after boot (cordis.staged.json); plugins in the boot roster (cordis.patch.yml / profile) are still all-or-nothing — put new plugins in the staged file first.',
17
17
  staged: () => (isZh() ? '候选' : 'staged'),
18
18
  promoted: () => (isZh() ? '转正' : 'promoted'),
19
19
  entries: () => (isZh() ? '插件条目' : 'Plugin entries'),
@@ -41,6 +41,14 @@ const strings = {
41
41
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
42
42
  events: () => (isZh() ? '最近事件' : 'Recent events'),
43
43
  attempts: (n) => (isZh() ? `失败 ${n} 次` : `failed ×${n}`),
44
+ frozenHint: () =>
45
+ isZh()
46
+ ? '已冻结:连续失败停止自动重试——点击刷新按钮手动重试,或移除该条目'
47
+ : 'Frozen: auto-retry stopped after repeated failures — retry manually or remove the entry',
48
+ failureDependency: () => (isZh() ? '依赖缺失' : 'Dependency'),
49
+ failureCode: () => (isZh() ? '代码错误' : 'Code error'),
50
+ failureOther: () => (isZh() ? '其他' : 'Other'),
51
+ installHint: () => (isZh() ? '安装建议' : 'Install'),
44
52
  }
45
53
 
46
54
  // ── api ───────────────────────────────────────────────────────────────
@@ -82,6 +90,20 @@ function statusLabel(status) {
82
90
  }
83
91
  }
84
92
 
93
+ /** Failure-classification badge label (issue #86): dependency / code / other. */
94
+ function failureTypeLabel(type) {
95
+ switch (type) {
96
+ case 'dependency':
97
+ return strings.failureDependency()
98
+ case 'code':
99
+ return strings.failureCode()
100
+ case 'other':
101
+ return strings.failureOther()
102
+ default:
103
+ return type
104
+ }
105
+ }
106
+
85
107
  // ── event log ─────────────────────────────────────────────────────────
86
108
  // Event type → badge label + color variant (mirrors the dfa-op chip style).
87
109
  const EVENT_LABELS = {
@@ -123,14 +123,19 @@ function EntryList({ rows, onAction }) {
123
123
  )
124
124
  }
125
125
 
126
- /** Recent guardian event log: badge + key info + time per entry. */
126
+ /** 高频噪音事件:每次启动/热重载都会大量产生,挤掉真正重要的诊断信息。 */
127
+ const EVENT_NOISE = new Set(['entry-init', 'entry-dispose'])
128
+
129
+ /** Recent guardian event log: badge + key info + time per entry.
130
+ * 过滤 entry-init/entry-dispose 噪音,优先展示隔离/冻结/更新失败等关键事件。 */
127
131
  function EventList({ events }) {
128
- if (events.length === 0) return null
132
+ const important = events.filter((event) => !EVENT_NOISE.has(event.type))
133
+ if (important.length === 0) return null
129
134
  return createElement(
130
135
  'div',
131
136
  { className: 'dsh-my-guardian-events' },
132
137
  createElement('div', { className: 'dsh-my-guardian-events-title' }, icon.clock(14), strings.events()),
133
- events.map((event, index) =>
138
+ important.map((event, index) =>
134
139
  createElement(
135
140
  'div',
136
141
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-guardian",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "DSH 插件治理守护:新装/更新插件先进候选区,启动后热挂载——成功转正、失败自动禁用、连续失败冻结,一键安全模式,侧边栏诊断面板。DSH web plugin: staged plugin loading with auto-disable on failure, freeze, safe mode and a sidebar panel.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",