Clawern deep-dive
The autonomous pipeline — watch a folder, read what arrives, remember across documents — and the seven-report eval arc that hardened it.
src/claw/watcher.ts199 lines · ClawWatcher L39–198
Outline 12 symbols
- logger const
- WatchEvent type export
- WatcherOptions interface export
- ClawWatcher class export
- constructor method
- resolvePath method
- isSupported method
- start method
- stop method
- isRunning method
- handleFileEvent method
- recordExisting method
1/**
2 * File Watcher — Eyes on the corridors.
3 *
4 * Watches all configured paths using Node.js `fs.watch`.
5 * Debounces events (files often trigger multiple events on save),
6 * filters to supported document types, and emits callbacks
7 * for new/changed files.
8 *
9 * Also performs a full startup scan so the registry is current
10 * before the watcher begins.
11 */
12
13import * as fs from 'node:fs';
14import * as os from 'node:os';
15import * as path from 'node:path';
16import { SUPPORTED_EXTENSIONS } from '../documents/parser.js';
17import { config } from '../config.js';
18import { createLogger } from '../utils/logger.js';
19
20const logger = createLogger('CLAW-WATCHER');
21
22// ── Types ────────────────────────────────────────────────────────────────
23
24export type WatchEvent = 'new' | 'changed';
25
26export interface WatcherOptions {
27 /** Paths to watch (supports ~ for home directory) */
28 watchPaths: string[];
29 /** Debounce interval in ms (default: 2000) */
30 debounceMs?: number;
31 /** Callback when a document file is detected as new or changed */
32 onChange: (filePath: string, event: WatchEvent) => void;
33 /** Debug logging */
34 debug?: boolean;
35}
36
37// ── Watcher ──────────────────────────────────────────────────────────────
38
39export class ClawWatcher {
40 private watchers: fs.FSWatcher[] = [];
41 private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
42 private debounceMs: number;
43 private onChange: (filePath: string, event: WatchEvent) => void;
44 private knownPaths = new Set<string>();
45 private debug: boolean;
46 private running = false;
47
48 constructor(options: WatcherOptions) {
49 this.debounceMs = options.debounceMs ?? 2000;
50 this.onChange = options.onChange;
51 this.debug = options.debug ?? false;
52 }
53
54 /**
55 * Resolve a path — expand ~ and resolve to absolute.
56 */
57 private resolvePath(p: string): string {
58 const resolved = path.resolve(p.replace(/^~/, os.homedir()));
59 // Security: reject path traversal attempts
60 if (p.includes('..')) {
61 logger.warn('Rejected watch path with traversal', { path: p });
62 return ''; // Will fail existsSync check
63 }
64 return resolved;
65 }
66
67 /**
68 * Check if a file is a supported document type.
69 */
70 private isSupported(filePath: string): boolean {
71 const ext = path.extname(filePath).toLowerCase();
72 return SUPPORTED_EXTENSIONS.has(ext);
73 }
74
75 /**
76 * Start watching all configured paths.
77 * Also records currently existing files so we can distinguish
78 * new files from changes.
79 */
80 start(watchPaths: string[]): void {
81 if (this.running) return;
82 this.running = true;
83
84 for (const wp of watchPaths) {
85 const resolved = this.resolvePath(wp);
86 if (!fs.existsSync(resolved)) {
87 if (this.debug) logger.info('Watch path does not exist', { path: resolved });
88 continue;
89 }
90
91 // Record existing files
92 this.recordExisting(resolved);
93
94 try {
95 const watcher = fs.watch(resolved, { recursive: true }, (eventType, filename) => {
96 if (!filename) return;
97 const full = path.join(resolved, filename);
98
99 // Skip non-documents, hidden files, node_modules
100 if (!this.isSupported(full)) return;
101 const basename = filename.split('/').pop() ?? filename;
102 if (basename.startsWith('.') || filename.includes('node_modules')) return;
103
104 // Debounce: many editors trigger multiple events per save
105 const existing = this.debounceTimers.get(full);
106 if (existing) clearTimeout(existing);
107
108 this.debounceTimers.set(full, setTimeout(() => {
109 this.debounceTimers.delete(full);
110 this.handleFileEvent(full);
111 }, this.debounceMs));
112 });
113
114 this.watchers.push(watcher);
115 if (this.debug) logger.info('Watching', { path: resolved });
116 } catch (err) {
117 logger.error('Failed to watch path', { path: resolved, error: err });
118 }
119 }
120 }
121
122 /**
123 * Stop all watchers and clear timers.
124 */
125 stop(): void {
126 this.running = false;
127 for (const w of this.watchers) {
128 try { w.close(); } catch { /* ignore */ }
129 }
130 this.watchers = [];
131
132 for (const timer of this.debounceTimers.values()) {
133 clearTimeout(timer);
134 }
135 this.debounceTimers.clear();
136 }
137
138 /**
139 * Whether the watcher is currently active.
140 */
141 get isRunning(): boolean {
142 return this.running;
143 }
144
145 // ── Private ────────────────────────────────────────────────────────────
146
147 private handleFileEvent(filePath: string): void {
148 try {
149 // Single lstatSync call replaces existsSync + lstatSync (removes TOCTOU window).
150 // lstatSync throws ENOENT if the file was deleted, caught by outer try/catch.
151 const lstat = fs.lstatSync(filePath);
152
153 // SECURITY: Skip symlinks — prevent traversal outside watch paths
154 if (lstat.isSymbolicLink()) return;
155 if (!lstat.isFile()) return;
156
157 // SECURITY: Skip oversized files — prevent memory exhaustion
158 if (lstat.size > config.claw.maxFileSizeBytes) {
159 if (this.debug) logger.info('Skipping oversized file', { filePath, sizeMB: (lstat.size / 1024 / 1024).toFixed(1) });
160 return;
161 }
162
163 const event: WatchEvent = this.knownPaths.has(filePath) ? 'changed' : 'new';
164 this.knownPaths.add(filePath);
165
166 if (this.debug) logger.info('File event', { event, filePath });
167 // Catch async callback errors to prevent unhandled rejections crashing the daemon
168 Promise.resolve(this.onChange(filePath, event)).catch(err => {
169 logger.error('Error processing file', { filePath, error: err });
170 });
171 } catch (err) {
172 // ENOENT is expected — file deleted between event and stat
173 if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
174 logger.warn('Unexpected error processing file', { filePath, error: err });
175 }
176 }
177 }
178
179 private recordExisting(dir: string): void {
180 try {
181 const entries = fs.readdirSync(dir, { withFileTypes: true });
182 for (const entry of entries) {
183 if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
184 // SECURITY: Skip symlinks
185 if (entry.isSymbolicLink()) continue;
186
187 const full = path.join(dir, entry.name);
188 if (entry.isDirectory()) {
189 this.recordExisting(full);
190 } else if (entry.isFile() && this.isSupported(full)) {
191 this.knownPaths.add(full);
192 }
193 }
194 } catch {
195 // Permission denied — skip
196 }
197 }
198}
199