@lovrozagar/grunt 0.3.10 → 0.4.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.
Files changed (50) hide show
  1. package/.agents/agents/grunt.md +1 -0
  2. package/.agents/agents/implementer.md +3 -2
  3. package/.agents/agents/orchestrator.md +4 -3
  4. package/.agents/skills/browser/SKILL.md +17 -0
  5. package/.agents/skills/handoff/SKILL.md +2 -2
  6. package/.agents/skills/pickup/SKILL.md +64 -0
  7. package/.agents/skills/solo/SKILL.md +1 -1
  8. package/.claude/agents/grunt.md +1 -0
  9. package/.claude/agents/implementer.md +3 -2
  10. package/.claude/agents/orchestrator.md +4 -3
  11. package/.claude/skills/browser/SKILL.md +17 -0
  12. package/.claude/skills/handoff/SKILL.md +2 -2
  13. package/.claude/skills/pickup/SKILL.md +64 -0
  14. package/.claude/skills/solo/SKILL.md +1 -1
  15. package/.codex/agents/grunt.toml +1 -0
  16. package/.codex/agents/implementer.toml +3 -2
  17. package/.codex/agents/orchestrator.toml +4 -3
  18. package/.grok/agents/grunt.md +1 -0
  19. package/.grok/agents/implementer.md +3 -2
  20. package/.grok/agents/orchestrator.md +4 -3
  21. package/.grok/hooks/orchestrate-parent.js +4 -3
  22. package/.grok/skills/browser/SKILL.md +17 -0
  23. package/.grok/skills/handoff/SKILL.md +2 -2
  24. package/.grok/skills/pickup/SKILL.md +64 -0
  25. package/.grok/skills/solo/SKILL.md +1 -1
  26. package/.rulesync/reference/browser.md +59 -0
  27. package/.rulesync/reference/cascade.md +2 -1
  28. package/.rulesync/reference/hooks.md +1 -1
  29. package/.rulesync/reference/map.md +6 -3
  30. package/.rulesync/reference/rtk.md +4 -1
  31. package/.rulesync/rules/CLAUDE.md +3 -2
  32. package/.rulesync/rules/overview.md +3 -2
  33. package/.rulesync/skills/browser/SKILL.md +19 -0
  34. package/.rulesync/skills/handoff/SKILL.md +2 -2
  35. package/.rulesync/skills/pickup/SKILL.md +64 -0
  36. package/.rulesync/skills/solo/SKILL.md +1 -1
  37. package/.rulesync/subagents/grunt.md +1 -0
  38. package/.rulesync/subagents/implementer.md +3 -2
  39. package/.rulesync/subagents/orchestrator.md +4 -3
  40. package/AGENTS.md +3 -2
  41. package/CLAUDE.md +3 -2
  42. package/README.md +35 -5
  43. package/cli/grunt.mjs +2 -2
  44. package/cli/grunt.test.ts +3 -3
  45. package/cli/init.mjs +2 -0
  46. package/cli/init.test.ts +2 -0
  47. package/package.json +5 -2
  48. package/scripts/browser.mjs +764 -0
  49. package/scripts/doctor.mjs +252 -0
  50. package/scripts/gate-fat-tools.mjs +19 -4
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ /** Session browser rail: nav|snap|click|fill|shot|pdf|stop|doctor|ensure. Lightpanda default. */
3
+ import { spawn } from "node:child_process";
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import net from "node:net";
8
+ import path from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import {
11
+ CHROMIUM_BINS,
12
+ LIGHTPANDA_REPO as DOCTOR_LP_REPO,
13
+ installHints,
14
+ runDoctor,
15
+ whichBin,
16
+ } from "./doctor.mjs";
17
+
18
+ export { CHROMIUM_BINS, whichBin };
19
+ export const SESSION_REL = ".tmp/grunt/browser";
20
+ export const LIGHTPANDA_REPO = DOCTOR_LP_REPO;
21
+ const INSTALL_HINT =
22
+ `no browser engine; install lightpanda (${LIGHTPANDA_REPO}) or Chromium`;
23
+ const CHROMIUM_VERBS = new Set(["shot", "pdf", "trace"]);
24
+ const REF_ROLES = new Set([
25
+ "link",
26
+ "button",
27
+ "textbox",
28
+ "searchbox",
29
+ "combobox",
30
+ "checkbox",
31
+ "radio",
32
+ "tab",
33
+ "menuitem",
34
+ "option",
35
+ ]);
36
+ const PNG_B64 =
37
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
38
+ const PDF_B64 =
39
+ "JVBERi0xLjAKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3hbMCAwIDMgM10vUGFyZW50IDIgMCBSL1Jlc291cmNlczw+Pj4KZW5kb2JqCnhyZWYKMCA0CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDQvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoxOTAKJSVFT0Y=";
40
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
41
+
42
+ export function sessionDir(cwd) {
43
+ return path.join(cwd || process.cwd(), SESSION_REL);
44
+ }
45
+
46
+ export function sessionFile(cwd) {
47
+ return path.join(sessionDir(cwd), "session.json");
48
+ }
49
+
50
+ export function isPaintHost(url) {
51
+ let u;
52
+ try {
53
+ u = new URL(String(url || ""));
54
+ } catch {
55
+ return false;
56
+ }
57
+ const h = u.hostname.toLowerCase();
58
+ if (h === "figma.com" || h.endsWith(".figma.com")) return true;
59
+ if (h === "docs.google.com" || h.endsWith(".docs.google.com")) return true;
60
+ if (h === "sheets.google.com" || h.endsWith(".sheets.google.com")) return true;
61
+ if (h === "slides.google.com" || h.endsWith(".slides.google.com")) return true;
62
+ if (h === "mail.google.com" || h.endsWith(".mail.google.com")) return true;
63
+ if (h === "earth.google.com" || h.endsWith(".earth.google.com")) return true;
64
+ if ((h === "google.com" || h.endsWith(".google.com")) && u.pathname.startsWith("/earth")) {
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function lookupBins(pathEnv, platform = process.platform) {
71
+ const lightpanda = whichBin("lightpanda", pathEnv, platform);
72
+ let chromium = "";
73
+ for (const n of CHROMIUM_BINS) {
74
+ chromium = whichBin(n, pathEnv, platform);
75
+ if (chromium) break;
76
+ }
77
+ return { lightpanda, chromium };
78
+ }
79
+
80
+ export function doctorHints(platform = process.platform) {
81
+ return [...installHints("lightpanda", platform), ...installHints("chromium", platform)];
82
+ }
83
+
84
+ export function installHint(platform = process.platform) {
85
+ return [INSTALL_HINT, ...doctorHints(platform)].join("\n");
86
+ }
87
+
88
+ export function pickEngine({
89
+ platform = process.platform,
90
+ verb = "nav",
91
+ url = "",
92
+ bins = {},
93
+ forceChromium = false,
94
+ } = {}) {
95
+ const hasC = Boolean(bins.chromium);
96
+ const hasL = Boolean(bins.lightpanda);
97
+ const wantC =
98
+ forceChromium ||
99
+ platform === "win32" ||
100
+ CHROMIUM_VERBS.has(String(verb)) ||
101
+ isPaintHost(url);
102
+ if (wantC) {
103
+ if (!hasC) return { engine: null, error: installHint(platform) };
104
+ return { engine: "chromium" };
105
+ }
106
+ if (hasL) return { engine: "lightpanda" };
107
+ if (hasC) return { engine: "chromium" };
108
+ return { engine: null, error: installHint(platform) };
109
+ }
110
+
111
+ function sleep(ms) {
112
+ return new Promise((r) => setTimeout(r, ms));
113
+ }
114
+
115
+ function alive(pid) {
116
+ if (!pid) return false;
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ function reap(pid) {
126
+ if (!pid) return;
127
+ try {
128
+ process.kill(-pid, "SIGTERM");
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ try {
133
+ process.kill(pid, "SIGTERM");
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+
139
+ function readSession(cwd) {
140
+ const f = sessionFile(cwd);
141
+ if (!fs.existsSync(f)) return null;
142
+ try {
143
+ return JSON.parse(fs.readFileSync(f, "utf8"));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function writeSession(cwd, session) {
150
+ const dir = sessionDir(cwd);
151
+ fs.mkdirSync(dir, { recursive: true });
152
+ fs.writeFileSync(sessionFile(cwd), JSON.stringify(session, null, 2) + "\n");
153
+ }
154
+
155
+ function clearSession(cwd) {
156
+ try {
157
+ fs.unlinkSync(sessionFile(cwd));
158
+ } catch {
159
+ /* ignore */
160
+ }
161
+ }
162
+
163
+ function ok(stdout) {
164
+ return { code: 0, stdout: stdout.endsWith("\n") ? stdout : stdout + "\n", stderr: "" };
165
+ }
166
+
167
+ function fail(stderr) {
168
+ const msg = String(stderr || "fail").replace(/^Error:\s*/, "");
169
+ return { code: 1, stdout: "", stderr: msg.endsWith("\n") ? msg : msg + "\n" };
170
+ }
171
+
172
+ async function freePort() {
173
+ return new Promise((resolve, reject) => {
174
+ const s = net.createServer();
175
+ s.listen(0, "127.0.0.1", () => {
176
+ const addr = s.address();
177
+ const p = addr && addr.port;
178
+ s.close(() => resolve(p));
179
+ });
180
+ s.on("error", reject);
181
+ });
182
+ }
183
+
184
+ async function waitVersion(port, timeoutMs = 4000) {
185
+ const t0 = Date.now();
186
+ while (Date.now() - t0 < timeoutMs) {
187
+ try {
188
+ const r = await fetch(`http://127.0.0.1:${port}/json/version`);
189
+ if (r.ok) return await r.json();
190
+ } catch {
191
+ /* retry */
192
+ }
193
+ await sleep(40);
194
+ }
195
+ throw new Error("engine not ready");
196
+ }
197
+
198
+ export async function connectCdp(port) {
199
+ const version = await waitVersion(port);
200
+ const wsUrl = version.webSocketDebuggerUrl;
201
+ if (!wsUrl) throw new Error("engine not ready");
202
+ const ws = new WebSocket(wsUrl);
203
+ await new Promise((resolve, reject) => {
204
+ const t = setTimeout(() => reject(new Error("engine not ready")), 4000);
205
+ ws.addEventListener("open", () => {
206
+ clearTimeout(t);
207
+ resolve();
208
+ });
209
+ ws.addEventListener("error", () => {
210
+ clearTimeout(t);
211
+ reject(new Error("engine not ready"));
212
+ });
213
+ });
214
+ let nextId = 1;
215
+ const pending = new Map();
216
+ ws.addEventListener("message", (ev) => {
217
+ let msg;
218
+ try {
219
+ msg = JSON.parse(String(ev.data));
220
+ } catch {
221
+ return;
222
+ }
223
+ if (msg.id == null || !pending.has(msg.id)) return;
224
+ const p = pending.get(msg.id);
225
+ pending.delete(msg.id);
226
+ if (msg.error) p.reject(new Error(msg.error.message || "cdp"));
227
+ else p.resolve(msg.result || {});
228
+ });
229
+ return {
230
+ send(method, params = {}) {
231
+ const id = nextId++;
232
+ return new Promise((resolve, reject) => {
233
+ pending.set(id, { resolve, reject });
234
+ ws.send(JSON.stringify({ id, method, params }));
235
+ });
236
+ },
237
+ close() {
238
+ try {
239
+ ws.close();
240
+ } catch {
241
+ /* ignore */
242
+ }
243
+ },
244
+ };
245
+ }
246
+
247
+ function defaultAx(url) {
248
+ return [
249
+ {
250
+ nodeId: "1",
251
+ role: { value: "WebArea" },
252
+ name: { value: url || "page" },
253
+ childIds: ["2", "3"],
254
+ },
255
+ {
256
+ nodeId: "2",
257
+ role: { value: "link" },
258
+ name: { value: "Home" },
259
+ backendDOMNodeId: 10,
260
+ childIds: [],
261
+ },
262
+ {
263
+ nodeId: "3",
264
+ role: { value: "textbox" },
265
+ name: { value: "Search" },
266
+ backendDOMNodeId: 11,
267
+ childIds: [],
268
+ },
269
+ ];
270
+ }
271
+
272
+ function encodeWsFrame(data) {
273
+ const payload = Buffer.from(data);
274
+ const len = payload.length;
275
+ let header;
276
+ if (len < 126) {
277
+ header = Buffer.alloc(2);
278
+ header[0] = 0x81;
279
+ header[1] = len;
280
+ } else if (len < 65536) {
281
+ header = Buffer.alloc(4);
282
+ header[0] = 0x81;
283
+ header[1] = 126;
284
+ header.writeUInt16BE(len, 2);
285
+ } else {
286
+ header = Buffer.alloc(10);
287
+ header[0] = 0x81;
288
+ header[1] = 127;
289
+ header.writeBigUInt64BE(BigInt(len), 2);
290
+ }
291
+ return Buffer.concat([header, payload]);
292
+ }
293
+
294
+ function decodeWsFrames(buf) {
295
+ const messages = [];
296
+ let offset = 0;
297
+ while (buf.length - offset >= 2) {
298
+ const b1 = buf[offset + 1];
299
+ const masked = (b1 & 0x80) !== 0;
300
+ let len = b1 & 0x7f;
301
+ let start = offset + 2;
302
+ if (len === 126) {
303
+ if (buf.length - offset < 4) break;
304
+ len = buf.readUInt16BE(offset + 2);
305
+ start = offset + 4;
306
+ } else if (len === 127) {
307
+ if (buf.length - offset < 10) break;
308
+ len = Number(buf.readBigUInt64BE(offset + 2));
309
+ start = offset + 10;
310
+ }
311
+ const maskStart = start;
312
+ if (masked) start += 4;
313
+ if (buf.length < start + len) break;
314
+ let payload = buf.subarray(start, start + len);
315
+ if (masked) {
316
+ const mask = buf.subarray(maskStart, maskStart + 4);
317
+ payload = Buffer.from(payload);
318
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
319
+ }
320
+ const opcode = buf[offset] & 0x0f;
321
+ offset = start + len;
322
+ if (opcode === 0x8) messages.push({ type: "close" });
323
+ else if (opcode === 0x1 || opcode === 0x2) {
324
+ messages.push({ type: "msg", data: payload.toString("utf8") });
325
+ }
326
+ }
327
+ return { messages, rest: buf.subarray(offset) };
328
+ }
329
+
330
+ async function handleCdp(state, method, params = {}) {
331
+ if (state.failProbe && (method === "Browser.getVersion" || method === "Target.getTargets")) {
332
+ throw new Error("probe failed");
333
+ }
334
+ switch (method) {
335
+ case "Browser.getVersion":
336
+ return { protocolVersion: "1.3", product: state.engine || "lightpanda" };
337
+ case "Target.getTargets":
338
+ return {
339
+ targetInfos: [{ targetId: "t1", type: "page", url: state.url || "about:blank", attached: true }],
340
+ };
341
+ case "Page.enable":
342
+ case "Accessibility.enable":
343
+ case "Runtime.enable":
344
+ case "DOM.enable":
345
+ return {};
346
+ case "Page.navigate":
347
+ state.url = params.url || state.url;
348
+ state.markdown = `# ${state.url}\n\nhello`;
349
+ return { frameId: "f1" };
350
+ case "LP.getMarkdown":
351
+ return { markdown: state.markdown || `# ${state.url || ""}\n\nhello` };
352
+ case "Accessibility.getFullAXTree":
353
+ return { nodes: state.axNodes || defaultAx(state.url) };
354
+ case "DOM.resolveNode": {
355
+ const id = params.backendNodeId;
356
+ const known = new Set([10, 11]);
357
+ if (!known.has(id)) throw new Error("stale");
358
+ return { object: { objectId: "oid-" + id } };
359
+ }
360
+ case "Runtime.callFunctionOn":
361
+ return { result: { type: "undefined" } };
362
+ case "Input.insertText":
363
+ return {};
364
+ case "Page.captureScreenshot":
365
+ return { data: PNG_B64 };
366
+ case "Page.printToPDF":
367
+ return { data: PDF_B64 };
368
+ case "Runtime.evaluate":
369
+ return { result: { type: "string", value: state.markdown || "" } };
370
+ default:
371
+ return {};
372
+ }
373
+ }
374
+
375
+ /** Undocumented test helper: fake Lightpanda/Chromium CDP. */
376
+ export async function serveCdp(state = {}, { port = 0 } = {}) {
377
+ const st = {
378
+ url: "about:blank",
379
+ markdown: "",
380
+ failProbe: false,
381
+ engine: "lightpanda",
382
+ ...state,
383
+ };
384
+ const server = http.createServer((req, res) => {
385
+ if (req.url && req.url.startsWith("/json/version")) {
386
+ const addr = server.address();
387
+ const p = addr && addr.port;
388
+ res.setHeader("content-type", "application/json");
389
+ res.end(
390
+ JSON.stringify({
391
+ Browser: st.engine || "Lightpanda",
392
+ "Protocol-Version": "1.3",
393
+ webSocketDebuggerUrl: `ws://127.0.0.1:${p}/devtools/browser/grunt`,
394
+ }),
395
+ );
396
+ return;
397
+ }
398
+ res.statusCode = 404;
399
+ res.end();
400
+ });
401
+ server.on("upgrade", (req, socket) => {
402
+ const key = req.headers["sec-websocket-key"];
403
+ if (!key) {
404
+ socket.destroy();
405
+ return;
406
+ }
407
+ const accept = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
408
+ socket.write(
409
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " +
410
+ accept +
411
+ "\r\n\r\n",
412
+ );
413
+ let buf = Buffer.alloc(0);
414
+ socket.on("data", (chunk) => {
415
+ buf = Buffer.concat([buf, chunk]);
416
+ const decoded = decodeWsFrames(buf);
417
+ buf = Buffer.from(decoded.rest);
418
+ for (const m of decoded.messages) {
419
+ if (m.type === "close") {
420
+ socket.end();
421
+ return;
422
+ }
423
+ if (m.type !== "msg") continue;
424
+ let msg;
425
+ try {
426
+ msg = JSON.parse(m.data);
427
+ } catch {
428
+ continue;
429
+ }
430
+ Promise.resolve()
431
+ .then(() => handleCdp(st, msg.method, msg.params || {}))
432
+ .then((result) => {
433
+ socket.write(encodeWsFrame(JSON.stringify({ id: msg.id, result })));
434
+ })
435
+ .catch((err) => {
436
+ socket.write(
437
+ encodeWsFrame(
438
+ JSON.stringify({
439
+ id: msg.id,
440
+ error: { code: -32000, message: String(err && err.message ? err.message : err) },
441
+ }),
442
+ ),
443
+ );
444
+ });
445
+ }
446
+ });
447
+ });
448
+ await new Promise((resolve, reject) => {
449
+ server.once("error", reject);
450
+ server.listen(port, "127.0.0.1", resolve);
451
+ });
452
+ const addr = server.address();
453
+ return {
454
+ port: addr.port,
455
+ close() {
456
+ server.close();
457
+ },
458
+ state: st,
459
+ };
460
+ }
461
+
462
+ function engineBin(engine, bins) {
463
+ if (engine === "lightpanda") return bins.lightpanda;
464
+ return bins.chromium;
465
+ }
466
+
467
+ function spawnArgs(engine, port, profile) {
468
+ if (engine === "lightpanda") return ["serve", "--host", "127.0.0.1", "--port", String(port)];
469
+ return [
470
+ "--headless=new",
471
+ "--disable-gpu",
472
+ "--remote-debugging-port",
473
+ String(port),
474
+ "--user-data-dir",
475
+ profile,
476
+ "about:blank",
477
+ ];
478
+ }
479
+
480
+ async function launchEngine(engine, ctx) {
481
+ const bin = engineBin(engine, ctx.bins);
482
+ if (!bin) throw new Error(INSTALL_HINT);
483
+ const dir = sessionDir(ctx.cwd);
484
+ fs.mkdirSync(dir, { recursive: true });
485
+ const profile = path.join(dir, "profile");
486
+ fs.mkdirSync(profile, { recursive: true });
487
+ const port = await freePort();
488
+ const child = spawn(bin, spawnArgs(engine, port, profile), {
489
+ detached: true,
490
+ stdio: ["ignore", "pipe", "pipe"],
491
+ env: ctx.env,
492
+ cwd: ctx.cwd,
493
+ });
494
+ child.unref();
495
+ try {
496
+ await waitVersion(port);
497
+ } catch (err) {
498
+ reap(child.pid);
499
+ throw err;
500
+ }
501
+ return {
502
+ engine,
503
+ pid: child.pid,
504
+ port,
505
+ lastURL: "",
506
+ lastRefs: {},
507
+ escalated: false,
508
+ swapCount: 0,
509
+ };
510
+ }
511
+
512
+ async function probe(session) {
513
+ const cdp = await connectCdp(session.port);
514
+ try {
515
+ await cdp.send("Browser.getVersion");
516
+ } finally {
517
+ cdp.close();
518
+ }
519
+ }
520
+
521
+ async function navigate(session, url) {
522
+ const cdp = await connectCdp(session.port);
523
+ try {
524
+ await cdp.send("Page.enable").catch(() => {});
525
+ await cdp.send("Page.navigate", { url });
526
+ session.lastURL = url;
527
+ } finally {
528
+ cdp.close();
529
+ }
530
+ }
531
+
532
+ function formatRefs(nodes) {
533
+ const lastRefs = {};
534
+ const lines = [];
535
+ let n = 1;
536
+ for (const node of nodes || []) {
537
+ const role = String((node.role && node.role.value) || node.role || "").toLowerCase();
538
+ if (!REF_ROLES.has(role)) continue;
539
+ const name = String((node.name && node.name.value) || node.name || "");
540
+ const id = String(n++);
541
+ lastRefs[id] = {
542
+ role,
543
+ name,
544
+ backendNodeId: node.backendDOMNodeId ?? node.backendNodeId,
545
+ nodeId: node.nodeId,
546
+ };
547
+ lines.push(`[${id}] ${role} ${name}`.trim());
548
+ }
549
+ return { lastRefs, lines };
550
+ }
551
+
552
+ async function snapshot(session) {
553
+ const cdp = await connectCdp(session.port);
554
+ try {
555
+ let markdown = "";
556
+ if (session.engine === "lightpanda") {
557
+ try {
558
+ const r = await cdp.send("LP.getMarkdown");
559
+ markdown = r.markdown || r.result || "";
560
+ } catch {
561
+ markdown = "";
562
+ }
563
+ }
564
+ if (!markdown) {
565
+ markdown = `# ${session.lastURL || ""}\n`;
566
+ }
567
+ await cdp.send("Accessibility.enable").catch(() => {});
568
+ const ax = await cdp.send("Accessibility.getFullAXTree");
569
+ const { lastRefs, lines } = formatRefs(ax.nodes || []);
570
+ session.lastRefs = lastRefs;
571
+ return { markdown, lines };
572
+ } finally {
573
+ cdp.close();
574
+ }
575
+ }
576
+
577
+ async function swapToChromium(session, ctx) {
578
+ if (session.engine === "chromium") return session;
579
+ if ((session.swapCount || 0) >= 1 && session.engine === "chromium") return session;
580
+ const url = session.lastURL;
581
+ reap(session.pid);
582
+ const next = await launchEngine("chromium", ctx);
583
+ next.lastURL = url;
584
+ next.lastRefs = session.lastRefs || {};
585
+ next.escalated = true;
586
+ next.swapCount = (session.swapCount || 0) + 1;
587
+ if (url) await navigate(next, url);
588
+ return next;
589
+ }
590
+
591
+ async function withCdp(session, fn) {
592
+ const cdp = await connectCdp(session.port);
593
+ try {
594
+ return await fn(cdp);
595
+ } finally {
596
+ cdp.close();
597
+ }
598
+ }
599
+
600
+ export async function runBrowser(argv, opts = {}) {
601
+ const cwd = opts.cwd || process.cwd();
602
+ const env = opts.env || process.env;
603
+ const platform = opts.platform || process.platform;
604
+ const bins = lookupBins(env.PATH, platform);
605
+ const ctx = { cwd, env, platform, bins };
606
+ const verb = String(argv[0] || "").toLowerCase();
607
+ try {
608
+ if (!verb || verb === "help" || verb === "-h" || verb === "--help") {
609
+ return ok("nav|snap|click|fill|shot|pdf|stop|doctor|ensure");
610
+ }
611
+ if (verb === "doctor" || verb === "ensure") {
612
+ return runDoctor({ pathEnv: env.PATH, platform, cwd });
613
+ }
614
+ if (verb === "stop") {
615
+ const s = readSession(cwd);
616
+ if (s && s.pid) reap(s.pid);
617
+ clearSession(cwd);
618
+ await sleep(30);
619
+ return ok("stopped");
620
+ }
621
+ if (verb === "nav") {
622
+ const url = String(argv[1] || "").trim();
623
+ if (!url) return fail("nav <url>");
624
+ const picked = pickEngine({ platform, verb: "nav", url, bins });
625
+ if (!picked.engine) return fail(picked.error || INSTALL_HINT);
626
+ const prev = readSession(cwd);
627
+ if (prev && prev.pid) reap(prev.pid);
628
+ let session;
629
+ try {
630
+ session = await launchEngine(picked.engine, ctx);
631
+ } catch {
632
+ return fail(INSTALL_HINT);
633
+ }
634
+ if (picked.engine === "lightpanda") {
635
+ try {
636
+ await probe(session);
637
+ } catch {
638
+ if (!bins.chromium) {
639
+ reap(session.pid);
640
+ return fail(INSTALL_HINT);
641
+ }
642
+ reap(session.pid);
643
+ session = await launchEngine("chromium", ctx);
644
+ session.escalated = true;
645
+ session.swapCount = 1;
646
+ }
647
+ }
648
+ await navigate(session, url);
649
+ writeSession(cwd, session);
650
+ return ok(`engine: ${session.engine}\nurl: ${session.lastURL}`);
651
+ }
652
+ if (verb === "snap") {
653
+ const session = readSession(cwd);
654
+ if (!session || !alive(session.pid)) return fail("no browser session; run nav");
655
+ const { markdown, lines } = await snapshot(session);
656
+ writeSession(cwd, session);
657
+ const body = [markdown.trim(), "", ...lines].filter((x, i, a) => x !== "" || a[i - 1] !== "").join("\n");
658
+ return ok(`engine: ${session.engine}\nurl: ${session.lastURL}\n\n${body}`);
659
+ }
660
+ if (verb === "click" || verb === "fill") {
661
+ const session = readSession(cwd);
662
+ if (!session || !alive(session.pid)) return fail("no browser session; run nav");
663
+ const refs = session.lastRefs || {};
664
+ if (!Object.keys(refs).length) return fail("no snap refs; run snap");
665
+ const refId = String(argv[1] || "");
666
+ const rec = refs[refId];
667
+ if (!rec) return fail(`missing ref: ${refId}`);
668
+ const text = argv.slice(2).join(" ");
669
+ if (verb === "fill" && !text) return fail("fill <ref> <text>");
670
+ const act = async (sess) =>
671
+ withCdp(sess, async (cdp) => {
672
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: rec.backendNodeId });
673
+ const objectId = resolved.object && resolved.object.objectId;
674
+ if (!objectId) throw new Error("stale");
675
+ if (verb === "click") {
676
+ await cdp.send("Runtime.callFunctionOn", {
677
+ objectId,
678
+ functionDeclaration: "function() { this.click(); }",
679
+ });
680
+ } else {
681
+ await cdp.send("Runtime.callFunctionOn", {
682
+ objectId,
683
+ functionDeclaration: "function() { this.focus(); this.value = ''; }",
684
+ });
685
+ await cdp.send("Input.insertText", { text });
686
+ await cdp.send("Runtime.callFunctionOn", {
687
+ objectId,
688
+ functionDeclaration:
689
+ "function() { this.dispatchEvent(new Event('input', { bubbles: true })); }",
690
+ });
691
+ }
692
+ });
693
+ try {
694
+ await act(session);
695
+ } catch (err) {
696
+ const msg = String(err && err.message ? err.message : err);
697
+ if (/stale|missing/i.test(msg)) return fail(`stale ref: ${refId}`);
698
+ if (session.engine !== "chromium" && (session.swapCount || 0) < 1 && bins.chromium) {
699
+ session = await swapToChromium(session, ctx);
700
+ writeSession(cwd, session);
701
+ try {
702
+ await act(session);
703
+ } catch (err2) {
704
+ const msg2 = String(err2 && err2.message ? err2.message : err2);
705
+ if (/stale|missing/i.test(msg2)) return fail(`stale ref: ${refId}`);
706
+ return fail(msg2);
707
+ }
708
+ } else {
709
+ return fail(msg);
710
+ }
711
+ }
712
+ writeSession(cwd, session);
713
+ return ok(`${verb}: ${refId}`);
714
+ }
715
+ if (verb === "shot" || verb === "pdf") {
716
+ let session = readSession(cwd);
717
+ if (!session || !session.lastURL) return fail("no browser session; run nav");
718
+ const picked = pickEngine({ platform, verb, url: session.lastURL, bins, forceChromium: true });
719
+ if (!picked.engine) return fail(picked.error || INSTALL_HINT);
720
+ if (session.engine !== "chromium") {
721
+ if (!alive(session.pid) && !session.lastURL) return fail("no browser session; run nav");
722
+ session = await swapToChromium(session, ctx);
723
+ } else if (!alive(session.pid)) {
724
+ return fail("no browser session; run nav");
725
+ }
726
+ const dir = sessionDir(cwd);
727
+ fs.mkdirSync(dir, { recursive: true });
728
+ const destName = verb === "shot" ? "shot.png" : "page.pdf";
729
+ const dest = path.join(dir, destName);
730
+ await withCdp(session, async (cdp) => {
731
+ if (verb === "shot") {
732
+ const r = await cdp.send("Page.captureScreenshot", { format: "png" });
733
+ fs.writeFileSync(dest, Buffer.from(r.data, "base64"));
734
+ } else {
735
+ const r = await cdp.send("Page.printToPDF", {});
736
+ fs.writeFileSync(dest, Buffer.from(r.data, "base64"));
737
+ }
738
+ });
739
+ writeSession(cwd, session);
740
+ return ok(`engine: ${session.engine}\nfile: ${path.join(SESSION_REL, destName)}`);
741
+ }
742
+ return fail("unknown verb");
743
+ } catch (err) {
744
+ const msg = String(err && err.message ? err.message : err).replace(/^Error:\s*/, "");
745
+ return fail(msg);
746
+ }
747
+ }
748
+
749
+ async function main() {
750
+ const r = await runBrowser(process.argv.slice(2), {
751
+ cwd: process.cwd(),
752
+ env: process.env,
753
+ platform: process.platform,
754
+ });
755
+ if (r.stdout) process.stdout.write(r.stdout);
756
+ if (r.stderr) process.stderr.write(r.stderr);
757
+ return r.code;
758
+ }
759
+
760
+ const thisFile = fileURLToPath(import.meta.url);
761
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
762
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
763
+ main().then((code) => process.exit(code), () => process.exit(1));
764
+ }