@yangdcm/dsh-expert-team 1.3.9 → 1.3.10

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
@@ -3,6 +3,59 @@
3
3
  本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
4
4
  `package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
5
5
 
6
+ ## 1.3.10
7
+
8
+ **四项一次收口:推荐插件自检 · 画布轮询也走 single-flight · 底盘缓存 · 角色解析限次。**
9
+ 这一版全部由**真机实测**驱动,改动都带可复现的判据(下面每条都写明"改前 → 目标")。
10
+
11
+ ### ① 启动时自检推荐插件(缺了说一句,装了不吭声)
12
+
13
+ - 事实前提(只读调查):本插件**零硬依赖** —— `package.json` 连 `dependencies` 字段都没有,
14
+ 全库没有一条外部 import;点名到的外部插件只有 `@vectorize-io/hindsight-coding-agents`(工具名
15
+ `hindsight_*`)与 `dsh-cost-meter`,**缺了都不会报错**,只是少了对应体验。
16
+ - `detectOptionalPlugins(ctx)`(**纯读、无副作用**)三条探测路径:
17
+ ① `ctx.get('loader').entries()` 的 `options.name` = **装没装**;
18
+ ② `ctx.get('costMeter')`(服务名是**驼峰**)= 费用插件是否可用;
19
+ ③ `ctx.get('tools').get('hindsight_ingest_document')` = **此刻能不能用** —— Hindsight 在
20
+ "装了但未配置/被 opt-out"时**根本不注册工具**,所以这一条天然把「没装」与「装了没配」分开。
21
+ - 状态四态:`ready` / `missing` / `installed-not-ready` / `unknown`(loader 探测不可用 + 服务也
22
+ 拿不到时**不假装知道**,且不进提示)。
23
+ - 加载时**最多一行**(进程内去重),都齐则**完全不打扰**;`/team help` 里加一行说明。
24
+
25
+ ### ② 客户端的**所有** /state 轮询统一走一个 hub(上一版只护住了面板)
26
+
27
+ - 实测问题:面板一套 `load()`(1.3.5 已加守卫),而 `HeaderButton` 徽章与画布另走 `liveStore` 的
28
+ `setInterval(liveTick, 2500)` —— **画布一开就有 2–3 个轮询并发压同一个重端点**:10 秒 **7 发**、
29
+ **6 次重叠**,单发被拖到 **5,891 / 8,347 ms**(并发叠加服务端约 2 s 的同步 CPU)。
30
+ - 现在只有 `stateHub` 一个时钟、一份在飞表:**同 URL 同刻只跑一次**(后来者复用在飞 promise)、
31
+ 全局单一时钟、统一退避 `clamp(max(基础间隔, 最慢一次×2), 基础间隔, 30000)`,
32
+ 团队有人跑时基础间隔按 40% 加速(面板原有意图,现在对徽章/画布同样生效)。
33
+ - 结构性护栏:`client.js` 里**不再存在**任何裸的 `fetch('/plugins/dsh-expert-team/state…')`
34
+ (唯一调用点在 `stateHubFetch` 内),也**只剩一个** `setInterval(stateHubTick, …)`。
35
+
36
+ ### ③ 底盘:`listSessions` 只在真缺人时查 + 并发合并 + 短 TTL
37
+
38
+ - 实测残留:`/state` 热态仍是 **2,887 / 2,603 ms**(诊断点名的"固定底盘 1500–2400 ms")。
39
+ - 两处都遵循"**先证明需要,再花这笔钱**":① 旧实现只要 `knownIds` 非空就无条件
40
+ `listSessions()`(宿主侧 = 枚举全部 artifact、逐个读 header,本机 475 个、~1.5–2.4 s),
41
+ 现在先算"活注册表里查不到的人",一个不缺就整段跳过;② 真的要查时**并发合并 + 2 s TTL**。
42
+ - **缓存纪律(写死在注释里)**:只服务"已结束/已释放子代理"这一支(其 header 不再变化);
43
+ **活代理一律走 listChildren/agents 实时读,绝不经过缓存**;失败不毒化缓存。
44
+ - 另加**分步计时**(`DSH_EXPERT_TEAM_STATE_PROFILE=1` 才收集,默认响应体不变):
45
+ `profile: {runs+select, subs, wfLabels, roles, tail, rolesBudget}` —— 让"哪一步还贵"下一次**可测**
46
+ 而不是靠猜。
47
+
48
+ ### ④ 角色解析:**限次摊平**(不是流式,且不假装流式)
49
+
50
+ - 诊断建议"真流式:读到首条 `user/message` 即停"。**做不到**:宿主公开 API
51
+ (`readSession/listEvents/filterEvents/readEvent`)**没有**投影/限量/流式参数
52
+ (`projectionMode` 只在内部 corpus.read),要"只读首帧"只能碰持久化层的非契约内部方法。
53
+ - 改为**有界**:每次 `/state` 最多读 **4** 条子会话日志(角色结果**永久缓存** ⇒ 几个 tick 内收敛),
54
+ 其余留到下一 tick,并把"本轮被推迟"的条数作为 `rolesDeferred` **如实**交给调用方 ——
55
+ 「还没解析」与「解析不出来」两件事**分得清**(两种零可区分),**不臆造角色**。
56
+
57
+ > 本版**未**改文档/图片(`README*.md`、`docs/images/*` 是前几版刚定稿的);未改 `files` 字段。
58
+
6
59
  ## 1.3.9
7
60
 
8
61
  **新增第二张真实画布截图:`事` 视图(任务依赖图)**(纯文档/资产:`lib/`、`client.js` 零改动)。
package/client.js CHANGED
@@ -1342,7 +1342,6 @@ window.__ModuleLoader__.load({
1342
1342
  var draftMeta = useRef({ loadedAt: '', dirty: false })
1343
1343
  var planMsgS = useState(''); var planMsg = planMsgS[0], setPlanMsg = planMsgS[1]
1344
1344
  var err = useState(''); var errV = err[0], setErr = err[1]
1345
- var pollRef = useRef(0)
1346
1345
 
1347
1346
  function stateUrl() {
1348
1347
  var url = '/plugins/dsh-expert-team/state', q = []
@@ -1350,23 +1349,12 @@ window.__ModuleLoader__.load({
1350
1349
  if (selRunV && selRunV.workspace && selRunV.runId) { q.push('workspace=' + encodeURIComponent(selRunV.workspace)); q.push('run=' + encodeURIComponent(selRunV.runId)) }
1351
1350
  return url + (q.length ? ('?' + q.join('&')) : '')
1352
1351
  }
1353
- // ── single-flight + 自适应退避(2026-09-15 性能修复)──────────────────────────
1354
- // 实测:8 秒内曾发 5 /state、其中 4 个重叠,而单发可达 7–283 秒 ⇒ 队列只增不减,
1355
- // 服务端被压垮(连轻量端点 /settings 都涨到 20.7 s / 42.5 s,整个 dsh web 一起卡)。
1356
- // 两条纪律:① **未回绝不发下一个**(晚一点看到状态,好过把 web 拖死);
1357
- // 间隔随上次耗时**退避**(见 reschedule),重活端点不该 1.2 秒一发。
1358
- var inFlightRef = useRef(false)
1359
- var lastMsRef = useRef(0)
1360
- function load() {
1361
- if (inFlightRef.current) return
1362
- inFlightRef.current = true
1363
- var t0 = (window.performance && performance.now) ? performance.now() : Date.now()
1364
- var done = function () {
1365
- inFlightRef.current = false
1366
- var now = (window.performance && performance.now) ? performance.now() : Date.now()
1367
- lastMsRef.current = Math.max(0, now - t0)
1368
- }
1369
- fetch(stateUrl()).then(function (r) { return r.ok ? r.json() : null }).then(function (d) {
1352
+ // ── 数据到达后的副作用(**本组件不再自带轮询器**;由 stateHub 统一驱动)─────────
1353
+ // 2026-09-15 性能修复 #2:面板原先自己 hold 一个 setInterval + in-flight 守卫,而徽章与
1354
+ // 画布另走 liveStore setInterval 画布一开就有 2–3 个轮询并发压同一个重端点
1355
+ // (实测 10 7 发、6 次重叠、单发被拖到 5.9/8.3 s)。现在全部经 stateHub:
1356
+ // URL 同刻只跑一次、全局单一时钟、统一退避(见 stateHub 的注释)。
1357
+ function onState(d) {
1370
1358
  if (d && d.ok) {
1371
1359
  setData(d); setErr(''); reschedule(d)
1372
1360
  // N2: keep the composer takeover in sync with pendingDecision
@@ -1400,30 +1388,16 @@ window.__ModuleLoader__.load({
1400
1388
  } catch (e) {}
1401
1389
  } else if (d && !d.ok && d.runsAvailable === 0) { setData(null); if (d.runs) setRunsMeta(d.runs); setErr(t('还没有专家团 run。先运行 /team <task> 开一个。', 'No team run yet — run /team <task> first.')) }
1402
1390
  else if (d && !d.ok) { if (d.runs) setRunsMeta(d.runs); setErr(d.error || ''); }
1403
- }).catch(function () {}).then(done, done)
1404
- }
1405
- // Adaptive refresh: while any member is running, poll fast (workers are
1406
- // touching files right now); otherwise the configured cadence.
1407
- // 2026-09-15:再叠一层**退避** —— 间隔 = clamp(max(基础间隔, 上次耗时×2), 基础间隔, 30000)。
1408
- // 单发越慢 ⇒ 下次越晚;display.pollMs 仍是基础节奏(设置里那一项依然说了算)。
1409
- function reschedule(d) {
1410
- var m = (d && d.members) || {}
1411
- var busy = Object.keys(m).some(function (k) { var v = m[k]; return v && typeof v === 'object' && (v.activity === 'running' || v.shortStatus === 'running') })
1412
- clearInterval(pollRef.current)
1413
- // 1.3.4:间隔来自设置 display.pollMs(忙碌时按同一意图加速到 40%,下限 300ms)
1414
- var base = busy ? Math.max(300, Math.round(dispCfg.pollMs * 0.4)) : dispCfg.pollMs
1415
- var backoff = Math.max(base, Math.min(30000, Math.round((lastMsRef.current || 0) * 2)))
1416
- pollRef.current = setInterval(load, backoff)
1417
1391
  }
1418
1392
  useEffect(function () {
1419
1393
  if (!isOpen && !viewMode) return undefined // panel closed & not canvas → no polling
1420
- load(); pollRef.current = setInterval(load, dispCfg.pollMs)
1394
+ var off = stateHubSubscribe(stateUrl(), dispCfg.pollMs, onState)
1421
1395
  // catch up instantly when the user returns to the tab / window
1422
- var onVis = function () { if (document.visibilityState === 'visible') load() }
1423
- var onFocus = function () { load() }
1396
+ var onVis = function () { if (document.visibilityState === 'visible') stateHubFetch(stateUrl(), onState) }
1397
+ var onFocus = function () { stateHubFetch(stateUrl(), onState) }
1424
1398
  document.addEventListener('visibilitychange', onVis)
1425
1399
  window.addEventListener('focus', onFocus)
1426
- return function () { clearInterval(pollRef.current); document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onFocus) }
1400
+ return function () { off(); document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onFocus) }
1427
1401
  }, [sessionId, selRunV, isOpen, viewMode, dispCfg.pollMs])
1428
1402
 
1429
1403
  // Make the docked panel reserve space (shift the shell frame) instead of
@@ -2365,17 +2339,93 @@ window.__ModuleLoader__.load({
2365
2339
  function Boundary(props) { var e = useState(null); if (e[0]) return h('div', { className: 'exp-preview' }, t('专家团面板渲染出错:', 'Team panel render error: ') + esc(String(e[0] && e[0].message || e[0]))); return h('div', {}, props.children) }
2366
2340
  function wrap(Component) { return function (props) { return h(Boundary, null, h(Component, props || {})) } }
2367
2341
 
2342
+ // ── 统一的 /state 加载器(2026-09-15 性能修复 #2)────────────────────────────
2343
+ // 为什么必须统一:同一个重端点在客户端曾有三套轮询 —— 面板组件自己的 load()、
2344
+ // HeaderButton 徽章与画布共用的 liveStore.liveTick()(还有画布标签自己那一份)。
2345
+ // 画布一开就有 2–3 个 setInterval 并发压同一个 /state(实测 10 秒 7 发、6 次重叠,
2346
+ // 单发被拖到 5.9/8.3 s;服务端每请求还有 ~2 s 同步 CPU ⇒ 把整个 web 一起拖慢)。
2347
+ // 现在**所有** /state 拉取都经这里:
2348
+ // ① 同一 URL 同刻只跑一次(in-flight 合并,后来者复用在飞 promise 的结果);
2349
+ // ② 全局**一个**时钟(不再每个组件一个 setInterval);
2350
+ // ③ 间隔 = clamp(max(基础间隔, 上次耗时×2), 基础间隔, 30000);
2351
+ // 团队有人在跑时基础间隔按 40% 加速(面板原有意图,现在对徽章/画布同样生效)。
2352
+ // 纪律不变:**未回绝不发下一个**(晚一点看到状态,好过把 web 拖死)。
2353
+ var stateHub = { inflight: {}, lastMs: {}, subs: [], timer: 0, busy: false }
2354
+ function stateHubNow() { return (window.performance && performance.now) ? performance.now() : Date.now() }
2355
+ function stateHubUrls() { var out = []; stateHub.subs.forEach(function (x) { if (out.indexOf(x.url) < 0) out.push(x.url) }) ; return out }
2356
+ function stateHubBase() {
2357
+ var base = 0
2358
+ stateHub.subs.forEach(function (x) { if (!base || x.base < base) base = x.base })
2359
+ if (!base) base = 3000
2360
+ return stateHub.busy ? Math.max(300, Math.round(base * 0.4)) : base
2361
+ }
2362
+ function stateHubSchedule() {
2363
+ clearInterval(stateHub.timer)
2364
+ stateHub.timer = 0
2365
+ if (!stateHub.subs.length) return
2366
+ var slowest = 0
2367
+ stateHubUrls().forEach(function (u) { if ((stateHub.lastMs[u] || 0) > slowest) slowest = stateHub.lastMs[u] })
2368
+ var base = stateHubBase()
2369
+ var next = Math.max(base, Math.min(30000, Math.round(slowest * 2)))
2370
+ stateHub.timer = setInterval(stateHubTick, next)
2371
+ }
2372
+ function stateHubDeliver(url) {
2373
+ return function (d) {
2374
+ var list = stateHub.subs.slice()
2375
+ for (var i = 0; i < list.length; i++) { if (list[i].url === url) { try { list[i].onData(d) } catch (e) {} } }
2376
+ }
2377
+ }
2378
+ function stateHubFetch(url, onData) {
2379
+ if (typeof fetch === 'undefined') return
2380
+ var p = stateHub.inflight[url]
2381
+ if (p) { p.then(function (d) { try { onData(d) } catch (e) {} }); return } // 同刻同 URL:复用在飞结果
2382
+ var t0 = stateHubNow()
2383
+ p = fetch(url).then(function (r) { return r.ok ? r.json() : null }).then(function (d) {
2384
+ delete stateHub.inflight[url]
2385
+ stateHub.lastMs[url] = Math.max(0, stateHubNow() - t0)
2386
+ return d
2387
+ }, function () { delete stateHub.inflight[url]; return null })
2388
+ stateHub.inflight[url] = p
2389
+ p.then(function (d) { try { onData(d) } catch (e) {} })
2390
+ }
2391
+ function stateHubTick() {
2392
+ if (!stateHub.subs.length) { clearInterval(stateHub.timer); stateHub.timer = 0; return }
2393
+ stateHubUrls().forEach(function (u) { stateHubFetch(u, stateHubDeliver(u)) })
2394
+ stateHubSchedule()
2395
+ }
2396
+ function stateHubSubscribe(url, base, onData) {
2397
+ var sub = { url: url, base: base || 3000, onData: onData }
2398
+ stateHub.subs.push(sub)
2399
+ stateHubFetch(url, onData) // 立即拉一次(面板/徽章打开即有数据)
2400
+ stateHubSchedule()
2401
+ return function () {
2402
+ var i = stateHub.subs.indexOf(sub)
2403
+ if (i >= 0) stateHub.subs.splice(i, 1)
2404
+ if (!stateHub.subs.length) { clearInterval(stateHub.timer); stateHub.timer = 0 }
2405
+ else stateHubSchedule()
2406
+ }
2407
+ }
2408
+
2368
2409
  // ── 共享 state 轮询(HeaderButton / LiveCapsule 复用,单例;无订阅者即停)──
2369
- var liveStore = { data: null, sid: '', timer: 0, subs: new Set() }
2410
+ var liveStore = { data: null, sid: '', off: null, subs: new Set() }
2411
+ var LIVE_BASE_MS = 2500 // 徽章/画布的基础节奏(与面板的 dispCfg.pollMs 取更小者由 hub 统一决定)
2412
+ function liveUrl(sid) { return '/plugins/dsh-expert-team/state?sessionId=' + encodeURIComponent(sid) }
2413
+ // 手动催一次(子代理刚出现时立刻刷新一次徽章,不必等下一个 tick)
2370
2414
  function liveTick() {
2371
2415
  var sid = liveStore.sid
2372
2416
  if (!sid || typeof fetch === 'undefined') return
2373
- fetch('/plugins/dsh-expert-team/state?sessionId=' + encodeURIComponent(sid)).then(function (r) { return r.ok ? r.json() : null }).then(function (d) {
2374
- if (!d) return
2375
- liveStore.data = d
2376
- try { harvestActivity(d) } catch (e) {}
2377
- liveStore.subs.forEach(function (f) { try { f(d) } catch (e) {} })
2378
- }).catch(function () {})
2417
+ stateHubFetch(liveUrl(sid), liveDeliver)
2418
+ }
2419
+ function liveDeliver(d) {
2420
+ if (!d) return
2421
+ liveStore.data = d
2422
+ try { harvestActivity(d) } catch (e) {}
2423
+ try { stateHub.busy = teamBusy(d) } catch (e) {}
2424
+ liveStore.subs.forEach(function (f) { try { f(d) } catch (e) {} })
2425
+ }
2426
+ function teamBusy(d) {
2427
+ var m = (d && d.members) || {}
2428
+ return Object.keys(m).some(function (k) { var v = m[k]; return v && typeof v === 'object' && (v.activity === 'running' || v.shortStatus === 'running') })
2379
2429
  }
2380
2430
  function useLiveState(sid) {
2381
2431
  var st = useState(liveStore.data); var data = st[0], setData = st[1]
@@ -2384,10 +2434,11 @@ window.__ModuleLoader__.load({
2384
2434
  liveStore.sid = sid
2385
2435
  var fn = function (d) { setData(d) }
2386
2436
  liveStore.subs.add(fn)
2387
- if (!liveStore.timer) { liveTick(); liveStore.timer = setInterval(liveTick, 2500) }
2437
+ // hub 订阅:与面板/画布共用同一个时钟与在飞守卫(这里**不再**自己 setInterval
2438
+ var off = stateHubSubscribe(liveUrl(sid), LIVE_BASE_MS, liveDeliver)
2388
2439
  return function () {
2389
2440
  liveStore.subs.delete(fn)
2390
- if (!liveStore.subs.size && liveStore.timer) { clearInterval(liveStore.timer); liveStore.timer = 0 }
2441
+ off()
2391
2442
  }
2392
2443
  }, [sid])
2393
2444
  return data
@@ -60,6 +60,7 @@ export const USAGE = [
60
60
  ' /team wait [<run>] 查看在飞任务进展(不阻塞;无进展超 5 分钟会提示)',
61
61
  ' /team clear [<run>] 清理一个或全部 run',
62
62
  ' /team uninstall 回收本插件铺到 $DSH_HOME 的副本(skill 现默认走运行时注册,本就不落地)',
63
+ ' ⓘ 推荐插件(可选,缺了不报错):Hindsight = 跨项目记忆;dsh-cost-meter = 费用视图',
63
64
  ].join('\n');
64
65
 
65
66
  // ── command parsing ────────────────────────────────────────────────────────
package/lib/command.js CHANGED
@@ -906,6 +906,107 @@ async function uninstallInstalled() {
906
906
  * 会把"响亮"变成噪声,而噪声的代价是所有告警一起被降权。
907
907
  */
