claude-code-session-manager 0.83.0 → 0.85.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 (92) hide show
  1. package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
  2. package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
  3. package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
  4. package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
  5. package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
  6. package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
  7. package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
  9. package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
  10. package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
  11. package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
  12. package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
  13. package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
  15. package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
  16. package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
  18. package/dist/assets/Settings-BX3FElXk.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
  20. package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
  21. package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
  22. package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
  23. package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
  24. package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
  25. package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
  26. package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
  27. package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +242 -1
  51. package/src/main/__tests__/telemetryContract.test.cjs +930 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +241 -22
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +76 -0
  83. package/src/main/lib/telemetryClient.cjs +150 -5
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-DXVgKLUx.js +0 -3
  92. package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
@@ -26,6 +26,7 @@
26
26
  const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron');
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
+ const { reportCrash } = require('./lib/crashTelemetry.cjs');
29
30
 
30
31
  let logs = null;
31
32
  let getPowerBlockerId = () => -1;
@@ -139,6 +140,7 @@ function checkPreviousRun() {
139
140
  try { prev = JSON.parse(fs.readFileSync(sentinelPath, 'utf8')); } catch { return; }
140
141
  if (!prev || prev.open !== true) return;
141
142
  const s = prev.lastSample;
143
+ const lastTop = s && Array.isArray(s.procs) ? [...s.procs].sort((a, b) => b.mb - a.mb).slice(0, 4).map((p) => `${p.type}:${p.mb}MB`) : undefined;
142
144
  logs?.writeLine({
143
145
  scope: 'crash-diag',
144
146
  level: 'error',
@@ -149,11 +151,24 @@ function checkPreviousRun() {
149
151
  lastSampleTs: s?.ts,
150
152
  lastTotalMb: s?.totalMb,
151
153
  lastMainRssMb: s?.main?.rssMb,
152
- lastTop: s && Array.isArray(s.procs) ? [...s.procs].sort((a, b) => b.mb - a.mb).slice(0, 4).map((p) => `${p.type}:${p.mb}MB`) : undefined,
154
+ lastTop,
153
155
  powerBlockerHeldAtDeath: s?.powerBlockerHeld,
154
156
  hint: 'Check `journalctl -k | grep -i oom` around lastSampleTs to confirm the kernel OOM-killer.',
155
157
  },
156
158
  });
159
+ // The unclean-shutdown postmortem is the report most likely to be the ONLY
160
+ // thing ever received from a machine that just OOM-killed the app — the
161
+ // dying process couldn't write it itself. See crashTelemetry.cjs header.
162
+ reportCrash({
163
+ reason: 'unclean-shutdown',
164
+ meta: {
165
+ lastSampleTs: s?.ts,
166
+ lastTotalMb: s?.totalMb,
167
+ lastMainRssMb: s?.main?.rssMb,
168
+ lastTop,
169
+ powerBlockerHeldAtDeath: s?.powerBlockerHeld,
170
+ },
171
+ });
157
172
  }
158
173
 
159
174
  function registerCrashHooks() {
@@ -167,6 +182,10 @@ function registerCrashHooks() {
167
182
  message: 'render-process-gone',
168
183
  meta: { reason: details?.reason, exitCode: details?.exitCode, lastTotalMb: lastSample?.totalMb },
169
184
  });
185
+ reportCrash({
186
+ reason: details?.reason || 'unknown',
187
+ meta: { type: 'render', exitCode: details?.exitCode, lastTotalMb: lastSample?.totalMb },
188
+ });
170
189
  });
171
190
 
172
191
  // GPU / Utility / Pepper / network-service child death.
@@ -183,6 +202,15 @@ function registerCrashHooks() {
183
202
  lastTotalMb: lastSample?.totalMb,
184
203
  },
185
204
  });
205
+ reportCrash({
206
+ reason: details?.reason || 'unknown',
207
+ meta: {
208
+ type: details?.type,
209
+ name: details?.name || details?.serviceName,
210
+ exitCode: details?.exitCode,
211
+ lastTotalMb: lastSample?.totalMb,
212
+ },
213
+ });
186
214
  });
187
215
 
188
216
  // powerMonitor ties the suspend story to the crash timeline. If we ever log
@@ -16,7 +16,7 @@ const { checkDelegationReadiness } = require('./lib/delegationReadiness.cjs');
16
16
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
17
17
  const { migratePrds } = require('./lib/prdMigration.cjs');
