dsh-sidecard-ask 1.5.0 → 1.6.0

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
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0
4
+
5
+ **主题:最新版 DSH(0.2.1-alpha.2)适配 + 新旧包名共载时的单例护栏。**
6
+
7
+ 十版本真机矩阵(隔离 DSH_HOME、真实安装启动、HTTP/探针双证据)发现并修复:
8
+
9
+ - **0.2.1-alpha.2 侧边作答引擎失效(真问题)**:该版本把 `subagents.start(provider, request)`
10
+ 重构为受管激活 API `subagents.startActivation({provider, label, request, signal, delivery})`
11
+ (返回值由 `{id, localAgent, result, dispose}` 变为 `{childId, result, dispose}`)。
12
+ 旧代码的能力门只认 `start()`,导致 provider 明明注册成功(spawn/fork)却自检显示
13
+ 「侧边作答引擎:不可用」,侧答走降级。现按运行时方法面自动选择两代 API:新 API 用
14
+ `delivery: 'caller'` 保持"答案不进主对话"的旧语义,流式帧改由子会话 id 归因(新运行时不再
15
+ 提供 live Agent 句柄)。0.1.5-rc.3~0.2.1-alpha.1 的调用路径保持不变。
16
+ - **新旧包名同时启用时双重触发(真问题)**:`dsh-selection-followup`(≤1.0.1 旧名)与
17
+ `dsh-sidecard-ask` 被同时装入一个 profile 时,选区旁叠出两个悬浮按钮,一次 Alt+Q 弹两个
18
+ 提问框。现新增**页面内家族注册表**(`window.__dshSidecardFamily`):协作的副本按版本号选举
19
+ 唯一领导者(同版本先加载者胜),落选副本保留设置页(显示停用说明横幅)但不挂载任何交互面、
20
+ 不响应快捷键;无法协作的 1.0.x 旧版通过结构特征识别(无 owner 标记的 `dsa-layer`),
21
+ 领导者注入一条样式规则隐藏其整个交互层,并提示卸载旧包。
22
+ - **data-slot 锚点自检结果不稳定(真问题)**:旧逻辑用"当前选区是否落在锚点链内"反推
23
+ "本版本是否支持锚点",选区落在直接挂 body 的节点(引导弹窗、第三方裸 DOM 面板)时会把
24
+ 全局判定翻成"缺失",导致同一次自检两次结果不一致。改为构建级探测(全文档是否存在
25
+ `[data-slot]`),单个无锚点选区不再影响全局判定。
26
+
27
+ 客户端自检上报新增 `family` 字段(leader/legacy/peers),宿主侧白名单做同样的长度裁剪。
28
+
29
+ 本机实测:verify 53 / contract 323 / float-placement 38 / smoke 163 = **577 项全部通过**;
30
+ 另在 0.2.0-rc.2 与 0.2.1-alpha.2 真机(headless)复核:两版引擎均可用、共载单实例、
31
+ 锚点判定稳定、better-sidebar 0.25.0 承载面识别正常。
32
+
33
+ ## 1.5.1
34
+
35
+ **主题:1.5.0 的发布收尾——把发布闸门补全,并把校验记录进版本号。**
36
+
37
+ 功能与 1.5.0 **完全一致,没有任何行为改动**(`client.js` 与 `index.js` 只有版本号字符串不同,
38
+ 可用 sha256 逐字节核对)。这个补丁版存在的理由是发布流程本身:
39
+
40
+ - `README` 第十三节的发布闸门原先只列了 `verify` / `contract-test` / `smoke-test` 三个脚本,
41
+ **漏掉了 1.5.0 新增的 `float-placement`**——照那一节执行会漏跑浮层几何用例(1.5.0 发布时就漏跑了,
42
+ 事后才补上,38 项通过)。现已把 `node test/float-placement.mjs` 加进闸门,并把 `git tag v1.5.0`
43
+ 改成 `git tag v<package.json 的 version>`,避免照抄上一版的标签号。**1.5.0 的 npm 包内 README 是补全前的版本,这一版起才是补全后的。**
44
+ - 版本号三处同步到 1.5.1:`package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION`、`client.js` 的 `CLIENT_VERSION`
45
+ (`test/verify.mjs` 会同时断言这三处一致)。这样 npm 上的 `latest` 带上了完整的闸门说明,
46
+ 下一次发布不会再照着缺一行的清单执行。
47
+ - 1.5.0 的发布过程有个值得记的坑:`npm publish` 返回的是 **HTTP 202 "being processed"**,
48
+ 之后约 2 分钟 registry 文档仍显示 `latest: 1.4.0`、`...-1.5.0.tgz` 还是 404。
49
+ 这**不是失败**,不要立即重发(重发会撞版本已存在);等 2–5 分钟再查 `npm view dsh-sidecard-ask dist-tags` 即可。
50
+
51
+ 本机实测(四项均 exit 0):verify 53 / contract 296 / float-placement 38 / smoke 138 = **525 项全部通过**。
52
+
3
53
  ## 1.5.0
