bwb-browser 3.1.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -15
- package/README.md +56 -10
- package/lib/act.mjs +26 -3
- package/lib/browser.mjs +121 -2
- package/lib/diagnose.mjs +11 -1
- package/lib/fetch.mjs +127 -0
- package/lib/fingerprint.mjs +9 -4
- package/lib/helpers.mjs +1 -2
- package/lib/setup.mjs +23 -0
- package/lib/tabs.mjs +147 -4
- package/lib/vigil.mjs +77 -0
- package/package.json +5 -3
- package/server.mjs +282 -58
package/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) ·
|
|
5
|
-
> **Last updated:** 2026-
|
|
4
|
+
> **Package:** [`bwb-browser`](https://www.npmjs.com/package/bwb-browser) · ~136KB source · 26 tools · static-first (v4)
|
|
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
|
|
|
@@ -52,7 +52,7 @@ npm install -g bwb-browser
|
|
|
52
52
|
|
|
53
53
|
# 2. Verify it works
|
|
54
54
|
bwb --version
|
|
55
|
-
# → bwb-browser 3.
|
|
55
|
+
# → bwb-browser 3.2.0
|
|
56
56
|
|
|
57
57
|
# 3. Add to your AI agent's MCP config
|
|
58
58
|
```
|
|
@@ -64,10 +64,10 @@ 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).
|
|
70
|
-
It has
|
|
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
|
+
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")
|
|
73
73
|
- `browser_watch` — live capture of console messages, network requests, JS exceptions, page navigations
|
|
@@ -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()` — take a screenshot (also 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
|
|
@@ -167,14 +169,14 @@ internally — not just what it looks like.
|
|
|
167
169
|
| **`browser_diagnose`** | 🔥 Full page health check — perf, errors, broken images, score |
|
|
168
170
|
| **`browser_fingerprint`** | 🔥 Realistic browser profile for testing |
|
|
169
171
|
| `browser_goto` | Navigate to a URL |
|
|
170
|
-
| `browser_screenshot` | Take a screenshot (saves to disk + returns base64) |
|
|
172
|
+
| `browser_screenshot` | Take a screenshot — full page, viewport, or a single element via `selector` (saves to disk + returns base64) |
|
|
171
173
|
| `browser_html` | Get page/selector HTML |
|
|
172
174
|
| `browser_text` | Get page/selector visible text |
|
|
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
|
|
|
@@ -74,6 +90,36 @@ 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
|
|
|
93
|
+
### 7. Element Screenshots (new in 3.2.0)
|
|
94
|
+
|
|
95
|
+
Capture just one element — a login form, a chart, a product card — not the whole page:
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
browser_screenshot({selector: "#price-chart"})
|
|
99
|
+
browser_screenshot({selector: "h1"}) // The headline, cropped
|
|
100
|
+
browser_screenshot({fullPage: true}) // The whole page
|
|
101
|
+
browser_screenshot({}) // Just the viewport
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Every screenshot is saved to disk (Android: `/storage/emulated/0/Download/bwb-screenshots/`, desktop: `~/bwb-screenshots/`) **and** returned to your agent as a base64 image.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## What's New in 3.2.0
|
|
109
|
+
|
|
110
|
+
### New
|
|
111
|
+
- **`browser_screenshot({ selector })`** — element-level capture. Grab just the login form, the chart, the product card — not the whole page.
|
|
112
|
+
- **Screenshot directory auto-detect** — Termux/Android → `/storage/emulated/0/Download/bwb-screenshots/`, desktop → `~/bwb-screenshots/`. Override with `BWB_SCREENSHOTS_DIR`.
|
|
113
|
+
|
|
114
|
+
### Bug Fixes
|
|
115
|
+
- **`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.
|
|
116
|
+
- **`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"
|
|
117
|
+
- **`killOrphanedChrome` safety** — graceful SIGTERM→SIGKILL, now scoped to bwb's own profile so it never kills another agent's browser
|
|
118
|
+
- **`browser_restart` hygiene** — watch listeners can't outlive the dying protocol
|
|
119
|
+
- **`browser_status` accuracy** — uses the real bound port, no more hardcoded 9222 poke
|
|
120
|
+
|
|
121
|
+
*Fixes from the PR #1 code review by @netzro (Hermes Agent) are incorporated and credited in the [changelog](./CHANGELOG.md).*
|
|
122
|
+
|
|
77
123
|
---
|
|
78
124
|
|
|
79
125
|
## Quick Install
|
|
@@ -81,7 +127,7 @@ Normalizes `navigator.webdriver`, plugins, languages, and user-agent for testing
|
|
|
81
127
|
```bash
|
|
82
128
|
npm install -g bwb-browser
|
|
83
129
|
bwb --version
|
|
84
|
-
# → bwb-browser 3.
|
|
130
|
+
# → bwb-browser 3.2.0
|
|
85
131
|
```
|
|
86
132
|
|
|
87
133
|
Done. If you have Chrome/Chromium anywhere on your system, bwb finds it. No config files. No environment variables. Just works.
|
|
@@ -97,7 +143,7 @@ bwb
|
|
|
97
143
|
|
|
98
144
|
---
|
|
99
145
|
|
|
100
|
-
## All
|
|
146
|
+
## All 26 Tools
|
|
101
147
|
|
|
102
148
|
| Tool | Description |
|
|
103
149
|
|------|-------------|
|
|
@@ -106,7 +152,7 @@ bwb
|
|
|
106
152
|
| **`browser_diagnose`** | 🔥 Full page health check — perf, errors, broken images, score |
|
|
107
153
|
| **`browser_fingerprint`** | 🔥 Realistic browser profile for testing |
|
|
108
154
|
| `browser_goto` | Navigate to a URL |
|
|
109
|
-
| `browser_screenshot` | Take a screenshot |
|
|
155
|
+
| `browser_screenshot` | Take a screenshot — whole page, viewport, or a single element via `selector` |
|
|
110
156
|
| `browser_html` | Get page/selector HTML |
|
|
111
157
|
| `browser_text` | Get page/selector text |
|
|
112
158
|
| `browser_title` | Get page title |
|
package/lib/act.mjs
CHANGED
|
@@ -165,7 +165,10 @@ export async function executeInstruction(protocol, instruction) {
|
|
|
165
165
|
const lower = instruction.trim().toLowerCase();
|
|
166
166
|
|
|
167
167
|
// ─── Pattern: "go to URL" / "navigate to URL" / "open URL" ──────────────
|
|
168
|
-
|
|
168
|
+
// Capture only the URL token — stop at natural instruction boundaries so
|
|
169
|
+
// compound instructions ("go to X and tell me the title") don't swallow
|
|
170
|
+
// the whole sentence into the URL.
|
|
171
|
+
const navMatch = lower.match(/^(?:go to|navigate to|open|visit|take me to)\s+([^\s,]+(?:\.[^\s,]+)*)(?=\s|$)/);
|
|
169
172
|
if (navMatch) {
|
|
170
173
|
const url = normalizeUrl(navMatch[1]);
|
|
171
174
|
if (url) {
|
|
@@ -187,7 +190,9 @@ export async function executeInstruction(protocol, instruction) {
|
|
|
187
190
|
// If navMatch but URL was invalid (normalizeUrl returned null), fall through to search
|
|
188
191
|
|
|
189
192
|
// ─── Pattern: "search for X" / "search X" ───────────────────────────────
|
|
190
|
-
|
|
193
|
+
// Anchored to string start: unanchored, "fill search with X" would be
|
|
194
|
+
// hijacked by this pattern before fill could handle it.
|
|
195
|
+
const searchMatch = lower.match(/^search\s+(?:for\s+)?(.+)/);
|
|
191
196
|
if (searchMatch) {
|
|
192
197
|
const query = searchMatch[1];
|
|
193
198
|
let inputInfo = await findInput(Runtime, "search");
|
|
@@ -263,7 +268,25 @@ export async function executeInstruction(protocol, instruction) {
|
|
|
263
268
|
}
|
|
264
269
|
|
|
265
270
|
// ─── Pattern: "fill X with Y" / "type Y in X" / "enter Y into X" ──────
|
|
266
|
-
|
|
271
|
+
// NOTE: "fill X with Y" puts the VALUE in X ("fill email with foo@bar.com"),
|
|
272
|
+
// while "type Y in X" puts the TEXT in X ("type hello in search").
|
|
273
|
+
const fillWithMatch = lower.match(/fill\s+(.+?)\s+with\s+(.+)/);
|
|
274
|
+
if (fillWithMatch) {
|
|
275
|
+
const target = fillWithMatch[1];
|
|
276
|
+
const text = fillWithMatch[2];
|
|
277
|
+
const inputInfo = await findInput(Runtime, target);
|
|
278
|
+
if (inputInfo.found && inputInfo.selector) {
|
|
279
|
+
await Runtime.evaluate({
|
|
280
|
+
expression: `document.querySelector(${JSON.stringify(inputInfo.selector)})?.focus()`,
|
|
281
|
+
});
|
|
282
|
+
await Input.insertText({ text });
|
|
283
|
+
await new Promise(r => setTimeout(r, 200));
|
|
284
|
+
return { action: "fill", target, text, inputTag: inputInfo.tag };
|
|
285
|
+
}
|
|
286
|
+
return { action: "fill_error", target, error: `Could not find input matching "${target}"` };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const fillMatch = lower.match(/(?:type|enter)\s+(.+?)\s+(?:in|into)\s+(.+)/);
|
|
267
290
|
if (fillMatch) {
|
|
268
291
|
const text = fillMatch[1];
|
|
269
292
|
const target = fillMatch[2];
|
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).
|
|
@@ -41,8 +81,18 @@ export const cfg = {
|
|
|
41
81
|
// NOT the user's normal Chrome browser.
|
|
42
82
|
export function killOrphanedChrome() {
|
|
43
83
|
try {
|
|
84
|
+
// Only touch Chromes using OUR user-data-dir — never another agent's browser.
|
|
85
|
+
const scopedMatch = cfg.userDataDir
|
|
86
|
+
? `grep "remote-debugging-port" | grep "${String(cfg.userDataDir).replace(/["\\]/g, "\\$&")}" | grep -v grep`
|
|
87
|
+
: `grep "remote-debugging-port" | grep -v grep`;
|
|
88
|
+
// SIGTERM first for clean shutdown
|
|
44
89
|
execSync(
|
|
45
|
-
`ps aux |
|
|
90
|
+
`ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -15 2>/dev/null; true`,
|
|
91
|
+
{ encoding: "utf8", timeout: 5000 }
|
|
92
|
+
);
|
|
93
|
+
// Give them a moment to exit cleanly, then SIGKILL survivors
|
|
94
|
+
execSync(
|
|
95
|
+
`sleep 1 && ps aux | ${scopedMatch} | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
|
|
46
96
|
{ encoding: "utf8", timeout: 5000 }
|
|
47
97
|
);
|
|
48
98
|
} catch {}
|
|
@@ -145,6 +195,7 @@ export async function ensureBrowser() {
|
|
|
145
195
|
"--disable-software-rasterizer",
|
|
146
196
|
"--remote-debugging-port=" + debugPort,
|
|
147
197
|
"--user-data-dir=" + cfg.userDataDir,
|
|
198
|
+
...leanArgs(),
|
|
148
199
|
];
|
|
149
200
|
|
|
150
201
|
if (!cfg.headless) args.shift();
|
|
@@ -189,7 +240,13 @@ export async function ensureBrowser() {
|
|
|
189
240
|
CDP({ port: actualCdpPort })
|
|
190
241
|
.then((cdp) => {
|
|
191
242
|
protocol = cdp;
|
|
192
|
-
|
|
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
|
+
})();
|
|
193
250
|
})
|
|
194
251
|
.catch((err) => {
|
|
195
252
|
try { browser.kill("SIGKILL"); } catch {}
|
|
@@ -208,6 +265,56 @@ export async function ensureBrowser() {
|
|
|
208
265
|
return browserStartup;
|
|
209
266
|
}
|
|
210
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
|
+
|
|
211
318
|
// ─── Restart ──────────────────────────────────────────────────────────────────
|
|
212
319
|
|
|
213
320
|
export async function restartBrowser() {
|
|
@@ -236,6 +343,18 @@ export async function restartBrowser() {
|
|
|
236
343
|
return { status: "restarted" };
|
|
237
344
|
}
|
|
238
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
|
+
|
|
239
358
|
// ─── Screenshot Helper ────────────────────────────────────────────────────────
|
|
240
359
|
|
|
241
360
|
export function saveScreenshot(base64Data) {
|
package/lib/diagnose.mjs
CHANGED
|
@@ -16,9 +16,16 @@ export async function diagnosePage(protocol) {
|
|
|
16
16
|
// Collect console errors during diagnostic
|
|
17
17
|
const consoleErrors = [];
|
|
18
18
|
let unsubConsole, unsubException;
|
|
19
|
+
let runtimeWasAlreadyEnabled = false;
|
|
19
20
|
|
|
20
21
|
try {
|
|
21
|
-
|
|
22
|
+
// Check if Runtime is already enabled; if not, enable it
|
|
23
|
+
try {
|
|
24
|
+
await Runtime.enable();
|
|
25
|
+
} catch {
|
|
26
|
+
// Already enabled — that's fine, but don't disable it later
|
|
27
|
+
runtimeWasAlreadyEnabled = true;
|
|
28
|
+
}
|
|
22
29
|
unsubConsole = Runtime.consoleAPICalled((params) => {
|
|
23
30
|
if (params.type === "error" || params.type === "warning") {
|
|
24
31
|
consoleErrors.push({
|
|
@@ -114,6 +121,9 @@ export async function diagnosePage(protocol) {
|
|
|
114
121
|
// Cleanup
|
|
115
122
|
try { if (unsubConsole) unsubConsole(); } catch {}
|
|
116
123
|
try { if (unsubException) unsubException(); } catch {}
|
|
124
|
+
if (!runtimeWasAlreadyEnabled) {
|
|
125
|
+
try { await Runtime.disable(); } catch {}
|
|
126
|
+
}
|
|
117
127
|
|
|
118
128
|
return {
|
|
119
129
|
url: currentUrl,
|
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/fingerprint.mjs
CHANGED
|
@@ -49,11 +49,16 @@ export async function applyRealisticProfile(protocol) {
|
|
|
49
49
|
configurable: true,
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
// Remove automation-specific chrome.runtime
|
|
52
|
+
// Remove automation-specific chrome.runtime (strict-mode safe:
|
|
53
|
+
// delete on a non-configurable prop throws, defineProperty with a
|
|
54
|
+
// getter is legal even in strict mode)
|
|
53
55
|
if (window.chrome) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
try {
|
|
57
|
+
Object.defineProperty(window.chrome, 'runtime', {
|
|
58
|
+
get: () => undefined,
|
|
59
|
+
configurable: true,
|
|
60
|
+
});
|
|
61
|
+
} catch {}
|
|
57
62
|
if (!window.chrome.loadTimes) {
|
|
58
63
|
window.chrome.loadTimes = function() { return {}; };
|
|
59
64
|
}
|
package/lib/helpers.mjs
CHANGED
|
@@ -116,8 +116,7 @@ export async function waitForSelector(runtime, selector, opts = {}) {
|
|
|
116
116
|
const info = JSON.parse(result?.value || "{}");
|
|
117
117
|
|
|
118
118
|
if (disappear && info.status === "NOT_FOUND") return true;
|
|
119
|
-
if (!disappear && info.status === "FOUND" && !info.hidden) return true;
|
|
120
|
-
if (!disappear && info.status === "FOUND" && !opts.visible) return true;
|
|
119
|
+
if (!disappear && info.status === "FOUND" && (!opts.visible || !info.hidden)) return true;
|
|
121
120
|
|
|
122
121
|
await new Promise((r) => setTimeout(r, 200));
|
|
123
122
|
}
|
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');
|