dsh-builtin-browser 0.4.10 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,121 @@
4
4
 
5
5
  ---
6
6
 
7
+ # 第五十轮(2026-10-17,对齐 DSH 0.2.1 的插件契约 + 工具描述瘦身)
8
+
9
+ ## 一、版本范围改到 DSH 真正读取的位置
10
+
11
+ DSH 从 0.2.1-alpha.3 起改成:**有 `engines.dsh` 就只看它**,`@deepseek-ai/dsh*` 的 `peerDependencies` 被忽略;没有才回退到 peer 规则(官方指南 `docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`)。
12
+
13
+ 本插件原来把范围写在 **`dsh.compatibility.dsh`** —— **那一块没有任何代码读取**(在 `packages/boot` 里搜 `manifest.dsh` 只命中 `dsh.bundle` 与 `dsh.profile.bundles`,`dshReleases` 零匹配)。当时的兼容完全是 peer 回退规则顺带成立的,而且声明下限还与 peers 不一致。
14
+
15
+ 现在 `engines.dsh: ">=0.1.1-rc.2 <0.3.0"`,与 peers 逐字相同。用 DSH 自己的 `evaluatePluginCompatibility` 验证:改前改后在 `0.2.1-rc.1` 上都是 compatible,`0.3.0` 与 `0.1.0-rc.8` 两个方向都被 BLOCKED,非字符串取值被明确拒绝。
16
+
17
+ ## 二、更新路径改成两步
18
+
19
+ `plugin-install-over-installed`(0.2.1-alpha.2):`installBundle` 对已依赖的包**在跑 pnpm 之前就失败**,报 `already-installed`(代码证据:`plugin-manager/src/index.ts:439`、`:444`)。原文档写的"更新它 = 把依赖更新到新版本"照做会撞上这个失败。已改为明确的 `remove` 然后 `add <版本>`,两版 README 与用户指南都同步。
20
+
21
+ ## 三、工具描述瘦身(省 5%)
22
+
23
+ 先测量,再动手。34 个工具的**模型可见**开销是 `name + description + parameters` —— 注意**不含 `output.schema`**:DSH 的 `schemaOf()` 只投影前三个字段,所以第一版把 `output` 算进去得出的 79k 字符 / 22k tokens 是**高估**,实际是 20,348 字符 / 约 5,650 tokens。
24
+
25
+ 据此重排优先级:参数 9,782 字符(48%)、描述 9,932 字符(49%),平均每个工具 166 tokens —— **已经算紧凑**,所以"精简能省一大截"不成立。
26
+
27
+ 只压最长的 6 条描述(restrict / screenshot / auth / click / open / fill / a11y),保留全部操作约束、参数含义与安全警告,删掉解释性从句与重复列举。结果:
28
+
29
+ | | 改前 | 改后 |
30
+ | --- | --- | --- |
31
+ | 描述合计 | 9,932 字符 | 8,932 字符 |
32
+ | 模型可见合计 | 20,348 字符 | 19,348 字符 |
33
+ | 估算 token | 约 5,650 | 约 5,374 |
34
+
35
+ **收益 5%,如实记录**:不是数量级改进,但它是确定性的、无风险的部分。
36
+
37
+ ## 四、评估后不做的两条
38
+
39
+ - **`presentationMeta`**:查了 `packages/core/tools/src/presentation.ts`,它是把结构化数据存进会话日志、由 `presentResult` 读回来渲染成**特定卡片类型**(`card: 'read'`、`'search'`)。UI 只认已定义的卡片,浏览器元素没有对应类型,加了只会回退到原始文本 —— **收益为零**,不做。
40
+ - **`ToolSchema.deferLoading`**:看似正是为这种情况设计的(Anthropic 的 `defer_loading`)。但 DSH 的语义是"延迟的基线工具仍需一次 tool-addition 才能激活",且在不支持 tool-update 的路由上标志会被**剥掉、全部立即加载**。插件单方面标记它的风险是工具直接不可用,不做。
41
+
42
+ **真正能省一个量级的是 PTC 模式**:prompt 里从 34 个工具变成 `run_code` 一个入口,省掉约 5,000 tokens。但它需要宿主有 `ctx.ptcRuntime` 且 agent preset 切到 `mode: ptc` —— **插件无法单方面启用**,只能写进文档由使用者选择。
43
+
44
+ ## 五、验证
45
+
46
+ - `scripts/verify-host-compat.mjs` 在 **0.2.1-rc.1** 上全绿:34 个工具注册、`dsh_browser_session` 与 `dsh_browser_a11y` 实跑成功;
47
+ - DSH 自己的 `evaluatePluginCompatibility` 对改动前后、四个版本边界逐一验证;
48
+ - **测试 290 → 290(289 通过 + 1 条平台门控跳过)**,`tsc` 干净。
49
+
50
+ ---
51
+
52
+ # 第四十九轮(2026-10-17,DSH 0.2.1 的兼容性新规:版本范围必须写在 `engines.dsh`)
53
+
54
+ DSH 升到 **`0.2.1-rc.1`** 后,兼容性判定规则变了。本插件把版本范围写在一个**没有任何代码读取**的位置,当时的"兼容"是靠回退规则蒙对的。
55
+
56
+ ## 一、规则变了什么
57
+
58
+ 官方迁移指南:`docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`(英文/中文各一份)。判定实现在 `packages/boot/app-boot/src/plugin-compatibility.ts`。
59
+
60
+ **新规**:**有 `engines.dsh` 就只看它**,`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 的 `peerDependencies` **被忽略**;**没有** `engines.dsh` 才回退到 peer 规则。另有三条附带约束:
61
+
62
+ - `engines.dsh` 里**不接受** `workspace:` 值(只有 peers 接受),写成那样等于声明不兼容;
63
+ - 非对象 `engines` 或非字符串 `engines.dsh` → manifest 对兼容性而言**不可读** → 拒绝该包;
64
+ - `IncompatiblePlugin` / `PluginCompatibility` 的 `peers` 字段**改名为 `requirements`**,键为 `engines.dsh` 或 peer 包名。
65
+
66
+ ## 二、本插件的处境
67
+
68
+ 范围原来写在 **`dsh.compatibility.dsh`**:`>=0.1.1-rc.1 <0.3.0`。**那一块没有任何代码读取** —— 在 `packages/boot` 里搜 `manifest.dsh` 只命中 `dsh.bundle` 与 `dsh.profile.bundles`,`dshReleases` 零匹配。当时的兼容完全是 peer 回退规则(`@deepseek-ai/dsh-*: >=0.1.1-rc.2 <0.3.0`)顺带成立的。
69
+
70
+ 于是这个声明在两个意义上都是虚的:写在没人读的地方,而且它的下限也与 peers 的 `<0.1.1-rc.2` 不一致。
71
+
72
+ ## 三、改法
73
+
74
+ `engines` 里加 `dsh: ">=0.1.1-rc.2 <0.3.0"`,**与 peers 逐字相同**。迁移指南第 1 条明确要求"范围必须覆盖所有支持的 release,因为 peers 不再增减它" —— 取与 peers 相同的值,是保证"换规则前后答案不变"最直接的方式。
75
+
76
+ `dsh.compatibility` 块保留但**去掉 `dsh` 字段**,并加 `_comment` 说明它现在只是给人看的兼容性记录、不参与任何判定;`dshReleases` 补记 `0.2.1-rc.1`。留着比删掉好 —— 逐版本的支持记录对人有价值,而且它不会被误认为判定依据了:真正的判据在 `engines.dsh` 里一眼可见。
77
+
78
+ 第三条不受影响:插件代码从不读 `IncompatiblePlugin` / `PluginCompatibility`(`src/` 里那三处 `requirements` 是注释里对需求文档的引用,与 API 无关)。
79
+
80
+ ## 四、验证:用 DSH 自己的判定函数
81
+
82
+ 不靠推断,直接 import `packages/boot/app-boot/lib/types/plugin-compatibility.js` 里的 `evaluatePluginCompatibility`:
83
+
84
+ | manifest | `engines.dsh` | 0.2.1-rc.1 上的判定 |
85
+ | --- | --- | --- |
86
+ | 改之前 | 无(靠 peers 回退) | compatible |
87
+ | 改之后 | `>=0.1.1-rc.2 <0.3.0` | compatible |
88
+
89
+ 范围真的生效,两个方向都试了:`0.3.0` → BLOCKED、`0.1.0-rc.8` → BLOCKED、`0.2.0-rc.2` → compatible;非字符串的 `engines.dsh` 被明确拒绝(`Plugin manifest engines.dsh must be a string`)。
90
+
91
+ **改前改后都是 compatible,这一点值得说清楚**:这一轮不是修一个"装不上"的故障,而是把一个**恰好成立的默认**换成**显式成立的声明**。区别在于今后 DSH 再收紧规则、或有人照着 `dsh.compatibility` 去改范围时,行为会与声明一致,而不是继续靠巧合。
92
+
93
+ **测试**:285 → 290(新增 5 条:`tests/manifest-compatibility.test.mjs`)。其中一条直接调用 DSH 的判定函数,在 checkout 不存在时跳过(CI 上没有那个 checkout);另一条守住下面那条文档要求。`tsc` 干净。
94
+
95
+ ## 五、其余迁移指南逐条核对(结论:一条要改文档,其余无关)
96
+
97
+ 0.2.1 前后共有 40 余份迁移指南。按"本插件是否用到被改动的 API/配置"逐条核对,用 grep 确认而非推断:
98
+
99
+ | 指南 | 是否影响 | 依据 |
100
+ | --- | --- | --- |
101
+ | `plugin-engines-dsh` | **是,已改** | 见上文 |
102
+ | `plugin-install-over-installed` | **是,改文档** | 见下 |
103
+ | `tool-presentation-mode` | 否 | 搜索 `presentAs` / `mode: both` / `DSH_TOOLS_MODE` 零匹配 |
104
+ | `jsonl-persistence-plugin` | 否 | 不使用 `sessionPersistence`,自有一份 history 存储 |
105
+ | `web-listener-trust-config` | 否 | 不注入 `webRuntime`,设置路由走 `ctx.webServer` |
106
+ | `remove-runtime-invariants` | 否 | 不导入 `@deepseek-ai/dsh-invariants` |
107
+ | `working-directory` | 否 | 34 个工具用的 `output` 是返回值 JSON Schema,不是 `output.presentationMeta`(该字段零匹配) |
108
+ | `instruction-home-override` | 否 | 不配置 `agent-instructions` |
109
+ | `subpath-plugin-display-manifest` | 否 | 本包是包根插件,不是子路径插件 |
110
+ | `plugin-option-placement` | 否 | 那是 CLI 参数位置,不影响已安装的包 |
111
+ | `experimental-package-composition` | 否 | 本包的 `dsh.bundle.patch` 是完整 insertion,不是"只启用被移除的行" |
112
+ | `new-session-generations`、`account-*`、`pi-ai-*`、`sdk-*`、`subagent-*`、`python-*`、`desktop-*`、`schedule-*` 等 | 否 | 均不涉及浏览器插件使用的任何接缝 |
113
+
114
+ **`plugin-install-over-installed` 要改的是文档,不是代码**:从 0.2.1-alpha.2 起,`installBundle` 对 profile 已依赖的包**在跑 pnpm 之前就失败**,报 `already-installed`,且不改任何 profile 文件。代码侧证据:`packages/boot/plugin-manager/src/index.ts:439`、`:444` 的 `refused('already-installed', ...)`,以及 `tools.ts:18` 的错误文案本身就写着"to install another version, call remove_bundle first, then install_bundle again"。
115
+
116
+ 原来的文档只说"更新它 = 把依赖更新到新版本",**照做会撞上这个失败**。已改为明确的两步(`remove` 然后 `add <版本>`),并写明替代路径(Web 插件管理页的 Update 按钮会自己先移除再安装),README 中英两版都加了这一步的提示。
117
+
118
+ 这一条加了一条守护测试:断言文档里 `remove` 出现在 `add` 之前,且 README 两版都带这步 —— 否则下次改文档时很容易又把一步写回去。
119
+
120
+ ---
121
+
7
122
  # 第四十八轮(2026-10-16,实机四载体检验:三次误判、一个真 bug、一个跨载体缺陷)
8
123
 
9
124
  YG 要求逐个检验四种载体(侧栏 / 自托管 / Chrome / Edge)能否正常拉开并操作。检验过程里我犯了一个贯穿始终的错误,也发现了两个真实缺陷。
package/README.en.md CHANGED
@@ -27,6 +27,8 @@ In one line: **install the plugin and you have a real browser shared with, and d
27
27
 
28
28
  **First, which side are you on** — these are two separate installs; updating one does not update the other, and they host the browser differently.
29
29
 
30
+ > **Updating an installed plugin takes two steps** (since DSH 0.2.1-alpha.2, installing over an installed package fails outright with `already-installed`): run `dsh plugin --profile <profile> remove dsh-builtin-browser` first, then `add` the target version. See the [user guide](docs/user-guide.md) (Chinese).
31
+
30
32
  ### DSH Desktop
31
33
 
32
34
  ```sh
