bwb-browser 2.0.4 → 3.1.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/lib/act.mjs ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * bwb-browser — Natural Language Page Interaction Engine
3
+ *
4
+ * Translates natural language instructions into CDP browser actions.
5
+ * Rule-based: no LLM dependency. Handles search, navigation, clicking,
6
+ * form filling, content extraction, and scrolling.
7
+ *
8
+ * The goal: ONE tool call does what would normally take 5-10 tool calls.
9
+ * This is the "Computer Use for MCP" moment.
10
+ */
11
+
12
+ // ─── Smart Input Finder ──────────────────────────────────────────────────────
13
+
14
+ async function findInput(Runtime, labelText) {
15
+ const { result } = await Runtime.evaluate({
16
+ expression: `(() => {
17
+ const inputs = document.querySelectorAll('input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, [contenteditable=true]');
18
+ const query = ${JSON.stringify((labelText || '').toLowerCase())};
19
+ if (!query) return { found: true, selector: inputs[0] ? buildSelector(inputs[0]) : null };
20
+
21
+ for (const el of inputs) {
22
+ const p = (el.placeholder || '').toLowerCase();
23
+ const aria = (el.getAttribute('aria-label') || '').toLowerCase();
24
+ const name = (el.name || '').toLowerCase();
25
+ const id = (el.id || '').toLowerCase();
26
+ const type = (el.type || '').toLowerCase();
27
+ let label = '';
28
+ if (el.id) {
29
+ const lbl = document.querySelector('label[for="' + el.id + '"]');
30
+ if (lbl) label = lbl.textContent.toLowerCase();
31
+ }
32
+ const parent = el.closest('label');
33
+ if (parent) label += ' ' + parent.textContent.toLowerCase();
34
+
35
+ const searchText = p + ' ' + aria + ' ' + label + ' ' + name + ' ' + id + ' ' + type;
36
+ if (searchText.includes(query)) {
37
+ return { found: true, selector: buildSelector(el), tag: el.tagName, type: el.type };
38
+ }
39
+ }
40
+ // Fallback: return first input if nothing matches
41
+ if (inputs[0]) return { found: true, selector: buildSelector(inputs[0]), tag: inputs[0].tagName };
42
+ return { found: false };
43
+
44
+ function buildSelector(el) {
45
+ if (el.id) return '#' + CSS.escape(el.id);
46
+ if (el.name) return el.tagName.toLowerCase() + '[name=' + JSON.stringify(el.name) + ']';
47
+ if (el.placeholder) return el.tagName.toLowerCase() + '[placeholder=' + JSON.stringify(el.placeholder) + ']';
48
+ if (el.getAttribute('aria-label')) return el.tagName.toLowerCase() + '[aria-label=' + JSON.stringify(el.getAttribute('aria-label')) + ']';
49
+ return el.tagName.toLowerCase();
50
+ }
51
+ })()`,
52
+ returnByValue: true,
53
+ });
54
+ return result?.value || { found: false };
55
+ }
56
+
57
+ // ─── Smart Element Finder by Text ────────────────────────────────────────────
58
+
59
+ async function findElementByText(Runtime, text, tag = "*") {
60
+ const { result } = await Runtime.evaluate({
61
+ expression: `(() => {
62
+ const elements = document.querySelectorAll('${tag}');
63
+ const query = ${JSON.stringify(text.toLowerCase())};
64
+ let best = null, bestScore = 0;
65
+
66
+ for (const el of elements) {
67
+ const t = (el.textContent || '').trim().toLowerCase();
68
+ const aria = (el.getAttribute('aria-label') || '').toLowerCase();
69
+ const val = (el.getAttribute('value') || '').toLowerCase();
70
+ const title = (el.title || '').toLowerCase();
71
+
72
+ // Exact match is best
73
+ if (t === query) return makeResult(el, 100);
74
+ // Contains match
75
+ if (t.includes(query) && query.length > 2) return makeResult(el, 90);
76
+ // Starts with
77
+ if (t.startsWith(query)) return makeResult(el, 80);
78
+ // aria-label match
79
+ if (aria.includes(query)) return makeResult(el, 70);
80
+ // title match
81
+ if (title.includes(query)) return makeResult(el, 60);
82
+ // value match
83
+ if (val.includes(query)) return makeResult(el, 50);
84
+
85
+ // Fuzzy: word-in-text match
86
+ const words = t.split(/\\s+/);
87
+ const qWords = query.split(/\\s+/);
88
+ let score = 0;
89
+ for (const qw of qWords) {
90
+ if (qw.length < 3) continue;
91
+ if (words.some(w => w.includes(qw))) score += 10;
92
+ }
93
+ if (score > bestScore) {
94
+ bestScore = score;
95
+ best = el;
96
+ }
97
+ }
98
+
99
+ if (best) return makeResult(best, bestScore);
100
+ return { found: false };
101
+
102
+ function makeResult(el, score) {
103
+ const tag = el.tagName.toLowerCase();
104
+ let selector = tag;
105
+ if (el.id) selector = '#' + CSS.escape(el.id);
106
+ else if (el.getAttribute('data-testid')) selector = '[' + JSON.stringify(el.getAttribute('data-testid')) + ']';
107
+ else if (el.className && typeof el.className === 'string') {
108
+ selector = tag + '.' + el.className.split(' ').filter(Boolean).map(c => CSS.escape(c)).join('.');
109
+ }
110
+ return {
111
+ found: true,
112
+ selector,
113
+ tag,
114
+ text: (el.textContent || '').trim().slice(0, 200),
115
+ score,
116
+ href: el.href || '',
117
+ };
118
+ }
119
+ })()`,
120
+ returnByValue: true,
121
+ });
122
+ return result?.value || { found: false };
123
+ }
124
+
125
+ // ─── URL Normalizer ──────────────────────────────────────────────────────────
126
+
127
+ function normalizeUrl(text) {
128
+ let url = text.trim();
129
+ // Remove common prefixes from natural language
130
+ url = url.replace(/^(https?:\/\/)?(www\.)?/i, "");
131
+ // Handle "google.com" type inputs
132
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
133
+ // Check if it looks like a domain (contains a dot)
134
+ if (url.includes(".") && !url.includes(" ")) {
135
+ url = "https://" + url;
136
+ } else {
137
+ // Treat as search query
138
+ return null;
139
+ }
140
+ }
141
+ return url;
142
+ }
143
+
144
+ // ─── Execute Instruction ─────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Parse a natural language instruction and execute the appropriate browser action.
148
+ *
149
+ * Supported patterns:
150
+ * "search for X" / "search X" → Find search input, type, submit
151
+ * "go to URL" / "navigate to URL" → Navigate to URL
152
+ * "click the X button/link" → Find by text, click
153
+ * "fill X with Y" → Find input X, type Y
154
+ * "type X in Y" → Type X into input Y
155
+ * "get me X" / "extract X" → Find content by text
156
+ * "scroll down" / "scroll up" → Scroll page
157
+ * "what is X" → Return page content about X
158
+ *
159
+ * @param {import('chrome-remote-interface').Protocol} protocol
160
+ * @param {string} instruction - Natural language instruction
161
+ * @returns {Promise<object>} Result of the action
162
+ */
163
+ export async function executeInstruction(protocol, instruction) {
164
+ const { Runtime, Page, Input } = protocol;
165
+ const lower = instruction.trim().toLowerCase();
166
+
167
+ // ─── Pattern: "go to URL" / "navigate to URL" / "open URL" ──────────────
168
+ const navMatch = lower.match(/^(?:go to|navigate to|open|visit|take me to)\s+(.+)/);
169
+ if (navMatch) {
170
+ const url = normalizeUrl(navMatch[1]);
171
+ if (url) {
172
+ await Page.navigate({ url });
173
+ await new Promise(r => setTimeout(r, 2000));
174
+ const { result: titleResult } = await Runtime.evaluate({ expression: "document.title" });
175
+ const { result: textResult } = await Runtime.evaluate({ expression: "(document.body?.textContent || '').trim().slice(0, 1000)" });
176
+ return {
177
+ action: "navigate",
178
+ url,
179
+ title: titleResult?.value || "",
180
+ preview: (textResult?.value || "").slice(0, 300),
181
+ };
182
+ }
183
+ // If not a URL, treat as search
184
+ }
185
+
186
+ // ─── Pattern: "go to URL" that's a search fallback ──────────────────────
187
+ // If navMatch but URL was invalid (normalizeUrl returned null), fall through to search
188
+
189
+ // ─── Pattern: "search for X" / "search X" ───────────────────────────────
190
+ const searchMatch = lower.match(/search\s+(?:for\s+)?(.+)/);
191
+ if (searchMatch) {
192
+ const query = searchMatch[1];
193
+ let inputInfo = await findInput(Runtime, "search");
194
+ if (!inputInfo.found || !inputInfo.selector) {
195
+ inputInfo = await findInput(Runtime, "query");
196
+ }
197
+ if (!inputInfo.found || !inputInfo.selector) {
198
+ // Try common search selectors
199
+ const { result } = await Runtime.evaluate({
200
+ expression: `(() => {
201
+ const el = document.querySelector('input[type=search], input[name=q], input[name=search], [role=search] input, form[role=search] input');
202
+ if (el) return JSON.stringify({ found: true, selector: 'input[type=search]' });
203
+ return JSON.stringify({ found: false });
204
+ })()`,
205
+ returnByValue: true,
206
+ });
207
+ inputInfo = JSON.parse(result?.value || "{}");
208
+ }
209
+ if (inputInfo.found && inputInfo.selector) {
210
+ await Runtime.evaluate({
211
+ expression: `document.querySelector(${JSON.stringify(inputInfo.selector)})?.focus()`,
212
+ });
213
+ await Input.insertText({ text: query });
214
+ await new Promise(r => setTimeout(r, 300));
215
+ // Try Enter key
216
+ await Input.dispatchKeyEvent({ type: "keyDown", key: "Enter", windowsVirtualKeyCode: 13 });
217
+ await Input.dispatchKeyEvent({ type: "keyUp", key: "Enter", windowsVirtualKeyCode: 13 });
218
+ await new Promise(r => setTimeout(r, 2000));
219
+
220
+ const { result: titleResult } = await Runtime.evaluate({ expression: "document.title" });
221
+ const { result: textResult } = await Runtime.evaluate({
222
+ expression: "(document.body?.textContent || '').trim().slice(0, 2000)",
223
+ });
224
+ return {
225
+ action: "search",
226
+ query,
227
+ title: titleResult?.value || "",
228
+ preview: (textResult?.value || "").slice(0, 500),
229
+ };
230
+ }
231
+ return { action: "search_error", query, error: "Could not find a search input on this page" };
232
+ }
233
+
234
+ // ─── Pattern: "click X" / "click the X button" / "click on X" ──────────
235
+ const clickMatch = lower.match(/click\s+(?:the\s+|on\s+)?(.+?)(?:\s+button|\s+link|\s+element)?$/);
236
+ if (clickMatch) {
237
+ const target = clickMatch[1];
238
+ // Try buttons and clickable elements first
239
+ let el = await findElementByText(Runtime, target, "button, [role=button], input[type=submit], input[type=button], a");
240
+ if (!el.found) el = await findElementByText(Runtime, target, "a");
241
+ if (!el.found) el = await findElementByText(Runtime, target);
242
+ if (el.found && el.selector) {
243
+ // Get bounding rect via JS for CDP click
244
+ const { result } = await Runtime.evaluate({
245
+ expression: `(() => {
246
+ const el = document.querySelector(${JSON.stringify(el.selector)});
247
+ if (!el) return null;
248
+ const rect = el.getBoundingClientRect();
249
+ return JSON.stringify({ x: Math.round(rect.x + rect.width/2), y: Math.round(rect.y + rect.height/2) });
250
+ })()`,
251
+ });
252
+ const pos = result?.value ? JSON.parse(result.value) : null;
253
+ if (pos) {
254
+ await Input.dispatchMouseEvent({ type: "mousePressed", x: pos.x, y: pos.y, button: "left", clickCount: 1 });
255
+ await Input.dispatchMouseEvent({ type: "mouseReleased", x: pos.x, y: pos.y, button: "left", clickCount: 1 });
256
+ await new Promise(r => setTimeout(r, 1500));
257
+
258
+ const { result: titleResult } = await Runtime.evaluate({ expression: "document.title" });
259
+ return { action: "click", target, clicked: el.text, title: titleResult?.value };
260
+ }
261
+ }
262
+ return { action: "click_error", target, error: `Could not find element matching "${target}"` };
263
+ }
264
+
265
+ // ─── Pattern: "fill X with Y" / "type Y in X" / "enter Y into X" ──────
266
+ const fillMatch = lower.match(/(?:fill|type|enter)\s+(.+?)\s+(?:in|into|with)\s+(.+)/);
267
+ if (fillMatch) {
268
+ const text = fillMatch[1];
269
+ const target = fillMatch[2];
270
+ const inputInfo = await findInput(Runtime, target);
271
+ if (inputInfo.found && inputInfo.selector) {
272
+ await Runtime.evaluate({
273
+ expression: `document.querySelector(${JSON.stringify(inputInfo.selector)})?.focus()`,
274
+ });
275
+ await Input.insertText({ text });
276
+ await new Promise(r => setTimeout(r, 200));
277
+ return { action: "fill", target, text, inputTag: inputInfo.tag };
278
+ }
279
+ return { action: "fill_error", target, error: `Could not find input matching "${target}"` };
280
+ }
281
+
282
+ // ─── Pattern: "get me X" / "extract X" / "find X" / "what is X" ──────
283
+ const extractMatch = lower.match(/^(?:get me|extract|find|what is|show me|tell me about)\s+(.+)/);
284
+ if (extractMatch) {
285
+ const target = extractMatch[1];
286
+ const el = await findElementByText(Runtime, target);
287
+ if (el.found && el.text) {
288
+ return { action: "extract", target, content: el.text };
289
+ }
290
+ // Return page summary
291
+ const { result: titleResult } = await Runtime.evaluate({ expression: "document.title" });
292
+ const { result: bodyResult } = await Runtime.evaluate({
293
+ expression: "JSON.stringify({ text: (document.body?.textContent || '').trim().slice(0, 2000), headings: Array.from(document.querySelectorAll('h1,h2,h3')).map(h => h.textContent.trim()).slice(0, 10) })",
294
+ returnByValue: true,
295
+ });
296
+ const body = JSON.parse(bodyResult?.value || "{}");
297
+ return {
298
+ action: "extract",
299
+ target,
300
+ pageTitle: titleResult?.value || "",
301
+ headings: body.headings || [],
302
+ preview: (body.text || "").slice(0, 500),
303
+ };
304
+ }
305
+
306
+ // ─── Pattern: "scroll down" / "scroll up" ──────────────────────────────
307
+ if (lower.includes("scroll down")) {
308
+ await Runtime.evaluate({ expression: "window.scrollBy(0, window.innerHeight)" });
309
+ return { action: "scroll", direction: "down" };
310
+ }
311
+ if (lower.includes("scroll up")) {
312
+ await Runtime.evaluate({ expression: "window.scrollBy(0, -window.innerHeight)" });
313
+ return { action: "scroll", direction: "up" };
314
+ }
315
+
316
+ // ─── Fallback: return page info + help ─────────────────────────────────
317
+ const { result: titleResult } = await Runtime.evaluate({ expression: "document.title" });
318
+ const { result: urlResult } = await Runtime.evaluate({ expression: "location.href" });
319
+ return {
320
+ action: "unknown",
321
+ instruction,
322
+ pageTitle: titleResult?.value || "",
323
+ url: urlResult?.value || "",
324
+ tip: "Try: 'search for laptops', 'click the login button', 'go to google.com', 'fill email with test@example.com', 'extract prices', 'scroll down'",
325
+ };
326
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * bwb-browser — Browser lifecycle management
3
+ *
4
+ * Handles Chrome/Chromium process spawning, CDP connection, browser detection,
5
+ * restart, orphan cleanup, and screenshot persistence.
6
+ */
7
+
8
+ import { spawn, execSync } from "child_process";
9
+ import { homedir, platform } from "os";
10
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
11
+ import { join } from "path";
12
+ import CDP from "chrome-remote-interface";
13
+
14
+ // ─── Shared State ──────────────────────────────────────────────────────────────
15
+
16
+ /** @type {import("child_process").ChildProcess|null} */
17
+ export let browser = null;
18
+ /** @type {import("chrome-remote-interface").Protocol|null} */
19
+ export let protocol = null;
20
+ /** @type {Promise<import("chrome-remote-interface").Protocol>|null} */
21
+ let browserStartup = null;
22
+ export let browserExited = false;
23
+ export let actualCdpPort = null;
24
+ export let cleaningUp = false;
25
+
26
+ /** Runtime config — set by server.mjs after parseArgs */
27
+ export const cfg = {
28
+ port: 0,
29
+ headless: true,
30
+ userDataDir: "",
31
+ screenshotsDir: "",
32
+ navTimeout: 30000,
33
+ browserPath: null,
34
+ };
35
+
36
+ // ─── Kill Orphaned Chrome (Termux-safe) ────────────────────────────────────────
37
+
38
+ // `fuser -k` and `lsof` can't read /proc/net/tcp on Termux/Android (permission denied).
39
+ // Instead, kill by PID from `ps` — works on every platform.
40
+ // Only kills bwb's headless Chrome instances (marked by `remote-debugging-port` flag),
41
+ // NOT the user's normal Chrome browser.
42
+ export function killOrphanedChrome() {
43
+ try {
44
+ execSync(
45
+ `ps aux | grep "remote-debugging-port" | grep -v grep | awk '{print $2}' | xargs -r kill -9 2>/dev/null; true`,
46
+ { encoding: "utf8", timeout: 5000 }
47
+ );
48
+ } catch {}
49
+ }
50
+
51
+ // ─── Browser Detection ────────────────────────────────────────────────────────
52
+
53
+ export function findBrowserPath(cliPath) {
54
+ if (cliPath) return cliPath;
55
+ const envPath = process.env.BWB_CHROME_PATH;
56
+ if (envPath) return envPath;
57
+
58
+ const os = platform();
59
+ const home = homedir();
60
+
61
+ const candidates = {
62
+ android: [
63
+ "/data/data/com.termux/files/usr/bin/chromium-browser",
64
+ "/data/data/com.termux/files/usr/bin/chromium",
65
+ "/data/data/com.termux/files/usr/bin/google-chrome",
66
+ ],
67
+ linux: [
68
+ "google-chrome",
69
+ "chromium-browser",
70
+ "chromium",
71
+ "google-chrome-stable",
72
+ "/usr/bin/google-chrome",
73
+ "/usr/bin/chromium-browser",
74
+ "/snap/bin/chromium",
75
+ ],
76
+ darwin: [
77
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
78
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
79
+ ],
80
+ win32: [
81
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
82
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
83
+ join(home, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"),
84
+ ],
85
+ };
86
+
87
+ const osCandidates = candidates[os] || candidates.linux;
88
+ for (const bin of osCandidates) {
89
+ if (bin.startsWith("/") || bin.startsWith("C:\\") || bin.startsWith("\\")) {
90
+ if (existsSync(bin)) return bin;
91
+ continue;
92
+ }
93
+ try {
94
+ const path = execSync(`which "${bin}" 2>/dev/null || echo "no"`, { encoding: "utf8", timeout: 3000 }).trim();
95
+ if (path && path !== "no") return path;
96
+ } catch { /* try next */ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function assertBrowserExists(cliPath) {
102
+ const path = findBrowserPath(cliPath);
103
+ if (!path) {
104
+ throw new Error(
105
+ "Cannot find Chrome/Chromium. Set BWB_CHROME_PATH env var or pass --browser-path.\n" +
106
+ "Install on Termux: pkg install chromium\n" +
107
+ "Install on Linux: apt install chromium-browser\n" +
108
+ "Install on macOS: brew install --cask google-chrome\n" +
109
+ "Install on Windows: Download from https://www.google.com/chrome/"
110
+ );
111
+ }
112
+ return path;
113
+ }
114
+
115
+ // ─── Browser Lifecycle ────────────────────────────────────────────────────────
116
+
117
+ export async function ensureBrowser() {
118
+ if (protocol && !browserExited) return protocol;
119
+ if (browserStartup) return browserStartup;
120
+
121
+ if (protocol) {
122
+ try { await protocol.close(); } catch {}
123
+ protocol = null;
124
+ }
125
+ browserExited = false;
126
+ actualCdpPort = null;
127
+
128
+ let startResolve, startReject;
129
+ browserStartup = new Promise((res, rej) => { startResolve = res; startReject = rej; });
130
+ browserStartup.catch(() => { browserStartup = null; });
131
+
132
+ (async () => {
133
+ try {
134
+ const browserPath = assertBrowserExists(cfg.browserPath);
135
+ killOrphanedChrome();
136
+ await new Promise(r => setTimeout(r, 500));
137
+
138
+ const debugPort = cfg.port || 0;
139
+ const args = [
140
+ "--headless",
141
+ "--no-sandbox",
142
+ "--disable-gpu",
143
+ "--disable-dev-shm-usage",
144
+ "--disable-setuid-sandbox",
145
+ "--disable-software-rasterizer",
146
+ "--remote-debugging-port=" + debugPort,
147
+ "--user-data-dir=" + cfg.userDataDir,
148
+ ];
149
+
150
+ if (!cfg.headless) args.shift();
151
+
152
+ browser = spawn(browserPath, args, {
153
+ stdio: ["ignore", "pipe", "pipe"],
154
+ env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
155
+ });
156
+
157
+ let resolved = false;
158
+
159
+ browser.on("exit", (code, signal) => {
160
+ browserExited = true;
161
+ if (!resolved) {
162
+ clearTimeout(startTimeout);
163
+ startReject(new Error(`Browser exited with code ${code} (signal ${signal}) before CDP connected`));
164
+ }
165
+ });
166
+
167
+ browser.on("error", (err) => {
168
+ if (!resolved) {
169
+ clearTimeout(startTimeout);
170
+ startReject(new Error(`Browser spawn failed: ${err.message}`));
171
+ }
172
+ });
173
+
174
+ const startTimeout = setTimeout(() => {
175
+ if (!resolved) {
176
+ browserExited = true;
177
+ try { browser.kill("SIGKILL"); } catch {}
178
+ startReject(new Error(`Browser startup timed out after 15s. Check: ${browserPath}`));
179
+ }
180
+ }, 15000);
181
+
182
+ browser.stderr.on("data", (data) => {
183
+ const msg = data.toString();
184
+ const portMatch = msg.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
185
+ if (portMatch) {
186
+ actualCdpPort = parseInt(portMatch[1], 10);
187
+ clearTimeout(startTimeout);
188
+ resolved = true;
189
+ CDP({ port: actualCdpPort })
190
+ .then((cdp) => {
191
+ protocol = cdp;
192
+ startResolve(cdp);
193
+ })
194
+ .catch((err) => {
195
+ try { browser.kill("SIGKILL"); } catch {}
196
+ browserExited = true;
197
+ startReject(new Error(`CDP connection failed: ${err.message}`));
198
+ });
199
+ }
200
+ });
201
+ } catch (err) {
202
+ browserStartup = null;
203
+ browserExited = true;
204
+ startReject(err);
205
+ }
206
+ })();
207
+
208
+ return browserStartup;
209
+ }
210
+
211
+ // ─── Restart ──────────────────────────────────────────────────────────────────
212
+
213
+ export async function restartBrowser() {
214
+ if (cleaningUp) return;
215
+ cleaningUp = true;
216
+
217
+ try {
218
+ if (protocol) {
219
+ try { await protocol.close(); } catch {}
220
+ protocol = null;
221
+ }
222
+ if (browser) {
223
+ browser.kill("SIGTERM");
224
+ await new Promise(r => setTimeout(r, 2000));
225
+ try { browser.kill("SIGKILL"); } catch {}
226
+ browser = null;
227
+ }
228
+ } catch {}
229
+
230
+ browserExited = false;
231
+ actualCdpPort = null;
232
+ browserStartup = null;
233
+ cleaningUp = false;
234
+
235
+ // Next ensureBrowser call will start fresh
236
+ return { status: "restarted" };
237
+ }
238
+
239
+ // ─── Screenshot Helper ────────────────────────────────────────────────────────
240
+
241
+ export function saveScreenshot(base64Data) {
242
+ const now = new Date();
243
+ const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
244
+ const filename = `bwb-${timestamp}.jpeg`;
245
+ const filepath = join(cfg.screenshotsDir, filename);
246
+ try {
247
+ mkdirSync(cfg.screenshotsDir, { recursive: true });
248
+ writeFileSync(filepath, Buffer.from(base64Data, "base64"));
249
+ return filepath;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ // ─── Cleanup ──────────────────────────────────────────────────────────────────
256
+
257
+ function cleanupSync() {
258
+ if (cleaningUp) return;
259
+ cleaningUp = true;
260
+ try {
261
+ if (browser) {
262
+ browser.kill("SIGTERM");
263
+ setTimeout(() => {
264
+ try { browser?.kill("SIGKILL"); } catch {}
265
+ }, 3000);
266
+ browser = null;
267
+ }
268
+ } catch {}
269
+ }
270
+
271
+ async function cleanupAsync() {
272
+ if (cleaningUp) return;
273
+ cleaningUp = true;
274
+ try {
275
+ if (protocol) await protocol.close();
276
+ } catch {}
277
+ try {
278
+ if (browser) {
279
+ browser.kill("SIGTERM");
280
+ await new Promise(r => setTimeout(r, 2000));
281
+ try { browser?.kill("SIGKILL"); } catch {}
282
+ browser = null;
283
+ }
284
+ } catch {}
285
+ }
286
+
287
+ process.on("exit", cleanupSync);
288
+ process.on("SIGINT", () => { cleanupSync(); process.exit(0); });
289
+ process.on("SIGTERM", () => { cleanupSync(); process.exit(0); });
290
+ process.on("SIGHUP", () => { cleanupSync(); process.exit(0); });