dsh-side-branch 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -42,6 +42,7 @@
42
42
  | `cordis.patch.yml` | bundle patch,只 insert 一行,`name` 必须是真实包名 |
43
43
  | `package.json` | `dsh.bundle.patch` 声明 patch;`dsh.client` 声明浏览器半 |
44
44
  | `scripts/smoke.mjs` | 契约自检:加载宿主半、验证只读白名单与 guard 形状、核验依赖包存在。**只在源码仓库里存在**,不随 npm 包发布(它断言的是源码形状,脱离源码树跑不了) |
45
+ | `scripts/client-check.mjs` | 客户端半的**运行时**自检:在 Node 里用极简 React + 极简 DOM 加载**真实 bundle**、渲染**真实组件**、喂**真实形状的 SSE 帧**,然后断言渲染出来的树里有没有那个答案。**同样只在源码仓库里存在**,不随 npm 包发布(它断言的是真实运行行为,脱离源码树跑不了) |
45
46
 
46
47
  ## 改动前的硬约束
47
48
 
@@ -59,6 +60,24 @@
59
60
 
60
61
  **不要删掉「跑前解锁、跑完回藏」这套循环,也不要把 `unarchiveSession` 改成硬调。** DSH `0.1.7-rc.1` 起,`dsh-api-session-controller` 的 `ArchivedSessionGate` 会拒掉**已归档会话**的任何模型步(`agent/pre-step` ⇒ reject ⇒ `dsh-agent-loop` 把轮次收成 `turn/end { reason: 'blocked' }`,**连模型请求都不会发出**)。本插件正是用归档来隐藏侧会话 ⇒ 少了 `handleStart` 里 `followup` 之前那次 `releaseArchiveGate()`,或少了 `finishJob` 里那次 `rehideAfterTurn()`,侧枝在 0.1.7 上就**每一轮全废**(面板会显示 `T.blocked`;那句「内容被安全策略拦截」与内容策略毫无关系,别被它带偏)。而 `unarchiveSession` 是 **`0.1.6-alpha.2`** 才有的方法:**必须走 `typeof` 特性探测**,硬调会让 `0.1.5-rc.2` 抛错,直接毁掉向后兼容。`scripts/smoke.mjs` 的第 ⑥ 节守着这套形状。
61
62
 
63
+ **不要删掉 Re-Fork,也不要在它上面"省事"。** 每一轮追问都要先看 `shouldReFork`:**主会话结算了新回合**,或者**模型/推理等级变了**,就新建一段(种子 = 主会话最新已结算前缀)并把上一段侧枝自己的对话重放进去。四条不能动:
64
+ 1. **`shouldReFork` 必须同时判这两条。** 只判"主会话长了"的话,用户**换了模型但主会话没动**时那一轮会继续用旧模型 —— 静默忽略用户的选择。
65
+ 2. **顺序:先重放成功,再释放老会话。** 反了就没有退路(老会话没了、新的没历史)。重放失败要**丢弃刚建的新会话**、老会话原样保留、本轮回 `side-branch/refork-failed`。⛔ 绝不静默降级成"没有历史的新会话"。
66
+ 3. **重放只传 `event.data` 原样,且只重放 5 种 surface 事件。** 手搓 `assistant/message` 必被 `dsh-session` 拒(它要求真实 `source.provider/model` 与 `stream` 数组);`turn/start`/`tool/call` 之类是纯日志事件,`append` 它们会抛。⛔ **也绝不要"顺手补全" `event.sourceEventSeqs`** —— `tool/result` 在事件层带着 `[旧会话的 callSeq]`,抄过来会撞 `sourceEventSeqs must reference earlier events`。
67
+ 4. **不截断侧枝历史。** 到上下文上限就**拒绝**(`estimateRequestTokens` + `side-branch/context-full`)。"拒绝是显式失败,截断是隐式失真"。
68
+
69
+ **不要把"换模型 ⇒ 带历史换段"加回来**(`seedSource`、`sameAgentOptions` 交接、`skipPrependNotice` 手动传递)。re-fork 把这条路径整个吸收了:换模型只是"该重建"的又一个理由。`createConversation` 只能以**父会话**为种子。
70
+
71
+ **别把 `parentCut` 漏在建段路径上。** `createConversation` 建段时就要记 `parentCut`(= 那一刻父会话已结算前缀的长度),`sleepConversation`/`resumeConversation` 也要带着它走。漏了的话 `completedTurnPrefixLength(...) > undefined` 恒为真 ⇒ **每一轮都白 re-fork 一次**。
72
+
73
+ **多段回答(`job.segments`)别改回"两个字符串槽位"。** 一个 turn 的**每个 step** 都会发一个 `start` 帧:`turn`/`step` 变了才是"新的一段",同 step 换 `attemptId` 才是**重试**(只有这时才丢该 step 的段落)。把每个 `start` 都当重试清空,就是"中间那段思考和正文消失"那个 BUG。另外 `replace` **只替换当前段**(整段替换会把前面几段一起覆盖),`snapshot`/终态必须带 `segments`(否则刷新后多段又没了)。
74
+
75
+ **别把 `lib/client.js` 里那些"两边共用"的常量/纯函数塞进任何一个函数内部。** `clampString` / `SEGMENT_KINDS` / `MAX_SEGMENTS` / `MAX_SEGMENT_CHARS` / `MAX_SEGMENTS_TOTAL_CHARS` / `normalizeSegment` / `normalizeSegments` / `trimSegments` 这几个**必须留在模块作用域** —— `createPanelStore()` 与 `SideBranchBody` **两边都用**(store 里做持久化形状校验、组件里做流式写入)。放进其中一边,另一边就会 `ReferenceError`,而那个错**只会出现在浏览器 console 里、被事件派发吞掉**(现象:面板静默不动、答案不显示)。`scripts/client-check.mjs` 就是为了抓这类错误。
76
+
77
+ **不要删掉实例租约(`side-branch-instances/`),也不要绕过它做孤儿清理。** 清理判据是"磁盘上的 `side-*` 不在本进程账本里",而账本**在启动时必然是空的** ⇒ **两个共用同一个 `DSH_HOME` 的实例同时跑**时,后启动的那个会把先启动的那个**正在用的**侧枝目录当孤儿删掉。所以清理前必须先立自己的租约、再看有没有别的**活着的**外来租约 —— 有就**整个跳过**。
78
+
79
+ **`GET /side-branch/inherited` 的返回绝不能进面板持久化状态。** 一份主会话前缀可能几十万字符,而面板状态走 `sessionStorage`(5–10 MB 配额)⇒ 塞进 `rounds`/`normalizeRound` 会立刻爆配额、坏掉"刷新不丢"。它只放组件内存态、按需拉取。
80
+
62
81
  **SSE 响应是整条手写的**(`Connection: close`)。换成框架自带的写法会坏,原因写在 `lib/index.js` 的 `handleStream` 注释里。
63
82
 
64
83
  **客户端一切输入都不可信。** 问题长度、模型标识、`conversationId` 归属都要在宿主侧校验。
@@ -75,7 +94,9 @@
75
94
 
76
95
  **为什么不用官方的可续子会话。** `ctx.subagents.startContinuable` 表面上正好合适(官方提供的、可继续对话的子会话),但它结算时会把子会话的最终回答当成一条**父会话用户消息**投回父会话,并唤醒父模型跑一整轮(`dsh-subagent` 的 `watchSettlement` / `notifySettlement`,官方 README 说明这是设计行为且没有开关)。对多数场景合理,但对本插件是致命的:直接违反"答案不进主会话"这个前提,还要为此多跑一轮模型。
77
96
 
78
- **为什么自建侧会话、以及为什么种子取"已完成回合前缀"。** 现在的做法是 `ctx.agents.create()` 建一个**普通侧会话**(`meta.parentSession` 只当血缘,**不设** `origin:'subagent'` —— 不需要子代理的生命周期,也不想让主会话里出现子代理卡片),出生时用公开的 `session.snapshotEvents()` 读出父会话事件、自己切到最后一个 `turn/end` 当种子,再在 `setup` 里 `agentPresets.composeFrom(agentCtx, parent.ctx)` 继承父会话的 preset/工具/persona。这样提示词前缀与父会话逐字一致,**继续命中原有的前缀缓存**;多轮追问就是同一个会话上继续 `followup`。代价是继承的是**开段那一刻的快照**:开段之后父会话的新回合不进这条分支(否则每次追问都要重算种子,前缀跟着变,缓存白拿),用户要带上新内容得「清空」另起一段。
97
+ **为什么自建侧会话、以及为什么种子取"已完成回合前缀"。** 现在的做法是 `ctx.agents.create()` 建一个**普通侧会话**(`meta.parentSession` 只当血缘,**不设** `origin:'subagent'` —— 不需要子代理的生命周期,也不想让主会话里出现子代理卡片),出生时用公开的 `session.snapshotEvents()` 读出父会话事件、自己切到最后一个 `turn/end` 当种子,再在 `setup` 里 `agentPresets.composeFrom(agentCtx, parent.ctx)` 继承父会话的 preset/工具/persona。这样提示词前缀与父会话逐字一致,**继续命中原有的前缀缓存**。
98
+
99
+ **为什么每轮都"重建"(re-fork),而不是让一段会话一直活下去。** 一段会话活着,它的种子就冻在建段那一刻 —— 主会话之后聊了什么,这条分支**永远看不到**,用户只能「清空」另起一段(0.1.x 就是这样)。反过来,每次追问都新建一段、重新取主会话**最新**的已结算前缀当种子、再把侧枝自己历来的问答重放进去,就同时拿到两件事:**主会话的更新**和**侧枝自己的连续**。缓存上也不亏:主会话最新的那段内容**刚被主会话自己请求过**,很可能还在供应商的热缓存里,re-fork 的请求前缀正好和它对得上(走缓存读);而"前缀冻结 + 往后追加"的做法里,同样的内容是被侧枝**第一次**以那种形态发出去(走缓存写)。**只在有变化时才重建**(主会话没长、模型也没换 ⇒ 沿用当前段):种子会逐字节相同,重建只会多一条平台删不掉的会话记录与一次深拷贝。
79
100
 
80
101
  **为什么侧会话要归档、又为什么要"跑前解锁、跑完回藏"。** 侧会话是一条**真实会话**,不处理它就会在左侧边栏多出一行、和用户自己的对话混在一起。官方把会话从分组界面藏起来的唯一手段就是**归档**(官方 README 自己把归档集描述成 "sessions hidden from every grouping surface"),所以本插件建段即归档。问题是 `0.1.7-rc.1` 给"已归档"加了第二层语义:**已归档 ⇒ 不许运行**(`ArchivedSessionGate` 在 `agent/pre-step` 上拒绝)。于是"建段即归档"这个写法在新版本上会**自己把自己锁死**(每一轮都在 `followup` 之前被拦,连模型请求都不发)。而**不能**改成"干脆不归档"就完事——那会让侧会话重新出现在会话列表里,那是产品体验、不是实现细节。现在的取舍是三步:**空闲时归档(列表干净)→ 每轮投递前取消归档(不被闸)→ 该轮结算后再归档(列表重新干净)**。代价如实登记:回答进行中它可能短暂出现在侧栏,以及每轮多两三次持久写盘。另一条路是官方 `origin: 'subagent'`(侧栏原生隐藏子代理来源的行),但它会连带子代理的生命周期语义,与"不设 `origin:'subagent'`"那条前提冲突,所以没走。
