gm-plugkit 2.0.1156 → 2.0.1158

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/bootstrap.js CHANGED
@@ -777,14 +777,6 @@ function startSpoolDaemon() {
777
777
  return { ok: false, error: `wrapper not at ${wrapper} — ensureReady() must run first` };
778
778
  }
779
779
  const runtime = process.platform === 'win32' ? 'bun.exe' : 'bun';
780
- let cmd = runtime;
781
- let args = [wrapper, 'spool'];
782
- try {
783
- require('child_process').execFileSync(runtime, ['--version'], { stdio: 'ignore' });
784
- } catch (_) {
785
- cmd = process.execPath;
786
- args = [wrapper, 'spool'];
787
- }
788
780
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
789
781
  const spoolDir = path.join(projectDir, '.gm', 'exec-spool');
790
782
  fs.mkdirSync(spoolDir, { recursive: true });
@@ -796,18 +788,43 @@ function startSpoolDaemon() {
796
788
  fs.renameSync(logPath, path.join(spoolDir, '.watcher.log.1'));
797
789
  }
798
790
  } catch (_) {}
791
+
792
+ const supervisor = path.join(__dirname, 'supervisor.js');
793
+ if (process.env.PLUGKIT_SKIP_SUPERVISOR === '1' || !fs.existsSync(supervisor)) {
794
+ let cmd = runtime;
795
+ let args = [wrapper, 'spool'];
796
+ try {
797
+ require('child_process').execFileSync(runtime, ['--version'], { stdio: 'ignore' });
798
+ } catch (_) {
799
+ cmd = process.execPath;
800
+ args = [wrapper, 'spool'];
801
+ }
802
+ const logFd = fs.openSync(logPath, 'a');
803
+ try { fs.writeSync(logFd, `\n--- daemon spawn ${new Date().toISOString()} parent=${process.pid} (no supervisor) ---\n`); } catch (_) {}
804
+ const child = require('child_process').spawn(cmd, args, {
805
+ detached: true,
806
+ stdio: ['ignore', logFd, logFd],
807
+ windowsHide: true,
808
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir, PLUGKIT_BOOT_REASON: 'direct-no-supervisor' },
809
+ });
810
+ try { fs.closeSync(logFd); } catch (_) {}
811
+ const pid = child.pid;
812
+ child.unref();
813
+ return { ok: true, pid, wrapper, runtime: cmd, logPath, supervised: false };
814
+ }
815
+
799
816
  const logFd = fs.openSync(logPath, 'a');
