@sidady/dsh-steward 0.9.14 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-steward · client entry — v0.9.14 (model-mode conversational entry + L1 briefing;
2
+ * dsh-steward · client entry — v0.9.16 (L0 observe-only duty loop + model-mode entry + L1 briefing;
3
3
  * ChatBar rebuilt as a mature chat composer: one rounded input bar, mode chip on the
4
4
  * left, round send button on the right, settings panel below the bar;
5
5
  * v0.7.5 collapse: a collapsed group lists EVERY peer as a two-line summary row
@@ -99,10 +99,10 @@ window.__ModuleLoader__.load({
99
99
  // finding → 动作直达映射(点按钮 = 切到动作中心并展开该动作)
100
100
  const ACTION_FOR = {
101
101
  'data-at-risk': 'backup',
102
+ 'backup-freshness': 'backup',
102
103
  'boot-crash-latest': 'disable',
103
104
  'disk-advice': 'clean',
104
105
  'knowledge': 'knowledge',
105
- 'model-health': 'model-default',
106
106
  };
107
107
 
108
108
  // v0.9.8: every request used to be able to hang forever. The server is allowed
@@ -210,7 +210,12 @@ window.__ModuleLoader__.load({
210
210
  // a green primary button reads as "this is healthy", which is data.
211
211
  border: '1px solid ' + (props.primary ? 'transparent' : C.line),
212
212
  background: props.primary && !props.disabled ? C.solid : C.fill,
213
- color: props.tone || (props.primary && !props.disabled ? C.on : C.text), marginRight: 8,
213
+ // A primary button inverts the surface: its foreground MUST be the
214
+ // inverted fg. C.text and C.solid both map to --dsw-alias-label-primary,
215
+ // so letting a caller's `tone` win here paints white-on-white (a blank
216
+ // bar instead of a button). Colour is reserved for data — emphasis is
217
+ // inversion, and inversion cannot be overridden by an accent.
218
+ color: props.primary && !props.disabled ? C.on : (props.tone || C.text), marginRight: 8,
214
219
  },
215
220
  }, props.children);
216
221
  }
@@ -265,6 +270,7 @@ window.__ModuleLoader__.load({
265
270
  },
266
271
  h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } },
267
272
  f.muted ? h(Badge, { color: C.faint }, '已静音') : h(Badge, { color: sev.color }, sev.label),
273
+ f.falsePositive ? h(Badge, { color: C.faint }, '已判错') : null,
268
274
  typeof f.score === 'number' ? h('span', {
269
275
  title: (f.scoreReasons || []).join(';') || '评分构成见体检报告',
270
276
  style: { fontSize: 11, color: C.faint, marginLeft: 6, cursor: 'help' },
@@ -281,9 +287,11 @@ window.__ModuleLoader__.load({
281
287
  style: { fontSize: 11.5, marginTop: 6, padding: '5px 8px', borderRadius: 6, background: C.fill, border: '1px dashed ' + C.info, color: C.sub, whiteSpace: 'pre-wrap' },
282
288
  }, '🧠 上次也出现过 · ' + localStamp(f.recall.at) + ':「' + (f.recall.headline || '') + '」(当时严重 ' + ((f.recall.counts && f.recall.counts.critical) || 0) + ' / 留意 ' + ((f.recall.counts && f.recall.counts.warning) || 0) + '),详情见「审计趋势」的记录)') : null,
283
289
  h('div', { style: { marginTop: 8, display: 'flex', gap: 0, flexWrap: 'wrap' } },
284
- props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
290
+ props.onGo ? h(Btn, { onClick: props.onGo, primary: true }, props.goLabel || '去处置 →') : null,
285
291
  props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
286
292
  props.onUnack ? h(TinyBtn, { onClick: props.onUnack, tone: C.info }, '恢复显示此项') : null,
293
+ props.onFalsePositive ? h(TinyBtn, { onClick: props.onFalsePositive, tone: C.warning }, '这条判错了') : null,
294
+ props.onUnfalsePositive ? h(TinyBtn, { onClick: props.onUnfalsePositive, tone: C.info }, '撤销判错') : null,
287
295
  ),
288
296
  );
289
297
  }
@@ -411,16 +419,25 @@ window.__ModuleLoader__.load({
411
419
  return arr;
412
420
  };
413
421
  // 台账行必须说人话:内部 kind/error 码是机器字段,直接 dump 给用户等于没做呈现层。
414
- const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现' };
422
+ const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现', feedback: '标记一条判错', unfeedback: '撤销判错标记' };
415
423
  const ERR_ZH = {
416
424
  'unknown-or-expired-plan': '计划已过期或指纹不对——指纹是一次性的,请重新「预览计划」',
417
425
  'confirm-missing': '缺少授权确认——执行必须带 confirm:true',
418
426
  };
419
427
  const countsZh = (c) => (c ? ['严重 ' + (c.critical || 0), '留意 ' + (c.warning || 0), '提示 ' + (c.info || 0)].join(' · ') + ((c.muted || 0) > 0 ? ' · 静音 ' + c.muted : '') : '');
428
+ const tv = data && data.trendVerdict;
420
429
  return h('div', {},
421
430
  h('div', { style: { fontSize: 12, color: C.sub, lineHeight: 1.7, margin: '2px 0 10px', padding: '8px 10px', borderLeft: '2px solid ' + C.info, background: C.fill, borderRadius: 6 } },
422
431
  '这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
423
432
  '用途:核对「上次到底清了什么、什么时候、成没成」,以及看体检结论随时间是变好还是变坏。'),
433
+ tv && tv.text ? h('div', { style: { fontSize: 12, marginBottom: 10, padding: '7px 10px', borderRadius: 6, borderLeft: '2px solid ' + (tv.state === 'recovered' ? C.ok : tv.state === 'worsening' ? C.warning : C.info), background: C.fill, color: C.text } }, '趋势结论 · ' + tv.text) : null,
434
+ data && data.duty ? h('div', { style: { fontSize: 12, marginBottom: 10, padding: '8px 10px', borderRadius: 6, borderLeft: '2px solid ' + C.ok, background: C.fill, color: C.text, lineHeight: 1.7 } },
435
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '当班台账 · ' + (data.duty.latest ? localStamp(data.duty.latest.at) : '还没当过班') + '(L0 观察档 · 每 ' + data.duty.intervalMinutes + ' 分钟一班)'),
436
+ data.duty.latest ? h('div', {}, data.duty.latest.seen) : h('div', { style: { color: C.faint } }, '管家会在后台按周期自己醒来体检;这一版只观察、只把建议备好,不代替你按确认。'),
437
+ data.duty.latest ? h('div', {}, data.duty.latest.action) : null,
438
+ data.duty.suggestions && data.duty.suggestions.length ? h('div', { style: { marginTop: 4, color: C.sub } }, '预备好的处置建议(等你确认):' + data.duty.suggestions.map((s) => s.text).join(';')) : null,
439
+ Array.isArray(data.duty.log) && data.duty.log.length > 1 ? h('div', { style: { marginTop: 4, color: C.faint, fontSize: 11 } }, '最近 ' + data.duty.log.length + ' 班:' + data.duty.log.slice(-5).map((d) => localStamp(d.at) + ' ' + d.state).join(' · ')) : null,
440
+ ) : null,
424
441
  h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
425
442
  h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
426
443
  // 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
@@ -463,7 +480,7 @@ window.__ModuleLoader__.load({
463
480
  }
464
481
 
465
482
  // 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
466
- const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null };
483
+ const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null, autoScanMinutes: 10, wiring: null, duty: null };
467
484
  // v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
468
485
  // 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
469
486
  // ---------- 右下角临时弹窗(v0.9.1) ----------
@@ -524,6 +541,31 @@ window.__ModuleLoader__.load({
524
541
  toastedIds.add(f.id);
525
542
  pushToast(toastOf(f));
526
543
  }
544
+ // v0.9.15: 好转与连续恶化也主动说。结论由服务端算好人话,客户端只渲染。
545
+ const tv = report && report.trendVerdict;
546
+ if (tv && (tv.state === 'recovered' || (tv.state === 'worsening' && tv.worseningStreak >= 2))) {
547
+ const key = 'trend-' + tv.state + '-' + (report.generatedAt || '');
548
+ if (!toastedIds.has(key)) {
549
+ toastedIds.add(key);
550
+ pushToast({ kind: 'trend', findingId: null, actionKey: null,
551
+ severity: tv.state === 'recovered' ? 'ok' : 'info',
552
+ title: tv.state === 'recovered' ? '状况好转了' : '连续几次体检都在变差',
553
+ body: tv.text || '' });
554
+ }
555
+ }
556
+ // v0.9.16(未发布):插件新版本——服务端已算好利弊/风险/建议,客户端只弹一次。
557
+ // 不改 toastWorthy(info 级 finding 仍不全局放开),用独立 key 按版本指纹去重。
558
+ const ua = report && report.updates && report.updates.advice;
559
+ if (ua && ua.fingerprint && ua.outdatedCount > 0 && toastConfig.mode !== 'off') {
560
+ const vkey = 'version-' + ua.fingerprint;
561
+ if (!toastedIds.has(vkey)) {
562
+ toastedIds.add(vkey);
563
+ pushToast({ kind: 'version', findingId: null, actionKey: null,
564
+ severity: 'info',
565
+ title: ua.toastTitle || '有插件出了新版本',
566
+ body: ua.toastBody || '' });
567
+ }
568
+ }
527
569
  }
528
570
 
529
571
  // 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
@@ -1002,7 +1044,7 @@ window.__ModuleLoader__.load({
1002
1044
  const gs = Array.isArray(res.groups) ? res.groups : [];
1003
1045
  setGroups(gs);
1004
1046
  setDraft(flatOf(gs));
1005
- setSaved(res.savedAt ? "上次保存:" + String(res.savedAt).slice(0, 16).replace("T", " ") : "还没改过(全部是推荐默认值)");
1047
+ setSaved(res.savedAt ? "上次保存:" + localStamp(res.savedAt) + "(本地时间)" : "还没改过(全部是推荐默认值)");
1006
1048
  } catch (e) { setError(e && e.message ? e.message : String(e)); }
1007
1049
  finally { setLoading(false); }
1008
1050
  }, []);
