gm-skill 2.0.1503 → 2.0.1505

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.
@@ -0,0 +1,13 @@
1
+ function selectIdleBrowserSessions(ports, now, limitMs) {
2
+ const idle = [];
3
+ if (!ports || typeof ports !== 'object') return idle;
4
+ for (const [sid, entry] of Object.entries(ports)) {
5
+ if (!entry || typeof entry !== 'object') continue;
6
+ const lastUse = Number.isFinite(entry.lastUse) ? entry.lastUse : 0;
7
+ const idleMs = now - lastUse;
8
+ if (idleMs >= limitMs) idle.push({ sid, entry, idleMs });
9
+ }
10
+ return idle;
11
+ }
12
+
13
+ module.exports = { selectIdleBrowserSessions };
@@ -0,0 +1,46 @@
1
+ const assert = require('assert');
2
+ const { selectIdleBrowserSessions } = require('./browser-idle.js');
3
+
4
+ const NOW = 1_000_000;
5
+ const LIMIT = 10 * 60 * 1000;
6
+
7
+ (function onlyPastLimitSelected() {
8
+ const ports = {
9
+ active: { pid: 1, lastUse: NOW - 1000 },
10
+ idle: { pid: 2, lastUse: NOW - (LIMIT + 5000) },
11
+ };
12
+ const idle = selectIdleBrowserSessions(ports, NOW, LIMIT);
13
+ assert.strictEqual(idle.length, 1, 'exactly one idle session selected');
14
+ assert.strictEqual(idle[0].sid, 'idle', 'the idle session is selected, active untouched');
15
+ })();
16
+
17
+ (function boundaryIsInclusive() {
18
+ const ports = { edge: { pid: 1, lastUse: NOW - LIMIT } };
19
+ const idle = selectIdleBrowserSessions(ports, NOW, LIMIT);
20
+ assert.strictEqual(idle.length, 1, 'idleMs == limit closes (>=)');
21
+ })();
22
+
23
+ (function missingLastUseReapedAsStale() {
24
+ const ports = { orphan: { pid: 1 } };
25
+ const idle = selectIdleBrowserSessions(ports, NOW, LIMIT);
26
+ assert.strictEqual(idle.length, 1, 'entry with no lastUse is treated as stale (epoch 0) and reaped');
27
+ assert.strictEqual(idle[0].sid, 'orphan');
28
+ })();
29
+
30
+ (function concurrentIsolation() {
31
+ const ports = {
32
+ sessA: { pid: 1, lastUse: NOW - 2000 },
33
+ sessB: { pid: 2, lastUse: NOW - (LIMIT + 1) },
34
+ sessC: { pid: 3, lastUse: NOW - (LIMIT + 999999) },
35
+ };
36
+ const idle = selectIdleBrowserSessions(ports, NOW, LIMIT).map(x => x.sid).sort();
37
+ assert.deepStrictEqual(idle, ['sessB', 'sessC'], 'only the idle sessions, active sessA preserved');
38
+ })();
39
+
40
+ (function emptyAndMalformed() {
41
+ assert.deepStrictEqual(selectIdleBrowserSessions({}, NOW, LIMIT), [], 'empty ports');
42
+ assert.deepStrictEqual(selectIdleBrowserSessions(null, NOW, LIMIT), [], 'null ports');
43
+ assert.deepStrictEqual(selectIdleBrowserSessions({ bad: null, str: 'x' }, NOW, LIMIT), [], 'malformed entries skipped');
44
+ })();
45
+
46
+ console.log('browser-idle.test.js: all assertions passed');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1503",
3
+ "version": "2.0.1505",
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": {
@@ -563,6 +563,31 @@ function browserStateDir(cwd) {
563
563
  function browserPortsFile(cwd) { return path.join(browserStateDir(cwd), 'browser-ports.json'); }
564
564
  function browserSessionsFile(cwd) { return path.join(browserStateDir(cwd), 'browser-sessions.json'); }
565
565
 
566
+ function selectIdleBrowserSessions(ports, now, limitMs) {
567
+ const idle = [];
568
+ if (!ports || typeof ports !== 'object') return idle;
569
+ for (const [sid, entry] of Object.entries(ports)) {
570
+ if (!entry || typeof entry !== 'object') continue;
571
+ const lastUse = Number.isFinite(entry.lastUse) ? entry.lastUse : 0;
572
+ const idleMs = now - lastUse;
573
+ if (idleMs >= limitMs) idle.push({ sid, entry, idleMs });
574
+ }
575
+ return idle;
576
+ }
577
+
578
+ function stampBrowserLastUse(cwd, claudeSessionId) {
579
+ try {
580
+ const portsFile = browserPortsFile(cwd);
581
+ const ports = readJsonFile(portsFile, {});
582
+ const entry = ports[claudeSessionId];
583
+ if (entry && typeof entry === 'object') {
584
+ entry.lastUse = Date.now();
585
+ ports[claudeSessionId] = entry;
586
+ writeJsonFile(portsFile, ports);
587
+ }
588
+ } catch (_) {}
589
+ }
590
+
566
591
  function atomicWriteJson(filePath, obj) {
567
592
  const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(36).slice(2, 8);
568
593
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
@@ -1020,6 +1045,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1020
1045
  const sid = parseSessionId(r.stdout || '');
1021
1046
  if (sid) {
1022
1047
  existing.pwSessionId = sid;
1048
+ existing.lastUse = Date.now();
1023
1049
  ports[claudeSessionId] = existing;
1024
1050
  sessions[claudeSessionId] = [sid];
1025
1051
  writeJsonFile(portsFile, ports);
@@ -1092,7 +1118,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1092
1118
  logEvent('plugkit', 'browser.launch-failed', { reason: 'session-id-unparseable', stdout: r.stdout });
1093
1119
  throw new Error(`could not parse managed browser session id from: ${scrubBrowserRunnerText(r.stdout || '')}`);
1094
1120
  }
1095
- ports[claudeSessionId] = { profileDir, pid: browserPid, port, wsEndpoint, pwSessionId };
1121
+ ports[claudeSessionId] = { profileDir, pid: browserPid, port, wsEndpoint, pwSessionId, lastUse: Date.now() };
1096
1122
  sessions[claudeSessionId] = [pwSessionId];
1097
1123
  writeJsonFile(portsFile, ports);
1098
1124
  writeJsonFile(sessionsFile, sessions);
@@ -1833,7 +1859,7 @@ function makeHostFunctions(instanceRef) {
1833
1859
 
1834
1860
  if (trimmed === 'session new' || trimmed === '') {
1835
1861
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
1836
- try { lastBrowserActivityMs = Date.now(); } catch (_) {}
1862
+ stampBrowserLastUse(cwd, sessionId);
1837
1863
  return writeWasmJson(instanceRef.value, {
1838
1864
  ok: true,
1839
1865
  stdout: `Session ${pwSessionId} attached to locally-profiled chromium at ${path.join(cwd, '.gm', 'browser-profile')}`,
@@ -1845,12 +1871,26 @@ function makeHostFunctions(instanceRef) {
1845
1871
 
1846
1872
  if (trimmed.startsWith('session ')) {
1847
1873
  const parts = trimmed.slice(8).trim().split(/\s+/);
1848
- // playwriter's actual session subcommands are list|new|delete|reset.
1849
- // Map the legacy close/kill aliases (recognized here historically) to delete,
1850
- // and pass list/new/delete/reset through verbatim. Anything else is rejected
1851
- // by playwriter with its own usage text, which is the correct surface.
1852
1874
  if (parts[0] === 'close' || parts[0] === 'kill') parts[0] = 'delete';
1853
1875
  const r = runBrowserRunner(pw, ['session', ...parts], 30000, cwd, sessionId);
1876
+ if (r.status === 0 && (parts[0] === 'delete' || parts[0] === 'reset')) {
1877
+ try {
1878
+ const portsFile = browserPortsFile(cwd);
1879
+ const sessionsFile = browserSessionsFile(cwd);
1880
+ const ports = readJsonFile(portsFile, {});
1881
+ const sessions = readJsonFile(sessionsFile, {});
1882
+ const entry = ports[sessionId];
1883
+ if (entry && typeof entry === 'object') {
1884
+ if (Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
1885
+ gracefulCloseBrowser(entry, `session-${parts[0]}`);
1886
+ }
1887
+ delete ports[sessionId];
1888
+ delete sessions[sessionId];
1889
+ writeJsonFile(portsFile, ports);
1890
+ writeJsonFile(sessionsFile, sessions);
1891
+ }
1892
+ } catch (_) {}
1893
+ }
1854
1894
  return writeWasmJson(instanceRef.value, {
1855
1895
  ok: r.status === 0,
1856
1896
  stdout: scrubBrowserRunnerText(r.stdout || ''),
@@ -1860,7 +1900,7 @@ function makeHostFunctions(instanceRef) {
1860
1900
  }
1861
1901
 
1862
1902
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
1863
- try { lastBrowserActivityMs = Date.now(); } catch (_) {}
1903
+ stampBrowserLastUse(cwd, sessionId);
1864
1904
  let evalBody = body;
1865
1905
  let timeoutMs = 14000;
1866
1906
  const timeoutMatch = body.match(/^timeout=(\d+)\s*\n([\s\S]*)$/);
@@ -2538,27 +2578,49 @@ async function runSpoolWatcher(instance, spoolDir) {
2538
2578
  }
2539
2579
  }, 60_000);
2540
2580
 
2541
- const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 60 * 60 * 1000;
2542
- let lastBrowserActivityMs = Date.now();
2581
+ const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 10 * 60 * 1000;
2543
2582
  setInterval(() => {
2544
2583
  try {
2545
- const browserIdleMs = Date.now() - lastBrowserActivityMs;
2546
- if (browserIdleMs < BROWSER_IDLE_LIMIT_MS) return;
2547
2584
  const portsFile = browserPortsFile(process.cwd());
2548
2585
  const sessionsFile = browserSessionsFile(process.cwd());
2549
2586
  const ports = readJsonFile(portsFile, {});
2550
- let closed = 0;
2587
+ const sessions = readJsonFile(sessionsFile, {});
2588
+ const now = Date.now();
2589
+ const idle = selectIdleBrowserSessions(ports, now, BROWSER_IDLE_LIMIT_MS);
2590
+ const idleSids = new Set(idle.map((x) => x.sid));
2591
+ let mutated = false;
2592
+ for (const { sid, entry, idleMs } of idle) {
2593
+ if (Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
2594
+ try { gracefulCloseBrowser(entry, 'browser-idle'); } catch (_) {}
2595
+ }
2596
+ delete ports[sid];
2597
+ delete sessions[sid];
2598
+ mutated = true;
2599
+ logEvent('plugkit', 'browser.idle-closed', { sid, pid: entry.pid || null, idle_ms: idleMs });
2600
+ }
2551
2601
  for (const [sid, entry] of Object.entries(ports)) {
2552
- if (entry && Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
2553
- try { gracefulCloseBrowser(entry, 'browser-idle'); closed++; } catch (_) {}
2602
+ if (idleSids.has(sid) || !entry || typeof entry !== 'object') continue;
2603
+ const pidAlive = Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid);
2604
+ if (!pidAlive) {
2605
+ delete ports[sid];
2606
+ delete sessions[sid];
2607
+ mutated = true;
2608
+ logEvent('plugkit', 'browser.stale-reclaimed', { sid, pid: entry.pid || null, reason: 'pid-dead' });
2609
+ continue;
2610
+ }
2611
+ const cdpOk = !!fetchJsonSync(`http://127.0.0.1:${entry.port}/json/version`, 1000);
2612
+ if (!cdpOk) {
2613
+ try { gracefulCloseBrowser(entry, 'orphan-cdp-dead'); } catch (_) {}
2614
+ delete ports[sid];
2615
+ delete sessions[sid];
2616
+ mutated = true;
2617
+ logEvent('plugkit', 'browser.stale-reclaimed', { sid, pid: entry.pid || null, reason: 'cdp-dead' });
2554
2618
  }
2555
2619
  }
2556
- if (closed > 0) {
2557
- try { fs.unlinkSync(portsFile); } catch (_) {}
2558
- try { fs.unlinkSync(sessionsFile); } catch (_) {}
2559
- logEvent('plugkit', 'browser.idle-closed', { count: closed, idle_ms: browserIdleMs });
2620
+ if (mutated) {
2621
+ try { writeJsonFile(portsFile, ports); } catch (_) {}
2622
+ try { writeJsonFile(sessionsFile, sessions); } catch (_) {}
2560
2623
  }
2561
- lastBrowserActivityMs = Date.now();
2562
2624
  } catch (e) {
2563
2625
  console.error(`[browser-idle] error: ${e.message}`);
2564
2626
  }
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1503",
3
+ "version": "2.0.1505",
4
4
  "description": "Spool-dispatch orchestration engine with unified state machine, skills, and automated git enforcement",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
package/lib/browser.js CHANGED
@@ -115,17 +115,22 @@ async function closeSession(sessionId) {
115
115
  return { ok: true, sessionId };
116
116
  }
117
117
 
118
- async function closeAllSessions(excludeSessionId = null) {
118
+ async function closeAllSessions(opts = {}) {
119
119
  if (!fs.existsSync(SESSION_STATE_DIR)) return { ok: true, closed: 0 };
120
+ const all = opts && opts.all === true;
121
+ const only = opts && typeof opts.sessionId === 'string' ? opts.sessionId : null;
122
+ if (!all && !only) {
123
+ return { ok: false, error: 'closeAllSessions is session-scoped: pass {sessionId} to close one, or {all:true} to close every session in this state dir. The per-session idle timer auto-closes idle browsers and they re-open seamlessly on next use, so a blanket close-all is no longer routine cleanup.', closed: 0 };
124
+ }
120
125
  const files = fs.readdirSync(SESSION_STATE_DIR);
121
126
  let closed = 0;
122
127
  for (const file of files) {
123
128
  if (!file.endsWith('.json')) continue;
124
129
  const sessionId = file.replace('.json', '');
125
- if (excludeSessionId && sessionId === excludeSessionId) continue;
130
+ if (only && sessionId !== only) continue;
126
131
  try { await closeSession(sessionId); closed++; } catch (e) {}
127
132
  }
128
- return { ok: true, closed };
133
+ return { ok: true, closed, scope: only ? 'session' : 'all' };
129
134
  }
130
135
 
131
136
  module.exports = { createSession, sendCommand, executeScript, getScreenshot, closeSession, closeAllSessions, isBrowserAvailable, loadSessionState, saveSessionState };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-skill",
3
- "version": "2.0.1503",
3
+ "version": "2.0.1505",
4
4
  "description": "Canonical universal harness — AI-native software engineering via skill-driven orchestration; bootstraps plugkit for task execution and session isolation. Install in any AI coding agent host.",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",