- 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
222 lines
6.8 KiB
TypeScript
222 lines
6.8 KiB
TypeScript
import { jsonResponse, errorResponse, corsHeaders } from '../utils/cors'
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
import { spawn } from 'child_process'
|
|
import { WORKING_DIR } from '../config'
|
|
|
|
// Agent transcript directories
|
|
const AGENT_DIRS: Record<string, string> = {
|
|
ejecutor: join(WORKING_DIR, '.claude-ejecutor', 'projects'),
|
|
nucleo000: join(WORKING_DIR, '.claude-nucleo000', 'projects'),
|
|
claude: join(homedir(), '.claude', 'projects')
|
|
}
|
|
|
|
// Agent CLI commands (these .cmd wrappers handle CLAUDE_CONFIG_DIR + cd)
|
|
const AGENT_COMMANDS: Record<string, string> = {
|
|
ejecutor: 'ejecutor',
|
|
nucleo000: 'nucleo000',
|
|
claude: 'claude'
|
|
}
|
|
|
|
// Track running processes per session to prevent concurrent sends
|
|
const runningProcesses = new Map<string, { pid: number; kill: () => void }>()
|
|
|
|
// Broadcast callback — set by sync-server to push WebSocket messages
|
|
let broadcastFn: ((msg: string) => void) | null = null
|
|
|
|
export function setTranscriptDebugBroadcast(fn: (msg: string) => void) {
|
|
broadcastFn = fn
|
|
}
|
|
|
|
// Project hash for this project
|
|
const PROJECT_HASH = 'C--Users-jodar-agent-ui'
|
|
|
|
function getProjectDir(agent: string): string | null {
|
|
const baseDir = AGENT_DIRS[agent]
|
|
if (!baseDir || !existsSync(baseDir)) return null
|
|
|
|
// Try exact project hash first
|
|
const exact = join(baseDir, PROJECT_HASH)
|
|
if (existsSync(exact)) return exact
|
|
|
|
// Fallback: first directory
|
|
const dirs = readdirSync(baseDir)
|
|
return dirs.length > 0 ? join(baseDir, dirs[0]) : null
|
|
}
|
|
|
|
function extractFirstUserMessage(filePath: string): string {
|
|
try {
|
|
const content = readFileSync(filePath, 'utf-8')
|
|
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 ''
|
|
}
|
|
|
|
export function handleTranscriptDebugSessions(url: URL): Response {
|
|
const agent = url.searchParams.get('agent') || 'ejecutor'
|
|
const projectDir = getProjectDir(agent)
|
|
if (!projectDir || !existsSync(projectDir)) {
|
|
return jsonResponse([])
|
|
}
|
|
|
|
const files = readdirSync(projectDir)
|
|
.filter(f => f.endsWith('.jsonl'))
|
|
.map(f => {
|
|
const fullPath = join(projectDir, f)
|
|
const stat = statSync(fullPath)
|
|
return {
|
|
id: f.replace('.jsonl', ''),
|
|
filename: f,
|
|
size: stat.size,
|
|
mtime: stat.mtimeMs,
|
|
mtimeISO: stat.mtime.toISOString(),
|
|
firstUserMessage: extractFirstUserMessage(fullPath)
|
|
}
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime)
|
|
|
|
return jsonResponse(files)
|
|
}
|
|
|
|
export function handleTranscriptDebugRaw(sessionId: string, url: URL): Response {
|
|
const agent = url.searchParams.get('agent') || 'ejecutor'
|
|
const projectDir = getProjectDir(agent)
|
|
if (!projectDir) {
|
|
return errorResponse(`No project directory found for agent: ${agent}`, 404)
|
|
}
|
|
|
|
const filePath = join(projectDir, `${sessionId}.jsonl`)
|
|
if (!existsSync(filePath)) {
|
|
return errorResponse(`Session ${sessionId} not found`, 404)
|
|
}
|
|
|
|
const content = readFileSync(filePath, 'utf-8')
|
|
return new Response(content, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
...corsHeaders
|
|
}
|
|
})
|
|
}
|
|
|
|
export function handleTranscriptDebugStatus(url: URL): Response {
|
|
const sessionId = url.searchParams.get('sessionId')
|
|
if (!sessionId) return jsonResponse({ processing: false })
|
|
return jsonResponse({ processing: runningProcesses.has(sessionId) })
|
|
}
|
|
|
|
export async function handleTranscriptDebugSend(req: Request): Promise<Response> {
|
|
if (req.method !== 'POST') {
|
|
return errorResponse('Method not allowed', 405)
|
|
}
|
|
|
|
let body: { agent?: string; sessionId?: string; prompt?: string }
|
|
try {
|
|
body = await req.json()
|
|
} catch {
|
|
return errorResponse('Invalid JSON body')
|
|
}
|
|
|
|
const { agent = 'ejecutor', sessionId, prompt } = body
|
|
|
|
if (!sessionId) return errorResponse('sessionId is required')
|
|
if (!prompt?.trim()) return errorResponse('prompt is required')
|
|
if (!AGENT_COMMANDS[agent]) return errorResponse(`Unknown agent: ${agent}`)
|
|
|
|
// Verify session file exists
|
|
const projectDir = getProjectDir(agent)
|
|
if (!projectDir) return errorResponse(`No project directory for agent: ${agent}`, 404)
|
|
|
|
const sessionFile = join(projectDir, `${sessionId}.jsonl`)
|
|
if (!existsSync(sessionFile)) {
|
|
return errorResponse(`Session ${sessionId} not found`, 404)
|
|
}
|
|
|
|
// Prevent concurrent sends to the same session
|
|
if (runningProcesses.has(sessionId)) {
|
|
return errorResponse('A prompt is already being processed for this session', 409)
|
|
}
|
|
|
|
// Use the agent .cmd wrapper directly (handles CLAUDE_CONFIG_DIR + cd)
|
|
// Escape double quotes for cmd.exe and pass as single command string
|
|
const agentCmd = AGENT_COMMANDS[agent]
|
|
const escaped = prompt.replace(/"/g, '""')
|
|
const cmd = `${agentCmd} --resume "${sessionId}" --permission-mode default -p "${escaped}"`
|
|
|
|
const env = { ...process.env }
|
|
delete env.CLAUDECODE
|
|
|
|
console.log(`[TranscriptDebug] Spawning: ${agentCmd} --resume ${sessionId.slice(0, 8)}...`)
|
|
|
|
const child = spawn(cmd, {
|
|
cwd: WORKING_DIR,
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
shell: true
|
|
} as any)
|
|
|
|
// Track the running process
|
|
runningProcesses.set(sessionId, {
|
|
pid: child.pid || 0,
|
|
kill: () => child.kill()
|
|
})
|
|
|
|
let stderr = ''
|
|
child.stdout?.on('data', (data: Buffer) => {
|
|
console.log(`[TranscriptDebug] stdout: ${data.toString().slice(0, 200)}`)
|
|
})
|
|
child.stderr?.on('data', (data: Buffer) => { stderr += data.toString() })
|
|
|
|
child.on('close', (code) => {
|
|
runningProcesses.delete(sessionId)
|
|
if (code !== 0) {
|
|
console.error(`[TranscriptDebug] claude exited with code ${code}`)
|
|
if (stderr) console.error(`[TranscriptDebug] stderr: ${stderr.slice(0, 500)}`)
|
|
} else {
|
|
console.log(`[TranscriptDebug] claude completed for session ${sessionId.slice(0, 8)}...`)
|
|
}
|
|
// Notify frontend via WebSocket
|
|
if (broadcastFn) {
|
|
broadcastFn(JSON.stringify({
|
|
type: 'transcript-debug-done',
|
|
sessionId,
|
|
exitCode: code
|
|
}))
|
|
}
|
|
})
|
|
|
|
child.on('error', (err) => {
|
|
runningProcesses.delete(sessionId)
|
|
console.error(`[TranscriptDebug] Failed to spawn claude:`, err.message)
|
|
if (broadcastFn) {
|
|
broadcastFn(JSON.stringify({
|
|
type: 'transcript-debug-done',
|
|
sessionId,
|
|
error: err.message
|
|
}))
|
|
}
|
|
})
|
|
|
|
return jsonResponse({
|
|
success: true,
|
|
pid: child.pid,
|
|
message: `Prompt sent to ${agent} session ${sessionId.slice(0, 8)}...`
|
|
})
|
|
}
|