@@ -1106,7 +1148,7 @@ window.__ModuleLoader__.load({
1106
1148
  const timer = setTimeout(() => { setLeft((v) => v - 1); }, 1000);
1107
1149
  return () => clearTimeout(timer);
1108
1150
  }, [left, paused]);
1109
- const tone = t.severity === 'critical' ? C.critical : C.warning;
1151
+ const tone = t.severity === 'critical' ? C.critical : t.severity === 'ok' ? C.ok : t.severity === 'info' ? C.info : C.warning;
1110
1152
  return h('div', {
1111
1153
  // 鼠标移上去暂停计时:正在读的时候消失,比不弹更烦人。
1112
1154
  onMouseEnter: () => setPaused(true),
@@ -1119,7 +1161,7 @@ window.__ModuleLoader__.load({
1119
1161
  },
1120
1162
  },
1121
1163
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 } },
1122
- h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : '留意'),
1164
+ h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : t.severity === 'ok' ? '好转' : t.severity === 'info' ? '提醒' : '留意'),
1123
1165
  h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.title),
1124
1166
  h('button', {
1125
1167
  onClick: props.onDismiss, title: '关闭',
@@ -1128,15 +1170,15 @@ window.__ModuleLoader__.load({
1128
1170
  ),
1129
1171
  t.body ? h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 7, whiteSpace: 'pre-wrap' } }, t.body) : null,
