claude-code-session-manager 0.122.0 → 0.123.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 (93) hide show
  1. package/dist/assets/{AgentLibrary-BmJ8hIPw.js → AgentLibrary-DxWN3FSN.js} +2 -2
  2. package/dist/assets/{Button-BS4kmz4p.js → Button-CISLFSKb.js} +1 -1
  3. package/dist/assets/DataModel-CYPbhGrx.js +1 -0
  4. package/dist/assets/{History-pg00b2Gd.js → History-Fhouektw.js} +2 -2
  5. package/dist/assets/{Hooks-BTEs0UZY.js → Hooks-COfIcyYV.js} +3 -3
  6. package/dist/assets/{Library-DuaJ-VSn.js → Library-Bt5WOyyu.js} +7 -7
  7. package/dist/assets/ListDetail-DH7uzYd5.js +1 -0
  8. package/dist/assets/{McpServers-Dy-wnG_X.js → McpServers-CwyVEzMN.js} +2 -2
  9. package/dist/assets/{Memory-Bs1T1Jo7.js → Memory-k9TWT9N4.js} +8 -8
  10. package/dist/assets/{Panel-8UhLXEOW.js → Panel-Di8JqCYN.js} +1 -1
  11. package/dist/assets/{Permissions-BfwzvmBS.js → Permissions-FFs6Rq1H.js} +3 -3
  12. package/dist/assets/{Plugins-McjyvvKh.js → Plugins-uK5vl_RE.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-D0Ov_6WJ.js → ProvenanceBadge-CGDyGDoN.js} +1 -1
  14. package/dist/assets/{SaveBar-Cd6hJR6K.js → SaveBar-Cnwc_XfF.js} +1 -1
  15. package/dist/assets/{Scheduler-BZXKqYJP.js → Scheduler-aACa6_mC.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-C24ysV1K.js → ScopeSwitcher-CoG3CSlC.js} +1 -1
  17. package/dist/assets/Settings-BG0RM6Di.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-BsWuiTbw.js → SkillReferenceGraph-COffGu_A.js} +1 -1
  19. package/dist/assets/Skills--qQpFjjf.js +2 -0
  20. package/dist/assets/SystemPrompt-Cr9RRSiZ.js +1 -0
  21. package/dist/assets/TagLibrary-D_ZbXwBv.js +1 -0
  22. package/dist/assets/{TiptapBody-C4kIvIMH.js → TiptapBody-CCHsa8Jv.js} +1 -1
  23. package/dist/assets/{Toggle-pP19zlyk.js → Toggle-BUOj4pws.js} +1 -1
  24. package/dist/assets/{ViewTabs-CpzN8e5I.js → ViewTabs-BdpsL7yr.js} +1 -1
  25. package/dist/assets/{historyTotals-Caq78bb5.js → historyTotals-Dx6ca881.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-Bjtguv6I.js → index-CkNGj8FA.js} +151 -151
  28. package/dist/assets/{settingsSchema-CC2ySu3d.js → settingsSchema-BZhkpr9e.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-hDhNo8jE.js → skillFrontmatter-N13KGqbs.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BuOtY25U.js → useAgentsDirWatch-De9UsbDt.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +1 -1
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +29 -24
  34. package/scripts/scheduler-mcp-server.cjs +142 -78
  35. package/src/main/agentLibrary.cjs +8 -3
  36. package/src/main/build-info.json +4 -4
  37. package/src/main/crashDiagnostics.cjs +24 -0
  38. package/src/main/historyAggregator.cjs +41 -7
  39. package/src/main/index.cjs +13 -3
  40. package/src/main/ipcSchemas.cjs +30 -22
  41. package/src/main/lib/activeSessions.cjs +93 -32
  42. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  43. package/src/main/lib/epic/delegationSelfHeal.cjs +77 -0
  44. package/src/main/lib/epic/epicMint.cjs +1 -1
  45. package/src/main/lib/epic/epicStandingInstructions.cjs +73 -0
  46. package/src/main/lib/epic/guardSpawnSettings.cjs +76 -0
  47. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +9 -2
  48. package/src/main/lib/feedback-remote/remoteClientSessions.cjs +3 -11
  49. package/src/main/lib/feedback-remote/remoteE2eCrypto.cjs +1 -1
  50. package/src/main/lib/git/gitWorktree.cjs +9 -4
  51. package/src/main/lib/git/worktreeDeps.cjs +13 -2
  52. package/src/main/lib/git/worktreeSetup.cjs +212 -0
  53. package/src/main/lib/mcpToolCatalog.cjs +36 -9
  54. package/src/main/lib/persona-macro/agentPersonaSchema.cjs +4 -0
  55. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +23 -0
  56. package/src/main/lib/prd/prdAgentType.cjs +28 -0
  57. package/src/main/lib/prd/prdBodyPatch.cjs +138 -0
  58. package/src/main/lib/prd/prdCreate.cjs +245 -27
  59. package/src/main/lib/prd/prdGateFiles.cjs +31 -0
  60. package/src/main/lib/scheduler/definitionOfDone.cjs +24 -3
  61. package/src/main/lib/scheduler/queueStore.cjs +5 -4
  62. package/src/main/lib/scheduler/scheduleJobSchema.cjs +1 -0
  63. package/src/main/lib/scheduler/schedulerBatch.cjs +43 -5
  64. package/src/main/lib/scheduler/schedulerPaths.cjs +2 -0
  65. package/src/main/lib/scheduler/watchdogHelpers.cjs +114 -22
  66. package/src/main/lib/telemetry/mainHeapSnapshot.cjs +69 -0
  67. package/src/main/lib/telemetry/telemetryBoot.cjs +25 -3
  68. package/src/main/lib/telemetry/telemetryClient.cjs +12 -0
  69. package/src/main/lib/telemetry/telemetryCounters.cjs +25 -2
  70. package/src/main/pty.cjs +0 -53
  71. package/src/main/runVerify.cjs +38 -11
  72. package/src/main/scheduler/prdParser.cjs +3 -0
  73. package/src/main/scheduler/prdPaths.cjs +48 -2
  74. package/src/main/scheduler.cjs +135 -31
  75. package/src/main/seedDevPlugin.cjs +1 -1
  76. package/src/main/seedSchedulerMcp.cjs +1 -1
  77. package/src/main/templates/PRD_AUTHORING.md +8 -4
  78. package/src/main/webRemote.cjs +38 -132
  79. package/src/preload/api.d.ts +4 -52
  80. package/src/preload/index.cjs +0 -24
  81. package/src/seed/agents/dev-lead.md +2 -1
  82. package/src/seed/agents/validator.md +2 -1
  83. package/dist/assets/DataModel-DZHBZrfL.js +0 -1
  84. package/dist/assets/ListDetail-Cud72s-O.js +0 -1
  85. package/dist/assets/RemoteMachines-gOlFTvWq.js +0 -3
  86. package/dist/assets/Settings-DWnN2qxn.js +0 -4
  87. package/dist/assets/Skills-6pHn6glu.js +0 -2
  88. package/dist/assets/SystemPrompt-DT0LBH-K.js +0 -1
  89. package/dist/assets/TagLibrary-DbJ0zbYL.js +0 -1
  90. package/dist/assets/useRemotePeers-CNQmRBpt.js +0 -1
  91. package/src/main/lib/feedback-remote/relayClientAuth.cjs +0 -133
  92. package/src/main/remoteClient.cjs +0 -334
  93. package/src/main/remoteClientIpc.cjs +0 -133
@@ -244,6 +244,36 @@ function renderFilesSection(files) {
244
244
  ];
245
245
  }
