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 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 |
@@ -224,6 +226,18 @@ BWB_CHROME_PATH=/path/to/chrome bwb
224
226
  bwb --browser-path /path/to/chrome
225
227
  ```
226
228
 
229
+ ### Attach Mode (drive the window you're looking at)
230
+ ```bash
231
+ # Launch your browser with remote debugging first, e.g.:
232
+ # brave --remote-debugging-port=9222
233
+ BWB_ATTACH_PORT=9222 bwb
234
+ # or
235
+ bwb --attach-port 9222
236
+ ```
237
+ Guest mode: no spawn, no kill, no journal restore. `browser_newTab` opens a
238
+ VISIBLE tab in your window. Auto-shed is disabled (those are your real tabs) —
239
+ pressure is reported, you close them. `browser_status` shows `attached: true`.
240
+
227
241
  ---
228
242
 
229
243
  ## License
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
 
@@ -74,7 +90,7 @@ Create tabs, close them, switch between them, save cookies, load them back. Like
74
90
 
75
91
  Normalizes `navigator.webdriver`, plugins, languages, and user-agent for testing environments. Not "stealth mode" — just honest fingerprint normalization so your tests actually match real user conditions.
76
92
 
77
- ### 7. Element Screenshots (new in 3.2.0)
93
+ ### 7. Element Screenshots
78
94
 
79
95
  Capture just one element — a login form, a chart, a product card — not the whole page:
80
96
 
@@ -89,20 +105,24 @@ Every screenshot is saved to disk (Android: `/storage/emulated/0/Download/bwb-sc
89
105
 
90
106
  ---
91
107
 
92
- ## What's New in 3.2.0
108
+ ## What's New in 4.0.0 — "Lightweight Like Air"
109
+
110
+ ### Static-first fetch ladder
111
+ - **`browser_goto` no longer spawns Chromium for plain pages** — fetch + extract in milliseconds (`mode: "static"`). JS pages escalate automatically (`mode: "browser"` + reason). Dead URLs error without spawning anything.
112
+ - **On-demand capabilities** — `browser_download` / `browser_export` ship as verbs, not weight. Missing backends prompt for consent install. Nothing heavy is ever bundled.
113
+
114
+ ### Vigilance system
115
+ - **Every tool response carries a `[bwb resources]` footer** — MCP + Chromium MB, tabs, ok/watch/critical. `browser_watch` streams memory samples on its existing poll rhythm.
116
+ - **Thresholds act, then report** — critical pressure hibernates tabs, tears down at one tab, journals everything. The agent reads about the save, never discovers the OOM.
93
117
 
94
- ### New
95
- - **`browser_screenshot({ selector })`** — element-level capture. Grab just the login form, the chart, the product card — not the whole page.
96
- - **Screenshot directory auto-detect** — Termux/Android → `/storage/emulated/0/Download/bwb-screenshots/`, desktop → `~/bwb-screenshots/`. Override with `BWB_SCREENSHOTS_DIR`.
118
+ ### Survival profile
119
+ - **`--lean` auto-enables on Termux** — capped renderers, silenced background services, 3-tab cap, 5-minute mayfly teardown, 256MB JS heap. `--nuclear` opts into `--single-process`.
120
+ - **Tab journal + lazy restore** — kills become resume points, not disasters. `bwb --setup` prints a survival guide.
97
121
 
