@jobshimo/browser-link 0.28.9 → 0.30.0

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 (58) hide show
  1. package/dist/activity/extract.js +3 -0
  2. package/dist/activity/extract.js.map +1 -1
  3. package/dist/agent-instructions/content.js +11 -1
  4. package/dist/agent-instructions/content.js.map +1 -1
  5. package/dist/bridge/dispatch.js +5 -3
  6. package/dist/bridge/dispatch.js.map +1 -1
  7. package/dist/bridge/events.d.ts +1 -1
  8. package/dist/bridge/events.js.map +1 -1
  9. package/dist/cdp/inpage/builders.d.ts +15 -0
  10. package/dist/cdp/inpage/builders.js +46 -0
  11. package/dist/cdp/inpage/builders.js.map +1 -1
  12. package/dist/cdp/screenshot.d.ts +109 -0
  13. package/dist/cdp/screenshot.js +189 -0
  14. package/dist/cdp/screenshot.js.map +1 -0
  15. package/dist/cdp/support.d.ts +2 -1
  16. package/dist/cdp/support.js +7 -1
  17. package/dist/cdp/support.js.map +1 -1
  18. package/dist/cdp/transport.js +15 -1
  19. package/dist/cdp/transport.js.map +1 -1
  20. package/dist/cli.js +8 -0
  21. package/dist/cli.js.map +1 -1
  22. package/dist/commands/about.js +4 -0
  23. package/dist/commands/about.js.map +1 -1
  24. package/dist/commands/config.d.ts +12 -0
  25. package/dist/commands/config.js +49 -4
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/config.d.ts +8 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/downloads.d.ts +127 -0
  30. package/dist/downloads.js +303 -0
  31. package/dist/downloads.js.map +1 -0
  32. package/dist/extension/background.js +74 -1
  33. package/dist/extension/background.js.map +1 -1
  34. package/dist/extension/download-capture.d.ts +96 -0
  35. package/dist/extension/download-capture.js +131 -0
  36. package/dist/extension/download-capture.js.map +1 -0
  37. package/dist/extension/inpage/builders.d.ts +15 -0
  38. package/dist/extension/inpage/builders.js +46 -0
  39. package/dist/extension/inpage/builders.js.map +1 -1
  40. package/dist/extension/manifest.json +1 -1
  41. package/dist/extension/screenshot.d.ts +113 -0
  42. package/dist/extension/screenshot.js +193 -0
  43. package/dist/extension/screenshot.js.map +1 -0
  44. package/dist/permissions.js +12 -0
  45. package/dist/permissions.js.map +1 -1
  46. package/dist/server.js +8 -0
  47. package/dist/server.js.map +1 -1
  48. package/dist/tools/browser-definitions.js +101 -1
  49. package/dist/tools/browser-definitions.js.map +1 -1
  50. package/dist/tools/browser-dispatch.d.ts +12 -1
  51. package/dist/tools/browser-dispatch.js +171 -0
  52. package/dist/tools/browser-dispatch.js.map +1 -1
  53. package/dist/tools/responses.d.ts +14 -2
  54. package/dist/tools/responses.js +21 -0
  55. package/dist/tools/responses.js.map +1 -1
  56. package/dist/tools/server-instructions.js +27 -4
  57. package/dist/tools/server-instructions.js.map +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,303 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { extname, isAbsolute, join } from 'node:path';
