gm-oc 2.0.1059 → 2.0.1060

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,895 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const https = require('https');
8
+ const crypto = require('crypto');
9
+ const { URL } = require('url');
10
+
11
+ const RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
12
+ const ATTEMPT_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const STALL_TIMEOUT_MS = 15 * 1000;
14
+ const MAX_ATTEMPTS = 5;
15
+ const BACKOFF_MS = [2000, 5000, 15000, 30000];
16
+ // Worst case: a slow link downloading 140MB at 1MB/s = ~140s. Allow 30 minutes
17
+ // before another bootstrap process treats this lock as abandoned. Below this,
18
+ // concurrent bootstrap calls would wipe an in-progress download mid-stream
19
+ // (see the v0.1.294 incident where a race between two wrappers blew away the
20
+ // .partial during a 10-minute fetch).
21
+ const LOCK_STALE_MS = 30 * 60 * 1000;
22
+
23
+ function log(msg) {
24
+ try { process.stderr.write(`[plugkit-bootstrap] ${msg}\n`); } catch (_) {}
25
+ }
26
+
27
+ function probeBinaryVersion(binPath) {
28
+ try {
29
+ const { spawnSync } = require('child_process');
30
+ const r = spawnSync(binPath, ['--version'], { timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
31
+ if (r.error) return null;
32
+ const text = `${r.stdout || ''} ${r.stderr || ''}`.trim();
33
+ const m = text.match(/(\d+\.\d+\.\d+)/);
34
+ return m ? m[1] : null;
35
+ } catch (_) { return null; }
36
+ }
37
+
38
+ function writeBootstrapError(spec) {
39
+ try {
40
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
41
+ const spoolDir = path.join(projectDir, '.gm', 'exec-spool');
42
+ fs.mkdirSync(spoolDir, { recursive: true });
43
+ const out = path.join(spoolDir, '.bootstrap-error.json');
44
+ fs.writeFileSync(out, JSON.stringify({ ts: new Date().toISOString(), ...spec }, null, 2));
45
+ } catch (_) {}
46
+ }
47
+
48
+ function clearBootstrapError() {
49
+ try {
50
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ const out = path.join(projectDir, '.gm', 'exec-spool', '.bootstrap-error.json');
52
+ fs.unlinkSync(out);
53
+ } catch (_) {}
54
+ }
55
+
56
+ function obsEvent(subsystem, event, fields) {
57
+ if (process.env.GM_LOG_DISABLE) return;
58
+ try {
59
+ const root = process.env.GM_LOG_DIR
60
+ || path.join(os.homedir(), '.claude', 'gm-log');
61
+ const day = new Date().toISOString().slice(0, 10);
62
+ const dir = path.join(root, day);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const line = JSON.stringify({
65
+ ts: new Date().toISOString(),
66
+ sub: subsystem,
67
+ event,
68
+ pid: process.pid,
69
+ sess: process.env.CLAUDE_SESSION_ID || process.env.GM_SESSION_ID || '',
70
+ ...fields,
71
+ });
72
+ fs.appendFileSync(path.join(dir, `${subsystem}.jsonl`), line + '\n');
73
+ } catch (_) {}
74
+ }
75
+
76
+ function platformKey() {
77
+ const p = os.platform();
78
+ const a = os.arch();
79
+ if (p === 'win32') return a === 'arm64' ? 'win32-arm64' : 'win32-x64';
80
+ if (p === 'darwin') return a === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
81
+ return (a === 'arm64' || a === 'aarch64') ? 'linux-arm64' : 'linux-x64';
82
+ }
83
+
84
+ function binaryName() {
85
+ const key = platformKey();
86
+ return key.startsWith('win32') ? `plugkit-${key}.exe` : `plugkit-${key}`;
87
+ }
88
+
89
+ function rtkBinaryName() {
90
+ const key = platformKey();
91
+ return key.startsWith('win32') ? `rtk-${key}.exe` : `rtk-${key}`;
92
+ }
93
+
94
+ function cacheRoot() {
95
+ const home = os.homedir();
96
+ if (process.env.PLUGKIT_CACHE_DIR) return process.env.PLUGKIT_CACHE_DIR;
97
+ if (os.platform() === 'win32') {
98
+ const base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
99
+ return path.join(base, 'plugkit', 'bin');
100
+ }
101
+ if (os.platform() === 'darwin') return path.join(home, 'Library', 'Caches', 'plugkit', 'bin');
102
+ const xdg = process.env.XDG_CACHE_HOME || path.join(home, '.cache');
103
+ return path.join(xdg, 'plugkit', 'bin');
104
+ }
105
+
106
+ function fallbackCacheRoot() {
107
+ return path.join(os.tmpdir(), 'plugkit-cache', 'bin');
108
+ }
109
+
110
+ function gmToolsDir() {
111
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
112
+ return path.join(home, '.claude', 'gm-tools');
113
+ }
114
+
115
+ // Copy the freshly-resolved plugkit binary + its version+sha manifests to
116
+ // ~/.claude/gm-tools so hooks.json can invoke plugkit directly without going
117
+ // through node. Self-update inside the Rust binary keeps gm-tools fresh from
118
+ // here on. Skipped silently on any error — the next session-start hook will
119
+ // retry via ensure_tools_current.
120
+ function killHoldersOfPath(targetPath) {
121
+ if (process.platform !== 'win32') return 0;
122
+ try {
123
+ const { spawnSync } = require('child_process');
124
+ const norm = path.resolve(targetPath).replace(/\//g, '\\');
125
+ const r = spawnSync('wmic', ['process', 'where', `ExecutablePath='${norm.replace(/\\/g, '\\\\')}'`, 'get', 'ProcessId', '/format:value'], { encoding: 'utf8', windowsHide: true, timeout: 5000 });
126
+ if (r.status !== 0 || !r.stdout) return 0;
127
+ const pids = [];
128
+ for (const line of r.stdout.split(/\r?\n/)) {
129
+ const m = line.match(/ProcessId=(\d+)/);
130
+ if (m) {
131
+ const pid = parseInt(m[1], 10);
132
+ if (Number.isFinite(pid) && pid !== process.pid) pids.push(pid);
133
+ }
134
+ }
135
+ for (const pid of pids) {
136
+ try { spawnSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true, timeout: 3000 }); } catch (_) {}
137
+ }
138
+ return pids.length;
139
+ } catch (_) { return 0; }
140
+ }
141
+
142
+ function cleanOrphanNewFiles(dst, exeName) {
143
+ try {
144
+ for (const name of fs.readdirSync(dst)) {
145
+ if (name === exeName + '.new') continue;
146
+ if (/^plugkit(\.\d+\.\d+\.\d+)?\.new$/i.test(name)) {
147
+ try { fs.unlinkSync(path.join(dst, name)); } catch (_) {}
148
+ }
149
+ }
150
+ } catch (_) {}
151
+ }
152
+
153
+ function renameWithRetry(src, dst, attempts) {
154
+ for (let i = 0; i < attempts; i++) {
155
+ try { fs.renameSync(src, dst); return true; }
156
+ catch (err) {
157
+ if (err.code !== 'EEXIST' && err.code !== 'EPERM' && err.code !== 'EBUSY' && err.code !== 'EACCES') throw err;
158
+ if (i === Math.floor(attempts / 2)) killHoldersOfPath(dst);
159
+ try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 200)'], { timeout: 400, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function copyToGmTools(finalPath, wrapperDir, version) {
166
+ const dst = gmToolsDir();
167
+ fs.mkdirSync(dst, { recursive: true });
168
+ const exeName = process.platform === 'win32' ? 'plugkit.exe' : 'plugkit';
169
+ const target = path.join(dst, exeName);
170
+ const targetTmp = target + '.new';
171
+ cleanOrphanNewFiles(dst, exeName);
172
+ if (fs.existsSync(target)) {
173
+ let needsRefresh = true;
174
+ try {
175
+ const cur = sha256OfFileSync(target);
176
+ const src = sha256OfFileSync(finalPath);
177
+ if (cur === src) needsRefresh = false;
178
+ } catch (_) {}
179
+ if (!needsRefresh) {
180
+ try { fs.writeFileSync(path.join(dst, 'plugkit.version'), version); } catch (_) {}
181
+ return;
182
+ }
183
+ try { killHoldersOfPath(target); } catch (_) {}
184
+ }
185
+ fs.copyFileSync(finalPath, targetTmp);
186
+ if (!renameWithRetry(targetTmp, target, 8)) {
187
+ try { killHoldersOfPath(target); } catch (_) {}
188
+ try { fs.unlinkSync(target); } catch (_) {}
189
+ try { fs.renameSync(targetTmp, target); }
190
+ catch (_) {
191
+ try { fs.unlinkSync(targetTmp); } catch (_) {}
192
+ obsEvent('bootstrap', 'gmtools.rename.failed', { target });
193
+ writeBootstrapError({
194
+ expected_version: version,
195
+ cached_version: null,
196
+ error_phase: 'gmtools-rename',
197
+ error_message: `cannot replace ${target} after kill+unlink retry; orphan removed`,
198
+ });
199
+ throw new Error(`gm-tools update blocked: cannot replace ${target} (held open by running plugkit and kill-retry exhausted)`);
200
+ }
201
+ }
202
+ try {
203
+ for (const name of fs.readdirSync(dst)) {
204
+ if (/^plugkit(\.\d+\.\d+\.\d+)?\.new$/i.test(name)) {
205
+ try { fs.unlinkSync(path.join(dst, name)); } catch (_) {}
206
+ }
207
+ }
208
+ } catch (_) {}
209
+ if (process.platform !== 'win32') {
210
+ try { fs.chmodSync(target, 0o755); } catch (_) {}
211
+ }
212
+ fs.writeFileSync(path.join(dst, 'plugkit.version'), version);
213
+ try {
214
+ const srcSha = path.join(wrapperDir, 'plugkit.sha256');
215
+ if (fs.existsSync(srcSha)) fs.copyFileSync(srcSha, path.join(dst, 'plugkit.sha256'));
216
+ } catch (_) {}
217
+ }
218
+
219
+ function ensureDir(dir) {
220
+ fs.mkdirSync(dir, { recursive: true });
221
+ }
222
+
223
+ function readVersionFile(wrapperDir) {
224
+ const p = path.join(wrapperDir, 'plugkit.version');
225
+ if (!fs.existsSync(p)) throw new Error(`plugkit.version not found at ${p}`);
226
+ return fs.readFileSync(p, 'utf8').trim();
227
+ }
228
+
229
+ function readRtkVersion(wrapperDir) {
230
+ const p = path.join(wrapperDir, 'rtk.version');
231
+ if (!fs.existsSync(p)) return null;
232
+ const v = fs.readFileSync(p, 'utf8').trim();
233
+ return v || null;
234
+ }
235
+
236
+ function sha256OfFileSync(filePath) {
237
+ const h = crypto.createHash('sha256');
238
+ const fd = fs.openSync(filePath, 'r');
239
+ try {
240
+ const buf = Buffer.alloc(1024 * 1024);
241
+ for (;;) {
242
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
243
+ if (n <= 0) break;
244
+ h.update(buf.subarray(0, n));
245
+ }
246
+ } finally { try { fs.closeSync(fd); } catch (_) {} }
247
+ return h.digest('hex');
248
+ }
249
+
250
+ function healIfShaMatches(binPath, expectedSha, sentinelPath, partialPath, kind) {
251
+ if (!fs.existsSync(binPath)) return false;
252
+ if (partialPath) { try { if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath); } catch (_) {} }
253
+ if (!expectedSha) return false;
254
+ let got;
255
+ try { got = sha256OfFileSync(binPath); }
256
+ catch (_) { return false; }
257
+ if (got !== expectedSha) {
258
+ try { fs.unlinkSync(binPath); } catch (_) {}
259
+ return false;
260
+ }
261
+ try { fs.writeFileSync(sentinelPath, new Date().toISOString()); } catch (_) { return false; }
262
+ obsEvent('bootstrap', 'cache.heal', { path: binPath, kind });
263
+ return true;
264
+ }
265
+
266
+ function readShaManifest(wrapperDir, manifestName) {
267
+ const p = path.join(wrapperDir, manifestName || 'plugkit.sha256');
268
+ if (!fs.existsSync(p)) return null;
269
+ const out = {};
270
+ for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
271
+ const m = line.match(/^([0-9a-f]{64})\s+(\S+)\s*$/i);
272
+ if (m) out[m[2]] = m[1].toLowerCase();
273
+ }
274
+ return out;
275
+ }
276
+
277
+ function pidAlive(pid) {
278
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
279
+ }
280
+
281
+ function acquireLock(lockPath) {
282
+ const start = Date.now();
283
+ for (;;) {
284
+ try {
285
+ const fd = fs.openSync(lockPath, 'wx');
286
+ fs.writeSync(fd, String(process.pid));
287
+ fs.closeSync(fd);
288
+ return true;
289
+ } catch (err) {
290
+ if (err.code !== 'EEXIST') throw err;
291
+ let stale = false;
292
+ try {
293
+ const st = fs.statSync(lockPath);
294
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true;
295
+ const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
296
+ if (Number.isFinite(owner) && owner !== process.pid && !pidAlive(owner)) stale = true;
297
+ } catch (_) { stale = true; }
298
+ if (stale) {
299
+ try { fs.unlinkSync(lockPath); } catch (_) {}
300
+ continue;
301
+ }
302
+ if (Date.now() - start > ATTEMPT_TIMEOUT_MS) throw new Error(`lock wait timeout: ${lockPath}`);
303
+ try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 2000)'], { timeout: 2500, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
304
+ }
305
+ }
306
+ }
307
+
308
+ function releaseLock(lockPath) {
309
+ try { fs.unlinkSync(lockPath); } catch (_) {}
310
+ }
311
+
312
+ function sha256OfFile(filePath) {
313
+ return new Promise((resolve, reject) => {
314
+ const h = crypto.createHash('sha256');
315
+ const s = fs.createReadStream(filePath);
316
+ s.on('data', c => h.update(c));
317
+ s.on('end', () => resolve(h.digest('hex')));
318
+ s.on('error', reject);
319
+ });
320
+ }
321
+
322
+ function fetchToFile(url, destPath, expectedTotal) {
323
+ return new Promise((resolve, reject) => {
324
+ let existing = 0;
325
+ try { existing = fs.statSync(destPath).size; } catch (_) {}
326
+ const headers = { 'User-Agent': 'plugkit-bootstrap', 'Accept': '*/*' };
327
+ if (existing > 0) headers['Range'] = `bytes=${existing}-`;
328
+
329
+ const u = new URL(url);
330
+ const req = https.request({
331
+ method: 'GET',
332
+ hostname: u.hostname,
333
+ path: u.pathname + u.search,
334
+ headers,
335
+ timeout: ATTEMPT_TIMEOUT_MS,
336
+ }, (res) => {
337
+ if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) {
338
+ res.resume();
339
+ return resolve(fetchToFile(res.headers.location, destPath, expectedTotal));
340
+ }
341
+ if (res.statusCode === 416) {
342
+ res.resume();
343
+ try { fs.unlinkSync(destPath); } catch (_) {}
344
+ return reject(new Error('range-not-satisfiable: cleared partial, retry'));
345
+ }
346
+ if (!(res.statusCode === 200 || res.statusCode === 206)) {
347
+ res.resume();
348
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
349
+ }
350
+ const append = res.statusCode === 206 && existing > 0;
351
+ // Ensure parent dir exists — a concurrent prune may have removed it
352
+ // between lock-acquire and now. Recreating is cheap and avoids a
353
+ // confusing ENOENT later.
354
+ try { ensureDir(path.dirname(destPath)); } catch (_) {}
355
+ const out = fs.createWriteStream(destPath, { flags: append ? 'a' : 'w' });
356
+ let bytes = append ? existing : 0;
357
+ let lastStderr = Date.now();
358
+ let lastByte = Date.now();
359
+ const fetchStart = Date.now();
360
+ const safeUrl = (() => { try { const p = new URL(url); return p.hostname + p.pathname; } catch(_) { return url.split('?')[0]; } })();
361
+ obsEvent('bootstrap', 'fetch.start', { url: safeUrl, resume_from: existing, status: res.statusCode });
362
+ const stallTimer = setInterval(() => {
363
+ if (Date.now() - lastByte > STALL_TIMEOUT_MS) {
364
+ clearInterval(stallTimer);
365
+ req.destroy(new Error(`stalled: no bytes for ${STALL_TIMEOUT_MS}ms`));
366
+ }
367
+ }, 2000);
368
+ res.on('data', c => {
369
+ bytes += c.length;
370
+ lastByte = Date.now();
371
+ if (Date.now() - lastStderr > 5000) {
372
+ const pct = expectedTotal ? ` ${Math.floor(bytes / expectedTotal * 100)}%` : '';
373
+ try { process.stderr.write(`[plugkit-bootstrap] downloading: ${(bytes / 1048576).toFixed(1)} MiB${pct}\n`); } catch (_) {}
374
+ lastStderr = Date.now();
375
+ }
376
+ });
377
+ res.pipe(out);
378
+ out.on('finish', () => {
379
+ clearInterval(stallTimer);
380
+ obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: true });
381
+ out.close(() => resolve(bytes));
382
+ });
383
+ out.on('error', err => { clearInterval(stallTimer); obsEvent('bootstrap', 'fetch.end', { url: safeUrl, bytes, dur_ms: Date.now() - fetchStart, ok: false, err: String(err.message || err) }); reject(err); });
384
+ res.on('error', err => { clearInterval(stallTimer); reject(err); });
385
+ res.on('end', () => clearInterval(stallTimer));
386
+ });
387
+ req.on('timeout', () => { req.destroy(new Error(`timeout after ${ATTEMPT_TIMEOUT_MS}ms`)); });
388
+ req.on('error', reject);
389
+ req.end();
390
+ });
391
+ }
392
+
393
+ async function downloadWithRetry(url, destPath) {
394
+ let lastErr;
395
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
396
+ try {
397
+ log(`fetch attempt ${attempt}/${MAX_ATTEMPTS}: ${url}`);
398
+ await fetchToFile(url, destPath);
399
+ return;
400
+ } catch (err) {
401
+ lastErr = err;
402
+ log(`attempt ${attempt} failed: ${err.message}`);
403
+ obsEvent('bootstrap', 'fetch.attempt_failed', { url, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
404
+ if (attempt < MAX_ATTEMPTS) {
405
+ const wait = BACKOFF_MS[attempt - 1] || 120000;
406
+ log(`backing off ${wait}ms`);
407
+ await new Promise(r => setTimeout(r, wait));
408
+ }
409
+ }
410
+ }
411
+ throw lastErr;
412
+ }
413
+
414
+ function isLockStale(lockPath) {
415
+ try {
416
+ const st = fs.statSync(lockPath);
417
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
418
+ const owner = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
419
+ if (Number.isFinite(owner) && !pidAlive(owner)) return true;
420
+ } catch (_) { return true; }
421
+ return false;
422
+ }
423
+
424
+ function pruneOldVersions(root, keepVersion, keepRtkVersion) {
425
+ try {
426
+ const entries = fs.readdirSync(root);
427
+ for (const e of entries) {
428
+ const isPlugkit = e.startsWith('v') && !e.startsWith('rtk-');
429
+ const isRtk = e.startsWith('rtk-v');
430
+ if (!isPlugkit && !isRtk) continue;
431
+ if (isPlugkit && e === `v${keepVersion}`) continue;
432
+ if (isRtk && keepRtkVersion && e === `rtk-v${keepRtkVersion}`) continue;
433
+ const dir = path.join(root, e);
434
+ const lock = path.join(dir, '.lock');
435
+ if (fs.existsSync(lock) && !isLockStale(lock)) continue;
436
+ if (fs.existsSync(lock)) { try { fs.unlinkSync(lock); } catch (_) {} }
437
+ try {
438
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 });
439
+ log(`pruned ${dir}`);
440
+ } catch (err) { log(`prune skip ${dir}: ${err.message}`); }
441
+ }
442
+ } catch (_) {}
443
+ }
444
+
445
+ async function bootstrap(opts) {
446
+ opts = opts || {};
447
+ const wrapperDir = opts.wrapperDir || __dirname;
448
+ const version = opts.version || readVersionFile(wrapperDir);
449
+ const shaManifest = readShaManifest(wrapperDir);
450
+ const binName = binaryName();
451
+ const expectedSha = shaManifest ? shaManifest[binName] : null;
452
+
453
+ let root = cacheRoot();
454
+ try { ensureDir(root); }
455
+ catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
456
+
457
+ const verDir = path.join(root, `v${version}`);
458
+ ensureDir(verDir);
459
+
460
+ const finalPath = path.join(verDir, binName);
461
+ const okSentinel = path.join(verDir, '.ok');
462
+ const partialPath = `${finalPath}.partial`;
463
+
464
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
465
+ if (expectedSha) {
466
+ const actualSha = sha256OfFileSync(finalPath);
467
+ if (actualSha === expectedSha) {
468
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sha-match', version, path: finalPath });
469
+ copyToGmTools(finalPath, wrapperDir, version);
470
+ clearBootstrapError();
471
+ return finalPath;
472
+ }
473
+ log(`decision: fetch reason: cache-hit-sha-mismatch (dir=v${version} expected ${expectedSha.slice(0,12)}… got ${(actualSha||'').slice(0,12)}…)`);
474
+ writeBootstrapError({
475
+ expected_version: version,
476
+ cached_version: null,
477
+ error_phase: 'cache-hit-sha-mismatch',
478
+ error_message: `cached binary at ${finalPath} sha=${actualSha} but manifest expects ${expectedSha}`,
479
+ });
480
+ try { fs.unlinkSync(finalPath); } catch (_) {}
481
+ try { fs.unlinkSync(okSentinel); } catch (_) {}
482
+ } else {
483
+ obsEvent('bootstrap', 'decision.hit', { reason: 'sentinel+no-sha-manifest', path: finalPath });
484
+ copyToGmTools(finalPath, wrapperDir, version);
485
+ clearBootstrapError();
486
+ return finalPath;
487
+ }
488
+ }
489
+
490
+ if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
491
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match', path: finalPath });
492
+ spawnDetachedRtkFetch(wrapperDir);
493
+ copyToGmTools(finalPath, wrapperDir, version);
494
+ clearBootstrapError();
495
+ return finalPath;
496
+ }
497
+
498
+ const lockPath = path.join(verDir, '.lock');
499
+ acquireLock(lockPath);
500
+ try {
501
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
502
+ obsEvent('bootstrap', 'decision.hit', { reason: 'lock-race-resolved', path: finalPath });
503
+ copyToGmTools(finalPath, wrapperDir, version);
504
+ clearBootstrapError();
505
+ return finalPath;
506
+ }
507
+ if (healIfShaMatches(finalPath, expectedSha, okSentinel, partialPath, 'plugkit')) {
508
+ obsEvent('bootstrap', 'decision.heal', { reason: 'sha-match-under-lock', path: finalPath });
509
+ spawnDetachedRtkFetch(wrapperDir);
510
+ copyToGmTools(finalPath, wrapperDir, version);
511
+ clearBootstrapError();
512
+ return finalPath;
513
+ }
514
+
515
+ if (fs.existsSync(partialPath)) {
516
+ try {
517
+ const st = fs.statSync(partialPath);
518
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
519
+ fs.unlinkSync(partialPath);
520
+ log(`cleared stale partial: ${partialPath}`);
521
+ }
522
+ } catch (_) {}
523
+ }
524
+ const url = `https://github.com/${RELEASE_REPO}/releases/download/v${version}/${binName}`;
525
+ try {
526
+ await downloadWithRetry(url, partialPath);
527
+ } catch (fetchErr) {
528
+ writeBootstrapError({
529
+ expected_version: version,
530
+ cached_version: null,
531
+ error_phase: 'download',
532
+ error_message: fetchErr && fetchErr.message ? fetchErr.message : String(fetchErr),
533
+ });
534
+ throw fetchErr;
535
+ }
536
+
537
+ if (expectedSha) {
538
+ const got = await sha256OfFile(partialPath);
539
+ if (got !== expectedSha) {
540
+ try { fs.unlinkSync(partialPath); } catch (_) {}
541
+ writeBootstrapError({
542
+ expected_version: version,
543
+ cached_version: null,
544
+ error_phase: 'sha256-mismatch',
545
+ error_message: `sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`,
546
+ });
547
+ throw new Error(`sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`);
548
+ }
549
+ log('sha256 verified');
550
+ } else {
551
+ log('no sha256 manifest — skipping verify');
552
+ }
553
+
554
+ try { fs.renameSync(partialPath, finalPath); }
555
+ catch (err) {
556
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
557
+ try { fs.unlinkSync(finalPath); } catch (_) {}
558
+ fs.renameSync(partialPath, finalPath);
559
+ } else throw err;
560
+ }
561
+
562
+ if (os.platform() !== 'win32') {
563
+ try { fs.chmodSync(finalPath, 0o755); } catch (_) {}
564
+ }
565
+
566
+ fs.writeFileSync(okSentinel, new Date().toISOString());
567
+ log(`decision: fetch reason: install-complete (${finalPath})`);
568
+ obsEvent('bootstrap', 'install.done', { path: finalPath, version, kind: 'plugkit' });
569
+ proactiveKillForNewInstall(version, finalPath);
570
+ pruneOldVersions(root, version, readRtkVersion(wrapperDir));
571
+ spawnDetachedRtkFetch(wrapperDir);
572
+ copyToGmTools(finalPath, wrapperDir, version);
573
+ clearBootstrapError();
574
+ return finalPath;
575
+ } finally {
576
+ releaseLock(lockPath);
577
+ }
578
+ }
579
+
580
+ function spawnDetachedRtkFetch(wrapperDir) {
581
+ try {
582
+ const { spawn } = require('child_process');
583
+ const child = spawn(process.execPath, [__filename, '--rtk-only', '--wrapper-dir', wrapperDir], {
584
+ detached: true,
585
+ stdio: 'ignore',
586
+ windowsHide: true,
587
+ });
588
+ child.unref();
589
+ obsEvent('bootstrap', 'rtk.detached.spawned', { pid: child.pid, wrapperDir });
590
+ } catch (err) {
591
+ log(`rtk detach spawn failed: ${err.message}`);
592
+ }
593
+ }
594
+
595
+ function rtkCacheDir(root, wrapperDir, plugkitVerDir) {
596
+ const rtkVer = readRtkVersion(wrapperDir);
597
+ if (!rtkVer) return plugkitVerDir;
598
+ const dir = path.join(root, `rtk-v${rtkVer}`);
599
+ ensureDir(dir);
600
+ return dir;
601
+ }
602
+
603
+ async function bootstrapRtk(plugkitVerDir, plugkitVersion, wrapperDir, silent, root) {
604
+ const rtkName = rtkBinaryName();
605
+ const cacheDir = rtkCacheDir(root || cacheRoot(), wrapperDir, plugkitVerDir);
606
+ const rtkPath = path.join(cacheDir, rtkName);
607
+ const rtkOk = path.join(cacheDir, '.rtk-ok');
608
+ if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) {
609
+ if (!silent) log(`rtk cache hit: ${rtkPath}`);
610
+ return rtkPath;
611
+ }
612
+ const rtkSha = readShaManifest(wrapperDir, 'rtk.sha256');
613
+ const expected = rtkSha ? rtkSha[rtkName] : null;
614
+ const tmp = `${rtkPath}.partial`;
615
+ if (healIfShaMatches(rtkPath, expected, rtkOk, tmp, 'rtk')) {
616
+ if (!silent) log(`rtk cache heal (sha match): ${rtkPath}`);
617
+ return rtkPath;
618
+ }
619
+ const url = `https://github.com/${RELEASE_REPO}/releases/download/v${plugkitVersion}/${rtkName}`;
620
+ await downloadWithRetry(url, tmp);
621
+ if (expected) {
622
+ const got = await sha256OfFile(tmp);
623
+ if (got !== expected) {
624
+ try { fs.unlinkSync(tmp); } catch (_) {}
625
+ throw new Error(`rtk sha256 mismatch: expected ${expected}, got ${got}`);
626
+ }
627
+ }
628
+ try { fs.renameSync(tmp, rtkPath); }
629
+ catch (err) {
630
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
631
+ try { fs.unlinkSync(rtkPath); } catch (_) {}
632
+ fs.renameSync(tmp, rtkPath);
633
+ } else throw err;
634
+ }
635
+ if (os.platform() !== 'win32') { try { fs.chmodSync(rtkPath, 0o755); } catch (_) {} }
636
+ fs.writeFileSync(rtkOk, new Date().toISOString());
637
+ log(`installed ${rtkPath}`);
638
+ obsEvent('bootstrap', 'install.done', { path: rtkPath, plugkit_version: plugkitVersion, rtk_version: readRtkVersion(wrapperDir) || plugkitVersion, kind: 'rtk' });
639
+ return rtkPath;
640
+ }
641
+
642
+ function resolveCachedRtk(opts) {
643
+ opts = opts || {};
644
+ const wrapperDir = opts.wrapperDir || __dirname;
645
+ const version = opts.version || readVersionFile(wrapperDir);
646
+ const root = (() => {
647
+ try { const r = cacheRoot(); ensureDir(r); return r; }
648
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
649
+ })();
650
+ const plugkitVerDir = path.join(root, `v${version}`);
651
+ const cacheDir = rtkCacheDir(root, wrapperDir, plugkitVerDir);
652
+ const rtkPath = path.join(cacheDir, rtkBinaryName());
653
+ const rtkOk = path.join(cacheDir, '.rtk-ok');
654
+ if (fs.existsSync(rtkPath) && fs.existsSync(rtkOk)) return rtkPath;
655
+ return null;
656
+ }
657
+
658
+ function resolveCachedBinary(opts) {
659
+ opts = opts || {};
660
+ const wrapperDir = opts.wrapperDir || __dirname;
661
+ const version = opts.version || readVersionFile(wrapperDir);
662
+ const root = (() => {
663
+ try { const r = cacheRoot(); ensureDir(r); return r; }
664
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
665
+ })();
666
+ const verDir = path.join(root, `v${version}`);
667
+ const finalPath = path.join(verDir, binaryName());
668
+ const okSentinel = path.join(verDir, '.ok');
669
+ if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) return finalPath;
670
+ return null;
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Daemon kill on version change.
675
+ //
676
+ // The plugin tarball pins `plugkit.version`. When that pin advances and we
677
+ // install a newer cached binary, any long-running daemon (the runner) holds
678
+ // stale code and serves stale RPCs until killed. We track which version the
679
+ // daemon was last started under via `.daemon-version`; on every wrapper
680
+ // invocation, if the wrapper-pinned version differs, we kill the daemon so
681
+ // the next exec spawns it fresh under the new binary.
682
+ // ---------------------------------------------------------------------------
683
+
684
+ function daemonVersionSentinel() {
685
+ const root = (() => {
686
+ try { const r = cacheRoot(); ensureDir(r); return r; }
687
+ catch (_) { const r = fallbackCacheRoot(); ensureDir(r); return r; }
688
+ })();
689
+ return path.join(root, '.daemon-version');
690
+ }
691
+
692
+ function readDaemonVersion() {
693
+ try { return fs.readFileSync(daemonVersionSentinel(), 'utf8').trim(); }
694
+ catch (_) { return null; }
695
+ }
696
+
697
+ function writeDaemonVersion(v) {
698
+ try { fs.writeFileSync(daemonVersionSentinel(), String(v)); } catch (_) {}
699
+ }
700
+
701
+ function killPid(pid) {
702
+ if (!Number.isFinite(pid) || pid === process.pid || !pidAlive(pid)) return false;
703
+ try { process.kill(pid, 'SIGTERM'); }
704
+ catch (_) { try { process.kill(pid); } catch (_) {} }
705
+ if (os.platform() === 'win32' && pidAlive(pid)) {
706
+ try {
707
+ const { spawnSync } = require('child_process');
708
+ spawnSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, timeout: 3000, killSignal: 'SIGKILL' });
709
+ } catch (_) {}
710
+ }
711
+ return true;
712
+ }
713
+
714
+ function killRunningDaemons(reason) {
715
+ const tmp = os.tmpdir();
716
+ const killedPids = [];
717
+ for (const pidFile of ['glootie-runner.pid', 'plugkit-runner.pid']) {
718
+ const pidPath = path.join(tmp, pidFile);
719
+ if (!fs.existsSync(pidPath)) continue;
720
+ try {
721
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
722
+ if (killPid(pid)) {
723
+ killedPids.push(pid);
724
+ obsEvent('bootstrap', 'daemon.killed', { pid, pidFile, reason });
725
+ }
726
+ try { fs.unlinkSync(pidPath); } catch (_) {}
727
+ } catch (_) {}
728
+ }
729
+ return killedPids;
730
+ }
731
+
732
+ function listRunningPlugkitImagePaths() {
733
+ const out = [];
734
+ try {
735
+ const { spawnSync } = require('child_process');
736
+ if (os.platform() === 'win32') {
737
+ let parsed = null;
738
+ try {
739
+ const p = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', "Get-Process plugkit* -ErrorAction SilentlyContinue | Select-Object Id,Path | ConvertTo-Json -Compress"], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
740
+ const text = ((p && p.stdout) || '').trim();
741
+ if (text) {
742
+ const j = JSON.parse(text);
743
+ parsed = Array.isArray(j) ? j : [j];
744
+ }
745
+ } catch (_) {}
746
+ if (parsed) {
747
+ for (const item of parsed) {
748
+ if (!item) continue;
749
+ const pid = parseInt(item.Id, 10);
750
+ if (!Number.isFinite(pid)) continue;
751
+ out.push({ pid, path: (item.Path || '').trim() });
752
+ }
753
+ } else {
754
+ const r = spawnSync('tasklist', ['/FI', 'IMAGENAME eq plugkit*', '/FO', 'CSV', '/NH'], { windowsHide: true, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
755
+ const text = (r && r.stdout) || '';
756
+ const seen = new Set();
757
+ for (const line of text.split(/\r?\n/)) {
758
+ const m = line.match(/^"([^"]+)","(\d+)"/);
759
+ if (!m) continue;
760
+ const pid = parseInt(m[2], 10);
761
+ if (!Number.isFinite(pid) || seen.has(pid)) continue;
762
+ seen.add(pid);
763
+ out.push({ pid, path: '' });
764
+ }
765
+ }
766
+ } else if (os.platform() === 'linux') {
767
+ let entries = [];
768
+ try { entries = fs.readdirSync('/proc'); } catch (_) {}
769
+ for (const e of entries) {
770
+ if (!/^\d+$/.test(e)) continue;
771
+ const pid = parseInt(e, 10);
772
+ let comm = '';
773
+ try { comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); } catch (_) { continue; }
774
+ if (!/^plugkit/i.test(comm)) continue;
775
+ let imagePath = '';
776
+ try { imagePath = fs.readlinkSync(`/proc/${pid}/exe`); } catch (_) {}
777
+ out.push({ pid, path: imagePath });
778
+ }
779
+ } else {
780
+ const r = spawnSync('ps', ['-axo', 'pid=,comm='], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
781
+ const text = (r && r.stdout) || '';
782
+ for (const line of text.split(/\r?\n/)) {
783
+ const m = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
784
+ if (!m) continue;
785
+ if (!/plugkit/i.test(m[2])) continue;
786
+ const pid = parseInt(m[1], 10);
787
+ let imagePath = '';
788
+ try {
789
+ const p = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
790
+ imagePath = ((p && p.stdout) || '').trim().split(/\s+/)[0] || '';
791
+ } catch (_) {}
792
+ out.push({ pid, path: imagePath });
793
+ }
794
+ }
795
+ } catch (_) {}
796
+ return out;
797
+ }
798
+
799
+ function killSpoolWatcherInCwd(reason) {
800
+ try {
801
+ const pidPath = path.join(process.cwd(), '.gm', 'exec-spool', '.watcher.pid');
802
+ if (!fs.existsSync(pidPath)) return null;
803
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
804
+ if (killPid(pid)) {
805
+ obsEvent('bootstrap', 'watcher.killed', { pid, reason });
806
+ try { fs.unlinkSync(pidPath); } catch (_) {}
807
+ return pid;
808
+ }
809
+ try { fs.unlinkSync(pidPath); } catch (_) {}
810
+ } catch (_) {}
811
+ return null;
812
+ }
813
+
814
+ function proactiveKillForNewInstall(installedVersion, finalPath) {
815
+ try {
816
+ const reason = `install:v${installedVersion}`;
817
+ const target = finalPath ? path.resolve(finalPath).toLowerCase() : null;
818
+ const cacheRootNorm = (() => {
819
+ try { return path.resolve(cacheRoot()).toLowerCase(); } catch (_) { return null; }
820
+ })();
821
+ const procs = listRunningPlugkitImagePaths();
822
+ for (const { pid, path: imagePath } of procs) {
823
+ if (!Number.isFinite(pid) || pid === process.pid) continue;
824
+ if (!imagePath) continue;
825
+ const norm = path.resolve(imagePath).toLowerCase();
826
+ if (target && norm === target) continue;
827
+ if (!cacheRootNorm || !norm.startsWith(cacheRootNorm + path.sep.toLowerCase())) continue;
828
+ if (killPid(pid)) {
829
+ try { process.stderr.write(`[bootstrap] killed stale daemon pid=${pid} path=${imagePath} (current install: v${installedVersion})\n`); } catch (_) {}
830
+ obsEvent('bootstrap', 'daemon.killed', { pid, oldPath: imagePath, installedVersion, mechanism: 'process-path' });
831
+ }
832
+ }
833
+ killRunningDaemons(reason);
834
+ killSpoolWatcherInCwd(reason);
835
+ writeDaemonVersion(installedVersion);
836
+ } catch (_) {}
837
+ }
838
+
839
+ // Compare wrapper-pinned version against last-recorded daemon version. If
840
+ // they differ, kill the daemon so it respawns under the new binary.
841
+ function killStaleDaemonIfVersionChanged(wrapperDir) {
842
+ let currentVersion;
843
+ try { currentVersion = readVersionFile(wrapperDir); } catch (_) { return; }
844
+ const cached = resolveCachedBinary({ wrapperDir, version: currentVersion });
845
+ if (cached) {
846
+ proactiveKillForNewInstall(currentVersion, cached);
847
+ return;
848
+ }
849
+ const recorded = readDaemonVersion();
850
+ if (recorded === currentVersion) return;
851
+ if (recorded) killRunningDaemons(`version_change:${recorded}->${currentVersion}`);
852
+ writeDaemonVersion(currentVersion);
853
+ }
854
+
855
+ module.exports = { bootstrap, resolveCachedBinary, resolveCachedRtk, platformKey, binaryName, rtkBinaryName, cacheRoot, obsEvent, killRunningDaemons, killStaleDaemonIfVersionChanged, killSpoolWatcherInCwd, proactiveKillForNewInstall };
856
+
857
+ if (require.main === module) {
858
+ const argv = process.argv.slice(2);
859
+ if (argv.includes('--rtk-only')) {
860
+ const wIdx = argv.indexOf('--wrapper-dir');
861
+ const wrapperDir = wIdx >= 0 ? argv[wIdx + 1] : __dirname;
862
+ (async () => {
863
+ try {
864
+ const version = readVersionFile(wrapperDir);
865
+ let root = cacheRoot();
866
+ try { ensureDir(root); }
867
+ catch (_) { root = fallbackCacheRoot(); ensureDir(root); }
868
+ const verDir = path.join(root, `v${version}`);
869
+ ensureDir(verDir);
870
+ await bootstrapRtk(verDir, version, wrapperDir, true, root);
871
+ process.exit(0);
872
+ } catch (err) {
873
+ obsEvent('bootstrap', 'rtk.detached.failed', { err: String(err.message || err) });
874
+ process.exit(1);
875
+ }
876
+ })();
877
+ } else {
878
+ bootstrap({ silent: false })
879
+ .then(p => { process.stdout.write(p + '\n'); process.exit(0); })
880
+ .catch(err => {
881
+ log(`FATAL: ${err.message}`);
882
+ obsEvent('bootstrap', 'fatal', { err: String(err.message || err) });
883
+ try {
884
+ const pinned = (() => { try { return readVersionFile(__dirname); } catch (_) { return null; } })();
885
+ writeBootstrapError({
886
+ expected_version: pinned,
887
+ cached_version: null,
888
+ error_phase: 'fatal',
889
+ error_message: String(err && err.message || err),
890
+ });
891
+ } catch (_) {}
892
+ process.exit(1);
893
+ });
894
+ }
895
+ }
package/bin/plugkit.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Hot path: spawnSync to ~/.claude/gm-tools/plugkit.exe with inherited stdio.
4
+ // Cold path (session-start / prompt-submit OR missing binary): synchronously
5
+ // ensure gm-tools/plugkit{.exe} matches the pinned version, then run hook.
6
+ // Cache-aware: when local matches the pin (sha-checked), zero network calls.
7
+
8
+ const { spawnSync } = require('child_process');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+
13
+ const wrapperDir = __dirname;
14
+
15
+ function toolsBin() {
16
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
17
+ const exe = process.platform === 'win32' ? 'plugkit.exe' : 'plugkit';
18
+ return path.join(home, '.claude', 'gm-tools', exe);
19
+ }
20
+
21
+ function sha256OfFileSync(filePath) {
22
+ try {
23
+ const crypto = require('crypto');
24
+ const h = crypto.createHash('sha256');
25
+ const fd = fs.openSync(filePath, 'r');
26
+ try {
27
+ const buf = Buffer.alloc(1 << 20);
28
+ let n;
29
+ while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n));
30
+ } finally { fs.closeSync(fd); }
31
+ return h.digest('hex');
32
+ } catch (_) { return null; }
33
+ }
34
+
35
+ function platformAsset() {
36
+ const p = process.platform;
37
+ const a = process.arch;
38
+ if (p === 'win32') return a === 'arm64' ? 'plugkit-win32-arm64.exe' : 'plugkit-win32-x64.exe';
39
+ if (p === 'darwin') return a === 'arm64' ? 'plugkit-darwin-arm64' : 'plugkit-darwin-x64';
40
+ return (a === 'arm64' || a === 'aarch64') ? 'plugkit-linux-arm64' : 'plugkit-linux-x64';
41
+ }
42
+
43
+ function readPinnedVersion() {
44
+ try { return fs.readFileSync(path.join(wrapperDir, 'plugkit.version'), 'utf8').trim(); } catch (_) { return null; }
45
+ }
46
+
47
+ function readExpectedSha() {
48
+ try {
49
+ const manifest = fs.readFileSync(path.join(wrapperDir, 'plugkit.sha256'), 'utf8');
50
+ const asset = platformAsset();
51
+ for (const line of manifest.split(/\r?\n/)) {
52
+ const parts = line.trim().split(/\s+/);
53
+ if (parts.length >= 2 && parts[parts.length - 1].replace(/^\*/, '') === asset) {
54
+ return parts[0].toLowerCase();
55
+ }
56
+ }
57
+ } catch (_) {}
58
+ return null;
59
+ }
60
+
61
+ // Returns true if gm-tools binary matches pinned version by sha. Fast: no network.
62
+ function isReady() {
63
+ const bin = toolsBin();
64
+ if (!fs.existsSync(bin)) return false;
65
+ const expected = readExpectedSha();
66
+ if (!expected) return true; // no manifest to compare against — trust existence
67
+ const actual = sha256OfFileSync(bin);
68
+ return actual && actual.toLowerCase() === expected;
69
+ }
70
+
71
+ // Synchronously run bootstrap.js in a child node. Blocks until install finishes
72
+ // (or fails). Bootstrap itself is cache-aware: re-download only when sha differs
73
+ // from manifest. Wraps stdio:inherit so the user sees progress.
74
+ function ensureReady(silent) {
75
+ if (isReady()) return true;
76
+ const bootstrap = path.join(wrapperDir, 'bootstrap.js');
77
+ const r = spawnSync(process.execPath, [bootstrap], {
78
+ stdio: silent ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'],
79
+ windowsHide: true,
80
+ });
81
+ return r.status === 0 && isReady();
82
+ }
83
+
84
+ function main() {
85
+ const args = process.argv.slice(2);
86
+ const isHook = args[0] === 'hook';
87
+ const hookSubcmd = isHook ? (args[1] || '') : '';
88
+
89
+ // Synchronous readiness check on these hooks. Hot path: isReady() is sha-match
90
+ // against pinned manifest, returns true in <50ms with no network.
91
+ const blocksUntilReady = hookSubcmd === 'session-start' || hookSubcmd === 'prompt-submit';
92
+
93
+ if (blocksUntilReady) {
94
+ if (!ensureReady(false)) {
95
+ process.stderr.write('[plugkit] bootstrap failed; aborting hook\n');
96
+ process.exit(1);
97
+ }
98
+ } else if (!fs.existsSync(toolsBin())) {
99
+ // For non-blocking hooks (pre-tool-use, post-tool-use, stop, etc.): if the
100
+ // binary doesn't exist yet, exit cleanly — session-start will populate it.
101
+ if (isHook) process.exit(0);
102
+ process.exit(1);
103
+ }
104
+
105
+ const bin = toolsBin();
106
+ const r = spawnSync(bin, args, { stdio: 'inherit', windowsHide: true });
107
+ process.exit(r.status ?? 1);
108
+ }
109
+
110
+ main();
@@ -0,0 +1,6 @@
1
+ e5569efe81e4ef06c8349678253ca2845571495e619babb0a79be9268ea83c2a plugkit-win32-x64.exe
2
+ ce2f09f8ea0dd522345a9d9c3e5b04ba44bc37b2046d311d7ff1737f1b3fbf1a plugkit-win32-arm64.exe
3
+ a1a1d376986551828e5a39e4ae931accf66f00663aceac1439b2778cb4fffd27 plugkit-darwin-x64
4
+ 7c36d730edab5cddf678211146ca670c9ce1def17d8b454234ce4bc04a4d7e85 plugkit-darwin-arm64
5
+ c9db60a399caf53c490dc08705713c7d83a1f62db057585a3950d64ab8fa449a plugkit-linux-x64
6
+ b9ebabaace995b1768d1d96ae13ca18a6dc5e2ca65b774fcdd457f069a7d115c plugkit-linux-arm64
@@ -0,0 +1 @@
1
+ 0.1.366
package/bin/rtk.sha256 ADDED
@@ -0,0 +1,6 @@
1
+ 53224d66572e937507a8d2877c768e4e6cc3da66aa6f8d0f132afaab2edc2a10 rtk-win32-x64.exe
2
+ dedabc1d89641c60c91f09570353b6270dba4f5d53f8597018a708e515265d53 rtk-win32-arm64.exe
3
+ cf3190554b82c7395948b7a478c78bbe2241549b00777e660deff4cbb9e0c4b6 rtk-darwin-x64
4
+ c815bad459b4eaccc8be4a5d74dba397fdfe7d3716e0b6023b188d2351128b82 rtk-darwin-arm64
5
+ 7d60dd5abc15f6d46ffd89b5de7253a067e2a3ef6f1cd8ae5a236eda05a504f4 rtk-linux-x64
6
+ cd5dd78035845eef4b362927c61f61e23925af3c12779131024d8334bad87a6b rtk-linux-arm64
@@ -0,0 +1 @@
1
+ 0.40.0
package/cli.js CHANGED
@@ -33,6 +33,7 @@ try {
33
33
  copyRecursive(path.join(srcDir, 'lang'), path.join(ocConfigDir, 'lang'));
34
34
  copyRecursive(path.join(srcDir, 'bin'), path.join(ocConfigDir, 'bin'));
35
35
  copyRecursive(path.join(srcDir, 'hooks'), path.join(ocConfigDir, 'hooks'));
36
+ copyRecursive(path.join(srcDir, 'scripts'), path.join(ocConfigDir, 'scripts'));
36
37
 
37
38
  const ocJsonPath = path.join(ocConfigDir, 'opencode.json');
38
39
  let ocConfig = {};
package/gm-oc.mjs CHANGED
@@ -15,64 +15,32 @@ function runPlugkit(args) {
15
15
  const bin = join(__dirname, '..', 'bin', 'plugkit.js');
16
16
  if (!existsSync(bin)) return '';
17
17
  try {
18
- const r = spawnSync('node', [bin, ...args], { encoding: 'utf-8', timeout: 15000, windowsHide: true });
18
+ const r = spawnSync('node', [bin, ...args], { encoding: 'utf-8', timeout: 300000, windowsHide: true });
19
19
  return (r.stdout || '').trim() || (r.stderr || '').trim();
20
20
  } catch(e) { return ''; }
21
21
  }
22
22
 
23
23
  function safePrintf(s) {
24
- return "printf '%s' '" + String(s).replace(/\\\\/g,'\\\\\\\\').replace(/'/g,"'\\\\''")+"'";
24
+ return "printf '%s' '" + String(s).replace(/'/g,"'\\\\''")+"'";
25
25
  }
26
26
 
27
27
  function stripFooter(s) { return s ? s.replace(/\n\[Running tools\][\s\S]*$/, '').trimEnd() : ''; }
28
28
 
29
- function tryLangPlugin(lang, code, cwd) {
30
- const projectDir = cwd || process.cwd();
31
- const candidates = [join(projectDir, 'lang', lang+'.js'), join(__dirname, '..', 'lang', lang+'.js')];
32
- for (const langPluginFile of candidates) {
33
- if (!existsSync(langPluginFile)) continue;
34
- try {
35
- const plugin = require(langPluginFile);
36
- if (plugin && plugin.exec && plugin.exec.run) {
37
- const result = plugin.exec.run(code, projectDir);
38
- if (result && typeof result.then === 'function') continue;
39
- return String(result === undefined ? '' : result);
40
- }
41
- } catch(e) {}
42
- }
43
- return null;
44
- }
45
-
46
29
  function runExecSync(rawLang, code, cwd) {
47
30
  const lang = LANG_ALIASES[rawLang] || rawLang || 'nodejs';
48
- const opts = { encoding: 'utf-8', timeout: 30000, windowsHide: true, ...(cwd && { cwd }) };
49
- const out = (r) => { const o = (r.stdout||'').trimEnd(), e = stripFooter(r.stderr||'').trimEnd(); return o && e ? o+'\n[stderr]\n'+e : o||e||'(no output)'; };
50
- if (lang === 'codesearch' || lang === 'search') return runPlugkit(['search', '--path', cwd || process.cwd(), code.trim()]);
51
- if (lang === 'runner') return runPlugkit(['runner', code.trim()]);
52
- if (lang === 'status') return runPlugkit(['status', code.trim()]);
53
- if (lang === 'sleep') return runPlugkit(['sleep', code.trim()]);
54
- if (lang === 'close') return runPlugkit(['close', code.trim()]);
55
- if (lang === 'browser') return runPlugkit(['exec', '--lang', 'browser', '--code', code.trim(), '--cwd', cwd || process.cwd()]);
56
- if (lang === 'cmd') return out(spawnSync('cmd',['/c',code],opts));
57
- const pluginResult = tryLangPlugin(lang, code, cwd);
58
- if (pluginResult !== null) return pluginResult;
59
- if (lang === 'python') return out(spawnSync('python3',['-c',code],opts));
60
- if (lang === 'bash' || lang === 'sh') {
61
- const tmp = join(tmpdir(),'gm-exec-'+Date.now()+'.sh');
62
- writeFileSync(tmp,code,'utf-8');
63
- const r = spawnSync('bash',[tmp],opts);
64
- try { unlinkSync(tmp); } catch(e) {}
65
- return out(r);
31
+ const projectDir = cwd || process.cwd();
32
+ if (lang === 'codesearch' || lang === 'search') return runPlugkit(['search', '--path', projectDir, code.trim()]);
33
+ if (['runner','status','sleep','close'].includes(lang)) return runPlugkit([lang, code.trim()]);
34
+ if (['browser','tail','watch','wait','type','kill-port','health','recall','memorize','forget','feedback','discipline','pause'].includes(lang)) {
35
+ return runPlugkit(['exec', '--lang', lang, '--code', code.trim(), '--cwd', projectDir]);
66
36
  }
67
- const ext = lang === 'typescript' ? 'ts' : 'mjs';
68
- const tmp = join(tmpdir(),'gm-exec-'+Date.now()+'.'+ext);
69
- const src = lang === 'typescript' ? code : 'const __r=await(async()=>{\n'+code+'\n})();if(__r!==undefined){if(typeof __r==="object"){console.log(JSON.stringify(__r,null,2));}else{console.log(__r);}}';
70
- writeFileSync(tmp,src,'utf-8');
71
- const r = spawnSync('bun',['run',tmp],opts);
72
- try { unlinkSync(tmp); } catch(e) {}
73
- let result = out(r);
74
- if (result) result = result.split(tmp).join('<script>');
75
- return result;
37
+ if (lang === 'cmd') {
38
+ const opts = { encoding: 'utf-8', timeout: 30000, windowsHide: true, cwd: projectDir };
39
+ const r = spawnSync('cmd', ['/c', code], opts);
40
+ const o = (r.stdout || '').trimEnd(), e = stripFooter(r.stderr || '').trimEnd();
41
+ return o && e ? o + '\n[stderr]\n' + e : o || e || '(no output)';
42
+ }
43
+ return runPlugkit(['exec', '--lang', lang, '--code', code.trim(), '--cwd', projectDir]);
76
44
  }
77
45
 
78
46
  const BANNED_BASH = ['grep','rg','find','glob','awk','sed','cat','head','tail'];
@@ -90,6 +58,7 @@ export async function GmPlugin({ directory }) {
90
58
 
91
59
  return {
92
60
  'experimental.chat.system.transform': async (input, output) => {
61
+ const gmDir = join(directory, '.gm');
93
62
  try {
94
63
  const giPath = join(directory, '.gitignore');
95
64
  const entry = '.gm-stop-verified';
@@ -113,6 +82,12 @@ export async function GmPlugin({ directory }) {
113
82
  if (prd) output.system.push('\nPENDING WORK (.prd):\n'+prd);
114
83
  }
115
84
  } catch(e) {}
85
+ try {
86
+ const needsGmPath = join(gmDir, 'needs-gm');
87
+ if (existsSync(needsGmPath)) {
88
+ output.system.push('\nCRITICAL: You MUST invoke the `gm` skill before any other tool. Your current state is BLOCKED by a safety gate until gm runs.');
89
+ }
90
+ } catch(e) {}
116
91
  },
117
92
 
118
93
  'experimental.chat.messages.transform': async (input, output) => {
@@ -207,10 +182,10 @@ export async function GmPlugin({ directory }) {
207
182
  if (input.tool !== 'bash' && input.tool !== 'Bash' && input.tool !== 'shell' && input.tool !== 'Shell' && input.tool !== 'spawn/exec') return;
208
183
  const cmd = (output.args && output.args.command) || '';
209
184
  if (!cmd) return;
210
- if (/^\s*git(?:\s|$)/.test(cmd)) return;
211
- const m = cmd.match(/^exec(?::(\S+))?\n([\s\S]+)$/);
185
+ if (/^\s*(git|gh)(?:\s|$)/.test(cmd)) return;
186
+ const m = cmd.match(/^exec(?::(\S+))?\s*\n([\s\S]+)$/);
212
187
  if (!m) {
213
- throw new Error('Use exec: prefix for Bash. The command must start with `exec` or `exec:<lang>` on its own line, then the body on the next line. Examples:\n\nexec\nls -la\n\nexec:nodejs\nconsole.log("hello")\n\nexec:bash\ngit status\n\nLanguages: nodejs (default), bash, python, typescript, go, rust, deno, cmd. File I/O via exec:nodejs + require("fs"). Raw JIT execution can also bypass Bash entirely: write to `.gm/exec-spool/in/<lang>/<N>.<ext>` (e.g. in/nodejs/42.js) and the spool watcher executes it and writes `.gm/exec-spool/out/<N>.json`. Codebase search: exec:codesearch on its own line, then a two-word query.');
188
+ throw new Error('Use exec: prefix for Bash. The command must start with `exec` or `exec:<lang>` on its own line, then the body on the next line. Examples:\\n\\nexec\\nls -la\\n\\nexec:nodejs\\nconsole.log("hello")\\n\\nexec:bash\\ngit status\\n\\nLanguages: nodejs (default), bash, python, typescript, go, rust, deno, cmd. File I/O via exec:nodejs + require("fs"). Raw JIT execution can also bypass Bash entirely: write to `.gm/exec-spool/in/<lang>/<N>.<ext>` (e.g. in/nodejs/42.js) and the spool watcher executes it and writes `.gm/exec-spool/out/<N>.json`. Codebase search: exec:codesearch on its own line, then a two-word query.');
214
189
  }
215
190
  const rawLang = (m[1]||'').toLowerCase();
216
191
  if (rawLang === 'bash' || rawLang === 'sh' || rawLang === '') {
@@ -218,7 +193,7 @@ export async function GmPlugin({ directory }) {
218
193
  if (banned) throw new Error('`'+banned+'` is blocked in exec:bash. Use exec:codesearch instead. For raw JIT execution, write code to `.gm/exec-spool/in/<lang>/<N>.<ext>` (e.g. in/nodejs/42.js); the spool watcher executes it and writes out/<N>.json.');
219
194
  }
220
195
  const result = runExecSync(m[1]||'', m[2], output.args.workdir || directory);
221
- output.args = { ...output.args, command: safePrintf('exec:'+(m[1]||'nodejs')+' output:\n\n'+result) };
196
+ throw new Error('exec:'+(m[1]||'nodejs')+' output:\n\n'+result);
222
197
  },
223
198
  'message.updated': async (input, output) => {
224
199
  try {
package/install.js CHANGED
@@ -33,10 +33,14 @@ function install() {
33
33
  if (!isInsideNodeModules()) return;
34
34
  const projectRoot = getProjectRoot();
35
35
  if (!projectRoot) return;
36
- const ocDir = path.join(projectRoot, '.config', 'opencode', 'plugin');
36
+ const ocDir = path.join(projectRoot, '.opencode', 'plugins', 'gm-oc');
37
37
  const sourceDir = __dirname;
38
38
  safeCopyDirectory(path.join(sourceDir, 'agents'), path.join(ocDir, 'agents'));
39
39
  safeCopyDirectory(path.join(sourceDir, 'hooks'), path.join(ocDir, 'hooks'));
40
+ safeCopyDirectory(path.join(sourceDir, 'bin'), path.join(ocDir, 'bin'));
41
+ safeCopyDirectory(path.join(sourceDir, 'skills'), path.join(ocDir, 'skills'));
42
+ safeCopyDirectory(path.join(sourceDir, 'lang'), path.join(ocDir, 'lang'));
43
+ safeCopyDirectory(path.join(sourceDir, 'scripts'), path.join(ocDir, 'scripts'));
40
44
  }
41
45
 
42
46
  install();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-oc",
3
- "version": "2.0.1059",
3
+ "version": "2.0.1060",
4
4
  "description": "State machine agent with hooks, skills, and automated git enforcement",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "agents/",
38
+ "bin/",
38
39
  "hooks/",
39
40
  "scripts/",
40
41
  "skills/",