dsh-expert-group 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client-bundle.js CHANGED
@@ -635,6 +635,62 @@
635
635
  * 保证长期运行的面板不会无界增长。
636
636
  */
637
637
  const SIDEBAR_AUTO_KEY_LIMIT = 128;
638
+ /**
639
+ * 宿主 close() 请求的 watchdog 上限。
640
+ *
641
+ * 宿主 closeIn 在 actions / surface 缺失、tab 不存在或 canCloseTab=false 时会静默 no-op,也可能
642
+ * 返回一个永不 settle 的 Promise。没有上限,Auto 的 busy 闸门与 pending 锁就会被一次宿主调用永久
643
+ * 占住(后续候选再也推进不了)。10 秒 = 四次 2.5 秒轮询:足够正常宿主结算,也足够在真实 UI 上
644
+ * 表现为「这一轮让位、下一轮重试」。
645
+ */
646
+ const SIDEBAR_TAB_CLOSE_WATCHDOG_MS = 10000;
647
+ /**
648
+ * close 请求 resolve 之后的验证窗口:resolve 只代表请求完成,绝不代表 tab 已经关闭。
649
+ *
650
+ * 验证只认 readSidebarOpenTabs() 的**精确 tabId** 结果,并沿用既有的
651
+ * SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS = 2 次连续缺失确认(2.5 秒轮询 ⇒ 正常验证窗口约 5 秒)。
652
+ * 10 秒是这条验证链的上限:超时后释放租约、保留 record / origin / attempt,由下一次同步决定重试。
653
+ */
654
+ const SIDEBAR_TAB_CLOSE_VERIFY_TIMEOUT_MS = 10000;
655
+ /**
656
+ * watchdog 超时后的迟到结果隔离窗。
657
+ *
658
+ * 宿主 Promise 不可取消:超时之后它仍可能迟到 resolve / reject。隔离窗至少跨过一次 2.5 秒
659
+ * inventory 采样,因此这段时间里同一个 tab 绝不再发第二次 close,迟到结果也就没有机会与新的
660
+ * 一次关闭重叠——它只能作用于仍匹配的旧租约(见 requestSidebarTabClose 的收口)。
661
+ *
662
+ * **这个窗口覆盖不到的边界**(必须由别的证据补齐,绝不能被它假装覆盖):
663
+ * 1. 它是**固定长度**,只保证「窗口内不重叠」——宿主可能在窗口释放之后才落地一次不可取消的副作用;
664
+ * 2. 它不知道这个 tabId 此刻承载的是谁——宿主可以把已经释放的 tabId 复用给**另一个资源**。
665
+ * 因此「重试之前必须用地址证据证明这个 tabId 仍然是我们要关的那个资源」是独立于本窗口的硬闸门
666
+ * (见 sidebarTabPresenceInInventory 的 contentId 判据与 closeSidebarTabWithLease 的 preflight):
667
+ * 本窗口只负责把迟到结果的落点钉死在旧租约上,绝不承担「id 没有被复用」这个假设。
668
+ */
669
+ const SIDEBAR_TAB_CLOSE_LATE_QUARANTINE_MS = 3000;
670
+ /**
671
+ * 自动关闭失败后的冷却慢车道:60 秒起,指数退避 60/120/240/480 秒,上限 15 分钟。
672
+ *
673
+ * 三次快速预算用尽(或「resolve 后 inventory 仍 present」累计三次)后**不再删除 origin**,而是
674
+ * 进入冷却:冷却期内同一个 key 零次 close 请求,origin / record 原样保留,到期后允许一次新的
675
+ * 真实 close(失败则退避到下一档)。
676
+ */
677
+ const SIDEBAR_AUTO_CLOSE_COOLDOWN_BASE_MS = 60000;
678
+ const SIDEBAR_AUTO_CLOSE_COOLDOWN_MAX_MS = 900000;
679
+
680
+ /**
681
+ * 关闭租约的四种结算原因。
682
+ *
683
+ * 等待者(用户触发的手动单项与批量)绝不能只看一个布尔「成功/失败」:同一个 tabId 上的两次
684
+ * 关闭可能属于**不同的 key**(宿主把已经释放的 id 回收给了另一个资源),而「上一次结算是
685
+ * recycled」对没有地址证据的等待者什么都不能证明。因此结算必须携带原因,等待者用自己的期望
686
+ * 地址重新探针复核之后再决定下一步(见 closeSidebarTabWithLease 的等待分支)。
687
+ * 布尔写法保留兼容:true ⇒ "closed"、false ⇒ "failed"。
688
+ */
689
+ const SIDEBAR_TAB_CLOSE_SETTLE_REASONS = new Set(["closed", "recycled", "failed", "timeout"]);
690
+ function closeSettleReason(outcome) {
691
+ if (SIDEBAR_TAB_CLOSE_SETTLE_REASONS.has(outcome) === true) return outcome;
692
+ return outcome === true ? "closed" : "failed";
693
+ }
638
694
 
639
695
  function object(value) {
640
696
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -868,6 +924,18 @@
868
924
  + String(rootSessionId) + SIDEBAR_TAB_KEY_SEPARATOR + String(resourceAddress);
869
925
  }
870
926
 
927
+ /**
928
+ * 「同一个 tab 的关闭在途」锁的复合键:sessionId + rootSessionId + 宿主 tabId。
929
+ *
930
+ * 与 sidebarTabKey(按资源地址记账)刻意不同:同一个资源地址在别的 session / 别的 root 里对应的是
931
+ * 另一个右栏 tab,只有 tabId 才能证明「宿主正在处理同一个 tab」。关闭的副作用作用在 tabId 上,
932
+ * 因此重复关闭的判据也只能是 tabId。
933
+ */
934
+ function sidebarTabCloseKey(sessionId, rootSessionId, tabId) {
935
+ return String(sessionId) + SIDEBAR_TAB_KEY_SEPARATOR
936
+ + String(rootSessionId) + SIDEBAR_TAB_KEY_SEPARATOR + String(tabId);
937
+ }
938
+
871
939
  function sessionEntries(value) {
872
940
  if (value == null) return [];
873
941
  if (Array.isArray(value)) {
@@ -2580,6 +2648,124 @@
2580
2648
  return { rootSessionId, snapshot: cached, loading: cached === null, error: null };
2581
2649
  };
2582
2650
 
2651
+ /**
2652
+ * 跨 Panel 实例共享的 Auto 账本(同一插件 install 内共享;重装插件或重启进程即随之消失)。
2653
+ *
2654
+ * 症状与根因:真实 DSH 的 keyed session surface 会卸载旧 Panel 实例、再用同一个已安装组件挂载
2655
+ * 新实例,而宿主右栏的 tab 与它自己那份 inventory 原样保留。这三份账本原先都建在 Panel 内的
2656
+ * useRef 上,卸载即丢失,因此新实例在 syncSidebarTabRecords() 的收养路径里只能把宿主里已经
2657
+ * 存在的 tab 记成 manual 来源(recordSidebarTab 的兜底),Auto 随后主动放弃关闭它——用户看到的
2658
+ * 就是「子代理已经空闲,Auto 开出来的那个 tab 却永远留在右栏」。
2659
+ *
2660
+ * 只共享回答「这个 tab 是不是 Auto 亲手打开、还归不归 Auto 管」所必需的集合:
2661
+ * - origin:Auto 亲手发出过打开动作的资源(键与打开记录同构);它同时是「绝不重复打开」的记账;
2662
+ * - suppressed:用户手动关闭过的资源,Auto 不得立刻重开;
2663
+ * - closeAttempts:自动关闭的有界重试计数(重挂载不得白送或重置预算);
2664
+ * - closeAttemptOwners:每条 closeAttempts 预算**当前所有者**的不可复用令牌(见下方说明)。
2665
+ *
2666
+ * 重试预算必须带所有者身份:预支与回滚分别发生在「候选选择」和「动作在副作用之前被撤销」两个
2667
+ * 时刻,中间可能跨越一次面板重挂载或同一个实例的 A→B→A 回代。预算本身按 key 共享,因此旧代次
2668
+ * 的迟到回滚若只按 key 写回 attemptsBefore,就会删掉/覆盖新代次已经预支的那一笔预算——同一个
2669
+ * key 的实际 close 次数随之突破 3 次上限。owner 账本把「这笔预算现在是谁的」也放进同一层共享
2670
+ * 状态,回滚只认对象身份,旧令牌一律 fail-safe。
2671
+ *
2672
+ * 其余状态一律留在 Panel 实例内:在途动作与预留(reservation)、Auto 代次(epoch)、失败屏障、
2673
+ * busy 闸门、tab 打开记录(records/openings)、手动意图都属于「当前这一帧 surface 的在途状态」,
2674
+ * 跨实例共享只会让旧实例的在途状态污染新实例。键始终是(sidebar session + rootSessionId +
2675
+ * 资源地址)复合键,因此共享也不会让不同 root / 不同 session 互相影响。
2676
+ */
2677
+ const sharedSidebarAutoOrigin = new Map();
2678
+ const sharedSidebarAutoSuppressed = new Map();
2679
+ const sharedSidebarAutoCloseAttempts = new Map();
2680
+ /**
2681
+ * 跨 Panel 实例共享的「这条自动关闭重试预算归谁」账本:key → 本次预支预算的**不可复用**令牌。
2682
+ *
2683
+ * 键与 closeAttempts 完全同构,取值是候选选择那一刻新建的对象(对象身份即所有权),因此任何
2684
+ * 旧代次手里的令牌都不可能与后来者的令牌相等。写入与删除与 closeAttempts 同步进行,并沿用同一
2685
+ * 个 trimAutoKeyMap/128 上限,绝不引入第二份无界 Map。
2686
+ */
2687
+ const sharedSidebarAutoCloseAttemptOwners = new Map();
2688
+ /**
2689
+ * 跨 Panel 实例共享的「close 请求已经发出、等待 inventory 验证」状态:key → 验证记录
2690
+ * { sessionId, rootSessionId, tabId, address, attemptToken, manual, misses, deadline, lease }。
2691
+ *
2692
+ * 关闭请求 resolve 之后绝不能立刻收口:记录只在**连续两次可读 inventory 都读不到这个精确 tabId**
2693
+ * 时才算 verified-closed。验证链因此必须与来源账本同层共享——重挂载后的新实例继续采样、继续收口,
2694
+ * 而不是把「请求完成」当成「已经关闭」。租约同时被这条记录持有,下一轮同步绝不会重复 close。
2695
+ */
2696
+ const sharedSidebarAutoCloseVerifications = new Map();
2697
+ /**
2698
+ * 跨 Panel 实例共享的自动关闭失败冷却:key → { step, until }。
2699
+ *
2700
+ * 与重试计数同层共享:scope remount 或同一个实例 A→B→A 绝不能绕过失败上限(否则频繁重挂载就等于
2701
+ * 无限重试)。generation token 只负责阻止旧动作越权,不改变这份预算的生命周期。
2702
+ */
2703
+ const sharedSidebarAutoCloseCooldowns = new Map();
2704
+ /**
2705
+ * 跨 Panel 实例共享的 Auto 候选采样计数:key → { sessionId, rootSessionId, tabId, misses }。
2706
+ *
2707
+ * 症状与根因:候选选择阶段的在场判据原先只采**一次**样。宿主 inventory 只要在这一帧短暂缺项
2708
+ * (布局换桶、面板重挂载、宿主提交窗口的中间态都是正常抖动),单次 absent 就会立刻删掉这个 key
2709
+ * 的 record / 来源 / 预算——tab 明明还在宿主里,却永久失去 Auto 归属,资源结束后再也不会被
2710
+ * 自动收回(用户看到的正是「偶尔漏收」)。记录同步器(record.misses)与关闭验证链
2711
+ * (verification.misses)都要求 SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS 次**连续**缺失,候选选择
2712
+ * 必须与它们同一条确认语义,否则同一份 inventory 在两处被解释成两种强度。
2713
+ *
2714
+ * - 生命周期:第一次 absent/recycled 采样时写入;任一 present 采样立刻清零(与既有语义一致);
2715
+ * 这个 key 的 Auto 关闭记账整体收口时(clearAutoCloseAttempt,覆盖预算 / owner / 冷却的
2716
+ * 所有入口)一并删除,因此绝不跨代残留;
2717
+ * - 计数只对**同一个 tabId** 有效:record 换成宿主新分配的 tabId 时旧计数自动作废;
2718
+ * - 限容:**纯 LRU**(按最近一次采样时间排序)+ SIDEBAR_AUTO_KEY_LIMIT,绝不新增无界 Map;
2719
+ * 绝不用当前实例的局部 record / 在途状态判定别的 scope 的共享条目(见 trimSharedSidebarAutoTabMisses)。
2720
+ */
2721
+ const sharedSidebarAutoTabMisses = new Map();
2722
+ /**
2723
+ * sharedSidebarAutoTabMisses 的限容淘汰:**纯 LRU**——按「最近一次采样」排序,淘汰最久没被采样的条目。
2724
+ *
2725
+ * 为什么这条 Map 绝不能用 trimAutoKeyMap(map, true) 那套「活动感知」淘汰:
2726
+ * 1. 生命周期不匹配。这份计数必须**跨 Panel 实例、跨 root / sidebar session 共享**(确认序列要在
2727
+ * 重挂载之后继续推进),而活动判定 sidebarAutoCloseKeyIsActive 读的是**当前这个实例**的局部
2728
+ * 在途状态(pending / opening / reservation / verification / cooldown)与**当前 scope** 的
2729
+ * record。别的 scope 的条目在当前实例里天然「不活动」,于是它们会在别的 scope 的一次普通采样
2730
+ * 写入时被当成历史条目淘汰——正是本插件要修的「偶尔漏收」:目标 key 的确认计数被打回 1,永远
2731
+ * 到不了 SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS,record / origin 保留但那个 tab 再也不自动收回。
2732
+ * 2. 采样计数本来就与在途状态无关:它只回答「这个 tabId 连续几次被**可读采样**判为不在场」。
2733
+ * 跨 scope 的条目同样需要完整数满两次,因此可淘汰性只能由「最后一次采样有多旧」决定,绝不能由
2734
+ * 当前帧的 record / 在途状态决定。
2735
+ *
2736
+ * 为什么 128 上限够用:确认只需 2 次采样,而**每次采样都会把这个 key 重新插到队尾**
2737
+ * (见 sampleAutoTabMisses),所以一个正在确认中的 key 只有在「它两次采样之间又插入了 128 次其它
2738
+ * 采样」时才会被挤掉;每个 surface 每轮至多推进自己的一条采样,真实轮询周期远小于这个量级。
2739
+ * 上限本身继续防止复合键(session + root + 资源地址)随长生命周期无界增长。
2740
+ */
2741
+ const trimSharedSidebarAutoTabMisses = () => {
2742
+ while (sharedSidebarAutoTabMisses.size > SIDEBAR_AUTO_KEY_LIMIT) {
2743
+ const oldest = sharedSidebarAutoTabMisses.keys().next();
2744
+ if (oldest.done === true) break;
2745
+ sharedSidebarAutoTabMisses.delete(oldest.value);
2746
+ }
2747
+ };
2748
+ /**
2749
+ * 跨 Panel 实例共享的「同一个 tab 上已经有一次关闭在途」租约:key =(sidebar session + root + tabId)
2750
+ * → 租约对象(身份 + 结算信号)。
2751
+ *
2752
+ * 症状与根因:宿主右栏的 close() 可能是异步的(返回 Promise,结算后才真的移除 tab)。旧 Panel 实例
2753
+ * 发出这次关闭后就被卸载了,但那次调用的续体仍在运行;而它的 pending 锁与 busy 闸门都是**实例级**
2754
+ * 的,新实例一概看不见——宿主 inventory 里这个 tab 还在,新实例收养它之后会为同一个 tabId 再发一次
2755
+ * close。宿主若非幂等(或复用了 tabId),用户就会看到重复副作用(关掉别人的 tab / 抛错)。
2756
+ *
2757
+ * 因此这把锁必须与来源账本同层共享:身份是(当前 sidebar session + root + tabId),Auto 单 tab 关闭、
2758
+ * 手动单 tab 关闭与批量关闭三个入口共用它;只在真正调用宿主 close 的那一小段持有,并在 finally 归还。
2759
+ * 锁只回答「这个 tab 现在有人在关吗」,不携带任何 Panel 实例状态。
2760
+ *
2761
+ * 值不是「无结果的 token」而是一份**可结算的租约**:它额外携带那一次 close 的真实结果(成功 / 失败)
2762
+ * 与一个等待信号。竞争方(用户触发的手动单项与批量)必须等到结算再决定下一步——上次成功就当作这个
2763
+ * tab 已经被关掉、绝不再 close;上次失败才允许用当前 inventory 复核同一个 tabId 后做一次有限重试。
2764
+ * 少了这层结果信号,等待者只能看到「锁空了」:要么对已经关掉的 tab 重复 close,要么把一次失败的
2765
+ * 竞争当成「有人处理了」而丢掉用户的关闭意图。
2766
+ */
2767
+ const sharedSidebarTabCloseInFlight = new Map();
2768
+
2583
2769
  function Panel(props) {
2584
2770
  const ctx = props?.ctx || config.ctx;
2585
2771
  let tabInfo = null;
@@ -2659,6 +2845,48 @@
2659
2845
  currentRootRef.current = rootSessionId;
2660
2846
  const currentSidebarSessionRef = typeof react.useRef === "function" ? react.useRef(sidebarSessionId) : { current: sidebarSessionId };
2661
2847
  currentSidebarSessionRef.current = sidebarSessionId;
2848
+ /**
2849
+ * surface 身份令牌:给每一条「跨 await 的异步动作」用的**不可复用**身份。
2850
+ *
2851
+ * 症状与根因:异步动作(自动打开/关闭、批量关闭、等待竞争租约)在 await 之后只用
2852
+ * mountedRef + currentRootRef + currentSidebarSessionRef 复核「当前 surface」。这三条判据
2853
+ * 比较的是**当前字符串值**,于是有一条真实交错会漏过去:同一个 Panel 实例快速
2854
+ * root/sidebar session A→B→A 之后,旧闭包捕获的字符串会与当前值**重新相等**——旧动作由此
2855
+ * 把自己的迟到收尾当成当前 surface 的收尾继续执行,删掉「A 里已经换成新 tabId 的 record /
2856
+ * Auto 来源」,并用旧快照推进同步。字符串判据挡得住「切走了没回来」,挡不住「切走又切回」。
2857
+ *
2858
+ * 因此每个 Panel 实例在挂载时生成一份唯一身份,并在 sidebarSessionId / rootSessionId 每一次
2859
+ * 变化时换成**新对象**(A→B→A 两次都换):异步动作启动时捕获自己那一份,await 之后只回答
2860
+ * 「还是不是我出发时那一份」。令牌必须比较对象身份,绝不能只比较当前字符串值。
2861
+ *
2862
+ * 生成时机:首次渲染(挂载)与「root/sidebar session 与上一帧不同」的那一帧。同一组值下的
2863
+ * 普通重渲染(轮询、通知、展开状态变化)与 StrictMode 的双渲染都不会换令牌(hook 槽位不变),
2864
+ * 因此正在执行的异步动作不会被误判成 stale;组件重挂载则必然换(新实例 = 新 ref 槽位)。
2865
+ */
2866
+ const surfaceTokenScopeRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
2867
+ const surfaceTokenRef = typeof react.useRef === "function" ? react.useRef(null) : { current: null };
2868
+ const surfaceTokenScope = surfaceTokenScopeRef.current;
2869
+ if (surfaceTokenScope === null
2870
+ || surfaceTokenScope.rootSessionId !== rootSessionId
2871
+ || surfaceTokenScope.sidebarSessionId !== sidebarSessionId) {
2872
+ surfaceTokenScopeRef.current = { rootSessionId, sidebarSessionId };
2873
+ surfaceTokenRef.current = { rootSessionId, sidebarSessionId };
2874
+ }
2875
+ /** 本帧闭包捕获的 surface 身份:所有异步动作在启动时捕获它,await 之后只与这一份比较。 */
2876
+ const surfaceToken = surfaceTokenRef.current;
2877
+ /**
2878
+ * 异步动作的 surface 闸门:mounted / root / sidebar session 三条既有判据**同时**成立,
2879
+ * 并且令牌仍然是出发时那一份,才允许继续。
2880
+ *
2881
+ * 两组判据缺一不可:字符串判据负责「切走了、没回来」与「别的 root 的同地址记录」,令牌负责
2882
+ * 「切走了又切回同一组值」(A→B→A)与「重挂载后的旧闭包」。await 之后、旧闭包调用
2883
+ * syncSidebarTabs() 之前、以及会改共享 Auto 账本 / 记录 / 手动抑制的迟到收尾分支,都必须先过
2884
+ * 这一道闸门。
2885
+ */
2886
+ const sidebarSurfaceStillCurrent = () => mountedRef.current === true
2887
+ && currentRootRef.current === rootSessionId
2888
+ && currentSidebarSessionRef.current === sidebarSessionId
2889
+ && surfaceTokenRef.current === surfaceToken;
2662
2890
  const refreshStateRef = typeof react.useRef === "function"
2663
2891
  ? react.useRef({ rootSessionId: null, inFlight: null, pending: false })
2664
2892
  : { current: { rootSessionId: null, inFlight: null, pending: false } };
@@ -2811,10 +3039,30 @@
2811
3039
  * (手动意图始终优先)。
2812
3040
  * 两个集合都按当前快照与上限清理(见 syncAutoSidebarTabs 的清理阶段)。
2813
3041
  */
2814
- const sidebarAutoOriginRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
2815
- const sidebarAutoSuppressedRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
2816
- /** 自动关闭的重试记账:key → 已尝试次数(成功或资源重新变成运行中时清空)。 */
2817
- const sidebarAutoCloseAttemptsRef = typeof react.useRef === "function" ? react.useRef(new Map()) : { current: new Map() };
3042
+ // 这几个集合指向 createExpertGroupPanel 闭包里的共享账本:重挂载后的新实例靠它认领旧实例开出的
3043
+ // tab(见 sharedSidebarAutoOrigin 的说明)。ref 只是持有者,读写结构逐字不变。
3044
+ const sidebarAutoOriginRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoOrigin) : { current: sharedSidebarAutoOrigin };
3045
+ const sidebarAutoSuppressedRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoSuppressed) : { current: sharedSidebarAutoSuppressed };
3046
+ /** 自动关闭的重试记账:key → 已尝试次数(成功或资源重新变成运行中时清空);跨重挂载保留。 */
3047
+ const sidebarAutoCloseAttemptsRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoCloseAttempts) : { current: sharedSidebarAutoCloseAttempts };
3048
+ /**
3049
+ * 自动关闭重试预算的所有者账本:key → 本次预支预算的不可复用令牌;与重试计数同层共享。
3050
+ *
3051
+ * 只有「当前 owner 就是自己出发时那一份令牌」的动作才允许回滚这次预支(见 releaseAutoCloseAttempt),
3052
+ * 因此旧代次在副作用之前被撤销时绝不可能删掉/覆盖新代次已经预支的预算。
3053
+ */
3054
+ const sidebarAutoCloseAttemptOwnersRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoCloseAttemptOwners) : { current: sharedSidebarAutoCloseAttemptOwners };
3055
+ /** 关闭请求的验证记录(跨重挂载保留:验证由后续任意一次同步按精确 inventory 完成)。 */
3056
+ const sidebarAutoCloseVerificationsRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoCloseVerifications) : { current: sharedSidebarAutoCloseVerifications };
3057
+ /** 自动关闭失败冷却账本(跨重挂载保留:remount 绝不能绕过失败上限)。 */
3058
+ const sidebarAutoCloseCooldownsRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoCloseCooldowns) : { current: sharedSidebarAutoCloseCooldowns };
3059
+ /**
3060
+ * Auto 候选采样到的「这个 tab 连续不在场」计数(跨重挂载保留,见 sharedSidebarAutoTabMisses)。
3061
+ *
3062
+ * 与来源 / 抑制 / 预算同层共享:一次重挂载绝不能把「已经确认缺失一次」重置成「还没采样过」,
3063
+ * 否则同一条确认语义会被 remount 反复打断(那正是「单次缺失即收口」的另一种写法)。
3064
+ */
3065
+ const sidebarAutoTabMissesRef = typeof react.useRef === "function" ? react.useRef(sharedSidebarAutoTabMisses) : { current: sharedSidebarAutoTabMisses };
2818
3066
  /**
2819
3067
  * 自动打开的**预留**(reservation):key → { sessionId, rootSessionId, address, epoch }。
2820
3068
  *
@@ -3604,6 +3852,12 @@
3604
3852
  * - contentId 精确等于集合中的地址:同一个 kind 下也可能是另一个子代理的地址。
3605
3853
  *
3606
3854
  * 结果按 tabId 去重:宿主 inventory 理论上可能有重复记录,重复 tabId 只能关一次。
3855
+ *
3856
+ * 候选携带 { tabId, address } **两个字段**,而不是只带 tabId:候选集合是点击那一刻的渲染
3857
+ * 快照,而真正调用 close() 之前可能已经跨过若干次 await(前一个候选的 close、宿主的提交)。
3858
+ * 宿主可以把一个已经释放的 tabId 回收给**另一个资源**——只按 tabId 执行就等于盲关别人的 tab。
3859
+ * 地址与 tabId 一起进候选,执行时才能用同一个地址做「这个 id 此刻仍然是我们要关的那个资源」
3860
+ * 的复核(见 handleSidebarBatchClose 与 sidebarTabPresenceInInventory 的 contentId 判据)。
3607
3861
  */
