claude-code-session-manager 0.85.0 → 0.87.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 (147) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +15 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  40. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  41. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  42. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  43. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  44. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  45. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  46. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  47. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  48. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  49. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  50. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  51. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  52. package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
  53. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  54. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  55. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  56. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  57. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  58. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  59. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  60. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  61. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  62. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  63. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  64. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  65. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  66. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  67. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  68. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  69. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  70. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
  71. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  72. package/src/main/__tests__/telemetryClient.test.cjs +75 -5
  73. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  74. package/src/main/chatRunner.cjs +8 -5
  75. package/src/main/health.cjs +76 -3
  76. package/src/main/historyAggregator.cjs +5 -0
  77. package/src/main/index.cjs +95 -44
  78. package/src/main/ipcSchemas.cjs +47 -0
  79. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  80. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  81. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  82. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  83. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  84. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  85. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  86. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  87. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  88. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  89. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
  90. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  91. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  92. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  93. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  94. package/src/main/lib/agentModelResolve.cjs +65 -27
  95. package/src/main/lib/bootSelfHeal.cjs +88 -0
  96. package/src/main/lib/delegationReadiness.cjs +290 -225
  97. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  98. package/src/main/lib/ephemeralCwd.cjs +1 -1
  99. package/src/main/lib/epicMint.cjs +3 -3
  100. package/src/main/lib/gitWorktree.cjs +42 -12
  101. package/src/main/lib/guardShims.cjs +156 -0
  102. package/src/main/lib/jobDirtFilter.cjs +7 -2
  103. package/src/main/lib/launchFailure.cjs +2 -1
  104. package/src/main/lib/loadGate.cjs +23 -1
  105. package/src/main/lib/machineProfile.cjs +15 -0
  106. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  107. package/src/main/lib/needsReviewLedger.cjs +205 -0
  108. package/src/main/lib/opsErrorLog.cjs +1 -1
  109. package/src/main/lib/opsOwnership.cjs +1 -1
  110. package/src/main/lib/prdCreate.cjs +56 -1
  111. package/src/main/lib/prdDisposition.cjs +199 -0
  112. package/src/main/lib/prdFrontmatter.cjs +8 -2
  113. package/src/main/lib/prdLocations.cjs +167 -45
  114. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  115. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  116. package/src/main/lib/projectRootResolve.cjs +1 -1
  117. package/src/main/lib/queueHistory.cjs +19 -1
  118. package/src/main/lib/queueStore.cjs +6 -1
  119. package/src/main/lib/reaperHelpers.cjs +181 -15
  120. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  121. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  122. package/src/main/lib/schedulerBatch.cjs +12 -1
  123. package/src/main/lib/schedulerConfig.cjs +37 -0
  124. package/src/main/lib/telemetryBoot.cjs +11 -8
  125. package/src/main/lib/telemetryClient.cjs +44 -2
  126. package/src/main/lib/telemetrySettings.cjs +20 -3
  127. package/src/main/lib/usageCircuit.cjs +159 -0
  128. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  129. package/src/main/pty.cjs +9 -0
  130. package/src/main/scheduler/prdParser.cjs +13 -0
  131. package/src/main/scheduler.cjs +1869 -193
  132. package/src/main/templates/PRD_AUTHORING.md +50 -0
  133. package/src/main/templates/project-pages-catalog.json +1 -1
  134. package/src/main/usage.cjs +21 -3
  135. package/src/preload/api.d.ts +92 -1
  136. package/src/preload/index.cjs +10 -0
  137. package/web/README.md +41 -0
  138. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  139. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  140. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  141. package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
  142. package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
  143. package/dist/assets/Permissions-DKoNVgzj.js +0 -3
  144. package/dist/assets/Scheduler-CbES7MC8.js +0 -14
  145. package/dist/assets/Settings-BX3FElXk.js +0 -3
  146. package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
  147. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -32,6 +32,8 @@
32
32
 