908
908
  const INSTALL_FAILURE_WARNED = new Set();
909
+ // ── 推荐插件自检(2026-09-15 · 用户要求)───────────────────────────────────────
910
+ // 事实(只读调查结论,别再重证):本插件**零硬依赖** —— package.json 连 `dependencies`
911
+ // 字段都没有,全库没有一条外部 import;下面这些插件**缺了都不会报错**,只是会让某些体验打折。
912
+ // 所以这里只做一件事:**装了就一声不吭,缺了说一句怎么办**(不吓人、不重复、不影响加载)。
913
+ //
914
+ // 三条探测路径(都有宿主源码依据):
915
+ // ① loader 条目(`ctx.get('loader').entries()` → `options.name`)= **装没装**(最通用);
916
+ // ② 服务探测(`ctx.get('costMeter')`)= 费用插件是否可用(服务名是**驼峰** costMeter);
917
+ // ③ 工具探测(`ctx.get('tools').get('hindsight_ingest_document')`)= **此刻能不能用**:
918
+ // Hindsight 在"装了但未配置/被 opt-out"时**根本不注册工具**(其 registerTools 开头
919
+ // 模板不可用即 return)⇒ 这一条天然把「没装」与「装了没配」分开 —— 两种零可区分。
920
+ // 全部 best-effort:任何异常都当"不知道",**绝不**因此影响插件加载。
921
+ const OPTIONAL_PLUGINS = [
922
+ {
923
+ id: 'hindsight',
924
+ pkg: '@vectorize-io/hindsight-coding-agents',
925
+ match: (name) => name === '@vectorize-io/hindsight-coding-agents' || name.indexOf('@vectorize-io/hindsight-coding-agents/') === 0,
926
+ what: '跨项目/跨会话长期记忆(clarify 前召回、deliver 时落库)',
927
+ install: 'dsh plugin --profile web add @vectorize-io/hindsight-coding-agents',
928
+ },
929
+ {
930
+ id: 'cost-meter',
931
+ pkg: 'dsh-cost-meter',
932
+ match: (name) => name === 'dsh-cost-meter' || name.indexOf('dsh-cost-meter/') === 0,
933
+ what: '会话费用 / token 账(浮层「在用模型」那一行指向它)',
934
+ install: 'dsh plugin --profile web add dsh-cost-meter',
935
+ },
936
+ ];
937
+ /** 装了哪些插件(loader 条目名)。返回 null = 探测不可用(**不是**"没装")。 */
938
+ function loaderEntryNames(ctx) {
939
+ try {
940
+ const loader = ctx && typeof ctx.get === 'function' ? ctx.get('loader') : null;
941
+ if (!loader || typeof loader.entries !== 'function') return null;
942
+ const out = [];
943
+ for (const entry of loader.entries() || []) {
944
+ const n = entry && entry.options && entry.options.name;
945
+ if (n) out.push(String(n));
946
+ }
947
+ return out;
948
+ } catch { return null; }
949
+ }
950
+ /** 工具面探测:Hindsight 的工具在"未配置/被关"时压根不注册 ⇒ 天然区分没装与装了没配。 */
951
+ function hindsightToolReady(ctx) {
952
+ try {
953
+ const tools = ctx && typeof ctx.get === 'function' ? ctx.get('tools') : null;
954
+ if (!tools || typeof tools.get !== 'function') return null;
955
+ return !!tools.get('hindsight_ingest_document');
956
+ } catch { return null; }
957
+ }
958
+ function costMeterReady(ctx) {
959
+ try { return !!(ctx && typeof ctx.get === 'function' && ctx.get('costMeter')); } catch { return null; }
960
+ }
961
+ /**
962
+ * 自检推荐插件。**纯读、无副作用**。
963
+ * @returns {{items:Array, needsAttention:Array, hint:string}}
964
+ * status: 'ready'(装了且可用)/ 'installed-not-ready'(装了但当下不可用)/ 'missing'(没装)
965
+ * / 'unknown'(探测不可用 ⇒ 不下结论)
966
+ */
967
+ function detectOptionalPlugins(ctx) {
968
+ const names = loaderEntryNames(ctx);
969
+ const items = OPTIONAL_PLUGINS.map((spec) => {
970
+ const installed = names ? names.some(spec.match) : null; // null = 探测不可用
971
+ const usable = spec.id === 'hindsight' ? hindsightToolReady(ctx) : (spec.id === 'cost-meter' ? costMeterReady(ctx) : null);
972
+ let status;
973
+ // 只有**确定**的结论才给确定的状态:
974
+ // · 工具/服务可用 ⇒ ready(最强的肯定证据);
975
+ // · loader 明确列出"没有" ⇒ missing(确定没装);
976
+ // · loader 列出有、但工具/服务不可用 ⇒ installed-not-ready(装了没配 —— 第二种零);
977
+ // · 其余(loader 探测不可用 **且** 服务也拿不到)⇒ **unknown**:这时"没装"与"装了没配"
978
+ // 分不开,就**不假装知道**。unknown 不进提示、不阻断任何功能。
979
+ if (usable === true) status = 'ready';
980
+ else if (installed === false) status = 'missing';
981
+ else if (installed === true && usable === false) status = 'installed-not-ready';
982
+ else status = 'unknown';
983
+ return { id: spec.id, pkg: spec.pkg, what: spec.what, install: spec.install, installed, usable, status };
984
+ });
985
+ const needsAttention = items.filter((i) => i.status === 'missing' || i.status === 'installed-not-ready');
986
+ let hint = '';
987
+ if (needsAttention.length) {
988
+ const parts = needsAttention.map((i) => {
989
+ const why = i.status === 'missing' ? '未安装' : '已安装但当前不可用(未配置或被关闭)';
990
+ return `${i.pkg}(${i.what}):${why} ⇒ ${i.install}`;
991
+ });
992
+ hint = `[expert-team] 推荐插件未就绪(不影响使用,只是少了对应体验):${parts.join(';')}`;
993
+ }
994
+ return { items, needsAttention, hint };
995
+ }
996
+ let OPTIONAL_HINT_DONE = false;
997
+ /** 加载时提示一次(**装了/都齐则完全不吭声**;进程内最多一行)。 */
998
+ function hintOptionalPluginsOnce(ctx) {
999
+ if (OPTIONAL_HINT_DONE) return '';
1000
+ try {
1001
+ const r = detectOptionalPlugins(ctx);
1002
+ if (!r.hint) return '';
1003
+ OPTIONAL_HINT_DONE = true;
1004
+ console.log(r.hint);
1005
+ return r.hint;
1006
+ } catch { return ''; }
1007
+ }
1008
+ function _resetOptionalHintOnce() { OPTIONAL_HINT_DONE = false; }
1009
+
909
1010
  function warnInstallFailureOnce(kind) {
910
1011
  if (INSTALL_FAILURE_WARNED.has(kind)) return;
911
1012
  INSTALL_FAILURE_WARNED.add(kind);
@@ -2787,6 +2888,41 @@ function subHeaderIndex(ctx, subs, wfLabels) {
2787
2888
  return out;
2788
2889
  }
2789
2890
 
2891
+ /**
2892
+ * 并发合并 + 短 TTL 的 `sessionQuery.listSessions()`。
2893
+ *
2894
+ * 为什么需要它(2026-09-15 性能诊断):宿主的 listSessions 在持久化层是
2895
+ * **枚举全部 artifact、逐个读 header**(本机 475 个、实测 ~1.5–2.4 s),而 /state 每 3 秒轮询;
2896
+ * 客户端在飞重叠时更会 6 个请求同时跑 ⇒ 同一份枚举被重算 6 遍。
2897
+ *
2898
+ * **缓存纪律(本仓对"看到的就是真的"很敏感,所以写死在这里)**:
2899
+ * · 只服务"**已结束/已释放**的子代理"这一支(`listSubagentStatusBySession` 的兜底分支)——
2900
+ * 它们的 header 不再变化,短 TTL 不会让用户看到旧状态;
2901
+ * · **活代理**的状态一律走 listChildren/agents **实时读**,绝不经过这个缓存;
2902
+ * · 失效条件:2 s TTL(兜底)+ 并发合并(同刻只跑一次)。TTL 之后必然重算,
2903
+ * 所以"新生效的会话"最多晚 2 秒可见 —— 这是**有界的**,且不会让在飞代理看起来停滞。
2904
+ */
2905
+ const LIST_SESSIONS_TTL_MS = 2000;
2906
+ let LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null };
2907
+ async function cachedListSessions(q) {
2908
+ const now = Date.now();
2909
+ if (LIST_SESSIONS_CACHE.sessions && (now - LIST_SESSIONS_CACHE.at) < LIST_SESSIONS_TTL_MS) {
2910
+ return LIST_SESSIONS_CACHE.sessions;
2911
+ }
2912
+ if (LIST_SESSIONS_CACHE.inflight) return LIST_SESSIONS_CACHE.inflight; // 同刻只有一个枚举在跑
2913
+ const p = Promise.resolve()
2914
+ .then(() => q.listSessions())
2915
+ .then((sessions) => {
2916
+ LIST_SESSIONS_CACHE = { at: Date.now(), sessions: Array.isArray(sessions) ? sessions : [], inflight: null };
2917
+ return LIST_SESSIONS_CACHE.sessions;
2918
+ })
2919
+ .catch((e) => { LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null }; throw e; });
2920
+ LIST_SESSIONS_CACHE.inflight = p;
2921
+ return p;
2922
+ }
2923
+ /** 仅供测试:清空枚举缓存并复位(生产代码不需要调用它)。 */
2924
+ function _resetListSessionsCache() { LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null }; }
2925
+
2790
2926
  async function listSubagentStatusBySession(ctx, sid, knownIds) {
2791
2927
  const root = sid ? await rootSessionId(ctx, sid) : '';
2792
2928
  let rows = [];
@@ -2818,11 +2954,18 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
2818
2954
  }