246
246
 
247
+ /**
248
+ * Inverse of renderFilesSection: read the bullet entries under the `# Files`
249
+ * heading (up to the next top-level heading) back out of a PRD body. Returns
250
+ * [] when there is no such section. Strips only the renderer's `- ` prefix
251
+ * (plus tolerated wrapping backticks); prose lines are ignored.
252
+ *
253
+ * @param {string} body
254
+ * @returns {string[]}
255
+ */
256
+ function parseFilesSection(body) {
257
+ if (typeof body !== 'string') return [];
258
+ const lines = body.split(/\r?\n/);
259
+ const start = lines.findIndex((l) => /^#\s+Files\s*$/.test(l));
260
+ if (start === -1) return [];
261
+ const out = [];
262
+ let inFence = false;
263
+ for (let i = start + 1; i < lines.length; i++) {
264
+ const line = lines[i];
265
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
266
+ if (!inFence && /^#\s/.test(line)) break;
267
+ const m = line.match(/^[-*]\s+(.+?)\s*$/);
268
+ if (!m) continue;
269
+ let entry = m[1];
270
+ const tick = entry.match(/^`([^`]+)`$/);
271
+ if (tick) entry = tick[1];
272
+ if (entry && !out.includes(entry)) out.push(entry);
273
+ }
274
+ return out;
275
+ }
276
+
247
277
  /**
248
278
  * Render the `# Gate` body section as an array of lines. `commandsOrNone`
249
279
  * must already be validated (validateGate's `commands` output) — either
@@ -280,5 +310,6 @@ module.exports = {
280
310
  validateGate,
281
311
  validateFiles,
282
312
  renderFilesSection,
313
+ parseFilesSection,
283
314
  renderGateSection,
284
315
  };
@@ -427,7 +427,8 @@ function extractAcCommand(prdBody) {
427
427
  * Runs the resolved gate with `runGateSequence`, the same runner the
428
428
  * scheduler's shadow gate uses: `&&` semantics, each run gets its own
429
429
  * isolated TMPDIR and `CI=1`, and at most one gate runs machine-wide at a
430
- * time. This never spawns `claude` and never writes to queue.json — it only
430
+ * time (the flag-free variant is runIsolatedSequence, which never reports
431
+ * 'busy'). This never spawns `claude` and never writes to queue.json — it only
431
432
  * re-runs the already-authored gate command.
432
433
  *
433
434
  * Result `status` meanings:
@@ -756,6 +757,7 @@ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
756
757
  // records the outcome next to the verdict. Not a claude -p run, so it takes no
757
758
  // slot from sessionSlots; instead a single module-level flag keeps at most one
758
759
  // shadow gate in flight machine-wide (the scheduler is one process).
760
+ // runIsolatedSequence is the flag-free variant: same run, no single-flight.
759
761
  let gateInFlight = false;
760
762
  const OUTPUT_TAIL_CHARS = 1000;
761
763
  // Bounded full-output ring kept for baseline comparison (failing test ids).
@@ -829,9 +831,28 @@ function runOneGateCommand(cmd, { cwd, env }) {
829
831
  */
830
832
  async function runGateSequence(sequence, { cwd }) {
831
833
  if (gateInFlight) return { status: 'busy', results: [] };
834
+ gateInFlight = true;
835
+ try {
836
+ return await runIsolatedSequence(sequence, { cwd });
837
+ } finally {
838
+ gateInFlight = false;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Flag-free variant of runGateSequence: same `&&` semantics, own mkdtemp
844
+ * TMPDIR (removed afterwards) and CI=1, but it never reads or writes
845
+ * `gateInFlight`, so it never returns 'busy' and never makes a concurrent
846
+ * runGateSequence return 'busy'. Meant for per-worktree work (e.g. worktree
847
+ * setup) whose callers bound their own parallelism.
848
+ *
849
+ * @param {Array<{argv:string[], timeoutMs:number, env?:object}>} sequence
850
+ * @param {{ cwd: string }} opts
851
+ * @returns {Promise<{ status: 'green'|'red'|'unavailable', results: object[] }>}
852
+ */
853
+ async function runIsolatedSequence(sequence, { cwd }) {
832
854
  if (!Array.isArray(sequence) || !sequence.length) return { status: 'unavailable', results: [] };
833
855
  try { fs.statSync(cwd); } catch { return { status: 'unavailable', results: [] }; }
834
- gateInFlight = true;
835
856
  let tmp = null;
836
857
  try {
837
858
  tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gate-shadow-'));
@@ -847,7 +868,6 @@ async function runGateSequence(sequence, { cwd }) {
847
868
  } catch {
848
869
  return { status: 'unavailable', results: [] };
849
870
  } finally {
850
- gateInFlight = false;
851
871
  if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }
852
872
  }
853
873
  }
@@ -899,6 +919,7 @@ module.exports = {
899
919
  isNoneGate,
900
920
  resolveGate,
901
921
  runGateSequence,
922
+ runIsolatedSequence,
902
923
  extractSection,
903
924
  reverifyAc,
904
925
  reverifyBatch,
@@ -218,10 +218,11 @@ function stateCwds(opts) {
218
218
 
219
219
  function bustCwdCache() {
220
220
  cwdCache = { at: 0, cwds: Object.freeze([]) };
221
- // Without this chain, activeSessions' own scan cache hands stateCwds a
222
- // stale list even after this bust — turning a caller's "I just wrote a
223
- // brand-new project's first state file, make it visible now" into a
224
- // no-op until activeSessions' independent TTL expires.
221
+ // Shallow bust: activeSessions keeps its per-dir cache and the next scan
222
+ // only picks up new project dirs. That is enough for new-project
223
+ // visibility — a brand-new project's transcript dir changes
224
+ // ~/.claude/projects' mtime, which the incremental path already sees.
225
+ // Callers needing a never-stale scan pass { deep: true } themselves.
225
226
  bustProjectCwdCache();
226
227
  }
227
228
 
@@ -88,6 +88,7 @@ const ScheduleJobSchema = z
88
88
  runtime: ScheduleJobRuntimeSchema.optional(),
89
89
  verifierVerdict: z.string().optional(),
90
90
  dependsOn: z.array(z.string()).optional(),
91
+ files: z.array(z.string()).optional(),
91
92
  originSessionId: z.string().nullable().optional(),
92
93
  sourceTabId: z.string().nullable().optional(),
93
94
  sourcePromptId: z.string().nullable().optional(),
@@ -141,8 +141,32 @@ function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
141
141
  });
142
142
  }
143
143
 
144
+ /** Normalise a `files` entry for overlap comparison (`./a//b` -> `a/b`, trailing `/` kept). */
145
+ function normFilePath(f) {
146
+ const raw = String(f ?? '').trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/{2,}/g, '/');
147
+ return raw;
148
+ }
149
+
150
+ /** Equal paths, or a folder entry (ending in `/`) that prefixes the other path. */
151
+ function pathsOverlap(a, b) {
152
+ if (!a || !b) return false;
153
+ if (a === b) return true;
154
+ if (a.endsWith('/') && b.startsWith(a)) return true;
155
+ if (b.endsWith('/') && a.startsWith(b)) return true;
156
+ return false;
157
+ }
158
+
159
+ /** True when two jobs' `files` arrays overlap. Empty/missing files never overlap. */
160
+ function filesOverlap(aFiles, bFiles) {
161
+ if (!Array.isArray(aFiles) || !Array.isArray(bFiles)) return false;
162
+ const a = aFiles.map(normFilePath).filter(Boolean);
163
+ const b = bFiles.map(normFilePath).filter(Boolean);
164
+ return a.some((x) => b.some((y) => pathsOverlap(x, y)));
165
+ }
166
+
144
167
  /**
145
- * Per-project batch picker. `dependsOn` is the ONLY ordering primitive.
168
+ * Per-project batch picker. `dependsOn` is the only ORDERING primitive;
169
+ * files-overlap (see below) is a mutual-exclusion hold, never an order.
146
170
  *
147
171
  * Rules:
148
172
  * 1. Eligible = pending, not already running, and no blocking dependency.
@@ -386,10 +410,24 @@ function pickForProject(projectJobs, runningSlugsInProject, slots, heldSlugs = n
386
410
  // parallelGroup is a PRIORITY HINT, not a barrier: when there are more
387
411
  // eligible jobs than slots, the lower (earlier-authored) PRD numbers go
388
412
  // first. Everything else about the number is display-only.
389
- const batch = pending
390
- .slice()
391
- .sort((a, b) => (a.parallelGroup ?? 99) - (b.parallelGroup ?? 99))
392
- .slice(0, effectiveSlots);
413
+ // files-overlap: mutual exclusion only. An eligible job is skipped while a
414
+ // running job (or one picked earlier in this call) shares a path with it.
415
+ const inFlight = projectJobs.filter((j) => j.status === 'running' || runningSlugsInProject.has(j.slug));
416
+ const batch = [];
417
+ for (const j of pending.slice().sort((a, b) => (a.parallelGroup ?? 99) - (b.parallelGroup ?? 99))) {
418
+ if (batch.length >= effectiveSlots) break;
419
+ const other = [...inFlight, ...batch].find((o) => o.slug !== j.slug && filesOverlap(j.files, o.files));
420
+ if (other) {
421
+ holds.push({ slug: j.slug, dep: null, depStatus: null, reason: `files-overlap:${other.slug}` });
422
+ continue;
423
+ }
424
+ batch.push(j);
425
+ }
426
+ if (batch.length === 0) {
427
+ const reason = `[scheduler] files-overlap [${projectCwd}]: holding ${pending.length} eligible job(s) behind running jobs sharing files`;
428
+ console.log(reason);
429
+ return { batch: [], reason, holds };
430
+ }
393
431
  console.log(
394
432
  `[scheduler] concurrency [${projectCwd}]: firing ${batch.length} of ${pending.length} ` +
395
433
  `eligible job(s) [${batch.map((j) => j.slug).join(', ')}]`,
@@ -119,6 +119,7 @@ function watchdogRelaunchLogPath() {
119
119
  );
120
120
  }
121
121
  function historyRollupStampPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'history-rollup.stamp'), 'historyRollupStampPath'); }
122
+ function historyRollupCursorPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'history-rollup.cursor.json'), 'historyRollupCursorPath'); }
122
123
  function historyRollupLockPath() { return assertNotLiveRoot(path.join(schedulerHome(), 'history-rollup.lock'), 'historyRollupLockPath'); }
123
124
  function historyRollupPath() { return path.join(schedulerHome(), 'history-rollup.jsonl'); }
124
125
  function auditLogPath() { return path.join(schedulerHome(), 'audit-log.jsonl'); }
@@ -206,6 +207,7 @@ module.exports = {
206
207
  watchdogRelaunchStatePath,
207
208
  watchdogRelaunchLogPath,
208
209
  historyRollupStampPath,
210
+ historyRollupCursorPath,
209
211
  historyRollupLockPath,
210
212
  historyRollupPath,
211
213
  auditLogPath,
@@ -158,6 +158,8 @@ function evaluateDispatchLiveness(entry, now = Date.now(), { deadMs = DEFAULT_DI
158
158
 
159
159
  const DEFAULT_RELAUNCH_DEBOUNCE_MS = 90_000;
160
160
  const DEFAULT_MAX_RELAUNCH_ATTEMPTS = 3;
161
+ // After the fast attempts are spent, keep retrying forever on this bounded backoff.
162
+ const DEFAULT_CAPPED_RETRY_MS = 30 * 60_000;
161
163
 
162
164
  /**
163
165
  * checkAppLiveness(opts?) → { alive: boolean, reason: string }
@@ -188,12 +190,26 @@ function readRelaunchState(statePath = schedulerPaths.watchdogRelaunchStatePath(
188
190
  return {
189
191
  lastAttemptTs: typeof raw.lastAttemptTs === 'number' ? raw.lastAttemptTs : null,
190
192
  attemptCount: typeof raw.attemptCount === 'number' ? raw.attemptCount : 0,
193
+ lastBackoffLogTs: typeof raw.lastBackoffLogTs === 'number' ? raw.lastBackoffLogTs : null,
191
194
  };
192
195
  } catch {
193
- return { lastAttemptTs: null, attemptCount: 0 };
196
+ return { lastAttemptTs: null, attemptCount: 0, lastBackoffLogTs: null };
194
197
  }
195
198
  }
196
199
 
200
+ /**
201
+ * resetRelaunchState — zero the attempt counter. Called by the watchdog on every
202
+ * fresh, non-degraded heartbeat. Writes only when the state is not already zeroed.
203
+ */
204
+ function resetRelaunchState({ statePath = schedulerPaths.watchdogRelaunchStatePath() } = {}) {
205
+ const state = readRelaunchState(statePath);
206
+ if (state.attemptCount === 0 && state.lastAttemptTs === null && state.lastBackoffLogTs === null) {
207
+ return { reset: false };
208
+ }
209
+ writeRelaunchState(statePath, { lastAttemptTs: null, attemptCount: 0 });
210
+ return { reset: true };
211
+ }
212
+
197
213
  /** Atomic write via atomicFs.cjs (mirrors config.cjs writeJsonSync). */
198
214
  function writeRelaunchState(statePath, state) {
199
215
  atomicFs.writeJsonAtomicSync(statePath, state);
@@ -231,15 +247,73 @@ function relaunchSpec(opts) {
231
247
  return { command: 'npx', args: ['claude-code-session-manager@latest'], packaged: false };
232
248
  }
233
249
 
234
- function defaultSpawnRelaunch({ logPath = schedulerPaths.watchdogRelaunchLogPath(), packaged, execPath, spawnFn } = {}) {
235
- const spawn = spawnFn || require('node:child_process').spawn;
250
+ /** Resolve an executable on a PATH string (no shell). Returns null when absent. */
251
+ function findOnPath(name, envPath) {
252
+ if (typeof envPath !== 'string') return null;
253
+ for (const dir of envPath.split(':')) {
254
+ if (!dir) continue;
255
+ const candidate = path.join(dir, name);
256
+ try {
257
+ fs.accessSync(candidate, fs.constants.X_OK);
258
+ return candidate;
259
+ } catch { /* keep scanning */ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * relaunchInvocation — pure. Under systemd (INVOCATION_ID set) the relaunch must
266
+ * live in its own transient user unit, or the oneshot watchdog unit's cgroup
267
+ * teardown kills it. KillMode=process so a successor spawned by the old app's
268
+ * npx survives that npx exiting.
269
+ */
270
+ function relaunchInvocation({ spec, env, logPath, now, systemdRunPath }) {
271
+ if (typeof env?.INVOCATION_ID === 'string' && env.INVOCATION_ID !== '' && typeof systemdRunPath === 'string') {
272
+ return {
273
+ mode: 'systemd-run',
274
+ command: systemdRunPath,
275
+ args: [
276
+ '--user', '--collect', '--quiet',
277
+ `--unit=session-manager-relaunch-${now}`,
278
+ '-p', 'KillMode=process',
279
+ '-p', `StandardOutput=append:${logPath}`,
280
+ '-p', `StandardError=append:${logPath}`,
281
+ `--setenv=PATH=${env.PATH}`,
282
+ '--',
283
+ spec.command, ...spec.args,
284
+ ],
285
+ };
286
+ }
287
+ return { mode: 'detached', command: spec.command, args: spec.args };
288
+ }
289
+
290
+ function defaultSpawnRelaunch({
291
+ logPath = schedulerPaths.watchdogRelaunchLogPath(), packaged, execPath, spawnFn, spawnSyncFn,
292
+ env: envIn = process.env, now = Date.now(),
293
+ } = {}) {
294
+ const cp = require('node:child_process');
295
+ const spawn = spawnFn || cp.spawn;
296
+ const spawnSync = spawnSyncFn || cp.spawnSync;
236
297
  const spec = relaunchSpec({ packaged, execPath });
237
298
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
299
+ const env = { ...envIn };
300
+ // The watchdog may itself run as node via the app binary; the relaunched app must not.
301
+ if (spec.packaged) delete env.ELECTRON_RUN_AS_NODE;
302
+ const inv = relaunchInvocation({
303
+ spec, env, logPath, now,
304
+ systemdRunPath: env.INVOCATION_ID ? findOnPath('systemd-run', env.PATH) : null,
305
+ });
306
+ let result = { mode: 'detached' };
307
+ if (inv.mode === 'systemd-run') {
308
+ const r = spawnSync(inv.command, inv.args, { timeout: 15000, stdio: 'ignore', env });
309
+ if (r && !r.error && r.status === 0) {
310
+ return { mode: 'systemd-run', ok: true, unit: `session-manager-relaunch-${now}` };
311
+ }
312
+ logRelaunchLine(logPath, `systemd-run failed (${r?.error?.message || `status ${r?.status}`}) — falling back to detached spawn`);
313
+ result = { mode: 'detached-fallback' };
314
+ }
238
315
  const fd = fs.openSync(logPath, 'a');
239
316
  try {
240
- const env = { ...process.env };
241
- // The watchdog may itself run as node via the app binary; the relaunched app must not.
242
- if (spec.packaged) delete env.ELECTRON_RUN_AS_NODE;
243
317
  const child = spawn(spec.command, spec.args, {
244
318
  detached: true,
245
319
  stdio: ['ignore', fd, fd],
@@ -252,6 +326,7 @@ function defaultSpawnRelaunch({ logPath = schedulerPaths.watchdogRelaunchLogPath
252
326
  } finally {
253
327
  fs.closeSync(fd);
254
328
  }
329
+ return result;
255
330
  }
256
331
 
257
332
  /**
@@ -259,14 +334,14 @@ function defaultSpawnRelaunch({ logPath = schedulerPaths.watchdogRelaunchLogPath
259
334
  *
260
335
  * Debounced, capped auto-relaunch:
261
336
  * - alive (heartbeat fresh or pid alive) → no-op, reset attempt state.
262
- * - dead + attemptCount already at maxAttempts → skip, log diagnostic (give up).
337
+ * - dead + attemptCount at maxAttempts, last attempt < cappedRetryMs ago
338
+ * → skip ('backoff'), one log line per window.
339
+ * - dead + attemptCount at maxAttempts, window elapsed → spawn again (retries forever).
263
340
  * - dead + last attempt < debounceMs ago → skip (still booting).
264
341
  * - dead + debounce elapsed + under cap → spawn, bump attemptCount, log.
265
342
  *
266
- * attemptCount only resets to 0 once a fresh heartbeat is observed (i.e. a
267
- * relaunch actually succeeded), so 3 relaunch attempts that each fail to
268
- * produce a fresh heartbeat permanently cap further attempts until a human
269
- * intervenes or the app comes up some other way.
343
+ * attemptCount resets to 0 when the alive branch here sees a live app, and the
344
+ * watchdog script also calls resetRelaunchState() on every fresh heartbeat.
270
345
  */
271
346
  function maybeRelaunchApp({
272
347
  heartbeatPath = schedulerPaths.heartbeatPath(),
@@ -275,6 +350,7 @@ function maybeRelaunchApp({
275
350
  logPath = schedulerPaths.watchdogRelaunchLogPath(),
276
351
  debounceMs = DEFAULT_RELAUNCH_DEBOUNCE_MS,
277
352
  maxAttempts = DEFAULT_MAX_RELAUNCH_ATTEMPTS,
353
+ cappedRetryMs = DEFAULT_CAPPED_RETRY_MS,
278
354
  now = Date.now(),
279
355
  spawnFn = defaultSpawnRelaunch,
280
356
  restartingMarkerPath = schedulerPaths.restartingMarkerPath(),
@@ -283,7 +359,7 @@ function maybeRelaunchApp({
283
359
  const state = readRelaunchState(statePath);
284
360
 
285
361
  if (liveness.alive) {
286
- if (state.attemptCount !== 0 || state.lastAttemptTs !== null) {
362
+ if (state.attemptCount !== 0 || state.lastAttemptTs !== null || state.lastBackoffLogTs !== null) {
287
363
  writeRelaunchState(statePath, { lastAttemptTs: null, attemptCount: 0 });
288
364
  }
289
365
  return { relaunched: false, reason: 'alive', attemptCount: 0 };
@@ -298,23 +374,35 @@ function maybeRelaunchApp({
298
374
  }
299
375
 
300
376
  if (state.attemptCount >= maxAttempts) {
301
- logRelaunchLine(
302
- logPath,
303
- `giving up: ${state.attemptCount} relaunch attempt(s) already made and heartbeat is still stale — manual intervention required`,
304
- );
305
- return { relaunched: false, reason: 'capped', attemptCount: state.attemptCount };
306
- }
307
-
308
- if (state.lastAttemptTs !== null && (now - state.lastAttemptTs) < debounceMs) {
377
+ const sinceLast = state.lastAttemptTs === null ? Infinity : now - state.lastAttemptTs;
378
+ if (sinceLast < cappedRetryMs) {
379
+ const nextRetryInMs = cappedRetryMs - sinceLast;
380
+ const logged = state.lastBackoffLogTs;
381
+ if (logged === null || logged < state.lastAttemptTs || now - logged >= cappedRetryMs) {
382
+ logRelaunchLine(
383
+ logPath,
384
+ `backoff: ${state.attemptCount} relaunch attempt(s) made and heartbeat is still stale — next retry in ${Math.ceil(nextRetryInMs / 60_000)} min`,
385
+ );
386
+ writeRelaunchState(statePath, {
387
+ lastAttemptTs: state.lastAttemptTs, attemptCount: state.attemptCount, lastBackoffLogTs: now,
388
+ });
389
+ }
390
+ return { relaunched: false, reason: 'backoff', attemptCount: state.attemptCount, nextRetryInMs };
391
+ }
392
+ // Window elapsed: fall through to spawn (skips debounce, which is shorter).
393
+ } else if (state.lastAttemptTs !== null && (now - state.lastAttemptTs) < debounceMs) {
309
394
  return { relaunched: false, reason: 'debounce', attemptCount: state.attemptCount };
310
395
  }
311
396
 
312
397
  const attemptCount = state.attemptCount + 1;
313
- spawnFn({ logPath });
398
+ const spawned = spawnFn({ logPath, now });
399
+ const how = spawned && spawned.mode === 'systemd-run'
400
+ ? `via systemd-run unit ${spawned.unit || `session-manager-relaunch-${now}`}${spawned.ok === false ? ' (failed)' : ''}`
401
+ : spawned && spawned.mode === 'detached-fallback' ? 'detached (systemd-run fallback)' : 'detached';
314
402
  writeRelaunchState(statePath, { lastAttemptTs: now, attemptCount });
315
403
  logRelaunchLine(
316
404
  logPath,
317
- `relaunch attempt ${attemptCount}/${maxAttempts}: spawning "npx claude-code-session-manager@latest" (heartbeat stale, last-known pid dead)`,
405
+ `relaunch attempt ${attemptCount}${attemptCount > maxAttempts ? ' (backoff retry)' : `/${maxAttempts}`}: spawning ${how} (heartbeat stale, last-known pid dead)`,
318
406
  );
319
407
  return { relaunched: true, reason: 'launched', attemptCount };
320
408
  }
@@ -463,8 +551,11 @@ module.exports = {
463
551
  releaseLock,
464
552
  checkAppLiveness,
465
553
  readRelaunchState,
554
+ resetRelaunchState,
466
555
  writeRelaunchState,
467
556
  defaultSpawnRelaunch,
557
+ relaunchInvocation,
558
+ findOnPath,
468
559
  relaunchSpec,
469
560
  maybeRelaunchApp,
470
561
  DEFAULT_MAX_AGE_MS,
@@ -474,6 +565,7 @@ module.exports = {
474
565
  DEFAULT_FINALIZE_BUDGET_MS,
475
566
  DEFAULT_RELAUNCH_DEBOUNCE_MS,
476
567
  DEFAULT_MAX_RELAUNCH_ATTEMPTS,
568
+ DEFAULT_CAPPED_RETRY_MS,
477
569
  };
478
570
 
479
571
  // Lazy default-path getters (SM_SCHEDULER_HOME resolved at read, never at require).
@@ -0,0 +1,69 @@
1
+ /**
2
+ * mainHeapSnapshot.cjs — bounded, automatic V8 heap snapshot of the MAIN process.
3
+ * Captures at most once per process when heapUsed crosses a threshold, keeps at
4
+ * most 2 `main-heap-*.heapsnapshot` files, and refuses when disk is tight.
5
+ * Synchronous (v8.writeHeapSnapshot) — acceptable once per boot for diagnosis.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ const DEFAULT_THRESHOLD_MB = 1800;
14
+ const KEEP = 2;
15
+ const PREFIX = 'main-heap-';
16
+ const SUFFIX = '.heapsnapshot';
17
+
18
+ const defaultState = { captured: false };
19
+
20
+ function resolveThreshold(thresholdMb) {
21
+ if (Number.isFinite(thresholdMb) && thresholdMb > 0) return thresholdMb;
22
+ const env = Number(process.env.SM_MAIN_HEAP_SNAPSHOT_MB);
23
+ return Number.isFinite(env) && env > 0 ? env : DEFAULT_THRESHOLD_MB;
24
+ }
25
+
26
+ function pruneOld(dir, keep) {
27
+ let names = [];
28
+ try { names = fs.readdirSync(dir).filter((n) => n.startsWith(PREFIX) && n.endsWith(SUFFIX)); } catch { return; }
29
+ // ISO timestamps sort lexically; oldest first.
30
+ names.sort();
31
+ for (const n of names.slice(0, Math.max(0, names.length - keep))) {
32
+ try { fs.unlinkSync(path.join(dir, n)); } catch { /* best-effort */ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {{ heapUsedMb:number, thresholdMb?:number, dir?:string, now?:Date,
38
+ * writeFn?:(p:string)=>any, statfsFn?:Function, state?:{captured:boolean} }} opts
39
+ * @returns {{ captured:boolean, filePath?:string, reason?:string }}
40
+ */
41
+ function maybeCaptureMainHeap(opts = {}) {
42
+ const state = opts.state || defaultState;
43
+ if (process.env.SM_MAIN_HEAP_SNAPSHOT === '0') return { captured: false, reason: 'disabled' };
44
+ if (state.captured) return { captured: false, reason: 'already-captured' };
45
+ const heapUsedMb = Number(opts.heapUsedMb);
46
+ if (!Number.isFinite(heapUsedMb) || heapUsedMb < resolveThreshold(opts.thresholdMb)) {
47
+ return { captured: false, reason: 'below-threshold' };
48
+ }
49
+ const dir = opts.dir || require('../scheduler/schedulerPaths.cjs').heapSnapshotDir();
50
+ const writeFn = opts.writeFn || ((p) => require('node:v8').writeHeapSnapshot(p));
51
+ const statfsFn = opts.statfsFn || fs.statfsSync;
52
+ const now = opts.now instanceof Date ? opts.now : new Date();
53
+ try {
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const st = statfsFn(dir);
56
+ const freeMb = (Number(st.bavail) * Number(st.bsize)) / 1048576;
57
+ if (!(freeMb >= 3 * heapUsedMb)) return { captured: false, reason: 'low-disk' };
58
+ // Leave room for the new file: keep KEEP-1 old ones, so KEEP remain after the write.
59
+ pruneOld(dir, KEEP - 1);
60
+ state.captured = true;
61
+ const filePath = path.join(dir, `${PREFIX}${now.toISOString().replace(/[:.]/g, '-')}${SUFFIX}`);
62
+ writeFn(filePath);
63
+ return { captured: true, filePath };
64
+ } catch (err) {
65
+ return { captured: false, reason: `error: ${err && err.message}` };
66
+ }
67
+ }
68
+
69
+ module.exports = { maybeCaptureMainHeap, DEFAULT_THRESHOLD_MB };
@@ -24,9 +24,31 @@ function resolveDeps(deps = {}) {
24
24
  telemetryClient: deps.telemetryClient || require('./telemetryClient.cjs'),
25
25
  buildMachineProfile: deps.buildMachineProfile || require('../machineProfile.cjs').buildMachineProfile,
26
26
  telemetryCounters: deps.telemetryCounters || require('./telemetryCounters.cjs'),
27
+ getSeedStatus: deps.getSeedStatus || (() => require('../../seedStatus.cjs').getSeedStatus()),
27
28
  };
28
29
  }
29
30
 
31
+ /**
32
+ * Maps getSeedStatus() to enum-only strings (never the fix text or a path).
33
+ * Any failure or malformed shape yields null — never a throw.
34
+ */
35
+ function buildSeeds(getSeedStatus) {
36
+ try {
37
+ const s = getSeedStatus();
38
+ const pick = (id) => {
39
+ const v = s && s[id] && s[id].status;
40
+ return typeof v === 'string' ? v : null;
41
+ };
42
+ return {
43
+ devPlugin: pick('dev-plugin'),
44
+ schedulerMcp: pick('scheduler-mcp'),
45
+ agentPersonas: pick('agent-personas'),
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
30
52
  /**
31
53
  * bootSequence({ now, appVersion, installChannel, deps }) — call once per
32
54
  * process from app.whenReady.
@@ -52,7 +74,7 @@ function resolveDeps(deps = {}) {
52
74
  */
53
75
  async function bootSequence({ now = Date.now(), appVersion, installChannel, deps: depsOverride } = {}) {
54
76
  const deps = resolveDeps(depsOverride);
55
- const { telemetrySettings, telemetryClient, buildMachineProfile, telemetryCounters } = deps;
77
+ const { telemetrySettings, telemetryClient, buildMachineProfile, telemetryCounters, getSeedStatus } = deps;
56
78
 
57
79
  const settings = await telemetrySettings.load();
58
80
  const versionChanged = settings.lastMachineReportVersion !== appVersion;
@@ -63,7 +85,7 @@ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps
63
85
  }
64
86
 
65
87
  if (telemetrySettings.isMachineReportDue(settings, { now, appVersion })) {
66
- const profile = await buildMachineProfile();
88
+ const profile = { ...(await buildMachineProfile()), seeds: buildSeeds(getSeedStatus) };
67
89
  const reportResult = await telemetryClient.reportInstall(profile);
68
90
  if (reportResult && reportResult.accepted) {
69
91
  await telemetrySettings.save({
@@ -76,4 +98,4 @@ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps
76
98
  telemetryCounters.trackAppLaunch({ installChannel, appVersion });
77
99
  }
78
100
 
79
- module.exports = { bootSequence };
101
+ module.exports = { bootSequence, buildSeeds };
@@ -580,9 +580,21 @@ function buildInstallWire(profile, installId) {
580
580
  // not the module-singleton one) plus the current process's test-runner
581
581
  // signal, same three-value contract as the other three channels.
582
582
  env: machineProfile.resolveEnv({ isTestRunner: isTestEnvironment(), installChannel: p.installChannel }),
583
+ // First-boot seeder outcomes (enum strings only) or null — see telemetryBoot.buildSeeds.
584
+ seeds: sanitizeSeeds(p.seeds),
583
585
  };
584
586
  }
585
587
 
588
+ const SEED_ENUM = new Set(['done', 'pending', 'failed', 'exhausted']);
589
+ function sanitizeSeeds(seeds) {
590
+ if (!seeds || typeof seeds !== 'object') return null;
591
+ const out = {};
592
+ for (const k of ['devPlugin', 'schedulerMcp', 'agentPersonas']) {
593
+ out[k] = SEED_ENUM.has(seeds[k]) ? seeds[k] : null;
594
+ }
595
+ return out;
596
+ }
597
+
586
598
  /**
587
599
  * Reports the once-per-cadence install/liveness record. Unlike track/logLine/
588
600
  * reportError this isn't arbitrary user content, so it skips redactDeep() —
@@ -29,8 +29,30 @@ function trackSessionOpen(deps) {
29
29
  track('session.open', {}, deps);
30
30
  }
31
31
 
32
- function trackEpicCreate(deps) {
33
- track('epic.create', {}, deps);
32
+ // Enum mappers — user-authored values never leave the machine; only the closed
33
+ // shipped sets do. Sets are required lazily so this module stays light.
34
+ function mapTag(tag) {
35
+ if (typeof tag !== 'string' || !tag) return null;
36
+ try {
37
+ const { EpicTagSchema } = require('../promptSessionSchema.cjs');
38
+ return EpicTagSchema.options.includes(tag) ? tag : 'other';
39
+ } catch { return 'other'; }
40
+ }
41
+
42
+ function mapAgentType(agentType) {
43
+ if (typeof agentType !== 'string' || !agentType) return null;
44
+ try {
45
+ const { PERSONAS } = require('../../seedAgentPersonas.cjs');
46
+ return PERSONAS.includes(agentType) ? agentType : 'custom';
47
+ } catch { return 'custom'; }
48
+ }
49
+
50
+ function trackEpicCreate({ tag, agentType } = {}, deps) {
51
+ track('epic.create', { tag: mapTag(tag), agentType: mapAgentType(agentType) }, deps);
52
+ }
53
+
54
+ function trackPrdCreate({ agentType } = {}, deps) {
55
+ track('prd.create', { agentType: mapAgentType(agentType) }, deps);
34
56
  }
35
57
 
36
58
  function trackSchedulerJobFinish({ status } = {}, deps) {
@@ -41,5 +63,6 @@ module.exports = {
41
63
  trackAppLaunch,
42
64
  trackSessionOpen,
43
65
  trackEpicCreate,
66
+ trackPrdCreate,
44
67
  trackSchedulerJobFinish,
45
68
  };