dsh-sidecard-ask 2.1.6 → 2.1.7

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.7
4
+
5
+ **主题:修复「技术方案评审团」在 0.2.1-rc.5(桌面 Electron 44.7.0 / 网页端同源)上复核出的 8 条缺陷。**
6
+
7
+ ### 修复
8
+
9
+ - **A1(中)页面重载后右侧栏留下关不掉的「幽灵面板」**。宿主会持久化栏布局,重载
10
+ 后把插件的 tab 还原出来;而「哪张卡拥有它」(`surfaces.native.cardId`)是进程内存,
11
+ 卡片本身也不持久化。旧归属门是「`cardId !== card.id` 就拒绝」,于是没有卡片认领、
12
+ 也永远关不掉,面板长期渲染「该卡片已关闭」。现在归属门改为**已知别的卡拥有才拒绝,
13
+ 无主时按 kind + 会话定位**;并在注册完成时、以及 900 ms 后各清扫一次无主的同 kind
14
+ tab(绝不碰其它插件的 kinds)。
15
+ - **A2(中)删除历史条目时孤儿 tab 不关**。`deleteRailTab` 与 `surfaces.better.close`
16
+ 在 `cardId === null` 时直接 return,删除后栏里留一块空面板。改为无主放行后,删除
17
+ 路径能真正清掉那张 tab。
18
+ - **A4(低)承载面标签标错宿主**。`CardHost` 过去读全局 `state.surface`;原生栏与
19
+ better 镜像同时存在时,better 栏里的卡会被标成「原生右栏」。两个宿主槽现在都显式
20
+ 传自己的 `surface`,标签优先采用它。
21
+ - **A8(低)设置页文案仍写死 `0.2.1-rc.3`**。只读开关与 preset 提示改为「0.2.x 宿主
22
+ 均不支持(`0.2.1-rc.3` 实测、`rc.5` 复核)」。
23
+ - **CANCEL(中)`/cancel` 接受不带 sessionId 的调用**。run 有归属会话时,旧逻辑只在
24
+ 「两边都非空且不等」才拒绝,于是不带 sessionId 的调用能中止任意会话的子代理。现在
25
+ 只要 run 知道归属,就要求调用方给出**相等**的 sessionId,否则拒绝;老客户端一直带
26
+ sessionId,不受影响。
27
+ - **AUTH-EDGE(低)`requestRejection` 的意外返回值被当成放行**。旧实现把任何非
28
+ 401/403 的值都当通过。现在只有 `undefined / null / false / 200` 算放行,其余按未
29
+ 认证(403)处理并留一条 warn;`connection` 服务缺失时的日志由 info 改为 warn
30
+ (网页端就是这个形态,等于「没有宿主级认证」,不该静音)。
31
+ - **MULTITAB(低)跨页历史被「最后写者」复活**。桌面窗口与浏览器各持一份 store、每次
32
+ 写整个数组:A 删掉一条后,B 的下一次同步会把它写回来。现在监听 `storage` 事件,
33
+ 别的页面写盘后立即采用磁盘上的权威集合。
34
+ - **承载面未证实的空面板(低)**。卡被判定「承载面未证实」迁回浮层时,栏里那张 tab
35
+ 没有关闭,留下「已移到浮层」的空面板。迁移时同时请求两个宿主关掉自己的 tab。
36
+
37
+ ### 评估后不改
38
+
39
+ - **run 没有归属会话时 `/cancel` 仍允许取消**。这类 run(宿主没给 sessionId、脚本
40
+ 直调)若一并拒绝,就只能等 120 s 超时才结束;归属会话已知时已经收紧,故保留。
41
+
42
+ ### 验证
43
+
44
+ `verify 57 / contract 1487 / float-placement 100 / smoke 283 = 1927 全绿`。本轮净增
45
+ 16 条断言:无主 tab 在注册时即被清扫且不碰其它 kind、删除历史条目时无主 tab 仍被
46
+ 关闭、承载面标签跟随宿主而非全局偏好、`/cancel` 拒绝错会话与缺会话。
47
+
3
48
  ## 2.1.6
4
49
 
5
50
  **主题:修复桌面端 0.2.1-rc.3 / 网页端 0.2.1-rc.4 与 0.2.2-alpha.2 上的 4 个承载面与会话归属缺陷。**