33
33
  const { appendAuditEvent } = require('./auditLog.cjs');
34
34
  const telemetryCounters = require('./telemetryCounters.cjs');
35
+ const queueHistory = require('./queueHistory.cjs');
36
+ const { buildNeedsReviewEntryLine, buildNeedsReviewResolutionLine } = require('./needsReviewLedger.cjs');
35
37
 
36
38
  // A run genuinely finished when it leaves 'running' for one of these —
37
39
  // distinct from every other legal edge in LEGAL_TRANSITIONS (retries,
@@ -195,6 +197,37 @@ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } =
195
197
  telemetryCounters.trackSchedulerJobFinish({ status: toStatus });
196
198
  }
197
199
 
200
+ // needs_review ledger (PRD: needs_review durability). This IS the single
201
+ // funnel every needs_review transition — entry or resolution — passes
202
+ // through (see this file's own header comment: transitionJob is the ONE
203
+ // place `status` is assigned), so hooking here, rather than any one of
204
+ // needs_review's several distinct entry call sites in scheduler.cjs,
205
+ // guarantees no path can bypass it.
206
+ //
207
+ // Fire-and-forget, exactly like every other best-effort side-channel write
208
+ // in this codebase (rcaReport.cjs, auditLog.cjs's own posture) — a ledger
209
+ // write failure must never fail or delay the transition it's recording.
210
+ if (toStatus === 'needs_review' && from !== 'needs_review') {
211
+ // ENTRY: stamp the episode's runId/enteredAt on the job itself so the
212
+ // RESOLUTION line (below) can reference the SAME runId even if the job's
213
+ // own `runId` is reassigned in between (e.g. a resume-recovery
214
+ // re-dispatch mints a fresh one before this episode resolves).
215
+ job.needsReviewEntryRunId = job.runId ?? null;
216
+ job.needsReviewEnteredAt = entry.at;
217
+ queueHistory.appendHistory([buildNeedsReviewEntryLine(job, entry)]).catch((e) => {
218
+ console.error('[scheduleJobTransitions] failed to append needs_review entry', e?.message ?? String(e));
219
+ });
220
+ } else if (from === 'needs_review' && toStatus !== 'needs_review') {
221
+ // RESOLUTION: read the episode stamped at entry, then clear it — the
222
+ // episode is over regardless of what this resolution line looks like.
223
+ const episode = { runId: job.needsReviewEntryRunId ?? null, enteredAt: job.needsReviewEnteredAt ?? null };
224
+ delete job.needsReviewEntryRunId;
225
+ delete job.needsReviewEnteredAt;
226
+ queueHistory.appendHistory([buildNeedsReviewResolutionLine(job, entry, episode)]).catch((e) => {
227
+ console.error('[scheduleJobTransitions] failed to append needs_review resolution', e?.message ?? String(e));
228
+ });
229
+ }
230
+
198
231
  return true;
199
232
  }
200
233
 
@@ -102,9 +102,20 @@ function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
102
102
  }
103
103
  return satisfiedBareSlugs.has(bareSlug(slug));
104
104
  };
105
+ // A dep row is blocking unless it's 'completed', OR it's a 'skipped' row
106
+ // stamped `needsReviewAutoResolvedSkip` — the bounded needs_review
107
+ // auto-resolve terminal decision (scheduler.cjs's
108
+ // applyNeedsReviewAutoResolve). That skip is deliberately NOT the
109
+ // "PRD source vanished, a human must author a fresh PRD" skip the
110
+ // heldBySkippedDep messaging above describes — it already got every
111
+ // bounded chance to resolve itself, so treating it as a permanent
112
+ // dependsOn block would defeat the whole point of that auto-resolve pass
113
+ // (a chain that never drains without an operator).
114
+ const isSatisfiedRow = (dep) => dep.status === 'completed'
115
+ || (dep.status === 'skipped' && dep.needsReviewAutoResolvedSkip === true);
105
116
  return (job.dependsOn ?? []).find((slug) => {
106
117
  const rows = rowsForDep(slug);
107
- if (rows.length > 0) return rows.some((dep) => dep.status !== 'completed');
118
+ if (rows.length > 0) return rows.some((dep) => !isSatisfiedRow(dep));
108
119
  return !isKnownSatisfied(slug);
109
120
  });
