@aexol/spectral 0.9.211 → 0.9.215

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 (115) hide show
  1. package/dist/agent/agents.d.ts.map +1 -1
  2. package/dist/agent/agents.js +40 -0
  3. package/dist/backend/generated-images.d.ts +90 -0
  4. package/dist/backend/generated-images.d.ts.map +1 -0
  5. package/dist/backend/generated-images.js +214 -0
  6. package/dist/backend/machine-routing.d.ts +25 -0
  7. package/dist/backend/machine-routing.d.ts.map +1 -0
  8. package/dist/backend/machine-routing.js +35 -0
  9. package/dist/commands/serve.d.ts.map +1 -1
  10. package/dist/commands/serve.js +31 -6
  11. package/dist/extensions/browser/browser-service.d.ts +75 -1
  12. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  13. package/dist/extensions/browser/browser-service.js +600 -34
  14. package/dist/extensions/browser/index.d.ts +10 -4
  15. package/dist/extensions/browser/index.d.ts.map +1 -1
  16. package/dist/extensions/browser/index.js +44 -5
  17. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  18. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  19. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  20. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/core.js +63 -2
  22. package/dist/extensions/desktop-control/index.d.ts +55 -0
  23. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  24. package/dist/extensions/desktop-control/index.js +633 -0
  25. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  26. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  27. package/dist/extensions/desktop-control/keys.js +170 -0
  28. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  29. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  30. package/dist/extensions/desktop-control/linux.js +195 -0
  31. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  32. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  33. package/dist/extensions/desktop-control/macos.js +382 -0
  34. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  35. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  36. package/dist/extensions/desktop-control/safety.js +157 -0
  37. package/dist/extensions/desktop-control/types.d.ts +79 -0
  38. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  39. package/dist/extensions/desktop-control/types.js +10 -0
  40. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  41. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  42. package/dist/extensions/desktop-control/windows.js +276 -0
  43. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  44. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  45. package/dist/extensions/desktop-screenshot/index.js +363 -17
  46. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  47. package/dist/extensions/image-generation/index.js +133 -34
  48. package/dist/extensions/seo/index.d.ts.map +1 -1
  49. package/dist/extensions/seo/index.js +4 -0
  50. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  51. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  52. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  53. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  54. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  55. package/dist/extensions/seo/tools/full-audit.js +1 -1
  56. package/dist/extensions/seo/tools/report.d.ts +10 -0
  57. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  58. package/dist/extensions/seo/tools/report.js +1 -1
  59. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  60. package/dist/extensions/spectral-vision-fallback.js +11 -1
  61. package/dist/generated/zeus/const.d.ts.map +1 -1
  62. package/dist/generated/zeus/const.js +148 -4
  63. package/dist/generated/zeus/index.d.ts +649 -0
  64. package/dist/generated/zeus/index.d.ts.map +1 -1
  65. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  66. package/dist/mcp/tool-registrar.js +5 -2
  67. package/dist/mcp/types.d.ts +5 -0
  68. package/dist/mcp/types.d.ts.map +1 -1
  69. package/dist/relay/client.d.ts +11 -0
  70. package/dist/relay/client.d.ts.map +1 -1
  71. package/dist/relay/client.js +24 -6
  72. package/dist/relay/dispatcher.d.ts +12 -1
  73. package/dist/relay/dispatcher.d.ts.map +1 -1
  74. package/dist/relay/dispatcher.js +44 -42
  75. package/dist/sdk/ai/providers/faux.js +1 -1
  76. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  77. package/dist/sdk/ai/providers/openai-completions.js +57 -16
  78. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  79. package/dist/sdk/ai/providers/openrouter-images.js +45 -9
  80. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  81. package/dist/sdk/ai/providers/simple-options.js +2 -0
  82. package/dist/sdk/ai/types.d.ts +37 -1
  83. package/dist/sdk/ai/types.d.ts.map +1 -1
  84. package/dist/sdk/ai/utils/image-source.d.ts +121 -0
  85. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  86. package/dist/sdk/ai/utils/image-source.js +410 -0
  87. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  90. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  92. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  93. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  95. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  97. package/dist/server/agent-bridge.d.ts +14 -0
  98. package/dist/server/agent-bridge.d.ts.map +1 -1
  99. package/dist/server/agent-bridge.js +105 -26
  100. package/dist/server/handlers/queue.d.ts.map +1 -1
  101. package/dist/server/handlers/queue.js +11 -34
  102. package/dist/server/image-attachments.d.ts +109 -0
  103. package/dist/server/image-attachments.d.ts.map +1 -0
  104. package/dist/server/image-attachments.js +195 -0
  105. package/dist/server/session-stream.d.ts +69 -0
  106. package/dist/server/session-stream.d.ts.map +1 -1
  107. package/dist/server/session-stream.js +388 -32
  108. package/dist/server/storage.d.ts.map +1 -1
  109. package/dist/server/storage.js +10 -31
  110. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  111. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  112. package/dist/server/ui/terminal-confirm.js +333 -0
  113. package/dist/server/wire.d.ts +30 -5
  114. package/dist/server/wire.d.ts.map +1 -1
  115. package/package.json +1 -1
