@causyne/agent 1.10.12 → 1.10.17

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