@johpaz/hive-sdk 0.0.16 → 0.0.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 (31) hide show
  1. package/README.md +83 -203
  2. package/bun.lock +543 -0
  3. package/bunfig.toml +7 -0
  4. package/docs/API-TOOLS-SKILLS-CHANNELS.md +61 -1
  5. package/docs/API-WORKERS-EVENTS.md +3 -3
  6. package/docs/INDEX.md +2 -2
  7. package/docs/TEMPLATE-HIVE-APP.md +6 -6
  8. package/package.json +2 -2
  9. package/packages/cli/src/index.ts +1 -1
  10. package/packages/core/src/agent/selectors/ToolSelector.ts +1 -0
  11. package/packages/core/src/api/createAgent.ts +10 -0
  12. package/packages/core/src/config/loader.ts +2 -2
  13. package/packages/core/src/index.ts +13 -0
  14. package/packages/core/src/skills/bundled-data.generated.ts +50 -0
  15. package/packages/core/src/skills/skills.test.ts +21 -0
  16. package/packages/core/src/tools/index.ts +1 -0
  17. package/packages/core/src/tools/web/api-request.test.ts +170 -0
  18. package/packages/core/src/tools/web/api-request.ts +239 -0
  19. package/packages/core/src/tools/web/browser-click.ts +2 -2
  20. package/packages/core/src/tools/web/browser-extract.ts +22 -6
  21. package/packages/core/src/tools/web/browser-navigate.ts +34 -18
  22. package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
  23. package/packages/core/src/tools/web/browser-script.ts +2 -2
  24. package/packages/core/src/tools/web/browser-service.test.ts +83 -0
  25. package/packages/core/src/tools/web/browser-service.ts +290 -341
  26. package/packages/core/src/tools/web/browser-type.ts +2 -2
  27. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  28. package/packages/core/src/tools/web/index.ts +3 -0
  29. package/CHANGELOG.md +0 -64
  30. package/docs/README.md +0 -161
  31. /package/packages/cli/bin/{hive → hives} +0 -0
@@ -1,234 +1,166 @@
1
1
  /**
2
- * BrowserService — lanza Chrome/Brave VISIBLE y lo controla via CDP (WebSocket).
2
+ * BrowserService — Browser automation via agent-browser CLI (Rust).
3
3
  *
4
4
  * Flujo:
5
- * 1. Detecta el browser instalado (nativo o Flatpak).
6
- * 2. Lo lanza con Bun.spawn + --remote-debugging-port=9222.
7
- * 3. CDPClient conecta via WebSocket al DevTools endpoint.
8
- * 4. Todas las herramientas de browser usan CDPClient como si fuera Puppeteer/Playwright.
5
+ * 1. Detecta si agent-browser está instalado (lazy install en primer uso).
6
+ * 2. Ejecuta comandos via CLI con --json para output estructurado.
7
+ * 3. El daemon de agent-browser maneja Chrome internamente via CDP.
8
+ * 4. Las herramientas de browser usan AgentBrowserView (API compatible con CDPClient).
9
9
  */
10
10
 
11
11
  import { logger } from "../../utils/logger.ts";
12
12
  import type { Config } from "../../config/loader.ts";
13
- import { existsSync, writeFileSync, chmodSync } from "fs";
14
- import { tmpdir } from "os";
13
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
14
+ import { homedir, tmpdir } from "os";
15
+ import { dirname, join, resolve } from "path";
15
16
 
16
17
  const log = logger.child("browser-service");
17
18
 