3
+ /**
4
+ * `browser.download` support: everything the tool needs that is NOT talking
5
+ * to Chrome — filename policy, the download directory, the file write, the
6
+ * in-page fetch expression and the shaping of the result the agent sees.
7
+ *
8
+ * WHY THE BYTES COME THROUGH THE SERVER. The obvious design — tell Chrome
9
+ * "save downloads to <dir>, never ask" with CDP `Browser.setDownloadBehavior`
10
+ * — is not available to us: Chromium refuses it (and the tab-scoped
11
+ * `Page.setDownloadBehavior`, which delegates to the same check) for any
12
+ * `chrome.debugger` client, because the extension debugger host reports
13
+ * `MayWriteLocalFiles() == false`. And `chrome.downloads.download({saveAs:
14
+ * false})` does NOT suppress the dialog either: Chrome still prompts when the
15
+ * user enabled "Ask where to save each file". So instead the bytes are
16
+ * fetched IN THE PAGE (with the page's own cookies) and written here, into a
17
+ * directory only the human configures.
18
+ *
19
+ * Chrome's download manager never SAVES anything. In url mode it is not
20
+ * involved at all. In selector mode the extension uses it only to catch the
21
+ * download a click starts: it hooks `chrome.downloads.onDeterminingFilename`,
22
+ * which Chrome consults before prompting, cancels and erases that download
23
+ * there, and hands its URL to the same in-page fetch (see the extension's
24
+ * `download-capture.ts`).
25
+ */
26
+ export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
27
+ export const MAX_DOWNLOAD_TIMEOUT_MS = 120_000;
28
+ /** Hard cap on one download's size. The bytes travel base64-encoded inside a
29
+ * CDP `Runtime.evaluate` result and then a bridge frame, so this also bounds
30
+ * the memory both hops can be asked to hold. */
31
+ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
32
+ /** Extra bridge timeout on top of each hop's own budget (selector mode has
33
+ * two hops: capture, then fetch), for the round trip only — the capture and
34
+ * the in-page fetch enforce their budgets themselves and always answer, so
35
+ * this is spent only when the bridge itself stalls. */
36
+ export const DOWNLOAD_BRIDGE_SLACK_MS = 10_000;
37
+ const MAX_FILENAME_LENGTH = 200;
38
+ // Characters no filename may carry on any OS we run on (Windows is the
39
+ // strictest) — plus the two path separators.
40
+ // eslint-disable-next-line no-control-regex
41
+ const RESERVED_CHARS = /[<>:"/\\|?*\u0000-\u001f]/;
42
+ // eslint-disable-next-line no-control-regex
43
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
44
+ /**
45
+ * Validate a filename the AGENT asked for. Strict on purpose: the agent may
46
+ * name the file but never choose where it lands, so anything that could be
47
+ * read as a path (separators, `..`, a drive prefix) is rejected outright
48
+ * rather than quietly cleaned up.
49
+ */
50
+ export function validateRequestedFilename(name) {
51
+ const fail = (why) => {
52
+ throw new Error(`browser.download: filename ${why} — pass a plain basename like "report.pdf"`);
53
+ };
54
+ if (typeof name !== 'string')
55
+ return fail('must be a string');
56
+ if (name.trim().length === 0)
57
+ return fail('must not be empty');
58
+ if (name.length > MAX_FILENAME_LENGTH)
59
+ return fail(`must be at most ${MAX_FILENAME_LENGTH} characters`);
60
+ if (name === '.' || name === '..')
61
+ return fail('must not be "." or ".."');
62
+ if (RESERVED_CHARS.test(name))
63
+ return fail('must not contain path separators or reserved characters');
64
+ if (/[.\s]$/.test(name))
65
+ return fail('must not end with a dot or a space');
66
+ if (WINDOWS_DEVICE_NAME.test(name)) {
67
+ return fail('must not be a Windows device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)');
68
+ }
69
+ return name;
70
+ }
71
+ /**
72
+ * Windows device names — `NUL`, `con.txt`, `LPT1.log`: with or without an
73
+ * extension, any case. Writing to one on Windows hits the device (or a
74
+ * normalized name), so the server would report a file that does not exist.
75
+ */
76
+ const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
77
+ /**
78
+ * Make a filename that came from the PAGE (Chrome's suggestion, a
79
+ * Content-Disposition header, a URL) safe to write: keep the last path
80
+ * segment, replace reserved characters, drop control characters and leading
81
+ * or trailing dots/spaces, and defuse Windows device names with a `_`
82
+ * prefix. Cleaned up rather than rejected: the page, not the agent, chose
83
+ * it. Returns null when nothing usable is left.
84
+ */
85
+ export function sanitizeDerivedFilename(raw) {
86
+ if (typeof raw !== 'string')
87
+ return null;
88
+ const lastSegment = raw.split(/[/\\]/).pop() ?? '';
89
+ const cleaned = lastSegment
90
+ .replace(CONTROL_CHARS, '')
91
+ .replace(/[<>:"|?*]/g, '_')
92
+ .replace(/^[.\s]+/, '')
93
+ .slice(0, MAX_FILENAME_LENGTH - 1)
94
+ .replace(/[.\s]+$/, '');
95
+ if (cleaned.length === 0)
96
+ return null;
97
+ return WINDOWS_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
98
+ }
99
+ /** Extract the filename from a Content-Disposition header, preferring the
100
+ * RFC 5987 `filename*=` form (it carries non-ASCII names correctly). */
101
+ export function filenameFromContentDisposition(header) {
102
+ if (typeof header !== 'string')
103
+ return null;
104
+ const star = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
105
+ if (star) {
106
+ try {
107
+ return decodeURIComponent(star[2].trim().replace(/^"|"$/g, ''));
108
+ }
109
+ catch {
110
+ // Malformed percent-encoding — fall through to the plain form.
111
+ }
112
+ }
113
+ const plain = /filename\s*=\s*("[^"]*"|[^;]+)/i.exec(header);
114
+ if (!plain)
115
+ return null;
116
+ const value = plain[1].trim().replace(/^"|"$/g, '');
117
+ return value.length > 0 ? value : null;
118
+ }
119
+ /** Last path segment of an http(s) URL, decoded. Null for `blob:`/`data:`
120
+ * URLs (their "path" is an opaque id or the payload itself). */
121
+ export function filenameFromUrl(url) {
122
+ if (typeof url !== 'string')
123
+ return null;
124
+ let parsed;
125
+ try {
126
+ parsed = new URL(url);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
132
+ return null;
133
+ const segment = parsed.pathname.split('/').pop() ?? '';
134
+ if (segment.length === 0)
135
+ return null;
136
+ try {
137
+ return decodeURIComponent(segment);
138
+ }
139
+ catch {
140
+ return segment;
141
+ }
142
+ }
143
+ /** requested → Chrome's suggestion → Content-Disposition → URL → "download". */
144
+ export function pickFilename(sources) {
145
+ if (sources.requested !== undefined)
146
+ return sources.requested;
147
+ return (sanitizeDerivedFilename(sources.suggested) ??
148
+ sanitizeDerivedFilename(filenameFromContentDisposition(sources.contentDisposition)) ??
149
+ sanitizeDerivedFilename(filenameFromUrl(sources.url)) ??
150
+ 'download');
151
+ }
152
+ export function clampDownloadTimeoutMs(value) {
153
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
154
+ return DEFAULT_DOWNLOAD_TIMEOUT_MS;
155
+ }
156
+ return Math.min(Math.floor(value), MAX_DOWNLOAD_TIMEOUT_MS);
157
+ }
158
+ /**
159
+ * Where downloads land: the human-configured `downloads.dir` when it is an
160
+ * absolute path, otherwise `<dataDir>/downloads`. config.json is untrusted
161
+ * (hand-edited, corrupted), so a relative or non-string value falls back to
162
+ * the default instead of resolving against whatever the cwd happens to be.
163
+ */
164
+ export function resolveDownloadsDir(cfg, dataDir) {
165
+ const configured = cfg.downloadsDir;
166
+ if (typeof configured === 'string' && configured.length > 0 && isAbsolute(configured)) {
167
+ return configured;
168
+ }
169
+ return join(dataDir, 'downloads');
170
+ }
171
+ /**
172
+ * Write `data` as `filename` inside `dir`, creating `dir` if needed. Never
173
+ * overwrites: an existing name becomes `name (1).ext`, `name (2).ext`, …
174
+ * (the `wx` flag makes the existence check and the create one atomic step).
175
+ * Returns the absolute path written.
176
+ */
177
+ export function writeDownloadFile(dir, filename, data) {
178
+ mkdirSync(dir, { recursive: true });
179
+ const ext = extname(filename);
180
+ const stem = ext.length > 0 ? filename.slice(0, -ext.length) : filename;
181
+ for (let attempt = 0; attempt < 1000; attempt++) {
182
+ const name = attempt === 0 ? filename : `${stem} (${attempt})${ext}`;
183
+ const path = join(dir, name);
184
+ try {
185
+ writeFileSync(path, data, { flag: 'wx' });
186
+ return path;
187
+ }
188
+ catch (err) {
189
+ if (err.code !== 'EEXIST')
190
+ throw err;
191
+ }
192
+ }
193
+ throw new Error(`could not find a free filename for ${filename} in ${dir}`);
194
+ }
195
+ /**
196
+ * Build the expression `Runtime.evaluate` runs in the page: fetch `url` with
197
+ * the page's credentials, stream the body (so `maxBytes` is enforced while
198
+ * reading, not after buffering everything), and base64 it. Every exit path
199
+ * resolves to a `FetchDownloadOutcome` — a timeout or an error reports how
200
+ * far it got rather than throwing, so the agent always gets partial info.
201
+ */
202
+ export function buildFetchDownloadJs(opts) {
203
+ const url = JSON.stringify(opts.url);
204
+ const timeoutMs = Math.floor(opts.timeoutMs);
205
+ const maxBytes = Math.floor(opts.maxBytes);
206
+ return `(async () => {
207
+ const url = ${url};
208
+ const timeoutMs = ${timeoutMs};
209
+ const maxBytes = ${maxBytes};
210
+ const ctrl = new AbortController();
211
+ let timedOut = false;
212
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
213
+ const out = { state: 'interrupted', bytes: 0, mime: null, content_disposition: null, final_url: null };
214
+ try {
215
+ const res = await fetch(url, { credentials: 'include', signal: ctrl.signal });
216
+ out.status = res.status;
217
+ out.mime = res.headers.get('content-type');
218
+ out.content_disposition = res.headers.get('content-disposition');
219
+ out.final_url = res.url || url;
220
+ if (!res.ok) {
221
+ out.error = 'HTTP ' + res.status;
222
+ return out;
223
+ }
224
+ const chunks = [];
225
+ if (res.body) {
226
+ const reader = res.body.getReader();
227
+ for (;;) {
228
+ const step = await reader.read();
229
+ if (step.done) break;
230
+ out.bytes += step.value.byteLength;
231
+ if (out.bytes > maxBytes) {
232
+ ctrl.abort();
233
+ out.error = 'download exceeds the ' + maxBytes + ' bytes cap';
234
+ return out;
235
+ }
236
+ chunks.push(step.value);
237
+ }
238
+ }
239
+ let binary = '';
240
+ for (const chunk of chunks) {
241
+ for (let i = 0; i < chunk.length; i += 0x8000) {
242
+ binary += String.fromCharCode.apply(null, chunk.subarray(i, i + 0x8000));
243
+ }
244
+ }
245
+ out.data_b64 = btoa(binary);
246
+ out.state = 'complete';
247
+ return out;
248
+ } catch (err) {
249
+ if (timedOut) {
250
+ out.state = 'timeout';
251
+ out.error = 'timed out after ' + timeoutMs + 'ms';
252
+ } else {
253
+ out.error = err && err.message ? err.message : String(err);
254
+ }
255
+ return out;
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ })()`;
260
+ }
261
+ /**
262
+ * Turn the in-page fetch outcome into the agent-facing result, writing the
263
+ * file on `complete`. A failed write downgrades to `interrupted` with the
264
+ * error — the agent must never be told a file exists when it does not.
265
+ */
266
+ export function shapeDownloadResult(outcome, sources, save) {
267
+ const filename = pickFilename({
268
+ ...sources,
269
+ contentDisposition: outcome.content_disposition,
270
+ url: outcome.final_url ?? sources.url,
271
+ });
272
+ const base = {
273
+ filename,
274
+ mime: outcome.mime ?? null,
275
+ url: outcome.final_url ?? sources.url,
276
+ };
277
+ if (outcome.state === 'complete' && typeof outcome.data_b64 === 'string') {
278
+ const data = Buffer.from(outcome.data_b64, 'base64');
279
+ try {
280
+ const path = save(filename, data);
281
+ return { ok: true, state: 'complete', path, bytes: data.length, ...base };
282
+ }
283
+ catch (err) {
284
+ return {
285
+ ok: false,
286
+ state: 'interrupted',
287
+ path: null,
288
+ bytes: data.length,
289
+ ...base,
290
+ error: `could not write the file: ${err instanceof Error ? err.message : String(err)}`,
291
+ };
292
+ }
293
+ }
294
+ return {
295
+ ok: false,
296
+ state: outcome.state === 'timeout' ? 'timeout' : 'interrupted',
297
+ path: null,
298
+ bytes: outcome.bytes,
299
+ ...base,
300
+ error: outcome.error ?? 'download did not complete',
301
+ };
302
+ }
303
+ //# sourceMappingURL=downloads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"downloads.js","sourceRoot":"","sources":["../src/downloads.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,8 @@
1
- import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, buildDragProbeJs, } from './inpage/builders.js';
1
+ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, buildDragProbeJs, buildElementRectJs, } from './inpage/builders.js';
2
+ import { runScreenshot } from './screenshot.js';
2
3
  import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
3
4
  import { resolveSettleParams, settleSafely } from './settle.js';
5
+ import { captureAround, createDownloadCapture } from './download-capture.js';
4
6
  import { runFlow, } from './flow.js';
5
7
  import { MAX_DETACHED_FLOW_MS, appendFlowHistory, createFlowRegistry, detachedFlowKey, detachedFlowRecordsToEvict, finishedDetachedFlowRecord, flowHistoryKey, flowIdFromDetachedFlowKey, minimalDetachedFlowRecord, parseDetachedFlowRecord, parseFlowHistory, startedDetachedFlowRecord, terminatedDetachedFlowRecord, terminatedFlowHistoryEntry, toFlowStatus, toHistoryEntry, } from './flow-registry.js';
6
8
  import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
@@ -1393,6 +1395,27 @@ async function handleCanvasScreenshot(tabId, msg, p, optStr) {
1393
1395
  const value = await evaluateInTab(tabId, buildCanvasScreenshotJs({ selector, region, format }));
1394
1396
  return { kind: 'tool.response', id: msg.id, ok: true, result: value };
1395
1397
  }
1398
+ /**
1399
+ * `browser.screenshot`: viewport / full page / one element through the
1400
+ * already-attached debugger. `Page.captureScreenshot` with
1401
+ * `captureBeyondViewport` and a `clip` renders past the viewport, so no
1402
+ * scrolling or stitching is needed. Planning and the byte cap live in
1403
+ * `screenshot.ts` (unit-tested; shared verbatim with cdp-direct).
1404
+ */
1405
+ async function handleScreenshot(tabId, msg, p, optStr) {
1406
+ const mode = p.mode === 'full_page' || p.mode === 'element' ? p.mode : 'viewport';
1407
+ const result = await runScreenshot({
1408
+ send: (method, params) => cdp(tabId, method, params ?? {}),
1409
+ resolveElementRect: (selector) => evaluateInTab(tabId, buildElementRectJs({ selector })),
1410
+ }, {
1411
+ mode,
1412
+ selector: optStr('selector'),
1413
+ format: optStr('format') === 'jpeg' ? 'jpeg' : 'png',
1414
+ quality: typeof p.quality === 'number' ? p.quality : undefined,
1415
+ maxDimension: typeof p.max_dimension === 'number' ? p.max_dimension : undefined,
1416
+ });
1417
+ return { kind: 'tool.response', id: msg.id, ok: true, result };
1418
+ }
1396
1419
  function handleConsole(state, msg, optStr) {
1397
1420
  const level = optStr('level');
1398
1421
  const entries = level
@@ -1895,6 +1918,52 @@ async function handleDialogRespond(tabId, msg, p, optStr) {
1895
1918
  result: { accepted: accept },
1896
1919
  };
1897
1920
  }
1921
+ /** One worker-wide capture: `chrome.downloads` events are not tied to a tab,
1922
+ * so only one `browser.download` may wait for a download at a time. */
1923
+ const downloadCapture = createDownloadCapture(chrome.downloads);
1924
+ /** The tab's origin for `mayComeFrom`, or undefined when it has none worth
1925
+ * matching (opaque `null` origins, unparseable URLs). */
1926
+ function downloadOriginOf(url) {
1927
+ try {
1928
+ const origin = new URL(url).origin;
1929
+ return origin === 'null' ? undefined : origin;
1930
+ }
1931
+ catch {
1932
+ return undefined;
1933
+ }
1934
+ }
1935
+ /**
1936
+ * `browser.download`'s selector mode: arm the capture, click, and wait for
1937
+ * the download the click starts. The capture cancels that download before
1938
+ * Chrome can show "Save as" (see `download-capture.ts`) and hands its URL
1939
+ * back; the server then fetches the bytes in the page and writes the file.
1940
+ * A click that starts nothing within `timeout_ms` answers `state: "timeout"`.
1941
+ */
1942
+ async function handleDownloadCapture(tabId, msg, p) {
1943
+ const selector = typeof p.selector === 'string' ? p.selector : '';
1944
+ // No local default: the server always sends a clamped budget, and a second
1945
+ // copy of its default here would only drift.
1946
+ if (typeof p.timeout_ms !== 'number' || !(p.timeout_ms > 0)) {
1947
+ return { kind: 'tool.response', id: msg.id, ok: false, error: 'download: timeout_ms required' };
1948
+ }
1949
+ const timeoutMs = p.timeout_ms;
1950
+ // Narrow the capture to downloads that may come from this tab's origin —
1951
+ // `chrome.downloads` items carry no tab id (see `mayComeFrom`).
1952
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
1953
+ const origin = tab?.url ? downloadOriginOf(tab.url) : undefined;
1954
+ const outcome = await captureAround(downloadCapture, { timeoutMs, ...(origin ? { origin } : {}) },
1955
+ // settle_ms: 0 — the thing to wait for is the download, not DOM quiet.
1956
+ () => performClick(tabId, { selector, settle_ms: 0 }));
1957
+ if (!outcome.ok) {
1958
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
1959
+ }
1960
+ return {
1961
+ kind: 'tool.response',
1962
+ id: msg.id,
1963
+ ok: true,
1964
+ result: outcome.captured ? { state: 'captured', ...outcome.captured } : { state: 'timeout' },
1965
+ };
1966
+ }
1898
1967
  async function handleSetPermission(msg, str) {
1899
1968
  const origin = str('origin');
1900
1969
  const name = str('name');
@@ -1978,6 +2047,8 @@ async function handleTool(state, msg) {
1978
2047
  }
1979
2048
  case 'canvas_screenshot':
1980
2049
  return await handleCanvasScreenshot(tabId, msg, p, optStr);
2050
+ case 'screenshot':
2051
+ return await handleScreenshot(tabId, msg, p, optStr);
1981
2052
  case 'console':
1982
2053
  return handleConsole(state, msg, optStr);
1983
2054
  case 'network':
@@ -2059,6 +2130,8 @@ async function handleTool(state, msg) {
2059
2130
  return await handleDialogRespond(tabId, msg, p, optStr);
2060
2131
  case 'set_permission':
2061
2132
  return await handleSetPermission(msg, str);
2133
+ case 'download':
2134
+ return await handleDownloadCapture(tabId, msg, p);
2062
2135
  default:
2063
2136
  return { kind: 'tool.response', id: msg.id, ok: false, error: `Unknown tool: ${msg.tool}` };
2064
2137
  }