dsh-client-ui-quote 0.12.3-lab.27 → 0.12.3-lab.28

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
@@ -6,10 +6,11 @@
6
6
 
7
7
  - 侧边对话默认内置隐藏线程,不再需要安装 dsh-better-sidebar:宿主半注册 `POST /ui-quote/api/sidechat.start`(同源围栏),从在线父会话复制种子,用宿主 `agents.create` 建 `origin: "subagent"` 子会话,并在宿主内置 `subagentchat` 页签打开;descriptor 与会话标题使用与 better-sidebar 共享的 `Side: ` 约定,装不装它都不冲突。宿主半不可用(旧宿主 / 父会话不在线 / 创建被拒)时自动退回 `sessions.fork` + 本插件原生 `quote-sidechat` 页签;`sideChatBackend: "better"` 才优先走 better-sidebar。
8
8
  - 侧边线程记忆与清单:新增 `dsh-client-ui-quote:v1:sidechat-target` 记录隐藏线程的父会话;读取线程时用 subagent 地址 `{parentSessionId, childSessionId, mode:"continuable"}`;线程列表同时扫描会话列表里的 `Side:` 子会话,所以 better-sidebar(或旧版本)创建的线程也能在本插件里打开、带回和导出。
9
- - 离线断言 **638 组**(check 12 + capsule 436 + panel 153 + host 37);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
9
+ - 离线断言 **644 组**(check 12 + capsule 439 + panel 153 + host 40);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 继承切点 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
10
10
 
11
11
  ### Fixed
12
12
 
13
+ - 隐藏线程的侧聊页一打开就「历史加载失败:subagent descriptor is unavailable」:种子把 `subagent/descriptor` 放在继承前缀里、又用整段种子的长度当 `inheritedEventCount`,宿主的 `api-session-controller` 要求 descriptor 的 `seq` 不小于继承切点,于是整条子会话的历史都读不出来。现在种子先落一条 `session/end-seed { inherited: true }` 标出切点,回合收口、工作目录与 descriptor 全部排在切点之后;嵌套侧聊的父 descriptor 留在继承前缀里,我们自己的那条排在后面(投影按最后一次生效)。客户端同时学会丢弃读不出来的旧线程:复用前用一次 `sessions.retain` 试开,宿主拒绝就忘掉它并新建——修复前创建的坏线程不会再被反复打开。
13
14
  - 正文以 ``` 或 `~~~` 开头的普通引用在回读时丢了首行:识别代码引用只看首行围栏,却没有要求块尾有配对的收尾围栏,于是引用一段 Markdown 源码时那行被当围栏剥掉(只有一行的引用会整条消失,胶囊、悬停卡片与引用清单都少字)。现在围栏必须成对才算传输层——写出去的代码引用收尾围栏始终成对,旧消息解析不变;不成对的孤围栏按正文还原。
14
15
 
15
16
  ## 0.12.2 — 2026-10-22
package/README.md CHANGED
@@ -139,7 +139,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
139
139
 
140
140
  本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
141
141
 
142
- 两条线的断言入口相同:`npm test`(638 项:check 12 + capsule 436 + panel 153 + host 37);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
142
+ 两条线的断言入口相同:`npm test`(644 项:check 12 + capsule 439 + panel 153 + host 40);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
143
143
 
144
144
  **推送通道**:`git push` 优先,但本机 `github.com:443` **时通时不通**(2026-10-19 实测:短时可用、随后连续超时,每次失败等约 21 秒)。日常用工作区根的 `promote.ps1`:先试 git,失败自动转 REST 兜底(`.dsh-quote-work/publish-release.mjs`、`.dsh-quote-work/push-branch.mjs`,`-Channel rest` 可跳过探测),并核对远端 SHA。**别混用两条通道**:REST 重建的提交时区归一到 `+0000`,内容相同但 SHA 不同,同一分支混推会分叉(`git status` 看着干净,远端却多一条平行提交);恢复就先 `git fetch`、确认 `git diff <本地> <远端>` 为空,再把本地线 reset 到远端。
145
145
 
@@ -326,7 +326,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
326
326
  1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
327
327
  2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
328
328
  3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
329
- 4. **工程化**:638 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
329
+ 4. **工程化**:644 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
330
330
 
331
331
  反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
332
332
 
@@ -363,9 +363,9 @@ locale/zh.json 插件页卡片文案(中文)
363
363
  locale/en.json 插件页卡片文案(英文)
364
364
  test/check.mjs 离线自检(清单 + 组件契约)
365
365
  test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
366
- test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,436 项断言)
366
+ test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,439 项断言)
367
367
  test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,153 项断言)
368
- test/host.mjs 离线自检(宿主半:标签 / 种子复制 / 开回合收口 / 悬挂工具调用截断 / 嵌套 descriptor 替换 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,37 项断言)
368
+ test/host.mjs 离线自检(宿主半:标签 / 种子复制 / 开回合收口 / 悬挂工具调用截断 / 嵌套 descriptor 替换 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,40 项断言)
369
369
  test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
370
370
  ```
371
371
 
@@ -416,7 +416,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
416
416
  修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
417
417
  **浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
418
418
  座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
419
- (共 436 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
419
+ (共 439 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
420
420
  `test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
421
421
 
422
422
  `test/panel.mjs` 单独跑引用清单页与侧边线程回流(153 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
@@ -435,13 +435,14 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
435
435
  `bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
436
436
  `exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
437
437
 
438
- `test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: ` 标签的前缀与截断、父日志逐字复制、开着的回合补 `step/end` + `turn/end{interrupted}`、悬挂工具调用截到回合前并丢掉尾部 inbox splice、嵌套侧聊的父 descriptor 原位替换、seq 连续、窗口化日志拒绝;`agents.create` 收到 `origin: "subagent"` / `parentSession` / `isSeeded` / `inheritedEventCount` / 父会话当前模型路由 / preset setup / 清空继承 inbox / 固定标题;探针、409、503、403、405、404 与 `apply` 的注册/卸载全部有断言。
438
+ `test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: ` 标签的前缀与截断、父日志逐字复制、开着的回合补 `step/end` + `turn/end{interrupted}`、悬挂工具调用截到回合前并丢掉尾部 inbox splice、嵌套侧聊的父 descriptor 留在继承前缀、我们的排在切点之后、继承切点(`session/end-seed{inherited:true}`)落在 descriptor 之前、seq 连续、窗口化日志拒绝;`agents.create` 收到 `origin: "subagent"` / `parentSession` / `isSeeded` / `inheritedEventCount` / 父会话当前模型路由 / preset setup / 清空继承 inbox / 固定标题;探针、409、503、403、405、404 与 `apply` 的注册/卸载全部有断言。
439
439
 
440
- 合计 **638 组**(check 12 + capsule 436 + panel 153 + host 37),`npm test` 全绿。
440
+ 合计 **644 组**(check 12 + capsule 439 + panel 153 + host 40),`npm test` 全绿。
441
441
 
442
442
  ## 已知限制
443
443
 
444
444
  - **内置隐藏线程依赖宿主内部契约**(app.asar 0.2.1-rc.3 实测):宿主半 `lib/index.js` 用 `ctx.inject(["webServer"])` 注册 `POST /ui-quote/api/sidechat.start`,在宿主服务就绪后从父会话 `snapshotEvents()` 复制种子,再用 `ctx.get("agents").create({ meta: { parentSession, isSeeded: true, origin: "subagent", delegationDepth, agentPreset }, seed, inheritedEventCount, agentOptions, setup })` 建子会话;descriptor 为 `{version: 3, mode: "continuable", provider: "ui-quote", label}`,标题 `Side: <引用首行>`。`webServer` / `connection.requestRejection` / `agents` 缺失时探针失败,客户端退回 fork;`agents.create` 失败只影响这一条线程。
445
+ **种子必须标出继承切点**:复制来的父日志之后先放一条 `session/end-seed { inherited: true }`,`inheritedEventCount` 就是它的下标;我们自己的事件(回合收口、工作目录、descriptor)必须排在这条标记**之后**。宿主读子会话身份时走 `subagent` 投影(最后一次 descriptor 生效),并在 `api-session-controller` 里要求 `identity.seq >= inheritedEventCount`,否则整个子会话的历史都打不开(`subagent/catalog-diagnostic` · "subagent descriptor is unavailable")。不标切点时构造器会把整段种子当成继承前缀、再把标记补在末尾,descriptor 就落到切点之前——0.12.3-lab.26 就是这么坏的,0.12.3-lab.27 修好。嵌套侧聊(父会话本身是侧线程)沿用这条规则:父 descriptor 留在继承前缀里,我们自己的那条排在切点之后,投影按“最后一条生效”取到它。
445
446
  隐藏线程是 `continuable` 子会话,**父会话在线时可继续**;父会话关闭后宿主内置 `subagentchat` 页按自己的规则只读,重新打开父会话即可继续。宿主升级改了 `agents.create` / 种子格式时,线程创建会失败并自动退回 `sessions.fork`,不会白屏或报错。
446
447
  想优先用 dsh-better-sidebar(实测 0.28.0)的侧聊 UI,把 `sideChatBackend` 设成 `"better"`;它仍走自己的 `POST /sidebar/api/sidechat.start`,父会话未运行时会收到 409。两边共享 `Side: ` 标题约定,装不装都不会互相抢自动打开的焦点。
447
448
  - **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按 **`ref + 引用原文`** 归一键、上限 60 条),同一句话从两个不同位置引用时互不影响;没有 ref 的旧引用仍按原文键读写。它不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
@@ -480,7 +481,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
480
481
  - **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
481
482
  - **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
482
483
  - **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
483
- - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 638 项离线断言;隐藏线程的 `agents.create` / 种子格式已在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8791,CLI 0.2.1-rc.3)里确认:宿主半通过 `ctx.inject(["webServer"])` 晚到注册,`POST /ui-quote/api/sidechat.start` 的探针返回 `{"ok":true,"value":{"available":true,"version":1}}`,说明 `agents` 服务与同源围栏都就绪。本轮又在**运行中的桌面端**(link 模式,重启后加载 `lib/index.js`)核对过一次:`/ui-quote/api/*` 下任意路径都先被围栏拦成 `403 {"code":"forbidden"}`,而同样方法打一个不存在的顶层路径是 `404`,说明前缀路由确实挂上了、并且先过围栏。原生侧聊页签的 `SessionProvider` / `conversation.content` 嵌入契约还需要在真实窗口里重开一次侧边对话确认(离线桩只覆盖注册、地址、标题与 retain/release,不渲染宿主组件)。真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
484
+ - **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 644 项离线断言;隐藏线程的 `agents.create` / 种子格式已在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8791,CLI 0.2.1-rc.3)里确认:宿主半通过 `ctx.inject(["webServer"])` 晚到注册,`POST /ui-quote/api/sidechat.start` 的探针返回 `{"ok":true,"value":{"available":true,"version":1}}`,说明 `agents` 服务与同源围栏都就绪。本轮又在**运行中的桌面端**(link 模式,重启后加载 `lib/index.js`)核对过一次:`/ui-quote/api/*` 下任意路径都先被围栏拦成 `403 {"code":"forbidden"}`,而同样方法打一个不存在的顶层路径是 `404`,说明前缀路由确实挂上了、并且先过围栏。原生侧聊页签的 `SessionProvider` / `conversation.content` 嵌入契约还需要在真实窗口里重开一次侧边对话确认(离线桩只覆盖注册、地址、标题与 retain/release,不渲染宿主组件)。真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
484
485
  - **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
485
486
 
486
487
  ## 变更记录
package/lib/client.js CHANGED
@@ -5706,6 +5706,54 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5706
5706
  return openNativeThreadTab(sessionId, threadId);
5707
5707
  }
5708
5708
 
5709
+ /**
5710
+ * A stored hidden thread is only reused while the host can still open
5711
+ * it. A child minted by an older build keeps its descriptor before the
5712
+ * inherited cut, so the host refuses its history for good ("subagent
5713
+ * descriptor is unavailable"); reopening it can never succeed, and
5714
+ * forgetting it lets the next click mint a healthy thread instead. The
5715
+ * answer is cached per page: a healthy thread costs one retain.
5716
+ */
5717
+ function hiddenThreadReadable(threadId) {
5718
+ if (targetOfThread(threadId).kind !== "hidden") return Promise.resolve(true);
5719
+ const cached = hiddenThreadHealth.get(threadId);
5720
+ if (cached !== undefined) return Promise.resolve(cached);
5721
+ return withSession(threadRetainTarget(threadId), () => null).then((read) => {
5722
+ if (read.ok === true) {
5723
+ hiddenThreadHealth.set(threadId, true);
5724
+ return true;
5725
+ }
5726
+ // Only a host that refuses the address is evidence of a broken
5727
+ // child. A host with no session service, or a retain that resolves
5728
+ // nothing yet, is "unknown" and must not churn a healthy thread.
5729
+ if (read.reason !== "failed") return true;
5730
+ hiddenThreadHealth.set(threadId, false);
5731
+ return false;
5732
+ });
5733
+ }
5734
+
5735
+ /** Drop every binding to one thread (a dead or unreadable child). */
5736
+ function forgetSideThread(sessionId, threadId) {
5737
+ if (typeof sessionId !== "string" || sessionId === "" || typeof threadId !== "string" || threadId === "") return;
5738
+ hiddenThreadHealth.delete(threadId);
5739
+ rememberThreadTarget(threadId, null);
5740
+ const known = sideThreadsOf(sessionId);
5741
+ const quotes = {};
5742
+ for (const [quoteKey, value] of Object.entries(known.quotes)) {
5743
+ if (value !== threadId) quotes[quoteKey] = value;
5744
+ }
5745
+ const remaining = Object.values(quotes);
5746
+ const fallback = known.default === threadId ? (remaining.length > 0 ? remaining[0] : null) : known.default;
5747
+ const map = readThreadMap(SIDE_THREAD_KEY);
5748
+ if (fallback === null && remaining.length === 0) delete map[sessionId];
5749
+ else map[sessionId] = { threadId: fallback ?? remaining[0], quotes };
5750
+ try {
5751
+ localStorage.setItem(SIDE_THREAD_KEY, JSON.stringify(map));
5752
+ } catch (error) {
5753
+ console.warn("[ui-quote] the stale side thread was not forgotten:", error);
5754
+ }
5755
+ }
5756
+
5709
5757
  /**
5710
5758
  * The preferred third-row action: one hidden child created by the host
5711
5759
  * half, opened in the host's own subagent chat. `true` means the request
@@ -5722,6 +5770,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5722
5770
  if (!(await probeBuiltinSideChat())) return false;
5723
5771
  const bound = typeof quoteKey === "string" && quoteKey !== "" ? threadForQuote(sessionId, quoteKey) : null;
5724
5772
  let threadId = mode === "new" ? null : bound ?? storedSideThread(sessionId);
5773
+ if (threadId !== null && !(await hiddenThreadReadable(threadId))) {
5774
+ forgetSideThread(sessionId, threadId);
5775
+ threadId = null;
5776
+ }
5725
5777
  if (threadId === null) {
5726
5778
  try {
5727
5779
  threadId = await startBuiltinSideThread(sessionId, sideChatTitle(text));
@@ -5972,6 +6024,9 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
5972
6024
  /** In-flight thread creations per session (a second click folds into the first). */
5973
6025
  const sideChatPending = new Map();
5974
6026
 
6027
+ /** Hidden threads already checked this page: thread id → the host can open it. */
6028
+ const hiddenThreadHealth = new Map();
6029
+
5975
6030
  /** One stored "session → side thread" map, best effort (a denied store is fine). */
5976
6031
  function readThreadMap(key) {
5977
6032
  try {
package/lib/index.js CHANGED
@@ -178,14 +178,21 @@ function descriptorOf(options) {
178
178
  * The copy is verbatim so the model sees the same messages, tool results and
179
179
  * compactions it saw in the parent. A turn still open at the copy moment is
180
180
  * closed honestly (`turn/end` with `interrupted`); a dangling tool call
181
- * cannot be closed that way, so the open turn is cut instead. The
182
- * working-directory event and our subagent descriptor trail the copy. When
183
- * the parent is itself a side thread, its descriptor is REPLACED in place so
184
- * ours is the one the host folds — the seq layout stays contiguous.
181
+ * cannot be closed that way, so the open turn is cut instead.
182
+ *
183
+ * The copy then ends at an explicit inherited cut — a `session/end-seed`
184
+ * marker with `inherited: true` — and everything the child owns (turn
185
+ * closers, the working directory, our descriptor) is appended AFTER it. That
186
+ * placement is load-bearing, not cosmetic: the host reads a child's identity
187
+ * from its `subagent` projection and rejects any descriptor whose `seq` is
188
+ * below the cut ("subagent descriptor is unavailable"). Marking the cut also
189
+ * keeps a nested side thread honest — the parent's own descriptor stays in
190
+ * the inherited prefix while ours is the last one the projection folds.
185
191
  *
186
192
  * @param {Array<object>} events - the parent session's event log.
187
193
  * @param {object} options - cwd, label, model route, and an optional clock.
188
- * @returns {Array<object>} the seed, seq-contiguous from 0.
194
+ * @returns {{seed: Array<object>, inheritedEventCount: number}} the seed,
195
+ * seq-contiguous from 0, plus the index of its inherited cut.
189
196
  */
190
197
  export function buildThreadSeed(events, options = {}) {
191
198
  const source = Array.isArray(events) ? events : [];
@@ -194,29 +201,29 @@ export function buildThreadSeed(events, options = {}) {
194
201
  throw new SidechatError("bad-session", "the parent session log is not contiguous", 400);
195
202
  }
196
203
  }
204
+ const now = options.now ?? Date.now();
197
205
  let seed = source.map(copySeedEvent);
206
+ const closers = [];
198
207
  const boundary = lastTurnBoundary(seed);
199
208
  if (boundary >= 0 && seed[boundary]?.type === "turn/start") {
200
209
  const turn = numberAt(seed[boundary].data, "turn");
201
- const now = options.now ?? Date.now();
202
210
  if (hasDanglingToolCall(seed, boundary)) {
203
211
  seed = seed.slice(0, boundary);
204
212
  while (seed.length > 0 && seed[seed.length - 1]?.type === "agent/inbox/spliced") seed.pop();
205
213
  } else {
206
214
  const step = openStepOf(seed, boundary);
207
- if (step !== undefined) seed.push({ type: "step/end", seq: seed.length, time: now, data: { turn, step } });
208
- seed.push({ type: "turn/end", seq: seed.length, time: now, data: { turn, reason: { kind: "interrupted" } } });
215
+ if (step !== undefined) closers.push({ type: "step/end", time: now, data: { turn, step } });
216
+ closers.push({ type: "turn/end", time: now, data: { turn, reason: { kind: "interrupted" } } });
209
217
  }
210
218
  }
211
- const now = options.now ?? Date.now();
219
+ const inheritedEventCount = seed.length;
220
+ seed.push({ type: "session/end-seed", seq: inheritedEventCount, time: now, data: { inherited: true } });
221
+ for (const closer of closers) seed.push({ ...closer, seq: seed.length });
212
222
  if (typeof options.cwd === "string" && options.cwd !== "") {
213
223
  seed.push({ type: "working-directory/change", seq: seed.length, time: now, data: { cwd: options.cwd } });
214
224
  }
215
- const descriptor = descriptorOf(options);
216
- const existing = seed.findIndex((event) => event.type === "subagent/descriptor");
217
- if (existing >= 0) seed[existing] = { ...seed[existing], time: now, data: descriptor };
218
- else seed.push({ type: "subagent/descriptor", seq: seed.length, time: now, data: descriptor });
219
- return seed;
225
+ seed.push({ type: "subagent/descriptor", seq: seed.length, time: now, data: descriptorOf(options) });
226
+ return { seed, inheritedEventCount };
220
227
  }
221
228
 
222
229
  /** The host's preset composition for the child (a no-op without the service). */
@@ -293,7 +300,7 @@ export async function startSideChat(ctx, payload, handles = new Map()) {
293
300
  const routeModel = selection?.model ?? parent.options?.model;
294
301
  const routeEffort = selection?.reasoningEffort ?? parent.options?.reasoningEffort;
295
302
  const { agentPreset, setup } = await composeSetup(ctx, presetOf(parent.session.header, events));
296
- const seed = buildThreadSeed(events, {
303
+ const { seed, inheritedEventCount } = buildThreadSeed(events, {
297
304
  cwd,
298
305
  label,
299
306
  agentProvider: routeProvider,
@@ -320,7 +327,7 @@ export async function startSideChat(ctx, payload, handles = new Map()) {
320
327
  ...(agentPreset === undefined ? {} : { agentPreset })
321
328
  },
322
329
  seed,
323
- inheritedEventCount: seed.length,
330
+ inheritedEventCount,
324
331
  agentOptions,
325
332
  setup,
326
333
  ...(signal === undefined ? {} : { signal })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-client-ui-quote",
3
- "version": "0.12.3-lab.27",
3
+ "version": "0.12.3-lab.28",
4
4
  "description": "Turn any DSH selection into a reviewable quote — comment / add to chat / side chat, source highlights and jump-back, a searchable review list, side threads, revisions, and per-quote reply badges.",
5
5
  "icon": "icon.svg",
6
6
  "license": "MIT",
package/test/capsule.mjs CHANGED
@@ -983,6 +983,61 @@ try {
983
983
  JSON.stringify(hiddenState.draft)
984
984
  );
985
985
 
986
+ // A healthy thread costs exactly one retain before it is reused, so a
987
+ // broken one can be told apart below without guessing.
988
+ installShell("session-hidden-1");
989
+ sessionReadImpl = () => ({
990
+ ready: Promise.resolve(),
991
+ binding: { session: { eventSource: { getSnapshot: () => ({ entries: [] }) }, getSnapshot: () => ({ hasMore: false }) } },
992
+ release() {}
993
+ });
994
+ const readsBefore = sessionReads.length;
995
+ const startsBefore = fetches.filter((entry) => entry.payload.probe !== true).length;
996
+ await testing.hiddenSideChatRequest("session-hidden-main", "第三段引用", false, null, "", "reuse", "第三段引用");
997
+ check(
998
+ "a healthy hidden thread is reused after one retain",
999
+ sessionReads.length === readsBefore + 1 &&
1000
+ fetches.filter((entry) => entry.payload.probe !== true).length === startsBefore &&
1001
+ JSON.stringify(sessionReads.at(-1)?.address) === JSON.stringify({ parentSessionId: "session-hidden-main", childSessionId: "session-hidden-1", mode: "continuable" }),
1002
+ JSON.stringify(sessionReads.at(-1))
1003
+ );
1004
+
1005
+ // The host refusing the address is the one signal that the child is
1006
+ // unusable (an older build wrote its descriptor before the inherited
1007
+ // cut). Reopening it can never load, so the next click mints a new one.
1008
+ const staleMain = "session-hidden-stale";
1009
+ const staleThread = "session-hidden-stale-child";
1010
+ testing.rememberThreadTarget(staleThread, { kind: "hidden", parentId: staleMain });
1011
+ testing.rememberSideThread(staleMain, staleThread, { kind: "hidden", parentId: staleMain });
1012
+ installShell("session-hidden-fresh");
1013
+ sessionReadImpl = (address) => {
1014
+ if (address !== null && typeof address === "object" && address.childSessionId === staleThread) throw new Error("subagent descriptor is unavailable");
1015
+ return null;
1016
+ };
1017
+ globalThis.fetch = async (url, options) => {
1018
+ const payload = JSON.parse(options.body);
1019
+ fetches.push({ url, payload });
1020
+ if (payload.probe === true) {
1021
+ return { ok: true, status: 200, json: async () => ({ ok: true, value: { available: true, version: 1 } }) };
1022
+ }
1023
+ return { ok: true, status: 200, json: async () => ({ ok: true, value: { childId: "session-hidden-fresh", parentId: staleMain } }) };
1024
+ };
1025
+ openedResources.length = 0;
1026
+ const repaired = await testing.hiddenSideChatRequest(staleMain, "旧线程引用", false, null, "", "reuse", "旧线程引用");
1027
+ check(
1028
+ "a hidden thread the host refuses is replaced, not reopened",
1029
+ repaired === true &&
1030
+ testing.storedSideThread(staleMain) === "session-hidden-fresh" &&
1031
+ openedResources.length === 1 &&
1032
+ String(openedResources[0]?.address).includes("session-hidden-fresh"),
1033
+ JSON.stringify({ stored: testing.storedSideThread(staleMain), opened: openedResources })
1034
+ );
1035
+ check(
1036
+ "the unreadable thread's parent binding is dropped",
1037
+ testing.targetOfThread(staleThread).kind === "fork",
1038
+ JSON.stringify(testing.targetOfThread(staleThread))
1039
+ );
1040
+
986
1041
  // Reading the thread goes through the subagent address.
987
1042
  sessionReadImpl = () => {
988
1043
  return {
package/test/host.mjs CHANGED
@@ -63,30 +63,42 @@ const closedLog = [
63
63
  { type: "step/end", seq: 4, time: 5, data: { turn: 1, step: 1 } },
64
64
  { type: "turn/end", seq: 5, time: 6, data: { turn: 1, reason: { kind: "done" } } }
65
65
  ];
66
- const plainSeed = buildThreadSeed(closedLog, { cwd: "C:/work", label: "Side: 第一段", now: 100, agentProvider: "p", agentModel: "m", agentReasoningEffort: "high" });
66
+ const { seed: plainSeed, inheritedEventCount: plainCut } = buildThreadSeed(closedLog, { cwd: "C:/work", label: "Side: 第一段", now: 100, agentProvider: "p", agentModel: "m", agentReasoningEffort: "high" });
67
67
  check(
68
68
  "a closed parent log is copied verbatim",
69
69
  JSON.stringify(plainSeed.slice(0, closedLog.length)) === JSON.stringify(closedLog),
70
70
  JSON.stringify(plainSeed.slice(0, closedLog.length))
71
71
  );
72
72
  check(
73
- "the seed appends the working directory and our descriptor",
74
- plainSeed.length === closedLog.length + 2 &&
75
- plainSeed[closedLog.length].type === "working-directory/change" &&
76
- plainSeed[closedLog.length].data.cwd === "C:/work" &&
77
- plainSeed[closedLog.length + 1].type === "subagent/descriptor" &&
78
- plainSeed[closedLog.length + 1].data.version === 3 &&
79
- plainSeed[closedLog.length + 1].data.mode === "continuable" &&
80
- plainSeed[closedLog.length + 1].data.provider === "ui-quote" &&
81
- plainSeed[closedLog.length + 1].data.label === "Side: 第一段",
73
+ "the copy ends at a marked inherited cut",
74
+ plainCut === closedLog.length &&
75
+ plainSeed[plainCut].type === "session/end-seed" &&
76
+ plainSeed[plainCut].data.inherited === true,
77
+ JSON.stringify({ cut: plainCut, marker: plainSeed[plainCut] })
78
+ );
79
+ check(
80
+ "the seed appends the working directory and our descriptor after the cut",
81
+ plainSeed.length === closedLog.length + 3 &&
82
+ plainSeed[plainCut + 1].type === "working-directory/change" &&
83
+ plainSeed[plainCut + 1].data.cwd === "C:/work" &&
84
+ plainSeed[plainCut + 2].type === "subagent/descriptor" &&
85
+ plainSeed[plainCut + 2].data.version === 3 &&
86
+ plainSeed[plainCut + 2].data.mode === "continuable" &&
87
+ plainSeed[plainCut + 2].data.provider === "ui-quote" &&
88
+ plainSeed[plainCut + 2].data.label === "Side: 第一段",
82
89
  JSON.stringify(plainSeed.slice(closedLog.length))
83
90
  );
91
+ check(
92
+ "the descriptor sits after the inherited cut, which the host validates",
93
+ plainSeed.findIndex((event) => event.type === "subagent/descriptor") > plainCut,
94
+ JSON.stringify({ cut: plainCut, at: plainSeed.findIndex((event) => event.type === "subagent/descriptor") })
95
+ );
84
96
  check(
85
97
  "the descriptor carries the parent's route",
86
- plainSeed[closedLog.length + 1].data.agentProvider === "p" &&
87
- plainSeed[closedLog.length + 1].data.agentModel === "m" &&
88
- plainSeed[closedLog.length + 1].data.agentReasoningEffort === "high",
89
- JSON.stringify(plainSeed[closedLog.length + 1].data)
98
+ plainSeed[plainCut + 2].data.agentProvider === "p" &&
99
+ plainSeed[plainCut + 2].data.agentModel === "m" &&
100
+ plainSeed[plainCut + 2].data.agentReasoningEffort === "high",
101
+ JSON.stringify(plainSeed[plainCut + 2].data)
90
102
  );
91
103
  check(
92
104
  "the seed's sequence stays contiguous from zero",
@@ -95,16 +107,18 @@ check(
95
107
  );
96
108
 
97
109
  const openLog = closedLog.slice(0, 4);
98
- const openSeed = buildThreadSeed(openLog, { label: "Side: 开着", now: 50 });
110
+ const { seed: openSeed, inheritedEventCount: openCut } = buildThreadSeed(openLog, { label: "Side: 开着", now: 50 });
99
111
  check(
100
- "an open step is closed before the inherited cut",
101
- openSeed.length === openLog.length + 3 &&
102
- openSeed[4].type === "step/end" &&
103
- openSeed[4].data.turn === 1 &&
104
- openSeed[4].data.step === 1 &&
105
- openSeed[5].type === "turn/end" &&
106
- openSeed[5].data.reason.kind === "interrupted" &&
107
- openSeed[6].type === "subagent/descriptor",
112
+ "an open step is closed after the inherited cut",
113
+ openCut === openLog.length &&
114
+ openSeed.length === openLog.length + 4 &&
115
+ openSeed[openCut].type === "session/end-seed" &&
116
+ openSeed[5].type === "step/end" &&
117
+ openSeed[5].data.turn === 1 &&
118
+ openSeed[5].data.step === 1 &&
119
+ openSeed[6].type === "turn/end" &&
120
+ openSeed[6].data.reason.kind === "interrupted" &&
121
+ openSeed[7].type === "subagent/descriptor",
108
122
  JSON.stringify(openSeed.slice(openLog.length))
109
123
  );
110
124
 
@@ -114,11 +128,13 @@ const danglingLog = closedLog.concat([
114
128
  { type: "tool/call", seq: 8, time: 9, data: { callId: "call-1", name: "read" } },
115
129
  { type: "agent/inbox/spliced", seq: 9, time: 10, data: { target: "next-turn", start: 0, inserted: [] } }
116
130
  ]);
117
- const danglingSeed = buildThreadSeed(danglingLog, { label: "Side: 悬挂", now: 60 });
131
+ const { seed: danglingSeed, inheritedEventCount: danglingCut } = buildThreadSeed(danglingLog, { label: "Side: 悬挂", now: 60 });
118
132
  check(
119
133
  "a dangling tool call cuts the open turn, not the whole log",
120
- danglingSeed.length === closedLog.length + 1 &&
121
- danglingSeed[closedLog.length].type === "subagent/descriptor" &&
134
+ danglingCut === closedLog.length &&
135
+ danglingSeed.length === closedLog.length + 2 &&
136
+ danglingSeed[danglingCut].type === "session/end-seed" &&
137
+ danglingSeed[closedLog.length + 1].type === "subagent/descriptor" &&
122
138
  danglingSeed.every((event, index) => event.seq === index),
123
139
  JSON.stringify(danglingSeed.slice(closedLog.length))
124
140
  );
@@ -131,13 +147,16 @@ check(
131
147
  const nestedLog = closedLog.concat([
132
148
  { type: "subagent/descriptor", seq: 6, time: 7, data: { version: 3, mode: "continuable", provider: "sidechat", label: "Side: parent" } }
133
149
  ]);
134
- const nestedSeed = buildThreadSeed(nestedLog, { label: "Side: child", now: 70 });
150
+ const { seed: nestedSeed, inheritedEventCount: nestedCut } = buildThreadSeed(nestedLog, { label: "Side: child", now: 70 });
135
151
  check(
136
- "a parent descriptor is replaced in place, so ours is authoritative",
137
- nestedSeed.filter((event) => event.type === "subagent/descriptor").length === 1 &&
138
- nestedSeed[6].data.label === "Side: child" &&
139
- nestedSeed[6].data.provider === "ui-quote",
140
- JSON.stringify(nestedSeed[6])
152
+ "a nested thread keeps the parent's descriptor inherited and ours last",
153
+ nestedCut === nestedLog.length &&
154
+ nestedSeed[6].data.label === "Side: parent" &&
155
+ nestedSeed.at(-1).type === "subagent/descriptor" &&
156
+ nestedSeed.at(-1).data.label === "Side: child" &&
157
+ nestedSeed.at(-1).data.provider === "ui-quote" &&
158
+ nestedSeed.at(-1).seq > nestedCut,
159
+ JSON.stringify(nestedSeed.slice(nestedLog.length))
141
160
  );
142
161
 
143
162
  let gapError = null;
@@ -219,10 +238,17 @@ check(
219
238
  JSON.stringify(options.meta)
220
239
  );
221
240
  check(
222
- "the inherited count is the seed's own length",
223
- options.inheritedEventCount === options.seed.length && options.seed.every((event, index) => event.seq === index),
241
+ "the inherited count is the marked cut, not the whole seed",
242
+ options.inheritedEventCount === options.seed.findIndex((event) => event.type === "session/end-seed" && event.data.inherited === true) &&
243
+ options.inheritedEventCount < options.seed.length &&
244
+ options.seed.every((event, index) => event.seq === index),
224
245
  String(options.inheritedEventCount)
225
246
  );
247
+ check(
248
+ "the descriptor the host folds is the child's own",
249
+ options.seed.findIndex((event) => event.type === "subagent/descriptor") > options.inheritedEventCount,
250
+ JSON.stringify({ cut: options.inheritedEventCount, at: options.seed.findIndex((event) => event.type === "subagent/descriptor") })
251
+ );
226
252
  check(
227
253
  "the child requests the parent's current route, not the stale creation default",
228
254
  options.agentOptions.provider === "route" && options.agentOptions.model === "m-route" && options.agentOptions.reasoningEffort === "low",