4
54
 
5
55
  **主题:浮层不再压住主对话——「A 胶囊 + C 右栏」定案,一个算宽度的纯函数 + 一个默认收起的形态。**
package/README.md CHANGED
@@ -392,11 +392,27 @@ node tools/compat-probe.mjs --json tools/.cache/matrix.json
392
392
  | 没有 `data-slot` 锚点 | 检测到选区但槽位路径为空 → 判定"区域锚点不可用",**停用区域过滤**(否则 `captureZones:chat` 会静默失效)并在设置页自检里标明 | `contract-test`:`shouldOffer(..., {anchors:false})` 断言放宽且返回 `zoneFiltering:'unavailable'` |
393
393
  | 路由 kind 不被接受 | `prefix` 注册失败 → 退化为**逐方法精确路由**(`state/ask/cancel/config/reset`),处理器不变 | 前缀路由由 smoke 全流程覆盖;退化分支为纯 fallback(未在真机触发过) |
394
394
 
395
- ### 7.3 还没做真机验证的部分(如实标注)
395
+ ### 7.3 真机验证记录(1.6.0,2026-10-11)
396
+
397
+ 1.6.0 把 §7.1/§7.2 的产物层证据升级为**真机证据**:最近 10 个发布版(0.1.5-rc.3 → 0.2.1-alpha.2,
398
+ 按 npm 发布时间)全部在隔离 `DSH_HOME` 中真实安装、启动并读取运行时能力面;
399
+ 0.2.0-rc.2(= npm `latest`)与 0.2.1-alpha.2(= npm `next`)另做无头浏览器真机验证。
400
+
401
+ - 0.1.5-rc.3 … 0.2.1-alpha.1:引擎可用,provider=spawn/fork,0 启动告警。
402
+ - **0.2.1-alpha.2:1.5.1 真机不可用 → 1.6.0 已修复**。该版本把子代理 API 从 `subagents.start(provider, request)`
403
+ 重构为 `subagents.startActivation({provider, label, request, signal, delivery})`,1.5.1 的能力门只认旧方法
404
+ (provider 其实注册成功,自检却显示"不可用")。1.6.0 按运行时方法面自动选择两代 API,新 API 用
405
+ `delivery:'caller'` 保持"答案不进主对话",流式帧按子会话 id 归因。
406
+ - 更早的 0.1.2-rc.1 … 0.1.4 仍只有产物层证据(§7.1/§7.2 的降级分支仍保留,双 API 调度不改动旧路径)。
407
+ - 生态侧:25 个侧边卡片/侧边栏类插件做了产物扫描 + 4 组共载真机,localStorage 键 / 路由前缀 / tab kind /
408
+ patch id / 配置目录 / Alt+Q 零硬冲突;旧名包 `dsh-selection-followup@1.0.1` 共载时的双重触发由
409
+ 1.6.0 的页面内家族单例选举解决。完整矩阵、复现脚本与截图见 `.compat-report/`。
410
+
411
+ ### 7.4 更早版本仍未做真机验证的部分(如实标注)
396
412
 
397
413
  - 上表所有 ✅/❌ 都是**包内容层面的证据**(字符串/签名存在于该版本的发布产物),不等于"插件在该版本上跑起来过"。
398
- 唯一跑过真机的是 **0.1.7-rc.2**(见 §10.1 的实测记录)。
399
- - 在 0.1.2-rc.1 … 0.1.6-alpha.2 这几个版本上,我只验证了"所需 API 是否存在 + 插件有为缺失准备的分支",
414
+ 真机覆盖为 0.1.5-rc.3 → 0.2.1-alpha.2(1.6.0 实测)与 0.1.7-rc.2(§10.1 的历史实测)。
415
+ - 在 0.1.2-rc.1 … 0.1.4 这几个版本上,只验证了"所需 API 是否存在 + 插件有为缺失准备的分支",
400
416
  **没有**在那些版本上安装并启动过插件。
401
417
  - `inputActions` 作为会话槽位标准 prop:13 个版本的 `dsh-client-ui-conversation` 产物里都有该名字,
