diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 095d6e24..c7ad06df 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -585,11 +585,12 @@ function handleWorkspaceChange(event: z.infer { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if ((code === 'EMFILE' || code === 'ENOSPC') && (err?.stack ?? '').includes('FSWatcher')) { + console.error('[Main] file watcher error (non-fatal):', err); + return; + } + console.error('[Main] uncaught exception:', err); + dialog.showErrorBox( + 'A JavaScript error occurred in the main process', + err?.stack ?? String(err), + ); +}); + // run this as early in the main process as possible if (started) app.quit(); diff --git a/apps/x/packages/core/src/apps/server.ts b/apps/x/packages/core/src/apps/server.ts index 58565e7b..e73f26b9 100644 --- a/apps/x/packages/core/src/apps/server.ts +++ b/apps/x/packages/core/src/apps/server.ts @@ -719,6 +719,12 @@ async function startWatcher(): Promise { if (watcher) return; const w = chokidar.watch(APPS_DIR, { ignoreInitial: true, + // Installed apps may ship .git/node_modules trees — thousands of files + // no consumer renders, at one watch fd per file (chokidar v4, no fsevents). + ignored: (watchedPath: string) => { + const segments = path.relative(APPS_DIR, watchedPath).split(path.sep); + return segments.includes('.git') || segments.includes('node_modules'); + }, awaitWriteFinish: { stabilityThreshold: 180, pollInterval: 50 }, }); w.on('all', (eventName, absolutePath) => { diff --git a/apps/x/packages/core/src/workspace/watcher.ts b/apps/x/packages/core/src/workspace/watcher.ts index 0b3e983b..c50163d7 100644 --- a/apps/x/packages/core/src/workspace/watcher.ts +++ b/apps/x/packages/core/src/workspace/watcher.ts @@ -9,10 +9,28 @@ import { Stats } from 'node:fs'; export type WorkspaceChangeCallback = (event: z.infer) => void; +// The only WorkDir paths whose change events have a consumer (knowledge index +// invalidation in main, and the tree/editor, live-notes, email, sidebar and +// meetings views in the renderer). Everything else under WorkDir — runs-archive, +// storage/turns, engines, logs, code-mode, ... — is internal state that grows +// unboundedly with usage; chokidar v4 holds one OS watch handle per file, so +// watching all of WorkDir exhausts the process fd limit (EMFILE crash) once +// the workdir gets big enough. +const WATCHED_DIR_ROOTS = [ + 'knowledge', + 'bases', + 'inbox_lists', + 'gmail_sync', + 'calendar_sync', + 'bg-tasks', +]; +const WATCHED_FILE_ROOTS = ['config/agent-schedule.json']; + /** * Create a workspace watcher - * Watches the configured workspace root recursively and emits change events via callback - * + * Watches the user-facing workspace roots (WATCHED_DIR_ROOTS / WATCHED_FILE_ROOTS) + * recursively and emits WorkDir-relative change events via callback. + * * Returns a watcher instance that can be closed. * The watcher emits events immediately without debouncing. * Debouncing and lifecycle management should be handled by the caller. @@ -22,15 +40,24 @@ export async function createWorkspaceWatcher( ): Promise { await ensureWorkspaceRoot(); - // Code-section session worktrees are full repo checkouts (thousands of files, - // possibly node_modules) living under WorkDir — watching them would flood the - // event stream and burn file handles, and nothing in the app renders them - // from workspace events. - const codeModeDir = path.join(WorkDir, 'code-mode'); - const watcher = chokidar.watch(WorkDir, { + // chokidar v4 never picks up a watched directory that doesn't exist yet + // (a missing file is fine as long as its parent dir exists), so create the + // roots up front — otherwise e.g. calendar_sync/ appearing after app start + // would stay invisible until restart. + for (const rel of WATCHED_DIR_ROOTS) { + await fs.mkdir(path.join(WorkDir, rel), { recursive: true }); + } + for (const rel of WATCHED_FILE_ROOTS) { + await fs.mkdir(path.dirname(path.join(WorkDir, rel)), { recursive: true }); + } + + const roots = [...WATCHED_DIR_ROOTS, ...WATCHED_FILE_ROOTS].map((rel) => path.join(WorkDir, rel)); + const watcher = chokidar.watch(roots, { ignoreInitial: true, + // knowledge/ is a git repo (version history) — its .git object store is + // thousands of files nothing renders, so keep it out of the watch set. ignored: (watchedPath: string) => - watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep), + path.relative(WorkDir, watchedPath).split(path.sep).includes('.git'), awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50,