claude-code-session-manager 0.37.0 → 0.37.2

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 (38) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
  5. package/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/activeSessions.cjs +117 -0
  11. package/scripts/lib/watchdogHelpers.cjs +829 -0
  12. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  13. package/src/main/__tests__/docEdit.test.cjs +164 -2
  14. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  15. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  16. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  17. package/src/main/browserAgentServer.cjs +11 -10
  18. package/src/main/chatRunner.cjs +21 -2
  19. package/src/main/docEdit.cjs +125 -5
  20. package/src/main/health.cjs +15 -0
  21. package/src/main/index.cjs +12 -6
  22. package/src/main/ipcSchemas.cjs +14 -0
  23. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  25. package/src/main/lib/localAdminHttp.cjs +157 -0
  26. package/src/main/lib/personaImportHealth.cjs +161 -0
  27. package/src/main/lib/prdCreate.cjs +107 -17
  28. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  29. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  30. package/src/main/scheduler.cjs +198 -54
  31. package/src/main/templates/PRD_AUTHORING.md +4 -4
  32. package/src/main/transcripts.cjs +1 -85
  33. package/src/preload/api.d.ts +12 -1
  34. package/src/preload/index.cjs +6 -0
  35. package/dist/assets/index-CTTjT08J.css +0 -32
  36. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  37. package/src/main/__tests__/adminServer.test.cjs +0 -380
  38. package/src/main/adminServer.cjs +0 -242
