From 99d2c01cfd547f558ab31e47302505228820b6ce Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Thu, 1 Jan 2026 15:04:48 -0800 Subject: [PATCH 1/6] feat: add real-time task sync for external changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch the specs directory for changes made outside the app (CLI, AI agents, etc.) and update task views in real-time without requiring manual refresh. Features: - File watcher using chokidar monitors specs directory - Surgical Zustand store updates (no UI flash) - Debounced updates (400ms) to handle rapid changes - Opt-in via Settings > Agent Settings > "Auto-Sync External Changes" - Detects: new tasks, removed tasks, and task modifications Implementation: - Extended FileWatcher class with specs directory watching - Added IPC channels for spec events (added/removed/updated) - Added TASK_GET handler for fetching individual tasks - Surgical store handlers in task-store.ts - Settings toggle with dynamic watcher start/stop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/frontend/package-lock.json | 58 +++--- apps/frontend/src/main/file-watcher.ts | 176 ++++++++++++++++++ .../ipc-handlers/agent-events-handlers.ts | 28 +++ .../main/ipc-handlers/settings-handlers.ts | 31 ++- .../main/ipc-handlers/task/crud-handlers.ts | 54 +++++- apps/frontend/src/preload/api/task-api.ts | 59 ++++++ .../components/settings/AdvancedSettings.tsx | 1 + .../components/settings/GeneralSettings.tsx | 17 ++ apps/frontend/src/renderer/hooks/useIpc.ts | 27 ++- .../src/renderer/stores/task-store.ts | 90 +++++++++ apps/frontend/src/shared/constants/config.ts | 4 +- apps/frontend/src/shared/constants/ipc.ts | 6 + .../src/shared/i18n/locales/en/settings.json | 4 +- apps/frontend/src/shared/types/ipc.ts | 12 ++ apps/frontend/src/shared/types/settings.ts | 2 + 15 files changed, 535 insertions(+), 34 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 6b22c9832..10b396925 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "auto-claude-ui", - "version": "2.7.2-beta.10", + "version": "2.7.2-beta.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auto-claude-ui", - "version": "2.7.2-beta.10", + "version": "2.7.2-beta.12", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -201,6 +201,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -585,6 +586,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -628,6 +630,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -667,6 +670,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1073,7 +1077,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1095,7 +1098,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4280,8 +4282,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4468,6 +4469,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4478,6 +4480,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4576,6 +4579,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4988,6 +4992,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5048,6 +5053,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5220,7 +5226,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5615,6 +5620,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6285,8 +6291,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6654,6 +6659,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -6711,8 +6717,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -6788,6 +6793,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6925,7 +6931,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6946,7 +6951,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6962,7 +6966,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6973,7 +6976,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -7343,6 +7345,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8588,6 +8591,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9378,6 +9382,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10309,7 +10314,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12500,6 +12504,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12597,6 +12602,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12633,7 +12639,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12651,7 +12656,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12672,7 +12676,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12688,7 +12691,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12701,8 +12703,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proc-log": { "version": "2.0.1", @@ -12806,6 +12807,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12815,6 +12817,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14134,7 +14137,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -14191,7 +14195,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14218,7 +14221,6 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14240,7 +14242,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -14254,7 +14255,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -14269,7 +14269,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14586,6 +14585,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14935,6 +14935,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15976,6 +15977,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/frontend/src/main/file-watcher.ts b/apps/frontend/src/main/file-watcher.ts index e053518ea..41479a0c8 100644 --- a/apps/frontend/src/main/file-watcher.ts +++ b/apps/frontend/src/main/file-watcher.ts @@ -10,11 +10,28 @@ interface WatcherInfo { planPath: string; } +interface SpecsWatcherInfo { + projectId: string; + watcher: FSWatcher; + specsPath: string; +} + +// Debounce tracking for spec updates +interface PendingUpdate { + projectId: string; + specId: string; + timeout: NodeJS.Timeout; +} + /** * Watches implementation_plan.json files for real-time progress updates + * Also watches specs directories for new/removed spec folders */ export class FileWatcher extends EventEmitter { private watchers: Map = new Map(); + private specsWatchers: Map = new Map(); + private pendingUpdates: Map = new Map(); + private readonly DEBOUNCE_MS = 400; // Debounce time for file changes /** * Start watching a task's implementation plan @@ -121,6 +138,165 @@ export class FileWatcher extends EventEmitter { return null; } } + + // ============================================ + // Specs Directory Watching + // ============================================ + + /** + * Start watching a project's specs directory for new/removed spec folders + * and for changes to implementation_plan.json and task_metadata.json inside each spec + * Emits 'spec-added', 'spec-removed', and 'spec-updated' events + */ + async watchSpecsDirectory(projectId: string, specsPath: string): Promise { + // Stop any existing watcher for this project + await this.unwatchSpecsDirectory(projectId); + + // Check if specs directory exists + if (!existsSync(specsPath)) { + console.log(`[FileWatcher] Specs directory not found, skipping watch: ${specsPath}`); + return; + } + + console.log(`[FileWatcher] Starting specs directory watcher for project ${projectId}: ${specsPath}`); + + // Watch for new directories and file changes inside spec folders + const watcher = chokidar.watch(specsPath, { + persistent: true, + ignoreInitial: true, + depth: 1, // Watch spec folders and their direct children (JSON files) + awaitWriteFinish: { + stabilityThreshold: 300, + pollInterval: 100 + } + }); + + // Store watcher info + this.specsWatchers.set(projectId, { + projectId, + watcher, + specsPath + }); + + // Handle new spec directory added + watcher.on('addDir', (dirPath: string) => { + // Only emit for direct children of specs directory + const relativePath = path.relative(specsPath, dirPath); + if (relativePath && !relativePath.includes(path.sep)) { + const specId = path.basename(dirPath); + console.log(`[FileWatcher] Spec directory added: ${specId} in project ${projectId}`); + this.emit('spec-added', projectId, specId, dirPath); + } + }); + + // Handle spec directory removed + watcher.on('unlinkDir', (dirPath: string) => { + // Only emit for direct children of specs directory + const relativePath = path.relative(specsPath, dirPath); + if (relativePath && !relativePath.includes(path.sep)) { + const specId = path.basename(dirPath); + console.log(`[FileWatcher] Spec directory removed: ${specId} in project ${projectId}`); + this.emit('spec-removed', projectId, specId); + } + }); + + // Handle file changes inside spec folders (implementation_plan.json, task_metadata.json) + watcher.on('change', (filePath: string) => { + const fileName = path.basename(filePath); + // Only watch for our target files + if (fileName !== 'implementation_plan.json' && fileName !== 'task_metadata.json') { + return; + } + + // Extract specId from path: specsPath/specId/filename.json + const relativePath = path.relative(specsPath, filePath); + const parts = relativePath.split(path.sep); + if (parts.length !== 2) { + return; // Not a file directly inside a spec folder + } + + const specId = parts[0]; + this.debouncedSpecUpdate(projectId, specId); + }); + + // Also handle file additions (new JSON files in existing spec folders) + watcher.on('add', (filePath: string) => { + const fileName = path.basename(filePath); + if (fileName !== 'implementation_plan.json' && fileName !== 'task_metadata.json') { + return; + } + + const relativePath = path.relative(specsPath, filePath); + const parts = relativePath.split(path.sep); + if (parts.length !== 2) { + return; + } + + const specId = parts[0]; + this.debouncedSpecUpdate(projectId, specId); + }); + + // Handle errors + watcher.on('error', (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[FileWatcher] Specs watcher error for project ${projectId}:`, message); + }); + } + + /** + * Debounce spec update events to prevent rapid-fire updates + * when files are written multiple times quickly + */ + private debouncedSpecUpdate(projectId: string, specId: string): void { + const key = `${projectId}:${specId}`; + + // Clear any existing pending update for this spec + const existing = this.pendingUpdates.get(key); + if (existing) { + clearTimeout(existing.timeout); + } + + // Schedule new debounced update + const timeout = setTimeout(() => { + this.pendingUpdates.delete(key); + console.log(`[FileWatcher] Spec updated (debounced): ${specId} in project ${projectId}`); + this.emit('spec-updated', projectId, specId); + }, this.DEBOUNCE_MS); + + this.pendingUpdates.set(key, { projectId, specId, timeout }); + } + + /** + * Stop watching a project's specs directory + */ + async unwatchSpecsDirectory(projectId: string): Promise { + const watcherInfo = this.specsWatchers.get(projectId); + if (watcherInfo) { + console.log(`[FileWatcher] Stopping specs directory watcher for project ${projectId}`); + await watcherInfo.watcher.close(); + this.specsWatchers.delete(projectId); + } + } + + /** + * Check if a project's specs directory is being watched + */ + isWatchingSpecs(projectId: string): boolean { + return this.specsWatchers.has(projectId); + } + + /** + * Stop all specs directory watchers + */ + async unwatchAllSpecsDirectories(): Promise { + const closePromises = Array.from(this.specsWatchers.values()).map( + async (info) => { + await info.watcher.close(); + } + ); + await Promise.all(closePromises); + this.specsWatchers.clear(); + } } // Singleton instance diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index cbe4a67b6..deebc3730 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -200,4 +200,32 @@ export function registerAgenteventsHandlers( mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error); } }); + + // ============================================ + // Specs Directory Watcher Events → Renderer + // ============================================ + + fileWatcher.on('spec-added', (projectId: string, specId: string, specPath: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + console.log(`[IPC] Forwarding spec-added event: ${specId} in project ${projectId}`); + mainWindow.webContents.send(IPC_CHANNELS.TASK_SPEC_ADDED, projectId, specId, specPath); + } + }); + + fileWatcher.on('spec-removed', (projectId: string, specId: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + console.log(`[IPC] Forwarding spec-removed event: ${specId} in project ${projectId}`); + mainWindow.webContents.send(IPC_CHANNELS.TASK_SPEC_REMOVED, projectId, specId); + } + }); + + fileWatcher.on('spec-updated', (projectId: string, specId: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + console.log(`[IPC] Forwarding spec-updated event: ${specId} in project ${projectId}`); + mainWindow.webContents.send(IPC_CHANNELS.TASK_SPEC_UPDATED, projectId, specId); + } + }); } diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 8a49d8430..4a2d2f5fc 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -3,7 +3,7 @@ import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs'; import { execFileSync } from 'node:child_process'; import path from 'path'; import { is } from '@electron-toolkit/utils'; -import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, getSpecsDir } from '../../shared/constants'; import type { AppSettings, IPCResult @@ -14,6 +14,8 @@ import { getEffectiveVersion } from '../auto-claude-updater'; import { setUpdateChannel } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; import { configureTools, getToolPath, getToolInfo } from '../cli-tool-manager'; +import { fileWatcher } from '../file-watcher'; +import { projectStore } from '../project-store'; const settingsPath = getSettingsPath(); @@ -199,6 +201,33 @@ export function registerSettingsHandlers( setUpdateChannel(channel); } + // Handle filesystem watcher setting change + if (settings.watchFilesystemForExternalChanges !== undefined) { + const wasEnabled = currentSettings.watchFilesystemForExternalChanges ?? false; + const isNowEnabled = settings.watchFilesystemForExternalChanges; + + if (!wasEnabled && isNowEnabled) { + // Setting was turned ON - start watching all active projects + console.log('[SETTINGS_SAVE] Filesystem watching enabled, starting watchers for all projects'); + const projects = projectStore.getProjects(); + for (const project of projects) { + if (!fileWatcher.isWatchingSpecs(project.id)) { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsPath = path.join(project.path, specsBaseDir); + fileWatcher.watchSpecsDirectory(project.id, specsPath).catch((err) => { + console.error(`[SETTINGS_SAVE] Failed to start specs watcher for project ${project.id}:`, err); + }); + } + } + } else if (wasEnabled && !isNowEnabled) { + // Setting was turned OFF - stop all watchers + console.log('[SETTINGS_SAVE] Filesystem watching disabled, stopping all watchers'); + fileWatcher.unwatchAllSpecsDirectories().catch((err) => { + console.error('[SETTINGS_SAVE] Failed to stop specs watchers:', err); + }); + } + } + return { success: true }; } catch (error) { return { diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 232f54bed..7183dbd84 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -1,12 +1,23 @@ import { ipcMain } from 'electron'; -import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; -import type { IPCResult, Task, TaskMetadata } from '../../../shared/types'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, DEFAULT_APP_SETTINGS } from '../../../shared/constants'; +import type { IPCResult, Task, TaskMetadata, AppSettings } from '../../../shared/types'; import path from 'path'; import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs'; import { projectStore } from '../../project-store'; import { titleGenerator } from '../../title-generator'; import { AgentManager } from '../../agent'; import { findTaskAndProject } from './shared'; +import { fileWatcher } from '../../file-watcher'; +import { readSettingsFile } from '../../settings-utils'; + +/** + * Check if filesystem watching is enabled in settings + */ +function isFilesystemWatchingEnabled(): boolean { + const savedSettings = readSettingsFile() as Partial | undefined; + const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings }; + return settings.watchFilesystemForExternalChanges ?? false; +} /** * Register task CRUD (Create, Read, Update, Delete) handlers @@ -21,10 +32,49 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { console.warn('[IPC] TASK_LIST called with projectId:', projectId); const tasks = projectStore.getTasks(projectId); console.warn('[IPC] TASK_LIST returning', tasks.length, 'tasks'); + + // Start watching the specs directory for this project (if enabled in settings) + const project = projectStore.getProject(projectId); + if (project && isFilesystemWatchingEnabled()) { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsPath = path.join(project.path, specsBaseDir); + + // Only start watching if not already watching + if (!fileWatcher.isWatchingSpecs(projectId)) { + console.log(`[IPC] TASK_LIST starting specs watcher for project ${projectId}`); + fileWatcher.watchSpecsDirectory(projectId, specsPath).catch((err) => { + console.error(`[IPC] Failed to start specs watcher for project ${projectId}:`, err); + }); + } + } + return { success: true, data: tasks }; } ); + /** + * Get a single task by specId + * Used for surgical updates when a spec file changes + */ + ipcMain.handle( + IPC_CHANNELS.TASK_GET, + async (_, projectId: string, specId: string): Promise> => { + console.log(`[IPC] TASK_GET called with projectId: ${projectId}, specId: ${specId}`); + + // Get all tasks and filter to find the one we want + const tasks = projectStore.getTasks(projectId); + const task = tasks.find(t => t.specId === specId); + + if (task) { + console.log(`[IPC] TASK_GET found task: ${task.title}`); + return { success: true, data: task }; + } + + console.log(`[IPC] TASK_GET task not found for specId: ${specId}`); + return { success: true, data: null }; + } + ); + /** * Create a new task */ diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 6049f85b7..c7c3f3d8c 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -17,6 +17,7 @@ import type { export interface TaskAPI { // Task Operations getTasks: (projectId: string) => Promise>; + getTask: (projectId: string, specId: string) => Promise>; createTask: ( projectId: string, title: string, @@ -73,6 +74,11 @@ export interface TaskAPI { unwatchTaskLogs: (specId: string) => Promise; onTaskLogsChanged: (callback: (specId: string, logs: TaskLogs) => void) => () => void; onTaskLogsStream: (callback: (specId: string, chunk: TaskLogStreamChunk) => void) => () => void; + + // Specs Directory Events + onSpecAdded: (callback: (projectId: string, specId: string, specPath: string) => void) => () => void; + onSpecRemoved: (callback: (projectId: string, specId: string) => void) => () => void; + onSpecUpdated: (callback: (projectId: string, specId: string) => void) => () => void; } export const createTaskAPI = (): TaskAPI => ({ @@ -80,6 +86,9 @@ export const createTaskAPI = (): TaskAPI => ({ getTasks: (projectId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId), + getTask: (projectId: string, specId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_GET, projectId, specId), + createTask: ( projectId: string, title: string, @@ -280,5 +289,55 @@ export const createTaskAPI = (): TaskAPI => ({ return () => { ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_STREAM, handler); }; + }, + + // Specs Directory Events + onSpecAdded: ( + callback: (projectId: string, specId: string, specPath: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + specId: string, + specPath: string + ): void => { + callback(projectId, specId, specPath); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_SPEC_ADDED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_SPEC_ADDED, handler); + }; + }, + + onSpecRemoved: ( + callback: (projectId: string, specId: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + specId: string + ): void => { + callback(projectId, specId); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_SPEC_REMOVED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_SPEC_REMOVED, handler); + }; + }, + + onSpecUpdated: ( + callback: (projectId: string, specId: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + specId: string + ): void => { + callback(projectId, specId); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_SPEC_UPDATED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_SPEC_UPDATED, handler); + }; } }); diff --git a/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx b/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx index 74efdddfa..3f324c776 100644 --- a/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AdvancedSettings.tsx @@ -481,6 +481,7 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version /> ))} + ); diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index 134700bdb..7b2839856 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -163,6 +163,23 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General /> +
+
+
+ +

