diff --git a/docs/iterations/edit-project/README.md b/docs/iterations/edit-project/README.md index fb94c737..9d1ad8d4 100644 --- a/docs/iterations/edit-project/README.md +++ b/docs/iterations/edit-project/README.md @@ -12,7 +12,7 @@ - **State**: Zustand slice (`editorSlice.ts`) - **Виртуализация**: `@tanstack/react-virtual` (уже в проекте) - **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях) -- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped) — для встроенного Cmd+F поиска в файле. Остальное уже установлено +- **Новые npm-зависимости**: `@codemirror/search` (~15KB gzipped), `@radix-ui/react-context-menu` (итерация 3, контекстное меню) — для встроенного Cmd+F поиска в файле и CRUD-меню. Остальное уже установлено ## Ключевые архитектурные решения diff --git a/docs/iterations/edit-project/architecture.md b/docs/iterations/edit-project/architecture.md index 7ae49b1a..90cfbb69 100644 --- a/docs/iterations/edit-project/architecture.md +++ b/docs/iterations/edit-project/architecture.md @@ -51,12 +51,12 @@ ``` src/renderer/components/team/editor/ -├── ProjectEditorOverlay.tsx # Полноэкранный overlay (max 150 LOC) -├── EditorFileTree.tsx # Обёртка над generic FileTree (max 200 LOC) -├── EditorTabBar.tsx # Панель вкладок (max 100 LOC) -├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling (max 150 LOC) -├── EditorToolbar.tsx # Save, Undo, Redo, язык (max 100 LOC) -├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (max 80 LOC) +├── ProjectEditorOverlay.tsx # Полноэкранный overlay (~150-200 LOC) +├── EditorFileTree.tsx # Обёртка над generic FileTree (~150-200 LOC) +├── EditorTabBar.tsx # Панель вкладок (~100-130 LOC) +├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling + editorBridge (~250-350 LOC) +├── EditorToolbar.tsx # Save, Undo, Redo, язык (~80-100 LOC) +├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (~60-80 LOC) ├── EditorContextMenu.tsx # Context menu для дерева файлов (итерация 3) ├── NewFileDialog.tsx # Inline-input для имени нового файла (итерация 3) ├── QuickOpenDialog.tsx # Cmd+P fuzzy search (итерация 4) @@ -217,7 +217,7 @@ export interface EditorSlice { editorFileTreeLoading: boolean; editorFileTreeError: string | null; - editorExpandedDirs: Set; // Сохраняется при re-open (H7) + editorExpandedDirs: Record; // Сохраняется при re-open. Record — согласовано с editorModifiedFiles (Zustand не отслеживает мутации Set) openEditor: (projectPath: string) => Promise; closeEditor: () => void; @@ -230,7 +230,7 @@ export interface EditorSlice { // 2. stateCache.current.clear() — освободить все EditorState из Map // 3. scrollTopCache.current.clear() — освободить scroll positions // 4. viewRef.current?.destroy() — уничтожить активный EditorView - // 5. Сброс slice state: tabs=[], tree=null, modified=Set(), loading={}, errors={} + // 5. Сброс slice state: tabs=[], tree=null, modified={}, expandedDirs={}, loading={}, errors={} // } loadFileTree: (dirPath: string) => Promise; expandDirectory: (dirPath: string) => Promise; @@ -306,14 +306,17 @@ export const editorBridge = { }, /** Вызывается CodeMirrorEditor при unmount */ unregister() { stateCache = null; scrollTopCache = null; activeView = null; }, + /** Проверка: зарегистрирован ли bridge (HMR guard) */ + get isRegistered(): boolean { return stateCache !== null; }, /** Для saveFile() — контент из кешированного state */ getContent(filePath: string): string | null { return stateCache?.get(filePath)?.doc.toString() ?? null; }, /** Для saveAllFiles() — контент всех modified файлов */ - getAllModifiedContent(modifiedFiles: Set): Map { + getAllModifiedContent(modifiedFiles: Record): Map { const result = new Map(); - for (const fp of modifiedFiles) { + for (const fp of Object.keys(modifiedFiles)) { + if (!modifiedFiles[fp]) continue; const content = stateCache?.get(fp)?.doc.toString(); if (content !== undefined) result.set(fp, content); } @@ -333,6 +336,15 @@ export const editorBridge = { Паттерн аналогичен `ConfirmDialog.tsx` (module-level `globalSetState`) и `changeReviewSlice.ts` (module-level state). +**HMR guard**: При HMR модуль перезагружается → refs обнуляются. Компонент CodeMirrorEditor в `useEffect` проверяет `editorBridge.isRegistered` и перерегистрируется при необходимости: +```typescript +useEffect(() => { + editorBridge.register(stateCache.current, scrollTopCache.current, viewRef.current!); + return () => editorBridge.unregister(); +}, []); // single registration at mount +``` +Store actions проверяют `editorBridge.isRegistered` перед обращением — при false логируют warning и graceful fallback (не крашат). + ### EditorState pooling (Map в useRef) Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор. @@ -368,8 +380,12 @@ function switchTab(oldTabId: string, newTabId: string) { // LRU eviction при > 30 states: if (stateCache.current.size > 30) { - // Вытеснить oldest, сохранив { content: doc.toString(), cursorPos } - // При возврате -- восстановить через EditorState.create() + // LRU eviction: вытеснить наименее недавно использованный state (least recently used). + // Трекинг порядка: обновлять `accessOrder: string[]` при каждом switchTab (push tabId в конец, + // удалить предыдущее вхождение). Вытеснять accessOrder[0]. + // При eviction: + // 1. Удалить dirty flag из editorModifiedFiles (если был) + очистить draft из localStorage + // 2. Сохранить { content: doc.toString(), cursorPos } для восстановления через EditorState.create() } ``` @@ -566,7 +582,7 @@ removeEditorHandlers(ipcMain); ## Компоненты -### ProjectEditorOverlay.tsx (max 150 LOC) +### ProjectEditorOverlay.tsx (~150-200 LOC) **Ответственность**: Layout shell -- `fixed inset-0 z-50`, header с кнопкой закрытия, split layout (sidebar + main). @@ -578,7 +594,7 @@ removeEditorHandlers(ipcMain); - Escape/X с unsaved changes: ConfirmDialog с тремя кнопками -- "Save All & Close" / "Discard & Close" / "Cancel" - Кнопка `?` в header: открывает `EditorShortcutsHelp` -### EditorFileTree.tsx (max 200 LOC) +### EditorFileTree.tsx (~150-200 LOC) **Ответственность**: Тонкая обёртка над generic `FileTree`. @@ -592,7 +608,7 @@ removeEditorHandlers(ipcMain); - Длинные имена: `truncate` + `title` tooltip - ARIA: `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`, keyboard navigation (arrow keys) -### Generic FileTree.tsx (common/, max 250 LOC) +### Generic FileTree.tsx (common/, ~200-250 LOC) **Ответственность**: Переиспользуемый generic tree с render-props. @@ -604,7 +620,7 @@ interface FileTreeProps, isSelected: boolean, depth: number) => React.ReactNode; renderFolderLabel?: (node: TreeNode, isOpen: boolean, depth: number) => React.ReactNode; renderNodeIcon?: (node: TreeNode) => React.ReactNode; - collapsedFolders: Set; + collapsedFolders: Record; onToggleFolder: (fullPath: string) => void; } @@ -623,7 +639,7 @@ interface TreeNode { - `renderLeafNode` заменяет весь leaf-элемент (не просто "extra"), что покрывает сложные сценарии ReviewFileTree (11 пропсов из store) - Виртуализация через `@tanstack/react-virtual` с итерации 4: `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })` -### EditorTabBar.tsx (max 100 LOC) +### EditorTabBar.tsx (~100-130 LOC) **Ответственность**: Панель вкладок с переключением, закрытием, dirty indicator. @@ -634,32 +650,34 @@ interface TreeNode { - Middle-click close, X button close - ARIA: `role="tablist"`, `role="tab"`, `aria-selected` -### CodeMirrorEditor.tsx (max 150 LOC) +### CodeMirrorEditor.tsx (~250-350 LOC) -**Ответственность**: CM6 lifecycle -- EditorState pooling, extensions, keybindings. +**Ответственность**: CM6 lifecycle — EditorState pooling, extensions, keybindings, bridge registration, autosave. - Один EditorView на весь редактор (активный файл) - `Map` в useRef -- Extensions через `buildEditorExtensions(options)` -- фабрика, компонент не знает о конкретных CM plugins +- Extensions через `buildEditorExtensions(options)` — фабрика, компонент не знает о конкретных CM plugins - Dirty flag через debounced `EditorView.updateListener` (300ms) - LRU eviction при > 30 states +- `editorBridge.register()` при mount, `editorBridge.unregister()` при unmount (R3) +- Draft autosave в localStorage (30 сек debounce) + recovery при reopen - Паттерн lifecycle из `MembersJsonEditor.tsx` (строки 27-73) -### EditorStatusBar.tsx (max 80 LOC) +### EditorStatusBar.tsx (~60-80 LOC) **Ответственность**: Нижняя полоска: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2] | [LF]` - Данные из CM6 state (cursor position, language) - CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6` -### EditorBinaryState.tsx (max 60 LOC) +### EditorBinaryState.tsx (~50-60 LOC) **Ответственность**: Заглушка вместо CM6 для бинарных файлов. - Иконка файла, тип, размер - Кнопки "Open in System Viewer" (`shell:openPath`) и "Close Tab" -### EditorErrorState.tsx (max 60 LOC) +### EditorErrorState.tsx (~50-60 LOC) **Ответственность**: Заглушка при ошибке чтения. diff --git a/docs/iterations/edit-project/file-list.md b/docs/iterations/edit-project/file-list.md index 452ab482..93ff5425 100644 --- a/docs/iterations/edit-project/file-list.md +++ b/docs/iterations/edit-project/file-list.md @@ -10,7 +10,7 @@ | 4 | CM6 тормозит на файлах >2MB | Низкая | Средний | 1 | Hard limit 2MB + тиерная стратегия + external editor fallback | | 5 | TOCTOU race condition при save | Высокая | Высокий | 2 | Atomic write (tmp + rename) + post-read verify | | 6 | Race condition: агент и пользователь редактируют один файл | Высокая | Высокий | 5 | mtime check + conflict dialog (overwrite / cancel / diff) | -| 7 | Unsaved data loss при crash | Средняя | Средний | 2 | Возможен autosave в localStorage/IndexedDB (P2 фича) | +| 7 | Unsaved data loss при crash | Средняя | Средний | 2 | Draft autosave в localStorage (30 сек debounce, max 10 drafts x 500KB). Recovery banner при reopen | | 8 | Device file DoS (/dev/zero) | Средняя | Высокий | 1 | `fs.lstat()` + `isFile()` + block /dev/ /proc/ /sys/ | | 9 | Credential leakage (.env, .key) | Высокая | Высокий | 1 | `validateFilePath()` + визуальная пометка + блокировка чтения | | 10 | ReDoS в searchInFiles | Средняя | Средний | 4 | Только literal search + timeout через AbortController | @@ -22,7 +22,28 @@ --- -## Benchmarks +## Тест-стратегия + +### Unit-тесты (Vitest) +~15 файлов, покрывают: сервисы (ProjectFileService, FileSearchService, GitStatusService), store slices, утилиты (fileTreeBuilder, tabLabelDisambiguation, codemirrorLanguages, atomicWrite), IPC wrapper. Запуск: `pnpm test`. + +### Integration-тесты (Vitest + happy-dom) +Для компонентов использующих CM6 — happy-dom НЕ поддерживает `contenteditable` полностью. Стратегия: +- **CodeMirrorEditor**: тестировать через mock EditorView. Проверять lifecycle (mount → register bridge, unmount → unregister), tab switch (stateCache save/restore), dirty flag propagation +- **editorSlice + editorBridge**: интеграционный тест — store action вызывает bridge, bridge возвращает mock content +- **IPC handlers (editor.ts)**: тестировать с mock fs + mock ProjectFileService. Проверять security guards (path traversal, .git/ write block, device paths) + +### Manual smoke-тесты (каждая итерация) +Обязательный чеклист перед мёрджем каждого PR: +- [ ] Открыть editor, навигировать по дереву, открыть файл — подсветка работает +- [ ] Редактировать файл, Cmd+S — сохранение без ошибок +- [ ] Unsaved changes при закрытии — confirmation dialog +- [ ] ChangeReviewDialog по-прежнему работает корректно (regression) +- [ ] Горячие клавиши НЕ конфликтуют с глобальными при закрытом editor + +### Benchmarks (manual, один раз после iter-4) + +Запускать вручную через DevTools Performance tab + React DevTools Profiler. Результаты фиксировать в PR description. ``` Benchmark 1: EditorView memory @@ -52,6 +73,23 @@ Benchmark 5: Keystroke re-renders --- +## Стратегия отката + +Каждая итерация — отдельный PR. При проблемах — revert PR целиком. + +| Итерация | Fallback при провале | Минимально жизнеспособный результат | +|----------|---------------------|-------------------------------------| +| PR 0 | Revert PR. Рефакторинги механические (извлечение функций), не трогают review logic. При проблеме — revert + дублировать код в editor | — | +| Iter 1 | Read-only browser без CM6 — просто дерево + `
` с raw text | Кнопка "Open in Editor" → файловый браузер |
+| Iter 2 | Оставить read-only из iter-1, открывать файлы в external editor (`shell:openPath`) | Read-only + external editor fallback |
+| Iter 3 | Один таб (последний открытый файл). CRUD через terminal/external | Single-tab editor |
+| Iter 4 | Без Quick Open, без search — ручная навигация по дереву. Без виртуализации (работает до ~2000 файлов) | Editor без search/shortcuts |
+| Iter 5 | Без git badges, без file watcher — ручной F5 refresh. Без conflict detection — last-write-wins | Полный editor без live features |
+
+**Критическая точка невозврата**: нет. Каждый PR изолирован. Даже если iter-5 провалится, iter-1-4 дают полноценный editor без git/watcher.
+
+---
+
 ## Полный список файлов
 
 ### Новые файлы (~30)
@@ -77,7 +115,7 @@ Benchmark 5: Keystroke re-renders
 | 17 | `src/renderer/components/common/FileTree.tsx` | 1 | Generic FileTree с render-props |
 | 18 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | 1 | Full-screen overlay |
 | 19 | `src/renderer/components/team/editor/EditorFileTree.tsx` | 1 | Обёртка над FileTree |
-| 20 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | 1 | CM6 wrapper |
+| 20 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | 1 | CM6 wrapper (~250-350 LOC: pooling + LRU + bridge + dirty + autosave) |
 | 21 | `src/renderer/components/team/editor/EditorTabBar.tsx` | 2 | Панель вкладок |
 | 22 | `src/renderer/components/team/editor/EditorToolbar.tsx` | 2 | Toolbar |
 | 23 | `src/renderer/components/team/editor/EditorStatusBar.tsx` | 2 | Status bar |
diff --git a/docs/iterations/edit-project/iter-0-refactoring.md b/docs/iterations/edit-project/iter-0-refactoring.md
index a92b8f04..c2ce1d83 100644
--- a/docs/iterations/edit-project/iter-0-refactoring.md
+++ b/docs/iterations/edit-project/iter-0-refactoring.md
@@ -79,9 +79,26 @@ const wrapHandler = createIpcWrapper('IPC:editor');
 - `review.ts` импортирует `createIpcWrapper` из `ipcWrapper.ts`
 - `teams.ts` — миграция `wrapTeamHandler` → `createIpcWrapper` в отдельном follow-up PR (40+ замен, высокий blast radius)
 
+## Уровень риска по рефакторингам
+
+| R# | Что трогает | Уровень риска | Почему |
+|----|-------------|--------------|--------|
+| R1 | ReviewFileTree (дерево файлов) | Низкий | Извлечение чистой функции buildTree(). Diff-viewer, hunks, approve/reject — не затрагиваются |
+| R2 | CodeMirrorDiffView (языки) | Около нуля | Чистые stateless функции, просто выносим в отдельный файл |
+| R3 | CodeMirrorDiffView (тема) | Низкий | Разрезка CSS-объекта на base + diff-specific. Визуально проверить |
+| R4 | review.ts (IPC wrapper) | Около нуля | 15 LOC try-catch factory, тривиальная замена |
+
+**Что НЕ затрагивается**: ChangeReviewDialog logic, diff rendering, hunks, approve/reject, комментарии, changeReviewSlice state.
+
 ## Критерии готовности
 
-- `pnpm typecheck` проходит
-- Тесты `ReviewFileTree` и `CodeMirrorDiffView` проходят (zero behavior change)
-- ChangeReviewDialog работает корректно (manual check)
-- Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
+- [ ] `pnpm typecheck` проходит
+- [ ] `pnpm test` проходит (zero behavior change)
+- [ ] Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
+- [ ] Новые unit-тесты для `codemirrorLanguages.ts` — проверка маппинга расширений на языки
+- [ ] Manual smoke-тест: открыть ChangeReviewDialog → файловое дерево рендерится корректно, diff подсвечивается
+
+## Оценка
+
+- **Надёжность решения: 9/10** — все 4 рефакторинга механические (извлечение функций без изменения поведения). R2/R4 — около нуля риска, R1/R3 — низкий.
+- **Уверенность: 9/10** — код review workflow не затрагивается, трогается только утилитарная часть (дерево файлов, языки, тема, wrapper).
diff --git a/docs/iterations/edit-project/iter-1-walking-skeleton.md b/docs/iterations/edit-project/iter-1-walking-skeleton.md
index e3d24837..750913eb 100644
--- a/docs/iterations/edit-project/iter-1-walking-skeleton.md
+++ b/docs/iterations/edit-project/iter-1-walking-skeleton.md
@@ -61,10 +61,11 @@
 
 ## Security-требования
 
-1. `ProjectFileService.readDir()`: для каждого entry проверять containment через `isPathWithinAllowedDirectories()` (экспортирована из pathValidation.ts). Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать entries за пределами projectRoot (SEC-2). **НЕ вызывать `validateFilePath()` целиком** — она блокирует sensitive файлы, а readDir должен их ПОКАЗЫВАТЬ с пометкой `isSensitive: true`. Для пометки использовать новую экспортируемую функцию `matchesSensitivePattern()` из pathValidation.ts (сейчас приватная — нужно экспортировать) (SEC-6)
-2. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
-3. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
-4. Sensitive файлы: показывать с замком в дереве, "Sensitive file, cannot open" при клике (SEC-6)
+1. **SEC-15**: `editor:open` handler валидирует `projectPath` ДО установки `activeProjectRoot`: `path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`/`'C:\\'`, `!isPathWithinRoot(path, claudeDir)`. Без этого злонамеренный renderer может передать `"/"`, делая ВСЕ пути валидными
+2. `ProjectFileService.readDir()`: для каждого entry проверять containment через `isPathWithinAllowedDirectories()` (экспортирована из pathValidation.ts). Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать entries за пределами projectRoot (SEC-2). **НЕ вызывать `validateFilePath()` целиком** — она блокирует sensitive файлы, а readDir должен их ПОКАЗЫВАТЬ с пометкой `isSensitive: true`. Для пометки использовать новую экспортируемую функцию `matchesSensitivePattern()` из pathValidation.ts (сейчас приватная — нужно экспортировать) (SEC-6)
+3. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
+4. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
+5. Sensitive файлы: показывать с замком в дереве, "Sensitive file, cannot open" при клике (SEC-6)
 
 ## Performance-требования
 
diff --git a/docs/iterations/edit-project/iter-2-editable-save.md b/docs/iterations/edit-project/iter-2-editable-save.md
index b518a699..f26b38dc 100644
--- a/docs/iterations/edit-project/iter-2-editable-save.md
+++ b/docs/iterations/edit-project/iter-2-editable-save.md
@@ -48,7 +48,28 @@
 - Dirty flag через debounced `EditorView.updateListener` (300ms)
 - Гранулярные Zustand-селекторы: FileTreePanel не подписывается на tabs/content
 - EditorState pooling: один EditorView, Map в useRef
-- LRU eviction при > 30 states
+- LRU eviction при > 30 states. При eviction: удалить dirty flag из `editorModifiedFiles` (предотвращает stale dirty indicator), очистить draft из localStorage
+
+## Autosave (draft recovery)
+
+Минимальный autosave для защиты от потери данных при crash/kill:
+
+```typescript
+// В CodeMirrorEditor.tsx — debounced 30 секунд после последнего изменения
+const AUTOSAVE_DELAY = 30_000;
+
+// Сохранять draft в localStorage:
+// key: `editor-draft:${filePath}`
+// value: JSON.stringify({ content: doc.toString(), timestamp: Date.now() })
+// Очищать при успешном saveFile() или при closeTab()
+
+// При openFile() — проверить наличие draft:
+// if (draft exists && draft.timestamp > file.mtimeMs) → banner "Recovered unsaved changes. [Apply] [Discard]"
+// Edge case: если draft.timestamp < file.mtimeMs — файл был изменён извне ПОСЛЕ draft → draft устарел, удалить молча
+// Edge case: если file.mtimeMs === 0 или undefined (новый файл) — применять draft безусловно
+```
+
+Лимиты: max 10 drafts, max 500KB per draft. При превышении — вытеснять oldest. Не сохранять draft для read-only/preview файлов.
 
 ## UX-требования
 
@@ -56,6 +77,8 @@
 - Unsaved changes при закрытии overlay: три кнопки ("Save All & Close" / "Discard & Close" / "Cancel")
 - Dirty indicator (точка) на вкладке ПЕРЕД текстом
 - `hasUnsavedChanges()` в slice
+- `closeTab()` с dirty state: показать confirm dialog ("Save / Discard / Cancel") перед закрытием. При "Save" — сохранить через IPC, при "Discard" — закрыть + удалить draft, при "Cancel" — отмена. Закрытие overlay с dirty tabs — аналогично через "Save All & Close" / "Discard & Close" / "Cancel"
+- Draft recovery banner при обнаружении несохранённого draft
 
 ## Тестирование
 
@@ -65,7 +88,8 @@
 | 2 | `editorSlice` -- open/close файлы, dirty state, save | `test/renderer/store/editorSlice.test.ts` (расширение) |
 | 3 | `atomicWrite` -- unit тесты | `test/main/utils/atomicWrite.test.ts` |
 | 4 | EditorState pooling -- save/restore state при switch tab | — |
-| 5 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
+| 5 | Draft autosave — сохранение в localStorage, recovery при reopen, cleanup при save/close | — (manual + unit для localStorage logic) |
+| 6 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
 
 ## Критерии готовности
 
@@ -74,6 +98,7 @@
 - [ ] Dirty indicator на вкладке
 - [ ] Status bar показывает позицию курсора и язык
 - [ ] При закрытии overlay с unsaved changes -- confirmation dialog
+- [ ] Draft autosave: после 30 сек без сохранения — draft в localStorage, recovery при reopen
 - [ ] Benchmark: 0 re-render FileTreePanel/TabBar при наборе текста
 
 ## Оценка
diff --git a/docs/iterations/edit-project/iter-4-search-shortcuts.md b/docs/iterations/edit-project/iter-4-search-shortcuts.md
index 03c554a7..5a8a0fe7 100644
--- a/docs/iterations/edit-project/iter-4-search-shortcuts.md
+++ b/docs/iterations/edit-project/iter-4-search-shortcuts.md
@@ -39,13 +39,13 @@
 
 ## Security-требования
 
-1. `searchInFiles`: ТОЛЬКО literal string search, НЕ regex. Default case-insensitive (`line.toLowerCase().includes(query.toLowerCase())` — ReDoS-безопасно). Опция `caseSensitive?: boolean` в параметрах. Max 1000 файлов, max 1MB/файл. Каждый файл валидируется через `validateFilePath()`. AbortController timeout 5s (SEC-8)
+1. `searchInFiles`: ТОЛЬКО literal string search, НЕ regex. Default case-insensitive (`line.toLowerCase().includes(query.toLowerCase())` — ReDoS-безопасно). Опция `caseSensitive?: boolean` в параметрах. Max 1000 файлов, max 1MB/файл. Каждый файл валидируется через `validateFilePath()`. AbortController timeout 5s (SEC-8). **Cancellation**: предыдущий поиск отменяется AbortController при новом запросе (debounce 300ms на renderer перед IPC вызовом)
 
 ## Performance-требования
 
 - File tree виртуализация: `@tanstack/react-virtual` -- `flattenTree()` + `useVirtualizer({ estimateSize: () => 28 })`
 - Quick Open: кешировать flat file list при открытии editor. Invalidate по file watcher event или F5
-- Search in files: запускать с AbortController timeout
+- Search in files: запускать с AbortController timeout. На renderer: debounce 300ms + отмена предыдущего IPC запроса при новом вводе (хранить `abortControllerRef` в SearchInFilesPanel)
 
 ## Keyboard Scope Isolation (R1)
 
@@ -61,6 +61,8 @@ const editorOpen = useStore(s => s.editorProjectPath !== null);
 
 Плюс в `useEditorKeyboardShortcuts.ts` — все CM6 keybindings с `stopPropagation: true` как safety net.
 
+**Keyboard scope для диалогов внутри editor**: Escape в QuickOpenDialog/SearchInFilesPanel закрывает ДИАЛОГ, не overlay. Реализация: диалоги вызывают `e.stopPropagation()` на Escape, overlay слушает Escape только когда нет открытых диалогов (state-guard `quickOpenVisible || searchPanelVisible`).
+
 ## Изменения в существующих файлах (доп.)
 
 | # | Файл | Изменение |
diff --git a/docs/iterations/edit-project/iter-5-git-watching.md b/docs/iterations/edit-project/iter-5-git-watching.md
index 5e965ce8..a04d29ed 100644
--- a/docs/iterations/edit-project/iter-5-git-watching.md
+++ b/docs/iterations/edit-project/iter-5-git-watching.md
@@ -19,7 +19,7 @@ Git status в дереве файлов. Live refresh при изменения
 | # | Файл | Описание |
 |---|------|----------|
 | 1 | `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher (~250-300 LOC, не ~60!). fs.watch + burst coalescing (200ms debounce + batch) + ENOSPC fallback to polling (Linux). Фильтрация node_modules/.git/dist |