81
102
 
@@ -83,7 +104,18 @@
83
104
 
84
105
  **为什么设置不走官方设置服务。** `settings.register(ns, schema)` 要的是 **schemastery 的 `z<T>` 本身**(`dsh-settings` 的 `resolve()` 会直接调用它,schema 必须可调用),而 `@deepseek-ai/schemastery` 是平台内置包,从插件工作区里**静态 import / 动态 import / `createRequire` 三种方式都解析不到**(一律 `ERR_MODULE_NOT_FOUND`);手工造一个"兼容 schema"也不行(节点对象靠属性工作、不可调用)。⇒ 设置改为客户端 `localStorage` 持久化、每次请求把值带给宿主,宿主只做校验与夹紧。代价是官方设置页里看不到这个命名空间,插件自带齿轮面板。
85
106
 
86
- **为什么到达上下文上限是"拒绝"而不是"丢最早的轮次"。** 后者实现更简单、用户也不会被打断,但会让模型**静默失忆**:它不记得被丢掉的内容,却仍然表现得像记得,用户很难察觉。所以做法是每轮读子会话 `assistant/message` 的 usage(`input + cacheRead`)与 `request/context` 的 `contextWindow`,超过窗口的 80% 就拒绝发送,并把实际用量与上限显示在面板上。
107
+ **为什么到达上下文上限是"拒绝"而不是"丢最早的轮次"。** 后者实现更简单、用户也不会被打断,但会让模型**静默失忆**:它不记得被丢掉的内容,却仍然表现得像记得,用户很难察觉。所以做法是**到上限就拒绝发送**,把 `used` / `limit` / `window` 一起回给面板(`contextFull`),**绝不截断历史**。
108
+
109
+ ⚠️ **判据在 0.2.0 从"读 usage"改成了"估算"。** re-fork 之后新会话**还没有跑过任何一轮** ⇒ 老判据 `conv.lastContextTokens`(子会话 `assistant/message` 的 `input + cacheRead`)必然是 `undefined`,用不了。现在的口径(`estimateRequestTokens`)是:
110
+
111
+ ```text
112
+ used ≈ 父会话前缀的提示词 tokens(实测:父会话日志里最后一条 assistant/message 的 input + cacheRead + cacheWrite)
113
+ + (侧枝历史字符数 + 本轮问题字符数) × CONTEXT_ESTIMATE_SAFETY
114
+ ```
115
+
116
+ 超过 `contextWindow × CONTEXT_LIMIT_RATIO`(0.8)就拒绝。拿不到 `contextWindow` 时上限取 `CONTEXT_LIMIT_FALLBACK_TOKENS`,拿不到父会话 usage 时父前缀那半取 `CONTEXT_ESTIMATE_PARENT_FALLBACK`。⚠️ **不从父 Agent 上读 `lastContextTokens`**:**父 Agent 上没有这个字段**(那是本插件自己按会话事件维护的)。
117
+
118
+ ⚠️ **如实登记:`used` 是估算值。** 父前缀那一半是实测,侧枝历史与问题那一半是"字符数 × 安全系数" ⇒ 面板上显示的这个数字**不是**实测用量,它和该轮结束后用量行里的真实数字对不上是正常的。宁可早拒也不误发。
87
119
 
88
120
  **为什么放行名单不可配置。** 只读承诺的支点越少越好。如果放行面由"宿主常量 × 设置里的名字集合 × 客户端提交上来的子集"三者求交而成,三处必须始终一致,任何一处写错都可能让放行面变宽。现在只有一处:`ALLOWED_TOOLS` 与 `TOOL_CHOICES` 两个同源常量——模型看到的分支引导词也从同一个常量取,所以"告诉模型能用什么"与"执行层实际放行什么"不可能分叉。代价是用户无法自行收窄权限,想收窄只能改代码。
89
121
 
@@ -95,28 +127,40 @@
95
127
 
96
128
  | 方法与路径 | 请求体 | 响应 |
97
129
  | --- | --- | --- |
98
- | `POST /side-branch/start` | `{ sessionId, question, selection?, conversationId?, locale?, settings? }` | `{ jobId, conversationId, handoff? }`;失败时 `{ error, code }`,可能另带 `conversationGone: true` 或 `contextFull: { used, limit, window }` |
99
- | `GET /side-branch/stream?job=<jobId>` | — | SSE:`snapshot` / `delta` / `reasoning` / `reset` / `replace` / `tool` / `done` / `error` / `stopped` |
130
+ | `POST /side-branch/start` | `{ sessionId, question, selection?, conversationId?, locale?, settings? }` | `{ jobId, conversationId, handoff?, synced?, notice? }`;失败时 `{ error, code }`,可能另带 `conversationGone: true`、`contextFull: { used, limit, window }` |
131
+ | `GET /side-branch/stream?job=<jobId>` | — | SSE:`snapshot` / **`segment`** / `delta` / `reasoning` / `reset` / `replace` / `tool` / `done` / `error` / `stopped` |
100
132
  | `POST /side-branch/stop` | `{ job }` | `{ status }`,只停这一轮,侧会话保留 |
101
133
  | `POST /side-branch/close` | `{ conversationId }` | `{ status }`,释放这一段侧会话 |
134
+ | `GET /side-branch/inherited?conversationId=&locale=` | — | **只读**:这一段**实际继承到**的主会话回合(`{ turns, chars, truncated, parentHasNewer, synced }`;最后一轮全文、更早每轮一行摘要) |
102
135
  | `GET /side-branch/settings` | — | `{ settings: { quickEntry } }` |
103
136
  | `POST /side-branch/settings` | `{ settings: { quickEntry } }` | `{ settings: { quickEntry } }` |
104
137
 
138
+ `/start` 响应里三个可选字段的含义(客户端按它们决定行为):
139
+
140
+ - `handoff: true` = 这一次**重建了**这一段(re-fork)。历史是**接得上**的 ⇒ 客户端**不要**画"以下是新的一段"分隔线。
141
+ - `synced: { turns, chars }` = 这一次继承了多少主会话内容(面板那一行「已继承主会话 N 轮 · 约 X 字」)。
142
+ - `notice` = **注入给模型的分支引导词全文**(只有真的拼了的那一轮才回,即每段第一轮)。这是本插件注入的**唯一**文本;系统提示词与工具声明是 DSH 自己组装的,不属于本插件、不在这里给。
143
+
144
+ SSE 的载荷变化(0.2.0):新增 `segment`(进入下一个 step ⇒ 开新段);`snapshot`/`done`/`stopped`/`error` 都带 `segments`(有序段落 `{kind, text, turn, step}`);`reset`/`delta`/`reasoning`/`replace`/`tool` 都带 `turn`/`step`(客户端靠它精确地把 `reset` 限定在**同一个 step** 上)。
145
+
105
146
  设置只有一项:`quickEntry`(主会话快速入口按钮的开关)。**不接受**客户端提交工具名单——放行名单是宿主侧的常量,插件不提供"自选可用工具"的功能。若哪天要给这个功能开口子,先读 `lib/index.js` 头部「只读必须在执行层保证」那一段。
106
147
 
107
148
  失败的响应带机器可读的 `code`,一律 `side-branch/` 前缀加 kebab-case,例如 `side-branch/question-too-long`、`side-branch/context-full`。客户端按 `code` 分支,不按文案分支。路径存在但方法不对时回 **405 + `Allow`**(支持的方法表在 `lib/index.js` 的 `ROUTE_METHODS`),不是 404。
108
149
 
109
150
  ## 验证改动
110
151
 
111
- > ⚠️ 下面这条命令**只在源码仓库里有效**:`scripts/smoke.mjs` 不随 npm 包发布
112
- > (它读 `lib/` 与 `package.json` 做源码级断言,从 `node_modules` 里跑没有意义)。
152
+ > ⚠️ 下面这两条命令**只在源码仓库里有效**:`scripts/smoke.mjs` 与 `scripts/client-check.mjs`
153
+ > 都不随 npm 包发布(前者读 `lib/` 与 `package.json` 做源码级断言、后者真的把客户端 bundle 跑起来,
154
+ > 两者脱离源码树都没有意义)。
113
155
  > 如果你是照 npm 装好的插件在做二次检查,请到仓库里跑。
114
156
 
115
157
  ```powershell
116
- node scripts/smoke.mjs
158
+ node scripts/smoke.mjs # ① 源码级契约自检
159
+ node scripts/client-check.mjs # ② 客户端运行时自检
160
+ # 两条一起跑:npm run check | 只跑客户端那条:npm run client-check
117
161
  ```
118
162
 
119
- 不需要 DSH 运行时,直接加载宿主半并用假 ctx 调 `apply()`。它守的六处:
163
+ **① `scripts/smoke.mjs`(源码级)** 不需要 DSH 运行时,直接加载宿主半并用假 ctx 调 `apply()`。当前共 **136 条断言**,分九节:
120
164
 
121
165
  1. 模块能加载、导出名与 `package.json` 一致、路由前缀是 `/side-branch`、卸载 disposer 可调用;
122
166
  2. **只读承诺**:`ALLOWED_TOOLS` 与 `TOOL_CHOICES` 仍是那六个名字且互相一致,九个危险工具名都不在其中;客户端没有工具开关的残留、宿主不接受客户端提交的工具名单;分支引导词的名单直接来自常量;guard 的判据仍是按名单放行、注册点仍在 `setup(agentCtx)` 里、拿不到 guard 仍 fail-loud。
@@ -124,21 +168,56 @@ node scripts/smoke.mjs
124
168
  3. 客户端 bundle 存在、inject 仍是那五个服务名、不含调试导出;
125
169
  4. `package.json` 与 `cordis.patch.yml` 的包名一致;
126
170
  5. `dsh.client.inject` 里每个包在本机 DSH 安装目录里真实存在(用 `DSH_INSTALL_DIR` 指定路径,找不到就跳过这一项);
