bwb-browser 3.1.1 → 4.0.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/AGENTS.md +17 -15
- package/README.md +56 -10
- package/lib/act.mjs +26 -3
- package/lib/browser.mjs +121 -2
- package/lib/diagnose.mjs +11 -1
- package/lib/fetch.mjs +127 -0
- package/lib/fingerprint.mjs +9 -4
- package/lib/helpers.mjs +1 -2
- package/lib/setup.mjs +23 -0
- package/lib/tabs.mjs +147 -4
- package/lib/vigil.mjs +77 -0
- package/package.json +5 -3
- package/server.mjs +282 -58
package/server.mjs
CHANGED
|
@@ -12,20 +12,31 @@
|
|
|
12
12
|
* --headless / BWB_HEADLESS — Run headless (default: true)
|
|
13
13
|
* --screenshots-dir / BWB_SCREENSHOTS_DIR — Directory for saved screenshots
|
|
14
14
|
* --timeout / BWB_NAV_TIMEOUT — Navigation timeout in ms (default: 30000)
|
|
15
|
+
* --lean / BWB_LEAN — Survival profile: capped renderers,
|
|
16
|
+
* silenced background services, tab cap,
|
|
17
|
+
* mayfly teardown (default: auto on Termux)
|
|
18
|
+
* --nuclear / BWB_NUCLEAR — Add --single-process (max saving,
|
|
19
|
+
* min stability). Opt-in only.
|
|
20
|
+
* --idle / BWB_IDLE_MS — Mayfly teardown after N ms idle
|
|
21
|
+
* (default: 5min lean, off desktop)
|
|
22
|
+
* --tab-max / BWB_TAB_MAX — Live-tab cap, oldest hibernated
|
|
23
|
+
* (default: 3 lean, unlimited desktop)
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
27
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
28
|
import { z } from "zod";
|
|
20
29
|
import CDP from "chrome-remote-interface";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
30
|
+
import { execSync } from "child_process";
|
|
31
|
+
import { mkdirSync, readFileSync, existsSync } from "fs";
|
|
32
|
+
import { homedir, platform } from "os";
|
|
23
33
|
import { join, dirname } from "path";
|
|
24
34
|
import { fileURLToPath } from "url";
|
|
25
35
|
|
|
26
36
|
import {
|
|
27
|
-
ensureBrowser, restartBrowser, saveScreenshot,
|
|
37
|
+
ensureBrowser, restartBrowser, stopBrowser, saveScreenshot,
|
|
28
38
|
cfg, browser, browserExited, actualCdpPort,
|
|
39
|
+
isTermux, pokeActivity, setIdleSuppressed,
|
|
29
40
|
} from "./lib/browser.mjs";
|
|
30
41
|
|
|
31
42
|
// ─── --setup mode ──────────────────────────────────────────────────
|
|
@@ -41,8 +52,12 @@ import {
|
|
|
41
52
|
|
|
42
53
|
import {
|
|
43
54
|
getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
|
|
55
|
+
hibernateTab,
|
|
44
56
|
} from "./lib/tabs.mjs";
|
|
45
57
|
|
|
58
|
+
import { staticFetch } from "./lib/fetch.mjs";
|
|
59
|
+
import { sampleResources, assess, resourceFooter, resolveBudgets } from "./lib/vigil.mjs";
|
|
60
|
+
|
|
46
61
|
import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
|
|
47
62
|
import { diagnosePage } from "./lib/diagnose.mjs";
|
|
48
63
|
import { applyRealisticProfile } from "./lib/fingerprint.mjs";
|
|
@@ -68,6 +83,10 @@ function parseArgs() {
|
|
|
68
83
|
case "--headless": cliCfg.headless = args[++i] !== "false"; break;
|
|
69
84
|
case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
|
|
70
85
|
case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
|
|
86
|
+
case "--lean": cliCfg.lean = args[++i] !== "false"; break;
|
|
87
|
+
case "--nuclear": cliCfg.nuclear = args[++i] !== "false"; break;
|
|
88
|
+
case "--idle": cliCfg.idleMs = parseInt(args[++i], 10); break;
|
|
89
|
+
case "--tab-max": cliCfg.tabMax = parseInt(args[++i], 10); break;
|
|
71
90
|
case "--version": console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0);
|
|
72
91
|
case "--help": printHelp(); process.exit(0);
|
|
73
92
|
}
|
|
@@ -77,12 +96,12 @@ function parseArgs() {
|
|
|
77
96
|
|
|
78
97
|
function printHelp() {
|
|
79
98
|
console.log(`
|
|
80
|
-
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
99
|
+
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
81
100
|
|
|
82
|
-
Browser automation for AI agents.
|
|
83
|
-
|
|
101
|
+
Browser automation for AI agents. Static-first, lean like air. 26 tools.
|
|
102
|
+
Raw CDP — no Playwright, no Puppeteer. Chromium starts only when JS demands it.
|
|
84
103
|
|
|
85
|
-
Built on Termux/Android. Runs everywhere
|
|
104
|
+
Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
|
|
86
105
|
|
|
87
106
|
USAGE:
|
|
88
107
|
bwb [options]
|
|
@@ -94,18 +113,21 @@ OPTIONS:
|
|
|
94
113
|
--headless <bool> Run headless (default: true)
|
|
95
114
|
--screenshots-dir <path> Directory to save screenshots
|
|
96
115
|
--timeout <ms> Navigation timeout in ms (default: 30000)
|
|
116
|
+
--lean <bool> Survival profile (default: auto on Termux)
|
|
117
|
+
--nuclear Add --single-process (max saving, min stability)
|
|
118
|
+
--idle <ms> Mayfly teardown after N ms idle (default: 5min lean)
|
|
119
|
+
--tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
|
|
97
120
|
--version Print version
|
|
98
121
|
--help Show this help
|
|
99
122
|
|
|
100
|
-
TOOLS (
|
|
123
|
+
TOOLS (26):
|
|
101
124
|
CORE BROWSING:
|
|
102
|
-
browser_goto Navigate to
|
|
125
|
+
browser_goto Navigate (static-first, escalates to browser)
|
|
103
126
|
browser_screenshot Take a screenshot
|
|
104
127
|
browser_html Get page/selector HTML
|
|
105
128
|
browser_text Get page/selector text
|
|
106
|
-
browser_title Get page title
|
|
107
|
-
browser_url Get current URL
|
|
108
129
|
browser_back Go back in history
|
|
130
|
+
(title/url folded into browser_status — v4 breaking change)
|
|
109
131
|
|
|
110
132
|
INTERACTION:
|
|
111
133
|
browser_click Click an element
|
|
@@ -116,7 +138,7 @@ TOOLS (25):
|
|
|
116
138
|
|
|
117
139
|
🔥 ADVANCED:
|
|
118
140
|
browser_act Natural language page interaction (one tool does it all)
|
|
119
|
-
browser_watch Live page event capture (console, network)
|
|
141
|
+
browser_watch Live page event capture (console, network + resources)
|
|
120
142
|
browser_diagnose Full page health diagnostic
|
|
121
143
|
browser_fingerprint Realistic browser profile for testing
|
|
122
144
|
browser_waitForSelector Wait for element to appear/disappear
|
|
@@ -124,7 +146,7 @@ TOOLS (25):
|
|
|
124
146
|
MULTI-TAB:
|
|
125
147
|
browser_newTab Create a new tab
|
|
126
148
|
browser_closeTab Close a tab
|
|
127
|
-
browser_switchTab Switch to a
|
|
149
|
+
browser_switchTab Switch to a tab (hibernated tabs wake)
|
|
128
150
|
browser_listTabs List all open tabs
|
|
129
151
|
|
|
130
152
|
SESSION:
|
|
@@ -132,8 +154,12 @@ TOOLS (25):
|
|
|
132
154
|
browser_loadCookies Load session cookies from disk
|
|
133
155
|
browser_listSessions List saved sessions
|
|
134
156
|
|
|
157
|
+
ON-DEMAND (verbs ship, weight doesn't — backends install on consent):
|
|
158
|
+
browser_download Download media (needs system yt-dlp)
|
|
159
|
+
browser_export Export md/txt/html (pdf/docx/pptx need pip libs)
|
|
160
|
+
|
|
135
161
|
LIFECYCLE:
|
|
136
|
-
browser_status
|
|
162
|
+
browser_status Status + live resources + active profile
|
|
137
163
|
browser_restart Restart the browser
|
|
138
164
|
|
|
139
165
|
If bwb saves you time or money, consider supporting development:
|
|
@@ -174,8 +200,33 @@ Object.assign(cfg, parseArgs());
|
|
|
174
200
|
cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
|
|
175
201
|
cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
|
|
176
202
|
cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
|
|
177
|
-
cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR ||
|
|
203
|
+
cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || (() => {
|
|
204
|
+
// Auto-detect: Termux/Android path if available, else ~/bwb-screenshots/
|
|
205
|
+
const androidPath = "/storage/emulated/0/Download/bwb-screenshots";
|
|
206
|
+
if (platform() === "android" && existsSync("/storage/emulated/0/Download")) return androidPath;
|
|
207
|
+
if (process.env.HOME?.includes("com.termux")) return androidPath;
|
|
208
|
+
if (process.env.TERMUX_VERSION) return androidPath;
|
|
209
|
+
return join(homedir(), "bwb-screenshots");
|
|
210
|
+
})();
|
|
178
211
|
cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
|
|
212
|
+
// v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean
|
|
213
|
+
// unless explicitly overridden. Desktop behavior unchanged (all off).
|
|
214
|
+
if (cfg.lean === null || cfg.lean === undefined) {
|
|
215
|
+
if (process.env.BWB_LEAN !== undefined) cfg.lean = process.env.BWB_LEAN !== "false";
|
|
216
|
+
else cfg.lean = isTermux();
|
|
217
|
+
}
|
|
218
|
+
if (cfg.nuclear === undefined || cfg.nuclear === null) {
|
|
219
|
+
cfg.nuclear = process.env.BWB_NUCLEAR === "true";
|
|
220
|
+
}
|
|
221
|
+
if (cfg.idleMs === null || cfg.idleMs === undefined) {
|
|
222
|
+
if (process.env.BWB_IDLE_MS !== undefined) cfg.idleMs = parseInt(process.env.BWB_IDLE_MS, 10);
|
|
223
|
+
else cfg.idleMs = cfg.lean ? 5 * 60 * 1000 : 0;
|
|
224
|
+
}
|
|
225
|
+
if (cfg.tabMax === null || cfg.tabMax === undefined) {
|
|
226
|
+
if (process.env.BWB_TAB_MAX !== undefined) cfg.tabMax = parseInt(process.env.BWB_TAB_MAX, 10);
|
|
227
|
+
else cfg.tabMax = cfg.lean ? 3 : 0;
|
|
228
|
+
}
|
|
229
|
+
resolveBudgets(cfg.lean);
|
|
179
230
|
|
|
180
231
|
try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
|
|
181
232
|
|
|
@@ -236,28 +287,70 @@ const tools = {
|
|
|
236
287
|
// ═══════════════ CORE BROWSING ═══════════════
|
|
237
288
|
|
|
238
289
|
browser_goto: {
|
|
239
|
-
description: "Navigate to a URL. Returns page title and URL.",
|
|
290
|
+
description: "Navigate to a URL. Returns page title and URL. v4: static-first — plain pages are fetched + extracted with zero Chromium; JS pages escalate to CDP automatically (see mode field).",
|
|
240
291
|
schema: { url: z.string().describe("URL to navigate to") },
|
|
241
292
|
handler: async ({ url }) => {
|
|
293
|
+
// Rung 1: static fetch. No browser spawned, no LMK risk, milliseconds.
|
|
294
|
+
const attempt = await staticFetch(url, { timeout: Math.min(cfg.navTimeout, 15000) });
|
|
295
|
+
if (attempt.mode === "static") {
|
|
296
|
+
syncActiveTab(attempt.title, attempt.finalUrl);
|
|
297
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
298
|
+
mode: "static", title: attempt.title, url: attempt.finalUrl,
|
|
299
|
+
text: attempt.text, confidence: attempt.confidence,
|
|
300
|
+
note: "Served without Chromium. Need interaction/screenshots? Use browser_act / browser_screenshot — that escalates to the browser.",
|
|
301
|
+
}) }] };
|
|
302
|
+
}
|
|
303
|
+
if (attempt.mode === "error") {
|
|
304
|
+
// Dead URL — CDP shares the same network, don't spawn Chromium for a 404.
|
|
305
|
+
return { content: [{ type: "text", text: JSON.stringify({ mode: "error", error: attempt.error }) }] };
|
|
306
|
+
}
|
|
307
|
+
// Rung 2: escalate to Chromium (JS shell, auth wall, non-text).
|
|
242
308
|
const cdp = await getActiveProtocol();
|
|
243
309
|
const { Page, Runtime } = cdp;
|
|
244
310
|
const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
|
|
245
311
|
syncActiveTab(result.title, result.url);
|
|
246
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
312
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
|
|
247
313
|
},
|
|
248
314
|
},
|
|
249
315
|
|
|
250
316
|
browser_screenshot: {
|
|
251
|
-
description: "Take a screenshot of the current page.",
|
|
317
|
+
description: "Take a screenshot of the current page. Pass a CSS selector to capture just that element.",
|
|
252
318
|
schema: {
|
|
253
319
|
fullPage: z.boolean().describe("Full page screenshot (default false)").optional(),
|
|
254
320
|
quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
|
|
321
|
+
selector: z.string().describe("CSS selector to capture only that element (optional)").optional(),
|
|
255
322
|
},
|
|
256
|
-
handler: async ({ fullPage = false, quality = 80 }) => {
|
|
257
|
-
const { Page } = await getActiveProtocol();
|
|
258
|
-
|
|
323
|
+
handler: async ({ fullPage = false, quality = 80, selector }) => {
|
|
324
|
+
const { Page, Runtime } = await getActiveProtocol();
|
|
325
|
+
let clip;
|
|
326
|
+
if (selector) {
|
|
327
|
+
// Element capture: compute bounding rect in page coords, then clip
|
|
328
|
+
const { result } = await Runtime.evaluate({
|
|
329
|
+
expression: `(() => {
|
|
330
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
331
|
+
if (!el) return null;
|
|
332
|
+
const r = el.getBoundingClientRect();
|
|
333
|
+
const sx = window.scrollX || document.documentElement.scrollLeft;
|
|
334
|
+
const sy = window.scrollY || document.documentElement.scrollTop;
|
|
335
|
+
return JSON.stringify({ x: r.x + sx, y: r.y + sy, width: r.width, height: r.height });
|
|
336
|
+
})()`,
|
|
337
|
+
returnByValue: true,
|
|
338
|
+
});
|
|
339
|
+
const rect = result?.value ? JSON.parse(result.value) : null;
|
|
340
|
+
if (!rect || !rect.width || !rect.height) {
|
|
341
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Element not found or not visible: ${selector}` }) }] };
|
|
342
|
+
}
|
|
343
|
+
clip = { ...rect, scale: 1 };
|
|
344
|
+
}
|
|
345
|
+
// captureBeyondViewport=false keeps clip math in viewport space; a 0-size clip
|
|
346
|
+
// would be rejected by CDP, so guard against degenerate rects above.
|
|
347
|
+
const { data } = await Page.captureScreenshot({
|
|
348
|
+
format: "jpeg", quality,
|
|
349
|
+
captureBeyondViewport: fullPage || !!clip,
|
|
350
|
+
...(clip ? { clip } : {}),
|
|
351
|
+
});
|
|
259
352
|
const savedPath = saveScreenshot(data);
|
|
260
|
-
const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}
|
|
353
|
+
const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...`, captured: clip ? selector : (fullPage ? "full page" : "viewport") };
|
|
261
354
|
if (savedPath) response.savedTo = savedPath;
|
|
262
355
|
return { content: [
|
|
263
356
|
{ type: "image", data, mimeType: "image/jpeg" },
|
|
@@ -292,33 +385,22 @@ const tools = {
|
|
|
292
385
|
},
|
|
293
386
|
},
|
|
294
387
|
|
|
295
|
-
browser_title: {
|
|
296
|
-
description: "Get current page title.",
|
|
297
|
-
schema: {},
|
|
298
|
-
handler: async () => {
|
|
299
|
-
const { Runtime } = await getActiveProtocol();
|
|
300
|
-
const { result } = await Runtime.evaluate({ expression: "document.title" });
|
|
301
|
-
return { content: [{ type: "text", text: result?.value || "" }] };
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
|
|
305
|
-
browser_url: {
|
|
306
|
-
description: "Get current page URL.",
|
|
307
|
-
schema: {},
|
|
308
|
-
handler: async () => {
|
|
309
|
-
const { Runtime } = await getActiveProtocol();
|
|
310
|
-
const { result } = await Runtime.evaluate({ expression: "window.location.href" });
|
|
311
|
-
return { content: [{ type: "text", text: result?.value || "" }] };
|
|
312
|
-
},
|
|
313
|
-
},
|
|
314
|
-
|
|
315
388
|
browser_back: {
|
|
316
389
|
description: "Go back in browser history (like clicking the browser back button).",
|
|
317
390
|
schema: {},
|
|
318
391
|
handler: async () => {
|
|
319
392
|
const { Page, Runtime } = await getActiveProtocol();
|
|
320
|
-
|
|
321
|
-
|
|
393
|
+
// Native CDP back: walk history via navigation entries.
|
|
394
|
+
// NOTE: Page.goBack doesn't exist in the bundled CDP 1.3 protocol —
|
|
395
|
+
// getNavigationHistory + navigateToHistoryEntry is the native equivalent.
|
|
396
|
+
// The wrapper returns history FLAT ({currentIndex, entries}), not {result:{...}}.
|
|
397
|
+
const hist = await Page.getNavigationHistory();
|
|
398
|
+
const entries = hist?.entries || [];
|
|
399
|
+
const currentIdx = hist?.currentIndex ?? -1;
|
|
400
|
+
if (currentIdx > 0 && entries[currentIdx - 1]) {
|
|
401
|
+
await Page.navigateToHistoryEntry({ entryId: entries[currentIdx - 1].id });
|
|
402
|
+
}
|
|
403
|
+
await new Promise(r => setTimeout(r, Math.min(cfg.navTimeout, 1000)));
|
|
322
404
|
const { result } = await Runtime.evaluate({ expression: "document.title" });
|
|
323
405
|
syncActiveTab(result?.value, undefined);
|
|
324
406
|
return { content: [{ type: "text", text: JSON.stringify({ title: result?.value || "" }) }] };
|
|
@@ -424,16 +506,21 @@ const tools = {
|
|
|
424
506
|
await cdp.Runtime.enable();
|
|
425
507
|
await cdp.Network.enable();
|
|
426
508
|
setupWatch(events, cdp);
|
|
509
|
+
setIdleSuppressed(true); // recording in progress — mayfly must not teardown
|
|
427
510
|
return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
|
|
428
511
|
}
|
|
429
512
|
if (action === "poll") {
|
|
430
513
|
const snapshot = [...watchState.events];
|
|
431
514
|
watchState.events = [];
|
|
432
|
-
|
|
515
|
+
// Resource vigilance rides the existing poll rhythm — no new mechanism.
|
|
516
|
+
let resources = null;
|
|
517
|
+
try { resources = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length); } catch {}
|
|
518
|
+
return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot, resources }) }] };
|
|
433
519
|
}
|
|
434
520
|
if (action === "stop") {
|
|
435
521
|
const remaining = [...watchState.events];
|
|
436
522
|
cleanupWatch();
|
|
523
|
+
setIdleSuppressed(false);
|
|
437
524
|
return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
|
|
438
525
|
}
|
|
439
526
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
|
|
@@ -499,7 +586,7 @@ const tools = {
|
|
|
499
586
|
description: "Switch to a different browser tab by targetId.",
|
|
500
587
|
schema: { targetId: z.string().describe("Target tab ID to switch to") },
|
|
501
588
|
handler: async ({ targetId }) => {
|
|
502
|
-
const result = switchTab(targetId);
|
|
589
|
+
const result = await switchTab(targetId);
|
|
503
590
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
504
591
|
},
|
|
505
592
|
},
|
|
@@ -544,10 +631,105 @@ const tools = {
|
|
|
544
631
|
},
|
|
545
632
|
},
|
|
546
633
|
|
|
634
|
+
// ═══════════════ ON-DEMAND CAPABILITIES ═══════════════
|
|
635
|
+
// Verbs ship, weight doesn't. Heavy backends (yt-dlp, reportlab) are NEVER
|
|
636
|
+
// bundled — probed at call time, installed only on explicit user consent.
|
|
637
|
+
|
|
638
|
+
browser_download: {
|
|
639
|
+
description: "Download media from a URL (video, audio, subtitles, thumbnail). Requires yt-dlp on the system — if missing, returns install instructions instead of failing silently. No silent installs, ever.",
|
|
640
|
+
schema: {
|
|
641
|
+
url: z.string().describe("Media URL"),
|
|
642
|
+
format: z.enum(["best", "audio", "video", "subtitles", "thumbnail"]).describe("What to download").optional(),
|
|
643
|
+
quality: z.enum(["best", "good", "worst"]).describe("Quality tier").optional(),
|
|
644
|
+
},
|
|
645
|
+
handler: async ({ url, format = "best", quality = "best" }) => {
|
|
646
|
+
// No shell metachars ever reach execSync — http(s) only.
|
|
647
|
+
if (!/^https?:\/\/[^\\s"';`$(){}|&<>]+$/i.test(url)) {
|
|
648
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "refused: URL must be http(s) without shell metacharacters" }) }] };
|
|
649
|
+
}
|
|
650
|
+
let hasYtDlp = false;
|
|
651
|
+
try {
|
|
652
|
+
execSync("yt-dlp --version", { stdio: "ignore", timeout: 5000 });
|
|
653
|
+
hasYtDlp = true;
|
|
654
|
+
} catch {}
|
|
655
|
+
if (!hasYtDlp) {
|
|
656
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
657
|
+
needsInstall: true,
|
|
658
|
+
tool: "yt-dlp",
|
|
659
|
+
install: {
|
|
660
|
+
termux: "pkg install yt-dlp",
|
|
661
|
+
debian: "pip install yt-dlp",
|
|
662
|
+
macos: "brew install yt-dlp",
|
|
663
|
+
},
|
|
664
|
+
ask: "yt-dlp is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was downloaded.",
|
|
665
|
+
}) }] };
|
|
666
|
+
}
|
|
667
|
+
const outDir = join(dirname(cfg.screenshotsDir), "bwb-downloads");
|
|
668
|
+
try { mkdirSync(outDir, { recursive: true }); } catch {}
|
|
669
|
+
const args = ["--no-playlist", "-P", outDir, "--print", "after_move:filepath"];
|
|
670
|
+
if (format === "audio") args.push("-x", "--audio-format", "mp3");
|
|
671
|
+
else if (format === "subtitles") args.push("--write-subs", "--skip-download");
|
|
672
|
+
else if (format === "thumbnail") args.push("--write-thumbnail", "--skip-download");
|
|
673
|
+
if (quality === "worst") args.push("-f", "worst");
|
|
674
|
+
else if (quality === "good") args.push("-f", "best[height<=720]");
|
|
675
|
+
args.push(url);
|
|
676
|
+
try {
|
|
677
|
+
const out = execSync(`yt-dlp ${args.map((a) => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 600000, maxBuffer: 1024 * 1024 });
|
|
678
|
+
const file = out.trim().split("\n").pop();
|
|
679
|
+
return { content: [{ type: "text", text: JSON.stringify({ downloaded: file, format, quality }) }] };
|
|
680
|
+
} catch (err) {
|
|
681
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "download failed", detail: String(err.message || err).slice(0, 500) }) }] };
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
browser_export: {
|
|
687
|
+
description: "Export findings/text to a file. md/txt/html always work (zero deps). docx/pdf/pptx need python libs — if missing, returns install instructions. No silent installs.",
|
|
688
|
+
schema: {
|
|
689
|
+
text: z.string().describe("Content to export (markdown accepted)"),
|
|
690
|
+
format: z.enum(["md", "txt", "html", "pdf", "docx", "pptx"]).describe("Output format").optional(),
|
|
691
|
+
output_path: z.string().describe("Where to write the file").optional(),
|
|
692
|
+
title: z.string().describe("Document title").optional(),
|
|
693
|
+
},
|
|
694
|
+
handler: async ({ text, format = "md", output_path, title = "bwb export" }) => {
|
|
695
|
+
const { writeFileSync: wfs } = await import("fs");
|
|
696
|
+
const dest = output_path || join(dirname(cfg.screenshotsDir), `bwb-export-${Date.now()}.${format === "txt" ? "txt" : format === "html" ? "html" : "md"}`);
|
|
697
|
+
if (["md", "txt"].includes(format)) {
|
|
698
|
+
try { wfs(dest, text, "utf8"); } catch (err) {
|
|
699
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
700
|
+
}
|
|
701
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
702
|
+
}
|
|
703
|
+
if (format === "html") {
|
|
704
|
+
const esc = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
705
|
+
try { wfs(dest, `<!doctype html><html><head><meta charset="utf8"><title>${title}</title></head><body><pre>${esc}</pre></body></html>`, "utf8"); } catch (err) {
|
|
706
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
707
|
+
}
|
|
708
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
709
|
+
}
|
|
710
|
+
// pdf/docx/pptx need python libs — probe, then consent-gate.
|
|
711
|
+
const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
|
|
712
|
+
let have = false;
|
|
713
|
+
try {
|
|
714
|
+
execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
|
|
715
|
+
have = true;
|
|
716
|
+
} catch {}
|
|
717
|
+
if (!have) {
|
|
718
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
719
|
+
needsInstall: true,
|
|
720
|
+
tool: need,
|
|
721
|
+
install: `pip install ${need}`,
|
|
722
|
+
ask: `${need} is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was written. md/txt/html export works without it.`,
|
|
723
|
+
}) }] };
|
|
724
|
+
}
|
|
725
|
+
return { content: [{ type: "text", text: JSON.stringify({ ready: true, tool: need, note: "Backend present. Tell the agent to run the conversion explicitly — bwb never executes installs itself." }) }] };
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
|
|
547
729
|
// ═══════════════ LIFECYCLE ═══════════════
|
|
548
730
|
|
|
549
731
|
browser_status: {
|
|
550
|
-
description: "Get browser and page status including opened tabs and connection info.",
|
|
732
|
+
description: "Get browser and page status including opened tabs and connection info. v4: includes live resource readings (MCP + Chromium MB, budgets) so the agent sees pressure before Android does.",
|
|
551
733
|
schema: {},
|
|
552
734
|
handler: async () => {
|
|
553
735
|
const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
|
|
@@ -555,19 +737,23 @@ const tools = {
|
|
|
555
737
|
status.running = true;
|
|
556
738
|
status.pid = browser.pid;
|
|
557
739
|
status.tabs = listTabs();
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
status.
|
|
564
|
-
|
|
565
|
-
const targets = await CDP.List({ port: cfg.port || 9222 });
|
|
740
|
+
// actualCdpPort is the real bound port; cfg.port may be 0 (random).
|
|
741
|
+
// Never fall back to a hardcoded 9222 — that could be another tool's browser.
|
|
742
|
+
const listPort = actualCdpPort || cfg.port;
|
|
743
|
+
if (listPort) {
|
|
744
|
+
try {
|
|
745
|
+
status.actualPort = actualCdpPort || cfg.port;
|
|
746
|
+
const targets = await CDP.List({ port: listPort });
|
|
566
747
|
status.connected = true;
|
|
567
748
|
status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
|
|
568
|
-
}
|
|
569
|
-
}
|
|
749
|
+
} catch { status.connected = false; }
|
|
750
|
+
}
|
|
570
751
|
}
|
|
752
|
+
try {
|
|
753
|
+
status.resources = sampleResources(browser?.pid, status.tabs.filter((t) => !t.hibernated).length);
|
|
754
|
+
status.resources.state = assess(status.resources);
|
|
755
|
+
status.profile = { lean: cfg.lean, nuclear: cfg.nuclear, idleMs: cfg.idleMs, tabMax: cfg.tabMax };
|
|
756
|
+
} catch {}
|
|
571
757
|
return { content: [{ type: "text", text: JSON.stringify(status) }] };
|
|
572
758
|
},
|
|
573
759
|
},
|
|
@@ -577,6 +763,7 @@ const tools = {
|
|
|
577
763
|
schema: {},
|
|
578
764
|
handler: async () => {
|
|
579
765
|
clearTabs(); // Kill stale tab connections before restart
|
|
766
|
+
cleanupWatch(); // Detach event listeners from the dying protocol before it's gone
|
|
580
767
|
const result = await restartBrowser();
|
|
581
768
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
582
769
|
},
|
|
@@ -585,8 +772,45 @@ const tools = {
|
|
|
585
772
|
|
|
586
773
|
// ─── Register & Start ─────────────────────────────────────────────────────────
|
|
587
774
|
|
|
775
|
+
// Single choke point for every tool call: poke the mayfly timer, then append
|
|
776
|
+
// a ~100-byte resource footer. On critical pressure, shed load BEFORE
|
|
777
|
+
// returning — hibernate oldest tabs, teardown at one tab — and say so.
|
|
588
778
|
for (const [name, tool] of Object.entries(tools)) {
|
|
589
|
-
|
|
779
|
+
const inner = tool.handler;
|
|
780
|
+
server.tool(name, tool.description, tool.schema, async (args) => {
|
|
781
|
+
let result;
|
|
782
|
+
try {
|
|
783
|
+
result = await inner(args);
|
|
784
|
+
} finally {
|
|
785
|
+
try { pokeActivity(); } catch {}
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
const liveTabs = listTabs().filter((t) => !t.hibernated).length;
|
|
789
|
+
const sample = sampleResources(browser?.pid, liveTabs);
|
|
790
|
+
let note = "";
|
|
791
|
+
if (assess(sample) === "critical" && browser && !browserExited) {
|
|
792
|
+
// Evidence first: keep the peak numbers that triggered the shed.
|
|
793
|
+
const peak = `${sample.mcpMb}+${sample.chromiumMb ?? "?"}MB`;
|
|
794
|
+
// Shed oldest non-active tabs first; teardown at one tab. Journal keeps all.
|
|
795
|
+
const victims = listTabs().filter((t) => !t.active && !t.hibernated).map((t) => t.id);
|
|
796
|
+
for (const id of victims) {
|
|
797
|
+
await hibernateTab(id);
|
|
798
|
+
const after = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length);
|
|
799
|
+
note = `shed tab ${id.slice(0, 8)} at peak ${peak}`;
|
|
800
|
+
Object.assign(sample, after);
|
|
801
|
+
if (assess(sample) !== "critical") break;
|
|
802
|
+
}
|
|
803
|
+
if (assess(sample) === "critical" && listTabs().filter((t) => !t.hibernated).length <= 1) {
|
|
804
|
+
try { await stopBrowser("oom-guard"); } catch {}
|
|
805
|
+
note += `${note ? "; " : ""}browser stopped at peak ${peak} (oom-guard) — journal saved, next call resurrects`;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (result && Array.isArray(result.content)) {
|
|
809
|
+
result.content.push({ type: "text", text: resourceFooter(sample, note) });
|
|
810
|
+
}
|
|
811
|
+
} catch {}
|
|
812
|
+
return result;
|
|
813
|
+
});
|
|
590
814
|
}
|
|
591
815
|
|
|
592
816
|
await ensureDeps();
|