-| 2 | `src/main/services/editor/GitStatusService.ts` | `git --no-optional-locks status --porcelain -z` парсинг, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 LOC) |
+| 2 | `src/main/services/editor/GitStatusService.ts` | `git --no-optional-locks status --porcelain -z` парсинг (NUL-separated), кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 LOC) |
 | 3 | `src/main/services/editor/conflictDetection.ts` | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
 | 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
 
@@ -59,7 +59,12 @@ Git status в дереве файлов. Live refresh при изменения
 - Git status кешировать на 5 секунд. Invalidate по file watcher event
 - Git command: `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules`
   - `--no-optional-locks` — предотвращает `.git/index.lock` конфликты (критично!)
-  - `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами)
+  - `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами). **Важные особенности парсинга**:
+    - Записи разделены NUL (`\0`), НЕ newline
+    - Rename/copy (R/C коды): 2 пути через NUL — `R  old\0new\0` (3 записи в сплите, считывать парами)
+    - Conflict коды: `UU` (both modified), `AA` (both added), `DD` (both deleted) → маппить на `status: 'conflict'`
+    - Untracked: `?? path\0` → `status: 'untracked'`
+    - Формат каждой записи: `XY path` где X=index status, Y=worktree status (2 символа + пробел + путь)
   - `--ignore-submodules` — ускорение на монорепо
 - Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal
 - Graceful degradation:
@@ -85,7 +90,7 @@ Git status в дереве файлов. Live refresh при изменения
 
 ## Критерии готовности
 
-- [ ] Git status бейджи (M/U/A) в файловом дереве
+- [ ] Git status бейджи (M/U/A/C) в файловом дереве (C = conflict для UU/AA/DD)
 - [ ] Auto-refresh при изменениях на диске (при включённом watcher)
 - [ ] Conflict detection при сохранении
 - [ ] Line wrap toggle
diff --git a/src/main/index.ts b/src/main/index.ts
index 6660ea34..66a74fd7 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -214,6 +214,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
 
   // Wire file-change events to renderer and HTTP SSE
   const fileChangeHandler = (event: unknown): void => {
+    // Invalidate the scan cache so the next IPC request sees fresh data
+    context.projectScanner.clearScanCache();
+
     if (mainWindow && !mainWindow.isDestroyed()) {
       mainWindow.webContents.send('file-change', event);
     }
@@ -406,9 +409,11 @@ function initializeServices(): void {
     todosDir: localTodosDir,
   });
 
-  // Register and start local context
+  // Register context and start cache cleanup only.
+  // FileWatcher is deferred to did-finish-load to avoid blocking window creation
+  // with fs.watch() setup (especially slow on Windows NTFS with recursive watchers).
   contextRegistry.registerContext(localContext);