402
418
  0.1.7-rc.2 的槽位目录也把它列为 `conversation.input.right` 的 standardProps;**更早版本是否真的把它下发给该槽位条目未验证**,
@@ -494,8 +510,8 @@ node test/smoke-test.mjs # 端到端:流式/截断/取消/持久化/失
494
510
  ```
495
511
 
496
512
  四个脚本都以 `process.exitCode` 反映结果,失败会列出具体条目;测试会把 `DSH_HOME` 指向临时目录,不会污染真实配置。
497
- 当前规模(1.5.0 本机实测,四项均 exit 0):verify **53** 项 + contract **296** 项 + float-placement **38** 项 +
498
- smoke **138** 项 = **525 项全部通过**。
513
+ 当前规模(1.6.0 本机实测,四项均 exit 0):verify **53** 项 + contract **323** 项 + float-placement **38** 项 +
514
+ smoke **163** 项 = **577 项全部通过**。
499
515
 
500
516
  > 1.5.0 之前这里还放过 `tools/balance-check.mjs` 与 `tools/i18n-check.mjs` 两个「替代检查」。
501
517
  > 它们已被**删除**:前者用「正则字面量 vs 除号」的启发式判断,把 8 个源文件全部误报为残损;
@@ -635,7 +651,9 @@ npm deprecate "<旧包名>@*" "Renamed to <新包名> - <原因>" # 旧名
635
651
  1. **区域归类是启发式的**:`data-slot` 名称来自 harness 自己;其它插件若在自己的面板里不再经由槽位渲染,
636
652
  该区域会被归为"其它区域"——用 `captureZones: auto` + `showInUnclassified: true`(默认)仍然可用,或改用 `captureZones` 限制。
637
653
  本机实测能识别:`conversation.*`(聊天)、`rightbar` / `sidebar.right.*` / 名字含 task·todo·schedule·team·job·plan 的面板(任务)。
638
- 2. **浏览器内的视觉与交互未在本机验证**:本工作区没有浏览器自动化工具,因此"按钮出现在选区旁""卡片逐字增长""浮层不挡操作"这些**只能由你按第九节点一遍**。
654
+ 2. **浏览器内的视觉与交互**:1.6.0 起用无头 Edge(Playwright,系统浏览器 `channel:'msedge'`)做真机验证,
655
+ 脚本见 `.compat-report/scripts/`,截图见 `.compat-report/shots/`——选区浮按钮、Alt+Q 弹层、共载单实例、
656
+ 引擎自检均已在 0.2.0-rc.2 与 0.2.1-alpha.2 上自动核对。桌面 Electron 的视觉观感仍只有 2026-09-30 的人工实测(§5.3)。
639
657
  已做的保障是:只使用 `--dsw-alias-*` 主题令牌、只在槽位内渲染(`shell.overlay` / `settings.section` / `conversation.input.right`)、不写 `document.body`、不 import harness 客户端包。
640
658
  2b. **1.5.0 的浮层改动:四个测试脚本已在本机实跑通过(各 exit 0),但页面里的观感仍未验证**:
641
659
  写这个版本的前半段,本工作区的 shell 工具整体不可用(任何命令都返回
package/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  * the Host and the module's `api.version` both read it, so a report can
48
48
  * never claim a generation the browser is not actually running.
49
49
  */
50
- const CLIENT_VERSION = '1.5.0'
50
+ const CLIENT_VERSION = '1.6.0'
51
51
 
52
52
  /**
53
53
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -294,6 +294,10 @@ window.__ModuleLoader__.load({
294
294
  none: '无',
295
295
  unsupportedTrigger: '无法识别该快捷键,请用形如 Alt+Q / Ctrl+Shift+K 的写法',
296
296
  surfaceUnavailable: '所选侧边承载面不可用',
297
+ familySuppressed: '检测到同系列更新版本({version})已在本页运行,本实例已自动停用划词触发与快捷键,界面由更新版本接管。建议在插件页卸载本旧版本。',
298
+ familyLegacyToast: '检测到旧版 dsh-selection-followup 同时启用,已由本版本接管;建议卸载旧包',
299
+ familyLegacyDiag: '旧版共载',
300
+ familyPeers: '同页实例',
297
301
  },
298
302
  en: {
299
303
  triggerLabel: 'Ask about selection',
@@ -430,6 +434,10 @@ window.__ModuleLoader__.load({
430
434
  none: 'none',
431
435
  unsupportedTrigger: 'Unrecognized shortcut — use a form like Alt+Q / Ctrl+Shift+K',
432
436
  surfaceUnavailable: 'The chosen side surface is unavailable',
437
+ familySuppressed: 'A newer family build ({version}) is already running on this page; this copy has disabled its trigger and shortcut so only one UI answers. Uninstall this older package.',
438
+ familyLegacyToast: 'Legacy dsh-selection-followup detected alongside this plugin; this build took over. Uninstall the old package.',
439
+ familyLegacyDiag: 'Legacy co-load',
440
+ familyPeers: 'Copies on page',
433
441
  },
434
442
  }
435
443
 
@@ -659,6 +667,12 @@ window.__ModuleLoader__.load({
659
667
  zoneAnchors: null,
660
668
  /** Which ladder rung each registration actually landed on. */
661
669
  slots: {},
670
+ /** §4.5 family election: this copy owns the interaction surface. */
671
+ familyLeader: true,
672
+ /** Other cooperating copies currently live in this page. */
673
+ familyPeers: [],
674
+ /** A non-cooperating legacy build (≤1.0.x) is present and suppressed. */
675
+ familyLegacy: false,
662
676
  revision: 0,
663
677
  },
664
678
  listeners: new Set(),
@@ -686,6 +700,94 @@ window.__ModuleLoader__.load({
686
700
  return store.state
687
701
  }
688
702
 
703
+ // ─────────────────────────────────────────────────────────────────────
704
+ // §4.5 family singleton
705
+ //
706
+ // The plugin shipped under two npm names (`dsh-selection-followup` ≤1.0.1
707
+ // and `dsh-sidecard-ask` ≥1.1.0), and a profile can accidentally enable
708
+ // BOTH bundles — or the same bundle twice. Two live copies used to mean
709
+ // two trigger buttons stacked on the selection and two Alt+Q popovers.
710
+ //
711
+ // Cooperating copies (any build with this section) share one registry on
712
+ // `window` and elect a single leader by version (ties → first loaded);
713
+ // non-leaders keep their settings page but mount NO interaction surface.
714
+ //
715
+ // The old 1.0.x build cannot cooperate: it is detected structurally (its
716
+ // overlay carries the same `dsa-layer` class without an owner marker), the
717
+ // leader hides its UI with one stylesheet rule, and the user is told to
718
+ // uninstall the old package.
719
+ // ─────────────────────────────────────────────────────────────────────
720
+
721
+ const FAMILY_WINDOW_KEY = '__dshSidecardFamily'
722
+ const INSTANCE_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
723
+ const OWNER_TOKEN = `${PLUGIN_ID}@${CLIENT_VERSION}#${INSTANCE_ID}`
724
+
725
+ /** Compare dotted numeric versions; non-numeric parts sort first. */
726
+ function compareVersions(a, b) {
727
+ const parts = v => String(v).split(/[.+-]/).map(part => (/^\d+$/.test(part) ? Number(part) : -1))
728
+ const pa = parts(a)
729
+ const pb = parts(b)
730
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
731
+ const x = pa[i] ?? -1
732
+ const y = pb[i] ?? -1
733
+ if (x !== y) return x < y ? -1 : 1
734
+ }
735
+ return 0
736
+ }
737
+
738
+ /**
739
+ * Elect the leader: highest version wins; a tie goes to the member that
740
+ * joined first (Map insertion order). Pure so the contract test can pin it.
741
+ * @returns {string} the winning member id.
742
+ */
743
+ function electFamilyLeader(members, selfId) {
744
+ let winner = null
745
+ for (const member of members) {
746
+ // Strictly greater version replaces; an equal version keeps the member
747
+ // that joined earlier (Map insertion order = load order).
748
+ if (winner === null || compareVersions(member.version, winner.version) > 0) {
749
+ winner = member
750
+ }
751
+ }
752
+ return winner?.id ?? selfId
753
+ }
754
+
755
+ /** Fetch-or-create the cross-copy registry (survives HMR on `window`). */
756
+ function familyTable(win) {
757
+ const existing = win[FAMILY_WINDOW_KEY]
758
+ if (existing !== undefined && existing !== null && typeof existing.subscribe === 'function') {
759
+ return existing
760
+ }
761
+ const members = new Map()
762
+ const listeners = new Set()
763
+ const emit = () => { for (const fn of [...listeners]) { try { fn() } catch { /* peer listener */ } } }
764
+ const table = {
765
+ join(member) { members.set(member.id, member); emit() },
766
+ leave(id) { members.delete(id); emit() },
767
+ list: () => [...members.values()],
768
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
769
+ }
770
+ try {
771
+ Object.defineProperty(win, FAMILY_WINDOW_KEY, { value: table, configurable: true, writable: true })
772
+ } catch {
773
+ win[FAMILY_WINDOW_KEY] = table
774
+ }
775
+ return table
776
+ }
777
+
778
+ /**
779
+ * Find legacy (≤1.0.x, non-cooperating) family UI: a `dsa-layer` carrying
780
+ * a trigger button but no owner marker. Every layer this build renders
781
+ * stamps `data-dsa-owner`, so an unmarked layer can only be the old build.
782
+ */
783
+ function findLegacyLayers(root) {
784
+ if (typeof root.querySelectorAll !== 'function') return []
785
+ try {
786
+ return [...root.querySelectorAll('.dsa-layer:not([data-dsa-owner])')]
787
+ .filter(node => node.querySelector?.('.dsa-trigger, .dsa-pop') !== null)
788
+ } catch { return [] }
789
+ }
790
+
689
791
  /**
690
792
  * In-session override of the configured floating-layer shape. Deliberately
691
793
  * NOT persisted: the settings page owns the durable default, and a reload
@@ -1210,6 +1312,12 @@ window.__ModuleLoader__.load({
1210
1312
  ...(surfaces.better.reason === null ? {} : { reason: String(surfaces.better.reason) }),
1211
1313
  features: surfaces.better.features,
1212
1314
  },
1315
+ // §4.5 family singleton verdict (host-side diagnostics only).
1316
+ family: {
1317
+ leader: state.familyLeader !== false,
1318
+ peers: Array.isArray(state.familyPeers) ? state.familyPeers : [],
1319
+ legacy: state.familyLegacy === true,
1320
+ },
1213
1321
  }
1214
1322
  }
1215
1323
 
@@ -1368,6 +1476,15 @@ window.__ModuleLoader__.load({
1368
1476
  .dsa-check{display:flex;align-items:center;gap:6px;font-size:12px}
1369
1477
  .dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
1370
1478
  color:var(--dsw-alias-label-secondary, #555)}
1479
+ /* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
1480
+ dsh-selection-followup ≤1.0.x build. When this build is the elected
1481
+ leader it hides the old build's whole interaction surface so exactly
1482
+ one trigger button / popover answers the selection. Our own layer always
1483
+ carries data-dsa-owner, so this never matches current-generation UI. */
1484
+ .dsa-layer:not([data-dsa-owner]){display:none!important}
1485
+ .dsa-family-notice{padding:8px 10px;border-radius:8px;font-size:12px;
1486
+ border:1px solid var(--dsw-alias-state-warn-primary, #b8860b);
1487
+ color:var(--dsw-alias-state-warn-primary, #b8860b)}
1371
1488
  `
1372
1489
 
1373
1490
  /** Render the stylesheet as a React element (removed with its component). */
@@ -1977,10 +2094,19 @@ window.__ModuleLoader__.load({
1977
2094
  /** The `shell.overlay` entry: styles + trigger + popover + cards + toast. */
1978
2095
  function OverlayRoot({ actions }) {
1979
2096
  const state = useStoreState()
2097
+ // §4.5 a non-leader copy mounts no interaction surface at all (its
2098
+ // settings page stays available to explain the situation).
2099
+ if (state.familyLeader === false) return null
1980
2100
  const config = state.config
1981
2101
  const candidate = state.trigger
1982
2102
  const popover = state.popover
1983
- return h('div', { className: 'dsa-layer', 'data-dsa-root': '' },
2103
+ return h('div', {
2104
+ className: 'dsa-layer',
2105
+ 'data-dsa-root': '',
2106
+ // §4.5 stamps ownership so the legacy-suppression stylesheet can
2107
+ // distinguish this build's layer from an unmarked ≤1.0.x layer.
2108
+ 'data-dsa-owner': OWNER_TOKEN,
2109
+ },
1984
2110
  h(Styles, null),
1985
2111
  candidate !== null && popover === null && config.trigger !== 'shortcut'
1986
2112
  ? h(TriggerButton, { candidate, onOpen: () => actions.openPopover(candidate) })
@@ -2060,11 +2186,20 @@ window.__ModuleLoader__.load({
2060
2186
  }
2061
2187
  }
2062
2188
 
2189
+ // §4.5 a non-leader copy explains why its interaction surface is gone.
2190
+ const familyBanner = state.familyLeader === false
2191
+ ? h('div', { className: 'dsa-family-notice', role: 'status' },
2192
+ t('familySuppressed', { version: (state.familyPeers[0]?.version) ?? '?' }))
2193
+ : (state.familyLegacy === true
2194
+ ? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
2195
+ : null)
2196
+
2063
2197
  return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
2064
2198
  h(Styles, null),
2065
2199
  h('div', null,
2066
2200
  h('div', { className: 'dsa-title' }, t('settingsTitle')),
2067
2201
  h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
2202
+ familyBanner,
2068
2203
  h('div', { className: 'dsa-grid' },
2069
2204
  field(t('setTrigger'), select('trigger', [
2070
2205
  ['selection', t('setTriggerSelection')],
@@ -2671,20 +2806,30 @@ window.__ModuleLoader__.load({
2671
2806
  // ── selection listeners ──────────────────────────────────────────
2672
2807
  const evaluate = () => {
2673
2808
  pendingTimer = null
2809
+ // §4.5 a non-leader copy must not surface its trigger, even if a
2810
+ // stale selection event arrives.
2811
+ if (store.state.familyLeader === false) return
2674
2812
  const config = store.state.config
2675
2813
  const candidate = readSelection()
2676
2814
  // Zone anchors: the harness stamps every slot outlet with
2677
- // `data-slot="<key>"`. A build that renders no such marker (or a
2678
- // selection made outside the frame's own tree) leaves the slot path
2679
- // empty, and then region filtering is impossible — reported once and
2680
- // relaxed rather than silently swallowing every trigger.
2681
- if (candidate !== null) {
2682
- const anchored = candidate.slotPath.length > 0
2683
- if (store.state.zoneAnchors !== anchored) {
2684
- store.set({ zoneAnchors: anchored })
2685
- if (!anchored) {
2686
- console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
2687
- }
2815
+ // `data-slot="<key>"`. This is a property of the BUILD, not of the
2816
+ // current selection — a selection on a body-mounted node (an
2817
+ // onboarding modal, another plugin's raw-DOM panel) has an empty
2818
+ // slot path even though the frame is full of anchors. Probe the
2819
+ // whole document instead, or one such selection would flip the
2820
+ // global verdict to "missing" and silently disable zone filtering.
2821
+ if (candidate !== null && store.state.zoneAnchors !== true) {
2822
+ const buildHasAnchors = candidate.slotPath.length > 0
2823
+ || (typeof document !== 'undefined' && document.querySelector('[data-slot]') !== null)
2824
+ if (buildHasAnchors) {
2825
+ if (store.state.zoneAnchors !== true) scheduleReport(0)
2826
+ store.set({ zoneAnchors: true })
2827
+ } else if (store.state.zoneAnchors === null && candidate.slotPath.length === 0) {
2828
+ // Only the build-wide miss (and never a single unanchored
2829
+ // selection) flips the verdict to unavailable.
2830
+ store.set({ zoneAnchors: false })
2831
+ scheduleReport(0)
2832
+ console.info(`[${PLUGIN_ID}] 该版本没有 data-slot 锚点,区域过滤不可用,已按"全部区域"工作`)
2688
2833
  }
2689
2834
  }
2690
2835
  const decision = shouldOffer(candidate, config, { anchors: store.state.zoneAnchors !== false })
@@ -2715,6 +2860,9 @@ window.__ModuleLoader__.load({
2715
2860
  }
2716
2861
 
2717
2862
  const onKeyDown = (event) => {
2863
+ // §4.5 only the leader answers the shortcut; otherwise two copies
2864
+ // would each preventDefault and each open their own popover.
2865
+ if (store.state.familyLeader === false) return
2718
2866
  const config = store.state.config
2719
2867
  const facts = parseShortcut(config.shortcut)
2720
2868
  if (matchesShortcut(event, facts)) {
@@ -3153,6 +3301,61 @@ window.__ModuleLoader__.load({
3153
3301
  // Corrupt-but-parseable history must never block startup.
3154
3302
  console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
3155
3303
  }
3304
+
3305
+ // §4.5 family singleton: register this copy, (re)elect on every join
3306
+ // or leave, and gate the interaction surface on the verdict.
3307
+ const family = familyTable(window)
3308
+ family.join({ id: INSTANCE_ID, version: CLIENT_VERSION, at: Date.now() })
3309
+ const reelect = () => {
3310
+ const members = family.list()
3311
+ const leaderId = electFamilyLeader(members, INSTANCE_ID)
3312
+ const leader = leaderId === INSTANCE_ID
3313
+ const peers = members
3314
+ .filter(member => member.id !== INSTANCE_ID)
3315
+ .map(member => ({ id: member.id, version: member.version }))
3316
+ store.set({ familyLeader: leader, familyPeers: peers })
3317
+ scheduleReport(0)
3318
+ if (leader) detectLegacy()
3319
+ }
3320
+ const unsubscribeFamily = family.subscribe(reelect)
3321
+
3322
+ // Legacy (≤1.0.x) detection: it never joins the registry, so watch
3323
+ // the overlay tree for an unmarked dsa-layer carrying family UI. The
3324
+ // suppression stylesheet then hides it; we only need detection to
3325
+ // raise the toast/report. Repeated matches are a no-op.
3326
+ let legacyAnnounced = false
3327
+ let legacyObserver = null
3328
+ const detectLegacy = () => {
3329
+ if (store.state.familyLeader !== true || legacyAnnounced === true) return
3330
+ const root = typeof document !== 'undefined' ? document : null
3331
+ if (root === null) return
3332
+ const found = findLegacyLayers(root).length > 0
3333
+ if (found === false) return
3334
+ legacyAnnounced = true
3335
+ // Inject the suppression rule directly: the React stylesheet only
3336
+ // exists while our overlay is mounted, but the legacy layer must
3337
+ // stay hidden even during its own re-renders.
3338
+ try {
3339
+ if (root.getElementById('dsa-family-legacy-style') === null) {
3340
+ const style = root.createElement('style')
3341
+ style.id = 'dsa-family-legacy-style'
3342
+ style.textContent = '.dsa-layer:not([data-dsa-owner]){display:none!important}'
3343
+ root.head?.appendChild(style)
3344
+ }
3345
+ } catch { /* React stylesheet already carries the same rule */ }
3346
+ store.set({ familyLegacy: true })
3347
+ toast(t('familyLegacyToast'))
3348
+ scheduleReport(0)
3349
+ }
3350
+ const legacyTimers = [300, 1200, 3000].map(delay => setTimeout(detectLegacy, delay))
3351
+ if (typeof document !== 'undefined' && typeof MutationObserver === 'function') {
3352
+ legacyObserver = new MutationObserver(() => detectLegacy())
3353
+ try {
3354
+ legacyObserver.observe(document.documentElement ?? document, { childList: true, subtree: true })
3355
+ } catch { /* document without a root yet — timers still cover it */ }
3356
+ }
3357
+
3358
+ reelect()
3156
3359
  void refreshState().catch(() => { /* the settings page shows the failure */ })
3157
3360
  // One report after the slot ladders have had time to settle, so the
3158
3361
  // Host ends up holding the CLIENT's real registration state.
@@ -3170,6 +3373,15 @@ window.__ModuleLoader__.load({
3170
3373
  pendingProofs.clear()
3171
3374
  if (toastTimer !== null) clearTimeout(toastTimer)
3172
3375
  if (reportTimer !== null) clearTimeout(reportTimer)
3376
+ for (const timer of legacyTimers) clearTimeout(timer)
3377
+ try {
3378
+ if (typeof document !== 'undefined' && document.getElementById) {
3379
+ document.getElementById('dsa-family-legacy-style')?.remove()
3380
+ }
3381
+ } catch { /* torn down */ }
3382
+ try { legacyObserver?.disconnect() } catch { /* already gone */ }
3383
+ try { unsubscribeFamily() } catch { /* registry torn down */ }
3384
+ try { family.leave(INSTANCE_ID) } catch { /* registry torn down */ }
3173
3385
  store.set({
3174
3386
  trigger: null,
3175
3387
  popover: null,
@@ -3222,6 +3434,9 @@ window.__ModuleLoader__.load({
3222
3434
  renderRichText,
3223
3435
  pickSurface,
3224
3436
  floatPlacement,
3437
+ compareVersions,
3438
+ electFamilyLeader,
3439
+ findLegacyLayers,
3225
3440
  localizeError,
3226
3441
  errorCodes: ERROR_CODES,
3227
3442
  resolveLanguage,
package/index.js CHANGED
@@ -42,7 +42,7 @@ export const name = 'dsh-sidecard-ask'
42
42
  export const inject = ['webServer']
43
43
 
44
44
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.5.0'
45
+ export const PLUGIN_VERSION = '1.6.0'
46
46
 
47
47
  /** Route prefix of the plugin's own API. */
48
48
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -542,10 +542,64 @@ function createSideEngine(ctx, configOf) {
542
542
  }
543
543
  }), 'sidecard-ask: durable chunk bridge')
544
544
 
545
- /** The optional subagents service, or undefined. */
545
+ /**
546
+ * The optional subagents service, or undefined.
547
+ *
548
+ * 0.2.1-alpha.2 replaced `subagents.start(provider, request)` with the
549
+ * managed-activations API `subagents.startActivation(spec)`; a build carries
550
+ * exactly one of the two launch methods, so either one means the service is
551
+ * present and launchable.
552
+ */
546
553
  function subagentsService() {
547
554
  const service = ctx.get('subagents')
548
- return service !== undefined && typeof service.start === 'function' ? service : undefined
555
+ if (service === undefined) return undefined
556
+ const launchable = typeof service.start === 'function' || typeof service.startActivation === 'function'
557
+ return launchable ? service : undefined
558
+ }
559
+
560
+ /** Whether this runtime speaks the managed-activations API. */
561
+ function usesActivationApi(service) {
562
+ return typeof service?.startActivation === 'function' && typeof service?.start !== 'function'
563
+ }
564
+
565
+ /**
566
+ * Start one child answer, normalizing both subagents API generations to the
567
+ * shape the stream bridge consumes (`{id, localAgent?, result, dispose}`).
568
+ *
569
+ * Old API (≤0.2.1-alpha.1): `start(provider, request)` returns a SubagentRun.
570
+ * New API (0.2.1-alpha.2+): `startActivation({provider, label, request,
571
+ * signal, delivery: 'caller'})` returns `{childId, result, dispose}`; the
572
+ * caller-delivery policy keeps the child out of the parent's visible
573
+ * conversation — the same independent-answer semantics the old run had.
574
+ *
575
+ * @returns {Promise<{id: string, localAgent?: object, result: Promise<object>,
576
+ * dispose: Function, api: 'start'|'activation'}>}
577
+ */
578
+ async function startSubagent(service, provider, fields) {
579
+ if (typeof service.start === 'function') {
580
+ const run = await service.start(provider, fields)
581
+ return { id: run.id, localAgent: run.localAgent, result: run.result, dispose: run.dispose, api: 'start' }
582
+ }
583
+ const activation = await service.startActivation({
584
+ provider,
585
+ label: fields.label,
586
+ delivery: 'caller',
587
+ signal: fields.signal,
588
+ request: {
589
+ prompt: fields.prompt,
590
+ parent: fields.parent,
591
+ ...(fields.persona === undefined ? {} : { persona: fields.persona }),
592
+ ...(fields.toolFilter === undefined ? {} : { toolFilter: fields.toolFilter }),
593
+ },
594
+ })
595
+ return {
596
+ id: activation.childId,
597
+ // No live Agent handle on the activation face; frame attribution falls
598
+ // back to the child session id (onFrame already matches that path).
599
+ result: activation.result,
600
+ dispose: () => activation.dispose(),
601
+ api: 'activation',
602
+ }
549
603
  }
550
604
 
551
605
  /** Registered subagent provider names (empty when the service is absent). */
@@ -825,7 +879,7 @@ function createSideEngine(ctx, configOf) {
825
879
  const personaSupported = caps?.persona === true
826
880
  const toolFilterSupported = caps?.toolFilter === true
827
881
  const promptText = personaSupported ? prompt : `${SIDE_PERSONA}\n\n${prompt}`
828
- const run = await service.start(provider, {
882
+ const run = await startSubagent(service, provider, {
829
883
  label: `划词追问:${question.slice(0, 40)}`,
830
884
  prompt: [{ type: 'text', text: promptText }],
831
885
  parent: parent.agent,
@@ -1133,6 +1187,22 @@ export function apply(ctx, patchConfig) {
1133
1187
  .map(feature => feature.slice(0, 40))
1134
1188
  }
1135
1189
  }
1190
+ // §4.5 family singleton verdict (bounded, same hardening as slots).
1191
+ if (raw.family !== null && typeof raw.family === 'object' && !Array.isArray(raw.family)) {
1192
+ out.family = {
1193
+ leader: flag(raw.family.leader) ?? true,
1194
+ legacy: flag(raw.family.legacy) ?? false,
1195
+ peers: Array.isArray(raw.family.peers)
1196
+ ? raw.family.peers
1197
+ .filter(peer => peer !== null && typeof peer === 'object')
1198
+ .slice(0, 8)
1199
+ .map(peer => ({
1200
+ id: short(peer.id, 64) ?? 'peer',
1201
+ ...(short(peer.version, 24) !== undefined ? { version: short(peer.version, 24) } : {}),
1202
+ }))
1203
+ : [],
1204
+ }
1205
+ }
1136
1206
  return out
1137
1207
  }
1138
1208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",