Skip to content

Commit 4a0bb66

Browse files
Myoontyeeclaude
andcommitted
fix: replace polling timer with event-driven vscode.createFileSystemWatcher (v0.8.9)
Zero timers after startup. Uses VS Code's own file watcher API which fires OS-level notifications (ReadDirectoryChangesW on Windows) — no CPU cost when idle, handles recursive subdirectories reliably. Startup scan exports only sessions missing from .codex-history/ (de-duped by shortId). Write events debounced 1s per file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4c4739d commit 4a0bb66

File tree

2 files changed

+92
-66
lines changed

2 files changed

+92
-66
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "claude-code-exporter",
33
"displayName": "Claude Code Exporter — Claude Code, Codex & Cursor Conversation History",
44
"description": "Export AI coding agent conversations to Markdown. Supports Claude Code (→ .claude-code-history/), OpenAI Codex (→ .codex-history/), and Cursor Composer (→ .cursor-history/). Features: auto-watch, session inject/import for claude --resume, and repair of thinking-block signature errors when switching API providers.",
5-
"version": "0.8.8",
5+
"version": "0.8.9",
66
"publisher": "myoontyee",
77
"engines": {
88
"vscode": "^1.85.0"

src/codexWatcher.ts

Lines changed: 91 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
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

1514
import * as fs from 'fs';
@@ -19,15 +18,12 @@ import * as vscode from 'vscode';
1918
import { CodexExporter } from './codexExporter';
2019
import { scanCodexSessions } from './codexParser';
2120

22-
const POLL_INTERVAL_MS = 30_000;
23-
2421
export 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-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-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

Comments
 (0)