-  localContext.start();
+  localContext.startCacheOnly();
 
   logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`);
 
@@ -654,6 +659,12 @@ function createWindow(): void {
           mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen());
         }
       }, 0);
+      // Start file watchers now that the window is visible and responsive.
+      // Deferred from initializeServices() to avoid blocking window creation
+      // with fs.watch() setup (especially slow on Windows with recursive watchers).
+      const activeContext = contextRegistry.getActive();
+      activeContext.startFileWatcher();
+
       setTimeout(() => updaterService.checkForUpdates(), 3000);
 
       // Defer non-critical startup work to avoid thread pool contention.
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index b80e36e4..2acfc29f 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -78,6 +78,15 @@ export class ProjectScanner {
     { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null }
   >();
 
+  // Short-lived scan cache to prevent duplicate scans within the same request cycle.
+  // Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates.
+  private scanCache: { projects: Project[]; timestamp: number } | null = null;
+  private static readonly SCAN_CACHE_TTL_MS = 2000;
+
+  // Platform-aware batch sizes to avoid UV thread pool saturation on Windows
+  private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 32 : 128;
+  private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24;
+
   // Delegated services
   private readonly fsProvider: FileSystemProvider;
   private readonly sessionContentFilter: typeof SessionContentFilter;
@@ -108,6 +117,15 @@ export class ProjectScanner {
    * @returns Promise resolving to projects sorted by most recent activity
    */
   async scan(): Promise {
+    // Short-lived cache: prevents duplicate scans when getProjects() and
+    // getRepositoryGroups() fire in Promise.all() on startup/context switch.
+    if (
+      this.scanCache &&
+      Date.now() - this.scanCache.timestamp < ProjectScanner.SCAN_CACHE_TTL_MS
+    ) {
+      return this.scanCache.projects;
+    }
+
     const startedAt = Date.now();
     try {
       if (!(await this.fsProvider.exists(this.projectsDir))) {
@@ -128,7 +146,7 @@ export class ProjectScanner {
       // Process each project directory (may return multiple projects per dir)
       const projectArrays = await this.collectFulfilledInBatches(
         projectDirs,
-        this.fsProvider.type === 'ssh' ? 8 : 24,
+        this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
         async (dir) => this.scanProject(dir.name)
       );
 
@@ -142,6 +160,7 @@ export class ProjectScanner {
         );
       }
 
+      this.scanCache = { projects: validProjects, timestamp: Date.now() };
       return validProjects;
     } catch (error) {
       logger.error('Error scanning projects directory:', error);
@@ -149,6 +168,14 @@ export class ProjectScanner {
     }
   }
 
+  /**
+   * Clears the scan cache so the next scan() call reads fresh data.
+   * Call this when a file change is detected by FileWatcher.
+   */
+  clearScanCache(): void {
+    this.scanCache = null;
+  }
+
   // ===========================================================================
   // Repository Grouping (Worktree Support)
   // ===========================================================================
@@ -173,8 +200,33 @@ export class ProjectScanner {
         return [];
       }
 
-      // 2. Delegate to WorktreeGrouper
-      return this.worktreeGrouper.groupByRepository(projects);
+      // 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
+      // Git identity resolution is bypassed to avoid blocking I/O on startup.
+      // Each project becomes a single-worktree group.
+      const groups: RepositoryGroup[] = projects.map((project) => ({
+        id: project.id,
+        identity: null,
+        worktrees: [
+          {
+            id: project.id,
+            path: project.path,
+            name: project.name,
+            isMainWorktree: true,
+            source: 'unknown' as const,
+            sessions: project.sessions,
+            createdAt: project.createdAt,
+            mostRecentSession: project.mostRecentSession,
+          },
+        ],
+        name: project.name,
+        mostRecentSession: project.mostRecentSession,
+        totalSessions: project.sessions.length,
+      }));
+
+      // Sort by most recent activity (same order as the full git-aware version)
+      groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
+
+      return groups;
     } catch (error) {
       logger.error('Error scanning with worktree grouping:', error);
       return [];
@@ -226,7 +278,7 @@ export class ProjectScanner {
       const shouldSplitByCwd = this.fsProvider.type !== 'ssh';
       const sessionInfos = await this.collectFulfilledInBatches(
         sessionFiles,
-        this.fsProvider.type === 'ssh' ? 32 : 128,
+        this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
         async (file) => {
           const filePath = path.join(projectPath, file.name);
           const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath);
@@ -736,11 +788,8 @@ export class ProjectScanner {
       });
     }
 
-    // Check for subagents and load task list data in parallel
-    const [hasSubagents, todoData] = await Promise.all([
-      this.subagentLocator.hasSubagents(projectId, sessionId),
-      this.loadTodoData(sessionId),
-    ]);
+    // Check for subagents (todoData skipped here — loaded on-demand in detail view)
+    const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
     const metadataLevel: SessionMetadataLevel = 'deep';
     const firstMessageTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
     const createdAt =
@@ -752,7 +801,6 @@ export class ProjectScanner {
       id: sessionId,
       projectId,
       projectPath,
-      todoData,
       createdAt: Math.floor(createdAt),
       firstMessage: metadata.firstUserMessage?.text,
       messageTimestamp: metadata.firstUserMessage?.timestamp,
@@ -920,15 +968,16 @@ export class ProjectScanner {
   async loadTodoData(sessionId: string): Promise {
     try {
       const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId);
-
-      if (!(await this.fsProvider.exists(todoPath))) {
-        return undefined;
-      }
-
       const content = await this.fsProvider.readFile(todoPath);
       return JSON.parse(content) as unknown;
-    } catch (error) {
-      // Log but continue - task list data is non-critical
+    } catch (error: unknown) {
+      // ENOENT/EACCES = file missing or inaccessible — normal when no todos exist
+      if (error instanceof Error && 'code' in error) {
+        const code = (error as NodeJS.ErrnoException).code;
+        if (code === 'ENOENT' || code === 'EACCES') {
+          return undefined;
+        }
+      }
       logger.debug(`Failed to load task list data for session ${sessionId}:`, error);
       return undefined;
     }
diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts
index c6d030f3..86092d89 100644
--- a/src/main/services/discovery/SubagentLocator.ts
+++ b/src/main/services/discovery/SubagentLocator.ts
@@ -43,37 +43,21 @@ export class SubagentLocator {
   async hasSubagents(projectId: string, sessionId: string): Promise {
     // Check NEW structure: {projectId}/{sessionId}/subagents/
     const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
-    if (await this.fsProvider.exists(newSubagentsPath)) {
-      try {
-        const entries = await this.fsProvider.readdir(newSubagentsPath);
-        const subagentFiles = entries.filter(
-          (entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
-        );
-
-        // Check if at least one subagent file has content (not empty)
-        for (const entry of subagentFiles) {
-          const filePath = path.join(newSubagentsPath, entry.name);
-          try {
-            const stats = await this.fsProvider.stat(filePath);
-            // File must have size > 0 and contain at least one line
-            if (stats.size > 0) {
-              const content = await this.fsProvider.readFile(filePath);
-              if (content.trim().length > 0) {
-                return true;
-              }
-            }
-          } catch (error) {
-            // Skip this file if we can't read it - log for debugging
-            logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
-            continue;
-          }
-        }
-      } catch {
-        // Ignore errors
-      }
+    try {
+      const entries = await this.fsProvider.readdir(newSubagentsPath);
+      // A non-empty agent-*.jsonl file is sufficient proof of subagents.
+      // readdir() populates size from stat, so no extra I/O needed.
+      return entries.some(
+        (entry) =>
+          entry.name.startsWith('agent-') &&
+          entry.name.endsWith('.jsonl') &&
+          typeof entry.size === 'number' &&
+          entry.size > 0
+      );
+    } catch {
+      // Directory doesn't exist or is unreadable — no subagents
+      return false;
     }
-
-    return false;
   }
 
   /**
@@ -88,37 +72,22 @@ export class SubagentLocator {
   hasSubagentsSync(projectId: string, sessionId: string): boolean {
     // Check NEW structure: {projectId}/{sessionId}/subagents/
     const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
-    if (fs.existsSync(newSubagentsPath)) {
-      try {
-        const entries = fs.readdirSync(newSubagentsPath);
-        const subagentFiles = entries.filter(
-          (name) => name.startsWith('agent-') && name.endsWith('.jsonl')
-        );
-
-        // Check if at least one subagent file has content (not empty)
-        for (const fileName of subagentFiles) {
-          const filePath = path.join(newSubagentsPath, fileName);
-          try {
-            const stats = fs.statSync(filePath);
-            // File must have size > 0 and contain at least one line
-            if (stats.size > 0) {
-              const content = fs.readFileSync(filePath, 'utf8');
-              if (content.trim().length > 0) {
-                return true;
-              }
-            }
-          } catch (error) {
-            // Skip this file if we can't read it - log for debugging
-            logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
-            continue;
-          }
+    try {
+      const entries = fs.readdirSync(newSubagentsPath);
+      // A non-empty agent-*.jsonl file is sufficient proof of subagents.
+      return entries.some((name) => {
+        if (!name.startsWith('agent-') || !name.endsWith('.jsonl')) return false;
+        try {
+          const stats = fs.statSync(path.join(newSubagentsPath, name));
+          return stats.size > 0;
+        } catch {
+          return false;
         }
-      } catch {
-        // Ignore errors
-      }
+      });
+    } catch {
+      // Directory doesn't exist or is unreadable — no subagents
+      return false;
     }
-
-    return false;
   }
 
   /**
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts
index e6f91af2..d843134d 100644
--- a/src/main/services/infrastructure/FileWatcher.ts
+++ b/src/main/services/infrastructure/FileWatcher.ts
@@ -21,6 +21,7 @@ import {
 import { createLogger } from '@shared/utils/logger';
 import { EventEmitter } from 'events';
 import * as fs from 'fs';
+import * as fsp from 'fs/promises';
 import * as path from 'path';
 
 import { projectPathResolver } from '../discovery/ProjectPathResolver';
@@ -149,7 +150,9 @@ export class FileWatcher extends EventEmitter {
     if (this.fsProvider.type === 'ssh') {
       this.startPollingMode();
     } else {
-      this.ensureWatchers();
+      // Fire-and-forget: ensureWatchers is now async to avoid blocking the event loop
+      // with synchronous fs.existsSync() calls during startup.
+      void this.ensureWatchers();
     }
     this.startCatchUpTimer();
   }
@@ -278,18 +281,21 @@ export class FileWatcher extends EventEmitter {
   /**
    * Starts the projects directory watcher.
    */
-  private startProjectsWatcher(): void {
+  private async startProjectsWatcher(): Promise {
     if (this.projectsWatcher) {
       return;
     }
 
     try {
-      if (!fs.existsSync(this.projectsPath)) {
+      if (!(await this.pathExists(this.projectsPath))) {
         logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`);
         this.scheduleWatcherRetry();
         return;
       }
 
