gm-skill 2.0.1155 → 2.0.1157

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/README.md CHANGED
@@ -35,7 +35,7 @@ An earlier generation fanned out fifteen per-platform downstream repos (gm-cc, g
35
35
 
36
36
  ## Version
37
37
 
38
- `2.0.1155` — auto-bumped from the canonical `gm` repo. Every push to `AnEntrypoint/gm` (or any cascading sibling crate) republishes this package.
38
+ `2.0.1157` — auto-bumped from the canonical `gm` repo. Every push to `AnEntrypoint/gm` (or any cascading sibling crate) republishes this package.
39
39
 
40
40
  ## Source of truth
41
41
 
@@ -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
  }
@@ -4,10 +4,17 @@ import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import https from 'https';
6
6
  import { watch } from 'fs';
7
- import { spawn, spawnSync } from 'child_process';
7
+ import { spawn as _rawSpawn, spawnSync as _rawSpawnSync } from 'child_process';
8
8
  import net from 'net';
9
9
  import { fileURLToPath } from 'url';
10
10
 
11
+ function spawnSync(cmd, args, opts) {
12
+ return _rawSpawnSync(cmd, args, { windowsHide: true, ...(opts || {}) });
13
+ }
14
+ function spawn(cmd, args, opts) {
15
+ return _rawSpawn(cmd, args, { windowsHide: true, ...(opts || {}) });
16
+ }
17
+
11
18
  const __filename = fileURLToPath(import.meta.url);
12
19
  const __dirname = path.dirname(__filename);
13
20
 
@@ -116,8 +123,33 @@ function emitOrchestratorEvents(verb, taskBase, resultStr) {
116
123
  }
117
124
 
118
125
  const TMP_DIR = os.tmpdir();
119
- const BROWSER_PORTS_FILE = path.join(TMP_DIR, 'plugkit-browser-ports.json');
120
- 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
+ }
121
153
 
122
154
  function readJsonFile(fp, fallback) {
123
155
  try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch (_) { return fallback; }
@@ -239,8 +271,11 @@ function runPlaywriter(pw, args, timeoutMs) {
239
271
  }
240
272
 
241
273
  function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
242
- const ports = readJsonFile(BROWSER_PORTS_FILE, {});
243
- 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, {});
244
279
  const existing = ports[claudeSessionId];
245
280
  if (existing && existing.port && isPortAliveSync(existing.port)) {
246
281
  const pwIds = sessions[claudeSessionId] || [];
@@ -258,6 +293,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
258
293
  '--disable-features=Translate',
259
294
  ];
260
295
  const child = spawn(chrome, chromeArgs, { detached: true, stdio: 'ignore' });
296
+ const chromePid = child.pid;
261
297
  child.unref();
262
298
  const deadline = Date.now() + 10000;
263
299
  let alive = false;
@@ -281,10 +317,10 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
281
317
  try { const j = JSON.parse(out); pwSessionId = j.id || j.session_id || j.session; } catch (_) {}
282
318
  }
283
319
  if (!pwSessionId) throw new Error(`could not parse playwriter session id from: ${out}`);
284
- ports[claudeSessionId] = { port, profileDir };
320
+ ports[claudeSessionId] = { port, profileDir, pid: chromePid };
285
321
  sessions[claudeSessionId] = [pwSessionId];
286
- writeJsonFile(BROWSER_PORTS_FILE, ports);
287
- writeJsonFile(BROWSER_SESSIONS_FILE, sessions);
322
+ writeJsonFile(portsFile, ports);
323
+ writeJsonFile(sessionsFile, sessions);
288
324
  return pwSessionId;
289
325
  }
290
326
 
@@ -735,6 +771,83 @@ async function runSpoolWatcher(instance, spoolDir) {
735
771
  }
736
772
  acquireLock();
737
773
  setInterval(refreshLock, 5000);
