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 +13 -11
- package/README.md +23 -7
- package/lib/browser.mjs +110 -1
- 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 +218 -37
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) ·
|
|
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.
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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
|
-
| `
|
|
177
|
-
| `
|
|
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** —
|
|
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 | **
|
|
18
|
-
|
|
|
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
|
-
|
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
4
|
-
"description": "Browser Without Bloat —
|
|
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.
|
|
83
|
-
|
|
101
|
+
Browser automation for AI agents. Static-first, lean like air. 26 tools.
|
|
102
|
+
Raw CDP — no Playwright, no Puppeteer. Chromium starts only when JS demands it.
|
|
84
103
|
|
|
85
|
-
Built on Termux/Android. Runs everywhere
|
|
104
|
+
Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
|
|
86
105
|
|
|
87
106
|
USAGE:
|
|
88
107
|
bwb [options]
|
|
@@ -94,18 +113,21 @@ OPTIONS:
|
|
|
94
113
|
--headless <bool> Run headless (default: true)
|
|
95
114
|
--screenshots-dir <path> Directory to save screenshots
|
|
96
115
|
--timeout <ms> Navigation timeout in ms (default: 30000)
|
|
116
|
+
--lean <bool> Survival profile (default: auto on Termux)
|
|
117
|
+
--nuclear Add --single-process (max saving, min stability)
|
|
118
|
+
--idle <ms> Mayfly teardown after N ms idle (default: 5min lean)
|
|
119
|
+
--tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
|
|
97
120
|
--version Print version
|
|
98
121
|
--help Show this help
|
|
99
122
|
|
|
100
123
|
TOOLS (26):
|
|
101
124
|
CORE BROWSING:
|
|
102
|
-
browser_goto Navigate to
|
|
125
|
+
browser_goto Navigate (static-first, escalates to browser)
|
|
103
126
|
browser_screenshot Take a screenshot
|
|
104
127
|
browser_html Get page/selector HTML
|
|
105
128
|
browser_text Get page/selector text
|
|
106
|
-
browser_title Get page title
|
|
107
|
-
browser_url Get current URL
|
|
108
129
|
browser_back Go back in history
|
|
130
|
+
(title/url folded into browser_status — v4 breaking change)
|
|
109
131
|
|
|
110
132
|
INTERACTION:
|
|
111
133
|
browser_click Click an element
|
|
@@ -116,7 +138,7 @@ TOOLS (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
|
|
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
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
705
|
+
try { wfs(dest, `<!doctype html><html><head><meta charset="utf8"><title>${title}</title></head><body><pre>${esc}</pre></body></html>`, "utf8"); } catch (err) {
|
|
706
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
|
|
707
|
+
}
|
|
708
|
+
return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
|
|
709
|
+
}
|
|
710
|
+
// pdf/docx/pptx need python libs — probe, then consent-gate.
|
|
711
|
+
const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
|
|
712
|
+
let have = false;
|
|
713
|
+
try {
|
|
714
|
+
execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
|
|
715
|
+
have = true;
|
|
716
|
+
} catch {}
|
|
717
|
+
if (!have) {
|
|
718
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
719
|
+
needsInstall: true,
|
|
720
|
+
tool: need,
|
|
721
|
+
install: `pip install ${need}`,
|
|
722
|
+
ask: `${need} is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was written. md/txt/html export works without it.`,
|
|
723
|
+
}) }] };
|
|
724
|
+
}
|
|
725
|
+
return { content: [{ type: "text", text: JSON.stringify({ ready: true, tool: need, note: "Backend present. Tell the agent to run the conversion explicitly — bwb never executes installs itself." }) }] };
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
|
|
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
|
-
|
|
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();
|