gm-cc 2.0.727 → 2.0.1063

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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/agents/gm.md +1 -3
  3. package/agents/memorize.md +22 -2
  4. package/agents/research-worker.md +36 -0
  5. package/agents/textprocessing.md +47 -0
  6. package/bin/bootstrap.js +624 -34
  7. package/bin/plugkit.js +95 -53
  8. package/bin/plugkit.sha256 +6 -6
  9. package/bin/plugkit.version +1 -1
  10. package/bin/rtk.sha256 +6 -0
  11. package/bin/rtk.version +1 -0
  12. package/hooks/hooks.json +2 -46
  13. package/hooks/hooks.spec.json +48 -0
  14. package/package.json +2 -2
  15. package/plugin.json +1 -1
  16. package/skills/browser/SKILL.md +18 -16
  17. package/skills/code-search/SKILL.md +15 -15
  18. package/skills/create-lang-plugin/SKILL.md +22 -26
  19. package/skills/gm/SKILL.md +30 -67
  20. package/skills/gm-cc/SKILL.md +19 -0
  21. package/skills/gm-codex/SKILL.md +19 -0
  22. package/skills/gm-complete/SKILL.md +52 -69
  23. package/skills/gm-copilot-cli/SKILL.md +19 -0
  24. package/skills/gm-cursor/SKILL.md +19 -0
  25. package/skills/gm-emit/SKILL.md +44 -61
  26. package/skills/gm-execute/SKILL.md +42 -79
  27. package/skills/gm-gc/SKILL.md +19 -0
  28. package/skills/gm-jetbrains/SKILL.md +19 -0
  29. package/skills/gm-kilo/SKILL.md +19 -0
  30. package/skills/gm-oc/SKILL.md +19 -0
  31. package/skills/gm-vscode/SKILL.md +19 -0
  32. package/skills/gm-zed/SKILL.md +19 -0
  33. package/skills/governance/SKILL.md +24 -23
  34. package/skills/pages/SKILL.md +42 -92
  35. package/skills/planning/SKILL.md +53 -86
  36. package/skills/research/SKILL.md +43 -0
  37. package/skills/ssh/SKILL.md +15 -9
  38. package/skills/textprocessing/SKILL.md +40 -0
  39. package/skills/update-docs/SKILL.md +27 -21
  40. package/.github/workflows/publish-npm.yml +0 -44
  41. package/hooks/post-tool-use-hook.js +0 -34
  42. package/hooks/pre-tool-use-hook.js +0 -45
  43. package/hooks/prompt-submit-hook.js +0 -19
  44. package/hooks/session-start-hook.js +0 -23
package/bin/bootstrap.js CHANGED
@@ -9,16 +9,70 @@ const crypto = require('crypto');
9
9
  const { URL } = require('url');
10
10
 
11
11
  const RELEASE_REPO = 'AnEntrypoint/plugkit-bin';
12
- const ATTEMPT_TIMEOUT_MS = 60 * 1000;
13
- const STALL_TIMEOUT_MS = 20 * 1000;
12
+ const ATTEMPT_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const STALL_TIMEOUT_MS = 15 * 1000;
14
14
  const MAX_ATTEMPTS = 5;
15
15
  const BACKOFF_MS = [2000, 5000, 15000, 30000];
16
- const LOCK_STALE_MS = 5 * 60 * 1000;
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;
17
22
 
18
23
  function log(msg) {
19
24
  try { process.stderr.write(`[plugkit-bootstrap] ${msg}\n`); } catch (_) {}
20
25
  }