@@ -1,7 +1,210 @@
1
1
  import { createRequire } from "node:module";
2
+ import { execSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
2
4
  import { registerSessionResourceCleanup } from "../../sdk/ai/session-resources.js";
3
5
  import { getBrowserConfig } from "./config.js";
4
6
  const _require = createRequire(import.meta.url);
7
+ /**
8
+ * Unique switch added to every browser we launch. Playwright's `Browser` does
9
+ * not expose its child process (`process()` only exists on BrowserServer), so
10
+ * this marker lets us find the exact Chromium process tree we spawned (and
11
+ * nothing else) with `ps` when we have to SIGKILL it.
12
+ */
13
+ const BROWSER_MARKER = "--spectral-browser-token=";
14
+ /** `ps -eo pid,ppid,args` easily exceeds execSync's 1 MB default (ENOBUFS). */
15
+ const PS_MAX_BUFFER = 32 * 1024 * 1024;
16
+ /**
17
+ * How long a launch-time marker verification stays trustworthy on platforms
18
+ * that cannot re-verify synchronously (no `/proc`, i.e. macOS/Windows). Past
19
+ * that point the exit handler skips the SIGKILL: an orphaned browser window is
20
+ * annoying, SIGKILLing an unrelated process that reused the pid is not.
21
+ */
22
+ const PID_VERIFY_TTL_MS = 15 * 60 * 1000;
23
+ function newBrowserToken() {
24
+ return `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
25
+ }
26
+ /** Windows has no `ps`; probed once and cached. */
27
+ let psAvailable = null;
28
+ let psWarned = false;
29
+ function hasPs() {
30
+ if (psAvailable !== null)
31
+ return psAvailable;
32
+ try {
33
+ execSync(`ps -o pid= -p ${process.pid}`, {
34
+ encoding: "utf8",
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ maxBuffer: 64 * 1024,
37
+ });
38
+ psAvailable = true;
39
+ }
40
+ catch {
41
+ psAvailable = false;
42
+ process.stderr.write("[browser] `ps` is unavailable on this platform — browser processes cannot be located by marker; only graceful close is possible.\n");
43
+ }
44
+ return psAvailable;
45
+ }
46
+ function warnPsFailure(err) {
47
+ if (psWarned)
48
+ return;
49
+ psWarned = true;
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ process.stderr.write(`[browser] Could not read the process table (${msg}).\n`);
52
+ }
53
+ /** Never SIGKILL init/pid 1, ourselves, our parent or garbage pids. */
54
+ function isKillablePid(pid) {
55
+ return (typeof pid === "number" &&
56
+ Number.isFinite(pid) &&
57
+ pid > 1 &&
58
+ pid !== process.pid &&
59
+ pid !== process.ppid);
60
+ }
61
+ /**
62
+ * Identity check performed immediately before a SIGKILL: confirms the pid still
63
+ * runs *our* browser (pid reuse would otherwise make us kill an unrelated
64
+ * process). Without `ps` we cannot verify and fall back to a best-effort kill.
65
+ */
66
+ function pidHasMarker(pid, marker) {
67
+ if (!hasPs())
68
+ return true;
69
+ try {
70
+ const out = execSync(`ps -o args= -p ${pid}`, {
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ maxBuffer: PS_MAX_BUFFER,
74
+ });
75
+ return out.includes(marker);
76
+ }
77
+ catch {
78
+ // ESRCH (gone) or EPERM — in both cases we must not signal it.
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Liveness probe only — no subprocess, no signal delivered. Used to skip the
84
+ * (expensive) marker lookups for pids that are already gone, which is the
85
+ * normal case after a graceful `browser.close()`.
86
+ */
87
+ function pidAlive(pid) {
88
+ if (!isKillablePid(pid))
89
+ return false;
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ /**
99
+ * Marker verification without `execSync`, so it is usable from an `exit`
100
+ * handler: reads `/proc/<pid>/cmdline` (Linux). Returns `null` when the
101
+ * platform cannot answer synchronously (no `/proc`) — the caller then decides
102
+ * whether the launch-time evidence is still good enough.
103
+ */
104
+ let procfsAvailable = null;
105
+ function hasProcfs() {
106
+ if (procfsAvailable !== null)
107
+ return procfsAvailable;
108
+ try {
109
+ procfsAvailable = readFileSync("/proc/self/cmdline", "utf8").length > 0;
110
+ }
111
+ catch {
112
+ procfsAvailable = false;
113
+ }
114
+ return procfsAvailable;
115
+ }
116
+ function pidHasMarkerSync(pid, marker) {
117
+ if (!hasProcfs())
118
+ return null;
119
+ try {
120
+ // argv is NUL-separated there; a substring match is enough for our marker.
121
+ return readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(marker);
122
+ }
123
+ catch {
124
+ // ENOENT/ESRCH/EPERM — never signal what we cannot confirm is ours.
125
+ return false;
126
+ }
127
+ }
128
+ /**
129
+ * SIGKILL `pid` if it is still alive. Never throws and never spawns a child
130
+ * process, so it is safe from `process.on("exit")` / signal handlers.
131
+ */
132
+ function killPidSync(pid) {
133
+ if (!pidAlive(pid))
134
+ return;
135
+ try {
136
+ process.kill(pid, "SIGKILL");
137
+ }
138
+ catch {
139
+ /* it died between the probe and the kill — nothing to do */
140
+ }
141
+ }
142
+ /** SIGKILL `pid` if it is alive and (when `marker` is given) still ours. */
143
+ function killPid(pid, marker) {
144
+ // Free probe first: a dead pid needs no `ps` lookup at all.
145
+ if (!pidAlive(pid))
146
+ return;
147
+ if (marker && !pidHasMarker(pid, marker))
148
+ return;
149
+ try {
150
+ process.kill(pid, "SIGKILL");
151
+ }
152
+ catch {
153
+ /* it died between the probe and the kill — nothing to do */
154
+ }
155
+ }
156
+ /** Snapshot of the process table. Returns [] (and never throws) when `ps` is missing. */
157
+ function psRows() {
158
+ if (!hasPs())
159
+ return [];
160
+ let out = "";
161
+ try {
162
+ out = execSync("ps -eo pid,ppid,args", {
163
+ encoding: "utf8",
164
+ stdio: ["ignore", "pipe", "ignore"],
165
+ maxBuffer: PS_MAX_BUFFER,
166
+ timeout: 10_000,
167
+ });
168
+ }
169
+ catch (err) {
170
+ warnPsFailure(err);
171
+ return [];
172
+ }
173
+ const rows = [];
174
+ for (const line of out.split("\n")) {
175
+ const trimmed = line.trim();
176
+ if (!trimmed)
177
+ continue;
178
+ // First column must be a pid — skips the header row and wrapped lines.
179
+ const firstSpace = trimmed.indexOf(" ");
180
+ if (firstSpace <= 0)
181
+ continue;
182
+ const pid = Number.parseInt(trimmed.slice(0, firstSpace), 10);
183
+ if (!Number.isFinite(pid))
184
+ continue;
185
+ const rest = trimmed.slice(firstSpace + 1).trimStart();
186
+ const secondSpace = rest.indexOf(" ");
187
+ if (secondSpace <= 0)
188
+ continue;
189
+ const ppid = Number.parseInt(rest.slice(0, secondSpace), 10);
190
+ if (!Number.isFinite(ppid))
191
+ continue;
192
+ rows.push({ pid, ppid, args: rest.slice(secondSpace + 1) });
193
+ }
194
+ return rows;
195
+ }
196
+ /**
197
+ * Argv for a launch. Chromium ignores unknown switches, so the marker is
198
+ * harmless there; Firefox/WebKit parse argv strictly and may refuse to start on
199
+ * an unknown switch, so they get plain args (they can then only be killed
200
+ * through graceful close or a pid exposed by `browser.process()`).
201
+ */
202
+ function launchArgsFor(browserName, token) {
203
+ const base = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"];
204
+ if (browserName !== "chromium" || !token)
205
+ return base;
206
+ return [`${BROWSER_MARKER}${token}`, ...base];
207
+ }
5
208
  let _pw = null;
6
209
  let _pwError = null;
7
210
  let _preflightResult = null;
@@ -28,6 +231,95 @@ let unregisterInstanceCleanup = null;
28
231
  // cannot itself await (it is invoked synchronously from session.dispose()).
29
232
  let pendingClose = null;
30
233
  let closeAllPromise = null;
234
+ function isBrowserConnected(browser) {
235
+ if (!browser)
236
+ return false;
237
+ try {
238
+ return browser.isConnected();
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
244
+ /**
245
+ * All live owners, not a single slot: two BrowserService instances (e.g. after
246
+ * resetInstance()) must both be cleaned up, otherwise the older browser is left
247
+ * orphaned when the newer one detaches the shared handler.
248
+ */
249
+ const processCleanups = new Set();
250
+ let cleanupHandlersAttached = false;
251
+ let cleanupOnExit = null;
252
+ let cleanupOnSignal = null;
253
+ /**
254
+ * Safety net: synchronously SIGKILL the browser process if the host process
255
+ * exits (or is interrupted) while a browser is still attached — otherwise the
256
+ * Chromium window survives the CLI. Only one set of handlers is ever attached
257
+ * (shared by every owner) and it is detached again once the last owner is
258
+ * released, so no listeners accumulate.
259
+ */
260
+ function attachProcessCleanup(owner, kill) {
261
+ for (const existing of processCleanups) {
262
+ if (existing.owner === owner) {
263
+ existing.kill = kill;
264
+ return;
265
+ }
266
+ }
267
+ processCleanups.add({ owner, kill });
268
+ if (cleanupHandlersAttached)
269
+ return;
270
+ cleanupHandlersAttached = true;
271
+ cleanupOnExit = () => {
272
+ runProcessCleanup();
273
+ };
274
+ cleanupOnSignal = (sig) => {
275
+ runProcessCleanup();
276
+ // Never swallow the signal: if nothing else handles it, re-raise it so the
277
+ // process still terminates exactly like it would without our handler.
278
+ if (process.listenerCount(sig) === 0) {
279
+ try {
280
+ process.kill(process.pid, sig);
281
+ }
282
+ catch {
283
+ /* ignore */
284
+ }
285
+ }
286
+ };
287
+ process.once("exit", cleanupOnExit);
288
+ process.once("SIGINT", cleanupOnSignal);
289
+ process.once("SIGTERM", cleanupOnSignal);
290
+ }
291
+ function runProcessCleanup() {
292
+ for (const entry of [...processCleanups]) {
293
+ try {
294
+ entry.kill();
295
+ }
296
+ catch {
297
+ /* never let cleanup throw */
298
+ }
299
+ }
300
+ }
301
+ function releaseProcessCleanup(owner) {
302
+ if (owner) {
303
+ for (const entry of [...processCleanups]) {
304
+ if (entry.owner === owner)
305
+ processCleanups.delete(entry);
306
+ }
307
+ }
308
+ else {
309
+ processCleanups.clear();
310
+ }
311
+ if (processCleanups.size > 0 || !cleanupHandlersAttached)
312
+ return;
313
+ if (cleanupOnExit)
314
+ process.off("exit", cleanupOnExit);
315
+ if (cleanupOnSignal) {
316
+ process.off("SIGINT", cleanupOnSignal);
317
+ process.off("SIGTERM", cleanupOnSignal);
318
+ }
319
+ cleanupHandlersAttached = false;
320
+ cleanupOnExit = null;
321
+ cleanupOnSignal = null;
322
+ }
31
323
  export class BrowserService {
32
324
  browser = null;
33
325
  context = null;
@@ -41,6 +333,33 @@ export class BrowserService {
41
333
  _recording = null;
42
334
  closePromise = null;
43
335
  launchLock = null;
336
+ /** Unique marker identifying the process tree of the current launch. */
337
+ launchToken = null;
338
+ /**
339
+ * Token of the current *or previous* launch — only cleared by the next
340
+ * launch. Post-close fallbacks (`forceKillBrowserProcess()` / `browser_quit`)
341
+ * still need it to find the process tree after `close()` nulled launchToken.
342
+ * Every kill through this token is marker-verified first, so a recycled pid
343
+ * is never signalled.
344
+ */
345
+ lastLaunchToken = null;
346
+ /**
347
+ * Root pid + full marker-matched tree, resolved once after a successful
348
+ * launch (single `ps` call) so exit/signal handlers can SIGKILL without
349
+ * running `execSync` from inside the handler.
350
+ */
351
+ browserPid = null;
352
+ browserPids = [];
353
+ /**
354
+ * Evidence for the synchronous teardown path: the pids that were matched by
355
+ * *our* marker at launch time, when they were matched and under which token.
356
+ * `killBrowserProcessSync()` may only signal pids listed here — a pid that we
357
+ * never marker-verified (or that no longer carries the marker) can belong to
358
+ * somebody else by now.
359
+ */
360
+ verifiedBrowserPids = [];
361
+ verifiedBrowserPidsAt = 0;
362
+ verifiedBrowserPidsToken = null;
44
363
  constructor(config) {
45
364
  this.config = { ...getBrowserConfig(), ...config };
46
365
  }
@@ -71,11 +390,24 @@ export class BrowserService {
71
390
  static resetInstance() {
72
391
  unregisterInstanceCleanup?.();
73
392
  unregisterInstanceCleanup = null;
393
+ const previous = instance;
74
394
  instance = null;
75
395
  pendingClose = null;
76
396
  closeAllPromise = null;
77
397
  _preflightResult = null;
78
398
  _pwError = null;
399
+ // Never orphan the browser owned by the discarded instance: SIGKILL its
400
+ // process tree and detach its process handlers synchronously (the old
401
+ // instance is unreachable afterwards, so graceful close would never run).
402
+ if (previous) {
403
+ try {
404
+ previous.killBrowserProcess();
405
+ }
406
+ catch {
407
+ /* never let cleanup throw */
408
+ }
409
+ releaseProcessCleanup(previous);
410
+ }
79
411
  }
80
412
  /**
81
413
  * Close the active browser singleton (if any) and await completion. Unlike
@@ -162,13 +494,11 @@ export class BrowserService {
162
494
  const doLaunch = async () => {
163
495
  await this.close();
164
496
  const pw = loadPlaywright();
497
+ this.launchToken = newBrowserToken();
498
+ this.lastLaunchToken = this.launchToken;
165
499
  const launchOptions = {
166
500
  headless: this.config.headless,
167
- args: [
168
- "--no-sandbox",
169
- "--disable-setuid-sandbox",
170
- "--disable-dev-shm-usage",
171
- ],
501
+ args: launchArgsFor(this.config.browserName, this.launchToken),
172
502
  };
173
503
  switch (this.config.browserName) {
174
504
  case "firefox":
@@ -180,6 +510,10 @@ export class BrowserService {
180
510
  default:
181
511
  this.browser = await pw.chromium.launch(launchOptions);
182
512
  }
513
+ // Guarantee the browser process is killed even if the host process dies
514
+ // before close() ever runs.
515
+ this.resolveBrowserPids(this.launchToken);
516
+ attachProcessCleanup(this, () => this.killBrowserProcessSync());
183
517
  this.context = await this.browser.newContext({
184
518
  viewport: this.config.viewport,
185
519
  });
@@ -241,16 +575,51 @@ export class BrowserService {
241
575
  return { url: page.url(), title: await page.title() };
242
576
  }
243
577
  async closePage() {
578
+ const openBlankPage = async () => {
579
+ if (!this.context) {
580
+ this.pages = [];
581
+ this.activePageIndex = 0;
582
+ return;
583
+ }
584
+ try {
585
+ const newPage = await this.context.newPage();
586
+ this.trackNetworkRequest(newPage);
587
+ this.installDialogInterceptor(newPage);
588
+ this.pages = [newPage];
589
+ }
590
+ catch {
591
+ this.pages = [];
592
+ }
593
+ this.activePageIndex = 0;
594
+ };
595
+ // Nothing is open — do not blow up with "cannot read property close of
596
+ // undefined"; just make sure a blank page is available again.
597
+ if (this.pages.length === 0) {
598
+ await openBlankPage();
599
+ return;
600
+ }
601
+ if (this.activePageIndex < 0 || this.activePageIndex >= this.pages.length) {
602
+ this.activePageIndex = 0;
603
+ }
244
604
  const page = this.pages[this.activePageIndex];
245
- await page.close();
605
+ if (!page) {
606
+ await openBlankPage();
607
+ return;
608
+ }
609
+ try {
610
+ await page.close();
611
+ }
612
+ catch (e) {
613
+ // The page/window may already be gone (closed by the user or the tab
614
+ // crashed) — keep cleaning up instead of surfacing a confusing error.
615
+ process.stderr.write(`[browser] closePage: ${e.message}\n`);
616
+ }
246
617
  this.pages.splice(this.activePageIndex, 1);
247
- if (this.pages.length === 0 && this.context) {
248
- const newPage = await this.context.newPage();
249
- this.pages = [newPage];
250
- this.activePageIndex = 0;
618
+ if (this.pages.length === 0) {
619
+ await openBlankPage();
251
620
  }
252
621
  else {
253
- this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
622
+ this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, this.pages.length - 1));
254
623
  }
255
624
  }
256
625
  async resize(width, height) {
@@ -378,39 +747,224 @@ export class BrowserService {
378
747
  if (this.closePromise)
379
748
  return this.closePromise;
380
749
  this.closePromise = (async () => {
381
- const withTimeout = async (promise, ms, label) => {
750
+ const withTimeout = async (promise, ms, label, onTimeout) => {
751
+ let timer;
382
752
  try {
383
753
  return await Promise.race([
384
754
  promise,
385
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${ms}ms: ${label}`)), ms))
755
+ new Promise((_, reject) => {
756
+ timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms: ${label}`)), ms);
757
+ })
386
758
  ]);
387
759
  }
388
760
  catch (e) {
389
- process.stderr.write(`[browser] close ${label}: ${e.message}\n`);
761
+ const msg = e.message ?? "";
762
+ // A timeout is a failed close too: browser.close() is still hanging
763
+ // and the process must be SIGKILLed, exactly like a rejection.
764
+ if (msg.startsWith("Timed out after"))
765
+ onTimeout?.();
766
+ process.stderr.write(`[browser] close ${label}: ${msg}\n`);
767
+ }
768
+ finally {
769
+ if (timer)
770
+ clearTimeout(timer);
390
771
  }
391
772
  };
392
773
  const closeTimeout = 5000; // 5 seconds per operation
393
- for (const page of [...this.pages]) {
394
- await withTimeout(page.close().catch(() => { }), closeTimeout, 'page.close()');
395
- }
396
- this.pages = [];
397
- this.activePageIndex = 0;
774
+ const browser = this.browser;
398
775
  const context = this.context;
399
- this.context = null;
400
- if (context) {
401
- await withTimeout(context.close().catch(() => { }), closeTimeout, 'context.close()');
776
+ let browserCloseFailed = false;
777
+ try {
778
+ for (const page of [...this.pages]) {
779
+ await withTimeout(page.close().catch(() => { }), closeTimeout, 'page.close()');
780
+ }
781
+ this.pages = [];
782
+ this.activePageIndex = 0;
783
+ this.context = null;
784
+ if (context) {
785
+ await withTimeout(context.close().catch(() => { }), closeTimeout, 'context.close()');
786
+ }
787
+ this.browser = null;
788
+ if (browser) {
789
+ await withTimeout(browser.close().catch((e) => {
790
+ browserCloseFailed = true;
791
+ process.stderr.write(`[browser] close browser.close(): ${e.message}\n`);
792
+ }), closeTimeout, 'browser.close()', () => {
793
+ browserCloseFailed = true;
794
+ });
795
+ }
402
796
  }
403
- const browser = this.browser;
404
- this.browser = null;
405
- if (browser) {
406
- await withTimeout(browser.close().catch(() => { }), closeTimeout, 'browser.close()');
797
+ finally {
798
+ // browser.close() may have thrown, timed out or been abandoned — in
799
+ // that case the Chromium process is still alive and would be left
800
+ // orphaned, so SIGKILL it directly. A known launch token alone is not
801
+ // evidence of a live process, but the browser object may still expose
802
+ // one, so the kill is still attempted — just without the expensive
803
+ // process-table scan (see `scanProcessTable`).
804
+ const token = this.launchToken ?? this.lastLaunchToken;
805
+ const hasKnownPids = this.browserPid !== null || this.browserPids.length > 0;
806
+ // Anything in here means "there may still be a browser process".
807
+ const mayStillBeAlive = browserCloseFailed || isBrowserConnected(browser) || hasKnownPids;
808
+ if (browser && (mayStillBeAlive || token)) {
809
+ this.killBrowserProcess(browser, { scanProcessTable: mayStillBeAlive });
810
+ }
811
+ // Graceful close confirmed: forget the resolved pids so a later exit
812
+ // handler can never SIGKILL a recycled pid.
813
+ if (!browserCloseFailed && !isBrowserConnected(browser)) {
814
+ this.browserPid = null;
815
+ this.browserPids = [];
816
+ this.verifiedBrowserPids = [];
817
+ this.verifiedBrowserPidsAt = 0;
818
+ this.verifiedBrowserPidsToken = null;
819
+ }
820
+ releaseProcessCleanup(this);
821
+ this.pages = [];
822
+ this.activePageIndex = 0;
823
+ this.context = null;
824
+ this.browser = null;
825
+ this.launchToken = null;
826
+ // lastLaunchToken is intentionally kept (see field docs).
827
+ this._recording = null;
407
828
  }
408
- this._recording = null;
409
829
  })().finally(() => {
410
830
  this.closePromise = null;
411
831
  });
412
832
  return this.closePromise;
413
833
  }
834
+ /**
835
+ * Last-resort teardown used by the `browser_quit` tool: SIGKILL the browser
836
+ * process without waiting for a graceful close. Safe to call at any time.
837
+ */
838
+ forceKillBrowserProcess() {
839
+ this.killBrowserProcess();
840
+ }
841
+ /**
842
+ * SIGKILL the browser process behind `browser` (defaults to the live one).
843
+ * `browser.close()` can hang, be rejected or be abandoned while the host
844
+ * process is shutting down, which leaves a visible Chromium window behind.
845
+ *
846
+ * `opts.scanProcessTable === false` skips `findBrowserPids()` (a `ps -eo`
847
+ * fork) — use it when nothing indicates a live browser. Only pids we already
848
+ * know about (or the one `browser.process()` exposes) are then signalled,
849
+ * each still marker-verified before the SIGKILL.
850
+ */
851
+ killBrowserProcess(browser = this.browser, opts) {
852
+ try {
853
+ const token = this.launchToken ?? this.lastLaunchToken;
854
+ const marker = token ? `${BROWSER_MARKER}${token}` : undefined;
855
+ // `process()` only exists on BrowserServer/ElectronApplication, but keep
856
+ // using it whenever it is available.
857
+ const proc = browser
858
+ ?.process?.();
859
+ const directPid = proc?.pid ?? null;
860
+ // Locate the whole tree via the unique marker we passed to launch() —
861
+ // children (renderer/GPU/utility) inherit the switch, so they die too.
862
+ // Never kill unrelated Chrome instances: killPid() re-checks the marker.
863
+ const targets = new Set(this.browserPids);
864
+ if (opts?.scanProcessTable !== false) {
865
+ for (const found of this.findBrowserPids())
866
+ targets.add(found);
867
+ }
868
+ const root = directPid ?? this.browserPid;
869
+ if (root)
870
+ targets.add(root);
871
+ // Children first, root last: the root cannot respawn anything after it
872
+ // receives SIGKILL.
873
+ const ordered = [...targets].sort((a, b) => Number(b === root) - Number(a === root));
874
+ for (const pid of ordered)
875
+ killPid(pid, marker);
876
+ this.browserPid = null;
877
+ this.browserPids = [];
878
+ }
879
+ catch {
880
+ /* never let cleanup throw */
881
+ }
882
+ }
883
+ /**
884
+ * Exit/signal-handler path: SIGKILL the pids resolved right after launch.
885
+ * Deliberately execSync-free — spawning `ps` from inside an `exit` handler is
886
+ * unreliable (and can silently no-op).
887
+ *
888
+ * Never signals a pid that may not be ours: only pids whose marker was
889
+ * verified at launch time are eligible, and each candidate still has to pass
890
+ * a synchronous identity check — a `process.kill(pid, 0)` probe plus, where
891
+ * the platform allows it, a `/proc/<pid>/cmdline` read. Where no synchronous
892
+ * verification exists (no `/proc`), the launch-time evidence is trusted only
893
+ * while it is fresh (`PID_VERIFY_TTL_MS`): an orphaned browser window is
894
+ * preferable to SIGKILLing a recycled pid that now belongs to someone else.
895
+ */
896
+ killBrowserProcessSync() {
897
+ try {
898
+ const verified = new Set(this.verifiedBrowserPids);
899
+ if (verified.size === 0)
900
+ return;
901
+ const token = this.verifiedBrowserPidsToken ?? this.launchToken ?? this.lastLaunchToken;
902
+ if (!token)
903
+ return;
904
+ const marker = `${BROWSER_MARKER}${token}`;
905
+ const fresh = this.verifiedBrowserPidsAt > 0 &&
906
+ Date.now() - this.verifiedBrowserPidsAt <= PID_VERIFY_TTL_MS;
907
+ const pids = new Set(this.browserPids);
908
+ if (this.browserPid !== null)
909
+ pids.add(this.browserPid);
910
+ for (const pid of pids) {
911
+ // Resolved by anything other than our own marker match → hands off.
912
+ if (!verified.has(pid))
913
+ continue;
914
+ // Already gone: the free probe short-circuits, no subprocess at all.
915
+ if (!pidAlive(pid))
916
+ continue;
917
+ const stillOurs = pidHasMarkerSync(pid, marker);
918
+ // `/proc` answered and it is not our browser (pid reuse) → skip.
919
+ if (stillOurs === false)
920
+ continue;
921
+ // Cannot verify on this platform → trust only a recent verification.
922
+ if (stillOurs === null && !fresh)
923
+ continue;
924
+ killPidSync(pid);
925
+ }
926
+ }
927
+ catch {
928
+ /* never let cleanup throw */
929
+ }
930
+ }
931
+ /**
932
+ * Resolve the browser's pids once, right after a successful launch, so the
933
+ * synchronous teardown path never has to inspect the process table again.
934
+ */
935
+ resolveBrowserPids(token) {
936
+ this.browserPid = null;
937
+ this.browserPids = [];
938
+ this.verifiedBrowserPids = [];
939
+ this.verifiedBrowserPidsAt = 0;
940
+ this.verifiedBrowserPidsToken = null;
941
+ if (!token)
942
+ return;
943
+ const marker = `${BROWSER_MARKER}${token}`;
944
+ const rows = psRows().filter((r) => r.args.includes(marker) && isKillablePid(r.pid));
945
+ if (rows.length === 0)
946
+ return;
947
+ this.browserPids = rows.map((r) => r.pid);
948
+ // The root is the child of this process; fall back to the lowest pid (ps
949
+ // prints ascending pids and the root is spawned first).
950
+ const root = rows.find((r) => r.ppid === process.pid) ?? rows[0];
951
+ this.browserPid = root?.pid ?? null;
952
+ // Evidence for killBrowserProcessSync(): these pids were matched by our own
953
+ // marker right after launch, so the sync path may signal them.
954
+ this.verifiedBrowserPids = [...this.browserPids];
955
+ this.verifiedBrowserPidsAt = Date.now();
956
+ this.verifiedBrowserPidsToken = token;
957
+ }
958
+ /** Find the pids of the browser processes we spawned (marker match). */
959
+ findBrowserPids() {
960
+ const token = this.launchToken ?? this.lastLaunchToken;
961
+ if (!token)
962
+ return [];
963
+ const marker = `${BROWSER_MARKER}${token}`;
964
+ return psRows()
965
+ .filter((r) => r.args.includes(marker) && isKillablePid(r.pid))
966
+ .map((r) => r.pid);
967
+ }
414
968
  // ── Recording ──────────────────────────────────────────────
415
969
  async startRecording(opts) {
416
970
  if (this._recording) {
@@ -437,11 +991,13 @@ export class BrowserService {
437
991
  // Create new browser+context with recording enabled
438
992
  const pw = loadPlaywright();
439
993
  const recordHeadless = this.config.recordHeadless;
440
- const launchOpts = {
441
- headless: recordHeadless,
442
- args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
443
- };
444
994
  if (!this.browser?.isConnected()) {
995
+ this.launchToken = newBrowserToken();
996
+ this.lastLaunchToken = this.launchToken;
997
+ const launchOpts = {
998
+ headless: recordHeadless,
999
+ args: launchArgsFor(this.config.browserName, this.launchToken),
1000
+ };
445
1001
  switch (this.config.browserName) {
446
1002
  case "firefox":
447
1003
  this.browser = await pw.firefox.launch(launchOpts);
@@ -453,6 +1009,8 @@ export class BrowserService {
453
1009
  this.browser = await pw.chromium.launch(launchOpts);
454
1010
  break;
455
1011
  }
1012
+ this.resolveBrowserPids(this.launchToken);
1013
+ attachProcessCleanup(this, () => this.killBrowserProcessSync());
456
1014
  }
457
1015
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
458
1016
  const base = opts?.filename
@@ -482,7 +1040,13 @@ export class BrowserService {
482
1040
  this._recording = { dir, startedAt: Date.now(), outputPath, filename: opts?.filename };
483
1041
  return { outputPath, startedAt: this._recording.startedAt };
484
1042
  }
485
- async stopRecording() {
1043
+ /**
1044
+ * @param opts.relaunch Re-open a fresh non-recording context afterwards
1045
+ * (default true, what `browser_record_stop` needs). Pass `false` when the
1046
+ * session is shutting down — otherwise a brand new Chromium window is
1047
+ * spawned only to be closed again immediately.
1048
+ */
1049
+ async stopRecording(opts = {}) {
486
1050
  if (!this._recording) {
487
1051
  throw new Error("No active recording.");
488
1052
  }
@@ -519,8 +1083,10 @@ export class BrowserService {
519
1083
  catch { /* ignore rename failure, keep real path */ }
520
1084
  }
521
1085
  this._recording = null;
522
- // Create a fresh non-recording context for continued use
523
- await this.ensureBrowser();
1086
+ // Create a fresh non-recording context for continued use (skipped on
1087
+ // shutdown — see the `relaunch` option above).
1088
+ if (opts.relaunch !== false)
1089
+ await this.ensureBrowser();
524
1090
  return { outputPath: finalPath, durationSeconds };
525
1091
  }
526
1092
  getRecordStatus() {