gm-skill 2.0.1502 → 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.
- 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 +95 -20
- 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.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
|
-
|
|
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
|
-
|
|
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]*)$/);
|
|
@@ -2304,7 +2334,30 @@ async function runSpoolWatcher(instance, spoolDir) {
|
|
|
2304
2334
|
child.unref();
|
|
2305
2335
|
try { logEvent('plugkit', 'gm-plugkit.self-stale-respawn', { running_version: own, latest_version: latest }); } catch (_) {}
|
|
2306
2336
|
try { fs.writeFileSync(path.join(spoolDir, '.shutdown-reason.json'), JSON.stringify({ reason: 'gm-plugkit-self-stale', ts: Date.now(), pid: process.pid, running_version: own, latest_version: latest })); } catch (_) {}
|
|
2307
|
-
|
|
2337
|
+
// Wait for the replacement's fresh heartbeat before exiting (mirror the
|
|
2338
|
+
// version-drift path) instead of a blind 2s exit: the gm-plugkit download can
|
|
2339
|
+
// take many seconds, and exiting early lets the supervisor relaunch the SAME
|
|
2340
|
+
// stale version before the new one lands, so the update never sticks.
|
|
2341
|
+
const myPid = process.pid;
|
|
2342
|
+
const respawnDeadline = Date.now() + 90000;
|
|
2343
|
+
const exitSelfStale = () => { try { process.exit(0); } catch (_) {} };
|
|
2344
|
+
const pollSelfStaleReplacement = () => {
|
|
2345
|
+
try {
|
|
2346
|
+
const st = JSON.parse(fs.readFileSync(STATUS_PATH_FOR_TEARDOWN, 'utf8'));
|
|
2347
|
+
const freshHeartbeat = st && st.ts && (Date.now() - st.ts) < 15000;
|
|
2348
|
+
const differentProc = st && st.pid && st.pid !== myPid;
|
|
2349
|
+
if (freshHeartbeat && differentProc) {
|
|
2350
|
+
try { logEvent('plugkit', 'gm-plugkit.self-stale-respawn-confirmed', { old_pid: myPid, new_pid: st.pid, new_version: st.version, latest_version: latest }); } catch (_) {}
|
|
2351
|
+
return exitSelfStale();
|
|
2352
|
+
}
|
|
2353
|
+
} catch (_) {}
|
|
2354
|
+
if (Date.now() > respawnDeadline) {
|
|
2355
|
+
try { logEvent('plugkit', 'gm-plugkit.self-stale-respawn-timeout', { old_pid: myPid, waited_ms: 90000 }); } catch (_) {}
|
|
2356
|
+
return exitSelfStale();
|
|
2357
|
+
}
|
|
2358
|
+
setTimeout(pollSelfStaleReplacement, 1500);
|
|
2359
|
+
};
|
|
2360
|
+
setTimeout(pollSelfStaleReplacement, 3000);
|
|
2308
2361
|
} catch (e) {
|
|
2309
2362
|
console.error(`[plugkit-wasm] failed to spawn replacement on self-stale: ${e.message}`);
|
|
2310
2363
|
}
|
|
@@ -2515,27 +2568,49 @@ async function runSpoolWatcher(instance, spoolDir) {
|
|
|
2515
2568
|
}
|
|
2516
2569
|
}, 60_000);
|
|
2517
2570
|
|
|
2518
|
-
const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) ||
|
|
2519
|
-
let lastBrowserActivityMs = Date.now();
|
|
2571
|
+
const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 10 * 60 * 1000;
|
|
2520
2572
|
setInterval(() => {
|
|
2521
2573
|
try {
|
|
2522
|
-
const browserIdleMs = Date.now() - lastBrowserActivityMs;
|
|
2523
|
-
if (browserIdleMs < BROWSER_IDLE_LIMIT_MS) return;
|
|
2524
2574
|
const portsFile = browserPortsFile(process.cwd());
|
|
2525
2575
|
const sessionsFile = browserSessionsFile(process.cwd());
|
|
2526
2576
|
const ports = readJsonFile(portsFile, {});
|
|
2527
|
-
|
|
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
|
+
}
|
|
2528
2591
|
for (const [sid, entry] of Object.entries(ports)) {
|
|
2529
|
-
if (entry
|
|
2530
|
-
|
|
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' });
|
|
2531
2608
|
}
|
|
2532
2609
|
}
|
|
2533
|
-
if (
|
|
2534
|
-
try {
|
|
2535
|
-
try {
|
|
2536
|
-
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 (_) {}
|
|
2537
2613
|
}
|
|
2538
|
-
lastBrowserActivityMs = Date.now();
|
|
2539
2614
|
} catch (e) {
|
|
2540
2615
|
console.error(`[browser-idle] error: ${e.message}`);
|
|
2541
2616
|
}
|
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.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",
|