import { watch, existsSync } from "fs"; import { join, dirname, basename } from "path"; import { getAdditionalRepoPaths } from "./parse-beads"; /** * Get all issues.jsonl file paths that should be watched. * Returns the primary path plus any additional repo paths from config.yaml. */ export function getWatchPaths(beadsDir: string): string[] { const paths: string[] = []; // Primary JSONL const primary = join(beadsDir, "issues.jsonl"); if (existsSync(primary)) paths.push(primary); // Additional repo JSONLs const additionalRepos = getAdditionalRepoPaths(beadsDir); for (const repoPath of additionalRepos) { const jsonlPath = join(repoPath, ".beads", "issues.jsonl"); if (existsSync(jsonlPath)) paths.push(jsonlPath); } return paths; } /** * Watch all issues.jsonl files for a beads project. * Discovers files from the primary .beads dir and config.yaml repos.additional. * Debounces rapid changes (bd often writes multiple times per command). * * @param beadsDir - Absolute path to the primary .beads/ directory * @param onChange - Callback fired when any watched file changes (after debounce) * @param debounceMs - Debounce interval in milliseconds (default: 300) * @returns Cleanup function that closes all watchers */ export function watchBeadsFiles( beadsDir: string, onChange: () => void, debounceMs = 300 ): () => void { const filePaths = getWatchPaths(beadsDir); let timer: ReturnType | null = null; const watchers: ReturnType[] = []; // Deduplicate directories — multiple files may share the same .beads/ dir const dirToFiles = new Map>(); for (const filePath of filePaths) { const dir = dirname(filePath); const file = basename(filePath); if (!dirToFiles.has(dir)) dirToFiles.set(dir, new Set()); dirToFiles.get(dir)!.add(file); } console.log( `[heartbeads] Watching ${filePaths.length} file(s) in ${dirToFiles.size} dir(s) for changes` ); const debouncedOnChange = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { console.log("[heartbeads] File change detected, pushing update"); onChange(); }, debounceMs); }; // Watch directories instead of individual files. // This is far more reliable on macOS: fs.watch on a file breaks when the // file is atomically replaced (write-tmp + rename), which is how bd and // many editors write. Watching the directory catches renames reliably. for (const [dir, fileNames] of dirToFiles) { try { const watcher = watch(dir, { persistent: true }, (_eventType, filename) => { // Filter: only react to changes to our target files if (filename && fileNames.has(filename)) { debouncedOnChange(); } }); watchers.push(watcher); console.log(`[heartbeads] Watching dir: ${dir} for [${[...fileNames].join(", ")}]`); } catch (err) { console.warn(`[heartbeads] Failed to watch ${dir}:`, err); } } if (filePaths.length === 0) { console.warn("[heartbeads] No issues.jsonl files found to watch"); } // Return cleanup function return () => { if (timer) clearTimeout(timer); for (const w of watchers) { w.close(); } console.log("[heartbeads] File watchers closed"); }; }