dsh-browser-plus 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -17,7 +17,7 @@
17
17
  import type { Context } from '@deepseek-ai/cordis'
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import type {} from '@deepseek-ai/dsh-system-prompt'
20
- import type { BrowserSessionId } from '../browser/types.ts'
20
+ import type { BrowserSessionId, BrowserSnapshotResult } from '../browser/types.ts'
21
21
 
22
22
  /** Plugin name used by loader diagnostics. */
23
23
  export const name = 'tool-browser'
@@ -280,6 +280,88 @@ function formatSnapshot(snapshot: {
280
280
  return `${header}\n\n${body}${tail}${banner}${user}`
281
281
  }
282
282
 
283
+ /**
284
+ * The snapshot shape both `browser_open` and `browser_snapshot` return.
285
+ *
286
+ * Shared on purpose: a tool output schema rejects unknown properties, so a
287
+ * field declared on only one of the two schemas is silently dropped before the
288
+ * model ever sees it — which is exactly what happened to `frames` and the
289
+ * element `frame` index when iframe support landed (declared on browser_open,
290
+ * missing from browser_snapshot, and dropped from browser_open's own mapper).
291
+ */
292
+ const SNAPSHOT_OUTPUT_SCHEMA = {
293
+ type: 'object',
294
+ additionalProperties: false,
295
+ properties: {
296
+ snapshotId: { type: 'string', required: true },
297
+ url: { type: 'string', required: true },
298
+ title: { type: 'string' },
299
+ truncated: { type: 'boolean' },
300
+ frames: {
301
+ type: 'array',
302
+ description: 'Every iframe on the page; readable:false marks the cross-origin ones whose contents cannot be read.',
303
+ items: {
304
+ type: 'object',
305
+ additionalProperties: false,
306
+ properties: {
307
+ index: { type: 'number', required: true },
308
+ url: { type: 'string', required: true },
309
+ readable: { type: 'boolean', required: true },
310
+ },
311
+ },
312
+ },
313
+ elements: {
314
+ type: 'array',
315
+ required: true,
316
+ items: {
317
+ type: 'object',
318
+ additionalProperties: false,
319
+ properties: {
320
+ ref: { type: 'number', required: true },
321
+ kind: { type: 'string', required: true },
322
+ label: { type: 'string', required: true },
323
+ x: { type: 'number', required: true },
324
+ y: { type: 'number', required: true },
325
+ loc: { type: 'string', required: true },
326
+ frame: { type: 'number', description: 'Index into frames when this element lives inside an iframe.' },
327
+ },
328
+ },
329
+ },
330
+ challenge: {
331
+ type: 'object',
332
+ additionalProperties: false,
333
+ properties: {
334
+ blocked: { type: 'boolean', required: true },
335
+ kind: { type: 'string' },
336
+ reason: { type: 'string' },
337
+ },
338
+ },
339
+ userControlling: { type: 'boolean' },
340
+ },
341
+ } as const
342
+
343
+ /** Project a provider snapshot onto that shared model-facing shape. */
344
+ function snapshotOutput(snapshot: BrowserSnapshotResult) {
345
+ return {
346
+ snapshotId: snapshot.snapshotId,
347
+ url: snapshot.url,
348
+ ...snapshot.title !== undefined ? { title: snapshot.title } : {},
349
+ ...snapshot.frames !== undefined ? { frames: snapshot.frames.map(frame => ({ index: frame.index, url: frame.url, readable: frame.readable })) } : {},
350
+ elements: snapshot.elements.map(el => ({
351
+ ref: el.ref,
352
+ kind: el.kind,
353
+ label: el.label,
354
+ x: el.x,
355
+ y: el.y,
356
+ loc: el.loc,
357
+ ...el.frame !== undefined ? { frame: el.frame } : {},
358
+ })),
359
+ truncated: snapshot.truncated,
360
+ ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
361
+ ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
362
+ }
363
+ }
364
+
283
365
  /** Register all browser tools with `ctx.tools`. */
284
366
  export function apply(ctx: Context, config: Config = {}): void {
285
367
  const timeoutMs = config.timeoutMs ?? 60_000
@@ -311,42 +393,7 @@ export function apply(ctx: Context, config: Config = {}): void {
311
393
  space: { type: 'string', description: 'Optional browser-task label shown in the task manager and active window title.' },
312
394
  },
313
395
  output: {
314
- schema: {
315
- type: 'object',
316
- additionalProperties: false,
317
- properties: {
318
- snapshotId: { type: 'string', required: true },
319
- url: { type: 'string', required: true },
320
- title: { type: 'string' },
321
- truncated: { type: 'boolean' },
322
- elements: {
323
- type: 'array',
324
- required: true,
325
- items: {
326
- type: 'object',
327
- additionalProperties: false,
328
- properties: {
329
- ref: { type: 'number', required: true },
330
- kind: { type: 'string', required: true },
331
- label: { type: 'string', required: true },
332
- x: { type: 'number', required: true },
333
- y: { type: 'number', required: true },
334
- loc: { type: 'string', required: true },
335
- },
336
- },
337
- },
338
- challenge: {
339
- type: 'object',
340
- additionalProperties: false,
341
- properties: {
342
- blocked: { type: 'boolean', required: true },
343
- kind: { type: 'string' },
344
- reason: { type: 'string' },
345
- },
346
- },
347
- userControlling: { type: 'boolean' },
348
- },
349
- },
396
+ schema: SNAPSHOT_OUTPUT_SCHEMA,
350
397
  render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
351
398
  },
352
399
  timeoutMs,
@@ -362,15 +409,7 @@ export function apply(ctx: Context, config: Config = {}): void {
362
409
  ...args.newTab === true ? { newTab: true } : {},
363
410
  }, exec.signal)
364
411
  const snapshot = await browser.snapshot(session, {}, exec.signal)
365
- return {
366
- snapshotId: snapshot.snapshotId,
367
- url: snapshot.url,
368
- ...snapshot.title !== undefined ? { title: snapshot.title } : {},
369
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, loc: el.loc })),
370
- truncated: snapshot.truncated,
371
- ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
372
- ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
373
- }
412
+ return snapshotOutput(snapshot)
374
413
  }, args.space)
