@proagentstore/cli 0.4.33 → 0.4.35

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.
@@ -40,7 +40,11 @@ export function gitArgv(cmd, opts = {}) {
40
40
  const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
41
41
  switch (cmd) {
42
42
  case "status":
43
- return ["status", "--short"];
43
+ // `--branch` adds ONE header line (`## main...origin/main [ahead 1]`). Without it a
44
+ // caller can learn a tree is dirty but never which branch it is dirty on, which is how
45
+ // a delegated run pushed a PR branch and left the checkout parked there unnoticed
46
+ // (#276). Additive: every existing consumer keeps the same file lines it always got.
47
+ return ["status", "--short", "--branch"];
44
48
  case "diff":
45
49
  return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
46
50
  case "diff-stat":
@@ -2,6 +2,7 @@
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { startRunnerServer } from "./server.js";
5
+ import { reapOnStartup } from "./reaper.js";
5
6
  import { randomUUID } from "node:crypto";
6
7
  // Resilience: a stray error in any runtime must NOT take the whole runner down —
7
8
  // that drops the tunnel and forces the user to restart `pags up` (and lose their
@@ -71,6 +72,12 @@ if (!config.token && !LOOPBACK.has(config.host)) {
71
72
  `Set --token (or PAGS_RUNNER_TOKEN), or bind to 127.0.0.1.\n`);
72
73
  process.exit(1);
73
74
  }
75
+ // Recover a machine that has already leaked (#274). A browser whose launcher was
76
+ // SIGKILLed is reparented to init and runs forever — 41 of them took the owner's
77
+ // machine to load 253. Nothing in-process can clean those up, so we do it here,
78
+ // before we start competing for the same CPU. Narrow by construction: see the
79
+ // SAFETY block in reaper.ts for why the user's real Chrome can never match.
80
+ reapOnStartup((line) => process.stderr.write(`${line}\n`));
74
81
  const started = await startRunnerServer(config);
75
82
  process.stdout.write(`ProAgentStore browser runtime listening at ${started.url}\n`);
76
83
  process.stdout.write(`Data dir: ${config.dataDir}\n`);
@@ -79,12 +86,47 @@ if (config.token)
79
86
  process.stdout.write("Auth: bearer token required\n");
80
87
  if (config.instanceId)
81
88
  process.stdout.write(`Instance binding: ${config.instanceId}\n`);
89
+ /**
90
+ * Exit once, and ALWAYS exit (#274).
91
+ *
92
+ * Three holes lived here, and each one ended with a user reaching for `kill -9`,
93
+ * which is precisely the signal that orphans the browser:
94
+ *
95
+ * - `started.close()` rejects if the browser is already gone. `void shutdown()`
96
+ * then produced an unhandled rejection, the handler above logged it, and the
97
+ * process stayed up forever holding the port and the browser.
98
+ * - A hung `browserContext.close()` (a wedged renderer) blocked exit with no
99
+ * upper bound, looking identical to a freeze.
100
+ * - Two signals, or a signal racing the parent-death watchdog, ran the teardown
101
+ * twice concurrently.
102
+ *
103
+ * So: idempotent, failure-tolerant, and time-boxed. `process.exit` also runs
104
+ * Playwright's own synchronous exit handler, which kills any browser it launched
105
+ * in this process — that is what makes a clean exit leave nothing behind.
106
+ */
107
+ let exiting = false;
82
108
  const shutdown = async () => {
83
- await started.close();
109
+ if (exiting)
110
+ return;
111
+ exiting = true;
112
+ const forced = setTimeout(() => {
113
+ process.stderr.write("[runner] shutdown timed out after 10s; exiting anyway\n");
114
+ process.exit(0);
115
+ }, 10_000);
116
+ forced.unref();
117
+ try {
118
+ await started.close();
119
+ }
120
+ catch (err) {
121
+ process.stderr.write(`[runner] shutdown error (exiting anyway): ${err instanceof Error ? err.message : String(err)}\n`);
122
+ }
84
123
  process.exit(0);
85
124
  };
86
125
  process.on("SIGINT", () => void shutdown());
87
126
  process.on("SIGTERM", () => void shutdown());
127
+ // SIGHUP was missing: closing the terminal that ran `pags up` killed the runner
128
+ // without ever running teardown, leaving the browser behind.
129
+ process.on("SIGHUP", () => void shutdown());
88
130
  // Self-exit if our parent (the `runner connect` CLI) dies — otherwise we'd orphan
89
131
  // and keep holding the port, making the NEXT `pags up` fail with EADDRINUSE / a 401
90
132
  // against our stale token. When the parent dies we're reparented (ppid changes to
@@ -1,6 +1,76 @@
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { createConnection } from "@playwright/mcp";
2
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
6
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { sweepStalePlaywrightProfiles } from "./reaper.js";
8
+ /**
9
+ * Throwaway profile dirs created by this module and not yet removed (#274).
10
+ *
11
+ * `stop()` cannot reliably win this race on its own: the MCP server releases the
12
+ * browser, but Chrome's exit is asynchronous and it rewrites `Local State` on the
13
+ * way out — measurably AFTER `stop()` has returned, so an rmSync there leaves a
14
+ * re-created directory behind. The browser is only certainly gone once Playwright's
15
+ * own `exit` handler has run, so the last pass belongs at process exit.
16
+ *
17
+ * This also covers the dir being stranded when `stop()` is never called at all —
18
+ * a throw between `start()` and `stop()`, which no `try/finally` in the caller
19
+ * would catch either.
20
+ */
21
+ const ownedProfileDirs = new Set();
22
+ let exitHookInstalled = false;
23
+ /**
24
+ * Try to remove a profile dir now. It stays registered unless it is really gone,
25
+ * so a dir Chrome re-creates while shutting down gets another pass at exit rather
26
+ * than being forgotten after one failed attempt.
27
+ */
28
+ function removeProfileDir(dir) {
29
+ try {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ catch {
33
+ // best effort; the dir is disk, not correctness
34
+ }
35
+ if (!existsSync(dir))
36
+ ownedProfileDirs.delete(dir);
37
+ }
38
+ /** Await a promise, giving up (never throwing) after `ms`. */
39
+ async function withTimeout(p, ms) {
40
+ if (!p)
41
+ return;
42
+ let timer;
43
+ try {
44
+ await Promise.race([
45
+ p.catch(() => undefined),
46
+ new Promise((resolve) => {
47
+ timer = setTimeout(resolve, ms);
48
+ }),
49
+ ]);
50
+ }
51
+ finally {
52
+ if (timer)
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ function rememberProfileDir(dir) {
57
+ ownedProfileDirs.add(dir);
58
+ if (exitHookInstalled)
59
+ return;
60
+ exitHookInstalled = true;
61
+ // Registered AFTER Playwright's own launch-time exit handler, so by the time
62
+ // this runs Playwright has already killed the browsers it started.
63
+ process.on("exit", () => {
64
+ for (const d of ownedProfileDirs) {
65
+ try {
66
+ rmSync(d, { recursive: true, force: true });
67
+ }
68
+ catch {
69
+ // exiting anyway
70
+ }
71
+ }
72
+ });
73
+ }
4
74
  /**
5
75
  * Hosts the INDUSTRY-STANDARD `@playwright/mcp` server in the runner and an in-process
6
76
  * MCP client to drive it. Every browser action goes through the standard Playwright MCP
@@ -11,12 +81,51 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
11
81
  export class McpRuntime {
12
82
  server;
13
83
  client;
84
+ /** A profile dir this instance created and is therefore responsible for deleting. */
85
+ ownedProfileDir;
14
86
  async start(opts) {
15
87
  if (this.client)
16
88
  return;
17
- const browser = opts.cdpEndpoint
18
- ? { cdpEndpoint: opts.cdpEndpoint }
19
- : { userDataDir: opts.userDataDir, isolated: opts.isolated, launchOptions: { headless: opts.headless ?? false } };
89
+ let browser;
90
+ if (opts.cdpEndpoint) {
91
+ // Production: attach to the browser the runner already launched. Nothing
92
+ // is created here, so nothing can leak here.
93
+ browser = { cdpEndpoint: opts.cdpEndpoint };
94
+ }
95
+ else {
96
+ // This is the ONE path in this package that produced the `#274` orphans.
97
+ //
98
+ // `isolated: true` makes @playwright/mcp call the non-persistent
99
+ // `chromium.launch()`, and Playwright then mkdtemps
100
+ // `$TMPDIR/playwright_chromiumdev_profile-XXXXXX` and deletes it only on a
101
+ // clean close — so every SIGKILLed parent left both a running browser AND a
102
+ // few hundred MB of profile behind, under a name we did not know and could
103
+ // not clean up afterwards.
104
+ //
105
+ // Asking for a directory WE created gets the same isolation (it is fresh
106
+ // every start) while making the leak addressable: we know the path, we
107
+ // delete it in `stop()`, and it is named so a human can see where it came
108
+ // from. It also keeps the throwaway-profile pattern out of our own output,
109
+ // so the reaper's matches are unambiguously other people's abandonment.
110
+ // Self-heal before adding one more. Chrome writes its final state as it
111
+ // dies — after `stop()`, and after our own `exit` hook — so the last
112
+ // skeleton of a directory can outlive the process that owned it no matter
113
+ // when we try to delete it. Sweeping here means a stale one never survives
114
+ // a second run, without needing the runner to be restarted. Only touches
115
+ // dirs no live process holds; see reaper.ts for the safety argument.
116
+ try {
117
+ sweepStalePlaywrightProfiles();
118
+ }
119
+ catch {
120
+ // cleanup must never stop a browser from starting
121
+ }
122
+ const userDataDir = opts.userDataDir ?? mkdtempSync(join(tmpdir(), "pags-mcp-profile-"));
123
+ if (!opts.userDataDir) {
124
+ this.ownedProfileDir = userDataDir;
125
+ rememberProfileDir(userDataDir);
126
+ }
127
+ browser = { userDataDir, isolated: false, launchOptions: { headless: opts.headless ?? false } };
128
+ }
20
129
  // The runner is a trusted local process uploading the user's OWN résumé, which
21
130
  // lives under the runner's data dir (outside the CWD). Lift the file-root guard
22
131
  // (meant to stop an LLM reading arbitrary host files) so browser_file_upload can
@@ -40,9 +149,22 @@ export class McpRuntime {
40
149
  return (res.content || []).map((c) => c.text ?? "").join("\n");
41
150
  }
42
151
  async stop() {
43
- await this.client?.close().catch(() => undefined);
44
- await this.server?.close().catch(() => undefined);
152
+ // Bounded. Closing a persistent context flushes the whole profile to disk and
153
+ // on a cold/slow machine that measurably exceeds 10s — and a browser wedged
154
+ // mid-close would otherwise block teardown with no upper limit at all, which
155
+ // is the state that ends in someone sending the SIGKILL that orphans it (#274).
156
+ // Returning early is safe: the `exit` hook and reaper.ts's sweep both still run.
157
+ await withTimeout(this.client?.close(), 20_000);
158
+ await withTimeout(this.server?.close(), 20_000);
45
159
  this.client = undefined;
46
160
  this.server = undefined;
161
+ // Remove the profile only if we made it — a caller-supplied dir belongs to
162
+ // the caller. A best-effort pass now, ordered after the closes so the browser
163
+ // has released its locks; the `exit` hook above is the pass that always runs,
164
+ // and reaper.ts's startup sweep covers a process that was killed outright.
165
+ if (this.ownedProfileDir) {
166
+ removeProfileDir(this.ownedProfileDir);
167
+ this.ownedProfileDir = undefined;
168
+ }
47
169
  }
48
170
  }
@@ -0,0 +1,281 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readdirSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ const DEFAULT_MIN_AGE_MS = 10 * 60 * 1000;
6
+ /**
7
+ * A throwaway browser-profile directory name, anchored.
8
+ *
9
+ * Two producers, both of which mkdtemp — so the trailing six characters are
10
+ * exactly what `fs.mkdtemp` appends, and pinning that length is what stops a
11
+ * user-chosen directory which merely CONTAINS this text from matching:
12
+ *
13
+ * - `playwright_<browser>dev_profile-` — Playwright's own, created per
14
+ * non-persistent `launch()`. These are the orphans measured in #274.
15
+ * - `pags-mcp-profile-` — ours, from `McpRuntime` (see mcp-runtime.ts). Chrome
16
+ * can rewrite this directory during its async shutdown, after `stop()` has
17
+ * already removed it, so it needs a sweeper too.
18
+ *
19
+ * A name matching neither is never touched, whatever it contains.
20
+ */
21
+ const TEMP_PROFILE_NAME = /^(?:playwright_[a-z]+dev_profile|pags-mcp-profile)-[A-Za-z0-9]{6}$/;
22
+ /** Every path the system might hand out as the temp root, de-duplicated. */
23
+ export function tempRoots() {
24
+ const roots = new Set();
25
+ for (const root of [tmpdir(), process.env.TMPDIR, "/tmp"]) {
26
+ if (!root)
27
+ continue;
28
+ roots.add(root.replace(/\/+$/, ""));
29
+ }
30
+ return [...roots];
31
+ }
32
+ /**
33
+ * Is this `--user-data-dir` a Playwright throwaway temp profile?
34
+ *
35
+ * Both halves matter. The basename pattern says "Playwright mkdtemp'd this"; the
36
+ * temp-root check says "and it is under the system temp dir, not somewhere a
37
+ * human keeps a real profile". A directory that satisfies only one is rejected.
38
+ */
39
+ export function isPlaywrightTempProfile(userDataDir, roots = tempRoots()) {
40
+ if (!userDataDir)
41
+ return false;
42
+ const dir = userDataDir.replace(/\/+$/, "");
43
+ if (!TEMP_PROFILE_NAME.test(basename(dir)))
44
+ return false;
45
+ const parent = dirname(dir).replace(/\/+$/, "");
46
+ return roots.some((root) => parent === root);
47
+ }
48
+ /** Pull `--user-data-dir=<path>` out of a command line. "" when absent. */
49
+ export function userDataDirOf(command) {
50
+ // Deliberately `\S+`: every path this reaper acts on is a mkdtemp name under
51
+ // the temp root, which never contains a space. A path WITH a space therefore
52
+ // fails to parse and is skipped — the safe direction to be wrong in.
53
+ const m = command.match(/--user-data-dir=(\S+)/);
54
+ return m ? m[1] : "";
55
+ }
56
+ /**
57
+ * Parse `ps -o etime` — `[[DD-]HH:]MM:SS` — into seconds. Returns 0 for anything
58
+ * unparseable, which reads as "brand new" and therefore never reapable.
59
+ */
60
+ export function parseEtime(etime) {
61
+ const trimmed = etime.trim();
62
+ const m = trimmed.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
63
+ if (!m)
64
+ return 0;
65
+ const [, days, hours, minutes, seconds] = m;
66
+ return Number(days ?? 0) * 86400 + Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds);
67
+ }
68
+ /** Parse `ps -wwAo pid=,ppid=,etime=,command=` output into candidate rows. */
69
+ export function parsePsOutput(stdout) {
70
+ const out = [];
71
+ for (const line of stdout.split("\n")) {
72
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
73
+ if (!m)
74
+ continue;
75
+ const userDataDir = userDataDirOf(m[4]);
76
+ if (!userDataDir)
77
+ continue;
78
+ out.push({ pid: Number(m[1]), ppid: Number(m[2]), ageSeconds: parseEtime(m[3]), userDataDir });
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Age of a profile directory's last write, in ms.
84
+ *
85
+ * A MISSING directory returns Infinity, and that is deliberate rather than a
86
+ * fallback: Playwright removes this directory only when the browser closed
87
+ * cleanly. A process still running on a temp profile that no longer exists is
88
+ * unambiguously abandoned.
89
+ */
90
+ export function profileIdleMs(dir, now, stat = safeMtimeMs) {
91
+ const mtime = stat(dir);
92
+ return mtime === null ? Number.POSITIVE_INFINITY : now - mtime;
93
+ }
94
+ function safeMtimeMs(path) {
95
+ try {
96
+ return statSync(path).mtimeMs;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * The whole safety decision, as one pure function — see the SAFETY block above.
104
+ * All four conditions must hold; any one of them failing spares the process.
105
+ */
106
+ export function isReapable(proc, opts) {
107
+ if (proc.ppid !== 1)
108
+ return false;
109
+ if (!isPlaywrightTempProfile(proc.userDataDir, opts.roots))
110
+ return false;
111
+ if (proc.ageSeconds * 1000 < opts.minAgeMs)
112
+ return false;
113
+ const idle = (opts.idleMs ?? profileIdleMs)(proc.userDataDir, opts.now);
114
+ return idle >= opts.minAgeMs;
115
+ }
116
+ function ps() {
117
+ try {
118
+ // -ww: never truncate the command line, or the --user-data-dir we match on
119
+ // could be cut off and a real orphan would go unnoticed.
120
+ return execFileSync("ps", ["-wwAo", "pid=,ppid=,etime=,command="], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
121
+ }
122
+ catch {
123
+ return "";
124
+ }
125
+ }
126
+ /** Every Playwright-temp-profile browser process currently alive, orphan or not. */
127
+ export function listPlaywrightBrowsers(psOutput = ps()) {
128
+ return parsePsOutput(psOutput).filter((p) => isPlaywrightTempProfile(p.userDataDir));
129
+ }
130
+ function signal(pid, sig) {
131
+ try {
132
+ process.kill(pid, sig);
133
+ }
134
+ catch {
135
+ // already gone, or not ours to signal — either way there is nothing to do
136
+ }
137
+ }
138
+ function alive(pid) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * Kill abandoned Playwright browsers and delete their temp profiles.
149
+ *
150
+ * Synchronous on purpose: this runs once at runner startup, before anything else
151
+ * competes for the machine, and the whole point is that the CPU is already gone.
152
+ */
153
+ export function reapOrphanedPlaywrightBrowsers(opts = {}) {
154
+ const log = opts.log ?? ((line) => console.warn(line));
155
+ const empty = { reaped: [], skippedYoung: 0 };
156
+ if (process.platform === "win32")
157
+ return empty; // no `ps`; the leak is a POSIX-orphan story
158
+ if (process.pid === 1) {
159
+ // See SAFETY note 2: as PID 1 we cannot tell an orphan from our own live child.
160
+ return empty;
161
+ }
162
+ const now = opts.now ?? Date.now();
163
+ const minAgeMs = opts.minAgeMs ?? DEFAULT_MIN_AGE_MS;
164
+ const all = listPlaywrightBrowsers();
165
+ const orphanParents = all.filter((p) => p.ppid === 1);
166
+ const doomed = orphanParents.filter((p) => isReapable(p, { now, minAgeMs }));
167
+ const skippedYoung = orphanParents.length - doomed.length;
168
+ if (doomed.length === 0)
169
+ return { reaped: [], skippedYoung };
170
+ const dirs = new Set(doomed.map((p) => p.userDataDir));
171
+ const verb = opts.dryRun ? "would reap" : "reaping";
172
+ log(`[runner] ${verb} ${doomed.length} abandoned Playwright browser(s) (parent gone, idle >${Math.round(minAgeMs / 60000)}m):`);
173
+ for (const p of doomed)
174
+ log(`[runner] pid ${p.pid} profile ${p.userDataDir}`);
175
+ if (opts.dryRun)
176
+ return { reaped: [...dirs], skippedYoung };
177
+ // Ask first. The measurement says they ignore it, but a browser that CAN exit
178
+ // cleanly should be given the chance to flush and remove its own profile.
179
+ for (const p of doomed)
180
+ signal(p.pid, "SIGTERM");
181
+ const deadline = Date.now() + 2000;
182
+ while (Date.now() < deadline && doomed.some((p) => alive(p.pid))) {
183
+ // Busy-wait: this is startup, single-purpose, and bounded at 2s. A timer
184
+ // would need the event loop, and callers want a settled machine on return.
185
+ execFileSync("sleep", ["0.1"], { stdio: "ignore" });
186
+ }
187
+ for (const p of doomed)
188
+ if (alive(p.pid))
189
+ signal(p.pid, "SIGKILL");
190
+ // The renderer/GPU/network helpers are children of the parent we just killed and
191
+ // normally follow it down. Sweep any that did not, matched by the SAME profile
192
+ // dirs we already cleared — never by name, never by executable.
193
+ for (const p of listPlaywrightBrowsers()) {
194
+ if (dirs.has(p.userDataDir))
195
+ signal(p.pid, "SIGKILL");
196
+ }
197
+ for (const dir of dirs) {
198
+ try {
199
+ rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ catch {
202
+ // a leftover directory is untidy, not harmful
203
+ }
204
+ }
205
+ return { reaped: [...dirs], skippedYoung };
206
+ }
207
+ /**
208
+ * Delete Playwright temp profile directories with no live process behind them.
209
+ *
210
+ * Separate from the process reap because the two leak independently: a browser
211
+ * that IS killed by `kill -9` leaves its directory behind with nobody to remove
212
+ * it. These are a few hundred MB each once they have been used.
213
+ */
214
+ export function sweepStalePlaywrightProfiles(opts = {}) {
215
+ if (process.platform === "win32")
216
+ return [];
217
+ const now = opts.now ?? Date.now();
218
+ const minAgeMs = opts.minAgeMs ?? DEFAULT_MIN_AGE_MS;
219
+ const inUse = new Set(listPlaywrightBrowsers().map((p) => p.userDataDir));
220
+ const removed = [];
221
+ for (const root of tempRoots()) {
222
+ let entries;
223
+ try {
224
+ entries = readdirSync(root);
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ for (const name of entries) {
230
+ if (!TEMP_PROFILE_NAME.test(name))
231
+ continue;
232
+ const dir = join(root, name);
233
+ if (inUse.has(dir))
234
+ continue;
235
+ if (profileIdleMs(dir, now) < minAgeMs)
236
+ continue;
237
+ try {
238
+ rmSync(dir, { recursive: true, force: true });
239
+ removed.push(dir);
240
+ }
241
+ catch {
242
+ // best effort
243
+ }
244
+ }
245
+ }
246
+ if (removed.length > 0) {
247
+ (opts.log ?? ((l) => console.warn(l)))(`[runner] removed ${removed.length} stale Playwright temp profile dir(s)`);
248
+ }
249
+ return removed;
250
+ }
251
+ /**
252
+ * The startup entry point: recover a machine that has already leaked.
253
+ *
254
+ * On by default. Default-off would mean the users who most need it — the ones
255
+ * whose machine is already at load 253 — never get it, and the discriminator is
256
+ * narrow enough (see SAFETY) that a false positive would require a real browser
257
+ * to be running out of a Playwright mkdtemp directory, orphaned, and idle.
258
+ * PAGS_RUNNER_REAP=0 disable entirely
259
+ * PAGS_RUNNER_REAP_DRY_RUN=1 report what it would kill, kill nothing
260
+ * PAGS_RUNNER_REAP_MIN_AGE_MIN idle threshold in minutes (default 10)
261
+ */
262
+ export function reapOnStartup(log = (l) => console.warn(l)) {
263
+ if (process.env.PAGS_RUNNER_REAP === "0")
264
+ return;
265
+ const minutes = Number(process.env.PAGS_RUNNER_REAP_MIN_AGE_MIN);
266
+ const minAgeMs = Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : DEFAULT_MIN_AGE_MS;
267
+ const dryRun = process.env.PAGS_RUNNER_REAP_DRY_RUN === "1";
268
+ try {
269
+ const { reaped, skippedYoung } = reapOrphanedPlaywrightBrowsers({ minAgeMs, dryRun, log });
270
+ if (skippedYoung > 0)
271
+ log(`[runner] left ${skippedYoung} orphaned browser(s) alone — not idle long enough yet`);
272
+ if (!dryRun && reaped.length > 0)
273
+ log(`[runner] reaped ${reaped.length} orphaned browser(s); their CPU is yours again`);
274
+ if (!dryRun)
275
+ sweepStalePlaywrightProfiles({ minAgeMs, log });
276
+ }
277
+ catch (err) {
278
+ // Never let cleanup stop the runner from starting.
279
+ log(`[runner] browser reap skipped: ${err instanceof Error ? err.message : String(err)}`);
280
+ }
281
+ }
@@ -180,12 +180,26 @@ export class LocalRunner {
180
180
  void this.endTakeover(id).catch(() => undefined);
181
181
  return task;
182
182
  }
183
+ /**
184
+ * Tear everything down, and never let one failure strand the rest (#274).
185
+ *
186
+ * `browserContext.close()` rejects routinely — the browser crashed, or was
187
+ * already killed. It used to reject straight out of here, which skipped the
188
+ * state reset AND rejected the caller's shutdown, so the process stayed up
189
+ * holding a live browser until someone `kill -9`ed it. That kill is exactly
190
+ * what orphans the browser, so an unswallowed error here MADE the leak.
191
+ */
183
192
  async close() {
184
- this.coding.closeAll();
193
+ try {
194
+ this.coding.closeAll();
195
+ }
196
+ catch {
197
+ // a stuck coding session must not block the browser teardown below
198
+ }
185
199
  await this.mcp?.stop().catch(() => undefined);
186
200
  this.mcp = null;
187
201
  this.cdpEndpoint = null;
188
- await this.browserContext?.close();
202
+ await this.browserContext?.close().catch(() => undefined);
189
203
  this.browserContext = null;
190
204
  this.launchedProfileDir = null;
191
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",