1130
1172
  h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
1131
- h('button', {
1173
+ t.actionKey ? h('button', {
1132
1174
  onClick: () => { props.onOpen(); props.onDismiss(); },
1133
1175
  style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + tone, background: 'transparent', color: tone },
1134
- }, '去看看'),
1135
- h('button', {
1176
+ }, '去看看') : null,
1177
+ t.findingId ? h('button', {
1136
1178
  onClick: () => { props.onAck(); props.onDismiss(); },
1137
1179
  title: '静音这条,直到有效期结束',
1138
1180
  style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.sub },
1139
- }, '已知晓'),
1181
+ }, '已知晓') : null,
1140
1182
  h('span', { style: { flex: '1 1 auto' } }),
1141
1183
  h('span', { style: { fontSize: 10, color: C.faint, flex: '0 0 auto' } }, paused ? '暂停' : left + 's'),
1142
1184
  ),
@@ -1175,6 +1217,9 @@ window.__ModuleLoader__.load({
1175
1217
  try {
1176
1218
  const data = await call('report');
1177
1219
  if (!alive) return;
1220
+ if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1221
+ if (data && data.wiring) cache.wiring = data.wiring;
1222
+ if (data && data.duty) cache.duty = data.duty;
1178
1223
  if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
1179
1224
  } catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
1180
1225
  })();