package/README.md CHANGED
@@ -27,6 +27,8 @@
27
27
 
28
28
  **先确认你在哪一端** —— 两端是两份独立安装,**更新其中一端不会带上另一端**,浏览器的承载方式也不同。
29
29
 
30
+ > **更新已安装的插件是两步**(DSH 0.2.1-alpha.2 起,对已装包再装会直接报 `already-installed`):先 `dsh plugin --profile <profile> remove dsh-builtin-browser`,再 `add` 目标版本。详见[用户指南](docs/user-guide.md#先移除再安装)。
31
+
30
32
  ### DSH Desktop(桌面端)
31
33
 
32
34
  ```sh
@@ -609,6 +609,20 @@ async function handle(request) { const op = String(request?.op ?? '')
609
609
  }
610
610
 
611
611
 
612
+ /**
613
+ * When the bridge instance currently running was constructed.
614
+ *
615
+ * Recorded once and republished unchanged, because a reader uses it to answer "how long has this
616
+ * bridge been up". Refreshing it on every keepalive made it mean "the last keepalive" instead: a
617
+ * reporter read two values 15 seconds apart, concluded the bridge had restarted, and only noticed
618
+ * otherwise because the port had not changed. Each module evaluation gets its own value, so a
619
+ * genuinely new instance is still reported as such.
620
+ */
621
+ const instanceStartedAt = (() => {
622
+ const at = new Date().toISOString()
623
+ return () => at
624
+ })()
625
+
612
626
  /**
613
627
  * Publish the endpoint, and keep it published.
614
628
  *
@@ -624,7 +638,7 @@ function publishEndpoint(port, token) {
624
638
  port,
625
639
  token,
626
640
  pid: process.pid,
627
- startedAt: new Date().toISOString(),
641
+ startedAt: instanceStartedAt(),
628
642
  updatedAt: new Date().toISOString(),
629
643
  }
630
644
  try {
@@ -31,8 +31,16 @@ node scripts/verify-host-compat.mjs
31
31
 
32
32
  它用某个 profile 里**真实安装**的 `@deepseek-ai/*` 包组装一个宿主,加载本插件的编译产物、经宿主自己的 `defineTool` 注册工具、再用一个假 provider 真调两个工具(`dsh_browser_session` / `dsh_browser_a11y`)。默认读 `$DSH_HOME/profiles` 下的 profile,可用 `DSH_PROFILE` / `DSH_HOME` 指定。
33
33
 
34
- ## 关于 peer 范围的 semver 语义
34
+ ## 关于 DSH 版本范围
35
35
 
36
- 插件声明 `>=0.1.1-rc.2 <0.3.0`。这个范围在 **semver 默认语义**下**不匹配**任何带预发布标签的版本(如 `0.2.1-alpha.1`)—— 这是规范行为:预发布只被"同一 major.minor.patch 元组"的比较器允许。
36
+ 插件在 **`engines.dsh`** 里声明 `>=0.1.1-rc.2 <0.3.0`。
37
37
 
38
- DSH 判定插件兼容性时用的是 `includePrerelease: true`(`packages/boot/app-boot/src/plugin-compatibility.ts`),因此这些预发布宿主**实际都在范围内**。若某个外部工具用默认语义得出"不兼容",那是它的语义与 DSH 不同,不是这里的声明写错了。
38
+ 位置不是随便选的。DSH 从 0.2.1-alpha.3 之后改成:**有 `engines.dsh` 就只看它,`@deepseek-ai/dsh*` 的 `peerDependencies` 被忽略;没有 `engines.dsh` 才回退到 peer 规则**(`packages/boot/app-boot/src/plugin-compatibility.ts`;官方迁移指南在 `docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/`)。
39
+
40
+ 本插件 0.4.12 之前把范围写在 `dsh.compatibility.dsh` 里 —— 那个位置**没有任何代码读取**,当时的兼容性实际是靠 peer 回退蒙对的。现在两处一致(`engines.dsh` 与 peers 同范围),所以哪条规则生效都得到同一个答案。
41
+
42
+ 这个范围在 **semver 默认语义**下**不匹配**任何带预发布标签的版本(如 `0.2.1-rc.1`)—— 规范如此:预发布只被"同一 major.minor.patch 元组"的比较器允许。
43
+
44
+ DSH 判定时用的是 `includePrerelease: true`,因此这些预发布宿主**实际都在范围内**。若某个外部工具用默认语义得出"不兼容",那是它的语义与 DSH 不同,不是这里的声明写错了。
45
+
46
+ `engines.dsh` 里不接受 `workspace:` 值(只有 peers 接受);写成那样等于声明不兼容。
@@ -160,7 +160,7 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
160
160
 
161
161
  | 组件 | 版本 |
162
162
  | --- | --- |
163
- | DeepSeek Harness(dsh) | `0.2.1-alpha.1`(peer 声明 `>=0.1.1-rc.2 <0.3.0`;更早的 `0.2.0-rc.2`、`0.1.x` 亦有实测记录) |
163
+ | DeepSeek Harness(dsh) | `0.2.1-rc.1`(`engines.dsh` 声明 `>=0.1.1-rc.2 <0.3.0`;更早的 `0.2.1-alpha.1`、`0.2.0-rc.2`、`0.1.x` 亦有实测记录) |
164
164
  | Electron | `44.0.0`(推荐 ≥ 40;33.x 存在合成器缺陷) |
165
165
  | Node.js | `22.20.0` |
166
166
  | 本机 Chrome / Edge(可选载体) | `154.0.8037.58` / `154.0.4258.37` |
@@ -174,6 +174,23 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
174
174
 
175
175
  插件在两种宿主里各有一份安装,更新路径也不同 —— **更新其中一端不会连带更新另一端**。
176
176
 
177
+ ### 先移除,再安装
178
+
179
+ **DSH 0.2.1-alpha.2 起,对已经装着的包再执行一次安装会直接失败**,报 `already-installed`,并且在跑 pnpm 之前就失败、不改任何 profile 文件(官方迁移指南:`docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/`)。这条在启用 HMR 的 profile 上生效,而自带 profile 都启用 HMR。
180
+
181
+ 所以更新一个**已安装**的插件是两步:
182
+
183
+ ```bash
184
+ dsh plugin --profile web remove dsh-builtin-browser
185
+ dsh plugin --profile web add dsh-builtin-browser@<目标版本>
186
+ ```
187
+
188
+ 移除会停掉该 bundle 的插件并清掉它的包映射,新版本随即重新映射,因此**不需要重启**就能生效(桌面端仍需重启,理由见下)。
189
+
190
+ 替代做法:Web 界面的插件管理页对官方插件提供 Update 按钮,它自己会先移除再安装。桌面端尚无可用的新版,按上面的两步来。
191
+
192
+ ## 桌面端细节
193
+
177
194
  **桌面端(DSH Desktop)**
178
195
 
179
196
  - 插件是桌面端 profile 里的依赖(profile 目录通常是 `$DSH_HOME/profiles/desktop`)。更新它 = 把该 profile 里的依赖更新到新版本,然后**重启 DSH Desktop**,客户端设置栏与工具才会换成新代码。
@@ -186,12 +203,14 @@ DSH Desktop 上②命中即可用(0.1.18+ 插件自带 electron 包;44+ 二进
186
203
  - 浏览器内核默认由桌面端自带的 Electron 提供,插件不会再下载一份 Electron;也可以在设置里改用它自己装的 Chrome / Edge。
187
204
  - **升级桌面端本身不会带上插件**,按上面的方法单独更新它。
188
205
 
206
+ ## Web 端细节
207
+
189
208
  **Web 端(`dsh web`)**
190
209
 
191
210
  - 插件是 web profile 里的依赖(`$DSH_HOME/profiles/web`),更新后**重启 `dsh web`** 生效。
192
211
  - Web 端**没有侧栏、也没有 bridge**:共享浏览器由插件自托管拉起。首次安装可能需要 Electron 二进制;若包管理器的构建白名单拦下了它(pnpm v10+ 会拦 `electron` 的 postinstall),执行一次 `npx install-electron` 补上即可。
193
212
  - 同样可以在设置里改用本机 Chrome / Edge —— 这是两端**行为一致**的选项。
194
- - 更新方式与首次安装它时一致(按 npm 包名 `dsh-builtin-browser`、按 GitHub 仓库 `wqty123/dsh-browser`,或本地目录)。
213
+ - 更新方式与首次安装它时一致(按 npm 包名 `dsh-builtin-browser`、按 GitHub 仓库 `wqty123/dsh-browser`,或本地目录),但**已安装时先按上面的两步移除再装**。
195
214
 
196
215
  **两端一致的体验**
197
216
 
@@ -472,6 +472,22 @@ export interface CarrierPage {
472
472
  readonly url: string;
473
473
  readonly title?: string;
474
474
  }
475
+ /**
476
+ * Why a carrier reported that it can no longer be used.
477
+ *
478
+ * Two different failures reach the same fallback, and saying which one is the difference between a
479
+ * message a user can act on and one that misleads them:
480
+ *
481
+ * - `shell-exited`: the shell process behind the carrier is gone. Its published endpoint stops
482
+ * being refreshed, which is the condition the fallback was originally written for.
483
+ * - `panel-unusable`: the shell is alive and answering, but its sidebar panel cannot provide a page
484
+ * — on a shell that renders that panel as an iframe there is no webContents and no CDP endpoint,
485
+ * so the carrier is undrivable rather than merely unready.
486
+ *
487
+ * The fallback announced the first of these unconditionally, so a `panel-unusable` fallback told
488
+ * the user their desktop shell had exited while it was running normally. Reported as such.
489
+ */
490
+ export type CarrierLossReason = 'shell-exited' | 'panel-unusable';
475
491
  /** Request to open a URL: into the active tab or a new tab. */ export interface BrowserOpenRequest {
476
492
  /** The URL to open. */
477
493
  readonly url: string;
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js';
26
26
  import type { BrowserUserAction } from './provider.js';
27
- import type { CarrierPage } from '../browser/types.js';
27
+ import type { CarrierPage, CarrierLossReason } from '../browser/types.js';
28
28
  /** Absolute path of the endpoint file the shell writes. */
29
29
  export declare function bridgeEndpointPath(): string;
30
30
  /**
@@ -102,14 +102,15 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
102
102
  */
103
103
  private constructor();
104
104
  /**
105
- * Called by the transport once the shell behind it is gone.
105
+ * Called when this carrier concludes it cannot serve anything more.
106
106
  *
107
107
  * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
108
108
  * browser, and a host that tried to replace itself would be reaching outside its own contract.
109
+ * @param reason - which failure this was, so the fallback can say so accurately.
109
110
  */
110
111
  private reportLost;
111
112
  /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
112
- onLost(listener: () => void): void;
113
+ onLost(listener: (reason: CarrierLossReason) => void): void;
113
114
  /**
114
115
  * Find a usable desktop bridge, if this surface has one.
115
116
  *
@@ -118,17 +118,20 @@ export class DesktopBridgeViewHost {
118
118
  */
119
119
  constructor(endpoint, sessionId) {
120
120
  this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
121
- this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost());
121
+ // The transport reaches this only by concluding the shell stopped refreshing its endpoint, which
122
+ // is `shell-exited`.
123
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost('shell-exited'));
122
124
  }
