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.
- package/gm-plugkit/browser-idle.js +13 -0
- package/gm-plugkit/browser-idle.test.js +46 -0
- package/gm-plugkit/package.json +1 -1
- package/gm-plugkit/plugkit-wasm-wrapper.js +81 -19
- package/gm.json +1 -1
- package/lib/browser.js +8 -3
- package/package.json +1 -1
|
@@ -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');
|
package/gm-plugkit/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gm-plugkit",
|
|
3
|
-
"version": "2.0.
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
2553
|
-
|
|
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 (
|
|
2557
|
-
try {
|
|
2558
|
-
try {
|
|
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
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(
|
|
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 (
|
|
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.
|
|
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",
|