@@ -1218,6 +1263,9 @@ window.__ModuleLoader__.load({
1218
1263
  setLoading(true); setError('');
1219
1264
  try {
1220
1265
  const data = await call('report');
1266
+ if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1267
+ if (data && data.wiring) cache.wiring = data.wiring;
1268
+ if (data && data.duty) cache.duty = data.duty;
1221
1269
  try { publishReport(data && data.report); } catch { /* noop */ }
1222
1270
  cache.report = data.report || null;
1223
1271
  cache.error = '';
@@ -1233,12 +1281,13 @@ window.__ModuleLoader__.load({
1233
1281
  try { setBundles(await call('bundles')); } catch { setBundles(null); }
1234
1282
  }, []);
1235
1283
 
1236
- // 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过 10 分钟→后台刷新。
1284
+ // 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过设置的分钟数→后台刷新。
1237
1285
  // 先例:disk-cleaner 面板同样挂载即扫,同一 GUI 正常运行——二分定案证明卡死元凶不是自动扫描。
1238
1286
  useEffect(() => {
1239
1287
  if (!cache.report) { void scan(); return; }
1240
1288
  const age = Date.now() - Date.parse(String(cache.report.generatedAt || ''));
1241
- if (Number.isFinite(age) && age > 10 * 60 * 1000) void scan();
1289
+ const maxAge = (Number.isFinite(cache.autoScanMinutes) ? cache.autoScanMinutes : 10) * 60 * 1000;
1290
+ if (Number.isFinite(age) && age > maxAge) void scan();
1242
1291
  }, [scan]);
1243
1292
 
1244
1293
  const crashFinding = report && Array.isArray(report.findings)
@@ -1270,6 +1319,16 @@ window.__ModuleLoader__.load({
1270
1319
  await scan();
1271
1320
  } catch (e) { setError(e && e.message ? e.message : String(e)); }
1272
1321
  }, [scan]);
1322
+ const falsePositiveIt = useCallback(async (finding, undo) => {
1323
+ try {
1324
+ const p = await call('plan', { kind: undo ? 'unfeedback' : 'feedback', id: finding.id });
1325
+ if (p.ok === false) throw new Error(p.error || 'plan 失败');
1326
+ await call('execute', { planHash: p.planHash, confirm: true });
1327
+ await scan();
1328
+ } catch (e) { setError(e && e.message ? e.message : String(e)); }
1329
+ }, [scan]);
1330
+ const falsePositiveRef = useRef(falsePositiveIt);
1331
+ falsePositiveRef.current = falsePositiveIt;
1273
1332
 
1274
1333
  // 把面板内的能力交给 overlay 里的弹窗宿主。
1275
1334
  React.useEffect(() => {
@@ -1316,8 +1375,10 @@ window.__ModuleLoader__.load({
1316
1375
  finding: f,
1317
1376
  onAck: !f.muted ? () => ackIt(f) : null,
1318
1377
  onUnack: f.muted ? () => unackIt(f) : null,
1378
+ onFalsePositive: !f.falsePositive ? () => falsePositiveIt(f, false) : null,
1379
+ onUnfalsePositive: f.falsePositive ? () => falsePositiveIt(f, true) : null,
1319
1380
  onGo: ACTION_FOR[f.id] && !f.muted && f.severity !== 'info' ? () => jumpToAction(ACTION_FOR[f.id]) : null,
1320
- goLabel: f.id === 'data-at-risk' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
1381
+ goLabel: f.id === 'data-at-risk' || f.id === 'backup-freshness' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
1321
1382
  });
1322
1383
  const groupBlock = (label, color, list) => {
1323
1384
  if (!list.length) return null;
@@ -1423,6 +1484,12 @@ window.__ModuleLoader__.load({
1423
1484
  h('div', {}, '动作执行全部写入 audit.jsonl,可在「审计趋势」回看。'),
1424
1485
  h('div', { style: { marginTop: 4 } }, '依赖清单与降级行为、设计取舍见插件目录 README(dsh-steward/README.md)。')) : null,
1425
1486
  report ? h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 8 } }, '生成时间 · ' + localStamp(report.generatedAt) + (agoText(report.generatedAt) ? '(' + agoText(report.generatedAt) + ')' : '')) : null,
1487
+ report && cache.wiring ? h('div', { style: { fontSize: 10.5, marginTop: 4, color: cache.wiring.route === 'registered' ? C.faint : C.critical } },
1488
+ cache.wiring.route === 'registered'
1489
+ ? '接口已挂上 · 路由与工具随服务就绪后注册'
1490
+ : '接口未挂上(' + String(cache.wiring.route) + ')——停用再启用本插件,或重启 DSH Web') : null,
1491
+ cache.duty ? h('div', { style: { fontSize: 10.5, marginTop: 4, color: C.faint } },
1492
+ '当班 · ' + (cache.duty.lastAt ? localStamp(cache.duty.lastAt) : '待首班') + ' · L0 观察档(每 ' + cache.duty.intervalMinutes + ' 分钟自检一次,只观察不动作)') : null,
1426
1493
  ) : null,
1427
1494
 
1428
1495
  tab === 'actions' ? h('div', { style: { marginTop: 12 } },
@@ -1496,81 +1563,99 @@ window.__ModuleLoader__.load({
1496
1563
  return {
1497
1564
  inject: ['slots'],
1498
1565
  apply(ctx) {
1499
- // v0.8.4:入口切换不再需要刷新页面。
1500
- // 旧版只在 apply() 里读一次 ui.entry,改完必须重载整个 Web 界面才生效;
1501
- // 而桌面版里 Ctrl+R 未必有绑定(用户实测「好像没有效果」)——把「改完要刷新」
1502
- // 当成使用前提,本身就是设计缺陷:一个设置项不该要求用户去猜快捷键。
1503
- // 现在两个入口先都挂上,读到设置再裁掉不要的那个;设置页保存成功后通过
1504
- // ENTRY_APPLIER 就地重挂,全程不刷新。
1505
- let currentDisposers = [];
1506
- const disposeAllNow = () => {
1507
- for (const d of currentDisposers) { try { d(); } catch { /* noop */ } }
1508
- currentDisposers = [];
1566
+ // 三处注册彼此独立、按入口状态幂等地挂/卸,只在插件整体卸载时由框架级联销毁。
1567
+ // 曾经的 bug:把右侧栏 tab 的「类型 + 正文 + 浮层提醒」全塞进 mountRight(),又让
1568
+ // mountFor() 每次都 disposeAllNow() 全拆一遍。ui.entry='right' 时初次挂 both、异步
1569
+ // 读到设置后再挂 right,这第二次重挂会把刚注册好的正文槽位 dispose 掉;更糟的是
1570
+ // 正文注册还嵌在 ctx.inject(['sidebarRightTabs']) 的回调里,服务重载时机一错就丢。
1571
+ // 宿主把它当成「会话日志里恢复的、插件已不再挂载的旧 tab」,显示「这类内容还没有
1572
+ // 可用的查看方式。」——芯片标题是打开时捕获的,所以标题还在、正文空了。
1573
+ // 现在:右侧整套按 wantRight 幂等挂/卸(wantRight 不变就不拆不挂),正文槽位顶层
1574
+ // 直接挂、不再依赖 sidebarRightTabs 服务的回调,浮层提醒独立常驻不进任何拆装。
1575
+ // 对照:config-manager / better-sidebar / univer-office 都把正文槽位顶层一次性注册。
1576
+
1577
+ // ① 右侧栏 tab:类型 + 正文。用 rightMounted 做幂等门,避免每次 mountFor 反复拆装。
1578
+ let rightMounted = false;
1579
+ let rightDisposers = [];
1580
+ const unmountRight = () => {
1581
+ for (const d of rightDisposers) { try { d(); } catch { /* noop */ } }
1582
+ rightDisposers = [];
1583
+ rightMounted = false;
1509
1584
  };
1510
-
1511
1585
  const mountRight = () => {
1512
- const disposers = [];
1586
+ if (rightMounted) return; // 幂等:wantRight 没变就别动,否则正文会被二次重挂 dispose 掉
1587
+ rightMounted = true;
1588
+ // 正文(keyed 槽位):key 恒为 PANEL_ID(= 类型实现 id)。顶层直接挂,
1589
+ // 不夹带在 sidebarRightTabs 的 inject 回调里——服务重载不会连累正文。
1590
+ try {
1591
+ rightDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () =>
1592
+ ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PANEL_ID }, (p) =>
1593
+ h(Panel, { host: (p && p.host) || 'sidebar' })),
1594
+ ));
1595
+ } catch { /* sidebar body unavailable — contained */ }
1596
+ // 类型(sidebarRightTabs):服务可能晚于 apply 就绪,必须用 ctx.inject 延迟注册;
1597
+ // 用注入进来的 scoped ctx 的 effect 管生命周期,服务重跑时先 dispose 旧类型再注册。
1513
1598
  try {
1514
1599
  const off = ctx.inject(['sidebarRightTabs'], (c) => {
1515
1600
  try {
1516
1601
  const svc = c && c.sidebarRightTabs;
1517
1602
  if (!svc || typeof svc.register !== 'function') return;
1518
- const r = svc.register({
1519
- id: PANEL_ID, kind: PANEL_ID, title: () => '智能管家',
1520
- guide: [{ id: PANEL_ID, order: 25, title: () => '智能管家', description: () => '稳定 · 可靠 · 整洁 · 知识的授权守护', icon: PanelIcon }],
1521
- });
1522
- if (typeof r === 'function') disposers.push(r);
1523
- disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () =>
1524
- ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PANEL_ID }, (p) =>
1525
- h(Panel, { host: (p && p.host) || 'sidebar' })),
1526
- ));
1603
+ c.effect(() => {
1604
+ const dispose = svc.register({
1605
+ id: PANEL_ID, kind: PANEL_ID, title: () => '智能管家',
1606
+ guide: [{ id: PANEL_ID, order: 25, title: () => '智能管家', description: () => '稳定 · 可靠 · 整洁 · 知识的授权守护', icon: PanelIcon }],
1607
+ });
1608
+ return () => { try { if (typeof dispose === 'function') dispose(); } catch { /* noop */ } };
1609
+ }, 'dsh-steward: right tab type');
1527
1610
  } catch { /* sidebar unavailable — contained */ }
1528
1611
  });
1529
- if (typeof off === 'function') disposers.push(off);
1612
+ if (typeof off === 'function') rightDisposers.push(off);
1530
1613
  } catch { /* inject unsupported — contained */ }
1531
- return () => { for (const d of disposers) { try { d(); } catch { /* noop */ } } };
1532
1614
  };