127
- 6. **归档闸对策**:解锁必须排在 `followup` 之前、必须经 `typeof` 特性探测、失败不得抛;回藏必须挂在 `finishJob`、要有界退避、同样不得抛;建段仍然先归档。这一节守的就是"同一份源码同时兼容 0.1.5 与 0.1.7"这条线。
171
+ 6. **归档闸对策**:解锁必须排在 `followup` 之前、必须经 `typeof` 特性探测、失败不得抛;回藏必须挂在 `finishJob`、要有界退避、同样不得抛;建段仍然先归档。这一节守的就是"同一份源码同时兼容 0.1.5 与 0.1.7"这条线;
172
+ 7. **Re-Fork 架构(0.2.0)**:`shouldReFork` 必须**同时**判"主会话多了已结算回合"与"模型/推理等级变了"两条;`parentCut` 必须在**建段时**就记(睡觉/唤醒也要带着它走,漏了就是每轮白 re-fork);重放白名单**正好**是 5 种 surface 事件(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)且不含纯日志事件;重放**绝不复制 `sourceEventSeqs`**;重放**不截断**;**先重放成功、再释放老会话**(失败时丢弃刚建的新会话);每轮 re-fork 都回 `handoff: true`;旧的 `seedSource` 换段路径已彻底删除。这一节还守着上限改成估算的三处:`estimateRequestTokens` / `lastPromptTokensOf` 存在,且**不再**用 `conv.lastContextTokens` 那条 re-fork 之后必然为 `undefined` 的旧判据;
173
+ 8. **多段回答(0.2.0)**:`job.segments` 有序段落;`start` 帧按 `turn`/`step` 分流(同 step 换 `attemptId` 才算重试 ⇒ 只丢该 step,step 变了 ⇒ 发 `segment` 开新段);`replace` **只**替换当前段;`snapshot` 与终态都带 `segments`;`syncSegmentShortcuts` 让 `text`/`reasoning` 成为段落数组的**纯函数**;旧的 `attempts` 字段已拆成 `steps`/`retries`;客户端 `normalizeSegments` 对**老数据**(只有 `answer`/`reasoning`)向后兼容;另守「引用随轮入档」:发送时把收窄后的引用原文记进该轮(`reference`),渲染成问题上方的可折叠引用块,并**自动清空**引用槽;`normalizeRound` 保留它 ⇒ 刷新后引用块还在。
174
+ 9. **启动清理与只读路由(0.2.0)**:`/inherited` 登记进 `ROUTE_METHODS`(不登记的话错方法会掉进 404,破坏 405 + `Allow` 契约);`handleInherited` 按"最后一轮全文 + 更早每轮一行摘要"截断;继承内容**不进** `normalizeRound`(只放组件内存态);孤儿判据要求目录名**正好**是 `side-<uuid>`;**实例租约**(`side-branch-instances/*.json`,心跳 + PID 判活);发现别的活着的实例就**整个跳过**;删除留**清单日志**;清理严格限定在 `sessions` 根目录下、只认 `side-` 前缀(不碰 `session-*` 主会话)。
128
175
 
129
- 它**不验证**回答质量、真实 guard 拦截、SSE 行为与界面。端到端验证需要:
176
+ **① 不验证**的东西:回答质量、真实 guard 拦截(第 2 节那几条是**源码级正则断言**,不是真驱动一次拦截)、SSE 行为与界面 —— 客户端那半由下面的 ② 补到"渲染树"为止;真实 DOM 布局 / CSS / 真实滚动位置 / 真实网络 / 真实模型它同样够不到,那几处只能靠仓库**外**的一次性 headless 端到端 harness(见下)与人工。
177
+
178
+ ### ② 客户端运行时自检(`scripts/client-check.mjs`)
179
+
180
+ ```powershell
181
+ node scripts/client-check.mjs # 只跑这一条
182
+ node scripts/client-check.mjs <别的 client.js 路径> # 反向验证脚本本身(见下)
183
+ ```
184
+
185
+ **为什么会有它。** 人工看面板时连撞了几个 bug,而当时**三处自动化全都查不出来**:`node --check` 只看语法;`scripts/smoke.mjs` 是**源码级正则断言**;端到端 harness 打的是 **HTTP 路由、根本不加载客户端**。最严重的一个是:`const MAX_SEGMENTS = 200` 等常量写在 `createPanelStore()` **内部**,却在 `SideBranchBody` **组件**里用 ⇒ 第一个 SSE `delta` 帧一到就抛 `ReferenceError: MAX_SEGMENTS is not defined`,被浏览器的事件派发**吞掉**(只在 console 里)⇒ 每个增量都丢、**答案永远不显示**,用户看到的是"问了之后面板像卡住"。这个脚本就是为补这个窟窿写的,并且**反向验证过**:拿一份故意改坏的副本跑,它确实 FAIL 并打印 `FAIL SSE 帧 snapshot 的处理函数抛错:MAX_SEGMENTS is not defined`。
186
+
187
+ **它怎么做到的。** 不装浏览器,而是在 Node 里把 `lib/client.js` **真的跑起来**:bundle 的形态是 `window.__ModuleLoader__.load({ factory })`,它只从 `require` 取三样东西 —— `react`、`react-dom`、`@deepseek-ai/dsh-client-ui-primitives`(后两样**故意不提供**,bundle 自带降级分支,正好连降级路径一起测了);用到的 React API 只有 **8 个**(`createElement` / `useState` / `useEffect` / `useLayoutEffect` / `useReducer` / `useRef` / `Fragment` / `Component`),所以一个极简 React + 极简 DOM 就够。面板的能力**全部来自席位的 `inject` face**(`inject: () => ({ startSideBranch, … })`),脚本照官方那样把它展开进 props —— 顺便也就验了 face 里该有的键。再用真实形状的 `fetch` / `EventSource` 桩驱动:往输入框打字(走真实 `onChange`)、点发送(走真实 `onClick`)、喂真实形状的 SSE 帧。
188
+
189
+ **它验了什么。** bundle 能装载、导出 `apply()`、正文组件被注册并渲染成功;客户端把词典注册进了 locale 服务(断言因此盯的是**真实文案**,不是"返回 key"那种弱断言);点发送确实发**一次** `POST /side-branch/start` 且带上问题原文,并打开指向该 job 的 SSE 流;喂进去的每一帧都有监听函数;**多段正文都渲染出来且顺序正确、工具行夹在两段正文之间**("只剩最后一段"那个 bug 的回归判据);注入的分支引导词全文可见;**自动滚底落在真正可滚动的那一层**上(`.dsh-side-branch-scroll`),而不可滚动的轮次列表上没有挂 ref。
190
+
191
+ 当前共 **34 项**,PASS/FAIL 直接看 stdout(全过时末行是 `全部通过(34 项)`,有失败则以非零退出码结束)。
192
+
193
+ ⚠️ **它不覆盖什么(如实登记)**:真实 DOM 布局、CSS、真实滚动位置、真实浏览器事件、真实网络与真实模型。它是**"数据 → 渲染树"这一段的测试,不是浏览器测试** —— 那几处仍然只能人工在浏览器里看。
194
+
195
+ **可选参数用于反向验证脚本本身**:`node scripts/client-check.mjs <别的 client.js 路径>` 让它去跑另一份 bundle(例如一份故意改坏的副本),那份**必须 FAIL**。**一个不会失败的测试等于没有测试。**
196
+
197
+ **手工**端到端验证需要:
130
198
 
131
199
  1. 一个已装好本插件的 profile;
132
200
  2. 一个主会话,至少完成一个回合;
133
201
  3. 在页面里划选文字、打开面板、提问;
134
202
  4. 检查启动日志出现 `[side-branch]` 前缀的行。
135
203
 
136
- 仓库内没有端到端测试套件。改动 guard、提示词前缀、模型可见文本(`PROMPT_TEXT`)、归档闸那几处或 SSE 时,第 2 步到第 4 步必须人工跑一遍。
204
+ 仓库内**有两个自检(源码级 + 客户端运行时),但没有端到端套件**。改动 guard、提示词前缀、模型可见文本(`PROMPT_TEXT`)、归档闸那几处、Re-Fork、多段回答或 SSE 时,第 2 步到第 4 步必须人工跑一遍;改动**客户端半**(`lib/client.js`)时同样必须人工跑一遍 —— `scripts/client-check.mjs` 只把"数据 → 渲染树"那一段自动化了,界面本身还是得自己看。
205
+
206
+ ### 仓库外的一次性 headless 端到端 harness
207
+
208
+ 仓库里**没有**端到端套件(它是测试资产:不随 npm 包发布,也不该留在工作区里)。当改动落在单测够不到的地方 —— guard 的真实拦截、提示词前缀、归档闸、Re-Fork、多段回答、SSE —— 就自己搭一个**一次性** harness。做法:在**同一个 DSH 进程**里同时扮演两个角色 ——
209
+
210
+ - **客户端**:先按官方信任栅栏换成浏览器会话 cookie,再打插件**真实的** `POST /side-branch/start`,并读 `GET /side-branch/stream` 的 SSE;
211
+ - **主会话**:用 `ctx.agents.create` 建一条**真的**父会话(`agentPresets.resolve` + `mount` 挂上 `standard` preset,所以**有工具**)并跑真回合。
212
+
213
+ 它能覆盖 `scripts/smoke.mjs` 够不到的那几处:侧枝这一轮**真的走了工具**(`tool/call` + `tool/result` 都在);主会话推进后追问 ⇒ re-fork(`conversationId` 变了、`handoff: true`、继承轮数递增);re-fork 后模型同时答得出**三个来源**的暗号(只在侧枝历史里的 / 只在最新主会话前缀里的 / 只在**被重放的工具结果**里的);主会话没变化 ⇒ **不**重建;只换推理等级 ⇒ **也**重建;`GET /side-branch/inherited` 返回 200 且最后一轮有全文、错方法回 **405 + `Allow: GET`**;侧枝自己的暗号**没有回流**主会话;跑完的侧枝**回到归档集合**;伪造 `conversationId` 回 `conversation-gone`;以及"被重放的 `tool/result` 与新段里那条 `tool/call` 的 `toolCallId` 对得上"(孤儿工具结果是这条链路最阴的坑)。
214
+
215
+ ⚠️ 只在**发布前**做一次性验证:跑完别把 harness 提交进仓库,也别把它的日志或结论写进 `docs/`。
137
216
 
138
217
  ## 提交与推送
139
218
 
140
219
  - **提交信息(commit message)一律用英文。** 这是公开仓库、历史面向英文读者:标题用 `type: 祈使句`(如 `fix: un-archive the side session before each turn`),正文讲"为什么",**不要混中文**。
141
220
  - 建议拆成两个提交:`lib/` + `scripts/` 的行为改动一个,纯文档 / 版本号一个。
142
- - **推之前**先跑 `node scripts/smoke.mjs`(上一节),并在真宿主里人工跑一遍受影响的链路。
143
- - 版本号与 `docs/CHANGELOG.md` 同步更新;发布时打 `v<version>` 标签(已有 `v0.1.0`)。
221
+ - **推之前**先跑 `node scripts/smoke.mjs` 与 `node scripts/client-check.mjs`(上一节;或 `npm run check` 两条一起跑),并在真宿主里人工跑一遍受影响的链路。
222
+ - 版本号与 `docs/CHANGELOG.md` 同步更新;发布时打 `v<version>` 标签(已有 `v0.1.0`、`v0.1.1`;当前版本是 **0.2.0**)。
144
223
  - 远端:`origin` = `github.com/wucl12/dsh-side-branch`,主分支 `main`。
