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/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
|
}
|