1533
1615
 
1534
- // 浮层宿主与入口无关:三个入口模式下提醒都必须能到,所以在 mountFor 之外
1535
- // 单独挂一次,且只挂一次(重复注册会让同一个提醒弹两遍)。
1616
+ // ② 浮层提醒:与入口无关,独立挂一次;不再放进任何会被 disposeAllNow 清掉的列表。
1536
1617
  try {
1537
- currentDisposers.push(ctx.slots.inject('shell.overlay', () =>
1538
- ctx.slots.register(
1539
- { name: 'shell.overlay', id: PANEL_ID + '-toast', order: 900, label: () => '智能管家提醒' },
1540
- () => h(ToastHost, {
1541
- onOpen: (actionKey) => {
1542
- try { if (PANEL_NAVIGATOR) PANEL_NAVIGATOR(actionKey); } catch { /* noop */ }
1543
- },
1544
- onAck: (findingId) => {
1545
- try { if (PANEL_ACK && findingId) PANEL_ACK(findingId); } catch { /* noop */ }
1546
- },
1547
- }),
1548
- )));
1618
+ ctx.effect(() => {
1619
+ const disposeToast = ctx.slots.inject('shell.overlay', () =>
1620
+ ctx.slots.register(
1621
+ { name: 'shell.overlay', id: PANEL_ID + '-toast', order: 900, label: () => '智能管家提醒' },
1622
+ () => h(ToastHost, {
1623
+ onOpen: (actionKey) => { try { if (PANEL_NAVIGATOR) PANEL_NAVIGATOR(actionKey); } catch { /* noop */ } },
1624
+ onAck: (findingId) => { try { if (PANEL_ACK && findingId) PANEL_ACK(findingId); } catch { /* noop */ } },
1625
+ }),
1626
+ ));
1627
+ return () => { try { if (typeof disposeToast === 'function') disposeToast(); } catch { /* noop */ } };
1628
+ }, 'dsh-steward: toast overlay');
1549
1629
  } catch { /* overlay 不可用:提醒降级为只出现在面板里,不影响体检 */ }