98
- ### Bug Fixes
99
- - **`browser_back` rewritten** — native CDP history navigation (the `Page.goBack` call doesn't exist in bundled CDP 1.3; the old `history.back()` JS hack is gone). Verified with real two-step back navigation.
100
- - **`browser_act` precision fixes** — navigation regex no longer swallows compound instructions ("go to X and read the title" works), "fill X with Y" vs "type Y in X" no longer swap target/text, and `search` can't hijack "fill search with X"
101
- - **`killOrphanedChrome` safety** — graceful SIGTERM→SIGKILL, now scoped to bwb's own profile so it never kills another agent's browser
102
- - **`browser_restart` hygiene** — watch listeners can't outlive the dying protocol
103
- - **`browser_status` accuracy** — uses the real bound port, no more hardcoded 9222 poke
122
+ ### Breaking
123
+ - `browser_title` + `browser_url` folded into `browser_status.targets`. Still 26 tools — that's now a release gate.
104
124
 
105
- *Fixes from the PR #1 code review by @netzro (Hermes Agent) are incorporated and credited in the [changelog](./CHANGELOG.md).*
125
+ *Full story in the [changelog](./CHANGELOG.md). Older releases documented there too.*
106
126
 
107
127
  ---
108
128
 
@@ -111,7 +131,7 @@ Every screenshot is saved to disk (Android: `/storage/emulated/0/Download/bwb-sc
111
131
  ```bash
112
132
  npm install -g bwb-browser
113
133
  bwb --version
114
- # → bwb-browser 3.2.0
134
+ # → bwb-browser 4.0.0
115
135
  ```
116
136
 
117
137
  Done. If you have Chrome/Chromium anywhere on your system, bwb finds it. No config files. No environment variables. Just works.
@@ -135,12 +155,10 @@ bwb
135
155
  | **`browser_watch`** | 🔥 Live event capture — console, network, errors, navigation |
136
156
  | **`browser_diagnose`** | 🔥 Full page health check — perf, errors, broken images, score |
137
157
  | **`browser_fingerprint`** | 🔥 Realistic browser profile for testing |
138
- | `browser_goto` | Navigate to a URL |
158
+ | `browser_goto` | Navigate — static-first, escalates to browser with reason |
139
159
  | `browser_screenshot` | Take a screenshot — whole page, viewport, or a single element via `selector` |
140
160
  | `browser_html` | Get page/selector HTML |
141
161
  | `browser_text` | Get page/selector text |
142
- | `browser_title` | Get page title |
143
- | `browser_url` | Get current URL |
144
162
  | `browser_back` | Go back in history |
145
163
  | `browser_click` | Click an element (native CDP) |
146
164
  | `browser_fill` | Fill an input field (native CDP) |
@@ -155,7 +173,9 @@ bwb
155
173
  | `browser_saveCookies` | Save session to disk |
156
174
  | `browser_loadCookies` | Load session from disk |
157
175
  | `browser_listSessions` | List saved sessions |
158
- | `browser_status` | Browser connection info |
176
+ | `browser_download` | Download media (needs system yt-dlp, consent-gated) |
177
+ | `browser_export` | Export md/txt/html (pdf/docx/pptx need pip libs, consent-gated) |
178
+ | `browser_status` | Status + live resources + active profile |
159
179
  | `browser_restart` | Restart the browser |
160
180
 
161
181
  ---
@@ -196,13 +216,13 @@ See [AGENTS.md](./AGENTS.md) for copy-paste configs for each one.
196
216
 
197
217
  ## The Backstory
198
218
 
199
- I built this because I was tired of every browser automation tool assuming you have 400MB to spare and a desktop-class machine. I work from my phone sometimes. Termux exists. Why shouldn't browser automation work there too?
219
+ Every browser automation tool assumes you have 400MB to spare and a desktop-class machine. That assumption excludes phones, cheap VPS boxes, Raspberry Pis, and CI runners — most of the world's computers.
200
220
 
201
- So I did what any reasonable person would do: I ignored all the existing solutions and wrote my own, using nothing but raw CDP — the protocol Chrome speaks natively. No wrappers. No abstractions. Just JSON messages over WebSocket.
221
+ bwb is engineered against the hardest constraint first: **a memory-pressured device where every megabyte is contested.** No bundled browser. No wrapper frameworks. Just raw CDP — the protocol Chrome speaks natively — plus a static-fetch ladder so Chromium only starts when JavaScript demands it. Mobile-first isn't a feature here. It's the design spec everything else has to survive.
202
222
 
203
- The result is 76KB of source code that does what 400MB of dependencies do. It's not _better_ code — it's _less_ code. And sometimes less is all you need.
223
+ The result is ~136KB of source that does what 400MB of dependencies do. Not better code — less code, held to budgets: 26 tools max, 60 kB tarball max, zero native modules. Constraints are features.
204
224
 
205
- *— Krish Tiwari ([@krshforever](https://github.com/krshforever)), somewhere on an Indian train, writing code on a phone*
225
+ *— Krish Tiwari ([@krshforever](https://github.com/krshforever))*
206
226
 
207
227
  ---
208
228
 
package/lib/browser.mjs CHANGED
@@ -31,8 +31,55 @@ 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
38
+ attachPort: 0, // 0 = off (spawn own browser). N = attach to existing
39
+ // browser's CDP port (e.g. 9222) — never spawns, never kills.
34
40
  };
35
41
 
42
+ // True when connected to a foreign (user-owned) browser via --attach-port.
43
+ // Attached browsers are never spawned, killed, or journal-restored —
44
+ // bwb is a guest there. Tab hibernation still works but closes REAL tabs.
45
+ export let attached = false;
46
+
47
+ // ─── Environment ─────────────────────────────────────────────────────────────
48
+
49
+ export function isTermux() {
50
+ return Boolean(
51
+ process.env.TERMUX_VERSION ||
52
+ process.env.PREFIX?.includes("com.termux") ||
53
+ process.env.HOME?.includes("com.termux")
54
+ );
55
+ }
56
+
57
+ // ─── Idle Mayfly (auto-teardown) ─────────────────────────────────────────────
58
+
59
+ let idleTimer = null;
60
+ let idleSuppressed = false; // true while browser_watch is recording
61
+
62
+ export function setIdleSuppressed(suppressed) {
63
+ idleSuppressed = suppressed;
64
+ if (suppressed) disarmIdle();
65
+ else pokeActivity();
66
+ }
67
+
68
+ export function disarmIdle() {
69
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
70
+ }
71
+
72
+ /** Reset the mayfly timer. Called after every tool call via the server wrapper. */
73
+ export function pokeActivity() {
74
+ disarmIdle();
75
+ if (!cfg.idleMs || idleSuppressed || !browser || browserExited) return;
76
+ idleTimer = setTimeout(() => {
77
+ idleTimer = null;
78
+ if (!idleSuppressed) stopBrowser("idle-timeout").catch(() => {});
79
+ }, cfg.idleMs);
80
+ if (idleTimer.unref) idleTimer.unref(); // never hold the MCP process open
81
+ }
82
+
36
83
  // ─── Kill Orphaned Chrome (Termux-safe) ────────────────────────────────────────
37
84
 
38
85
  // `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
@@ -79,8 +126,11 @@ export function findBrowserPath(cliPath) {
79
126
  "chromium-browser",
80
127
  "chromium",
81
128
  "google-chrome-stable",
129
+ "brave-browser",
130
+ "brave",
82
131
  "/usr/bin/google-chrome",
83
132
  "/usr/bin/chromium-browser",
133
+ "/usr/bin/brave-browser",
84
134
  "/snap/bin/chromium",
85
135
  ],
86
136
  darwin: [
@@ -139,6 +189,34 @@ export async function ensureBrowser() {
139
189
  browserStartup = new Promise((res, rej) => { startResolve = res; startReject = rej; });
140
190
  browserStartup.catch(() => { browserStartup = null; });
141
191
 
192
+ // ─── Attach mode: guest on someone else's browser ──────────────────────
193
+ // No spawn, no orphan-kill, no journal restore (never navigate a user's
194
+ // tabs on connect). ensureDefaultTab() registers whatever is already open.
195
+ if (cfg.attachPort) {
196
+ (async () => {
197
+ try {
198
+ const port = cfg.attachPort;
199
+ const cdp = await CDP({ port });
200
+ protocol = cdp;
201
+ browser = null;
202
+ attached = true;
203
+ actualCdpPort = port;
204
+ startResolve(cdp);
205
+ pokeActivity();
206
+ } catch (err) {
207
+ browserStartup = null;
208
+ browserExited = true;
209
+ attached = false;
210
+ startReject(new Error(
211
+ `Attach failed: no browser listening on CDP port ${cfg.attachPort} (${err.message}). ` +
212
+ `Launch one with --remote-debugging-port=${cfg.attachPort} first.`
213
+ ));
214
+ }
215
+ })();
216
+ return browserStartup;
217
+ }
218
+ attached = false;
219
+
142
220
  (async () => {
143
221
  try {
144
222
  const browserPath = assertBrowserExists(cfg.browserPath);
@@ -155,6 +233,7 @@ export async function ensureBrowser() {
155
233
  "--disable-software-rasterizer",
156
234
  "--remote-debugging-port=" + debugPort,
157
235
  "--user-data-dir=" + cfg.userDataDir,
236
+ ...leanArgs(),
158
237
  ];
159
238
 
160
239
  if (!cfg.headless) args.shift();
@@ -199,7 +278,13 @@ export async function ensureBrowser() {
199
278
  CDP({ port: actualCdpPort })
200
279
  .then((cdp) => {
201
280
  protocol = cdp;
202
- startResolve(cdp);
281
+ // Awaited, not fire-and-forget: callers must see the restored
282
+ // working set, not a blank tab that later jumps. Capped + fast.
283
+ (async () => {
284
+ try { await restoreJournalTabs(); } catch {}
285
+ startResolve(cdp);
286
+ pokeActivity();
287
+ })();
203
288
  })
204
289
  .catch((err) => {
205
290
  try { browser.kill("SIGKILL"); } catch {}
@@ -218,6 +303,58 @@ export async function ensureBrowser() {
218
303
  return browserStartup;
219
304
  }
220
305
 
306
+ // ─── Lean Flags (v4 survival profile) ─────────────────────────────────────────
307
+ // Compat flags above make Chromium RUN on Termux. These make it SURVIVE:
308
+ // capped renderers, silenced background services, bounded cache + JS heap.
309
+ // --single-process stays behind --nuclear: biggest saving, weakest stability.
310
+
311
+ function leanArgs() {
312
+ if (!cfg.lean) return [];
313
+ const flags = [
314
+ "--renderer-process-limit=1",
315
+ "--disable-background-networking",
316
+ "--disable-component-update",
317
+ "--disable-sync",
318
+ "--mute-audio",
319
+ "--disable-features=Translate",
320
+ "--disk-cache-size=67108864",
321
+ "--js-flags=--max-old-space-size=256",
322
+ ];
323
+ if (cfg.nuclear) flags.push("--single-process", "--no-zygote");
324
+ return flags;
325
+ }
326
+
327
+ // ─── Stop (graceful mayfly teardown — journal keeps the working set) ─────────
328
+
329
+ export async function stopBrowser(reason = "manual") {
330
+ if (cleaningUp) return { status: "busy" };
331
+ cleaningUp = true;
332
+ disarmIdle();
333
+ try {
334
+ if (protocol) {
335
+ try { await protocol.close(); } catch {}
336
+ protocol = null;
337
+ }
338
+ // Attached browsers are foreign — disconnect only, never kill.
339
+ if (browser && !attached) {
340
+ try { browser.kill("SIGTERM"); } catch {}
341
+ await new Promise(r => setTimeout(r, 1500));
342
+ try { browser.kill("SIGKILL"); } catch {}
343
+ browser = null;
344
+ }
345
+ } catch {}
346
+ browserExited = true; // next ensureBrowser() respawns fresh + restores journal
347
+ actualCdpPort = null;
348
+ attached = false;
349
+ browserStartup = null;
350
+ cleaningUp = false;
351
+ try {
352
+ const { clearTabs } = await import("./tabs.mjs");
353
+ clearTabs();
354
+ } catch {}
355
+ return { status: "stopped", reason };
356
+ }
357
+
221
358
  // ─── Restart ──────────────────────────────────────────────────────────────────
222
359
 
223
360
  export async function restartBrowser() {
@@ -229,7 +366,9 @@ export async function restartBrowser() {
229
366
  try { await protocol.close(); } catch {}
230
367
  protocol = null;
231
368
  }
232
- if (browser) {
369
+ // Attached browsers are foreign — disconnect only, never kill.
370
+ // Next ensureBrowser() re-attaches (no journal restore in attach mode).
371
+ if (browser && !attached) {
233
372
  browser.kill("SIGTERM");
234
373
  await new Promise(r => setTimeout(r, 2000));
235
374
  try { browser.kill("SIGKILL"); } catch {}
@@ -246,6 +385,18 @@ export async function restartBrowser() {
246
385
  return { status: "restarted" };
247
386
  }
248
387
 
388
+ // ─── Journal Restore (post-kill resurrection — lazy, capped) ─────────────────
389
+ // Reopens the journaled working set after a fresh spawn (LMK kill, mayfly
390
+ // teardown, restart). First entry navigates now; the rest become placeholders
391
+ // woken on switchTab. Never re-spikes memory at startup to "restore".
392
+
393
+ async function restoreJournalTabs() {
394
+ try {
395
+ const tabs = await import("./tabs.mjs");
396
+ await tabs.restoreJournal();
397
+ } catch {}
398
+ }
399
+
249
400
  // ─── Screenshot Helper ────────────────────────────────────────────────────────
250
401
 
251
402
  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');