claude-code-session-manager 0.16.0 → 0.17.1

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 (26) hide show
  1. package/dist/assets/{TiptapBody-CFh7PFkf.js → TiptapBody-B16U7NOC.js} +1 -1
  2. package/dist/assets/{cssMode-Cll4QpTW.js → cssMode-BlRHn_L6.js} +1 -1
  3. package/dist/assets/{freemarker2-lo-fLEl5.js → freemarker2-Bk2fXLx4.js} +1 -1
  4. package/dist/assets/{handlebars-CMA5gb0k.js → handlebars-eLqjBxBa.js} +1 -1
  5. package/dist/assets/{html-BQ2X1VHF.js → html-BzvHaJnM.js} +1 -1
  6. package/dist/assets/{htmlMode-Da8nU8Ys.js → htmlMode-C9rcOKw8.js} +1 -1
  7. package/dist/assets/{index-6uZy0Pbe.css → index-BqYM-JWd.css} +1 -1
  8. package/dist/assets/{index-Bs_D2jQM.js → index-DqO1hmJF.js} +1261 -1268
  9. package/dist/assets/{javascript-BfjktvBd.js → javascript-BAOAmGHx.js} +1 -1
  10. package/dist/assets/{jsonMode-Duc74W4E.js → jsonMode-CM0KALCa.js} +1 -1
  11. package/dist/assets/{liquid-CONgoaDI.js → liquid-Bi-cszJ-.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DdHwUVu5.js → lspLanguageFeatures-BxkipFwP.js} +1 -1
  13. package/dist/assets/{mdx-DIQgvOto.js → mdx-DwbRtWl-.js} +1 -1
  14. package/dist/assets/{python-BcGmrT3q.js → python-D-IprHOk.js} +1 -1
  15. package/dist/assets/{razor-B2RyqYc0.js → razor-CC6ogdu3.js} +1 -1
  16. package/dist/assets/{tsMode-CRnbNLOY.js → tsMode-BJR_Ezbp.js} +1 -1
  17. package/dist/assets/{typescript-CeTpx2hJ.js → typescript-FkJpv2Nj.js} +1 -1
  18. package/dist/assets/{xml-DIN_EFpG.js → xml-BVuV92it.js} +1 -1
  19. package/dist/assets/{yaml-DpCxufnr.js → yaml-CKj9MriX.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +5 -2
  22. package/src/main/crashDiagnostics.cjs +217 -0
  23. package/src/main/files.cjs +35 -4
  24. package/src/main/index.cjs +21 -1
  25. package/src/main/lib/sendToRenderer.cjs +16 -3
  26. package/src/preload/api.d.ts +1 -1
@@ -0,0 +1,217 @@
1
+ // Crash & OOM diagnostics for the main process.
2
+ //
3
+ // WHY THIS EXISTS: the app has been dying "silently" — its own logs end
4
+ // mid-stream with no error line. That signature is a *native* termination
5
+ // (kernel OOM-kill / SIGKILL / renderer segfault), which `process.on
6
+ // ('uncaughtException')` can NEVER catch because no JS exception is thrown;
7
+ // the process just disappears. System journal confirmed repeated
8
+ // `oom-kill` events against the Electron renderer (Chromium tags renderers
9
+ // with oom_score_adj:300, making them the kernel's first victim under
10
+ // memory pressure). Because the app holds a `powerSaveBlocker` suspend
11
+ // inhibitor, its death releases the org.freedesktop.login1 inhibitor and
12
+ // Pop!_OS idle-suspends — hence "app crashes -> machine falls asleep".
13
+ //
14
+ // This module adds the four things needed to catch the next occurrence:
15
+ // 1. Chromium crash-reason hooks — render-process-gone / child-process-gone
16
+ // surface `reason: 'oom' | 'crashed' | 'killed'` and the dead process type.
17
+ // 2. A memory heartbeat — per-process working-set sampled on an interval and
18
+ // persisted to a sentinel file, so the LAST sample before a SIGKILL
19
+ // survives the crash and is logged at next boot.
20
+ // 3. An unclean-shutdown sentinel — if the previous run never marked a clean
21
+ // exit, we log a WARN at boot with that last memory sample (the OOM
22
+ // postmortem the app could not write while dying).
23
+ // 4. powerMonitor + blocker-state logging — ties suspend/resume events and
24
+ // the inhibitor's health to the crash timeline.
25
+
26
+ const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron');
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ let logs = null;
31
+ let getPowerBlockerId = () => -1;
32
+ let heartbeatTimer = null;
33
+ let sentinelPath = null;
34
+ let startedAtIso = null;
35
+ let lastSample = null;
36
+
37
+ // Warn threshold for total working set across all Electron processes. The OOM
38
+ // journal showed a single renderer hitting ~900 MB anon-rss before the kernel
39
+ // stepped in; warning at 1.5 GB total gives an early signal in the app's own
40
+ // log BEFORE the kill, turning a silent death into a visible ramp. Override
41
+ // with SM_MEM_WARN_MB.
42
+ const MEM_WARN_MB = Number(process.env.SM_MEM_WARN_MB) || 1500;
43
+ // Heartbeat cadence. 30s is fine-grained enough to catch the last sample
44
+ // before an OOM yet cheap (getAppMetrics is O(numProcesses), ~5-10 procs).
45
+ const HEARTBEAT_MS = Number(process.env.SM_MEM_HEARTBEAT_MS) || 30_000;
46
+
47
+ function sentinelFile() {
48
+ return path.join(app.getPath('userData'), 'logs', 'last-run.json');
49
+ }
50
+
51
+ // crashReporter collects local minidumps for renderer/GPU SIGSEGV/SIGABRT
52
+ // crashes (NOT OOM — a SIGKILL leaves no dump). Local-only, never uploaded.
53
+ // Must run before the app is ready, so call this at module require time from
54
+ // index.cjs's top level.
55
+ function startCrashReporter() {
56
+ try {
57
+ crashReporter.start({
58
+ productName: 'claude-code-session-manager',
59
+ companyName: 'session-manager',
60
+ uploadToServer: false,
61
+ compress: true,
62
+ });
63
+ } catch { /* best-effort: diagnostics must never break boot */ }
64
+ }
65
+
66
+ // Snapshot per-process memory. app.getAppMetrics() returns one entry per
67
+ // Electron process (Browser/GPU/Tab/Utility) with workingSetSize in KB.
68
+ function sampleMemory() {
69
+ let metrics = [];
70
+ try { metrics = app.getAppMetrics(); } catch { /* not ready yet */ }
71
+ const procs = metrics.map((m) => ({
72
+ pid: m.pid,
73
+ type: m.type,
74
+ name: m.name || m.serviceName || undefined,
75
+ // workingSetSize is in KB; normalize to MB for readability.
76
+ mb: Math.round((m.memory?.workingSetSize || 0) / 1024),
77
+ }));
78
+ const totalMb = procs.reduce((s, p) => s + p.mb, 0);
79
+ const mu = process.memoryUsage();
80
+ return {
81
+ ts: new Date().toISOString(),
82
+ totalMb,
83
+ main: {
84
+ rssMb: Math.round(mu.rss / 1048576),
85
+ heapUsedMb: Math.round(mu.heapUsed / 1048576),
86
+ externalMb: Math.round(mu.external / 1048576),
87
+ },
88
+ powerBlockerHeld: (() => {
89
+ try { return powerSaveBlocker.isStarted(getPowerBlockerId()); } catch { return null; }
90
+ })(),
91
+ procs,
92
+ };
93
+ }
94
+
95
+ function persistSentinel(clean) {
96
+ if (!sentinelPath) return;
97
+ const payload = {
98
+ open: !clean, // true while running; flipped false on clean quit
99
+ pid: process.pid,
100
+ startedAt: startedAtIso,
101
+ closedAt: clean ? new Date().toISOString() : undefined,
102
+ lastSample,
103
+ };
104
+ try {
105
+ fs.writeFileSync(sentinelPath, JSON.stringify(payload), { mode: 0o600 });
106
+ } catch { /* best-effort */ }
107
+ }
108
+
109
+ function heartbeat() {
110
+ lastSample = sampleMemory();
111
+ // Persist every tick so the final pre-crash sample survives a SIGKILL.
112
+ persistSentinel(false);
113
+ const top = [...lastSample.procs].sort((a, b) => b.mb - a.mb).slice(0, 4);
114
+ const level = lastSample.totalMb >= MEM_WARN_MB ? 'warn' : 'debug';
115
+ logs?.writeLine({
116
+ scope: 'crash-diag',
117
+ level,
118
+ message: level === 'warn' ? 'memory high' : 'memory heartbeat',
119
+ meta: {
120
+ totalMb: lastSample.totalMb,
121
+ mainRssMb: lastSample.main.rssMb,
122
+ powerBlockerHeld: lastSample.powerBlockerHeld,
123
+ top: top.map((p) => `${p.type}:${p.mb}MB`),
124
+ },
125
+ });
126
+ }
127
+
128
+ // Read the previous run's sentinel. If it was still `open`, the app died
129
+ // without a clean quit — almost certainly the OOM-kill we're hunting. Log it
130
+ // as the postmortem the dying process couldn't write, including its last
131
+ // memory sample so we can see the footprint at time of death.
132
+ function checkPreviousRun() {
133
+ let prev = null;
134
+ try { prev = JSON.parse(fs.readFileSync(sentinelPath, 'utf8')); } catch { return; }
135
+ if (!prev || prev.open !== true) return;
136
+ const s = prev.lastSample;
137
+ logs?.writeLine({
138
+ scope: 'crash-diag',
139
+ level: 'error',
140
+ message: 'previous session ended UNCLEANLY (likely OOM-kill / native crash — no graceful quit)',
141
+ meta: {
142
+ prevPid: prev.pid,
143
+ startedAt: prev.startedAt,
144
+ lastSampleTs: s?.ts,
145
+ lastTotalMb: s?.totalMb,
146
+ lastMainRssMb: s?.main?.rssMb,
147
+ lastTop: s ? [...s.procs].sort((a, b) => b.mb - a.mb).slice(0, 4).map((p) => `${p.type}:${p.mb}MB`) : undefined,
148
+ powerBlockerHeldAtDeath: s?.powerBlockerHeld,
149
+ hint: 'Check `journalctl -k | grep -i oom` around lastSampleTs to confirm the kernel OOM-killer.',
150
+ },
151
+ });
152
+ }
153
+
154
+ function registerCrashHooks() {
155
+ // Renderer death — reason is the prize: 'oom' | 'crashed' | 'killed' |
156
+ // 'launch-failed' | 'integrity-failure'. This fires even when the kernel
157
+ // OOM-kills only the renderer (main process survives to log it).
158
+ app.on('render-process-gone', (_e, _wc, details) => {
159
+ logs?.writeLine({
160
+ scope: 'crash-diag',
161
+ level: 'error',
162
+ message: 'render-process-gone',
163
+ meta: { reason: details?.reason, exitCode: details?.exitCode, lastTotalMb: lastSample?.totalMb },
164
+ });
165
+ });
166
+
167
+ // GPU / Utility / Pepper / network-service child death.
168
+ app.on('child-process-gone', (_e, details) => {
169
+ logs?.writeLine({
170
+ scope: 'crash-diag',
171
+ level: 'error',
172
+ message: 'child-process-gone',
173
+ meta: {
174
+ type: details?.type,
175
+ name: details?.name || details?.serviceName,
176
+ reason: details?.reason,
177
+ exitCode: details?.exitCode,
178
+ lastTotalMb: lastSample?.totalMb,
179
+ },
180
+ });
181
+ });
182
+
183
+ // powerMonitor ties the suspend story to the crash timeline. If we ever log
184
+ // a 'suspend' while the app is alive, the inhibitor failed; if the app dies
185
+ // first, the unclean-shutdown postmortem at next boot is the trail instead.
186
+ try {
187
+ powerMonitor.on('suspend', () => {
188
+ logs?.writeLine({ scope: 'crash-diag', level: 'warn', message: 'system suspend (powerMonitor) — inhibitor did NOT hold while app alive', meta: { powerBlockerHeld: lastSample?.powerBlockerHeld } });
189
+ });
190
+ powerMonitor.on('resume', () => {
191
+ logs?.writeLine({ scope: 'crash-diag', level: 'info', message: 'system resume (powerMonitor)' });
192
+ });
193
+ } catch { /* powerMonitor needs a ready app on some platforms */ }
194
+ }
195
+
196
+ function init(opts) {
197
+ logs = opts.logs;
198
+ if (typeof opts.getPowerBlockerId === 'function') getPowerBlockerId = opts.getPowerBlockerId;
199
+ sentinelPath = sentinelFile();
200
+ startedAtIso = new Date().toISOString();
201
+
202
+ checkPreviousRun(); // postmortem for the previous (possibly OOM'd) run
203
+ persistSentinel(false); // mark this run as open
204
+ registerCrashHooks();
205
+
206
+ heartbeat(); // immediate baseline sample
207
+ heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS);
208
+ if (heartbeatTimer.unref) heartbeatTimer.unref(); // don't keep the loop alive
209
+ }
210
+
211
+ // Call from will-quit so a graceful exit is distinguishable from an OOM-kill.
212
+ function markCleanShutdown() {
213
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
214
+ persistSentinel(true);
215
+ }
216
+
217
+ module.exports = { startCrashReporter, init, markCleanShutdown };
@@ -90,6 +90,30 @@ async function listDir(dirPath, showHidden) {
90
90
  }