+ {t('general.watchFilesystemDescription')} +

+
+ onSettingsChange({ ...settings, watchFilesystemForExternalChanges: checked })} + /> +
+
{/* Feature Model Configuration */}
diff --git a/apps/frontend/src/renderer/hooks/useIpc.ts b/apps/frontend/src/renderer/hooks/useIpc.ts index 7291ce238..27f8c80b8 100644 --- a/apps/frontend/src/renderer/hooks/useIpc.ts +++ b/apps/frontend/src/renderer/hooks/useIpc.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useTaskStore } from '../stores/task-store'; +import { useTaskStore, handleSpecAdded, handleSpecRemoved, handleSpecUpdated } from '../stores/task-store'; import { useRoadmapStore } from '../stores/roadmap-store'; import { useRateLimitStore } from '../stores/rate-limit-store'; import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo } from '../../shared/types'; @@ -166,6 +166,28 @@ export function useIpcListeners(): void { } ); + // Specs directory event listeners (for CLI-created specs) + const cleanupSpecAdded = window.electronAPI.onSpecAdded( + (projectId: string, specId: string, _specPath: string) => { + console.log(`[useIpc] Spec added event received: ${specId} in project ${projectId}`); + handleSpecAdded(projectId, specId); + } + ); + + const cleanupSpecRemoved = window.electronAPI.onSpecRemoved( + (projectId: string, specId: string) => { + console.log(`[useIpc] Spec removed event received: ${specId} in project ${projectId}`); + handleSpecRemoved(projectId, specId); + } + ); + + const cleanupSpecUpdated = window.electronAPI.onSpecUpdated( + (projectId: string, specId: string) => { + console.log(`[useIpc] Spec updated event received: ${specId} in project ${projectId}`); + handleSpecUpdated(projectId, specId); + } + ); + // Cleanup on unmount return () => { cleanupProgress(); @@ -179,6 +201,9 @@ export function useIpcListeners(): void { cleanupRoadmapStopped(); cleanupRateLimit(); cleanupSDKRateLimit(); + cleanupSpecAdded(); + cleanupSpecRemoved(); + cleanupSpecUpdated(); }; }, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, setError]); } diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 43ba85a51..8462220cf 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -19,6 +19,7 @@ interface TaskState { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; clearTasks: () => void; + removeTaskBySpecId: (specId: string) => void; // Selectors getSelectedTask: () => Task | undefined; @@ -163,6 +164,20 @@ export const useTaskStore = create((set, get) => ({ clearTasks: () => set({ tasks: [], selectedTaskId: null }), + removeTaskBySpecId: (specId) => + set((state) => { + const taskToRemove = state.tasks.find((t) => t.specId === specId); + if (!taskToRemove) return state; + + // Clear selection if this task was selected + const newSelectedTaskId = state.selectedTaskId === taskToRemove.id ? null : state.selectedTaskId; + + return { + tasks: state.tasks.filter((t) => t.specId !== specId), + selectedTaskId: newSelectedTaskId + }; + }), + getSelectedTask: () => { const state = get(); return state.tasks.find((t) => t.id === state.selectedTaskId); @@ -534,6 +549,81 @@ export function getTaskByGitHubIssue(issueNumber: number): Task | undefined { return store.tasks.find(t => t.metadata?.githubIssueNumber === issueNumber); } +// ============================================ +// Spec Directory Event Handlers +// ============================================ + +/** + * Handle a new spec being added to the specs directory + * Fetches only the new task and adds it to the store (no full reload) + */ +export async function handleSpecAdded(projectId: string, specId: string): Promise { + console.log(`[TaskStore] Handling spec-added event: ${specId} in project ${projectId}`); + + const store = useTaskStore.getState(); + + try { + // Fetch only the new task (surgical update) + const result = await window.electronAPI.getTask(projectId, specId); + if (result.success && result.data) { + // Check if task already exists (avoid duplicates) + const existing = store.tasks.find(t => t.specId === specId); + if (!existing) { + store.addTask(result.data); + console.log(`[TaskStore] Added new task: ${result.data.title}`); + } else { + // Task already exists, update it instead + store.updateTask(specId, result.data); + console.log(`[TaskStore] Updated existing task: ${result.data.title}`); + } + } + } catch (error) { + console.error('[TaskStore] Error fetching new task:', error); + // Fallback to full reload if surgical update fails + await loadTasks(projectId); + } +} + +/** + * Handle a spec being removed from the specs directory + * Removes the task from the store + */ +export function handleSpecRemoved(projectId: string, specId: string): void { + console.log(`[TaskStore] Handling spec-removed event: ${specId} in project ${projectId}`); + + const store = useTaskStore.getState(); + store.removeTaskBySpecId(specId); +} + +/** + * Handle a spec being updated (implementation_plan.json or task_metadata.json changed) + * Fetches only the updated task and updates it in the store (surgical update) + */ +export async function handleSpecUpdated(projectId: string, specId: string): Promise { + console.log(`[TaskStore] Handling spec-updated event: ${specId} in project ${projectId}`); + + const store = useTaskStore.getState(); + + // Check if this task exists in our store + const existingTask = store.tasks.find(t => t.specId === specId); + if (!existingTask) { + console.log(`[TaskStore] Task not found in store, skipping update: ${specId}`); + return; + } + + try { + // Fetch only the updated task (surgical update) + const result = await window.electronAPI.getTask(projectId, specId); + if (result.success && result.data) { + // Update the task in the store + store.updateTask(specId, result.data); + console.log(`[TaskStore] Updated task: ${result.data.title}`); + } + } catch (error) { + console.error('[TaskStore] Error fetching updated task:', error); + } +} + // ============================================ // Task State Detection Helpers // ============================================ diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index 093c77863..da102fcfb 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -48,7 +48,9 @@ export const DEFAULT_APP_SETTINGS = { // Beta updates opt-in (receive pre-release versions) betaUpdates: false, // Language preference (default to English) - language: 'en' as const + language: 'en' as const, + // Filesystem watcher for external changes (off by default - opt-in feature) + watchFilesystemForExternalChanges: false }; // ============================================ diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 5169f934a..e6739f82d 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -48,6 +48,12 @@ export const IPC_CHANNELS = { TASK_LOG: 'task:log', TASK_STATUS_CHANGE: 'task:statusChange', TASK_EXECUTION_PROGRESS: 'task:executionProgress', + TASK_SPEC_ADDED: 'task:specAdded', + TASK_SPEC_REMOVED: 'task:specRemoved', + TASK_SPEC_UPDATED: 'task:specUpdated', + + // Single task operations + TASK_GET: 'task:get', // Task phase logs (persistent, collapsible logs by phase) TASK_LOGS_GET: 'task:logsGet', // Load logs from spec dir diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index a39a135ec..e8d1bb033 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -99,7 +99,9 @@ "autoClaudePathDescription": "Relative path to auto-claude directory in projects", "autoClaudePathPlaceholder": "auto-claude (default)", "autoNameTerminals": "Automatically name terminals", - "autoNameTerminalsDescription": "Use AI to generate descriptive names for terminal tabs based on their activity" + "autoNameTerminalsDescription": "Use AI to generate descriptive names for terminal tabs based on their activity", + "watchFilesystem": "Auto-Sync External Changes", + "watchFilesystemDescription": "Automatically update task views when tasks are created or modified outside the app (e.g., via CLI or AI agents)" }, "theme": { "title": "Appearance", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index ccbee86f3..d585ee26a 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -147,6 +147,7 @@ export interface ElectronAPI { // Task operations getTasks: (projectId: string) => Promise>; + getTask: (projectId: string, specId: string) => Promise>; createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise>; deleteTask: (taskId: string) => Promise; updateTask: (taskId: string, updates: { title?: string; description?: string }) => Promise>; @@ -662,6 +663,17 @@ export interface ElectronAPI { callback: (specId: string, chunk: TaskLogStreamChunk) => void ) => () => void; + // Specs directory event listeners (for CLI-created/modified specs) + onSpecAdded: ( + callback: (projectId: string, specId: string, specPath: string) => void + ) => () => void; + onSpecRemoved: ( + callback: (projectId: string, specId: string) => void + ) => () => void; + onSpecUpdated: ( + callback: (projectId: string, specId: string) => void + ) => () => void; + // File explorer operations listDirectory: (dirPath: string) => Promise>; readFile: (filePath: string) => Promise>; diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 76f668f18..80abecd3e 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -265,6 +265,8 @@ export interface AppSettings { customIDEPath?: string; // For 'custom' IDE preferredTerminal?: SupportedTerminal; customTerminalPath?: string; // For 'custom' terminal + // Filesystem watcher for external changes (CLI, etc.) + watchFilesystemForExternalChanges?: boolean; } // Auto-Claude Source Environment Configuration (for auto-claude repo .env) From a119d908c8a6361e2fe408de69cab088e6359988 Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Thu, 1 Jan 2026 15:51:40 -0800 Subject: [PATCH 2/6] refactor: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicate file event handlers into handleSpecFileEvent helper - Add optimized getTask() method to ProjectStore for single-task loading - Add loadTaskFromSpecFolder() private method to avoid loading all tasks - Update TASK_GET handler to use efficient single-task loader 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/frontend/src/main/file-watcher.ts | 25 +--- .../main/ipc-handlers/task/crud-handlers.ts | 5 +- apps/frontend/src/main/project-store.ts | 136 ++++++++++++++++++ 3 files changed, 144 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/main/file-watcher.ts b/apps/frontend/src/main/file-watcher.ts index 41479a0c8..c70e61095 100644 --- a/apps/frontend/src/main/file-watcher.ts +++ b/apps/frontend/src/main/file-watcher.ts @@ -200,8 +200,8 @@ export class FileWatcher extends EventEmitter { } }); - // Handle file changes inside spec folders (implementation_plan.json, task_metadata.json) - watcher.on('change', (filePath: string) => { + // Helper to handle file change/add events for spec files + const handleSpecFileEvent = (filePath: string) => { const fileName = path.basename(filePath); // Only watch for our target files if (fileName !== 'implementation_plan.json' && fileName !== 'task_metadata.json') { @@ -217,24 +217,11 @@ export class FileWatcher extends EventEmitter { const specId = parts[0]; this.debouncedSpecUpdate(projectId, specId); - }); - - // Also handle file additions (new JSON files in existing spec folders) - watcher.on('add', (filePath: string) => { - const fileName = path.basename(filePath); - if (fileName !== 'implementation_plan.json' && fileName !== 'task_metadata.json') { - return; - } + }; - const relativePath = path.relative(specsPath, filePath); - const parts = relativePath.split(path.sep); - if (parts.length !== 2) { - return; - } - - const specId = parts[0]; - this.debouncedSpecUpdate(projectId, specId); - }); + // Handle file changes and additions inside spec folders + watcher.on('change', handleSpecFileEvent); + watcher.on('add', handleSpecFileEvent); // Handle errors watcher.on('error', (error: unknown) => { diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 7183dbd84..fc173b5e4 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -61,9 +61,8 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { async (_, projectId: string, specId: string): Promise> => { console.log(`[IPC] TASK_GET called with projectId: ${projectId}, specId: ${specId}`); - // Get all tasks and filter to find the one we want - const tasks = projectStore.getTasks(projectId); - const task = tasks.find(t => t.specId === specId); + // Use optimized single-task loader instead of loading all tasks + const task = projectStore.getTask(projectId, specId); if (task) { console.log(`[IPC] TASK_GET found task: ${task.title}`); diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index be1bf529a..86b1cdc0a 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -306,6 +306,142 @@ export class ProjectStore { return tasks; } + /** + * Get a single task by specId (optimized for surgical updates) + * Only loads the specific spec folder instead of scanning all tasks + */ + getTask(projectId: string, specId: string): Task | null { + const project = this.getProject(projectId); + if (!project) { + return null; + } + + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + const specPath = path.join(specsDir, specId); + + // Check if spec folder exists + if (!existsSync(specPath)) { + return null; + } + + // Load just this specific spec folder + return this.loadTaskFromSpecFolder(specPath, specId, project.path, projectId, specsBaseDir); + } + + /** + * Load a single task from a specific spec folder + */ + private loadTaskFromSpecFolder( + specPath: string, + specId: string, + basePath: string, + projectId: string, + specsBaseDir: string + ): Task | null { + try { + const planPath = path.join(specPath, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + const specFilePath = path.join(specPath, AUTO_BUILD_PATHS.SPEC_FILE); + + // Try to read implementation plan + let plan: ImplementationPlan | null = null; + if (existsSync(planPath)) { + try { + const content = readFileSync(planPath, 'utf-8'); + plan = JSON.parse(content); + } catch { + // Ignore parse errors + } + } + + // Try to read spec file for description + let description = ''; + if (existsSync(specFilePath)) { + try { + const content = readFileSync(specFilePath, 'utf-8'); + const overviewMatch = content.match(/## Overview\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/); + if (overviewMatch) { + description = overviewMatch[1].trim(); + } + } catch { + // Ignore read errors + } + } + + // Fallback: read description from implementation_plan.json + if (!description && plan?.description) { + description = plan.description; + } + + // Try to read task metadata + const metadataPath = path.join(specPath, 'task_metadata.json'); + let metadata: TaskMetadata | undefined; + if (existsSync(metadataPath)) { + try { + const content = readFileSync(metadataPath, 'utf-8'); + metadata = JSON.parse(content); + } catch { + // Ignore parse errors + } + } + + // Determine task status and review reason + const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata); + + // Extract subtasks from plan + const subtasks = plan?.phases?.flatMap((phase) => { + const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || []; + return items.map((subtask) => ({ + id: subtask.id, + title: subtask.description, + description: subtask.description, + status: subtask.status, + files: [] + })); + }) || []; + + // Extract staged status + const planWithStaged = plan as unknown as { stagedInMainProject?: boolean; stagedAt?: string } | null; + + // Determine title + let title = plan?.feature || plan?.title || specId; + const looksLikeSpecId = /^\d{3}-/.test(title); + if (looksLikeSpecId && existsSync(specFilePath)) { + try { + const specContent = readFileSync(specFilePath, 'utf-8'); + const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m); + if (titleMatch && titleMatch[1]) { + title = titleMatch[1].trim(); + } + } catch { + // Keep the original title on error + } + } + + return { + id: specId, + specId, + projectId, + title, + description, + status, + reviewReason, + subtasks, + logs: [], + metadata, + stagedInMainProject: planWithStaged?.stagedInMainProject, + stagedAt: planWithStaged?.stagedAt, + location: 'main', + specsPath: specPath, + createdAt: new Date(plan?.created_at || Date.now()), + updatedAt: new Date(plan?.updated_at || Date.now()) + }; + } catch (error) { + console.error(`[ProjectStore] Error loading spec ${specId}:`, error); + return null; + } + } + /** * Load tasks from a specs directory (helper method for main project and worktrees) */ From 188c7b03b143419d9a60c3f37a0e974b848b733f Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Thu, 1 Jan 2026 16:42:37 -0800 Subject: [PATCH 3/6] fix: add missing browser mock methods for file watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/frontend/src/renderer/lib/mocks/task-mock.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/lib/mocks/task-mock.ts b/apps/frontend/src/renderer/lib/mocks/task-mock.ts index 3c5175d91..2868ca905 100644 --- a/apps/frontend/src/renderer/lib/mocks/task-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/task-mock.ts @@ -11,6 +11,11 @@ export const taskMock = { data: mockTasks.filter(t => t.projectId === projectId) }), + getTask: async (_projectId: string, _specId: string) => ({ + success: true, + data: null + }), + createTask: async (projectId: string, title: string, description: string) => ({ success: true, data: { @@ -91,5 +96,10 @@ export const taskMock = { onTaskStatusChange: () => () => {}, onTaskExecutionProgress: () => () => {}, onTaskLogsChanged: () => () => {}, - onTaskLogsStream: () => () => {} + onTaskLogsStream: () => () => {}, + + // Spec file watcher events (no-op in browser) + onSpecAdded: () => () => {}, + onSpecRemoved: () => () => {}, + onSpecUpdated: () => () => {} }; From 5716cecbcaa627204c8d4c43125e7ec5402a88ac Mon Sep 17 00:00:00 2001 From: Navid Date: Fri, 2 Jan 2026 01:08:11 +0300 Subject: [PATCH 4/6] fix: prefer versioned Homebrew Python over system python3 (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhance Python detection to find versioned Homebrew installations Fixes issue where users with Python 3.9.6 at /usr/bin/python3 would get "Auto Claude requires Python 3.10 or higher" error even when they had newer Python versions installed via Homebrew. Changes: - Updated findHomebrewPython() to check for versioned Python installations - Now searches for python3.13, python3.12, python3.11, python3.10 in addition to generic python3 - Validates each found Python to ensure it meets version requirements - Checks both Apple Silicon (/opt/homebrew/bin) and Intel Mac (/usr/local/bin) locations This ensures the app automatically finds and uses the latest compatible Python version instead of falling back to the potentially outdated system Python. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * Update apps/frontend/src/main/python-detector.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Address PR review findings for Python detection enhancement Addresses all review findings from Auto Claude PR Review: 1. [HIGH] Align version ordering between python-detector.ts and cli-tool-manager.ts - Both now use consistent order: versioned first (3.13→3.10), then generic python3 - This ensures different parts of the app use the same Python version - Added validation in cli-tool-manager.ts (was missing before) 2. [MEDIUM] Add try/catch around validatePythonVersion calls - Wrapped validation in try/catch to handle timeouts and permission errors - Follows same pattern as findPythonCommand() - Ensures graceful fallback to next candidate on validation failure 3. [LOW] Add debug logging for Python detection - Added console.log for successful detection with version info - Added console.warn for rejected candidates with reason - Added logging when no valid Python found - Improves troubleshooting of user Python detection issues 4. [LOW] Document maintenance requirement for version list - Added JSDoc note about updating list for new Python releases - Added TODO comment for Python 3.14+ updates - Applied to both files for consistency Additional improvements: - Fixed bug in cli-tool-manager.ts that returned first found Python without validation - Both detection systems now validate Python version requirements (3.10+) - Consistent logging format between both detection systems 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * Add Python 3.14 support to version detection Python 3.14 was released, so adding it to the detection lists: - Updated pythonNames arrays in both python-detector.ts and cli-tool-manager.ts - Added python3.14 to SAFE_PYTHON_COMMANDS set - Updated JSDoc comments to reflect Python 3.14 support - Removed TODO about Python 3.14 (now implemented) This ensures the app can detect and use Python 3.14 installations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * Refactor: Extract shared Homebrew Python detection logic Eliminated code duplication by extracting shared Python detection logic into a reusable utility module. Changes: - Created apps/frontend/src/main/utils/homebrew-python.ts - Exported findHomebrewPython() utility function - Accepts validation function and log prefix as parameters - Contains all shared detection logic (version list, validation, logging) - Updated python-detector.ts - Removed duplicate findHomebrewPython() implementation (45 lines) - Now imports and delegates to shared utility - Maintains identical behavior and error semantics - Updated cli-tool-manager.ts - Removed duplicate findHomebrewPython() implementation (52 lines) - Now imports and delegates to shared utility - Maintains identical behavior and error semantics Benefits: - Single source of truth for Homebrew Python detection - Easier to maintain (update version list in one place) - Consistent behavior across the application - Reduced code duplication (~90 lines eliminated) The refactored code maintains 100% backward compatibility with identical return values, logging behavior, and error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- apps/frontend/src/main/cli-tool-manager.ts | 33 ++------ apps/frontend/src/main/python-detector.ts | 18 ++--- .../src/main/utils/homebrew-python.ts | 77 +++++++++++++++++++ 3 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 apps/frontend/src/main/utils/homebrew-python.ts diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index e54dd43b4..4e0c6d0a4 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -27,6 +27,7 @@ import os from 'os'; import { app } from 'electron'; import { findExecutable } from './env-utils'; import type { ToolDetectionResult } from '../shared/types'; +import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python'; /** * Supported CLI tools managed by this system @@ -731,37 +732,15 @@ class CLIToolManager { /** * Find Homebrew Python on macOS - * - * Checks both Apple Silicon and Intel Homebrew locations. - * Searches for python3, python3.13, python3.12, etc. in order. + * Delegates to shared utility function. * * @returns Path to Homebrew Python or null if not found */ private findHomebrewPython(): string | null { - const homebrewDirs = [ - '/opt/homebrew/bin', // Apple Silicon - '/usr/local/bin', // Intel Mac - ]; - - // Check for generic python3 first, then specific versions (newest first) - const pythonNames = [ - 'python3', - 'python3.13', - 'python3.12', - 'python3.11', - 'python3.10', - ]; - - for (const dir of homebrewDirs) { - for (const name of pythonNames) { - const pythonPath = path.join(dir, name); - if (existsSync(pythonPath)) { - return pythonPath; - } - } - } - - return null; + return findHomebrewPythonUtil( + (pythonPath) => this.validatePython(pythonPath), + '[CLI Tools]' + ); } /** diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index f8c80d20c..e15bcd451 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -2,6 +2,7 @@ import { execSync, execFileSync } from 'child_process'; import { existsSync, accessSync, constants } from 'fs'; import path from 'path'; import { app } from 'electron'; +import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python'; /** * Get the path to the bundled Python executable. @@ -34,23 +35,12 @@ export function getBundledPythonPath(): string | null { /** * Find the first existing Homebrew Python installation. - * Checks common Homebrew paths for Python 3. + * Delegates to shared utility function. * * @returns The path to Homebrew Python, or null if not found */ function findHomebrewPython(): string | null { - const homebrewPaths = [ - '/opt/homebrew/bin/python3', // Apple Silicon (M1/M2/M3) - '/usr/local/bin/python3' // Intel Mac - ]; - - for (const pythonPath of homebrewPaths) { - if (existsSync(pythonPath)) { - return pythonPath; - } - } - - return null; + return findHomebrewPythonUtil(validatePythonVersion, '[Python]'); } /** @@ -308,6 +298,7 @@ const ALLOWED_PATH_PATTERNS: RegExp[] = [ /** * Known safe Python commands (not full paths). * These are resolved by the shell/OS and are safe. + * Note: Update this list when new Python versions are released. */ const SAFE_PYTHON_COMMANDS = new Set([ 'python', @@ -316,6 +307,7 @@ const SAFE_PYTHON_COMMANDS = new Set([ 'python3.11', 'python3.12', 'python3.13', + 'python3.14', 'py', 'py -3', ]); diff --git a/apps/frontend/src/main/utils/homebrew-python.ts b/apps/frontend/src/main/utils/homebrew-python.ts new file mode 100644 index 000000000..9891c1234 --- /dev/null +++ b/apps/frontend/src/main/utils/homebrew-python.ts @@ -0,0 +1,77 @@ +/** + * Homebrew Python Detection Utility + * + * Shared logic for finding Python installations in Homebrew directories. + * Used by both python-detector.ts and cli-tool-manager.ts to ensure + * consistent Python detection across the application. + */ + +import { existsSync } from 'fs'; +import path from 'path'; + +/** + * Validation result for a Python installation. + */ +export interface PythonValidation { + valid: boolean; + version?: string; + message: string; +} + +/** + * Find the first valid Homebrew Python installation. + * Checks common Homebrew paths for Python 3, including versioned installations. + * Prioritizes newer Python versions (3.14, 3.13, 3.12, 3.11, 3.10). + * + * Note: This list should be updated when new Python versions are released. + * Check for specific versions first to ensure we find the latest available version. + * + * @param validateFn - Function to validate a Python path and return validation result + * @param logPrefix - Prefix for log messages (e.g., '[Python]', '[CLI Tools]') + * @returns The path to Homebrew Python, or null if not found + */ +export function findHomebrewPython( + validateFn: (pythonPath: string) => PythonValidation, + logPrefix: string +): string | null { + const homebrewDirs = [ + '/opt/homebrew/bin', // Apple Silicon (M1/M2/M3) + '/usr/local/bin' // Intel Mac + ]; + + // Check for specific Python versions first (newest to oldest), then fall back to generic python3. + // This ensures we find the latest available version that meets our requirements. + const pythonNames = [ + 'python3.14', + 'python3.13', + 'python3.12', + 'python3.11', + 'python3.10', + 'python3', + ]; + + for (const dir of homebrewDirs) { + for (const name of pythonNames) { + const pythonPath = path.join(dir, name); + if (existsSync(pythonPath)) { + try { + // Validate that this Python meets version requirements + const validation = validateFn(pythonPath); + if (validation.valid) { + console.log(`${logPrefix} Found valid Homebrew Python: ${pythonPath} (${validation.version})`); + return pythonPath; + } else { + console.warn(`${logPrefix} ${pythonPath} rejected: ${validation.message}`); + } + } catch (error) { + // Version check failed (e.g., timeout, permission issue), try next candidate + console.warn(`${logPrefix} Failed to validate ${pythonPath}: ${error}`); + continue; + } + } + } + } + + console.log(`${logPrefix} No valid Homebrew Python found in ${homebrewDirs.join(', ')}`); + return null; +} From 85d8920098220540d02c78a4147f495413363007 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:17:15 +0100 Subject: [PATCH 5/6] fix(pr-review): use temporary worktree for PR review isolation (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review agents were reading files from the current checkout branch (e.g., develop) instead of the actual PR branch when using Read/Grep/Glob tools. This caused incorrect review findings. The fix creates a temporary detached worktree at the PR head commit for each review, ensuring agents read from the correct branch state: - Add head_sha/base_sha fields to PRContext dataclass - Create worktree at PR commit before spawning specialist agents - Use worktree path as project_dir for SDK client - Cleanup worktree after review with fallback chain - Add startup cleanup for orphaned worktrees from crashed runs Worktrees are stored in .auto-claude/pr-review-worktrees/ (already gitignored) to avoid /tmp filesystem boundary issues and support concurrent reviews. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- .../runners/github/context_gatherer.py | 5 + .../parallel_orchestrator_reviewer.py | 161 +++++++++++++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/apps/backend/runners/github/context_gatherer.py b/apps/backend/runners/github/context_gatherer.py index 0b55feff4..0ce48bf5e 100644 --- a/apps/backend/runners/github/context_gatherer.py +++ b/apps/backend/runners/github/context_gatherer.py @@ -201,6 +201,9 @@ class PRContext: ai_bot_comments: list[AIBotComment] = field(default_factory=list) # Flag indicating if full diff was skipped (PR > 20K lines) diff_truncated: bool = False + # Commit SHAs for worktree creation (PR review isolation) + head_sha: str = "" # Commit SHA of PR head (headRefOid) + base_sha: str = "" # Commit SHA of PR base (baseRefOid) class PRContextGatherer: @@ -291,6 +294,8 @@ async def gather(self) -> PRContext: total_deletions=pr_data.get("deletions", 0), ai_bot_comments=ai_bot_comments, diff_truncated=diff_truncated, + head_sha=pr_data.get("headRefOid", ""), + base_sha=pr_data.get("baseRefOid", ""), ) async def _fetch_pr_metadata(self) -> dict: diff --git a/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py index 14ce349e4..bcdbe95c1 100644 --- a/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py +++ b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py @@ -20,6 +20,9 @@ import hashlib import logging import os +import shutil +import subprocess +import uuid from pathlib import Path from typing import Any @@ -60,6 +63,9 @@ # Check if debug mode is enabled DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") +# Directory for PR review worktrees (project-local, same filesystem) +PR_WORKTREE_DIR = ".auto-claude/pr-review-worktrees" + class ParallelOrchestratorReviewer: """ @@ -116,6 +122,115 @@ def _load_prompt(self, filename: str) -> str: logger.warning(f"Prompt file not found: {prompt_file}") return "" + def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path: + """Create a temporary worktree at the PR head commit. + + Args: + head_sha: The commit SHA of the PR head + pr_number: The PR number for naming + + Returns: + Path to the created worktree + + Raises: + RuntimeError: If worktree creation fails + """ + worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}" + worktree_dir = self.project_dir / PR_WORKTREE_DIR + worktree_dir.mkdir(parents=True, exist_ok=True) + worktree_path = worktree_dir / worktree_name + + # Fetch the commit if not available locally (handles fork PRs) + subprocess.run( + ["git", "fetch", "origin", head_sha], + cwd=self.project_dir, + capture_output=True, + timeout=60, + ) + + # Create detached worktree at the PR commit + result = subprocess.run( + ["git", "worktree", "add", "--detach", str(worktree_path), head_sha], + cwd=self.project_dir, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to create worktree: {result.stderr}") + + logger.info(f"[PRReview] Created worktree at {worktree_path}") + print(f"[PRReview] Created worktree at {worktree_path.name}", flush=True) + return worktree_path + + def _cleanup_pr_worktree(self, worktree_path: Path) -> None: + """Remove a temporary PR review worktree with fallback chain. + + Args: + worktree_path: Path to the worktree to remove + """ + if not worktree_path or not worktree_path.exists(): + return + + # Try 1: git worktree remove + result = subprocess.run( + ["git", "worktree", "remove", "--force", str(worktree_path)], + cwd=self.project_dir, + capture_output=True, + ) + + if result.returncode == 0: + logger.info(f"[PRReview] Cleaned up worktree: {worktree_path.name}") + print(f"[PRReview] Cleaned up worktree: {worktree_path.name}", flush=True) + return + + # Try 2: shutil.rmtree fallback + try: + shutil.rmtree(worktree_path, ignore_errors=True) + subprocess.run( + ["git", "worktree", "prune"], + cwd=self.project_dir, + capture_output=True, + ) + logger.warning(f"[PRReview] Used shutil fallback for: {worktree_path.name}") + except Exception as e: + logger.error(f"[PRReview] Failed to cleanup worktree {worktree_path}: {e}") + + def _cleanup_stale_pr_worktrees(self) -> None: + """Clean up orphaned PR review worktrees on startup.""" + worktree_dir = self.project_dir / PR_WORKTREE_DIR + if not worktree_dir.exists(): + return + + # Get registered worktrees from git + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=self.project_dir, + capture_output=True, + text=True, + ) + registered = { + Path(line.split(" ", 1)[1]) + for line in result.stdout.split("\n") + if line.startswith("worktree ") + } + + # Remove unregistered directories + stale_count = 0 + for item in worktree_dir.iterdir(): + if item.is_dir() and item not in registered: + logger.info(f"[PRReview] Removing stale worktree: {item.name}") + shutil.rmtree(item, ignore_errors=True) + stale_count += 1 + + if stale_count > 0: + subprocess.run( + ["git", "worktree", "prune"], + cwd=self.project_dir, + capture_output=True, + ) + print(f"[PRReview] Cleaned up {stale_count} stale worktree(s)", flush=True) + def _define_specialist_agents(self) -> dict[str, AgentDefinition]: """ Define specialist agents for the SDK. @@ -400,6 +515,12 @@ async def review(self, context: PRContext) -> PRReviewResult: f"[ParallelOrchestrator] Starting review for PR #{context.pr_number}" ) + # Clean up any stale worktrees from previous runs + self._cleanup_stale_pr_worktrees() + + # Track worktree for cleanup + worktree_path: Path | None = None + try: self._report_progress( "orchestrating", @@ -411,12 +532,36 @@ async def review(self, context: PRContext) -> PRReviewResult: # Build orchestrator prompt prompt = self._build_orchestrator_prompt(context) - # Get project root - project_root = ( - self.project_dir.parent.parent - if self.project_dir.name == "backend" - else self.project_dir - ) + # Create temporary worktree at PR head commit for isolated review + # This ensures agents read from the correct PR state, not the current checkout + head_sha = context.head_sha or context.head_branch + if not head_sha: + logger.warning( + "[ParallelOrchestrator] No head_sha available, using current checkout" + ) + # Fallback to original behavior if no SHA available + project_root = ( + self.project_dir.parent.parent + if self.project_dir.name == "backend" + else self.project_dir + ) + else: + try: + worktree_path = self._create_pr_worktree( + head_sha, context.pr_number + ) + project_root = worktree_path + except RuntimeError as e: + logger.warning( + f"[ParallelOrchestrator] Worktree creation failed, " + f"using current checkout: {e}" + ) + # Fallback to original behavior if worktree creation fails + project_root = ( + self.project_dir.parent.parent + if self.project_dir.name == "backend" + else self.project_dir + ) # Use model and thinking level from config (user settings) model = self.config.model or "claude-sonnet-4-5-20250929" @@ -559,6 +704,10 @@ async def review(self, context: PRContext) -> PRReviewResult: success=False, error=str(e), ) + finally: + # Always cleanup worktree, even on error + if worktree_path: + self._cleanup_pr_worktree(worktree_path) def _parse_structured_output( self, structured_output: dict[str, Any] From ca894259bad9d8ff52eee2a7df33948cefe566f0 Mon Sep 17 00:00:00 2001 From: Greg Hughes Date: Thu, 1 Jan 2026 21:24:14 -0800 Subject: [PATCH 6/6] fix(watcher): clear pending debounced updates when stopping watchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback - prevents memory leaks by clearing pending setTimeout callbacks when unwatchSpecsDirectory() or unwatchAllSpecsDirectories() is called. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/frontend/src/main/file-watcher.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/frontend/src/main/file-watcher.ts b/apps/frontend/src/main/file-watcher.ts index c70e61095..cc737ab49 100644 --- a/apps/frontend/src/main/file-watcher.ts +++ b/apps/frontend/src/main/file-watcher.ts @@ -260,11 +260,25 @@ export class FileWatcher extends EventEmitter { const watcherInfo = this.specsWatchers.get(projectId); if (watcherInfo) { console.log(`[FileWatcher] Stopping specs directory watcher for project ${projectId}`); + // Clear any pending debounced updates for this project + this.clearPendingUpdatesForProject(projectId); await watcherInfo.watcher.close(); this.specsWatchers.delete(projectId); } } + /** + * Clear all pending debounced updates for a specific project + */ + private clearPendingUpdatesForProject(projectId: string): void { + for (const [key, pending] of this.pendingUpdates.entries()) { + if (pending.projectId === projectId) { + clearTimeout(pending.timeout); + this.pendingUpdates.delete(key); + } + } + } + /** * Check if a project's specs directory is being watched */ @@ -276,6 +290,12 @@ export class FileWatcher extends EventEmitter { * Stop all specs directory watchers */ async unwatchAllSpecsDirectories(): Promise { + // Clear all pending debounced updates + for (const pending of this.pendingUpdates.values()) { + clearTimeout(pending.timeout); + } + this.pendingUpdates.clear(); + const closePromises = Array.from(this.specsWatchers.values()).map( async (info) => { await info.watcher.close();