123
125
  /**
124
- * Called by the transport once the shell behind it is gone.
126
+ * Called when this carrier concludes it cannot serve anything more.
125
127
  *
126
128
  * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
127
129
  * browser, and a host that tried to replace itself would be reaching outside its own contract.
130
+ * @param reason - which failure this was, so the fallback can say so accurately.
128
131
  */
129
- reportLost() {
132
+ reportLost(reason) {
130
133
  try {
131
- this.lostListener?.();
134
+ this.lostListener?.(reason);
132
135
  }
133
136
  catch {
134
137
  // The listener's failure is not the transport's problem.
@@ -326,8 +329,12 @@ export class DesktopBridgeViewHost {
326
329
  // shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
327
330
  // the bridge made it worse than not installing it": the carrier was switched to one that
328
331
  // cannot work, with no path back to the one that does.
332
+ //
333
+ // `panel-unusable`, not `shell-exited`: the shell is alive and answered this very request. The
334
+ // fallback's message used to announce an exiting shell unconditionally, which told the user
335
+ // their desktop had crashed when it was running normally.
329
336
  if (String(answer.error).includes('produced no page'))
330
- this.reportLost();
337
+ this.reportLost('panel-unusable');
331
338
  throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
332
339
  }
333
340
  const id = Number(answer.id);
@@ -127,19 +127,29 @@ export function apply(ctx, config) {
127
127
  *
128
128
  * This is the half the startup retries never covered: a bridge that was alive at apply time and
129
129
  * exited later left every subsequent call dialling a port nobody owns, because the swap happened
130
- * once and only in one direction. Reaching here means the shell is gone, so the choice made at
131
- * startup is no longer a choice — the alternative to falling back is a browser surface that does
132
- * nothing until DSH is restarted.
130
+ * once and only in one direction. Reaching here means the carrier cannot serve anything more, so
131
+ * the choice made at startup is no longer a choice — the alternative to falling back is a browser
132
+ * surface that does nothing until DSH is restarted.
133
+ *
134
+ * Two failures arrive here and the message must distinguish them. The first draft announced an
135
+ * exiting shell unconditionally, so a fallback caused by an undrivable sidebar panel told the
136
+ * user their desktop shell had exited while it was running normally.
133
137
  * @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
138
+ * @param reason - which failure this was; see {@link CarrierLossReason}.
134
139
  */
135
- const restoreSelfHosted = (adopted) => {
140
+ const restoreSelfHosted = (adopted, reason) => {
136
141
  try {
137
142
  const restored = new RemoteElectronViewHost(defaultHostMainPath());
138
143
  unregister();
139
144
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig));
140
- ctx.logger?.warn?.('dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
141
- + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
142
- + 'open the page again.');
145
+ ctx.logger?.warn?.(reason === 'shell-exited'
146
+ ? 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
147
+ + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
148
+ + 'open the page again.'
149
+ : 'dsh-builtin-browser: this desktop shell renders the sidebar browser panel in a way that '
150
+ + 'cannot be driven (no webContents, so no CDP endpoint), so the browser this plugin hosts '
151
+ + 'itself takes over. The shell is still running and nothing else is affected; the page '
152
+ + 'opens in this plugin\'s own window. Set browser.channel to another value to silence this.');
143
153
  }
144
154
  catch (error) {
145
155
  // Nothing better is available: the adopted carrier is gone and a provider that throws is
@@ -147,7 +157,21 @@ export function apply(ctx, config) {
147
157
  ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`);
148
158
  }
149
159
  finally {
150
- adopted.dispose?.();
160
+ // Disposed on a LATER turn of the event loop, deliberately.
161
+ //
162
+ // Disposing synchronously here closes the bridge socket while the request that triggered this
163
+ // fallback is still settling, and the close rejects every call still waiting on that socket —
164
+ // so the caller received "bridge closed" instead of the reason it actually failed for. The
165
+ // fallback is what the user is supposed to be able to understand; replacing its explanation
166
+ // with one about the connection loses exactly the information this path exists to convey.
167
+ setImmediate(() => {
168
+ try {
169
+ adopted.dispose?.();
170
+ }
171
+ catch {
172
+ // A carrier that cannot be released is not the caller's problem; the swap already happened.
173
+ }
174
+ });
151
175
  }
152
176
  };
153
177
  /**
@@ -169,7 +193,7 @@ export function apply(ctx, config) {
169
193
  // A borrowed carrier can lose the process behind it, and this is the only notification that
170
194
  // will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
171
195
  // against a provider that was never installed.
172
- host.onLost?.(() => { restoreSelfHosted(host); });
196
+ host.onLost?.(reason => { restoreSelfHosted(host, reason); });
173
197
  const previous = adoptedCarrier;
174
198
  adoptedCarrier = host;
175
199
  // Released AFTER the replacement is installed, so the surface is never without a provider.
@@ -9,7 +9,7 @@
9
9
  import { type VisitedPage } from './history-store.js';
10
10
  import type { BrowserSettings } from './settings-store.js';
11
11
  import type { BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, ExportedCookie } from '../browser/types.js';
12
- import type { CarrierPage } from '../browser/types.js';
12
+ import type { CarrierPage, CarrierLossReason } from '../browser/types.js';
13
13
  /** Stable provider id registered with `ctx.browser`. */
14
14
  export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
15
15
  /**
@@ -157,9 +157,14 @@ export interface ElectronBrowserViewHost {
157
157
  * startup, so the choice made then is final for the process's life — which is what a user
158
158
  * experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
159
159
  * owns its own lifetime has nothing to report.
160
+ *
161
+ * The listener receives WHY, because the fallback's message has to match what happened: a shell
162
+ * that exited and a shell whose panel cannot provide a page both reach the same fallback, and
163
+ * announcing "the shell has exited" for the second tells the user their desktop crashed when it
164
+ * did not.
160
165
  * @param listener - called once, when the carrier concludes it is gone.
161
166
  */
162
- onLost?(listener: () => void): void;
167
+ onLost?(listener: (reason: CarrierLossReason) => void): void;
163
168
  /**
164
169
  * Fold the carrier's presentation away while keeping the page alive.
165
170
  *
@@ -699,11 +699,38 @@ export class SystemBrowserViewHost {
699
699
  // The port file is deliberately NOT deleted here, and that is a reversal.
700
700
  //
701
701
  // It used to be removed so the launch loop below could not read a previous browser's port. But
702
- // the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
703
- // deletion never protected it — while it DID destroy the only record of a browser that may
704
- // still be running for this profile. That record is what `adoptRunningBrowser` needs when the
705
- // launch comes back as a hand-off: the browser was alive the whole time, and without the file
706
- // there is no way to find it again.
702
+ // the loop judges freshness against a snapshot taken below, so the deletion never protected it
703
+ // — while it DID destroy the only record of a browser that may still be running for this
704
+ // profile. That record is what `adoptRunningBrowser` needs when the launch comes back as a
705
+ // hand-off: the browser was alive the whole time, and without the file there is no way to find
706
+ // it again.
707
+ const portFile = join(this.profileDir, 'DevToolsActivePort');
708
+ /**
709
+ * What the port file was like BEFORE this launch, so "fresh" is judged against a snapshot
710
+ * rather than against a clock.
711
+ *
712
+ * The test used to be `mtimeMs < spawnedAt` — a filesystem timestamp compared to `Date.now()`.
713
+ * Those are different clocks with different resolutions, and CI is where it showed: a file the
714
+ * fake browser had just written was rejected as stale, so the loop waited out its full 30s
715
+ * against a browser that was already listening, because the file's mtime had rounded down
716
+ * below the spawn time. That is not a test-only problem: on any filesystem whose mtime
717
+ * resolution is coarser than a millisecond, the plugin would fail to see the browser it had
718
+ * just launched, and report a CDP timeout for a browser that was up the whole time.
719
+ *
720
+ * `undefined` means there was no file, so any file that appears afterwards is fresh. When one
721
+ * existed, a later mtime OR different contents makes the new one fresh. Byte-identical contents
722
+ * with an unchanged mtime is the PREVIOUS browser's port, and adopting it is what this whole
723
+ * check exists to prevent: it may name a port that now belongs to something else entirely.
724
+ */
725
+ const portFileBefore = (() => {
726
+ try {
727
+ return { mtimeMs: statSync(portFile).mtimeMs, contents: readFileSync(portFile, 'utf8') };
728
+ }
729
+ catch {
730
+ return undefined;
731
+ }
732
+ })();
733
+ // Kept for the failure message and for the exit listener's diagnostics; no longer a judgement.
707
734
  const spawnedAt = Date.now();
708
735
  let child;
709
736
  try {
@@ -739,22 +766,28 @@ export class SystemBrowserViewHost {
739
766
  // Clearing these is what makes the restart usable rather than merely alive.
740
767
  this.forgetBrowserState();
741
768
  });
742
- const portFile = join(this.profileDir, 'DevToolsActivePort');
743
769
  /**
744
770
  * The port this launch wrote, or undefined while it has not written one yet.
745
771
  *
746
- * A file left by the PREVIOUS browser is not evidence about this one. Its contents
747
- * name a port that may now belong to something else entirely — another Chromium, a
748
- * node --inspect — and connecting there publishes a client this host does not own and
749
- * dispose() can never kill. Only a file written after the spawn counts.
772
+ * A file left by the PREVIOUS browser is not evidence about this one. Its contents name a port
773
+ * that may now belong to something else entirely — another Chromium, a `node --inspect` — and
774
+ * connecting there publishes a client this host does not own and dispose() can never kill.
775
+ * Only a file that CHANGED across this launch counts, which is what `portFileBefore` records.
750
776
  */
751
777
  const freshPort = () => {
752
778
  try {
753
779
  if (!existsSync(portFile))
754
780
  return undefined;
755
- if (statSync(portFile).mtimeMs < spawnedAt)
781
+ const after = statSync(portFile);
782
+ const contents = readFileSync(portFile, 'utf8');
783
+ // Unchanged since before the launch: this is the previous browser's file, whatever its
784
+ // mtime says. Comparing against the snapshot rather than the spawn time is what keeps this
785
+ // correct on a filesystem whose timestamps are coarser than the clock behind spawnedAt.
786
+ if (portFileBefore !== undefined
787
+ && after.mtimeMs <= portFileBefore.mtimeMs
788
+ && contents === portFileBefore.contents)
756
789
  return undefined;
757
- const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '');
790
+ const value = Number(contents.split('\n')[0]?.trim() ?? '');
758
791
  return Number.isInteger(value) && value > 0 ? value : undefined;
759
792
  }
760
793
  catch {
@@ -319,7 +319,7 @@ export function apply(ctx, config = {}) {
319
319
  };
320
320
  registerSafely(defineTool({
321
321
  name: 'dsh_browser_open',
322
- description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot. A fresh session starts on a blank, uncommitted tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target website — it requires the authenticated URL printed by `dsh web`, and the bare root is refused outright.',
322
+ description: 'Open a URL in the shared browser window and return the resulting snapshot. Opens this task\'s session on first use; newTab is optional. A fresh session starts on a blank tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target — it needs the authenticated URL printed by `dsh web`, and the bare root is refused.',
323
323
  parameters: {
324
324
  url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
325
325
  newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
@@ -445,7 +445,7 @@ export function apply(ctx, config = {}) {
445
445
  }));
446
446
  registerSafely(defineTool({
447
447
  name: 'dsh_browser_a11y',
448
- description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…), (coordinates only when coords: true). Prefer this over dsh_browser_snapshot to understand a page\'s structure and find the right element: roles and names tell you WHAT each node is, and the name is what dsh_browser_click matches when you target by text. Penetrates same-origin iframes and shadow roots.',
448
+ description: 'Read the accessibility tree: every interactive node with its role (button/link/textbox/checkbox/…), accessible name, value, and states (enabled/checked/expanded/…); coordinates only with coords: true. Prefer this over dsh_browser_snapshot to understand structure and find the right element — roles and names say WHAT each node is, and the name is what dsh_browser_click matches when targeting by text. Penetrates same-origin iframes and shadow roots.',
449
449
  parameters: {
450
450
  includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
451
451
  maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
@@ -744,7 +744,7 @@ export function apply(ctx, config = {}) {
744
744
  }));
745
745
  registerSafely(defineTool({
746
746
  name: 'dsh_browser_click',
747
- description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a dsh_browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
747
+ description: 'Click in the shared browser. Prefer target {by: css|text|xpath, value, index?}: the element is located from the DOM, scrolled into view, and clicked at its centre — no image input needed, and the only form that works without vision. Alternatively x/y viewport coordinates, meaningful only after a vision model located the element on a dsh_browser_screenshot; refused under the non-visual strategy. Provide exactly one of target or x/y.',
748
748
  parameters: {
749
749
  target: {
750
750
  type: 'object',
@@ -965,7 +965,7 @@ export function apply(ctx, config = {}) {
965
965
  }));
966
966
  registerSafely(defineTool({
967
967
  name: 'dsh_browser_fill',
968
- description: 'Fill a form in one batch: pass fields with a CSS selector or name/label/placeholder text and the value to set (string, number, or boolean for checkbox/radio; for selects or radio groups pass the option value or visible text). Values are applied with the native setter plus input/change events, so React/Vue controlled inputs update correctly. Optionally submit the containing form. Prefer this over hand-written dsh_browser_execute for form filling; per-field failures are reported instead of throwing.',
968
+ description: 'Fill a form in one batch: each field takes a CSS selector or name/label/placeholder text plus its value (boolean for checkbox/radio; option value or visible text for selects and radio groups). The native setter plus input/change events fire, so React/Vue controlled inputs update. Optionally submit the form. Prefer this over hand-written dsh_browser_execute; per-field failures are reported instead of throwing.',
969
969
  parameters: {
970
970
  fields: {
971
971
  type: 'array',
@@ -1263,7 +1263,7 @@ export function apply(ctx, config = {}) {
1263
1263
  }));
1264
1264
  registerSafely(defineTool({
1265
1265
  name: 'dsh_browser_screenshot',
1266
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — dsh_browser_snapshot, dsh_browser_a11y and dsh_browser_content carry the same page as text, and dsh_browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; some carriers (the desktop sidebar) return PNG instead. Downscaling works on every carrier.',
1266
+ description: 'Screenshot the shared-browser page (PNG default, JPEG optional) for models that can read images: layout, charts, CAPTCHAs, or locating an element before clicking its coordinates. Without image input it gains nothing — dsh_browser_snapshot/a11y/content carry the same page as text, dsh_browser_scrape extracts structured data. Supports fullPage, savePath, JPEG quality, and maxWidth/maxHeight downscaling to cut vision tokens. JPEG needs a carrier whose CDP encoder works (self-hosted, installed Chrome/Edge); the desktop sidebar returns PNG. Downscaling works everywhere.',
1267
1267
  parameters: {
1268
1268
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1269
1269
  savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
@@ -1716,7 +1716,7 @@ export function apply(ctx, config = {}) {
1716
1716
  }));
1717
1717
  registerSafely(defineTool({
1718
1718
  name: 'dsh_browser_restrict',
1719
- description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["dsh_browser_snapshot","dsh_browser_content","dsh_browser_click"]) — any other dsh_browser_* call is refused. Pass an empty list or omit to lift the restriction. Never blocked, whatever the list says: the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and the three that lift a restriction or recover from a bad state (restrict/reset/reset_session) — so a restriction can always be read around and undone. IMPORTANT: this is a SOFT guardrail against accidental actions, NOT a security boundary — you (the model) can lift it yourself with an empty list.',
1719
+ description: 'Allow only the listed browser tools; any other dsh_browser_* call is refused. Empty list or omitted lifts it. Never blocked whatever the list says — the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and restrict/reset/reset_session — so a restriction can always be read around and undone. A SOFT guardrail against stray clicks, NOT a security boundary: you can lift it with an empty list.',
1720
1720
  parameters: {
1721
1721
  allowed: {
1722
1722
  type: 'array',
@@ -1751,7 +1751,7 @@ export function apply(ctx, config = {}) {
1751
1751
  }));
1752
1752
  registerSafely(defineTool({
1753
1753
  name: 'dsh_browser_auth',
1754
- description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1754
+ description: 'Export or restore the session\'s cookies (login state): "flush" returns a JSON cookie list, "restore" takes that list back (e.g. after the host restarted). Works on all carriers. Refused when the credential switch is off. Exported cookies are LIVE CREDENTIALS: never echo them into the conversation or logs; keep the list in a private file.',
1755
1755
  parameters: {
1756
1756
  action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
1757
1757
  cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.4.10",
3
+ "version": "0.4.13",
4
4
  "description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (dsh_browser_auth), CAPTCHA detection, batch form fill (dsh_browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -65,7 +65,8 @@
65
65
  "url": "https://github.com/wqty123/dsh-browser/issues"
66
66
  },
67
67
  "engines": {
68
- "node": ">=22.19"
68
+ "node": ">=22.19",
69
+ "dsh": ">=0.1.1-rc.2 <0.3.0"
69
70
  },
70
71
  "dependencies": {
71
72
  "electron": ">= 30.0.0"
@@ -97,7 +98,7 @@
97
98
  ]
98
99
  },
99
100
  "compatibility": {
100
- "dsh": ">=0.1.1-rc.1 <0.3.0",
101
+ "_comment": "Documentation only. DSH decides plugin compatibility from engines.dsh (falling back to @deepseek-ai/dsh* peerDependencies when it is absent); nothing reads this block. Kept because the release-by-release record is useful to a human and costs nothing.",
101
102
  "profiles": [
102
103
  "web",
103
104
  "desktop"
@@ -108,7 +109,8 @@
108
109
  "0.1.1-rc.2": "compatible",
109
110
  "0.2.0-rc.1": "compatible",
110
111
  "0.2.0-rc.2": "compatible",
111
- "0.2.1-alpha.1": "compatible"
112
+ "0.2.1-alpha.1": "compatible",
113
+ "0.2.1-rc.1": "compatible"
112
114
  }
113
115
  }
114
116
  }
@@ -511,6 +511,23 @@ export interface CarrierPage {
511
511
  readonly title?: string
512
512
  }
513
513
 
514
+ /**
515
+ * Why a carrier reported that it can no longer be used.
516
+ *
517
+ * Two different failures reach the same fallback, and saying which one is the difference between a
518
+ * message a user can act on and one that misleads them:
519
+ *
520
+ * - `shell-exited`: the shell process behind the carrier is gone. Its published endpoint stops
521
+ * being refreshed, which is the condition the fallback was originally written for.
522
+ * - `panel-unusable`: the shell is alive and answering, but its sidebar panel cannot provide a page
523
+ * — on a shell that renders that panel as an iframe there is no webContents and no CDP endpoint,
524
+ * so the carrier is undrivable rather than merely unready.
525
+ *
526
+ * The fallback announced the first of these unconditionally, so a `panel-unusable` fallback told
527
+ * the user their desktop shell had exited while it was running normally. Reported as such.
528
+ */
529
+ export type CarrierLossReason = 'shell-exited' | 'panel-unusable'
530
+
514
531
  /** Request to open a URL: into the active tab or a new tab. */export interface BrowserOpenRequest {
515
532
  /** The URL to open. */
516
533
  readonly url: string
@@ -30,7 +30,7 @@ import { join } from 'node:path'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
32
32
  import type { BrowserUserAction } from './provider.js'
33
- import type { CarrierPage } from '../browser/types.js'
33
+ import type { CarrierPage, CarrierLossReason } from '../browser/types.js'
34
34
 
35
35
 
36
36
  /** Absolute path of the endpoint file the shell writes. */
@@ -120,32 +120,35 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
120
120
  */
121
121
  private userActionHandler: ((action: BrowserUserAction) => void) | undefined
122
122
  /** Told when the shell behind this bridge exits; see `onLost`. */
123
- private lostListener: (() => void) | undefined
123
+ private lostListener: ((reason: CarrierLossReason) => void) | undefined
124
124
 
125
125
  /**
126
126
  * @param endpoint - the shell's published bridge endpoint.
127
127
  */
128
128
  private constructor(endpoint: BridgeEndpoint, sessionId?: string) {
129
129
  this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39,39) ? sessionId : randomUUID()
130
- this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost())
130
+ // The transport reaches this only by concluding the shell stopped refreshing its endpoint, which
131
+ // is `shell-exited`.
132
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost('shell-exited'))
131
133
  }
132
134
 
133
135
  /**
134
- * Called by the transport once the shell behind it is gone.
136
+ * Called when this carrier concludes it cannot serve anything more.
135
137
  *
136
138
  * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
137
139
  * browser, and a host that tried to replace itself would be reaching outside its own contract.
140
+ * @param reason - which failure this was, so the fallback can say so accurately.
138
141
  */
139
- private reportLost(): void {
142
+ private reportLost(reason: CarrierLossReason): void {
140
143
  try {
141
- this.lostListener?.()
144
+ this.lostListener?.(reason)
142
145
  } catch {
143
146
  // The listener's failure is not the transport's problem.
144
147
  }
145
148
  }
146
149
 
147
150
  /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
148
- onLost(listener: () => void): void {
151
+ onLost(listener: (reason: CarrierLossReason) => void): void {
149
152
  this.lostListener = listener
150
153
  }
151
154
 
@@ -335,7 +338,11 @@ export class DesktopBridgeViewHost implements ElectronBrowserViewHost {
335
338
  // shell whose sidebar panel is an iframe, that is every call forever. Reported as "installing
336
339
  // the bridge made it worse than not installing it": the carrier was switched to one that
337
340
  // cannot work, with no path back to the one that does.
338
- if (String(answer.error).includes('produced no page')) this.reportLost()
341
+ //
342
+ // `panel-unusable`, not `shell-exited`: the shell is alive and answered this very request. The
343
+ // fallback's message used to announce an exiting shell unconditionally, which told the user
344
+ // their desktop had crashed when it was running normally.
345
+ if (String(answer.error).includes('produced no page')) this.reportLost('panel-unusable')
339
346
  throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`)
340
347
  }
341
348
  const id = Number(answer.id)
@@ -16,6 +16,7 @@ import z from '@deepseek-ai/schemastery'
16
16
  import type { BrowserRuntime } from '../browser/runtime.js'
17
17
  import { ElectronBrowserProvider } from './provider.js'
18
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
+ import type { CarrierLossReason } from '../browser/types.js'
19
20
  import { randomUUID } from 'node:crypto'
20
21
  import { existsSync, mkdirSync, renameSync } from 'node:fs'
21
22
  import { homedir } from 'node:os'
@@ -165,27 +166,50 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
165
166
  *
166
167
  * This is the half the startup retries never covered: a bridge that was alive at apply time and
167
168
  * exited later left every subsequent call dialling a port nobody owns, because the swap happened
168
- * once and only in one direction. Reaching here means the shell is gone, so the choice made at
169
- * startup is no longer a choice — the alternative to falling back is a browser surface that does
170
- * nothing until DSH is restarted.
169
+ * once and only in one direction. Reaching here means the carrier cannot serve anything more, so
170
+ * the choice made at startup is no longer a choice — the alternative to falling back is a browser
171
+ * surface that does nothing until DSH is restarted.
172
+ *
173
+ * Two failures arrive here and the message must distinguish them. The first draft announced an
174
+ * exiting shell unconditionally, so a fallback caused by an undrivable sidebar panel told the
175
+ * user their desktop shell had exited while it was running normally.
171
176
  * @param adopted - the carrier that reported the loss, disposed once its replacement is in place.
177
+ * @param reason - which failure this was; see {@link CarrierLossReason}.
172
178
  */
173
- const restoreSelfHosted = (adopted: ElectronBrowserViewHost): void => {
179
+ const restoreSelfHosted = (adopted: ElectronBrowserViewHost, reason: CarrierLossReason): void => {
174
180
  try {
175
181
  const restored = new RemoteElectronViewHost(defaultHostMainPath())
176
182
  unregister()
177
183
  unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(restored, providerConfig))
178
184
  ctx.logger?.warn?.(
179
- 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
180
- + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
181
- + 'open the page again.',
185
+ reason === 'shell-exited'
186
+ ? 'dsh-builtin-browser: the desktop shell backed the sidebar browser and has exited, so the '
187
+ + 'browser this plugin hosts itself takes over. Pages from the sidebar are gone with it; '
188
+ + 'open the page again.'
189
+ : 'dsh-builtin-browser: this desktop shell renders the sidebar browser panel in a way that '
190
+ + 'cannot be driven (no webContents, so no CDP endpoint), so the browser this plugin hosts '
191
+ + 'itself takes over. The shell is still running and nothing else is affected; the page '
192
+ + 'opens in this plugin\'s own window. Set browser.channel to another value to silence this.',
182
193
  )
183
194
  } catch (error) {
184
195
  // Nothing better is available: the adopted carrier is gone and a provider that throws is
185
196
  // worse than one that explains.
186
197
  ctx.logger?.warn?.(`dsh-builtin-browser: could not fall back to the self-hosted browser: ${String(error)}`)
187
198
  } finally {
188
- adopted.dispose?.()
199
+ // Disposed on a LATER turn of the event loop, deliberately.
200
+ //
201
+ // Disposing synchronously here closes the bridge socket while the request that triggered this
202
+ // fallback is still settling, and the close rejects every call still waiting on that socket —
203
+ // so the caller received "bridge closed" instead of the reason it actually failed for. The
204
+ // fallback is what the user is supposed to be able to understand; replacing its explanation
205
+ // with one about the connection loses exactly the information this path exists to convey.
206
+ setImmediate(() => {
207
+ try {
208
+ adopted.dispose?.()
209
+ } catch {
210
+ // A carrier that cannot be released is not the caller's problem; the swap already happened.
211
+ }
212
+ })
189
213
  }
190
214
  }
191
215
 
@@ -208,7 +232,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
208
232
  // A borrowed carrier can lose the process behind it, and this is the only notification that
209
233
  // will ever arrive. Registering AFTER the swap succeeded means a listener can never fire
210
234
  // against a provider that was never installed.
211
- host.onLost?.(() => { restoreSelfHosted(host) })
235
+ host.onLost?.(reason => { restoreSelfHosted(host, reason) })
212
236
  const previous = adoptedCarrier
213
237
  adoptedCarrier = host
214
238
  // Released AFTER the replacement is installed, so the surface is never without a provider.
@@ -51,7 +51,7 @@ import type {
51
51
  ExportedCookie,
52
52
  } from '../browser/types.js'
53
53
  import { BrowserError } from '../browser/types.js'
54
- import type { CarrierPage } from '../browser/types.js'
54
+ import type { CarrierPage, CarrierLossReason } from '../browser/types.js'
55
55
 
56
56
  /**
57
57
  * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
@@ -255,9 +255,14 @@ export interface ElectronBrowserViewHost {
255
255
  * startup, so the choice made then is final for the process's life — which is what a user
256
256
  * experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
257
257
  * owns its own lifetime has nothing to report.
258
+ *
259
+ * The listener receives WHY, because the fallback's message has to match what happened: a shell
260
+ * that exited and a shell whose panel cannot provide a page both reach the same fallback, and
261
+ * announcing "the shell has exited" for the second tells the user their desktop crashed when it
262
+ * did not.
258
263
  * @param listener - called once, when the carrier concludes it is gone.
259
264
  */
260
- onLost?(listener: () => void): void
265
+ onLost?(listener: (reason: CarrierLossReason) => void): void
261
266
  /**
262
267
  * Fold the carrier's presentation away while keeping the page alive.
263
268
  *
@@ -721,11 +721,37 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
721
721
  // The port file is deliberately NOT deleted here, and that is a reversal.
722
722
  //
723
723
  // It used to be removed so the launch loop below could not read a previous browser's port. But
724
- // the loop already refuses any file older than the spawn (`freshPort` compares mtimes), so the
725
- // deletion never protected it — while it DID destroy the only record of a browser that may
726
- // still be running for this profile. That record is what `adoptRunningBrowser` needs when the
727
- // launch comes back as a hand-off: the browser was alive the whole time, and without the file
728
- // there is no way to find it again.
724
+ // the loop judges freshness against a snapshot taken below, so the deletion never protected it
725
+ // — while it DID destroy the only record of a browser that may still be running for this
726
+ // profile. That record is what `adoptRunningBrowser` needs when the launch comes back as a
727
+ // hand-off: the browser was alive the whole time, and without the file there is no way to find
728
+ // it again.
729
+ const portFile = join(this.profileDir, 'DevToolsActivePort')
730
+ /**
731
+ * What the port file was like BEFORE this launch, so "fresh" is judged against a snapshot
732
+ * rather than against a clock.
733
+ *
734
+ * The test used to be `mtimeMs < spawnedAt` — a filesystem timestamp compared to `Date.now()`.
735
+ * Those are different clocks with different resolutions, and CI is where it showed: a file the
736
+ * fake browser had just written was rejected as stale, so the loop waited out its full 30s
737
+ * against a browser that was already listening, because the file's mtime had rounded down
738
+ * below the spawn time. That is not a test-only problem: on any filesystem whose mtime
739
+ * resolution is coarser than a millisecond, the plugin would fail to see the browser it had
740
+ * just launched, and report a CDP timeout for a browser that was up the whole time.
741
+ *
742
+ * `undefined` means there was no file, so any file that appears afterwards is fresh. When one
743
+ * existed, a later mtime OR different contents makes the new one fresh. Byte-identical contents
744
+ * with an unchanged mtime is the PREVIOUS browser's port, and adopting it is what this whole
745
+ * check exists to prevent: it may name a port that now belongs to something else entirely.
746
+ */
747
+ const portFileBefore = ((): { mtimeMs: number; contents: string } | undefined => {
748
+ try {
749
+ return { mtimeMs: statSync(portFile).mtimeMs, contents: readFileSync(portFile, 'utf8') }
750
+ } catch {
751
+ return undefined
752
+ }
753
+ })()
754
+ // Kept for the failure message and for the exit listener's diagnostics; no longer a judgement.
729
755
  const spawnedAt = Date.now()
730
756
  let child: ReturnType<typeof spawn>
731
757
  try {
@@ -759,20 +785,26 @@ export class SystemBrowserViewHost implements ElectronBrowserViewHost {
759
785
  // Clearing these is what makes the restart usable rather than merely alive.
760
786
  this.forgetBrowserState()
761
787
  })
762
- const portFile = join(this.profileDir, 'DevToolsActivePort')
763
788
  /**
764
789
  * The port this launch wrote, or undefined while it has not written one yet.
765
790
  *
766
- * A file left by the PREVIOUS browser is not evidence about this one. Its contents
767
- * name a port that may now belong to something else entirely — another Chromium, a
768
- * node --inspect — and connecting there publishes a client this host does not own and
769
- * dispose() can never kill. Only a file written after the spawn counts.
791
+ * A file left by the PREVIOUS browser is not evidence about this one. Its contents name a port
792
+ * that may now belong to something else entirely — another Chromium, a `node --inspect` — and
793
+ * connecting there publishes a client this host does not own and dispose() can never kill.
794
+ * Only a file that CHANGED across this launch counts, which is what `portFileBefore` records.
770
795
  */
771
796
  const freshPort = (): number | undefined => {
772
797
  try {
773
798
  if (!existsSync(portFile)) return undefined
774
- if (statSync(portFile).mtimeMs < spawnedAt) return undefined
775
- const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '')
799
+ const after = statSync(portFile)
800
+ const contents = readFileSync(portFile, 'utf8')
801
+ // Unchanged since before the launch: this is the previous browser's file, whatever its
802
+ // mtime says. Comparing against the snapshot rather than the spawn time is what keeps this
803
+ // correct on a filesystem whose timestamps are coarser than the clock behind spawnedAt.
804
+ if (portFileBefore !== undefined
805
+ && after.mtimeMs <= portFileBefore.mtimeMs
806
+ && contents === portFileBefore.contents) return undefined
807
+ const value = Number(contents.split('\n')[0]?.trim() ?? '')
776
808
  return Number.isInteger(value) && value > 0 ? value : undefined
777
809
  } catch {
778
810
  return undefined
@@ -375,7 +375,7 @@ export function apply(ctx: Context, config: Config = {}): void {
375
375
 
376
376
  registerSafely(defineTool({
377
377
  name: 'dsh_browser_open',
378
- description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot. A fresh session starts on a blank, uncommitted tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target website — it requires the authenticated URL printed by `dsh web`, and the bare root is refused outright.',
378
+ description: 'Open a URL in the shared browser window and return the resulting snapshot. Opens this task\'s session on first use; newTab is optional. A fresh session starts on a blank tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target — it needs the authenticated URL printed by `dsh web`, and the bare root is refused.',
379
379
  parameters: {
380
380
  url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
381
381
  newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
@@ -501,7 +501,7 @@ export function apply(ctx: Context, config: Config = {}): void {
501
501
 
502
502
  registerSafely(defineTool({
503
503
  name: 'dsh_browser_a11y',
504
- description: 'Read the page\'s accessibility tree: every interactive node with its semantic role (button/link/textbox/checkbox/…), accessible name, current value, and states (enabled/disabled/checked/expanded/…), (coordinates only when coords: true). Prefer this over dsh_browser_snapshot to understand a page\'s structure and find the right element: roles and names tell you WHAT each node is, and the name is what dsh_browser_click matches when you target by text. Penetrates same-origin iframes and shadow roots.',
504
+ description: 'Read the accessibility tree: every interactive node with its role (button/link/textbox/checkbox/…), accessible name, value, and states (enabled/checked/expanded/…); coordinates only with coords: true. Prefer this over dsh_browser_snapshot to understand structure and find the right element — roles and names say WHAT each node is, and the name is what dsh_browser_click matches when targeting by text. Penetrates same-origin iframes and shadow roots.',
505
505
  parameters: {
506
506
  includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
507
507
  maxNodes: { type: 'number', description: 'Maximum nodes (default 150, range 10-5000).' },
@@ -799,7 +799,7 @@ export function apply(ctx: Context, config: Config = {}): void {
799
799
 
800
800
  registerSafely(defineTool({
801
801
  name: 'dsh_browser_click',
802
- description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a dsh_browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
802
+ description: 'Click in the shared browser. Prefer target {by: css|text|xpath, value, index?}: the element is located from the DOM, scrolled into view, and clicked at its centre — no image input needed, and the only form that works without vision. Alternatively x/y viewport coordinates, meaningful only after a vision model located the element on a dsh_browser_screenshot; refused under the non-visual strategy. Provide exactly one of target or x/y.',
803
803
  parameters: {
804
804
  target: {
805
805
  type: 'object',
@@ -1018,7 +1018,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1018
1018
 
1019
1019
  registerSafely(defineTool({
1020
1020
  name: 'dsh_browser_fill',
1021
- description: 'Fill a form in one batch: pass fields with a CSS selector or name/label/placeholder text and the value to set (string, number, or boolean for checkbox/radio; for selects or radio groups pass the option value or visible text). Values are applied with the native setter plus input/change events, so React/Vue controlled inputs update correctly. Optionally submit the containing form. Prefer this over hand-written dsh_browser_execute for form filling; per-field failures are reported instead of throwing.',
1021
+ description: 'Fill a form in one batch: each field takes a CSS selector or name/label/placeholder text plus its value (boolean for checkbox/radio; option value or visible text for selects and radio groups). The native setter plus input/change events fire, so React/Vue controlled inputs update. Optionally submit the form. Prefer this over hand-written dsh_browser_execute; per-field failures are reported instead of throwing.',
1022
1022
  parameters: {
1023
1023
  fields: {
1024
1024
  type: 'array',
@@ -1315,7 +1315,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1315
1315
 
1316
1316
  registerSafely(defineTool({
1317
1317
  name: 'dsh_browser_screenshot',
1318
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — dsh_browser_snapshot, dsh_browser_a11y and dsh_browser_content carry the same page as text, and dsh_browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; some carriers (the desktop sidebar) return PNG instead. Downscaling works on every carrier.',
1318
+ description: 'Screenshot the shared-browser page (PNG default, JPEG optional) for models that can read images: layout, charts, CAPTCHAs, or locating an element before clicking its coordinates. Without image input it gains nothing — dsh_browser_snapshot/a11y/content carry the same page as text, dsh_browser_scrape extracts structured data. Supports fullPage, savePath, JPEG quality, and maxWidth/maxHeight downscaling to cut vision tokens. JPEG needs a carrier whose CDP encoder works (self-hosted, installed Chrome/Edge); the desktop sidebar returns PNG. Downscaling works everywhere.',
1319
1319
  parameters: {
1320
1320
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1321
1321
  savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
@@ -1764,7 +1764,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1764
1764
 
1765
1765
  registerSafely(defineTool({
1766
1766
  name: 'dsh_browser_restrict',
1767
- description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["dsh_browser_snapshot","dsh_browser_content","dsh_browser_click"]) — any other dsh_browser_* call is refused. Pass an empty list or omit to lift the restriction. Never blocked, whatever the list says: the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and the three that lift a restriction or recover from a bad state (restrict/reset/reset_session) — so a restriction can always be read around and undone. IMPORTANT: this is a SOFT guardrail against accidental actions, NOT a security boundary — you (the model) can lift it yourself with an empty list.',
1767
+ description: 'Allow only the listed browser tools; any other dsh_browser_* call is refused. Empty list or omitted lifts it. Never blocked whatever the list says — the observing tools (snapshot/a11y/content/scrape/screenshot/get_value/wait/challenge/list_tabs/session/history/visited) and restrict/reset/reset_session — so a restriction can always be read around and undone. A SOFT guardrail against stray clicks, NOT a security boundary: you can lift it with an empty list.',
1768
1768
  parameters: {
1769
1769
  allowed: {
1770
1770
  type: 'array',
@@ -1798,7 +1798,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1798
1798
 
1799
1799
  registerSafely(defineTool({
1800
1800
  name: 'dsh_browser_auth',
1801
- description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a private file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on all three carriers: the self-hosted browser reads its own session, while the desktop sidebar and an installed Chrome/Edge go through CDP. Refused entirely when the credential switch is off. Exported cookies are LIVE CREDENTIALS: treat them as secrets — do not echo them into the conversation, keep them out of logs, and store the list in a private file.',
1801
+ description: 'Export or restore the session\'s cookies (login state): "flush" returns a JSON cookie list, "restore" takes that list back (e.g. after the host restarted). Works on all carriers. Refused when the credential switch is off. Exported cookies are LIVE CREDENTIALS: never echo them into the conversation or logs; keep the list in a private file.',
1802
1802
  parameters: {
1803
1803
  action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
1804
1804
  cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },