/** * 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 fs.watch with { recursive: true } which is reliable on Windows (ReadDirectoryChangesW), * macOS (FSEvents) and Linux (inotify). Same strategy as git-handler.ts. * File size tracking is used to filter out duplicate/spurious events. * * Broadcasts enriched events including new JSONL content and session metadata, * so clients can update incrementally without additional HTTP requests. */ import { watch, existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync, type FSWatcher } from 'fs' import { join, basename } from 'path' import { homedir } from 'os' const AGENT_NAMES = ['ejecutor', 'nucleo000', 'claude'] const DEBOUNCE_MS = 150 // Per-file debounce — balances responsiveness and batching const MAX_WS_DELTA_BYTES = 256 * 1024 // Skip pushing content > 256KB via WS // Per-agent state interface AgentWatcherState { projectDir: string watcher: FSWatcher | null fileSizeCache: Map debounceTimers: Map> } const agentStates = new Map() // Cache firstUserMessage per session — never changes after first extraction const firstMessageCache = new Map() // 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 extractFirstUserMessage(filePath: string): string { // Read only first 8KB — enough for session header + first user message try { const fd = openSync(filePath, 'r') const buf = Buffer.alloc(8192) const bytesRead = readSync(fd, buf, 0, 8192, 0) closeSync(fd) const content = buf.toString('utf-8', 0, bytesRead) const lines = content.split('\n') for (const line of lines) { if (!line.trim()) continue try { const obj = JSON.parse(line) if (obj.type === 'user' && obj.message) { const c = obj.message.content if (typeof c === 'string') return c.slice(0, 120) if (Array.isArray(c)) { const textBlock = c.find((b: any) => b.type === 'text' && b.text?.trim()) if (textBlock) return textBlock.text.slice(0, 120) } } } catch {} } } catch {} return '' } function getFirstUserMessage(agent: string, sessionId: string, filePath: string): string { const key = `${agent}:${sessionId}` const cached = firstMessageCache.get(key) if (cached) return cached const msg = extractFirstUserMessage(filePath) if (msg) firstMessageCache.set(key, msg) return msg } function readNewBytes(filePath: string, offset: number, size: number): string { const length = size - offset if (length <= 0) return '' try { const fd = openSync(filePath, 'r') const buf = Buffer.alloc(length) readSync(fd, buf, 0, length, offset) closeSync(fd) return buf.toString('utf-8') } catch { return '' } } function emitChange(agent: string, filename: string, state: AgentWatcherState, broadcast: (message: string) => void) { const sessionId = filename.replace('.jsonl', '') const filePath = join(state.projectDir, filename) // Per-file debounce so rapid writes to the same file coalesce const existing = state.debounceTimers.get(filename) if (existing) clearTimeout(existing) state.debounceTimers.set(filename, setTimeout(() => { state.debounceTimers.delete(filename) // Check actual size change to filter spurious events let size = 0 let mtime = 0 try { const stat = statSync(filePath) size = stat.size mtime = stat.mtimeMs } catch { return // File may have been deleted } const prevSize = state.fileSizeCache.get(filename) || 0 if (size === prevSize) return // No actual change // Read new bytes (only the delta) — skip if too large const deltaSize = size - prevSize let newContent = '' if (deltaSize > 0 && deltaSize <= MAX_WS_DELTA_BYTES) { newContent = readNewBytes(filePath, prevSize, size) } // Get or cache firstUserMessage const firstUserMessage = getFirstUserMessage(agent, sessionId, filePath) state.fileSizeCache.set(filename, size) console.log(`[TranscriptDebug:${agent}] Change: ${filename} (${prevSize} → ${size} bytes, +${deltaSize})`) broadcast(JSON.stringify({ type: 'transcript-debug-change', sessionId, agent, filename, size, prevSize, mtime, firstUserMessage, newContent, // empty string if delta was too large 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(), debounceTimers: new Map() } // 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 with recursive: true — reliable on Windows, macOS, and Linux try { state.watcher = watch(projectDir, { recursive: true }, (_, filename) => { if (!filename) return // recursive may report subdirectory paths — extract just the filename const base = basename(filename) if (!base.endsWith('.jsonl')) return emitChange(agent, base, state, broadcast) }) console.log(`[TranscriptDebug] Watching ${agent}: ${projectDir} (recursive)`) } 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') } } export function cleanupTranscriptDebugWatcher() { for (const [, state] of agentStates) { if (state.watcher) { state.watcher.close() } for (const [, timer] of state.debounceTimers) { clearTimeout(timer) } } agentStates.clear() }