2819
2955
  // Fallback: completed subagents may be disposed from the live registry —
2820
2956
  // the durable session projection (sessionQuery) still lists them.
2821
- if (knownIds && knownIds.length) {
2957
+ //
2958
+ // ⚠️ 2026-09-15 性能修复:**只有真的缺人才花这笔钱**。
2959
+ // 旧实现只要 `knownIds` 非空就无条件 `listSessions()`,而它在宿主侧是"枚举全部 artifact、
2960
+ // 逐个读 header"(本机 475 个、~1.5–2.4 s);代理都还在活注册表里时这笔钱白花。
2961
+ // 现在先算"活注册表里查不到的人",一个都不缺就整段跳过 ⇒ 常见情形直接省下这笔开销。
2962
+ // 并发合并与 TTL 见 cachedListSessions() 的注释(缓存纪律写在那里)。
2963
+ const missingIds = (knownIds || []).filter((id) => !rows.some((r) => r.id === id));
2964
+ if (missingIds.length) {
2822
2965
  try {
2823
2966
  const q = (ctx && typeof ctx.get === 'function') ? ctx.get('sessionQuery') : null;
2824
2967
  if (q && typeof q.listSessions === 'function') {
2825
- const sessions = await q.listSessions();
2968
+ const sessions = await cachedListSessions(q);
2826
2969
  const byId = new Map(rows.map((r) => [r.id, r]));
2827
2970
  for (const rec of sessions || []) {
2828
2971
  const h = rec?.header || rec;
@@ -2907,6 +3050,22 @@ function roleOfSub(sub, wfLabels) {
2907
3050
  // A child's role never changes, and reading its log is the expensive path —
2908
3051
  // cache once per child session id ('' entries are cached too, to avoid re-reads).
2909
3052
  const SUB_ROLE_LOG_CACHE = new Map();
3053
+ // ── 每请求的"子会话日志读取"预算(2026-09-15)──────────────────────────────────
3054
+ // 诊断建议改成"真流式:读到首条 user/message 即停"。**做不到,且不假装做到**:
3055
+ // 宿主公开 API(`sessionQuery.readSession/listEvents/filterEvents/readEvent`)**没有**
3056
+ // 投影/限量/流式参数 —— `projectionMode` 只存在于内部 `corpus.read`,公开方法不暴露;
3057
+ // `filterEvents`/`readEvent` 也都是"先把整条日志 load 进内存再筛"。要"只读首帧"只能绕过
3058
+ // 公开接口去碰持久化层的内部方法(`readFirstZstdLine` 之类),那是**非契约、脆的**。
3059
+ // ⇒ 采用**有界**做法:每次 /state 最多读 N 条子会话日志(角色结果**永久缓存**,几个 tick
3060
+ // 内自然收敛),其余留到下一个 tick。**这不是"优化成流式",而是"把一次 20 秒的尖峰摊平"**,
3061
+ // 并把"本轮被推迟"的条数如实交给调用方(rolesDeferred)⇒「还没解析」与「解析不出来」
3062
+ // 两件事分得清(本仓纪律:两种零必须可区分),**不臆造角色**。
3063
+ const ROLE_READ_BUDGET_PER_REQUEST = 4;
3064
+ let ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST;
3065
+ let ROLE_READS_DEFERRED = 0;
3066
+ /** /state 每次进来复位预算(同一请求内多次调用共享这一份)。 */
3067
+ function resetRoleReadBudget() { ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST; ROLE_READS_DEFERRED = 0; }
3068
+ function roleReadBudgetSnapshot() { return { per: ROLE_READ_BUDGET_PER_REQUEST, left: ROLE_READS_LEFT, deferred: ROLE_READS_DEFERRED }; }
2910
3069
  /**
2911
3070
  * Infer a child's role from its own session log. **Only the first `user/message`
2912
3071
  * counts** — it is the delegation prompt the lead passed, e.g.
@@ -2919,6 +3078,10 @@ const SUB_ROLE_LOG_CACHE = new Map();
2919
3078
  async function roleFromChildLog(ctx, id) {
2920
3079
  if (!id) return '';
2921
3080
  if (SUB_ROLE_LOG_CACHE.has(id)) return SUB_ROLE_LOG_CACHE.get(id);
3081
+ // 预算用尽 ⇒ 本轮不读,**且不写缓存**(下一 tick 再试)。这是"本轮被推迟",
3082
+ // 不是"解析不出来" —— 调用方通过 rolesDeferred 如实区分两者(两种零可区分)。
3083
+ if (ROLE_READS_LEFT <= 0) { ROLE_READS_DEFERRED += 1; return ''; }
3084
+ ROLE_READS_LEFT -= 1;
2922
3085
  let role = '';
2923
3086
  try {
2924
3087
  const q = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : null;
@@ -4649,7 +4812,7 @@ function warnLeadToolFaceOnce(status, detail) {
4649
4812
  LEAD_TOOLFACE_WARNED.add(status);
4650
4813
  console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
4651
4814
  }
4652
- export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings };
4815
+ export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings, detectOptionalPlugins, OPTIONAL_PLUGINS, hintOptionalPluginsOnce, _resetOptionalHintOnce, loaderEntryNames, hindsightToolReady, costMeterReady, listSubagentStatusBySession, cachedListSessions, _resetListSessionsCache, LIST_SESSIONS_TTL_MS, resetRoleReadBudget, roleReadBudgetSnapshot, ROLE_READ_BUDGET_PER_REQUEST, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
4653
4816
 
4654
4817
  export function apply(ctx, config) {
4655
4818
  // 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
@@ -4694,6 +4857,9 @@ export function apply(ctx, config) {
4694
4857
  installInBackground('preset', () => ensurePresetInstalled());
4695
4858
  // skill 已有运行时注册时这里立即返回(零成本);只有宿主没有 skill 注册表时才真复制。
4696
4859
  installInBackground('skill', () => ensureSkillInstalled());
4860
+ // 推荐插件自检:纯读、缺了只打一行、装了完全不打扰(见 detectOptionalPlugins 的注释)。
4861
+ // 放在加载路径上但**不 await**(探测本身是同步的,这里只是不阻塞后续注册)。
4862
+ hintOptionalPluginsOnce(ctx);
4697
4863
  void loadSessionRuns(); // session→run memory for overlay auto-select
4698
4864
  ctx.commands.register({
4699
4865
  name: 'team',
@@ -4916,6 +5082,13 @@ export function apply(ctx, config) {
4916
5082
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4917
5083
  try {
4918
5084
  if (req.method !== 'GET') { res.writeHead(405); res.end(); return; }
5085
+ // 每请求复位"子会话日志读取"预算(见 roleFromChildLog 的注释)。
5086
+ resetRoleReadBudget();
5087
+ // 分步计时:只有显式打开才收集,平时零成本(不打印、不进响应)。
5088
+ // 用途:装上新版后**由人**核对"哪一步还贵",而不是靠猜——验收需要可复现数字。
5089
+ const STATE_PROFILE_ON = String((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_STATE_PROFILE) || '') === '1';
5090
+ const prof = STATE_PROFILE_ON ? { at: Date.now(), steps: {} } : null;
5091
+ const mark = (name) => { if (prof) { const n = Date.now(); prof.steps[name] = n - prof.at; prof.at = n; } };
4919
5092
  const url = new URL(req.url ?? '/plugins/dsh-expert-team/state', 'http://127.0.0.1');
4920
5093
  const sid = (url.searchParams.get('sessionId') ?? '').trim();
4921
5094
  // cwd is OPTIONAL: if absent (e.g. overlay has no session id), fall
@@ -4956,6 +5129,7 @@ export function apply(ctx, config) {
4956
5129
  if (nr) sel = await snapshotRun(nr.workspace, nr.runId);
4957
5130
  }
4958
5131
  }
5132
+ mark('runs+select');
4959
5133
  if (!runs.length) { json(200, { ok: false, runs, workspaces, runsAvailable: 0, error: '还没有专家团 run。先运行 /team <task> 开一个。' }); return; }
4960
5134
  if (!sel) {
4961
5135
  // current workspace has no team run (but other workspaces do)
@@ -4997,8 +5171,11 @@ export function apply(ctx, config) {
4997
5171
  // ① wfLabels — the workflow delegation labels in THIS session's own
4998
5172
  // log (covers interrupted fan-outs, which have no chat card); then
4999
5173
  // ② the child's own role prompt (roleFromChildLog).
5174
+ mark('subs');
5000
5175
  const wfLabels = await workflowChildLabels(ctx, peopleSid);
5176
+ mark('wfLabels');
5001
5177
  await resolveSubRoles(ctx, subs, wfLabels);
5178
+ mark('roles');
5002
5179
  // R12 + R13 + R17(F-1 · P0 跨 run 成员串号):**显示与写盘都只吃过滤后的 `subById`**。
5003
5180
  // R13 裁决:只严写盘的话,数据污染修好了但用户看到的串号现象一点没变 —— 面板照样把
5004
5181
  // 别的 run 的 8 个活人显示成本 run 成员,而 membersUnresolved 当前 client.js 并不渲染,
@@ -5164,7 +5341,16 @@ export function apply(ctx, config) {
5164
5341
  // 以及上面的「派工即登记」写本 run 的 `STATE.json`。它们同样值得搬到显式路径,
5165
5342
  // 但那属于「第二个写者」的设计问题(见 `docs/专家团-开发计划.md` 的第 17 项)。
5166
5343
  persistSessionRuns();
5167
- json(200, { ok: true, runs, workspaces, cwd, agents: sel.agents, ...sel });
5344
+ mark('tail');
5345
+ // profile 只在 DSH_EXPERT_TEAM_STATE_PROFILE=1 时出现(默认不含该字段,
5346
+ // 线上响应体不变);rolesDeferred = 本轮因预算被推迟的子会话日志读取条数,
5347
+ // 让调用方能把"还没解析"与"解析不出来"分开(两种零可区分)。
5348
+ const budget = roleReadBudgetSnapshot();
5349
+ json(200, Object.assign(
5350
+ { ok: true, runs, workspaces, cwd, agents: sel.agents, rolesDeferred: budget.deferred },
5351
+ sel,
5352
+ prof ? { profile: Object.assign({}, prof.steps, { rolesBudget: budget }) } : null,
5353
+ ));
5168
5354
  } catch (e) {
5169
5355
  json(500, { ok: false, error: String(e && e.message ? e.message : e) });
5170
5356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yangdcm/dsh-expert-team",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "description": "dsh「专家团」bundle:一句自然语言自动组建/持久化一支 12 角色多智能体团队,共享工作区协议 + 阶段门控编排 + 结构化交接 + 质量门禁/自动调度,实现者直接改代码并产出持久工件;带 live 团队浮层(质量门禁/覆盖率/工件预览)。 · Role-based multi-agent expert team for DeepSeek Harness: one sentence in, a staged and gated team delivery out.",
5
5
  "type": "module",
6
6
  "license": "MIT",