package/client.js CHANGED
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  * the Host and the module's `api.version` both read it, so a report can
118
118
  * never claim a generation the browser is not actually running.
119
119
  */
120
- const CLIENT_VERSION = '2.1.6'
120
+ const CLIENT_VERSION = '2.1.7'
121
121
 
122
122
  /**
123
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -707,9 +707,9 @@ window.__ModuleLoader__.load({
707
707
  setMinChars: '最小触发字符数',
708
708
  setConcurrency: '并发作答上限',
709
709
  setSideTools: '侧边作答工具权限',
710
- setSideToolsReadonly: '只读(需宿主支持,0.2.1 无效)',
710
+ setSideToolsReadonly: '只读(需宿主支持,0.2.x 无效)',
711
711
  setSideToolsInherit: '继承当前会话',
712
- setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.1-rc.3 不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1-rc.3 已支持)。',
712
+ setSideToolsHint: '这个开关只在宿主支持工具白名单时才生效;0.2.x 宿主(0.2.1-rc.3 实测、rc.5 复核)都不支持,侧边作答会继承当前会话的全部工具。要真正收窄,请填写下面的 preset(0.2.1 起支持)。',
713
713
  setSideAgentPreset: '只读 preset(可选)',
714
714
  setSideAgentPresetPlaceholder: '留空则不启用',
715
715
  setSideAgentPresetHint: '子代理以该 agent preset 启动,工具集由 preset 决定(0.2.1 唯一可靠的收窄通道);留空时退回探针拒写',
@@ -982,9 +982,9 @@ window.__ModuleLoader__.load({
982
982
  setMinChars: 'Minimum selection length',
983
983
  setConcurrency: 'Max concurrent answers',
984
984
  setSideTools: 'Side answerer tool access',
985
- setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.1)',
985
+ setSideToolsReadonly: 'Read-only (needs host support; no-op on 0.2.x)',
986
986
  setSideToolsInherit: 'Inherit from the session',
987
- setSideToolsHint: 'This switch only works when the host supports a tool allow-list; 0.2.1-rc.3 does not, so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported on 0.2.1-rc.3).',
987
+ setSideToolsHint: 'This switch only works when the host supports a tool allow-list; no 0.2.x host does (verified on 0.2.1-rc.3 and re-checked on rc.5), so the side answerer inherits every tool of the session. To really narrow it, set the preset below (supported since 0.2.1).',
988
988
  setSideAgentPreset: 'Read-only preset (optional)',
989
989
  setSideAgentPresetPlaceholder: 'Leave empty to disable',
990
990
  setSideAgentPresetHint: 'Starts the subagent with this agent preset, so the preset decides the tool set — the only reliable narrowing channel on 0.2.1; when empty the write-refusing probe is the fallback',
@@ -2368,7 +2368,12 @@ window.__ModuleLoader__.load({
2368
2368
  // tab 把 contentId 固定写成 `sidebar://<kind>`(不是卡片 id),所以
2369
2369
  // 过去用 `contentId === card.id` 判归属永远匹配失败、关闭恒失效
2370
2370
  // (2.1.5 R2 的回归)。kind 单地址模型下 `cardId` 才是唯一归属信号。
2371
- if (this.cardId !== card.id) return false
2371
+ // 严格归属(只认 `cardId === card.id`)治好了 2.1.5 R2 的"串卡",
2372
+ // 却把另一种真实场景关在门外:页面重载后宿主会从布局里恢复出这张
2373
+ // tab,而 `cardId` 是内存态、必然是 null —— 此时没有卡片认领它,
2374
+ // 删除历史条目也永远关不掉,就是专家复现的"幽灵面板"。
2375
+ // 规则改为:**已知别的卡拥有它才拒绝**;无主时按 kind + 会话定位。
2376
+ if (this.cardId !== null && this.cardId !== card.id) return false
2372
2377
  // 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
2373
2378
  const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2374
2379
  ? card.sessionId
@@ -2472,7 +2477,9 @@ window.__ModuleLoader__.load({
2472
2477
  close(card) {
2473
2478
  if (!this.available() || typeof this.service.closeTab !== 'function') return false
2474
2479
  // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
2475
- if (this.cardId !== card.id) return false
2480
+ // 同 native.close:无主(cardId 为 null,页面重载后)也要放行,
2481
+ // 否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
2482
+ if (this.cardId !== null && this.cardId !== card.id) return false
2476
2483
  const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
2477
2484
  ? card.sessionId
2478
2485
  : (store.state.sessionId ?? undefined)
@@ -6271,7 +6278,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6271
6278
  if (better.service === null || typeof better.service?.closeTab !== 'function') return
6272
6279
  // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
6273
6280
  // 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
6274
- if (better.cardId !== id) return
6281
+ // 无主(null)时也放行:理由见 better.close。
6282
+ if (better.cardId !== null && better.cardId !== id) return
6275
6283
  const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
6276
6284
  const mountedId = embeddedTabIds.get(id)
6277
6285
  if (typeof mountedId === 'string' && mountedId !== '') {
@@ -6302,12 +6310,44 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6302
6310
  const native = surfaces.native
6303
6311
  if (native === null || native === undefined) return
6304
6312
  if (native.controller === null || native.controller === undefined) return
6305
- if (native.cardId !== id) return
6313
+ // 无主(null)时也放行:理由见 native.close。
6314
+ if (native.cardId !== null && native.cardId !== id) return
6306
6315
  try {
6307
6316
  native.close({ id, sessionId })
6308
6317
  } catch { /* the rail owns its tab's lifetime */ }