package/README.md CHANGED
@@ -41,10 +41,11 @@ This project strictly chooses to enforce read-only at the **execution layer**. T
41
41
  ## ✨ Core Features
42
42
 
43
43
  * **True Branch, Zero Pollution:** The host strictly prevents writing anything back to the parent session. The Side Ask Q&A will not appear in the main session's model context.
44
- * **Rapid Prefix Cache Hits:** Automatically reads the completed turn prefix of the main session as a seed. When using the same model, it hits the existing prefix cache, avoiding recalculation.
44
+ * **Follows the Main Session, Every Turn:** Every follow-up rebuilds the branch from the main session's **latest settled** content — a fresh seed plus a replay of the branch's own earlier questions and answers — so the branch always sees the main session as it is now **without losing anything it was asked before**. Reusing the main session's model keeps hitting the existing prefix cache, avoiding recalculation.
45
45
  * **Execution-Layer Read-Only Isolation:** Security policies do not rely on prompts. Forced takeover by the underlying `ctx.tools.guard` whitelists only six query tools without side effects (`read`, `glob`, `grep`, `web_search`, `web_fetch`, `lsp`). Other instructions (like writing files or executing scripts) are directly rejected at the execution layer.
46
46
  * **Ultimate Streaming Experience:** Text and reasoning processes (collapsible "Thinking" rows) are streamed token-by-token. Background generation won't interrupt even if the browser tab is closed.
47
47
  * **Persistent Session State:** Panel states are persisted locally per "Session + Panel Grid". Automatically sleeps when idle and silently wakes up on the next query.
48
+ * **Inspectable by Design:** Expand "Inherited from the main session" to see, turn by turn, what the branch actually received — the question, its reasoning, and the answer. The branch notice injected into the prompt is shown verbatim as well, so nothing about what the model sees is a black box.
48
49
 
49
50
 
50
51
  ## 📦 Installation & Setup
@@ -65,14 +66,17 @@ dsh plugin --profile web add github:wucl12/dsh-side-branch
65
66
 
66
67
  ## 🚀 How to Use
67
68
 
68
- 1. **Ask via Selection:** Select any text in the main session's body, click the pop-up "Quote and ask", and a temporary session will open in the right panel with the original text sent as a quote.
69
+ 1. **Ask via Selection:** Select any text in the main session's body, click the pop-up "Quote and ask", and a temporary session will open in the right panel with the original text sent as a quote. The passage is then **recorded with that round** — a collapsible quote block above your question — and the composer's quote slot clears itself, so the next question starts clean.
69
70
  2. **Quick Entry:** There is a quick entry button `↗` on the right side of the main session's input box. Click it to directly summon the Side Ask panel (can be disabled in settings).
70
71
  3. **Independent Settings:** In the Side Ask panel, you can individually specify the model and reasoning effort for the current branch without following the main session.
71
72
 
72
73
  ## 🛡️ Security & Limitations
73
74
 
74
75
  * **Read-Only Boundaries:** "Read-only" here means **no system side effects** (like writing files or executing commands). However, `web_fetch` / `web_search` still have network access, and `read` can read files within the process's permissions. The allowlist is hardcoded and cannot be relaxed via settings.
75
- * **Context Snapshot Timing:** The temporary branch inherits a history snapshot **at the exact moment it is opened**. New content generated in the main session afterward will not automatically sync to the current branch. Click "Clear" at the bottom left to start a new topic if you need to include newer context.
76
+ * **What the Branch Can See:** Every follow-up rebuilds it from the main session's **latest settled turns**, so turns the main session has finished do follow along automatically. A turn the main session **has not finished yet** is not visible to the branch (the cut is the last `turn/end`) — but **manual selection quoting is not affected by that**: the limitation is on "seeing it automatically", not on "being able to quote it".
77
+ * **Rebuilds Leave Session Records:** Each rebuild creates a new side-session record, and the platform has **no API to delete a session** ⇒ records would pile up. They are reclaimed by an **orphan sweep at plugin startup**, which logs item by item what it deleted. While **two instances share the same `DSH_HOME`** and run at the same time, that sweep **skips entirely** — a second DSH never has its live branches deleted.
78
+ * **The Context Ceiling Is a Refusal:** On reaching it the plugin **refuses** the turn and tells you to "Clear"; the oldest turns are never silently dropped. `contextFull.used` is an **estimate** (a rebuilt session has no usage yet), so it will not match the real usage once a turn settles.
79
+ * **Cache Hits Are Measured, Not Guaranteed:** The high prefix-cache hit rate is a measurement, not a design promise — the platform may replace the system node in place on the side session's first step, invalidating the cache **from token 0 on**. And **hitting the cache does not mean those tokens stop occupying the context window**.
76
80
  * **PTC Mode Exception:** If the main session uses the `ptc` preset (pure code execution tool surface), since `run_code` is permanently rejected by this plugin, the branch will fall back to a **pure text Q&A mode** (no tools can be called).
77
81
 
78
82
  ## 📚 Further Documentation
package/docs/AGENTS.en.md CHANGED
@@ -42,6 +42,7 @@ Failure criteria and what they mean:
42
42
  | `cordis.patch.yml` | Bundle patch, inserts a single line, `name` must be the real package name |
43
43
  | `package.json` | `dsh.bundle.patch` declares the patch; `dsh.client` declares the browser half |
44
44
  | `scripts/smoke.mjs` | Contract self-check: loads the host half, verifies the read-only allow-list and the shape of the guard, confirms the dependency packages exist. **Only exists in the source repository** and is not published with the npm package (what it asserts is source shape, so it cannot run outside the source tree) |
45
+ | `scripts/client-check.mjs` | **Runtime** self-check for the client half: inside Node it loads the **real bundle** with a minimal React + minimal DOM, renders the **real component**, feeds it **SSE frames of the real shape**, and then asserts whether the answer is present in the rendered tree. **Likewise only exists in the source repository** and is not published with the npm package (what it asserts is real runtime behaviour, so it cannot run outside the source tree) |
45
46
 
46
47
  ## Hard constraints before changing anything
47
48
 
@@ -59,6 +60,25 @@ Failure criteria and what they mean:
59
60
 
60
61
  **Do not remove the "un-archive before a turn, re-archive after it" cycle, and do not turn `unarchiveSession` into a hard call.** From DSH `0.1.7-rc.1`, `dsh-api-session-controller`'s `ArchivedSessionGate` rejects any model step proposed for an **archived session** (`agent/pre-step` ⇒ reject ⇒ `dsh-agent-loop` ends the turn as `turn/end { reason: 'blocked' }`, **without a model request ever being sent**). This plugin hides side sessions precisely by archiving them ⇒ drop the `releaseArchiveGate()` call before `followup` in `handleStart`, or the `rehideAfterTurn()` call in `finishJob`, and the branch is **dead on every single turn** on 0.1.7 (the panel shows `T.blocked`; that "content blocked by security policy" wording has nothing to do with any content policy — do not be misled by it). And `unarchiveSession` only exists from **`0.1.6-alpha.2`**: it **must be feature-detected with `typeof`**, because a hard call throws on `0.1.5-rc.2` and destroys backward compatibility outright. Section ⑥ of `scripts/smoke.mjs` guards this shape.
61
62
 
