- Transcript debug: JSONL viewer, parsed chat view, realtime WebSocket updates, session selector - Multi-agent: ejecutor, nucleo000, and claude (global ~/.claude/projects/) with agent switcher - Hooks approval: permission/plan request forwarding via PowerShell hooks, long-poll API, UI modals - Chat features: session ID copy, select mode with checkboxes, multi-select copy, select all/deselect all - File watchers for all agent transcript directories with polling fallback on Windows
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
/**
|
|
* 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<string, number>
|
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
}
|
|
|
|
const agentStates = new Map<string, AgentWatcherState>()
|
|
let pollTimer: ReturnType<typeof setInterval> | 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
|
|
}
|
|
}
|