3608
3862
  const sidebarBatchCandidates = (addresses, inventory) => {
3609
3863
  const seen = new Set();
@@ -3621,7 +3875,7 @@
3621
3875
  if (contentId === null || addresses.has(contentId) !== true) continue;
3622
3876
  if (seen.has(tabId) === true) continue;
3623
3877
  seen.add(tabId);
3624
- candidates.push(tabId);
3878
+ candidates.push({ tabId, address: contentId });
3625
3879
  } catch { continue; }
3626
3880
  }
3627
3881
  return candidates;
@@ -3654,9 +3908,17 @@
3654
3908
  /**
3655
3909
  * 两行批量状态的合并签名:只在候选集合真的变化时触发一次重渲染(空闲轮询绝不空转重渲染)。
3656
3910
  * 两行必须一起进签名——只跟踪主代理行会让空闲分组的手动开/关 tab 迟迟不被反映。
3911
+ *
3912
+ * 候选是 { tabId, address }:签名必须把**两个字段**都编码进去。只取 tabId 会让「同一个 id 换了
3913
+ * 资源地址」这种真实变化被当成没变化(按钮计数不变,但该关的目标已经变了);直接 join 对象则退化
3914
+ * 成 "[object Object]"——两种都会让签名失去意义。分隔符用 NUL,与复合键同一个约定。
3657
3915
  */
3658
3916
  const sidebarBatchSignatureOf = (runningState, idleState) => {
3659
- const part = (state) => (state.kind === "close" ? "close:" + state.candidates.join(",") : state.kind);
3917
+ const part = (state) => (state.kind === "close"
3918
+ ? "close:" + state.candidates
3919
+ .map((candidate) => candidate.tabId + SIDEBAR_TAB_KEY_SEPARATOR + candidate.address)
3920
+ .join(",")
3921
+ : state.kind);
3660
3922
  return part(runningState) + "|" + part(idleState);
3661
3923
  };
3662
3924
 
@@ -3726,15 +3988,39 @@
3726
3988
  * 再被后续同步当成「还没确认的打开」而重建关闭记录(用户明明已经收回了这个 tab)。因此这里
3727
3989
  * 一并删除该 key 的 opening 记录;意图缺失时它是一次幂等空操作。
3728
3990
  */
