@causyne/agent 1.10.13 → 1.10.18

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 (76) hide show
  1. package/docs/ARCHITECTURE.md +118 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +46 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +123 -21
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/browserRuntime.js +88 -0
  12. package/src/browser/browserRuntime.ts +107 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/cliMode.js +13 -1
  23. package/src/commands.js +37 -8
  24. package/src/config.d.ts +2 -0
  25. package/src/decisionPolicy.js +68 -0
  26. package/src/decisionPolicy.ts +106 -0
  27. package/src/hooks.js +121 -107
  28. package/src/hooks.ts +193 -0
  29. package/src/implementationPlan.js +50 -0
  30. package/src/implementationPlan.ts +59 -0
  31. package/src/mcp/mcpClient.js +520 -132
  32. package/src/mcp/mcpClient.ts +584 -0
  33. package/src/mcp/mcpRegistry.js +399 -185
  34. package/src/mcp/mcpRegistry.ts +451 -0
  35. package/src/planCache.js +473 -450
  36. package/src/planCache.ts +840 -0
  37. package/src/plugins/pluginManager.js +40 -5
  38. package/src/plugins/pluginSkills.js +84 -0
  39. package/src/plugins/pluginSkills.ts +106 -0
  40. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  41. package/src/provisioning/capabilityProvisioner.js +5 -0
  42. package/src/provisioning/capabilityTools.js +2 -1
  43. package/src/sandbox.js +32 -2
  44. package/src/sessionJournal.js +471 -0
  45. package/src/sessionJournal.ts +496 -0
  46. package/src/sessionStore.js +3 -279
  47. package/src/skillsManager.js +18 -0
  48. package/src/subagentJournal.js +215 -0
  49. package/src/subagentJournal.ts +254 -0
  50. package/src/taskShape.js +179 -198
  51. package/src/taskShape.ts +259 -0
  52. package/src/toolPacks.js +7 -5
  53. package/src/tools/browserAutomator.js +45 -241
  54. package/src/tools/browserAutomator.ts +57 -0
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/registry.js +4 -3
  57. package/src/tools/requestDecision.js +87 -0
  58. package/src/tools/requestDecision.ts +107 -0
  59. package/src/tools/subagentOrchestrator.js +73 -12
  60. package/src/tools/updatePlan.js +37 -84
  61. package/src/tools/updatePlan.ts +48 -0
  62. package/src/tools/webFetch.js +183 -68
  63. package/src/tools/webFetch.ts +185 -0
  64. package/src/tools/webSearch.js +50 -75
  65. package/src/tools/webSearch.ts +56 -0
  66. package/src/tui/ask.js +41 -1
  67. package/src/tui/chatRenderer.js +28 -0
  68. package/src/tui/dialogs.js +91 -0
  69. package/src/tui/prompt.js +32 -3
  70. package/src/tui/toolDisplay.js +0 -1
  71. package/src/types/contracts.js +8 -0
  72. package/src/types/contracts.ts +263 -0
  73. package/src/utils/paths.d.ts +3 -0
  74. package/src/version.d.ts +1 -0
  75. package/src/mcp/jsonRpc.js +0 -165
  76. package/src/tools/todoWrite.js +0 -75
