dsh-side-branch 0.1.1
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 +144 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/SECURITY.md +30 -0
- package/assets/panel-answering.png +0 -0
- package/assets/selection-entry.png +0 -0
- package/assets/sidebar-guide.png +0 -0
- package/cordis.patch.yml +11 -0
- package/docs/AGENTS.en.md +144 -0
- package/docs/CHANGELOG.md +57 -0
- package/docs/README.zh-CN.md +88 -0
- package/docs/SECURITY.zh-CN.md +36 -0
- package/docs/TECHNICAL.md +225 -0
- package/docs/TECHNICAL.zh-CN.md +227 -0
- package/docs/assets.md +11 -0
- package/lib/client.js +3268 -0
- package/lib/index.js +1867 -0
- package/package.json +65 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
[English](./docs/AGENTS.en.md) · 简体中文 · [README](./README.md)
|
|
4
|
+
|
|
5
|
+
面向自动化 agent 的说明:如何安装、验证、以及改动这个插件时的硬约束。
|
|
6
|
+
|
|
7
|
+
## 这是什么
|
|
8
|
+
|
|
9
|
+
`dsh-side-branch` 是一个 DSH Web 插件。它从主会话派生一条**只读分支会话**,继承主会话已完成的回合前缀与 agent preset,回答**不进入主会话的模型上下文**。
|
|
10
|
+
|
|
11
|
+
## 安装与验证
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
0. 确认本机 PATH 上有 pnpm:dsh plugin 是 pnpm 的转发器,缺了它直接以 127 退出。
|
|
15
|
+
1. 确认插件目录绝对路径不含空格。Windows 上 dsh plugin add 通过 shell 转发给 pnpm,
|
|
16
|
+
路径含空格会把参数拆成两段并装出错误的链接。
|
|
17
|
+
含空格时改走"不经过命令行"的路:在 profile 的 package.json 里写
|
|
18
|
+
"dsh-side-branch": "link:<绝对路径,正斜杠>",再跑 dsh plugin --profile <profile> install。
|
|
19
|
+
2. dsh plugin --profile <profile> add <插件目录绝对路径>
|
|
20
|
+
3. dsh --profile <profile> --dump-config | Select-String dsh-side-branch
|
|
21
|
+
期望看到:# == dsh-side-branch / id: dsh-side-branch / name: dsh-side-branch
|
|
22
|
+
4. 重启 DSH。客户端插件只在启动时装载,刷新浏览器不够。
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
成功判据:第 3 步输出里出现 `id: dsh-side-branch`,且第 4 步启动日志里出现 `[side-branch] 已注册 /side-branch 路由`。
|
|
26
|
+
|
|
27
|
+
失败判据与含义:
|
|
28
|
+
|
|
29
|
+
| 现象 | 原因 |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `ERR_MODULE_NOT_FOUND`,提到 dsh-side-branch | `cordis.patch.yml` 的 `name` 与 `package.json` 的 `name` 不一致 |
|
|
32
|
+
| 启动日志出现 `missing bundle` | profile 的 `dsh.profile.bundles` 里没有 `dsh-side-branch`,或依赖没装 |
|
|
33
|
+
| 启动日志出现 `missing bundle ... one build instruction` | 客户端 bundle 不存在(本仓库直接提交 `lib/client.js`,正常不会出现) |
|
|
34
|
+
| 页面白屏、插件列表显示 pending | 客户端 `inject` 里写了不存在的服务名 |
|
|
35
|
+
|
|
36
|
+
## 文件结构
|
|
37
|
+
|
|
38
|
+
| 路径 | 作用 |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `lib/index.js` | 宿主半。注册 HTTP 路由、建侧会话、挂只读 guard、驱动轮次 |
|
|
41
|
+
| `lib/client.js` | 客户端半。右栏面板、划选入口、SSE 读取、设置区。浏览器 bundle,由 DSH 以静态模块加载 |
|
|
42
|
+
| `cordis.patch.yml` | bundle patch,只 insert 一行,`name` 必须是真实包名 |
|
|
43
|
+
| `package.json` | `dsh.bundle.patch` 声明 patch;`dsh.client` 声明浏览器半 |
|
|
44
|
+
| `scripts/smoke.mjs` | 契约自检:加载宿主半、验证只读白名单与 guard 形状、核验依赖包存在。**只在源码仓库里存在**,不随 npm 包发布(它断言的是源码形状,脱离源码树跑不了) |
|
|
45
|
+
|
|
46
|
+
## 改动前的硬约束
|
|
47
|
+
|
|
48
|
+
**不要用 `toolFilter` 或 `tools.restrict` 实现只读。** 这两者作用在提示词层,会让侧会话的提示词前缀与主会话分叉,前缀缓存全部失效。只读必须用 `ctx.tools.guard()` 在执行层拦。guard 要注册在该侧 Agent 自己的 ctx 上(`setup(agentCtx)` 内)。
|
|
49
|
+
|
|
50
|
+
**不要动提示词前缀。** 分支引导词拼在每轮问题前面,不进系统提示词。工具名单是插件级常量,模型看到的名单与执行层判据同源。改动引导词的任何字都会让该段的前缀缓存失效。
|
|
51
|
+
|
|
52
|
+
**不要把推给模型的文本改回中文,也不要在 zh/en 两张表里各写一份。** 分支引导词、PTC 变体引导词与 guard 的拒绝理由都来自唯一的 `PROMPT_TEXT`(`lib/index.js`,**英文**),两张语言表只是引用它;只有**用户可见**的文案(面板里的错误与提示)才随界面语言。理由:DSH 自身的系统提示词与工具描述都是英文,指令语言与平台一致更稳;而且**换界面语言不该换提示词**(行为可复现、问题可对照)。`scripts/smoke.mjs` 会检查这段文本里没有中文、且两张表引用同一份。
|
|
53
|
+
|
|
54
|
+
**不要把"自选可用工具"加回来。** 放行名单只存在于 `ALLOWED_TOOLS` 与 `TOOL_CHOICES` 两个常量里,插件不接受客户端提交的工具名:多一层可配置就多一处能把只读保证改坏的地方。
|
|
55
|
+
|
|
56
|
+
**不要改回官方 `ctx.subagents.startContinuable`。** 它在结算时会把子会话的最终回答当成父会话的用户消息投回来,并唤醒父模型跑一整轮,直接违反本插件的前提。
|
|
57
|
+
|
|
58
|
+
**不要给客户端 `inject` 加服务名。** 写错一个名字会让 fiber 永久 pending 并白屏。当前五个:`slots`、`locale`、`sidebarRightTabs`、`sidebarRight`、`modelDirectories`。
|
|
59
|
+
|
|
60
|
+
**不要删掉「跑前解锁、跑完回藏」这套循环,也不要把 `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
|
+
**SSE 响应是整条手写的**(`Connection: close`)。换成框架自带的写法会坏,原因写在 `lib/index.js` 的 `handleStream` 注释里。
|
|
63
|
+
|
|
64
|
+
**客户端一切输入都不可信。** 问题长度、模型标识、`conversationId` 归属都要在宿主侧校验。
|
|
65
|
+
|
|
66
|
+
**只读的四条护栏都别动**:`ALLOWED_TOOLS` 常量、`setup(agentCtx)` 里的 `tools.guard`(拿不到就 `throw`,fail-loud)、`ROUTE_METHODS`(405 + `Allow` 的方法契约)、以及宿主侧对 `conversationId` 归属的校验。
|
|
67
|
+
|
|
68
|
+
**`peerDependencies` 里的 `@deepseek-ai/cordis` 是声明性的**:插件代码**不 import 它**(宿主半的运行期服务全部经 `ctx` 软获取,客户端半只 `require` 平台内置模块)。它是按生态惯例声明的,用来表明本插件挂在哪套契约上——**别当成漏删的依赖删掉**,也别为了"用上它"去加 import。
|
|
69
|
+
|
|
70
|
+
## 为什么这样做(改动前先读,别照着直觉改回去)
|
|
71
|
+
|
|
72
|
+
下面这些是"看起来更省事、但会破坏本插件前提"的做法,以及我们为什么不走。**它们不是历史记录,是取舍本身**——不写下来,下一个维护者很可能会去试。
|
|
73
|
+
|
|
74
|
+
**只读为什么必须在执行层。** 直觉做法是 `toolFilter:{allow:[]}` 或 `tools.restrict`——它们作用在**提示词层**(工具从提示词里消失),功能上确实达到只读。代价是侧会话的提示词前缀不再与主会话逐字一致,父会话已有的**前缀缓存全部失效**,每轮都要重算整个上下文。所以只读改用 `ctx.tools.guard()`(`dsh-tools` 的公开契约),作用在**执行层**:工具仍在提示词里,模型可以尝试调用,但执行被拒 ⇒ 提示词一个字不动,缓存照旧命中。guard 必须注册在**该侧 Agent 自己的 ctx** 上(`setup(agentCtx)` 内取),官方语义是经 `agent.ctx` 注册的 guard 只作用于该 agent——这对并行存在的多段侧枝会话是必需的。
|
|
75
|
+
|
|
76
|
+
**为什么不用官方的可续子会话。** `ctx.subagents.startContinuable` 表面上正好合适(官方提供的、可继续对话的子会话),但它结算时会把子会话的最终回答当成一条**父会话用户消息**投回父会话,并唤醒父模型跑一整轮(`dsh-subagent` 的 `watchSettlement` / `notifySettlement`,官方 README 说明这是设计行为且没有开关)。对多数场景合理,但对本插件是致命的:直接违反"答案不进主会话"这个前提,还要为此多跑一轮模型。
|
|
77
|
+
|
|
78
|
+
**为什么自建侧会话、以及为什么种子取"已完成回合前缀"。** 现在的做法是 `ctx.agents.create()` 建一个**普通侧会话**(`meta.parentSession` 只当血缘,**不设** `origin:'subagent'` —— 不需要子代理的生命周期,也不想让主会话里出现子代理卡片),出生时用公开的 `session.snapshotEvents()` 读出父会话事件、自己切到最后一个 `turn/end` 当种子,再在 `setup` 里 `agentPresets.composeFrom(agentCtx, parent.ctx)` 继承父会话的 preset/工具/persona。这样提示词前缀与父会话逐字一致,**继续命中原有的前缀缓存**;多轮追问就是同一个会话上继续 `followup`。代价是继承的是**开段那一刻的快照**:开段之后父会话的新回合不进这条分支(否则每次追问都要重算种子,前缀跟着变,缓存白拿),用户要带上新内容得「清空」另起一段。
|
|
79
|
+
|
|
80
|
+
**为什么侧会话要归档、又为什么要"跑前解锁、跑完回藏"。** 侧会话是一条**真实会话**,不处理它就会在左侧边栏多出一行、和用户自己的对话混在一起。官方把会话从分组界面藏起来的唯一手段就是**归档**(官方 README 自己把归档集描述成 "sessions hidden from every grouping surface"),所以本插件建段即归档。问题是 `0.1.7-rc.1` 给"已归档"加了第二层语义:**已归档 ⇒ 不许运行**(`ArchivedSessionGate` 在 `agent/pre-step` 上拒绝)。于是"建段即归档"这个写法在新版本上会**自己把自己锁死**(每一轮都在 `followup` 之前被拦,连模型请求都不发)。而**不能**改成"干脆不归档"就完事——那会让侧会话重新出现在会话列表里,那是产品体验、不是实现细节。现在的取舍是三步:**空闲时归档(列表干净)→ 每轮投递前取消归档(不被闸)→ 该轮结算后再归档(列表重新干净)**。代价如实登记:回答进行中它可能短暂出现在侧栏,以及每轮多两三次持久写盘。另一条路是官方 `origin: 'subagent'`(侧栏原生隐藏子代理来源的行),但它会连带子代理的生命周期语义,与"不设 `origin:'subagent'`"那条前提冲突,所以没走。
|
|
81
|
+
|
|
82
|
+
**为什么分支引导词拼在问题前面。** 引导词若进系统提示词,前缀就与主会话分叉、缓存全废。所以它拼在**每段第一轮的问题前面**,且同段内必须逐字一致。
|
|
83
|
+
|
|
84
|
+
**为什么设置不走官方设置服务。** `settings.register(ns, schema)` 要的是 **schemastery 的 `z<T>` 本身**(`dsh-settings` 的 `resolve()` 会直接调用它,schema 必须可调用),而 `@deepseek-ai/schemastery` 是平台内置包,从插件工作区里**静态 import / 动态 import / `createRequire` 三种方式都解析不到**(一律 `ERR_MODULE_NOT_FOUND`);手工造一个"兼容 schema"也不行(节点对象靠属性工作、不可调用)。⇒ 设置改为客户端 `localStorage` 持久化、每次请求把值带给宿主,宿主只做校验与夹紧。代价是官方设置页里看不到这个命名空间,插件自带齿轮面板。
|
|
85
|
+
|
|
86
|
+
**为什么到达上下文上限是"拒绝"而不是"丢最早的轮次"。** 后者实现更简单、用户也不会被打断,但会让模型**静默失忆**:它不记得被丢掉的内容,却仍然表现得像记得,用户很难察觉。所以做法是每轮读子会话 `assistant/message` 的 usage(`input + cacheRead`)与 `request/context` 的 `contextWindow`,超过窗口的 80% 就拒绝发送,并把实际用量与上限显示在面板上。
|
|
87
|
+
|
|
88
|
+
**为什么放行名单不可配置。** 只读承诺的支点越少越好。如果放行面由"宿主常量 × 设置里的名字集合 × 客户端提交上来的子集"三者求交而成,三处必须始终一致,任何一处写错都可能让放行面变宽。现在只有一处:`ALLOWED_TOOLS` 与 `TOOL_CHOICES` 两个同源常量——模型看到的分支引导词也从同一个常量取,所以"告诉模型能用什么"与"执行层实际放行什么"不可能分叉。代价是用户无法自行收窄权限,想收窄只能改代码。
|
|
89
|
+
|
|
90
|
+
**几个不改会出事的实现细节**(都在代码注释里有详细说明,这里只列点):闲置到点是让侧会话**睡觉**(`resume` 唤醒**必须显式传 `agentOptions`**,否则那一轮 ~25ms 静默空转);`tab.signal` abort 只断流、**不删面板状态桶**;SSE 响应整条手写、显式 `Connection: close`(框架自带写法会声明 chunked 而实际发裸数据);宿主文案由客户端随请求发下 `locale`(宿主进程里没有浏览器的 locale)。
|
|
91
|
+
|
|
92
|
+
## 宿主 HTTP API
|
|
93
|
+
|
|
94
|
+
所有路由先过 `ctx.connection.requestRejection` 的信任栅栏。
|
|
95
|
+
|
|
96
|
+
| 方法与路径 | 请求体 | 响应 |
|
|
97
|
+
| --- | --- | --- |
|
|
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` |
|
|
100
|
+
| `POST /side-branch/stop` | `{ job }` | `{ status }`,只停这一轮,侧会话保留 |
|
|
101
|
+
| `POST /side-branch/close` | `{ conversationId }` | `{ status }`,释放这一段侧会话 |
|
|
102
|
+
| `GET /side-branch/settings` | — | `{ settings: { quickEntry } }` |
|
|
103
|
+
| `POST /side-branch/settings` | `{ settings: { quickEntry } }` | `{ settings: { quickEntry } }` |
|
|
104
|
+
|
|
105
|
+
设置只有一项:`quickEntry`(主会话快速入口按钮的开关)。**不接受**客户端提交工具名单——放行名单是宿主侧的常量,插件不提供"自选可用工具"的功能。若哪天要给这个功能开口子,先读 `lib/index.js` 头部「只读必须在执行层保证」那一段。
|
|
106
|
+
|
|
107
|
+
失败的响应带机器可读的 `code`,一律 `side-branch/` 前缀加 kebab-case,例如 `side-branch/question-too-long`、`side-branch/context-full`。客户端按 `code` 分支,不按文案分支。路径存在但方法不对时回 **405 + `Allow`**(支持的方法表在 `lib/index.js` 的 `ROUTE_METHODS`),不是 404。
|
|
108
|
+
|
|
109
|
+
## 验证改动
|
|
110
|
+
|
|
111
|
+
> ⚠️ 下面这条命令**只在源码仓库里有效**:`scripts/smoke.mjs` 不随 npm 包发布
|
|
112
|
+
> (它读 `lib/` 与 `package.json` 做源码级断言,从 `node_modules` 里跑没有意义)。
|
|
113
|
+
> 如果你是照 npm 装好的插件在做二次检查,请到仓库里跑。
|
|
114
|
+
|
|
115
|
+
```powershell
|
|
116
|
+
node scripts/smoke.mjs
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
不需要 DSH 运行时,直接加载宿主半并用假 ctx 调 `apply()`。它守的六处:
|
|
120
|
+
|
|
121
|
+
1. 模块能加载、导出名与 `package.json` 一致、路由前缀是 `/side-branch`、卸载 disposer 可调用;
|
|
122
|
+
2. **只读承诺**:`ALLOWED_TOOLS` 与 `TOOL_CHOICES` 仍是那六个名字且互相一致,九个危险工具名都不在其中;客户端没有工具开关的残留、宿主不接受客户端提交的工具名单;分支引导词的名单直接来自常量;guard 的判据仍是按名单放行、注册点仍在 `setup(agentCtx)` 里、拿不到 guard 仍 fail-loud。
|
|
123
|
+
⚠️ 这一项里"guard 的判据"是**源码级正则断言**,不是真的驱动一次拦截 —— 真拦截要靠端到端(见下);
|
|
124
|
+
3. 客户端 bundle 存在、inject 仍是那五个服务名、不含调试导出;
|
|
125
|
+
4. `package.json` 与 `cordis.patch.yml` 的包名一致;
|
|
126
|
+
5. `dsh.client.inject` 里每个包在本机 DSH 安装目录里真实存在(用 `DSH_INSTALL_DIR` 指定路径,找不到就跳过这一项);
|
|
127
|
+
6. **归档闸对策**:解锁必须排在 `followup` 之前、必须经 `typeof` 特性探测、失败不得抛;回藏必须挂在 `finishJob`、要有界退避、同样不得抛;建段仍然先归档。这一节守的就是"同一份源码同时兼容 0.1.5 与 0.1.7"这条线。
|
|
128
|
+
|
|
129
|
+
它**不验证**回答质量、真实 guard 拦截、SSE 行为与界面。端到端验证需要:
|
|
130
|
+
|
|
131
|
+
1. 一个已装好本插件的 profile;
|
|
132
|
+
2. 一个主会话,至少完成一个回合;
|
|
133
|
+
3. 在页面里划选文字、打开面板、提问;
|
|
134
|
+
4. 检查启动日志出现 `[side-branch]` 前缀的行。
|
|
135
|
+
|
|
136
|
+
仓库内没有端到端测试套件。改动 guard、提示词前缀、模型可见文本(`PROMPT_TEXT`)、归档闸那几处或 SSE 时,第 2 步到第 4 步必须人工跑一遍。
|
|
137
|
+
|
|
138
|
+
## 提交与推送
|
|
139
|
+
|
|
140
|
+
- **提交信息(commit message)一律用英文。** 这是公开仓库、历史面向英文读者:标题用 `type: 祈使句`(如 `fix: un-archive the side session before each turn`),正文讲"为什么",**不要混中文**。
|
|
141
|
+
- 建议拆成两个提交:`lib/` + `scripts/` 的行为改动一个,纯文档 / 版本号一个。
|
|
142
|
+
- **推之前**先跑 `node scripts/smoke.mjs`(上一节),并在真宿主里人工跑一遍受影响的链路。
|
|
143
|
+
- 版本号与 `docs/CHANGELOG.md` 同步更新;发布时打 `v<version>` 标签(已有 `v0.1.0`)。
|
|
144
|
+
- 远端:`origin` = `github.com/wucl12/dsh-side-branch`,主分支 `main`。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chenglin Wu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# DSH Side Ask (Side Branch)
|
|
2
|
+
|
|
3
|
+
English | [简体中文](./docs/README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Select text in the main session, and a read-only branch session will pop up in the right sidebar. It inherits the existing context of the main session, streams its answers token-by-token, and **its responses never pollute the model context of the main session**.
|
|
6
|
+
|
|
7
|
+
Core Scenario: Asking follow-up questions, looking up terms, or exploring ideas based on the current conversation without interrupting the flow of the main session.
|
|
8
|
+
|
|
9
|
+
* **Package Name (Implementation):** `dsh-side-branch`
|
|
10
|
+
* **UI Name (Display):** Side Ask
|
|
11
|
+
* **Prerequisites:** Node.js ≥ 20, and `pnpm` installed in the local `PATH`.
|
|
12
|
+
* **Verified DSH Version:** `0.1.5-rc.2` and `0.1.7-rc.2`
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="assets/sidebar-guide.png" alt="The Side Ask entry in the right sidebar guide" width="600">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<img src="assets/selection-entry.png" alt="Selection Entry" width="600">
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<img src="assets/panel-answering.png" alt="Panel Answering" width="600">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
## 💡 Why Build This? (Motivation)
|
|
27
|
+
|
|
28
|
+
Before developing this project (Sep 2026), I surveyed 14 side-chat/bubble plugins in the DSH ecosystem and found that **none of them could simultaneously meet all the following hard requirements**:
|
|
29
|
+
|
|
30
|
+
1. **True Context Inheritance** (not just a truncated summary).
|
|
31
|
+
2. **Support for Multi-turn Follow-ups** within the same branch (not a one-off bubble).
|
|
32
|
+
3. **Token-by-Token Streaming** (including reasoning processes).
|
|
33
|
+
4. **Lightweight & Stable** (using only official public APIs, no bloated dependencies).
|
|
34
|
+
5. **Execution-Layer Read-Only, strictly preserving the prefix cache.**
|
|
35
|
+
|
|
36
|
+
The closest alternatives all had compromises: `@michengai/dsh-btw` was one-off and lacked streaming; `dsh-side-chat-plus` didn't inherit history by default; `dsh-sidenote` was feature-rich but extremely heavy (requiring a 14.7 MB dependency); `@lukeknow0/dsh-side-chat` had excellent architecture but was unmaintained.
|
|
37
|
+
|
|
38
|
+
The biggest divergence lies in how "read-only" is implemented: similar plugins universally use `toolFilter` to forcefully remove tools from the prompt. However, this causes the branch's prompt prefix to diverge from the main session, **rendering the parent session's accumulated prefix cache completely invalid**.
|
|
39
|
+
This project strictly chooses to enforce read-only at the **execution layer**. Thus, the branch's prompt prefix remains **byte-identical** to the main session, continuing to enjoy an extremely high prefix cache hit rate (empirically 97%–98%).
|
|
40
|
+
|
|
41
|
+
## ✨ Core Features
|
|
42
|
+
|
|
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.
|
|
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
|
+
* **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
|
+
* **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
|
+
|
|
49
|
+
|
|
50
|
+
## 📦 Installation & Setup
|
|
51
|
+
|
|
52
|
+
After installation, you **must restart DSH** for the client plugin to load properly.
|
|
53
|
+
|
|
54
|
+
**Install via npm (Recommended):**
|
|
55
|
+
```bash
|
|
56
|
+
dsh plugin --profile web add dsh-side-branch
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Install via GitHub:**
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile web add github:wucl12/dsh-side-branch
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> 🤖 **Prompt for AI Agents:** If you are an Agent responsible for installing this plugin or reading its source code, please prioritize reading [`AGENTS.md`](AGENTS.md) in this repository for hard constraints, architectural details, and automated installation guides.
|
|
65
|
+
|
|
66
|
+
## 🚀 How to Use
|
|
67
|
+
|
|
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
|
+
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
|
+
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
|
+
## 🛡️ Security & Limitations
|
|
73
|
+
|
|
74
|
+
* **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
|
+
* **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
|
+
|
|
78
|
+
## 📚 Further Documentation
|
|
79
|
+
|
|
80
|
+
* [简体中文](./docs/README.zh-CN.md): 中文说明(同一份内容)。
|
|
81
|
+
* [`docs/TECHNICAL.md`](docs/TECHNICAL.md): full technical reference — prefix-cache mechanics, the read-only enforcement pipeline, PTC behaviour, and every known limitation.
|
|
82
|
+
* [`AGENTS.md`](AGENTS.md): Architectural contracts and hard constraints strictly written for AI Agents.
|
|
83
|
+
* [`SECURITY.md`](SECURITY.md): Security commitment scope and vulnerability reporting channels.
|
|
84
|
+
* [`docs/CHANGELOG.md`](docs/CHANGELOG.md): Version release history.
|
|
85
|
+
|
|
86
|
+
## 📄 License
|
|
87
|
+
|
|
88
|
+
[MIT](LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
[中文](./docs/SECURITY.zh-CN.md) · English · [Repository home](./README.md)
|
|
4
|
+
|
|
5
|
+
The core promise of this plugin is **execution-layer read-only**: a side branch may call six read-only tools, and every other tool is denied **at the execution layer** (`ctx.tools.guard()`, keyed on the plugin-level `ALLOWED_TOOLS` name allow-list, failing closed).
|
|
6
|
+
|
|
7
|
+
## Reporting a security issue
|
|
8
|
+
|
|
9
|
+
Use a GitHub private security advisory: <https://github.com/wucl12/dsh-side-branch/security/advisories/new>
|
|
10
|
+
|
|
11
|
+
Please do **not** open a public issue for read-only bypasses. You will get a response within 7 days.
|
|
12
|
+
|
|
13
|
+
## In scope
|
|
14
|
+
|
|
15
|
+
- **Guard bypass**: any path that lets a side branch execute a tool outside the allow-list (writing files, running commands, spawning grandchild sessions, mutating the host), including `run_code` (PTC) and `mcp__*` tools.
|
|
16
|
+
- **Answer leakage**: any path that gets a side branch's answer into the main session's model context.
|
|
17
|
+
- **Host route bypass**: reaching `/side-branch/*` without passing `ctx.connection.requestRejection`, or making the host act on unvalidated input from the client (question length, model identifier, `conversationId` ownership).
|
|
18
|
+
- **Client XSS / injection**: script execution while rendering an answer or quoted source text.
|
|
19
|
+
|
|
20
|
+
## Out of scope (by design)
|
|
21
|
+
|
|
22
|
+
These are documented in the README's "Read-only is not harmless" section and in [`AGENTS.md`](./AGENTS.md):
|
|
23
|
+
|
|
24
|
+
- The capabilities of the six allow-listed tools themselves: `web_fetch` / `web_search` are **network egress** (sensitive content in a branch can be sent out), `lsp` **starts language-server processes**, and `read` / `glob` / `grep` can read **any path the process can reach** — there is no per-branch sandbox.
|
|
25
|
+
- Prompt injection inside quoted source text or fetched external content. The plugin emits anti-injection declarations, but that is a **prompt-layer** constraint, not an execution-layer one.
|
|
26
|
+
- The allow-list not being configurable (narrowing it means editing code). This is a deliberate trade-off, for the reasons above.
|
|
27
|
+
|
|
28
|
+
## Supported versions
|
|
29
|
+
|
|
30
|
+
Fixes are provided for the latest released version only. The plugin is verified on the DSH version declared in the README.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-side-branch 的 bundle patch。
|
|
2
|
+
#
|
|
3
|
+
# 语义(依据 @deepseek-ai/dsh-base/cordis.patch.yml 的头部注释):
|
|
4
|
+
# 每个 bundle 的 patch 是一次顶层数组 patch,按顺序叠加在 profile 根(空列表)之上。
|
|
5
|
+
# - insert: 往条目列表里插入新行。
|
|
6
|
+
# - id: 这一行在本 profile 组合树里的身份(后续 bundle 与用户的 patch 靠它定位)。
|
|
7
|
+
# - name: 模块 specifier。**必须是真实包名**,scoped 的要带 scope。
|
|
8
|
+
# 写成短名 / 错名会在加载期 ERR_MODULE_NOT_FOUND。
|
|
9
|
+
- insert:
|
|
10
|
+
- id: dsh-side-branch
|
|
11
|
+
name: 'dsh-side-branch'
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# AGENTS.md — Installation, Verification and Hard Constraints
|
|
2
|
+
|
|
3
|
+
[English](./AGENTS.en.md) · [简体中文](../AGENTS.md) · [README](../README.md)
|
|
4
|
+
|
|
5
|
+
Notes for automated agents: how to install and verify this plugin, and the hard constraints on changing it.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
`dsh-side-branch` is a DSH Web plugin. It derives a **read-only side branch session** from the main session, inheriting the main session's completed-turn prefix and its agent preset, and its answers **never enter the main session's model context**.
|
|
10
|
+
|
|
11
|
+
## Installation and verification
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
0. 确认本机 PATH 上有 pnpm:dsh plugin 是 pnpm 的转发器,缺了它直接以 127 退出。
|
|
15
|
+
1. 确认插件目录绝对路径不含空格。Windows 上 dsh plugin add 通过 shell 转发给 pnpm,
|
|
16
|
+
路径含空格会把参数拆成两段并装出错误的链接。
|
|
17
|
+
含空格时改走"不经过命令行"的路:在 profile 的 package.json 里写
|
|
18
|
+
"dsh-side-branch": "link:<绝对路径,正斜杠>",再跑 dsh plugin --profile <profile> install。
|
|
19
|
+
2. dsh plugin --profile <profile> add <插件目录绝对路径>
|
|
20
|
+
3. dsh --profile <profile> --dump-config | Select-String dsh-side-branch
|
|
21
|
+
期望看到:# == dsh-side-branch / id: dsh-side-branch / name: dsh-side-branch
|
|
22
|
+
4. 重启 DSH。客户端插件只在启动时装载,刷新浏览器不够。
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Success criteria: step 3's output contains `id: dsh-side-branch`, and step 4's startup log contains `[side-branch] 已注册 /side-branch 路由`.
|
|
26
|
+
|
|
27
|
+
Failure criteria and what they mean:
|
|
28
|
+
|
|
29
|
+
| Symptom | Cause |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `ERR_MODULE_NOT_FOUND`, mentioning dsh-side-branch | the `name` in `cordis.patch.yml` does not match the `name` in `package.json` |
|
|
32
|
+
| `missing bundle` in the startup log | `dsh-side-branch` is absent from the profile's `dsh.profile.bundles`, or its dependencies are not installed |
|
|
33
|
+
| `missing bundle ... one build instruction` in the startup log | the client bundle does not exist (this repository commits `lib/client.js` directly, so this should not normally happen) |
|
|
34
|
+
| blank page, plugin list shows pending | the client `inject` names a service that does not exist |
|
|
35
|
+
|
|
36
|
+
## File structure
|
|
37
|
+
|
|
38
|
+
| Path | Role |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `lib/index.js` | Host half. Registers the HTTP routes, creates the side session, attaches the read-only guard, drives turns |
|
|
41
|
+
| `lib/client.js` | Client half. Right-hand panel, selection entry point, SSE reading, settings area. A browser bundle, loaded by DSH as a static module |
|
|
42
|
+
| `cordis.patch.yml` | Bundle patch, inserts a single line, `name` must be the real package name |
|
|
43
|
+
| `package.json` | `dsh.bundle.patch` declares the patch; `dsh.client` declares the browser half |
|
|
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
|
+
|
|
46
|
+
## Hard constraints before changing anything
|
|
47
|
+
|
|
48
|
+
**Do not implement read-only with `toolFilter` or `tools.restrict`.** Both act at the prompt layer, which forks the side session's prompt prefix away from the main session's and invalidates the entire prefix cache. Read-only must be blocked at the execution layer with `ctx.tools.guard()`. The guard must be registered on that side Agent's own ctx (inside `setup(agentCtx)`).
|
|
49
|
+
|
|
50
|
+
**Do not touch the prompt prefix.** The branch preamble is concatenated in front of each turn's question; it does not go into the system prompt. The tool list is a plugin-level constant, so the list the model sees and the execution-layer criterion come from the same source. Changing any character of the preamble invalidates that segment's prefix cache.
|
|
51
|
+
|
|
52
|
+
**Do not translate the model-facing text back into Chinese, and do not keep one copy per language table.** The branch preamble, the PTC variant and the guard's deny reason all come from the single `PROMPT_TEXT` constant in `lib/index.js` (**English**); the zh/en tables merely reference it. Only **user-visible** strings (panel errors and hints) follow the interface language. Why: DSH's own system prompts and tool descriptions are English, so matching the platform's instruction language is more reliable; and **switching the interface language must not switch the prompt** (reproducible behaviour, comparable reports). `scripts/smoke.mjs` checks that this text contains no CJK and that both tables reference the same copy.
|
|
53
|
+
|
|
54
|
+
**Do not add "choose your own available tools" back.** The allow-list exists only in the two constants `ALLOWED_TOOLS` and `TOOL_CHOICES`, and the plugin does not accept tool names submitted by the client: one more configurable layer is one more place where the read-only guarantee can be broken.
|
|
55
|
+
|
|
56
|
+
**Do not revert to the official `ctx.subagents.startContinuable`.** On settlement it posts the child session's final answer back as a user message of the parent session and wakes the parent model for a full turn, which directly violates this plugin's premise.
|
|
57
|
+
|
|
58
|
+
**Do not add service names to the client `inject`.** Getting one name wrong leaves the fiber permanently pending and blanks the page. There are currently five: `slots`, `locale`, `sidebarRightTabs`, `sidebarRight`, `modelDirectories`.
|
|
59
|
+
|
|
60
|
+
**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
|
+
**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
|
+
|
|
64
|
+
**All client input is untrusted.** Question length, model identifier and `conversationId` ownership must all be validated host-side.
|
|
65
|
+
|
|
66
|
+
**Do not touch any of the four read-only guardrails**: the `ALLOWED_TOOLS` constant, the `tools.guard` in `setup(agentCtx)` (`throw` if it cannot be obtained — fail-loud), `ROUTE_METHODS` (the 405 + `Allow` method contract), and the host-side validation of `conversationId` ownership.
|
|
67
|
+
|
|
68
|
+
**The `@deepseek-ai/cordis` in `peerDependencies` is declarative**: the plugin code **does not import it** (the host half obtains all of its runtime services softly via `ctx`, and the client half only `require`s platform built-in modules). It is declared by ecosystem convention, to state which contract this plugin hangs off — **do not delete it as a dependency someone forgot to remove**, and do not add an import just "to use it".
|
|
69
|
+
|
|
70
|
+
## Why it is done this way (read before changing anything; do not revert to intuition)
|
|
71
|
+
|
|
72
|
+
The following are approaches that "look like less work but break this plugin's premise", and why we do not take them. **They are not a historical record, they are the trade-offs themselves** — if they are not written down, the next maintainer will very likely try them.
|
|
73
|
+
|
|
74
|
+
**Why read-only must be at the execution layer.** The intuitive approach is `toolFilter:{allow:[]}` or `tools.restrict` — they act at the **prompt layer** (the tools disappear from the prompt) and do functionally achieve read-only. The cost is that the side session's prompt prefix is no longer word-for-word identical to the main session's, the parent session's existing **prefix cache is entirely invalidated**, and the whole context has to be recomputed every turn. So read-only instead uses `ctx.tools.guard()` (a public contract of `dsh-tools`), acting at the **execution layer**: the tools remain in the prompt, the model may attempt to call them, but execution is denied ⇒ not one word of the prompt changes and the cache keeps hitting as before. The guard must be registered on **that side Agent's own ctx** (obtained inside `setup(agentCtx)`); the official semantics are that a guard registered via `agent.ctx` only affects that agent — which is necessary for multiple side branch sessions existing in parallel.
|
|
75
|
+
|
|
76
|
+
**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
|
+
|
|
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.
|
|
79
|
+
|
|
80
|
+
**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
|
+
|
|
82
|
+
**Why the branch preamble is concatenated in front of the question.** If the preamble went into the system prompt, the prefix would fork away from the main session's and the cache would be entirely wasted. So it is concatenated **in front of the question of the first turn of each segment**, and must be word-for-word identical within a segment.
|
|
83
|
+
|
|
84
|
+
**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
|
+
|
|
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.
|
|
87
|
+
|
|
88
|
+
**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
|
+
|
|
90
|
+
**A few implementation details that break things if left alone** (all explained in detail in code comments; only listed as points here): idling to the deadline puts the side session **to sleep** (`resume` to wake it **must be passed `agentOptions` explicitly**, otherwise that turn silently spins for ~25ms); a `tab.signal` abort only cuts the stream and **does not delete the panel state bucket**; the SSE response is hand-written from end to end, with an explicit `Connection: close` (the framework's built-in way would declare chunked while actually sending raw data); host-facing copy is sent down by the client with the request as `locale` (there is no browser locale in the host process).
|
|
91
|
+
|
|
92
|
+
## Host HTTP API
|
|
93
|
+
|
|
94
|
+
All routes first pass through the trust fence of `ctx.connection.requestRejection`.
|
|
95
|
+
|
|
96
|
+
| Method and path | Request body | Response |
|
|
97
|
+
| --- | --- | --- |
|
|
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` |
|
|
100
|
+
| `POST /side-branch/stop` | `{ job }` | `{ status }`, stops only this turn, the side session is kept |
|
|
101
|
+
| `POST /side-branch/close` | `{ conversationId }` | `{ status }`, releases this segment's side session |
|
|
102
|
+
| `GET /side-branch/settings` | — | `{ settings: { quickEntry } }` |
|
|
103
|
+
| `POST /side-branch/settings` | `{ settings: { quickEntry } }` | `{ settings: { quickEntry } }` |
|
|
104
|
+
|
|
105
|
+
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
|
+
|
|
107
|
+
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
|
+
|
|
109
|
+
## Verifying changes
|
|
110
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
```powershell
|
|
116
|
+
node scripts/smoke.mjs
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
It needs no DSH runtime; it loads the host half directly and calls `apply()` with a fake ctx. The six things it guards:
|
|
120
|
+
|
|
121
|
+
1. the module loads, the export name matches `package.json`, the route prefix is `/side-branch`, and the unload disposer is callable;
|
|
122
|
+
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.
|
|
123
|
+
⚠️ The "guard's criterion" in this item is a **source-level regex assertion**, not an actual driven interception — real interception needs end-to-end (see below);
|
|
124
|
+
3. the client bundle exists, the inject is still those five service names, and there are no debug exports;
|
|
125
|
+
4. the package names in `package.json` and `cordis.patch.yml` match;
|
|
126
|
+
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.
|
|
128
|
+
|
|
129
|
+
It does **not** verify answer quality, real guard interception, SSE behaviour or the interface. End-to-end verification needs:
|
|
130
|
+
|
|
131
|
+
1. a profile with this plugin installed;
|
|
132
|
+
2. a main session with at least one completed turn;
|
|
133
|
+
3. select text on the page, open the panel, ask a question;
|
|
134
|
+
4. check the startup log for lines with the `[side-branch]` prefix.
|
|
135
|
+
|
|
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.
|
|
137
|
+
|
|
138
|
+
## Committing and pushing
|
|
139
|
+
|
|
140
|
+
- **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
|
+
- 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).
|
|
144
|
+
- Remote: `origin` = `github.com/wucl12/dsh-side-branch`, main branch `main`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
[中文](#中文版本记录) · English · [README](../README.md)
|
|
4
|
+
|
|
5
|
+
## 0.1.1
|
|
6
|
+
|
|
7
|
+
Fixes the branch being blocked on DSH `0.1.7-rc.1` and later.
|
|
8
|
+
|
|
9
|
+
- **A branch session is archived to keep it out of the session list; from DSH `0.1.7-rc.1` an archived session also becomes unrunnable.** `dsh-api-session-controller`'s `ArchivedSessionGate` rejects any model step proposed for an archived session, so every turn ended as `turn/end { reason: 'blocked' }` with no model request — the panel reported "content blocked by security policy", which was this plugin's own wording for that reason, not a content policy.
|
|
10
|
+
- The side session is now **un-archived immediately before each turn is delivered and archived again once it settles**, so an idle branch stays hidden and a running one is not gated. The re-archive waits and retries on a bounded backoff because the registry refuses to archive a session that still has activity; if it never succeeds the branch merely stays visible in the sidebar.
|
|
11
|
+
- **`unarchiveSession()` only exists from DSH `0.1.6-alpha.2`**, so the call is feature-detected: on `0.1.5-rc.2` (no method, no gate) both extra steps are no-ops and behaviour is unchanged. One published version therefore serves both DSH lines.
|
|
12
|
+
- **The model-facing text is now English in every interface language.** The branch preamble, its PTC variant and the guard's deny reason used to be translated per interface language; they now come from a single English `PROMPT_TEXT` constant, matching DSH's own English prompts and keeping the instruction set identical no matter which language the panel is in. User-visible strings still follow the interface language.
|
|
13
|
+
- Verified on DSH `0.1.5-rc.2` and `0.1.7-rc.2`; `scripts/smoke.mjs` gained nine assertions guarding this cycle, the feature detection, and the single English prompt text.
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
First public release.
|
|
18
|
+
|
|
19
|
+
- Read-only branch session derived from the main session: it inherits the completed-turn prefix and the agent preset, and keeps hitting the prefix cache when the main session's model is reused.
|
|
20
|
+
- Answers never enter the main session's model context.
|
|
21
|
+
- Read-only enforced at the execution layer by `tools.guard`: the allow-list passes `read`, `glob`, `grep`, `web_search`, `web_fetch`, `lsp`; everything else is denied. The allow-list is not configurable.
|
|
22
|
+
- Multi-turn follow-ups inside one branch; each branch can pick its own model and reasoning effort, and a new branch seeds from the previous history.
|
|
23
|
+
- Token-by-token streaming of the answer and the reasoning; a disconnecting client does not abort the background answer.
|
|
24
|
+
- Selection entry, plus a quick-entry button on the right of the main session's composer.
|
|
25
|
+
- Panel state kept per *session + panel pane*, surviving a page refresh.
|
|
26
|
+
- Idle side sessions release their live instance but keep their record, and wake on the next question.
|
|
27
|
+
- In-panel settings: the main-session quick-entry toggle.
|
|
28
|
+
- When the main session uses a PTC tool surface, the branch preamble switches to the one explaining that this branch offers no tools.
|
|
29
|
+
- Zero third-party runtime dependencies, no build step.
|
|
30
|
+
|
|
31
|
+
## 中文版本记录
|
|
32
|
+
|
|
33
|
+
## 0.1.1
|
|
34
|
+
|
|
35
|
+
修复在 DSH `0.1.7-rc.1` 及之后版本上侧枝被拦的问题。
|
|
36
|
+
|
|
37
|
+
- **侧会话靠"归档"从会话列表里藏起来;而 DSH `0.1.7-rc.1` 起,已归档的会话同时变成不可运行。** `dsh-api-session-controller` 的 `ArchivedSessionGate` 会拒掉已归档会话的任何模型步,于是每一轮都以 `turn/end { reason: 'blocked' }` 收口、连模型请求都不发出——面板显示的是本插件自己给这个 reason 配的文案「内容被安全策略拦截」,与内容策略无关。
|
|
38
|
+
- 现在改为**每轮投递前先取消归档、该轮结算后再归档**:空闲的分支照样藏起来,正在跑的一轮不再被闸。回藏会按有界退避重试,因为注册表拒绝归档"仍有活动"的会话;即使始终失败,也只是这段分支留在侧栏可见。
|
|
39
|
+
- **`unarchiveSession()` 是 DSH `0.1.6-alpha.2` 才有的方法**,所以调用点做了特性探测:在 `0.1.5-rc.2` 上(既没有该方法、也没有那道闸)两步都是空操作,行为与旧版一致。因此同一份发布版本同时兼容两条 DSH 版本线。
|
|
40
|
+
- **推给模型的文本改成一律英文(不再随界面语言)。** 分支引导词、它的 PTC 变体与 guard 的拒绝理由原先按界面语言翻译,现在统一取自唯一的英文常量 `PROMPT_TEXT`:与 DSH 自身的英文提示词一致,也让面板用哪种语言时模型收到的指令完全相同。用户可见的文案仍然随界面语言。
|
|
41
|
+
- 已在 DSH `0.1.5-rc.2` 与 `0.1.7-rc.2` 上验证;`scripts/smoke.mjs` 新增 9 条断言守住这套循环、特性探测与"模型可见文本只有一份英文"。
|
|
42
|
+
|
|
43
|
+
## 0.1.0
|
|
44
|
+
|
|
45
|
+
首个公开版本。
|
|
46
|
+
|
|
47
|
+
- 从主会话派生只读分支会话:继承已完成的回合前缀与 agent preset,沿用主会话模型时继续命中前缀缓存。
|
|
48
|
+
- 答案不进入主会话的模型上下文。
|
|
49
|
+
- 只读由执行层 `tools.guard` 保证,白名单放行 `read`、`glob`、`grep`、`web_search`、`web_fetch`、`lsp`,名单外一律拒绝。放行名单不可配置。
|
|
50
|
+
- 同一段内多轮追问;每条可单独指定模型与推理等级,换段时把上一段历史作为种子。
|
|
51
|
+
- 正文与推理过程逐词流式;客户端断开不中止后台回答。
|
|
52
|
+
- 划选引用入口与主会话输入框右侧的快速入口。
|
|
53
|
+
- 面板状态按「会话 + 面板格」保留,刷新页面不丢。
|
|
54
|
+
- 侧会话闲置后释放活动实例并保留记录,下次提问自动唤醒。
|
|
55
|
+
- 面板内就地设置:主会话快速入口按钮的开关。
|
|
56
|
+
- 主会话使用 PTC 工具面时,分支引导词换成说明「本分支不提供任何工具」的那一份。
|
|
57
|
+
- 零第三方运行时依赖,无构建步骤。
|