3729
- const cancelPendingSidebarSplit = (reason) => {
3991
+ const cancelPendingSidebarSplit = (reason, expectedIntentToken) => {
3730
3992
  const intent = sidebarSplitIntentRef.current;
3731
3993
  if (intent === null) return;
3994
+ /**
3995
+ * 可选的令牌闸门:显式传入令牌的调用方(关闭收口)必须与当前意图的**不可复用令牌**逐对象
3996
+ * 相等才允许取消;不传令牌的调用方(卸载、超时、动作被撤销、打开失败)保持既有语义——它们
3997
+ * 手里的意图就是它们此刻正在处理的那一份。null 是**有效**的期望值:它只可能来自「出发时根本
3998
+ * 没有意图」,此刻槽位里的任何意图都必然是后来者,一律不取消。
3999
+ */
4000
+ if (expectedIntentToken !== undefined && intent.token !== expectedIntentToken) return;
3732
4001
  sidebarSplitIntentRef.current = null;
3733
4002
  // 意图里带着 key,收口范围因此精确到这一次点击,绝不误伤别的行的在途记录。
3734
4003
  sidebarTabOpeningRef.current.delete(intent.key);
3735
4004
  if (cancelSidebarSplit === null) return;
3736
4005
  try { cancelSidebarSplit.call(config, intent.token, { reason }); } catch { /* 收口失败不能阻断面板同步。 */ }
3737
4006
  };
4007
+ /**
4008
+ * 关闭收口专用的 split 事务取消:只有当前意图**仍然是这次关闭出发时看到的那一份**才允许取消。
4009
+ *
4010
+ * 特征判据必须是比较意图的**不可复用令牌**(每次 beginSidebarSplit 新建的对象身份),而不是
4011
+ * 只比 key:split 意图槽位在一个实例里只有一份,同一个 key 上的新一次打开会把它整份替换掉,
4012
+ * 旧 close 的迟到收口(尤其 verified-recycled)若按 key 取消,取消的就是新 generation 的
4013
+ * split/open 事务——右栏从临时 50% 掉回、新 pane 的打开失去归属,而那次打开与这次关闭毫无关系。
4014
+ *
4015
+ * 令牌为 null(出发时根本没有意图)时一律不取消:它只可能属于后来者。
4016
+ */
4017
+ const cancelPendingSidebarSplitOfClose = (key, splitIntentToken) => {
4018
+ const intent = sidebarSplitIntentRef.current;
4019
+ if (intent === null || intent.key !== key) return;
4020
+ if (splitIntentToken === null || splitIntentToken === undefined) return;
4021
+ if (intent.token !== splitIntentToken) return;
4022
+ cancelPendingSidebarSplit("close", splitIntentToken);
4023
+ };
3738
4024
 
3739
4025
  /**
3740
4026
  * 只有 inventory 与 DOM 同时证明目标资源位于本次返回的 pane,才结束临时扩栏事务。
@@ -3783,8 +4069,26 @@
3783
4069
  const openings = sidebarTabOpeningRef.current;
3784
4070
  const now = Date.now();
3785
4071
  const openingResolved = [];
3786
- if (records.size === 0) {
4072
+ /**
4073
+ * 初始收养的判据是「当前(sidebar session + root)还没有任何打开记录」,而不是全局
4074
+ * records.size:同一个 Panel 实例会按新的 props.scope 切换到另一个 root,前一个 root
4075
+ * 遗留的记录绝不能让当前 root 跳过这一步——否则宿主里已经存在的 tab 永远收不到记录,
4076
+ * Auto 也就再也认不出自己打开过的 tab(箭头停在 >>,资源结束后也不会自动关闭)。
4077
+ *
4078
+ * 记录键始终是(sidebar session + root + 地址)复合键,因此按 scope 收养不会覆盖或
4079
+ * 误伤别的 root 的记录。tabId 一律当场从当前 openTabs inventory 现读(见 recordSidebarTab),
4080
+ * 绝不跨重挂载缓存 tabId:宿主的 tabId 只由它自己那份 inventory 证明。
4081
+ */
4082
+ let scopedRecordExists = false;
4083
+ for (const record of records.values()) {
4084
+ if (record.sessionId === sidebarSessionId && record.rootSessionId === rootSessionId) {
4085
+ scopedRecordExists = true;
4086
+ break;
4087
+ }
4088
+ }
4089
+ if (scopedRecordExists !== true) {
3787
4090
  for (const resourceAddress of knownSidebarResourceAddresses()) {
4091
+ if (sidebarAdoptionEvidenceAvailable(resourceAddress) !== true) continue;
3788
4092
  const key = sidebarTabKey(sidebarSessionId, rootSessionId, resourceAddress);
3789
4093
  if (recordSidebarTab(key, resourceAddress)) openings.delete(key);
3790
4094
  }
@@ -3863,14 +4167,37 @@
3863
4167
  const inventory = readSidebarOpenTabs();
3864
4168
  if (inventory === null) return { readable: false, tabId: null };
3865
4169
  for (const entry of inventory) {
3866
- const candidate = object(entry);
3867
- if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
3868
- if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
3869
- if (text(candidate.contentId, null) !== resourceAddress) continue;
3870
- return { readable: true, tabId: text(candidate.tabId, null) };
4170
+ /**
4171
+ * 单条畸形条目(属性 getter 抛错)只跳过它自己:它既证明不了资源已打开,也不代表整份
4172
+ * inventory 不可读,因此绝不让它把异常抛到同步链路上、也绝不因此改变 readable 的语义。
4173
+ */
4174
+ try {
4175
+ const candidate = object(entry);
4176
+ if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
4177
+ if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
4178
+ if (text(candidate.contentId, null) !== resourceAddress) continue;
4179
+ return { readable: true, tabId: text(candidate.tabId, null) };
4180
+ } catch { continue; }
3871
4181
  }
3872
4182
  return { readable: true, tabId: null };
3873
4183
  };
4184
+ /**
4185
+ * 收养路径(面板重挂载 / 切换 root 后从宿主重建记录)的证据门槛。
4186
+ *
4187
+ * 收养没有「本次动作」这一上下文,而 recordSidebarTab 的 active() 兜底读面**不带 session**:
4188
+ * 同一个 root 的 child surface 是独立右栏,它自己的 active tab 不能证明当前 session 已经打开
4189
+ * 了这个资源。若在 inventory 可读时也接受兜底,新 Session 就会借用旧 Session 的 tabId(<< 指向
4190
+ * 别人的 tab,点击会关闭别的栏里的会话)。
4191
+ *
4192
+ * 因此:inventory 可读 ⇒ 必须由**当前 session** 的条目证明;只有 inventory 不可读(旧宿主 /
4193
+ * 读取抛错)才沿用 active() 这一唯一证据——那条路径的既有契约是「外部已存在的 tab 记为手动
4194
+ * 来源,Auto 既不接管也不关闭」(见 recordSidebarTab 与 Auto 的相关用例)。
4195
+ */
4196
+ const sidebarAdoptionEvidenceAvailable = (resourceAddress) => {
4197
+ const state = sidebarResourceTabStateOf(resourceAddress);
4198
+ if (state.readable !== true) return true;
4199
+ return state.tabId !== null;
4200
+ };
3874
4201
  /**
3875
4202
  * active() 是否已经是这个资源(kind 与 contentId 同时匹配)。
3876
4203
  *
@@ -3920,9 +4247,12 @@
3920
4247
  // 只处理当前 surface 的记账:别的 session/root 的键在当前快照里无法判定,绝不越权操作。
3921
4248
  if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
3922
4249
  if (running.has(entry.address) === true) {
3923
- // 资源重新变成运行中:它已经开着就保持开着,重试记账一并清零。
3924
- attempts.delete(key);
4250
+ // 资源重新变成运行中:它已经开着就保持开着,重试预算、owner 身份与失败冷却一并清零。
4251
+ // **绝不**丢弃仍在验证中的关闭:那条链属于「已经发出的一次关闭」,它的收口(删记录、
4252
+ // 撤销来源 / 登记抑制)与重试预算无关;丢掉它会让 record 与箭头永久停在 <<。
4253
+ clearAutoCloseBudget(key);
3925
4254
  continue;
4255
+
3926
4256
  }
3927
4257
  const record = records.get(key);
3928
4258
  const scopedRecord = record !== undefined
@@ -3936,19 +4266,75 @@
3936
4266
  if (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId) continue;
3937
4267
  if (sidebarTabPendingRef.current.has(key) === true) continue;
3938
4268
  origin.delete(key);
3939
- attempts.delete(key);
4269
+ clearAutoCloseAttempt(key);
3940
4270
  continue;
3941
4271
  }
3942
4272
  if (scopedRecord.origin !== SIDEBAR_TAB_ORIGIN_AUTO) {
3943
4273
  // 记录已经被手动接管(用户手动打开或外部 tab):手动优先,撤销自动来源,绝不关闭它。
3944
4274
  origin.delete(key);
3945
- attempts.delete(key);
4275
+ clearAutoCloseAttempt(key);
4276
+ continue;
4277
+ }
4278
+ /**
4279
+ * 同一个 tab 上已经有一次关闭在途(典型:旧 Panel 实例的 close Promise 还没结算,当前实例
4280
+ * 刚刚从宿主 inventory 收养了它):这一轮直接让位,绝不排队第二次关闭。
4281
+ *
4282
+ * 这里既不推进重试计数、也不撤销来源——那次关闭的成败由发起它的链路按成功路径结算;当前
4283
+ * 轮次只是「有人在关,别插手」。真正的执行点还会再确认一次(候选选择与执行之间隔着一次
4284
+ * 微任务跳转与一次可能的组件重挂载,见 handleSidebarAction)。
4285
+ */
4286
+ if (sidebarTabCloseInFlight(scopedRecord.tabId) === true) continue;
4287
+ /**
4288
+ * 宿主的公开 inventory 明确读到这个 tabId 不在(absent),或已经被回收给**另一个资源**
4289
+ * (recycled:条目暴露的 contentId 与本 key 的资源地址不一致)——两者都必须先过
4290
+ * 「连续两次可读采样」的确认闸门,才允许收口这个 key 的归属:
4291
+ *
4292
+ * 单次缺项**不构成**「tab 已经不在」的证据。宿主 inventory 在这类时机短暂缺项是正常抖动
4293
+ * (布局换桶、面板重挂载、提交窗口的中间态),而单次 absent 就删掉 record / 来源 / 预算等于
4294
+ * 让一个仍然开着的 tab 永久脱离 Auto(正是用户报的「偶尔漏收」);单次 recycled 更是把一次
4295
+ * 读取抖动升格成「旧 tab 已消失」,会顺手清掉冷却与预算。记录同步器与关闭验证链都要求
4296
+ * SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS 次连续缺失,候选选择必须与它们同一条强度。
4297
+ *
4298
+ * 计数语义(见 sharedSidebarAutoTabMisses):
4299
+ * - present:计数清零,照旧继续走关闭路径;
4300
+ * - absent / recycled:累加连续计数,未达确认次数时**保留** record / 来源 / 预算 / 冷却,
4301
+ * 本轮既不关闭也不收口(下一轮可读采样继续确认);
4302
+ * - unreadable(旧宿主只有 active()):既不证明存在也不证明不存在,绝不推进计数——保持既有
4303
+ * 语义,仍按已确认记录发起关闭,由 closeSidebarTabWithLease 的 preflight fail-closed。
4304
+ *
4305
+ * 确认收口时两种证据的收口范围与旧实现逐字一致:absent 清预算(保留冷却),recycled 连冷却
4306
+ * 一起清(这个 key 的 tab 已经不存在,一条永远等不到自己 tab 的冷却只会白白屏蔽真实关闭)。
4307
+ * 别的 key 的记录不在本次收口范围内:每个 key 只按自己的地址与自己的 record 结算。
4308
+ */
4309
+ const recordPresence = sidebarTabPresenceInInventory(scopedRecord.tabId, entry.address);
4310
+ const recordMissing = recordPresence === "absent" || recordPresence === "recycled";
4311
+ if (recordMissing === true) {
4312
+ // 还没到连续两次的确认次数:本轮保留 record / 来源 / 预算 / 冷却,既不关闭也不收口。
4313
+ if (sampleAutoTabMisses(key, scopedRecord.tabId, true) < SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS) continue;
4314
+ } else if (recordPresence === "present") {
4315
+ // 任一正向证据清零,与记录同步器「正向证据清零」逐字同源。
4316
+ sampleAutoTabMisses(key, scopedRecord.tabId, false);
4317
+ }
4318
+ // 落到这里的其余情况只有 unreadable:既不推进计数也不清零——读不到既不证明存在也不证明不存在,
4319
+ // 与关闭验证链同一条语义,随后仍按已确认记录走关闭路径(preflight 会 fail-closed)。
4320
+ if (recordPresence === "absent") {
4321
+ records.delete(key);
4322
+ origin.delete(key);
4323
+ clearAutoCloseAttempt(key);
4324
+ bump((value) => value + 1);
4325
+ continue;
4326
+ }
4327
+ if (recordPresence === "recycled") {
4328
+ records.delete(key);
4329
+ origin.delete(key);
4330
+ clearAutoCloseBudget(key);
4331
+ bump((value) => value + 1);
3946
4332
  continue;
3947
4333
  }
3948
4334
  if (closeSidebarResource === null) {
3949
4335
  // 宿主没有公开 close(旧宿主):撤销记账,绝不退化成别的关闭方式。
3950
4336
  origin.delete(key);
3951
- attempts.delete(key);
4337
+ clearAutoCloseAttempt(key);
3952
4338
  continue;
3953
4339
  }
3954
4340
  if (sidebarTabPendingRef.current.has(key) === true) continue;
@@ -3956,17 +4342,35 @@
3956
4342
  if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
3957
4343
  // 用户刚刚在这个 key 上表达过手动意图:交给手动链路结算,Auto 本轮让位(手动恒优先)。
3958
4344
  if (sidebarManualIntentOf(key) !== undefined) continue;
4345
+ // 上一次关闭请求还在验证中(租约被验证链持有):这一轮让位,绝不为同一个 key 再发一次 close。
4346
+ if (sidebarAutoCloseVerificationsRef.current.has(key) === true) continue;
4347
+ // 冷却慢车道:冷却期内零次 close 请求,origin / record 一律保留(绝不删除归属)。
4348
+ if (autoCloseCooldownActive(key) === true) continue;
4349
+ // 冷却到期:清空快速预算,允许一次新的真实 close(失败后再退避到下一档)。
4350
+ releaseExpiredAutoCloseCooldown(key);
3959
4351
  const used = attempts.get(key) ?? 0;
3960
4352
  if (used >= SIDEBAR_AUTO_CLOSE_MAX_ATTEMPTS) {
3961
- // 有界重试用尽:只撤销来源标记,tab 留给用户(绝不无限重试,也绝不改用批量入口)。
3962
- origin.delete(key);
3963
- attempts.delete(key);
4353
+ // 快速预算用尽:**保留 origin 与 record**,改为进入冷却慢车道(60 秒起指数退避)。
4354
+ // 绝不删除归属——删除就等于让一个仍然打开的 tab 永久脱离 Auto(「偶尔漏收」的症状)。
4355
+ enterAutoCloseCooldown(key);
3964
4356
  continue;
3965
4357
  }
4358
+ /**
4359
+ * 每次候选都创建一份**不可复用**的身份令牌(对象身份,而不是可重复的计数或字符串),并
4360
+ * 登记进共享 owner 账本:这条预算现在归这一次动作。只有令牌仍然匹配的动作才允许回滚它,
4361
+ * 因此旧代次在副作用之前被撤销时绝不可能删掉/覆盖新代次已经预支的那一笔预算。
4362
+ */
4363
+ const attemptToken = { key, attemptsBefore: used };
3966
4364
  attempts.set(key, used + 1);
4365
+ sidebarAutoCloseAttemptOwnersRef.current.set(key, attemptToken);
4366
+ // 与来源 / 抑制两个集合同一把安全阀:复合键跨 root 保留,计数绝不能随长生命周期无界增长。
4367
+ // owner 账本与它同序写入,因此用同一个上限、同一套淘汰规则(绝不新增无界 Map)。
4368
+ trimAutoKeyMap(attempts, true);
4369
+ trimAutoKeyMap(sidebarAutoCloseAttemptOwnersRef.current, true);
3967
4370
  // attemptsBefore 让「动作在真正调用 close 之前被撤销」的路径能精确回退这一次尝试计数,
3968
- // 否则一次被取消的自动关闭就会白烧掉一次有界重试预算。
3969
- return { key, address: entry.address, kind: "close", attemptsBefore: used };
4371
+ // 否则一次被取消的自动关闭就会白烧掉一次有界重试预算;attemptToken 是回滚的**所有权凭据**,
4372
+ // 必须随 autoContext 一路带到每个可能回滚的分支(见 handleSidebarAction)。
4373
+ return { key, address: entry.address, kind: "close", attemptsBefore: used, attemptToken };
3970
4374
  }
3971
4375
  return null;
3972
4376
  };
@@ -3986,6 +4390,9 @@
3986
4390
  if (suppressed.has(key) === true) continue;
3987
4391
  // 本次运行已经为它成功发出过打开动作:绝不重复打开(与失败无关,失败会撤销预留)。
3988
4392
  if (origin.has(key) === true) continue;
4393
+ // 容量 fail-closed:Auto 归属账本已满且没有可淘汰的历史条目时,拒绝新的自动打开——
4394
+ // 绝不淘汰仍可精确关闭的 Auto owner(那会让已打开的 tab 永久失去归属)。
4395
+ if (autoSidebarOwnershipHasCapacity(key) !== true) continue;
3989
4396
  // 已经有一个候选为它预留:同一资源绝不并发两个动作。
3990
4397
  if (reservations.has(key) === true) continue;
3991
4398
  if (sidebarTabPendingRef.current.has(key) === true) continue;
@@ -4017,13 +4424,648 @@
4017
4424
  }
4018
4425
  return null;
4019
4426
  };
