claude-code-session-manager 0.62.0 → 0.63.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 (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -74,6 +74,34 @@ class LRUCache {
74
74
  */
75
75
  const aggrCache = new LRUCache(CACHE_MAX);
76
76
 
77
+ // ── bounded concurrency helper ───────────────────────────────────────────────
78
+
79
+ /**
80
+ * Run async fn(item) over items with at most `limit` in flight at once, via a
81
+ * pull-based worker pool (not chunked batches, so a slow item never stalls
82
+ * the rest of a batch). Node's fs promise APIs are backed by libuv's
83
+ * fixed-size threadpool (UV_THREADPOOL_SIZE, default 4) — firing every call
84
+ * at once via a single Promise.all still queues behind that pool one way or
85
+ * another, so bounding `limit` mainly avoids building thousands of pending
86
+ * promises/Buffers at once for a 25k+-file walk. Results are returned in
87
+ * input order; a thrown fn(item) rejects the whole call (callers here always
88
+ * catch inside fn instead).
89
+ */
90
+ async function mapWithConcurrency(items, limit, fn) {
91
+ const results = new Array(items.length);
92
+ let next = 0;
93
+ async function worker() {
94
+ for (;;) {
95
+ const i = next++;
96
+ if (i >= items.length) return;
97
+ results[i] = await fn(items[i], i);
98
+ }
99
+ }
100
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker);
101
+ await Promise.all(workers);
102
+ return results;
103
+ }
104
+
77
105
  // ── date helpers ──────────────────────────────────────────────────────────────
78
106
 
79
107
  function decodeCwd(encoded) {
@@ -558,63 +586,171 @@ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
558
586
  return { finalizedDates: partial ? [] : Array.from(dates), partial };
559
587
  }
560
588
 
589
+ // ── intraday fast-walk state ──────────────────────────────────────────────────
590
+ //
591
+ // Two persistent (in-memory only, process-lifetime) caches make repeat ticks
592
+ // cheap:
593
+ //
594
+ // 1. `intradayDirRegistry` — encodedCwd -> { mtimeMs, fileNames }. Verified
595
+ // empirically on this machine (ext4): appending to an EXISTING file does
596
+ // NOT change its parent directory's mtime — only creating/removing/
597
+ // renaming a directory entry does. So a directory's mtime is a safe signal
598
+ // for "no file was added or removed here since last tick", letting us skip
599
+ // re-`readdir()`ing it (2,044 directories on the dev machine — this alone
600
+ // doesn't bound the file-level stat count).
601
+ //
602
+ // 2. `intradayNotToday` — a permanent Set<filePath> of files CONFIRMED to
603
+ // belong to a day other than today, so they can never contribute to
604
+ // today's bucket again. This is the lever that makes the walk actually
605
+ // proportional to files modified today, and it's safe only because of an
606
+ // existing, unrelated invariant this PRD does not change: a transcript's
607
+ // `sessionDate` is fixed by its FIRST message's timestamp and never
608
+ // updates on later appends (see parseJSONL's cache — `merged.sessionDate
609
+ // = prev.sessionDate`), so `aggregate()`/`refreshIntradayToday()` already
610
+ // attribute 100% of a file's activity to its original day no matter how
611
+ // many later days it's appended on. A file whose mtime is confirmed to
612
+ // predate today can have no content newer than that mtime, so its
613
+ // sessionDate can't be today either — that classification is exclusion
614
+ // forever, not just for the rest of today. Combined with #1 (a file
615
+ // relevant to today must have been CREATED today, which does bump its
616
+ // directory's mtime and is therefore always discovered), the walk over
617
+ // time shrinks to: files still active today (small, must keep re-stat-ing
618
+ // to catch growth) + any brand-new file since the last tick. Everything
619
+ // else is skipped without even a stat(2) call. Remaining stat/readdir
620
+ // calls run with bounded concurrency instead of one sequential await per
621
+ // call, since that was the other half of the sequential-walk cost
622
+ // (29,625 stats + 2,044 readdirs, 589ms measured on this machine).
623
+ let intradayDirRegistry = new Map(); // encodedCwd -> { mtimeMs, fileNames: string[] }
624
+ let intradayNotToday = new Set(); // filePath -> confirmed not-today, forever
625
+
626
+ const INTRADAY_STAT_CONCURRENCY = 64;
627
+ const INTRADAY_STRATEGY = 'directory-mtime skips re-readdir of unchanged dirs (new-file detection); ' +
628
+ 'a file confirmed to belong to a past day (mtime, then sessionDate) is excluded from every future tick ' +
629
+ '(sessionDate is pinned to a transcript\'s first message and never changes on later appends); ' +
630
+ `remaining stat/readdir calls run with bounded concurrency (${INTRADAY_STAT_CONCURRENCY} in flight)`;
631
+
632
+ /** Test-only: clears the in-memory directory/exclusion registries between fixtures. */
633
+ function resetIntradayRegistryForTests() {
634
+ intradayDirRegistry = new Map();
635
+ intradayNotToday = new Set();
636
+ }
637
+
561
638
  /**
562
- * Refresh TODAY's rollup buckets from a live (LRU-warm, cheap) parse of every
563
- * transcript touched today, and upsert them as provisional lines (v2, no
564
- * finalizedAt) so the History dashboard's "today" row stays current without
565
- * the renderer ever triggering a transcript scan itself. Meant to be called
566
- * on a timer (HISTORY_INTRADAY_REFRESH_MS) plus once at app start.
567
- *
568
- * readRollup/appendRollupDays last-write-wins per (date, projectDir, modelId)
569
- * key, so re-running this simply replaces today's previous provisional lines
570
- * with fresher ones — no separate "clear provisional" step needed.
639
+ * Pure walk-and-parse step (no rollup write) — split out from
640
+ * refreshIntradayToday() so it can be benchmarked read-only against the real
641
+ * ~/.claude/projects (see scripts/bench-intraday-walk.cjs) without touching
642
+ * the rollup file on disk.
571
643
  */
572
- async function refreshIntradayToday() {
644
+ async function computeIntradayBuckets() {
573
645
  const today = localDate(new Date());
574
646
 
575
647
  let projectDirs;
576
648
  try {
577
649
  projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
578
650
  } catch {
579
- return { date: today, projectsUpdated: 0 };
651
+ return { today, buckets: new Map() };
580
652
  }
581
653
 
582
- const buckets = new Map();
583
- for (const projEntry of projectDirs) {
584
- if (!projEntry.isDirectory()) continue;
585
- const encodedCwd = projEntry.name;
586
- const projectDir = path.join(PROJECTS_DIR, encodedCwd);
654
+ const dirEntries = projectDirs.filter((e) => e.isDirectory());
587
655
 
588
- let files;
589
- try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
656
+ // Directory count (thousands) is far smaller than file count (tens of
657
+ // thousands), so stat-ing every directory concurrently is cheap and tells
658
+ // us which ones had an entry added/removed since we last looked.
659
+ const dirStats = await mapWithConcurrency(dirEntries, INTRADAY_STAT_CONCURRENCY, async (entry) => {
660
+ const dirPath = path.join(PROJECTS_DIR, entry.name);
661
+ try {
662
+ const stat = await fsp.stat(dirPath);
663
+ return { encodedCwd: entry.name, dirPath, mtimeMs: stat.mtimeMs };
664
+ } catch {
665
+ return null;
666
+ }
667
+ });
590
668
 
591
- for (const fileEntry of files) {
592
- if (!fileEntry.name.endsWith('.jsonl')) continue;
593
- const filePath = path.join(projectDir, fileEntry.name);
669
+ // Resolve the jsonl file list per directory — reuse the cached list (no
670
+ // readdir syscall) when the directory's mtime hasn't moved since the last
671
+ // tick; re-enumerate only directories that are new or changed. Files
672
+ // already known to be irrelevant to any future "today" are dropped here so
673
+ // they never reach the stat step below.
674
+ const fileCandidates = []; // { filePath, encodedCwd }
675
+ await mapWithConcurrency(dirStats.filter(Boolean), INTRADAY_STAT_CONCURRENCY, async (d) => {
676
+ const cached = intradayDirRegistry.get(d.encodedCwd);
677
+ let fileNames;
678
+ if (cached && cached.mtimeMs === d.mtimeMs) {
679
+ fileNames = cached.fileNames;
680
+ } else {
681
+ let files;
682
+ try { files = await fsp.readdir(d.dirPath, { withFileTypes: true }); } catch { files = []; }
683
+ fileNames = files.filter((f) => f.isFile() && f.name.endsWith('.jsonl')).map((f) => f.name);
684
+ intradayDirRegistry.set(d.encodedCwd, { mtimeMs: d.mtimeMs, fileNames });
685
+ }
686
+ for (const name of fileNames) {
687
+ const filePath = path.join(d.dirPath, name);
688
+ if (intradayNotToday.has(filePath)) continue;
689
+ fileCandidates.push({ filePath, encodedCwd: d.encodedCwd });
690
+ }
691
+ });
594
692
 
595
- let stat;
596
- try { stat = await fsp.stat(filePath); } catch { continue; }
693
+ // Known caveat: parseJSONL separately detects a same-path file replacement
694
+ // (inode change — e.g. a `claude --resume` compaction rewriting a
695
+ // transcript from scratch) and re-parses it in full. If that happens to a
696
+ // path already in intradayNotToday, this walk will never look at it again
697
+ // to notice its (hypothetical) new sessionDate, since exclusion here skips
698
+ // the stat entirely. This is safe in practice: the History dashboard's
699
+ // actual reads (aggregate()) don't consult this cache at all and always
700
+ // re-walk live for "today", so the only blast radius is a stale PROVISIONAL
701
+ // rollup line for the affected project until the next app restart resets
702
+ // this cache — never a wrong number on the dashboard itself.
703
+ const buckets = new Map();
704
+ await mapWithConcurrency(fileCandidates, INTRADAY_STAT_CONCURRENCY, async ({ filePath, encodedCwd }) => {
705
+ let stat;
706
+ try { stat = await fsp.stat(filePath); } catch { return; }
707
+
708
+ // A file whose mtime is before today can't have any content from today —
709
+ // and per the sessionDate-pinning invariant above, can never gain any —
710
+ // so it's excluded forever, with no parse ever needed.
711
+ const mtimeDate = localDate(new Date(stat.mtimeMs));
712
+ if (mtimeDate < today) {
713
+ intradayNotToday.add(filePath);
714
+ return;
715
+ }
597
716
 
598
- // A file whose mtime is before today can't have been touched today —
599
- // skip it without even reading it (matches aggregate()'s fast-skip).
600
- const mtimeDate = localDate(new Date(stat.mtimeMs));
601
- if (mtimeDate < today) continue;
717
+ const { result: parsed } = await parseJSONL(filePath, stat);
718
+ if (parsed.skipped) return;
719
+ if (parsed.sessionDate !== today) {
720
+ intradayNotToday.add(filePath);
721
+ return;
722
+ }
602
723
 
603
- const { result: parsed } = await parseJSONL(filePath, stat);
604
- if (parsed.skipped) continue;
605
- if (parsed.sessionDate !== today) continue;
724
+ const key = `${today}|${encodedCwd}`;
725
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
726
+ foldParsedIntoBucket(buckets.get(key), parsed);
727
+ });
606
728
 
607
- const key = `${today}|${encodedCwd}`;
608
- if (!buckets.has(key)) buckets.set(key, emptyBucket(today, encodedCwd));
609
- foldParsedIntoBucket(buckets.get(key), parsed);
610
- }
611
- }
729
+ return { today, buckets };
730
+ }
731
+
732
+ /**
733
+ * Refresh TODAY's rollup buckets from a live (LRU-warm, cheap) parse of every
734
+ * transcript touched today, and upsert them as provisional lines (v2, no
735
+ * finalizedAt) so the History dashboard's "today" row stays current without
736
+ * the renderer ever triggering a transcript scan itself. Meant to be called
737
+ * on a timer (HISTORY_INTRADAY_REFRESH_MS) plus once at app start.
738
+ *
739
+ * readRollup/appendRollupDays last-write-wins per (date, projectDir, modelId)
740
+ * key, so re-running this simply replaces today's previous provisional lines
741
+ * with fresher ones — no separate "clear provisional" step needed.
742
+ *
743
+ * See INTRADAY_STRATEGY / intradayDirRegistry's comment above for why this is
744
+ * both faster and no less correct than the old unconditional full walk.
745
+ */
746
+ async function refreshIntradayToday() {
747
+ const { today, buckets } = await computeIntradayBuckets();
612
748
 
613
749
  const entries = [];
614
750
  for (const b of buckets.values()) entries.push(...bucketToRollupLines(b));
615
751
  if (entries.length) await historyRollup.appendRollupDays(entries);
616
752
 
617
- return { date: today, projectsUpdated: buckets.size };
753
+ return { date: today, projectsUpdated: buckets.size, strategy: INTRADAY_STRATEGY };
618
754
  }
619
755
 
620
756
  // ── aggregate ─────────────────────────────────────────────────────────────────
@@ -821,6 +957,7 @@ module.exports = {
821
957
  MODEL_PRICING,
822
958
  finalizeClosedDays,
823
959
  refreshIntradayToday,
960
+ computeIntradayBuckets,
824
961
  // exported for tests
825
962
  scanAggrLines,
826
963
  parseJSONL,
@@ -828,6 +965,9 @@ module.exports = {
828
965
  CACHE_MAX,
829
966
  LRUCache,
830
967
  computeActiveMinutes,
968
+ mapWithConcurrency,
969
+ resetIntradayRegistryForTests,
970
+ INTRADAY_STAT_CONCURRENCY,
831
971
  // exported for historyDashboard.cjs (pure, no I/O at require-time; reused
832
972
  // rather than re-implemented so date-range math stays in one place)
833
973
  localDate,
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, nativeImage, powerSaveBlocker, protocol } = require('electron');
1
+ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
2
2
  const { spawn, execFile, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
@@ -9,8 +9,6 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
10
10
  const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
11
11
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
12
- const browserView = require('./browserView.cjs');
13
- const browserCapture = require('./browserCapture.cjs');
14
12
  const configMgr = require('./config.cjs');
15
13
  const transcripts = require('./transcripts.cjs');
16
14
  const sessionsStore = require('./sessionsStore.cjs');
@@ -40,13 +38,6 @@ const adminHttp = createAdminHttp();
40
38
  scheduler.registerAdminRoutes(adminHttp);
41
39
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
42
40
  chatRunner.registerAdminRoute(adminHttp);
43
- const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
44
- const browserAgentServer = createBrowserAgentServer({
45
- listTabs: () => browserView.listViews(),
46
- screenshot: ({ viewId }) => browserView.captureShotForAgent({ viewId }),
47
- action: (params) => browserView.dispatchViewAction(params),
48
- dom: ({ viewId }) => browserView.captureAgentDom({ viewId }),
49
- });
50
41
  const supervisor = require('./supervisor.cjs');
51
42
  const watchers = require('./watchers.cjs');
52
43
  const teams = require('./teams.cjs');
@@ -67,9 +58,9 @@ const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
67
58
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
68
59
  const agentMemory = require('./agentMemory.cjs');
69
60
  const git = require('./git.cjs');
61
+ const heapSnapshot = require('./heapSnapshot.cjs');
70
62
  const filesIpc = require('./files.cjs');
71
63
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
72
- const webRemote = require('./webRemote.cjs');
73
64
  const { listExchanges } = require('./exchanges.cjs');
74
65
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
75
66
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
@@ -306,8 +297,6 @@ async function rebootApp() {
306
297
  ptyManager.attachWindow(mainWindow);
307
298
  configMgr.attachWindow(mainWindow);
308
299
  transcripts.attachWindow(mainWindow);
309
- browserView.attachWindow(mainWindow);
310
- browserCapture.attachWindow(mainWindow);
311
300
  voiceHotkey.init(mainWindow).catch((e) => {
312
301
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
313
302
  });
@@ -506,25 +495,6 @@ ipcMain.handle('app:pick-directory', async () => {
506
495
 
507
496
  ipcMain.on('app:reboot-app', () => rebootApp());
508
497
 
509
- // Recorder export "Save to file…" (PRD 412) — native Save As dialog, written
510
- // directly since the path is user-chosen, not renderer input.
511
- ipcMain.handle('browser:save-recording', validated(schemas.browserSaveRecording, async ({ defaultName, text }) => {
512
- try {
513
- const result = await dialog.showSaveDialog(mainWindow, {
514
- defaultPath: defaultName,
515
- filters: [
516
- { name: 'Markdown', extensions: ['md'] },
517
- { name: 'All Files', extensions: ['*'] },
518
- ],
519
- });
520
- if (result.canceled || !result.filePath) return { ok: false, canceled: true };
521
- await fsp.writeFile(result.filePath, text, 'utf8');
522
- return { ok: true, path: result.filePath };
523
- } catch (e) {
524
- return { ok: false, error: e && e.message ? e.message : String(e) };
525
- }
526
- }));
527
-
528
498
  // Image paste — Ctrl+V in the Terminal pane. Reads the OS clipboard via
529
499
  // Electron's native API (renderer's navigator.clipboard.read() doesn't expose
530
500
  // raw image MIME types under contextIsolation), saves the bitmap to a temp
@@ -570,19 +540,6 @@ ipcMain.handle('clipboard:write-text', validated(schemas.clipboardWriteText, ({
570
540
  }
571
541
  }));
572
542
 
573
- // PRD 407 Capture panel — write side of paste-image's read. Writes a
574
- // screenshot capture to the OS clipboard as an image.
575
- ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
576
- try {
577
- const img = nativeImage.createFromDataURL(dataUrl);
578
- if (!img || img.isEmpty()) return { ok: false, error: 'empty image' };
579
- clipboard.writeImage(img);
580
- return { ok: true };
581
- } catch (e) {
582
- return { ok: false, error: e && e.message ? e.message : String(e) };
583
- }
584
- }));
585
-
586
543
  // Hooks tab "Test fire": run a hook command with a fake event payload piped
587
544
  // to stdin. shell:true is intentional — Claude Code's hook field is a shell
588
545
  // string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
@@ -841,7 +798,6 @@ agentMemory.registerAgentMemoryHandlers();
841
798
  git.register(ipcMain);
842
799
  filesIpc.registerFilesHandlers();
843
800
  registerDocEditHandlers();
844
- webRemote.registerRemoteHandlers();
845
801
  chatRunner.registerChatHandlers();
846
802
 
847
803
  // Direct in-process PRD authoring for a queued PromptTicket classified
@@ -875,6 +831,10 @@ ipcMain.handle('otel:set-config', async (_e, cfg) => {
875
831
  ipcMain.handle('otel:status', () => otel.status());
876
832
  ipcMain.handle('otel:config-path', () => otelSettings.storePath());
877
833
 
834
+ // Diagnostic-only renderer heap snapshot. No-op unless SM_HEAP_SNAPSHOT=1 —
835
+ // see heapSnapshot.cjs for why this stays off by default.
836
+ heapSnapshot.registerIpc({ ipcMain, getWindow: () => mainWindow });
837
+
878
838
  // --- App lifecycle ---
879
839
 
880
840
  // E2E under xvfb-run has no working GPU; the GPU process exits ~15 seconds
@@ -943,11 +903,6 @@ app.on('web-contents-created', (_e, wc) => {
943
903
  });
944
904
 
945
905
  wc.on('will-navigate', (event, url) => {
946
- // The embedded Browser tab's WebContentsView is a real browser — it must
947
- // be able to navigate anywhere. Exempt ONLY webContents registered by
948
- // browserView.cjs; the main window's lock below is unchanged.
949
- if (browserView.isBrowserViewContents(wc.id)) return;
950
-
951
906
  const allowed = useDevServer
952
907
  ? ['http://localhost:5173', 'http://127.0.0.1:5173']
953
908
  : [];
@@ -1087,6 +1042,8 @@ app.whenReady().then(async () => {
1087
1042
  try { await systemPreferences.askForMediaAccess('microphone'); } catch { /* */ }
1088
1043
  }
1089
1044
 
1045
+ const heapSnapshotMenuItem = heapSnapshot.buildMenuItem(() => mainWindow);
1046
+
1090
1047
  const template = [
1091
1048
  {
1092
1049
  label: 'Session Manager',
@@ -1123,6 +1080,8 @@ app.whenReady().then(async () => {
1123
1080
  submenu: [
1124
1081
  { label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
1125
1082
  { label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
1083
+ // Diagnostic-only; absent unless SM_HEAP_SNAPSHOT=1 (see heapSnapshot.cjs).
1084
+ ...(heapSnapshotMenuItem ? [heapSnapshotMenuItem] : []),
1126
1085
  ],
1127
1086
  },
1128
1087
  {
@@ -1144,16 +1103,12 @@ app.whenReady().then(async () => {
1144
1103
  ptyManager.attachWindow(mainWindow);
1145
1104
  configMgr.attachWindow(mainWindow);
1146
1105
  transcripts.attachWindow(mainWindow);
1147
- browserView.registerBrowserView({ mainWindow, ipcMain });
1148
- browserCapture.attachWindow(mainWindow);
1149
- browserCapture.registerBrowserCapture({ ipcMain, getView: browserView.getView });
1150
1106
  voiceHotkey.init(mainWindow).catch((e) => {
1151
1107
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
1152
1108
  });
1153
1109
  scheduler.attachWindow(mainWindow);
1154
1110
  watchers.attachWindow(mainWindow);
1155
1111
  pluginInstall.attachWindow(mainWindow);
1156
- webRemote.attachWindow(mainWindow);
1157
1112
  chatRunner.attachWindow(mainWindow);
1158
1113
  promptSessionEvents.attachWindow(mainWindow);
1159
1114
  attachDocEditWindow(mainWindow);
@@ -1177,18 +1132,12 @@ app.whenReady().then(async () => {
1177
1132
  console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
1178
1133
  logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
1179
1134
  }
1180
- browserAgentServer.start().catch((e) => {
1181
- logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1182
- });
1183
1135
  // First-boot default: install the bundled session-manager-dev plugin (its 10
1184
1136
  // dev skills) from the app's own marketplace. One-shot + idempotent; never
1185
1137
  // throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
1186
1138
  seedDevPlugin({ logger: console }).catch((e) => {
1187
1139
  logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
1188
1140
  });
1189
- webRemote.init().catch((e) => {
1190
- logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
1191
- });
1192
1141
  // History rollup finalize pass: deferred 30s past boot so it never competes
1193
1142
  // with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
1194
1143
  // is just the one-shot in-app pass).
@@ -1249,7 +1198,6 @@ let teardownDone = false;
1249
1198
  function runShutdownCleanup() {
1250
1199
  if (teardownDone) return; // idempotent — will-quit may still fire after an app.exit path
1251
1200
  teardownDone = true;
1252
- webRemote.destroy();
1253
1201
  // Mark a clean exit so the next boot can distinguish a graceful quit from an
1254
1202
  // OOM-kill / native crash (which leaves the sentinel `open`).
1255
1203
  crashDiagnostics.markCleanShutdown();
@@ -1287,7 +1235,6 @@ app.on('before-quit', () => {
1287
1235
  transcripts.closeAll();
1288
1236
  watchers.manager.killAll();
1289
1237
  adminHttp.stop().catch(() => {});
1290
- browserAgentServer.stop().catch(() => {});
1291
1238
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1292
1239
  // ceiling so a wedged exporter can't hold quit.
1293
1240
  otel.shutdown().catch(() => {});