375
414
  },
376
415
  }))
@@ -597,42 +636,7 @@ export function apply(ctx: Context, config: Config = {}): void {
597
636
  limit: { type: 'number', description: 'Maximum number of elements to return (1-1000; default 60).' },
598
637
  },
599
638
  output: {
600
- schema: {
601
- type: 'object',
602
- additionalProperties: false,
603
- properties: {
604
- snapshotId: { type: 'string', required: true },
605
- url: { type: 'string', required: true },
606
- title: { type: 'string' },
607
- truncated: { type: 'boolean' },
608
- elements: {
609
- type: 'array',
610
- required: true,
611
- items: {
612
- type: 'object',
613
- additionalProperties: false,
614
- properties: {
615
- ref: { type: 'number', required: true },
616
- kind: { type: 'string', required: true },
617
- label: { type: 'string', required: true },
618
- x: { type: 'number', required: true },
619
- y: { type: 'number', required: true },
620
- loc: { type: 'string', required: true },
621
- },
622
- },
623
- },
624
- challenge: {
625
- type: 'object',
626
- additionalProperties: false,
627
- properties: {
628
- blocked: { type: 'boolean', required: true },
629
- kind: { type: 'string' },
630
- reason: { type: 'string' },
631
- },
632
- },
633
- userControlling: { type: 'boolean' },
634
- },
635
- },
639
+ schema: SNAPSHOT_OUTPUT_SCHEMA,
636
640
  render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
637
641
  },
638
642
  timeoutMs,
@@ -645,15 +649,7 @@ export function apply(ctx: Context, config: Config = {}): void {
645
649
  ...args.query !== undefined ? { query: args.query } : {},
646
650
  ...args.limit !== undefined ? { limit: args.limit } : {},
647
651
  }, exec.signal))
648
- return {
649
- snapshotId: snapshot.snapshotId,
650
- url: snapshot.url,
651
- ...snapshot.title !== undefined ? { title: snapshot.title } : {},
652
- elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y, loc: el.loc })),
653
- truncated: snapshot.truncated,
654
- ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
655
- ...snapshot.userControlling !== undefined ? { userControlling: snapshot.userControlling } : {},
656
- }
652
+ return snapshotOutput(snapshot)
657
653
  },
658
654
  }))
659
655
 
