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
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * telemetryClient — the single egress module that ships telemetry to
3
- * bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error).
3
+ * bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error,
4
+ * /install). The `install` channel is a durable upsert keyed on install_id
5
+ * (app_installs), not an append like the other three — its wire body is a
6
+ * single flat snake_case object, never `{batch:[...]}` — so it is routed
7
+ * through the same queue/dedup/backoff machinery but sent one record at a
8
+ * time via sendSingle() rather than sendBatch().
4
9
  *
5
10
  * Records accumulate durably in ~/.config/session-manager/telemetry-queue.jsonl
6
11
  * the instant they're accepted, and are only ever sent by flush(reason) — on
@@ -83,14 +88,42 @@ const S = {
83
88
  errSeen: new Map(),
84
89
  dailyTimer: null,
85
90
  flushChain: Promise.resolve(),
91
+ lastError: null,
92
+ lastFlushAt: null,
93
+ lastFlushReason: null,
86
94
  };
87
95
 
96
+ /**
97
+ * SM_TELEMETRY_SPOOL, when set, overrides the spool directory in place of
98
+ * `~/.config/session-manager` — the explicit opt-in a test that genuinely
99
+ * needs to exercise real queue/sent-file I/O uses to prove it isn't about to
100
+ * write into a real user's spool (see isTestEnvironment() below).
101
+ */
102
+ function spoolDir() {
103
+ return process.env.SM_TELEMETRY_SPOOL || path.join(os.homedir(), '.config', 'session-manager');
104
+ }
105
+
88
106
  function queuePath() {
89
- return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-queue.jsonl');
107
+ return path.join(spoolDir(), 'telemetry-queue.jsonl');
90
108
  }
91
109
 
92
110
  function sentPath() {
93
- return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-sent.json');
111
+ return path.join(spoolDir(), 'telemetry-sent.json');
112
+ }
113
+
114
+ /**
115
+ * True when running under a test runner (VITEST, NODE_ENV=test) with no
116
+ * explicit SM_TELEMETRY_SPOOL override. Gates appendRecord() below so a test
117
+ * that merely exercises epicMint/scheduler/etc. code paths — without itself
118
+ * setting up an isolated spool — can never accumulate fake records into a
119
+ * real user's telemetry-queue.jsonl. A test that deliberately wants to
120
+ * exercise real persistence (telemetryClient's own suite) sets
121
+ * SM_TELEMETRY_SPOOL to an isolated directory, which counts as an explicit
122
+ * opt-in and disables this guard.
123
+ */
124
+ function isTestEnvironment() {
125
+ if (process.env.SM_TELEMETRY_SPOOL) return false;
126
+ return !!process.env.VITEST || process.env.NODE_ENV === 'test';
94
127
  }
95
128
 
96
129
  function getBeaconToken() {
@@ -365,6 +398,9 @@ function pushRecent(rec) {
365
398
  }
366
399
 
367
400
  async function appendRecord(channel, wire, recordId) {
401
+ if (isTestEnvironment()) {
402
+ return { accepted: false, reason: 'test-environment', recordId };
403
+ }
368
404
  if (S.queueIds.has(recordId) || S.sentIds.has(recordId)) {
369
405
  S.dedupedAppends += 1;
370
406
  return { accepted: false, reason: 'duplicate', recordId };
@@ -472,6 +508,47 @@ async function reportError(opts) {
472
508
  }
473
509
  }
474
510
 
511
+ function buildInstallWire(profile, installId) {
512
+ const p = safeObj(profile);
513
+ return {
514
+ install_id: safeStr(installId, 80),
515
+ app: 'session-manager',
516
+ app_version: safeStr(p.appVersion, 20),
517
+ platform: safeStr(p.platform, 20),
518
+ os_release: safeStr(p.osRelease, 80),
519
+ arch: safeStr(p.arch, 20),
520
+ cpu_count: typeof p.cpuCount === 'number' ? p.cpuCount : 0,
521
+ total_mem_mb: typeof p.totalMemMb === 'number' ? p.totalMemMb : 0,
522
+ node_version: safeStr(p.nodeVersion, 20),
523
+ electron_version: safeStr(p.electronVersion, 20),
524
+ install_channel: safeStr(p.installChannel, 20),
525
+ locale: safeStr(p.locale, 20),
526
+ // machineProfile deliberately never carries an IANA timezone name — only
527
+ // the numeric UTC offset (see machineProfile.cjs) — so that is what maps
528
+ // onto the server's `timezone` column.
529
+ timezone: safeStr(p.timezoneOffsetMinutes, 20),
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Reports the once-per-cadence install/liveness record. Unlike track/logLine/
535
+ * reportError this isn't arbitrary user content, so it skips redactDeep() —
536
+ * every field already comes from machineProfile's own anonymity contract —
537
+ * and it never merges an attribution block onto the body, since the server's
538
+ * app_installs upsert doesn't read a recordId.
539
+ */
540
+ async function reportInstall(profile) {
541
+ try {
542
+ await ensureInit();
543
+ if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
544
+ const recordId = crypto.randomUUID();
545
+ const wire = buildInstallWire(profile, S.settings.installId);
546
+ return await appendRecord('install', wire, recordId);
547
+ } catch {
548
+ return { accepted: false, reason: 'error' };
549
+ }
550
+ }
551
+
475
552
  // ─── egress ────────────────────────────────────────────────────────────
476
553
 
477
554
  async function sendBatch(channel, wireBatch) {
@@ -493,16 +570,42 @@ async function sendBatch(channel, wireBatch) {
493
570
  }
494
571
  }
495
572
 
573
+ /** Like sendBatch(), but posts `wire` as a flat body — the install route takes one upsert, never a `{batch:[...]}` envelope. */
574
+ async function sendSingle(channel, wire) {
575
+ const fetchFn = S.fetchImpl || (typeof fetch === 'function' ? fetch : null);
576
+ if (typeof fetchFn !== 'function') return { ok: false, status: 0 };
577
+ const base = telemetrySettings.resolveEndpoint(S.settings);
578
+ const url = `${base}/api/telemetry/${channel}`;
579
+ const headers = {
580
+ 'Content-Type': 'application/json',
581
+ 'X-SM-Beacon': `session-manager/${S.profile.appVersion}`,
582
+ 'X-SM-Beacon-Key': getBeaconToken(),
583
+ };
584
+ try {
585
+ const res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify(wire) });
586
+ if (res && res.ok) return { ok: true };
587
+ return { ok: false, status: res ? res.status : 0 };
588
+ } catch {
589
+ return { ok: false, status: 0 };
590
+ }
591
+ }
592
+
593
+ function describeFailureStatus(status) {
594
+ return status === 0 ? 'network error' : `HTTP ${status}`;
595
+ }
596
+
496
597
  function applyFailureBackoff(status) {
497
598
  if (status === 429 || status === 0 || status >= 500) {
498
599
  S.consecutiveFailures += 1;
499
600
  const ms = Math.min(BACKOFF_BASE_MS * (2 ** (S.consecutiveFailures - 1)), BACKOFF_MAX_MS);
500
601
  S.backoffUntil = Date.now() + ms;
602
+ S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
501
603
  return;
502
604
  }
503
605
  if (status >= 400 && status < 500) {
504
606
  // Malformed-contract circuit breaker (includes 401) — not retried.
505
607
  S.disabledForProcess = true;
608
+ S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
506
609
  }
507
610
  }
508
611
 
@@ -528,21 +631,41 @@ async function flushImpl(reason) {
528
631
  if (S.disabledForProcess) return result;
529
632
  if (Date.now() < S.backoffUntil) return result;
530
633
 
531
- const byChannel = { event: [], log: [], error: [] };
634
+ const byChannel = { event: [], log: [], error: [], install: [] };
532
635
  for (const r of S.queue) {
533
636
  if (byChannel[r.channel]) byChannel[r.channel].push(r);
534
637
  }
535
638
 
536
639
  let sawFailure = false;
537
- for (const channel of ['event', 'log', 'error']) {
640
+ for (const channel of ['event', 'log', 'error', 'install']) {
538
641
  if (sawFailure) break;
539
642
  const records = byChannel[channel];
643
+ if (channel === 'install') {
644
+ for (const r of records) {
645
+ const res = await sendSingle('install', r.wire);
646
+ if (res.ok) {
647
+ S.consecutiveFailures = 0;
648
+ S.backoffUntil = 0;
649
+ S.lastError = null;
650
+ removeFromQueue(r.recordId);
651
+ addToSent(r.recordId);
652
+ result.sent.push(r.recordId);
653
+ } else {
654
+ result.failed.push(r.recordId);
655
+ applyFailureBackoff(res.status);
656
+ sawFailure = true;
657
+ break;
658
+ }
659
+ }
660
+ continue;
661
+ }
540
662
  for (let i = 0; i < records.length; i += MAX_BATCH) {
541
663
  const batch = records.slice(i, i + MAX_BATCH);
542
664
  const res = await sendBatch(channel, batch.map((r) => r.wire));
543
665
  if (res.ok) {
544
666
  S.consecutiveFailures = 0;
545
667
  S.backoffUntil = 0;
668
+ S.lastError = null;
546
669
  for (const r of batch) {
547
670
  removeFromQueue(r.recordId);
548
671
  addToSent(r.recordId);
@@ -564,6 +687,8 @@ async function flushImpl(reason) {
564
687
  S.settings = await telemetrySettings.save({ ...S.settings, lastDailyFlushAt: new Date().toISOString() });
565
688
  }
566
689
  } catch { /* fail-inert */ }
690
+ S.lastFlushAt = Date.now();
691
+ S.lastFlushReason = safeReason;
567
692
  return result;
568
693
  }
569
694
 
@@ -584,6 +709,9 @@ function status() {
584
709
  consecutiveFailures: S.consecutiveFailures,
585
710
  backoffUntil: S.backoffUntil,
586
711
  profileBuildCount: S.profileBuildCount,
712
+ lastError: S.lastError,
713
+ lastFlushAt: S.lastFlushAt,
714
+ lastFlushReason: S.lastFlushReason,
587
715
  };
588
716
  }
589
717
 
@@ -591,6 +719,21 @@ function recentRecords() {
591
719
  return S.recent.slice();
592
720
  }
593
721
 
722
+ /**
723
+ * Opt-out side effect: drops every not-yet-sent record from disk + memory so
724
+ * a re-enable later never resends a payload that was only ever queued while
725
+ * the user was opted out. Does NOT touch telemetry-sent.json or the backlog
726
+ * watermarks — those describe history that already left the machine, and
727
+ * clearing them would make a re-enable resend it. Never throws.
728
+ */
729
+ async function clearQueue() {
730
+ S.queue = [];
731
+ S.queueIds = new Set();
732
+ try {
733
+ await config.writeTextAtomic(queuePath(), '', { mode: 0o600 });
734
+ } catch { /* best-effort */ }
735
+ }
736
+
594
737
  async function isPending(recordId) {
595
738
  await ensureInit();
596
739
  return S.queueIds.has(recordId);
@@ -610,11 +753,13 @@ module.exports = {
610
753
  track,
611
754
  logLine,
612
755
  reportError,
756
+ reportInstall,
613
757
  flush,
614
758
  shutdown,
615
759
  status,
616
760
  recentRecords,
617
761
  isPending,
762
+ clearQueue,
618
763
  queuePath,
619
764
  sentPath,
620
765
  _setFetchImpl,
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * telemetryConsent — the write-path the `telemetry:set-config` IPC handler
5
+ * delegates to, pulled out of src/main/index.cjs so it can be unit-tested
6
+ * without an Electron runtime (index.cjs requires('electron') at module
7
+ * load, which vitest can't satisfy).
8
+ *
9
+ * The one side effect beyond a plain settings save: turning telemetry OFF
10
+ * clears the pending queue file, so a later re-enable never resends a
11
+ * payload that only ever accumulated while the user was opted out. It does
12
+ * NOT touch telemetry-sent.json or the backlog watermarks — those describe
13
+ * history already delivered, and clearing them would make a re-enable
14
+ * resend it. See session-manager-operations/architecture/telemetry.md.
15
+ */
16
+
17
+ function resolveDeps(deps = {}) {
18
+ return {
19
+ telemetrySettings: deps.telemetrySettings || require('./telemetrySettings.cjs'),
20
+ telemetryClient: deps.telemetryClient || require('./telemetryClient.cjs'),
21
+ };
22
+ }
23
+
24
+ async function applyConsentUpdate(cfg, depsOverride) {
25
+ const deps = resolveDeps(depsOverride);
26
+ const before = await deps.telemetrySettings.load();
27
+ const saved = await deps.telemetrySettings.save(cfg);
28
+ if (before.enabled && !saved.enabled) {
29
+ await deps.telemetryClient.clearQueue();
30
+ }
31
+ return saved;
32
+ }
33
+
34
+ module.exports = { applyConsentUpdate };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * telemetryCounters.cjs — the ONE place every usage counter event is emitted
5
+ * from, so 'session.open'/'epic.create'/'scheduler.job.finish'/'app.launch'
6
+ * each have exactly one call site's worth of shape logic even though they're
7
+ * fired from four different subsystems (pty.cjs, epicMint.cjs,
8
+ * scheduleJobTransitions.cjs, telemetryBoot.cjs).
9
+ *
10
+ * Structural props only — no free text, no cwd, no path — so a counter event
11
+ * can never carry a prompt/transcript/project-name leak by accident. Lazily
12
+ * requires telemetryClient.cjs (mirrors opsErrorLog.cjs's lazy require of
13
+ * opsOwnership.cjs) so this module stays Electron-free and importable from a
14
+ * plain-Node test.
15
+ */
16
+
17
+ function track(name, props, deps) {
18
+ try {
19
+ const telemetryClient = (deps && deps.telemetryClient) || require('./telemetryClient.cjs');
20
+ telemetryClient.track(name, props);
21
+ } catch { /* counters must never break the caller */ }
22
+ }
23
+
24
+ function trackAppLaunch({ installChannel, appVersion } = {}, deps) {
25
+ track('app.launch', { installChannel: installChannel || null, appVersion: appVersion || null }, deps);
26
+ }
27
+
28
+ function trackSessionOpen(deps) {
29
+ track('session.open', {}, deps);
30
+ }
31
+
32
+ function trackEpicCreate(deps) {
33
+ track('epic.create', {}, deps);
34
+ }
35
+
36
+ function trackSchedulerJobFinish({ status } = {}, deps) {
37
+ track('scheduler.job.finish', { status: status || null }, deps);
38
+ }
39
+
40
+ module.exports = {
41
+ trackAppLaunch,
42
+ trackSessionOpen,
43
+ trackEpicCreate,
44
+ trackSchedulerJobFinish,
45
+ };
package/src/main/pty.cjs CHANGED
@@ -22,6 +22,7 @@ const { checkInsideHome } = require('./lib/insideHome.cjs');
22
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
23
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
24
24
  const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
25
+ const telemetryCounters = require('./lib/telemetryCounters.cjs');
25
26
 
26
27
  // Absolute path to the installed package root (src/main/ -> ../../), shown in
27
28
  // the remediation message so the user can cd there and rebuild.
@@ -200,6 +201,7 @@ class PtyManager {
200
201
  return { pid: null, cwd, reattached: false, error: String(err?.message || 'spawn-failed') };
201
202
  }
202
203
  console.log('[pty] spawned pid=', proc.pid, 'for tabId=', tabId);
204
+ telemetryCounters.trackSessionOpen();
203
205
 
204
206
  const spawnedAt = Date.now();
205
207
  let gotData = false;