21
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
+
22
76
  function platformKey() {
23
77
  const p = os.platform();
24
78
  const a = os.arch();
@@ -32,6 +86,11 @@ function binaryName() {
32
86
  return key.startsWith('win32') ? `plugkit-${key}.exe` : `plugkit-${key}`;
33
87
  }
34
88
 
89
+ function rtkBinaryName() {
90
+ const key = platformKey();
91
+ return key.startsWith('win32') ? `rtk-${key}.exe` : `rtk-${key}`;
92
+ }
93
+
35
94
  function cacheRoot() {
36
95
  const home = os.homedir();
37
96
  if (process.env.PLUGKIT_CACHE_DIR) return process.env.PLUGKIT_CACHE_DIR;
@@ -48,6 +107,115 @@ function fallbackCacheRoot() {
48
107
  return path.join(os.tmpdir(), 'plugkit-cache', 'bin');
49
108
  }
50
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
+
51
219
  function ensureDir(dir) {
52
220
  fs.mkdirSync(dir, { recursive: true });
53
221
  }
@@ -58,8 +226,45 @@ function readVersionFile(wrapperDir) {
58
226
  return fs.readFileSync(p, 'utf8').trim();
59
227
  }
60
228
 
61
- function readShaManifest(wrapperDir) {
62
- const p = path.join(wrapperDir, 'plugkit.sha256');
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');
63
268
  if (!fs.existsSync(p)) return null;
64
269
  const out = {};
65
270
  for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
@@ -95,9 +300,7 @@ function acquireLock(lockPath) {
95
300
  continue;
96
301
  }
97
302
  if (Date.now() - start > ATTEMPT_TIMEOUT_MS) throw new Error(`lock wait timeout: ${lockPath}`);
98
- const waitMs = 2000;
99
- const deadline = Date.now() + waitMs;
100
- while (Date.now() < deadline) {}
303
+ try { const { spawnSync } = require('child_process'); spawnSync(process.execPath, ['-e', 'setTimeout(()=>{}, 2000)'], { timeout: 2500, killSignal: 'SIGKILL', stdio: 'ignore', windowsHide: true }); } catch (_) {}
101
304
  }
102
305
  }
103
306
  }
@@ -145,10 +348,17 @@ function fetchToFile(url, destPath, expectedTotal) {
145
348
  return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
146
349
  }
147
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 (_) {}
148
355
  const out = fs.createWriteStream(destPath, { flags: append ? 'a' : 'w' });
149
356
  let bytes = append ? existing : 0;
150
- let lastLog = Date.now();
357
+ let lastStderr = Date.now();
151
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 });
152
362
  const stallTimer = setInterval(() => {
153
363
  if (Date.now() - lastByte > STALL_TIMEOUT_MS) {
154
364
  clearInterval(stallTimer);
@@ -158,15 +368,19 @@ function fetchToFile(url, destPath, expectedTotal) {
158
368
  res.on('data', c => {
159
369
  bytes += c.length;
160
370
  lastByte = Date.now();
161
- if (Date.now() - lastLog > 5000) {
371
+ if (Date.now() - lastStderr > 5000) {
162
372
  const pct = expectedTotal ? ` ${Math.floor(bytes / expectedTotal * 100)}%` : '';
163
- log(`downloading: ${(bytes / 1048576).toFixed(1)} MiB${pct}`);
164
- lastLog = Date.now();
373
+ try { process.stderr.write(`[plugkit-bootstrap] downloading: ${(bytes / 1048576).toFixed(1)} MiB${pct}\n`); } catch (_) {}
374
+ lastStderr = Date.now();
165
375
  }
166
376
  });
167
377
  res.pipe(out);
168
- out.on('finish', () => { clearInterval(stallTimer); out.close(() => resolve(bytes)); });
169
- out.on('error', err => { clearInterval(stallTimer); reject(err); });
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); });
170
384
  res.on('error', err => { clearInterval(stallTimer); reject(err); });
171
385
  res.on('end', () => clearInterval(stallTimer));
172
386
  });
