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
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
import { PORT_GIT, WORKING_DIR } from '../config'
|
||||
import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
|
||||
import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler'
|
||||
import { setupTranscriptDebugWatcher, cleanupTranscriptDebugWatcher } from './handlers/transcript-debug-handler'
|
||||
import { setTranscriptDebugBroadcast } from '../routes/transcript-debug'
|
||||
import { setHooksApprovalBroadcast } from '../routes/hooks-approval'
|
||||
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
|
||||
|
||||
// Connected clients
|
||||
@@ -31,13 +34,13 @@ export function getClients() {
|
||||
export function startSyncServer() {
|
||||
const server = Bun.serve({
|
||||
port: PORT_GIT,
|
||||
fetch(req, server) {
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// CORS headers
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
}
|
||||
|
||||
@@ -45,6 +48,18 @@ export function startSyncServer() {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Broadcast endpoint (used by API server cross-process)
|
||||
if (url.pathname === '/broadcast' && req.method === 'POST') {
|
||||
try {
|
||||
const message = await req.text()
|
||||
console.log(`[Sync] /broadcast received (${message.length} bytes) → ${clients.size} clients`)
|
||||
broadcast(message)
|
||||
return Response.json({ ok: true, clients: clients.size }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
const torchStatus = getTorchStatus()
|
||||
@@ -100,6 +115,11 @@ export function startSyncServer() {
|
||||
// Start file watchers
|
||||
setupGitWatcher(WORKING_DIR, broadcast)
|
||||
setupComponentsWatcher(WORKING_DIR, broadcast)
|
||||
setupTranscriptDebugWatcher(WORKING_DIR, broadcast)
|
||||
|
||||
// Give the route handler access to broadcast for process-complete notifications
|
||||
setTranscriptDebugBroadcast(broadcast)
|
||||
setHooksApprovalBroadcast(broadcast)
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -107,6 +127,7 @@ export function startSyncServer() {
|
||||
export function stopSyncServer() {
|
||||
cleanupGitWatcher()
|
||||
cleanupComponentsWatcher()
|
||||
cleanupTranscriptDebugWatcher()
|
||||
cleanupTorchHandler()
|
||||
clients.clear()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user