@@ -0,0 +1,829 @@
1
+ 'use strict';
2
+
3
+ // watchdogHelpers.cjs — pure helpers for scheduler-watchdog.cjs (no side effects).
4
+ // Testable without spawning the watchdog entry script.
5
+
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const { activeProjectCwds } = require('./activeSessions.cjs');
11
+
12
+ // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
13
+ const DEFAULT_HEARTBEAT_PATH = path.join(
14
+ os.homedir(), '.claude', 'session-manager', 'scheduler-heartbeat.log',
15
+ );
16
+
17
+ // The in-app heartbeat ticks every 60 s; 3 missed ticks = stale.
18
+ const DEFAULT_MAX_AGE_MS = 180_000;
19
+
20
+ /** Local-TZ 'YYYY-MM-DD' for `d` (default now). Single source of truth for
21
+ * the watchdog's notion of "today" — used for both the log filename and the
22
+ * history-rollup once-per-day gate. */
23
+ function localDateStr(d = new Date()) {
24
+ const y = d.getFullYear();
25
+ const m = String(d.getMonth() + 1).padStart(2, '0');
26
+ const day = String(d.getDate()).padStart(2, '0');
27
+ return `${y}-${m}-${day}`;
28
+ }
29
+
30
+ // Tail bytes to read — enough to hold several JSON heartbeat lines without
31
+ // loading a potentially 1 MB file. O(1) in file size.
32
+ const TAIL_BYTES = 4096;
33
+
34
+ /**
35
+ * readLastHeartbeat(heartbeatPath?) → object | null
36
+ *
37
+ * Reads the last ~4 KB of the heartbeat log, reverse-scans for the last
38
+ * non-empty line, and returns the full parsed JSON object.
39
+ * Returns null on missing / empty / unparseable file.
40
+ *
41
+ * Single source of truth for the file-read + reverse-scan logic; used by
42
+ * readLastHeartbeatTs() and heartbeatFresh().
43
+ */
44
+ function readLastHeartbeat(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
45
+ let buf;
46
+ try {
47
+ const stat = fs.statSync(heartbeatPath);
48
+ const size = stat.size;
49
+ if (size === 0) return null;
50
+
51
+ const readSize = Math.min(TAIL_BYTES, size);
52
+ const fd = fs.openSync(heartbeatPath, 'r');
53
+ try {
54
+ buf = Buffer.alloc(readSize);
55
+ fs.readSync(fd, buf, 0, readSize, size - readSize);
56
+ } finally {
57
+ fs.closeSync(fd);
58
+ }
59
+ } catch {
60
+ return null;
61
+ }
62
+
63
+ const lines = buf.toString('utf8').split('\n');
64
+ // Walk lines in reverse to find last non-empty parseable one.
65
+ for (let i = lines.length - 1; i >= 0; i--) {
66
+ const line = lines[i].trim();
67
+ if (!line) continue;
68
+ try {
69
+ return JSON.parse(line);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * readLastHeartbeatTs(heartbeatPath?) → number | null
79
+ *
80
+ * Returns the `ts` field (epoch ms) from the last heartbeat entry.
81
+ * Returns null on missing / empty / unparseable file or missing ts field.
82
+ */
83
+ function readLastHeartbeatTs(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
84
+ const entry = readLastHeartbeat(heartbeatPath);
85
+ return (entry !== null && typeof entry.ts === 'number') ? entry.ts : null;
86
+ }
87
+
88
+ /**
89
+ * heartbeatFresh(heartbeatPath?, maxAgeMs?) → boolean
90
+ *
91
+ * Returns true iff the last heartbeat ts is within maxAgeMs of `now`.
92
+ * Missing / empty / unparseable file → false.
93
+ */
94
+ function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAULT_MAX_AGE_MS) {
95
+ const ts = readLastHeartbeatTs(heartbeatPath);
96
+ if (ts === null) return false;
97
+ return (Date.now() - ts) < maxAgeMs;
98
+ }
99
+
100
+ // Default paths — callers can override via opts for testing.
101
+ const DEFAULT_QUEUE_PATH = path.join(
102
+ os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
103
+ );
104
+ const DEFAULT_PRDS_DIR = path.join(
105
+ os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
106
+ );
107
+ const PLUGIN_CACHE_ROOT = path.join(
108
+ os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
109
+ );
110
+
111
+ /**
112
+ * compareVersionsDesc(a, b) → number
113
+ *
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[]
138
+ *
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:
142
+ *
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.
150
+ *
151
+ * pluginCacheRoot is overridable for tests; defaults to the real plugin cache.
152
+ */
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
+ ];
157
+
158
+ let entries = [];
159
+ try {
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));
165
+ }
166
+
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 */ }
182
+ }
183
+ return null;
184
+ }
185
+
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;
200
+ try {
201
+ process.kill(pid, 0);
202
+ return true;
203
+ } catch (e) {
204
+ return e?.code === 'EPERM';
205
+ }
206
+ }
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
+
215
+ // ── slugify ──────────────────────────────────────────────────────────────────
216
+
217
+ /** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
218
+ function slugify(name) {
219
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
220
+ }
221
+
222
+ // ── YAML frontmatter stripper ─────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Strip leading YAML frontmatter block (---\n…\n---) from skill/standards files
226
+ * so they can be inlined cleanly into a PRD body.
227
+ */
228
+ function stripFrontmatter(content) {
229
+ if (!content.startsWith('---')) return content;
230
+ const end = content.indexOf('\n---', 3);
231
+ if (end === -1) return content;
232
+ return content.slice(end + 4).replace(/^\n/, '');
233
+ }
234
+
235
+ /**
236
+ * Strip a leading H1 line (`# …\n`) — used to drop the `# Engineering standards`
237
+ * heading from standards.md before adding our own `## Engineering standards` heading.
238
+ */
239
+ function stripLeadingH1(content) {
240
+ return content.replace(/^# [^\n]*\n/, '');
241
+ }
242
+
243
+ // ── hasOpenFeedback ───────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * hasOpenFeedback(cwd) → boolean
247
+ *
248
+ * Returns true iff `<cwd>/session-manager-operations/feedback/` (the PRD-462
249
+ * convention) or, as a transitional fallback for repos not yet relocated,
250
+ * the legacy `<cwd>/feedback/` exists AND contains at least one *.md file
251
+ * directly in its root (i.e. NOT inside `processed/` or any subdirectory).
252
+ * Pure filesystem check — no LLM call.
253
+ *
254
+ * Mirrors process-feedback skill step 0 (cheap quick-exit signal).
255
+ * Complexity: O(F) where F ≤ entries in the feedback folder root.
256
+ */
257
+ function hasOpenFeedback(cwd) {
258
+ for (const folderName of [path.join('session-manager-operations', 'feedback'), 'feedback']) {
259
+ const folderPath = path.join(cwd, folderName);
260
+ let entries;
261
+ try {
262
+ entries = fs.readdirSync(folderPath);
263
+ } catch {
264
+ continue; // folder does not exist
265
+ }
266
+ for (const entry of entries) {
267
+ if (!entry.endsWith('.md')) continue;
268
+ // README.md is the folder's convention doc, not an open feedback item —
269
+ // counting it makes a folder with only a README look perpetually "pending".
270
+ if (entry.toLowerCase() === 'readme.md') continue;
271
+ try {
272
+ const st = fs.statSync(path.join(folderPath, entry));
273
+ if (st.isFile()) return true;
274
+ } catch { continue; }
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
280
+ // ── emitFeedbackPRD ──────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * emitFeedbackPRD(cwd, opts?) → { emitted: boolean, slug?: string, prdPath?: string, reason?: string }
284
+ *
285
+ * De-dups against queue.json (pending/running jobs whose slug matches
286
+ * `^\d+-feedback-<project>$`). If a match exists, returns { emitted: false, reason: 'duplicate' }.
287
+ *
288
+ * Otherwise picks the next free NN by scanning prdsDir, writes a self-contained
289
+ * PRD file with the inlined process-feedback procedure + engineering standards,
290
+ * and returns { emitted: true, slug, prdPath }.
291
+ *
292
+ * Never writes to queue.json — the app's reconcile picks up new PRD files.
293
+ * Atomic write: tmp → rename (mirrors config.cjs writeJsonSync pattern).
294
+ *
295
+ * Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
296
+ */
297
+ function emitFeedbackPRD(cwd, {
298
+ prdsDir = DEFAULT_PRDS_DIR,
299
+ queuePath = DEFAULT_QUEUE_PATH,
300
+ skillPath,
301
+ standardsPath,
302
+ pluginCacheRoot = PLUGIN_CACHE_ROOT,
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
+ }
315
+ const project = slugify(path.basename(cwd));
316
+
317
+ // De-dup: check queue.json for pending/running feedback job for this project.
318
+ let queueState = { jobs: [] };
319
+ try {
320
+ queueState = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
321
+ } catch { /* queue.json may not exist */ }
322
+
323
+ // project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
324
+ const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
325
+ const isDuplicate = (queueState.jobs ?? []).some(
326
+ (j) => (j.status === 'pending' || j.status === 'running') && dupRe.test(j.slug),
327
+ );
328
+ if (isDuplicate) {
329
+ return { emitted: false, reason: 'duplicate' };
330
+ }
331
+
332
+ // Pick next NN: max existing NN prefix + 1. O(P) scan.
333
+ // We ALSO dedup against the prds dir here: while the app is down it never
334
+ // ingests new PRD files into queue.json, so the queue check above can't see a
335
+ // feedback PRD we already wrote on a previous tick. Without this, every stale
336
+ // watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
337
+ const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
338
+ let maxNN = 0;
339
+ let prdFiles = [];
340
+ try {
341
+ prdFiles = fs.readdirSync(prdsDir);
342
+ } catch { /* prdsDir may not exist yet */ }
343
+ for (const f of prdFiles) {
344
+ if (prdDupRe.test(f)) {
345
+ return { emitted: false, reason: 'duplicate' };
346
+ }
347
+ const m = f.match(/^(\d+)-/);
348
+ if (m) {
349
+ const n = parseInt(m[1], 10);
350
+ if (n > maxNN) maxNN = n;
351
+ }
352
+ }
353
+ const nn = String(maxNN + 1).padStart(2, '0');
354
+ const slug = `${nn}-feedback-${project}`;
355
+ const prdPath = path.join(prdsDir, `${slug}.md`);
356
+
357
+ // Read and inline skill content.
358
+ let rawSkill = '';
359
+ if (skillPath) {
360
+ try {
361
+ rawSkill = fs.readFileSync(skillPath, 'utf8');
362
+ } catch { /* handled by the empty-body guard below */ }
363
+ }
364
+ const skillBody = stripFrontmatter(rawSkill).trim();
365
+
366
+ // Read and inline standards — strip H1 so our ## heading is the section anchor.
367
+ let rawStandards = '';
368
+ if (standardsPath) {
369
+ try {
370
+ rawStandards = fs.readFileSync(standardsPath, 'utf8');
371
+ } catch { /* handled by the empty-body guard below */ }
372
+ }
373
+ const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
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
+
393
+ const projectName = path.basename(cwd);
394
+ const body = [
395
+ '---',
396
+ `title: Process feedback for ${projectName}`,
397
+ `cwd: ${cwd}`,
398
+ 'estimateMinutes: 15',
399
+ '---',
400
+ '',
401
+ '# Goal',
402
+ '',
403
+ `Process the inbound feedback folder for ${projectName}. The quick-exit (step 0 below)`,
404
+ 'means this run bails in milliseconds if no open items exist — safe to execute even if',
405
+ 'feedback was already cleared between scheduling and execution.',
406
+ '',
407
+ '# Acceptance criteria',
408
+ '',
409
+ '- [ ] All open feedback items evaluated and either queued via /develop, declined with RESOLUTION, or forwarded upstream.',
410
+ '- [ ] Processed items archived to session-manager-operations/feedback/processed/ (or, for repos not yet relocated, legacy feedback/processed/) with RESOLUTION notes.',
411
+ '- [ ] session-manager-operations/feedback/README.md (or legacy feedback/README.md) self-improved with lessons from this pass.',
412
+ '- [ ] timeout 60 git diff --exit-code runs clean (no uncommitted inline work).',
413
+ '',
414
+ '# Implementation notes',
415
+ '',
416
+ 'Follow the inlined process-feedback procedure below exactly. No skills are loaded in',
417
+ 'headless execution — the procedure is fully self-contained. Use /develop for any work',
418
+ 'that belongs to this project; never implement feedback inline.',
419
+ '',
420
+ '# Out of scope',
421
+ '',
422
+ '- Implementing feedback items directly (that is /develop → scheduler).',
423
+ '- Cross-project feedback beyond filing upstream items.',
424
+ '',
425
+ skillBody,
426
+ '',
427
+ '## Engineering standards',
428
+ '',
429
+ standardsBody,
430
+ ].join('\n');
431
+
432
+ // Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync).
433
+ const tmpPath = `${prdPath}.tmp-${process.pid}-${Date.now()}`;
434
+ try {
435
+ fs.mkdirSync(prdsDir, { recursive: true });
436
+ fs.writeFileSync(tmpPath, body + '\n', 'utf8');
437
+ fs.renameSync(tmpPath, prdPath);
438
+ } catch (e) {
439
+ try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
440
+ throw e;
441
+ }
442
+
443
+ return { emitted: true, slug, prdPath };
444
+ }
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
+
598
+ // ── sweep ─────────────────────────────────────────────────────────────────────
599
+
600
+ /**
601
+ * sweep(opts?) → { scanned: number, emitted: number, skipped: number }
602
+ *
603
+ * For each active-session cwd (from activeProjectCwds), cheaply checks for open
604
+ * feedback. Projects without open feedback cost a readdir per folder name — no LLM.
605
+ * Projects with open feedback get a PRD emitted into prdsDir (de-duped against queue).
606
+ *
607
+ * Never mutates queue.json — only adds PRD files, so it is safe to run while the
608
+ * in-app scheduler is alive.
609
+ *
610
+ * Complexity: O(C × F) where C = active cwds (≤50) and F = root entries per
611
+ * feedback folder (typically small).
612
+ *
613
+ * opts:
614
+ * projectsDir — forwarded to activeProjectCwds for testing
615
+ * prdsDir, queuePath, skillPath, standardsPath — forwarded to emitFeedbackPRD
616
+ */
617
+ function sweep({
618
+ projectsDir,
619
+ prdsDir = DEFAULT_PRDS_DIR,
620
+ queuePath = DEFAULT_QUEUE_PATH,
621
+ skillPath,
622
+ standardsPath,
623
+ } = {}) {
624
+ const activeOpts = {};
625
+ if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
626
+
627
+ const cwds = activeProjectCwds(90, activeOpts);
628
+
629
+ let scanned = 0;
630
+ let emitted = 0;
631
+ let skipped = 0;
632
+
633
+ for (const cwd of cwds) {
634
+ scanned++;
635
+ if (!hasOpenFeedback(cwd)) continue;
636
+
637
+ let result;
638
+ try {
639
+ result = emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
640
+ } catch (e) {
641
+ process.stderr.write(`[sweep] error emitting PRD for ${cwd}: ${e?.message}\n`);
642
+ continue;
643
+ }
644
+
645
+ if (result.emitted) {
646
+ emitted++;
647
+ process.stderr.write(`[sweep] emitted ${result.slug} for ${cwd}\n`);
648
+ } else {
649
+ skipped++;
650
+ process.stderr.write(`[sweep] skipped ${cwd} (${result.reason})\n`);
651
+ }
652
+ }
653
+
654
+ process.stderr.write(`[sweep] scanned=${scanned} emitted=${emitted} skipped=${skipped}\n`);
655
+ return { scanned, emitted, skipped };
656
+ }
657
+
658
+ // ── maybeFinalizeHistory ─────────────────────────────────────────────────────
659
+ //
660
+ // Precomputes closed History-dashboard days outside Electron, so the app's
661
+ // first paint after a boot (possibly weeks after the app was last open) is
662
+ // instant, and old transcripts stay safe to age out without losing analytics.
663
+ // See src/main/historyAggregator.cjs's finalizeClosedDays() for the actual
664
+ // walk/persist logic — this is just the once-per-day scheduling + lock
665
+ // wrapper around it, tailored to being invoked repeatedly by a systemd timer.
666
+
667
+ const DEFAULT_STAMP_PATH = path.join(
668
+ os.homedir(), '.claude', 'session-manager', 'history-rollup.stamp',
669
+ );
670
+ const DEFAULT_LOCK_PATH = path.join(
671
+ os.homedir(), '.claude', 'session-manager', 'history-rollup.lock',
672
+ );
673
+ const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
674
+ const DEFAULT_FINALIZE_BUDGET_MS = 60_000;
675
+
676
+ function readStampDate(stampPath) {
677
+ try {
678
+ return fs.readFileSync(stampPath, 'utf8').trim();
679
+ } catch {
680
+ return null;
681
+ }
682
+ }
683
+
684
+ /** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
685
+ function writeStampDate(stampPath, date) {
686
+ fs.mkdirSync(path.dirname(stampPath), { recursive: true });
687
+ const tmpPath = `${stampPath}.tmp-${process.pid}-${Date.now()}`;
688
+ fs.writeFileSync(tmpPath, date, 'utf8');
689
+ fs.renameSync(tmpPath, stampPath);
690
+ }
691
+
692
+ /**
693
+ * tryAcquireLock(lockPath, staleMs) → boolean
694
+ *
695
+ * O_EXCL ('wx') lock file so the in-app Electron boot pass and this cron
696
+ * pass never interleave writes to the rollup. A lock older than staleMs is
697
+ * assumed abandoned (a crashed holder) and reclaimed; otherwise the caller
698
+ * loses the race and must skip silently.
699
+ */
700
+ function tryAcquireLock(lockPath, staleMs = DEFAULT_LOCK_STALE_MS) {
701
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
702
+ try {
703
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
704
+ return true;
705
+ } catch (e) {
706
+ if (e.code !== 'EEXIST') throw e;
707
+ }
708
+
709
+ let st;
710
+ try {
711
+ st = fs.statSync(lockPath);
712
+ } catch {
713
+ st = null; // lock vanished between our failed create and this stat
714
+ }
715
+
716
+ if (st !== null && Date.now() - st.mtimeMs <= staleMs) {
717
+ return false; // held by a live (or at least recent) owner — loser skips
718
+ }
719
+
720
+ // Stale (or already gone) — reclaim. The unlink+wx pair isn't atomic, so
721
+ // two concurrent reclaimers could theoretically both win here — but that
722
+ // only happens when the prior holder already crashed/vanished, and the
723
+ // resulting "both run finalize" outcome is benign: appendRollupDays is
724
+ // append-only + last-write-wins-deduped, so a redundant concurrent pass
725
+ // costs extra work, not corrupted data.
726
+ try {
727
+ if (st !== null) fs.unlinkSync(lockPath);
728
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
729
+ return true;
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+
735
+ function releaseLock(lockPath) {
736
+ try { fs.unlinkSync(lockPath); } catch { /* already gone / never created */ }
737
+ }
738
+
739
+ /** Lazily require historyAggregator.cjs — deferred so a missing PRD-650
740
+ * dependency fails inside maybeFinalizeHistory's try/catch, not at require
741
+ * time for the whole watchdog script. */
742
+ function defaultFinalizeClosedDays(opts) {
743
+ const { finalizeClosedDays } = require('../../src/main/historyAggregator.cjs');
744
+ return finalizeClosedDays(opts);
745
+ }
746
+
747
+ /**
748
+ * maybeFinalizeHistory(opts?) → Promise<{ ran, reason, date, finalizedDates? }>
749
+ *
750
+ * Runs at most once per local day (stamp-file gated, O(1) same-day skip).
751
+ * Guards against interleaving with the in-app Electron boot pass via an
752
+ * O_EXCL lock file. Bounded by opts.budgetMs so a huge transcript corpus
753
+ * can't make a single watchdog tick run long; a budget-partial pass does
754
+ * NOT stamp the day complete, so a later tick resumes/retries.
755
+ *
756
+ * opts:
757
+ * stampPath — override for testing (default ~/.claude/session-manager/history-rollup.stamp)
758
+ * lockPath — override for testing (default ~/.claude/session-manager/history-rollup.lock)
759
+ * staleLockMs — lock staleness threshold (default 10 min)
760
+ * budgetMs — cap forwarded to finalizeClosedDays (default 60_000)
761
+ * dryRun — compute without writing (default SM_WATCHDOG_DRYRUN === '1')
762
+ * finalizeFn — injectable finalizeClosedDays for testing
763
+ */
764
+ async function maybeFinalizeHistory({
765
+ stampPath = DEFAULT_STAMP_PATH,
766
+ lockPath = DEFAULT_LOCK_PATH,
767
+ staleLockMs = DEFAULT_LOCK_STALE_MS,
768
+ budgetMs = DEFAULT_FINALIZE_BUDGET_MS,
769
+ dryRun = process.env.SM_WATCHDOG_DRYRUN === '1',
770
+ finalizeFn = defaultFinalizeClosedDays,
771
+ } = {}) {
772
+ const today = localDateStr();
773
+
774
+ if (readStampDate(stampPath) === today) {
775
+ return { ran: false, reason: 'already-finalized-today', date: today };
776
+ }
777
+
778
+ if (!tryAcquireLock(lockPath, staleLockMs)) {
779
+ return { ran: false, reason: 'lock-contended', date: today };
780
+ }
781
+
782
+ try {
783
+ const result = await finalizeFn({ budgetMs, dryRun });
784
+
785
+ if (dryRun) {
786
+ return { ran: false, reason: 'dry-run', date: today, finalizedDates: result.finalizedDates };
787
+ }
788
+
789
+ if (!result.partial) {
790
+ writeStampDate(stampPath, today);
791
+ return { ran: true, reason: 'finalized', date: today, finalizedDates: result.finalizedDates };
792
+ }
793
+
794
+ return { ran: true, reason: 'partial', date: today, finalizedDates: result.finalizedDates };
795
+ } finally {
796
+ releaseLock(lockPath);
797
+ }
798
+ }
799
+
800
+ module.exports = {
801
+ readLastHeartbeat,
802
+ readLastHeartbeatTs,
803
+ heartbeatFresh,
804
+ isPidAlive,
805
+ hasOpenFeedback,
806
+ resolveSkillFile,
807
+ listSkillCandidates,
808
+ emitFeedbackPRD,
809
+ sweep,
810
+ localDateStr,
811
+ maybeFinalizeHistory,
812
+ tryAcquireLock,
813
+ releaseLock,
814
+ checkAppLiveness,
815
+ readRelaunchState,
816
+ writeRelaunchState,
817
+ defaultSpawnRelaunch,
818
+ maybeRelaunchApp,
819
+ DEFAULT_HEARTBEAT_PATH,
820
+ DEFAULT_MAX_AGE_MS,
821
+ DEFAULT_STAMP_PATH,
822
+ DEFAULT_LOCK_PATH,
823
+ DEFAULT_LOCK_STALE_MS,
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,
829
+ };