774
+
775
+ const IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_IDLE_LIMIT_MS, 10) || 15 * 60 * 1000;
776
+ const IDLE_CHECK_MS = 60_000;
777
+ const SHUTDOWN_REASON_PATH = path.join(spoolDir, '.shutdown-reason.json');
778
+ const STATUS_PATH_FOR_TEARDOWN = path.join(spoolDir, '.status.json');
779
+ const ACPTOAPI_STATUS_PATH = path.join(process.cwd(), '.gm', 'acptoapi-status.json');
780
+ let lastActivityMs = Date.now();
781
+ function markActivity(source) {
782
+ lastActivityMs = Date.now();
783
+ }
784
+
785
+ function killPidQuiet(pid) {
786
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
787
+ try { process.kill(pid, 'SIGTERM'); } catch (_) {}
788
+ if (process.platform === 'win32') {
789
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', timeout: 3000 }); } catch (_) {}
790
+ }
791
+ return true;
792
+ }
793
+
794
+ function teardownAll(reason) {
795
+ try {
796
+ logEvent('plugkit', 'watcher.teardown', { reason, idle_ms: Date.now() - lastActivityMs });
797
+ console.log(`[plugkit-wasm] teardown reason=${reason}`);
798
+ } catch (_) {}
799
+
800
+ try {
801
+ if (fs.existsSync(ACPTOAPI_STATUS_PATH)) {
802
+ const status = JSON.parse(fs.readFileSync(ACPTOAPI_STATUS_PATH, 'utf-8'));
803
+ if (status && Number.isFinite(status.pid)) killPidQuiet(status.pid);
804
+ try { fs.unlinkSync(ACPTOAPI_STATUS_PATH); } catch (_) {}
805
+ }
806
+ } catch (_) {}
807
+
808
+ try {
809
+ const portsFile = browserPortsFile(process.cwd());
810
+ const sessionsFile = browserSessionsFile(process.cwd());
811
+ const ports = readJsonFile(portsFile, {});
812
+ for (const [sid, entry] of Object.entries(ports)) {
813
+ if (entry && Number.isFinite(entry.pid)) killPidQuiet(entry.pid);
814
+ }
815
+ try { fs.unlinkSync(portsFile); } catch (_) {}
816
+ try { fs.unlinkSync(sessionsFile); } catch (_) {}
817
+ } catch (_) {}
818
+
819
+ try {
820
+ fs.writeFileSync(SHUTDOWN_REASON_PATH, JSON.stringify({
821
+ reason,
822
+ ts: Date.now(),
823
+ pid: process.pid,
824
+ idle_ms: Date.now() - lastActivityMs,
825
+ }));
826
+ } catch (_) {}
827
+
828
+ try { fs.unlinkSync(STATUS_PATH_FOR_TEARDOWN); } catch (_) {}
829
+ try { releaseLock(); } catch (_) {}
830
+ process.exit(0);
831
+ }
832
+
833
+ setInterval(() => {
834
+ try {
835
+ const idleMs = Date.now() - lastActivityMs;
836
+ if (idleMs < IDLE_LIMIT_MS) return;
837
+ try {
838
+ const ports = readJsonFile(browserPortsFile(process.cwd()), {});
839
+ let browserAlive = false;
840
+ for (const entry of Object.values(ports)) {
841
+ if (entry && Number.isFinite(entry.port) && isPortAliveSync(entry.port)) { browserAlive = true; break; }
842
+ }
843
+ if (browserAlive) { markActivity('browser-port-alive'); return; }
844
+ } catch (_) {}
845
+ teardownAll('idle');
846
+ } catch (e) {
847
+ console.error(`[idle-check] error: ${e.message}`);
848
+ }
849
+ }, IDLE_CHECK_MS);
850
+
738
851
  process.on('SIGINT', () => { releaseLock(); process.exit(0); });
739
852
  process.on('SIGTERM', () => { releaseLock(); process.exit(0); });
740
853
  process.on('exit', releaseLock);
