claude-code-session-manager 0.82.0 → 0.84.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-pwlkAFb3.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-BVRjxJjS.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-YPNm9W92.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CvkIXNl1.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-D93o-sxe.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-DQipg16I.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-DApB4DHS.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-DA3N2Up3.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 +143 -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 +810 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -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 +97 -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 +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -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 +233 -20
  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 +71 -0
  83. package/src/main/lib/telemetryClient.cjs +653 -0
  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-BVrAle90.js +0 -3
  92. package/dist/assets/SystemPrompt-8PiTyUyL.js +0 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * telemetryBoot.test.cjs — unit tests for the boot-time telemetry send rules:
3
+ * the machine-profile heartbeat (version-change OR 30-day liveness) and the
4
+ * boot/version-change flush triggers.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/telemetryBoot.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect } from 'vitest';
11
+ const { bootSequence } = require('../telemetryBoot.cjs');
12
+ const telemetrySettings = require('../telemetrySettings.cjs');
13
+
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+
16
+ function fakeDeps({ settings } = {}) {
17
+ let saved = { ...telemetrySettings.DEFAULTS, ...settings };
18
+ const flushCalls = [];
19
+ return {
20
+ state: () => saved,
21
+ flushCalls,
22
+ deps: {
23
+ telemetrySettings: {
24
+ load: async () => saved,
25
+ save: async (next) => { saved = { ...saved, ...next }; return saved; },
26
+ isMachineReportDue: telemetrySettings.isMachineReportDue,
27
+ },
28
+ telemetryClient: {
29
+ flush: async (reason) => { flushCalls.push(reason); return { sent: [], failed: [], reason }; },
30
+ track: async (name, props) => ({ accepted: true, name, props }),
31
+ },
32
+ buildMachineProfile: async () => ({ appVersion: '1.0.0', platform: 'linux', arch: 'x64', machineDigest: 'abc123' }),
33
+ telemetryCounters: { trackAppLaunch: () => {} },
34
+ },
35
+ };
36
+ }
37
+
38
+ test('a second boot at the same version within 30 days sends nothing', async () => {
39
+ const now = Date.now();
40
+ const priorReportAt = new Date(now - DAY_MS).toISOString();
41
+ const { deps, state } = fakeDeps({
42
+ settings: { lastMachineReportAt: priorReportAt, lastMachineReportVersion: '1.0.0' },
43
+ });
44
+ const seen = [];
45
+ deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
46
+
47
+ await bootSequence({ now, appVersion: '1.0.0', deps });
48
+
49
+ expect(seen.filter((e) => e.name === 'install.machine')).toHaveLength(0);
50
+ expect(state().lastMachineReportAt).toBe(priorReportAt);
51
+ });
52
+
53
+ test('a version bump sends the machine profile exactly once', async () => {
54
+ const now = Date.now();
55
+ const { deps, state } = fakeDeps({
56
+ settings: { lastMachineReportAt: new Date(now - DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
57
+ });
58
+ const seen = [];
59
+ deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
60
+
61
+ await bootSequence({ now, appVersion: '2.0.0', deps });
62
+
63
+ const installEvents = seen.filter((e) => e.name === 'install.machine');
64
+ expect(installEvents.length).toBe(1);
65
+ expect(state().lastMachineReportVersion).toBe('2.0.0');
66
+ });
67
+
68
+ test('31 days on an unchanged version sends the machine profile exactly once (liveness heartbeat)', async () => {
69
+ const now = Date.now();
70
+ const { deps, state } = fakeDeps({
71
+ settings: { lastMachineReportAt: new Date(now - 31 * DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
72
+ });
73
+ const seen = [];
74
+ deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
75
+
76
+ await bootSequence({ now, appVersion: '1.0.0', deps });
77
+
78
+ const installEvents = seen.filter((e) => e.name === 'install.machine');
79
+ expect(installEvents.length).toBe(1);
80
+ expect(state().lastMachineReportAt).toBe(new Date(now).toISOString());
81
+ });
82
+
83
+ test('the install.machine event appVersion prop equals the appVersion stamped on a sibling error record', async () => {
84
+ const now = Date.now();
85
+ const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '', lastMachineReportAt: null } });
86
+ const seen = [];
87
+ deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
88
+ // Sibling error record stamped by the SAME process's telemetryClient with the same appVersion.
89
+ const siblingErrorStampAppVersion = '1.0.0';
90
+
91
+ await bootSequence({ now, appVersion: '1.0.0', deps });
92
+
93
+ const [install] = seen.filter((e) => e.name === 'install.machine');
94
+ expect(install.props.appVersion).toBe(siblingErrorStampAppVersion);
95
+ });
96
+
97
+ test('on app ready, flush("boot") always runs', async () => {
98
+ const now = Date.now();
99
+ const { deps, flushCalls } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
100
+ await bootSequence({ now, appVersion: '1.0.0', deps });
101
+ expect(flushCalls).toContain('boot');
102
+ });
103
+
104
+ test('on a detected app-version change, flush("version-change") also runs', async () => {
105
+ const now = Date.now();
106
+ const { deps, flushCalls } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
107
+ await bootSequence({ now, appVersion: '2.0.0', deps });
108
+ expect(flushCalls).toEqual(['boot', 'version-change']);
109
+ });
110
+
111
+ test('with no version change, flush("version-change") does NOT run', async () => {
112
+ const now = Date.now();
113
+ const { deps, flushCalls } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
114
+ await bootSequence({ now, appVersion: '1.0.0', deps });
115
+ expect(flushCalls).toEqual(['boot']);
116
+ });
117
+
118
+ test('fires app.launch via telemetryCounters with installChannel + appVersion', async () => {
119
+ const now = Date.now();
120
+ const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '1.0.0', lastMachineReportAt: new Date(now).toISOString() } });
121
+ const calls = [];
122
+ deps.telemetryCounters = { trackAppLaunch: (props) => calls.push(props) };
123
+ await bootSequence({ now, appVersion: '1.0.0', installChannel: 'npx', deps });
124
+ expect(calls).toEqual([{ installChannel: 'npx', appVersion: '1.0.0' }]);
125
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * telemetryConsent.test.cjs — unit tests for the telemetry:set-config
3
+ * handler's write path (PRD 1142).
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/lib/__tests__/telemetryConsent.test.cjs
6
+ */
7
+ 'use strict';
8
+
9
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
10
+ const fsp = require('node:fs/promises');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ const tmpDirs = [];
15
+ let originalHome;
16
+
17
+ beforeEach(() => {
18
+ originalHome = process.env.HOME;
19
+ });
20
+
21
+ afterEach(async () => {
22
+ if (originalHome !== undefined) process.env.HOME = originalHome; else delete process.env.HOME;
23
+ vi.unstubAllGlobals();
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
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-telemetry-consent-home-'));
32
+ tmpDirs.push(dir);
33
+ return dir;
34
+ }
35
+
36
+ function freshModules(home) {
37
+ process.env.HOME = home;
38
+ for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
39
+ const resolved = require.resolve(p);
40
+ delete require.cache[resolved];
41
+ }
42
+ const telemetryConsent = require('../telemetryConsent.cjs');
43
+ const telemetrySettings = require('../telemetrySettings.cjs');
44
+ const telemetryClient = require('../telemetryClient.cjs');
45
+ telemetryClient._setMachineProfileBuilder(async () => ({
46
+ appVersion: '0.83.0', platform: 'linux', arch: 'x64', machineDigest: 'abc123', installChannel: 'dev',
47
+ }));
48
+ return { telemetryConsent, telemetrySettings, telemetryClient };
49
+ }
50
+
51
+ test('turning telemetry off persists enabled:false and telemetrySettings.isEnabled() reflects it', async () => {
52
+ const home = await mkHome();
53
+ const { telemetryConsent, telemetrySettings } = freshModules(home);
54
+ const cfg = await telemetrySettings.load();
55
+ expect(cfg.enabled).toBe(true);
56
+
57
+ const saved = await telemetryConsent.applyConsentUpdate({ ...cfg, enabled: false });
58
+ expect(saved.enabled).toBe(false);
59
+ expect(telemetrySettings.isEnabled(saved)).toBe(false);
60
+ });
61
+
62
+ test('after turning off, telemetryClient drops subsequent records at ingress', async () => {
63
+ const home = await mkHome();
64
+ const { telemetryConsent, telemetrySettings, telemetryClient } = freshModules(home);
65
+ const cfg = await telemetrySettings.load();
66
+ await telemetryConsent.applyConsentUpdate({ ...cfg, enabled: false });
67
+
68
+ const res = await telemetryClient.track('some.event', { a: 1 });
69
+ expect(res).toEqual({ accepted: false, reason: 'disabled' });
70
+ telemetryClient.shutdown();
71
+ });
72
+
73
+ test('turning off clears the pending queue file so a re-enable never resends it', async () => {
74
+ const home = await mkHome();
75
+ const { telemetryConsent, telemetrySettings, telemetryClient } = freshModules(home);
76
+
77
+ // Accumulate a record while still enabled.
78
+ const accepted = await telemetryClient.reportError({ name: 'BoomError', msg: 'kaboom', stack: 'at x' });
79
+ expect(accepted.accepted).toBe(true);
80
+ const beforeQueue = await fsp.readFile(telemetryClient.queuePath(), 'utf8');
81
+ expect(beforeQueue.trim().length).toBeGreaterThan(0);
82
+
83
+ const cfg = await telemetrySettings.load();
84
+ await telemetryConsent.applyConsentUpdate({ ...cfg, enabled: false });
85
+
86
+ const afterQueue = await fsp.readFile(telemetryClient.queuePath(), 'utf8');
87
+ expect(afterQueue.trim()).toBe('');
88
+ telemetryClient.shutdown();
89
+ });
90
+
91
+ test('turning off does NOT clear telemetry-sent.json or the watermarks — re-enabling later does not resend delivered history', async () => {
92
+ const home = await mkHome();
93
+ const { telemetryConsent, telemetrySettings, telemetryClient } = freshModules(home);
94
+
95
+ telemetryClient._setFetchImpl(async () => ({ ok: true, status: 200 }));
96
+ await telemetryClient.reportError({ name: 'SentError', msg: 'already delivered', stack: 'at y' });
97
+ await telemetryClient.flush('manual');
98
+ const sentPathContent = await fsp.readFile(telemetryClient.sentPath(), 'utf8');
99
+ expect(JSON.parse(sentPathContent).ids.length).toBeGreaterThan(0);
100
+
101
+ // Also seed a watermarks file to prove opt-out leaves it untouched.
102
+ const watermarksDir = path.join(home, '.config', 'session-manager');
103
+ await fsp.mkdir(watermarksDir, { recursive: true });
104
+ const watermarksPath = path.join(watermarksDir, 'telemetry-watermarks.json');
105
+ const watermarksBefore = { 'abc123/errors-2026-01-01.jsonl': { bytesEnqueued: 100, bytesConfirmed: 100 } };
106
+ await fsp.writeFile(watermarksPath, JSON.stringify(watermarksBefore));
107
+
108
+ const cfg = await telemetrySettings.load();
109
+ await telemetryConsent.applyConsentUpdate({ ...cfg, enabled: false });
110
+
111
+ const sentAfter = await fsp.readFile(telemetryClient.sentPath(), 'utf8');
112
+ expect(sentAfter).toBe(sentPathContent);
113
+ const watermarksAfter = await fsp.readFile(watermarksPath, 'utf8');
114
+ expect(JSON.parse(watermarksAfter)).toEqual(watermarksBefore);
115
+ telemetryClient.shutdown();
116
+ });
117
+
118
+ test('turning telemetry on (already enabled) is a no-op on the queue', async () => {
119
+ const home = await mkHome();
120
+ const { telemetryConsent, telemetrySettings, telemetryClient } = freshModules(home);
121
+ await telemetryClient.reportError({ name: 'KeptError', msg: 'still queued', stack: 'at z' });
122
+ const before = await fsp.readFile(telemetryClient.queuePath(), 'utf8');
123
+
124
+ const cfg = await telemetrySettings.load();
125
+ await telemetryConsent.applyConsentUpdate({ ...cfg, enabled: true });
126
+
127
+ const after = await fsp.readFile(telemetryClient.queuePath(), 'utf8');
128
+ expect(after).toBe(before);
129
+ telemetryClient.shutdown();
130
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * telemetryCounters.test.cjs — unit tests for the single shared emission
3
+ * point behind 'app.launch' / 'session.open' / 'epic.create' /
4
+ * 'scheduler.job.finish'. Each real call site (pty.cjs, epicMint.cjs,
5
+ * scheduleJobTransitions.cjs, telemetryBoot.cjs) delegates here, so this is
6
+ * where the event name + prop-key shape is verified once.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/telemetryCounters.test.cjs
9
+ */
10
+ 'use strict';
11
+
12
+ import { test, expect } from 'vitest';
13
+ const counters = require('../telemetryCounters.cjs');
14
+
15
+ function fakeClient() {
16
+ const calls = [];
17
+ return { calls, deps: { telemetryClient: { track: (name, props) => { calls.push({ name, props }); } } } };
18
+ }
19
+
20
+ test('trackAppLaunch fires app.launch once with installChannel + appVersion', () => {
21
+ const { calls, deps } = fakeClient();
22
+ counters.trackAppLaunch({ installChannel: 'npx', appVersion: '0.83.0' }, deps);
23
+ expect(calls).toHaveLength(1);
24
+ expect(calls[0].name).toBe('app.launch');
25
+ expect(Object.keys(calls[0].props).sort()).toEqual(['appVersion', 'installChannel'].sort());
26
+ });
27
+
28
+ test('trackSessionOpen fires session.open once', () => {
29
+ const { calls, deps } = fakeClient();
30
+ counters.trackSessionOpen(deps);
31
+ expect(calls).toHaveLength(1);
32
+ expect(calls[0].name).toBe('session.open');
33
+ });
34
+
35
+ test('trackEpicCreate fires epic.create once', () => {
36
+ const { calls, deps } = fakeClient();
37
+ counters.trackEpicCreate(deps);
38
+ expect(calls).toHaveLength(1);
39
+ expect(calls[0].name).toBe('epic.create');
40
+ });
41
+
42
+ test('trackSchedulerJobFinish fires scheduler.job.finish once with a status-only prop', () => {
43
+ const { calls, deps } = fakeClient();
44
+ counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
45
+ expect(calls).toHaveLength(1);
46
+ expect(calls[0].name).toBe('scheduler.job.finish');
47
+ expect(Object.keys(calls[0].props)).toEqual(['status']);
48
+ expect(calls[0].props.status).toBe('completed');
49
+ });
50
+
51
+ test('a throwing telemetryClient.track never escapes any counter function', () => {
52
+ const deps = { telemetryClient: { track: () => { throw new Error('boom'); } } };
53
+ expect(() => counters.trackAppLaunch({ installChannel: 'dev', appVersion: '1.0.0' }, deps)).not.toThrow();
54
+ expect(() => counters.trackSessionOpen(deps)).not.toThrow();
55
+ expect(() => counters.trackEpicCreate(deps)).not.toThrow();
56
+ expect(() => counters.trackSchedulerJobFinish({ status: 'failed' }, deps)).not.toThrow();
57
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * telemetryCountersMetadataColumn.test.cjs — the event channel has no version
3
+ * column (telemetryClient.test.cjs's "funnel_events has no version column"
4
+ * test covers the generic case); this asserts the same survives specifically
5
+ * for each of the four counter events this PRD adds, once serialized the way
6
+ * the /api/telemetry/event route persists it into `metadata`.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs
9
+ */
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fsp = require('node:fs/promises');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+
17
+ const counters = require('../telemetryCounters.cjs');
18
+
19
+ const tmpDirs = [];
20
+ let originalHome;
21
+
22
+ afterEach(async () => {
23
+ if (originalHome !== undefined) process.env.HOME = originalHome;
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
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-telemetry-counters-home-'));
33
+ tmpDirs.push(dir);
34
+ process.env.HOME = dir;
35
+ return dir;
36
+ }
37
+
38
+ function freshClient() {
39
+ for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
40
+ const resolved = require.resolve(p);
41
+ delete require.cache[resolved];
42
+ }
43
+ const client = require('../telemetryClient.cjs');
44
+ client._setMachineProfileBuilder(async () => ({
45
+ appVersion: '0.83.0', platform: 'linux', arch: 'x64', machineDigest: 'deadbeefcafe',
46
+ }));
47
+ return client;
48
+ }
49
+
50
+ async function readQueueLines(client) {
51
+ const fs = require('node:fs/promises');
52
+ try {
53
+ const raw = await fs.readFile(client.queuePath(), 'utf8');
54
+ return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
55
+ } catch (e) {
56
+ if (e.code === 'ENOENT') return [];
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ function asPersistedMetadata(rec) {
62
+ // Mirrors exactly what the deployed /api/telemetry/event INSERT persists
63
+ // into the `metadata` column (see telemetryClient.test.cjs).
64
+ return JSON.parse(JSON.stringify(rec.wire.props));
65
+ }
66
+
67
+ test('every counter event still yields appVersion + machineDigest once persisted into metadata', async () => {
68
+ await mkHome();
69
+ const client = freshClient();
70
+ const deps = { telemetryClient: client };
71
+
72
+ counters.trackAppLaunch({ installChannel: 'npx', appVersion: '0.83.0' }, deps);
73
+ counters.trackSessionOpen(deps);
74
+ counters.trackEpicCreate(deps);
75
+ counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
76
+
77
+ // track() ingress does real (albeit fast) fs I/O before it resolves; wait a
78
+ // tick since the counter functions fire it without awaiting the promise.
79
+ await new Promise((r) => setTimeout(r, 50));
80
+
81
+ const recs = await readQueueLines(client);
82
+ expect(recs.length).toBe(4);
83
+ for (const rec of recs) {
84
+ const meta = asPersistedMetadata(rec);
85
+ expect(meta.appVersion).toBe('0.83.0');
86
+ expect(meta.machineDigest).toBe('deadbeefcafe');
87
+ }
88
+ client.shutdown();
89
+ });
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * crashTelemetry.cjs — the electron-free tap crashDiagnostics.cjs's
5
+ * render-process-gone / child-process-gone hooks and unclean-shutdown
6
+ * postmortem call into. Split out so it's unit-testable under plain vitest —
7
+ * crashDiagnostics.cjs itself requires 'electron' at module scope (it always
8
+ * has; that's why it has never had a unit test), so the tap logic lives here
9
+ * instead of inline in that file.
10
+ *
11
+ * Named 'crash.<reason>' so an OOM report (reason: 'oom'), a renderer segfault
12
+ * (reason: 'crashed'), and the unclean-shutdown postmortem (reason:
13
+ * 'unclean-shutdown') are distinguishable in aggregate without parsing free
14
+ * text. telemetryClient.reportError already stamps the full attribution
15
+ * (appVersion/platform/arch/machineDigest) on every record — that's the
16
+ * whole reason this module exists: these reports are frequently the ONLY
17
+ * thing ever received from a machine that just got OOM-killed, so losing the
18
+ * build/spec attribution on them is close to useless.
19
+ */
20
+
21
+ function reportCrash({ reason, meta, deps } = {}) {
22
+ try {
23
+ const telemetryClient = (deps && deps.telemetryClient) || require('./telemetryClient.cjs');
24
+ // Returned (not awaited here) so callers/tests can await delivery
25
+ // acceptance; telemetryClient.reportError is itself fail-inert, so a
26
+ // rejected/throwing implementation still can't escape this try/catch.
27
+ return telemetryClient.reportError({
28
+ name: `crash.${reason || 'unknown'}`,
29
+ msg: String(reason || 'unknown'),
30
+ context: meta || {},
31
+ });
32
+ } catch {
33
+ return undefined; // crash diagnostics must never break on a telemetry failure
34
+ }
35
+ }
36
+
37
+ module.exports = { reportCrash };
@@ -30,6 +30,8 @@ const PRD_WRITE_GUARD_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'script
30
30
  const PRD_WRITE_GUARD_MATCHER = 'Write|Edit|NotebookEdit';
31
31
  const DESTRUCTIVE_GIT_GUARD_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'scripts', 'hooks', 'guard-destructive-git.cjs');
32
32
  const DESTRUCTIVE_GIT_GUARD_MATCHER = 'Bash';
33
+ const INLINE_IMPLEMENTATION_GUARD_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'scripts', 'hooks', 'guard-inline-implementation.cjs');
34
+ const INLINE_IMPLEMENTATION_GUARD_MATCHER = 'Write|Edit|NotebookEdit';
33
35
  const LIVE_PROBE_TIMEOUT_MS = 10_000;
34
36
  const LIVE_PROBE_TTL_MS = 60_000;
35
37
  const REQUIRED_LIVE_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
@@ -446,6 +448,12 @@ async function installPrdWriteGuard({ cwd }) {
446
448
  // the human has in there.
447
449
  return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
448
450
  }
451
+ if (!fs.existsSync(PRD_WRITE_GUARD_SCRIPT)) {
452
+ // Writing a hook that points at a script that doesn't exist would report
453
+ // installed while silently guarding nothing (a non-zero-without-2 exit is
454
+ // a non-blocking error to the harness) — refuse instead of lying.
455
+ return { ok: false, action: 'error', error: `${PRD_WRITE_GUARD_SCRIPT} does not exist — cannot install a guard hook that points at a missing script` };
456
+ }
449
457
  const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
450
458
 
451
459
  const command = `node ${PRD_WRITE_GUARD_SCRIPT}`;
@@ -546,6 +554,9 @@ async function installDestructiveGitGuard({ cwd }) {
546
554
  if (existing === undefined) {
547
555
  return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
548
556
  }
557
+ if (!fs.existsSync(DESTRUCTIVE_GIT_GUARD_SCRIPT)) {
558
+ return { ok: false, action: 'error', error: `${DESTRUCTIVE_GIT_GUARD_SCRIPT} does not exist — cannot install a guard hook that points at a missing script` };
559
+ }
549
560
  const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
550
561
 
551
562
  const command = `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`;
@@ -585,7 +596,110 @@ async function installDestructiveGitGuard({ cwd }) {
585
596
  }
586
597
 
587
598
  /**
588
- * Runs all seven delegation-readiness checks for `cwd`. Every filesystem read
599
+ * `ok` must mean "a hook that will actually run", not "the settings file
600
+ * mentions guard-inline-implementation somewhere" — same rationale as
601
+ * checkPrdWriteGuard above. Note this guard is deliberately a NUDGE that
602
+ * fails OPEN twice over (see guard-inline-implementation.cjs's header), not
603
+ * an ownership law like guard-prd-writes — this check's label/detail must
604
+ * not imply it's a hard gate.
605
+ */
606
+ function checkInlineImplementationGuard({ cwd }) {
607
+ const settings = readJsonSafe(path.join(cwd, '.claude', 'settings.json'), null);
608
+ const preToolUse = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
609
+
610
+ let mentioned = false;
611
+ let resolvedScript = null;
612
+ for (const matcher of preToolUse) {
613
+ if (!Array.isArray(matcher?.hooks)) continue;
614
+ for (const h of matcher.hooks) {
615
+ if (typeof h?.command !== 'string' || !h.command.includes('guard-inline-implementation')) continue;
616
+ mentioned = true;
617
+ const raw = extractGuardScriptPath(h.command, 'guard-inline-implementation.cjs');
618
+ if (!raw) continue;
619
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
620
+ if (fs.existsSync(abs)) { resolvedScript = abs; break; }
621
+ }
622
+ if (resolvedScript) break;
623
+ }
624
+
625
+ const ok = !!resolvedScript;
626
+ let detail;
627
+ if (ok) detail = `guard-inline-implementation PreToolUse hook found in ${cwd}/.claude/settings.json, resolving to ${resolvedScript}`;
628
+ else if (mentioned) detail = `guard-inline-implementation PreToolUse hook in ${cwd}/.claude/settings.json names a script that does not exist — it would silently guard nothing`;
629
+ else detail = `no guard-inline-implementation PreToolUse hook in ${cwd}/.claude/settings.json — this is a nudge, not a hard gate`;
630
+
631
+ return {
632
+ id: 'inline-implementation-guard',
633
+ label: 'Inline-implementation guard hook installed',
634
+ ok,
635
+ detail,
636
+ fix: ok
637
+ ? null
638
+ : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Write|Edit|NotebookEdit that runs node ${INLINE_IMPLEMENTATION_GUARD_SCRIPT} (a nudge, not a hard gate)`,
639
+ fixAction: ok ? null : 'install-inline-implementation-guard',
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Install the inline-implementation guard into `<cwd>/.claude/settings.json`.
645
+ * Same reference-not-vendor rationale and merge/repair/idempotence behaviour
646
+ * as installPrdWriteGuard above — see that function's header. Unlike
647
+ * installDestructiveGitGuard, this guard shares installPrdWriteGuard's
648
+ * matcher (`Write|Edit|NotebookEdit`), so this appends into that SAME
649
+ * matcher's `hooks` array when it already exists, rather than pushing a new
650
+ * matcher entry.
651
+ */
652
+ async function installInlineImplementationGuard({ cwd }) {
653
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
654
+ addAllowedRoot(cwd);
655
+
656
+ const existing = fs.existsSync(settingsPath) ? readJsonSafe(settingsPath, undefined) : {};
657
+ if (existing === undefined) {
658
+ return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
659
+ }
660
+ if (!fs.existsSync(INLINE_IMPLEMENTATION_GUARD_SCRIPT)) {
661
+ return { ok: false, action: 'error', error: `${INLINE_IMPLEMENTATION_GUARD_SCRIPT} does not exist — cannot install a guard hook that points at a missing script` };
662
+ }
663
+ const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
664
+
665
+ const command = `node ${INLINE_IMPLEMENTATION_GUARD_SCRIPT}`;
666
+ const hooks = (settings.hooks && typeof settings.hooks === 'object') ? settings.hooks : {};
667
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.slice() : [];
668
+
669
+ if (checkInlineImplementationGuard({ cwd }).ok) {
670
+ return { ok: true, action: 'already-installed', settingsPath, command };
671
+ }
672
+
673
+ let repaired = false;
674
+ for (const matcher of preToolUse) {
675
+ if (!Array.isArray(matcher?.hooks)) continue;
676
+ for (const h of matcher.hooks) {
677
+ if (typeof h?.command === 'string' && h.command.includes('guard-inline-implementation')) {
678
+ h.command = command;
679
+ h.type = 'command';
680
+ repaired = true;
681
+ }
682
+ }
683
+ }
684
+
685
+ if (!repaired) {
686
+ const target = preToolUse.find((m) => m?.matcher === INLINE_IMPLEMENTATION_GUARD_MATCHER);
687
+ if (target) {
688
+ target.hooks = Array.isArray(target.hooks) ? target.hooks : [];
689
+ target.hooks.push({ type: 'command', command });
690
+ } else {
691
+ preToolUse.push({ matcher: INLINE_IMPLEMENTATION_GUARD_MATCHER, hooks: [{ type: 'command', command }] });
692
+ }
693
+ }
694
+
695
+ const next = { ...settings, hooks: { ...hooks, PreToolUse: preToolUse } };
696
+ await writeJson(settingsPath, next);
697
+
698
+ return { ok: true, action: repaired ? 'repaired' : 'installed', settingsPath, command };
699
+ }
700
+
701
+ /**
702
+ * Runs all eight delegation-readiness checks for `cwd`. Every filesystem read
589
703
  * is wrapped (readJsonSafe / try-catch) so a missing or unparseable file
590
704
  * yields ok:false with a detail, never a thrown exception. Async because
591
705
  * scheduler-mcp-live genuinely spawns a process and waits on it (bounded by
@@ -608,6 +722,7 @@ async function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
608
722
  checkAgentPersonas({ cwd, homeDir }),
609
723
  checkPrdWriteGuard({ cwd }),
610
724
  checkDestructiveGitGuard({ cwd }),
725
+ checkInlineImplementationGuard({ cwd }),
611
726
  ].map((c) => ({ fixAction: null, warn: false, ...c }));
612
727
 
613
728
  return {
@@ -620,10 +735,13 @@ module.exports = {
620
735
  checkDelegationReadiness,
621
736
  installPrdWriteGuard,
622
737
  installDestructiveGitGuard,
738
+ installInlineImplementationGuard,
623
739
  probeSchedulerMcpLive,
624
740
  clearLiveProbeCache,
625
741
  PRD_WRITE_GUARD_SCRIPT,
626
742
  PRD_WRITE_GUARD_MATCHER,
627
743
  DESTRUCTIVE_GIT_GUARD_SCRIPT,
628
744
  DESTRUCTIVE_GIT_GUARD_MATCHER,
745
+ INLINE_IMPLEMENTATION_GUARD_SCRIPT,
746
+ INLINE_IMPLEMENTATION_GUARD_MATCHER,
629
747
  };
@@ -35,6 +35,7 @@ const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
35
35
  const { assertOpsWrite } = require('./opsOwnership.cjs');
36
36
  const { appendAuditEvent } = require('./auditLog.cjs');
37
37
  const { mirrorEpicStatus, removeEpicMirror } = require('./epicStatusMirror.cjs');
38
+ const telemetryCounters = require('./telemetryCounters.cjs');
38
39
  // Required as the module object (not destructured) so a test can
39
40
  // monkeypatch promptSessionSchema.assertValidPromptSession in place to
40
41
  // simulate a corrupted construction — the real construction below is
@@ -380,6 +381,7 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
380
381
 
381
382
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
382
383
  fs.mkdirSync(prdDir, { recursive: true });
384
+ telemetryCounters.trackEpicCreate();
383
385
  return { epicId, prdDir, created: true };
384
386
  });
385
387
  }