claude-code-session-manager 0.37.1 → 0.38.0

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 (60) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  11. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  12. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  13. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  14. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  15. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  16. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  17. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  18. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  19. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  23. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  24. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  25. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  26. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  27. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  28. package/scripts/lib/watchdogHelpers.cjs +296 -166
  29. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  30. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  31. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  32. package/src/main/__tests__/docEdit.test.cjs +164 -2
  33. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  34. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  35. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  36. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  37. package/src/main/browserAgentServer.cjs +11 -10
  38. package/src/main/chatRunner.cjs +58 -14
  39. package/src/main/docEdit.cjs +125 -5
  40. package/src/main/health.cjs +15 -0
  41. package/src/main/index.cjs +12 -6
  42. package/src/main/ipcSchemas.cjs +16 -3
  43. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  44. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  45. package/src/main/lib/localAdminHttp.cjs +157 -0
  46. package/src/main/lib/personaImportHealth.cjs +161 -0
  47. package/src/main/lib/prdCreate.cjs +107 -17
  48. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  49. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  50. package/src/main/scheduler.cjs +223 -56
  51. package/src/main/sessionsStore.cjs +4 -5
  52. package/src/main/templates/PRD_AUTHORING.md +4 -4
  53. package/src/main/transcripts.cjs +1 -85
  54. package/src/main/webRemote.cjs +3 -3
  55. package/src/preload/api.d.ts +16 -6
  56. package/src/preload/index.cjs +9 -2
  57. package/dist/assets/index-CTTjT08J.css +0 -32
  58. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  59. package/src/main/__tests__/adminServer.test.cjs +0 -380
  60. package/src/main/adminServer.cjs +0 -242
@@ -9,11 +9,6 @@ const path = require('node:path');
9
9
 
10
10
  const { activeProjectCwds } = require('./activeSessions.cjs');
11
11
 
12
- // Relative path into src/main/lib/ — the watchdog is external to the app, so
13
- // we re-use the helpers without importing any Electron code.
14
- // (Same claudePidAlive + classifyRunOutcome used by scheduler.cjs boot reconciliation.)
15
- const { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP } = require('../../src/main/lib/reaperHelpers.cjs');
16
-
17
12
  // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