110
121
  }
@@ -56,6 +56,16 @@ module.exports = {
56
56
  // (pause/resume, job start/finish/reap/reset) bypass this via
57
57
  // broadcast({ flush: true }).
58
58
  BROADCAST_COALESCE_MS: 200,
59
+ // reconcile() pass duration above which a single warn-level breakdown is
60
+ // logged (per-phase ms, PRD file count, resolved dir count) — the only
61
+ // evidence trail for a slow pass otherwise requires hand-timing the scan
62
+ // from a throwaway script. Baseline measured 2026-09-13 on a machine with
63
+ // ~160 PRD dirs: allProjectCwds() ~270ms x5/pass, resolvePrdsDirs()
64
+ // ~430ms, historyTerminalBySlug() ~418ms over 784 entries — a healthy pass
65
+ // today is comfortably under 2s; this should trip well before a
66
+ // regression back toward those numbers compounds into the reported
67
+ // "schedule.state timed out after 5000ms" toast.
68
+ RECONCILE_SLOW_PASS_MS: 2_000,
59
69
  // Terminal (completed/failed) jobs older than this move from queue.json's
60
70
  // hot jobs[] into the append-only history.jsonl sidecar. See
61
71
  // src/main/lib/queueHistory.cjs.
@@ -81,6 +91,19 @@ module.exports = {
81
91
  // 1086). Escalation only — warn + audit, never dispatch.
82
92
  STARVATION_ESCALATE_MS: 45 * 60_000,
83
93
 
94
+ // A project still meeting findStarvedProjects' STARVED verdict past THIS
95
+ // (later) threshold gets a bounded, automated consequence beyond the
96
+ // repeating project_starved WARN above: a one-shot 'project_starve_escalated'
97
+ // audit event + a user-facing toast naming the project and the last tick's
98
+ // hold reason (scheduler.cjs's `lastTick`). Escalation only — never
99
+ // auto-resets, auto-cancels, or bypasses a gate; the queue's own state is
100
+ // untouched. The 2026-09-12 audit log showed /home/bilko/Projects/Bilko
101
+ // emit project_starved for 19h straight (ageMs 60.5M → 68.9M) with zero
102
+ // consequence and no toast — this is the fix. Latched per-cwd so the SAME
103
+ // starve stretch escalates exactly once; the latch resets the moment that
104
+ // cwd stops appearing in findStarvedProjects.
105
+ STARVE_ESCALATION_MS: 60 * 60_000,
106
+
84
107
  // A RUNNING job that has overrun its own PRD's `estimateMinutes` by this
85
108
  // factor is escalated. Distinct from MAX_JOB_DURATION_MS (4h), which is a
86
109
  // deadman kill: a 20-minute PRD still running at 3h is 9x over estimate but
@@ -96,6 +119,20 @@ module.exports = {
96
119
  // SM_JOB_OVERRUN_FLOOR_MINUTES.
97
120
  JOB_OVERRUN_FLOOR_MS: 45 * 60_000,
98
121
 
122
+ // Wall-clock KILL budget (distinct from JOB_OVERRUN_FACTOR/JOB_OVERRUN_FLOOR_MS
123
+ // above, which only ever ESCALATE — log + audit, deliberately never kill).
124
+ // This one ACTS: a RUNNING job past `budgetMs = clamp(estimateMinutes *
125
+ // JOB_BUDGET_FACTOR, JOB_BUDGET_FLOOR_MS, JOB_BUDGET_CEILING_MS)` is
126
+ // SIGTERMed and parked needs_review. Calibrated against 606 run-meta
127
+ // records since 2026-08-25: p50=13m, p90=60m, max=240m, against a
128
+ // concurrencyCap of 4 — two 4h outliers hold half the machine's dispatch
129
+ // capacity for an afternoon with nothing noticing or acting on it. Override
130
+ // with SM_JOB_BUDGET_FACTOR / SM_JOB_BUDGET_FLOOR_MINUTES /
131
+ // SM_JOB_BUDGET_CEILING_MINUTES.
132
+ JOB_BUDGET_FACTOR: 3,
133
+ JOB_BUDGET_FLOOR_MS: 45 * 60_000,
134
+ JOB_BUDGET_CEILING_MS: 180 * 60_000,
135
+
99
136
  // A 'running' row with no runtime.pid recorded (spawnJob's status:running
100
137
  // mutate at scheduler.cjs:~3524 landed, but the pid-bearing runtime={}
101
138
  // mutate that follows executeJob's spawn callback never did — the spawn
@@ -38,8 +38,10 @@ function resolveDeps(deps = {}) {
38
38
  * signal that also gates the machine-profile heartbeat below).
39
39
  * 3. install upsert — sent once via telemetryClient.reportInstall(profile)
40
40
  * when telemetrySettings.isMachineReportDue() is true (a version bump, OR
41
- * the 30-day liveness heartbeat), then lastMachineReportAt/Version are
42
- * persisted so a same-version reboot within the window sends nothing.
41
+ * the 30-day liveness heartbeat). lastMachineReportAt/Version are only
42
+ * persisted when reportInstall() reports success — a failed/dropped
43
+ * upsert stays due so the next boot retries, rather than being marked
44
+ * done regardless of outcome.
43
45
  * This lands in bilko.run's app_installs table (the sole source for
44
46
  * every install-shaped number on the site). It replaces the former
45
47
  * track('install.machine', profile) call, which duplicated the same
@@ -62,12 +64,13 @@ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps
62
64
 
63
65
  if (telemetrySettings.isMachineReportDue(settings, { now, appVersion })) {
64
66
  const profile = await buildMachineProfile();
65
- await telemetryClient.reportInstall(profile);
66
- await telemetrySettings.save({
67
- ...settings,
68
- lastMachineReportAt: new Date(now).toISOString(),
69
- lastMachineReportVersion: appVersion,
70
- });
67
+ const reportResult = await telemetryClient.reportInstall(profile);
68
+ if (reportResult && reportResult.accepted) {
69
+ await telemetrySettings.save({
70
+ lastMachineReportAt: new Date(now).toISOString(),
71
+ lastMachineReportVersion: appVersion,
72
+ });
73
+ }
71
74
  }
72
75
 
73
76
  telemetryCounters.trackAppLaunch({ installChannel, appVersion });
@@ -91,8 +91,24 @@ const S = {
91
91
  lastError: null,
92
92
  lastFlushAt: null,
93
93
  lastFlushReason: null,
94
+ logger: null,
94
95
  };
95
96
 
97
+ /**
98
+ * Best-effort warn logger for failures this module would otherwise swallow
99
+ * (e.g. reportInstall()'s bare catch). Lazily requires logs.cjs — which
100
+ * requires('electron') at module scope — so this stays a no-op under plain
101
+ * vitest with no Electron runtime, exactly like machineProfile.cjs's
102
+ * resolveElectronApp(). Injectable via _setLogger so tests can observe it
103
+ * without an Electron runtime.
104
+ */
105
+ function logWarn(message, meta) {
106
+ try {
107
+ const fn = S.logger || require('../logs.cjs').writeLine;
108
+ fn({ scope: 'telemetry', level: 'warn', message, meta });
109
+ } catch { /* logs unavailable outside an Electron runtime */ }
110
+ }
111
+
96
112
  /**
97
113
  * SM_TELEMETRY_SPOOL, when set, overrides the spool directory in place of
98
114
  * `~/.config/session-manager` — the explicit opt-in a test that genuinely
@@ -130,6 +146,18 @@ function getBeaconToken() {
130
146
  return process.env.SM_BEACON_KEY || FALLBACK_BEACON_KEY;
131
147
  }
132
148
 
149
+ /**
150
+ * The wire-level `env` discriminator for the currently-running process,
151
+ * resolved from real signals (never from an appVersion string — see
152
+ * machineProfile.cjs's resolveEnv header comment for why that matters).
153
+ */
154
+ function currentEnv() {
155
+ return machineProfile.resolveEnv({
156
+ isTestRunner: isTestEnvironment(),
157
+ installChannel: S.profile && S.profile.installChannel,
158
+ });
159
+ }
160
+
133
161
  // ─── safe coercion helpers (never throw, regardless of input shape) ───────
134
162
 
135
163
  function safeObj(v) {
@@ -436,6 +464,7 @@ async function track(name, props) {
436
464
  props: meta,
437
465
  session_id: S.sessionId,
438
466
  visitor_id: S.settings.installId,
467
+ env: currentEnv(),
439
468
  };
440
469
  return await appendRecord('event', wire, recordId);
441
470
  } catch {
@@ -465,6 +494,7 @@ async function logLine(opts) {
465
494
  session_id: S.sessionId,
466
495
  fields: meta,
467
496
  ts: Date.now(),
497
+ env: currentEnv(),
468
498
  };
469
499
  return await appendRecord('log', wire, recordId);
470
500
  } catch {
@@ -501,6 +531,7 @@ async function reportError(opts) {
501
531
  session_id: S.sessionId,
502
532
  context: meta,
503
533
  ts: Date.now(),
534
+ env: currentEnv(),
504
535
  };
505
536
  return await appendRecord('error', wire, recordId);
506
537
  } catch {
@@ -527,6 +558,11 @@ function buildInstallWire(profile, installId) {
527
558
  // the numeric UTC offset (see machineProfile.cjs) — so that is what maps
528
559
  // onto the server's `timezone` column.
529
560
  timezone: safeStr(p.timezoneOffsetMinutes, 20),
561
+ // Resolved from this passed-in profile's own installChannel (never
562
+ // S.profile — reportInstall() is always given a freshly-built profile,
563
+ // not the module-singleton one) plus the current process's test-runner
564
+ // signal, same three-value contract as the other three channels.
565
+ env: machineProfile.resolveEnv({ isTestRunner: isTestEnvironment(), installChannel: p.installChannel }),
530
566
  };
531
567
  }
532
568
 
@@ -544,7 +580,8 @@ async function reportInstall(profile) {
544
580
  const recordId = crypto.randomUUID();
545
581
  const wire = buildInstallWire(profile, S.settings.installId);
546
582
  return await appendRecord('install', wire, recordId);
547
- } catch {
583
+ } catch (e) {
584
+ logWarn('reportInstall failed', { error: e && e.message ? e.message : String(e) });
548
585
  return { accepted: false, reason: 'error' };
549
586
  }
550
587
  }
