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:
2026-02-18 23:55:09 -06:00
parent d0fdd04132
commit 9bd6123f97
37 changed files with 5663 additions and 30 deletions

View 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
}
}