1550
1630
 
1551
- // entry: 'both' | 'left' | 'right'
1552
- // left = 左侧栏导航项 + main 面板本体(点导航项打开的就是 main)
1553
- // right = 右侧栏标签页;只留右侧栏时不挂 left/main,避免两处重复
1631
+ // ③ 左侧入口 / main:随 ui.entry 切换。这部分拆装只管 left/main,不碰右侧 tab。
1632
+ let leftMounted = false;
1633
+ let currentDisposers = [];
1634
+ const disposeAllNow = () => {
1635
+ for (const d of currentDisposers) { try { d(); } catch { /* noop */ } }
1636
+ currentDisposers = [];
1637
+ };
1638
+ const mountLeft = () => {
1639
+ if (leftMounted) return;
1640
+ leftMounted = true;
1641
+ try {
1642
+ currentDisposers.push(ctx.slots.inject('sidebar.panellist', () =>
1643
+ ctx.slots.register(
1644
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 55, label: () => '智能管家' },
1645
+ (p) => h(PanelIcon, { size: p && p.size }),
1646
+ )));
1647
+ } catch { /* contained */ }
1648
+ try {
1649
+ currentDisposers.push(ctx.slots.inject('main', () =>
1650
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(Panel))));
1651
+ } catch { /* contained */ }
1652
+ };
1653
+ const unmountLeft = () => { disposeAllNow(); leftMounted = false; };
1554
1654
  const mountFor = (entry) => {
1555
- disposeAllNow();
1556
1655
  const wantLeft = entry !== 'right';
1557
1656
  const wantRight = entry !== 'left';
1558
- if (wantLeft) {
1559
- try {
1560
- currentDisposers.push(ctx.slots.inject('sidebar.panellist', () =>
1561
- ctx.slots.register(
1562
- { name: 'sidebar.panellist', id: PANEL_ID, order: 55, label: () => '智能管家' },
1563
- (p) => h(PanelIcon, { size: p && p.size }),
1564
- )));
1565
- } catch { /* contained */ }
1566
- try {
1567
- currentDisposers.push(ctx.slots.inject('main', () =>
1568
- ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(Panel))));
1569
- } catch { /* contained */ }
1570
- }
1571
- if (wantRight) {
1572
- try { currentDisposers.push(mountRight()); } catch { /* contained */ }
1573
- }
1657
+ if (wantLeft) mountLeft(); else unmountLeft();
1658
+ if (wantRight) mountRight(); else unmountRight();
1574
1659
  };
1575
1660
 