@@ -0,0 +1,495 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { capabilityDirectory, ensureCapability, loadCapabilityPackage } from '../provisioning/capabilityProvisioner.js';
5
+ import { resolveBrowserRuntime } from './browserRuntime.js';
6
+ import { assertPublicWebUrl } from './urlPolicy.js';
7
+ import { windowSnapshot } from './snapshotWindow.js';
8
+ const NAVIGATION_TIMEOUT_MS = 30_000;
9
+ const ACTION_TIMEOUT_MS = 12_000;
10
+ const IDLE_CLOSE_MS = 10 * 60_000;
11
+ const MAX_SEARCH_RESULTS = 10;
12
+ const MAX_EXTRACT_CHARS = 80_000;
13
+ const MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024;
14
+ const MAX_SCREENSHOT_BYTES = 25 * 1024 * 1024;
15
+ const INTERACTIVE_ROLES = new Set([
16
+ 'button', 'checkbox', 'combobox', 'link', 'menuitem', 'option', 'radio',
17
+ 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem'
18
+ ]);
19
+ export class BrowserCapabilityError extends Error {
20
+ requiresConsent;
21
+ constructor(message, requiresConsent = false) {
22
+ super(message);
23
+ this.name = 'BrowserCapabilityError';
24
+ this.requiresConsent = requiresConsent;
25
+ }
26
+ }
27
+ function errorMessage(error) {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+ function boundedText(value, limit = MAX_EXTRACT_CHARS) {
31
+ const text = String(value ?? '').replace(/\u0000/g, '');
32
+ return text.length > limit ? `${text.slice(0, limit)}\n\n[Browser output truncated at ${limit} characters]` : text;
33
+ }
34
+ function isWithin(root, candidate) {
35
+ const relative = path.relative(root, candidate);
36
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
37
+ }
38
+ export function parseAriaRefs(snapshot) {
39
+ const refs = new Map();
40
+ const seen = new Map();
41
+ let counter = 1;
42
+ for (const line of String(snapshot ?? '').split('\n')) {
43
+ if (counter > 500)
44
+ break;
45
+ const match = line.match(/^\s*-\s+([a-z][\w-]*)(?:\s+"([^"]*)")?/i);
46
+ if (!match)
47
+ continue;
48
+ const role = (match[1] ?? '').toLowerCase();
49
+ if (!INTERACTIVE_ROLES.has(role))
50
+ continue;
51
+ const name = match[2] ?? '';
52
+ const key = `${role}:${name}`;
53
+ const nth = seen.get(key) ?? 0;
54
+ seen.set(key, nth + 1);
55
+ refs.set(`e${counter++}`, { role, name, nth });
56
+ }
57
+ return refs;
58
+ }
59
+ export function annotateAriaSnapshot(snapshot, refs) {
60
+ const queues = new Map();
61
+ for (const [id, ref] of refs) {
62
+ const key = `${ref.role}:${ref.name}`;
63
+ queues.set(key, [...(queues.get(key) ?? []), id]);
64
+ }
65
+ return String(snapshot ?? '').split('\n').map(line => {
66
+ const match = line.match(/^(\s*-\s+)([a-z][\w-]*)(?:\s+"([^"]*)")?/i);
67
+ if (!match)
68
+ return line;
69
+ const key = `${(match[2] ?? '').toLowerCase()}:${match[3] ?? ''}`;
70
+ const id = queues.get(key)?.shift();
71
+ return id ? `${line} [${id}]` : line;
72
+ }).join('\n');
73
+ }
74
+ const SEARCH_EXTRACT_SCRIPT = `(() => {
75
+ const text = value => String(value || '').replace(/\\s+/g, ' ').trim();
76
+ const candidates = [
77
+ ...document.querySelectorAll('#rso h3, #search h3, li.b_algo h2, article h2, a.result__a')
78
+ ];
79
+ const seen = new Set();
80
+ const results = [];
81
+ for (const heading of candidates) {
82
+ const anchor = heading.matches('a[href]') ? heading : heading.closest('a[href]');
83
+ if (!anchor || !anchor.href) continue;
84
+ let href = anchor.href;
85
+ try {
86
+ const parsed = new URL(href, document.baseURI);
87
+ if (parsed.hostname.includes('google.') && parsed.pathname === '/url') {
88
+ href = parsed.searchParams.get('q') || parsed.searchParams.get('url') || href;
89
+ }
90
+ if (/(^|\.)duckduckgo\.com$/i.test(parsed.hostname) && parsed.pathname.startsWith('/l/')) {
91
+ href = parsed.searchParams.get('uddg') || href;
92
+ }
93
+ const clean = new URL(href, document.baseURI);
94
+ if (!/^https?:$/.test(clean.protocol) || seen.has(clean.href)) continue;
95
+ if (/^(www\\.)?(google|bing|duckduckgo)\\./i.test(clean.hostname)) continue;
96
+ seen.add(clean.href);
97
+ href = clean.href;
98
+ } catch { continue; }
99
+ const title = text(heading.textContent);
100
+ if (!title) continue;
101
+ const block = heading.closest('.MjjYud, .g, li.b_algo, article, .result') || anchor.parentElement?.parentElement;
102
+ let snippet = '';
103
+ if (block) {
104
+ const snippetNode = block.querySelector('.VwiC3b, .b_caption p, .result__snippet, [data-sncf]');
105
+ snippet = text(snippetNode?.textContent || '');
106
+ if (!snippet) snippet = text(block.textContent).replace(title, '').slice(0, 500);
107
+ }
108
+ results.push({ title: title.slice(0, 300), url: href, snippet: snippet.slice(0, 500) });
109
+ if (results.length >= ${MAX_SEARCH_RESULTS}) break;
110
+ }
111
+ return results;
112
+ })()`;
113
+ export class CausyneBrowser {
114
+ workspace;
115
+ ensureBrowserCapability;
116
+ loadBrowserEngine;
117
+ resolveRuntime;
118
+ profileDirectory;
119
+ visible;
120
+ validateUrl;
121
+ session = null;
122
+ starting = null;
123
+ tabs = new Map();
124
+ activeTabId = null;
125
+ idleTimer = null;
126
+ securedPages = new WeakSet();
127
+ pageStates = new WeakMap();
128
+ contextRouteInstalled = false;
129
+ constructor(options = {}) {
130
+ this.workspace = path.resolve(options.workspace ?? process.cwd());
131
+ this.ensureBrowserCapability = options.ensureBrowserCapability ?? (() => ensureCapability('browser'));
132
+ this.loadBrowserEngine = options.loadBrowserEngine ?? (async () => await loadCapabilityPackage('camoufox-js'));
133
+ this.resolveRuntime = options.resolveRuntime ?? resolveBrowserRuntime;
134
+ this.profileDirectory = path.resolve(options.profileDirectory ?? path.join(capabilityDirectory(), 'browser-profile'));
135
+ this.visible = options.visible ?? process.env.CAUSYNE_BROWSER_VISIBLE === '1';
136
+ this.validateUrl = options.validateUrl ?? assertPublicWebUrl;
137
+ }
138
+ async run(actions) {
139
+ if (!Array.isArray(actions) || actions.length === 0 || actions.length > 40) {
140
+ throw new Error('Browser actions must contain between 1 and 40 operations');
141
+ }
142
+ await this.start();
143
+ const results = [];
144
+ for (const action of actions)
145
+ results.push(await this.runAction(action));
146
+ this.touch();
147
+ return results;
148
+ }
149
+ async search(query) {
150
+ const cleaned = String(query ?? '').trim();
151
+ if (!cleaned || cleaned.length > 1_000)
152
+ throw new Error('Search query must contain 1-1000 characters');
153
+ await this.start();
154
+ const state = await this.activeTab();
155
+ const targets = [
156
+ { engine: 'google', url: `https://www.google.com/search?q=${encodeURIComponent(cleaned)}` },
157
+ { engine: 'duckduckgo', url: `https://duckduckgo.com/html/?q=${encodeURIComponent(cleaned)}` },
158
+ { engine: 'bing', url: `https://www.bing.com/search?q=${encodeURIComponent(cleaned)}` }
159
+ ];
160
+ const failures = [];
161
+ for (const target of targets) {
162
+ try {
163
+ await this.navigate(state, target.url);
164
+ await state.page.waitForTimeout(250);
165
+ const extracted = await state.page.evaluate(SEARCH_EXTRACT_SCRIPT);
166
+ if (Array.isArray(extracted) && extracted.length > 0) {
167
+ return extracted.slice(0, MAX_SEARCH_RESULTS).map(result => ({ ...result, engine: target.engine }));
168
+ }
169
+ failures.push(`${target.engine}: no organic results`);
170
+ }
171
+ catch (error) {
172
+ failures.push(`${target.engine}: ${errorMessage(error)}`);
173
+ }
174
+ }
175
+ throw new Error(`Search providers exhausted. ${failures.join('; ')}`);
176
+ }
177
+ async snapshot(offset = 0) {
178
+ await this.start();
179
+ const state = await this.activeTab();
180
+ const raw = await state.page.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
181
+ state.refs = parseAriaRefs(raw);
182
+ state.lastSnapshot = annotateAriaSnapshot(raw, state.refs);
183
+ return {
184
+ ...windowSnapshot(state.lastSnapshot, offset),
185
+ tabId: state.id,
186
+ url: state.page.url(),
187
+ title: await state.page.title(),
188
+ refsCount: state.refs.size
189
+ };
190
+ }
191
+ async close() {
192
+ if (this.idleTimer)
193
+ clearTimeout(this.idleTimer);
194
+ this.idleTimer = null;
195
+ const session = this.session;
196
+ this.session = null;
197
+ this.starting = null;
198
+ this.tabs.clear();
199
+ this.activeTabId = null;
200
+ this.contextRouteInstalled = false;
201
+ this.securedPages = new WeakSet();
202
+ this.pageStates = new WeakMap();
203
+ if (session)
204
+ await session.close().catch(() => { });
205
+ }
206
+ async start() {
207
+ if (this.session)
208
+ return;
209
+ if (this.starting)
210
+ return this.starting;
211
+ this.starting = (async () => {
212
+ const verdict = await this.ensureBrowserCapability();
213
+ if (!verdict.ok)
214
+ throw new BrowserCapabilityError(verdict.reason ?? 'Browser capability is unavailable', verdict.requiresConsent);
215
+ const runtime = this.resolveRuntime();
216
+ process.env.CAMOUFOX_INSTALL_DIR = runtime.installDirectory;
217
+ await fs.mkdir(this.profileDirectory, { recursive: true, mode: 0o700 });
218
+ const module = await this.loadBrowserEngine();
219
+ if (!module || typeof module.Camoufox !== 'function')
220
+ throw new Error('Causyne Browser has no usable launcher');
221
+ this.session = await module.Camoufox({
222
+ headless: !this.visible,
223
+ user_data_dir: this.profileDirectory,
224
+ humanize: 0.35,
225
+ block_webrtc: true,
226
+ geoip: false,
227
+ enable_cache: true
228
+ });
229
+ if (typeof this.session.route === 'function') {
230
+ await this.session.route('**/*', route => this.guardRoute(route));
231
+ this.contextRouteInstalled = true;
232
+ }
233
+ this.session.on?.('page', page => { void this.registerPage(page).catch(() => { }); });
234
+ const existing = this.session.pages().find(page => !page.isClosed());
235
+ if (existing)
236
+ await this.registerPage(existing);
237
+ else
238
+ await this.createTab();
239
+ })();
240
+ try {
241
+ await this.starting;
242
+ }
243
+ finally {
244
+ this.starting = null;
245
+ }
246
+ }
247
+ async registerPage(page) {
248
+ const known = this.pageStates.get(page);
249
+ if (known) {
250
+ this.activeTabId = known.id;
251
+ return known;
252
+ }
253
+ const id = crypto.randomUUID();
254
+ page.setDefaultTimeout(ACTION_TIMEOUT_MS);
255
+ if (!this.contextRouteInstalled && !this.securedPages.has(page)) {
256
+ await page.route('**/*', route => this.guardRoute(route));
257
+ this.securedPages.add(page);
258
+ }
259
+ const state = { id, page, refs: new Map(), lastSnapshot: '' };
260
+ this.pageStates.set(page, state);
261
+ this.tabs.set(id, state);
262
+ this.activeTabId = id;
263
+ return state;
264
+ }
265
+ async guardRoute(route) {
266
+ const url = route.request().url();
267
+ if (/^(about:|data:|blob:|moz-extension:)/i.test(url))
268
+ return route.continue();
269
+ try {
270
+ const parsed = new URL(url);
271
+ if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
272
+ parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:';
273
+ await this.validateUrl(parsed.href);
274
+ }
275
+ else {
276
+ await this.validateUrl(url);
277
+ }
278
+ await route.continue();
279
+ }
280
+ catch {
281
+ await route.abort('blockedbyclient');
282
+ }
283
+ }
284
+ async createTab(url) {
285
+ if (!this.session)
286
+ throw new Error('Browser is not started');
287
+ const state = await this.registerPage(await this.session.newPage());
288
+ if (url)
289
+ await this.navigate(state, url);
290
+ return state;
291
+ }
292
+ async activeTab() {
293
+ const active = this.activeTabId ? this.tabs.get(this.activeTabId) : undefined;
294
+ if (active && !active.page.isClosed())
295
+ return active;
296
+ return this.createTab();
297
+ }
298
+ requestedTab(action) {
299
+ if (!action.tabId)
300
+ return null;
301
+ const state = this.tabs.get(action.tabId);
302
+ if (!state || state.page.isClosed())
303
+ throw new Error(`Unknown or closed browser tab: ${action.tabId}`);
304
+ this.activeTabId = state.id;
305
+ return state;
306
+ }
307
+ async navigate(state, value) {
308
+ const url = await this.validateUrl(value);
309
+ const response = await state.page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
310
+ const status = response?.status() ?? 0;
311
+ if (status >= 400)
312
+ throw new Error(`Destination returned HTTP ${status}`);
313
+ await state.page.waitForLoadState('domcontentloaded', { timeout: NAVIGATION_TIMEOUT_MS });
314
+ await this.validateUrl(state.page.url());
315
+ state.refs.clear();
316
+ state.lastSnapshot = '';
317
+ }
318
+ locatorFor(state, action) {
319
+ const refId = action.ref ?? (action.index !== undefined ? `e${action.index}` : undefined);
320
+ if (refId) {
321
+ const ref = state.refs.get(refId);
322
+ if (!ref)
323
+ throw new Error(`Unknown element ref ${refId}. Take a new snapshot before interacting.`);
324
+ return state.page.getByRole(ref.role, { name: ref.name, exact: true }).nth(ref.nth);
325
+ }
326
+ if (action.selector)
327
+ return state.page.locator(action.selector).first();
328
+ throw new Error(`${action.type} requires a current ref or selector`);
329
+ }
330
+ async saveDownload(state, action) {
331
+ if (!action.outputFile)
332
+ throw new Error('download requires outputFile');
333
+ const target = path.resolve(this.workspace, action.outputFile);
334
+ if (!isWithin(this.workspace, target))
335
+ throw new Error('Download target must stay inside the selected workspace');
336
+ const locator = this.locatorFor(state, action);
337
+ const [download] = await Promise.all([
338
+ state.page.waitForEvent('download', { timeout: NAVIGATION_TIMEOUT_MS }),
339
+ locator.click({ timeout: ACTION_TIMEOUT_MS })
340
+ ]);
341
+ // outputFile is an explicit file destination. Never let a server-selected
342
+ // filename alter the caller's workspace-scoped target.
343
+ const finalTarget = target;
344
+ const temporary = `${finalTarget}.part-${crypto.randomUUID()}`;
345
+ await fs.mkdir(path.dirname(finalTarget), { recursive: true });
346
+ try {
347
+ await download.saveAs(temporary);
348
+ const failure = await download.failure();
349
+ if (failure)
350
+ throw new Error(failure);
351
+ const stat = await fs.stat(temporary);
352
+ if (stat.size > MAX_DOWNLOAD_BYTES)
353
+ throw new Error(`Download exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
354
+ await fs.rename(temporary, finalTarget);
355
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: `Downloaded ${stat.size} bytes to ${finalTarget}` };
356
+ }
357
+ finally {
358
+ await fs.unlink(temporary).catch(() => { });
359
+ }
360
+ }
361
+ async saveScreenshot(state, action) {
362
+ if (!action.outputFile)
363
+ throw new Error('screenshot requires outputFile');
364
+ const target = path.resolve(this.workspace, action.outputFile);
365
+ if (!isWithin(this.workspace, target))
366
+ throw new Error('Screenshot target must stay inside the selected workspace');
367
+ const extension = path.extname(target).toLowerCase();
368
+ if (!['.png', '.jpg', '.jpeg'].includes(extension))
369
+ throw new Error('Screenshot outputFile must end in .png, .jpg, or .jpeg');
370
+ const bytes = await state.page.screenshot({
371
+ fullPage: action.fullPage ?? false,
372
+ type: extension === '.png' ? 'png' : 'jpeg'
373
+ });
374
+ if (bytes.byteLength > MAX_SCREENSHOT_BYTES)
375
+ throw new Error(`Screenshot exceeds ${MAX_SCREENSHOT_BYTES} bytes`);
376
+ const temporary = `${target}.part-${crypto.randomUUID()}`;
377
+ await fs.mkdir(path.dirname(target), { recursive: true });
378
+ try {
379
+ await fs.writeFile(temporary, bytes, { mode: 0o600 });
380
+ await fs.rename(temporary, target);
381
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: `Saved ${bytes.byteLength}-byte screenshot to ${target}` };
382
+ }
383
+ finally {
384
+ await fs.unlink(temporary).catch(() => { });
385
+ }
386
+ }
387
+ async runAction(action) {
388
+ if (!action || typeof action.type !== 'string')
389
+ throw new Error('Every browser action needs a type');
390
+ if (action.type === 'newTab') {
391
+ const state = await this.createTab(action.url);
392
+ return { action: action.type, tabId: state.id, url: state.page.url(), title: await state.page.title() };
393
+ }
394
+ if (action.type === 'listTabs') {
395
+ const data = await Promise.all([...this.tabs.values()].filter(tab => !tab.page.isClosed()).map(async (tab) => ({
396
+ tabId: tab.id, active: tab.id === this.activeTabId, url: tab.page.url(), title: await tab.page.title()
397
+ })));
398
+ return { action: action.type, data };
399
+ }
400
+ const requested = this.requestedTab(action);
401
+ const state = requested ?? await this.activeTab();
402
+ if (action.type === 'switchTab')
403
+ return { action: action.type, tabId: state.id, url: state.page.url(), title: await state.page.title() };
404
+ if (action.type === 'closeTab') {
405
+ await state.page.close();
406
+ this.tabs.delete(state.id);
407
+ if (this.activeTabId === state.id)
408
+ this.activeTabId = null;
409
+ return { action: action.type, tabId: state.id, output: 'Tab closed' };
410
+ }
411
+ if (action.type === 'search') {
412
+ const results = await this.search(action.query ?? action.text ?? '');
413
+ const active = await this.activeTab();
414
+ return { action: action.type, tabId: active.id, url: active.page.url(), data: results };
415
+ }
416
+ if (action.type === 'goto') {
417
+ if (!action.url)
418
+ throw new Error('goto requires url');
419
+ await this.navigate(state, action.url);
420
+ return { action: action.type, tabId: state.id, url: state.page.url(), title: await state.page.title() };
421
+ }
422
+ if (action.type === 'snapshot' || action.type === 'scrape') {
423
+ const snapshot = await this.snapshot(action.offset ?? 0);
424
+ return { action: action.type, tabId: snapshot.tabId, url: snapshot.url, title: snapshot.title, output: snapshot.text, data: {
425
+ refsCount: snapshot.refsCount, truncated: snapshot.truncated, totalChars: snapshot.totalChars, nextOffset: snapshot.nextOffset
426
+ } };
427
+ }
428
+ if (action.type === 'click' || action.type === 'clickIndex') {
429
+ await this.locatorFor(state, action).click({ timeout: ACTION_TIMEOUT_MS });
430
+ state.refs.clear();
431
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: 'Element clicked' };
432
+ }
433
+ if (action.type === 'type' || action.type === 'typeIndex') {
434
+ if (action.text === undefined)
435
+ throw new Error(`${action.type} requires text`);
436
+ await this.locatorFor(state, action).fill(action.text, { timeout: ACTION_TIMEOUT_MS });
437
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: 'Text entered' };
438
+ }
439
+ if (action.type === 'selectOption') {
440
+ const value = action.value ?? action.text;
441
+ if (value === undefined)
442
+ throw new Error('selectOption requires value');
443
+ await this.locatorFor(state, action).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
444
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: 'Option selected' };
445
+ }
446
+ if (action.type === 'keyPress') {
447
+ await state.page.keyboard.press(action.key ?? 'Enter');
448
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: `Pressed ${action.key ?? 'Enter'}` };
449
+ }
450
+ if (action.type === 'scroll') {
451
+ const amount = Math.max(1, Math.min(10_000, Math.floor(action.amount ?? 700)));
452
+ const direction = action.direction ?? 'down';
453
+ const x = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
454
+ const y = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
455
+ await state.page.mouse.wheel(x, y);
456
+ return { action: action.type, tabId: state.id, url: state.page.url(), output: `Scrolled ${direction} ${amount}px` };
457
+ }
458
+ if (action.type === 'back')
459
+ await state.page.goBack({ waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
460
+ else if (action.type === 'forward')
461
+ await state.page.goForward({ waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
462
+ else if (action.type === 'reload')
463
+ await state.page.reload({ waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT_MS });
464
+ else if (action.type === 'extract') {
465
+ const selector = action.selector ?? 'body';
466
+ const output = boundedText(await state.page.locator(selector).first().innerText({ timeout: ACTION_TIMEOUT_MS }));
467
+ return { action: action.type, tabId: state.id, url: state.page.url(), output };
468
+ }
469
+ else if (action.type === 'download')
470
+ return this.saveDownload(state, action);
471
+ else if (action.type === 'screenshot')
472
+ return this.saveScreenshot(state, action);
473
+ else
474
+ throw new Error(`Unsupported browser action: ${action.type}`);
475
+ state.refs.clear();
476
+ return { action: action.type, tabId: state.id, url: state.page.url(), title: await state.page.title() };
477
+ }
478
+ touch() {
479
+ if (this.idleTimer)
480
+ clearTimeout(this.idleTimer);
481
+ this.idleTimer = setTimeout(() => { void this.close(); }, IDLE_CLOSE_MS);
482
+ this.idleTimer.unref?.();
483
+ }
484
+ }
485
+ let sharedBrowser = null;
486
+ export function causyneBrowser() {
487
+ sharedBrowser ??= new CausyneBrowser();
488
+ return sharedBrowser;
489
+ }
490
+ export async function closeCausyneBrowser() {
491
+ const browser = sharedBrowser;
492
+ sharedBrowser = null;
493
+ if (browser)
494
+ await browser.close();
495
+ }