@@ -186,6 +400,7 @@ async function downloadWithRetry(url, destPath) {
186
400
  } catch (err) {
187
401
  lastErr = err;
188
402
  log(`attempt ${attempt} failed: ${err.message}`);
403
+ obsEvent('bootstrap', 'fetch.attempt_failed', { url, attempt, max: MAX_ATTEMPTS, err: String(err.message || err) });
189
404
  if (attempt < MAX_ATTEMPTS) {
190
405
  const wait = BACKOFF_MS[attempt - 1] || 120000;
191
406
  log(`backing off ${wait}ms`);
@@ -196,17 +411,31 @@ async function downloadWithRetry(url, destPath) {
196
411
  throw lastErr;
197
412
  }
198
413
 
199
- function pruneOldVersions(root, keepVersion) {
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) {
200
425
  try {
201
426
  const entries = fs.readdirSync(root);
202
427
  for (const e of entries) {
203
- if (!e.startsWith('v')) continue;
204
- if (e === `v${keepVersion}`) continue;
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;
205
433
  const dir = path.join(root, e);
206
434
  const lock = path.join(dir, '.lock');
207
- if (fs.existsSync(lock)) continue;
435
+ if (fs.existsSync(lock) && !isLockStale(lock)) continue;
436
+ if (fs.existsSync(lock)) { try { fs.unlinkSync(lock); } catch (_) {} }
208
437
  try {
209
- fs.rmSync(dir, { recursive: true, force: true });
438
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 1, retryDelay: 50 });
210
439
  log(`pruned ${dir}`);
211
440
  } catch (err) { log(`prune skip ${dir}: ${err.message}`); }
212
441
  }
@@ -230,10 +459,39 @@ async function bootstrap(opts) {
230
459
 
231
460
  const finalPath = path.join(verDir, binName);
232
461
  const okSentinel = path.join(verDir, '.ok');
462
+ const partialPath = `${finalPath}.partial`;
233
463
 
234
464
  if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
235
- if (!opts.silent) log(`cache hit: ${finalPath}`);
236
- pruneOldVersions(root, version);
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();
237
495
  return finalPath;
238
496
  }
239
497
 
@@ -241,18 +499,51 @@ async function bootstrap(opts) {
241
499
  acquireLock(lockPath);
242
500
  try {
243
501
  if (fs.existsSync(finalPath) && fs.existsSync(okSentinel)) {
244
- pruneOldVersions(root, version);
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();
245
512
  return finalPath;
246
513
  }
247
514
 
248
- const tmpPath = `${finalPath}.partial`;
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
+ }
249
524
  const url = `https://github.com/${RELEASE_REPO}/releases/download/v${version}/${binName}`;
250
- await downloadWithRetry(url, tmpPath);
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
+ }
251
536
 
252
537
  if (expectedSha) {
253
- const got = await sha256OfFile(tmpPath);
538
+ const got = await sha256OfFile(partialPath);
254
539
  if (got !== expectedSha) {
255
- try { fs.unlinkSync(tmpPath); } catch (_) {}
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
+ });
256
547
  throw new Error(`sha256 mismatch for ${binName}: expected ${expectedSha}, got ${got}`);
257
548
  }
258
549
  log('sha256 verified');
@@ -260,11 +551,11 @@ async function bootstrap(opts) {
260
551
  log('no sha256 manifest — skipping verify');
261
552
  }
262
553
 
263
- try { fs.renameSync(tmpPath, finalPath); }
554
+ try { fs.renameSync(partialPath, finalPath); }
264
555
  catch (err) {
265
556
  if (err.code === 'EEXIST' || err.code === 'EPERM') {
266
557
  try { fs.unlinkSync(finalPath); } catch (_) {}
267
- fs.renameSync(tmpPath, finalPath);
558
+ fs.renameSync(partialPath, finalPath);
268
559
  } else throw err;
269
560
  }
270
561
 
@@ -273,14 +564,97 @@ async function bootstrap(opts) {
273
564
  }
274
565
 
275
566
  fs.writeFileSync(okSentinel, new Date().toISOString());
276
- log(`installed ${finalPath}`);
277
- pruneOldVersions(root, version);
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();
278
574
  return finalPath;
279
575
  } finally {
280
576
  releaseLock(lockPath);
281
577
  }
282
578
  }
283
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
+
284
658
  function resolveCachedBinary(opts) {
285
659
  opts = opts || {};
286
660
  const wrapperDir = opts.wrapperDir || __dirname;
@@ -296,10 +670,226 @@ function resolveCachedBinary(opts) {
296
670
  return null;
297
671
  }
298
672
 
299
- module.exports = { bootstrap, resolveCachedBinary, platformKey, binaryName, cacheRoot };
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 };
300
856
 
301
857
  if (require.main === module) {
302
- bootstrap({ silent: false })
303
- .then(p => { process.stdout.write(p + '\n'); process.exit(0); })
304
- .catch(err => { log(`FATAL: ${err.message}`); process.exit(1); });
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
+ }
305
895
  }