gm-plugkit 2.0.1633 → 2.0.1635

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.
@@ -44,9 +44,13 @@ dom=<css-selector>\n
44
44
 
45
45
  **`dom=<css-selector>\n` is the zero-boilerplate element probe.** Returns `{selector, match_count, elements:[{tag, text, attrs, visible, rect}]}` for up to 20 matches -- the fastest answer to "is this element there and what does it say." An invalid selector returns `result.error` (no crash). Composes with `url=`.
46
46
 
47
+ **One session per run -- reuse it, then close it.** A browser session is keyed by its spool `sessionId`; every dispatch carrying the SAME sessionId reuses the SAME chromium. A DIFFERENT sessionId opens its OWN chromium -- so a run that invents `probe`/`w2`/`w3`/... names leaks one browser per name. Pick one sessionId, use it for every dispatch, and end with `session close` so nothing is left open; the eval envelope carries a `multi_session_warning` the moment a second distinct session opens. The idle reaper (closes sessions unused past the idle window) and the OS-orphan reaper (kills managed chromiums no live session owns, sparing in-use ones and your own Chrome) are backstops for crashes, not a license to leave sessions open -- close yours.
48
+
49
+ **The session closes when YOU expect it to, not under you.** A session stays open across turns and think-gaps -- the idle window is generous (15 min of no use), measured from the END of your last dispatch, so a long read or a slow eval never shortens it. A dispatch in flight is never closed mid-run: the idle reaper and the orphan reaper both skip a session while its eval is executing, and a just-launched browser has a grace period before any reaper can touch it. An explicit `session close` is immediate. If the idle/orphan backstop did close a session and you dispatch to it again, it transparently re-launches and the envelope carries `session_relaunched: true` with a `relaunch_note` -- your in-page `window.*` state was reset, so re-establish it; you are told, never silently surprised.
50
+
47
51
  ## Envelope
48
52
 
49
- `{ok, stdout, stderr, exit_code, session_id?, navigation_requested, landed_on_blank?, hint?}`. `stdout` = stringified eval result; `stderr` = page errors + launch diagnostics; `exit_code` non-zero = the dispatch did not land -- read `stderr` and re-dispatch, never blind. `navigation_requested` reflects whether the dispatch carried a `url=`/bare-URL navigation; `landed_on_blank: true` with a `hint` means the expression ran against `about:blank` -- prefix `url=<target>` and re-dispatch.
53
+ `{ok, stdout, stderr, exit_code, session_id?, navigation_requested, landed_on_blank?, hint?, multi_session_warning?}`. `stdout` = stringified eval result; `stderr` = page errors + launch diagnostics; `exit_code` non-zero = the dispatch did not land -- read `stderr` and re-dispatch, never blind. `navigation_requested` reflects whether the dispatch carried a `url=`/bare-URL navigation; `landed_on_blank: true` with a `hint` means the expression ran against `about:blank` -- prefix `url=<target>` and re-dispatch.
50
54
 
51
55
  ## Headed by default
52
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1633",
3
+ "version": "2.0.1635",
4
4
  "description": "Bootstrap and daemon-spawn tool for gm plugkit binary. Downloads the correct platform binary, verifies SHA256, and starts the spool watcher daemon. Includes plugkit-wasm-wrapper for WASM-based spool watching.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -873,6 +873,97 @@ function reapOrphanBrowserSessions(pw, cwd, claudeSessionId, reason) {
873
873
  }
874
874
  }
875
875
 