@@ -1224,9 +1220,11 @@ export function apply(ctx: Context, config: Config = {}): void {
1224
1220
 
1225
1221
  ctx.tools.register(defineTool({
1226
1222
  name: 'browser_wait_for',
1227
- description: 'Wait until an element matching a CSS selector appears (and is visible), polling every 250ms. Use before interacting with dynamically-loaded content (SPA views, toasts, menus).',
1223
+ description: 'Wait until a selector, some text, or both reaches a state, polling every 250ms. `state` is visible (default), attached, hidden or detached — hidden/detached are how you wait for something to go away. Use before clicking or reading, instead of sleeping.',
1228
1224
  parameters: {
1229
- selector: { type: 'string', required: true, description: 'CSS selector to wait for.' },
1225
+ selector: { type: 'string', description: 'CSS selector to wait for. Omit to watch the document text (give `text`).' },
1226
+ text: { type: 'string', description: 'Text that must be present: inside the matched element when a selector is given, otherwise anywhere in the document.' },
1227
+ state: { type: 'string', enum: ['visible', 'attached', 'hidden', 'detached'], description: 'Which state to wait for. visible (default) needs a 4x4 px element that is not display:none / visibility:hidden; attached only needs it to exist; hidden and detached wait for it to go away.' },
1230
1228
  timeoutMs: { type: 'number', description: `Total budget in ms (default 15000), capped below this tool's ${String(timeoutMs)}ms budget.` },
1231
1229
  visible: { type: 'boolean', description: 'Require visibility (>4x4 px, not display:none). Default true.' },
1232
1230
  },
@@ -1236,12 +1234,18 @@ export function apply(ctx: Context, config: Config = {}): void {
1236
1234
  additionalProperties: false,
1237
1235
  properties: {
1238
1236
  found: { type: 'boolean', required: true },
1237
+ state: { type: 'string', required: true },
1239
1238
  selector: { type: 'string', required: true },
1240
1239
  tag: { type: 'string', required: true },
1241
1240
  text: { type: 'string' },
1242
1241
  },
1243
1242
  },
1244
- render: (_args, value) => [{ type: 'text', text: `Found <${value.tag}> ${value.selector}${value.text !== undefined ? ` — "${value.text.slice(0, 80)}"` : ''}.` }],
1243
+ render: (_args, value) => [{
1244
+ type: 'text',
1245
+ text: value.selector === ''
1246
+ ? `Found text "${(value.text ?? '').slice(0, 80)}" (state "${value.state}").`
1247
+ : `Found <${value.tag}> ${value.selector}${value.text !== undefined ? ` — "${value.text.slice(0, 80)}"` : ''}.`,
1248
+ }],
1245
1249
  },
1246
1250
  timeoutMs,
1247
1251
  isConcurrencySafe: () => true,
@@ -1250,11 +1254,13 @@ export function apply(ctx: Context, config: Config = {}): void {
1250
1254
  const browser = ctx.get('browser')
1251
1255
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1252
1256
  const result = await withTaskAction(browser, taskKey(exec), 'wait for element', exec, session => browser.waitForElement(session, {
1253
- selector: args.selector,
1257
+ ...args.selector !== undefined ? { selector: args.selector } : {},
1258
+ ...args.text !== undefined ? { text: args.text } : {},
1259
+ ...args.state !== undefined ? { state: args.state as 'visible' | 'attached' | 'hidden' | 'detached' } : {},
1254
1260
  timeoutMs: withinToolBudget(args.timeoutMs, 15_000),
1255
1261
  ...args.visible !== undefined ? { visible: args.visible } : {},
1256
1262
  }, exec.signal))
1257
- return { found: true, selector: result.selector, tag: result.tag, text: result.text }
1263
+ return { found: true, state: result.state, selector: result.selector, tag: result.tag, text: result.text }
1258
1264
  },
1259
1265
  }))
1260
1266
 
@@ -1492,6 +1498,97 @@ export function apply(ctx: Context, config: Config = {}): void {
1492
1498
  },
1493
1499
  }))
1494
1500
 