800
- try { fs.writeSync(logFd, `\n--- daemon spawn ${new Date().toISOString()} parent=${process.pid} ---\n`); } catch (_) {}
801
- const child = require('child_process').spawn(cmd, args, {
817
+ try { fs.writeSync(logFd, `\n--- supervisor spawn ${new Date().toISOString()} parent=${process.pid} ---\n`); } catch (_) {}
818
+ const child = require('child_process').spawn(process.execPath, [supervisor], {
802
819
  detached: true,
803
820
  stdio: ['ignore', logFd, logFd],
804
821
  windowsHide: true,
805
- env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
822
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir, PLUGKIT_RUNTIME: runtime },
806
823
  });
807
824
  try { fs.closeSync(logFd); } catch (_) {}
808
825
  const pid = child.pid;
809
826
  child.unref();
810
- return { ok: true, pid, wrapper, runtime: cmd, logPath };
827
+ return { ok: true, pid, wrapper, supervisor, runtime, logPath, supervised: true };
811
828
  } catch (e) {
812
829
  return { ok: false, error: e.message };
813
830
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1156",
3
+ "version": "2.0.1158",
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": {
@@ -123,8 +123,33 @@ function emitOrchestratorEvents(verb, taskBase, resultStr) {
123
123
  }
124
124
 
125
125
  const TMP_DIR = os.tmpdir();
126
- const BROWSER_PORTS_FILE = path.join(TMP_DIR, 'plugkit-browser-ports.json');
127
- const BROWSER_SESSIONS_FILE = path.join(TMP_DIR, 'plugkit-browser-sessions.json');
126
+ const LEGACY_BROWSER_PORTS_FILE = path.join(TMP_DIR, 'plugkit-browser-ports.json');
127
+ const LEGACY_BROWSER_SESSIONS_FILE = path.join(TMP_DIR, 'plugkit-browser-sessions.json');
128
+
129
+ function browserStateDir(cwd) {
130
+ const dir = path.join(cwd || process.cwd(), '.gm', 'exec-spool');
131
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {}
132
+ return dir;
133
+ }
134
+ function browserPortsFile(cwd) { return path.join(browserStateDir(cwd), 'browser-ports.json'); }
135
+ function browserSessionsFile(cwd) { return path.join(browserStateDir(cwd), 'browser-sessions.json'); }
136
+
137
+ function migrateLegacyBrowserState(cwd) {
138
+ const dst1 = browserPortsFile(cwd);
139
+ const dst2 = browserSessionsFile(cwd);
140
+ try {
141
+ if (!fs.existsSync(dst1) && fs.existsSync(LEGACY_BROWSER_PORTS_FILE)) {
142
+ const legacy = JSON.parse(fs.readFileSync(LEGACY_BROWSER_PORTS_FILE, 'utf-8'));
143
+ if (legacy && typeof legacy === 'object') fs.writeFileSync(dst1, JSON.stringify(legacy, null, 2));
144
+ }
145
+ } catch (_) {}
146
+ try {
147
+ if (!fs.existsSync(dst2) && fs.existsSync(LEGACY_BROWSER_SESSIONS_FILE)) {
148
+ const legacy = JSON.parse(fs.readFileSync(LEGACY_BROWSER_SESSIONS_FILE, 'utf-8'));
149
+ if (legacy && typeof legacy === 'object') fs.writeFileSync(dst2, JSON.stringify(legacy, null, 2));
150
+ }
151
+ } catch (_) {}
152
+ }
128
153
 
129
154
  function readJsonFile(fp, fallback) {
130
155
  try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch (_) { return fallback; }
@@ -246,8 +271,11 @@ function runPlaywriter(pw, args, timeoutMs) {
246
271
  }
247
272
 
248
273
  function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
249
- const ports = readJsonFile(BROWSER_PORTS_FILE, {});
250
- const sessions = readJsonFile(BROWSER_SESSIONS_FILE, {});
274
+ migrateLegacyBrowserState(cwd);
275
+ const portsFile = browserPortsFile(cwd);
276
+ const sessionsFile = browserSessionsFile(cwd);
277
+ const ports = readJsonFile(portsFile, {});
278
+ const sessions = readJsonFile(sessionsFile, {});
251
279
  const existing = ports[claudeSessionId];
252
280
  if (existing && existing.port && isPortAliveSync(existing.port)) {
253
281
  const pwIds = sessions[claudeSessionId] || [];
@@ -265,6 +293,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
265
293
  '--disable-features=Translate',
266
294
  ];
267
295
  const child = spawn(chrome, chromeArgs, { detached: true, stdio: 'ignore' });
296
+ const chromePid = child.pid;
268
297
  child.unref();
269
298
  const deadline = Date.now() + 10000;
270
299
  let alive = false;
@@ -288,10 +317,10 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
288
317
  try { const j = JSON.parse(out); pwSessionId = j.id || j.session_id || j.session; } catch (_) {}
289
318
  }
290
319
  if (!pwSessionId) throw new Error(`could not parse playwriter session id from: ${out}`);
291
- ports[claudeSessionId] = { port, profileDir };
320
+ ports[claudeSessionId] = { port, profileDir, pid: chromePid };
292
321
  sessions[claudeSessionId] = [pwSessionId];
293
- writeJsonFile(BROWSER_PORTS_FILE, ports);
294
- writeJsonFile(BROWSER_SESSIONS_FILE, sessions);
322
+ writeJsonFile(portsFile, ports);
323
+ writeJsonFile(sessionsFile, sessions);
295
324
  return pwSessionId;
296
325
  }
297
326
 