6309
6318
  }
6310
6319
 
6320
+ /**
6321
+ * Close rail tabs that no live card can claim.
6322
+ *
6323
+ * The host persists the rail layout, so after a page reload our tab is
6324
+ * restored — but "which card owns it" (`surfaces.native.cardId` /
6325
+ * `surfaces.better.cardId`) is process memory, and cards themselves are
6326
+ * not persisted. The result is a panel that renders "card closed" and
6327
+ * that the old build could not even close (expert A1). When no card of
6328
+ * this page owns the address, the tab is provably orphaned: close it.
6329
+ */
6330
+ function sweepOrphanRailTabs() {
6331
+ const native = surfaces.native
6332
+ if (native !== null && native !== undefined && native.cardId === null
6333
+ && native.controller !== null && native.controller !== undefined) {
6334
+ let rows = []
6335
+ try {
6336
+ rows = typeof native.controller.openTabs?.getSnapshot === 'function'
6337
+ ? native.controller.openTabs.getSnapshot()
6338
+ : []
6339
+ } catch { rows = [] }
6340
+ for (const row of Array.isArray(rows) ? rows : []) {
6341
+ if (row?.kind !== CARD_KIND) continue
6342
+ if (typeof row.sessionId !== 'string' || typeof row.tabId !== 'string') continue
6343
+ try { native.controller.closeIn(row.sessionId, row.tabId) } catch { /* rail owns it */ }
6344
+ }
6345
+ }
6346
+ // better 栏刻意不在这里扫:插件的 tab 只有在宿主真的 mount 它时才
6347
+ // 存在,而 mount 是“当前页面有卡片”的证明;多窗口下另一个窗口的
6348
+ // 活跃卡片与本页共享同一个 session,盲扫会替它关掉 tab。
6349
+ }
6350
+
6311
6351
  const actions = {
6312
6352
  openPopover(candidate) {
6313
6353
  // P2-2: the composer must be remembered BEFORE the popover's
@@ -6554,6 +6594,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6554
6594
  if (renderedCardId === card.id) return
6555
6595
  store.set({ surface: 'flow', surfaceNote: t('surfaceUnproven') })
6556
6596
  patchCard(card.id, { surface: 'flow' })
6597
+ // 卡已经回到浮层,栏里那张 tab 就没有内容了;不关掉会留下
6598
+ // 一块"已移到浮层"的空面板,用户还得自己找它(专家 E)。
6599
+ try { surfaces.native.close(card) } catch { /* rail owns it */ }
6600
+ try { surfaces.better.close(card) } catch { /* host may be gone */ }
6557
6601
  toast(t('surfaceUnproven'))
6558
6602
  scheduleReport(0)
6559
6603
  }, 600)
@@ -7386,6 +7430,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7386
7430
  if (registry === null || registry === undefined) return
7387
7431
  surfaces.native.registry = registry
7388
7432
  surfaces.native.controller = controller ?? null
7433
+ // 布局恢复出来的 tab 没有卡片认领,注册完立刻清一次。
7434
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7389
7435
  // Register the tab TYPE first, then its body/title slots — and
