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/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";
21
- import { mkdirSync, readFileSync } from "fs";
22
- import { homedir } from "os";
30
+ import { execSync } from "child_process";
31
+ import { mkdirSync, readFileSync, existsSync } from "fs";
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. 76KB. 25 tools. Zero heavy dependencies.
83
- Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
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. Weighs nothing.
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
- TOOLS (25):
123
+ TOOLS (26):
101
124
  CORE BROWSING:
102
- browser_goto Navigate to a URL
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 (25):
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 (25):
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 different tab
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 (25):
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 Browser connection 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:
@@ -174,8 +200,33 @@ Object.assign(cfg, parseArgs());
174
200
  cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
175
201
  cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
176
202
  cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
177
- cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || "/storage/emulated/0/Download/bwb-screenshots";
203
+ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || (() => {
204
+ // Auto-detect: Termux/Android path if available, else ~/bwb-screenshots/
205
+ const androidPath = "/storage/emulated/0/Download/bwb-screenshots";
206
+ if (platform() === "android" && existsSync("/storage/emulated/0/Download")) return androidPath;
207
+ if (process.env.HOME?.includes("com.termux")) return androidPath;
208
+ if (process.env.TERMUX_VERSION) return androidPath;
209
+ return join(homedir(), "bwb-screenshots");
210
+ })();
178
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);
179
230
 
180
231
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
181
232
 
@@ -236,28 +287,70 @@ const tools = {
236
287
  // ═══════════════ CORE BROWSING ═══════════════
237
288
 
238
289
  browser_goto: {
239
- 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).",
240
291
  schema: { url: z.string().describe("URL to navigate to") },
241
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).
242
308
  const cdp = await getActiveProtocol();
243
309
  const { Page, Runtime } = cdp;
244
310
  const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
245
311
  syncActiveTab(result.title, result.url);
246
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
312
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
247
313
  },
248
314
  },
249
315
 
250
316
  browser_screenshot: {
251
- description: "Take a screenshot of the current page.",
317
+ description: "Take a screenshot of the current page. Pass a CSS selector to capture just that element.",
252
318
  schema: {
253
319
  fullPage: z.boolean().describe("Full page screenshot (default false)").optional(),
254
320
  quality: z.number().describe("JPEG quality 0-100 (default 80)").optional(),
321
+ selector: z.string().describe("CSS selector to capture only that element (optional)").optional(),
255
322
  },
256
- handler: async ({ fullPage = false, quality = 80 }) => {
257
- const { Page } = await getActiveProtocol();
258
- const { data } = await Page.captureScreenshot({ format: "jpeg", quality, captureBeyondViewport: fullPage });
323
+ handler: async ({ fullPage = false, quality = 80, selector }) => {
324
+ const { Page, Runtime } = await getActiveProtocol();
325
+ let clip;
326
+ if (selector) {
327
+ // Element capture: compute bounding rect in page coords, then clip
328
+ const { result } = await Runtime.evaluate({
329
+ expression: `(() => {
330
+ const el = document.querySelector(${JSON.stringify(selector)});
331
+ if (!el) return null;
332
+ const r = el.getBoundingClientRect();
333
+ const sx = window.scrollX || document.documentElement.scrollLeft;
334
+ const sy = window.scrollY || document.documentElement.scrollTop;
335
+ return JSON.stringify({ x: r.x + sx, y: r.y + sy, width: r.width, height: r.height });
336
+ })()`,
337
+ returnByValue: true,
338
+ });
339
+ const rect = result?.value ? JSON.parse(result.value) : null;
340
+ if (!rect || !rect.width || !rect.height) {
341
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Element not found or not visible: ${selector}` }) }] };
342
+ }
343
+ clip = { ...rect, scale: 1 };
344
+ }
345
+ // captureBeyondViewport=false keeps clip math in viewport space; a 0-size clip
346
+ // would be rejected by CDP, so guard against degenerate rects above.
347
+ const { data } = await Page.captureScreenshot({
348
+ format: "jpeg", quality,
349
+ captureBeyondViewport: fullPage || !!clip,
350
+ ...(clip ? { clip } : {}),
351
+ });
259
352
  const savedPath = saveScreenshot(data);
260
- const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...` };
353
+ const response = { screenshot: `data:image/jpeg;base64,${data.slice(0, 40)}...`, captured: clip ? selector : (fullPage ? "full page" : "viewport") };
261
354
  if (savedPath) response.savedTo = savedPath;
262
355
  return { content: [
263
356
  { type: "image", data, mimeType: "image/jpeg" },
@@ -292,33 +385,22 @@ const tools = {
292
385
  },
293
386
  },
294
387
 
295
- browser_title: {
296
- description: "Get current page title.",
297
- schema: {},
298
- handler: async () => {
299
- const { Runtime } = await getActiveProtocol();
300
- const { result } = await Runtime.evaluate({ expression: "document.title" });
301
- return { content: [{ type: "text", text: result?.value || "" }] };
302
- },
303
- },
304
-
305
- browser_url: {
306
- description: "Get current page URL.",
307
- schema: {},
308
- handler: async () => {
309
- const { Runtime } = await getActiveProtocol();
310
- const { result } = await Runtime.evaluate({ expression: "window.location.href" });
311
- return { content: [{ type: "text", text: result?.value || "" }] };
312
- },
313
- },
314
-
315
388
  browser_back: {
316
389
  description: "Go back in browser history (like clicking the browser back button).",
317
390
  schema: {},
318
391
  handler: async () => {
319
392
  const { Page, Runtime } = await getActiveProtocol();
320
- await Page.navigate({ url: "javascript:history.back()" });
321
- await new Promise(r => setTimeout(r, 500));
393
+ // Native CDP back: walk history via navigation entries.
394
+ // NOTE: Page.goBack doesn't exist in the bundled CDP 1.3 protocol —
395
+ // getNavigationHistory + navigateToHistoryEntry is the native equivalent.
396
+ // The wrapper returns history FLAT ({currentIndex, entries}), not {result:{...}}.
397
+ const hist = await Page.getNavigationHistory();
398
+ const entries = hist?.entries || [];
399
+ const currentIdx = hist?.currentIndex ?? -1;
400
+ if (currentIdx > 0 && entries[currentIdx - 1]) {
401
+ await Page.navigateToHistoryEntry({ entryId: entries[currentIdx - 1].id });
402
+ }
403
+ await new Promise(r => setTimeout(r, Math.min(cfg.navTimeout, 1000)));
322
404
  const { result } = await Runtime.evaluate({ expression: "document.title" });
323
405
  syncActiveTab(result?.value, undefined);
324
406
  return { content: [{ type: "text", text: JSON.stringify({ title: result?.value || "" }) }] };
@@ -424,16 +506,21 @@ const tools = {
424
506
  await cdp.Runtime.enable();
425
507
  await cdp.Network.enable();
426
508
  setupWatch(events, cdp);
509
+ setIdleSuppressed(true); // recording in progress — mayfly must not teardown
427
510
  return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
428
511
  }
429
512
  if (action === "poll") {
430
513
  const snapshot = [...watchState.events];
431
514
  watchState.events = [];
432
- return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot }) }] };
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 }) }] };
433
519
  }
434
520
  if (action === "stop") {
435
521
  const remaining = [...watchState.events];
436
522
  cleanupWatch();
523
+ setIdleSuppressed(false);
437
524
  return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
438
525
  }
439
526
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
@@ -499,7 +586,7 @@ const tools = {
499
586
  description: "Switch to a different browser tab by targetId.",
500
587
  schema: { targetId: z.string().describe("Target tab ID to switch to") },
501
588
  handler: async ({ targetId }) => {
502
- const result = switchTab(targetId);
589
+ const result = await switchTab(targetId);
503
590
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
504
591
  },
505
592
  },
@@ -544,10 +631,105 @@ const tools = {
544
631
  },
545
632
  },
546
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+
547
729
  // ═══════════════ LIFECYCLE ═══════════════
548
730
 
549
731
  browser_status: {
550
- 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.",
551
733
  schema: {},
552
734
  handler: async () => {
553
735
  const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
@@ -555,19 +737,23 @@ const tools = {
555
737
  status.running = true;
556
738
  status.pid = browser.pid;
557
739
  status.tabs = listTabs();
558
- try {
559
- if (actualCdpPort) {
560
- status.actualPort = actualCdpPort;
561
- const targets = await CDP.List({ port: actualCdpPort });
562
- status.connected = true;
563
- status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
564
- } else {
565
- const targets = await CDP.List({ port: cfg.port || 9222 });
740
+ // actualCdpPort is the real bound port; cfg.port may be 0 (random).
741
+ // Never fall back to a hardcoded 9222 — that could be another tool's browser.
742
+ const listPort = actualCdpPort || cfg.port;
743
+ if (listPort) {
744
+ try {
745
+ status.actualPort = actualCdpPort || cfg.port;
746
+ const targets = await CDP.List({ port: listPort });
566
747
  status.connected = true;
567
748
  status.targets = targets.map(t => ({ type: t.type, url: t.url, title: t.title }));
568
- }
569
- } catch { status.connected = false; }
749
+ } catch { status.connected = false; }
750
+ }
570
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 {}
571
757
  return { content: [{ type: "text", text: JSON.stringify(status) }] };
572
758
  },
573
759
  },
@@ -577,6 +763,7 @@ const tools = {
577
763
  schema: {},
578
764
  handler: async () => {
579
765
  clearTabs(); // Kill stale tab connections before restart
766
+ cleanupWatch(); // Detach event listeners from the dying protocol before it's gone
580
767
  const result = await restartBrowser();
581
768
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
582
769
  },
@@ -585,8 +772,45 @@ const tools = {
585
772
 
586
773
  // ─── Register & Start ─────────────────────────────────────────────────────────
587
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.
588
778
  for (const [name, tool] of Object.entries(tools)) {
589
- server.tool(name, tool.description, tool.schema, tool.handler);
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
+ });
590
814
  }
591
815
 
592
816
  await ensureDeps();