@@ -684,7 +721,7 @@ async function flushImpl(reason) {
684
721
  await persistSent();
685
722
 
686
723
  if (safeReason === 'daily' && result.failed.length === 0) {
687
- S.settings = await telemetrySettings.save({ ...S.settings, lastDailyFlushAt: new Date().toISOString() });
724
+ S.settings = await telemetrySettings.save({ lastDailyFlushAt: new Date().toISOString() });
688
725
  }
689
726
  } catch { /* fail-inert */ }
690
727
  S.lastFlushAt = Date.now();
@@ -749,6 +786,10 @@ function _setMachineProfileBuilder(fn) {
749
786
  S.profileBuilder = typeof fn === 'function' ? fn : machineProfile.buildMachineProfile;
750
787
  }
751
788
 
789
+ function _setLogger(fn) {
790
+ S.logger = typeof fn === 'function' ? fn : null;
791
+ }
792
+
752
793
  module.exports = {
753
794
  track,
754
795
  logLine,
@@ -764,4 +805,5 @@ module.exports = {
764
805
  sentPath,
765
806
  _setFetchImpl,
766
807
  _setMachineProfileBuilder,
808
+ _setLogger,
767
809
  };
@@ -78,11 +78,28 @@ function normalize(cfg) {
78
78
  };
79
79
  }
