11/**
2- * Session Filewatch — Event-driven JSONL capture via fs.watch .
2+ * Session Filewatch — Event-driven JSONL capture via chokidar .
33 *
4- * Watches ~/.claude/projects/ recursively for JSONL changes.
4+ * Watches ~/.claude/projects/ for JSONL changes.
55 * Reacts only when a file is written — zero CPU when idle.
66 * Reads incrementally from stored offset, debounced 500ms per file.
77 */
88
9- import { type FSWatcher , watch } from 'node:fs' ;
109import { homedir } from 'node:os' ;
11- import { basename , join } from 'node:path' ;
10+ import { basename , isAbsolute , join , resolve } from 'node:path' ;
11+ import { type FSWatcher , watch } from 'chokidar' ;
1212
1313import { buildWorkerMap , ingestFileFull , setLiveWorkPending } from './session-capture.js' ;
1414
@@ -23,6 +23,7 @@ let watcher: FSWatcher | null = null;
2323const offsetCache = new Map < string , number > ( ) ;
2424const debounceTimers = new Map < string , ReturnType < typeof setTimeout > > ( ) ;
2525const DEBOUNCE_MS = 500 ;
26+ const WATCH_DEPTH = 4 ;
2627
2728/**
2829 * Sessions where ingest raised an unrecoverable (FK) error — logged once,
@@ -72,10 +73,11 @@ async function loadOffsets(sql: SqlClient): Promise<void> {
7273// File event handler
7374// ============================================================================
7475
75- function extractSessionInfo (
76+ export function extractSessionInfo (
7677 filePath : string ,
7778) : { sessionId : string ; projectPath : string ; parentSessionId : string | null ; isSubagent : boolean } | null {
78- // Main: ~/.claude/projects/<hash>/sessions/<id>.jsonl
79+ // Main: ~/.claude/projects/<hash>/<id>.jsonl
80+ // Legacy main: ~/.claude/projects/<hash>/sessions/<id>.jsonl
7981 // Subagent: ~/.claude/projects/<hash>/<parent-id>/subagents/<id>.jsonl
8082 if ( ! filePath . endsWith ( '.jsonl' ) ) return null ;
8183
@@ -93,11 +95,18 @@ function extractSessionInfo(
9395 }
9496
9597 if ( sessionsIdx > 0 ) {
96- // Main session
98+ // Legacy main session
9799 const projectPath = parts . slice ( 0 , sessionsIdx ) . join ( '/' ) ;
98100 return { sessionId, projectPath, parentSessionId : null , isSubagent : false } ;
99101 }
100102
103+ const projectIdx = parts . lastIndexOf ( 'projects' ) ;
104+ if ( projectIdx >= 0 && parts . length === projectIdx + 3 ) {
105+ // Main session
106+ const projectPath = parts . slice ( 0 , projectIdx + 2 ) . join ( '/' ) ;
107+ return { sessionId, projectPath, parentSessionId : null , isSubagent : false } ;
108+ }
109+
101110 return null ;
102111}
103112
@@ -163,6 +172,55 @@ export async function handleFileChange(
163172 }
164173}
165174
175+ function shouldIgnoreWatchPath ( path : string , stats ?: { isFile : ( ) => boolean } ) : boolean {
176+ return stats ?. isFile ( ) === true && ! path . endsWith ( '.jsonl' ) ;
177+ }
178+
179+ function normalizeWatchEventPath ( claudeDir : string , filePath : string ) : string {
180+ return isAbsolute ( filePath ) ? filePath : resolve ( claudeDir , filePath ) ;
181+ }
182+
183+ function scheduleFileChange ( filePath : string , sql : SqlClient ) : void {
184+ if ( ! filePath . endsWith ( '.jsonl' ) ) return ;
185+
186+ const existing = debounceTimers . get ( filePath ) ;
187+ if ( existing ) clearTimeout ( existing ) ;
188+
189+ debounceTimers . set (
190+ filePath ,
191+ setTimeout ( ( ) => {
192+ debounceTimers . delete ( filePath ) ;
193+ handleFileChange ( filePath , sql ) . catch ( ( err ) => {
194+ const message = err instanceof Error ? err . message : String ( err ) ;
195+ console . error ( `[filewatch] unhandled error for ${ filePath } : ${ message } ` ) ;
196+ } ) ;
197+ } , DEBOUNCE_MS ) ,
198+ ) ;
199+ }
200+
201+ export function createJsonlWatcher ( claudeDir : string , onJsonlChange : ( filePath : string ) => void ) : FSWatcher {
202+ const jsonlWatcher = watch ( claudeDir , {
203+ ignoreInitial : true ,
204+ depth : WATCH_DEPTH ,
205+ ignored : shouldIgnoreWatchPath ,
206+ awaitWriteFinish : {
207+ stabilityThreshold : DEBOUNCE_MS ,
208+ pollInterval : 100 ,
209+ } ,
210+ atomic : true ,
211+ } ) ;
212+
213+ const emitJsonlChange = ( filePath : string ) : void => {
214+ if ( ! filePath . endsWith ( '.jsonl' ) ) return ;
215+ onJsonlChange ( normalizeWatchEventPath ( claudeDir , filePath ) ) ;
216+ } ;
217+
218+ jsonlWatcher . on ( 'add' , emitJsonlChange ) ;
219+ jsonlWatcher . on ( 'change' , emitJsonlChange ) ;
220+
221+ return jsonlWatcher ;
222+ }
223+
166224// ============================================================================
167225// Start / Stop
168226// ============================================================================
@@ -176,30 +234,11 @@ export async function startFilewatch(sql: SqlClient): Promise<boolean> {
176234 await loadOffsets ( sql ) ;
177235
178236 try {
179- watcher = watch ( claudeDir , { recursive : true } , ( _eventType , filename ) => {
180- if ( ! filename || ! filename . endsWith ( '.jsonl' ) ) return ;
181-
182- const fullPath = join ( claudeDir , filename ) ;
183-
184- // Debounce per file — Claude writes multiple lines per turn
185- const existing = debounceTimers . get ( fullPath ) ;
186- if ( existing ) clearTimeout ( existing ) ;
187-
188- debounceTimers . set (
189- fullPath ,
190- setTimeout ( ( ) => {
191- debounceTimers . delete ( fullPath ) ;
192- handleFileChange ( fullPath , sql ) . catch ( ( err ) => {
193- const message = err instanceof Error ? err . message : String ( err ) ;
194- console . error ( `[filewatch] unhandled error for ${ fullPath } : ${ message } ` ) ;
195- } ) ;
196- } , DEBOUNCE_MS ) ,
197- ) ;
198- } ) ;
237+ watcher = createJsonlWatcher ( claudeDir , ( fullPath ) => scheduleFileChange ( fullPath , sql ) ) ;
199238
200239 watcher . on ( 'error' , ( err ) => {
201- console . error ( '[filewatch] watcher error:' , err . message ) ;
202- // Could fall back to polling here in the future
240+ const message = err instanceof Error ? err . message : String ( err ) ;
241+ console . error ( '[filewatch] watcher error:' , message ) ;
203242 } ) ;
204243
205244 console . log ( `[filewatch] watching ${ claudeDir } (${ offsetCache . size } sessions cached)` ) ;
@@ -213,7 +252,7 @@ export async function startFilewatch(sql: SqlClient): Promise<boolean> {
213252
214253export function stopFilewatch ( ) : void {
215254 if ( watcher ) {
216- watcher . close ( ) ;
255+ void watcher . close ( ) ;
217256 watcher = null ;
218257 }
219258 for ( const timer of debounceTimers . values ( ) ) {
0 commit comments