bwb-browser 3.2.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tabs.mjs CHANGED
@@ -8,6 +8,49 @@
8
8
 
9
9
  import CDP from "chrome-remote-interface";
10
10
  import { ensureBrowser, protocol, actualCdpPort, cfg } from "./browser.mjs";
11
+ import { writeFileSync, readFileSync, existsSync } from "fs";
12
+ import { join } from "path";
13
+
14
+ // ─── Tab Journal (working-set survival across LMK kills / mayfly teardown) ───
15
+ // Tiny JSON append on every mutation. Journal is the truth the next fresh
16
+ // browser restores from — cookies live in user-data-dir, tab URLs live here.
17
+
18
+ function journalPath() {
19
+ if (!cfg.userDataDir) return null;
20
+ return join(cfg.userDataDir, "bwb-tabs.json");
21
+ }
22
+
23
+ function saveJournal() {
24
+ try {
25
+ const path = journalPath();
26
+ if (!path) return;
27
+ const entries = [];
28
+ for (const [id, tab] of tabs) {
29
+ if (tab.url && tab.url !== "about:blank") {
30
+ entries.push({ url: tab.url, title: tab.title || "", active: id === activeTabId });
31
+ }
32
+ }
33
+ writeFileSync(path, JSON.stringify(entries));
34
+ } catch {}
35
+ }
36
+
37
+ function loadJournal() {
38
+ try {
39
+ const path = journalPath();
40
+ if (!path || !existsSync(path)) return [];
41
+ const entries = JSON.parse(readFileSync(path, "utf8"));
42
+ return Array.isArray(entries) ? entries.filter((e) => e && e.url) : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ // Live-tab cap: 0/unset = unlimited (desktop). Lean profiles set 3.
49
+ function liveCount() {
50
+ let n = 0;
51
+ for (const tab of tabs.values()) if (!tab.hibernated) n++;
52
+ return n;
53
+ }
11
54
 
12
55
  // ─── Tab State ───────────────────────────────────────────────────────────────
13
56
 
@@ -47,6 +90,7 @@ export async function getActiveProtocol() {
47
90
 
48
91
  if (activeTabId && tabs.has(activeTabId)) {
49
92
  const tab = tabs.get(activeTabId);
93
+ if (tab.hibernated) return wakeTab(activeTabId);
50
94
  if (tab.protocol) return tab.protocol;
51
95
  }
52
96
 
@@ -54,6 +98,72 @@ export async function getActiveProtocol() {
54
98
  return protocol;
55
99
  }
56
100
 
101
+ /**
102
+ * Hibernate a tab: URL stays in the journal + visible in listTabs, but the
103
+ * renderer is closed and memory freed. Woken transparently on switchTab.
104
+ * Never hibernates the active tab — caller must switch away first.
105
+ */
106
+ export async function hibernateTab(targetId) {
107
+ const id = targetId || [...tabs.keys()].find((k) => k !== activeTabId && !tabs.get(k)?.hibernated);
108
+ if (!id || !tabs.has(id)) return null;
109
+ const tab = tabs.get(id);
110
+ if (tab.hibernated) return { id, hibernated: true };
111
+ const port = actualCdpPort || cfg.port;
112
+ try { await CDP.Close({ id, port }); } catch {}
113
+ if (tab.protocol && tab.protocol !== protocol) {
114
+ try { await tab.protocol.close(); } catch {}
115
+ }
116
+ tabs.set(id, { protocol: null, title: tab.title, url: tab.url, hibernated: true });
117
+ saveJournal();
118
+ return { id, hibernated: true, url: tab.url };
119
+ }
120
+
121
+ /** Wake a hibernated tab: fresh target, journaled URL re-navigated. */
122
+ async function wakeTab(targetId) {
123
+ const tab = tabs.get(targetId);
124
+ if (!tab || !tab.hibernated) return tab?.protocol || protocol;
125
+ const port = actualCdpPort || cfg.port;
126
+ const info = await CDP.New({ port, url: tab.url || "about:blank" });
127
+ const newProtocol = await CDP({ target: info.id, port });
128
+ tabs.delete(targetId);
129
+ tabs.set(info.id, { protocol: newProtocol, title: info.title || tab.title, url: info.url || tab.url });
130
+ if (activeTabId === targetId) activeTabId = info.id;
131
+ saveJournal();
132
+ return newProtocol;
133
+ }
134
+
135
+ /**
136
+ * Restore the journaled working set after a fresh spawn. First entry
137
+ * navigates now; the rest become about:blank placeholders woken on switch.
138
+ * Capped at cfg.tabMax so restore never re-spikes memory at startup.
139
+ */
140
+ export async function restoreJournal() {
141
+ const entries = loadJournal();
142
+ if (!entries.length) return false;
143
+ await ensureDefaultTab();
144
+ const cap = cfg.tabMax && cfg.tabMax > 0 ? cfg.tabMax : entries.length;
145
+ const wanted = entries.slice(0, Math.max(cap, 1));
146
+ const port = actualCdpPort || cfg.port;
147
+ let first = true;
148
+ for (const entry of wanted) {
149
+ if (first) {
150
+ first = false;
151
+ try {
152
+ await protocol.Page.navigate({ url: entry.url });
153
+ syncActiveTab(entry.title, entry.url);
154
+ } catch {}
155
+ continue;
156
+ }
157
+ try {
158
+ const info = await CDP.New({ port, url: "about:blank" });
159
+ const newProtocol = await CDP({ target: info.id, port });
160
+ tabs.set(info.id, { protocol: newProtocol, title: entry.title || "", url: "", pendingUrl: entry.url });
161
+ } catch {}
162
+ }
163
+ saveJournal();
164
+ return true;
165
+ }
166
+
57
167
  // ─── Create Tab ──────────────────────────────────────────────────────────────
58
168
 
59
169
  /**
@@ -63,6 +173,16 @@ export async function getActiveProtocol() {
63
173
  export async function createTab(url) {
64
174
  await ensureDefaultTab();
65
175
 
176
+ // Live-tab cap: hibernate the oldest non-active tab instead of growing
177
+ // renderers until Android LMK notices. Journal keeps everything restorable.
178
+ if (cfg.tabMax && cfg.tabMax > 0) {
179
+ while (liveCount() >= cfg.tabMax) {
180
+ const victim = [...tabs.keys()].find((k) => k !== activeTabId && !tabs.get(k)?.hibernated);
181
+ if (!victim) break;
182
+ await hibernateTab(victim);
183
+ }
184
+ }
185
+
66
186
  const port = actualCdpPort || cfg.port;
67
187
  const info = await CDP.New({ port, url: url || "about:blank" });
68
188
 
@@ -70,6 +190,7 @@ export async function createTab(url) {
70
190
  const newProtocol = await CDP({ target: info.id, port });
71
191
  tabs.set(info.id, { protocol: newProtocol, title: info.title || "", url: info.url || url || "" });
72
192
  activeTabId = info.id;
193
+ saveJournal();
73
194
 
74
195
  return { id: info.id, title: info.title || "", url: info.url || "" };
75
196
  }
@@ -103,6 +224,7 @@ export async function closeTab(targetId) {
103
224
  if (activeTabId === id) {
104
225
  activeTabId = tabs.keys().next().value;
105
226
  }
227
+ saveJournal();
106
228
 
107
229
  return { closed: id, activeTab: activeTabId };
108
230
  }
@@ -110,17 +232,35 @@ export async function closeTab(targetId) {
110
232
  // ─── Switch Tab ──────────────────────────────────────────────────────────────
111
233
 
112
234
  /**
113
- * Switches to a different tab by targetId.
235
+ * Switches to a different tab by targetId. Hibernated tabs and restored
236
+ * placeholders wake transparently (async navigation on first switch).
114
237
  */
115
- export function switchTab(targetId) {
238
+ export async function switchTab(targetId) {
116
239
  if (!targetId) throw new Error("No targetId provided");
117
240
  if (!tabs.has(targetId)) {
118
241
  throw new Error(`Tab not found: ${targetId}`);
119
242
  }
120
243
 
121
- activeTabId = targetId;
122
244
  const tab = tabs.get(targetId);
123
- return { id: targetId, title: tab.title, url: tab.url };
245
+ if (tab.hibernated) {
246
+ await wakeTab(targetId);
247
+ const woken = tabs.get(activeTabId);
248
+ return { id: activeTabId, title: woken?.title, url: woken?.url, woken: true };
249
+ }
250
+ activeTabId = targetId;
251
+ if (tab.pendingUrl) {
252
+ const url = tab.pendingUrl;
253
+ delete tab.pendingUrl;
254
+ try {
255
+ const cdp = tab.protocol || protocol;
256
+ const { Page } = cdp;
257
+ await Page.navigate({ url });
258
+ tab.url = url;
259
+ } catch {}
260
+ }
261
+ saveJournal();
262
+ const current = tabs.get(activeTabId);
263
+ return { id: activeTabId, title: current?.title, url: current?.url };
124
264
  }
125
265
 
126
266
  // ─── List Tabs ───────────────────────────────────────────────────────────────
@@ -136,6 +276,8 @@ export function listTabs() {
136
276
  title: tab.title,
137
277
  url: tab.url,
138
278
  active: id === activeTabId,
279
+ ...(tab.hibernated ? { hibernated: true } : {}),
280
+ ...(tab.pendingUrl ? { pendingUrl: tab.pendingUrl } : {}),
139
281
  });
140
282
  }
141
283
  return result;
@@ -161,5 +303,6 @@ export function syncActiveTab(title, url) {
161
303
  const tab = tabs.get(activeTabId);
162
304
  if (title) tab.title = title;
163
305
  if (url) tab.url = url;
306
+ saveJournal();
164
307
  }
165
308
  }
package/lib/vigil.mjs ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * bwb-browser — Resource vigilance (v4)
3
+ *
4
+ * Pure measurement + policy. No imports from tabs/browser (avoid cycles) —
5
+ * the server.mjs response wrapper executes the actions assess() recommends.
6
+ * MCP can't push, so every tool response carries a ~100-byte footer and the
7
+ * watcher stream carries memory samples. That IS the "instant" channel.
8
+ */
9
+
10
+ import { execSync } from "child_process";
11
+
12
+ // 1GB-box budgets (MB) when lean; roomier desktop defaults otherwise.
13
+ // Override any via BWB_WARN_MB / BWB_CRIT_MB. resolveBudgets() is called
14
+ // by server.mjs after config — before that, lean-safe values apply.
15
+ export let WARN_MB = parseInt(process.env.BWB_WARN_MB || "300", 10);
16
+ export let CRIT_MB = parseInt(process.env.BWB_CRIT_MB || "450", 10);
17
+
18
+ export function resolveBudgets(isLean) {
19
+ if (process.env.BWB_WARN_MB !== undefined) WARN_MB = parseInt(process.env.BWB_WARN_MB, 10);
20
+ else WARN_MB = isLean ? 300 : 1024;
21
+ if (process.env.BWB_CRIT_MB !== undefined) CRIT_MB = parseInt(process.env.BWB_CRIT_MB, 10);
22
+ else CRIT_MB = isLean ? 450 : 1536;
23
+ }
24
+
25
+ const MB = 1024 * 1024;
26
+
27
+ function selfMb() {
28
+ return Math.round(process.memoryUsage().rss / MB);
29
+ }
30
+
31
+ // Sum RSS of a pid + its descendants via ps. Null when unmeasurable.
32
+ function treeMb(rootPid) {
33
+ if (!rootPid) return null;
34
+ try {
35
+ const out = execSync("ps -o pid=,ppid=,rss= -e", { encoding: "utf8", timeout: 3000 });
36
+ const procs = out.trim().split("\n").map((l) => {
37
+ const [pid, ppid, rss] = l.trim().split(/\s+/).map(Number);
38
+ return { pid, ppid, rss };
39
+ });
40
+ const kids = new Set([Number(rootPid)]);
41
+ let grew = true;
42
+ while (grew) {
43
+ grew = false;
44
+ for (const p of procs) {
45
+ if (kids.has(p.ppid) && !kids.has(p.pid)) { kids.add(p.pid); grew = true; }
46
+ }
47
+ }
48
+ let kb = 0;
49
+ for (const p of procs) if (kids.has(p.pid) && Number.isFinite(p.rss)) kb += p.rss;
50
+ return Math.round(kb / 1024);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Snapshot: {mcpMb, chromiumMb|null, tabCount} */
57
+ export function sampleResources(browserPid, tabCount = 0) {
58
+ return { mcpMb: selfMb(), chromiumMb: treeMb(browserPid), tabCount };
59
+ }
60
+
61
+ /**
62
+ * Policy verdict: 'ok' | 'watch' | 'critical'.
63
+ * critical = shed load NOW (hibernate tabs → teardown). watch = warn only.
64
+ */
65
+ export function assess(sample) {
66
+ const total = (sample.chromiumMb || 0) + sample.mcpMb;
67
+ if (total >= CRIT_MB) return "critical";
68
+ if (total >= WARN_MB) return "watch";
69
+ return "ok";
70
+ }
71
+
72
+ /** ~100-byte footer appended as an extra text block to every tool response. */
73
+ export function resourceFooter(sample, note = "") {
74
+ const c = sample.chromiumMb === null ? "browser:off" : `browser:${sample.chromiumMb}MB`;
75
+ const trend = assess(sample);
76
+ return `[bwb resources] mcp:${sample.mcpMb}MB ${c} tabs:${sample.tabCount} state:${trend}${note ? " " + note : ""}]`;
77
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bwb-browser",
3
- "version": "3.2.0",
4
- "description": "Browser Without Bloat — 76KB MCP server that gives AI agents browser superpowers. Natural language interaction, live event watching, persistent sessions, multi-tab. Runs on Termux/Android, desktop, and CI. Zero heavy dependencies.",
3
+ "version": "4.0.1",
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
  }