gm-skill 2.0.1503 → 2.0.1504

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.1504",
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,21 @@ 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
+ const { selectIdleBrowserSessions } = require('./browser-idle.js');
567
+
568
+ function stampBrowserLastUse(cwd, claudeSessionId) {
569
+ try {
570
+ const portsFile = browserPortsFile(cwd);
571
+ const ports = readJsonFile(portsFile, {});
572
+ const entry = ports[claudeSessionId];
573
+ if (entry && typeof entry === 'object') {
574
+ entry.lastUse = Date.now();
575
+ ports[claudeSessionId] = entry;
576
+ writeJsonFile(portsFile, ports);
577
+ }
578
+ } catch (_) {}
579
+ }
580
+
566
581
  function atomicWriteJson(filePath, obj) {
567
582
  const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(36).slice(2, 8);
568
583
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
@@ -1020,6 +1035,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1020
1035
  const sid = parseSessionId(r.stdout || '');
1021
1036
  if (sid) {
1022
1037
  existing.pwSessionId = sid;
1038
+ existing.lastUse = Date.now();
1023
1039
  ports[claudeSessionId] = existing;
1024
1040
  sessions[claudeSessionId] = [sid];
1025
1041
  writeJsonFile(portsFile, ports);
@@ -1092,7 +1108,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1092
1108
  logEvent('plugkit', 'browser.launch-failed', { reason: 'session-id-unparseable', stdout: r.stdout });
1093
1109
  throw new Error(`could not parse managed browser session id from: ${scrubBrowserRunnerText(r.stdout || '')}`);
1094
1110
  }
1095
- ports[claudeSessionId] = { profileDir, pid: browserPid, port, wsEndpoint, pwSessionId };
1111
+ ports[claudeSessionId] = { profileDir, pid: browserPid, port, wsEndpoint, pwSessionId, lastUse: Date.now() };
1096
1112
  sessions[claudeSessionId] = [pwSessionId];
1097
1113
  writeJsonFile(portsFile, ports);
1098
1114
  writeJsonFile(sessionsFile, sessions);
@@ -1833,7 +1849,7 @@ function makeHostFunctions(instanceRef) {
1833
1849
 
1834
1850
  if (trimmed === 'session new' || trimmed === '') {
1835
1851
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
1836
- try { lastBrowserActivityMs = Date.now(); } catch (_) {}
1852
+ stampBrowserLastUse(cwd, sessionId);
1837
1853
  return writeWasmJson(instanceRef.value, {
1838
1854
  ok: true,
1839
1855
  stdout: `Session ${pwSessionId} attached to locally-profiled chromium at ${path.join(cwd, '.gm', 'browser-profile')}`,
@@ -1845,12 +1861,26 @@ function makeHostFunctions(instanceRef) {
1845
1861
 
1846
1862
  if (trimmed.startsWith('session ')) {
1847
1863
  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
1864
  if (parts[0] === 'close' || parts[0] === 'kill') parts[0] = 'delete';
1853
1865
  const r = runBrowserRunner(pw, ['session', ...parts], 30000, cwd, sessionId);
1866
+ if (r.status === 0 && (parts[0] === 'delete' || parts[0] === 'reset')) {
1867
+ try {
1868
+ const portsFile = browserPortsFile(cwd);
1869
+ const sessionsFile = browserSessionsFile(cwd);
1870
+ const ports = readJsonFile(portsFile, {});
1871
+ const sessions = readJsonFile(sessionsFile, {});
1872
+ const entry = ports[sessionId];
1873
+ if (entry && typeof entry === 'object') {
1874
+ if (Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
1875
+ gracefulCloseBrowser(entry, `session-${parts[0]}`);
1876
+ }
1877
+ delete ports[sessionId];
1878
+ delete sessions[sessionId];
1879
+ writeJsonFile(portsFile, ports);
1880
+ writeJsonFile(sessionsFile, sessions);
1881
+ }
1882
+ } catch (_) {}
1883
+ }
1854
1884
  return writeWasmJson(instanceRef.value, {
1855
1885
  ok: r.status === 0,
1856
1886
  stdout: scrubBrowserRunnerText(r.stdout || ''),
@@ -1860,7 +1890,7 @@ function makeHostFunctions(instanceRef) {
1860
1890
  }
1861
1891
 
1862
1892
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
1863
- try { lastBrowserActivityMs = Date.now(); } catch (_) {}
1893
+ stampBrowserLastUse(cwd, sessionId);
1864
1894
  let evalBody = body;
1865
1895
  let timeoutMs = 14000;
1866
1896
  const timeoutMatch = body.match(/^timeout=(\d+)\s*\n([\s\S]*)$/);
@@ -2538,27 +2568,49 @@ async function runSpoolWatcher(instance, spoolDir) {
2538
2568
  }
2539
2569
  }, 60_000);
2540
2570
 
2541
- const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 60 * 60 * 1000;
2542
- let lastBrowserActivityMs = Date.now();
2571
+ const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 10 * 60 * 1000;
2543
2572
  setInterval(() => {
2544
2573
  try {
2545
- const browserIdleMs = Date.now() - lastBrowserActivityMs;
2546
- if (browserIdleMs < BROWSER_IDLE_LIMIT_MS) return;
2547
2574
  const portsFile = browserPortsFile(process.cwd());
2548
2575
  const sessionsFile = browserSessionsFile(process.cwd());
2549
2576
  const ports = readJsonFile(portsFile, {});
2550
- let closed = 0;
2577
+ const sessions = readJsonFile(sessionsFile, {});
2578
+ const now = Date.now();
2579
+ const idle = selectIdleBrowserSessions(ports, now, BROWSER_IDLE_LIMIT_MS);
2580
+ const idleSids = new Set(idle.map((x) => x.sid));
2581
+ let mutated = false;
2582
+ for (const { sid, entry, idleMs } of idle) {
2583
+ if (Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
2584
+ try { gracefulCloseBrowser(entry, 'browser-idle'); } catch (_) {}
2585
+ }
2586
+ delete ports[sid];
2587
+ delete sessions[sid];
2588
+ mutated = true;
2589
+ logEvent('plugkit', 'browser.idle-closed', { sid, pid: entry.pid || null, idle_ms: idleMs });
2590
+ }
2551
2591
  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 (_) {}
2592
+ if (idleSids.has(sid) || !entry || typeof entry !== 'object') continue;
2593
+ const pidAlive = Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid);
2594
+ if (!pidAlive) {
2595
+ delete ports[sid];
2596
+ delete sessions[sid];
2597
+ mutated = true;
2598
+ logEvent('plugkit', 'browser.stale-reclaimed', { sid, pid: entry.pid || null, reason: 'pid-dead' });
2599
+ continue;
2600
+ }
2601
+ const cdpOk = !!fetchJsonSync(`http://127.0.0.1:${entry.port}/json/version`, 1000);
2602
+ if (!cdpOk) {
2603
+ try { gracefulCloseBrowser(entry, 'orphan-cdp-dead'); } catch (_) {}
2604
+ delete ports[sid];
2605
+ delete sessions[sid];
2606
+ mutated = true;
2607
+ logEvent('plugkit', 'browser.stale-reclaimed', { sid, pid: entry.pid || null, reason: 'cdp-dead' });
2554
2608
  }
2555
2609
  }
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 });
2610
+ if (mutated) {
2611
+ try { writeJsonFile(portsFile, ports); } catch (_) {}
2612
+ try { writeJsonFile(sessionsFile, sessions); } catch (_) {}
2560
2613
  }
2561
- lastBrowserActivityMs = Date.now();
2562
2614
  } catch (e) {
2563
2615
  console.error(`[browser-idle] error: ${e.message}`);
2564
2616
  }
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1503",
3
+ "version": "2.0.1504",
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.1504",
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",