@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.
- package/README.md +83 -203
- package/bun.lock +543 -0
- package/bunfig.toml +7 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +61 -1
- package/docs/API-WORKERS-EVENTS.md +3 -3
- package/docs/INDEX.md +2 -2
- package/docs/TEMPLATE-HIVE-APP.md +6 -6
- package/package.json +2 -2
- package/packages/cli/src/index.ts +1 -1
- package/packages/core/src/agent/selectors/ToolSelector.ts +1 -0
- package/packages/core/src/api/createAgent.ts +10 -0
- package/packages/core/src/config/loader.ts +2 -2
- package/packages/core/src/index.ts +13 -0
- package/packages/core/src/skills/bundled-data.generated.ts +50 -0
- package/packages/core/src/skills/skills.test.ts +21 -0
- package/packages/core/src/tools/index.ts +1 -0
- package/packages/core/src/tools/web/api-request.test.ts +170 -0
- package/packages/core/src/tools/web/api-request.ts +239 -0
- package/packages/core/src/tools/web/browser-click.ts +2 -2
- package/packages/core/src/tools/web/browser-extract.ts +22 -6
- package/packages/core/src/tools/web/browser-navigate.ts +34 -18
- package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
- package/packages/core/src/tools/web/browser-script.ts +2 -2
- package/packages/core/src/tools/web/browser-service.test.ts +83 -0
- package/packages/core/src/tools/web/browser-service.ts +290 -341
- package/packages/core/src/tools/web/browser-type.ts +2 -2
- package/packages/core/src/tools/web/browser-wait.ts +2 -2
- package/packages/core/src/tools/web/index.ts +3 -0
- package/CHANGELOG.md +0 -64
- package/docs/README.md +0 -161
- /package/packages/cli/bin/{hive → hives} +0 -0
|
@@ -1,234 +1,166 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BrowserService —
|
|
2
|
+
* BrowserService — Browser automation via agent-browser CLI (Rust).
|
|
3
3
|
*
|
|
4
4
|
* Flujo:
|
|
5
|
-
* 1. Detecta
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
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,
|
|
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
|
-
// ───
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
+
async function installAgentBrowser(): Promise<void> {
|
|
43
|
+
mkdirSync(AGENT_BROWSER_DIR, { recursive: true });
|
|
76
44
|
|
|
77
|
-
|
|
78
|
-
const
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
allInstances.add(this);
|
|
59
|
+
if (exitCode !== 0) {
|
|
60
|
+
throw new Error(`bun add agent-browser failed: ${stderr}`);
|
|
122
61
|
}
|
|
123
62
|
|
|
124
|
-
|
|
63
|
+
log.info("✅ agent-browser instalado.");
|
|
64
|
+
}
|
|
125
65
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
// ─── AgentBrowserView (API compatible con CDPClient) ──────────────────────────
|
|
192
119
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
181
|
+
const res = await this.run(args);
|
|
182
|
+
if (!res.success) throw new Error(res.error || "screenshot failed");
|
|
250
183
|
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
190
|
+
// Cleanup temp file
|
|
191
|
+
try { rmSync(path); } catch { /* ignore */ }
|
|
192
|
+
|
|
193
|
+
return base64;
|
|
272
194
|
}
|
|
273
195
|
|
|
274
|
-
|
|
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
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
332
|
-
|
|
333
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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 =
|
|
324
|
+
export type BrowserView = AgentBrowserView;
|
|
379
325
|
|
|
380
|
-
let _client:
|
|
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(
|
|
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
|
|
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
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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(
|
|
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
|
-
|
|
431
|
-
|
|
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<
|
|
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
|
-
|
|
458
|
-
getViewSync(): CDPClient | null {
|
|
410
|
+
getViewSync(): AgentBrowserView | null {
|
|
459
411
|
return _client;
|
|
460
412
|
}
|
|
461
413
|
|
|
462
|
-
async getPage(): Promise<
|
|
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:
|
|
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:
|
|
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:
|
|
488
|
+
view: AgentBrowserView,
|
|
537
489
|
selector: string
|
|
538
490
|
): Promise<string> {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return
|
|
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
|
}
|