@@ -406,6 +435,192 @@ function kvFilePath(ns, key) {
406
435
  return path.join(dir, safeKey + '.json');
407
436
  }
408
437
 
438
+ const __tasks = new Map();
439
+
440
+ function tasksDir(cwd) {
441
+ const d = path.join(cwd || process.cwd(), '.gm', 'exec-spool', 'tasks');
442
+ try { fs.mkdirSync(d, { recursive: true }); } catch (_) {}
443
+ return d;
444
+ }
445
+
446
+ function taskMetaPath(cwd, id) { return path.join(tasksDir(cwd), `${id}.json`); }
447
+ function taskOutPath(cwd, id, which) { return path.join(tasksDir(cwd), `${id}.${which}.log`); }
448
+
449
+ function writeTaskMeta(cwd, id, meta) {
450
+ try { fs.writeFileSync(taskMetaPath(cwd, id), JSON.stringify(meta, null, 2)); } catch (_) {}
451
+ }
452
+
453
+ function nextTaskId(cwd) {
454
+ const counterPath = path.join(tasksDir(cwd), '.counter');
455
+ let n = 0;
456
+ try { n = parseInt(fs.readFileSync(counterPath, 'utf-8'), 10) || 0; } catch (_) {}
457
+ n += 1;
458
+ try { fs.writeFileSync(counterPath, String(n)); } catch (_) {}
459
+ return `t${n}`;
460
+ }
461
+
462
+ function langToCmd(lang, code) {
463
+ if (lang === 'nodejs' || lang === 'js' || lang === 'javascript' || lang === 'node') return { cmd: process.execPath, args: ['-e', code], stdinCode: null };
464
+ if (lang === 'python' || lang === 'py') return { cmd: 'python', args: ['-c', code], stdinCode: null };
465
+ if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') return { cmd: 'bash', args: ['-c', code], stdinCode: null };
466
+ if (lang === 'powershell' || lang === 'ps1') return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', code], stdinCode: null };
467
+ if (lang === 'deno') return { cmd: 'deno', args: ['eval', code], stdinCode: null };
468
+ return null;
469
+ }
470
+
471
+ function spawnTask({ cwd, lang, code, timeoutMs }) {
472
+ const id = nextTaskId(cwd);
473
+ const built = langToCmd(lang, code);
474
+ if (!built) return { ok: false, error: `unsupported lang: ${lang}` };
475
+ const outLog = taskOutPath(cwd, id, 'stdout');
476
+ const errLog = taskOutPath(cwd, id, 'stderr');
477
+ let outFd = null, errFd = null;
478
+ try { outFd = fs.openSync(outLog, 'a'); } catch (_) {}
479
+ try { errFd = fs.openSync(errLog, 'a'); } catch (_) {}
480
+ const startedMs = Date.now();
481
+ const isPosix = process.platform !== 'win32';
482
+ const child = spawn(built.cmd, built.args, {
483
+ cwd: cwd || process.cwd(),
484
+ detached: isPosix,
485
+ stdio: ['ignore', outFd || 'ignore', errFd || 'ignore'],
486
+ windowsHide: true,
487
+ env: process.env,
488
+ });
489
+ try { if (outFd !== null) fs.closeSync(outFd); } catch (_) {}
490
+ try { if (errFd !== null) fs.closeSync(errFd); } catch (_) {}
491
+ const meta = {
492
+ id,
493
+ pid: child.pid,
494
+ pgid: isPosix ? child.pid : null,
495
+ lang,
496
+ cmd: built.cmd,
497
+ cwd: cwd || process.cwd(),
498
+ started_ms: startedMs,
499
+ timeout_ms: timeoutMs,
500
+ deadline_ms: startedMs + timeoutMs,
501
+ status: 'running',
502
+ exit_code: null,
503
+ stdout_log: outLog,
504
+ stderr_log: errLog,
505
+ };
506
+ __tasks.set(id, { child, meta });
507
+ writeTaskMeta(cwd, id, meta);
508
+ child.on('exit', (code, signal) => {
509
+ meta.status = signal ? 'killed' : (code === 0 ? 'completed' : 'failed');
510
+ meta.exit_code = code;
511
+ meta.signal = signal;
512
+ meta.ended_ms = Date.now();
513
+ writeTaskMeta(meta.cwd, id, meta);
514
+ });
515
+ child.on('error', (err) => {
516
+ meta.status = 'error';
517
+ meta.error = err.message;
518
+ meta.ended_ms = Date.now();
519
+ writeTaskMeta(meta.cwd, id, meta);
520
+ });
521
+ logEvent('plugkit', 'task.spawn', { task_id: id, pid: child.pid, lang, timeout_ms: timeoutMs });
522
+ return { ok: true, task_id: id, pid: child.pid, started_ms: startedMs };
523
+ }
524
+
525
+ function stopTaskById(id) {
526
+ const entry = __tasks.get(id);
527
+ if (!entry) {
528
+ return { ok: false, error: 'unknown task_id', task_id: id };
529
+ }
530
+ const { child, meta } = entry;
531
+ if (meta.status !== 'running') return { ok: true, already: meta.status, task_id: id };
532
+ const pid = meta.pid;
533
+ const isPosix = process.platform !== 'win32';
534
+ try {
535
+ if (isPosix && meta.pgid) {
536
+ try { process.kill(-meta.pgid, 'SIGTERM'); } catch (_) {}
537
+ } else {
538
+ try { child.kill('SIGTERM'); } catch (_) {}
539
+ }
540
+ } catch (_) {}
541
+ const graceTimer = setTimeout(() => {
542
+ if (meta.status !== 'running') return;
543
+ if (isPosix && meta.pgid) {
544
+ try { process.kill(-meta.pgid, 'SIGKILL'); } catch (_) {}
545
+ } else if (process.platform === 'win32') {
546
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', timeout: 3000 }); } catch (_) {}
547
+ } else {
548
+ try { child.kill('SIGKILL'); } catch (_) {}
549
+ }
550
+ }, 2000);
551
+ graceTimer.unref && graceTimer.unref();
552
+ logEvent('plugkit', 'task.stop', { task_id: id, pid });
553
+ return { ok: true, task_id: id, pid };
554
+ }
555
+
556
+ function tailFile(filePath, maxBytes) {
557
+ try {
558
+ const stat = fs.statSync(filePath);
559
+ if (stat.size <= maxBytes) return fs.readFileSync(filePath, 'utf-8');
560
+ const fd = fs.openSync(filePath, 'r');
561
+ try {
562
+ const buf = Buffer.alloc(maxBytes);
563
+ fs.readSync(fd, buf, 0, maxBytes, stat.size - maxBytes);
564
+ return buf.toString('utf-8');
565
+ } finally { try { fs.closeSync(fd); } catch (_) {} }
566
+ } catch (_) { return ''; }
567
+ }
568
+
569
+ function listTasks(cwd) {
570
+ const d = tasksDir(cwd);
571
+ const out = [];
572
+ try {
573
+ for (const entry of fs.readdirSync(d)) {
574
+ if (!entry.endsWith('.json') || entry.startsWith('.')) continue;
575
+ try {
576
+ const meta = JSON.parse(fs.readFileSync(path.join(d, entry), 'utf-8'));
577
+ out.push(meta);
578
+ } catch (_) {}
579
+ }
580
+ } catch (_) {}
581
+ return out;
582
+ }
583
+
584
+ function reapTimedOutTasks() {
585
+ const now = Date.now();
586
+ for (const [id, entry] of __tasks) {
587
+ const m = entry.meta;
588
+ if (m.status === 'running' && m.deadline_ms && now > m.deadline_ms) {
589
+ logEvent('plugkit', 'task.timeout', { task_id: id, pid: m.pid, deadline_ms: m.deadline_ms, now_ms: now });
590
+ stopTaskById(id);
591
+ }
592
+ }
593
+ }
594
+
595
+ function killAllTasks(reason) {
596
+ let killed = 0;
597
+ for (const [id, entry] of __tasks) {
598
+ if (entry.meta.status === 'running') {
599
+ stopTaskById(id);
600
+ killed += 1;
601
+ }
602
+ }
603
+ if (killed > 0) logEvent('plugkit', 'task.killAll', { reason, count: killed });
604
+ return killed;
605
+ }
606
+
607
+ function hostTaskProc(action, params) {
608
+ switch (action) {
609
+ case 'spawn': return spawnTask(params);
610
+ case 'stop': return stopTaskById(params.id || params.task_id);
611
+ case 'list': return { ok: true, tasks: listTasks(params.cwd) };
612
+ case 'output': return {
613
+ ok: true,
614
+ task_id: params.id || params.task_id,
615
+ stdout: tailFile(taskOutPath(params.cwd, params.id || params.task_id, 'stdout'), params.max_bytes || 65536),
616
+ stderr: tailFile(taskOutPath(params.cwd, params.id || params.task_id, 'stderr'), params.max_bytes || 65536),
617
+ };
618
+ case 'reap': { reapTimedOutTasks(); return { ok: true }; }
619
+ case 'killAll': { const n = killAllTasks(params.reason || 'host_task_proc'); return { ok: true, killed: n }; }
620
+ default: return { ok: false, error: `unknown action: ${action}` };
621
+ }
622
+ }
623
+
409
624
  function makeHostFunctions(instanceRef) {
410
625
  return {
411
626
  host_fs_read: (pathPtr, pathLen) => {
@@ -684,6 +899,19 @@ function makeHostFunctions(instanceRef) {
684
899
  return 0n;
685
900
  }
686
901
  },
902
+
903
+ host_task_proc: (actionPtr, actionLen, paramsPtr, paramsLen) => {
904
+ try {
905
+ const action = readWasmStr(instanceRef.value, actionPtr, actionLen);
906
+ const paramsStr = readWasmStr(instanceRef.value, paramsPtr, paramsLen);
907
+ const params = paramsStr ? JSON.parse(paramsStr) : {};
908
+ if (!params.cwd) params.cwd = process.cwd();
909
+ const result = hostTaskProc(action, params);
910
+ return writeWasmJson(instanceRef.value, result);
911
+ } catch (e) {
912
+ return writeWasmJson(instanceRef.value, { ok: false, error: e.message });
913
+ }
914
+ },
687
915
  };
688
916
  }
