dsh-builtin-browser 0.1.5 → 0.1.6

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.
@@ -64,6 +64,8 @@ export interface BrowserExecuteRequest {
64
64
  readonly script: string;
65
65
  /** Optional arguments injected into the script scope as `arguments[0..n]`. */
66
66
  readonly args?: readonly unknown[];
67
+ /** Evaluation budget in ms; a wedged page rejects with a timeout. Default 30000. */
68
+ readonly timeoutMs?: number;
67
69
  }
68
70
  /**
69
71
  * Result of an execute. Mirrors CDP `Runtime.evaluate` semantics: either a
@@ -177,11 +177,16 @@ export class ElectronBrowserProvider {
177
177
  const expression = hasArgs
178
178
  ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
179
179
  : `(function(){ return Function(${JSON.stringify(body)})() })()`;
180
- const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
180
+ // CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
181
+ // (navigate returned but the renderer has not committed). Bound it so a
182
+ // stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
183
+ // whole tool call. The caller's signal wins when it fires first.
184
+ const timeoutMs = request.timeoutMs ?? 30_000;
185
+ const result = await withTimeout(handle.sendCommand(CDP_RUNTIME_EVALUATE, {
181
186
  expression,
182
187
  returnByValue: true,
183
188
  awaitPromise: true,
184
- });
189
+ }), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
185
190
  if (result.exceptionDetails !== undefined) {
186
191
  const detail = result.exceptionDetails;
187
192
  return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
@@ -226,7 +231,10 @@ export class ElectronBrowserProvider {
226
231
  }
227
232
  return { url, title, elements: out, truncated: out.length >= cap }
228
233
  })()`;
229
- const result = await handleSendEvaluate(tab.handle, script);
234
+ // Same hang guard as execute: a renderer that has not committed after
235
+ // navigate would otherwise block snapshot forever.
236
+ const timeoutMs = 30_000;
237
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`);
230
238
  if (!result.ok)
231
239
  throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
232
240
  const value = result.value;
@@ -357,6 +365,46 @@ export class ElectronBrowserProvider {
357
365
  return result.ok && typeof result.value === 'string' ? result.value : '';
358
366
  }
359
367
  }
368
+ /**
369
+ * Bound a promise so a wedged CDP call surfaces as an error instead of
370
+ * hanging the tool call forever. The caller's signal, when provided, wins
371
+ * over the timeout if it fires first.
372
+ * @param promise - the operation to bound.
373
+ * @param ms - the timeout budget.
374
+ * @param signal - optional caller signal.
375
+ * @param message - the timeout error message.
376
+ * @returns the promise's value, or a rejected promise on timeout/abort.
377
+ */
378
+ function withTimeout(promise, ms, signal, message) {
379
+ return new Promise((resolve, reject) => {
380
+ let done = false;
381
+ const timer = setTimeout(() => {
382
+ if (done)
383
+ return;
384
+ done = true;
385
+ reject(new Error(message));
386
+ }, ms);
387
+ const finish = (fn) => {
388
+ if (done)
389
+ return;
390
+ done = true;
391
+ clearTimeout(timer);
392
+ if (signal !== undefined)
393
+ signal.removeEventListener('abort', onAbort);
394
+ fn();
395
+ };
396
+ const onAbort = () => {
397
+ if (done)
398
+ return;
399
+ done = true;
400
+ clearTimeout(timer);
401
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
402
+ };
403
+ if (signal !== undefined)
404
+ signal.addEventListener('abort', onAbort, { once: true });
405
+ promise.then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
406
+ });
407
+ }
360
408
  /**
361
409
  * Run a `Runtime.evaluate` through a view handle and normalize the result.
362
410
  * Shared by execute, snapshot, content, and internal URL reads.
@@ -358,12 +358,17 @@ export class RemoteElectronViewHost {
358
358
  class DeferredRemoteView {
359
359
  id;
360
360
  materialize;
361
+ materialized;
361
362
  constructor(id, materialize) {
362
363
  this.id = id;
363
364
  this.materialize = materialize;
364
365
  }
365
366
  async sendCommand(method, params) {
366
- const view = await this.materialize();
367
+ // Materialize once and cache: every sendCommand on the same handle must
368
+ // target the SAME child view. Re-materializing would re-run createView
369
+ // (duplicate window) and drop the established view.
370
+ this.materialized ??= this.materialize();
371
+ const view = await this.materialized;
367
372
  return view.sendCommand(method, params);
368
373
  }
369
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-builtin-browser",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Shared real browser capability for DeepSeek Harness: a visible browser the human watches and can take over, driven by the agent over CDP (snapshot/execute/content/tab management); host-side seam, provider, and browser_* tools",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",