80
80
 
81
+ /**
82
+ * Persists `patch` merged onto a FRESHLY-READ copy of the on-disk config —
83
+ * never onto a caller-held snapshot. Two independent modules (telemetryBoot's
84
+ * install-report stamp, telemetryClient's own daily-flush stamp) each keep
85
+ * their own in-memory copy of settings loaded at different times; if save()
86
+ * blindly wrote a caller's full snapshot, whichever call landed last would
87
+ * silently revert every field the OTHER caller had just written (a lost
88
+ * update — the root cause behind lastMachineReportAt never sticking). Calls
89
+ * are still serialized through the existing writeQueue chain, so the fresh
90
+ * read for call N+1 always observes call N's write.
91
+ */
81
92
  let writeQueue = Promise.resolve();
82
- async function save(cfg) {
83
- if (!isValid(cfg)) throw new Error('Invalid telemetry config');
84
- const next = normalize(cfg);
93
+ async function save(patch) {
94
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Invalid telemetry config');
95
+ for (const k of Object.keys(patch)) {
96
+ if (!KNOWN_KEYS.has(k)) throw new Error('Invalid telemetry config');
97
+ }
85
98
  const run = async () => {
99
+ const current = await readRaw();
100
+ const merged = { ...current, ...patch };
101
+ if (!isValid(merged)) throw new Error('Invalid telemetry config');
102
+ const next = normalize(merged);
86
103
  await config.writeTextAtomic(storePath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
87
104
  return next;
88
105
  };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * usageCircuit.cjs — a breaker over the /api/oauth/usage meter (PRD ss-02).
5
+ * Degraded mode is explicit, never a silent 0: repeated failures open the
6
+ * circuit with jittered backoff; degradedBudget() carries forward the last
7
+ * known-good BINDING window's utilization (never 0), pinning to 100 on a
8
+ * fresh executor-observed 429 and capping concurrency at 2. Pure Node.
9
+ */
10
+
11
+ const FLOOR_MS = 30_000;
12
+ const CAP_MS = 8 * 60 * 1000;
13
+
14
+ /** Doubles from FLOOR_MS, capped at CAP_MS. 0/falsy prevMs starts at the floor. */
15
+ function doubledBase(prevMs) {
16
+ return prevMs > 0 ? Math.min(prevMs * 2, CAP_MS) : FLOOR_MS;
17
+ }
18
+
19
+ /**
20
+ * Next backoff interval: doubles `prevMs` (floor FLOOR_MS, cap CAP_MS), then
21
+ * jitters by 0.5 + rand()*0.5 so concurrent instances don't retry in lockstep.
22
+ */
23
+ function backoffWithJitter(prevMs) {
24
+ const base = doubledBase(prevMs);
25
+ return base * (0.5 + Math.random() * 0.5);
26
+ }
27
+
28
+ /**
29
+ * A cached reset timestamp is fresh only if it's strictly in the future
30
+ * relative to `now` — null/unparseable/at-or-before-now all read as stale,
31
+ * since a passed reset no longer describes a live window.
32
+ */
33
+ function isResetFresh(iso, now) {
34
+ if (!iso) return false;
35
+ const resetMs = Date.parse(iso);
36
+ if (!Number.isFinite(resetMs)) return false;
37
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
38
+ if (!Number.isFinite(nowMs)) return false;
39
+ return resetMs > nowMs;
40
+ }
41
+
42
+ /**
43
+ * Which window is actually binding dispatch. Prefers `payload.limits[]`'s
44
+ * `is_active` entry (the richer per-window shape); falls back to the flat
45
+ * `five_hour` field when `limits[]` is absent, matching usage.cjs's shape.
46
+ */
47
+ function bindingWindow(payload) {
48
+ if (payload && Array.isArray(payload.limits) && payload.limits.length) {
49
+ const active = payload.limits.find((l) => l && l.is_active);
50
+ if (active) {
51
+ return {
52
+ name: active.type || active.name || 'unknown',
53
+ utilization: active.utilization,
54
+ resets_at: active.resets_at ?? null,
55
+ };
56
+ }
57
+ }
58
+ const fh = payload && payload.five_hour;
59
+ return {
60
+ name: 'five_hour',
61
+ utilization: fh ? fh.utilization : undefined,
62
+ resets_at: fh ? fh.resets_at : null,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Conservative budget to run on while the meter is down. utilization is
68
+ * carried forward from the last known-good BINDING window — never 0, which
69
+ * would read as "plenty of headroom" instead of "we don't know." A fresh
70
+ * executor-observed 429 (its own window not yet passed) pins utilization to
71
+ * 100 regardless of the stale cached value. concurrencyCap never exceeds 2.
72
+ */
73
+ function degradedBudget(lastGoodPayload, executorEvidence = {}) {
74
+ const window = bindingWindow(lastGoodPayload);
75
+ let utilization = Number.isFinite(window.utilization) && window.utilization > 0
76
+ ? window.utilization
77
+ : 100;
78
+
79
+ const configuredCap = Number.isFinite(executorEvidence.configuredCap)
80
+ ? executorEvidence.configuredCap
81
+ : 2;
82
+ const concurrencyCap = Math.min(configuredCap, 2);
83
+
84
+ if (executorEvidence.observed429 && isResetFresh(executorEvidence.resetsAt, executorEvidence.now)) {
85
+ utilization = 100;
86
+ }
87
+
88
+ return { utilization, concurrencyCap };
89
+ }
90
+
91
+ /** Wraps `fn` so concurrent callers awaiting an in-flight call share one promise. */
92
+ function singleFlight(fn) {
93
+ let inFlight = null;
94
+ return function singleFlightWrapped(...args) {
95
+ if (inFlight) return inFlight;
96
+ inFlight = Promise.resolve(fn(...args)).finally(() => { inFlight = null; });
97
+ return inFlight;
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Breaker state machine over the meter: closed -> open after 3 consecutive
103
+ * failures; open allows exactly one probe per backoff interval (half_open);
104
+ * a successful probe closes the circuit and resets backoff, a failed probe
105
+ * reopens it with backoff doubled.
106
+ */
107
+ function createUsageCircuit({ now = () => Date.now() } = {}) {
108
+ let consecutiveFailures = 0;
109
+ let currentState = 'closed';
110
+ let backoffBaseMs = 0;
111
+ let nextProbeAtMs = 0;
112
+ let probeInFlight = false;
113
+
114
+ function scheduleNextProbe() {
115
+ const intervalMs = backoffWithJitter(backoffBaseMs);
116
+ backoffBaseMs = doubledBase(backoffBaseMs);
117
+ nextProbeAtMs = now() + intervalMs;
118
+ }
119
+
120
+ function state() {
121
+ if (currentState === 'open' && !probeInFlight && now() >= nextProbeAtMs) {
122
+ currentState = 'half_open';
123
+ probeInFlight = true;
124
+ }
125
+ return currentState;
126
+ }
127
+
128
+ function recordFailure(kind) { // eslint-disable-line no-unused-vars
129
+ consecutiveFailures += 1;
130
+ if (currentState === 'half_open') {
131
+ probeInFlight = false;
132
+ currentState = 'open';
133
+ scheduleNextProbe();
134
+ return;
135
+ }
136
+ if (currentState === 'closed' && consecutiveFailures >= 3) {
137
+ currentState = 'open';
138
+ scheduleNextProbe();
139
+ }
140
+ }
141
+
142
+ function recordSuccess(payload) { // eslint-disable-line no-unused-vars
143
+ consecutiveFailures = 0;
144
+ probeInFlight = false;
145
+ backoffBaseMs = 0;
146
+ currentState = 'closed';
147
+ }
148
+
149
+ return { recordSuccess, recordFailure, state };
150
+ }
151
+
152
+ module.exports = {
153
+ createUsageCircuit,
154
+ backoffWithJitter,
155
+ isResetFresh,
156
+ bindingWindow,
157
+ degradedBudget,
158
+ singleFlight,
159
+ };
@@ -362,7 +362,7 @@ function releaseLock(lockPath) {
362
362
  * dependency fails inside maybeFinalizeHistory's try/catch, not at require
363
363
  * time for the whole watchdog script. */
364
364
  function defaultFinalizeClosedDays(opts) {
365
- const { finalizeClosedDays } = require('../../src/main/historyAggregator.cjs');
365
+ const { finalizeClosedDays } = require('../historyAggregator.cjs');
366
366
  return finalizeClosedDays(opts);
367
367
  }
368
368
 
package/src/main/pty.cjs CHANGED
@@ -109,6 +109,15 @@ class PtyManager {
109
109
  // renderer will re-register its data/exit listeners on the same IPC
110
110
  // channels. The data stream is live; pre-reattach output is lost, which
111
111
  // is acceptable for a dev reload.
112
+ //
113
+ // Telemetry: this branch deliberately does NOT call
114
+ // telemetryCounters.trackSessionOpen() (see below, after the real spawn).
115
+ // Tab = claudeSessionId is a 1:1 mapping (CLAUDE.md domain model) — a
116
+ // reattach is the SAME session process still running, not a new one, so
117
+ // counting it here would double-count every renderer reload and every
118
+ // switch back to an already-open Epic's Terminal pane within the same
119
+ // Electron process. `session.open` counts fresh PTY spawns only; see
120
+ // telemetry.md for the full rationale and its trade-off.
112
121
  const existing = this.sessions.get(tabId);
113
122
  if (existing) {
114
123
  console.log('[pty] reattach to existing session tabId=', tabId, 'pid=', existing.proc.pid);
@@ -119,10 +119,23 @@ async function parsePrdRaw(filePath) {
119
119
  // dependent follow-up PRD (scheduler-launch-as-persona) resolves this
120
120
  // into a launch decision, which this module does not do.
121
121
  agentType: fm.agentType || null,
122
+ // Wave-authoring decision (scheduler wave-disposition PRD): 'append' or
123
+ // 'new-head', set only when createPrd() actually had a disposition to
124
+ // record — see prdCreate.cjs/prdDisposition.cjs. Absent on every PRD
125
+ // authored before this field existed, and on any PRD that never needed
126
+ // the decision (first-ever PRD in its Epic, or one with its own explicit
127
+ // dependsOn).
128
+ disposition: (fm.disposition === 'append' || fm.disposition === 'new-head') ? fm.disposition : null,
122
129
  // Opt-in exclusive-lease flag (PRD 1107, quietMachineLease.cjs +
123
130
  // schedulerBatch.cjs's pickNextBatch). Only a literal `true` opts in —
124
131
  // matches prdFrontmatter.cjs's applyKey semantics for this field.
125
132
  quietMachine: fm.quietMachine === 'true',
133
+ // Explicit opt-out of the wall-clock budget kill (scheduler.cjs's budget
134
+ // watchdog) — only a literal `true` opts out, matching quietMachine's
135
+ // parse semantics above. quietMachine jobs are exempt regardless of this
136
+ // field; this is for a job that isn't quiet-machine-eligible but still
137
+ // needs no wall-clock kill ceiling.
138
+ budgetExempt: fm.budgetExempt === 'true',
126
139
  body: body.trim(),
127
140
  };
128
141
  }