689
917
 
@@ -742,6 +970,96 @@ async function runSpoolWatcher(instance, spoolDir) {
742
970
  }
743
971
  acquireLock();
744
972
  setInterval(refreshLock, 5000);
973
+
974
+ const IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_IDLE_LIMIT_MS, 10) || 15 * 60 * 1000;
975
+ const IDLE_CHECK_MS = 60_000;
976
+ const SHUTDOWN_REASON_PATH = path.join(spoolDir, '.shutdown-reason.json');
977
+ const STATUS_PATH_FOR_TEARDOWN = path.join(spoolDir, '.status.json');
978
+ const ACPTOAPI_STATUS_PATH = path.join(process.cwd(), '.gm', 'acptoapi-status.json');
979
+ let lastActivityMs = Date.now();
980
+ function markActivity(source) {
981
+ lastActivityMs = Date.now();
982
+ }
983
+
984
+ function killPidQuiet(pid) {
985
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
986
+ try { process.kill(pid, 'SIGTERM'); } catch (_) {}
987
+ if (process.platform === 'win32') {
988
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', timeout: 3000 }); } catch (_) {}
989
+ }
990
+ return true;
991
+ }
992
+
993
+ function teardownAll(reason) {
994
+ try {
995
+ logEvent('plugkit', 'watcher.teardown', { reason, idle_ms: Date.now() - lastActivityMs });
996
+ console.log(`[plugkit-wasm] teardown reason=${reason}`);
997
+ } catch (_) {}
998
+
999
+ try { killAllTasks(`teardown:${reason}`); } catch (_) {}
1000
+
1001
+ try {
1002
+ if (fs.existsSync(ACPTOAPI_STATUS_PATH)) {
1003
+ const status = JSON.parse(fs.readFileSync(ACPTOAPI_STATUS_PATH, 'utf-8'));
1004
+ if (status && Number.isFinite(status.pid)) killPidQuiet(status.pid);
1005
+ try { fs.unlinkSync(ACPTOAPI_STATUS_PATH); } catch (_) {}
1006
+ }
1007
+ } catch (_) {}
1008
+
1009
+ try {
1010
+ const portsFile = browserPortsFile(process.cwd());
1011
+ const sessionsFile = browserSessionsFile(process.cwd());
1012
+ const ports = readJsonFile(portsFile, {});
1013
+ for (const [sid, entry] of Object.entries(ports)) {
1014
+ if (entry && Number.isFinite(entry.pid)) killPidQuiet(entry.pid);
1015
+ }
1016
+ try { fs.unlinkSync(portsFile); } catch (_) {}
1017
+ try { fs.unlinkSync(sessionsFile); } catch (_) {}
1018
+ } catch (_) {}
1019
+
1020
+ try {
1021
+ fs.writeFileSync(SHUTDOWN_REASON_PATH, JSON.stringify({
1022
+ reason,
1023
+ ts: Date.now(),
1024
+ pid: process.pid,
1025
+ idle_ms: Date.now() - lastActivityMs,
1026
+ }));
1027
+ } catch (_) {}
1028
+
1029
+ try { fs.unlinkSync(STATUS_PATH_FOR_TEARDOWN); } catch (_) {}
1030
+ try { releaseLock(); } catch (_) {}
1031
+ process.exit(0);
1032
+ }
1033
+
1034
+ setInterval(() => {
1035
+ try { reapTimedOutTasks(); } catch (_) {}
1036
+ }, 5000);
1037
+
1038
+ setInterval(() => {
1039
+ try {
1040
+ const idleMs = Date.now() - lastActivityMs;
1041
+ if (idleMs < IDLE_LIMIT_MS) return;
1042
+ try {
1043
+ const ports = readJsonFile(browserPortsFile(process.cwd()), {});
1044
+ let browserAlive = false;
1045
+ for (const entry of Object.values(ports)) {
1046
+ if (entry && Number.isFinite(entry.port) && isPortAliveSync(entry.port)) { browserAlive = true; break; }
1047
+ }
1048
+ if (browserAlive) { markActivity('browser-port-alive'); return; }
1049
+ } catch (_) {}
1050
+ try {
1051
+ let anyRunning = false;
1052
+ for (const entry of __tasks.values()) {
1053
+ if (entry.meta.status === 'running') { anyRunning = true; break; }
1054
+ }
1055
+ if (anyRunning) { markActivity('task-running'); return; }
1056
+ } catch (_) {}
1057
+ teardownAll('idle');
1058
+ } catch (e) {
1059
+ console.error(`[idle-check] error: ${e.message}`);
1060
+ }
1061
+ }, IDLE_CHECK_MS);
1062
+
745
1063
  process.on('SIGINT', () => { releaseLock(); process.exit(0); });