4020
- /** 上限淘汰:按插入顺序丢弃最旧的键(复合键跨 root 保留时的安全阀,见 SIDEBAR_AUTO_KEY_LIMIT)。 */
4021
- const trimAutoKeyMap = (map) => {
4427
+ /**
4428
+ * 上限淘汰(复合键跨 root 保留时的安全阀,见 SIDEBAR_AUTO_KEY_LIMIT)。
4429
+ *
4430
+ * activeAware = true 时优先淘汰「没有当前 record / 没有在途动作 / 没有验证 / 没有冷却」的历史条目;
4431
+ * 若 128 项全是活动条目,则**什么都不淘汰**(fail-closed):绝不丢掉仍可精确关闭的 Auto owner,
4432
+ * 新增的自动打开改由 autoSidebarOwnershipHasCapacity 拒绝。false 保持既有语义(按插入顺序丢最旧)。
4433
+ */
4434
+ const trimAutoKeyMap = (map, activeAware) => {
4022
4435
  while (map.size > SIDEBAR_AUTO_KEY_LIMIT) {
4023
- const oldest = map.keys().next();
4024
- if (oldest.done === true) break;
4025
- map.delete(oldest.value);
4436
+ let victim = null;
4437
+ for (const candidate of map.keys()) {
4438
+ if (activeAware !== true || sidebarAutoCloseKeyIsActive(candidate) !== true) {
4439
+ victim = candidate;
4440
+ break;
4441
+ }
4442
+ }
4443
+ if (victim === null) break;
4444
+ map.delete(victim);
4445
+ }
4446
+ };
4447
+ /**
4448
+ * 这个 key 现在是否还有「活动的 Auto 关闭记账」。
4449
+ *
4450
+ * 判据(任一成立即活动):仍在途的打开记录 / 预留 / pending 动作 / 关闭验证 / 失败冷却 / 当前 scope
4451
+ * 下仍有精确 record。限容淘汰与「快照缺项」清理都必须给这些 key 让位——它们仍然可能被精确关闭。
4452
+ */
4453
+ const sidebarAutoCloseKeyIsActive = (key) => {
4454
+ if (sidebarTabPendingRef.current.has(key) === true) return true;
4455
+ if (sidebarTabOpeningRef.current.has(key) === true) return true;
4456
+ if (sidebarAutoReservationRef.current.has(key) === true) return true;
4457
+ if (sidebarAutoCloseVerificationsRef.current.has(key) === true) return true;
4458
+ if (sidebarAutoCloseCooldownsRef.current.has(key) === true) return true;
4459
+ const record = sidebarTabRecordsRef.current.get(key);
4460
+ return record !== undefined
4461
+ && record.sessionId === sidebarSessionId
4462
+ && record.rootSessionId === rootSessionId;
4463
+ };
4464
+ /** 清空一个 key 的失败冷却与重试预算(verified-closed / 手动接管 / 资源重新运行共用)。 */
4465
+ const clearAutoCloseBudget = (key) => {
4466
+ clearAutoCloseAttempt(key);
4467
+ sidebarAutoCloseCooldownsRef.current.delete(key);
4468
+ };
4469
+ /** 进入冷却慢车道:60 秒起指数退避、上限 15 分钟;origin / record 一律保留。 */
4470
+ const enterAutoCloseCooldown = (key) => {
4471
+ const cooldowns = sidebarAutoCloseCooldownsRef.current;
4472
+ const previous = cooldowns.get(key);
4473
+ const step = previous === undefined ? 0 : Math.min(previous.step + 1, 4);
4474
+ const delay = Math.min(SIDEBAR_AUTO_CLOSE_COOLDOWN_BASE_MS * (2 ** step), SIDEBAR_AUTO_CLOSE_COOLDOWN_MAX_MS);
4475
+ cooldowns.set(key, { step, until: Date.now() + delay });
4476
+ trimAutoKeyMap(cooldowns, true);
4477
+ };
4478
+ /** 冷却是否仍在生效:生效期内同一个 key 零次 close 请求。 */
4479
+ const autoCloseCooldownActive = (key) => {
4480
+ const entry = sidebarAutoCloseCooldownsRef.current.get(key);
4481
+ return entry !== undefined && entry.until > Date.now();
4482
+ };
4483
+ /** 冷却到期:清空快速预算,让这个 key 重新进入快速车道(失败后由 enterAutoCloseCooldown 退避到下一档)。 */
4484
+ const releaseExpiredAutoCloseCooldown = (key) => {
4485
+ const cooldowns = sidebarAutoCloseCooldownsRef.current;
4486
+ const entry = cooldowns.get(key);
4487
+ if (entry === undefined || entry.until > Date.now()) return;
4488
+ clearAutoCloseAttempt(key);
4489
+ };
4490
+ /**
4491
+ * Auto 归属账本是否还有容纳新 key 的容量(fail-closed)。
4492
+ *
4493
+ * 账本已满且没有任何可淘汰的历史条目时拒绝新的自动打开:宁可不开,也绝不淘汰仍可精确关闭的
4494
+ * Auto owner(那会让一个已经打开的 tab 永久失去归属,正是本插件要修的症状)。
4495
+ */
4496
+ const autoSidebarOwnershipHasCapacity = (key) => {
4497
+ const origin = sidebarAutoOriginRef.current;
4498
+ if (origin.has(key) === true) return true;
4499
+ if (origin.size < SIDEBAR_AUTO_KEY_LIMIT) return true;
4500
+ for (const candidate of origin.keys()) {
4501
+ if (sidebarAutoCloseKeyIsActive(candidate) !== true) return true;
4502
+ }
4503
+ return false;
4504
+ };
4505
+ /**
4506
+ * 无条件收口一个 key 的共享自动关闭记账(重试预算 + owner 身份)。
4507
+ *
4508
+ * 只用于「这个 key 的预算整体不再需要」的路径:资源重新变成运行中、记录消失或被手动接管、
4509
+ * inventory 已证明 tab 不在、宿主没有公开 close、有界重试用尽、成功关闭后的收口与清理阶段。
4510
+ * 预算与 owner 必须同生共死:只删预算而留下 owner,会让旧令牌在**下一次**预支之后仍被误判成
4511
+ * 所有者;只删 owner 而留下预算,则旧令牌只能 fail-safe 地放弃回滚(白烧一次预算,但有界性
4512
+ * 不受影响)。两者一起删,语义最直白。
4513
+ */
4514
+ const clearAutoCloseAttempt = (key) => {
4515
+ sidebarAutoCloseAttemptsRef.current.delete(key);
4516
+ sidebarAutoCloseAttemptOwnersRef.current.delete(key);
4517
+ // 候选采样计数与这条预算同生共死:预算整体不再需要时,采样序列也一并作废,绝不跨代残留。
4518
+ sidebarAutoTabMissesRef.current.delete(key);
4519
+ };
4520
+ /**
4521
+ * 候选选择阶段的「这个 tab 连续不在场」计数(见 sharedSidebarAutoTabMisses)。
4522
+ *
4523
+ * 只有计数记录里的 tabId 与当前 record 的 tabId 一致时才有效:宿主为同一个 key 重新分配了
4524
+ * tabId(旧 tab 消失、同一个资源换成新 tab)之后,旧计数绝不能计入新一代的确认。
4525
+ */
4526
+ const autoTabMissesOf = (key, tabId) => {
4527
+ const entry = sidebarAutoTabMissesRef.current.get(key);
4528
+ if (entry === undefined || entry.tabId !== tabId) return 0;
4529
+ return Number.isFinite(entry.misses) ? entry.misses : 0;
4530
+ };
4531
+ /**
4532
+ * 记一次候选采样结果,返回这个 tabId 的连续不在场次数。
4533
+ *
4534
+ * present(missing !== true)一律清零,与记录同步器「任一正向证据清零」逐字同源;absent 与
4535
+ * recycled 共用同一条连续计数(两者都独立证明「这个 tabId 已经不再承载我们的资源」)。
4536
+ * unreadable 的采样绝不走到这里——读不到既不证明存在也不证明不存在,绝不推进计数。
4537
+ */
4538
+ const sampleAutoTabMisses = (key, tabId, missing) => {
4539
+ const map = sidebarAutoTabMissesRef.current;
4540
+ // 先读旧计数(只有同一个 tabId 才有效),再摘掉旧条目:两步都不能省(见下面的 LRU 说明)。
4541
+ const previousMisses = autoTabMissesOf(key, tabId);
4542
+ map.delete(key);
4543
+ // present:清零就是「条目保持删除状态」,与记录同步器「任一正向证据清零」逐字同源。
4544
+ if (missing !== true) return 0;
4545
+ const misses = previousMisses + 1;
4546
+ map.set(key, { sessionId: sidebarSessionId, rootSessionId, tabId, misses });
4547
+ /**
4548
+ * 次序即 LRU:Map 对已存在的键 set 不会改变插入位置,因此上面先 delete 再 set,让这次采样把这
4549
+ * 个 key 刷到队尾;淘汰也只按「最后一次采样有多旧」进行(见 trimSharedSidebarAutoTabMisses)。
4550
+ * 与其它 Auto 账本同一个 SIDEBAR_AUTO_KEY_LIMIT 上限:候选采样计数绝不成为第二个无界 Map。
4551
+ */
4552
+ trimSharedSidebarAutoTabMisses();
4553
+ return misses;
4554
+ };
4555
+ /**
4556
+ * 一次「已经真的关掉」的结算后的预算收口(成功关闭 / 已证明 tab 消失)。
4557
+ *
4558
+ * - 手动动作(autoContext === null):手动恒优先,直接清空这个 key 的预算与 owner;
4559
+ * - 自动关闭动作:只有 owner 仍是本次动作的令牌时才清空。旧代次的迟到结算绝不碰后来者的预算——
4560
+ * 它可能已经在同一个 key 上重新预支了一笔,清零就等于把 3 次上限又还给它;
4561
+ * - 自动打开动作(没有 close 令牌,却因为期间出现了记录而走到关闭):只要这个 key 上已经存在
4562
+ * 任何更新的 owner,就同样不清零——令牌不属于本次动作,那笔预算就与它无关。
4563
+ */
4564
+ const settleAutoCloseAttempt = (key, context) => {
4565
+ if (context !== null && context !== undefined) {
4566
+ const owners = sidebarAutoCloseAttemptOwnersRef.current;
4567
+ if (context.kind === "close") {
4568
+ if (owners.get(key) !== context.attemptToken) return;
4569
+ } else if (owners.has(key) === true) {
4570
+ return;
4571
+ }
4026
4572
  }
4573
+ clearAutoCloseAttempt(key);
4574
+ };
4575
+ /**
4576
+ * 当前 surface 下某个宿主 tabId 的关闭锁身份(见 sharedSidebarTabCloseInFlight)。
4577
+ *
4578
+ * session / root 都取**当前渲染**的值:旧实例的迟到收尾用它自己那一轮的 surface 身份归还锁,
4579
+ * 因此绝不会误删新实例(或别的 root / session)持有的那一把。
4580
+ */
4581
+ const sidebarTabCloseKeyOf = (tabId) => sidebarTabCloseKey(sidebarSessionId, rootSessionId, tabId);
4582
+ /** 当前 surface 下一个 tabId 的在途关闭租约(没有在途关闭时返回 null)。 */
4583
+ const sidebarTabCloseLeaseOf = (tabId) => sharedSidebarTabCloseInFlight.get(sidebarTabCloseKeyOf(tabId)) ?? null;
4584
+ /**
4585
+ * 这个 tab 当前是否有一次关闭在途。
4586
+ *
4587
+ * 只读探测,供候选选择阶段提前让位:不为一个已经在关的 tab 预支一次有界重试预算,也不把它
4588
+ * 记成「尝试失败」(真正的执行点还会在调用宿主 close 之前再确认一次,见 handleSidebarAction)。
4589
+ */
4590
+ const sidebarTabCloseInFlight = (tabId) => sidebarTabCloseLeaseOf(tabId) !== null;
4591
+ /**
4592
+ * 取得一个 tab 的关闭权(fail-closed):已经有一次关闭在途时返回 null,调用方按各自的并发语义
4593
+ * 决定「等待它结算」还是「直接让位」。
4594
+ *
4595
+ * 租约不是无结果的 token,而是一份**可结算的信号**:token.ok 记录那一次关闭的**验证结果**
4596
+ * (true = inventory 连续两次读不到这个精确 tabId,verified-closed;false = 失败 / 超时 / 未验证),
4597
+ * token.wait() 让竞争者等到结算再决定下一步——上次已验证关闭就不必再 close,上次未验证才用当前
4598
+ * inventory 证明同一个 tabId 仍在之后做一次有限重试。宿主 Promise 的 resolve 绝不直接置 ok=true:
4599
+ * 它只把租约推进到 verifying,真正的结算由验证链或 watchdog 完成。
4600
+ *
4601
+ * 返回的 release 只在「Map 里仍然是本次 token」时才删除,因此旧实例的迟到收尾绝不会删掉新实例
4602
+ * (或后来者)持有的锁;release 只归还这一个身份,不触碰任何 Panel 状态(绝不改另一个实例的
4603
+ * pending / records / 来源账本)。调用方必须把它放进 finally:同步 throw、Promise reject、组件
4604
+ * 卸载后迟到的续体都走同一条归还路径,锁绝不留到下一次动作。
4605
+ */
4606
+ const acquireSidebarTabClose = (tabId) => {
4607
+ const key = sidebarTabCloseKeyOf(tabId);
4608
+ if (sharedSidebarTabCloseInFlight.has(key) === true) return null;
4609
+ let settleWaiters = null;
4610
+ const settled = new Promise((resolve) => { settleWaiters = resolve; });
4611
+ const token = {
4612
+ key,
4613
+ /**
4614
+ * 结算原因:null = 尚未结算;"closed" = 这次 close 已被 inventory 验证为关闭(或缺席);
4615
+ * "recycled" = 这次 close 的依据是「这个 tabId 已被宿主回收给另一个资源」(旧 tab 已消失,
4616
+ * close 一次都没有发出);"failed" = 失败 / 未验证 / 让位;"timeout" = watchdog 或验证窗口超时。
4617
+ * 宿主 Promise 的 resolve **绝不**直接把结果置成 "closed"(resolve 只代表请求完成)。
4618
+ */
4619
+ result: null,
4620
+ /**
4621
+ * 结算结果的布尔投影(仅 "closed" 为 true),保留给「只关心关没关」的读取点。
4622
+ *
4623
+ * 竞争方**不得**只看它:跨 key 交错时(A 的「旧 tab 已消失」与 T 自己的关闭落在同一个
4624
+ * tabId 上)布尔值分不清「谁证明的、凭什么证明的」。等待者必须读 result,并用自己的
4625
+ * 期望地址重新探针复核之后再决定(见 closeSidebarTabWithLease 的等待分支)。
4626
+ */
4627
+ ok: null,
4628
+ settled: false,
4629
+ /** 租约状态:in-flight(请求已发出)→ verifying(等待 inventory 验证)/ quarantined(超时隔离窗)。 */
4630
+ state: "in-flight",
4631
+ /** 验证窗口或迟到隔离窗的截止时间(虚拟时间;0 = 未设置)。 */
4632
+ holdUntil: 0,
4633
+ /** 等待这次关闭结算(永不 reject:结果通过 token.result / token.ok 读取)。 */
4634
+ wait: () => settled,
4635
+ /** 结算入口的同一份实现(同步链路需要从 map 里归还隔离窗到期的租约,见 verifyPendingSidebarCloses)。 */
4636
+ release: null,
4637
+ };
4638
+ sharedSidebarTabCloseInFlight.set(key, token);
4639
+ let released = false;
4640
+ /**
4641
+ * 结算并归还租约。outcome 是**原因**(四值,见 SIDEBAR_TAB_CLOSE_SETTLE_REASONS),布尔写法保留
4642
+ * 兼容:true ⇒ "closed"、false ⇒ "failed"。先写结果再唤醒等待者(等待者读到的一定是这次关闭的
4643
+ * 原因,而不是「锁空了」),最后只在 Map 里仍然是自己这个 token 时才删除——旧实例的迟到收尾
4644
+ * 绝不移除后来者的租约。
4645
+ */
4646
+ const release = (outcome) => {
4647
+ if (released === true) return;
4648
+ released = true;
4649
+ const reason = closeSettleReason(outcome);
4650
+ token.result = reason;
4651
+ token.ok = reason === "closed";
4652
+ token.settled = true;
4653
+ const resolve = settleWaiters;
4654
+ settleWaiters = null;
4655
+ if (resolve !== null) resolve(reason);
4656
+ if (sharedSidebarTabCloseInFlight.get(key) === token) sharedSidebarTabCloseInFlight.delete(key);
4657
+ };
4658
+ token.release = release;
4659
+ return {
4660
+ token,
4661
+ /** 保留租约到 holdUntil:期间候选选择一律让位(绝不重复 close),迟到结果也没有机会重叠。 */
4662
+ hold(state, holdUntil) {
4663
+ if (released === true) return;
4664
+ token.state = state;
4665
+ token.holdUntil = holdUntil;
4666
+ },
4667
+ /**
4668
+ * 结算并归还:先写结果再唤醒等待者(等待者读到的一定是这次关闭的结果,而不是「锁空了」),
4669
+ * 最后只在 Map 里仍然是自己这个 token 时才删除——旧实例的迟到收尾绝不移除后来者的租约。
4670
+ * ok 只有在 inventory 验证为关闭(verified-closed)时才为 true。
4671
+ */
4672
+ release,
4673
+ };
4674
+ };
4675
+ /**
4676
+ * 当前 inventory 里这个**精确 tabId** 的在场证据(必须属于当前 sidebar session,且仍是本插件的
4677
+ * 子代理会话 kind)。四态返回,因为「读不到」「确认没有」与「这个 id 已经换了资源」必须分开:
4678
+ * - "present":命中同一个 (session, kind, tabId),且条目若暴露了资源地址就与期望地址逐字一致
4679
+ * → 可以安全地再发一次 close;
4680
+ * - "absent":这个 tab 已经离开宿主,绝不再 close(用户的关闭意图已经达成);
4681
+ * - "recycled":tabId 还在,但它此刻承载的是**另一个资源**的地址——旧 tab 已经消失、宿主把这个
4682
+ * id 回收给了别的资源。绝不对这个 id 发 close(那会关掉别人的 tab),只按「旧 tab 已消失」
4683
+ * 收口自己这个 key 的记账;
4684
+ * - "unreadable":证明不了任何事 → fail-closed,绝不按 active tab 猜一个 id 去关。
4685
+ *
4686
+ * expectedAddress 是可选的第二个判据(调用方拿得到这个 key 的资源地址时才传):
4687
+ * 地址判据**只在条目真的暴露了可读的非空 contentId 时才生效**——条目缺 contentId、值为空串或
4688
+ * 类型不可读(getter 抛错被跳过)都**不构成任何 mismatch 证据**,一律退回 present(这是本轮
4689
+ * 刻意的 fail-open 取舍,必须保留)。绝不能用可能系统性缺失的字段推断「id 被复用」,那会退化成
4690
+ * 永远不关闭:只要宿主某一版 inventory 结构性缺 contentId,本插件的每一次关闭都会在 preflight
4691
+ * 被判定成 recycled,一个 tab 也关不掉(用户可见的**永久漏收**),比下面的残余风险更糟。
4692
+ *
4693
+ * **残余风险(本轮已知、未消除)**:contentId 缺失 ⇒ 无法区分「这个 tabId 仍是我们的 tab」与
4694
+ * 「这个 tabId 已被宿主回收给另一个资源」。在这种宿主上,一次基于 tabId 的关闭请求理论上可能
4695
+ * 落到复用了这个 id 的新 tab 上。风险边界由三件事共同限制,缺一不可:
4696
+ * 1. 命中条件仍要求 (当前 sidebar session + kind=subagentchat + 精确 tabId) 三条同时成立,
4697
+ * 别的 surface、别的 kind 的同名 id 一律不会被命中;
4698
+ * 2. 关闭入口仍受「会话 + root + tabId」租约与迟到隔离窗约束(同一时刻只有一个在途请求),
4699
+ * 不会并发重复发请求;
4700
+ * 3. 只有具备可关闭 tabId 的 Auto 归属、用户手动点击或批量候选才会走到这里,绝不主动扫描
4701
+ * inventory 去关闭任何 tab。
4702
+ * 一旦宿主提供了 contentId,判据立刻恢复为地址逐字比较(同一个函数、同一条记录)。
4703
+ *
4704
+ * 地址判据与 recordSidebarTab / sidebarBatchCandidates 逐条同源:一条 record 能存在,本身就要
4705
+ * 求当时的 inventory 条目(或 active())的 contentId 与 key 的资源地址逐字相等;而传给宿主
4706
+ * openResource() 的也正是同一个地址字符串。因此「现在这一条的 contentId 变了」恰好就是
4707
+ * 「这个 tabId 已经不是我们的资源」的精确证据(见 closeSidebarTabWithLease 的 preflight)。
4708
+ */
4709
+ const sidebarTabPresenceInInventory = (tabId, expectedAddress = null) => {
4710
+ const inventory = readSidebarOpenTabs();
4711
+ if (inventory === null) return "unreadable";
4712
+ const expected = text(expectedAddress, null);
4713
+ for (const entry of inventory) {
4714
+ // 单条畸形记录只跳过它自己:它既不证明存在,也不代表整份 inventory 不可读。
4715
+ try {
4716
+ const candidate = object(entry);
4717
+ if (text(candidate.sessionId, null) !== sidebarSessionId) continue;
4718
+ if (text(candidate.kind, null) !== SUBAGENT_CHAT_KIND) continue;
4719
+ if (text(candidate.tabId, null) !== tabId) continue;
4720
+ // 没有期望地址,或这一条没有暴露可读地址:无法证伪 → 保持既有的 present 语义。
4721
+ if (expected === null) return "present";
4722
+ const contentId = text(candidate.contentId, null);
4723
+ // fail-open(刻意保留):条目没有可读的 contentId 时无法证伪,只能按 present 处理,
4724
+ // 否则在「inventory 结构性缺 contentId」的宿主上永远关不掉任何 tab。残余风险见上方说明。
4725
+ if (contentId === null) return "present";
4726
+ return contentId === expected ? "present" : "recycled";
4727
+ } catch { continue; }
4728
+ }
4729
+ return "absent";
4730
+ };
4731
+ /**
4732
+ * 发出一次宿主 close 请求,并用 watchdog 封顶。返回 "resolved" | "rejected" | "timeout"。
4733
+ *
4734
+ * 宿主 Promise 不可取消:超时之后它仍可能迟到 resolve / reject。这里用一次性的 settled 闸门把
4735
+ * 迟到结果彻底隔离——它既不释放租约、也不清理任何归属,只让这一次调用安静地结束;同一个 tab 在
4736
+ * 隔离窗(SIDEBAR_TAB_CLOSE_LATE_QUARANTINE_MS)内绝不会再收到第二次 close。
4737
+ */
4738
+ const requestSidebarTabClose = (tabId) => new Promise((resolve) => {
4739
+ let settled = false;
4740
+ let timer = null;
4741
+ const finish = (outcome) => {
4742
+ if (settled === true) return;
4743
+ settled = true;
4744
+ if (timer !== null) {
4745
+ try { globalThis.clearTimeout?.(timer); } catch { /* 清理失败不影响结果。 */ }
4746
+ }
4747
+ resolve(outcome);
4748
+ };
4749
+ try {
4750
+ timer = typeof globalThis.setTimeout === "function"
4751
+ ? globalThis.setTimeout(() => finish("timeout"), SIDEBAR_TAB_CLOSE_WATCHDOG_MS)
4752
+ : null;
4753
+ } catch { timer = null; }
4754
+ let result = null;
4755
+ try {
4756
+ // close() 可同步抛错;结算前不能删掉来源,否则失败 tab 会被当成手动 tab 而永不重试。
4757
+ result = closeSidebarResource.call(sidebarRight, tabId);
4758
+ } catch {
4759
+ finish("rejected");
4760
+ return;
4761
+ }
4762
+ Promise.resolve(result).then(() => finish("resolved"), () => finish("rejected"));
4763
+ });
4764
+ /**
4765
+ * 等待一个竞争租约结算,最多等 timeoutMs;超时返回 false(绝不无限 await)。
4766
+ *
4767
+ * 手动单项与批量关闭共用这条等待:宿主的 Promise 可能永不 settle,没有上限就会把用户的点击
4768
+ * 永久挂在一次与它无关的宿主调用上。
4769
+ */
4770
+ const waitForSidebarCloseLease = (leaseToken, timeoutMs) => new Promise((resolve) => {
4771
+ let done = false;
4772
+ let timer = null;
4773
+ const finish = (value) => {
4774
+ if (done === true) return;
4775
+ done = true;
4776
+ if (timer !== null) {
4777
+ try { globalThis.clearTimeout?.(timer); } catch { /* 同上。 */ }
4778
+ }
4779
+ resolve(value);
4780
+ };
4781
+ try {
4782
+ timer = typeof globalThis.setTimeout === "function" ? globalThis.setTimeout(() => finish(false), timeoutMs) : null;
4783
+ } catch { timer = null; }
4784
+ Promise.resolve(leaseToken.wait()).then(() => finish(true), () => finish(true));
4785
+ });
4786
+ /**
4787
+ * 登记一条「close 请求已经发出、等待 inventory 验证」记录,并让租约保持在验证窗口里。
4788
+ *
4789
+ * 记录随来源账本同层共享:重挂载后的新实例继续采样、继续收口;租约被它持有,因此下一轮同步
4790
+ * 绝不会为同一个 tab 再发第二次 close。attemptToken 与 manual 决定验证成功后如何收口预算与来源。
4791
+ */
4792
+ const beginSidebarCloseVerification = (lease, tabId, verify) => {
4793
+ const entry = {
4794
+ sessionId: sidebarSessionId,
4795
+ rootSessionId,
4796
+ tabId,
4797
+ address: verify?.address ?? null,
4798
+ attemptToken: verify?.attemptToken ?? null,
4799
+ manual: verify?.manual === true,
4800
+ /**
4801
+ * 这次关闭出发时看到的 split 意图身份(不可复用令牌,见 sidebarSplitIntentRef)。
4802
+ *
4803
+ * 收口时只有当前意图**仍然是这一份**才允许取消它:这次关闭可能在验证窗口里跨越一次
4804
+ * 面板重挂载或用户/Auto 的新一次打开,按 key 无条件取消会把同 key 新 generation 的
4805
+ * split/open 事务一起掐掉(右栏从临时 50% 掉回、新 pane 的打开失去归属)。
4806
+ */
4807
+ splitIntentToken: verify?.splitIntentToken ?? null,
4808
+ misses: 0,
4809
+ deadline: Date.now() + SIDEBAR_TAB_CLOSE_VERIFY_TIMEOUT_MS,
4810
+ lease,
4811
+ };
4812
+ sidebarAutoCloseVerificationsRef.current.set(verify.key, entry);
4813
+ trimAutoKeyMap(sidebarAutoCloseVerificationsRef.current, true);
4814
+ lease.hold("verifying", entry.deadline);
4815
+ return entry;
4816
+ };
4817
+ /**
4818
+ * verified-closed / verified-recycled 的唯一收口点:删除 record、按令牌结算 attempt、撤销
4819
+ * origin(手动则登记抑制)、清掉冷却与验证记录,最后归还租约并**带上原因**(见 reason 参数)。
4820
+ *
4821
+ * 两种证据走同一条收口:连续两次读不到这个**精确 tabId**,或连续两次读到这个 id 已经被另一个
4822
+ * 资源复用(地址不一致)——后者说明旧 tab 已经消失、这个 id 不再属于我们,同样不能再关。
4823
+ * 但两者的**租约结算原因必须不同**("closed" vs "recycled"):等待者可能是另一个 key 的关闭,
4824
+ * 它只能用自己的地址证据复核,绝不能把「这个 id 被别人回收了」当成「我那个 tab 已经关掉」。
4825
+ *
4826
+ * 记录只在它的 tabId 与本次验证完全一致时才删除:旧代次的验证绝不能删掉新 tabId 的 record。
4827
+ */
4828
+ const finishVerifiedSidebarClose = (key, entry, reason) => {
4829
+ const records = sidebarTabRecordsRef.current;
4830
+ const record = records.get(key);
4831
+ const scopedRecord = record !== undefined
4832
+ && record.sessionId === sidebarSessionId
4833
+ && record.rootSessionId === rootSessionId
4834
+ ? record
4835
+ : undefined;
4836
+ /**
4837
+ * (key, tabId, attemptToken) 一致性闸门:这次验证只对「它自己关闭的那个 tabId」有收口权。
4838
+ * 宿主换过 tabId 时(旧 tab 消失、同一个资源换成新 tab),旧验证只能归还自己的租约并清理
4839
+ * 自己那一笔令牌对应的预算,绝不删除新 generation 的 record / origin / 冷却。
4840
+ */
4841
+ const sameTab = scopedRecord !== undefined && scopedRecord.tabId === entry.tabId;
4842
+ /**
4843
+ * 分栏事务的收口只认**这次关闭出发时看到的那一份意图**(不可复用令牌逐对象相等):
4844
+ * record 可能已被记录同步层删掉,但这次关闭确实发生了,事务必须收口,否则右栏会一直停在
4845
+ * 临时 50%。反过来,意图已经被新 generation 取代时(用户/Auto 重新打开同一个资源)绝不取消
4846
+ * 它——旧 close 的迟到收口取消一个新事务,会让右栏从 50% 掉回、新 pane 的打开失去归属。
4847
+ */
4848
+ cancelPendingSidebarSplitOfClose(key, entry.splitIntentToken);
4849
+ if (sameTab === true) {
4850
+ records.delete(key);
4851
+ if (entry.attemptToken === null || entry.attemptToken === undefined) clearAutoCloseAttempt(key);
4852
+ else settleAutoCloseAttempt(key, { kind: "close", attemptToken: entry.attemptToken });
4853
+ sidebarAutoCloseCooldownsRef.current.delete(key);
4854
+ sidebarAutoOriginRef.current.delete(key);
4855
+ if (entry.manual === true && entry.address !== null && entry.address !== undefined) {
4856
+ sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
4857
+ trimAutoKeyMap(sidebarAutoSuppressedRef.current);
4858
+ }
4859
+ } else if (entry.attemptToken !== null && entry.attemptToken !== undefined) {
4860
+ // tabId 已经不属于这次验证:只按令牌结算本次动作自己的那一笔预算(owner 已被后来者替换时
4861
+ // settleAutoCloseAttempt 会自动放弃)。
4862
+ settleAutoCloseAttempt(key, { kind: "close", attemptToken: entry.attemptToken });
4863
+ }
4864
+ sidebarAutoCloseVerificationsRef.current.delete(key);
4865
+ // 结算原因随租约带给等待者:recycled 与 closed 对**别人**的证明力完全不同(见 token.result)。
4866
+ entry.lease?.release?.(reason === "recycled" ? "recycled" : "closed");
4867
+ bump((value) => value + 1);
4868
+ };
4869
+
4870
+ /**
4871
+ * 关闭请求的验证链(每轮同步跑一次):
4872
+ * 1. 隔离窗到期的迟到结果租约:只归还自己那一把(不碰 record / origin / attempt);
4873
+ * 2. 验证记录:只有连续两次可读 inventory 都读不到这个**精确 tabId**、或读到这个 id 已经被
4874
+ * **另一个资源**复用(contentId 与本 key 的资源地址不一致)才算 verified-closed;
4875
+ * present 重置缺失计数并保留归属;unreadable 不推进缺失计数(读不到证明不了任何事);
4876
+ * 超过验证窗口仍未验证则释放租约、保留 record/origin/attempt,由下一次同步决定是否重试。
4877
+ */
4878
+ const verifyPendingSidebarCloses = () => {
4879
+ const now = Date.now();
4880
+ for (const [, token] of sharedSidebarTabCloseInFlight) {
4881
+ if (token.state !== "quarantined") continue;
4882
+ if (token.holdUntil > now) continue;
4883
+ // 隔离窗到期只说明那次请求**从未被验证**(watchdog 超时):结算原因必须是 timeout,
4884
+ // 绝不能是 closed——等待者据此知道「什么都没被证明」,需要自己用地址证据复核。
4885
+ token.release?.("timeout");
4886
+ }
4887
+ const verifications = sidebarAutoCloseVerificationsRef.current;
4888
+ for (const [key, entry] of [...verifications]) {
4889
+ if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
4890
+ // 地址证据来自这次关闭自己那个 key 的资源地址(批量入口没有 key,地址为 null ⇒ 不比较地址)。
4891
+ const presence = sidebarTabPresenceInInventory(entry.tabId, entry.address);
4892
+ if (presence === "present") entry.misses = 0;
4893
+ else if (presence !== "unreadable") entry.misses += 1;
4894
+ /**
4895
+ * absent 与 recycled 都独立证明「这个精确 tabId 已经不再承载我们的资源」,因此可以混用同
4896
+ * 一个缺失计数:两种证据各采一次同样满足「连续两次」的确认要求(unreadable 绝不推进计数)。
4897
+ */
4898
+ if (presence !== "present" && presence !== "unreadable"
4899
+ && entry.misses >= SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS) {
4900
+ // 结算原因按证据分型:recycled(id 已被别的资源占用)绝不能对外冒充 verified-closed。
4901
+ finishVerifiedSidebarClose(key, entry, presence === "recycled" ? "recycled" : "closed");
4902
+ continue;
4903
+ }
4904
+ if (now >= entry.deadline) {
4905
+ // 验证超时:释放租约与验证记录,**保留** record / origin / attempt。
4906
+ verifications.delete(key);
4907
+ entry.lease?.release?.("timeout");
4908
+ // 同一批微任务级联里绝不立刻重选同一个 key(下一轮外部同步才重试)。
4909
+ sidebarAutoFailedKeysRef.current.add(key);
4910
+ }
4911
+ }
4912
+ };
4913
+
4914
+ /**
4915
+ * 关闭一个 tab 的唯一执行入口(单 tab 关闭与批量关闭共用),把「租约 + 结算信号 + 有限重试」
4916
+ * 全部收在这里。
4917
+ *
4918
+ * 并发语义由 waitForCompeting 区分:
4919
+ * - true(用户触发的手动单项与批量):同一个 tabId 上已经有一次关闭在途时,等待它结算,然后
4920
+ * **用它自己的期望地址重新探针复核**再决定——结算原因只说明「上一次关闭凭什么结束」,而
4921
+ * 这个 tabId 在两次关闭之间完全可能被宿主回收给另一个资源:
4922
+ * · 复核为 present(且地址一致)⇒ 这个 id 此刻仍然是我们要关的那个 tab,才允许一次有限重试;
4923
+ * · 复核为 absent ⇒ 我们的 tab 已经不在,按 verified-closed 收口,绝不再 close;
4924
+ * · 复核为 recycled ⇒ 这个 id 已承载别的资源,按 verified-recycled 收口自己这个 key;
4925
+ * · 复核为 unreadable ⇒ fail-closed,什么都不做;
4926
+ * · 上一次结算原因是 recycled 而**自己又没有期望地址**可复核 ⇒ 一律跳过:别人的
4927
+ * 「旧 tab 已消失」对没有地址证据的等待者什么都证明不了(recycled-unverifiable)。
4928
+ * - false(Auto 自动同步):遇到在途关闭立刻返回 contended 让位,绝不 await、绝不排队重试
4929
+ * (调用方负责回退本轮 closeAttempts/预留并维持防微任务循环屏障)。
4930
+ *
4931
+ * status 语义(只有 verified-closed / closed / verified-recycled 才允许调用方收口归属):
4932
+ * - verified-closed:preflight 或验证链用**精确 tabId** 的 inventory 证据证明这个 tab 已经不在;
4933
+ * - verified-recycled:这个 tabId 已经被宿主回收给**另一个资源**(条目暴露的资源地址与本次
4934
+ * 关闭那个 key 的地址不一致)⇒ 旧 tab 已经消失、新 tab 不是我们的。close **一次都没有发出**,
4935
+ * 但结论与 verified-closed 等价:这个 key 的 record / 预算 / 冷却必须收口,且绝不允许重试;
4936
+ * - request-resolved-unverified:宿主 close 已经 resolve,但仍在验证窗口里(记录/来源/计数全部保留);
4937
+ * - failed:宿主 close 同步抛错或 rejected(请求真的发出过,绝不回滚 count);
4938
+ * - timeout:watchdog 超时(同样不回滚 count,租约进入迟到隔离窗);
4939
+ * - contended / contended-timeout:有人在关,本次不发 / 等待竞争租约超过同一个 10 秒上限;
4940
+ * - recycled-unverifiable:上次结算是 recycled,而本次等待者没有地址证据可复核 ⇒ 跳过
4941
+ * (既不关闭也不收口归属),等下一次带地址证据的动作或同步链自行收口;
4942
+ * - unreadable:inventory 不可读(或地址不可证伪时保持既有语义),fail-closed 不发;
4943
+ * - stale:等待期间 surface 换代;unavailable:宿主没有 close 能力。
4944
+ */
4945
+ const closeSidebarTabWithLease = async (tabId, options) => {
4946
+ if (closeSidebarResource === null) return { status: "unavailable", closed: false };
4947
+ const waitForCompeting = options?.waitForCompeting === true;
4948
+ // verify:close 请求 resolve 之后的验证上下文 { key, address, attemptToken, manual }。
4949
+ const verify = options?.verify ?? null;
4950
+ let lease = acquireSidebarTabClose(tabId);
4951
+ if (lease === null) {
4952
+ if (waitForCompeting !== true) return { status: "contended", closed: false };
4953
+ const competing = sidebarTabCloseLeaseOf(tabId);
4954
+ // 探测与读取之间没有 await,因此这里理论上不可达;真出现只可能是锁被别的实现动过,
4955
+ // fail-closed 让位总好过在拿不到结算信号的情况下盲发一次 close。
4956
+ if (competing === null) return { status: "contended", closed: false };
4957
+ // 等待结算:同一个 10 秒 watchdog 上限,绝不无限 await(宿主 Promise 不可取消)。
4958
+ const settledInTime = await waitForSidebarCloseLease(competing, SIDEBAR_TAB_CLOSE_WATCHDOG_MS);
4959
+ if (settledInTime !== true) return { status: "contended-timeout", closed: false };
4960
+ /**
4961
+ * 等待可能跨越一次组件重挂载、root / sidebar session 切换,或同一个实例的 A→B→A 快速
4962
+ * 回返:那次关闭属于旧 surface,本次动作绝不在新 surface 上继续(旧 tab 的归属由它自己
4963
+ * 那一轮的链路与既有同步器结算)。批量入口在 await 之后也有同一道复核,这里是共享执行
4964
+ * 入口里的等价收口;判据含不可复用的 surface 令牌,因此「切走又切回同一组值」同样作废。
4965
+ */
4966
+ if (sidebarSurfaceStillCurrent() !== true) return { status: "stale", closed: false };
4967
+ /**
4968
+ * 等待者绝不直接采信别人的结算结论(旧实现只读布尔 ok),一律用自己的期望地址重新探针:
4969
+ *
4970
+ * 结算原因只回答「上一次关闭凭什么结束」:closed / recycled / failed / timeout。它**不**回答
4971
+ * 「此刻这个 tabId 是不是我那个 tab」——两次关闭之间,宿主完全可能把这个已经释放的 id 回收给
4972
+ * 另一个资源。跨 key 交错时(A 的 verified-recycled 与 T 的等待落在同一个 tabId 上),把 A 的
4973
+ * 「旧 tab 已消失」当成 T 自己的关闭完成,就会让 T 误删自己的 record / 来源 / 抑制,或者相反:
4974
+ * 对别人复用的新 tab 再发一次 close。地址复核是唯一的防线。
4975
+ */
4976
+ const settledReason = competing.result;
4977
+ if (settledReason === "recycled"
4978
+ && (verify?.address === null || verify?.address === undefined)) {
4979
+ // 没有地址证据的等待者遇到 recycled:既不关闭也不收口——跳过这一次,绝不盲从别人的结论。
4980
+ return { status: "recycled-unverifiable", closed: false };
4981
+ }
4982
+ /**
4983
+ * 只有当前 inventory 明确证明同一个 tabId 仍存在**且仍然是我们要关的那个资源**(条目暴露的
4984
+ * contentId 与本次关闭的地址一致),才允许这一次有限重试。
4985
+ * - recycled:这个 id 已经被宿主回收给别的资源 ⇒ 旧 tab 已经消失,绝不重试(绝不关别人的 tab),
4986
+ * 按「旧 tab 已消失」结算,调用方据此收口自己这个 key 的记账;
4987
+ * - absent:同上(用户的关闭意图已经达成);unreadable:fail-closed。
4988
+ */
4989
+ const presence = sidebarTabPresenceInInventory(tabId, verify?.address ?? null);
4990
+ if (presence === "unreadable") return { status: "unreadable", closed: false };
4991
+ if (presence === "recycled") return { status: "verified-recycled", closed: false };
4992
+ if (presence !== "present") return { status: "verified-closed", closed: true };
4993
+ lease = acquireSidebarTabClose(tabId);
4994
+ // 已经有别人接手(或又一次在途):有限重试到此为止,绝不排队等第二次。
4995
+ if (lease === null) return { status: "contended", closed: false };
4996
+ }
4997
+ /**
4998
+ * 调用 close 之前的 preflight:必须用**当前** openTabs inventory 精确确认目标 tabId,并且
4999
+ * ——只要调用方给了这个 key 的资源地址——确认这个 id 此刻仍然承载**同一个资源地址**。
5000
+ *
5001
+ * 这是本插件唯一一次在副作用之前回答「这仍然是我们决定要关的那个 tab 吗」的地方:
5002
+ * - present:可以调用 close;
5003
+ * - absent:精确证据上的 verified-closed;
5004
+ * - recycled:id 已被宿主回收给别的资源(旧 tab 已经消失)⇒ 绝不调用 close(那会关掉别人的
5005
+ * tab),按「旧 tab 已消失」结算并让调用方收口本 key 的 record / 预算 / 冷却;
5006
+ * - unreadable:一律 fail-closed(不调用 close、不清理归属),绝不把「读不到」当成「没有」。
5007
+ */
5008
+ const preflight = sidebarTabPresenceInInventory(tabId, verify?.address ?? null);
5009
+ /**
5010
+ * 手动路径在**读面缺失**(旧宿主没有 openTabs)时的兼容:没有验证证据,但用户明确要求关闭——
5011
+ * 照旧调用 close 并按历史语义收口,绝不因为读面缺失把用户的关闭意图丢掉。
5012
+ * 自动路径没有这条退路:没有精确证据时 fail-closed(不调用 close、不清理归属)。
5013
+ */
5014
+ const manualWithoutEvidence = preflight === "unreadable" && verify !== null && verify.manual === true;
5015
+ if (preflight === "unreadable" && manualWithoutEvidence !== true) {
5016
+ // 什么都没发出、也什么都没证明:结算原因只能是 failed(等待者必须自己复核)。
5017
+ lease.release("failed");
5018
+ return { status: "unreadable", closed: false };
5019
+ }
5020
+
5021
+ if (preflight === "absent") {
5022
+ lease.release("closed");
5023
+ return { status: "verified-closed", closed: true };
5024
+ }
5025
+ if (preflight === "recycled") {
5026
+ /**
5027
+ * 地址证据证明这个 tabId 已经换了资源:旧 tab 已经消失。绝不调用 close——重试一次就会把
5028
+ * 别人(复用了这个 id 的新 tab)关掉。
5029
+ *
5030
+ * 租约必须按**原因**归还("recycled"),而不是布尔 ok=true:这个结论只对「本次关闭那个
5031
+ * key」有效(旧 tab 已消失),对同一个 tabId 上**别的 key** 的等待者完全没有证明力——
5032
+ * 「id 被复用」恰恰意味着这个 id 现在承载的不是我们的资源。等待者一律用自己的期望地址重新
5033
+ * 探针复核后再决定(见 closeSidebarTabWithLease 的等待分支),没有地址证据的一方直接跳过。
5034
+ *
5035
+ * 本次调用自己的 key 不受这条归还值影响:调用方按 verified-recycled 收口自己的 record /
5036
+ * 预算 / 冷却(见 handleSidebarAction),并且同样过 (key, tabId) 一致性闸门。
5037
+ */
5038
+ lease.release("recycled");
5039
+ return { status: "verified-recycled", closed: false };
5040
+ }
5041
+ const outcome = await requestSidebarTabClose(tabId);
5042
+ if (outcome === "rejected") {
5043
+ // 请求真的发出去过并失败:租约按未验证归还(failed),绝不回滚 count(那会突破三次快速上限)。
5044
+ lease.release("failed");
5045
+ return { status: "failed", closed: false };
5046
+ }
5047
+ if (outcome === "timeout") {
5048
+ // 迟到结果隔离窗:租约保留到隔离窗结束(由同步链路归还),期间绝不再发第二次 close。
5049
+ lease.hold("quarantined", Date.now() + SIDEBAR_TAB_CLOSE_LATE_QUARANTINE_MS);
5050
+ return { status: "timeout", closed: false };
5051
+ }
5052
+ /**
5053
+ * resolve 只代表请求完成:登记验证记录并**保留租约**,由后续同步按精确 inventory 收口。
5054
+ * 没有可验证的 key(调用方没有 Auto 记账,例如批量入口)时按未验证归还租约,绝不当作已关闭。
5055
+ */
5056
+ if (manualWithoutEvidence === true) {
5057
+ // 无 inventory 读面的手动关闭:历史语义(用户意图优先),按「已关闭」结算并归还租约。
5058
+ lease.release("closed");
5059
+ return { status: "closed", closed: true };
5060
+ }
5061
+ if (verify === null || verify.key === null || verify.key === undefined) {
5062
+ // 没有可验证的 key(批量入口):请求完成了但**什么都没被证明** ⇒ failed,等待者自己复核。
5063
+ lease.release("failed");
5064
+ return { status: "request-resolved-unverified", closed: false };
5065
+ }
5066
+
5067
+ beginSidebarCloseVerification(lease, tabId, verify);
5068
+ return { status: "request-resolved-unverified", closed: false };
4027
5069
  };
4028
5070
  /**
4029
5071
  * 清理已经离开当前快照的复合键(来源与抑制两个集合),避免长期泄漏。
@@ -4037,8 +5079,13 @@
4037
5079
  if (entry.sessionId !== sidebarSessionId || entry.rootSessionId !== rootSessionId) continue;
4038
5080
  if (key === actingKey) continue;
4039
5081
  if (known.has(entry.address) === true) continue;
5082
+ /**
5083
+ * 快照里暂时缺了这个地址,但 key 上仍有活动记账(record / 在途打开 / pending / 验证 / 冷却):
5084
+ * 绝不删除 origin——否则冷却到期后就再也关不掉这个 tab(快照缺项不等于宿主里没有)。
5085
+ */
5086
+ if (sidebarAutoCloseKeyIsActive(key) === true) continue;
4040
5087
  map.delete(key);
4041
- sidebarAutoCloseAttemptsRef.current.delete(key);
5088
+ clearAutoCloseBudget(key);
4042
5089
  }