18
- // ─── Detección del browser ────────────────────────────────────────────────────
19
-
20
- const FLATPAK_BROWSERS = [
21
- "com.google.Chrome",
22
- "com.brave.Browser",
23
- "org.chromium.Chromium",
24
- "com.microsoft.Edge",
25
- ];
26
-
27
- const NATIVE_PATHS: Record<string, string[]> = {
28
- linux: [
29
- "/usr/bin/google-chrome",
30
- "/usr/bin/google-chrome-stable",
31
- "/usr/bin/brave-browser",
32
- "/usr/bin/brave",
33
- "/usr/bin/chromium-browser",
34
- "/usr/bin/chromium",
35
- "/usr/bin/microsoft-edge",
36
- "/snap/bin/chromium",
37
- ],
38
- darwin: [
39
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
40
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
41
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
42
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
43
- `${process.env.HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
44
- ],
45
- win32: [
46
- `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
47
- `${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
48
- `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
49
- `${process.env.LOCALAPPDATA}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`,
50
- `${process.env.PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
51
- ],
52
- };
53
-
54
- export type LaunchSpec =
55
- | { kind: "native"; path: string }
56
- | { kind: "flatpak"; appId: string };
57
-
58
- export function detectBrowser(): LaunchSpec | undefined {
59
- if (process.env.BUN_CHROME_PATH && existsSync(process.env.BUN_CHROME_PATH)) {
60
- return { kind: "native", path: process.env.BUN_CHROME_PATH };
61
- }
62
- const platform = process.platform as string;
63
- const natives = (NATIVE_PATHS[platform] ?? NATIVE_PATHS.linux).filter(Boolean);
64
- const found = natives.find(p => existsSync(p));
65
- if (found) return { kind: "native", path: found };
66
-
67
- if (platform === "linux" && existsSync("/usr/bin/flatpak")) {
68
- for (const appId of FLATPAK_BROWSERS) {
69
- const r = Bun.spawnSync(["flatpak", "info", appId], { stdout: "pipe", stderr: "pipe" });
70
- if (r.exitCode === 0) return { kind: "flatpak", appId };
71
- }
19
+ // ─── Instalación lazy de agent-browser ────────────────────────────────────────
20
+
21
+ const HIVE_DIR = join(homedir(), ".hive");
22
+ const AGENT_BROWSER_DIR = join(HIVE_DIR, "agent-browser");
23
+ const AGENT_BROWSER_PKG_JSON = join(AGENT_BROWSER_DIR, "package.json");
24
+ const DEFAULT_SESSION_NAME = "hive";
25
+
26
+ /** Check if agent-browser is installed in the cache dir by running --version */
27
+ async function isAgentBrowserInstalled(): Promise<boolean> {
28
+ if (!existsSync(AGENT_BROWSER_PKG_JSON)) return false;
29
+ try {
30
+ const proc = Bun.spawn(["bun", "run", "agent-browser", "--version"], {
31
+ cwd: AGENT_BROWSER_DIR,
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ const exitCode = await proc.exited;
36
+ return exitCode === 0;
37
+ } catch {
38
+ return false;
72
39
  }
73
40
  }
74
41
 
75
- // ─── CDP Client ───────────────────────────────────────────────────────────────
42
+ async function installAgentBrowser(): Promise<void> {
43
+ mkdirSync(AGENT_BROWSER_DIR, { recursive: true });
76
44
 
77
- const CDP_PORT = 9222;
78
- const allInstances = new Set<CDPClient>();
45
+ // Create minimal package.json
46
+ const pkg = { name: "hive-agent-browser", version: "1.0.0", dependencies: {} };
47
+ await Bun.write(AGENT_BROWSER_PKG_JSON, JSON.stringify(pkg, null, 2));
79
48
 
80
- export class CDPClient {
81
- private ws: WebSocket | null = null;
82
- private proc: ReturnType<typeof Bun.spawn> | null = null;
83
- private cmdId = 0;
84
- private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
85
- private _url = "";
86
- private _focusedSelector: string | null = null;
49
+ log.info("📦 Instalando agent-browser (primera vez, ~75MB)...");
50
+ const proc = Bun.spawn(["bun", "add", "agent-browser@latest"], {
51
+ cwd: AGENT_BROWSER_DIR,
52
+ stdout: "pipe",
53
+ stderr: "pipe",
54
+ });
87
55
 
88
- get url(): string { return this._url; }
89
- get title(): string { return ""; }
90
- get loading(): boolean { return false; }
91
- get isConnected(): boolean { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; }
92
-
93
- // ── Launch ──────────────────────────────────────────────────────────────────
94
-
95
- async launch(spec: LaunchSpec): Promise<void> {
96
- const commonArgs = [
97
- `--remote-debugging-port=${CDP_PORT}`,
98
- "--no-first-run",
99
- "--no-default-browser-check",
100
- "--disable-popup-blocking",
101
- `--user-data-dir=${tmpdir()}/hive-browser-profile`,
102
- "about:blank",
103
- ];
104
-
105
- if (spec.kind === "native") {
106
- this.proc = Bun.spawn([spec.path, ...commonArgs], {
107
- stdout: "ignore",
108
- stderr: "ignore",
109
- });
110
- log.info(`Lanzando browser nativo: ${spec.path} (PID ${this.proc.pid})`);
111
- } else {
112
- this.proc = Bun.spawn(["flatpak", "run", spec.appId, ...commonArgs], {
113
- stdout: "ignore",
114
- stderr: "ignore",
115
- });
116
- log.info(`Lanzando Flatpak ${spec.appId} (PID ${this.proc.pid})`);
117
- }
56
+ const exitCode = await proc.exited;
57
+ const stderr = await new Response(proc.stderr).text();
118
58
 
119
- await this._waitForCDP();
120
- await this._connect();
121
- allInstances.add(this);
59
+ if (exitCode !== 0) {
60
+ throw new Error(`bun add agent-browser failed: ${stderr}`);
122
61
  }
123
62
 
124
- // ── CDP WebSocket ───────────────────────────────────────────────────────────
63
+ log.info("✅ agent-browser instalado.");
64
+ }
125
65
 
126
- private async _waitForCDP(timeout = 15000): Promise<void> {
127
- const deadline = Date.now() + timeout;
128
- while (Date.now() < deadline) {
129
- try {
130
- const r = await fetch(`http://localhost:${CDP_PORT}/json/version`);
131
- if (r.ok) return;
132
- } catch { /* not ready yet */ }
133
- await new Promise<void>(r => setTimeout(r, 300));
134
- }
135
- throw new Error(`CDP no respondió en ${timeout}ms en puerto ${CDP_PORT}`);
136
- }
137
-
138
- private async _connect(): Promise<void> {
139
- const r = await fetch(`http://localhost:${CDP_PORT}/json`);
140
- const targets = await r.json() as Array<{ type: string; webSocketDebuggerUrl: string }>;
141
- const target = targets.find(t => t.type === "page") ?? targets[0];
142
- if (!target?.webSocketDebuggerUrl) throw new Error("No hay target CDP disponible");
143
-
144
- await new Promise<void>((resolve, reject) => {
145
- const ws = new WebSocket(target.webSocketDebuggerUrl);
146
- ws.onopen = () => {
147
- this.ws = ws;
148
- ws.onmessage = (ev: MessageEvent) => {
149
- const msg = JSON.parse(ev.data as string) as {
150
- id?: number;
151
- result?: unknown;
152
- error?: { message: string };
153
- };
154
- if (msg.id !== undefined) {
155
- const p = this.pending.get(msg.id);
156
- if (p) {
157
- this.pending.delete(msg.id);
158
- if (msg.error) p.reject(new Error(msg.error.message));
159
- else p.resolve(msg.result ?? {});
160
- }
161
- }
162
- };
163
- resolve();
164
- };
165
- ws.onerror = () => reject(new Error("WebSocket CDP falló al conectar"));
166
- ws.onclose = () => {
167
- // Rechazar todos los pendientes
168
- for (const p of this.pending.values()) p.reject(new Error("CDP WebSocket cerrado"));
169
- this.pending.clear();
170
- };
171
- });
66
+ /** Run agent-browser CLI from the cache directory — cross-platform via bun run */
67
+ async function runAgentBrowser(
68
+ args: string[]
69
+ ): Promise<{ success: boolean; data?: any; error?: string }> {
70
+ const proc = Bun.spawn(["bun", "run", "agent-browser", ...args], {
71
+ cwd: AGENT_BROWSER_DIR,
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ });
172
75
 
173
- await this.cdp("Page.enable");
174
- await this.cdp("Runtime.enable");
76
+ const stdout = await new Response(proc.stdout).text();
77
+ const stderr = await new Response(proc.stderr).text();
78
+ const exitCode = await proc.exited;
79
+
80
+ if (exitCode !== 0 && !stdout.trim()) {
81
+ throw new Error(stderr || `agent-browser ${args[0]} failed`);
175
82
  }
176
83
 
177
- // ── CDP raw command ─────────────────────────────────────────────────────────
84
+ try {
85
+ const result = JSON.parse(stdout.trim().split("\n").pop() || "{}");
86
+ return result;
87
+ } catch {
88
+ return { success: true, data: { raw: stdout.trim() } };
89
+ }
90
+ }
178
91
 
179
- async cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
180
- if (!this.ws) throw new Error("CDP no conectado");
181
- const id = ++this.cmdId;
182
- return new Promise<T>((resolve, reject) => {
183
- this.pending.set(id, {
184
- resolve: v => resolve(v as T),
185
- reject,
92
+ async function ensureChromeInstalled(): Promise<void> {
93
+ log.info("🔍 Verificando Chrome para agent-browser...");
94
+ const res = await runAgentBrowser(["open", "about:blank", "--session", DEFAULT_SESSION_NAME, "--json"]);
95
+
96
+ if (!res.success) {
97
+ const err = res.error || "";
98
+ // Chrome not installed — trigger install
99
+ if (err.includes("not found") || err.includes("install")) {
100
+ log.info("📥 Descargando Chrome (agent-browser install)...");
101
+ const installProc = Bun.spawn(["bun", "run", "agent-browser", "install"], {
102
+ cwd: AGENT_BROWSER_DIR,
103
+ stdout: "pipe",
104
+ stderr: "pipe",
186
105
  });
187
- this.ws!.send(JSON.stringify({ id, method, params: params ?? {} }));
188
- });
106
+ const installExit = await installProc.exited;
107
+ if (installExit !== 0) {
108
+ const installErr = await new Response(installProc.stderr).text();
109
+ throw new Error(`agent-browser install failed: ${installErr}`);
110
+ }
111
+ log.info("✅ Chrome descargado.");
112
+ return;
113
+ }
114
+ throw new Error(`agent-browser chrome check failed: ${err}`);
189
115
  }
116
+ }
190
117
 
191
- // ── navigate ────────────────────────────────────────────────────────────────
118
+ // ─── AgentBrowserView (API compatible con CDPClient) ──────────────────────────
192
119
 
193
- async navigate(url: string): Promise<void> {
194
- this._focusedSelector = null;
195
- await this.cdp("Page.navigate", { url });
196
- // Esperar hasta document.readyState === 'complete'
197
- const deadline = Date.now() + 30000;
198
- while (Date.now() < deadline) {
199
- await new Promise<void>(r => setTimeout(r, 150));
200
- try {
201
- const res = await this.cdp<{ result: { value: string } }>("Runtime.evaluate", {
202
- expression: "document.readyState",
203
- returnByValue: true,
204
- });
205
- if (res.result?.value === "complete") break;
206
- } catch { /* continuar */ }
207
- }
208
- // Actualizar URL real (puede haber redirect)
209
- try {
210
- const res = await this.cdp<{ result: { value: string } }>("Runtime.evaluate", {
211
- expression: "location.href",
212
- returnByValue: true,
213
- });
214
- this._url = res.result?.value || url;
215
- } catch {
216
- this._url = url;
217
- }
120
+ export class AgentBrowserView {
121
+ private sessionName: string;
122
+ private _url = "";
123
+
124
+ get url(): string { return this._url; }
125
+ get title(): string { return ""; }
126
+ get loading(): boolean { return false; }
127
+ get isConnected(): boolean { return true; }
128
+
129
+ constructor(sessionName: string = DEFAULT_SESSION_NAME) {
130
+ this.sessionName = sessionName;
218
131
  }
219
132
 
220
- // ── evaluate ────────────────────────────────────────────────────────────────
133
+ protected async run(args: string[]): Promise<{ success: boolean; data?: any; error?: string }> {
134
+ return runAgentBrowser(["--session", this.sessionName, "--json", ...args]);
135
+ }
221
136
 
222
- async evaluate<T = unknown>(script: string): Promise<T> {
223
- const res = await this.cdp<{ result: { value: T } }>("Runtime.evaluate", {
224
- expression: `(async () => { return (${script}) })()`,
225
- returnByValue: true,
226
- awaitPromise: true,
227
- });
228
- return res.result?.value as T;
137
+ async navigate(url: string): Promise<void> {
138
+ // Ensure protocol
139
+ const target = /^https?:\/\//.test(url) ? url : `https://${url}`;
140
+ const res = await this.run(["open", target]);
141
+ if (!res.success) throw new Error(res.error || "navigate failed");
142
+ this._url = res.data?.url || target;
143
+ // Small delay to let JS settle (same as old implementation)
144
+ await new Promise(r => setTimeout(r, 500));
229
145
  }
230
146
 
231
- // ── screenshot ──────────────────────────────────────────────────────────────
147
+ async evaluate<T = unknown>(script: string): Promise<T> {
148
+ let wrapped = script;
149
+ const trimmed = script.trim();
150
+
151
+ // If script contains top-level await, wrap in async IIFE to make it valid JS
152
+ if (/\bawait\b/.test(script) && !trimmed.startsWith("(async") && !trimmed.startsWith("async function")) {
153
+ if (trimmed.startsWith("return")) {
154
+ wrapped = `(async () => { ${script} })()`;
155
+ } else {
156
+ wrapped = `(async () => { return ${script}; })()`;
157
+ }
158
+ }
159
+
160
+ const res = await this.run(["eval", wrapped]);
161
+ if (!res.success) throw new Error(res.error || "eval failed");
162
+ return res.data?.result as T;
163
+ }
232
164
 
233
165
  async screenshot(options?: {
234
166
  encoding?: "blob" | "buffer" | "base64" | "shmem";
@@ -236,156 +168,172 @@ export class CDPClient {
236
168
  quality?: number;
237
169
  clip?: { x: number; y: number; width: number; height: number; scale: number };
238
170
  }): Promise<string> {
239
- const params: Record<string, unknown> = {
240
- format: options?.format ?? "png",
241
- };
242
- if (options?.quality) params.quality = options.quality;
243
- if (options?.clip) params.clip = options.clip;
171
+ // Build args
172
+ const args: string[] = ["screenshot"];
244
173
 
245
- const res = await this.cdp<{ data: string }>("Page.captureScreenshot", params);
246
- return res.data;
247
- }
174
+ if (options?.format === "jpeg") {
175
+ args.push("--screenshot-format", "jpeg");
176
+ }
177
+ if (options?.quality) {
178
+ args.push("--screenshot-quality", String(options.quality));
179
+ }
248
180
 
249
- // ── click ───────────────────────────────────────────────────────────────────
181
+ const res = await this.run(args);
182
+ if (!res.success) throw new Error(res.error || "screenshot failed");
250
183
 
251
- async click(selector: string, _options?: Record<string, unknown>): Promise<void> {
252
- // 1. Verificar que el elemento existe y obtener coordenadas para visual feedback
253
- const box = await this.evaluate<{ x: number; y: number; width: number; height: number } | null>(`
254
- (() => {
255
- const el = document.querySelector(${JSON.stringify(selector)});
256
- if (!el) return null;
257
- el.scrollIntoView({ behavior: "instant", block: "center" });
258
- const r = el.getBoundingClientRect();
259
- return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), width: r.width, height: r.height };
260
- })()
261
- `);
262
- if (!box) throw new Error(`Selector no encontrado: ${selector}`);
184
+ const path = res.data?.path as string;
185
+ if (!path) throw new Error("screenshot did not return a path");
263
186
 
264
- // 2. Mover el cursor CDP al elemento (visual feedback en el browser visible)
265
- await this.cdp("Input.dispatchMouseEvent", {
266
- type: "mouseMoved", x: box.x, y: box.y, button: "none",
267
- });
187
+ const data = readFileSync(path);
188
+ const base64 = Buffer.from(data).toString("base64");
268
189
 
269
- // 3. element.click() para trigger fiable de onclick/event listeners
270
- await this.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
271
- this._focusedSelector = selector;
190
+ // Cleanup temp file
191
+ try { rmSync(path); } catch { /* ignore */ }
192
+
193
+ return base64;
272
194
  }
273
195
 
274
- // ── type ────────────────────────────────────────────────────────────────────
196
+ async click(selector: string, _options?: Record<string, unknown>): Promise<void> {
197
+ const res = await this.run(["click", selector]);
198
+ if (!res.success) throw new Error(res.error || `click failed: ${selector}`);
199
+ }
275
200
 
276
201
  async type(text: string): Promise<void> {
277
- // Si sabemos qué elemento fue clickeado, escribimos directamente en él.
278
- // Esto es más fiable que Input.insertText o dispatchKeyEvent char, que
279
- // dependen de que CDP tenga el focus sincronizado correctamente.
280
- if (this._focusedSelector) {
281
- const sel = this._focusedSelector;
282
- await this.evaluate(`
283
- (() => {
284
- const el = document.querySelector(${JSON.stringify(sel)});
285
- if (!el) return;
286
- const s = el.selectionStart ?? el.value?.length ?? 0;
287
- const e = el.selectionEnd ?? el.value?.length ?? 0;
288
- const before = (el.value ?? "").substring(0, s);
289
- const after = (el.value ?? "").substring(e);
290
- el.value = before + ${JSON.stringify(text)} + after;
291
- el.selectionStart = el.selectionEnd = before.length + ${JSON.stringify(text)}.length;
292
- el.dispatchEvent(new Event('input', { bubbles: true }));
293
- el.dispatchEvent(new Event('change', { bubbles: true }));
294
- })()
295
- `);
296
- } else {
297
- // Fallback: char events al elemento activo del browser
298
- for (const char of text) {
299
- await this.cdp("Input.dispatchKeyEvent", { type: "char", text: char });
300
- }
301
- }
202
+ // Fallback: keyboard inserttext (requires focused element)
203
+ const res = await this.run(["keyboard", "inserttext", text]);
204
+ if (!res.success) throw new Error(res.error || "type failed");
302
205
  }
303
206
 
304
- // ── press ───────────────────────────────────────────────────────────────────
207
+ async typeIn(selector: string, text: string): Promise<void> {
208
+ const res = await this.run(["type", selector, text]);
209
+ if (!res.success) throw new Error(res.error || `type failed: ${selector}`);
210
+ }
305
211
 
306
- async press(key: string, options?: { modifiers?: string[] }): Promise<void> {
307
- const modifierBits = (options?.modifiers ?? []).reduce((acc, m) => {
308
- if (m === "Alt") return acc | 1;
309
- if (m === "Control" || m === "Meta") return acc | 2;
310
- if (m === "Shift") return acc | 8;
311
- return acc;
312
- }, 0);
313
-
314
- await this.cdp("Input.dispatchKeyEvent", { type: "keyDown", key, modifiers: modifierBits });
315
- // El evento 'char' es necesario para que el navegador procese teclas como Enter
316
- // y dispare comportamientos del DOM (submit de formularios, saltos de línea, etc.)
317
- await this.cdp("Input.dispatchKeyEvent", {
318
- type: "char",
319
- key: key === "Return" || key === "Enter" ? "\r" : key.length === 1 ? key : "",
320
- modifiers: modifierBits,
321
- });
322
- await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key, modifiers: modifierBits });
212
+ async fill(selector: string, text: string): Promise<void> {
213
+ const res = await this.run(["fill", selector, text]);
214
+ if (!res.success) throw new Error(res.error || `fill failed: ${selector}`);
323
215
  }
324
216
 
325
- // ── scroll ──────────────────────────────────────────────────────────────────
217
+ async press(key: string, options?: { modifiers?: string[] }): Promise<void> {
218
+ const modifiers = options?.modifiers ?? [];
219
+ const combo = modifiers.length > 0
220
+ ? `${modifiers.join("+")}+${key}`
221
+ : key;
222
+ const res = await this.run(["press", combo]);
223
+ if (!res.success) throw new Error(res.error || `press failed: ${combo}`);
224
+ }
326
225
 
327
226
  async scroll(dx: number, dy: number): Promise<void> {
328
- await this.evaluate(`window.scrollBy(${dx}, ${dy})`);
227
+ const dir = dy > 0 ? "down" : dy < 0 ? "up" : dx > 0 ? "right" : "left";
228
+ const px = Math.abs(dy || dx);
229
+ const res = await this.run(["scroll", dir, String(px)]);
230
+ if (!res.success) throw new Error(res.error || "scroll failed");
329
231
  }
330
232
 
331
- async scrollTo(selector: string, options?: { behavior?: "smooth" | "instant" }): Promise<void> {
332
- const behavior = options?.behavior ?? "smooth";
333
- await this.evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({ behavior: ${JSON.stringify(behavior)}, block: "center" })`);
233
+ async scrollTo(selector: string, _options?: { behavior?: "smooth" | "instant" }): Promise<void> {
234
+ // agent-browser has scrollintoview (behavior not supported via CLI)
235
+ const res = await this.run(["scrollintoview", selector]);
236
+ if (!res.success) throw new Error(res.error || `scrollTo failed: ${selector}`);
334
237
  }
335
238
 
336
- // ── navigation helpers ──────────────────────────────────────────────────────
337
-
338
239
  async back(): Promise<void> {
339
- await this.evaluate("history.back()");
240
+ const res = await this.run(["back"]);
241
+ if (!res.success) throw new Error(res.error || "back failed");
340
242
  await new Promise<void>(r => setTimeout(r, 800));
341
243
  }
342
244
 
343
245
  async forward(): Promise<void> {
344
- await this.evaluate("history.forward()");
246
+ const res = await this.run(["forward"]);
247
+ if (!res.success) throw new Error(res.error || "forward failed");
345
248
  await new Promise<void>(r => setTimeout(r, 800));
346
249
  }
347
250
 
348
251
  async reload(): Promise<void> {
349
- await this.cdp("Page.reload");
252
+ const res = await this.run(["reload"]);
253
+ if (!res.success) throw new Error(res.error || "reload failed");
350
254
  await new Promise<void>(r => setTimeout(r, 1000));
351
255
  }
352
256
 
353
257
  async resize(width: number, height: number): Promise<void> {
354
- await this.cdp("Emulation.setDeviceMetricsOverride", {
355
- width, height, deviceScaleFactor: 1, mobile: false,
356
- });
258
+ const res = await this.run(["set", "viewport", String(width), String(height)]);
259
+ if (!res.success) throw new Error(res.error || "resize failed");
260
+ }
261
+
262
+ /** Capture accessibility tree snapshot (compact, AI-optimized). ~200-600 chars vs ~3000+ innerText. */
263
+ async snapshot(options?: { compact?: boolean; depth?: number; interactiveOnly?: boolean }): Promise<string> {
264
+ const args = ["snapshot"];
265
+ if (options?.compact !== false) args.push("-c");
266
+ if (options?.depth) args.push("-d", String(options.depth));
267
+ if (options?.interactiveOnly) args.push("-i");
268
+
269
+ const res = await this.run(args);
270
+ if (!res.success) throw new Error(res.error || "snapshot failed");
271
+ return res.data?.snapshot as string || "";
357
272
  }
358
273
 
359
- // ── close ───────────────────────────────────────────────────────────────────
274
+ async cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
275
+ const script = `
276
+ (() => {
277
+ // agent-browser does not expose raw CDP directly via CLI for all methods.
278
+ // For common methods we can emulate; for others we return a notice.
279
+ const method = ${JSON.stringify(method)};
280
+ const params = ${JSON.stringify(params ?? {})};
281
+ return { method, params, note: "CDP passthrough not fully supported by agent-browser CLI" };
282
+ })()
283
+ `;
284
+ const res = await this.run(["eval", script]);
285
+ if (!res.success) throw new Error(res.error || `cdp failed: ${method}`);
286
+ return res.data?.result as T;
287
+ }
360
288
 
361
289
  close(): void {
362
- try { this.ws?.close(); } catch { /* ignore */ }
363
- try { this.proc?.kill(); } catch { /* ignore */ }
364
- this.ws = null;
365
- this.proc = null;
366
- this._url = "";
367
- allInstances.delete(this);
290
+ // Close the session
291
+ this.run(["close"]).catch(() => { /* ignore */ });
292
+ }
293
+ }
294
+
295
+ // ─── Backwards compatibility exports ──────────────────────────────────────────
296
+
297
+ /** @deprecated Use AgentBrowserView instead */
298
+ export class CDPClient extends AgentBrowserView {
299
+ private _launched = false;
300
+
301
+ async launch(_spec?: unknown, _options?: unknown): Promise<void> {
302
+ if (this._launched) return;
303
+ // Verify agent-browser is working by opening about:blank
304
+ const res = await this.run(["open", "about:blank"]);
305
+ if (!res.success) throw new Error(res.error || "Failed to launch agent-browser");
306
+ this._launched = true;
368
307
  }
369
308
 
370
309
  static closeAll(): void {
371
- for (const inst of allInstances) inst.close();
372
- allInstances.clear();
310
+ // agent-browser sessions are managed by the daemon; no explicit cleanup needed
373
311
  }
374
312
  }
375
313
 
314
+ /** @deprecated No longer used — agent-browser handles browser detection internally */
315
+ export function detectBrowser(_options?: unknown): undefined {
316
+ return undefined;
317
+ }
318
+
319
+ /** @deprecated No longer used */
320
+ export type LaunchSpec = { kind: "remote"; cdpUrl: string };
321
+
376
322
  // ─── BrowserService (singleton) ───────────────────────────────────────────────
377
323
 
378
- export type BrowserView = CDPClient;
324
+ export type BrowserView = AgentBrowserView;
379
325
 
380
- let _client: CDPClient | null = null;
381
- let _spec: LaunchSpec | undefined = undefined;
326
+ let _client: AgentBrowserView | null = null;
382
327
  let _available = false;
383
328
  let _launching = false;
384
329
 
385
330
  export class BrowserService {
386
331
  private static instance: BrowserService | null = null;
332
+ private readonly config: Config;
387
333
 
388
- private constructor(_config: Config) {}
334
+ private constructor(config: Config) {
335
+ this.config = config;
336
+ }
389
337
 
390
338
  static getInstance(config: Config): BrowserService {
391
339
  if (!BrowserService.instance) {
@@ -395,40 +343,52 @@ export class BrowserService {
395
343
  }
396
344
 
397
345
  /**
398
- * Probe-only: detect if a browser is installed and mark tools as available.
399
- * Does NOT launch the browser — that happens lazily on first tool use.
346
+ * Probe / lazy install agent-browser.
400
347
  */
401
348
  async start(): Promise<boolean> {
402
- _spec = detectBrowser();
403
- if (!_spec) {
404
- log.warn("Ningún browser Chromium encontrado.");
405
- log.warn(" Linux nativo: sudo dnf install chromium");
406
- log.warn(" Flatpak: flatpak install flathub com.google.Chrome");
407
- log.warn(" Manual: export BUN_CHROME_PATH=/ruta/a/chrome");
349
+ const b = this.config.tools?.browser;
350
+ if (b?.enabled === false) {
351
+ _available = false;
352
+ return false;
353
+ }
354
+
355
+ const installed = await isAgentBrowserInstalled();
356
+
357
+ if (!installed) {
358
+ try {
359
+ await installAgentBrowser();
360
+ } catch (err) {
361
+ log.warn(`No se pudo instalar agent-browser: ${(err as Error).message}`);
362
+ log.warn(" Instalar manualmente: bun add -g agent-browser");
363
+ _available = false;
364
+ return false;
365
+ }
366
+ }
367
+
368
+ try {
369
+ await ensureChromeInstalled();
370
+ } catch (err) {
371
+ log.warn(`Chrome no pudo prepararse: ${(err as Error).message}`);
408
372
  _available = false;
409
373
  return false;
410
374
  }
375
+
411
376
  _available = true;
412
- log.info(`✅ Browser detectado (${_spec.kind === "native" ? _spec.path : _spec.appId}) — se abrirá al primer uso`);
377
+ log.info(" agent-browser listo — se abrirá al primer uso");
413
378
  return true;
414
379
  }
415
380
 
416
- /**
417
- * Lazy launch: called by getView() on first tool use.
418
- */
419
381
  private async _ensureLaunched(): Promise<boolean> {
420
382
  if (_client) return true;
421
- if (!_spec) return false;
422
383
  if (_launching) {
423
- // Wait up to 10s for concurrent launch to finish
424
384
  const deadline = Date.now() + 10000;
425
385
  while (_launching && Date.now() < deadline) await new Promise(r => setTimeout(r, 100));
426
386
  return !!_client;
427
387
  }
428
388
  _launching = true;
429
389
  try {
430
- _client = new CDPClient();
431
- await _client.launch(_spec);
390
+ const sessionName = this.config.tools?.browser?.sessionName ?? DEFAULT_SESSION_NAME;
391
+ _client = new AgentBrowserView(sessionName);
432
392
  log.info("✅ Browser abierto — el usuario verá las acciones del agente");
433
393
  return true;
434
394
  } catch (err) {
@@ -441,25 +401,17 @@ export class BrowserService {
441
401
  }
442
402
  }
443
403
 
444
- async getView(): Promise<CDPClient | null> {
404
+ async getView(): Promise<AgentBrowserView | null> {
445
405
  if (!_available) return null;
446
-
447
- // Health-check: if Chrome was closed by the user or crashed, relaunch on next call
448
- if (_client && !_client.isConnected) {
449
- log.warn("Browser connection lost — relaunching on next tool call");
450
- _client = null;
451
- }
452
-
453
406
  await this._ensureLaunched();
454
407
  return _client;
455
408
  }
456
409
 
457
- /** Sync version — returns existing client only (no launch). Use getView() in tools. */
458
- getViewSync(): CDPClient | null {
410
+ getViewSync(): AgentBrowserView | null {
459
411
  return _client;
460
412
  }
461
413
 
462
- async getPage(): Promise<CDPClient | null> {
414
+ async getPage(): Promise<AgentBrowserView | null> {
463
415
  return this.getView();
464
416
  }
465
417
 
@@ -505,7 +457,7 @@ export function getBrowserService(): BrowserService | null {
505
457
  // ─── Helpers (misma API que antes) ───────────────────────────────────────────
506
458
 
507
459
  export async function waitForSelector(
508
- view: CDPClient,
460
+ view: AgentBrowserView,
509
461
  selector: string,
510
462
  timeout = 30000
511
463
  ): Promise<void> {
@@ -519,7 +471,7 @@ export async function waitForSelector(
519
471
  }
520
472
 
521
473
  export async function waitForCondition(
522
- view: CDPClient,
474
+ view: AgentBrowserView,
523
475
  expression: string,
524
476
  timeout = 30000
525
477
  ): Promise<void> {
@@ -533,22 +485,19 @@ export async function waitForCondition(
533
485
  }
534
486
 
535
487
  export async function screenshotElement(
536
- view: CDPClient,
488
+ view: AgentBrowserView,
537
489
  selector: string
538
490
  ): Promise<string> {
539
- const box = await view.evaluate<{ x: number; y: number; width: number; height: number } | null>(`
540
- (() => {
541
- const el = document.querySelector(${JSON.stringify(selector)});
542
- if (!el) return null;
543
- const r = el.getBoundingClientRect();
544
- return { x: r.left, y: r.top, width: r.width, height: r.height };
545
- })()
546
- `);
547
-
548
- if (!box) throw new Error(`Elemento no encontrado: ${selector}`);
549
-
550
- return view.screenshot({
551
- format: "png",
552
- clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
553
- });
491
+ const res = await (view as any).run(["screenshot", selector]);
492
+ if (!res.success) throw new Error(res.error || `screenshot failed: ${selector}`);
493
+
494
+ const path = res.data?.path as string;
495
+ if (!path) throw new Error("screenshot did not return a path");
496
+
497
+ const data = readFileSync(path);
498
+ const base64 = Buffer.from(data).toString("base64");
499
+
500
+ try { rmSync(path); } catch { /* ignore */ }
501
+
502
+ return base64;
554
503
  }