dsh-builtin-browser 0.1.22 → 0.1.23

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/README.en.md CHANGED
@@ -310,8 +310,12 @@ Code layout:
310
310
  | 17 | 2026-09-16 | **Screenshot savePath confined + localized download dir** (issue #13): `browser_screenshot` wrote straight to `writeFileSync` — anywhere the process could reach, silently replacing existing files (bypassing the read-only sandbox's write protection) → one shared `admitSavePath` gate for downloads AND screenshots (absolute, inside `downloadDir`, never overwriting an existing file), plus parent-directory creation for screenshots; the default download directory is no longer hardcoded to `~/Downloads` but probed in order: `downloadDir` → `XDG_DOWNLOAD_DIR` → `~/Downloads` / `~/下载` / `~/下載` → fallback (a Chinese desktop needs no configuration) |
311
311
 
312
312
  | **0.1.22** | 2026-09-16 | **Release**: the macOS binary-probe fix (issues #9 / #14) and rounds 15–17 (issue #11 toolbar SyntaxError / #10 self-hosted trio / #13 screenshot savePath + download dir) ship as **0.1.22** (build clean, **35/35 tests pass**, tag `v0.1.22`) |
313
+ | Round 18 | 2026-09-20 | **Windows on-device trio + probe self-healing + locate verdicts** (measured on a real self-hosted `dsh web` host; defects ①–④ were all the "CDP answered success, the page received nothing" kind): ① Chromium's `CalculateNativeWinOcclusion` marks the plugin window HIDDEN while another window covers it — the page stops producing frames and **every** synthesized mouse/key event is dropped by the renderer (`CanReceiveInput()` false) while CDP replies `{}` → the child appends `disable-features=CalculateNativeWinOcclusion` before `app.whenReady()` (win32 only); ② `click()` had no leading `mouseMoved`, so the first click on a fresh view was routed away and lost → now move→press→release; ③ a fresh view holds no web focus, so the FIRST `browser_key` of a session vanished → new host `focus` op (optional `focus?()` on the view handle), `key()` focuses best-effort before dispatch and waits 80ms only when focus had to move (focus lands asynchronously; a key dispatched in the same turn is still dropped); ④ `available()` cached a FAILED Electron probe for the host's lifetime while provider selection runs once per process, so an Electron that arrived after DSH started was never adopted → successes stay cached, failures re-probe after a cooldown (`DSH_BROWSER_PROBE_RETRY_MS`, default 30s), and `resolveProvider()` now distinguishes "no provider registered" from "registered but reports itself unavailable" with the matching remedy; ⑤ **a failed locate was masked by the outer timeout** — the in-page locate script polls for its whole budget and answers only afterwards, while the outer wait used the SAME budget, so `browser: click timed out after 10000ms` won the race and the in-page verdict never got out; a css/xpath **parse error** also reported as "not found yet", polling a selector that can never become valid. Now a parse error is terminal and names itself (`invalid CSS selector "…" / invalid XPath …`), the outer wait gives the in-page answer 2 s of transport grace, and a miss reports the strategy the provider assumed (`by` defaults to `"by":"css"`) plus the time spent; `scrape`'s item selector fails the same way at once. Same call: before `click timed out after 10000ms`, after `element not found: {"value":"Learn more","by":"css"} (looked for 10000ms)`. 7 new regression tests (**47/47 pass**), 17/17 end-to-end steps against the real host, plus 4/4 locate-verdict steps |
313
314
 
314
- > The npm badge at the top is the authority on the registry's latest version: `0.1.22` is committed and tagged; if the badge still reads `0.1.21`, that version is not published yet.
315
+ | 19 | 2026-10-01 | **DSH 0.2 compatibility**: DSH moved to the 0.2 line (`@deepseek-ai/dsh@0.2.0-rc.2`, with `dsh-llm`/`dsh-tools`/`dsh-system-prompt` following to `0.2.0-rc.2`), while our declaration `>=0.1.1-rc.1 <0.2.0` shut 0.2 out → verified the plugin's (narrow) runtime dependency surface against a **real 0.2.0-rc.2 host** (`cordis` Context/Service, `dsh-tools` defineTool, `dsh-llm` HarnessError, `schemastery`) and found no breaking change: session, navigation, snapshot and the screenshot trio (outside-path refused / legal write / overwrite refused) all pass → peer ranges for the three dsh packages widened to `>=0.1.1-rc.2 <0.3.0`, `dsh.compatibility.dsh` widened to `>=0.1.1-rc.1 <0.3.0`, and `dshReleases` gained `0.2.0-rc.1`/`0.2.0-rc.2` = compatible |
316
+ | **0.1.23** | 2026-10-01 | **Release**: round 18 (PR #15: the Windows synthesized-input trio + Electron probe self-healing + locate verdicts) and round 19 (DSH 0.2 compatibility) ship as **0.1.23** (build clean, **47/47 tests pass**, tag `v0.1.23`) |
317
+
318
+ > The npm badge at the top is the authority on the registry's latest version: `0.1.23` is committed and tagged; if the badge still reads `0.1.22`, that version is not published yet.
315
319
 
316
320
  ## Acknowledgements
317
321
 
package/README.md CHANGED
@@ -310,8 +310,12 @@ npm run build
310
310
  | 第十七轮 | 2026-09-16 | **截图 savePath 收敛 + 下载目录本地化**(issue #13):`browser_screenshot` 原先直接 `writeFileSync`,可写进程可达的任意路径并**静默覆盖**已有文件(等于绕过只读沙箱的写保护)→ 抽出唯一下载/截图共用准入门 `admitSavePath`(绝对路径 + `downloadDir` 内 + **不覆盖已存在文件**),截图补父目录自动创建;默认下载目录不再写死 `~/Downloads`,按序探测 `downloadDir` → `XDG_DOWNLOAD_DIR` → `~/Downloads`/`~/下载`/`~/下載` → 回退(中文桌面免配置) |
311
311
 
312
312
  | **0.1.22** | 2026-09-16 | **发布**:macOS 二进制探测修复(issue #9 / #14)与第十五~十七轮(issue #11 工具栏 SyntaxError / #10 自托管三连 / #13 截图 savePath + 下载目录)随 **0.1.22** 收录(构建零错误、**35 项测试全绿**,tag `v0.1.22`) |
313
+ | 第十八轮 | 2026-09-20 | **Windows 真机三连 + 探测自愈 + 定位判词**(真实 `dsh web` 自托管宿主实测,缺陷①~④同为「CDP 报成功、页面没收到」):① Windows 的 `CalculateNativeWinOcclusion` 把被遮挡的插件窗口判定为 HIDDEN → 整站停帧,且每条合成鼠标/键盘事件被渲染端静默丢弃(CanReceiveInput=false)而 CDP 回 `{}` → 子进程 ready 前追加 `disable-features=CalculateNativeWinOcclusion`(仅 win32);② `click()` 缺前置 `mouseMoved`,新视图第一次点击落空 → 改为 move→press→release;③ 新视图从未持有 web focus,`browser_key` 第一次调用无效 → 宿主新增 `focus` op(view handle 可选 `focus?()`),`key()` 派发前 best-effort 聚焦,并在焦点需要移动时等 80ms(焦点落地异步,同轮派发的键仍会被丢);④ `available()` 把**失败**探测按宿主生命周期永久缓存,而 provider 选择每进程一次 → Electron 晚于 DSH 到位就永远接不上 → 成功仍缓存、失败 30s 冷却后重探(`DSH_BROWSER_PROBE_RETRY_MS`),`resolveProvider()` 报错区分「一个都没注册」与「注册了但自报不可用」并附处置;⑤ **定位失败被外层超时掩盖** —— 页内定位脚本会把预算轮询到底才回答,外层却用同一个预算,于是 `browser: click timed out after 10000ms` 抢走了页内早已写好的判词;而 css/xpath **解析失败**(选择器根本不合法)也被写成「还没找到」,对着永远不可能匹配的东西把预算轮完 → 解析失败即刻终止并报 `invalid CSS selector "…" / invalid XPath …`;外层给页内判词 2s 传输余量;未命中判词补上提供方实际采用的策略(`by` 缺省即 `"by":"css"`)与实际耗时;`scrape` 的 item 选择器同样即刻失败(同一个漏写 `by` 的调用:修前 `click timed out after 10000ms`,修后 `element not found: {"value":"Learn more","by":"css"} (looked for 10000ms)`);新增 7 条回归测试(**47/47 全绿**),真实宿主端到端 **17/17** + 定位判词 **4/4** |
313
314
 
314
- > registry 上的最新版本以顶部 npm 徽章为准:本次 `0.1.22` 已入库并打 tag;若徽章仍显示 `0.1.21`,说明该版本尚未 publish。
315
+ | 第十九轮 | 2026-10-01 | **DSH 0.2 兼容性**:DSH 进入 0.2 线(`@deepseek-ai/dsh@0.2.0-rc.2`,peer 包 `dsh-llm`/`dsh-tools`/`dsh-system-prompt` 同步到 `0.2.0-rc.2`),而原声明 `>=0.1.1-rc.1 <0.2.0` 把 0.2 挡在门外 → 在**真实 0.2.0-rc.2 宿主**上实测插件的运行时依赖面(`cordis` 的 Context/Service、`dsh-tools` 的 defineTool、`dsh-llm` 的 HarnessError、`schemastery`),确认无破坏性变更:会话、导航、快照、截图三态(越界拒绝/合法写入/覆盖拒绝)全部通过 → `peerDependencies` 三个 dsh 包范围改为 `>=0.1.1-rc.2 <0.3.0`,`dsh.compatibility.dsh` 放宽为 `>=0.1.1-rc.1 <0.3.0`,`dshReleases` 增加 `0.2.0-rc.1`/`0.2.0-rc.2` = compatible |
316
+ | **0.1.23** | 2026-10-01 | **发布**:第十八轮(PR #15:Windows 合成输入三连 + Electron 探测自愈 + 定位判词)与第十九轮(DSH 0.2 兼容)随 **0.1.23** 发布(构建零错误、**47 项测试全绿**,tag `v0.1.23`) |
317
+
318
+ > registry 上的最新版本以顶部 npm 徽章为准:本次 `0.1.23` 已入库并打 tag;若徽章仍显示 `0.1.22`,说明该版本尚未 publish。
315
319
 
316
320
  ## 特别感谢
317
321
 
@@ -70,7 +70,15 @@ export class BrowserRuntime extends Service {
70
70
  const usable = [...providers.values()].filter(provider => provider.available());
71
71
  const [single] = usable;
72
72
  if (single === undefined) {
73
- throw new BrowserError('no usable browser provider is registered', 'BROWSER_PROVIDER_UNAVAILABLE');
73
+ // Two very different causes used to read identically here — nothing is
74
+ // registered, or something is registered but cannot run — and only the
75
+ // second one has a fix the user can apply without reinstalling.
76
+ const registered = [...providers.values()].map(provider => provider.id);
77
+ if (registered.length === 0) {
78
+ throw new BrowserError('no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile', 'BROWSER_PROVIDER_UNAVAILABLE');
79
+ }
80
+ throw new BrowserError(`browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
81
+ + 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH', 'BROWSER_PROVIDER_UNAVAILABLE');
74
82
  }
75
83
  if (usable.length > 1) {
76
84
  const ids = usable.map(provider => provider.id).join(', ');
@@ -13,6 +13,7 @@
13
13
  * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
14
14
  * { id, op: 'groupView', viewId, windowId, label? } |
15
15
  * { id, op: 'command', viewId, method, params } |
16
+ * { id, op: 'focus', viewId } |
16
17
  * { id, op: 'userActionError', windowId, message }
17
18
  * -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
18
19
  * parent's stdin token; the parent refuses the connection otherwise)
@@ -13,6 +13,7 @@
13
13
  * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
14
14
  * { id, op: 'groupView', viewId, windowId, label? } |
15
15
  * { id, op: 'command', viewId, method, params } |
16
+ * { id, op: 'focus', viewId } |
16
17
  * { id, op: 'userActionError', windowId, message }
17
18
  * -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
18
19
  * parent's stdin token; the parent refuses the connection otherwise)
@@ -235,6 +236,13 @@ function withTimeout(promise, ms, label) {
235
236
  * only the tool layer's 10–30s timeout eventually cuts off.
236
237
  */
237
238
  const COMMAND_TIMEOUT_MS = 20000;
239
+ /**
240
+ * How long to let a `focus` request land before answering it. Focus moves
241
+ * asynchronously and a key dispatched in the same turn as the request is still
242
+ * dropped, so the caller needs this settle — but only on the call where focus
243
+ * actually had to move.
244
+ */
245
+ const FOCUS_SETTLE_MS = 80;
238
246
  /**
239
247
  * A brand-new WebContentsView has no renderer process until something loads,
240
248
  * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
@@ -725,6 +733,29 @@ async function handle(op, msg) {
725
733
  reply(msg.id, { ok: true, result });
726
734
  return;
727
735
  }
736
+ case 'focus': {
737
+ const viewId = msg.viewId;
738
+ if (viewId === undefined)
739
+ throw new Error('focus missing viewId');
740
+ const win = windowOfView(viewId);
741
+ const entry = win?.views.get(viewId);
742
+ if (win === undefined || entry === undefined)
743
+ throw new Error(`focus: unknown view ${viewId}`);
744
+ // Keys go to the view that holds web focus. A view that was never
745
+ // clicked holds none, so the FIRST browser_key of a session was dropped
746
+ // by the renderer while CDP still answered the dispatch with success —
747
+ // and `document.hasFocus()` inside the page even read true.
748
+ const contents = entry.webContentsView.webContents;
749
+ const hadWebFocus = contents.isFocused();
750
+ contents.focus();
751
+ // Focus lands asynchronously, and a key dispatched in the same turn as
752
+ // the request is still dropped, so hold the reply until the renderer can
753
+ // take input. Only on the call where focus actually had to move.
754
+ if (!hadWebFocus)
755
+ await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS));
756
+ reply(msg.id, { ok: true, focused: contents.isFocused() });
757
+ return;
758
+ }
728
759
  case 'capture': {
729
760
  const viewId = msg.viewId;
730
761
  if (viewId === undefined)
@@ -997,6 +1028,35 @@ async function handle(op, msg) {
997
1028
  reply(msg.id, { ok: false, err: String(error) });
998
1029
  }
999
1030
  }
1031
+ /**
1032
+ * Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
1033
+ * window is covered by another window and, when it is, moves the WebContents to
1034
+ * `Visibility::HIDDEN`. That is the wrong model for this host: the shared
1035
+ * browser window normally sits *behind* whatever the human is working in, and
1036
+ * that is exactly the state the agent drives it from. A hidden WebContents
1037
+ * produces no frames at all (`requestAnimationFrame` never fires) and
1038
+ * `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
1039
+ * `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
1040
+ * floor while the CDP call itself still answers `{}` — clicks and keys that
1041
+ * silently do nothing, with no error anywhere. Keep the window's visibility
1042
+ * state application-controlled instead; the parent already shows/raises views
1043
+ * explicitly, and `webContentsView.setVisible()` remains the single source of
1044
+ * truth for whether a view renders.
1045
+ */
1046
+ function keepWindowsNonOccluding() {
1047
+ if (process.platform !== 'win32')
1048
+ return;
1049
+ const DISABLED = 'CalculateNativeWinOcclusion';
1050
+ const current = app.commandLine.getSwitchValue('disable-features') ?? '';
1051
+ const features = current
1052
+ .split(',')
1053
+ .map(part => part.trim())
1054
+ .filter(part => part !== '');
1055
+ if (!features.includes(DISABLED))
1056
+ features.push(DISABLED);
1057
+ app.commandLine.appendSwitch('disable-features', features.join(','));
1058
+ }
1059
+ keepWindowsNonOccluding();
1000
1060
  /**
1001
1061
  * Electron entry: connect back to the parent's RPC server (port from
1002
1062
  * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
@@ -126,6 +126,12 @@ export interface ElectronViewHandle {
126
126
  * @returns the CDP `result` object.
127
127
  */
128
128
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
129
+ /**
130
+ * Give the backing view web focus so keyboard input reaches its page.
131
+ * Optional: an adapter that cannot focus a view omits it, and the provider
132
+ * then behaves exactly as before.
133
+ */
134
+ focus?(): Promise<void>;
129
135
  }
130
136
  /** Provider config: navigation admission defaults and snapshot caps. */
131
137
  export interface ElectronBrowserProviderConfig {
@@ -151,14 +157,16 @@ export interface CdpNavigateParams {
151
157
  readonly url: string;
152
158
  }
153
159
  /**
154
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
160
+ * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
161
+ * of a click's press+release pair. `button`/`clickCount` belong to the press and
162
+ * release halves; a move carries only the position.
155
163
  */
156
164
  export interface CdpMouseParams {
157
- readonly type: 'mousePressed' | 'mouseReleased';
165
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased';
158
166
  readonly x: number;
159
167
  readonly y: number;
160
- readonly button: 'left';
161
- readonly clickCount: number;
168
+ readonly button?: 'left';
169
+ readonly clickCount?: number;
162
170
  }
163
171
  /** CDP method/params for `Input.insertText`. */
164
172
  export interface CdpInsertTextParams {
@@ -278,6 +286,17 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
278
286
  * rather than reporting a success the page never saw.
279
287
  */
280
288
  private present;
289
+ /**
290
+ * Give the view web focus before synthesizing keyboard input.
291
+ *
292
+ * A renderer only delivers key events to the view that holds web focus. A
293
+ * view that was created but never clicked holds none — and `Input` events
294
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
295
+ * session vanished inside the renderer while the command still answered
296
+ * success. Hosts that cannot focus a view (a test double, a different shell
297
+ * adapter) simply have no `focus`, and nothing changes for them.
298
+ */
299
+ private focusView;
281
300
  /** Execute JS in the active tab's page context. */
282
301
  execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
283
302
  /**
@@ -342,6 +361,8 @@ export declare class ElectronBrowserProvider implements BrowserProvider {
342
361
  * (polling until it appears or the budget runs out) and then runs `body`
343
362
  * with `el` in scope. Shared by the target-based tools: click/type (②),
344
363
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
364
+ * A selector that fails to PARSE is reported immediately instead of being
365
+ * polled until the budget expires — see the comment in `match`.
345
366
  */
346
367
  private buildTargetScript;
347
368
  /**
@@ -419,6 +419,27 @@ export class ElectronBrowserProvider {
419
419
  throw new BrowserError(`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`, 'BROWSER_VIEW_NOT_PRESENTED', { cause: error });
420
420
  }
421
421
  }
422
+ /**
423
+ * Give the view web focus before synthesizing keyboard input.
424
+ *
425
+ * A renderer only delivers key events to the view that holds web focus. A
426
+ * view that was created but never clicked holds none — and `Input` events
427
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
428
+ * session vanished inside the renderer while the command still answered
429
+ * success. Hosts that cannot focus a view (a test double, a different shell
430
+ * adapter) simply have no `focus`, and nothing changes for them.
431
+ */
432
+ async focusView(handle) {
433
+ if (handle.focus === undefined)
434
+ return;
435
+ try {
436
+ await handle.focus();
437
+ }
438
+ catch {
439
+ // Best-effort: a view that refuses focus must not fail the caller's key
440
+ // press; the dispatch below reports a real channel failure itself.
441
+ }
442
+ }
422
443
  /** Execute JS in the active tab's page context. */
423
444
  async execute(session, request, signal) {
424
445
  const s = this.session(session);
@@ -915,24 +936,48 @@ export class ElectronBrowserProvider {
915
936
  // Input.* is only delivered to a view with a current display surface; the
916
937
  // barrier also re-presents after a navigation replaced the renderer.
917
938
  await this.present(s, signal);
918
- const press = (type) => handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
919
939
  const timeoutMs = 30_000;
940
+ const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
941
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
942
+ // hover target rather than hit-testing the coordinates, and a real pointer
943
+ // click is always preceded by movement. A view that has never received a
944
+ // mouse event has no hover target yet, so the press of the FIRST click on a
945
+ // fresh tab went nowhere while CDP still answered success; the second click
946
+ // landed because the first had quietly established the target. Move the
947
+ // pointer first so a click is self-contained.
920
948
  try {
921
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`);
949
+ await send({ type: 'mouseMoved', x, y }, 'move');
950
+ }
951
+ catch (error) {
952
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
953
+ }
954
+ const release = () => {
955
+ void handle
956
+ .sendCommand('Input.dispatchMouseEvent', {
957
+ type: 'mouseReleased',
958
+ x,
959
+ y,
960
+ button: 'left',
961
+ clickCount: 1,
962
+ })
963
+ .catch(() => { });
964
+ };
965
+ try {
966
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
922
967
  }
923
968
  catch (error) {
924
969
  // The press may still land late; release best-effort so the button is
925
970
  // never left in a stuck pressed state.
926
- void press('mouseReleased').catch(() => { });
971
+ release();
927
972
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
928
973
  }
929
974
  try {
930
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`);
975
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
931
976
  }
932
977
  catch (error) {
933
978
  // The press already landed; retry the release so the button is not
934
979
  // left pressed before surfacing the failure.
935
- void press('mouseReleased').catch(() => { });
980
+ release();
936
981
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
937
982
  }
938
983
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true);
@@ -1061,6 +1106,7 @@ export class ElectronBrowserProvider {
1061
1106
  const timeoutMs = 15_000;
1062
1107
  // Input.dispatchKeyEvent is subject to the same display-surface rule.
1063
1108
  await this.present(s, signal);
1109
+ await this.focusView(handle);
1064
1110
  const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
1065
1111
  try {
1066
1112
  await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
@@ -1231,10 +1277,16 @@ export class ElectronBrowserProvider {
1231
1277
  * (polling until it appears or the budget runs out) and then runs `body`
1232
1278
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1233
1279
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1280
+ * A selector that fails to PARSE is reported immediately instead of being
1281
+ * polled until the budget expires — see the comment in `match`.
1234
1282
  */
1235
1283
  buildTargetScript(spec, timeoutMs, body) {
1284
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1285
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1286
+ // plain label was queried as a CSS selector.
1287
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' };
1236
1288
  return `(async () => {
1237
- const spec = ${JSON.stringify(spec)}
1289
+ const spec = ${JSON.stringify(resolvedSpec)}
1238
1290
  const timeoutMs = ${String(timeoutMs)}
1239
1291
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1240
1292
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1246,7 +1298,13 @@ export class ElectronBrowserProvider {
1246
1298
  const index = typeof spec.index === 'number' ? spec.index : 0
1247
1299
  let els = []
1248
1300
  if (by === 'css') {
1249
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1301
+ // A selector that does not PARSE can never match on a later poll, so
1302
+ // it must not look like a miss: the old "return null" kept the caller
1303
+ // polling until the whole budget was gone, and "element not found
1304
+ // after 10s" reads like a slow page while the real cause is a
1305
+ // malformed selector (a label or plain text passed where one belongs).
1306
+ try { els = Array.from(document.querySelectorAll(value)) }
1307
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1250
1308
  } else if (by === 'xpath') {
1251
1309
  try {
1252
1310
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1254,7 +1312,7 @@ export class ElectronBrowserProvider {
1254
1312
  const n = snap.snapshotItem(i)
1255
1313
  if (n instanceof Element) els.push(n)
1256
1314
  }
1257
- } catch { return null }
1315
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1258
1316
  } else {
1259
1317
  const all = Array.from(document.querySelectorAll('body *'))
1260
1318
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1268,9 +1326,10 @@ export class ElectronBrowserProvider {
1268
1326
  const deadline = Date.now() + timeoutMs
1269
1327
  let el = null
1270
1328
  for (;;) {
1271
- el = match()
1329
+ // Only a parse error escapes the poll: see the comment in match() above.
1330
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1272
1331
  if (el !== null) break
1273
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1332
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1274
1333
  await sleep(100)
1275
1334
  }
1276
1335
  ${body}
@@ -1282,7 +1341,12 @@ export class ElectronBrowserProvider {
1282
1341
  * evaluation failure or an in-page `{ ok: false, error }` verdict.
1283
1342
  */
1284
1343
  async runTargetScript(tab, script, timeoutMs, signal, errorCode, errorLabel) {
1285
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1344
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1345
+ // outer budget of the same size fires at the same instant and wins: the
1346
+ // caller saw `click timed out after 10000ms` while the page had already
1347
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1348
+ // get back before the generic timeout replaces it.
1349
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1286
1350
  if (!result.ok)
1287
1351
  throw new BrowserError(`${errorLabel} failed: ${result.exception}`, errorCode);
1288
1352
  const value = result.value;
@@ -1474,7 +1538,9 @@ export class ElectronBrowserProvider {
1474
1538
  const deadline = Date.now() + timeoutMs
1475
1539
  let items = []
1476
1540
  for (;;) {
1477
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1541
+ // Immediate, like match() above: an unparseable selector can never match.
1542
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1543
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1478
1544
  if (items.length > 0) break
1479
1545
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1480
1546
  await sleep(100)
@@ -1913,6 +1979,12 @@ const SETTLE_TIMEOUT_MS = 5_000;
1913
1979
  const SETTLE_GRACE_MS = 150;
1914
1980
  /** Poll interval for the document-settle loop. */
1915
1981
  const SETTLE_POLL_MS = 25;
1982
+ /**
1983
+ * Extra time the transport gets over an in-page locate script's own budget: the
1984
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
1985
+ * must outlast it or its generic timeout hides the in-page reason.
1986
+ */
1987
+ const TARGET_SCRIPT_GRACE_MS = 2_000;
1916
1988
  /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
1917
1989
  function stripTabPrefix(id) {
1918
1990
  return id.startsWith('tab:') ? id.slice(4) : id;
@@ -99,14 +99,20 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
99
99
  /** Test seam: the executable to spawn instead of the resolved Electron
100
100
  * binary. Absent -> resolveElectronPath() (production behavior). */
101
101
  private readonly spawnExecutable?;
102
+ /** Test seam: the binary probe behind `available()`. Absent ->
103
+ * resolveElectronPath(). */
104
+ private readonly probe;
102
105
  private client;
103
106
  private server;
104
107
  private pendingSocket;
105
108
  private readonly views;
106
109
  private readyPromise;
107
110
  private disposed;
108
- /** Cached probe result so `available()` stays cheap after the first call. */
111
+ /** Last probe result. A success is kept for the host's lifetime; a failure
112
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
109
113
  private electronAvailable;
114
+ /** Earliest time the next probe may run after a negative one. */
115
+ private nextProbeAt;
110
116
  /** Window groups (windowId per view), re-sent on every materialization so
111
117
  * a restarted child still places views in the right windows. */
112
118
  private readonly groups;
@@ -115,13 +121,22 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
115
121
  constructor(hostMainPath: string,
116
122
  /** Test seam: the executable to spawn instead of the resolved Electron
117
123
  * binary. Absent -> resolveElectronPath() (production behavior). */
118
- spawnExecutable?: string | undefined);
124
+ spawnExecutable?: string | undefined,
125
+ /** Test seam: the binary probe behind `available()`. Absent ->
126
+ * resolveElectronPath(). */
127
+ probe?: () => void);
119
128
  /**
120
129
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
121
- * is filesystem-only (no network), per the seam's contract, and the result
122
- * is cached for the host's lifetime — a missing binary surfaces as
123
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
124
- * confusing spawn failure on first use.
130
+ * is filesystem-only (no network), per the seam's contract, so a missing
131
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
132
+ * instead of a confusing spawn failure on first use.
133
+ *
134
+ * A success is cached for the host's lifetime. A failure is NOT: an install
135
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
136
+ * long after DSH started, and provider selection happens once per process —
137
+ * caching "no" permanently reported "no usable browser provider is
138
+ * registered" until DSH restarted, with no way back short of that. Retrying
139
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
125
140
  */
126
141
  available(): boolean;
127
142
  /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
@@ -624,6 +624,10 @@ class RemoteView {
624
624
  params: params ?? {},
625
625
  });
626
626
  }
627
+ /** Give this view web focus in the child, so keyboard input reaches the page. */
628
+ focus() {
629
+ return this.client.call('focus', { viewId: this.id });
630
+ }
627
631
  /** Ask the child to download a URL to a local file (keeps cookies/login). */
628
632
  async download(url, savePath) {
629
633
  // The child writes the file itself (temp + rename); only a small
@@ -644,6 +648,14 @@ class RemoteView {
644
648
  return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
645
649
  }
646
650
  }
651
+ /**
652
+ * How long a FAILED Electron-binary probe is remembered before the binary
653
+ * search runs again (`available()`). A success is kept for the host's lifetime.
654
+ */
655
+ const PROBE_RETRY_MS = (() => {
656
+ const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS);
657
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30000;
658
+ })();
647
659
  /**
648
660
  * Self-hosted view host: spawns the plugin's Electron child on first use and
649
661
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -652,14 +664,18 @@ class RemoteView {
652
664
  export class RemoteElectronViewHost {
653
665
  hostMainPath;
654
666
  spawnExecutable;
667
+ probe;
655
668
  client;
656
669
  server;
657
670
  pendingSocket;
658
671
  views = new Map();
659
672
  readyPromise;
660
673
  disposed = false;
661
- /** Cached probe result so `available()` stays cheap after the first call. */
674
+ /** Last probe result. A success is kept for the host's lifetime; a failure
675
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
662
676
  electronAvailable;
677
+ /** Earliest time the next probe may run after a negative one. */
678
+ nextProbeAt = 0;
663
679
  /** Window groups (windowId per view), re-sent on every materialization so
664
680
  * a restarted child still places views in the right windows. */
665
681
  groups = new Map();
@@ -668,30 +684,43 @@ export class RemoteElectronViewHost {
668
684
  constructor(hostMainPath,
669
685
  /** Test seam: the executable to spawn instead of the resolved Electron
670
686
  * binary. Absent -> resolveElectronPath() (production behavior). */
671
- spawnExecutable) {
687
+ spawnExecutable,
688
+ /** Test seam: the binary probe behind `available()`. Absent ->
689
+ * resolveElectronPath(). */
690
+ probe = resolveElectronPath) {
672
691
  this.hostMainPath = hostMainPath;
673
692
  this.spawnExecutable = spawnExecutable;
693
+ this.probe = probe;
674
694
  }
675
695
  /**
676
696
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
677
- * is filesystem-only (no network), per the seam's contract, and the result
678
- * is cached for the host's lifetime — a missing binary surfaces as
679
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
680
- * confusing spawn failure on first use.
697
+ * is filesystem-only (no network), per the seam's contract, so a missing
698
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
699
+ * instead of a confusing spawn failure on first use.
700
+ *
701
+ * A success is cached for the host's lifetime. A failure is NOT: an install
702
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
703
+ * long after DSH started, and provider selection happens once per process —
704
+ * caching "no" permanently reported "no usable browser provider is
705
+ * registered" until DSH restarted, with no way back short of that. Retrying
706
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
681
707
  */
682
708
  available() {
683
- if (this.electronAvailable === undefined) {
684
- try {
685
- resolveElectronPath();
686
- this.electronAvailable = true;
687
- }
688
- catch (error) {
689
- this.electronAvailable = false;
690
- // The provider will be reported unavailable, so the detailed
691
- // resolution error (install-electron / ELECTRON_PATH guidance) would
692
- // otherwise never reach the user — surface it on stderr.
693
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
694
- }
709
+ if (this.electronAvailable === true)
710
+ return true;
711
+ if (Date.now() < this.nextProbeAt)
712
+ return false;
713
+ this.nextProbeAt = Date.now() + PROBE_RETRY_MS;
714
+ try {
715
+ this.probe();
716
+ this.electronAvailable = true;
717
+ }
718
+ catch (error) {
719
+ this.electronAvailable = false;
720
+ // The provider will be reported unavailable, so the detailed
721
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
722
+ // otherwise never reach the user — surface it on stderr.
723
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
695
724
  }
696
725
  return this.electronAvailable;
697
726
  }
@@ -978,6 +1007,9 @@ class DeferredRemoteView {
978
1007
  async capture(opts) {
979
1008
  return this.withRecovery(view => view.capture(opts));
980
1009
  }
1010
+ async focus() {
1011
+ return this.withRecovery(view => view.focus());
1012
+ }
981
1013
  async flushAuth() {
982
1014
  return this.withRecovery(view => view.flushAuth());
983
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
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 (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -65,9 +65,9 @@
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@deepseek-ai/cordis": "^4.0.1",
68
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
69
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
70
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0",
69
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.3.0",
70
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 <0.3.0",
71
71
  "@deepseek-ai/schemastery": "^3.18.1"
72
72
  },
73
73
  "devDependencies": {
@@ -79,7 +79,7 @@
79
79
  "patch": "./cordis.patch.yml"
80
80
  },
81
81
  "compatibility": {
82
- "dsh": ">=0.1.1-rc.1 <0.2.0",
82
+ "dsh": ">=0.1.1-rc.1 <0.3.0",
83
83
  "profiles": [
84
84
  "web",
85
85
  "desktop"
@@ -87,7 +87,9 @@
87
87
  "dshReleases": {
88
88
  "0.1.0-rc.8": "unknown",
89
89
  "0.1.1-rc.1": "compatible",
90
- "0.1.1-rc.2": "compatible"
90
+ "0.1.1-rc.2": "compatible",
91
+ "0.2.0-rc.1": "compatible",
92
+ "0.2.0-rc.2": "compatible"
91
93
  }
92
94
  }
93
95
  }
@@ -177,7 +177,21 @@ export class BrowserRuntime extends Service {
177
177
  const usable = [...providers.values()].filter(provider => provider.available())
178
178
  const [single] = usable
179
179
  if (single === undefined) {
180
- throw new BrowserError('no usable browser provider is registered', 'BROWSER_PROVIDER_UNAVAILABLE')
180
+ // Two very different causes used to read identically here — nothing is
181
+ // registered, or something is registered but cannot run — and only the
182
+ // second one has a fix the user can apply without reinstalling.
183
+ const registered = [...providers.values()].map(provider => provider.id)
184
+ if (registered.length === 0) {
185
+ throw new BrowserError(
186
+ 'no browser provider is registered — install a browser plugin (e.g. dsh-builtin-browser) and enable it in this profile',
187
+ 'BROWSER_PROVIDER_UNAVAILABLE',
188
+ )
189
+ }
190
+ throw new BrowserError(
191
+ `browser provider(s) ${registered.join(', ')} are registered but report themselves unavailable — `
192
+ + 'for dsh-builtin-browser this means no spawnable Electron binary was found; run `npx install-electron` in the active profile or set ELECTRON_PATH',
193
+ 'BROWSER_PROVIDER_UNAVAILABLE',
194
+ )
181
195
  }
182
196
  if (usable.length > 1) {
183
197
  const ids = usable.map(provider => provider.id).join(', ')
@@ -13,6 +13,7 @@
13
13
  * { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
14
14
  * { id, op: 'groupView', viewId, windowId, label? } |
15
15
  * { id, op: 'command', viewId, method, params } |
16
+ * { id, op: 'focus', viewId } |
16
17
  * { id, op: 'userActionError', windowId, message }
17
18
  * -> { id: 0, op: 'hello', token } (our FIRST message — proves we know the
18
19
  * parent's stdin token; the parent refuses the connection otherwise)
@@ -286,6 +287,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
286
287
  */
287
288
  const COMMAND_TIMEOUT_MS = 20000
288
289
 
290
+ /**
291
+ * How long to let a `focus` request land before answering it. Focus moves
292
+ * asynchronously and a key dispatched in the same turn as the request is still
293
+ * dropped, so the caller needs this settle — but only on the call where focus
294
+ * actually had to move.
295
+ */
296
+ const FOCUS_SETTLE_MS = 80
297
+
289
298
  /**
290
299
  * A brand-new WebContentsView has no renderer process until something loads,
291
300
  * and CDP commands (Runtime.evaluate …) sent to a renderer-less view never
@@ -714,6 +723,26 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
714
723
  reply(msg.id, { ok: true, result })
715
724
  return
716
725
  }
726
+ case 'focus': {
727
+ const viewId = msg.viewId
728
+ if (viewId === undefined) throw new Error('focus missing viewId')
729
+ const win = windowOfView(viewId)
730
+ const entry = win?.views.get(viewId)
731
+ if (win === undefined || entry === undefined) throw new Error(`focus: unknown view ${viewId}`)
732
+ // Keys go to the view that holds web focus. A view that was never
733
+ // clicked holds none, so the FIRST browser_key of a session was dropped
734
+ // by the renderer while CDP still answered the dispatch with success —
735
+ // and `document.hasFocus()` inside the page even read true.
736
+ const contents = entry.webContentsView.webContents
737
+ const hadWebFocus = contents.isFocused()
738
+ contents.focus()
739
+ // Focus lands asynchronously, and a key dispatched in the same turn as
740
+ // the request is still dropped, so hold the reply until the renderer can
741
+ // take input. Only on the call where focus actually had to move.
742
+ if (!hadWebFocus) await new Promise(resolve => setTimeout(resolve, FOCUS_SETTLE_MS))
743
+ reply(msg.id, { ok: true, focused: contents.isFocused() })
744
+ return
745
+ }
717
746
  case 'capture': {
718
747
  const viewId = msg.viewId
719
748
  if (viewId === undefined) throw new Error('capture missing viewId')
@@ -942,6 +971,34 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
942
971
  }
943
972
  }
944
973
 
974
+ /**
975
+ * Chromium's native-window occlusion tracker (Windows only) asks DWM whether a
976
+ * window is covered by another window and, when it is, moves the WebContents to
977
+ * `Visibility::HIDDEN`. That is the wrong model for this host: the shared
978
+ * browser window normally sits *behind* whatever the human is working in, and
979
+ * that is exactly the state the agent drives it from. A hidden WebContents
980
+ * produces no frames at all (`requestAnimationFrame` never fires) and
981
+ * `RenderWidgetHostImpl::CanReceiveInput()` returns false, so every
982
+ * `Input.dispatchMouseEvent`/`dispatchKeyEvent` sent over CDP is dropped on the
983
+ * floor while the CDP call itself still answers `{}` — clicks and keys that
984
+ * silently do nothing, with no error anywhere. Keep the window's visibility
985
+ * state application-controlled instead; the parent already shows/raises views
986
+ * explicitly, and `webContentsView.setVisible()` remains the single source of
987
+ * truth for whether a view renders.
988
+ */
989
+ function keepWindowsNonOccluding(): void {
990
+ if (process.platform !== 'win32') return
991
+ const DISABLED = 'CalculateNativeWinOcclusion'
992
+ const current = app.commandLine.getSwitchValue('disable-features') ?? ''
993
+ const features = current
994
+ .split(',')
995
+ .map(part => part.trim())
996
+ .filter(part => part !== '')
997
+ if (!features.includes(DISABLED)) features.push(DISABLED)
998
+ app.commandLine.appendSwitch('disable-features', features.join(','))
999
+ }
1000
+ keepWindowsNonOccluding()
1001
+
945
1002
  /**
946
1003
  * Electron entry: connect back to the parent's RPC server (port from
947
1004
  * `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
@@ -207,6 +207,12 @@ export interface ElectronViewHandle {
207
207
  * @returns the CDP `result` object.
208
208
  */
209
209
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
210
+ /**
211
+ * Give the backing view web focus so keyboard input reaches its page.
212
+ * Optional: an adapter that cannot focus a view omits it, and the provider
213
+ * then behaves exactly as before.
214
+ */
215
+ focus?(): Promise<void>
210
216
  }
211
217
 
212
218
  /** One tab inside a session: its view plus a stable id. */
@@ -254,14 +260,16 @@ export interface CdpNavigateParams {
254
260
  }
255
261
 
256
262
  /**
257
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
263
+ * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
264
+ * of a click's press+release pair. `button`/`clickCount` belong to the press and
265
+ * release halves; a move carries only the position.
258
266
  */
259
267
  export interface CdpMouseParams {
260
- readonly type: 'mousePressed' | 'mouseReleased'
268
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
261
269
  readonly x: number
262
270
  readonly y: number
263
- readonly button: 'left'
264
- readonly clickCount: number
271
+ readonly button?: 'left'
272
+ readonly clickCount?: number
265
273
  }
266
274
 
267
275
  /** CDP method/params for `Input.insertText`. */
@@ -664,6 +672,26 @@ export class ElectronBrowserProvider implements BrowserProvider {
664
672
  }
665
673
  }
666
674
 
675
+ /**
676
+ * Give the view web focus before synthesizing keyboard input.
677
+ *
678
+ * A renderer only delivers key events to the view that holds web focus. A
679
+ * view that was created but never clicked holds none — and `Input` events
680
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
681
+ * session vanished inside the renderer while the command still answered
682
+ * success. Hosts that cannot focus a view (a test double, a different shell
683
+ * adapter) simply have no `focus`, and nothing changes for them.
684
+ */
685
+ private async focusView(handle: ElectronViewHandle): Promise<void> {
686
+ if (handle.focus === undefined) return
687
+ try {
688
+ await handle.focus()
689
+ } catch {
690
+ // Best-effort: a view that refuses focus must not fail the caller's key
691
+ // press; the dispatch below reports a real channel failure itself.
692
+ }
693
+ }
694
+
667
695
  /** Execute JS in the active tab's page context. */
668
696
  async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
669
697
  const s = this.session(session)
@@ -1196,23 +1224,51 @@ export class ElectronBrowserProvider implements BrowserProvider {
1196
1224
  // Input.* is only delivered to a view with a current display surface; the
1197
1225
  // barrier also re-presents after a navigation replaced the renderer.
1198
1226
  await this.present(s, signal)
1199
- const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
1200
- handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
1201
1227
  const timeoutMs = 30_000
1228
+ const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
1229
+ withTimeout(
1230
+ handle.sendCommand('Input.dispatchMouseEvent', params),
1231
+ timeoutMs,
1232
+ signal,
1233
+ `browser: click ${label} timed out after ${timeoutMs}ms`,
1234
+ )
1235
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
1236
+ // hover target rather than hit-testing the coordinates, and a real pointer
1237
+ // click is always preceded by movement. A view that has never received a
1238
+ // mouse event has no hover target yet, so the press of the FIRST click on a
1239
+ // fresh tab went nowhere while CDP still answered success; the second click
1240
+ // landed because the first had quietly established the target. Move the
1241
+ // pointer first so a click is self-contained.
1202
1242
  try {
1203
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`)
1243
+ await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
1244
+ } catch (error) {
1245
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1246
+ }
1247
+ const release = (): void => {
1248
+ void handle
1249
+ .sendCommand('Input.dispatchMouseEvent', {
1250
+ type: 'mouseReleased',
1251
+ x,
1252
+ y,
1253
+ button: 'left',
1254
+ clickCount: 1,
1255
+ } satisfies CdpMouseParams)
1256
+ .catch(() => {})
1257
+ }
1258
+ try {
1259
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
1204
1260
  } catch (error) {
1205
1261
  // The press may still land late; release best-effort so the button is
1206
1262
  // never left in a stuck pressed state.
1207
- void press('mouseReleased').catch(() => {})
1263
+ release()
1208
1264
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1209
1265
  }
1210
1266
  try {
1211
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`)
1267
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
1212
1268
  } catch (error) {
1213
1269
  // The press already landed; retry the release so the button is not
1214
1270
  // left pressed before surfacing the failure.
1215
- void press('mouseReleased').catch(() => {})
1271
+ release()
1216
1272
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1217
1273
  }
1218
1274
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
@@ -1375,6 +1431,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1375
1431
  const timeoutMs = 15_000
1376
1432
  // Input.dispatchKeyEvent is subject to the same display-surface rule.
1377
1433
  await this.present(s, signal)
1434
+ await this.focusView(handle)
1378
1435
  const release = (): Promise<Record<string, unknown>> =>
1379
1436
  handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
1380
1437
  try {
@@ -1552,10 +1609,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
1552
1609
  * (polling until it appears or the budget runs out) and then runs `body`
1553
1610
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1554
1611
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1612
+ * A selector that fails to PARSE is reported immediately instead of being
1613
+ * polled until the budget expires — see the comment in `match`.
1555
1614
  */
1556
1615
  private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
1616
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1617
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1618
+ // plain label was queried as a CSS selector.
1619
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
1557
1620
  return `(async () => {
1558
- const spec = ${JSON.stringify(spec)}
1621
+ const spec = ${JSON.stringify(resolvedSpec)}
1559
1622
  const timeoutMs = ${String(timeoutMs)}
1560
1623
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1561
1624
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1567,7 +1630,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1567
1630
  const index = typeof spec.index === 'number' ? spec.index : 0
1568
1631
  let els = []
1569
1632
  if (by === 'css') {
1570
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1633
+ // A selector that does not PARSE can never match on a later poll, so
1634
+ // it must not look like a miss: the old "return null" kept the caller
1635
+ // polling until the whole budget was gone, and "element not found
1636
+ // after 10s" reads like a slow page while the real cause is a
1637
+ // malformed selector (a label or plain text passed where one belongs).
1638
+ try { els = Array.from(document.querySelectorAll(value)) }
1639
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1571
1640
  } else if (by === 'xpath') {
1572
1641
  try {
1573
1642
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1575,7 +1644,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1575
1644
  const n = snap.snapshotItem(i)
1576
1645
  if (n instanceof Element) els.push(n)
1577
1646
  }
1578
- } catch { return null }
1647
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1579
1648
  } else {
1580
1649
  const all = Array.from(document.querySelectorAll('body *'))
1581
1650
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1589,9 +1658,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1589
1658
  const deadline = Date.now() + timeoutMs
1590
1659
  let el = null
1591
1660
  for (;;) {
1592
- el = match()
1661
+ // Only a parse error escapes the poll: see the comment in match() above.
1662
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1593
1663
  if (el !== null) break
1594
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1664
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1595
1665
  await sleep(100)
1596
1666
  }
1597
1667
  ${body}
@@ -1611,9 +1681,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
1611
1681
  errorCode: string,
1612
1682
  errorLabel: string,
1613
1683
  ): Promise<Record<string, unknown>> {
1684
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1685
+ // outer budget of the same size fires at the same instant and wins: the
1686
+ // caller saw `click timed out after 10000ms` while the page had already
1687
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1688
+ // get back before the generic timeout replaces it.
1614
1689
  const result = await withTimeout(
1615
1690
  handleSendEvaluate(tab.handle, script),
1616
- timeoutMs,
1691
+ timeoutMs + TARGET_SCRIPT_GRACE_MS,
1617
1692
  signal,
1618
1693
  `${errorLabel} timed out after ${timeoutMs}ms`,
1619
1694
  () => terminatePage(tab.handle),
@@ -1813,7 +1888,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1813
1888
  const deadline = Date.now() + timeoutMs
1814
1889
  let items = []
1815
1890
  for (;;) {
1816
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1891
+ // Immediate, like match() above: an unparseable selector can never match.
1892
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1893
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1817
1894
  if (items.length > 0) break
1818
1895
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1819
1896
  await sleep(100)
@@ -2295,6 +2372,13 @@ const SETTLE_GRACE_MS = 150
2295
2372
  /** Poll interval for the document-settle loop. */
2296
2373
  const SETTLE_POLL_MS = 25
2297
2374
 
2375
+ /**
2376
+ * Extra time the transport gets over an in-page locate script's own budget: the
2377
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
2378
+ * must outlast it or its generic timeout hides the in-page reason.
2379
+ */
2380
+ const TARGET_SCRIPT_GRACE_MS = 2_000
2381
+
2298
2382
  /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
2299
2383
  function stripTabPrefix(id: string): string {
2300
2384
  return id.startsWith('tab:') ? id.slice(4) : id
@@ -657,6 +657,11 @@ class RemoteView implements ElectronViewHandle {
657
657
  })
658
658
  }
659
659
 
660
+ /** Give this view web focus in the child, so keyboard input reaches the page. */
661
+ focus(): Promise<void> {
662
+ return this.client.call<void>('focus', { viewId: this.id })
663
+ }
664
+
660
665
  /** Ask the child to download a URL to a local file (keeps cookies/login). */
661
666
  async download(url: string, savePath: string): Promise<void> {
662
667
  // The child writes the file itself (temp + rename); only a small
@@ -681,6 +686,15 @@ class RemoteView implements ElectronViewHandle {
681
686
  }
682
687
  }
683
688
 
689
+ /**
690
+ * How long a FAILED Electron-binary probe is remembered before the binary
691
+ * search runs again (`available()`). A success is kept for the host's lifetime.
692
+ */
693
+ const PROBE_RETRY_MS = (() => {
694
+ const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS)
695
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30000
696
+ })()
697
+
684
698
  /**
685
699
  * Self-hosted view host: spawns the plugin's Electron child on first use and
686
700
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -693,8 +707,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
693
707
  private readonly views = new Map<string, ElectronViewHandle>()
694
708
  private readyPromise: Promise<void> | undefined
695
709
  private disposed = false
696
- /** Cached probe result so `available()` stays cheap after the first call. */
710
+ /** Last probe result. A success is kept for the host's lifetime; a failure
711
+ * is retried after `PROBE_RETRY_MS` (see `available()`). */
697
712
  private electronAvailable: boolean | undefined
713
+ /** Earliest time the next probe may run after a negative one. */
714
+ private nextProbeAt = 0
698
715
  /** Window groups (windowId per view), re-sent on every materialization so
699
716
  * a restarted child still places views in the right windows. */
700
717
  private readonly groups = new Map<string, { windowId: string; label?: string }>()
@@ -706,27 +723,37 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
706
723
  /** Test seam: the executable to spawn instead of the resolved Electron
707
724
  * binary. Absent -> resolveElectronPath() (production behavior). */
708
725
  private readonly spawnExecutable?: string,
726
+ /** Test seam: the binary probe behind `available()`. Absent ->
727
+ * resolveElectronPath(). */
728
+ private readonly probe: () => void = resolveElectronPath,
709
729
  ) {}
710
730
 
711
731
  /**
712
732
  * Cheap usability probe: can we find an Electron binary to spawn? The scan
713
- * is filesystem-only (no network), per the seam's contract, and the result
714
- * is cached for the host's lifetime — a missing binary surfaces as
715
- * `BROWSER_PROVIDER_UNAVAILABLE` at provider selection instead of a
716
- * confusing spawn failure on first use.
733
+ * is filesystem-only (no network), per the seam's contract, so a missing
734
+ * binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
735
+ * instead of a confusing spawn failure on first use.
736
+ *
737
+ * A success is cached for the host's lifetime. A failure is NOT: an install
738
+ * (`npm i electron`, or the postinstall that fetches the binary) can land
739
+ * long after DSH started, and provider selection happens once per process —
740
+ * caching "no" permanently reported "no usable browser provider is
741
+ * registered" until DSH restarted, with no way back short of that. Retrying
742
+ * on a cooldown heals it by itself while keeping the scan off the hot path.
717
743
  */
718
744
  available(): boolean {
719
- if (this.electronAvailable === undefined) {
720
- try {
721
- resolveElectronPath()
722
- this.electronAvailable = true
723
- } catch (error) {
724
- this.electronAvailable = false
725
- // The provider will be reported unavailable, so the detailed
726
- // resolution error (install-electron / ELECTRON_PATH guidance) would
727
- // otherwise never reach the user — surface it on stderr.
728
- process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
729
- }
745
+ if (this.electronAvailable === true) return true
746
+ if (Date.now() < this.nextProbeAt) return false
747
+ this.nextProbeAt = Date.now() + PROBE_RETRY_MS
748
+ try {
749
+ this.probe()
750
+ this.electronAvailable = true
751
+ } catch (error) {
752
+ this.electronAvailable = false
753
+ // The provider will be reported unavailable, so the detailed
754
+ // resolution error (install-electron / ELECTRON_PATH guidance) would
755
+ // otherwise never reach the user — surface it on stderr.
756
+ process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
730
757
  }
731
758
  return this.electronAvailable
732
759
  }
@@ -1027,6 +1054,10 @@ class DeferredRemoteView implements ElectronViewHandle {
1027
1054
  return this.withRecovery(view => view.capture(opts))
1028
1055
  }
1029
1056
 
1057
+ async focus(): Promise<void> {
1058
+ return this.withRecovery(view => view.focus())
1059
+ }
1060
+
1030
1061
  async flushAuth(): Promise<ExportedCookie[]> {
1031
1062
  return this.withRecovery(view => view.flushAuth())
1032
1063
  }