63
+ **Do not remove Re-Fork, and do not "cut corners" on it.** Every follow-up must first consult `shouldReFork`: if **the main session has settled a new turn** or **the model / reasoning effort changed**, a new segment is created (seed = the main session's latest settled prefix) and the previous segment's own side-branch conversation is replayed into it. Four things are not negotiable:
64
+
65
+ 1. **`shouldReFork` must test both conditions.** Testing only "the main session grew" means that when the user **changes the model while the main session has not moved**, that turn keeps using the old model — silently ignoring the user's choice.
66
+ 2. **Order: replay successfully first, release the old session second.** Reversed there is no way back (the old session is gone and the new one has no history). If the replay fails, **discard the segment just created**, leave the old session exactly as it was, and refuse the turn with `side-branch/refork-failed`. ⛔ Never silently degrade to "a new session with no history".
67
+ 3. **The replay passes only `event.data`, verbatim, and replays only the 5 surface event types.** Hand-building an `assistant/message` is always rejected by `dsh-session` (it requires a real `source.provider/model` and a `stream` array); `turn/start`/`tool/call` and the like are pure log events and `append`ing them throws. ⛔ **And never "helpfully" fill in `event.sourceEventSeqs`** — a `tool/result` carries `[the old session's callSeq]` at the event level, and copying it over trips `sourceEventSeqs must reference earlier events`.
68
+ 4. **Do not truncate the side branch's history.** At the context limit the turn is **refused** (`estimateRequestTokens` + `side-branch/context-full`). "A refusal is an explicit failure; truncation is silent distortion."
69
+
70
+ **Do not bring back "changed model ⇒ carry the history into a new segment"** (`seedSource`, the `sameAgentOptions` handoff, the manual `skipPrependNotice` handover). Re-fork absorbs that whole path: changing the model is just one more reason to rebuild. `createConversation` can only be seeded from the **parent session**.
71
+
72
+ **Do not drop `parentCut` from the segment-creation path.** `createConversation` must record `parentCut` when a segment is created (= the length of the parent session's settled prefix at that moment), and `sleepConversation`/`resumeConversation` must carry it along. Without it, `completedTurnPrefixLength(...) > undefined` is always true ⇒ **every turn re-forks for nothing**.
73
+
74
+ **Do not turn multi-segment answers (`job.segments`) back into "two string slots".** **Every step** of a turn emits its own `start` frame: only a changed `turn`/`step` means "a new segment", and only a changed `attemptId` within the same step means a **retry** (that, and only that, drops that step's segments). Treating every `start` as a retry is exactly the "the middle reasoning and the middle answer disappeared" bug. Also, `replace` rewrites **only the current segment** (replacing the whole answer would overwrite the earlier segments), and `snapshot`/terminal events must carry `segments` (otherwise a refresh loses every segment again).
75
+
76
+ **Do not stuff the constants and pure functions that "both sides share" in `lib/client.js` into any function.** `clampString` / `SEGMENT_KINDS` / `MAX_SEGMENTS` / `MAX_SEGMENT_CHARS` / `MAX_SEGMENTS_TOTAL_CHARS` / `normalizeSegment` / `normalizeSegments` / `trimSegments` **must stay at module scope** — `createPanelStore()` and `SideBranchBody` **both use them** (the store for persisted-shape validation, the component for live streaming writes). Put them inside one of the two and the other gets a `ReferenceError`, and that error **only ever shows up in the browser console, swallowed by event dispatch** (the symptom: the panel sits still and the answer never appears). `scripts/client-check.mjs` exists precisely to catch this class of error.
77
+
78
+ **Do not remove the instance lease (`side-branch-instances/`), and do not sweep orphans around it.** The sweep's criterion is "a `side-*` directory on disk that is not in this process's ledger", and that ledger **is necessarily empty at startup** ⇒ when **two instances share one `DSH_HOME` and run at the same time**, the one that starts later deletes the segments the earlier one **is using** as orphans. So before sweeping, an instance must post its own lease and then check whether any other **live** lease is present — if one is, **skip the entire sweep**.
79
+
80
+ **The payload of `GET /side-branch/inherited` must never enter persisted panel state.** A main-session prefix can be hundreds of kilobytes, and panel state goes through `sessionStorage` (a 5–10 MB quota) ⇒ putting it into `rounds`/`normalizeRound` blows the quota instantly and breaks "survives a refresh". It lives only in component memory and is fetched on demand.
81
+
62
82
  **The SSE response is hand-written from end to end** (`Connection: close`). Replacing it with the framework's built-in way of writing it will break it; the reason is in the `handleStream` comment in `lib/index.js`.
63
83
 
64
84
  **All client input is untrusted.** Question length, model identifier and `conversationId` ownership must all be validated host-side.
@@ -75,7 +95,9 @@ The following are approaches that "look like less work but break this plugin's p
75
95
 
76
96
  **Why not use the official continuable child session.** `ctx.subagents.startContinuable` looks exactly right on the surface (officially provided, a child session you can keep talking to), but on settlement it posts the child session's final answer back into the parent session as a **parent-session user message** and wakes the parent model for a full turn (`watchSettlement` / `notifySettlement` in `dsh-subagent`; the official README states this is designed behaviour with no switch). That is reasonable for most scenarios, but fatal for this plugin: it directly violates the "the answer does not enter the main session" premise, and costs an extra model turn on top.
77
97
 
78
- **Why we build the side session ourselves, and why the seed is the "completed-turn prefix".** The current approach is `ctx.agents.create()` to create a **plain side session** (`meta.parentSession` is used only as lineage, `origin:'subagent'` is **not** set — we do not need a subagent lifecycle, and we do not want a subagent card appearing in the main session), then at birth we use the public `session.snapshotEvents()` to read out the parent session's events and cut to the last `turn/end` ourselves as the seed, and in `setup` we use `agentPresets.composeFrom(agentCtx, parent.ctx)` to inherit the parent session's preset/tools/persona. This makes the prompt prefix word-for-word identical to the parent session's, so it **keeps hitting the existing prefix cache**; follow-up questions are just a continued `followup` on the same session. The cost is that what is inherited is the **snapshot at the moment the segment was opened**: new turns in the parent session after the segment is opened do not enter this branch (otherwise every follow-up would have to recompute the seed, the prefix would change with it, and the cache would be wasted), and a user who wants to carry new content in has to "Clear" and start a new segment.
98
+ **Why we build the side session ourselves, and why the seed is the "completed-turn prefix".** The current approach is `ctx.agents.create()` to create a **plain side session** (`meta.parentSession` is used only as lineage, `origin:'subagent'` is **not** set — we do not need a subagent lifecycle, and we do not want a subagent card appearing in the main session), then at birth we use the public `session.snapshotEvents()` to read out the parent session's events and cut to the last `turn/end` ourselves as the seed, and in `setup` we use `agentPresets.composeFrom(agentCtx, parent.ctx)` to inherit the parent session's preset/tools/persona. This makes the prompt prefix word-for-word identical to the parent session's, so it **keeps hitting the existing prefix cache**. That seed is re-taken from the main session on every follow-up — see the next paragraph.
99
+
100
+ **Why every turn "rebuilds" (re-fork) instead of letting one session live on.** While a session lives, its seed is frozen at the moment the segment was created — whatever the main session says afterwards is **never** visible to that branch, and the only way to see it is "Clear" and start a new segment (that is what 0.1.x did). Conversely, creating a new segment on every follow-up, re-taking the main session's **latest** settled prefix as the seed, and then replaying the branch's own questions and answers into it gets you two things at once: **the main session's updates** and **the branch's own continuity**. It is no worse for the cache either: the main session's newest content **was just requested by the main session itself**, so it is probably still in the provider's hot cache, and the re-fork request prefix lines up with it exactly (a cache read); under "frozen prefix + append", the same content is sent by the branch in that shape for the **first** time (a cache write). **Rebuild only when something actually changed** (the main session has not grown and the model is unchanged ⇒ keep the current segment): the seed would be byte-identical, and rebuilding would only add one more session record the platform cannot delete plus one deep copy. This is also why the model-facing text now says the inherited history is rebuilt from the main session every turn rather than describing it as a snapshot taken when the branch was opened.
79
101
 
80
102
  **Why the side session is archived, and why it is un-archived and re-archived around every turn.** A side session is a **real session**, and left alone it would add an extra row to the left sidebar, mixed in with the user's own conversations. The only official way to hide a session from the grouping surfaces is to **archive** it (the official README itself describes the archive set as "sessions hidden from every grouping surface"), so the plugin archives a side session the moment it is created. The problem is that `0.1.7-rc.1` gave "archived" a second meaning: **archived ⇒ must not run** (`ArchivedSessionGate` rejects it at `agent/pre-step`). So "archive at birth" **locks the plugin out of itself** on the new version (every turn is stopped before `followup`, no model request is sent). And it **cannot** simply be changed to "do not archive at all" — that would put side sessions back into the session list, which is product behaviour, not an implementation detail. The current trade-off is three steps: **archive while idle (clean list) → un-archive before each turn is delivered (not gated) → archive again once that turn settles (clean list again)**. The costs are recorded honestly: the row may briefly appear in the sidebar while an answer is running, and each turn costs two or three extra durable writes. The other route is the official `origin: 'subagent'` (the sidebar natively hides rows whose summary has that origin), but it drags in subagent lifecycle semantics and conflicts with the "do not set `origin:'subagent'`" premise, so it was not taken.
81
103
 
@@ -83,7 +105,18 @@ The following are approaches that "look like less work but break this plugin's p
83
105
 
84
106
  **Why settings do not go through the official settings service.** `settings.register(ns, schema)` wants **schemastery's `z<T>` itself** (`resolve()` in `dsh-settings` calls it directly, so the schema must be callable), and `@deepseek-ai/schemastery` is a platform built-in package that **cannot be resolved from the plugin workspace by static import, dynamic import or `createRequire`** (all three give `ERR_MODULE_NOT_FOUND`); hand-building a "compatible schema" does not work either (node objects work through properties and are not callable). ⇒ Settings are instead persisted client-side in `localStorage`, with the value sent to the host on every request, and the host only validates and clamps. The cost is that this namespace is not visible in the official settings page, and the plugin carries its own gear panel.
85
107
 
86
- **Why reaching the context limit is a "rejection" rather than "drop the earliest turns".** The latter is simpler to implement and does not interrupt the user, but it makes the model **silently amnesiac**: it does not remember the dropped content yet still behaves as if it does, which the user can hardly notice. So instead, every turn reads the side session's `assistant/message` usage (`input + cacheRead`) and the `contextWindow` of `request/context`, refuses to send once the window is more than 80% used, and displays the actual usage and the limit in the panel.
108
+ **Why reaching the context limit is a "rejection" rather than "drop the earliest turns".** The latter is simpler to implement and does not interrupt the user, but it makes the model **silently amnesiac**: it does not remember the dropped content yet still behaves as if it does, which the user can hardly notice. So instead the turn is **refused** at the limit, with `used` / `limit` / `window` handed back to the panel (`contextFull`), and the history is **never truncated**.
109
+
110
+ ⚠️ **In 0.2.0 the criterion changed from "read the usage" to "estimate".** After a re-fork the new session **has not run a single turn yet** ⇒ the old criterion, `conv.lastContextTokens` (the side session's `assistant/message` `input + cacheRead`), is necessarily `undefined` and cannot be used. The criterion now (`estimateRequestTokens`) is:
111
+
112
+ ```text
113
+ used ≈ prompt tokens of the parent prefix (measured: the `input + cacheRead + cacheWrite` of the last `assistant/message` in the parent's log)
114
+ + (characters of the branch history + characters of this turn's question) × CONTEXT_ESTIMATE_SAFETY
115
+ ```
116
+
117
+ The turn is refused once that exceeds `contextWindow × CONTEXT_LIMIT_RATIO` (0.8). When `contextWindow` cannot be read the limit falls back to `CONTEXT_LIMIT_FALLBACK_TOKENS`, and when the parent's usage cannot be read the parent-prefix half falls back to `CONTEXT_ESTIMATE_PARENT_FALLBACK`. ⚠️ **Do not read `lastContextTokens` off the parent Agent instead**: the parent Agent **has no such field** (it is maintained by this plugin from session events).
118
+
119
+ ⚠️ **Recorded honestly: `used` is an estimate.** The parent-prefix half is measured; the branch-history-and-question half is "character count × safety factor" ⇒ the number shown in the panel is **not** a measured usage, and it is normal for it to disagree with the real number in the usage row once that turn settles. Better to refuse early than to send something too large.
87
120
 
88
121
  **Why the allow-list is not configurable.** The fewer pivot points a read-only promise has, the better. If the allowed surface were the intersection of "host constants × the set of names in settings × the subset submitted by the client", all three would have to stay consistent at all times, and a mistake in any one of them could widen the allowed surface. Now there is only one place: the two same-source constants `ALLOWED_TOOLS` and `TOOL_CHOICES` — the branch preamble the model sees is also taken from the same constant, so "what the model is told it may use" and "what the execution layer actually allows" cannot diverge. The cost is that users cannot narrow the permissions themselves; narrowing them means changing the code.
89
122
 
@@ -95,28 +128,41 @@ All routes first pass through the trust fence of `ctx.connection.requestRejectio
95
128
 
96
129
  | Method and path | Request body | Response |
97
130
  | --- | --- | --- |
98
- | `POST /side-branch/start` | `{ sessionId, question, selection?, conversationId?, locale?, settings? }` | `{ jobId, conversationId, handoff? }`; on failure `{ error, code }`, possibly with `conversationGone: true` or `contextFull: { used, limit, window }` |
99
- | `GET /side-branch/stream?job=<jobId>` | — | SSE: `snapshot` / `delta` / `reasoning` / `reset` / `replace` / `tool` / `done` / `error` / `stopped` |
131
+ | `POST /side-branch/start` | `{ sessionId, question, selection?, conversationId?, locale?, settings? }` | `{ jobId, conversationId, handoff?, synced?, notice? }`; on failure `{ error, code }`, possibly with `conversationGone: true` or `contextFull: { used, limit, window }` |
132
+ | `GET /side-branch/stream?job=<jobId>` | — | SSE: `snapshot` / **`segment`** / `delta` / `reasoning` / `reset` / `replace` / `tool` / `done` / `error` / `stopped` |
100
133
  | `POST /side-branch/stop` | `{ job }` | `{ status }`, stops only this turn, the side session is kept |
101
134
  | `POST /side-branch/close` | `{ conversationId }` | `{ status }`, releases this segment's side session |
135
+ | `GET /side-branch/inherited?conversationId=&locale=` | — | **read-only**: the main-session turns this segment **actually inherited** (`{ turns, chars, truncated, parentHasNewer, synced }`; the last turn in full, earlier turns as one-line summaries) |
102
136
  | `GET /side-branch/settings` | — | `{ settings: { quickEntry } }` |
103
137
  | `POST /side-branch/settings` | `{ settings: { quickEntry } }` | `{ settings: { quickEntry } }` |
104
138
 
139
+ The meaning of the three optional fields in the `/start` response (the client uses them to decide what to do):
140
+
141
+ - `handoff: true` = this call **rebuilt** the segment (re-fork). The history **does** connect ⇒ the client must **not** draw a "a new segment starts here" divider.
142
+ - `synced: { turns, chars }` = how much main-session content this turn inherited (the panel's `Inherited N main-session turns · ~X chars` row).
143
+ - `notice` = **the branch preamble verbatim, as injected into the model** (returned only on a turn where it was actually prepended, i.e. the first turn of a segment). This is the **only** text this plugin injects; the system prompt and the tool declarations are assembled by DSH, do not belong to this plugin, and are not handed out here.
144
+
145
+ SSE payload changes (0.2.0): `segment` is new (entering the next step ⇒ a new segment); `snapshot`/`done`/`stopped`/`error` all carry `segments` (the ordered segments `{kind, text, turn, step}`); `reset`/`delta`/`reasoning`/`replace`/`tool` all carry `turn`/`step` (the client uses them to scope a `reset` precisely to **the same step**).
146
+
105
147
  There is only one setting: `quickEntry` (the switch for the main session's quick entry button). Tool lists submitted by the client are **not accepted** — the allow-list is a host-side constant, and the plugin does not offer a "choose your own available tools" feature. If a door is ever to be opened for that feature, read the "read-only must be guaranteed at the execution layer" section at the head of `lib/index.js` first.
106
148
 
107
149
  Failure responses carry a machine-readable `code`, always with a `side-branch/` prefix in kebab-case, for example `side-branch/question-too-long`, `side-branch/context-full`. The client branches on `code`, not on the message text. When the path exists but the method is wrong, the reply is **405 + `Allow`** (the table of supported methods is `ROUTE_METHODS` in `lib/index.js`), not 404.
108
150
 
109
151
  ## Verifying changes
110
152
 
111
- > ⚠️ The command below **is only valid in the source repository**: `scripts/smoke.mjs` is not published with the npm package
112
- > (it reads `lib/` and `package.json` and makes source-level assertions, so running it from `node_modules` is meaningless).
113
- > If you are double-checking an npm-installed plugin, run it in the repository.
153
+ > ⚠️ Both commands below **are only valid in the source repository**: neither `scripts/smoke.mjs` nor
154
+ > `scripts/client-check.mjs` is published with the npm package (the first reads `lib/` and `package.json`
155
+ > and makes source-level assertions, the second really runs the client bundle; neither is meaningful outside
156
+ > the source tree).
157
+ > If you are double-checking an npm-installed plugin, run them in the repository.
114
158
 
115
159
  ```powershell
116
- node scripts/smoke.mjs
160
+ node scripts/smoke.mjs # ① source-level contract self-check
161
+ node scripts/client-check.mjs # ② client runtime self-check
162
+ # both at once: npm run check | only the client one: npm run client-check
117
163
  ```
118
164
 
119
- It needs no DSH runtime; it loads the host half directly and calls `apply()` with a fake ctx. The six things it guards:
165
+ **① `scripts/smoke.mjs` (source-level)** needs no DSH runtime; it loads the host half directly and calls `apply()` with a fake ctx. There are currently **136 assertions**, in nine sections:
120
166
 
121
167
  1. the module loads, the export name matches `package.json`, the route prefix is `/side-branch`, and the unload disposer is callable;
122
168
  2. **the read-only promise**: `ALLOWED_TOOLS` and `TOOL_CHOICES` are still those six names and consistent with each other, and none of the nine dangerous tool names is among them; the client has no leftover tool switches and the host does not accept a tool list submitted by the client; the branch preamble's list comes directly from the constants; the guard's criterion is still to allow by list, its registration point is still inside `setup(agentCtx)`, and failing to obtain the guard is still fail-loud.
@@ -124,21 +170,56 @@ It needs no DSH runtime; it loads the host half directly and calls `apply()` wit
124
170
  3. the client bundle exists, the inject is still those five service names, and there are no debug exports;
125
171
  4. the package names in `package.json` and `cordis.patch.yml` match;
126
172
  5. every package in `dsh.client.inject` really exists in the local DSH installation directory (give the path with `DSH_INSTALL_DIR`; if it cannot be found, skip this item);
127
- 6. **the archive-gate countermeasure**: the unlock must come before `followup`, must be feature-detected with `typeof`, and must not throw; the re-hide must hang off `finishJob`, use a bounded backoff, and equally must not throw; creation must still archive first. This section guards the "one published version serves both 0.1.5 and 0.1.7" line.
173
+ 6. **the archive-gate countermeasure**: the unlock must come before `followup`, must be feature-detected with `typeof`, and must not throw; the re-hide must hang off `finishJob`, use a bounded backoff, and equally must not throw; creation must still archive first. This section guards the "one published version serves both 0.1.5 and 0.1.7" line;
174
+ 7. **the Re-Fork architecture (0.2.0)**: `shouldReFork` must test **both** "the main session settled a new turn" and "the model/reasoning effort changed"; `parentCut` must be recorded **when the segment is created** (and carried through sleep/wake, or every turn re-forks for nothing); the replay allow-list is **exactly** the 5 surface event types (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) and contains no pure log events; the replay **never copies `sourceEventSeqs`**; the replay **never truncates**; **replay succeeds first, then the old session is released** (on failure the just-created segment is discarded); every re-fork answers `handoff: true`; the old `seedSource` handover path is gone for good. This section also guards the three spots of the estimate switch: `estimateRequestTokens` / `lastPromptTokensOf` exist, and the old `conv.lastContextTokens` criterion (necessarily `undefined` after a re-fork) is **no longer** used;
175
+ 8. **multi-segment answers (0.2.0)**: the ordered `job.segments`; the `start` frame being split by `turn`/`step` (a changed `attemptId` within the same step means a retry ⇒ only that step is dropped; a changed step ⇒ `segment` opens a new one); `replace` rewriting **only** the current segment; `snapshot` and the terminal events carrying `segments`; `syncSegmentShortcuts` making `text`/`reasoning` a **pure function** of the segment array; the old `attempts` field split into `steps`/`retries`; the client's `normalizeSegments` staying backward-compatible with **old data** (which only had `answer`/`reasoning`); They also guard "the quote is recorded with the round": sending stores the clipped reference on that round (`reference`), renders it as a collapsible quote block above the question, and **clears the composer's quote slot**; `normalizeRound` keeps it, so the block survives a refresh.
176
+ 9. **startup sweep and the read-only route (0.2.0)**: `/inherited` registered in `ROUTE_METHODS` (without it a wrong method falls into 404 and breaks the 405 + `Allow` contract); `handleInherited` truncating to "the last turn in full + one line per earlier turn"; the inherited content **not** entering `normalizeRound` (component memory only); the orphan criterion requiring the directory name to be **exactly** `side-<uuid>`; the **instance lease** (`side-branch-instances/*.json`, heartbeat + PID liveness); a live foreign instance making it **skip the entire sweep**; deletions leaving a **manifest log**; the sweep strictly confined under the `sessions` root and recognising only the `side-` prefix (never the `session-*` main sessions).
177
+
178
+ **① does not verify**: answer quality, real guard interception (those items in section 2 are **source-level regex assertions**, not a driven interception), SSE behaviour or the interface — the client half is covered by ② below, up to the rendered tree; real DOM layout, CSS, real scroll positions, the real network and a real model are equally out of its reach, and those spots depend on the one-off headless end-to-end harness **outside the repository** (see below) plus manual checking.
179
+
180
+ ### ② Client runtime self-check (`scripts/client-check.mjs`)
181
+
182
+ ```powershell
183
+ node scripts/client-check.mjs # run just this one
184
+ node scripts/client-check.mjs <another client.js> # reverse-verify the script itself (see below)
185
+ ```
186
+
187
+ **Why it exists.** Looking at the panel by hand during development turned up several bugs in a row, and **all three existing automations missed them**: `node --check` only looks at syntax; `scripts/smoke.mjs` is **source-level regex assertions**; the end-to-end harness hits the **HTTP routes and never loads the client at all**. The worst one: constants such as `const MAX_SEGMENTS = 200` were declared **inside `createPanelStore()`** yet used in the **`SideBranchBody` component** ⇒ the first SSE `delta` frame threw `ReferenceError: MAX_SEGMENTS is not defined`, which the browser's event dispatch **swallowed** (it only shows up in the console) ⇒ every increment was lost and **the answer never appeared**, so the user saw "the panel looks stuck after asking". This script was written to close that hole, and it was **verified in reverse**: run against a deliberately broken copy it does FAIL and prints its own diagnostic `FAIL SSE 帧 snapshot 的处理函数抛错:MAX_SEGMENTS is not defined`.
188
+
189
+ **How it does it.** No browser is installed; instead it **really runs** `lib/client.js` inside Node: the bundle's shape is `window.__ModuleLoader__.load({ factory })`, and it takes only three things from `require` — `react`, `react-dom` and `@deepseek-ai/dsh-client-ui-primitives` (the last two are **deliberately not provided**, and the bundle carries its own degraded branches, which conveniently exercises those branches too); only **8** React APIs are used (`createElement` / `useState` / `useEffect` / `useLayoutEffect` / `useReducer` / `useRef` / `Fragment` / `Component`), so a minimal React + minimal DOM is enough. The panel's capabilities **all come from the slot's `inject` face** (`inject: () => ({ startSideBranch, … })`), which the script spreads into props exactly as the official host does — which also verifies that the keys the face should have are there. Then real-shaped `fetch` / `EventSource` stubs drive it: type into the input through the real `onChange`, press send through the real `onClick`, and feed in SSE frames of the real shape.
190
+
191
+ **What it verifies.** The bundle loads, exports `apply()`, and the body component is registered and renders successfully; the client registers its dictionaries with the locale service (so the assertions can watch **real copy** rather than the weak "returns the key" kind); pressing send really issues **one** `POST /side-branch/start` carrying the question verbatim and opens an SSE stream for that job; every frame fed in has a listener; **all multi-segment answer text is rendered, in the right order, with the tool line between the two answer segments** (the regression criterion for the "only the last segment is left" bug); the injected branch preamble is visible in full; and **auto-scroll lands on the layer that actually scrolls** (`.dsh-side-branch-scroll`), while the non-scrolling rounds list has no ref attached.
128
192
 
129
- It does **not** verify answer quality, real guard interception, SSE behaviour or the interface. End-to-end verification needs:
193
+ There are currently **34 items**; PASS/FAIL is read straight from stdout (when everything passes the last line is the script's own `全部通过(34 项)`, and any failure exits with a non-zero code).
194
+
195
+ ⚠️ **What it does not cover (recorded honestly)**: real DOM layout, CSS, real scroll positions, real browser events, the real network and a real model. It is a test of the **"data → rendered tree"** stretch, **not a browser test** — those spots still have to be eyeballed in a browser.
196
+
197
+ **The optional argument reverse-verifies the script itself**: `node scripts/client-check.mjs <another client.js>` makes it run a different bundle (for instance a deliberately broken copy), and that one **must FAIL**. **A test that cannot fail is no test at all.**
198
+
199
+ **Manual** end-to-end verification needs:
130
200
 
131
201
  1. a profile with this plugin installed;
132
202
  2. a main session with at least one completed turn;
133
203
  3. select text on the page, open the panel, ask a question;
134
204
  4. check the startup log for lines with the `[side-branch]` prefix.
135
205
 
136
- There is no end-to-end test suite in the repository. When changing the guard, the prompt prefix, the model-facing text (`PROMPT_TEXT`), the archive gate or the SSE spots, steps 2 to 4 must be run manually.
206
+ The repository has **two self-checks (source-level + client runtime), but no end-to-end suite**. When changing the guard, the prompt prefix, the model-facing text (`PROMPT_TEXT`), the archive gate, Re-Fork, multi-segment answers or the SSE spots, steps 2 to 4 must be run manually; the same applies when changing the **client half** (`lib/client.js`) — `scripts/client-check.mjs` only automates the "data → rendered tree" stretch, and the interface itself still has to be looked at.
207
+
208
+ ### The one-off headless end-to-end harness
209
+
210
+ The repository has **no** end-to-end suite (it is a test asset: not published with the npm package, and it does not belong in the working tree). When a change lands where the self-checks cannot reach — the guard's real denials, the prompt prefix, the archive gate, Re-Fork, multi-segment answers, the SSE spots — build a **one-off** harness. Its approach: inside **one DSH process** it plays two roles at once —
211
+
212
+ - **the client**: it first passes the official trust fence to obtain a browser session cookie, then hits the plugin's **real** `POST /side-branch/start` and reads the SSE stream from `GET /side-branch/stream`;
213
+ - **the main session**: it uses `ctx.agents.create` to build a **real** parent session (`agentPresets.resolve` + `mount` with the `standard` preset, so it **has tools**) and runs real turns.
214
+
215
+ It covers exactly what the unit check (`scripts/smoke.mjs`) cannot reach: the branch **really used a tool** on that turn (`tool/call` + `tool/result` both present); a follow-up after the main session advanced ⇒ re-fork (`conversationId` changed, `handoff: true`, the inherited turn count rising); after the re-fork the model could answer with **three different origins** at once (a token that exists only in the branch history / one that exists only in the latest main-session prefix / one that exists only in the **replayed tool result**); the main session unchanged ⇒ **no** rebuild; only the reasoning effort changed ⇒ a rebuild **too**; `GET /side-branch/inherited` returning 200 with the last turn in full and a wrong method returning **405 + `Allow: GET`**; the branch's own token **not** flowing back into the main session; the finished branch **back in the archive set**; a forged `conversationId` answering `conversation-gone`; and "the replayed `tool/result` matching the `toolCallId` of the `tool/call` in the new segment's log" (orphan tool results are the nastiest trap on this path).
216
+
217
+ ⚠️ Use it only for a **pre-release** pass: do not commit the harness, and do not write its logs or verdicts into `docs/`.
137
218
 
138
219
  ## Committing and pushing
139
220
 
140
221
  - **Commit messages are always in English.** This is a public repository and its history is read in English: use `type: imperative subject` (e.g. `fix: un-archive the side session before each turn`) and explain "why" in the body. Do not mix Chinese into it.
141
222
  - Prefer two commits: one for the `lib/` + `scripts/` behaviour change, one for documentation / version bumps.
142
- - **Before pushing**, run `node scripts/smoke.mjs` (previous section) and run the affected path manually in a real host.
143
- - Keep the version in `package.json` and `docs/CHANGELOG.md` in sync; tag releases as `v<version>` (`v0.1.0` exists).
223
+ - **Before pushing**, run `node scripts/smoke.mjs` and `node scripts/client-check.mjs` (previous section; or `npm run check` to run both at once) and run the affected path manually in a real host.
224
+ - Keep the version in `package.json` and `docs/CHANGELOG.md` in sync; tag releases as `v<version>` (`v0.1.0` and `v0.1.1` exist; the current version is **0.2.0**).
144
225
  - Remote: `origin` = `github.com/wucl12/dsh-side-branch`, main branch `main`.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  [中文](#中文版本记录) · English · [README](../README.md)
4
4
 
5
+ ## 0.2.0
6
+
7
+ Behaviour change: the branch now **follows the main session** instead of being frozen at the moment it was opened.
8
+
9
+ - **Re-fork.** Every follow-up whose main session has settled a new turn — or whose model/reasoning effort changed — rebuilds the branch: a fresh side session is seeded from the main session's **latest settled prefix**, the branch's own questions and answers are replayed into it, and only then is the new question delivered. The branch therefore always sees the main session as it is now, while keeping everything it was asked before.
10
+ - **Only when something changed.** If the main session has not advanced and the model is unchanged, the existing segment simply continues — the seed would be byte-identical, so rebuilding would only add a session record (which the platform cannot delete) and a deep copy for no gain.
11
+ - `conversationId` changes **whenever** a re-fork happens, and `/start` answers `handoff: true` so the panel does not draw a "new segment" divider: the history really is continuous.
12
+ - **Failure is explicit.** If rebuilding fails, the previous segment is left untouched and the turn is refused with `side-branch/refork-failed`. The plugin never silently falls back to "a new session with no history" — the model would think the branch had never been asked anything while the panel still shows the old answers.
13
+ - **Fixed: a turn with several steps only showed the last step.** A turn can contain several steps, and every step opens a new streaming attempt and emits its own `start` frame; the plugin treated *every* `start` as a retry and cleared the accumulated text, so step 2 wiped step 1, and with only one `text` slot and one `reasoning` slot the intermediate output could not be kept at all. A job now keeps an **ordered `segments` list** (`{kind: 'reasoning'|'text'|'tool', text, turn, step}`), `start` frames are split by `turn`/`step` (same step with a new `attemptId` = retry, so only that step's segments are dropped), `chunk`/`tool/call` append to the current segment, and the terminal `replace` rewrites **only** the current segment. New SSE event `segment`; `snapshot`, `done`, `stopped` and `error` now carry `segments` so a refresh keeps every segment.
14
+ - This also fixes a related wobble: when the last step had no text (a pure tool finish), the old "replace the whole answer with the final text" branch never fired and the panel kept *some* intermediate segment as the final answer.
15
+ - **Context limit is now estimated instead of read.** After a re-fork the new session has no usage yet, so the old `lastContextTokens` check cannot work. The host estimates: the parent prefix's prompt tokens (measured — the `input + cacheRead + cacheWrite` of the parent's last `assistant/message`) plus the branch history and question in characters. At the limit the turn is **refused** with the actual numbers (`contextFull: { used, limit, window }`); history is never truncated.
16
+ - **New: the panel shows what was inherited and what was injected.** A `▸ Inherited N main-session turns · ~X chars` row per segment, expandable into a lazy read-only `GET /side-branch/inherited` view (the last inherited turn in full, earlier turns as one-line summaries — truncated server-side, no model call, never persisted into panel state because a main-session prefix can be hundreds of kilobytes and panel state lives in `sessionStorage`). The branch notice that is injected into the prompt is also shown once, verbatim. System prompts and tool declarations are assembled by DSH, not by this plugin, and are deliberately not shown.
17
+ - **The inherited reasoning is shown too, and counted.** The main session's thinking is part of the seed and *is* sent to the model — `dsh-llm-deepseek` serializes an assistant history `reasoning` block as `{ type: 'thinking' }` (only user and tool-result content drops reasoning). Each inherited turn therefore has its own collapsible `Thinking · N chars`, and `~X chars` counts question + reasoning + answer. Counting only the visible text under-reported what the model actually receives.
18
+ - **The per-turn question is the first `user/message` of that turn only.** DSH appends its own runtime snapshot (file policy, approval policy, skills, time context — over a thousand characters) as a *second* `user/message` after every user turn, and concatenating them displayed that boilerplate as "what the user asked" and inflated the character count several-fold.
19
+ - **The quoted text is now part of the record.** Selecting text in the main session and clicking "Quote and ask" used to put the passage only into the prompt envelope: the panel showed just the question, so a round never recorded what had been quoted, and the one-line quote chip stayed in the composer until it was cleared by hand. A round now keeps the clipped reference (the exact text the model received) and renders it as a collapsible quote block above the question, and sending clears the composer's quote slot automatically so the next question starts clean. The quote survives a refresh with the rest of the panel state.
20
+ - **New: orphan side-session records are swept at startup.** Re-fork creates one session record per rebuild and the platform has **no** API to delete a session, so records accumulate. At `apply()` the plugin deletes `side-<uuid>` records that no instance can still be using. The sweep is guarded by an **instance lease** (`<DSH_HOME>/side-branch-instances/<pid>-<uuid>.json` with a heartbeat): if any other live instance shares the same `DSH_HOME`, the sweep is skipped entirely, so a second DSH never has its live branches deleted. Everything deleted is logged as a manifest.
21
+ - **Removed:** the "switch model ⇒ carry history into a new segment" path (`seedSource`, the `sameAgentOptions` handoff, `skipPrependNotice` handover). Re-fork subsumes it: changing the model is just another reason to rebuild. A `reasoningEffort` change counts as a model change too, so the chosen effort is never silently ignored.
22
+ - The prompt text now tells the model that its inherited history is rebuilt from the main session on every turn, instead of describing it as a snapshot taken when the branch was opened.
23
+
5
24
  ## 0.1.1
6
25
 
7
26
  Fixes the branch being blocked on DSH `0.1.7-rc.1` and later.
@@ -30,6 +49,27 @@ First public release.
30
49
 
31
50
  ## 中文版本记录
32
51
 
52
+ ## 0.2.0
53
+
54
+ 行为变更:侧枝不再冻结在开段那一刻,而是**跟着主会话走**。
55
+
56
+ - **Re-Fork(每轮重建)。** 只要主会话**结算了新的回合**,或者**模型/推理等级变了**,这一次追问就会重建侧枝:新建一条侧会话、种子取主会话**最新的已结算前缀**、把侧枝自己历来的问答**重放**进去,然后才投递新问题。于是侧枝看到的永远是"主会话此刻的样子",同时一条都不丢自己之前问过什么。
57
+ - **只在"真的有变化"时重建。** 主会话没长、模型也没换时,就沿用当前这一段 —— 种子会逐字节相同,重建只会白白多一条**平台删不掉**的会话记录和一次深拷贝。
58
+ - `conversationId` **在重建时**会变,而 `/start` 一律回 `handoff: true`,所以面板**不会**画"以下是新的一段"分隔线 —— 历史确实是连续的。
59
+ - **失败是显式的。** 重建失败时**上一段原样保留**,这一轮以 `side-branch/refork-failed` 拒绝。插件绝不静默降级成"没有历史的新会话" —— 那会让模型以为侧枝没聊过,而面板上还显示着旧答案。
60
+ - **修复:一个回合里多段回答只剩最后一段。** 一个 turn 可以含多个 step,每个 step 都会开一次新的流式 attempt、各发一个 `start` 帧;插件把**每一个** `start` 都当成重试并清空已累积正文,于是 step 2 一来 step 1 就没了,而 job 只有 `text`/`reasoning` 两个槽位、根本装不下多段。现在 job 保有**有序的 `segments`**(`{kind: 'reasoning'|'text'|'tool', text, turn, step}`),`start` 帧按 `turn`/`step` 分流(同 step 换 `attemptId` 才算重试 ⇒ 只丢那一个 step 的段落),`chunk`/`tool/call` 追加到当前段,终局的 `replace` **只**重写当前段。新增 SSE 事件 `segment`;`snapshot`/`done`/`stopped`/`error` 都带 `segments`,刷新后每一段都在。
61
+ - 顺带修掉一处"显示哪一段是飘忽的":最后一步**没有正文**(纯工具收尾)时,老代码"用终局文本整段替换"那条分支根本不触发,面板会留着**某一段中间输出**当最终答案。
62
+ - **上下文上限改成估算。** re-fork 之后新会话还没有 usage,老判据 `lastContextTokens` 用不了。现在按"父前缀的提示词 tokens(**实测**:父会话最后一条 `assistant/message` 的 `input + cacheRead + cacheWrite`)+ 侧枝历史与问题的字符数"估算。到上限就**拒绝**并把实际数字给面板(`contextFull: { used, limit, window }`),**绝不截断**历史。
63
+ - **新增:面板能看见"继承了什么、注入了什么"。** 每段一行 `▸ 已继承主会话 N 轮 · 约 X 字`,点开按需拉一次只读的 `GET /side-branch/inherited`(**最后一轮全文 + 更早每轮一行摘要**,服务端截断、不调模型;**绝不进面板持久化状态** —— 一份主会话前缀可能几十万字符,而面板状态走 `sessionStorage`)。注入给模型的分支引导词也**逐字**展示一次。系统提示词与工具声明是 DSH 自己组装的,不属于本插件,**刻意不显示**。
64
+ - **继承来的"思考"也展示、也计数。** 主会话的推理**在种子里**,而且**确实会发给模型** —— `dsh-llm-deepseek` 把 assistant 历史里的 `reasoning` 块序列化成 `{ type: 'thinking' }`(只有 user / tool-result 内容才丢掉推理)。所以每轮里多一层可折叠的 `思考 · N 字`,而 `约 X 字` = **问 + 思考 + 答**。只算正文是**低报**:模型实际收到的比面板显示的多得多。
65
+ - **每轮的「问」只取该轮第一条 `user/message`。** DSH 会在每个用户回合之后**追加一条自己那上千字的运行期快照**(文件策略 / 审批策略 / 技能清单 / 时间上下文)作为**第二条** `user/message`;拼起来看就会把那一大坨平台样板文字当成"用户问的话",字数也被撑大好几倍。
66
+ - **面板现在真的会自动滚到底了。** 原先"自动滚底"的 ref 挂在**不滚动**的轮次列表(`.dsh-side-branch-rounds`,`display:flex`)上,`scrollTop = scrollHeight` 是空操作 —— 只是以前内容短、看不出来。在轮次上方加了可展开的「继承 / 注入」区之后,新答案会被顶到可视区之外,现象就是"问了之后回答不出现"(其实回答已经在 DOM 里)。现在 ref 指向真正可滚动的 `.dsh-side-branch-scroll`,并在段落数变化与继承区展开/收起时也重新滚底。
67
+ - **划选入口不再在侧枝自己的面板里弹出。** 那个按钮的语义是"把**主会话**原文引用进来";在面板里点它会把这**一段自己**的内容引用回**同一段**(`quoteBus` 按最后聚焦的面板投递 ⇒ 确实会投给自己),形成自指循环。黑名单里加上了 `.dsh-side-branch-panel` 与模型选择悬浮窗。
68
+ - **引用原文现在会进入这一轮的记录。** 在主会话里划选文字、点「引用并提问」,以前这段话**只进 prompt 信封**:面板上只显示问题原文,所以一轮记录里查不到"我引用了什么",而输入框上方那行引用条会一直挂着,得手动点 × 或「清空」。现在这一轮会保留**收窄后的引用原文**(与模型收到的**逐字一致**),渲染成问题上方的可折叠引用块;发送后引用槽**自动清空**,下一轮从干净的输入框开始。引用块随面板状态一起落盘 ⇒ 刷新后还在。
69
+ - **新增:启动时清理孤儿的侧枝会话记录。** re-fork 每次重建都会新增一条会话记录,而平台**没有**删除会话的 API ⇒ 记录会堆积。`apply()` 时删掉 `side-<uuid>` 形状、不可能还有实例在用的记录。清理由**实例租约**(`<DSH_HOME>/side-branch-instances/<pid>-<uuid>.json` + 心跳)把守:只要还有**别的活着的实例**共用同一个 `DSH_HOME`,就**整个跳过**,所以同时跑第二个 DSH 时绝不会删掉它正在用的侧枝。删掉哪些会**逐条记进日志**。
70
+ - **删除:** "换模型 ⇒ 带历史换段"那条路(`seedSource`、`sameAgentOptions` 交接、`skipPrependNotice` 手动传递)。re-fork 把它整个吸收了:换模型只是"该重建"的又一个理由。`reasoningEffort` 的变化同样算换模型,所以用户选的推理等级不会再被静默忽略。
71
+ - 推给模型的文本现在告诉它"继承的历史**每轮都会用主会话当时的最新内容重建**",不再说"开段那一刻的快照"。
72
+
33
73
  ## 0.1.1
34
74
 
35
75
  修复在 DSH `0.1.7-rc.1` 及之后版本上侧枝被拦的问题。
@@ -41,10 +41,11 @@
41
41
  ## ✨ 核心特性
42
42
 
43
43
  * **真分支,零污染:** 宿主端绝不向父会话写入任何内容。临时会话的问答不会出现在主会话的模型上下文中。
44
- * **前缀缓存极速命中:** 自动读取主会话已完成的回合前缀作为种子。沿用主会话模型时,继续命中原有前缀缓存,无需重算。
44
+ * **每轮都跟着主会话走:** 每一次追问都用主会话**最新的已结算内容**重建这一段——重新取种子,再把这条分支自己历来的问答重放进去。于是它看到的永远是主会话此刻的样子,**同时一条都不丢自己之前问过什么**。沿用主会话模型时,继续命中原有前缀缓存,无需重算。
45
45
  * **执行层只读隔离:** 安全策略不依赖提示词。由底层 `ctx.tools.guard` 强制接管,仅白名单放行六个无副作用的查询类工具(`read`, `glob`, `grep`, `web_search`, `web_fetch`, `lsp`),其余指令(如写文件、执行脚本)在执行层被直接拦截。
46
46
  * **极致的流式体验:** 正文与推理过程(可折叠的“思考”行)逐词流式输出。即使关闭浏览器标签,后台回答也不会中断,重新打开即可续接。
47
47
  * **会话状态持久化:** 面板状态按「会话 + 面板格」本地持久化。闲置自动休眠,下次提问静默唤醒。
48
+ * **可核查:** 展开「已继承主会话」即可逐轮看到这条分支究竟收到了什么——问、它的思考、以及答案;注入给模型的分支引导词同样逐字展示,模型看到的输入没有黑箱。
48
49
 
49
50
 
50
51
  ## 📦 安装与启用
@@ -65,14 +66,17 @@ dsh plugin --profile web add github:wucl12/dsh-side-branch
65
66
 
66
67
  ## 🚀 如何使用
67
68
 
68
- 1. **划选提问:** 在主会话的正文区域选中任意文字,点击弹出的「引用并提问」,即可在右侧面板开启临时会话,原文将作为引用一并发送。
69
+ 1. **划选提问:** 在主会话的正文区域选中任意文字,点击弹出的「引用并提问」,即可在右侧面板开启临时会话,原文将作为引用一并发送。发送后这段引用会**随该轮入档**(问题上方一个可折叠的引用块),输入框上方的引用槽**自动清空**,下一轮从干净的输入框开始。
69
70
  2. **快速入口:** 主会话底部输入框右侧有一个 `↗` 快速入口按钮,点击可直接呼出临时会话面板(可在设置中关闭)。
70
71
  3. **独立设置:** 在临时会话面板中,支持为当前分支单独指定模型与推理等级,无需跟随主会话。
71
72
 
72
73
  ## 🛡️ 安全与限制说明
73
74
 
74
75
  * **关于“只读”边界:** 本插件的只读是指**不产生系统副作用**(如写文件、执行命令),但 `web_fetch` / `web_search` 仍具备网络访问能力,`read` 可以读取进程权限内的文件。放行名单硬编码在代码中,不可通过设置放宽。
75
- * **上下文继承的快照机制:** 临时分支继承的是**开段那一刻**的历史快照。开启后,主会话产生的新内容不会自动同步到当前分支。若需包含新内容,请点击面板左下角的「清空」另起一段。
76
+ * **分支能"看到"什么:** 每一次追问都会用主会话**最新的已结算回合**重建这一段,所以主会话**答完结算**的新回合会自动跟上。主会话**还没答完**的那一轮则看不到(切点是最后一个 `turn/end`)—— 但**主动划选引用不受这个限制**:受限的是"自动看到",不是"能不能引用"。
77
+ * **重建会留下会话记录:** 每次重建都新增一条侧会话记录,而平台**没有删除会话的 API** ⇒ 记录会堆积。它们由插件**启动时的孤儿清理**回收,删了什么会逐条记日志。同一时刻有**两个共用同一个 `DSH_HOME` 的实例**在跑时,清理会**整个跳过** —— 第二个 DSH 绝不会删掉它正在用的分支。
78
+ * **到上下文上限是"拒绝":** 到达上限时插件**拒绝发送**并提示「清空」,绝不静默丢掉最早的轮次。`contextFull.used` 是**估算值**(重建出来的会话还没有 usage),与该轮结束后的真实用量对不上是正常的。
79
+ * **缓存命中率是实测值,不是设计保证:** 高命中率是量出来的,不是承诺 —— 平台可能在子会话第一步**原地替换 system 节点**,那样**从第 0 个 token 起全废**。并且**命中缓存 ≠ 不占上下文窗口**,命中的 token 一样占窗口。
76
80
  * **PTC 模式的特殊处理:** 若主会话使用 `ptc` preset(纯代码执行工具面),由于 `run_code` 永远被本插件拒绝,此分支将变为**纯文本问答模式**(无法调用任何工具)。
77
81
 
78
82
  ## 📚 更多文档