4043
5090
  }
4044
5091
  // 预留同样要清理,但**在途动作自己那一条绝不能被清理**:它的 finally 才是唯一的释放点,
@@ -4071,7 +5118,18 @@
4071
5118
  * 尾随同步只用来推进「下一个候选」。它必须带着级联标记执行:这一轮里失败过的 key
4072
5119
  * 不会被重新选中,因此「失败 → 撤销预留 → 立刻重选」不在微任务里自转(见
4073
5120
  * sidebarAutoFailedKeysRef)。真正的重试留给下一个外部同步轮。
5121
+ *
5122
+ * 但这次推进同样只能由**仍然有效**的那一轮 surface 发起:动作的 await 可能跨越一次组件
5123
+ * 重挂载 / root 切换,此时本闭包的 mountedRef 已经是 false(旧实例的挂载 effect 已清理),
5124
+ * 而它手里的旧快照与共享 Auto 来源账本不是一回事——旧闭包的 syncSidebarTabs() 会把新
5125
+ * Panel 刚写入的其它 Auto 来源当成「已经离开旧快照」清掉(新 Panel 随后再也认不出自己
5126
+ * 打开过的 tab),并且「旧实例入口放弃动作 → 尾随同步又选中同一个候选」会形成不让出宏
5127
+ * 任务的微任务自转(页面被拖住)。因此这里与 handleSidebarAction 的收尾闸门用同一条判据
5128
+ * (含不可复用的 surface 令牌,重挂载与 A→B→A 都算失配):surface 失配时只归还本实例的
5129
+ * 级联闸门,绝不调用旧闭包的同步;新 Panel 的下一轮外部同步会按真实 inventory 继续推进
5130
+ * 候选与清理。
4074
5131
  */