746
1064
  process.on('SIGTERM', () => { releaseLock(); process.exit(0); });
747
1065
  process.on('exit', releaseLock);
@@ -767,7 +1085,49 @@ async function runSpoolWatcher(instance, spoolDir) {
767
1085
  const _bootVersion = resolveVersion(instance);
768
1086
  console.log(`[plugkit-wasm] plugkit v${_bootVersion} (wasm)`);
769
1087
  console.log(`[plugkit-wasm] watching ${inDir}`);
770
- logEvent('plugkit', 'watcher.boot', { version: _bootVersion, in_dir: inDir, out_dir: outDir, spool_dir: spoolDir });
1088
+
1089
+ let _priorShutdown = null;
1090
+ let _priorStatus = null;
1091
+ try { _priorShutdown = JSON.parse(fs.readFileSync(SHUTDOWN_REASON_PATH, 'utf-8')); } catch (_) {}
1092
+ try { _priorStatus = JSON.parse(fs.readFileSync(STATUS_PATH_FOR_TEARDOWN, 'utf-8')); } catch (_) {}
1093
+ const _bootReason = process.env.PLUGKIT_BOOT_REASON || 'unknown';
1094
+ const _supervisorPid = parseInt(process.env.PLUGKIT_SUPERVISOR_PID, 10) || null;
1095
+ const restartContext = {
1096
+ boot_reason: _bootReason,
1097
+ supervisor_pid: _supervisorPid,
1098
+ prior_shutdown: _priorShutdown,
1099
+ prior_status: _priorStatus,
1100
+ prior_status_age_ms: _priorStatus && Number.isFinite(_priorStatus.ts) ? Date.now() - _priorStatus.ts : null,
1101
+ };
1102
+ const _isPlannedBoot = _priorShutdown && (_priorShutdown.reason === 'idle' || _priorShutdown.reason === 'sigterm' || _priorShutdown.reason === 'version-change');
1103
+ const _isFirstBoot = !_priorShutdown && !_priorStatus;
1104
+ const UNPLANNED_RESTART_MARKER = path.join(spoolDir, '.unplanned-restart.json');
1105
+ if (!_isPlannedBoot && !_isFirstBoot) {
1106
+ const incidentPayload = {
1107
+ ts: Date.now(),
1108
+ version: _bootVersion,
1109
+ severity: 'critical',
1110
+ ...restartContext,
1111
+ log_tail_path: path.join(spoolDir, '.watcher.log'),
1112
+ gm_log_dir: GM_LOG_ROOT,
1113
+ instruction: 'Prior watcher died without a planned shutdown. This is treated as a critical failure. Inspect .watcher.log and gm-log/<day>/plugkit.jsonl events supervisor.watcher-exited-unexpectedly + supervisor.heartbeat-stale around the prior_status.ts timestamp to diagnose root cause.',
1114
+ };
1115
+ logEvent('plugkit', 'watcher.unplanned-restart', incidentPayload);
1116
+ try {
1117
+ let history = [];
1118
+ try { history = JSON.parse(fs.readFileSync(UNPLANNED_RESTART_MARKER, 'utf-8')).history || []; } catch (_) {}
1119
+ history.push(incidentPayload);
1120
+ if (history.length > 20) history = history.slice(-20);
1121
+ fs.writeFileSync(UNPLANNED_RESTART_MARKER, JSON.stringify({
1122
+ latest: incidentPayload,
1123
+ count: history.length,
1124
+ history,
1125
+ }, null, 2));
1126
+ } catch (_) {}
1127
+ console.error(`[plugkit-wasm] UNPLANNED RESTART detected — prior watcher died without writing .shutdown-reason.json. prior_status_age_ms=${restartContext.prior_status_age_ms} boot_reason=${_bootReason}`);
1128
+ }
1129
+ try { fs.unlinkSync(SHUTDOWN_REASON_PATH); } catch (_) {}
1130
+ logEvent('plugkit', 'watcher.boot', { version: _bootVersion, in_dir: inDir, out_dir: outDir, spool_dir: spoolDir, ...restartContext });
771
1131
 
772
1132
  const PROCESSED_MAX = 10000;
773
1133
  const processed = new Map();
@@ -934,6 +1294,7 @@ async function runSpoolWatcher(instance, spoolDir) {
934
1294
 
935
1295
  const pollInterval = setInterval(async () => {
936
1296
  const existing = walkDir(inDir);
1297
+ if (existing.length > 0) markActivity('poll');
937
1298
  for (const fullPath of existing) {
938
1299
  await processFile(fullPath);
939
1300
  }
@@ -1004,6 +1365,7 @@ async function runSpoolWatcher(instance, spoolDir) {
1004
1365
  watch(inDir, { recursive: true }, (eventType, filename) => {
1005
1366
  if (!filename) return;
1006
1367
  const fullPath = path.join(inDir, filename);
1368
+ markActivity('watch');
1007
1369
 
1008
1370
  clearTimeout(debounce[fullPath]);
1009
1371
  debounce[fullPath] = setTimeout(async () => {