7390
7436
  // roll the type back if a slot refuses to register.
7391
7437
  //
@@ -7418,7 +7464,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7418
7464
  slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
7419
7465
  name: 'sidebar.right.pane.tab',
7420
7466
  key: CARD_KIND,
7421
- }, props => h(CardHost, { props, actions }))))
7467
+ }, props => h(CardHost, { props, actions, surface: 'native-rightbar' }))))
7422
7468
  slotDisposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
7423
7469
  name: 'sidebar.right.pane.tab.title',
7424
7470
  key: CARD_KIND,
@@ -7494,9 +7540,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7494
7540
  component: props => h(CardHost, {
7495
7541
  props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
7496
7542
  actions,
7543
+ surface: 'better-sidebar',
7497
7544
  }),
7498
7545
  })
7499
7546
  surfaces.better.service = service
7547
+ // 同 native:注册完清掉无主的 better 栏 tab。
7548
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7500
7549
  surfaces.better.dispose = typeof off === 'function' ? off : null
7501
7550
  disposers.push(() => {
7502
7551
  try { surfaces.better.dispose?.() } catch { /* already disposed */ }
@@ -7525,12 +7574,17 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7525
7574
  const navigation = props?.tab?.navigation ?? props?.navigation
7526
7575
  const fromParams = navigation?.params?.cardId
7527
7576
  if (typeof fromParams === 'string' && fromParams !== '') return fromParams
7577
+ // 回退只对"宿主不给 tab 对象"的内置右栏成立(它本来就这么设计:
7578
+ // 导航参数不进 tab 记录)。better-sidebar 一定给 tab;它的 meta 里
7579
+ // 没有 cardId 时,这张 tab 不是卡片的,拿原生适配器的地址顶替会
7580
+ // 渲染出**另一张卡**(专家 A1-host 的跨面误伤)。
7581
+ if (props?.tab !== undefined && props.tab !== null) return undefined
7528
7582
  const recorded = surfaces.native.cardId
7529
7583
  return typeof recorded === 'string' && recorded !== '' ? recorded : undefined
7530
7584
  }
7531
7585
 
7532
7586
  /** Embedded card body used by both external hosts. */
7533
- function CardHost({ props }) {
7587
+ function CardHost({ props, surface }) {
7534
7588
  const state = useStoreState()
7535
7589
  const cardId = hostCardIdOf(props)
7536
7590
  const card = state.cards.find(item => item.id === cardId)
@@ -7578,7 +7632,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7578
7632
  h(Styles, null),
7579
7633
  h(MemoAnswerCard, {
7580
7634
  card,
7581
- surfaceLabel: state.surface === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
7635
+ // 标签说的是"这张卡现在挂在哪个承载面",与用户选的全局
7636
+ // 偏好无关:两个宿主可能同时存在(原生栏 + better 镜像),
7637
+ // 读 state.surface 会把 better 栏里的卡标成"原生右栏"(专家 A4)。
7638
+ surfaceLabel: (surface ?? state.surface) === 'better-sidebar' ? t('surfaceBetter') : t('surfaceNative'),
7582
7639
  onCopy: element => actions.copy(card, element),
7583
7640
  onFollowUp: text => actions.followUp(card, text),
7584
7641
  onRetry: () => actions.retry(card),
@@ -7602,6 +7659,35 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7602
7659
  console.warn(`[${PLUGIN_ID}] card history restore failed:`, error)
7603
7660
  }
7604
7661
 
7662
+ // The adapters register asynchronously (ctx.inject callbacks), so the
7663
+ // sweep above may run before the rail data exists; one delayed retry
7664
+ // covers a layout that is restored after this line.
7665
+ const orphanSweepTimer = setTimeout(() => {
7666
+ try { sweepOrphanRailTabs() } catch { /* best effort */ }
7667
+ }, 900)
7668
+ disposers.push(() => clearTimeout(orphanSweepTimer))
7669
+
7670
+ // Cross-tab history sync. Two pages (desktop window + browser) each
7671
+ // hold their own store and write the WHOLE array, so the last writer
7672
+ // wins: A deletes an entry, B still has it in memory, B's next write
7673
+ // resurrects it (expert MULTITAB). Adopting the on-disk envelope
7674
+ // whenever another page writes makes B's next sync start from the
7675
+ // authoritative set instead of its stale memory.
7676
+ let storageListener = null
7677
+ try {
7678
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
7679
+ storageListener = event => {
7680
+ if (event !== null && event !== undefined && typeof event.key === 'string'
7681
+ && event.key !== PERSIST_KEY) return
7682
+ try { store.set({ history: readPersisted() }) } catch { /* read defensively */ }
7683
+ }
7684
+ window.addEventListener('storage', storageListener)
7685
+ disposers.push(() => {
7686
+ try { window.removeEventListener('storage', storageListener) } catch { /* gone */ }
7687
+ })
7688
+ }
7689
+ } catch { /* a host without storage events simply has no cross-tab sync */ }
7690
+
7605
7691
  // §4.5 family singleton: register this copy, (re)elect on every join
7606
7692
  // or leave, and gate the interaction surface on the verdict.
7607
7693
  const family = familyTable(window)
package/index.js CHANGED
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
45
45
  export const inject = ['webServer']
46
46
 
47
47
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
48
- export const PLUGIN_VERSION = '2.1.6'
48
+ export const PLUGIN_VERSION = '2.1.7'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -2172,10 +2172,12 @@ function createSideEngine(ctx, configOf) {
2172
2172
  const record = runs.get(id)
2173
2173
  if (record === undefined) return false
2174
2174
  const owner = record.sessionId
2175
- if (typeof owner === 'string' && owner !== ''
2176
- && typeof sessionId === 'string' && sessionId !== ''
2177
- && owner !== sessionId) {
2178
- return false
2175
+ if (typeof owner === 'string' && owner !== '') {
2176
+ // 归属判定必须是"两边都在且相等":只要这个 run 知道自己在哪个会话,
2177
+ // 没带 sessionId(或带错)的调用方一律拒绝 —— 否则任何拿到 run id 的
2178
+ // 页面都能中止别的会话的子代理(专家 CANCEL)。老客户端一直带
2179
+ // sessionId,因此这条收紧不会打断既有调用方。
2180
+ if (typeof sessionId !== 'string' || sessionId === '' || owner !== sessionId) return false
2179
2181
  }
2180
2182
  record.abort()
2181
2183
  return true
@@ -2752,15 +2754,27 @@ export function apply(ctx, patchConfig) {
2752
2754
  const authRejection = req => {
2753
2755
  const connection = connectionService()
2754
2756
  if (connection === undefined) {
2757
+ // 专家 AUTH-EDGE:这是 WEB 端的正常形态(宿主没把 connection 服务
2758
+ // 暴露给插件),但它同时意味着"没有宿主级认证"—— 单条 info 等于把
2759
+ // 风险静音,改成 warn,且每个进程仍只记一次。
2755
2760
  if (!authGateNoted) {
2756
2761
  authGateNoted = true
2757
- ctx.logger?.info?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏')
2762
+ ctx.logger?.warn?.('[sidecard-ask] 未挂载 connection 服务:API 仅保留 loopback/Origin 围栏,无宿主级认证')
2758
2763
  }
2759
2764
  return undefined
2760
2765
  }
2761
2766
  try {
2762
2767
  const rejection = connection.requestRejection(req)
2763
- return rejection === 401 || rejection === 403 ? rejection : undefined
2768
+ if (rejection === 401 || rejection === 403) return rejection
2769
+ // 旧实现把"任何非 401/403 的值"都当放行,包括对象/字符串这类仲裁器
2770
+ // 可能用来表示"拒绝"的返回值(专家 AUTH-EDGE)。契约未公开,所以
2771
+ // 只在明确表示放行时放行:undefined/null/false/200;其余按未认证
2772
+ // 处理并留一条 warn,万一是契约误判能立刻被看见。
2773
+ if (rejection === undefined || rejection === null || rejection === false || rejection === 200) {
2774
+ return undefined
2775
+ }
2776
+ ctx.logger?.warn?.('[sidecard-ask] requestRejection 返回了意外的值,按未认证处理:', rejection)
2777
+ return 403
2764
2778
  } catch (error) {
2765
2779
  // Fail closed: an unreadable auth service must not widen the surface.
2766
2780
  ctx.logger?.warn?.('[sidecard-ask] 鉴权校验异常,按未认证处理:', error?.message ?? error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.6",
3
+ "version": "2.1.7",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",