5132
+ if (sidebarSurfaceStillCurrent() !== true) return;
4075
5133
  sidebarAutoCascadeRef.current = true;
4076
5134
  try {
4077
5135
  // 用引用取最新一轮渲染的同步器(与 observer/轮询同一写法),绝不复用当前闭包。
@@ -4142,13 +5200,35 @@
4142
5200
  sidebarAutoReservationRef.current.delete(key);
4143
5201
  releaseAutoCloseAttempt(key, context);
4144
5202
  };
4145
- /** 回退候选选择时预支的那一次自动关闭重试(动作在真正调用 close 之前被撤销时调用)。 */
5203
+ /**
5204
+ * 回退候选选择时预支的那一次自动关闭重试(动作在真正调用 close 之前被撤销时调用)。
5205
+ *
5206
+ * 只有**这条预算当前的所有者**才允许回滚:owner 账本里登记的必须与本次动作出发时拿到的是
5207
+ * 同一份不可复用令牌。旧代次的迟到收尾(重挂载、同一个实例 A→B→A、Auto 关闭取消)完全可能
5208
+ * 在新代次已经为同一个 key 重新预支预算之后才跑到这里——令牌不匹配时一律 fail-safe:绝不写、
5209
+ * 绝不删共享 attempts,否则新代次的预算会被删掉(等于复活),同一个 key 的实际 close 次数就会
5210
+ * 突破 3 次上限。owner 不存在或已被限容淘汰时同样 fail-safe(宁可白烧一次预算,也绝不动可能
5211
+ * 属于后来者的计数)。
5212
+ */
4146
5213
  const releaseAutoCloseAttempt = (key, context) => {
4147
5214
  if (context === null || context === undefined || context.kind !== "close") return;
5215
+ const owners = sidebarAutoCloseAttemptOwnersRef.current;
5216
+ const token = context.attemptToken;
5217
+ if (token === null || token === undefined || owners.get(key) !== token) return;
4148
5218
  const attempts = sidebarAutoCloseAttemptsRef.current;
4149
- if (attempts.has(key) !== true) return;
5219
+ // owner 还在、预算已经不在了(被限容淘汰或被别的收口路径清掉):只清掉自己这一份 owner。
5220
+ if (attempts.has(key) !== true) {
5221
+ owners.delete(key);
5222
+ return;
5223
+ }
4150
5224
  if (context.attemptsBefore === 0) attempts.delete(key);
4151
- else attempts.set(key, context.attemptsBefore);
5225
+ else {
5226
+ attempts.set(key, context.attemptsBefore);
5227
+ // 回退同样要限容:这一步是 set 的第二条路径,少了它这个 Map 就可能在长生命周期里无界增长。
5228
+ trimAutoKeyMap(attempts);
5229
+ }
5230
+ // 回滚完成即交还所有权:此后同一个 key 的新预支由新的令牌持有,旧令牌再也回不来。
5231
+ owners.delete(key);
4152
5232
  };
4153
5233
  /**
4154
5234
  * 自动动作在副作用之前必须让位的「手动接管」状态:
@@ -4212,7 +5292,8 @@
4212
5292
  record.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
4213
5293
  sidebarAutoSuppressedRef.current.delete(key);
4214
5294
  sidebarAutoOriginRef.current.delete(key);
4215
- sidebarAutoCloseAttemptsRef.current.delete(key);
5295
+ // 手动接管这个 key:未结算的自动关闭预算(含 owner)一并作废,Auto 不得再按旧预算重试。
5296
+ clearAutoCloseAttempt(key);
4216
5297
  sidebarManualIntentRef.current.delete(key);
4217
5298
  return;
4218
5299
  }
@@ -4240,7 +5321,8 @@
4240
5321
  sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
4241
5322
  trimAutoKeyMap(sidebarAutoSuppressedRef.current);
4242
5323
  sidebarAutoOriginRef.current.delete(key);
4243
- sidebarAutoCloseAttemptsRef.current.delete(key);
5324
+ // 手动关闭意图接管这个 key:未结算的自动关闭预算(含 owner)一并作废。
5325
+ clearAutoCloseAttempt(key);
4244
5326
  };
4245
5327
 
4246
5328
  /**
@@ -4258,6 +5340,8 @@
4258
5340
  */
4259
5341
  if (sidebarAutoCascadeRef.current !== true) sidebarAutoFailedKeysRef.current.clear();
4260
5342
  syncSidebarTabRecords();
5343
+ // 关闭请求的验证链先跑:上一轮 close 的验证结果必须先收口,候选选择才按真实归属推进。
5344
+ verifyPendingSidebarCloses();
4261
5345
  // Auto 开关是渲染输入:设置页保存或面板自身切换后必须重渲染一次(标题栏开关的勾选态)。
4262
5346
  // 与批量签名同一模式:只有真的变化才 bump,空闲轮询绝不产生重渲染。
4263
5347
  const autoNow = autoOpenEnabled();
@@ -4337,6 +5421,21 @@
4337
5421
  * Auto 绝不能在资源结束后把它当成自己的 tab 关掉。
4338
5422
  */
4339
5423
  const manualTakeover = sidebarManualIntentOf(key);
5424
+ /**
5425
+ * 共享 Auto 来源账本(键与打开记录同构,见 sharedSidebarAutoOrigin)。
5426
+ *
5427
+ * 收养路径(面板重挂载后 / 切 root 后从 inventory 重建记录)没有「本次动作的显式来源」,
5428
+ * 实例内的 previous 与 opening 也都是空的;唯一能证明「这个 tab 是 Auto 亲手打开的」的
5429
+ * 就是这条跨实例账本。缺了它,收养只能落到 manual 兜底,Auto 从此不再管这个 tab。
5430
+ *
5431
+ * 只认显式记成 auto 的账本条目,并且必须同 session + 同 root:别的 scope 的条目一律不可见,
5432
+ * 因此来源未知的外部 tab 与其它 pane 的同地址 tab 都不会被误收养成 Auto 的 tab。
5433
+ */
5434
+ const sharedOrigin = sidebarAutoOriginRef.current.get(key);
5435
+ const sharedOriginScoped = sharedOrigin !== undefined
5436
+ && sharedOrigin.sessionId === sidebarSessionId && sharedOrigin.rootSessionId === rootSessionId
5437
+ ? sharedOrigin
5438
+ : undefined;
4340
5439
  const origin = manualTakeover !== undefined && manualTakeover.intent === "open"
4341
5440
  ? SIDEBAR_TAB_ORIGIN_MANUAL
4342
5441
  : (declaredOrigin
@@ -4346,6 +5445,7 @@
4346
5445
  ?? (opening !== undefined && opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId
4347
5446
  ? opening.origin
4348
5447
  : null)
5448
+ ?? (sharedOriginScoped !== undefined ? SIDEBAR_TAB_ORIGIN_AUTO : null)
4349
5449
  ?? SIDEBAR_TAB_ORIGIN_MANUAL);
4350
5450
  const recordOrigin = origin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
