alvin-bot 4.8.8 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/dist/handlers/message.js +5 -2
- package/dist/index.js +14 -10
- package/dist/paths.js +2 -0
- package/dist/platforms/whatsapp-auth-helpers.js +53 -0
- package/dist/platforms/whatsapp.js +6 -2
- package/dist/services/browser-manager.js +470 -95
- package/dist/services/browser-webfetch.js +93 -0
- package/dist/services/cron-scheduling.js +142 -0
- package/dist/services/cron.js +32 -6
- package/dist/services/skills.js +15 -11
- package/dist/services/subagent-delivery.js +8 -2
- package/dist/services/subagents.js +49 -8
- package/dist/services/telegram.js +12 -3
- package/dist/services/watchdog-brake.js +113 -0
- package/dist/services/watchdog.js +56 -42
- package/dist/util/console-formatter.js +109 -0
- package/dist/util/debounce.js +24 -0
- package/dist/util/telegram-error-filter.js +62 -0
- package/dist/web/server.js +56 -0
- package/package.json +1 -1
- package/skills/browse/SKILL.md +123 -98
- package/test/browser-webfetch.test.ts +121 -0
- package/test/console-timestamps.test.ts +98 -0
- package/test/cron-restart-resilience.test.ts +191 -0
- package/test/debounce.test.ts +60 -0
- package/test/subagent-final-text.test.ts +132 -0
- package/test/telegram-error-filter.test.ts +85 -0
- package/test/watchdog-brake.test.ts +157 -0
- package/test/web-server-shutdown.test.ts +111 -0
- package/test/whatsapp-auth-resilience.test.ts +96 -0
|
@@ -1,34 +1,194 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-Strategy Browser Manager
|
|
2
|
+
* Multi-Strategy Browser Manager — with automatic fallback chain.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Strategy priority:
|
|
5
|
+
* 1. Gateway (browse-server.cjs HTTP server) — if script exists and is running
|
|
6
|
+
* 2. CDP (Chrome DevTools Protocol) — via hub browser.sh cdp, persistent cookies
|
|
7
|
+
* 3. Hub Stealth (Playwright + stealth plugin) — via hub browser.sh stealth
|
|
8
|
+
* 4. Raw CLI (bare Playwright) — last resort, easily blocked
|
|
9
|
+
*
|
|
10
|
+
* If a strategy is unavailable, we automatically cascade to the next one
|
|
11
|
+
* and log a warning so failures are visible, not silent.
|
|
8
12
|
*/
|
|
9
|
-
import { spawn } from "child_process";
|
|
13
|
+
import { execSync, spawn } from "child_process";
|
|
10
14
|
import http from "http";
|
|
11
15
|
import fs from "fs";
|
|
12
16
|
import { config } from "../config.js";
|
|
13
|
-
import { BROWSE_SERVER_SCRIPT } from "../paths.js";
|
|
17
|
+
import { BROWSE_SERVER_SCRIPT, HUB_BROWSER_SH } from "../paths.js";
|
|
14
18
|
import { screenshotUrl, extractText, generatePdf } from "./browser.js";
|
|
15
|
-
|
|
19
|
+
import { webfetchNavigate, WebfetchFailed } from "./browser-webfetch.js";
|
|
20
|
+
const CDP_PORT = 9222;
|
|
21
|
+
const EXEC_TIMEOUT = 60_000; // 60s for page loads via shell
|
|
22
|
+
// ── Logging ──────────────────────────────────────────────────────────
|
|
23
|
+
function log(msg) {
|
|
24
|
+
console.warn(`[browser-manager] ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
// ── Availability Checks ──────────────────────────────────────────────
|
|
27
|
+
function isGatewayScriptPresent() {
|
|
28
|
+
return fs.existsSync(BROWSE_SERVER_SCRIPT);
|
|
29
|
+
}
|
|
30
|
+
async function isGatewayRunning() {
|
|
31
|
+
try {
|
|
32
|
+
const health = await gatewayRequest("/health");
|
|
33
|
+
return !!health?.ok;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isHubBrowserAvailable() {
|
|
40
|
+
return fs.existsSync(HUB_BROWSER_SH);
|
|
41
|
+
}
|
|
42
|
+
async function isCDPAvailable() {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const req = http.get(`http://127.0.0.1:${CDP_PORT}/json/version`, (res) => {
|
|
45
|
+
let data = "";
|
|
46
|
+
res.on("data", (chunk) => (data += chunk));
|
|
47
|
+
res.on("end", () => resolve(res.statusCode === 200));
|
|
48
|
+
});
|
|
49
|
+
req.on("error", () => resolve(false));
|
|
50
|
+
req.setTimeout(3000, () => {
|
|
51
|
+
req.destroy();
|
|
52
|
+
resolve(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// ── Strategy Selection with Fallback ─────────────────────────────────
|
|
57
|
+
/** Pick the preferred strategy based on task type.
|
|
58
|
+
*
|
|
59
|
+
* Default for a one-shot read is `webfetch` — the cheapest tier. It
|
|
60
|
+
* only fails on JS-heavy or bot-guarded pages, and the cascade in
|
|
61
|
+
* resolveStrategy() handles the upgrade path automatically.
|
|
62
|
+
*/
|
|
16
63
|
export function selectStrategy(task = {}) {
|
|
17
64
|
if (task.useUserBrowser || config.cdpUrl)
|
|
18
65
|
return "cdp";
|
|
19
66
|
if (task.interactive || task.multiStep)
|
|
20
67
|
return "gateway";
|
|
68
|
+
return "webfetch";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the preferred strategy to one that's actually available.
|
|
72
|
+
*
|
|
73
|
+
* Cascade order:
|
|
74
|
+
* webfetch → hub-stealth → cdp → gateway → cli
|
|
75
|
+
*
|
|
76
|
+
* Rationale:
|
|
77
|
+
* - `webfetch` is a plain HTTP GET — instant, zero footprint.
|
|
78
|
+
* - `hub-stealth` (playwright+stealth) handles JS-rendered pages
|
|
79
|
+
* without a persistent browser process.
|
|
80
|
+
* - `cdp` brings cookies/auth for login-walled sites.
|
|
81
|
+
* - `gateway` exposes the multi-step HTTP API (ref-based ops, long
|
|
82
|
+
* sessions) when the browse-server.cjs helper is available.
|
|
83
|
+
* - `cli` (raw Playwright) is the last-resort fallback.
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveStrategy(preferred) {
|
|
86
|
+
const chain = [];
|
|
87
|
+
// Build fallback chain starting from preferred. webfetch and
|
|
88
|
+
// hub-stealth are always available (no external state check), so
|
|
89
|
+
// they're included as floor entries. CDP/gateway only get in if the
|
|
90
|
+
// caller asked for them explicitly, since they need running daemons.
|
|
91
|
+
switch (preferred) {
|
|
92
|
+
case "webfetch":
|
|
93
|
+
chain.push("webfetch", "hub-stealth", "cli");
|
|
94
|
+
break;
|
|
95
|
+
case "gateway":
|
|
96
|
+
chain.push("gateway", "cdp", "hub-stealth", "webfetch", "cli");
|
|
97
|
+
break;
|
|
98
|
+
case "cdp":
|
|
99
|
+
chain.push("cdp", "hub-stealth", "webfetch", "cli");
|
|
100
|
+
break;
|
|
101
|
+
case "hub-stealth":
|
|
102
|
+
chain.push("hub-stealth", "webfetch", "cli");
|
|
103
|
+
break;
|
|
104
|
+
case "cli":
|
|
105
|
+
chain.push("cli");
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
for (const strategy of chain) {
|
|
109
|
+
switch (strategy) {
|
|
110
|
+
case "webfetch":
|
|
111
|
+
// Native fetch is always present on Node ≥ 18 — no availability
|
|
112
|
+
// probe needed. Each call is self-contained, so we return the
|
|
113
|
+
// strategy tag and let navigate() handle per-call errors.
|
|
114
|
+
return "webfetch";
|
|
115
|
+
case "gateway":
|
|
116
|
+
if (isGatewayScriptPresent() && (await isGatewayRunning()))
|
|
117
|
+
return "gateway";
|
|
118
|
+
if (!isGatewayScriptPresent()) {
|
|
119
|
+
log("Gateway unavailable: browse-server.cjs not found. Falling back.");
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
log("Gateway not running. Falling back.");
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
case "cdp":
|
|
126
|
+
if (await isCDPAvailable())
|
|
127
|
+
return "cdp";
|
|
128
|
+
// Try starting CDP via hub script
|
|
129
|
+
if (isHubBrowserAvailable()) {
|
|
130
|
+
try {
|
|
131
|
+
log("CDP Chrome not running — attempting to start via hub browser.sh...");
|
|
132
|
+
execSync(`"${HUB_BROWSER_SH}" cdp start headless`, {
|
|
133
|
+
stdio: "pipe",
|
|
134
|
+
timeout: 15_000,
|
|
135
|
+
});
|
|
136
|
+
// Give it a moment to spin up
|
|
137
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
138
|
+
if (await isCDPAvailable()) {
|
|
139
|
+
log("CDP Chrome started successfully.");
|
|
140
|
+
return "cdp";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log(`Failed to start CDP Chrome: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
log("CDP unavailable. Falling back.");
|
|
148
|
+
break;
|
|
149
|
+
case "hub-stealth":
|
|
150
|
+
if (isHubBrowserAvailable())
|
|
151
|
+
return "hub-stealth";
|
|
152
|
+
log("Hub browser.sh not found. Falling back to raw Playwright.");
|
|
153
|
+
break;
|
|
154
|
+
case "cli":
|
|
155
|
+
return "cli"; // Always available as last resort
|
|
156
|
+
}
|
|
157
|
+
}
|
|
21
158
|
return "cli";
|
|
22
159
|
}
|
|
23
|
-
|
|
160
|
+
function execHub(args) {
|
|
161
|
+
try {
|
|
162
|
+
const result = execSync(`"${HUB_BROWSER_SH}" ${args}`, {
|
|
163
|
+
stdio: "pipe",
|
|
164
|
+
timeout: EXEC_TIMEOUT,
|
|
165
|
+
env: { ...process.env, PATH: process.env.PATH },
|
|
166
|
+
});
|
|
167
|
+
const stdout = result.toString().trim();
|
|
168
|
+
// Try to parse as JSON (stealth outputs JSON)
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(stdout);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Not JSON — return as raw text
|
|
174
|
+
return { title: "", url: "", raw: stdout };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
const error = err;
|
|
179
|
+
log(`Hub script failed: ${error.stderr?.toString()?.trim() || error.message}`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Gateway Management ───────────────────────────────────────────────
|
|
24
184
|
let gatewayProcess = null;
|
|
25
|
-
async function gatewayRequest(
|
|
185
|
+
async function gatewayRequest(urlPath, params = {}, timeoutMs = 15_000) {
|
|
26
186
|
const query = new URLSearchParams(params).toString();
|
|
27
|
-
const url = `http://127.0.0.1:${config.browseServerPort}${
|
|
187
|
+
const url = `http://127.0.0.1:${config.browseServerPort}${urlPath}${query ? "?" + query : ""}`;
|
|
28
188
|
return new Promise((resolve, reject) => {
|
|
29
|
-
http.get(url, (res) => {
|
|
189
|
+
const req = http.get(url, (res) => {
|
|
30
190
|
let data = "";
|
|
31
|
-
res.on("data", chunk => data += chunk);
|
|
191
|
+
res.on("data", (chunk) => (data += chunk));
|
|
32
192
|
res.on("end", () => {
|
|
33
193
|
try {
|
|
34
194
|
resolve(JSON.parse(data));
|
|
@@ -37,107 +197,314 @@ async function gatewayRequest(path, params = {}) {
|
|
|
37
197
|
reject(new Error(`Invalid JSON from gateway: ${data.slice(0, 200)}`));
|
|
38
198
|
}
|
|
39
199
|
});
|
|
40
|
-
})
|
|
200
|
+
});
|
|
201
|
+
req.on("error", reject);
|
|
202
|
+
req.setTimeout(timeoutMs, () => {
|
|
203
|
+
req.destroy(new Error(`Gateway request timed out after ${timeoutMs}ms: ${urlPath}`));
|
|
204
|
+
});
|
|
41
205
|
});
|
|
42
206
|
}
|
|
43
207
|
async function ensureGateway() {
|
|
44
208
|
// Check if already running
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
catch { /* not running */ }
|
|
51
|
-
// Start it
|
|
52
|
-
if (!fs.existsSync(BROWSE_SERVER_SCRIPT))
|
|
209
|
+
if (await isGatewayRunning())
|
|
210
|
+
return true;
|
|
211
|
+
// Try to start it
|
|
212
|
+
if (!isGatewayScriptPresent()) {
|
|
213
|
+
log("Cannot start gateway: browse-server.cjs not found.");
|
|
53
214
|
return false;
|
|
215
|
+
}
|
|
54
216
|
gatewayProcess = spawn("node", [BROWSE_SERVER_SCRIPT, String(config.browseServerPort)], {
|
|
55
217
|
stdio: "pipe",
|
|
56
218
|
detached: false,
|
|
57
219
|
});
|
|
58
|
-
gatewayProcess.on("exit", () => {
|
|
220
|
+
gatewayProcess.on("exit", () => {
|
|
221
|
+
gatewayProcess = null;
|
|
222
|
+
});
|
|
59
223
|
// Wait for startup (max 10s)
|
|
60
224
|
for (let i = 0; i < 20; i++) {
|
|
61
|
-
await new Promise(r => setTimeout(r, 500));
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (health.ok)
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
catch { /* still starting */ }
|
|
225
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
226
|
+
if (await isGatewayRunning())
|
|
227
|
+
return true;
|
|
68
228
|
}
|
|
229
|
+
log("Gateway failed to start within 10s.");
|
|
69
230
|
return false;
|
|
70
231
|
}
|
|
71
|
-
// ── Unified Operations
|
|
72
|
-
/** Navigate to URL using best strategy
|
|
232
|
+
// ── Unified Operations ───────────────────────────────────────────────
|
|
233
|
+
/** Navigate to URL using best available strategy.
|
|
234
|
+
*
|
|
235
|
+
* Error-based cascade: if the chosen tier throws, we walk DOWN the
|
|
236
|
+
* priority chain until one succeeds or we exhaust the list. This lets
|
|
237
|
+
* a 403 from webfetch transparently upgrade to hub-stealth without
|
|
238
|
+
* callers having to know about the fallback graph.
|
|
239
|
+
*/
|
|
73
240
|
export async function navigate(url, task = {}) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
241
|
+
const primary = await resolveStrategy(selectStrategy(task));
|
|
242
|
+
log(`navigate(${url}) using strategy: ${primary}`);
|
|
243
|
+
// Try primary, then hub-stealth as a universal fallback. We keep the
|
|
244
|
+
// fallback list short here to avoid cascading timeouts — the full
|
|
245
|
+
// cascade is only for resolveStrategy's availability check.
|
|
246
|
+
const attempt = async (strategy) => {
|
|
247
|
+
return navigateOne(strategy, url);
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
return await attempt(primary);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
log(`navigate(${url}) ${primary} failed: ${err.message}`);
|
|
254
|
+
if (primary === "webfetch") {
|
|
255
|
+
// Webfetch is the most common tier and the most common to hit a
|
|
256
|
+
// bot guard — cascade to hub-stealth explicitly, then cli.
|
|
257
|
+
try {
|
|
258
|
+
return await attempt("hub-stealth");
|
|
259
|
+
}
|
|
260
|
+
catch (err2) {
|
|
261
|
+
log(`navigate(${url}) hub-stealth fallback failed: ${err2.message}`);
|
|
262
|
+
return await attempt("cli");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Single-strategy navigate — no fallback logic, just do the thing. */
|
|
269
|
+
async function navigateOne(strategy, url) {
|
|
270
|
+
switch (strategy) {
|
|
271
|
+
case "webfetch": {
|
|
272
|
+
try {
|
|
273
|
+
const r = await webfetchNavigate(url);
|
|
274
|
+
return { title: r.title, url: r.url };
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
if (err instanceof WebfetchFailed)
|
|
278
|
+
throw err;
|
|
279
|
+
throw new WebfetchFailed(url, err.message, { cause: err });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
case "gateway": {
|
|
283
|
+
await ensureGateway();
|
|
284
|
+
return gatewayRequest("/navigate", { url });
|
|
285
|
+
}
|
|
286
|
+
case "cdp": {
|
|
287
|
+
// Try hub CDP first
|
|
288
|
+
if (isHubBrowserAvailable()) {
|
|
289
|
+
const result = execHub(`cdp goto "${url}"`);
|
|
290
|
+
if (result && !result.error) {
|
|
291
|
+
return { title: result.title || "", url: result.url || url };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Fallback: direct Playwright CDP
|
|
295
|
+
try {
|
|
296
|
+
const { chromium } = await import("playwright");
|
|
297
|
+
const browser = await chromium.connectOverCDP(config.cdpUrl || `http://127.0.0.1:${CDP_PORT}`);
|
|
298
|
+
const contexts = browser.contexts();
|
|
299
|
+
const page = contexts[0]?.pages()[0] || (await contexts[0]?.newPage()) || (await browser.newPage());
|
|
300
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
|
|
301
|
+
const title = await page.title();
|
|
302
|
+
return { title, url: page.url() };
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
log(`Direct CDP failed: ${err.message}`);
|
|
306
|
+
// Last resort: try stealth
|
|
307
|
+
if (isHubBrowserAvailable()) {
|
|
308
|
+
const stealthResult = execHub(`stealth "${url}"`);
|
|
309
|
+
if (stealthResult) {
|
|
310
|
+
return { title: stealthResult.title || "", url: stealthResult.url || url };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
case "hub-stealth": {
|
|
317
|
+
const result = execHub(`stealth "${url}"`);
|
|
318
|
+
if (result && !result.error) {
|
|
319
|
+
return { title: result.title || "", url: result.url || url };
|
|
320
|
+
}
|
|
321
|
+
// Fallback to raw CLI
|
|
322
|
+
log("Hub stealth failed, falling back to raw Playwright.");
|
|
323
|
+
const text = await extractText(url);
|
|
324
|
+
return { title: url, url, tree: [text.slice(0, 500)] };
|
|
325
|
+
}
|
|
326
|
+
case "cli":
|
|
327
|
+
default: {
|
|
328
|
+
const text = await extractText(url);
|
|
329
|
+
return { title: url, url, tree: [text.slice(0, 500)] };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
92
332
|
}
|
|
93
333
|
/** Take a screenshot */
|
|
94
334
|
export async function screenshot(url, options = {}) {
|
|
95
|
-
const strategy = selectStrategy();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
335
|
+
const strategy = await resolveStrategy(selectStrategy());
|
|
336
|
+
log(`screenshot(${url}) using strategy: ${strategy}`);
|
|
337
|
+
switch (strategy) {
|
|
338
|
+
case "gateway": {
|
|
339
|
+
await ensureGateway();
|
|
340
|
+
if (url)
|
|
341
|
+
await gatewayRequest("/navigate", { url });
|
|
342
|
+
const result = await gatewayRequest("/screenshot", options.fullPage ? { full: "true" } : {});
|
|
343
|
+
return result.path;
|
|
344
|
+
}
|
|
345
|
+
case "cdp": {
|
|
346
|
+
if (isHubBrowserAvailable()) {
|
|
347
|
+
const tmpName = `shot_${Date.now()}.png`;
|
|
348
|
+
const result = execHub(`cdp shot "${url}" ${tmpName}`);
|
|
349
|
+
if (result?.screenshot)
|
|
350
|
+
return result.screenshot;
|
|
351
|
+
}
|
|
352
|
+
// Fallback to raw Playwright
|
|
353
|
+
return screenshotUrl(url, { fullPage: options.fullPage });
|
|
354
|
+
}
|
|
355
|
+
case "hub-stealth": {
|
|
356
|
+
const tmpName = `shot_${Date.now()}.png`;
|
|
357
|
+
const result = execHub(`stealth "${url}" --screenshot=${tmpName}`);
|
|
358
|
+
if (result?.screenshot)
|
|
359
|
+
return result.screenshot;
|
|
360
|
+
// Fallback
|
|
361
|
+
return screenshotUrl(url, { fullPage: options.fullPage });
|
|
362
|
+
}
|
|
363
|
+
case "cli":
|
|
364
|
+
default:
|
|
365
|
+
return screenshotUrl(url, { fullPage: options.fullPage });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ── CDP Direct-Playwright Helper ─────────────────────────────────────
|
|
369
|
+
// Used as fallback when the gateway isn't running but CDP Chrome is.
|
|
370
|
+
// Each call opens a short-lived CDP connection, operates on the newest
|
|
371
|
+
// existing page in the current context (keeps Chrome itself alive), and
|
|
372
|
+
// disconnects. Safe for sub-agents that need a single op at a time.
|
|
373
|
+
async function withCdpPage(fn) {
|
|
374
|
+
const { chromium } = await import("playwright");
|
|
375
|
+
const browser = await chromium.connectOverCDP(config.cdpUrl || `http://127.0.0.1:${CDP_PORT}`);
|
|
376
|
+
try {
|
|
377
|
+
const context = browser.contexts()[0];
|
|
378
|
+
if (!context)
|
|
379
|
+
throw new Error("No CDP contexts available — is Chrome CDP running?");
|
|
380
|
+
const pages = context.pages();
|
|
381
|
+
const page = pages[pages.length - 1] || (await context.newPage());
|
|
382
|
+
return await fn(page);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
await browser.close(); // Closes CDP connection, not Chrome itself
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const NEEDS_INTERACTIVE_HINT = "Start CDP Chrome: ~/.claude/hub/SCRIPTS/browser.sh cdp start headless";
|
|
389
|
+
/**
|
|
390
|
+
* Get accessibility tree (gateway preferred, CDP fallback returns outerHTML).
|
|
391
|
+
* The @eN ref model only exists in the gateway; under CDP we return a
|
|
392
|
+
* best-effort DOM snippet instead so callers can still see what's there.
|
|
393
|
+
*/
|
|
107
394
|
export async function getTree(limit = 100) {
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
395
|
+
if (await isGatewayRunning()) {
|
|
396
|
+
return gatewayRequest("/tree", { limit: String(limit) });
|
|
397
|
+
}
|
|
398
|
+
if (await isCDPAvailable()) {
|
|
399
|
+
return withCdpPage(async (page) => {
|
|
400
|
+
const elements = await page.$$eval("a, button, input, select, textarea, [role=button], [role=link]", (els, max) => els.slice(0, max).map((el, i) => {
|
|
401
|
+
const tag = el.tagName.toLowerCase();
|
|
402
|
+
const text = (el.textContent || "").trim().slice(0, 60);
|
|
403
|
+
const id = el.id ? `#${el.id}` : "";
|
|
404
|
+
const name = el.name
|
|
405
|
+
? `[name=${el.name}]`
|
|
406
|
+
: "";
|
|
407
|
+
return `@e${i + 1} <${tag}${id}${name}> "${text}"`;
|
|
408
|
+
}), limit);
|
|
409
|
+
return { tree: elements, total: elements.length };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
throw new Error(`[browser-manager] getTree requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Click an element. Accepts a gateway ref (@eN → "eN") when gateway is
|
|
416
|
+
* running, or a CSS selector when only CDP is available.
|
|
417
|
+
*/
|
|
418
|
+
export async function click(refOrSelector) {
|
|
419
|
+
if (await isGatewayRunning()) {
|
|
420
|
+
return gatewayRequest("/click", { ref: refOrSelector });
|
|
421
|
+
}
|
|
422
|
+
if (await isCDPAvailable()) {
|
|
423
|
+
return withCdpPage(async (page) => {
|
|
424
|
+
await page.click(refOrSelector, { timeout: 10_000 });
|
|
425
|
+
return { title: await page.title(), url: page.url() };
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
throw new Error(`[browser-manager] click() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
429
|
+
}
|
|
430
|
+
/** Fill an input. refOrSelector semantics match click(). */
|
|
431
|
+
export async function fill(refOrSelector, value) {
|
|
432
|
+
if (await isGatewayRunning()) {
|
|
433
|
+
await gatewayRequest("/fill", { ref: refOrSelector, value });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (await isCDPAvailable()) {
|
|
437
|
+
await withCdpPage(async (page) => {
|
|
438
|
+
await page.fill(refOrSelector, value, { timeout: 10_000 });
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
throw new Error(`[browser-manager] fill() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
443
|
+
}
|
|
444
|
+
/** Type text character-by-character (for inputs that reject page.fill). */
|
|
445
|
+
export async function type(refOrSelector, text) {
|
|
446
|
+
if (await isGatewayRunning()) {
|
|
447
|
+
await gatewayRequest("/type", { ref: refOrSelector, text });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (await isCDPAvailable()) {
|
|
451
|
+
await withCdpPage(async (page) => {
|
|
452
|
+
await page.type(refOrSelector, text, { timeout: 10_000 });
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
throw new Error(`[browser-manager] type() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
457
|
+
}
|
|
458
|
+
/** Press a keyboard key (page-level if no ref, element-level with ref). */
|
|
459
|
+
export async function press(key, refOrSelector) {
|
|
460
|
+
if (await isGatewayRunning()) {
|
|
461
|
+
await gatewayRequest("/press", refOrSelector ? { key, ref: refOrSelector } : { key });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (await isCDPAvailable()) {
|
|
465
|
+
await withCdpPage(async (page) => {
|
|
466
|
+
if (refOrSelector) {
|
|
467
|
+
await page.locator(refOrSelector).press(key, { timeout: 10_000 });
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
await page.keyboard.press(key);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
throw new Error(`[browser-manager] press() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
476
|
+
}
|
|
477
|
+
/** Scroll page. CDP fallback uses window.scrollBy. */
|
|
132
478
|
export async function scroll(direction, amount = 600) {
|
|
133
|
-
await
|
|
134
|
-
|
|
479
|
+
if (await isGatewayRunning()) {
|
|
480
|
+
return gatewayRequest("/scroll", { direction, amount: String(amount) });
|
|
481
|
+
}
|
|
482
|
+
if (await isCDPAvailable()) {
|
|
483
|
+
return withCdpPage(async (page) => {
|
|
484
|
+
const delta = direction === "up" ? -amount :
|
|
485
|
+
direction === "top" ? -1e9 :
|
|
486
|
+
direction === "bottom" ? 1e9 :
|
|
487
|
+
amount;
|
|
488
|
+
await page.evaluate((d) => window.scrollBy(0, d), delta);
|
|
489
|
+
return { title: await page.title(), url: page.url() };
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
throw new Error(`[browser-manager] scroll() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
135
493
|
}
|
|
136
|
-
/** Evaluate JS
|
|
494
|
+
/** Evaluate JS in the page context. */
|
|
137
495
|
export async function evaluate(js) {
|
|
138
|
-
await
|
|
139
|
-
|
|
140
|
-
|
|
496
|
+
if (await isGatewayRunning()) {
|
|
497
|
+
const result = await gatewayRequest("/eval", { js });
|
|
498
|
+
return result.result;
|
|
499
|
+
}
|
|
500
|
+
if (await isCDPAvailable()) {
|
|
501
|
+
return withCdpPage(async (page) => {
|
|
502
|
+
// `page.evaluate(fn)` wraps a function — we need eval of a raw
|
|
503
|
+
// expression string, so wrap in an IIFE.
|
|
504
|
+
return page.evaluate(new Function(`return (${js})`));
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
throw new Error(`[browser-manager] evaluate() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
141
508
|
}
|
|
142
509
|
/** Generate PDF from URL */
|
|
143
510
|
export async function pdf(url) {
|
|
@@ -154,8 +521,16 @@ export async function close() {
|
|
|
154
521
|
gatewayProcess = null;
|
|
155
522
|
}
|
|
156
523
|
}
|
|
157
|
-
/** Get current page info (gateway) */
|
|
524
|
+
/** Get current page info (gateway preferred, CDP fallback reads newest page). */
|
|
158
525
|
export async function info() {
|
|
159
|
-
await
|
|
160
|
-
|
|
526
|
+
if (await isGatewayRunning()) {
|
|
527
|
+
return gatewayRequest("/info");
|
|
528
|
+
}
|
|
529
|
+
if (await isCDPAvailable()) {
|
|
530
|
+
return withCdpPage(async (page) => ({
|
|
531
|
+
title: await page.title(),
|
|
532
|
+
url: page.url(),
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`[browser-manager] info() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
|
|
161
536
|
}
|