/** * Transcript Debug Handler * Watches all agent project dirs (.claude-ejecutor/projects/, .claude-nucleo000/projects/) * for JSONL file changes and broadcasts notifications via the sync server. * * Uses a polling approach on Windows (fs.watch is unreliable for appends) * combined with fs.watch for immediate detection. */ import { watch, existsSync, readdirSync, statSync, type FSWatcher } from 'fs' import { join } from 'path' import { homedir } from 'os' const AGENT_NAMES = ['ejecutor', 'nucleo000', 'claude'] const DEBOUNCE_MS = 25 const POLL_INTERVAL_MS = 1500 // Per-agent state interface AgentWatcherState { projectDir: string watcher: FSWatcher | null fileSizeCache: Map debounceTimer: ReturnType | null } const agentStates = new Map() let pollTimer: ReturnType | null = null // Project hash matching the working dir const PROJECT_HASH = 'C--Users-jodar-agent-ui' function findProjectDir(workingDir: string, agent: string): string | null { const agentDir = agent === 'claude' ? join(homedir(), '.claude', 'projects') : join(workingDir, `.claude-${agent}`, 'projects') if (!existsSync(agentDir)) return null // For claude global dir, go straight to the project hash if (agent === 'claude') { const exact = join(agentDir, PROJECT_HASH) return existsSync(exact) ? exact : null } const dirs = readdirSync(agentDir) return dirs.length > 0 ? join(agentDir, dirs[0]) : null } function emitChange(agent: string, sessionId: string, filename: string, projectDir: string, broadcast: (message: string) => void) { const state = agentStates.get(agent) if (!state) return if (state.debounceTimer) clearTimeout(state.debounceTimer) state.debounceTimer = setTimeout(() => { const filePath = join(projectDir, filename) let size = 0 try { size = statSync(filePath).size } catch {} console.log(`[TranscriptDebug:${agent}] Change: ${filename} (${size} bytes)`) broadcast(JSON.stringify({ type: 'transcript-debug-change', sessionId, agent, filename, size, timestamp: Date.now() })) }, DEBOUNCE_MS) } export function setupTranscriptDebugWatcher(workingDir: string, broadcast: (message: string) => void) { let anyWatched = false for (const agent of AGENT_NAMES) { const projectDir = findProjectDir(workingDir, agent) if (!projectDir) continue const state: AgentWatcherState = { projectDir, watcher: null, fileSizeCache: new Map(), debounceTimer: null } // Initialize file size cache try { const files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl')) for (const f of files) { try { state.fileSizeCache.set(f, statSync(join(projectDir, f)).size) } catch {} } } catch {} // fs.watch for immediate detection try { state.watcher = watch(projectDir, { recursive: false }, (_, filename) => { if (!filename) return if (!filename.endsWith('.jsonl')) return const sessionId = filename.replace('.jsonl', '') emitChange(agent, sessionId, filename, projectDir, broadcast) }) console.log(`[TranscriptDebug] Watching ${agent}: ${projectDir}`) } catch (e: any) { console.error(`[TranscriptDebug] Watch failed for ${agent}: ${e.message}`) } agentStates.set(agent, state) anyWatched = true } if (!anyWatched) { console.log('[TranscriptDebug] No agent project directories found, skipping watcher') return } // Shared polling fallback for all agents pollTimer = setInterval(() => { for (const [agent, state] of agentStates) { try { const files = readdirSync(state.projectDir).filter(f => f.endsWith('.jsonl')) for (const f of files) { try { const size = statSync(join(state.projectDir, f)).size const prevSize = state.fileSizeCache.get(f) || 0 if (size !== prevSize) { state.fileSizeCache.set(f, size) const sessionId = f.replace('.jsonl', '') emitChange(agent, sessionId, f, state.projectDir, broadcast) } } catch {} } // Detect new files for (const f of files) { if (!state.fileSizeCache.has(f)) { state.fileSizeCache.set(f, 0) } } } catch {} } }, POLL_INTERVAL_MS) } export function cleanupTranscriptDebugWatcher() { for (const [, state] of agentStates) { if (state.watcher) { state.watcher.close() } if (state.debounceTimer) { clearTimeout(state.debounceTimer) } } agentStates.clear() if (pollTimer) { clearInterval(pollTimer) pollTimer = null } }