18
18
  const queueStore = require('./lib/queueStore.cjs');
19
- const { computeStallSummary } = require('./scheduler.cjs');
19
+ const { computeStallSummary, SCHEDULER_STATE_PATH, FAILURE_STREAK_WARN_THRESHOLD, classifyQueueStarvation } = require('./scheduler.cjs');
20
20
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
21
21
  const { allProjectCwds } = require('../../scripts/lib/activeSessions.cjs');
22
22
 
@@ -154,6 +154,37 @@ function computeProjectProblemCounts(jobs) {
154
154
  return byProject;
155
155
  }
156
156
 
157
+ // evaluateWorktreeCapBlocked(jobs, runningCount) → { ok, blocked, capBlockedSlugs? }
158
+ //
159
+ // The worktree cap (gitWorktree.cjs) can genuinely, correctly block every
160
+ // pending job at once — a real out-of-capacity condition, not a bug — but
161
+ // before this check existed that state was invisible to `npm run health`:
162
+ // nothing but a console.log line and a `heldReason` field on the row (see
163
+ // scheduler.cjs's spawnJob preflight). Rows in four separate project queues
164
+ // carried heldReason 'worktree cap reached (5 concurrent)' for 16 hours with
165
+ // zero entries in session-manager-operations/logs/ and nothing non-GREEN
166
+ // here. `runningCount === 0` is the load-bearing condition: while at least
167
+ // one job is running, the cap is doing its job as designed (bounding
168
+ // concurrency), not starving the queue.
169
+ function evaluateWorktreeCapBlocked(jobs, runningCount) {
170
+ // queueStore.readMergedSync() hands back jobs as an object map (merged
171
+ // across projects); other callers (tests, scheduler.cjs's own in-memory
172
+ // state) pass a plain array — accept either rather than forcing every
173
+ // caller to know this module's federated-storage detail.
174
+ const jobList = Array.isArray(jobs) ? jobs : Object.values(jobs || {});
175
+ const capBlocked = jobList.filter(
176
+ (j) => j.status === 'pending' && /^worktree cap reached\b/.test(j.heldReason || '')
177
+ );
178
+ if ((runningCount ?? 0) === 0 && capBlocked.length > 0) {
179
+ return {
180
+ ok: false,
181
+ blocked: true,
182
+ capBlockedSlugs: capBlocked.map((j) => j.slug),
183
+ };
184
+ }
185
+ return { ok: true, blocked: false };
186
+ }
187
+
157
188
  // evaluatePerProjectStall(stallSummary, lastRunAtIso, now, thresholdMs) →
158
189
  // { [cwd]: { stalled, pastThreshold?, ageMs?, caveat? } }
159
190
  //
@@ -335,6 +366,113 @@ function evaluateEpicIndexHealth(cwds) {
335
366
  return { component: { ok: !anyOrphanRows, byProject }, issues };
336
367
  }
337
368
 
369
+ // Pure evaluator over ~/.claude/session-manager/scheduler-state.json's parsed
370
+ // content (the sidecar scheduler.cjs's pollLoop maintains for the billing
371
+ // usage-meter / rate-limit-window poller — see rateLimitPollerStreak tests).
372
+ // `state` null/missing means the poller has never run yet (fresh install) —
373
+ // not a failure, matches evaluatePrdMigrationHealth's fail-open-on-absence
374
+ // spirit. Kept separate from the fs-touching check() call site, same pattern
375
+ // as every other evaluate* helper in this file.
376
+ function evaluateUsagePollerHealth(state, threshold = FAILURE_STREAK_WARN_THRESHOLD) {
377
+ if (!state || typeof state !== 'object') return { ok: true, applicable: false };
378
+ const consecutiveFailures = typeof state.consecutiveFailures === 'number' ? state.consecutiveFailures : 0;
379
+ const backoffMs = typeof state.backoffMs === 'number' ? state.backoffMs : null;
380
+ const lastPollAt = typeof state.lastPollAt === 'number' ? state.lastPollAt : null;
381
+ // Trips at the SAME count as scheduler.cjs's shouldWarnFailureStreak
382
+ // (`consecutiveFailures >= threshold`) — the WARN and this non-GREEN must
383
+ // fire together, not one failure apart, or an operator sees the log line
384
+ // while the health dashboard still reads green.
385
+ const ok = consecutiveFailures < threshold;
386
+ return {
387
+ ok,
388
+ applicable: true,
389
+ consecutiveFailures,
390
+ backoffMs,
391
+ lastPollAt,
392
+ threshold,
393
+ ...(ok ? {} : {
394
+ message: `usage/rate-limit poller has ${consecutiveFailures} consecutive failures `
395
+ + `(threshold ${threshold}), backoffMs=${backoffMs} — see logs scope=scheduler`,
396
+ }),
397
+ };
398
+ }
399
+
400
+ // Reads + parses a scheduler-state.json-shaped sidecar at `statePath`,
401
+ // classifying the outcome into exactly one of three shapes so the caller
402
+ // never has to distinguish "absent" from "corrupt" itself:
403
+ // { missing: true } — ENOENT: never run yet, not a failure
404
+ // { errorMessage: string } — unreadable OR unparseable (corrupt)
405
+ // { state: object } — parsed successfully
406
+ // Takes an explicit path (rather than reading SCHEDULER_STATE_PATH directly)
407
+ // so it's unit-testable against a real temp file instead of mocking global
408
+ // fs or exercising the full (slow, machine-coupled) check().
409
+ // A DISPATCHABLE pending job (not merely pending) with 0 running and no
410
+ // dispatch attempt in this long is the exact shape of the 2026-09-11
411
+ // incident (27 pending across two projects, 0 running, for days, every
412
+ // other surface reporting healthy). Deliberately much longer than
413
+ // scheduler.cjs's own QUEUE_STARVATION_MS (10 min) — that's the watchdog's
414
+ // OWN forcing threshold; this is the "even the watchdog isn't helping
415
+ // anymore" signal a human needs to see.
416
+ const DISPATCH_STALL_THRESHOLD_MS = 2 * 60 * 60_000;
417
+
418
+ /**
419
+ * evaluateQueueDispatchHealth(queueState, runningCount, now, thresholdMs) →
420
+ * { ok, blocked?, starved?, pending?, dispatchable?, idleMs?, message? }
421
+ *
422
+ * Reuses classifyQueueStarvation's own verdict rather than re-deriving a
423
+ * second "is the queue stuck" heuristic — evaluateTickLiveness above already
424
+ * has one, but it does not distinguish a genuinely blocked dependsOn chain
425
+ * (which no amount of ticking can fix) from real dispatchable starvation, so
426
+ * reusing it here would report a dependency-blocked project as unhealthy
427
+ * dispatch even though nothing is actually wrong with dispatch itself. A
428
+ * `blocked` verdict therefore stays `ok: true` (never trips the health gate)
429
+ * but is still reported by name so it isn't silently invisible either.
430
+ */
431
+ function evaluateQueueDispatchHealth(queueState, runningCount, now, thresholdMs = DISPATCH_STALL_THRESHOLD_MS) {
432
+ const verdict = classifyQueueStarvation({
433
+ jobs: queueState?.jobs,
434
+ paused: queueState?.paused,
435
+ runningCount,
436
+ lastRunAtMs: Date.parse(queueState?.lastDispatchAttemptAt ?? ''),
437
+ now,
438
+ thresholdMs,
439
+ });
440
+ if (!verdict) return { ok: true };
441
+ if (verdict.kind === 'blocked') {
442
+ return {
443
+ ok: true,
444
+ blocked: true,
445
+ pending: verdict.pending,
446
+ message: `${verdict.pending} pending job(s) behind a blocked dependsOn chain — dispatch cannot help, needs a human`,
447
+ };
448
+ }
449
+ const ageMin = Math.round(verdict.idleMs / 60_000);
450
+ return {
451
+ ok: false,
452
+ starved: true,
453
+ pending: verdict.pending,
454
+ dispatchable: verdict.dispatchable,
455
+ idleMs: verdict.idleMs,
456
+ message: `${verdict.dispatchable} dispatchable pending job(s), 0 running, no dispatch attempt in ~${ageMin}m `
457
+ + `(threshold ${Math.round(thresholdMs / 60_000)}m) — dispatch appears stuck`,
458
+ };
459
+ }
460
+
461
+ function loadUsagePollerState(statePath) {
462
+ let raw;
463
+ try {
464
+ raw = fs.readFileSync(statePath, 'utf8');
465
+ } catch (e) {
466
+ if (e.code === 'ENOENT') return { missing: true };
467
+ return { errorMessage: `cannot read scheduler-state.json: ${e.message}` };
468
+ }
469
+ try {
470
+ return { state: JSON.parse(raw) };
471
+ } catch (parseErr) {
472
+ return { errorMessage: `scheduler-state.json is corrupt: ${parseErr.message}` };
473
+ }
474
+ }
475
+
338
476
  async function check() {
339
477
  const start = Date.now();
340
478
  const status = {
@@ -407,6 +545,16 @@ async function check() {
407
545
  try {
408
546
  queueState = queueStore.readMergedSync();
409
547
  if (queueState.unreadable) throw new Error(queueState.unreadable);
548
+ // A torn scheduler-machine.json is now recovered rather than halting
549
+ // dispatch (queueStore.cjs's findLongestValidJsonPrefix) — but recovery
550
+ // happening at all means the file failed to parse moments ago, which is
551
+ // exactly the "machine-runtime file fails to parse" signal health must
552
+ // surface as non-GREEN even though scheduling itself kept running.
553
+ if (queueState.machineStateRecovered) {
554
+ status.issues.push(
555
+ `scheduler-machine.json failed to parse and was recovered (mode=${queueState.machineStateRecoveryMode}) — see logs scope=scheduler`
556
+ );
557
+ }
410
558
  const runningCount = Object.values(queueState.jobs || {}).filter(
411
559
  (j) => j.status === 'running'
412
560
  ).length;
@@ -440,7 +588,7 @@ async function check() {
440
588
  .map(([cwd]) => cwd);
441
589
 
442
590
  status.components.scheduler_queue = {
443
- ok: !liveness.stalled && projectsPastThreshold.length === 0,
591
+ ok: !liveness.stalled && projectsPastThreshold.length === 0 && !queueState.machineStateRecovered,
444
592
  path: queuePath,
445
593
  jobs: Object.keys(queueState.jobs || {}).length,
446
594
  running: runningCount,
@@ -450,6 +598,8 @@ async function check() {
450
598
  byProject: computeProjectProblemCounts(queueState.jobs),
451
599
  perProjectStall,
452
600
  tickLiveness: liveness.reason,
601
+ machineStateRecovered: queueState.machineStateRecovered ?? false,
602
+ machineStateRecoveryMode: queueState.machineStateRecoveryMode ?? null,
453
603
  // Informational only (PRD 1085): current 1-min loadavg per core vs the
454
604
  // launch-gate threshold, so a "nothing is launching" report can be
455
605
  // read next to the reason without opening the app.
@@ -485,6 +635,25 @@ async function check() {
485
635
  );
486
636
  }
487
637
  }
638
+ status.components.queue_dispatch = evaluateQueueDispatchHealth(queueState, runningCount, now);
639
+ if (!status.components.queue_dispatch.ok || status.components.queue_dispatch.blocked) {
640
+ status.issues.push(`Queue dispatch: ${status.components.queue_dispatch.message}`);
641
+ }
642
+
643
+ // Worktree cap blocking every dispatchable pending job with nothing
644
+ // running (see evaluateWorktreeCapBlocked's header) — a distinct
645
+ // condition from generic tick/dispatch staleness above, since a leaked
646
+ // cap count can hold a row `pending` with a fresh heldReason on every
647
+ // single tick (tickQueue keeps reaching spawnJob, per PRD 1141/1144's
648
+ // live evidence), never tripping evaluateTickLiveness's staleness check.
649
+ const worktreeCapBlocked = evaluateWorktreeCapBlocked(queueState.jobs, runningCount);
650
+ status.components.scheduler_queue.worktreeCapBlocked = worktreeCapBlocked;
651
+ if (!worktreeCapBlocked.ok) {
652
+ status.components.scheduler_queue.ok = false;
653
+ status.issues.push(
654
+ `Worktree cap is blocking dispatch with 0 jobs running: ${worktreeCapBlocked.capBlockedSlugs.join(', ')}`
655
+ );
656
+ }
488
657
  } catch (e) {
489
658
  if (e.code !== 'ENOENT') {
490
659
  status.issues.push(`Scheduler queue unreadable: ${e.message}`);
@@ -495,6 +664,26 @@ async function check() {
495
664
  exists: false,
496
665
  error: e.code === 'ENOENT' ? 'not yet created' : e.message,
497
666
  };
667
+ status.components.queue_dispatch = { ok: true };
668
+ }
669
+
670
+ // 3.5. Usage/rate-limit poller (scheduler.cjs pollLoop) — a distinct
671
+ // subsystem from scheduler_queue above: it can be alive (lastPollAt keeps
672
+ // advancing) while failing every single poll, silently, with nothing else
673
+ // surfacing it (the 57-consecutive-failure incident this check exists to
674
+ // catch). Missing state file means the poller hasn't run yet — not a
675
+ // failure. A CORRUPT file (the torn-write class fixed for the sibling
676
+ // scheduler-machine.json in f56bdc0) is NOT the same as missing — surfaced
677
+ // as non-GREEN rather than fail-open, via loadUsagePollerState below.
678
+ const loaded = loadUsagePollerState(SCHEDULER_STATE_PATH);
679
+ if (loaded.missing) {
680
+ status.components.usage_poller = { ok: true, applicable: false };
681
+ } else if (loaded.errorMessage) {
682
+ status.components.usage_poller = { ok: false, applicable: true, error: loaded.errorMessage };
683
+ status.issues.push(`Usage/rate-limit poller: ${loaded.errorMessage}`);
684
+ } else {
685
+ status.components.usage_poller = evaluateUsagePollerHealth(loaded.state);
686
+ if (status.components.usage_poller.message) status.issues.push(status.components.usage_poller.message);
498
687
  }
499
688
 
500
689
  // 4. Check PRDs directories — one per active project
@@ -688,7 +877,7 @@ async function check() {
688
877
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
689
878
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
690
879
  // Informational: app log age (shows if app is running, but not blocking).
691
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
880
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'queue_dispatch', 'usage_poller', 'prd_migration', 'claude_md_budget', 'delegation_chain'];
692
881
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
693
882
 
694
883
  status.elapsedMs = Date.now() - start;
@@ -713,9 +902,14 @@ module.exports = {
713
902
  computeEpicIndexDrift,
714
903
  evaluateEpicIndexHealth,
715
904
  computeProjectProblemCounts,
905
+ evaluateWorktreeCapBlocked,
716
906
  evaluatePerProjectStall,
717
907
  parseClaudeMdBudget,
718
908
  evaluateClaudeMdBudget,
909
+ evaluateUsagePollerHealth,
910
+ loadUsagePollerState,
911
+ evaluateQueueDispatchHealth,
912
+ DISPATCH_STALL_THRESHOLD_MS,
719
913
  TICK_STALL_THRESHOLD_MS,
720
914
  HEARTBEAT_STALE_MS,
721
915
  };
@@ -37,7 +37,7 @@ const epicWorktreeMint = require('./lib/epicWorktreeMint.cjs');
37
37
  const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
38
38
  const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
39
39
  const agentLibrary = require('./agentLibrary.cjs');
40
- const { checkDelegationReadiness, installPrdWriteGuard, installDestructiveGitGuard } = require('./lib/delegationReadiness.cjs');
40
+ const { checkDelegationReadiness, installPrdWriteGuard, installDestructiveGitGuard, installInlineImplementationGuard } = require('./lib/delegationReadiness.cjs');
41
41
  const { MCP_TOOL_CATALOG, MCP_RECIPES } = require('./lib/mcpToolCatalog.cjs');
42
42
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
43
43
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
@@ -63,6 +63,10 @@ const { seedSchedulerMcp } = require('./seedSchedulerMcp.cjs');
63
63
  const { getSeedStatus } = require('./seedStatus.cjs');
64
64
  const otel = require('./otel.cjs');
65
65
  const otelSettings = require('./otelSettings.cjs');
66
+ const telemetrySettings = require('./lib/telemetrySettings.cjs');
67
+ const telemetryClient = require('./lib/telemetryClient.cjs');
68
+ const telemetryBacklog = require('./lib/telemetryBacklog.cjs');
69
+ const telemetryConsent = require('./lib/telemetryConsent.cjs');
66
70
  const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayToday } = require('./historyAggregator.cjs');
67
71
  const runLogRetention = require('./lib/runLogRetention.cjs');
68
72
  const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
@@ -391,6 +395,16 @@ function createWindow() {
391
395
  if (firstPaintTimer) { clearTimeout(firstPaintTimer); firstPaintTimer = null; }
392
396
  mainWindow.maximize();
393
397
  mainWindow.show();
398
+
399
+ // Backlog drain: ships the pre-existing errors-<date>.jsonl backlog every
400
+ // known project has already accumulated, marking exactly what's
401
+ // CONFIRMED delivered. Fired after the window is shown (never blocks
402
+ // startup) and yields between files internally. See telemetryBacklog.cjs.
403
+ require('./lib/telemetryBacklog.cjs')
404
+ .bootDrain({ appVersion: app.getVersion() })
405
+ .catch((e) => {
406
+ logs.writeLine({ scope: 'telemetry', level: 'warn', message: 'telemetryBacklog.bootDrain failed', meta: { error: e?.message } });
407
+ });
394
408
  });
395
409
 
396
410
  // Native right-click menu — Copy / Paste / Select All everywhere. Roles
@@ -513,17 +527,23 @@ ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadiness
513
527
  checkDelegationReadiness(payload)
514
528
  ));
515
529
 
516
- // The two readiness checks with a sanctioned one-press install. Session Manager
517
- // standardizes on the REFERENCE approach (absolute path to this repo's guard
518
- // script, never a vendored copy) — see installPrdWriteGuard's header. They stay
519
- // two handlers (and two PreToolUse entries) because the hooks have different
520
- // matchers: Write|Edit|NotebookEdit vs Bash — installDestructiveGitGuard's header.
530
+ // The three readiness checks with a sanctioned one-press install. Session
531
+ // Manager standardizes on the REFERENCE approach (absolute path to this
532
+ // repo's guard script, never a vendored copy) — see installPrdWriteGuard's
533
+ // header. prd-write-guard and destructive-git-guard stay separate handlers
534
+ // (and separate PreToolUse entries) because those two hooks have different
535
+ // matchers: Write|Edit|NotebookEdit vs Bash — installDestructiveGitGuard's
536
+ // header. inline-implementation-guard shares prd-write-guard's matcher, so
537
+ // its installer appends into that SAME matcher's `hooks` array instead.
521
538
  ipcMain.handle('app:install-prd-write-guard', validated(schemas.delegationReadinessCwd, (payload) =>
522
539
  installPrdWriteGuard(payload)
523
540
  ));
524
541
  ipcMain.handle('app:install-destructive-git-guard', validated(schemas.delegationReadinessCwd, (payload) =>
525
542
  installDestructiveGitGuard(payload)
526
543
  ));
544
+ ipcMain.handle('app:install-inline-implementation-guard', validated(schemas.delegationReadinessCwd, (payload) =>
545
+ installInlineImplementationGuard(payload)
546
+ ));
527
547
 
528
548
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
529
549
 
@@ -896,6 +916,23 @@ ipcMain.handle('otel:set-config', async (_e, cfg) => {
896
916
  ipcMain.handle('otel:status', () => otel.status());
897
917
  ipcMain.handle('otel:config-path', () => otelSettings.storePath());
898
918
 
919
+ // Product telemetry (bilko.run) — anonymous, on-by-default, opt-out consent
920
+ // UX + inspector over the client PRDs above wired. Settings > Telemetry's
921
+ // "Product telemetry" section is the sole UI surface for this; see
922
+ // session-manager-operations/architecture/telemetry.md for the data model.
923
+ ipcMain.handle('telemetry:get-config', async () => telemetrySettings.load());
924
+ ipcMain.handle('telemetry:set-config', validated(schemas.telemetrySetConfig, async (cfg) => {
925
+ const saved = await telemetryConsent.applyConsentUpdate(cfg, { telemetrySettings, telemetryClient });
926
+ return { ok: true, config: saved, status: telemetryClient.status() };
927
+ }));
928
+ ipcMain.handle('telemetry:status', () => ({
929
+ ...telemetryClient.status(),
930
+ backlog: telemetryBacklog.lastRunSummary(),
931
+ }));
932
+ ipcMain.handle('telemetry:config-path', () => telemetrySettings.storePath());
933
+ ipcMain.handle('telemetry:recent-records', () => telemetryClient.recentRecords());
934
+ ipcMain.handle('telemetry:flush-now', () => telemetryClient.flush('manual'));
935
+
899
936
  // Diagnostic-only renderer heap snapshot. No-op unless SM_HEAP_SNAPSHOT=1 —
900
937
  // see heapSnapshot.cjs for why this stays off by default.
901
938
  heapSnapshot.registerIpc({ ipcMain, getWindow: () => mainWindow });
@@ -1019,13 +1056,31 @@ app.whenReady().then(async () => {
1019
1056
  }
1020
1057
 
1021
1058
  process.on('uncaughtException', (err) => {
1059
+ try {
1060
+ require('./lib/telemetryClient.cjs').reportError({ name: 'uncaughtException', msg: err?.message, stack: err?.stack });
1061
+ } catch { /* telemetry must never break the existing handling below */ }
1022
1062
  logs.writeLine({ scope: 'main', level: 'error', message: 'uncaughtException', meta: { error: err?.message, stack: err?.stack } });
1023
1063
  });
1024
1064
  process.on('unhandledRejection', (reason) => {
1025
1065
  const r = reason instanceof Error ? { error: reason.message, stack: reason.stack } : { reason: String(reason) };
1066
+ try {
1067
+ require('./lib/telemetryClient.cjs').reportError({ name: 'unhandledRejection', msg: r.error || r.reason, stack: r.stack });
1068
+ } catch { /* telemetry must never break the existing handling below */ }
1026
1069
  logs.writeLine({ scope: 'main', level: 'error', message: 'unhandledRejection', meta: r });
1027
1070
  });
1028
1071
 
1072
+ // Telemetry boot sequence: flush('boot') always, flush('version-change')
1073
+ // when detected, and the once-per-machine install upsert (app_installs) when
1074
+ // due (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
1075
+ require('./lib/telemetryBoot.cjs')
1076
+ .bootSequence({
1077
+ appVersion: app.getVersion(),
1078
+ installChannel: require('./lib/machineProfile.cjs').resolveInstallChannel({ appPath: app.getAppPath() }),
1079
+ })
1080
+ .catch((e) => {
1081
+ logs.writeLine({ scope: 'telemetry', level: 'warn', message: 'bootSequence failed', meta: { error: e?.message } });
1082
+ });
1083
+
1029
1084
  // Inject Content-Security-Policy for all renderer responses.
1030
1085
  // frame-src / frame-ancestors locked to 'none' — the app is a top-level
1031
1086
  // Electron BrowserWindow; iframes/embedding have no legitimate use.
@@ -1376,4 +1431,8 @@ app.on('before-quit', () => {
1376
1431
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1377
1432
  // ceiling so a wedged exporter can't hold quit.
1378
1433
  otel.shutdown().catch(() => {});
1434
+ // Best-effort delivery attempt for whatever telemetry accumulated this run.
1435
+ // flush() only removes records it actually delivered — an interrupted or
1436
+ // failed attempt leaves the queue file intact for the next boot's flush.
1437
+ require('./lib/telemetryClient.cjs').flush('quit').catch(() => {});
1379
1438
  });
@@ -610,8 +610,9 @@ const projectBriefCwd = z.object({
610
610
  }).strict();
611
611
 
612
612
  // ──────────────────────────────────────────── Delegation readiness
613
- // Shared by app:delegation-readiness, app:install-prd-write-guard and
614
- // app:install-destructive-git-guard — all three take exactly { cwd }.
613
+ // Shared by app:delegation-readiness, app:install-prd-write-guard,
614
+ // app:install-destructive-git-guard and app:install-inline-implementation-guard
615
+ // — all four take exactly { cwd }.
615
616
  const delegationReadinessCwd = z.object({
616
617
  cwd: z.string().min(1).max(4096),
617
618
  }).strict();
@@ -975,6 +976,21 @@ const pluginsAbort = z.object({
975
976
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
976
977
  }).passthrough();
977
978
 
979
+ // ──────────────────────────────────────────── Product telemetry (bilko.run)
980
+ // Mirrors telemetrySettings.cjs's own isValid() shape — the renderer always
981
+ // round-trips a full config object it got from telemetry:get-config, same
982
+ // pattern as otel:set-config / OtelConfig.
983
+ const telemetrySetConfig = z.object({
984
+ enabled: z.boolean(),
985
+ installId: z.string().max(200),
986
+ endpoint: z.string().min(1).max(2048),
987
+ noticeAckedAt: z.string().max(64).nullable(),
988
+ lastMachineReportAt: z.string().max(64).nullable(),
989
+ lastMachineReportVersion: z.string().max(64),
990
+ lastDailyFlushAt: z.string().max(64).nullable(),
991
+ schemaVersion: z.literal(1),
992
+ });
993
+
978
994
  /**
979
995
  * Wrap an IPC handler with schema validation. Returns a new handler that
980
996
  * parses the payload before calling the original. On invalid payload throws
@@ -1093,6 +1109,7 @@ module.exports = {
1093
1109
  agentsSavePersona,
1094
1110
  agentsGetPersonaBody,
1095
1111
  delegationReadinessCwd,
1112
+ telemetrySetConfig,
1096
1113
  },
1097
1114
  validated,
1098
1115
  };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * crashTelemetry.test.cjs — unit tests for the electron-free crash-report tap
3
+ * crashDiagnostics.cjs's render-process-gone / child-process-gone hooks and
4
+ * unclean-shutdown postmortem call into.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/crashTelemetry.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, afterEach } from 'vitest';
11
+ const fsp = require('node:fs/promises');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const { reportCrash } = require('../crashTelemetry.cjs');
16
+
17
+ const tmpDirs = [];
18
+ let originalHome;
19
+ let originalSmTelemetrySpool;
20
+
21
+ afterEach(async () => {
22
+ if (originalHome !== undefined) process.env.HOME = originalHome;
23
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
24
+ while (tmpDirs.length) {
25
+ const d = tmpDirs.pop();
26
+ await fsp.rm(d, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ async function mkHome() {
31
+ originalHome = process.env.HOME;
32
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
33
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-crash-telemetry-home-'));
34
+ tmpDirs.push(dir);
35
+ process.env.HOME = dir;
36
+ // Explicit opt-in: overrides the test-environment no-op guard so this
37
+ // suite's real telemetryClient calls actually persist into an isolated dir.
38
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
39
+ return dir;
40
+ }
41
+
42
+ function freshClient() {
43
+ for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
44
+ const resolved = require.resolve(p);
45
+ delete require.cache[resolved];
46
+ }
47
+ const client = require('../telemetryClient.cjs');
48
+ client._setMachineProfileBuilder(async () => ({
49
+ appVersion: '0.83.0', platform: 'linux', arch: 'x64', machineDigest: 'deadbeefcafe',
50
+ }));
51
+ return client;
52
+ }
53
+
54
+ async function readQueueLines(client) {
55
+ const fs = require('node:fs/promises');
56
+ try {
57
+ const raw = await fs.readFile(client.queuePath(), 'utf8');
58
+ return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
59
+ } catch (e) {
60
+ if (e.code === 'ENOENT') return [];
61
+ throw e;
62
+ }
63
+ }
64
+
65
+ test('reportCrash names the record "crash.<reason>"', async () => {
66
+ await mkHome();
67
+ const client = freshClient();
68
+ await reportCrash({ reason: 'oom', meta: { type: 'render' }, deps: { telemetryClient: client } });
69
+ const [rec] = await readQueueLines(client);
70
+ expect(rec.wire.name).toBe('crash.oom');
71
+ client.shutdown();
72
+ });
73
+
74
+ test('a crash record carries the full attribution stamp: appVersion, platform, arch, machineDigest', async () => {
75
+ await mkHome();
76
+ const client = freshClient();
77
+ await reportCrash({ reason: 'killed', meta: { type: 'child', name: 'gpu' }, deps: { telemetryClient: client } });
78
+ const [rec] = await readQueueLines(client);
79
+ expect(rec.wire.version).toBe('0.83.0');
80
+ expect(rec.wire.context.platform).toBeTruthy();
81
+ expect(rec.wire.context.arch).toBeTruthy();
82
+ expect(rec.wire.context.machineDigest).toBeTruthy();
83
+ client.shutdown();
84
+ });
85
+
86
+ test('the unclean-shutdown postmortem is reported as crash.unclean-shutdown with the last memory sample', async () => {
87
+ await mkHome();
88
+ const client = freshClient();
89
+ await reportCrash({
90
+ reason: 'unclean-shutdown',
91
+ meta: { lastTotalMb: 1800, lastMainRssMb: 900, lastTop: ['renderer:900MB'] },
92
+ deps: { telemetryClient: client },
93
+ });
94
+ const [rec] = await readQueueLines(client);
95
+ expect(rec.wire.name).toBe('crash.unclean-shutdown');
96
+ expect(rec.wire.context.lastTotalMb).toBe(1800);
97
+ client.shutdown();
98
+ });
99
+
100
+ test('reportCrash never throws even when the injected telemetryClient throws', () => {
101
+ const throwingClient = { reportError: () => { throw new Error('telemetry boom'); } };
102
+ expect(() => reportCrash({ reason: 'oom', meta: {}, deps: { telemetryClient: throwingClient } })).not.toThrow();
103
+ });