diff --git a/handoffs/current.md b/handoffs/current.md
new file mode 100644
index 00000000..731752d8
--- /dev/null
+++ b/handoffs/current.md
@@ -0,0 +1,142 @@
+# Current Handoff
+
+## Current Status
+
+Investigating a recurring `EXC_BREAKPOINT`/`SIGTRAP` crash on `CrBrowserMain`
+(main process). Confirmed root cause: `file-search-index.ts` rebuilds a
+~1.1M-entry home-directory index on an interval, and previously kept the old
+snapshot alive while building the new one — a multi-GB spike that blows any
+V8 heap ceiling. Fix is written, compiled, and packaged into the latest DMG,
+but **not yet confirmed working** — the last test run showed old (pre-fix)
+behavior, most likely because a stale process/build was tested rather than
+the new one. Branch `feat/fix-review-crash` also now contains two previously
+orphaned, unmerged upstream branches (see Decisions).
+
+## Completed Work
+
+- **`lastBuildError` leak** (`extension-runner.ts`): now cleared on successful
+ rebuild instead of growing forever.
+- **Window-manager worker fixes**: stopped silencing its stdout/stderr
+ (`main.ts`), which surfaced the real bug — `extract-file-icon` /
+ `node-gyp-build` weren't in `asarUnpack`, so `node-window-manager` failed to
+ load in packaged builds (`package.json`).
+- **V8 heap ceiling fix, corrected**: first attempt
+ (`app.commandLine.appendSwitch('js-flags', ...)`) confirmed NOT to reach the
+ main process (only affects Chromium renderer processes). Second attempt
+ (`v8.setFlagsFromString`) confirmed ineffective too (heap_size_limit is
+ fixed at isolate creation). Working fix: re-exec the process once with
+ `--js-flags=--max-old-space-size=4096` on argv (env-marker guarded against
+ looping). Verified live: process reports `~4096MB` limit.
+- **Diagnostics added** (`SC_HEAP_DEBUG=1` env gate): periodic
+ `process.memoryUsage()` log every 5 min, heap snapshot capture via
+ `Cmd+Alt+Shift+H` or `SIGUSR2`, written to `/tmp` (not `~/Library/Logs` —
+ that directory has repeatedly lost files minutes after being written,
+ cause unconfirmed, treat as unreliable for this investigation).
+- **Recovered two orphaned upstream branches** (both had open PRs, neither
+ merged into `main`, which itself is stalled at `2da7b9e`) and merged both
+ into `feat/fix-review-crash`, cleanly, no conflicts:
+ - `feat/Fix-shoutdown` (PR #630): LRU-cache extension bundles, stop
+ pre-warming window-manager-worker, non-blocking discovery, idle
+ transcription server shutdown (+ mid-request-kill bugfix), drain
+ in-flight extension/script work before quit (this one has its own ADR
+ diagnosing a *different* EXC_BREAKPOINT/SIGTRAP cause — a Node callback
+ completing after Electron tears down V8 on quit; not yet confirmed
+ whether it's still occurring separately from the FileIndex issue).
+ - `feat/feat-confetti` (PR #631): expanded tray menu (Settings, Extension
+ Store, Launch at Login, Check for Updates), Confetti/Fireworks/Snow/Rain
+ commands, esbuild moved to `optionalDependencies` (fixes `EBADPLATFORM`
+ on `npm install` for single-arch Macs).
+ - Pushed today's 4 relevant commits (lastBuildError, heap diagnostics,
+ window-manager stdio, heap-limit fix) onto `feat/Fix-shoutdown` and to
+ origin, updating PR #630 live (11 commits now).
+- **`file-search-index.ts` fix** (latest commit, root-cause fix):
+ - Old snapshot dropped (`activeIndex = null`) before building the new one,
+ instead of after — eliminates the double-memory rebuild spike. Trade-off:
+ search returns empty during the rebuild window.
+ - `MAX_INDEX_ENTRIES` 1.2M → 400k; `DEFAULT_REFRESH_INTERVAL_MS` 8min →
+ 60min.
+ - Added a manual `system-rebuild-file-index` command
+ (`commands.ts`/`executeCommand`) for on-demand refresh.
+ - Compiled binary verified to contain `400000` (not `1200000`) — the fix is
+ genuinely in the shipped DMG.
+
+## Work In Progress
+
+- **Confirming the FileIndex fix actually resolves the crash.** Last test run
+ showed 1.1M entries and sub-60min interval rebuilds — i.e. old behavior —
+ strongly suggesting a stale process or pre-fix install was tested, not that
+ the fix failed. User was about to redo the test cleanly (kill all
+ processes, reinstall from `~/Downloads/SuperCmd-1.0.26-arm64.dmg`, verify
+ `grep -ao "400000" .../app.asar` before launching).
+
+## Pending Work
+
+1. Re-verify the FileIndex fix with a clean process + confirmed-current
+ build, ideally past the ~48–96min window where every prior crash occurred.
+2. If it still crashes: get another `[HeapDebug]`/`[FileIndex]` log + `.ips`
+ from `~/Library/Logs/DiagnosticReports/` (read it immediately, it may
+ disappear) and check whether the heap trend shows another single big
+ jump (another oversized structure) or a slow genuine leak this time.
+3. If it holds: consider whether `feat/Fix-shoutdown`'s quit-time race
+ condition (drain-before-quit, already merged) is a second, independent,
+ much rarer cause — no direct evidence yet that it's firing separately.
+4. Two open upstream PRs (#630, #631) are `MERGEABLE` but `BLOCKED` on
+ review — no CI configured on this repo, no human reviewer assigned.
+ `main` itself has been stalled at `2da7b9e` this whole time; consider
+ whether that's expected or itself a process gap worth raising.
+5. Optional, not started: `global.gc()` periodic-forcing was proposed as a
+ heap mitigation but deferred by user request ("ya veremos") in favor of
+ chasing the FileIndex root cause first.
+
+## Blockers
+
+- None technical. Confirmation is blocked only on the user's next test run.
+
+## Decisions
+
+- Bring both orphaned branches into `feat/fix-review-crash` rather than
+ leave them stranded or merge straight to `main` (user's call, asked
+ explicitly).
+- Accept a brief "search returns nothing" window during FileIndex rebuilds
+ in exchange for not doubling peak memory (user's call, picked this option
+ explicitly over alternatives).
+- Diagnostics write to `/tmp`, not `~/Library/Logs` — that directory has
+ been unreliable mid-session (files vanishing) independent of anything in
+ this codebase; don't rely on it for future debugging in this repo either.
+
+## Risks
+
+- The ~48–96min crash timing has been remarkably consistent across multiple
+ different fixes (heap ceiling raised, FileIndex capped) — if the next test
+ still crashes around that window, the FileIndex theory may be wrong or
+ incomplete, and the quit-time-race theory (already fixed via
+ drain-before-quit) or a third, unfound cause should be considered before
+ assuming "still the same bug."
+- `git log --oneline` output was momentarily misleading mid-session (looked
+ stale after a `cherry-pick --abort`) — cross-checked via `rev-parse`/
+ `cat-file` and it was fine, but worth using low-level plumbing commands to
+ double check if branch state ever looks wrong again in this repo.
+- Multiple stale/orphaned SuperCmd processes have repeatedly confused test
+ results this session (old tray menu, "app still runs after deleting it",
+ and likely the last FileIndex test). Always confirm zero running processes
+ and verify the installed binary's content (e.g. `grep -ao` a known string)
+ before trusting a test result in this project going forward.
+
+## Next Actions
+
+- Wait for user's clean re-test of the FileIndex fix.
+- Depending on result, either close out the crash investigation or resume
+ hunting (see Pending Work #2–3).
+
+## References
+
+- Crash reports (may vanish from disk, read immediately if consulted):
+ `~/Library/Logs/DiagnosticReports/SuperCmd-2026-07-{17,20,21}-*.ips`
+- `src/main/file-search-index.ts`, `src/main/extension-runner.ts`,
+ `src/main/main.ts`, `src/main/commands.ts`
+- Upstream: `SuperCmdLabs/SuperCmd` PR #630, PR #631
+- ADR (via `codebase-memory-mcp`, project
+ `Users-cgomez-orca-workspaces-SuperCmd-Fix-shoutdown`): quit-time-race
+ diagnosis for the drain-before-quit fix
+- Local DMG builds: `~/Downloads/SuperCmd-1.0.26-arm64.dmg` (rebuilt several
+ times today — always verify MD5/content before trusting a test against it)
diff --git a/package-lock.json b/package-lock.json
index cbe18d8a..a6de6f4c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "supercmd",
- "version": "1.0.25-PMH",
+ "version": "1.0.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "supercmd",
- "version": "1.0.25-PMH",
+ "version": "1.0.26",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
@@ -39,6 +39,10 @@
"typescript": "^5.3.3",
"vite": "^5.0.11",
"wait-on": "^7.2.0"
+ },
+ "optionalDependencies": {
+ "@esbuild/darwin-arm64": "0.19.12",
+ "@esbuild/darwin-x64": "0.19.12"
}
},
"node_modules/@alloc/quick-lru": {
diff --git a/package.json b/package.json
index f262a8c1..9d2f3314 100644
--- a/package.json
+++ b/package.json
@@ -42,8 +42,6 @@
},
"dependencies": {
"@aptabase/electron": "^0.3.1",
- "@esbuild/darwin-arm64": "0.19.12",
- "@esbuild/darwin-x64": "0.19.12",
"@phosphor-icons/react": "^2.1.10",
"@raycast/api": "^1.104.5",
"electron-builder-notarize": "^1.5.2",
@@ -57,6 +55,10 @@
"react-dom": "^18.2.0",
"transliteration": "^2.6.1"
},
+ "optionalDependencies": {
+ "@esbuild/darwin-arm64": "0.19.12",
+ "@esbuild/darwin-x64": "0.19.12"
+ },
"build": {
"appId": "com.supercmd.app",
"productName": "SuperCmd",
@@ -76,6 +78,8 @@
"node_modules/@esbuild/**",
"node_modules/node-edge-tts/**",
"node_modules/node-window-manager/**",
+ "node_modules/extract-file-icon/**",
+ "node_modules/node-gyp-build/**",
"node_modules/electron-liquid-glass/**"
],
"files": [
diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs
index 695347ae..bfc3a2f4 100644
--- a/scripts/measure-extension-bundle-cache.mjs
+++ b/scripts/measure-extension-bundle-cache.mjs
@@ -229,8 +229,7 @@ async function main() {
const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => {
for (let i = 0; i < iterations; i++) {
- const commands = runner
- .discoverInstalledExtensionCommands()
+ const commands = (await runner.discoverInstalledExtensionCommands())
.filter((command) => command.mode === 'menu-bar');
assert.equal(commands.length, 1);
const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName);
diff --git a/src/main/commands.ts b/src/main/commands.ts
index 4a89f301..9a92cd71 100644
--- a/src/main/commands.ts
+++ b/src/main/commands.ts
@@ -13,6 +13,7 @@
import { app } from 'electron';
import { exec, execFile, spawn } from 'child_process';
import { promisify } from 'util';
+import { rebuildFileSearchIndex } from './file-search-index';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
@@ -67,6 +68,22 @@ const SHUTDOWN_ICON_DATA_URL = svgToBase64DataUrl(
''
);
+const CONFETTI_ICON_DATA_URL = svgToBase64DataUrl(
+ ''
+);
+
+const FIREWORKS_ICON_DATA_URL = svgToBase64DataUrl(
+ ''
+);
+
+const SNOW_ICON_DATA_URL = svgToBase64DataUrl(
+ ''
+);
+
+const RAIN_ICON_DATA_URL = svgToBase64DataUrl(
+ ''
+);
+
export interface CommandInfo {
id: string;
title: string;
@@ -129,19 +146,21 @@ function getCommandsDiskCachePath(): string {
return commandsDiskCachePath;
}
-function loadCommandsDiskCache(): CommandInfo[] | null {
+async function loadCommandsDiskCache(): Promise {
try {
- const raw = fs.readFileSync(getCommandsDiskCachePath(), 'utf-8');
+ const raw = await fs.promises.readFile(getCommandsDiskCachePath(), 'utf-8');
const parsed = JSON.parse(raw) as { version: number; commands: CommandInfo[] };
if (parsed?.version !== COMMANDS_DISK_CACHE_VERSION) return null;
const cmds = parsed.commands;
- // Re-attach icons from the per-app icon disk cache (fast file reads).
- for (const cmd of cmds) {
- if (cmd.path) {
- const icon = getCachedIcon(cmd.path);
- if (icon) cmd.iconDataUrl = icon;
- }
- }
+ // Re-attach icons from the per-app icon disk cache. Reading them
+ // concurrently (fs.promises, backed by libuv's threadpool) instead of one
+ // fs.readFileSync per command avoids serializing every icon read on the
+ // main thread right before the first window is created.
+ await Promise.all(cmds.map(async (cmd) => {
+ if (!cmd.path) return;
+ const icon = await getCachedIconAsync(cmd.path);
+ if (icon) cmd.iconDataUrl = icon;
+ }));
return cmds;
} catch {
return null;
@@ -163,8 +182,8 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void {
}
/** Call once after app.whenReady() to pre-populate the in-memory cache from disk. */
-export function initCommandsCache(): void {
- const cmds = loadCommandsDiskCache();
+export async function initCommandsCache(): Promise {
+ const cmds = await loadCommandsDiskCache();
if (cmds) {
cachedCommands = cmds;
staleCommandsFallback = cmds;
@@ -207,6 +226,15 @@ function getCachedIcon(bundlePath: string): string | undefined {
return undefined;
}
+async function getCachedIconAsync(bundlePath: string): Promise {
+ try {
+ const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
+ return await fs.promises.readFile(cacheFile, 'utf-8');
+ } catch {
+ return undefined;
+ }
+}
+
function setCachedIcon(bundlePath: string, dataUrl: string): void {
try {
const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
@@ -1308,6 +1336,38 @@ async function discoverAndBuildCommands(): Promise {
keywords: ['emoji', 'picker', 'trigger', 'smiley', 'emoticon'],
category: 'system',
},
+ {
+ id: 'system-confetti',
+ title: 'Confetti',
+ subtitle: 'Celebrate with a burst of confetti',
+ keywords: ['confetti', 'celebrate', 'party', 'fun', 'congrats', 'congratulations', 'celebration'],
+ iconDataUrl: CONFETTI_ICON_DATA_URL,
+ category: 'system',
+ },
+ {
+ id: 'system-fireworks',
+ title: 'Fireworks',
+ subtitle: 'Launch a fireworks show',
+ keywords: ['fireworks', 'rockets', 'celebrate', 'party', 'fun', 'show', 'pyrotechnics'],
+ iconDataUrl: FIREWORKS_ICON_DATA_URL,
+ category: 'system',
+ },
+ {
+ id: 'system-snow',
+ title: 'Snow',
+ subtitle: 'Let it snow on your screen',
+ keywords: ['snow', 'snowfall', 'winter', 'christmas', 'fun', 'weather'],
+ iconDataUrl: SNOW_ICON_DATA_URL,
+ category: 'system',
+ },
+ {
+ id: 'system-rain',
+ title: 'Rain',
+ subtitle: 'Make it rain on your screen',
+ keywords: ['rain', 'rainfall', 'storm', 'weather', 'fun'],
+ iconDataUrl: RAIN_ICON_DATA_URL,
+ category: 'system',
+ },
{
id: 'system-reset-launcher-position',
title: 'Reset Launcher Position',
@@ -1315,6 +1375,12 @@ async function discoverAndBuildCommands(): Promise {
iconDataUrl: RESET_POSITION_ICON_DATA_URL,
category: 'system',
},
+ {
+ id: 'system-rebuild-file-index',
+ title: 'Rebuild File Search Index',
+ keywords: ['rebuild', 'refresh', 'reindex', 'file', 'search', 'index', 'supercmd'],
+ category: 'system',
+ },
{
id: 'system-open-settings',
title: 'SuperCmd Settings',
@@ -1849,7 +1915,7 @@ async function discoverAndBuildCommands(): Promise {
// Installed community extensions
let extensionCommands: CommandInfo[] = [];
try {
- extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({
+ extensionCommands = (await discoverInstalledExtensionCommands()).map((ext) => ({
id: ext.id,
title: ext.title,
subtitle: ext.extensionTitle,
@@ -2108,6 +2174,16 @@ export async function executeCommand(id: string): Promise {
}
}
+ if (id === 'system-rebuild-file-index') {
+ try {
+ await rebuildFileSearchIndex('manual-command');
+ return true;
+ } catch (error) {
+ console.error('Failed to rebuild file search index:', error);
+ return false;
+ }
+ }
+
// Use stale fallback when available to avoid blocking on a fresh discovery
// while the cache is being rebuilt in the background.
const commands = cachedCommands ?? staleCommandsFallback ?? await getAvailableCommands();
diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts
index 2a275382..37f611d6 100644
--- a/src/main/extension-runner.ts
+++ b/src/main/extension-runner.ts
@@ -144,9 +144,53 @@ interface CachedTextFile {
value: string;
}
+/**
+ * Map-backed LRU cache with a hard size cap. Reads move the key to the
+ * most-recently-used position; inserts past the cap evict the oldest entry.
+ * Keeps long sessions that open many distinct extensions from accumulating
+ * unbounded bundle/manifest text in memory.
+ */
+class LruCache {
+ private readonly map = new Map();
+
+ constructor(private readonly maxEntries: number) {}
+
+ get(key: K): V | undefined {
+ const value = this.map.get(key);
+ if (value === undefined) return undefined;
+ this.map.delete(key);
+ this.map.set(key, value);
+ return value;
+ }
+
+ set(key: K, value: V): void {
+ this.map.delete(key);
+ this.map.set(key, value);
+ if (this.map.size > this.maxEntries) {
+ const oldestKey = this.map.keys().next().value as K;
+ this.map.delete(oldestKey);
+ }
+ }
+
+ delete(key: K): void {
+ this.map.delete(key);
+ }
+
+ clear(): void {
+ this.map.clear();
+ }
+
+ keys(): IterableIterator {
+ return this.map.keys();
+ }
+}
+
+const MAX_EXTENSION_MANIFEST_CACHE_ENTRIES = 200;
+const MAX_EXTENSION_BUNDLE_CACHE_ENTRIES = 40;
+
let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null;
-const _extensionManifestCache = new Map();
-const _extensionBundleCodeCache = new Map();
+const _extensionManifestCache = new LruCache(MAX_EXTENSION_MANIFEST_CACHE_ENTRIES);
+const _extensionBundleCodeCache = new LruCache(MAX_EXTENSION_BUNDLE_CACHE_ENTRIES);
function getManagedExtensionsDir(): string {
const dir = path.join(app.getPath('userData'), 'extensions');
@@ -497,13 +541,24 @@ function normalizePreferenceSchema(pref: any, scope: 'extension' | 'command'): E
// ─── Discovery ──────────────────────────────────────────────────────
+const DISCOVERY_YIELD_EVERY_N_EXTENSIONS = 5;
+
/**
* Scan installed extensions directory and return a flat list of
* commands that should appear in the launcher.
+ *
+ * Yields back to the event loop every few extensions so a large install
+ * (many manifests + icon reads, all sync fs calls) doesn't monopolize the
+ * single main-process thread for the whole scan in one uninterrupted burst.
*/
-export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] {
+export async function discoverInstalledExtensionCommands(): Promise {
const results: ExtensionCommandInfo[] = [];
- for (const source of collectInstalledExtensions()) {
+ const sources = collectInstalledExtensions();
+ for (let i = 0; i < sources.length; i++) {
+ if (i > 0 && i % DISCOVERY_YIELD_EVERY_N_EXTENSIONS === 0) {
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+ const source = sources[i];
const extPath = source.extPath;
const extName = source.extName;
@@ -1256,7 +1311,9 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom
extPath,
`${extName}/${cmdName}`
);
- return fs.existsSync(outFile);
+ const built = fs.existsSync(outFile);
+ if (built) lastBuildError.delete(`${extName}/${cmdName}`);
+ return built;
} catch (e: any) {
console.error(` On-demand esbuild failed for ${extName}/${cmdName}:`, e);
lastBuildError.set(`${extName}/${cmdName}`, e?.message || String(e));
diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts
index 9065be73..d9e0d8f3 100644
--- a/src/main/file-search-index.ts
+++ b/src/main/file-search-index.ts
@@ -57,12 +57,16 @@ type IndexSnapshot = {
const SEARCH_TOKEN_SPLIT_REGEX = /[^a-z0-9]+/g;
const MAX_PREFIX_LENGTH = 12;
-const MAX_INDEX_ENTRIES = 1_200_000;
+// A full rebuild briefly holds the old and new snapshot in memory at once
+// (see rebuildFileSearchIndex) — for home directories with hundreds of
+// thousands of entries, that peak alone can approach the process's heap
+// ceiling. Cap entries and space rebuilds out to keep that peak bounded.
+const MAX_INDEX_ENTRIES = 400_000;
const DEFAULT_MAX_RESULTS = 80;
const MAX_QUERY_RESULTS = 5_000;
const MAX_FILE_METADATA_STAT_RESULTS = 240;
const MIN_REBUILD_GAP_MS = 45_000;
-const DEFAULT_REFRESH_INTERVAL_MS = 8 * 60_000;
+const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60_000;
const WATCH_EVENT_DEBOUNCE_MS = 500;
const MAX_SPOTLIGHT_CANDIDATES = 10_000;
const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400;
@@ -637,6 +641,13 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise {
rebuildPromise = (async () => {
indexing = true;
+ // Drop the old snapshot before building the new one instead of after.
+ // Keeping both alive for the duration of the scan (previously: old stays
+ // referenced via `activeIndex` until the new one is fully built) doubles
+ // peak memory for a structure that can itself run into the GB range for
+ // large home directories — searches just return empty during the rebuild
+ // window instead, which is a fair trade against crashing the whole app.
+ activeIndex = null;
try {
const snapshot = await buildIndexSnapshot(configuredHomeDir);
activeIndex = snapshot;
diff --git a/src/main/main.ts b/src/main/main.ts
index 94985e40..b95527d3 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -14,6 +14,35 @@
process.stdout?.on?.('error', () => {});
process.stderr?.on?.('error', () => {});
+// Raise V8's old-space ceiling for THIS process (the main/browser process's
+// own embedded Node runtime) by re-exec'ing with the flag directly on argv.
+// Two other approaches were tried and confirmed NOT to work for this specific
+// process: app.commandLine.appendSwitch('js-flags', ...) only reaches
+// Chromium-managed renderer processes (a real crash at the heap ceiling
+// recurred with it in place), and v8.setFlagsFromString() cannot change
+// memory-layout flags after the isolate has already been created (verified
+// empirically — heap_size_limit is unchanged before/after the call). Passing
+// --js-flags on the actual argv of a fresh process is the one mechanism
+// Electron honors for its own main-process V8 instance.
+if (!process.env.SC_HEAP_REEXECED) {
+ try {
+ const { spawn } = require('child_process');
+ const child = spawn(
+ process.execPath,
+ ['--js-flags=--max-old-space-size=4096', ...process.argv.slice(1)],
+ { stdio: 'inherit', detached: true, env: { ...process.env, SC_HEAP_REEXECED: '1' } }
+ );
+ child.unref();
+ process.exit(0);
+ } catch (error) {
+ console.warn('[HeapDebug] Failed to re-exec with raised V8 old-space limit:', error);
+ }
+}
+try {
+ const limitMb = Math.round(require('v8').getHeapStatistics().heap_size_limit / 1048576);
+ console.log(`[HeapDebug] V8 old-space limit for this process: ~${limitMb}MB`);
+} catch {}
+
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
@@ -257,6 +286,43 @@ type ParakeetModelStatus = {
let parakeetModelStatus: ParakeetModelStatus | null = null;
let parakeetModelEnsurePromise: Promise | null = null;
+// Persistent transcription servers (parakeet/qwen3/whisper.cpp) keep a model
+// loaded in memory for fast repeat use, but otherwise sit idle for the rest
+// of the session once used once. Auto-kill them after a period of no use so
+// they don't hold their memory footprint until the app quits.
+const AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS = 10 * 60_000; // 10 minutes
+
+// `isBusy` guards against killing a server mid-request: ensureXServer() bumps
+// the timer once, up front, before the caller has even sent its request — for
+// a transcription that runs longer than idleMs (long audio, slow hardware),
+// nothing re-bumps the timer while it's in flight. Without this guard the
+// timer would fire and kill a server that's actively working, not idle.
+function createIdleShutdownScheduler(
+ killFn: () => void,
+ idleMs: number,
+ isBusy: () => boolean
+): { bump: () => void } {
+ let timer: ReturnType | null = null;
+ const scheduleCheck = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ if (isBusy()) {
+ // Still working — defer the kill and check again after another
+ // idle window rather than shutting down a busy server.
+ scheduleCheck();
+ return;
+ }
+ killFn();
+ }, idleMs);
+ };
+ return {
+ bump(): void {
+ scheduleCheck();
+ },
+ };
+}
+
// Persistent serve-mode process for fast transcription (models stay loaded in memory)
let parakeetServerProcess: any = null; // ChildProcess
let parakeetServerReady = false;
@@ -264,6 +330,11 @@ let parakeetServerStarting: Promise | null = null;
let parakeetServerBuffer = '';
type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void };
let parakeetPendingRequest: PendingParakeetRequest | null = null;
+const parakeetIdleShutdown = createIdleShutdownScheduler(
+ () => killParakeetServer(),
+ AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS,
+ () => parakeetPendingRequest !== null
+);
function killParakeetServer(): void {
if (parakeetServerProcess) {
@@ -283,6 +354,7 @@ function killParakeetServer(): void {
}
function ensureParakeetServer(): Promise {
+ parakeetIdleShutdown.bump();
if (parakeetServerReady && parakeetServerProcess && !parakeetServerProcess.killed) {
return Promise.resolve();
}
@@ -619,6 +691,11 @@ let qwen3ServerStarting: Promise | null = null;
let qwen3ServerBuffer = '';
type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void };
let qwen3PendingRequest: PendingQwen3Request | null = null;
+const qwen3IdleShutdown = createIdleShutdownScheduler(
+ () => killQwen3Server(),
+ AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS,
+ () => qwen3PendingRequest !== null
+);
function killQwen3Server(): void {
if (qwen3ServerProcess) {
@@ -638,6 +715,7 @@ function killQwen3Server(): void {
}
function ensureQwen3Server(): Promise {
+ qwen3IdleShutdown.bump();
if (qwen3ServerReady && qwen3ServerProcess && !qwen3ServerProcess.killed) {
return Promise.resolve();
}
@@ -1153,6 +1231,11 @@ let whisperCppServerStarting: Promise | null = null;
let whisperCppServerBuffer = '';
type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void };
let whisperCppPendingRequest: PendingWhisperCppRequest | null = null;
+const whisperCppIdleShutdown = createIdleShutdownScheduler(
+ () => killWhisperCppServer(),
+ AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS,
+ () => whisperCppPendingRequest !== null
+);
function killWhisperCppServer(): void {
if (whisperCppServerProcess) {
@@ -1172,6 +1255,7 @@ function killWhisperCppServer(): void {
}
function ensureWhisperCppServer(): Promise {
+ whisperCppIdleShutdown.bump();
if (whisperCppServerReady && whisperCppServerProcess && !whisperCppServerProcess.killed) {
return Promise.resolve();
}
@@ -1685,6 +1769,23 @@ const windowManagerWorkerPending = new Map;
}>();
+// ─── Graceful shutdown: drain in-flight extension/script work ───────
+// Extension bundling (esbuild + fs) and script command execution
+// (child_process) run real Node async work on the main process. If one of
+// those callbacks completes after Electron starts tearing down the V8/Node
+// environment on quit, Node has been observed to abort natively
+// (EXC_BREAKPOINT/SIGTRAP) instead of throwing a catchable JS error. Track
+// this work so before-quit can wait for it to settle first.
+const EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS = 2000;
+const inFlightExtensionWork = new Set>();
+
+function trackInFlightExtensionWork(promise: Promise): Promise {
+ inFlightExtensionWork.add(promise);
+ const untrack = () => { inFlightExtensionWork.delete(promise); };
+ promise.then(untrack, untrack);
+ return promise;
+}
+
function parseMajorVersion(value: string | undefined): number | null {
if (!value) return null;
const major = Number.parseInt(String(value).split('.')[0], 10);
@@ -1741,6 +1842,13 @@ function scheduleWindowManagerWorkerRestart(): void {
}
function attachWindowManagerWorkerListeners(proc: ChildProcess): void {
+ proc.stdout?.on('data', (chunk) => {
+ console.log(`[WindowManagerWorker] ${String(chunk).trimEnd()}`);
+ });
+ proc.stderr?.on('data', (chunk) => {
+ console.error(`[WindowManagerWorker] ${String(chunk).trimEnd()}`);
+ });
+
proc.on('message', (message: WindowManagerWorkerResponse) => {
if (!message || typeof message !== 'object') return;
const pending = windowManagerWorkerPending.get(Number(message.id));
@@ -1776,7 +1884,7 @@ function ensureWindowManagerWorker(): ChildProcess | null {
try {
const workerPath = getWindowManagerWorkerPath();
const proc = fork(workerPath, [], {
- stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
execArgv: [],
});
windowManagerWorker = proc;
@@ -2481,8 +2589,6 @@ let memoryStatusHideTimer: NodeJS.Timeout | null = null;
let memoryStatusFadeFinalizeTimer: NodeJS.Timeout | null = null;
let memoryStatusRenderSeq = 0;
let memoryStatusHideTimerSeq = 0;
-let confettiWindow: InstanceType | null = null;
-let confettiCloseTimer: NodeJS.Timeout | null = null;
let settingsWindow: InstanceType | null = null;
let extensionStoreWindow: InstanceType | null = null;
let notesWindow: InstanceType | null = null;
@@ -2841,24 +2947,25 @@ function getConfettiWindowHtml(): string {
const COLORS = ['#ff4d6d', '#ffd166', '#06d6a0', '#4cc9f0', '#f72585', '#b8f2e6', '#ffffff'];
const particles = [];
- // Two bursts from lower-left and lower-right, plus a center shower
+ // Two wide bursts from the bottom corners, plus a center shower, sized
+ // to cover most of the screen rather than three small isolated clumps.
const bursts = [
- { x: canvas.width * 0.15, y: canvas.height * 0.85, angle: -Math.PI * 0.30, spread: 0.9 },
- { x: canvas.width * 0.85, y: canvas.height * 0.85, angle: -Math.PI * 0.70, spread: 0.9 },
- { x: canvas.width * 0.50, y: canvas.height * 0.55, angle: -Math.PI * 0.50, spread: 1.1 },
+ { x: canvas.width * 0.06, y: canvas.height * 0.95, angle: -Math.PI * 0.28, spread: 1.0 },
+ { x: canvas.width * 0.94, y: canvas.height * 0.95, angle: -Math.PI * 0.72, spread: 1.0 },
+ { x: canvas.width * 0.50, y: canvas.height * 0.55, angle: -Math.PI * 0.50, spread: 1.3 },
];
- const gravity = 0.32 * dpr;
+ const gravity = 0.28 * dpr;
for (const b of bursts) {
- const count = 110;
+ const count = 220;
for (let i = 0; i < count; i++) {
const a = b.angle + (Math.random() - 0.5) * b.spread;
- const speed = (9 + Math.random() * 12) * dpr;
+ const speed = (14 + Math.random() * 22) * dpr;
particles.push({
x: b.x,
y: b.y,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
- size: (4 + Math.random() * 7) * dpr,
+ size: (6 + Math.random() * 10) * dpr,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
rot: Math.random() * Math.PI * 2,
rotSpeed: (Math.random() - 0.5) * 0.3,
@@ -2866,7 +2973,7 @@ function getConfettiWindowHtml(): string {
}
}
- const durationMs = 1800;
+ const durationMs = 2400;
const start = performance.now();
function tick(now) {
const elapsed = now - start;
@@ -2876,7 +2983,7 @@ function getConfettiWindowHtml(): string {
p.x += p.vx;
p.y += p.vy;
p.vy += gravity;
- p.vx *= 0.993;
+ p.vx *= 0.996;
p.rot += p.rotSpeed;
ctx.save();
ctx.translate(p.x, p.y);
@@ -2899,24 +3006,139 @@ function getConfettiWindowHtml(): string {