4351
5451
  if (previous !== undefined && previous.sessionId === sidebarSessionId
@@ -4371,9 +5471,10 @@
4371
5471
  * origin 是**内部**参数,默认 manual:普通 UI 调用(箭头、键盘)一律手动来源;只有自动同步
4372
5472
  * 显式传 auto。它决定三件事:记录来源、手动动作是否清除抑制/自动来源、关闭时是否登记抑制。
4373
5473
  *
4374
- * autoAction 也是**内部**参数,只在自动路径上出现:{ kind, epoch, attemptsBefore }。它让同一条
4375
- * 实现能在每个 await 之后、真正调用宿主 open/close 之前复核「Auto 是否仍然开启、动作是否已过期、
4376
- * 用户是否已经手动接管」,并在收尾时无条件释放在途预留(见 finally)。
5474
+ * autoAction 也是**内部**参数,只在自动路径上出现:{ kind, epoch, attemptsBefore, attemptToken }。
5475
+ * 它让同一条实现能在每个 await 之后、真正调用宿主 open/close 之前复核「Auto 是否仍然开启、动作
5476
+ * 是否已过期、用户是否已经手动接管」,并在收尾时无条件释放在途预留(见 finally)。关闭类候选还
5477
+ * 带着预支预算时新建的 attemptToken:回滚/收口共享重试计数只认这份不可复用的所有权凭据。
4377
5478
  */
4378
5479
  const handleSidebarAction = async (key, resourceAddress, actionOrigin, autoAction) => {
4379
5480
  const origin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
@@ -4382,12 +5483,18 @@
4382
5483
  kind: autoAction.kind === "close" ? "close" : "open",
4383
5484
  epoch: Number.isFinite(Number(autoAction.epoch)) ? Number(autoAction.epoch) : sidebarAutoEpochRef.current,
4384
5485
  attemptsBefore: Number.isFinite(Number(autoAction.attemptsBefore)) ? Number(autoAction.attemptsBefore) : 0,
5486
+ // 只有候选选择真的登记过一份令牌时才带上它:缺失/畸形一律按 null 处理,后续回滚因此
5487
+ // fail-safe 地放弃(绝不按 key 盲写共享计数)。
5488
+ attemptToken: autoAction.attemptToken !== null && autoAction.attemptToken !== undefined
5489
+ && typeof autoAction.attemptToken === "object"
5490
+ ? autoAction.attemptToken
5491
+ : null,
4385
5492
  }
4386
5493
  : null;
4387
5494
  // React 重挂载或 root 切换后,旧 DOM 可能短暂保留旧 handler;它不能操作当前 session-scoped 服务。
4388
- if (mountedRef.current !== true
4389
- || currentRootRef.current !== rootSessionId
4390
- || currentSidebarSessionRef.current !== sidebarSessionId) {
5495
+ // 这里的判据含不可复用的 surface 令牌:同一个实例 A→B→A 之后,旧 handler 手里的字符串会重新
5496
+ // 相等,只有令牌能证明它已经不属于当前 surface。
5497
+ if (sidebarSurfaceStillCurrent() !== true) {
4391
5498
  /**
4392
5499
  * 连入口都没通过的自动动作:这里必须自己释放候选选择时写下的预留(以及自动关闭的重试记账)。
4393
5500
  * 这些键属于已经被换掉的 surface,当前闭包之后再也不会为它们做清理。
@@ -4431,15 +5538,133 @@
4431
5538
  }
4432
5539
  // 关闭态:只调用记录里的精确 tabId。绝不根据 active() 当前是谁来决定关哪个 tab。
4433
5540
  if (record !== undefined && closeSidebarResource !== null) {
4434
- let closed = false;
4435
- try {
4436
- // close() 可异步失败;结算前不能删掉 Auto 来源,否则失败 tab 会被当成手动 tab 而永不重试。
4437
- await closeSidebarResource.call(sidebarRight, record.tabId);
4438
- closed = true;
4439
- } catch {
4440
- closed = false;
5541
+ /**
5542
+ * 关闭的唯一执行入口(见 closeSidebarTabWithLease):
5543
+ * - 手动单项(用户触发):同一个 tabId 上已经有一次关闭在途时等待它结算——上次成功就当作
5544
+ * 这个 tab 已经被关掉(绝不再 close),上次失败才用**当前** inventory 证明同一个 tabId
5545
+ * 仍在之后做一次有限重试。用户的关闭意图因此既不会重复,也不会在竞争失败时被静默丢失;
5546
+ * - 自动动作:遇到在途关闭立刻让位,绝不 await(Auto 同步绝不能被一次永不结算的宿主
5547
+ * Promise 拖住),并回退本轮 closeAttempts/预留、记入本次级联的失败屏障。
5548
+ */
5549
+ /**
5550
+ * 这次关闭出发时看到的 split 意图身份(不可复用令牌)。
5551
+ *
5552
+ * 它的作用域**只到这次关闭**:收口时只有当前意图仍然是这一份才允许取消(见
5553
+ * cancelPendingSidebarSplitOfClose)。只按 key 取消的旧写法会把验证窗口里出现的
5554
+ * 同 key 新 generation 的 split/open 事务一起掐掉。出发时没有意图就记 null——
5555
+ * 那一刻之后出现的任何意图都只属于后来者,这次关闭一律不碰。
5556
+ */
5557
+ const splitIntentAtClose = sidebarSplitIntentRef.current;
5558
+ const closeSplitIntentToken = splitIntentAtClose !== null && splitIntentAtClose.key === key
5559
+ ? splitIntentAtClose.token
5560
+ : null;
5561
+ const closeOutcome = await closeSidebarTabWithLease(record.tabId, {
5562
+ waitForCompeting: origin === SIDEBAR_TAB_ORIGIN_MANUAL,
5563
+ /**
5564
+ * 验证上下文:close 请求 resolve 之后必须由 inventory 的精确 tabId 证据收口。
5565
+ * 手动关闭同样要验证(attemptToken = null ⇒ 验证成功后按手动语义登记抑制)。
5566
+ * splitIntentToken 随验证记录一起保留:验证链的收口可能在若干轮同步之后才发生。
5567
+ */
5568
+ verify: {
5569
+ key,
5570
+ address: resourceAddress,
5571
+ attemptToken: autoContext === null ? null : autoContext.attemptToken,
5572
+ manual: origin === SIDEBAR_TAB_ORIGIN_MANUAL,
5573
+ splitIntentToken: closeSplitIntentToken,
5574
+ },
5575
+ });
5576
+
5577
+ /**
5578
+ * await 之后的 surface 身份闸门(单项关闭的收尾边界,与批量入口同一条判据)。
5579
+ *
5580
+ * 这次 await 可能跨越一次组件重挂载或 root / sidebar session 切换:本闭包里的 ref、
5581
+ * records 与快照都已经不属于当前 surface,而**共享**的 Auto 来源 / 抑制 / 重试记账却是
5582
+ * 同一份。此时若还按旧闭包继续收尾(删记录、删共享来源、登记抑制、bump)并调用旧闭包的
5583
+ * syncSidebarTabs(),旧快照会把新 Panel 刚刚写入的**其它** Auto 来源当成「已经离开当前
5584
+ * 快照」而清掉——新 Panel 随后再也认不出自己打开过的 tab,资源结束后也不会自动关闭它。
5585
+ *
5586
+ * 因此任何返回结果(closed / failed / settled-closed / contended / unreadable / stale)
5587
+ * 都必须在写共享账本、删记录、登记抑制或调用旧闭包同步**之前**复核 surface 身份;任一失配
5588
+ * 立即停止本实例的后续收尾。判据含不可复用的 surface 令牌:同一个实例 A→B→A 之后
5589
+ * root/sidebar session 字符串会重新相等,只有令牌能证明这次收尾已经不属于当前 surface
5590
+ * (否则旧动作会删掉 A 里已经换成新 tabId 的 record / Auto 来源)。租约已经由
5591
+ * closeSidebarTabWithLease 自己的 finally 结算并归还(这里绝不重复释放),旧 tab 的归属交给
5592
+ * 新 Panel 后续同步按真实 inventory 结算:成功关掉的由那次同步收口,失败 / 还没落地的则仍
5593
+ * 留在 inventory 里等下一轮有界重试。
5594
+ */
5595
+ if (sidebarSurfaceStillCurrent() !== true) {
5596
+ return;
4441
5597
  }
4442
- if (closed === true) {
5598
+ if (closeOutcome.status === "contended" || closeOutcome.status === "contended-timeout") {
5599
+ /**
5600
+ * 让位路径(自动动作):这次关闭根本没有发出,因此必须归还候选选择时预支的那一次重试
5601
+ * 预算与在途预留,否则一次「有人在关」的等待就会白烧掉一次有界重试,甚至让 Auto 提前
5602
+ * 放弃这个 tab。**绝不在这里清理来源**:来源只在关闭真的成功后才允许撤销(失败 / 让位
5603
+ * 都必须继续归 Auto 管,留给后续同步重试)。
5604
+ */
5605
+ if (autoContext !== null) {
5606
+ sidebarAutoReservationRef.current.delete(key);
5607
+ releaseAutoCloseAttempt(key, autoContext);
5608
+ /**
5609
+ * 同时记进本次级联的失败屏障:这次关闭没有发出,但它绝不能在**同一批微任务级联**里被
5610
+ * 重新选中(候选与执行之间只隔一次微任务,动作收尾的尾随同步会立刻再选一次;若冲突
5611
+ * 持续存在,那会变成不让出宏任务的微任务死循环)。屏障在下一次外部同步轮自动失效,
5612
+ * 那时锁通常已经归还,重试照常。
5613
+ */
5614
+ sidebarAutoFailedKeysRef.current.add(key);
5615
+ }
5616
+ return;
5617
+ }
5618
+ /**
5619
+ * 只有拿到「已经关掉」的结算才收口:亲手关闭成功,或等待竞争租约后拿到成功 / inventory
5620
+ * 已证明 tab 消失的结算。失败、inventory 不可读(fail-closed)与「有人接手」都不允许在
5621
+ * 这里改写记录或来源——记录只由真实结算(这里或既有同步器)收口,绝不因为「这次不是自己
5622
+ * 亲手关的」就跳过收口,否则会留下一份与宿主 inventory 不符的账。
5623
+ */
5624
+ if (closeOutcome.status === "unreadable") {
5625
+ // preflight 没通过:close 根本没有发出 → 按令牌回滚这次预支,绝不清理归属。
5626
+ if (autoContext !== null) {
5627
+ sidebarAutoReservationRef.current.delete(key);
5628
+ releaseAutoCloseAttempt(key, autoContext);
5629
+ sidebarAutoFailedKeysRef.current.add(key);
5630
+ }
5631
+ return;
5632
+ }
5633
+ if (closeOutcome.status === "stale") {
5634
+ // 等待期间 surface 换代:close 没有发出 → 同样按令牌回滚,旧 tab 交给新 generation 收口。
5635
+ if (autoContext !== null) {
5636
+ sidebarAutoReservationRef.current.delete(key);
5637
+ releaseAutoCloseAttempt(key, autoContext);
5638
+ }
5639
+ return;
5640
+ }
5641
+ if (closeOutcome.status === "failed" || closeOutcome.status === "timeout") {
5642
+ /**
5643
+ * 请求真的发出去过(reject 或 watchdog 超时):**绝不回滚 count**——回滚就等于把三次快速
5644
+ * 上限又还给它,实际 close 次数会突破上限。record / origin / 冷却一律保留,留给后续同步重试。
5645
+ */
5646
+ sidebarAutoFailedKeysRef.current.add(key);
5647
+ return;
5648
+ }
5649
+ if (closeOutcome.status === "request-resolved-unverified") {
5650
+ /**
5651
+ * resolve 只代表请求完成:验证链已经登记在共享账本里,record / origin / attempt 全部保留。
5652
+ * 这里直接返回,绝不落到下面的 openResource 分支,也绝不当成「已经关掉」收口。
5653
+ */
5654
+ return;
5655
+ }
5656
+ /**
5657
+ * verified-closed:精确 inventory 证据证明这个 tab 已经不在;
5658
+ * verified-recycled:这个 tabId 已经被宿主回收给另一个资源(地址不一致)——旧 tab 已经
5659
+ * 消失、close 一次也没有发出,但「这个 key 已经没有自己的 tab」这个结论与 verified-closed
5660
+ * 完全等价,因此收口规则一致(下面的 (key, tabId) 一致性闸门照旧生效);
5661
+ * closed:旧宿主没有 inventory 读面时的手动关闭(历史语义,用户意图优先)。
5662
+ * 只有这三种结算允许收口归属。
5663
+ */
5664
+ if (closeOutcome.status === "verified-closed" || closeOutcome.status === "closed"
5665
+ || closeOutcome.status === "verified-recycled") {
5666
+
5667
+
4443
5668
  /**
4444
5669
  * 关闭成功后一起收口这个 key 上尚未结算的分栏事务。
4445
5670
  *
@@ -4451,21 +5676,56 @@
4451
5676
  *
4452
5677
  * 只有同 key 的意图会被取消:别的行的在途打开照旧继续。
4453
5678
  */
4454
- if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("close");
4455
- sidebarTabRecordsRef.current.delete(key);
4456
- sidebarAutoCloseAttemptsRef.current.delete(key);
5679
+ /**
5680
+ * (key, tabId) 一致性闸门:只有这个 key 当前记录的 tabId 仍是本次动作关闭的那一个,才允许
5681
+ * 收口 record / origin。宿主换过 tabId(旧 tab 消失、同一个资源换成新 tab)时,旧代次的
5682
+ * 结算只能清理它自己那一笔预算,绝不能删掉新 generation 的 record 与 Auto 归属。
5683
+ */
5684
+ const currentRecord = sidebarTabRecordsRef.current.get(key);
5685
+ const recordStillSame = currentRecord !== undefined && currentRecord.tabId === record.tabId;
5686
+ /**
5687
+ * 分栏事务只收口**这次关闭出发时看到的那一份意图**:这次关闭确实发生了,事务必须收口
5688
+ * (否则右栏停在临时 50%);但同一个 key 上如果已经出现新 generation 的 split/open
5689
+ * 事务(用户或 Auto 重新打开了这个资源),旧 close 的结算绝不取消它——那会把新一次打开
5690
+ * 的 pane 归属与临时扩栏一起打断。令牌逐对象比较,A→B→A 或同 key 重开都无法冒充。
5691
+ */
5692
+ cancelPendingSidebarSplitOfClose(key, closeSplitIntentToken);
5693
+ if (recordStillSame === true) {
5694
+ sidebarTabRecordsRef.current.delete(key);
5695
+ }
5696
+ // 重试预算按所有权收口:手动关闭恒优先,直接清空;自动关闭只有 owner 仍是本次动作的
5697
+ // 令牌时才清空(旧代次的迟到成功结算绝不把后来者重新预支的预算一起清零)。
5698
+ settleAutoCloseAttempt(key, autoContext);
5699
+
4457
5700
  /**
4458
5701
  * 关闭成功后收口来源与抑制记账(两种开放的语义差别就在这两行):
4459
5702
  *
4460
5703
  * - 自动关闭:只撤销来源标记——本次运行不会再对同一个资源重复动作;
4461
5704
  * - 手动关闭:除撤销来源外还要登记**抑制**,Auto 不得立刻把它重新打开。抑制只由
4462
5705
  * 用户手动重新打开同一资源清除(手动意图优先)。
5706
+ *
5707
+ * 「等待竞争租约后拿到成功结算」同样走这里:tab 确实已经被关掉,收口规则与亲手关闭一致;
5708
+ * 反过来说,让位 / 失败 / inventory 不可读的路径一律在上面 return,绝不提前清理来源。
4463
5709
  */
4464
- sidebarAutoOriginRef.current.delete(key);
4465
- if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
4466
- sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
4467
- trimAutoKeyMap(sidebarAutoSuppressedRef.current);
5710
+ // 来源与抑制同样只在这个 key 仍然指向本次关闭的那个 tabId 时收口:否则删掉的会是
5711
+ // 新 generation 刚刚为**新 tabId** 写下的 Auto 归属。
5712
+ if (recordStillSame === true && closeOutcome.status === "verified-recycled") {
5713
+ /**
5714
+ * 地址复用证明旧 tab 已经消失:这个 key 的失败冷却也随之失效,必须一起清掉。否则一条
5715
+ * 永远等不到自己 tab 的冷却(最长 15 分钟)会白白屏蔽这个 key 后续的真实关闭。
5716
+ * 仍然只在 record 仍指向本次证明消失的那个 tabId 时清理:后来者已经接管这个 key 时,
5717
+ * 它的冷却记账不属于这次结算(与 finishVerifiedSidebarClose 的 sameTab 闸门同源)。
5718
+ */
5719
+ sidebarAutoCloseCooldownsRef.current.delete(key);
5720
+ }
5721
+ if (recordStillSame === true) {
5722
+ sidebarAutoOriginRef.current.delete(key);
5723
+ if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
5724
+ sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
5725
+ trimAutoKeyMap(sidebarAutoSuppressedRef.current);
5726
+ }
4468
5727
  }
5728
+
4469
5729
  bump((value) => value + 1);
4470
5730
  // 关闭改变了右栏组成,必须立刻重估一次:宽度与手柄锁都在同一条同步链路上。
4471
5731
  syncSidebarTabs();
@@ -4516,7 +5776,8 @@
4516
5776
  if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
4517
5777
  sidebarAutoSuppressedRef.current.delete(key);
4518
5778
  sidebarAutoOriginRef.current.delete(key);
4519
- sidebarAutoCloseAttemptsRef.current.delete(key);
5779
+ // 用户手动重新打开同一资源:手动接管,这个 key 上未结算的自动关闭预算(含 owner)一并作废。
5780
+ clearAutoCloseAttempt(key);
4520
5781
  }
4521
5782
  const targetPaneId = existingSubagentPaneId();
4522
5783
  let openOptions = null;
@@ -4565,9 +5826,7 @@
4565
5826
  // 宽度 action 返回只代表 store 已写入;必须等宿主把新宽度提交到 DockLayout,
4566
5827
  // 否则 split() 仍会读到旧 room.row=false,并弹出「栏宽不足」。
4567
5828
  await waitForSidebarSplitLayoutCommit();
4568
- const splitStillCurrent = mountedRef.current === true
4569
- && currentRootRef.current === rootSessionId
4570
- && currentSidebarSessionRef.current === sidebarSessionId
5829
+ const splitStillCurrent = sidebarSurfaceStillCurrent() === true
4571
5830
  && sidebarSplitIntentRef.current?.token === splitToken;
4572
5831
  if (!splitStillCurrent) {
4573
5832
  // session 切换、卸载或 deadline 已经收口时,不得让旧点击继续操作新 surface。
@@ -4646,7 +5905,7 @@
4646
5905
  */
4647
5906
  if (effectiveOrigin === SIDEBAR_TAB_ORIGIN_AUTO && canTrackOpen === true) {
4648
5907
  sidebarAutoOriginRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
4649
- trimAutoKeyMap(sidebarAutoOriginRef.current);
5908
+ trimAutoKeyMap(sidebarAutoOriginRef.current, true);
4650
5909
  }
4651
5910
  if (splitToken !== null) {
4652
5911
  let acknowledged = true;
@@ -4668,7 +5927,10 @@
4668
5927
  if (opened) sidebarTabOpeningRef.current.delete(key);
4669
5928
  // openResource 可能已经同步提交 tab:立即推进一次同步,让同 pane 回退不依赖下一轮 observer。
4670
5929
  // 若宿主稍后才渲染 DOM,keepMounted + observer/轮询仍会继续补偿。
4671
- syncSidebarTabs();
5930
+ // 旧闭包绝不允许在新 surface 上推进同步:中间的宿主调用可能已经触发一次重挂载或 A→B→A
5931
+ // 回返,那时这份闭包手里的快照会把新 Panel 的账本当成本轮的旧状态处理。新 Panel 自己的
5932
+ // 下一轮外部同步会按真实 inventory 继续收口。
5933
+ if (sidebarSurfaceStillCurrent() === true) syncSidebarTabs();
4672
5934
  } catch {
4673
5935
  if (sidebarSplitIntentRef.current?.key === key) cancelPendingSidebarSplit("resource-failed");
4674
5936
  sidebarTabOpeningRef.current.delete(key);
@@ -4677,25 +5939,37 @@
4677
5939
  finally {
4678
5940
  pending.delete(key);
4679
5941
  /**
4680
- * 自动动作收尾时无条件释放在途记账(split busy、准备失败、layout 失效、open 抛错、
4681
- * surface 变化、被撤销都走这里):
4682
- * - 预留必须释放,否则一次失败会被永久记成「已经打开过」,该资源在本次运行里再也打不开;
4683
- * - 打开没有真正发出时,这个 key 不存在「Auto 亲手打开」的事实,残留来源标记一并撤销。
5942
+ * 自动动作收尾时释放在途记账(split busy、准备失败、layout 失效、open 抛错、surface 变化、
5943
+ * 被撤销都走这里)。必须按 surface 是否仍是出发时那一份分成两种行为:
5944
+ *
5945
+ * - 仍是出发时那一份:历史行为逐字不变——预留必须释放(否则一次失败会被永久记成「已经
5946
+ * 打开过」,该资源在本次运行里再也打不开),打开没有真正发出时撤销残留来源,并结算用户
5947
+ * 在这个窗口里留下的手动意图;
5948
+ * - generation 已经换掉(组件重挂载,或同一个实例 A→B→A):只能清理**明确属于本实例这一次
5949
+ * 动作**的在途记录——预留是本实例的 Map,且候选选择在预留存在期间绝不会为同一个 key 再选
5950
+ * 一次,因此这一刻它只可能是本次动作写下的那一笔。共享 Auto 账本(来源 / 抑制 / 重试)与
5951
+ * 手动意图一律不碰:openIssued 为假说明本次动作从来没有发出过 openResource,此刻那个 key
5952
+ * 上的来源只可能属于新 generation 已经成功打开的那次动作(旧实现无条件 delete 会把它清掉,
5953
+ * 新 Panel 从此再也认不出自己开出的 tab,资源结束后也不会自动关闭它)。
4684
5954
  */
5955
+ const surfaceCurrent = sidebarSurfaceStillCurrent() === true;
4685
5956
  if (autoContext !== null) {
4686
5957
  sidebarAutoReservationRef.current.delete(key);
4687
- if (autoContext.kind === "open" && openIssued !== true) {
5958
+ if (surfaceCurrent === true && autoContext.kind === "open" && openIssued !== true) {
4688
5959
  sidebarAutoOriginRef.current.delete(key);
4689
5960
  // 本次打开没有发出:把 key 记进失败屏障,同一批微任务里绝不再重选它(见其说明)。
4690
5961
  sidebarAutoFailedKeysRef.current.add(key);
4691
5962
  }
4692
- if (openIssued === true) sidebarAutoFailedKeysRef.current.delete(key);
5963
+ if (surfaceCurrent === true && openIssued === true) sidebarAutoFailedKeysRef.current.delete(key);
4693
5964
  }
4694
5965
  /**
4695
5966
  * 在途动作收尾后立刻结算用户在这个窗口里表达的手动意图:手动恒优先,绝不因为「那次点击撞上
4696
5967
  * 了 pending 锁」而丢失。没有意图时这一步是幂等空操作。
5968
+ *
5969
+ * 只由仍然属于当前 surface 的闭包结算:applyPendingManualIntent 会改共享的来源 / 抑制 / 重试
5970
+ * 账本,并可能派发新的打开或关闭动作;surface 换代后这些收尾属于新 Panel 的下一轮外部同步。
4697
5971
  */
4698
- applyPendingManualIntent(key);
5972
+ if (surfaceCurrent === true) applyPendingManualIntent(key);
4699
5973
  }
4700
5974
  };
4701
5975
 
@@ -4708,17 +5982,22 @@
4708
5982
  * root/session 的批量按钮保持可用,绝不被一个与它无关的在途序列(甚至永不 settle 的
4709
5983
  * 宿主 Promise)阻塞;
4710
5984
  * 2) 候选集合在**点击那一刻**从同一个 inventory 快照固定下来(渲染期算好、随点击闭包传入),
4711
- * 后续再点同一个按钮也不会把新出现的 tab 混进这一轮;
4712
- * 3) 每次 await 前后都校验 mountedRef / currentRootRef / currentSidebarSessionRef:root 或
4713
- * sidebar session 切换、组件卸载后立即停止后续关闭,绝不让旧点击继续操作新 surface。
5985
+ * 后续再点同一个按钮也不会把新出现的 tab 混进这一轮;但这个快照只用来决定「处理哪些候选」,
5986
+ * 真正调用 close 之前必须逐个重读**当前** inventory:等待前一个候选的 close 期间,同一个 tab
5987
+ * 可能已经被单项入口成功关掉,按旧快照再 close 一次就是重复副作用。inventory 读不到时
5988
+ * fail-closed 跳过,绝不按 active tab 猜;
5989
+ * 3) 每次 await 前后都过 surface 闸门(mountedRef / currentRootRef / currentSidebarSessionRef
5990
+ * 三条既有判据 + 不可复用的 surface 令牌):root 或 sidebar session 切换、组件卸载,以及
5991
+ * 「切走又切回同一组值」(A→B→A)之后立即停止后续关闭,绝不让旧点击继续操作新 surface。
4714
5992
  *
4715
5993
  * 单项失败严格隔离:close 同步抛错或返回 rejected Promise 只跳过这一项并继续其余项,
4716
5994
  * 也绝不把失败项标成已关闭——关闭记录一律交给宿主 inventory 与既有同步器确认(这里不删记录)。
5995
+ * 与单项入口共用同一个执行入口(closeSidebarTabWithLease):候选上已经有一次关闭在途时等待它
5996
+ * 结算,上次成功就直接收口、上次失败才用当前 inventory 复核后接管一次有限重试;用户按下的
5997
+ * 批量意图绝不在冲突里被静默丢失,也绝不对已经关掉的 tab 重复调用 close。
4717
5998
  */
4718
5999
  const handleSidebarBatchClose = async (candidates) => {
4719
- if (mountedRef.current !== true
4720
- || currentRootRef.current !== rootSessionId
4721
- || currentSidebarSessionRef.current !== sidebarSessionId) return;
6000
+ if (sidebarSurfaceStillCurrent() !== true) return;
4722
6001
  if (closeSidebarResource === null) return;
4723
6002
  if (Array.isArray(candidates) !== true || candidates.length === 0) return;
4724
6003
  // 锁只覆盖「这一个 surface」:同一个 scope 的重复点击被拦下,别的 root/session 的批量按钮
@@ -4730,29 +6009,55 @@
4730
6009
  // 立刻进入 pending 态:按钮 disabled + aria-busy,第二次点击在 has 检查处被拦下。
4731
6010
  bump((value) => value + 1);
4732
6011
  try {
4733
- for (const tabId of candidates) {
4734
- // await 之前:root/session 切换或卸载即停止后续关闭。
4735
- if (mountedRef.current !== true
4736
- || currentRootRef.current !== rootSessionId
4737
- || currentSidebarSessionRef.current !== sidebarSessionId) return;
4738
- try {
4739
- // 单项 await:同步抛错与 rejected Promise 都在这里被隔离,序列继续。
4740
- await closeSidebarResource.call(sidebarRight, tabId);
4741
- } catch { /* 单项失败只影响这一项:失败项绝不被标成已关闭,也不改写任何记录。 */ }
4742
- // await 之后:surface 已切换时不再继续(已经发出的那一次关闭照旧由宿主结算)。
4743
- if (mountedRef.current !== true
4744
- || currentRootRef.current !== rootSessionId
4745
- || currentSidebarSessionRef.current !== sidebarSessionId) return;
6012
+ for (const candidate of candidates) {
6013
+ // await 之前:surface 已经换掉(卸载、root/session 切换,或同一个实例 A→B→A 回返)即停止
6014
+ // 后续关闭。判据含不可复用的 surface 令牌,字符串重新相等不算「还是当前 surface」。
6015
+ if (sidebarSurfaceStillCurrent() !== true) return;
6016
+ /**
6017
+ * 候选形状闸门:候选必须**同时**携带 tabId 与资源地址(见 sidebarBatchCandidates)。
6018
+ * 缺任何一个都无法在副作用之前回答「这个 id 此刻仍然是我们要关的那个资源」——一律跳过
6019
+ * 这一项(其余候选继续处理),绝不退化成「只按 tabId 盲关」。
6020
+ */
6021
+ const tabId = text(object(candidate).tabId, null);
6022
+ const candidateAddress = text(object(candidate).address, null);
6023
+ if (tabId === null || candidateAddress === null) continue;
6024
+ /**
6025
+ * 每个候选调用前重新读**当前** inventory,并同时复核**地址**:候选集合只是点击那一刻的
6026
+ * 渲染快照,而等待前一个候选的 close 期间,同一个 tab 完全可能已经被别的入口(用户刚点的
6027
+ * 单项箭头、Auto 的关闭、旧 Panel 的迟到收尾)成功关闭,甚至这个 tabId 已经被宿主回收给了
6028
+ * 另一个资源。只有「present 且条目暴露的地址与候选地址逐字一致」才允许调用 close;
6029
+ * absent / recycled / unreadable 一律跳过这一项,绝不按 active tab 或渲染时快照猜一个 id。
6030
+ */
6031
+ if (sidebarTabPresenceInInventory(tabId, candidateAddress) !== "present") continue;
6032
+ /**
6033
+ * 与单 tab 关闭共用同一个执行入口(同一把 session + root + tabId 租约 + 结算信号):
6034
+ * 这个 tab 上已经有一次关闭在途时(典型:用户刚点的单项箭头还停在宿主 Promise 里),
6035
+ * 批量必须等待它结算再决定——上次成功就直接收口(绝不重复 close),上次失败且当前
6036
+ * inventory 仍证明这个精确 tabId 存在**且地址一致**时才接管**一次**有限重试。用户按下的
6037
+ * 批量意图因此绝不在冲突里被静默丢失,也不会对已经关掉的 tab 重复调用 close。
6038
+ */
6039
+ await closeSidebarTabWithLease(tabId, {
6040
+ waitForCompeting: true,
6041
+ /**
6042
+ * 批量入口没有 key(它的候选只有 tabId + 地址):按手动语义处理,不做验证收口——记录由
6043
+ * 同步层结算。地址必须传下去:它既是 preflight 的地址判据,也是等待竞争租约时等待者
6044
+ * 自己那份复核证据(没有地址证据的等待者遇到 recycled 结算只能跳过)。
6045
+ */
6046
+ verify: { key: null, address: candidateAddress, attemptToken: null, manual: true },
6047
+ });
6048
+
6049
+ // await 之后:surface 已切换(含同一个实例 A→B→A 回返)时不再继续(已经发出的那一次
6050
+ // 关闭照旧由宿主结算)。判据含不可复用的 surface 令牌,字符串重新相等不算「还是当前 surface」。
6051
+ if (sidebarSurfaceStillCurrent() !== true) return;
4746
6052
  }
4747
6053
  } finally {
4748
6054
  // 只释放自己那一个 scope:旧序列的收尾绝不改写别的 root/session 的锁。
4749
6055
  pendingScopes.delete(scopeKey);
4750
6056
  // 重渲染与同步只服务**自己这一轮渲染**的 surface:root/session 已经切换时它们会去操作
4751
6057
  // 新 surface(旧 scope 的 bump 会让新 root 白白重渲染、旧闭包的 sync 会去同步旧 root)。
4752
- // 回到同一个 root/session(键相同)时仍照常收尾,按钮不会因为缺少重渲染而停在 disabled。
4753
- if (mountedRef.current === true
4754
- && currentRootRef.current === rootSessionId
4755
- && currentSidebarSessionRef.current === sidebarSessionId) {
6058
+ // 判据含不可复用的 surface 令牌:同一个实例 A→B→A 之后字符串会重新相等,但这一轮批量序列
6059
+ // 已经不属于当前 surface,收尾交给新 Panel 自己的同步(bump 只由它自己发起)。
6060
+ if (sidebarSurfaceStillCurrent() === true) {
4756
6061
  bump((value) => value + 1);
4757
6062
  // 关闭序列结束后推进一次同步:候选集合与签名都按宿主 inventory 重新读取,
4758
6063
  // 真正被宿主关掉的 tab 由既有同步器(present/active/浮窗标题证据)结算,插件不抢先删记录。
@@ -12677,7 +13982,10 @@ window.__ModuleLoader__.load({
12677
13982
  const PROMPT_ROLE_IDS = SETTINGS_ROLE_IDS;
12678
13983
  const DEFAULT_PROMPT_MAX_GRAPHEMES = 32000;
12679
13984
  const PROMPT_WARNING_RATIO = 0.9;
12680
- const PROMPT_DISCIPLINE_HINT = "固定执行纪律由插件在运行时自动追加且不可编辑,包含叶子 worker、工具与权限边界、continuable child 约束、交付协议和中文输出要求。";
13985
+ // 只读提示的语义必须与运行期一致:固定执行纪律由 child 的 persona/system context
13986
+ // 与 scoped 工具策略(tools.restrict + runtime guard)在运行时注入,**不写进任务
13987
+ // 提示词**;用户只能编辑专家专属部分,纪律本身不可编辑、也不随草稿提交。
13988
+ const PROMPT_DISCIPLINE_HINT = "固定执行纪律不可编辑:它由 child 的 persona/system context 与 scoped 工具策略在运行时注入,不写进任务提示词;内容包含叶子 worker、工具与权限边界、continuable child 约束、交付协议和中文输出要求。";
12681
13989
 
12682
13990
  /**
12683
13991
  * grapheme 分段器按「locale + granularity」缓存。
@@ -16885,16 +18193,21 @@ window.__ModuleLoader__.load({
16885
18193
  disabled: saving,
16886
18194
  onChange: (event) => editDraft((previous) => ({ ...previous, notificationHoldMs: notificationHoldMsOf(Number(event.target.value)) })),
16887
18195
  }, NOTIFICATION_HOLD_OPTIONS.map((option) => react.createElement("option", { key: option.value, value: option.value }, option.label)))),
16888
- react.createElement("label", { className: "deg_settings_control" },
18196
+ // 两个输入控件分别提供可访问名称,避免一个 label 同时包裹多个可标记控件。
18197
+ react.createElement("div", { className: "deg_settings_control" },
16889
18198
  react.createElement("span", { className: "deg_settings_label" }, "通知音量"),
16890
18199
  react.createElement("input", {
18200
+ id: "deg-settings-notification-volume-range",
16891
18201
  type: "range", min: 0, max: 100, step: 1,
18202
+ "aria-label": "通知音量滑块",
16892
18203
  value: draft.notificationVolume,
16893
18204
  disabled: saving,
16894
18205
  onChange: (event) => editDraft((previous) => ({ ...previous, notificationVolume: clampNotificationVolume(event.target.value) })),
16895
18206
  }),
16896
18207
  react.createElement("input", {
18208
+ id: "deg-settings-notification-volume-number",
16897
18209
  type: "number", min: 0, max: 100, step: 1,
18210
+ "aria-label": "通知音量数值",
16898
18211
  value: draft.notificationVolume,
16899
18212
  disabled: saving,
16900
18213
  onChange: (event) => editDraft((previous) => ({ ...previous, notificationVolume: clampNotificationVolume(event.target.value) })),