91
91
  }
92
92
 
93
+ // Extension → MIME used for the binary-file fallback summary. Mirrors the
94
+ // SMFILE_MIME table in index.cjs; kept here so readFile can label binaries
95
+ // without importing the renderer-facing scheme handler.
96
+ const BINARY_MIME = {
97
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
98
+ tar: 'application/x-tar', png: 'image/png', jpg: 'image/jpeg',
99
+ jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
100
+ bmp: 'image/bmp', ico: 'image/x-icon', mp3: 'audio/mpeg', wav: 'audio/wav',
101
+ mp4: 'video/mp4', mov: 'video/quicktime', woff: 'font/woff', woff2: 'font/woff2',
102
+ ttf: 'font/ttf', otf: 'font/otf', exe: 'application/octet-stream',
103
+ bin: 'application/octet-stream', so: 'application/octet-stream',
104
+ dylib: 'application/octet-stream', wasm: 'application/wasm',
105
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
106
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
107
+ };
108
+
109
+ /** Heuristic binary sniff: a NUL byte in the first 8 KB ⇒ treat as binary.
110
+ * O(min(n, 8192)) — bounded by the sample, not file size. */
111
+ function looksBinary(buf) {
112
+ const n = Math.min(buf.length, 8192);
113
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
114
+ return false;
115
+ }
116
+
93
117
  async function readFile(filePath) {
94
118
  let resolved;
95
119
  try { resolved = validateHomePath(filePath); }
@@ -98,12 +122,19 @@ async function readFile(filePath) {
98
122
  try {
99
123
  const st = await fsp.stat(resolved);
100
124
  if (st.isDirectory()) return { ok: false, text: '', error: 'Path is a directory', size: 0 };
101
- // 5 MB cap preview pane shouldn't try to load huge logs.
125
+ const ext = resolved.toLowerCase().split('.').pop() || '';
126
+ const mime = BINARY_MIME[ext] || 'application/octet-stream';
127
+ // 5 MB cap — preview pane shouldn't try to load huge logs. Oversize files
128
+ // report as binary so the renderer shows the fallback pane (Open externally)
129
+ // rather than a bare error string.
102
130
  if (st.size > 5 * 1024 * 1024) {
103
- return { ok: false, text: '', error: 'File too large to preview (> 5 MB)', size: st.size };
131
+ return { ok: false, text: '', error: 'File too large to preview (> 5 MB)', size: st.size, binary: true, mime };
132
+ }
133
+ const buf = await fsp.readFile(resolved);
134
+ if (looksBinary(buf)) {
135
+ return { ok: false, text: '', error: 'Binary file', size: st.size, binary: true, mime };
104
136
  }
105
- const text = await fsp.readFile(resolved, 'utf8');
106
- return { ok: true, text, error: null, size: st.size };
137
+ return { ok: true, text: buf.toString('utf8'), error: null, size: st.size, binary: false, mime };
107
138
  } catch (e) {
108
139
  return { ok: false, text: '', error: e.message, size: 0 };
109
140
  }
@@ -13,6 +13,11 @@ const usageMatrix = require('./usageMatrix.cjs');
13
13
  const sessionsStore = require('./sessionsStore.cjs');
14
14
  const billing = require('./usage.cjs');
15
15
  const logs = require('./logs.cjs');
16
+ const crashDiagnostics = require('./crashDiagnostics.cjs');
17
+ // Start the local minidump collector before app-ready (required by Electron).
18
+ // Catches renderer/GPU SIGSEGV/SIGABRT; OOM-kills leave no dump but are caught
19
+ // by the sentinel + render-process-gone hooks in crashDiagnostics.init().
20
+ crashDiagnostics.startCrashReporter();
16
21
  const voiceHotkey = require('./voiceHotkey.cjs');
17
22
  const voiceWizard = require('./voiceWizard.cjs');
18
23
  const scheduler = require('./scheduler.cjs');
@@ -242,6 +247,10 @@ function createWindow() {
242
247
  contextIsolation: true,
243
248
  nodeIntegration: false,
244
249
  sandbox: true,
250
+ // Enables Chromium's built-in PDF viewer so the Editor's PdfPane iframe
251
+ // renders .pdf over smfile:// instead of triggering a download. The only
252
+ // Pepper "plugin" in modern Electron is the PDF viewer (Flash is gone).
253
+ plugins: true,
245
254
  },
246
255
  });