1576
1661
  try {
@@ -1581,7 +1666,8 @@ window.__ModuleLoader__.load({
1581
1666
  };
1582
1667
  ctx.effect(() => () => {
1583
1668
  ENTRY_APPLIER = null;
1584
- disposeAllNow();
1669
+ unmountLeft();
1670
+ unmountRight();
1585
1671
  }, 'dsh-steward: entries');
1586
1672
  } catch { /* contained */ }
1587
1673
 
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * Plans are zero-write; executes are audited; passwords never persist.
15
15
  * Contract for routes/tools mirrors the running dsh-disk-cleaner@1.6.2 host entry.
16
16
  */
17
- import { collectReport } from './report.js';
17
+ import { collectReport, dutyBriefing } from './report.js';
18
18
  import { createActionHub } from './actions.js';
19
19
  import { parseIntent } from './intent.js';
20
20
  import { runModel, normalizeBrainOutput } from './brain.js';
@@ -212,6 +212,7 @@ function renderReportText(report) {
212
212
  lines.push(report.summary.headline);
213
213
  lines.push('');
214
214
  lines.push(`生成时间: ${report.generatedAt}(UTC) | 状态: ${report.status} | 计数: critical=${report.summary.counts.critical} warning=${report.summary.counts.warning} info=${report.summary.counts.info}`);
215
+ if (report.trendVerdict && report.trendVerdict.text) lines.push(`趋势: ${report.trendVerdict.text}`);
215
216
  lines.push('');
216
217
  for (const f of report.findings) {
217
218
  lines.push(`## [${f.severity.toUpperCase()}] ${f.title}`);
@@ -270,6 +271,8 @@ export function apply(ctx) {
270
271
  configBaseline: path.join(dataDir(), 'config-baseline.json'),
271
272
  settings: path.join(dataDir(), 'settings.json'),
272
273
  modelProbes: path.join(dataDir(), 'model-probes.json'),
274
+ feedback: path.join(dataDir(), 'feedback.json'),
275
+ duty: path.join(dataDir(), 'duty.jsonl'),
273
276
  };
274
277
  // v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
275
278
  let settingsCache = null;
@@ -363,6 +366,63 @@ export function apply(ctx) {
363
366
  }
364
367
  } catch { /* nothing persisted — start clean */ }
365
368
  }
369
+ // ---------- v0.9.16 L0 当班环(observe-only,绝不碰授权面)----------
370
+ // 管家按 ui.autoScanMinutes 周期自己醒来体检,把「这一班看了什么 / 判断 /
371
+ // 做了没做 / 为什么没做」写进 duty.jsonl;预备建议由 dutyBriefing() 在服务端
372
+ // 算成人话,客户端只渲染。L0 永远不调用 plan/execute —— 观察档不代替你按确认。
373
+ const DUTY_LOG_CAP = 60;
374
+ let dutyRunning = false;
375
+ let dutyLastAt = 0;
376
+ let dutyTimer = null;
377
+
378
+ function dutyIntervalMs() {
379
+ const minutes = Number(settingValue(currentSettings(), 'ui.autoScanMinutes'));
380
+ const safe = Number.isFinite(minutes) && minutes >= 1 ? minutes : 10;
381
+ return Math.max(1, safe) * 60 * 1000;
382
+ }
383
+
384
+ async function readDutyLog() {
385
+ try {
386
+ const raw = await fsp.readFile(files.duty, 'utf8');
387
+ const lines = raw.split('\n').filter(Boolean).slice(-DUTY_LOG_CAP);
388
+ const out = [];
389
+ for (const line of lines) {
390
+ try { out.push(JSON.parse(line)); } catch { /* 跳过坏行,不让一条脏数据毁掉台账 */ }
391
+ }
392
+ return out;
393
+ } catch { return []; }
394
+ }
395
+
396
+ async function dutySnapshot() {
397
+ const log = await readDutyLog();
398
+ const latest = log.length ? log[log.length - 1] : null;
399
+ return {
400
+ mode: 'L0',
401
+ observeOnly: true,
402
+ intervalMinutes: Math.round(dutyIntervalMs() / 60000),
403
+ running: dutyRunning,
404
+ lastAt: latest ? latest.at : null,
405
+ latest,
406
+ log,
407
+ suggestions: latest && Array.isArray(latest.suggestions) ? latest.suggestions : [],
408
+ };
409
+ }
410
+
411
+ async function runDuty(trigger) {
412
+ if (dutyRunning) return null; // 重入保护:上一班没结束就不开新班
413
+ if (Date.now() - dutyLastAt < 1000) return null; // 去抖:1 秒内不重复当班
414
+ dutyRunning = true;
415
+ try {
416
+ const res = await run('report', {}); // 复用既有只读体检(采集 + 落盘)
417
+ const report = res && res.report ? res.report : (lastReport || {});
418
+ const briefing = dutyBriefing(report, { trigger: String(trigger || 'scheduled') });
419
+ await io.appendJsonl(files.duty, briefing);
420
+ dutyLastAt = Date.now();
421
+ return briefing;
422
+ } catch { return null; } // 当班失败绝不冒泡影响宿主
423
+ finally { dutyRunning = false; }
424
+ }
425
+
366
426
  const hub = createActionHub({
367
427
  routeFetch: makeRouteFetch(ctx),
368
428
  getPluginManager: () => getService(ctx, 'pluginManager'),
@@ -375,6 +435,7 @@ export function apply(ctx) {
375
435
  trendFile: files.trend,
376
436
  knowledgeFile: files.knowledge,
377
437
  ackedFile: files.acked,
438
+ feedbackFile: files.feedback,
378
439
  });
379
440
 
380
441
  // ---------- v0.9.1 模型健康 ----------
@@ -493,7 +554,7 @@ export function apply(ctx) {
493
554
  }
494
555
 
495
556
  async function run(action, payload) {
496
- if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring } };
557
+ if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring }, duty: await dutySnapshot() };
497
558
  // v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