@@ -760,7 +873,49 @@ async function runSpoolWatcher(instance, spoolDir) {
760
873
  const _bootVersion = resolveVersion(instance);
761
874
  console.log(`[plugkit-wasm] plugkit v${_bootVersion} (wasm)`);
762
875
  console.log(`[plugkit-wasm] watching ${inDir}`);
763
- logEvent('plugkit', 'watcher.boot', { version: _bootVersion, in_dir: inDir, out_dir: outDir, spool_dir: spoolDir });
876
+
877
+ let _priorShutdown = null;
878
+ let _priorStatus = null;
879
+ try { _priorShutdown = JSON.parse(fs.readFileSync(SHUTDOWN_REASON_PATH, 'utf-8')); } catch (_) {}
880
+ try { _priorStatus = JSON.parse(fs.readFileSync(STATUS_PATH_FOR_TEARDOWN, 'utf-8')); } catch (_) {}
881
+ const _bootReason = process.env.PLUGKIT_BOOT_REASON || 'unknown';
882
+ const _supervisorPid = parseInt(process.env.PLUGKIT_SUPERVISOR_PID, 10) || null;
883
+ const restartContext = {
884
+ boot_reason: _bootReason,
885
+ supervisor_pid: _supervisorPid,
886
+ prior_shutdown: _priorShutdown,
887
+ prior_status: _priorStatus,
888
+ prior_status_age_ms: _priorStatus && Number.isFinite(_priorStatus.ts) ? Date.now() - _priorStatus.ts : null,
889
+ };
890
+ const _isPlannedBoot = _priorShutdown && (_priorShutdown.reason === 'idle' || _priorShutdown.reason === 'sigterm' || _priorShutdown.reason === 'version-change');
891
+ const _isFirstBoot = !_priorShutdown && !_priorStatus;
892
+ const UNPLANNED_RESTART_MARKER = path.join(spoolDir, '.unplanned-restart.json');
893
+ if (!_isPlannedBoot && !_isFirstBoot) {
894
+ const incidentPayload = {
895
+ ts: Date.now(),
896
+ version: _bootVersion,
897
+ severity: 'critical',
898
+ ...restartContext,
899
+ log_tail_path: path.join(spoolDir, '.watcher.log'),
900
+ gm_log_dir: GM_LOG_ROOT,
901
+ 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.',
902
+ };
903
+ logEvent('plugkit', 'watcher.unplanned-restart', incidentPayload);
904
+ try {
905
+ let history = [];
906
+ try { history = JSON.parse(fs.readFileSync(UNPLANNED_RESTART_MARKER, 'utf-8')).history || []; } catch (_) {}
907
+ history.push(incidentPayload);
908
+ if (history.length > 20) history = history.slice(-20);
909
+ fs.writeFileSync(UNPLANNED_RESTART_MARKER, JSON.stringify({
910
+ latest: incidentPayload,
911
+ count: history.length,
912
+ history,
913
+ }, null, 2));
914
+ } catch (_) {}
915
+ 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}`);
916
+ }
917
+ try { fs.unlinkSync(SHUTDOWN_REASON_PATH); } catch (_) {}
918
+ logEvent('plugkit', 'watcher.boot', { version: _bootVersion, in_dir: inDir, out_dir: outDir, spool_dir: spoolDir, ...restartContext });
764
919
 
765
920
  const PROCESSED_MAX = 10000;
766
921
  const processed = new Map();
@@ -927,6 +1082,7 @@ async function runSpoolWatcher(instance, spoolDir) {
927
1082
 
928
1083
  const pollInterval = setInterval(async () => {
929
1084
  const existing = walkDir(inDir);
1085
+ if (existing.length > 0) markActivity('poll');
930
1086
  for (const fullPath of existing) {
931
1087
  await processFile(fullPath);
932
1088
  }
@@ -997,6 +1153,7 @@ async function runSpoolWatcher(instance, spoolDir) {
997
1153
  watch(inDir, { recursive: true }, (eventType, filename) => {
998
1154
  if (!filename) return;
999
1155
  const fullPath = path.join(inDir, filename);
1156
+ markActivity('watch');
1000
1157
 
1001
1158
  clearTimeout(debounce[fullPath]);
1002
1159
  debounce[fullPath] = setTimeout(async () => {
@@ -0,0 +1,211 @@
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 { spawn, spawnSync } = require('child_process');
8
+
9
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
+ const spoolDir = path.join(projectDir, '.gm', 'exec-spool');
11
+ fs.mkdirSync(spoolDir, { recursive: true });
12
+
13
+ const STATUS_PATH = path.join(spoolDir, '.status.json');
14
+ const SHUTDOWN_REASON_PATH = path.join(spoolDir, '.shutdown-reason.json');
15
+ const SUPERVISOR_PATH = path.join(spoolDir, '.supervisor.json');
16
+ const LOG_PATH = path.join(spoolDir, '.watcher.log');
17
+ const GM_LOG_ROOT = process.env.GM_LOG_DIR || path.join(os.homedir(), '.claude', 'gm-log');
18
+
19
+ const POLL_INTERVAL_MS = 10_000;
20
+ const STATUS_STALE_MS = 30_000;
21
+ const MAX_RESTART_BURST = 5;
22
+ const RESTART_WINDOW_MS = 60_000;
23
+
24
+ function logEvent(event, fields) {
25
+ try {
26
+ const day = new Date().toISOString().slice(0, 10);
27
+ const dir = path.join(GM_LOG_ROOT, day);
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ const line = JSON.stringify({
30
+ ts: Date.now(),
31
+ sub: 'plugkit',
32
+ event,
33
+ pid: process.pid,
34
+ sess: process.env.CLAUDE_SESSION_ID || '',
35
+ cwd: process.cwd(),
36
+ role: 'supervisor',
37
+ ...fields,
38
+ }) + '\n';
39
+ fs.appendFileSync(path.join(dir, 'plugkit.jsonl'), line);
40
+ } catch (_) {}
41
+ }
42
+
43
+ function writeSupervisorStatus(state, extra) {
44
+ try {
45
+ fs.writeFileSync(SUPERVISOR_PATH, JSON.stringify({
46
+ pid: process.pid,
47
+ ts: Date.now(),
48
+ state,
49
+ ...(extra || {}),
50
+ }));
51
+ } catch (_) {}
52
+ }
53
+
54
+ function pidAlive(pid) {
55
+ if (!Number.isFinite(pid) || pid <= 0) return false;
56
+ try { process.kill(pid, 0); return true; } catch (_) { return false; }
57
+ }
58
+
59
+ function readStatus() {
60
+ try { return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf-8')); } catch (_) { return null; }
61
+ }
62
+
63
+ function readShutdownReason() {
64
+ try { return JSON.parse(fs.readFileSync(SHUTDOWN_REASON_PATH, 'utf-8')); } catch (_) { return null; }
65
+ }
66
+
67
+ let lastSpawnedAt = 0;
68
+ let restartTimestamps = [];
69
+ let currentChildPid = null;
70
+ let currentBootReason = 'initial';
71
+
72
+ function spawnWatcher(bootReason) {
73
+ lastSpawnedAt = Date.now();
74
+ restartTimestamps.push(Date.now());
75
+ restartTimestamps = restartTimestamps.filter(t => Date.now() - t < RESTART_WINDOW_MS);
76
+ if (restartTimestamps.length > MAX_RESTART_BURST) {
77
+ logEvent('supervisor.giving-up', {
78
+ reason: 'restart-burst-exceeded',
79
+ restarts_in_window: restartTimestamps.length,
80
+ window_ms: RESTART_WINDOW_MS,
81
+ max: MAX_RESTART_BURST,
82
+ severity: 'critical',
83
+ });
84
+ writeSupervisorStatus('giving-up', { reason: 'restart-burst-exceeded' });
85
+ process.exit(2);
86
+ }
87
+
88
+ const wrapper = path.join(os.homedir(), '.claude', 'gm-tools', 'plugkit-wasm-wrapper.js');
89
+ if (!fs.existsSync(wrapper)) {
90
+ logEvent('supervisor.wrapper-missing', { wrapper, severity: 'critical' });
91
+ writeSupervisorStatus('error', { error: 'wrapper-missing' });
92
+ process.exit(3);
93
+ }
94
+
95
+ let runtime = process.env.PLUGKIT_RUNTIME || 'bun';
96
+ let cmd = runtime;
97
+ let args = [wrapper, 'spool'];
98
+ try {
99
+ spawnSync(runtime, ['--version'], { stdio: 'ignore', windowsHide: true });
100
+ } catch (_) {
101
+ cmd = process.execPath;
102
+ args = [wrapper, 'spool'];
103
+ }
104
+
105
+ let logFd = null;
106
+ try { logFd = fs.openSync(LOG_PATH, 'a'); } catch (_) {}
107
+ try {
108
+ if (logFd !== null) fs.writeSync(logFd, `\n--- watcher spawn ${new Date().toISOString()} supervisor=${process.pid} reason=${bootReason} ---\n`);
109
+ } catch (_) {}
110
+
111
+ const child = spawn(cmd, args, {
112
+ detached: false,
113
+ stdio: ['ignore', logFd || 'ignore', logFd || 'ignore'],
114
+ windowsHide: true,
115
+ env: {
116
+ ...process.env,
117
+ CLAUDE_PROJECT_DIR: projectDir,
118
+ PLUGKIT_BOOT_REASON: bootReason,
119
+ PLUGKIT_SUPERVISOR_PID: String(process.pid),
120
+ },
121
+ });
122
+
123
+ try { if (logFd !== null) fs.closeSync(logFd); } catch (_) {}
124
+ currentChildPid = child.pid;
125
+ currentBootReason = bootReason;
126
+ writeSupervisorStatus('watching', { watcher_pid: child.pid, boot_reason: bootReason });
127
+ logEvent('supervisor.spawned-watcher', { watcher_pid: child.pid, boot_reason: bootReason, runtime: cmd });
128
+
129
+ child.on('exit', (code, signal) => {
130
+ const shutdownReason = readShutdownReason();
131
+ const reason = shutdownReason && shutdownReason.reason;
132
+ const idleClean = reason === 'idle';
133
+ logEvent(idleClean ? 'supervisor.watcher-exited-idle' : 'supervisor.watcher-exited-unexpectedly', {
134
+ watcher_pid: currentChildPid,
135
+ exit_code: code,
136
+ signal,
137
+ shutdown_reason: reason || null,
138
+ had_shutdown_reason_file: shutdownReason !== null,
139
+ severity: idleClean ? 'info' : 'critical',
140
+ uptime_ms: Date.now() - lastSpawnedAt,
141
+ });
142
+ if (idleClean) {
143
+ writeSupervisorStatus('exited-idle', { watcher_pid: currentChildPid });
144
+ try { fs.unlinkSync(SUPERVISOR_PATH); } catch (_) {}
145
+ process.exit(0);
146
+ }
147
+ writeSupervisorStatus('restarting', {
148
+ prior_watcher_pid: currentChildPid,
149
+ prior_exit_code: code,
150
+ prior_signal: signal,
151
+ prior_shutdown_reason: reason || null,
152
+ });
153
+ setTimeout(() => spawnWatcher('unplanned-restart-after-exit'), 1500);
154
+ });
155
+
156
+ child.on('error', (err) => {
157
+ logEvent('supervisor.spawn-error', { error: err.message, severity: 'critical' });
158
+ });
159
+ }
160
+
161
+ function checkWatcherHealth() {
162
+ if (!currentChildPid) return;
163
+ if (!pidAlive(currentChildPid)) {
164
+ return;
165
+ }
166
+ const status = readStatus();
167
+ if (!status) {
168
+ logEvent('supervisor.status-missing', {
169
+ watcher_pid: currentChildPid,
170
+ severity: 'warn',
171
+ });
172
+ return;
173
+ }
174
+ const age = Date.now() - (status.ts || 0);
175
+ if (age > STATUS_STALE_MS) {
176
+ logEvent('supervisor.heartbeat-stale', {
177
+ watcher_pid: currentChildPid,
178
+ status_pid: status.pid,
179
+ status_age_ms: age,
180
+ stale_limit_ms: STATUS_STALE_MS,
181
+ severity: 'critical',
182
+ });
183
+ try { process.kill(currentChildPid, 'SIGTERM'); } catch (_) {}
184
+ if (process.platform === 'win32') {
185
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(currentChildPid)], { stdio: 'ignore', windowsHide: true, timeout: 3000 }); } catch (_) {}
186
+ }
187
+ }
188
+ }
189
+
190
+ process.on('SIGINT', () => {
191
+ logEvent('supervisor.shutdown', { reason: 'sigint' });
192
+ writeSupervisorStatus('shutdown', { reason: 'sigint' });
193
+ if (currentChildPid && pidAlive(currentChildPid)) {
194
+ try { process.kill(currentChildPid, 'SIGTERM'); } catch (_) {}
195
+ }
196
+ process.exit(0);
197
+ });
198
+ process.on('SIGTERM', () => {
199
+ logEvent('supervisor.shutdown', { reason: 'sigterm' });
200
+ writeSupervisorStatus('shutdown', { reason: 'sigterm' });
201
+ if (currentChildPid && pidAlive(currentChildPid)) {
202
+ try { process.kill(currentChildPid, 'SIGTERM'); } catch (_) {}
203
+ }
204
+ process.exit(0);
205
+ });
206
+
207
+ writeSupervisorStatus('starting', {});
208
+ logEvent('supervisor.starting', { spool_dir: spoolDir });
209
+ spawnWatcher('initial');
210
+ setInterval(checkWatcherHealth, POLL_INTERVAL_MS);
211
+ setInterval(() => writeSupervisorStatus('watching', { watcher_pid: currentChildPid, boot_reason: currentBootReason }), 10_000);
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1155",
3
+ "version": "2.0.1157",
4
4
  "description": "Spool-dispatch orchestration engine with unified state machine, skills, and automated git enforcement",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -120,7 +120,7 @@ function computeIndexDigest(cwd = process.cwd()) {
120
120
  }
121
121
  }
122
122
 
123
- function writeStatusFile(daemonName, status, sessionId) {
123
+ function writeStatusFile(daemonName, status, sessionId, childPid) {
124
124
  try {
125
125
  fs.mkdirSync(GM_STATE_DIR, { recursive: true });
126
126
  const statusFile = path.join(GM_STATE_DIR, `${daemonName}-status.json`);
@@ -129,7 +129,8 @@ function writeStatusFile(daemonName, status, sessionId) {
129
129
  status,
130
130
  sessionId,
131
131
  timestamp: new Date().toISOString(),
132
- pid: process.pid,
132
+ pid: Number.isFinite(childPid) ? childPid : process.pid,
133
+ parent_pid: process.pid,
133
134
  };
134
135
  fs.writeFileSync(statusFile, JSON.stringify(payload, null, 2));
135
136
  emitDaemonEvent(daemonName, 'info', 'Status written', { file: statusFile });
@@ -220,7 +221,7 @@ async function ensureAcptoapiRunning() {
220
221
  });
221
222
  child.unref();
222
223
  emitDaemonEvent('acptoapi', 'info', 'Daemon spawned', { pid: child.pid, port, sessionId });
223
- writeStatusFile('acptoapi', 'spawned', sessionId);
224
+ writeStatusFile('acptoapi', 'spawned', sessionId, child.pid);
224
225
  return { ok: true, message: 'acptoapi spawned', pid: child.pid };
225
226
  } catch (spawnErr) {
226
227
  emitDaemonEvent('acptoapi', 'warn', 'Spawn failed, fallback to SDK', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-skill",
3
- "version": "2.0.1155",
3
+ "version": "2.0.1157",
4
4
  "description": "Canonical universal harness — AI-native software engineering via skill-driven orchestration; bootstraps plugkit for task execution and session isolation. Install in any AI coding agent host.",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "gm.json"
40
40
  ],
41
41
  "dependencies": {
42
- "gm-plugkit": "^2.0.1155"
42
+ "gm-plugkit": "^2.0.1157"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=16.0.0"
@@ -20,6 +20,8 @@ If the `instruction` response carries a non-null `update_available`, plugkit dri
20
20
 
21
21
  The wasm artifact lives at `~/.claude/gm-tools/plugkit.wasm`; the spool watcher runs it. The watcher's own stdout/stderr is appended to `.gm/exec-spool/.watcher.log` — Read it to see plugkit's internal trace, dispatch timings, sweep actions, errors.
22
22
 
23
+ The watcher self-shuts-down after 15 minutes idle (no spool I/O, no live browser session) and is restarted on next agent activity by a detached supervisor. `.gm/exec-spool/.unplanned-restart.json` is a critical-failure marker — present means a prior watcher died without a planned shutdown. Treat as a PRD-worthy incident on sight: diagnose via `.watcher.log` and `gm-log/<day>/plugkit.jsonl` events `supervisor.watcher-exited-unexpectedly` and `supervisor.heartbeat-stale` around the prior_status.ts timestamp, then delete the marker once root cause is named.
24
+
23
25
  ## Boot the spool watcher (first turn only)
24
26
 
25
27
  Check `.gm/exec-spool/.status.json`. If absent or `ts` > 15s old, boot via the npm package — `bun x gm-plugkit@latest spool` fetches the freshest plugkit (wasm + wrapper), copies them into `~/.claude/gm-tools/`, then enters spool mode: