bwb-browser 3.2.0 → 4.0.1
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 +25 -11
- package/README.md +48 -28
- package/lib/browser.mjs +153 -2
- package/lib/fetch.mjs +127 -0
- 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 +234 -40
package/server.mjs
CHANGED
|
@@ -12,20 +12,35 @@
|
|
|
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)
|
|
24
|
+
* --attach-port / BWB_ATTACH_PORT — Attach to an already-running
|
|
25
|
+
* browser's CDP port (e.g. 9222)
|
|
26
|
+
* instead of spawning. Guest mode:
|
|
27
|
+
* never spawns, kills, or restores.
|
|
15
28
|
*/
|
|
16
29
|
|
|
17
30
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
32
|
import { z } from "zod";
|
|
20
33
|
import CDP from "chrome-remote-interface";
|
|
34
|
+
import { execSync } from "child_process";
|
|
21
35
|
import { mkdirSync, readFileSync, existsSync } from "fs";
|
|
22
36
|
import { homedir, platform } from "os";
|
|
23
37
|
import { join, dirname } from "path";
|
|
24
38
|
import { fileURLToPath } from "url";
|
|
25
39
|
|
|
26
40
|
import {
|
|
27
|
-
ensureBrowser, restartBrowser, saveScreenshot,
|
|
28
|
-
cfg, browser, browserExited, actualCdpPort,
|
|
41
|
+
ensureBrowser, restartBrowser, stopBrowser, saveScreenshot,
|
|
42
|
+
cfg, browser, browserExited, actualCdpPort, attached,
|
|
43
|
+
isTermux, pokeActivity, setIdleSuppressed,
|
|
29
44
|
} from "./lib/browser.mjs";
|
|
30
45
|
|
|
31
46
|
// ─── --setup mode ──────────────────────────────────────────────────
|
|
@@ -41,8 +56,12 @@ import {
|
|
|
41
56
|
|
|
42
57
|
import {
|
|
43
58
|
getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
|
|
59
|
+
hibernateTab,
|
|
44
60
|
} from "./lib/tabs.mjs";
|
|
45
61
|
|
|
62
|
+
import { staticFetch } from "./lib/fetch.mjs";
|
|
63
|
+
import { sampleResources, assess, resourceFooter, resolveBudgets } from "./lib/vigil.mjs";
|
|
64
|
+
|
|
46
65
|
import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
|
|
47
66
|
import { diagnosePage } from "./lib/diagnose.mjs";
|
|
48
67
|
import { applyRealisticProfile } from "./lib/fingerprint.mjs";
|
|
@@ -68,6 +87,11 @@ function parseArgs() {
|
|
|
68
87
|
case "--headless": cliCfg.headless = args[++i] !== "false"; break;
|
|
69
88
|
case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
|
|
70
89
|
case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
|
|
90
|
+
case "--lean": cliCfg.lean = args[++i] !== "false"; break;
|
|
91
|
+
case "--nuclear": cliCfg.nuclear = args[++i] !== "false"; break;
|
|
92
|
+
case "--idle": cliCfg.idleMs = parseInt(args[++i], 10); break;
|
|
93
|
+
case "--tab-max": cliCfg.tabMax = parseInt(args[++i], 10); break;
|
|
94
|
+
case "--attach-port": cliCfg.attachPort = parseInt(args[++i], 10); break;
|
|
71
95
|
case "--version": console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0);
|
|
72
96
|
case "--help": printHelp(); process.exit(0);
|
|
73
97
|
}
|
|
@@ -77,12 +101,12 @@ function parseArgs() {
|
|
|
77
101
|
|
|
78
102
|
function printHelp() {
|
|
79
103
|
console.log(`
|
|
80
|
-
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
104
|
+
bwb-browser v${BWB_VERSION} — Browser Without Bloat
|
|
81
105
|
|
|
82
|
-
Browser automation for AI agents.
|
|
83
|
-
|
|
106
|
+
Browser automation for AI agents. Static-first, lean like air. 26 tools.
|
|
107
|
+
Raw CDP — no Playwright, no Puppeteer. Chromium starts only when JS demands it.
|
|
84
108
|
|
|
85
|
-
Built on Termux/Android. Runs everywhere
|
|
109
|
+
Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
|
|
86
110
|
|
|
87
111
|
USAGE:
|
|
88
112
|
bwb [options]
|
|
@@ -94,18 +118,23 @@ OPTIONS:
|
|
|
94
118
|
--headless <bool> Run headless (default: true)
|
|
95
119
|
--screenshots-dir <path> Directory to save screenshots
|
|
96
120
|
--timeout <ms> Navigation timeout in ms (default: 30000)
|
|
121
|
+
--lean <bool> Survival profile (default: auto on Termux)
|
|
122
|
+
--nuclear Add --single-process (max saving, min stability)
|
|
123
|
+
--idle <ms> Mayfly teardown after N ms idle (default: 5min lean)
|
|
124
|
+
--tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
|
|
125
|
+
--attach-port <n> Attach to a running browser's CDP port (e.g. 9222).
|
|
126
|
+
Guest mode: no spawn, no kill, visible window.
|
|
97
127
|
--version Print version
|
|
98
128
|
--help Show this help
|
|
99
129
|
|
|
100
130
|
TOOLS (26):
|
|
101
131
|
CORE BROWSING:
|
|
102
|
-
browser_goto Navigate to
|
|
132
|
+
browser_goto Navigate (static-first, escalates to browser)
|
|
103
133
|
browser_screenshot Take a screenshot
|
|
104
134
|
browser_html Get page/selector HTML
|
|
105
135
|
browser_text Get page/selector text
|
|
106
|
-
browser_title Get page title
|
|
107
|
-
browser_url Get current URL
|
|
108
136
|
browser_back Go back in history
|
|
137
|
+
(title/url folded into browser_status — v4 breaking change)
|
|
109
138
|
|
|
110
139
|
INTERACTION:
|
|
111
140
|
browser_click Click an element
|
|
@@ -116,7 +145,7 @@ TOOLS (26):
|
|
|
116
145
|
|
|
117
146
|
🔥 ADVANCED:
|
|
118
147
|
browser_act Natural language page interaction (one tool does it all)
|
|
119
|
-
browser_watch Live page event capture (console, network)
|
|
148
|
+
browser_watch Live page event capture (console, network + resources)
|
|
120
149
|
browser_diagnose Full page health diagnostic
|
|
121
150
|
browser_fingerprint Realistic browser profile for testing
|
|
122
151
|
browser_waitForSelector Wait for element to appear/disappear
|
|
@@ -124,7 +153,7 @@ TOOLS (26):
|
|
|
124
153
|
MULTI-TAB:
|
|
125
154
|
browser_newTab Create a new tab
|
|
126
155
|
browser_closeTab Close a tab
|
|
127
|
-
browser_switchTab Switch to a
|
|
156
|
+
browser_switchTab Switch to a tab (hibernated tabs wake)
|
|
128
157
|
browser_listTabs List all open tabs
|
|
129
158
|
|
|
130
159
|
SESSION:
|
|
@@ -132,8 +161,12 @@ TOOLS (26):
|
|
|
132
161
|
browser_loadCookies Load session cookies from disk
|
|
133
162
|
browser_listSessions List saved sessions
|
|
134
163
|
|
|
164
|
+
ON-DEMAND (verbs ship, weight doesn't — backends install on consent):
|
|
165
|
+
browser_download Download media (needs system yt-dlp)
|
|
166
|
+
browser_export Export md/txt/html (pdf/docx/pptx need pip libs)
|
|
167
|
+
|
|
135
168
|
LIFECYCLE:
|
|
136
|
-
browser_status
|
|
169
|
+
browser_status Status + live resources + active profile
|
|
137
170
|
browser_restart Restart the browser
|
|
138
171
|
|
|
139
172
|
If bwb saves you time or money, consider supporting development:
|
|
@@ -172,6 +205,7 @@ async function ensureDeps() {
|
|
|
172
205
|
|
|
173
206
|
Object.assign(cfg, parseArgs());
|
|
174
207
|
cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
|
|
208
|
+
cfg.attachPort = cfg.attachPort || parseInt(process.env.BWB_ATTACH_PORT || "0", 10);
|
|
175
209
|
cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
|
|
176
210
|
cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
|
|
177
211
|
cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || (() => {
|
|
@@ -183,6 +217,24 @@ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || ((
|
|
|
183
217
|
return join(homedir(), "bwb-screenshots");
|
|
184
218
|
})();
|
|
185
219
|
cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
|
|
220
|
+
// v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean
|
|
221
|
+
// unless explicitly overridden. Desktop behavior unchanged (all off).
|
|
222
|
+
if (cfg.lean === null || cfg.lean === undefined) {
|
|
223
|
+
if (process.env.BWB_LEAN !== undefined) cfg.lean = process.env.BWB_LEAN !== "false";
|
|
224
|
+
else cfg.lean = isTermux();
|
|
225
|
+
}
|
|
226
|
+
if (cfg.nuclear === undefined || cfg.nuclear === null) {
|
|
227
|
+
cfg.nuclear = process.env.BWB_NUCLEAR === "true";
|
|
228
|
+
}
|
|
229
|
+
if (cfg.idleMs === null || cfg.idleMs === undefined) {
|
|
230
|
+
if (process.env.BWB_IDLE_MS !== undefined) cfg.idleMs = parseInt(process.env.BWB_IDLE_MS, 10);
|
|
231
|
+
else cfg.idleMs = cfg.lean ? 5 * 60 * 1000 : 0;
|
|
232
|
+
}
|
|
233
|
+
if (cfg.tabMax === null || cfg.tabMax === undefined) {
|
|
234
|
+
if (process.env.BWB_TAB_MAX !== undefined) cfg.tabMax = parseInt(process.env.BWB_TAB_MAX, 10);
|
|
235
|
+
else cfg.tabMax = cfg.lean ? 3 : 0;
|
|
236
|
+
}
|
|
237
|
+
resolveBudgets(cfg.lean);
|
|
186
238
|
|
|
187
239
|
try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
|
|
188
240
|
|
|
@@ -243,14 +295,29 @@ const tools = {
|
|
|
243
295
|
// ═══════════════ CORE BROWSING ═══════════════
|
|
244
296
|
|
|
245
297
|
browser_goto: {
|
|
246
|
-
description: "Navigate to a URL. Returns page title and URL.",
|
|
298
|
+
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).",
|
|
247
299
|
schema: { url: z.string().describe("URL to navigate to") },
|
|
248
300
|
handler: async ({ url }) => {
|
|
301
|
+
// Rung 1: static fetch. No browser spawned, no LMK risk, milliseconds.
|
|
302
|
+
const attempt = await staticFetch(url, { timeout: Math.min(cfg.navTimeout, 15000) });
|
|
303
|
+
if (attempt.mode === "static") {
|
|
304
|
+
syncActiveTab(attempt.title, attempt.finalUrl);
|
|
305
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
306
|
+
mode: "static", title: attempt.title, url: attempt.finalUrl,
|
|
307
|
+
text: attempt.text, confidence: attempt.confidence,
|
|
308
|
+
note: "Served without Chromium. Need interaction/screenshots? Use browser_act / browser_screenshot — that escalates to the browser.",
|
|
309
|
+
}) }] };
|
|
310
|
+
}
|
|
311
|
+
if (attempt.mode === "error") {
|
|
312
|
+
// Dead URL — CDP shares the same network, don't spawn Chromium for a 404.
|
|
313
|
+
return { content: [{ type: "text", text: JSON.stringify({ mode: "error", error: attempt.error }) }] };
|
|
314
|
+
}
|
|
315
|
+
// Rung 2: escalate to Chromium (JS shell, auth wall, non-text).
|
|
249
316
|
const cdp = await getActiveProtocol();
|
|
250
317
|
const { Page, Runtime } = cdp;
|
|
251
318
|
const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
|
|
252
319
|
syncActiveTab(result.title, result.url);
|
|
253
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
320
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
|
|
254
321
|
},
|
|
255
322
|
},
|
|
256
323
|
|
|
@@ -326,26 +393,6 @@ const tools = {
|
|
|
326
393
|
},
|
|
327
394
|
},
|
|
328
395
|
|
|
329
|
-
browser_title: {
|
|
330
|
-
description: "Get current page title.",
|
|
331
|
-
schema: {},
|
|
332
|
-
handler: async () => {
|
|
333
|
-
const { Runtime } = await getActiveProtocol();
|
|
334
|
-
const { result } = await Runtime.evaluate({ expression: "document.title" });
|
|
335
|
-
return { content: [{ type: "text", text: result?.value || "" }] };
|
|
336
|
-
},
|
|
337
|
-
},
|
|
338
|
-
|
|
339
|
-
browser_url: {
|
|
340
|
-
description: "Get current page URL.",
|
|
341
|
-
schema: {},
|
|
342
|
-
handler: async () => {
|
|
343
|
-
const { Runtime } = await getActiveProtocol();
|
|
344
|
-
const { result } = await Runtime.evaluate({ expression: "window.location.href" });
|
|
345
|
-
return { content: [{ type: "text", text: result?.value || "" }] };
|
|
346
|
-
},
|
|
347
|
-
},
|
|
348
|
-
|
|
349
396
|
browser_back: {
|
|
350
397
|
description: "Go back in browser history (like clicking the browser back button).",
|
|
351
398
|
schema: {},
|
|
@@ -467,16 +514,21 @@ const tools = {
|
|
|
467
514
|
await cdp.Runtime.enable();
|
|
468
515
|
await cdp.Network.enable();
|
|
469
516
|
setupWatch(events, cdp);
|
|
517
|
+
setIdleSuppressed(true); // recording in progress — mayfly must not teardown
|
|
470
518
|
return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
|
|
471
519
|
}
|
|
472
520
|
if (action === "poll") {
|
|
473
521
|
const snapshot = [...watchState.events];
|
|
474
522
|
watchState.events = [];
|
|
475
|
-
|
|
523
|
+
// Resource vigilance rides the existing poll rhythm — no new mechanism.
|
|
524
|
+
let resources = null;
|
|
525
|
+
try { resources = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length); } catch {}
|
|
526
|
+
return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot, resources }) }] };
|
|
476
527
|
}
|
|
477
528
|
if (action === "stop") {
|
|
478
529
|
const remaining = [...watchState.events];
|
|
479
530
|
cleanupWatch();
|
|
531
|
+
setIdleSuppressed(false);
|
|
480
532
|
return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
|
|
481
533
|
}
|
|
482
534
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
|
|
@@ -542,7 +594,7 @@ const tools = {
|
|
|
542
594
|
description: "Switch to a different browser tab by targetId.",
|
|
543
595
|
schema: { targetId: z.string().describe("Target tab ID to switch to") },
|
|
544
596
|
handler: async ({ targetId }) => {
|
|
545
|
-
const result = switchTab(targetId);
|
|
597
|
+
const result = await switchTab(targetId);
|
|
546
598
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
547
599
|
},
|
|
548
600
|
},
|
|
@@ -587,16 +639,113 @@ const tools = {
|
|
|
587
639
|
},
|
|
588
640
|
},
|
|
589
641
|
|
|
642
|
+
// ═══════════════ ON-DEMAND CAPABILITIES ═══════════════
|
|
643
|
+
// Verbs ship, weight doesn't. Heavy backends (yt-dlp, reportlab) are NEVER
|
|
644
|
+
// bundled — probed at call time, installed only on explicit user consent.
|
|
645
|
+
|
|
646
|
+
browser_download: {
|
|
647
|
+
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.",
|
|
648
|
+
schema: {
|
|
649
|
+
url: z.string().describe("Media URL"),
|
|
650
|
+
format: z.enum(["best", "audio", "video", "subtitles", "thumbnail"]).describe("What to download").optional(),
|
|
651
|
+
quality: z.enum(["best", "good", "worst"]).describe("Quality tier").optional(),
|
|
652
|
+
},
|
|
653
|
+
handler: async ({ url, format = "best", quality = "best" }) => {
|
|
654
|
+
// No shell metachars ever reach execSync — http(s) only.
|
|
655
|
+
if (!/^https?:\/\/[^\\s"';`$(){}|&<>]+$/i.test(url)) {
|
|
656
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "refused: URL must be http(s) without shell metacharacters" }) }] };
|
|
657
|
+
}
|
|
658
|
+
let hasYtDlp = false;
|
|
659
|
+
try {
|
|
660
|
+
execSync("yt-dlp --version", { stdio: "ignore", timeout: 5000 });
|
|
661
|
+
hasYtDlp = true;
|
|
662
|
+
} catch {}
|
|
663
|
+
if (!hasYtDlp) {
|
|
664
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
665
|
+
needsInstall: true,
|
|
666
|
+
tool: "yt-dlp",
|
|
667
|
+
install: {
|
|
668
|
+
termux: "pkg install yt-dlp",
|
|
669
|
+
debian: "pip install yt-dlp",
|
|
670
|
+
macos: "brew install yt-dlp",
|
|
671
|
+
},
|
|
672
|
+
ask: "yt-dlp is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was downloaded.",
|
|
673
|
+
}) }] };
|
|
674
|
+
}
|
|
675
|
+
const outDir = join(dirname(cfg.screenshotsDir), "bwb-downloads");
|
|
676
|
+
try { mkdirSync(outDir, { recursive: true }); } catch {}
|
|
677
|
+
const args = ["--no-playlist", "-P", outDir, "--print", "after_move:filepath"];
|
|
678
|
+
if (format === "audio") args.push("-x", "--audio-format", "mp3");
|
|
679
|
+
else if (format === "subtitles") args.push("--write-subs", "--skip-download");
|
|
680
|
+
else if (format === "thumbnail") args.push("--write-thumbnail", "--skip-download");
|
|
681
|
+
if (quality === "worst") args.push("-f", "worst");
|
|
682
|
+
else if (quality === "good") args.push("-f", "best[height<=720]");
|
|
683
|
+
args.push(url);
|
|
684
|
+
try {
|
|
685
|
+
const out = execSync(`yt-dlp ${args.map((a) => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 600000, maxBuffer: 1024 * 1024 });
|
|
686
|
+
const file = out.trim().split("\n").pop();
|
|
687
|
+
return { content: [{ type: "text", text: JSON.stringify({ downloaded: file, format, quality }) }] };
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "download failed", detail: String(err.message || err).slice(0, 500) }) }] };
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
browser_export: {
|
|
695
|
+
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.",
|
|
696
|
+
schema: {
|
|
697
|
+
text: z.string().describe("Content to export (markdown accepted)"),
|
|
698
|
+
format: z.enum(["md", "txt", "html", "pdf", "docx", "pptx"]).describe("Output format").optional(),
|
|
699
|
+
output_path: z.string().describe("Where to write the file").optional(),
|
|
700
|
+
title: z.string().describe("Document title").optional(),
|
|
701
|
+
},
|
|
702
|
+
handler: async ({ text, format = "md", output_path, title = "bwb export" }) => {
|
|
703
|
+
const { writeFileSync: wfs } = await import("fs");
|
|
704
|
+
const dest = output_path || join(dirname(cfg.screenshotsDir), `bwb-export-${Date.now()}.${format === "txt" ? "txt" : format === "html" ? "html" : "md"}`);
|
|
705
|
+
if (["md", "txt"].includes(format)) {
|
|
706
|
+
try { wfs(dest, text, "utf8"); } catch (err) {
|
|
707
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
708
|
+
}
|
|
709
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
710
|
+
}
|
|
711
|
+
if (format === "html") {
|
|
712
|
+
const esc = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
713
|
+
try { wfs(dest, `<!doctype html><html><head><meta charset="utf8"><title>${title}</title></head><body><pre>${esc}</pre></body></html>`, "utf8"); } catch (err) {
|
|
714
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
715
|
+
}
|
|
716
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
717
|
+
}
|
|
718
|
+
// pdf/docx/pptx need python libs — probe, then consent-gate.
|
|
719
|
+
const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
|
|
720
|
+
let have = false;
|
|
721
|
+
try {
|
|
722
|
+
execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
|
|
723
|
+
have = true;
|
|
724
|
+
} catch {}
|
|
725
|
+
if (!have) {
|
|
726
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
727
|
+
needsInstall: true,
|
|
728
|
+
tool: need,
|
|
729
|
+
install: `pip install ${need}`,
|
|
730
|
+
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.`,
|
|
731
|
+
}) }] };
|
|
732
|
+
}
|
|
733
|
+
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." }) }] };
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
|
|
590
737
|
// ═══════════════ LIFECYCLE ═══════════════
|
|
591
738
|
|
|
592
739
|
browser_status: {
|
|
593
|
-
description: "Get browser and page status including opened tabs and connection info.",
|
|
740
|
+
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.",
|
|
594
741
|
schema: {},
|
|
595
742
|
handler: async () => {
|
|
596
743
|
const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
|
|
597
|
-
|
|
744
|
+
// Attach mode: no child process (browser === null) — liveness is the CDP link.
|
|
745
|
+
if ((browser && !browserExited) || (attached && !browserExited)) {
|
|
598
746
|
status.running = true;
|
|
599
|
-
status.pid = browser.pid;
|
|
747
|
+
status.pid = browser ? browser.pid : null;
|
|
748
|
+
if (attached) status.attached = true;
|
|
600
749
|
status.tabs = listTabs();
|
|
601
750
|
// actualCdpPort is the real bound port; cfg.port may be 0 (random).
|
|
602
751
|
// Never fall back to a hardcoded 9222 — that could be another tool's browser.
|
|
@@ -610,6 +759,12 @@ const tools = {
|
|
|
610
759
|
} catch { status.connected = false; }
|
|
611
760
|
}
|
|
612
761
|
}
|
|
762
|
+
try {
|
|
763
|
+
status.resources = sampleResources(browser?.pid, status.tabs.filter((t) => !t.hibernated).length);
|
|
764
|
+
status.resources.state = assess(status.resources);
|
|
765
|
+
status.profile = { lean: cfg.lean, nuclear: cfg.nuclear, idleMs: cfg.idleMs, tabMax: cfg.tabMax };
|
|
766
|
+
if (attached) status.profile.attached = actualCdpPort || cfg.attachPort;
|
|
767
|
+
} catch {}
|
|
613
768
|
return { content: [{ type: "text", text: JSON.stringify(status) }] };
|
|
614
769
|
},
|
|
615
770
|
},
|
|
@@ -628,8 +783,47 @@ const tools = {
|
|
|
628
783
|
|
|
629
784
|
// ─── Register & Start ─────────────────────────────────────────────────────────
|
|
630
785
|
|
|
786
|
+
// Single choke point for every tool call: poke the mayfly timer, then append
|
|
787
|
+
// a ~100-byte resource footer. On critical pressure, shed load BEFORE
|
|
788
|
+
// returning — hibernate oldest tabs, teardown at one tab — and say so.
|
|
631
789
|
for (const [name, tool] of Object.entries(tools)) {
|
|
632
|
-
|
|
790
|
+
const inner = tool.handler;
|
|
791
|
+
server.tool(name, tool.description, tool.schema, async (args) => {
|
|
792
|
+
let result;
|
|
793
|
+
try {
|
|
794
|
+
result = await inner(args);
|
|
795
|
+
} finally {
|
|
796
|
+
try { pokeActivity(); } catch {}
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
const liveTabs = listTabs().filter((t) => !t.hibernated).length;
|
|
800
|
+
const sample = sampleResources(browser?.pid, liveTabs);
|
|
801
|
+
let note = "";
|
|
802
|
+
// Never auto-shed in attach mode: those are the user's REAL tabs.
|
|
803
|
+
// Report pressure in the footer; the human closes their own tabs.
|
|
804
|
+
if (!attached && assess(sample) === "critical" && browser && !browserExited) {
|
|
805
|
+
// Evidence first: keep the peak numbers that triggered the shed.
|
|
806
|
+
const peak = `${sample.mcpMb}+${sample.chromiumMb ?? "?"}MB`;
|
|
807
|
+
// Shed oldest non-active tabs first; teardown at one tab. Journal keeps all.
|
|
808
|
+
const victims = listTabs().filter((t) => !t.active && !t.hibernated).map((t) => t.id);
|
|
809
|
+
for (const id of victims) {
|
|
810
|
+
await hibernateTab(id);
|
|
811
|
+
const after = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length);
|
|
812
|
+
note = `shed tab ${id.slice(0, 8)} at peak ${peak}`;
|
|
813
|
+
Object.assign(sample, after);
|
|
814
|
+
if (assess(sample) !== "critical") break;
|
|
815
|
+
}
|
|
816
|
+
if (assess(sample) === "critical" && listTabs().filter((t) => !t.hibernated).length <= 1) {
|
|
817
|
+
try { await stopBrowser("oom-guard"); } catch {}
|
|
818
|
+
note += `${note ? "; " : ""}browser stopped at peak ${peak} (oom-guard) — journal saved, next call resurrects`;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (result && Array.isArray(result.content)) {
|
|
822
|
+
result.content.push({ type: "text", text: resourceFooter(sample, note) });
|
|
823
|
+
}
|
|
824
|
+
} catch {}
|
|
825
|
+
return result;
|
|
826
|
+
});
|
|
633
827
|
}
|
|
634
828
|
|
|
635
829
|
await ensureDeps();
|