498
559
  if (action === 'settings') {
499
560
  await loadSettings();
@@ -508,6 +569,8 @@ export function apply(ctx) {
508
569
  if (action === 'report') {
509
570
  let acked = [];
510
571
  try { acked = (await io.readJson(files.acked)) || []; } catch { acked = []; }
572
+ let feedback = [];
573
+ try { feedback = (await io.readJson(files.feedback)) || []; } catch { feedback = []; }
511
574
  const knowledge = await loadKnowledge();
512
575
  // ④ v0.7.6 config drift: compare against the baseline recorded on the
513
576
  // previous scan, then renew it. The baseline is ours (data dir), never the
@@ -528,13 +591,13 @@ export function apply(ctx) {
528
591
  if (saved && saved.report && typeof saved.report === 'object') previous = saved.report;
529
592
  } catch { previous = null; }
530
593
  const modelHealth = await modelHealthForReport();
531
- lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
594
+ lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], feedback: Array.isArray(feedback) ? feedback : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
532
595
  // Persist best-effort; a failed persist never fails the report itself.
533
596
  io.writeJson(files.lastReport, { report: lastReport }).catch(() => {});
534
597
  if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
535
598
  io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
536
599
  }
537
- return { report: lastReport, text: renderReportText(lastReport) };
600
+ return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring }, duty: await dutySnapshot() };
538
601
  }
539
602
  if (action === 'plan') return await hub.plan(payload.kind, payload);
540
603
  if (action === 'execute') {
@@ -656,7 +719,7 @@ export function apply(ctx) {
656
719
  } catch { auditLines = []; }
657
720
  let trend = [];
658
721
  try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
659
- return { ok: true, audit: auditLines, trend };
722
+ return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null, duty: await dutySnapshot() };
660
723
  }
661
724
  return { ok: false, error: `unknown action: ${String(action)}` };
662
725
  }
@@ -676,11 +739,46 @@ export function apply(ctx) {
676
739
  // without waiting for a human to ask. Read-only, fail-soft, never throws.
677
740
  try {
678
741
  const bootTimer = setTimeout(() => {
679
- run('report', {}).catch(() => {});
742
+ runDuty('boot').catch(() => {});
680
743
  }, 45_000);
681
744
  if (typeof bootTimer.unref === 'function') bootTimer.unref();
682
745
  } catch { /* timer unavailable */ }
683
746
 
747
+ // 1b-2) L0 当班:按设置周期自己醒来体检一遍(只读,绝不自动执行动作)。
748
+ // 周期跟随 ui.autoScanMinutes,改设置后下一班自动按新周期重排,不用重启。
749
+ let dutySpanMs = dutyIntervalMs();
750
+ const dutyTick = () => {
751
+ const next = dutyIntervalMs();
752
+ if (next !== dutySpanMs) {
753
+ dutySpanMs = next;
754
+ if (dutyTimer) clearInterval(dutyTimer);
755
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
756
+ if (dutyTimer && typeof dutyTimer.unref === 'function') dutyTimer.unref();
757
+ }
758
+ runDuty('scheduled').catch(() => {});
759
+ };
760
+ // 必须挂在 ctx.effect 上:fiber 被 HMR 换掉 / 插件停用时,dispose 会把定时器一起清掉。
761
+ // 裸 setInterval 在旧 fiber 里不会停 —— 每热重载一次就多一个永不停止的当班环
762
+ // (与 v0.9.14 那个「旧路由被 dispose、新的没接上」是同一类生命周期错误)。
763
+ try {
764
+ ctx.effect(() => {
765
+ try {
766
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
767
+ if (typeof dutyTimer.unref === 'function') dutyTimer.unref();
768
+ } catch { /* timer unavailable — 周期当班静默降级,不影响其它功能 */ }
769
+ return () => {
770
+ if (dutyTimer) { clearInterval(dutyTimer); dutyTimer = null; }
771
+ };
772
+ }, 'dsh-steward: L0 duty');
773
+ } catch {
774
+ // ctx.effect 不可用(极老宿主):退化为裸定时器,宁可多跑一班也不能让 boot 挂在这里。
775
+ try {
776
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
777
+ if (typeof dutyTimer.unref === 'function') dutyTimer.unref();
778
+ } catch { /* ignore */ }
779
+ }
780
+
781
+
684
782
  // 1c) v0.4 proactive exit, part 2: systemPrompt.section — PROVEN shape from
685
783
  // @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
686
784
  // getSectionOrder). Guarded: if the service or getSectionOrder is missing we