11/**
2- * codexWatcher.ts — Watch ~/.codex/sessions/ for new Codex CLI/APP sessions
3- * and auto-export to .codex-history/
2+ * codexWatcher.ts — Track Codex sessions and auto-export to .codex-history/
43 *
5- * Uses polling (every 30s) instead of fs.watch because fs.watch {recursive}
6- * on Windows misses files in newly created date subdirectories (YYYY/MM/DD/).
4+ * Design: zero polling timers.
5+ * 1. Startup scan — once, catches sessions created while Cursor was closed.
6+ * Only exports files that don't already have a matching .md in .codex-history/.
7+ * 2. VS Code file watcher — event-driven via vscode.workspace.createFileSystemWatcher.
8+ * The VS Code API uses OS-level notifications (ReadDirectoryChangesW on Windows),
9+ * handles recursive subdirectories reliably, and burns zero CPU when idle.
710 *
8- * CPU-safety guarantees:
9- * 1. Seeded at startup: existing files are recorded as "already exported"
10- * so the first poll only picks up files modified AFTER Cursor opened.
11- * 2. isPolling guard: if a poll cycle is still running when the next timer
12- * fires, the new tick is skipped entirely — no overlapping runs.
11+ * No setInterval / setTimeout involved after startup.
1312 */
1413
1514import * as fs from 'fs' ;
@@ -19,15 +18,12 @@ import * as vscode from 'vscode';
1918import { CodexExporter } from './codexExporter' ;
2019import { scanCodexSessions } from './codexParser' ;
2120
22- const POLL_INTERVAL_MS = 30_000 ;
23-
2421export class CodexWatcher implements vscode . Disposable {
25- private pollTimer ?: NodeJS . Timeout ;
22+ private fsWatcher ?: vscode . FileSystemWatcher ;
2623 private disposed = false ;
27- private isPolling = false ;
2824 private readonly codexSessionsDir : string ;
29- // path → mtime at last export; pre-seeded with current mtimes at startup
30- private readonly exported = new Map < string , number > ( ) ;
25+ // debounce: path → timer, so rapid writes to the same file export only once
26+ private readonly debounce = new Map < string , NodeJS . Timeout > ( ) ;
3127
3228 constructor (
3329 private readonly workspaceRoot : string ,
@@ -42,74 +38,104 @@ export class CodexWatcher implements vscode.Disposable {
4238 return ;
4339 }
4440
45- // Seed: record current mtime of every existing file WITHOUT exporting.
46- // This prevents a flood of exports on the first poll after Cursor opens.
47- try {
48- for ( const filePath of scanCodexSessions ( this . codexSessionsDir ) ) {
49- try {
50- this . exported . set ( filePath , fs . statSync ( filePath ) . mtimeMs ) ;
51- } catch { /* skip inaccessible */ }
52- }
53- } catch ( err ) {
54- console . error ( '[codex-watcher] Seed scan failed:' , err ) ;
55- }
41+ // ── 1. One-time startup scan ─────────────────────────────────────────────
42+ // Export sessions that are missing from .codex-history/ (created while Cursor was closed).
43+ // Uses the exporter's own de-dup (shortId matching) so nothing is doubled.
44+ setImmediate ( ( ) => this . startupScan ( ) ) ;
45+
46+ // ── 2. Event-driven watcher via VS Code API ──────────────────────────────
47+ // RelativePattern with a Uri base works outside the workspace root.
48+ // VS Code handles recursive subdirectory watching on all platforms.
49+ const pattern = new vscode . RelativePattern (
50+ vscode . Uri . file ( this . codexSessionsDir ) ,
51+ '**/*.jsonl'
52+ ) ;
53+ this . fsWatcher = vscode . workspace . createFileSystemWatcher ( pattern ) ;
5654
57- // Start polling; first real tick picks up only files changed since seed.
58- this . pollTimer = setInterval ( ( ) => this . poll ( ) , POLL_INTERVAL_MS ) ;
55+ this . fsWatcher . onDidCreate ( ( uri ) => this . scheduleExport ( uri . fsPath ) ) ;
56+ this . fsWatcher . onDidChange ( ( uri ) => this . scheduleExport ( uri . fsPath ) ) ;
57+ // onDidDelete: nothing to do — we leave the .md in place as history
5958
6059 console . log (
61- `[codex-watcher] Seeded ${ this . exported . size } existing sessions. ` +
62- `Polling every ${ POLL_INTERVAL_MS / 1000 } s → ${ this . workspaceRoot } /.codex-history/`
60+ `[codex-watcher] Watching ${ this . codexSessionsDir } (event-driven) → ${ this . workspaceRoot } /.codex-history/`
6361 ) ;
6462 }
6563
66- private poll ( ) : void {
67- if ( this . disposed || this . isPolling ) return ;
64+ /** One-time catch-up: export any session not yet in .codex-history/ */
65+ private startupScan ( ) : void {
66+ if ( this . disposed ) return ;
6867
6968 const cfg = vscode . workspace . getConfiguration ( 'claudeCodeExporter' ) ;
7069 if ( ! cfg . get < boolean > ( 'autoExport' ) ) return ;
7170
72- this . isPolling = true ;
71+ const outDir = path . join ( this . workspaceRoot , '.codex-history' ) ;
72+ const existingMds = new Set < string > ( ) ;
73+ if ( fs . existsSync ( outDir ) ) {
74+ for ( const f of fs . readdirSync ( outDir ) ) {
75+ existingMds . add ( f ) ;
76+ }
77+ }
78+
79+ let files : string [ ] ;
7380 try {
74- let files : string [ ] ;
81+ files = scanCodexSessions ( this . codexSessionsDir ) ;
82+ } catch ( err ) {
83+ console . error ( '[codex-watcher] Startup scan failed:' , err ) ;
84+ return ;
85+ }
86+
87+ let exported = 0 ;
88+ for ( const filePath of files ) {
89+ if ( this . disposed ) break ;
90+ // Skip if a markdown with this session's shortId already exists
91+ const basename = path . basename ( filePath , '.jsonl' ) ;
92+ // shortId: last UUID segment from rollout-DATE-UUID → first 8 hex chars of UUID
93+ const uuidMatch = basename . match ( / ( [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } ) $ / i) ;
94+ if ( uuidMatch ) {
95+ const shortId = uuidMatch [ 1 ] . replace ( / - / g, '' ) . slice ( 0 , 8 ) ;
96+ if ( [ ...existingMds ] . some ( ( md ) => md . includes ( shortId ) ) ) continue ;
97+ }
98+
99+ try {
100+ this . exporter . exportSessionToWorkspace ( filePath , this . workspaceRoot ) ;
101+ exported ++ ;
102+ } catch { /* skip unreadable */ }
103+ }
104+
105+ if ( exported > 0 ) {
106+ console . log ( `[codex-watcher] Startup scan exported ${ exported } missing session(s).` ) ;
107+ }
108+ }
109+
110+ /** Debounced export: wait 1s after last write before exporting */
111+ private scheduleExport ( filePath : string ) : void {
112+ if ( this . disposed ) return ;
113+
114+ const existing = this . debounce . get ( filePath ) ;
115+ if ( existing ) clearTimeout ( existing ) ;
116+
117+ const timer = setTimeout ( ( ) => {
118+ this . debounce . delete ( filePath ) ;
119+ if ( this . disposed ) return ;
120+
121+ const cfg = vscode . workspace . getConfiguration ( 'claudeCodeExporter' ) ;
122+ if ( ! cfg . get < boolean > ( 'autoExport' ) ) return ;
123+
75124 try {
76- files = scanCodexSessions ( this . codexSessionsDir ) ;
125+ const outPath = this . exporter . exportSessionToWorkspace ( filePath , this . workspaceRoot ) ;
126+ console . log ( `[codex-watcher] Exported → ${ outPath } ` ) ;
77127 } catch ( err ) {
78- console . error ( '[codex-watcher] Scan failed:' , err ) ;
79- return ;
128+ console . error ( `[codex-watcher] Export failed: ${ filePath } ` , err ) ;
80129 }
130+ } , 1000 ) ;
81131
82- for ( const filePath of files ) {
83- if ( this . disposed ) break ;
84-
85- let mtime : number ;
86- try {
87- mtime = fs . statSync ( filePath ) . mtimeMs ;
88- } catch {
89- continue ;
90- }
91-
92- const lastExported = this . exported . get ( filePath ) ?? 0 ;
93- if ( mtime <= lastExported ) continue ;
94-
95- try {
96- const outPath = this . exporter . exportSessionToWorkspace ( filePath , this . workspaceRoot ) ;
97- this . exported . set ( filePath , mtime ) ;
98- console . log ( `[codex-watcher] Auto-exported → ${ outPath } ` ) ;
99- } catch ( err ) {
100- console . error ( `[codex-watcher] Export failed: ${ filePath } ` , err ) ;
101- }
102- }
103- } finally {
104- this . isPolling = false ;
105- }
132+ this . debounce . set ( filePath , timer ) ;
106133 }
107134
108135 dispose ( ) : void {
109136 this . disposed = true ;
110- if ( this . pollTimer ) {
111- clearInterval ( this . pollTimer ) ;
112- this . pollTimer = undefined ;
113- }
137+ this . fsWatcher ?. dispose ( ) ;
138+ for ( const t of this . debounce . values ( ) ) clearTimeout ( t ) ;
139+ this . debounce . clear ( ) ;
114140 }
115141}
0 commit comments