feat: Add transcript-debug page with multi-agent support, hooks approval, and message selection
- 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
This commit is contained in:
160
server/services/handlers/transcript-debug-handler.ts
Normal file
160
server/services/handlers/transcript-debug-handler.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user