+      // Guard: stop() may have been called while awaiting pathExists
+      if (!this.isWatching) return;
+
       this.projectsWatcher = fs.watch(
         this.projectsPath,
         { recursive: true },
@@ -312,18 +318,21 @@ export class FileWatcher extends EventEmitter {
   /**
    * Starts the todos directory watcher.
    */
-  private startTodosWatcher(): void {
+  private async startTodosWatcher(): Promise {
     if (this.todosWatcher) {
       return;
     }
 
     try {
-      if (!fs.existsSync(this.todosPath)) {
+      if (!(await this.pathExists(this.todosPath))) {
         // Todos directory may not exist yet - that's OK
         this.scheduleWatcherRetry();
         return;
       }
 
+      // Guard: stop() may have been called while awaiting pathExists
+      if (!this.isWatching) return;
+
       this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => {
         if (filename) {
           this.handleTodosChange(eventType, filename);
@@ -342,17 +351,20 @@ export class FileWatcher extends EventEmitter {
   /**
    * Starts the teams directory watcher.
    */
-  private startTeamsWatcher(): void {
+  private async startTeamsWatcher(): Promise {
     if (this.teamsWatcher) {
       return;
     }
 
     try {
-      if (!fs.existsSync(this.teamsPath)) {
+      if (!(await this.pathExists(this.teamsPath))) {
         this.scheduleWatcherRetry();
         return;
       }
 
+      // Guard: stop() may have been called while awaiting pathExists
+      if (!this.isWatching) return;
+
       this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => {
         if (filename) {
           this.handleTeamsChange(eventType, filename);
@@ -370,17 +382,20 @@ export class FileWatcher extends EventEmitter {
   /**
    * Starts the tasks directory watcher.
    */
-  private startTasksWatcher(): void {
+  private async startTasksWatcher(): Promise {
     if (this.tasksWatcher) {
       return;
     }
 
     try {
-      if (!fs.existsSync(this.tasksPath)) {
+      if (!(await this.pathExists(this.tasksPath))) {
         this.scheduleWatcherRetry();
         return;
       }
 
+      // Guard: stop() may have been called while awaiting pathExists
+      if (!this.isWatching) return;
+
       this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => {
         if (filename) {
           this.handleTasksChange(eventType, filename);
@@ -395,15 +410,31 @@ export class FileWatcher extends EventEmitter {
     }
   }
 
-  private ensureWatchers(): void {
+  /**
+   * Async check for path existence. Replaces sync fs.existsSync()
+   * to avoid blocking the event loop during watcher initialization.
+   */
+  private async pathExists(p: string): Promise {
+    try {
+      await fsp.access(p, fs.constants.F_OK);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  private async ensureWatchers(): Promise {
     if (!this.isWatching || this.fsProvider.type === 'ssh') {
       return;
     }
 
-    this.startProjectsWatcher();
-    this.startTodosWatcher();
-    this.startTeamsWatcher();
-    this.startTasksWatcher();
+    // Start all watchers in parallel to minimize total startup latency
+    await Promise.all([
+      this.startProjectsWatcher(),
+      this.startTodosWatcher(),
+      this.startTeamsWatcher(),
+      this.startTasksWatcher(),
+    ]);
 
     if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
       this.scheduleWatcherRetry();
@@ -417,7 +448,7 @@ export class FileWatcher extends EventEmitter {
 
     this.retryTimer = setTimeout(() => {
       this.retryTimer = null;
-      this.ensureWatchers();
+      void this.ensureWatchers();
     }, WATCHER_RETRY_MS);
   }
 
diff --git a/src/main/services/infrastructure/LocalFileSystemProvider.ts b/src/main/services/infrastructure/LocalFileSystemProvider.ts
index ceb5a3fb..de104982 100644
--- a/src/main/services/infrastructure/LocalFileSystemProvider.ts
+++ b/src/main/services/infrastructure/LocalFileSystemProvider.ts
@@ -43,20 +43,27 @@ export class LocalFileSystemProvider implements FileSystemProvider {
 
   async readdir(dirPath: string): Promise {
     const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
-    // Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's
-    // mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined).
+    // Stat all entries concurrently to populate mtimeMs/birthtimeMs/size.
+    // Populating all three avoids a second stat() call in resolveFileDetails().
+    // Failures are silently ignored (fields stay undefined).
     return Promise.all(
       entries.map(async (entry) => {
         let mtimeMs: number | undefined;
+        let birthtimeMs: number | undefined;
+        let size: number | undefined;
         try {
           const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
           mtimeMs = stat.mtimeMs;
+          birthtimeMs = stat.birthtimeMs;
+          size = stat.size;
         } catch {
           // ignore
         }
         return {
           name: entry.name,
           mtimeMs,
+          birthtimeMs,
+          size,
           isFile: () => entry.isFile(),
           isDirectory: () => entry.isDirectory(),
         };
diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts
index b68683a4..cacf3d6f 100644
--- a/src/main/services/infrastructure/NotificationManager.ts
+++ b/src/main/services/infrastructure/NotificationManager.ts
@@ -17,6 +17,7 @@ import { createLogger } from '@shared/utils/logger';
 import { type BrowserWindow, Notification } from 'electron';
 import { EventEmitter } from 'events';
 import * as fs from 'fs';
+import * as fsp from 'fs/promises';
 import * as path from 'path';
 
 import { type DetectedError } from '../error/ErrorMessageBuilder';
@@ -90,6 +91,10 @@ export class NotificationManager extends EventEmitter {
   private mainWindow: BrowserWindow | null = null;
   private throttleMap = new Map();
   private isInitialized: boolean = false;
+  /** Promise that resolves when async initialization is complete.
+   *  Used by addError() to wait for notifications to be loaded from disk
+   *  before writing, preventing a race where save overwrites unloaded data. */
+  private initPromise: Promise | null = null;
 
   constructor(configManager?: ConfigManager) {
     super();
@@ -106,7 +111,9 @@ export class NotificationManager extends EventEmitter {
   static getInstance(): NotificationManager {
     if (!NotificationManager.instance) {
       NotificationManager.instance = new NotificationManager();
-      NotificationManager.instance.initialize();
+      // Async init: loads notifications without blocking startup.
+      // addError() awaits initPromise to prevent save-before-load races.
+      NotificationManager.instance.initPromise = NotificationManager.instance.initialize();
     }
     return NotificationManager.instance;
   }
@@ -133,12 +140,12 @@ export class NotificationManager extends EventEmitter {
    * Initializes the notification manager.
    * Loads existing notifications and prunes if needed.
    */
-  initialize(): void {
+  async initialize(): Promise {
     if (this.isInitialized) {
       return;
     }
 
-    this.loadNotifications();
+    await this.loadNotifications();
     this.pruneNotifications();
     this.isInitialized = true;
 
@@ -157,23 +164,25 @@ export class NotificationManager extends EventEmitter {
   // ===========================================================================
 
   /**
-   * Loads notifications from disk.
+   * Loads notifications from disk (async to avoid blocking startup).
    */
-  private loadNotifications(): void {
+  private async loadNotifications(): Promise {
     try {
-      if (fs.existsSync(NOTIFICATIONS_PATH)) {
-        const data = fs.readFileSync(NOTIFICATIONS_PATH, 'utf8');
-        const parsed = JSON.parse(data) as unknown;
+      await fsp.access(NOTIFICATIONS_PATH, fs.constants.F_OK);
+      const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
+      const parsed = JSON.parse(data) as unknown;
 
-        if (Array.isArray(parsed)) {
-          this.notifications = parsed as StoredNotification[];
-        } else {
-          logger.warn('Invalid notifications file format, starting fresh');
-          this.notifications = [];
-        }
+      if (Array.isArray(parsed)) {
+        this.notifications = parsed as StoredNotification[];
+      } else {
+        logger.warn('Invalid notifications file format, starting fresh');
+        this.notifications = [];
       }
     } catch (error) {
-      logger.error('Error loading notifications:', error);
+      // ENOENT is expected on first run — no file to load
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+        logger.error('Error loading notifications:', error);
+      }
       this.notifications = [];
     }
   }
@@ -451,6 +460,12 @@ export class NotificationManager extends EventEmitter {
    * @returns The stored notification, or null if filtered/throttled
    */
   async addError(error: DetectedError): Promise {
+    // Wait for async initialization to complete before modifying notifications.
+    // Prevents a race where saveNotifications() overwrites not-yet-loaded data.
+    if (this.initPromise) {
+      await this.initPromise;
+    }
+
     // Deduplicate by toolUseId: the same tool call can appear in both the
     // subagent JSONL file and the parent session JSONL (as a progress event).
     // Keep the subagent-annotated version (with subagentId) when possible.
diff --git a/src/main/services/infrastructure/ServiceContext.ts b/src/main/services/infrastructure/ServiceContext.ts
index f5be5a66..5fdabf56 100644
--- a/src/main/services/infrastructure/ServiceContext.ts
+++ b/src/main/services/infrastructure/ServiceContext.ts
@@ -137,6 +137,21 @@ export class ServiceContext {
     this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
   }
 
+  /**
+   * Starts only cache cleanup, deferring FileWatcher to later.
+   * Use this at app startup so the window appears without waiting for fs.watch().
+   * Call startFileWatcher() separately after the window is visible.
+   */
+  startCacheOnly(): void {
+    if (this.disposed) {
+      logger.error(`Cannot start disposed context: ${this.id}`);
+      return;
+    }
+
+    logger.info(`Starting ServiceContext (cache only): ${this.id}`);
+    this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
+  }
+
   /**
    * Stops the file watcher (for pausing on context switch).
    * Does not dispose resources - can be resumed with startFileWatcher().
diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts
index 152bf060..b76b91b0 100644
--- a/src/main/services/infrastructure/SshConnectionManager.ts
+++ b/src/main/services/infrastructure/SshConnectionManager.ts
@@ -309,7 +309,7 @@ export class SshConnectionManager extends EventEmitter {
    * Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell.
    */
   private async discoverAgentSocket(): Promise {
-    // 1. Check SSH_AUTH_SOCK env var
+    // 1. Check SSH_AUTH_SOCK env var (all platforms)
     if (process.env.SSH_AUTH_SOCK) {
       try {
         await fs.promises.access(process.env.SSH_AUTH_SOCK);
@@ -319,7 +319,19 @@ export class SshConnectionManager extends EventEmitter {
       }
     }
 
-    // 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
+    // 2. Windows: use OpenSSH named pipe (no Unix sockets on Windows)
+    if (process.platform === 'win32') {
+      const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
+      try {
+        await fs.promises.access(pipe);
+        return pipe;
+      } catch {
+        // OpenSSH agent not running
+      }
+      return null;
+    }
+
+    // 3. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
     if (process.platform === 'darwin') {
       try {
         const sock = await new Promise((resolve) => {
@@ -344,7 +356,7 @@ export class SshConnectionManager extends EventEmitter {
       }
     }
 
-    // 3. Try known socket paths
+    // 4. Try known socket paths (macOS/Linux only)
     const knownPaths = [
       // 1Password SSH agent
       path.join(
diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts
index 3be1d487..003df76a 100644
--- a/test/main/services/infrastructure/FileWatcher.test.ts
+++ b/test/main/services/infrastructure/FileWatcher.test.ts
@@ -24,6 +24,16 @@ vi.mock('fs', async () => {
   };
 });
 
+vi.mock('fs/promises', async () => {
+  const actual = await vi.importActual('fs/promises');
+  return {
+    ...actual,
+    access: vi.fn(),
+    // Stash the real access for tests with real files
+    __realAccess: actual.access,
+  };
+});
+
 vi.mock('../../../../src/main/services/error/ErrorDetector', () => ({
   errorDetector: {
     detectErrors: vi.fn().mockResolvedValue([]),
@@ -47,6 +57,7 @@ vi.mock('../../../../src/main/services/discovery/ProjectPathResolver', () => ({
 }));
 
 import * as fs from 'fs';
+import * as fsp from 'fs/promises';
 
 import { errorDetector } from '../../../../src/main/services/error/ErrorDetector';
 import { DataCache } from '../../../../src/main/services/infrastructure/DataCache';
@@ -97,16 +108,16 @@ describe('FileWatcher', () => {
     vi.restoreAllMocks();
   });
 
-  it('retries and starts watchers when directories appear later', () => {
+  it('retries and starts watchers when directories appear later', async () => {
     const dataCache = new DataCache(50, 10, false);
     let dirsAvailable = false;
 
-    const existsSyncMock = vi.mocked(fs.existsSync);
-    existsSyncMock.mockImplementation((targetPath) => {
-      if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') {
-        return dirsAvailable;
+    const accessMock = vi.mocked(fsp.access);
+    accessMock.mockImplementation(async (targetPath) => {
+      if ((targetPath === '/tmp/projects' || targetPath === '/tmp/todos') && dirsAvailable) {
+        return;
       }
-      return false;
+      throw new Error('ENOENT');
     });
 
     const watchMock = vi.mocked(fs.watch);
@@ -114,25 +125,29 @@ describe('FileWatcher', () => {
 
     const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
     watcher.start();
+    await vi.advanceTimersByTimeAsync(0);
 
     expect(watchMock).toHaveBeenCalledTimes(0);
 
     dirsAvailable = true;
-    vi.advanceTimersByTime(2000);
+    await vi.advanceTimersByTimeAsync(2000);
 
     expect(watchMock).toHaveBeenCalledTimes(2);
     watcher.stop();
   });
 
-  it('recovers from watcher errors by re-registering affected watcher', () => {
+  it('recovers from watcher errors by re-registering affected watcher', async () => {
     const dataCache = new DataCache(50, 10, false);
     const projectWatcher = createFakeWatcher();
     const todoWatcher = createFakeWatcher();
     const replacementProjectWatcher = createFakeWatcher();
 
-    const existsSyncMock = vi.mocked(fs.existsSync);
-    existsSyncMock.mockImplementation((targetPath) => {
-      return targetPath === '/tmp/projects' || targetPath === '/tmp/todos';
+    const accessMock = vi.mocked(fsp.access);
+    accessMock.mockImplementation(async (targetPath) => {
+      if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') {
+        return;
+      }
+      throw new Error('ENOENT');
     });
 
     const watchMock = vi.mocked(fs.watch);
@@ -143,10 +158,11 @@ describe('FileWatcher', () => {
 
     const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
     watcher.start();
+    await vi.advanceTimersByTimeAsync(0);
     expect(watchMock).toHaveBeenCalledTimes(2);
 
     (projectWatcher as unknown as EventEmitter).emit('error', new Error('watch failed'));
-    vi.advanceTimersByTime(2000);
+    await vi.advanceTimersByTimeAsync(2000);
 
     expect(watchMock).toHaveBeenCalledTimes(3);
     watcher.stop();
@@ -519,7 +535,7 @@ describe('FileWatcher', () => {
     it('starts catch-up timer on start() and clears on stop()', () => {
       const dataCache = new DataCache(50, 10, false);
 
-      vi.mocked(fs.existsSync).mockReturnValue(true);
+      vi.mocked(fsp.access).mockResolvedValue();
       vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher());
 
       const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');
@@ -540,7 +556,7 @@ describe('FileWatcher', () => {
     it('clears all tracking state on stop()', () => {
       const dataCache = new DataCache(50, 10, false);
 
-      vi.mocked(fs.existsSync).mockReturnValue(true);
+      vi.mocked(fsp.access).mockResolvedValue();
       vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher());
 
       const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos');