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/server.mjs CHANGED
@@ -12,20 +12,35 @@
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)
24
+ * --attach-port / BWB_ATTACH_PORT — Attach to an already-running
25
+ * browser's CDP port (e.g. 9222)
26
+ * instead of spawning. Guest mode:
27
+ * never spawns, kills, or restores.
15
28
  */
16
29
 
17
30
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
32
  import { z } from "zod";
20
33
  import CDP from "chrome-remote-interface";
34
+ import { execSync } from "child_process";
21
35
  import { mkdirSync, readFileSync, existsSync } from "fs";
22
36
  import { homedir, platform } from "os";
23
37
  import { join, dirname } from "path";
24
38
  import { fileURLToPath } from "url";
25
39
 
26
40
  import {
27
- ensureBrowser, restartBrowser, saveScreenshot,
28
- cfg, browser, browserExited, actualCdpPort,
41
+ ensureBrowser, restartBrowser, stopBrowser, saveScreenshot,
42
+ cfg, browser, browserExited, actualCdpPort, attached,
43
+ isTermux, pokeActivity, setIdleSuppressed,
29
44
  } from "./lib/browser.mjs";
30
45
 
31
46
  // ─── --setup mode ──────────────────────────────────────────────────
@@ -41,8 +56,12 @@ import {
41
56
 
42
57
  import {
43
58
  getActiveProtocol, createTab, closeTab, switchTab, listTabs, syncActiveTab, clearTabs,
59
+ hibernateTab,
44
60
  } from "./lib/tabs.mjs";
45
61
 
62
+ import { staticFetch } from "./lib/fetch.mjs";
63
+ import { sampleResources, assess, resourceFooter, resolveBudgets } from "./lib/vigil.mjs";
64
+
46
65
  import { saveSession, loadSession, listSessions } from "./lib/session.mjs";
47
66
  import { diagnosePage } from "./lib/diagnose.mjs";
48
67
  import { applyRealisticProfile } from "./lib/fingerprint.mjs";
@@ -68,6 +87,11 @@ function parseArgs() {
68
87
  case "--headless": cliCfg.headless = args[++i] !== "false"; break;
69
88
  case "--screenshots-dir": cliCfg.screenshotsDir = args[++i]; break;
70
89
  case "--timeout": cliCfg.navTimeout = parseInt(args[++i], 10); break;
90
+ case "--lean": cliCfg.lean = args[++i] !== "false"; break;
91
+ case "--nuclear": cliCfg.nuclear = args[++i] !== "false"; break;
92
+ case "--idle": cliCfg.idleMs = parseInt(args[++i], 10); break;
93
+ case "--tab-max": cliCfg.tabMax = parseInt(args[++i], 10); break;
94
+ case "--attach-port": cliCfg.attachPort = parseInt(args[++i], 10); break;
71
95
  case "--version": console.log(`bwb-browser ${BWB_VERSION}`); process.exit(0);
72
96
  case "--help": printHelp(); process.exit(0);
73
97
  }
@@ -77,12 +101,12 @@ function parseArgs() {
77
101
 
78
102
  function printHelp() {
79
103
  console.log(`
80
- bwb-browser v${BWB_VERSION} — Browser Without Bloat
104
+ bwb-browser v${BWB_VERSION} — Browser Without Bloat
81
105
 
82
- Browser automation for AI agents. 76KB. 26 tools. Zero heavy dependencies.
83
- Uses raw CDP — no Playwright, no Puppeteer, no 400MB downloads.
106
+ Browser automation for AI agents. Static-first, lean like air. 26 tools.
107
+ Raw CDP — no Playwright, no Puppeteer. Chromium starts only when JS demands it.
84
108
 
85
- Built on Termux/Android. Runs everywhere. Weighs nothing.
109
+ Built on Termux/Android. Runs everywhere — including 1GB VPS boxes.
86
110
 
87
111
  USAGE:
88
112
  bwb [options]
@@ -94,18 +118,23 @@ OPTIONS:
94
118
  --headless <bool> Run headless (default: true)
95
119
  --screenshots-dir <path> Directory to save screenshots
96
120
  --timeout <ms> Navigation timeout in ms (default: 30000)
121
+ --lean <bool> Survival profile (default: auto on Termux)
122
+ --nuclear Add --single-process (max saving, min stability)
123
+ --idle <ms> Mayfly teardown after N ms idle (default: 5min lean)
124
+ --tab-max <n> Live-tab cap, oldest hibernated (default: 3 lean)
125
+ --attach-port <n> Attach to a running browser's CDP port (e.g. 9222).
126
+ Guest mode: no spawn, no kill, visible window.
97
127
  --version Print version
98
128
  --help Show this help
99
129
 
100
130
  TOOLS (26):
101
131
  CORE BROWSING:
102
- browser_goto Navigate to a URL
132
+ browser_goto Navigate (static-first, escalates to browser)
103
133
  browser_screenshot Take a screenshot
104
134
  browser_html Get page/selector HTML
105
135
  browser_text Get page/selector text
106
- browser_title Get page title
107
- browser_url Get current URL
108
136
  browser_back Go back in history
137
+ (title/url folded into browser_status — v4 breaking change)
109
138
 
110
139
  INTERACTION:
111
140
  browser_click Click an element
@@ -116,7 +145,7 @@ TOOLS (26):
116
145
 
117
146
  🔥 ADVANCED:
118
147
  browser_act Natural language page interaction (one tool does it all)
119
- browser_watch Live page event capture (console, network)
148
+ browser_watch Live page event capture (console, network + resources)
120
149
  browser_diagnose Full page health diagnostic
121
150
  browser_fingerprint Realistic browser profile for testing
122
151
  browser_waitForSelector Wait for element to appear/disappear
@@ -124,7 +153,7 @@ TOOLS (26):
124
153
  MULTI-TAB:
125
154
  browser_newTab Create a new tab
126
155
  browser_closeTab Close a tab
127
- browser_switchTab Switch to a different tab
156
+ browser_switchTab Switch to a tab (hibernated tabs wake)
128
157
  browser_listTabs List all open tabs
129
158
 
130
159
  SESSION:
@@ -132,8 +161,12 @@ TOOLS (26):
132
161
  browser_loadCookies Load session cookies from disk
133
162
  browser_listSessions List saved sessions
134
163
 
164
+ ON-DEMAND (verbs ship, weight doesn't — backends install on consent):
165
+ browser_download Download media (needs system yt-dlp)
166
+ browser_export Export md/txt/html (pdf/docx/pptx need pip libs)
167
+
135
168
  LIFECYCLE:
136
- browser_status Browser connection status
169
+ browser_status Status + live resources + active profile
137
170
  browser_restart Restart the browser
138
171
 
139
172
  If bwb saves you time or money, consider supporting development:
@@ -172,6 +205,7 @@ async function ensureDeps() {
172
205
 
173
206
  Object.assign(cfg, parseArgs());
174
207
  cfg.port = cfg.port || parseInt(process.env.BWB_CDP_PORT || "0", 10);
208
+ cfg.attachPort = cfg.attachPort || parseInt(process.env.BWB_ATTACH_PORT || "0", 10);
175
209
  cfg.headless = cfg.headless !== undefined ? cfg.headless : (process.env.BWB_HEADLESS !== "false");
176
210
  cfg.userDataDir = cfg.userDataDir || process.env.BWB_USER_DATA_DIR || join(homedir(), ".cache", "bwb-browser");
177
211
  cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || (() => {
@@ -183,6 +217,24 @@ cfg.screenshotsDir = cfg.screenshotsDir || process.env.BWB_SCREENSHOTS_DIR || ((
183
217
  return join(homedir(), "bwb-screenshots");
184
218
  })();
185
219
  cfg.navTimeout = cfg.navTimeout || parseInt(process.env.BWB_NAV_TIMEOUT || "30000", 10);
220
+ // v4 survival defaults: lean auto-detects Termux; mayfly + tab cap follow lean
221
+ // unless explicitly overridden. Desktop behavior unchanged (all off).
222
+ if (cfg.lean === null || cfg.lean === undefined) {
223
+ if (process.env.BWB_LEAN !== undefined) cfg.lean = process.env.BWB_LEAN !== "false";
224
+ else cfg.lean = isTermux();
225
+ }
226
+ if (cfg.nuclear === undefined || cfg.nuclear === null) {
227
+ cfg.nuclear = process.env.BWB_NUCLEAR === "true";
228
+ }
229
+ if (cfg.idleMs === null || cfg.idleMs === undefined) {
230
+ if (process.env.BWB_IDLE_MS !== undefined) cfg.idleMs = parseInt(process.env.BWB_IDLE_MS, 10);
231
+ else cfg.idleMs = cfg.lean ? 5 * 60 * 1000 : 0;
232
+ }
233
+ if (cfg.tabMax === null || cfg.tabMax === undefined) {
234
+ if (process.env.BWB_TAB_MAX !== undefined) cfg.tabMax = parseInt(process.env.BWB_TAB_MAX, 10);
235
+ else cfg.tabMax = cfg.lean ? 3 : 0;
236
+ }
237
+ resolveBudgets(cfg.lean);
186
238
 
187
239
  try { mkdirSync(cfg.screenshotsDir, { recursive: true }); } catch {}
188
240
 
@@ -243,14 +295,29 @@ const tools = {
243
295
  // ═══════════════ CORE BROWSING ═══════════════
244
296
 
245
297
  browser_goto: {
246
- description: "Navigate to a URL. Returns page title and URL.",
298
+ 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
299
  schema: { url: z.string().describe("URL to navigate to") },
248
300
  handler: async ({ url }) => {
301
+ // Rung 1: static fetch. No browser spawned, no LMK risk, milliseconds.
302
+ const attempt = await staticFetch(url, { timeout: Math.min(cfg.navTimeout, 15000) });
303
+ if (attempt.mode === "static") {
304
+ syncActiveTab(attempt.title, attempt.finalUrl);
305
+ return { content: [{ type: "text", text: JSON.stringify({
306
+ mode: "static", title: attempt.title, url: attempt.finalUrl,
307
+ text: attempt.text, confidence: attempt.confidence,
308
+ note: "Served without Chromium. Need interaction/screenshots? Use browser_act / browser_screenshot — that escalates to the browser.",
309
+ }) }] };
310
+ }
311
+ if (attempt.mode === "error") {
312
+ // Dead URL — CDP shares the same network, don't spawn Chromium for a 404.
313
+ return { content: [{ type: "text", text: JSON.stringify({ mode: "error", error: attempt.error }) }] };
314
+ }
315
+ // Rung 2: escalate to Chromium (JS shell, auth wall, non-text).
249
316
  const cdp = await getActiveProtocol();
250
317
  const { Page, Runtime } = cdp;
251
318
  const result = await gotoUrl(Page, Runtime, url, cfg.navTimeout);
252
319
  syncActiveTab(result.title, result.url);
253
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
320
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, mode: "browser", escalated: attempt.reason }) }] };
254
321
  },
255
322
  },
256
323
 
@@ -326,26 +393,6 @@ const tools = {
326
393
  },
327
394
  },
328
395
 
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
396
  browser_back: {
350
397
  description: "Go back in browser history (like clicking the browser back button).",
351
398
  schema: {},
@@ -467,16 +514,21 @@ const tools = {
467
514
  await cdp.Runtime.enable();
468
515
  await cdp.Network.enable();
469
516
  setupWatch(events, cdp);
517
+ setIdleSuppressed(true); // recording in progress — mayfly must not teardown
470
518
  return { content: [{ type: "text", text: JSON.stringify({ status: "watching", events, msg: "Recording started. Poll to get events." }) }] };
471
519
  }
472
520
  if (action === "poll") {
473
521
  const snapshot = [...watchState.events];
474
522
  watchState.events = [];
475
- return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot }) }] };
523
+ // Resource vigilance rides the existing poll rhythm — no new mechanism.
524
+ let resources = null;
525
+ try { resources = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length); } catch {}
526
+ return { content: [{ type: "text", text: JSON.stringify({ count: snapshot.length, events: snapshot, resources }) }] };
476
527
  }
477
528
  if (action === "stop") {
478
529
  const remaining = [...watchState.events];
479
530
  cleanupWatch();
531
+ setIdleSuppressed(false);
480
532
  return { content: [{ type: "text", text: JSON.stringify({ status: "stopped", captured: remaining.length, events: remaining }) }] };
481
533
  }
482
534
  return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid action" }) }] };
@@ -542,7 +594,7 @@ const tools = {
542
594
  description: "Switch to a different browser tab by targetId.",
543
595
  schema: { targetId: z.string().describe("Target tab ID to switch to") },
544
596
  handler: async ({ targetId }) => {
545
- const result = switchTab(targetId);
597
+ const result = await switchTab(targetId);
546
598
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
547
599
  },
548
600
  },
@@ -587,16 +639,113 @@ const tools = {
587
639
  },
588
640
  },
589
641
 
642
+ // ═══════════════ ON-DEMAND CAPABILITIES ═══════════════
643
+ // Verbs ship, weight doesn't. Heavy backends (yt-dlp, reportlab) are NEVER
644
+ // bundled — probed at call time, installed only on explicit user consent.
645
+
646
+ browser_download: {
647
+ 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.",
648
+ schema: {
649
+ url: z.string().describe("Media URL"),
650
+ format: z.enum(["best", "audio", "video", "subtitles", "thumbnail"]).describe("What to download").optional(),
651
+ quality: z.enum(["best", "good", "worst"]).describe("Quality tier").optional(),
652
+ },
653
+ handler: async ({ url, format = "best", quality = "best" }) => {
654
+ // No shell metachars ever reach execSync — http(s) only.
655
+ if (!/^https?:\/\/[^\\s"';`$(){}|&<>]+$/i.test(url)) {
656
+ return { content: [{ type: "text", text: JSON.stringify({ error: "refused: URL must be http(s) without shell metacharacters" }) }] };
657
+ }
658
+ let hasYtDlp = false;
659
+ try {
660
+ execSync("yt-dlp --version", { stdio: "ignore", timeout: 5000 });
661
+ hasYtDlp = true;
662
+ } catch {}
663
+ if (!hasYtDlp) {
664
+ return { content: [{ type: "text", text: JSON.stringify({
665
+ needsInstall: true,
666
+ tool: "yt-dlp",
667
+ install: {
668
+ termux: "pkg install yt-dlp",
669
+ debian: "pip install yt-dlp",
670
+ macos: "brew install yt-dlp",
671
+ },
672
+ ask: "yt-dlp is not installed. Reply YES (agent: ask the human) to install it, or install manually and retry. Nothing was downloaded.",
673
+ }) }] };
674
+ }
675
+ const outDir = join(dirname(cfg.screenshotsDir), "bwb-downloads");
676
+ try { mkdirSync(outDir, { recursive: true }); } catch {}
677
+ const args = ["--no-playlist", "-P", outDir, "--print", "after_move:filepath"];
678
+ if (format === "audio") args.push("-x", "--audio-format", "mp3");
679
+ else if (format === "subtitles") args.push("--write-subs", "--skip-download");
680
+ else if (format === "thumbnail") args.push("--write-thumbnail", "--skip-download");
681
+ if (quality === "worst") args.push("-f", "worst");
682
+ else if (quality === "good") args.push("-f", "best[height<=720]");
683
+ args.push(url);
684
+ try {
685
+ const out = execSync(`yt-dlp ${args.map((a) => `"${a}"`).join(" ")}`, { encoding: "utf8", timeout: 600000, maxBuffer: 1024 * 1024 });
686
+ const file = out.trim().split("\n").pop();
687
+ return { content: [{ type: "text", text: JSON.stringify({ downloaded: file, format, quality }) }] };
688
+ } catch (err) {
689
+ return { content: [{ type: "text", text: JSON.stringify({ error: "download failed", detail: String(err.message || err).slice(0, 500) }) }] };
690
+ }
691
+ },
692
+ },
693
+
694
+ browser_export: {
695
+ 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.",
696
+ schema: {
697
+ text: z.string().describe("Content to export (markdown accepted)"),
698
+ format: z.enum(["md", "txt", "html", "pdf", "docx", "pptx"]).describe("Output format").optional(),
699
+ output_path: z.string().describe("Where to write the file").optional(),
700
+ title: z.string().describe("Document title").optional(),
701
+ },
702
+ handler: async ({ text, format = "md", output_path, title = "bwb export" }) => {
703
+ const { writeFileSync: wfs } = await import("fs");
704
+ const dest = output_path || join(dirname(cfg.screenshotsDir), `bwb-export-${Date.now()}.${format === "txt" ? "txt" : format === "html" ? "html" : "md"}`);
705
+ if (["md", "txt"].includes(format)) {
706
+ try { wfs(dest, text, "utf8"); } catch (err) {
707
+ return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
708
+ }
709
+ return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
710
+ }
711
+ if (format === "html") {
712
+ const esc = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
713
+ try { wfs(dest, `<!doctype html><html><head><meta charset="utf8"><title>${title}</title></head><body><pre>${esc}</pre></body></html>`, "utf8"); } catch (err) {
714
+ return { content: [{ type: "text", text: JSON.stringify({ error: `write failed: ${err.message}` }) }] };
715
+ }
716
+ return { content: [{ type: "text", text: JSON.stringify({ exported: dest, format }) }] };
717
+ }
718
+ // pdf/docx/pptx need python libs — probe, then consent-gate.
719
+ const need = { pdf: "reportlab", docx: "python-docx", pptx: "python-pptx" }[format];
720
+ let have = false;
721
+ try {
722
+ execSync(`python3 -c "import ${need.split("-").join("_")}"`, { stdio: "ignore", timeout: 10000 });
723
+ have = true;
724
+ } catch {}
725
+ if (!have) {
726
+ return { content: [{ type: "text", text: JSON.stringify({
727
+ needsInstall: true,
728
+ tool: need,
729
+ install: `pip install ${need}`,
730
+ 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.`,
731
+ }) }] };
732
+ }
733
+ 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." }) }] };
734
+ },
735
+ },
736
+
590
737
  // ═══════════════ LIFECYCLE ═══════════════
591
738
 
592
739
  browser_status: {
593
- description: "Get browser and page status including opened tabs and connection info.",
740
+ 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
741
  schema: {},
595
742
  handler: async () => {
596
743
  const status = { connected: false, port: cfg.port, actualPort: null, running: false, pid: null, tabs: [] };
597
- if (browser && !browserExited) {
744
+ // Attach mode: no child process (browser === null) — liveness is the CDP link.
745
+ if ((browser && !browserExited) || (attached && !browserExited)) {
598
746
  status.running = true;
599
- status.pid = browser.pid;
747
+ status.pid = browser ? browser.pid : null;
748
+ if (attached) status.attached = true;
600
749
  status.tabs = listTabs();
601
750
  // actualCdpPort is the real bound port; cfg.port may be 0 (random).
602
751
  // Never fall back to a hardcoded 9222 — that could be another tool's browser.
@@ -610,6 +759,12 @@ const tools = {
610
759
  } catch { status.connected = false; }
611
760
  }
612
761
  }
762
+ try {
763
+ status.resources = sampleResources(browser?.pid, status.tabs.filter((t) => !t.hibernated).length);
764
+ status.resources.state = assess(status.resources);
765
+ status.profile = { lean: cfg.lean, nuclear: cfg.nuclear, idleMs: cfg.idleMs, tabMax: cfg.tabMax };
766
+ if (attached) status.profile.attached = actualCdpPort || cfg.attachPort;
767
+ } catch {}
613
768
  return { content: [{ type: "text", text: JSON.stringify(status) }] };
614
769
  },
615
770
  },
@@ -628,8 +783,47 @@ const tools = {
628
783
 
629
784
  // ─── Register & Start ─────────────────────────────────────────────────────────
630
785
 
786
+ // Single choke point for every tool call: poke the mayfly timer, then append
787
+ // a ~100-byte resource footer. On critical pressure, shed load BEFORE
788
+ // returning — hibernate oldest tabs, teardown at one tab — and say so.
631
789
  for (const [name, tool] of Object.entries(tools)) {
632
- server.tool(name, tool.description, tool.schema, tool.handler);
790
+ const inner = tool.handler;
791
+ server.tool(name, tool.description, tool.schema, async (args) => {
792
+ let result;
793
+ try {
794
+ result = await inner(args);
795
+ } finally {
796
+ try { pokeActivity(); } catch {}
797
+ }
798
+ try {
799
+ const liveTabs = listTabs().filter((t) => !t.hibernated).length;
800
+ const sample = sampleResources(browser?.pid, liveTabs);
801
+ let note = "";
802
+ // Never auto-shed in attach mode: those are the user's REAL tabs.
803
+ // Report pressure in the footer; the human closes their own tabs.
804
+ if (!attached && assess(sample) === "critical" && browser && !browserExited) {
805
+ // Evidence first: keep the peak numbers that triggered the shed.
806
+ const peak = `${sample.mcpMb}+${sample.chromiumMb ?? "?"}MB`;
807
+ // Shed oldest non-active tabs first; teardown at one tab. Journal keeps all.
808
+ const victims = listTabs().filter((t) => !t.active && !t.hibernated).map((t) => t.id);
809
+ for (const id of victims) {
810
+ await hibernateTab(id);
811
+ const after = sampleResources(browser?.pid, listTabs().filter((t) => !t.hibernated).length);
812
+ note = `shed tab ${id.slice(0, 8)} at peak ${peak}`;
813
+ Object.assign(sample, after);
814
+ if (assess(sample) !== "critical") break;
815
+ }
816
+ if (assess(sample) === "critical" && listTabs().filter((t) => !t.hibernated).length <= 1) {
817
+ try { await stopBrowser("oom-guard"); } catch {}
818
+ note += `${note ? "; " : ""}browser stopped at peak ${peak} (oom-guard) — journal saved, next call resurrects`;
819
+ }
820
+ }
821
+ if (result && Array.isArray(result.content)) {
822
+ result.content.push({ type: "text", text: resourceFooter(sample, note) });
823
+ }
824
+ } catch {}
825
+ return result;
826
+ });
633
827
  }
634
828
 
635
829
  await ensureDeps();