1501
+ ctx.tools.register(defineTool({
1502
+ name: 'browser_pdf',
1503
+ description: 'Print the active tab to a PDF file — Chrome\'s "Save as PDF". The path must be inside the browser write roots. Returns the path and byte size.',
1504
+ parameters: {
1505
+ savePath: { type: 'string', required: true, description: 'Absolute path of the .pdf to write. Must be inside the browser write roots.' },
1506
+ landscape: { type: 'boolean', description: 'Landscape orientation. Default portrait.' },
1507
+ printBackground: { type: 'boolean', description: 'Include background colours and images. Default true — dark pages would otherwise print as white paper.' },
1508
+ paperWidth: { type: 'number', description: 'Paper width in inches. Default 8.5.' },
1509
+ paperHeight: { type: 'number', description: 'Paper height in inches. Default 11.' },
1510
+ },
1511
+ output: {
1512
+ schema: {
1513
+ type: 'object',
1514
+ additionalProperties: false,
1515
+ properties: {
1516
+ path: { type: 'string', required: true },
1517
+ bytes: { type: 'number', required: true },
1518
+ },
1519
+ },
1520
+ render: (_args, value) => [{ type: 'text', text: `Wrote ${value.bytes} bytes to ${value.path}` }],
1521
+ },
1522
+ timeoutMs,
1523
+ isConcurrencySafe: () => true,
1524
+ async execute(args, exec) {
1525
+ assertAllowed('browser_pdf', exec)
1526
+ const browser = ctx.get('browser')
1527
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1528
+ const result = await withTaskAction(browser, taskKey(exec), 'print to pdf', exec, session => browser.pdf(session, {
1529
+ savePath: args.savePath,
1530
+ ...args.landscape !== undefined ? { landscape: args.landscape } : {},
1531
+ ...args.printBackground !== undefined ? { printBackground: args.printBackground } : {},
1532
+ ...args.paperWidth !== undefined ? { paperWidth: args.paperWidth } : {},
1533
+ ...args.paperHeight !== undefined ? { paperHeight: args.paperHeight } : {},
1534
+ }, exec.signal))
1535
+ return { path: result.path, bytes: result.bytes }
1536
+ },
1537
+ }))
1538
+
1539
+ ctx.tools.register(defineTool({
1540
+ name: 'browser_highlight',
1541
+ description: 'Draw the DevTools-style highlight box over the first element matching a CSS selector, so a human watching the window can see what the Agent is about to touch. It goes through the CDP overlay, so the page DOM is never modified. Pass clear: true to remove it.',
1542
+ parameters: {
1543
+ selector: { type: 'string', description: 'CSS selector whose first match to highlight. Required unless clear is true.' },
1544
+ clear: { type: 'boolean', description: 'Remove the highlight instead of drawing one.' },
1545
+ },
1546
+ output: {
1547
+ schema: {
1548
+ type: 'object',
1549
+ additionalProperties: false,
1550
+ properties: {
1551
+ matched: { type: 'boolean', required: true },
1552
+ cleared: { type: 'boolean', required: true },
1553
+ nodeId: { type: 'number' },
1554
+ box: {
1555
+ type: 'object',
1556
+ additionalProperties: false,
1557
+ properties: {
1558
+ x: { type: 'number', required: true },
1559
+ y: { type: 'number', required: true },
1560
+ width: { type: 'number', required: true },
1561
+ height: { type: 'number', required: true },
1562
+ },
1563
+ },
1564
+ },
1565
+ },
1566
+ render: (_args, value) => [{
1567
+ type: 'text',
1568
+ text: value.matched
1569
+ ? `Highlighted node ${String(value.nodeId)} at ${Math.round(value.box?.x ?? 0)},${Math.round(value.box?.y ?? 0)} (${Math.round(value.box?.width ?? 0)}x${Math.round(value.box?.height ?? 0)})`
1570
+ : value.cleared ? 'Highlight cleared' : 'No element matched that selector',
1571
+ }],
1572
+ },
1573
+ timeoutMs,
1574
+ isConcurrencySafe: () => true,
1575
+ async execute(args, exec) {
1576
+ assertAllowed('browser_highlight', exec)
1577
+ const browser = ctx.get('browser')
1578
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1579
+ const result = await withTaskAction(browser, taskKey(exec), 'highlight element', exec, session => browser.highlight(session, {
1580
+ ...args.selector !== undefined ? { selector: args.selector } : {},
1581
+ ...args.clear !== undefined ? { clear: args.clear } : {},
1582
+ }, exec.signal))
1583
+ return {
1584
+ matched: result.matched,
1585
+ cleared: result.cleared,
1586
+ ...result.nodeId !== undefined ? { nodeId: result.nodeId } : {},
1587
+ ...result.box !== undefined ? { box: result.box } : {},
1588
+ }
1589
+ },
1590
+ }))
1591
+
1495
1592
  if (config.tabTools !== false) {
1496
1593
  ctx.tools.register(defineTool({
1497
1594
  name: 'browser_list_tabs',