bwb-browser 3.2.0 → 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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # bwb-browser — Agent Integration Guide
2
2
 
3
3
  > **Author:** Krish Tiwari ([@krshforever](https://github.com/krshforever))
4
- > **Package:** [`bwb-browser`](https://www.npmjs.com/package/bwb-browser) · 76KB source · 26 tools
4
+ > **Package:** [`bwb-browser`](https://www.npmjs.com/package/bwb-browser) · ~136KB source · 26 tools · static-first (v4)
5
5
  > **Last updated:** 2026-08-06
6
6
 
7
7
  ## What is bwb?
8
8
 
9
- **Browser Without Bloat** — a lightweight MCP server that gives any AI agent browser superpowers. 76KB. 26 tools. Zero heavy dependencies.
9
+ **Browser Without Bloat** — a lightweight MCP server that gives any AI agent browser superpowers. ~136KB source. 26 tools. Static-first: plain pages never spawn Chromium. Zero native dependencies.
10
10
 
11
11
  While other MCP browser tools ship a full browser binary (Playwright MCP = ~250MB, Puppeteer MCP = ~400MB), bwb uses **raw Chrome DevTools Protocol (CDP)** — the protocol Chrome speaks natively. It auto-detects the browser already on your system.
12
12
 
@@ -64,9 +64,9 @@ bwb --version
64
64
  Give this to any AI agent (Claude, OpenCode, Gemini, etc.) to auto-install and configure bwb:
65
65
 
66
66
  ```
67
- I want you to install and use bwb-browser — a 76KB MCP browser automation server with 26 tools.
67
+ I want you to install and use bwb-browser — a lightweight MCP browser automation server with 26 tools.
68
68
 
69
- bwb is Browser Without Bloat. It uses raw Chrome DevTools Protocol (no Playwright, no Puppeteer).
69
+ bwb is Browser Without Bloat. Static-first: plain pages are fetched + extracted with zero Chromium; JS pages escalate automatically. It uses raw Chrome DevTools Protocol (no Playwright, no Puppeteer) only when rendering is actually needed.
70
70
  It has 26 tools including:
71
71
 
72
72
  - `browser_act` — natural language interaction ("search for X", "click the button", "what's on this page")
@@ -116,20 +116,22 @@ Created by Krish Tiwari (@krshforever) from a phone on a train in India. npm: bw
116
116
 
117
117
  5. Verify the config is valid JSON and the file has proper syntax.
118
118
 
119
- 6. Test the integration by calling `browser_status` tool — it should return `connected: true`
120
- (it starts Chromium automatically on first call).
119
+ 6. Test the integration by calling `browser_status` tool — it should return `connected: true`
120
+ (static-first: plain pages never start Chromium; JS pages start it on first CDP call).
121
121
 
122
122
  ## Skill: I Know How to Use bwb
123
123
 
124
124
  When using bwb for web tasks, follow these patterns:
125
125
 
126
126
  ### Basic Browsing
127
- - `browser_goto({url: "https://example.com"})` — navigate to a page
128
- - `browser_title()` — check page title
129
- - `browser_screenshot({selector: "#chart"})` — take a screenshot (whole page, viewport, or one element; saves to /storage/emulated/0/Download/bwb-screenshots/ on Android or ~/bwb-screenshots/ on desktop)
127
+ - `browser_goto({url: "https://example.com"})` — navigate (static-first: `mode: "static"` needs no browser; `mode: "browser"` escalated to CDP)
130
128
  - `browser_text()` — get page text content
129
+ - `browser_screenshot({selector: "#chart"})` — take a screenshot (whole page, viewport, or one element; saves to /storage/emulated/0/Download/bwb-screenshots/ on Android or ~/bwb-screenshots/ on desktop)
131
130
  - `browser_html()` — get page HTML
132
131
  - `browser_elements({kind: "links"|"buttons"|"inputs"|"headings"})` — find interactive elements
132
+ - `browser_status()` — page title/URL live in `targets`, plus resource readings and active profile
133
+
134
+ Every tool response ends with a `[bwb resources]` footer (MCP + Chromium MB, tabs, ok/watch/critical). On critical, bwb sheds load itself and says so — read the footer before spawning more work.
133
135
 
134
136
  ### Interaction
135
137
  - `browser_fill({selector: "#search", text: "query"})` — fill input fields
@@ -173,8 +175,8 @@ internally — not just what it looks like.
173
175
  | `browser_click` | Click an element (native CDP mouse events) |
174
176
  | `browser_fill` | Fill an input field (native CDP keyboard events) |
175
177
  | `browser_elements` | List interactive elements by kind |
176
- | `browser_title` | Get page title |
177
- | `browser_url` | Get current URL |
178
+ | `browser_download` | Download media (needs system yt-dlp, consent-gated) |
179
+ | `browser_export` | Export md/txt/html (pdf/docx/pptx need pip libs, consent-gated) |
178
180
  | `browser_back` | Go back in history |
179
181
  | `browser_eval` | Execute JavaScript (with exception capture) |
180
182
  | `browser_setViewport` | Change viewport size |
package/README.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # bwb-browser
2
2
 
3
- **Browser Without Bloat** — 76KB. 26 tools. Zero dependencies. Runs on your phone.
3
+ **Browser Without Bloat** — 136KB source. 26 tools. Static-first. Runs on your phone, survives it too.
4
4
 
5
- A lightweight MCP server that gives any AI agent browser superpowers. Written by a guy in India on Termux because the existing tools were 200MB of "why."
5
+ A lightweight MCP server that gives any AI agent browser superpowers. Written by a guy in India on Termux because the existing tools were 200MB of "why" — then rewritten when Android kept killing those tools mid-run.
6
+
7
+ ---
8
+
9
+ ## v4: The Browser Starts Only When It Must
10
+
11
+ v1–v3 made the server light but spawned Chromium for everything — including reading a README. Android's out-of-memory killer ate whole Termux sessions for that. v4 inverts the default:
12
+
13
+ - **Static-first fetch ladder** — plain pages are fetched + extracted with zero Chromium. `browser_goto` returns `mode: "static"` in milliseconds. JS pages escalate to CDP automatically (`mode: "browser"` + reason). Dead URLs error without spawning anything.
14
+ - **Vigilance system** — every tool response carries a `[bwb resources]` footer. On critical pressure bwb hibernates tabs itself, tears down at one tab, journals everything, and tells the agent what it did.
15
+ - **Survival profile** — `--lean` auto-enables on Termux (capped renderers, 3-tab cap, 5-minute mayfly teardown, 256MB JS heap). Runs on a 1GB VPS. Your tabs resurrect from the journal after any kill.
16
+ - **On-demand capabilities** — `browser_download` / `browser_export` ship as verbs, not weight. Missing backends (yt-dlp, reportlab) prompt for consent install. Nothing heavy is ever bundled.
17
+
18
+ Measured on-device: a single YouTube tab costs **746MB** of Chromium tree. That's why the ladder exists.
6
19
 
7
20
  ---
8
21
 
@@ -12,17 +25,20 @@ Every other MCP browser tool ships a full browser binary. Playwright MCP? ~250MB
12
25
 
13
26
  bwb uses **raw Chrome DevTools Protocol (CDP)** — the same protocol Chrome speaks natively. It auto-detects the browser already on your system. No downloads. No binary mismatches. No "why is my disk full" panic.
14
27
 
15
- | Factor | bwb | Playwright MCP | Puppeteer MCP |
28
+ | Factor | bwb v4 | Playwright MCP | Puppeteer MCP |
16
29
  |--------|-----|----------------|---------------|
17
- | Source size | **76KB** | ~50MB+ | ~100MB+ |
18
- | Total install | **~1MB** | ~250MB | ~400MB |
30
+ | Source size | **~136KB** | ~50MB+ | ~100MB+ |
31
+ | Published tarball | **38.8 kB** | — | — |
32
+ | Total install (npm) | **~62MB, zero browsers** | ~250MB | ~400MB |
19
33
  | Bundled browser | **None** | Chromium (~200MB) | Chromium (~300MB) |
34
+ | Chromium spawns for plain pages | **Never (static-first)** | Always | Always |
20
35
  | Works on Termux/Android | **✅ Yes** | ❌ | ❌ |
21
- | Zero deps (no node_modules hell) | **✅ Yes** | ❌ | ❌ |
36
+ | Survives 1GB RAM / phone OOM | **✅ Lean profile + vigilance** | ❌ | ❌ |
37
+ | Zero native deps | **✅ Yes** | ❌ | ❌ |
22
38
  | Live event streaming | **✅** | ❌ | ❌ |
23
39
  | Natural language interaction | **✅** | ❌ | ❌ |
24
40
  | Persistent sessions | **✅** | ❌ | ❌ |
25
- | CPU profile at idle | Basically nothing | 🐌 | 🐌 |
41
+ | CPU profile at idle | Mayfly teardown (Termux) | 🐌 | 🐌 |
26
42
 
27
43
  ---
28
44
 
package/lib/browser.mjs CHANGED
@@ -31,8 +31,48 @@ export const cfg = {
31
31
  screenshotsDir: "",
32
32
  navTimeout: 30000,
33
33
  browserPath: null,
34
+ lean: null, // null = auto (Termux/1GB heuristics); true/false forces
35
+ nuclear: false, // --single-process: max RAM saving, min stability. Opt-in only.
36
+ idleMs: null, // null = auto (5min lean, off desktop); 0 = never teardown
37
+ tabMax: null, // null = auto (3 lean, unlimited desktop); 0 = unlimited
34
38
  };
35
39
 
40
+ // ─── Environment ─────────────────────────────────────────────────────────────
41
+
42
+ export function isTermux() {
43
+ return Boolean(
44
+ process.env.TERMUX_VERSION ||
45
+ process.env.PREFIX?.includes("com.termux") ||
46
+ process.env.HOME?.includes("com.termux")
47
+ );
48
+ }
49
+
50
+ // ─── Idle Mayfly (auto-teardown) ─────────────────────────────────────────────
51
+
52
+ let idleTimer = null;
53
+ let idleSuppressed = false; // true while browser_watch is recording
54
+
55
+ export function setIdleSuppressed(suppressed) {
56
+ idleSuppressed = suppressed;
57
+ if (suppressed) disarmIdle();
58
+ else pokeActivity();
59
+ }
60
+
61
+ export function disarmIdle() {
62
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
63
+ }
64
+
65
+ /** Reset the mayfly timer. Called after every tool call via the server wrapper. */
66
+ export function pokeActivity() {
67
+ disarmIdle();
68
+ if (!cfg.idleMs || idleSuppressed || !browser || browserExited) return;
69
+ idleTimer = setTimeout(() => {
70
+ idleTimer = null;
71
+ if (!idleSuppressed) stopBrowser("idle-timeout").catch(() => {});
72
+ }, cfg.idleMs);
73
+ if (idleTimer.unref) idleTimer.unref(); // never hold the MCP process open
74
+ }
75
+
36
76
  // ─── Kill Orphaned Chrome (Termux-safe) ────────────────────────────────────────
37
77
 
38
78
  // `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
@@ -155,6 +195,7 @@ export async function ensureBrowser() {
155
195
  "--disable-software-rasterizer",
156
196
  "--remote-debugging-port=" + debugPort,
157
197
  "--user-data-dir=" + cfg.userDataDir,
198
+ ...leanArgs(),
158
199
  ];
159
200
 
160
201
  if (!cfg.headless) args.shift();
@@ -199,7 +240,13 @@ export async function ensureBrowser() {
199
240
  CDP({ port: actualCdpPort })
200
241
  .then((cdp) => {
201
242
  protocol = cdp;
202
- startResolve(cdp);
243
+ // Awaited, not fire-and-forget: callers must see the restored
244
+ // working set, not a blank tab that later jumps. Capped + fast.
245
+ (async () => {
246
+ try { await restoreJournalTabs(); } catch {}
247
+ startResolve(cdp);
248
+ pokeActivity();
249
+ })();
203
250
  })
204
251
  .catch((err) => {
205
252
  try { browser.kill("SIGKILL"); } catch {}
@@ -218,6 +265,56 @@ export async function ensureBrowser() {
218
265
  return browserStartup;
219
266
  }
220
267
 
268
+ // ─── Lean Flags (v4 survival profile) ─────────────────────────────────────────
269
+ // Compat flags above make Chromium RUN on Termux. These make it SURVIVE:
270
+ // capped renderers, silenced background services, bounded cache + JS heap.
271
+ // --single-process stays behind --nuclear: biggest saving, weakest stability.
272
+
273
+ function leanArgs() {
274
+ if (!cfg.lean) return [];
275
+ const flags = [
276
+ "--renderer-process-limit=1",
277
+ "--disable-background-networking",
278
+ "--disable-component-update",
279
+ "--disable-sync",
280
+ "--mute-audio",
281
+ "--disable-features=Translate",
282
+ "--disk-cache-size=67108864",
283
+ "--js-flags=--max-old-space-size=256",
284
+ ];
285
+ if (cfg.nuclear) flags.push("--single-process", "--no-zygote");
286
+ return flags;
287
+ }
288
+
289
+ // ─── Stop (graceful mayfly teardown — journal keeps the working set) ─────────
290
+
291
+ export async function stopBrowser(reason = "manual") {
292
+ if (cleaningUp) return { status: "busy" };
293
+ cleaningUp = true;
294
+ disarmIdle();
295
+ try {
296
+ if (protocol) {
297
+ try { await protocol.close(); } catch {}
298
+ protocol = null;
299
+ }
300
+ if (browser) {
301
+ try { browser.kill("SIGTERM"); } catch {}
302
+ await new Promise(r => setTimeout(r, 1500));
303
+ try { browser.kill("SIGKILL"); } catch {}
304
+ browser = null;
305
+ }
306
+ } catch {}
307
+ browserExited = true; // next ensureBrowser() respawns fresh + restores journal
308
+ actualCdpPort = null;
309
+ browserStartup = null;
310
+ cleaningUp = false;
311
+ try {
312
+ const { clearTabs } = await import("./tabs.mjs");
313
+ clearTabs();
314
+ } catch {}
315
+ return { status: "stopped", reason };
316
+ }
317
+
221
318
  // ─── Restart ──────────────────────────────────────────────────────────────────
222
319
 
223
320
  export async function restartBrowser() {
@@ -246,6 +343,18 @@ export async function restartBrowser() {
246
343
  return { status: "restarted" };
247
344
  }
248
345
 
346
+ // ─── Journal Restore (post-kill resurrection — lazy, capped) ─────────────────
347
+ // Reopens the journaled working set after a fresh spawn (LMK kill, mayfly
348
+ // teardown, restart). First entry navigates now; the rest become placeholders
349
+ // woken on switchTab. Never re-spikes memory at startup to "restore".
350
+
351
+ async function restoreJournalTabs() {
352
+ try {
353
+ const tabs = await import("./tabs.mjs");
354
+ await tabs.restoreJournal();
355
+ } catch {}
356
+ }
357
+
249
358
  // ─── Screenshot Helper ────────────────────────────────────────────────────────
250
359
 
251
360
  export function saveScreenshot(base64Data) {
package/lib/fetch.mjs ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * bwb-browser — Static-first fetch (v4 fetch ladder, rung 1)
3
+ *
4
+ * Plain HTTP + Readability extraction. Zero Chromium, zero new persistent RAM:
5
+ * heavy deps are lazy-imported inside the function, so the MCP process pays
6
+ * nothing until the first static fetch. Called by browser_goto before CDP.
7
+ */
8
+
9
+ const FETCH_MAX_BYTES = 2_000_000; // never buffer more than 2MB per page
10
+ const MIN_TEXT_CHARS = 500; // below this + JS markers => probably a JS shell
11
+
12
+ // Framework shells / client-rendered markers — presence + thin text = escalate
13
+ const JS_MARKERS = [
14
+ 'id="root"', "id='root'", 'id="app"', "id='app'",
15
+ "__NEXT_DATA__", "ng-app", "ng-version", "data-reactroot",
16
+ "__NUXT__", "ember-view", "data-svelte", "_astro",
17
+ ];
18
+ // Login / auth walls — static fetch can't pass, but the browser (sessions) might
19
+ const AUTH_MARKERS = ["password", "sign in", "log in", "login", "captcha"];
20
+
21
+ const TEXT_TYPES = ["text/html", "text/plain", "application/xhtml+xml"];
22
+
23
+ /**
24
+ * Fetch + extract without Chromium.
25
+ * @returns {mode:'static',...} | {mode:'escalate', reason} | {mode:'error', error}
26
+ */
27
+ export async function staticFetch(url, { timeout = 15000 } = {}) {
28
+ let res;
29
+ try {
30
+ const ctrl = new AbortController();
31
+ const timer = setTimeout(() => ctrl.abort(), timeout);
32
+ try {
33
+ res = await fetch(url, {
34
+ signal: ctrl.signal,
35
+ redirect: "follow",
36
+ headers: {
37
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 bwb-static/4.0",
38
+ Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1",
39
+ },
40
+ });
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ } catch (err) {
45
+ // Network-level failure — CDP shares the same network, but surfaces
46
+ // better diagnostics (diagnosePage). Escalate rather than hard-fail.
47
+ return { mode: "escalate", reason: `static fetch failed (${err.name}): ${err.message}` };
48
+ }
49
+
50
+ // Auth walls: the browser (with sessions/cookies) may pass where fetch can't
51
+ if (res.status === 401 || res.status === 403 || res.status === 429) {
52
+ return { mode: "escalate", reason: `HTTP ${res.status} — possible auth wall / rate limit` };
53
+ }
54
+ if (!res.ok) {
55
+ // Dead URL — CDP won't resurrect it. Don't spawn Chromium for a 404.
56
+ return { mode: "error", error: `HTTP ${res.status} ${res.statusText} for ${url}` };
57
+ }
58
+
59
+ const contentType = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
60
+ if (contentType && !TEXT_TYPES.some((t) => contentType.startsWith(t))) {
61
+ return { mode: "escalate", reason: `non-text content (${contentType || "unknown"})` };
62
+ }
63
+
64
+ // Bounded body read — never buffer a whole ISO / stream
65
+ let html = "";
66
+ try {
67
+ const reader = res.body.getReader();
68
+ let received = 0;
69
+ for (;;) {
70
+ const { done, value } = await reader.read();
71
+ if (done) break;
72
+ received += value.length;
73
+ if (received > FETCH_MAX_BYTES) {
74
+ try { await reader.cancel(); } catch {}
75
+ return { mode: "escalate", reason: "page exceeds 2MB static cap — needs browser" };
76
+ }
77
+ html += Buffer.from(value).toString("utf8");
78
+ }
79
+ } catch (err) {
80
+ return { mode: "escalate", reason: `body read failed: ${err.message}` };
81
+ }
82
+
83
+ if (contentType.startsWith("text/plain")) {
84
+ const text = html.trim();
85
+ if (text.length < MIN_TEXT_CHARS) {
86
+ return { mode: "escalate", reason: "plain-text body too thin to trust" };
87
+ }
88
+ return { mode: "static", title: "", text, finalUrl: res.url, bytes: html.length, confidence: "high" };
89
+ }
90
+
91
+ // HTML → Readability (lazy deps: zero cost until first use)
92
+ let article;
93
+ try {
94
+ const { parseHTML } = await import("linkedom");
95
+ const { Readability } = await import("@mozilla/readability");
96
+ const { document } = parseHTML(html);
97
+ article = new Readability(document).parse();
98
+ } catch (err) {
99
+ return { mode: "escalate", reason: `extraction crashed: ${err.message}` };
100
+ }
101
+ if (!article || !(article.textContent || "").trim()) {
102
+ return { mode: "escalate", reason: "no readable article found" };
103
+ }
104
+
105
+ const text = article.textContent.trim().replace(/\n{3,}/g, "\n\n");
106
+ const lower = html.toLowerCase();
107
+ const hasJsShell = JS_MARKERS.some((m) => lower.includes(m.toLowerCase()));
108
+ const looksAuthed = AUTH_MARKERS.some((m) => lower.includes(m)) && text.length < MIN_TEXT_CHARS;
109
+
110
+ if (text.length < MIN_TEXT_CHARS && (hasJsShell || looksAuthed)) {
111
+ return {
112
+ mode: "escalate",
113
+ reason: looksAuthed && !hasJsShell
114
+ ? "thin text behind possible login wall"
115
+ : "thin text + JS shell markers — needs rendering",
116
+ };
117
+ }
118
+
119
+ return {
120
+ mode: "static",
121
+ title: article.title || "",
122
+ text,
123
+ finalUrl: res.url,
124
+ bytes: html.length,
125
+ confidence: text.length >= MIN_TEXT_CHARS ? "high" : "medium",
126
+ };
127
+ }
package/lib/setup.mjs CHANGED
@@ -305,9 +305,32 @@ export function runSetup() {
305
305
  }
306
306
 
307
307
  console.log(' 🚀 Ready to go! Try: browser_status\n');
308
+ printSurvivalGuide();
308
309
  return { configured: configured.length, alreadyDone: alreadyDone.length, errors: errors.length };
309
310
  }
310
311
 
312
+ // ─── Survival Guide (v4: OOM is the enemy, not setup) ───────────────────────
313
+ // WakeLock + battery exemption don't stop LMK — footprint does. One-time notes.
314
+
315
+ function printSurvivalGuide() {
316
+ const termux = Boolean(process.env.TERMUX_VERSION
317
+ || process.env.PREFIX?.includes('com.termux')
318
+ || process.env.HOME?.includes('com.termux'));
319
+ console.log(' ─── Survival guide (read once) ───────────────────────');
320
+ console.log(' v4 is static-first: plain pages never spawn Chromium.');
321
+ console.log(' Lean profile auto-enables on Termux (cap 3 tabs, 5-min mayfly).');
322
+ console.log(' Every tool response carries a [bwb resources] footer —');
323
+ console.log(' when it reads critical, bwb sheds tabs itself and says so.');
324
+ if (termux) {
325
+ console.log(' Termux overnight runs:');
326
+ console.log(' • termux-wake-lock (keeps CPU awake, does NOT stop LMK)');
327
+ console.log(' • Exempt Termux from battery optimization (Android Settings)');
328
+ console.log(' • The rest is footprint: fewer tabs, static-first,');
329
+ console.log(' journal restores your working set if Android still kills.');
330
+ }
331
+ console.log();
332
+ }
333
+
311
334
  function getVersion() {
312
335
  try {
313
336
  const pkg = path.resolve(__dirname, '..', 'package.json');
package/lib/tabs.mjs CHANGED
@@ -8,6 +8,49 @@
8
8
 
9
9
  import CDP from "chrome-remote-interface";
10
10
  import { ensureBrowser, protocol, actualCdpPort, cfg } from "./browser.mjs";
11
+ import { writeFileSync, readFileSync, existsSync } from "fs";
12
+ import { join } from "path";
13
+
14
+ // ─── Tab Journal (working-set survival across LMK kills / mayfly teardown) ───
15
+ // Tiny JSON append on every mutation. Journal is the truth the next fresh
16
+ // browser restores from — cookies live in user-data-dir, tab URLs live here.
17
+
18
+ function journalPath() {
19
+ if (!cfg.userDataDir) return null;
20
+ return join(cfg.userDataDir, "bwb-tabs.json");
21
+ }
22
+
23
+ function saveJournal() {
24
+ try {
25
+ const path = journalPath();
26
+ if (!path) return;
27
+ const entries = [];
28
+ for (const [id, tab] of tabs) {
29
+ if (tab.url && tab.url !== "about:blank") {
30
+ entries.push({ url: tab.url, title: tab.title || "", active: id === activeTabId });
31
+ }
32
+ }
33
+ writeFileSync(path, JSON.stringify(entries));
34
+ } catch {}
35
+ }
36
+
37
+ function loadJournal() {
38
+ try {
39
+ const path = journalPath();
40
+ if (!path || !existsSync(path)) return [];
41
+ const entries = JSON.parse(readFileSync(path, "utf8"));
42
+ return Array.isArray(entries) ? entries.filter((e) => e && e.url) : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ // Live-tab cap: 0/unset = unlimited (desktop). Lean profiles set 3.
49
+ function liveCount() {
50
+ let n = 0;
51
+ for (const tab of tabs.values()) if (!tab.hibernated) n++;
52
+ return n;
53
+ }
11
54
 
12
55
  // ─── Tab State ───────────────────────────────────────────────────────────────
13
56
 
@@ -47,6 +90,7 @@ export async function getActiveProtocol() {
47
90
 
48
91
  if (activeTabId && tabs.has(activeTabId)) {
49
92
  const tab = tabs.get(activeTabId);
93
+ if (tab.hibernated) return wakeTab(activeTabId);
50
94
  if (tab.protocol) return tab.protocol;
51
95
  }
52
96
 
@@ -54,6 +98,72 @@ export async function getActiveProtocol() {
54
98
  return protocol;
55
99
  }
56
100
 
101
+ /**
102
+ * Hibernate a tab: URL stays in the journal + visible in listTabs, but the
103
+ * renderer is closed and memory freed. Woken transparently on switchTab.
104
+ * Never hibernates the active tab — caller must switch away first.
105
+ */
106
+ export async function hibernateTab(targetId) {
107
+ const id = targetId || [...tabs.keys()].find((k) => k !== activeTabId && !tabs.get(k)?.hibernated);
108
+ if (!id || !tabs.has(id)) return null;
109
+ const tab = tabs.get(id);
110
+ if (tab.hibernated) return { id, hibernated: true };
111
+ const port = actualCdpPort || cfg.port;
112
+ try { await CDP.Close({ id, port }); } catch {}
113
+ if (tab.protocol && tab.protocol !== protocol) {
114
+ try { await tab.protocol.close(); } catch {}
115
+ }
116
+ tabs.set(id, { protocol: null, title: tab.title, url: tab.url, hibernated: true });
117
+ saveJournal();
118
+ return { id, hibernated: true, url: tab.url };
119
+ }
120
+
121
+ /** Wake a hibernated tab: fresh target, journaled URL re-navigated. */
122
+ async function wakeTab(targetId) {
123
+ const tab = tabs.get(targetId);
124
+ if (!tab || !tab.hibernated) return tab?.protocol || protocol;
125
+ const port = actualCdpPort || cfg.port;
126
+ const info = await CDP.New({ port, url: tab.url || "about:blank" });
127
+ const newProtocol = await CDP({ target: info.id, port });
128
+ tabs.delete(targetId);
129
+ tabs.set(info.id, { protocol: newProtocol, title: info.title || tab.title, url: info.url || tab.url });
130
+ if (activeTabId === targetId) activeTabId = info.id;
131
+ saveJournal();
132
+ return newProtocol;
133
+ }
134
+
135
+ /**
136
+ * Restore the journaled working set after a fresh spawn. First entry
137
+ * navigates now; the rest become about:blank placeholders woken on switch.
138
+ * Capped at cfg.tabMax so restore never re-spikes memory at startup.
139
+ */
140
+ export async function restoreJournal() {
141
+ const entries = loadJournal();
142
+ if (!entries.length) return false;
143
+ await ensureDefaultTab();
144
+ const cap = cfg.tabMax && cfg.tabMax > 0 ? cfg.tabMax : entries.length;
145
+ const wanted = entries.slice(0, Math.max(cap, 1));
146
+ const port = actualCdpPort || cfg.port;
147
+ let first = true;
148
+ for (const entry of wanted) {
149
+ if (first) {
150
+ first = false;
151
+ try {
152
+ await protocol.Page.navigate({ url: entry.url });
153
+ syncActiveTab(entry.title, entry.url);
154
+ } catch {}
155
+ continue;
156
+ }
157
+ try {
158
+ const info = await CDP.New({ port, url: "about:blank" });
159
+ const newProtocol = await CDP({ target: info.id, port });
160
+ tabs.set(info.id, { protocol: newProtocol, title: entry.title || "", url: "", pendingUrl: entry.url });
161
+ } catch {}
162
+ }
163
+ saveJournal();
164
+ return true;
165
+ }
166
+
57
167
  // ─── Create Tab ──────────────────────────────────────────────────────────────
58
168
 
59
169
  /**
@@ -63,6 +173,16 @@ export async function getActiveProtocol() {
63
173
  export async function createTab(url) {
64
174
  await ensureDefaultTab();
65
175
 
176
+ // Live-tab cap: hibernate the oldest non-active tab instead of growing
177
+ // renderers until Android LMK notices. Journal keeps everything restorable.
178
+ if (cfg.tabMax && cfg.tabMax > 0) {
179
+ while (liveCount() >= cfg.tabMax) {
180
+ const victim = [...tabs.keys()].find((k) => k !== activeTabId && !tabs.get(k)?.hibernated);
181
+ if (!victim) break;
182
+ await hibernateTab(victim);
183
+ }
184
+ }
185
+
66
186
  const port = actualCdpPort || cfg.port;
67
187
  const info = await CDP.New({ port, url: url || "about:blank" });
68
188
 
@@ -70,6 +190,7 @@ export async function createTab(url) {
70
190
  const newProtocol = await CDP({ target: info.id, port });
71
191
  tabs.set(info.id, { protocol: newProtocol, title: info.title || "", url: info.url || url || "" });
72
192
  activeTabId = info.id;
193
+ saveJournal();
73
194
 
74
195
  return { id: info.id, title: info.title || "", url: info.url || "" };
75
196
  }
@@ -103,6 +224,7 @@ export async function closeTab(targetId) {
103
224
  if (activeTabId === id) {
104
225
  activeTabId = tabs.keys().next().value;
105
226
  }
227
+ saveJournal();
106
228
 
107
229
  return { closed: id, activeTab: activeTabId };
108
230
  }
@@ -110,17 +232,35 @@ export async function closeTab(targetId) {
110
232
  // ─── Switch Tab ──────────────────────────────────────────────────────────────
111
233
 
112
234
  /**
113
- * Switches to a different tab by targetId.
235
+ * Switches to a different tab by targetId. Hibernated tabs and restored
236
+ * placeholders wake transparently (async navigation on first switch).
114
237
  */
115
- export function switchTab(targetId) {
238
+ export async function switchTab(targetId) {
116
239
  if (!targetId) throw new Error("No targetId provided");
117
240
  if (!tabs.has(targetId)) {
118
241
  throw new Error(`Tab not found: ${targetId}`);
119
242
  }
120
243
 
121
- activeTabId = targetId;
122
244
  const tab = tabs.get(targetId);
123
- return { id: targetId, title: tab.title, url: tab.url };
245
+ if (tab.hibernated) {
246
+ await wakeTab(targetId);
247
+ const woken = tabs.get(activeTabId);
248
+ return { id: activeTabId, title: woken?.title, url: woken?.url, woken: true };
249
+ }
250
+ activeTabId = targetId;
251
+ if (tab.pendingUrl) {
252
+ const url = tab.pendingUrl;
253
+ delete tab.pendingUrl;
254
+ try {
255
+ const cdp = tab.protocol || protocol;
256
+ const { Page } = cdp;
257
+ await Page.navigate({ url });
258
+ tab.url = url;
259
+ } catch {}
260
+ }
261
+ saveJournal();
262
+ const current = tabs.get(activeTabId);
263
+ return { id: activeTabId, title: current?.title, url: current?.url };
124
264
  }
125
265
 
126
266
  // ─── List Tabs ───────────────────────────────────────────────────────────────
@@ -136,6 +276,8 @@ export function listTabs() {
136
276
  title: tab.title,
137
277
  url: tab.url,
138
278
  active: id === activeTabId,
279
+ ...(tab.hibernated ? { hibernated: true } : {}),
280
+ ...(tab.pendingUrl ? { pendingUrl: tab.pendingUrl } : {}),
139
281
  });
140
282
  }
141
283
  return result;
@@ -161,5 +303,6 @@ export function syncActiveTab(title, url) {
161
303
  const tab = tabs.get(activeTabId);
162
304
  if (title) tab.title = title;
163
305
  if (url) tab.url = url;
306
+ saveJournal();
164
307
  }
165
308
  }
package/lib/vigil.mjs ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * bwb-browser — Resource vigilance (v4)
3
+ *
4
+ * Pure measurement + policy. No imports from tabs/browser (avoid cycles) —
5
+ * the server.mjs response wrapper executes the actions assess() recommends.
6
+ * MCP can't push, so every tool response carries a ~100-byte footer and the
7
+ * watcher stream carries memory samples. That IS the "instant" channel.
8
+ */
9
+
10
+ import { execSync } from "child_process";
11
+
12
+ // 1GB-box budgets (MB) when lean; roomier desktop defaults otherwise.
13
+ // Override any via BWB_WARN_MB / BWB_CRIT_MB. resolveBudgets() is called
14
+ // by server.mjs after config — before that, lean-safe values apply.
15
+ export let WARN_MB = parseInt(process.env.BWB_WARN_MB || "300", 10);
16
+ export let CRIT_MB = parseInt(process.env.BWB_CRIT_MB || "450", 10);
17
+
18
+ export function resolveBudgets(isLean) {
19
+ if (process.env.BWB_WARN_MB !== undefined) WARN_MB = parseInt(process.env.BWB_WARN_MB, 10);
20
+ else WARN_MB = isLean ? 300 : 1024;
21
+ if (process.env.BWB_CRIT_MB !== undefined) CRIT_MB = parseInt(process.env.BWB_CRIT_MB, 10);
22
+ else CRIT_MB = isLean ? 450 : 1536;
23
+ }
24
+
25
+ const MB = 1024 * 1024;
26
+
27
+ function selfMb() {
28
+ return Math.round(process.memoryUsage().rss / MB);
29
+ }
30
+
31
+ // Sum RSS of a pid + its descendants via ps. Null when unmeasurable.
32
+ function treeMb(rootPid) {
33
+ if (!rootPid) return null;
34
+ try {
35
+ const out = execSync("ps -o pid=,ppid=,rss= -e", { encoding: "utf8", timeout: 3000 });
36
+ const procs = out.trim().split("\n").map((l) => {
37
+ const [pid, ppid, rss] = l.trim().split(/\s+/).map(Number);
38
+ return { pid, ppid, rss };
39
+ });
40
+ const kids = new Set([Number(rootPid)]);
41
+ let grew = true;
42
+ while (grew) {
43
+ grew = false;
44
+ for (const p of procs) {
45
+ if (kids.has(p.ppid) && !kids.has(p.pid)) { kids.add(p.pid); grew = true; }
46
+ }
47
+ }
48
+ let kb = 0;
49
+ for (const p of procs) if (kids.has(p.pid) && Number.isFinite(p.rss)) kb += p.rss;
50
+ return Math.round(kb / 1024);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Snapshot: {mcpMb, chromiumMb|null, tabCount} */
57
+ export function sampleResources(browserPid, tabCount = 0) {
58
+ return { mcpMb: selfMb(), chromiumMb: treeMb(browserPid), tabCount };
59
+ }
60
+
61
+ /**
62
+ * Policy verdict: 'ok' | 'watch' | 'critical'.
63
+ * critical = shed load NOW (hibernate tabs → teardown). watch = warn only.
64
+ */
65
+ export function assess(sample) {
66
+ const total = (sample.chromiumMb || 0) + sample.mcpMb;
67
+ if (total >= CRIT_MB) return "critical";
68
+ if (total >= WARN_MB) return "watch";
69
+ return "ok";
70
+ }
71
+
72
+ /** ~100-byte footer appended as an extra text block to every tool response. */
73
+ export function resourceFooter(sample, note = "") {
74
+ const c = sample.chromiumMb === null ? "browser:off" : `browser:${sample.chromiumMb}MB`;
75
+ const trend = assess(sample);
76
+ return `[bwb resources] mcp:${sample.mcpMb}MB ${c} tabs:${sample.tabCount} state:${trend}${note ? " " + note : ""}]`;
77
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bwb-browser",
3
- "version": "3.2.0",
4
- "description": "Browser Without Bloat — 76KB MCP server that gives AI agents browser superpowers. Natural language interaction, live event watching, persistent sessions, multi-tab. Runs on Termux/Android, desktop, and CI. Zero heavy dependencies.",
3
+ "version": "4.0.0",
4
+ "description": "Browser Without Bloat — lightweight MCP server that gives AI agents browser superpowers. Static-first fetch ladder (Chromium only when JS demands it), lean profiles for Termux/1GB VPS, resource vigilance, tab journaling. Runs anywhere. Stays lean like air.",
5
5
  "bin": {
6
6
  "bwb": "bin/bwb"
7
7
  },
@@ -62,6 +62,8 @@
62
62
  "dependencies": {
63
63
  "@modelcontextprotocol/sdk": "^1.0.0",
64
64
  "chrome-remote-interface": "^0.34.0",
65
- "zod": "^3.22.0"
65
+ "zod": "^3.22.0",
66
+ "@mozilla/readability": "^0.6.0",
67
+ "linkedom": "^0.18.13"
66
68
  }
67
69
  }
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";
30
+ import { execSync } from "child_process";
21
31
  import { mkdirSync, readFileSync, existsSync } from "fs";
22
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. 76KB. 26 tools. Zero heavy dependencies.
83
- Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
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. Weighs nothing.
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
123
  TOOLS (26):
101
124
  CORE BROWSING:
102
- browser_goto Navigate to a URL
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 (26):
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 (26):
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 different tab
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 (26):
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 Browser connection 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:
@@ -183,6 +209,24 @@ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || ((
183
209
  return join(homedir(), "bwb-screenshots");
184
210
  })();
185
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);
186
230
 
187
231
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
188
232
 
@@ -243,14 +287,29 @@ const tools = {
243
287
  // ═══════════════ CORE BROWSING ═══════════════
244
288
 
245
289
  browser_goto: {
246
- 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).",
247
291
  schema: { url: z.string().describe("URL to navigate to") },
248
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).
249
308
  const cdp = await getActiveProtocol();
250
309
  const { Page, Runtime } = cdp;
251
310
  const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
252
311
  syncActiveTab(result.title, result.url);
253
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
312
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
254
313
  },
255
314
  },
256
315
 
@@ -326,26 +385,6 @@ const tools = {
326
385
  },
327
386
  },
328
387
 
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
388
  browser_back: {
350
389
  description: "Go back in browser history (like clicking the browser back button).",
351
390
  schema: {},
@@ -467,16 +506,21 @@ const tools = {
467
506
  await cdp.Runtime.enable();
468
507
  await cdp.Network.enable();
469
508
  setupWatch(events, cdp);
509
+ setIdleSuppressed(true); // recording in progress — mayfly must not teardown
470
510
  return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
471
511
  }
472
512
  if (action === "poll") {
473
513
  const snapshot = [...watchState.events];
474
514
  watchState.events = [];
475
- return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot }) }] };
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 }) }] };
476
519
  }
477
520
  if (action === "stop") {
478
521
  const remaining = [...watchState.events];
479
522
  cleanupWatch();
523
+ setIdleSuppressed(false);
480
524
  return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
481
525
  }
482
526
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
@@ -542,7 +586,7 @@ const tools = {
542
586
  description: "Switch to a different browser tab by targetId.",
543
587
  schema: { targetId: z.string().describe("Target tab ID to switch to") },
544
588
  handler: async ({ targetId }) => {
545
- const result = switchTab(targetId);
589
+ const result = await switchTab(targetId);
546
590
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
547
591
  },
548
592
  },
@@ -587,10 +631,105 @@ const tools = {
587
631
  },
588
632
  },
589
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+
590
729
  // ═══════════════ LIFECYCLE ═══════════════
591
730
 
592
731
  browser_status: {
593
- 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.",
594
733
  schema: {},
595
734
  handler: async () => {
596
735
  const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
@@ -610,6 +749,11 @@ const tools = {
610
749
  } catch { status.connected = false; }
611
750
  }
612
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 {}
613
757
  return { content: [{ type: "text", text: JSON.stringify(status) }] };
614
758
  },
615
759
  },
@@ -628,8 +772,45 @@ const tools = {
628
772
 
629
773
  // ─── Register & Start ─────────────────────────────────────────────────────────
630
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.
631
778
  for (const [name, tool] of Object.entries(tools)) {
632
- server.tool(name, tool.description, tool.schema, tool.handler);
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
+ });
633
814
  }
634
815
 
635
816
  await ensureDeps();