247
256
 
@@ -613,11 +622,13 @@ protocol.registerSchemesAsPrivileged([
613
622
  const SMFILE_MIME = {
614
623
  '.html': 'text/html', '.htm': 'text/html',
615
624
  '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
616
- '.json': 'application/json', '.svg': 'image/svg+xml',
625
+ '.json': 'application/json', '.jsonl': 'application/jsonl', '.ndjson': 'application/x-ndjson', '.svg': 'image/svg+xml',
617
626
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
618
627
  '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
628
+ '.avif': 'image/avif', '.bmp': 'image/bmp',
619
629
  '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
620
630
  '.txt': 'text/plain', '.csv': 'text/csv', '.xml': 'application/xml',
631
+ '.pdf': 'application/pdf',
621
632
  };
622
633
 
623
634
  // Single-instance lock (PRD F1 v2 §requestSingleInstanceLock).
@@ -671,6 +682,12 @@ app.whenReady().then(async () => {
671
682
  logs.pruneOld();
672
683
  logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
673
684
 
685
+ // Crash/OOM diagnostics: logs a postmortem if the PREVIOUS run died
686
+ // uncleanly (the silent OOM-kill we've been chasing), then starts the memory
687
+ // heartbeat + render/child-process-gone hooks for this run. See
688
+ // crashDiagnostics.cjs for the full rationale.
689
+ crashDiagnostics.init({ logs, getPowerBlockerId: () => powerBlockerId });
690
+
674
691
  // Boot-time detection 1: surface `claude` binary resolution so a missing
675
692
  // install becomes visible to the renderer instead of failing silently on
676
693
  // first spawn attempt.
@@ -877,6 +894,9 @@ app.whenReady().then(async () => {
877
894
  });
878
895
 
879
896
  app.on('will-quit', () => {
897
+ // Mark a clean exit so the next boot can distinguish a graceful quit from an
898
+ // OOM-kill / native crash (which leaves the sentinel `open`).
899
+ crashDiagnostics.markCleanShutdown();
880
900
  // PRD F1 v2 §IPC plumbing: must unregisterAll on will-quit.
881
901
  try { globalShortcut.unregisterAll(); } catch { /* */ }
882
902
  voiceHotkey.disposeOnQuit();
@@ -9,12 +9,25 @@
9
9
  'use strict';
10
10
 
11
11
  /**
12
- * Send a payload on a channel iff the BrowserWindow is alive.
12
+ * Send a payload on a channel iff the BrowserWindow AND its render frame are alive.
13
13
  * No-ops on null/destroyed windows so callers don't need their own guards.
14
+ *
15
+ * `window.isDestroyed()` alone is insufficient: during a reload/navigation the
16
+ * BrowserWindow stays alive while its underlying render frame (WebFrameMain) is
17
+ * disposed and recreated. In that window, `webContents.send` throws
18
+ * "Render frame was disposed before WebFrameMain could be accessed". The frame
19
+ * can also vanish between our check and the send (TOCTOU), so we both probe the
20
+ * webContents state and wrap the send in try/catch.
14
21
  */
15
22
  function sendIfAlive(window, channel, payload) {
16
- if (window && !window.isDestroyed()) {
17
- window.webContents.send(channel, payload);
23
+ if (!window || window.isDestroyed()) return;
24
+ const wc = window.webContents;
25
+ if (!wc || wc.isDestroyed() || wc.isCrashed()) return;
26
+ try {
27
+ wc.send(channel, payload);
28
+ } catch {
29
+ // Render frame disposed mid-send (reload/teardown race). Drop the message —
30
+ // a stale frame has no listener anyway.
18
31
  }
19
32
  }
20
33
 
@@ -467,7 +467,7 @@ export interface FileEntry {
467
467
  }
468
468
 
469
469
  export interface FilesListResult { ok: boolean; entries: FileEntry[]; error: string | null }
470
- export interface FilesReadResult { ok: boolean; text: string; error: string | null; size: number }
470
+ export interface FilesReadResult { ok: boolean; text: string; error: string | null; size: number; binary?: boolean; mime?: string }
471
471
  export interface FilesWriteResult { ok: boolean; error: string | null }
472
472
  export interface FilesCreateResult { ok: boolean; path?: string; error: string | null }
473
473
  export interface FilesRenameResult { ok: boolean; newPath?: string; error: string | null }