18
13
  const DEFAULT_HEARTBEAT_PATH = path.join(
19
14
  os.homedir(), '.claude', 'session-manager', 'scheduler-heartbeat.log',
@@ -44,7 +39,7 @@ const TAIL_BYTES = 4096;
44
39
  * Returns null on missing / empty / unparseable file.
45
40
  *
46
41
  * Single source of truth for the file-read + reverse-scan logic; used by
47
- * readLastHeartbeatTs(), heartbeatFresh(), and reconcileQueueOffline().
42
+ * readLastHeartbeatTs() and heartbeatFresh().
48
43
  */
49
44
  function readLastHeartbeat(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
50
45
  let buf;
@@ -106,175 +101,117 @@ function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAU
106
101
  const DEFAULT_QUEUE_PATH = path.join(
107
102
  os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
108
103
  );
109
- const DEFAULT_RUNS_DIR = path.join(
110
- os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'runs',
111
- );
112
104
  const DEFAULT_PRDS_DIR = path.join(
113
105
  os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
114
106
  );
115
- const DEFAULT_SKILL_PATH = path.join(
116
- os.homedir(), '.claude', 'skills', 'process-feedback', 'SKILL.md',
117
- );
118
- const DEFAULT_STANDARDS_PATH = path.join(
119
- os.homedir(), '.claude', 'skills', 'develop', 'standards.md',
107
+ const PLUGIN_CACHE_ROOT = path.join(
108
+ os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
120
109
  );
121
110
 
122
111
  /**
123
- * reconcileQueueOffline(opts?) → { reconciled: boolean, reapedCount: number, errors: string[] }
124
- *
125
- * Safe offline reconciliation of queue.json when the in-app scheduler is down.
112
+ * compareVersionsDesc(a, b) → number
126
113
  *
127
- * Guard: re-checks heartbeatFresh() at the top and returns a no-op result if the
128
- * app is alive the app owns the mutate lock and concurrent writes corrupt the queue.
129
- *
130
- * When stale: for each running job whose PID is dead (or whose app is dead):
131
- * success → completed
132
- * failed → failed
133
- * no_result / unknown → re-queue to pending (bounded by ORPHAN_REQUEUE_CAP)
114
+ * Numeric per-segment version compare (descending) for plugin-cache version
115
+ * dirs like "0.1.0" / "0.2.0" / "0.10.0". Plain lexicographic sort is wrong
116
+ * here — "0.10.0" < "0.2.0" as strings even though 10 > 2 numerically. Falls
117
+ * back to a string compare for any non-numeric segment (e.g. a stray dir name
118
+ * that isn't a version) so listSkillCandidates never throws on garbage input.
119
+ */
120
+ function compareVersionsDesc(a, b) {
121
+ const as = a.split('.');
122
+ const bs = b.split('.');
123
+ const len = Math.max(as.length, bs.length);
124
+ for (let i = 0; i < len; i++) {
125
+ const an = Number(as[i]);
126
+ const bn = Number(bs[i]);
127
+ if (Number.isFinite(an) && Number.isFinite(bn)) {
128
+ if (an !== bn) return bn - an;
129
+ } else if (as[i] !== bs[i]) {
130
+ return (as[i] ?? '') < (bs[i] ?? '') ? 1 : -1;
131
+ }
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ /**
137
+ * listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot?) → string[]
134
138
  *
135
- * If the PID is still alive but the app is dead, SIGTERM it before classifying.
139
+ * Ordered candidate paths for an inlined skill/standards file, most-specific
140
+ * first — the process-feedback SKILL.md and develop standards.md moved out of
141
+ * the legacy ~/.claude/skills/ location into the session-manager-dev plugin:
136
142
  *
137
- * Atomic write: queue.json.tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
143
+ * 1. <cwd>/plugins/session-manager-dev/skills/<skillName>/<fileName> repo-local
144
+ * copy, correct when the swept project vendors the plugin (e.g. session-manager itself).
145
+ * 2. <pluginCacheRoot>/<newest-version>/skills/<skillName>/<fileName> — the installed
146
+ * plugin cache (default ~/.claude/plugins/cache/session-manager/session-manager-dev).
147
+ * Enumerates every version dir present, newest (lexicographically-highest) first.
148
+ * 3. ~/.claude/skills/<skillName>/<fileName> — legacy location, kept last so an
149
+ * old install still works.
138
150
  *
139
- * O(n) in number of running jobs; classifyRunOutcome tails up to 64 KB per job.
151
+ * pluginCacheRoot is overridable for tests; defaults to the real plugin cache.
140
152
  */
141
- function reconcileQueueOffline({
142
- heartbeatPath = DEFAULT_HEARTBEAT_PATH,
143
- maxAgeMs = DEFAULT_MAX_AGE_MS,
144
- queuePath = DEFAULT_QUEUE_PATH,
145
- runsDir = DEFAULT_RUNS_DIR,
146
- } = {}) {
147
- // Safety guard: never touch queue.json while the app is alive.
148
- if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
149
- return { reconciled: false, reapedCount: 0, errors: [] };
150
- }
151
-
152
- // Second guard: stale heartbeat may just mean the app is under load.
153
- // If the heartbeat carries a pid and that process is still alive, the app
154
- // is running — do not reap. Backward-compat: if no pid field, fall through
155
- // to the classic age-only behavior so pre-existing orphan cleanup still works.
156
- const lastHeartbeat = readLastHeartbeat(heartbeatPath);
157
- if (lastHeartbeat !== null && typeof lastHeartbeat.pid === 'number') {
158
- try {
159
- process.kill(lastHeartbeat.pid, 0); // liveness check only — no signal
160
- // pid is alive → app is running; abort reap.
161
- return { reconciled: false, reapedCount: 0, errors: [] };
162
- } catch {
163
- // ESRCH → process is gone; proceed with reap.
164
- }
165
- }
153
+ function listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
154
+ const candidates = [
155
+ path.join(cwd, 'plugins', 'session-manager-dev', 'skills', skillName, fileName),
156
+ ];
166
157
 
167
- let state;
158
+ let entries = [];
168
159
  try {
169
- state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
170
- } catch (e) {
171
- return { reconciled: false, reapedCount: 0, errors: [`read queue.json: ${e?.message}`] };
172
- }
173
-
174
- const errors = [];
175
- let reapedCount = 0;
176
- let changed = false;
177
- // Stamp once so all jobs reapedQueue in the same watchdog run share a finishedAt.
178
- const reconciledAt = new Date().toISOString();
179
-
180
- for (const j of (state.jobs ?? [])) {
181
- if (j.status !== 'running') continue;
182
-
183
- const logPath = j.runId ? path.join(runsDir, j.runId, `${j.slug}.log`) : null;
184
- const pid = j.runtime?.pid;
185
-
186
- // If the orphan is still alive we must NOT classify its log this tick — the
187
- // process is still running/flushing, so a partial or in-flight log would be
188
- // misread (e.g. a job about to emit result:success gets read as no_result and
189
- // re-queued, double-running the same PRD while the original still edits the
190
- // repo). Instead escalate across ticks (SIGTERM → SIGKILL), matching the
191
- // scheduler's kill discipline (process group first, then the pid).
192
- //
193
- // Bounded so a job can't wedge in 'running' forever: after we've sent BOTH
194
- // SIGTERM and SIGKILL on prior ticks, the real claude process is gone. A pid
195
- // still reported "alive" here is an unkillable defunct — or, on macOS (no
196
- // /proc, so claudePidAlive can't verify identity), a REUSED pid we must stop
197
- // signalling. Either way we fall through to finalize from the now-settled log.
198
- if (pid && claudePidAlive(pid)) {
199
- if (!j.runtime.watchdogSigtermAt) {
200
- try { process.kill(-pid, 'SIGTERM'); }
201
- catch { try { process.kill(pid, 'SIGTERM'); } catch { /* ESRCH — died between checks */ } }
202
- j.runtime.watchdogSigtermAt = reconciledAt;
203
- changed = true;
204
- continue; // leave status 'running'; revisit next tick
205
- }
206
- if (!j.runtime.watchdogSigkillAt) {
207
- try { process.kill(-pid, 'SIGKILL'); }
208
- catch { try { process.kill(pid, 'SIGKILL'); } catch { /* ESRCH */ } }
209
- j.runtime.watchdogSigkillAt = reconciledAt;
210
- changed = true;
211
- continue; // give SIGKILL a tick to land before finalizing
212
- }
213
- // SIGTERM + SIGKILL already sent on earlier ticks → stop deferring; the
214
- // original process is dead. Fall through to classify + finalize.
215
- }
216
-
217
- const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
218
-
219
- if (outcome === 'success') {
220
- j.status = 'completed';
221
- j.exitCode = 0;
222
- j.error = null;
223
- j.finishedAt = reconciledAt;
224
- delete j.runtime;
225
- delete j.verifierVerdict;
226
- } else if (outcome === 'failed') {
227
- j.status = 'failed';
228
- j.exitCode = j.exitCode ?? 1;
229
- j.error = 'orphaned: watchdog found failed result while app was down';
230
- j.finishedAt = reconciledAt;
231
- delete j.runtime;
232
- delete j.verifierVerdict;
233
- } else {
234
- // no_result / unknown: interrupted with no evidence of merit failure — re-queue bounded.
235
- const tries = j.orphanRetries ?? 0;
236
- if (tries < ORPHAN_REQUEUE_CAP) {
237
- j.status = 'pending';
238
- j.runId = null;
239
- j.startedAt = null;
240
- j.finishedAt = null;
241
- j.exitCode = null;
242
- j.error = `orphaned: watchdog re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})`;
243
- j.orphanRetries = tries + 1;
244
- delete j.runtime;
245
- delete j.verifierVerdict;
246
- } else {
247
- j.status = 'failed';
248
- j.exitCode = j.exitCode ?? 1;
249
- j.error = `orphaned: watchdog exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts`;
250
- j.finishedAt = reconciledAt;
251
- delete j.runtime;
252
- delete j.verifierVerdict;
253
- }
254
- }
255
-
256
- reapedCount++;
257
- changed = true;
160
+ entries = fs.readdirSync(pluginCacheRoot, { withFileTypes: true });
161
+ } catch { /* plugin cache not installed */ }
162
+ const versions = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(compareVersionsDesc);
163
+ for (const version of versions) {
164
+ candidates.push(path.join(pluginCacheRoot, version, 'skills', skillName, fileName));
258
165
  }
259
166
 
260
- if (!changed) {
261
- return { reconciled: false, reapedCount: 0, errors };
167
+ candidates.push(path.join(os.homedir(), '.claude', 'skills', skillName, fileName));
168
+ return candidates;
169
+ }
170
+
171
+ /**
172
+ * resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot?) → string | null
173
+ *
174
+ * Returns the first candidate from listSkillCandidates() that is a readable
175
+ * file, or null if none resolve.
176
+ */
177
+ function resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
178
+ for (const candidate of listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot)) {
179
+ try {
180
+ if (fs.statSync(candidate).isFile()) return candidate;
181
+ } catch { /* try next candidate */ }
262
182
  }
183
+ return null;
184
+ }
263
185
 
264
- // Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
265
- const tmpPath = `${queuePath}.tmp-${process.pid}-${Date.now()}`;
186
+ /**
187
+ * isPidAlive(pid) → boolean
188
+ *
189
+ * Plain liveness check via process.kill(pid, 0) (no signal sent). Single
190
+ * source of truth for the "is this recorded pid still alive" check used by
191
+ * checkAppLiveness's defense-in-depth relaunch gate.
192
+ *
193
+ * EPERM (pid exists, owned by another user) counts as alive — only ESRCH
194
+ * (no such process) means dead. Watchdog and app run as the same user in
195
+ * practice, so this distinction rarely matters here, but treating EPERM as
196
+ * "dead" would be wrong on its face (the process demonstrably exists).
197
+ */
198
+ function isPidAlive(pid) {
199
+ if (typeof pid !== 'number' || !Number.isFinite(pid)) return false;
266
200
  try {
267
- fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
268
- fs.renameSync(tmpPath, queuePath);
201
+ process.kill(pid, 0);
202
+ return true;
269
203
  } catch (e) {
270
- errors.push(`write queue.json: ${e?.message}`);
271
- try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
272
- return { reconciled: false, reapedCount: 0, errors };
204
+ return e?.code === 'EPERM';
273
205
  }
274
-
275
- return { reconciled: true, reapedCount, errors };
276
206
  }
277
207
 
208
+ // Offline queue.json reconciliation (orphaned 'running' jobs whose pid died
209
+ // while Electron wasn't running) used to live here as reconcileQueueOffline().
210
+ // PRD 686 moved that responsibility into src/main/scheduler.cjs's own boot
211
+ // path (partitionBootOrphans/applyOrphanOutcome) now that PRD 685's watchdog
212
+ // relaunch means the app is never down long enough to need an external
213
+ // reconciler — the in-app scheduler is queue.json's single owner again.
214
+
278
215
  // ── slugify ──────────────────────────────────────────────────────────────────
279
216
 
280
217
  /** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
@@ -360,9 +297,21 @@ function hasOpenFeedback(cwd) {
360
297
  function emitFeedbackPRD(cwd, {
361
298
  prdsDir = DEFAULT_PRDS_DIR,
362
299
  queuePath = DEFAULT_QUEUE_PATH,
363
- skillPath = DEFAULT_SKILL_PATH,
364
- standardsPath = DEFAULT_STANDARDS_PATH,
300
+ skillPath,
301
+ standardsPath,
302
+ pluginCacheRoot = PLUGIN_CACHE_ROOT,
365
303
  } = {}) {
304
+ // Explicit overrides (used by tests) are honored verbatim and skip the
305
+ // candidate search entirely — only resolve via candidates when the caller
306
+ // didn't pass a path.
307
+ const skillPathExplicit = skillPath !== undefined;
308
+ const standardsPathExplicit = standardsPath !== undefined;
309
+ if (!skillPathExplicit) {
310
+ skillPath = resolveSkillFile(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
311
+ }
312
+ if (!standardsPathExplicit) {
313
+ standardsPath = resolveSkillFile(cwd, 'develop', 'standards.md', pluginCacheRoot);
314
+ }
366
315
  const project = slugify(path.basename(cwd));
367
316
 
368
317
  // De-dup: check queue.json for pending/running feedback job for this project.
@@ -407,22 +356,40 @@ function emitFeedbackPRD(cwd, {
407
356
 
408
357
  // Read and inline skill content.
409
358
  let rawSkill = '';
410
- try {
411
- rawSkill = fs.readFileSync(skillPath, 'utf8');
412
- } catch {
413
- process.stderr.write(`[emitFeedbackPRD] warning: skill file not readable: ${skillPath}\n`);
359
+ if (skillPath) {
360
+ try {
361
+ rawSkill = fs.readFileSync(skillPath, 'utf8');
362
+ } catch { /* handled by the empty-body guard below */ }
414
363
  }
415
364
  const skillBody = stripFrontmatter(rawSkill).trim();
416
365
 
417
366
  // Read and inline standards — strip H1 so our ## heading is the section anchor.
418
367
  let rawStandards = '';
419
- try {
420
- rawStandards = fs.readFileSync(standardsPath, 'utf8');
421
- } catch {
422
- process.stderr.write(`[emitFeedbackPRD] warning: standards file not readable: ${standardsPath}\n`);
368
+ if (standardsPath) {
369
+ try {
370
+ rawStandards = fs.readFileSync(standardsPath, 'utf8');
371
+ } catch { /* handled by the empty-body guard below */ }
423
372
  }
424
373
  const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
425
374
 
375
+ // Hard-fail rather than silently ship a hollow PRD: if either inline is
376
+ // empty/unreadable, refuse to write anything and name every candidate tried
377
+ // so a future relocation shows up loudly in the watchdog log instead of
378
+ // queueing a template with no procedure and no standards in it.
379
+ if (!skillBody || !standardsBody) {
380
+ const missing = [];
381
+ if (!skillBody) {
382
+ const tried = skillPathExplicit ? [skillPath] : listSkillCandidates(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
383
+ missing.push(`process-feedback SKILL.md (tried: ${tried.join(', ')})`);
384
+ }
385
+ if (!standardsBody) {
386
+ const tried = standardsPathExplicit ? [standardsPath] : listSkillCandidates(cwd, 'develop', 'standards.md', pluginCacheRoot);
387
+ missing.push(`develop standards.md (tried: ${tried.join(', ')})`);
388
+ }
389
+ process.stderr.write(`[emitFeedbackPRD] refusing to emit for ${cwd}: missing inline(s) — ${missing.join('; ')}\n`);
390
+ return { emitted: false, reason: 'missing-inline' };
391
+ }
392
+
426
393
  const projectName = path.basename(cwd);
427
394
  const body = [
428
395
  '---',
@@ -476,6 +443,158 @@ function emitFeedbackPRD(cwd, {
476
443
  return { emitted: true, slug, prdPath };
477
444
  }
478
445
 
446
+ // ── app liveness + auto-relaunch ─────────────────────────────────────────────
447
+ //
448
+ // The watchdog's one supervision job: is session-manager itself running, and
449
+ // if not, start it. Reuses the existing scheduler heartbeat (written every
450
+ // 60 s by scheduler.cjs's heartbeatInterval regardless of pause state) as the
451
+ // liveness signal — no second heartbeat file.
452
+
453
+ const DEFAULT_RELAUNCH_STATE_PATH = path.join(
454
+ os.homedir(), '.claude', 'session-manager', 'watchdog-relaunch-state.json',
455
+ );
456
+ const DEFAULT_RELAUNCH_LOG_PATH = path.join(
457
+ os.homedir(), '.claude', 'logs', 'scheduler-watchdog-relaunch.log',
458
+ );
459
+ const DEFAULT_RELAUNCH_DEBOUNCE_MS = 90_000;
460
+ const DEFAULT_MAX_RELAUNCH_ATTEMPTS = 3;
461
+
462
+ /**
463
+ * checkAppLiveness(opts?) → { alive: boolean, reason: string }
464
+ *
465
+ * Primary signal: heartbeatFresh() within the existing max-age window.
466
+ * Defense-in-depth: if the heartbeat is stale, but the pid it last recorded
467
+ * is still alive (app under heavy load, tick just missed), treat as alive
468
+ * rather than relaunching on top of a live process.
469
+ */
470
+ function checkAppLiveness({
471
+ heartbeatPath = DEFAULT_HEARTBEAT_PATH,
472
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
473
+ } = {}) {
474
+ if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
475
+ return { alive: true, reason: 'heartbeat-fresh' };
476
+ }
477
+ const last = readLastHeartbeat(heartbeatPath);
478
+ if (last !== null && typeof last.pid === 'number' && isPidAlive(last.pid)) {
479
+ return { alive: true, reason: 'pid-alive-heartbeat-stale' };
480
+ }
481
+ return { alive: false, reason: 'stale-and-dead' };
482
+ }
483
+
484
+ /** Default { lastAttemptTs: null, attemptCount: 0 } on missing/unparseable state file. */
485
+ function readRelaunchState(statePath = DEFAULT_RELAUNCH_STATE_PATH) {
486
+ try {
487
+ const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
488
+ return {
489
+ lastAttemptTs: typeof raw.lastAttemptTs === 'number' ? raw.lastAttemptTs : null,
490
+ attemptCount: typeof raw.attemptCount === 'number' ? raw.attemptCount : 0,
491
+ };
492
+ } catch {
493
+ return { lastAttemptTs: null, attemptCount: 0 };
494
+ }
495
+ }
496
+
497
+ /** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
498
+ function writeRelaunchState(statePath, state) {
499
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
500
+ const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now()}`;
501
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
502
+ fs.renameSync(tmpPath, statePath);
503
+ }
504
+
505
+ function logRelaunchLine(logPath, message) {
506
+ try {
507
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
508
+ fs.appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`);
509
+ } catch (e) {
510
+ process.stderr.write(`[watchdog-relaunch] log write failed: ${e?.message}\n`);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * defaultSpawnRelaunch(opts?) — launches a new session-manager instance the
516
+ * same way a user would: `npx claude-code-session-manager@latest` (the
517
+ * documented distribution method — CLAUDE.md's "Distribution" section), so
518
+ * this keeps working across npm publishes with no local path to maintain.
519
+ *
520
+ * Detached + stdio redirected to logPath + unref()'d so the watchdog's own
521
+ * (short-lived) process doesn't block on the launched app and the app
522
+ * survives the watchdog exiting.
523
+ */
524
+ function defaultSpawnRelaunch({ logPath = DEFAULT_RELAUNCH_LOG_PATH } = {}) {
525
+ const { spawn } = require('node:child_process');
526
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
527
+ const fd = fs.openSync(logPath, 'a');
528
+ try {
529
+ const child = spawn('npx', ['claude-code-session-manager@latest'], {
530
+ detached: true,
531
+ stdio: ['ignore', fd, fd],
532
+ });
533
+ child.on('error', (e) => {
534
+ logRelaunchLine(logPath, `spawn error: ${e?.message}`);
535
+ });
536
+ child.unref();
537
+ } finally {
538
+ fs.closeSync(fd);
539
+ }
540
+ }
541
+
542
+ /**
543
+ * maybeRelaunchApp(opts?) → { relaunched: boolean, reason: string, attemptCount: number }
544
+ *
545
+ * Debounced, capped auto-relaunch:
546
+ * - alive (heartbeat fresh or pid alive) → no-op, reset attempt state.
547
+ * - dead + attemptCount already at maxAttempts → skip, log diagnostic (give up).
548
+ * - dead + last attempt < debounceMs ago → skip (still booting).
549
+ * - dead + debounce elapsed + under cap → spawn, bump attemptCount, log.
550
+ *
551
+ * attemptCount only resets to 0 once a fresh heartbeat is observed (i.e. a
552
+ * relaunch actually succeeded), so 3 relaunch attempts that each fail to
553
+ * produce a fresh heartbeat permanently cap further attempts until a human
554
+ * intervenes or the app comes up some other way.
555
+ */
556
+ function maybeRelaunchApp({
557
+ heartbeatPath = DEFAULT_HEARTBEAT_PATH,
558
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
559
+ statePath = DEFAULT_RELAUNCH_STATE_PATH,
560
+ logPath = DEFAULT_RELAUNCH_LOG_PATH,
561
+ debounceMs = DEFAULT_RELAUNCH_DEBOUNCE_MS,
562
+ maxAttempts = DEFAULT_MAX_RELAUNCH_ATTEMPTS,
563
+ now = Date.now(),
564
+ spawnFn = defaultSpawnRelaunch,
565
+ } = {}) {
566
+ const liveness = checkAppLiveness({ heartbeatPath, maxAgeMs });
567
+ const state = readRelaunchState(statePath);
568
+
569
+ if (liveness.alive) {
570
+ if (state.attemptCount !== 0 || state.lastAttemptTs !== null) {
571
+ writeRelaunchState(statePath, { lastAttemptTs: null, attemptCount: 0 });
572
+ }
573
+ return { relaunched: false, reason: 'alive', attemptCount: 0 };
574
+ }
575
+
576
+ if (state.attemptCount >= maxAttempts) {
577
+ logRelaunchLine(
578
+ logPath,
579
+ `giving up: ${state.attemptCount} relaunch attempt(s) already made and heartbeat is still stale — manual intervention required`,
580
+ );
581
+ return { relaunched: false, reason: 'capped', attemptCount: state.attemptCount };
582
+ }
583
+
584
+ if (state.lastAttemptTs !== null && (now - state.lastAttemptTs) < debounceMs) {
585
+ return { relaunched: false, reason: 'debounce', attemptCount: state.attemptCount };
586
+ }
587
+
588
+ const attemptCount = state.attemptCount + 1;
589
+ spawnFn({ logPath });
590
+ writeRelaunchState(statePath, { lastAttemptTs: now, attemptCount });
591
+ logRelaunchLine(
592
+ logPath,
593
+ `relaunch attempt ${attemptCount}/${maxAttempts}: spawning "npx claude-code-session-manager@latest" (heartbeat stale, last-known pid dead)`,
594
+ );
595
+ return { relaunched: true, reason: 'launched', attemptCount };
596
+ }
597
+
479
598
  // ── sweep ─────────────────────────────────────────────────────────────────────
480
599
 
481
600
  /**
@@ -499,8 +618,8 @@ function sweep({
499
618
  projectsDir,
500
619
  prdsDir = DEFAULT_PRDS_DIR,
501
620
  queuePath = DEFAULT_QUEUE_PATH,
502
- skillPath = DEFAULT_SKILL_PATH,
503
- standardsPath = DEFAULT_STANDARDS_PATH,
621
+ skillPath,
622
+ standardsPath,
504
623
  } = {}) {
505
624
  const activeOpts = {};
506
625
  if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
@@ -682,18 +801,29 @@ module.exports = {
682
801
  readLastHeartbeat,
683
802
  readLastHeartbeatTs,
684
803
  heartbeatFresh,
685
- reconcileQueueOffline,
804
+ isPidAlive,
686
805
  hasOpenFeedback,
806
+ resolveSkillFile,
807
+ listSkillCandidates,
687
808
  emitFeedbackPRD,
688
809
  sweep,
689
810
  localDateStr,
690
811
  maybeFinalizeHistory,
691
812
  tryAcquireLock,
692
813
  releaseLock,
814
+ checkAppLiveness,
815
+ readRelaunchState,
816
+ writeRelaunchState,
817
+ defaultSpawnRelaunch,
818
+ maybeRelaunchApp,
693
819
  DEFAULT_HEARTBEAT_PATH,
694
820
  DEFAULT_MAX_AGE_MS,
695
821
  DEFAULT_STAMP_PATH,
696
822
  DEFAULT_LOCK_PATH,
697
823
  DEFAULT_LOCK_STALE_MS,
698
824
  DEFAULT_FINALIZE_BUDGET_MS,
825
+ DEFAULT_RELAUNCH_STATE_PATH,
826
+ DEFAULT_RELAUNCH_LOG_PATH,
827
+ DEFAULT_RELAUNCH_DEBOUNCE_MS,
828
+ DEFAULT_MAX_RELAUNCH_ATTEMPTS,
699
829
  };
@@ -75,6 +75,37 @@ test('cancelling an ACTIVE run broadcasts a terminal event (unsticks the UI)', a
75
75
  assert.match(terminal[0].payload.message, /cancel/i, 'message names the cancellation');
76
76
  });
77
77
 
78
+ test('cancel() resolves only AFTER the terminal broadcast fires, not immediately on SIGTERM (PRD 718)', async () => {
79
+ // Order-of-events assertion (not timing-based — a real SIGTERM can be fast
80
+ // enough on a lightweight stub child that a fixed-delay race would flake).
81
+ const order = [];
82
+ cr.attachWindow({
83
+ isDestroyed: () => false,
84
+ webContents: {
85
+ isDestroyed: () => false,
86
+ send: (channel, payload) => {
87
+ if (isTerminal(channel) && payload.tabId === 'T2') order.push('terminal');
88
+ },
89
+ },
90
+ });
91
+
92
+ cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'hello', cwd: process.cwd(), resume: false });
93
+
94
+ for (let i = 0; i < 6; i++) await tick();
95
+
96
+ await cr.cancel('T2').then(() => order.push('resolved'));
97
+
98
+ assert.deepEqual(order, ['terminal', 'resolved'], 'cancel() resolves strictly after the terminal broadcast');
99
+ });
100
+
101
+ test('cancel() on a tab with no in-flight or waiting run resolves immediately (no-op)', async () => {
102
+ cr.attachWindow({
103
+ isDestroyed: () => false,
104
+ webContents: { isDestroyed: () => false, send: () => {} },
105
+ });
106
+ await cr.cancel('no-such-tab'); // must resolve, not hang
107
+ });
108
+
78
109
  function isTerminal(channel) {
79
110
  return (
80
111
  channel === 'chat:run:complete' ||