876
+ const __openedSessionIds = new Set();
877
+ const __idleClosedSessions = new Set();
878
+ const __inflightDispatch = new Map();
879
+ const __launchingPids = new Map();
880
+ const INFLIGHT_MAX_MS = 130000;
881
+ const LAUNCH_GRACE_MS = 30000;
882
+ function markInflight(sessionId, pid) {
883
+ __inflightDispatch.set(sessionId, { pid: pid || null, ts: Date.now() });
884
+ }
885
+ function clearInflight(sessionId) {
886
+ __inflightDispatch.delete(sessionId);
887
+ }
888
+ function inflightPids() {
889
+ const now = Date.now();
890
+ const pids = new Set();
891
+ const sids = new Set();
892
+ for (const [sid, v] of __inflightDispatch) {
893
+ if (now - v.ts > INFLIGHT_MAX_MS) { __inflightDispatch.delete(sid); continue; }
894
+ sids.add(sid);
895
+ if (Number.isFinite(v.pid)) pids.add(v.pid);
896
+ }
897
+ return { pids, sids };
898
+ }
899
+ function markLaunching(pid) { if (Number.isFinite(pid)) __launchingPids.set(pid, Date.now()); }
900
+ function clearLaunching(pid) { __launchingPids.delete(pid); }
901
+ function launchingPidsFresh() {
902
+ const now = Date.now();
903
+ const pids = new Set();
904
+ for (const [pid, ts] of __launchingPids) {
905
+ if (now - ts > LAUNCH_GRACE_MS) { __launchingPids.delete(pid); continue; }
906
+ pids.add(pid);
907
+ }
908
+ return pids;
909
+ }
910
+ function enumerateManagedChromiums(profileRootMarker) {
911
+ const marker = String(profileRootMarker || '').toLowerCase().replace(/\\/g, '/');
912
+ const out = [];
913
+ try {
914
+ if (process.platform === 'win32') {
915
+ const ps = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -like '*--remote-debugging-port*' -and $_.CommandLine -like '*browser-profile*' -and $_.CommandLine -notlike '*--type=*' } | ForEach-Object { $_.ProcessId.ToString() + '|' + $_.CommandLine }`;
916
+ const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { encoding: 'utf-8', windowsHide: true, timeout: 10000 });
917
+ if (r.status === 0 && r.stdout) {
918
+ for (const line of r.stdout.split(/\r?\n/).filter(Boolean)) {
919
+ const bar = line.indexOf('|');
920
+ if (bar < 0) continue;
921
+ const pid = parseInt(line.slice(0, bar), 10);
922
+ const cmd = line.slice(bar + 1);
923
+ if (/--type=/.test(cmd)) continue;
924
+ if (Number.isFinite(pid) && cmd.toLowerCase().replace(/\\/g, '/').includes(marker)) out.push({ pid, cmd });
925
+ }
926
+ }
927
+ } else {
928
+ const r = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf-8', timeout: 10000 });
929
+ if (r.status === 0 && r.stdout) {
930
+ for (const line of r.stdout.split('\n').slice(1)) {
931
+ if (!/--remote-debugging-port/.test(line) || !/browser-profile/.test(line)) continue;
932
+ if (/--type=/.test(line)) continue;
933
+ const m = line.match(/^\s*(\d+)\s+(.+)$/);
934
+ if (!m) continue;
935
+ const pid = parseInt(m[1], 10);
936
+ if (Number.isFinite(pid) && m[2].toLowerCase().replace(/\\/g, '/').includes(marker)) out.push({ pid, cmd: m[2] });
937
+ }
938
+ }
939
+ }
940
+ } catch (_) {}
941
+ return out;
942
+ }
943
+ function reapOrphanChromiums(cwd, reason) {
944
+ try {
945
+ const root = browserRootDir(cwd);
946
+ const marker = path.join(root, '.gm', 'browser-profile').toLowerCase().replace(/\\/g, '/');
947
+ const procs = enumerateManagedChromiums(marker);
948
+ if (procs.length === 0) return { reaped: 0 };
949
+ const ports = readJsonFile(browserPortsFile(cwd), {});
950
+ const livePids = new Set();
951
+ for (const ent of Object.values(ports)) {
952
+ if (ent && Number.isFinite(ent.pid) && isProcessAliveSync(ent.pid)) livePids.add(ent.pid);
953
+ }
954
+ const { pids: protectedInflight } = inflightPids();
955
+ const launching = launchingPidsFresh();
956
+ let reaped = 0;
957
+ for (const { pid } of procs) {
958
+ if (livePids.has(pid) || protectedInflight.has(pid) || launching.has(pid)) continue;
959
+ try { killPidQuiet(pid); reaped++; logEvent('plugkit', 'browser.os-orphan-reaped', { pid, reason: reason || 'sweep' }); } catch (_) {}
960
+ }
961
+ return { reaped };
962
+ } catch (_) {
963
+ return { reaped: 0 };
964
+ }
965
+ }
966
+
876
967
  function resolveWindowsExeLocal(cmd) {
877
968
  if (process.platform !== 'win32') return cmd;
878
969
  try {
@@ -1292,6 +1383,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1292
1383
  logEvent('plugkit', 'browser.start', { profileDir });
1293
1384
  ({ pid: browserPid, port, wsEndpoint } = startManagedBrowser(pw, profileDir));
1294
1385
  }
1386
+ markLaunching(browserPid);
1295
1387
  const r = runBrowserRunner(pw, ['session', 'new', '--direct', wsEndpoint], 30000, cwd, claudeSessionId);
1296
1388
  if (!r || r.status !== 0) {
1297
1389
  const errTxt = scrubBrowserRunnerText((r && (r.stderr || r.stdout)) || 'unknown');
@@ -1307,6 +1399,11 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1307
1399
  sessions[claudeSessionId] = [pwSessionId];
1308
1400
  writeJsonFile(portsFile, ports);
1309
1401
  writeJsonFile(sessionsFile, sessions);
1402
+ clearLaunching(browserPid);
1403
+ if (!__openedSessionIds.has(claudeSessionId) && __openedSessionIds.size >= 1) {
1404
+ logEvent('hook', 'deviation.browser-multi-session', { sid: claudeSessionId, already_open: Array.from(__openedSessionIds), reason: 'a 2nd distinct browser sessionId launched its own chromium this run -- reuse one session per run and close it when done' });
1405
+ }
1406
+ __openedSessionIds.add(claudeSessionId);
1310
1407
  logEvent('plugkit', 'browser.attached', { pwSessionId, pid: browserPid, port });
1311
1408
  return pwSessionId;
1312
1409
  } finally { releaseSpawnLock(); }
@@ -2187,6 +2284,7 @@ function makeHostFunctions(instanceRef) {
2187
2284
  stderr: '',
2188
2285
  exit_code: 0,
2189
2286
  session_id: pwSessionId,
2287
+ hint: 'Reuse this same session for every browser dispatch this run (the spool sessionId selects it); a different sessionId opens its OWN chromium. Close it with `session close` when done -- the idle/orphan reaper is only a backstop.',
2190
2288
  });
2191
2289
  }
2192
2290
 
@@ -2220,8 +2318,13 @@ function makeHostFunctions(instanceRef) {
2220
2318
  });
2221
2319
  }
2222
2320
 
2321
+ const wasIdleClosed = __idleClosedSessions.has(sessionId);
2223
2322
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
2323
+ const curPid = (() => { try { const e = readJsonFile(browserPortsFile(cwd), {})[sessionId]; return e && e.pid; } catch (_) { return null; } })();
2324
+ const wasRelaunched = wasIdleClosed;
2325
+ __idleClosedSessions.delete(sessionId);
2224
2326
  stampBrowserLastUse(cwd, sessionId);
2327
+ markInflight(sessionId, curPid);
2225
2328
  let evalBody = body;
2226
2329
  let timeoutMs = 120000;
2227
2330
  const timeoutMatch = body.match(/^timeout=(\d+)\s*\n([\s\S]*)$/);
@@ -2348,7 +2451,13 @@ function makeHostFunctions(instanceRef) {
2348
2451
  evalBody = `const __RET=await (async()=>{${evalBody}})();\n` + emitResult + `return __RET;`;
2349
2452
  }
2350
2453
  const outerTimeoutMs = Math.min(timeoutMs + 6000, 126000);
2351
- const r = runBrowserRunner(pw, ['-s', pwSessionId, '--timeout', String(timeoutMs), '-e', evalBody], outerTimeoutMs, cwd, sessionId);
2454
+ let r;
2455
+ try {
2456
+ r = runBrowserRunner(pw, ['-s', pwSessionId, '--timeout', String(timeoutMs), '-e', evalBody], outerTimeoutMs, cwd, sessionId);
2457
+ } finally {
2458
+ clearInflight(sessionId);
2459
+ stampBrowserLastUse(cwd, sessionId);
2460
+ }
2352
2461
  const ok = r.status === 0;
2353
2462
  if (!ok && r.status === null) {
2354
2463
  logEvent('plugkit', 'browser.runner-timeout', { session_id: pwSessionId, timeout_ms: timeoutMs, body_bytes: evalBody.length });
@@ -2375,7 +2484,14 @@ function makeHostFunctions(instanceRef) {
2375
2484
  timeout_ms_used: timeoutMs,
2376
2485
  };
2377
2486
  if (resultParsed) envelope.result = parsedResult;
2487
+ if (wasRelaunched) {
2488
+ envelope.session_relaunched = true;
2489
+ envelope.relaunch_note = 'This session was closed (idle/orphan reaper) and re-launched fresh -- any window.* globals or in-page state from earlier dispatches are gone. Re-establish them before relying on them.';
2490
+ }
2378
2491
  envelope.navigation_requested = !!startUrl;
2492
+ if (__openedSessionIds.size > 1) {
2493
+ envelope.multi_session_warning = `${__openedSessionIds.size} distinct browser sessions opened this run, each its own chromium -- reuse ONE sessionId per run and 'session close' it when done to avoid leaking browsers.`;
2494
+ }
2379
2495
  if (landedOnBlank) {
2380
2496
  envelope.landed_on_blank = true;
2381
2497
  envelope.hint = "page is about:blank: this dispatch did not navigate, so the expression evaluated against an empty page. Prefix the body with 'url=<target>' (or send a bare 'https://...' URL) to open the page you want before evaluating.";
@@ -2488,6 +2604,7 @@ async function runSpoolWatcher(instance, spoolDir) {
2488
2604
  } catch (_) {}
2489
2605
 
2490
2606
  try { reapOrphanBrowserSessions(findBrowserRunner(), process.cwd(), process.env.CLAUDE_SESSION_ID || 'claude-loop-iter', 'watcher-boot'); } catch (_) {}
2607
+ try { reapOrphanChromiums(process.cwd(), 'watcher-boot'); } catch (_) {}
2491
2608
 
2492
2609
 
2493
2610
  const LOCK_PATH = path.join(spoolDir, '.watcher.lock');
@@ -2759,6 +2876,7 @@ async function runSpoolWatcher(instance, spoolDir) {
2759
2876
  }
2760
2877
  try { fs.unlinkSync(portsFile); } catch (_) {}
2761
2878
  try { fs.unlinkSync(sessionsFile); } catch (_) {}
2879
+ try { __inflightDispatch.clear(); __launchingPids.clear(); reapOrphanChromiums(process.cwd(), `teardown:${reason}`); } catch (_) {}
2762
2880
  } catch (_) {}
2763
2881
 
2764
2882
  try {
@@ -3124,7 +3242,7 @@ async function runSpoolWatcher(instance, spoolDir) {
3124
3242
  }
3125
3243
  }, 60_000);
3126
3244
 
3127
- const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 10 * 60 * 1000;
3245
+ const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 15 * 60 * 1000;
3128
3246
  setInterval(() => {
3129
3247
  try {
3130
3248
  const portsFile = browserPortsFile(process.cwd());
@@ -3132,13 +3250,15 @@ async function runSpoolWatcher(instance, spoolDir) {
3132
3250
  const ports = readJsonFile(portsFile, {});
3133
3251
  const sessions = readJsonFile(sessionsFile, {});
3134
3252
  const now = Date.now();
3135
- const idle = selectIdleBrowserSessions(ports, now, BROWSER_IDLE_LIMIT_MS);
3253
+ const { sids: inflightSids } = inflightPids();
3254
+ const idle = selectIdleBrowserSessions(ports, now, BROWSER_IDLE_LIMIT_MS).filter((x) => !inflightSids.has(x.sid));
3136
3255
  const idleSids = new Set(idle.map((x) => x.sid));
3137
3256
  let mutated = false;
3138
3257
  for (const { sid, entry, idleMs } of idle) {
3139
3258
  if (Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
3140
3259
  try { gracefulCloseBrowser(entry, 'browser-idle'); } catch (_) {}
3141
3260
  }
3261
+ try { __idleClosedSessions.add(sid); } catch (_) {}
3142
3262
  delete ports[sid];
3143
3263
  delete sessions[sid];
3144
3264
  mutated = true;
@@ -3168,6 +3288,7 @@ async function runSpoolWatcher(instance, spoolDir) {
3168
3288
  try { writeJsonFile(sessionsFile, sessions); } catch (_) {}
3169
3289
  }
3170
3290
  try { reapOrphanBrowserSessions(findBrowserRunner(), process.cwd(), process.env.CLAUDE_SESSION_ID || 'claude-loop-iter', 'idle-sweep'); } catch (_) {}
3291
+ try { reapOrphanChromiums(process.cwd(), 'idle-sweep'); } catch (_) {}
3171
3292
  } catch (e) {
3172
3293
  console.error(`[browser-idle] error: ${e.message}`);
3173
3294
  }