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:
330
server/routes/hooks-approval.ts
Normal file
330
server/routes/hooks-approval.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { PORT_GIT } from '../config'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface PermissionPayload {
|
||||
hook_event_name?: string
|
||||
session_id?: string
|
||||
tool_name?: string
|
||||
tool_input?: unknown
|
||||
agent_name?: string
|
||||
cwd?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface StopPayload {
|
||||
hook_event_name?: string
|
||||
session_id?: string
|
||||
permission_mode?: string
|
||||
stop_hook_active?: boolean
|
||||
transcript_messages?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface PendingRequest<T = unknown> {
|
||||
resolve: (result: T | null) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
payload: Record<string, unknown>
|
||||
type: 'permission' | 'plan'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
const TIMEOUT_MS = 120_000
|
||||
|
||||
// ── Broadcast ──
|
||||
// API server (4101) and sync server (4105) are separate Bun processes.
|
||||
// broadcastFn works when called from the sync server process (same module instance).
|
||||
// From the API server process, we fall back to HTTP POST → sync server /broadcast endpoint.
|
||||
|
||||
let broadcastFn: ((message: string) => void) | null = null
|
||||
|
||||
export function setHooksApprovalBroadcast(fn: (message: string) => void) {
|
||||
broadcastFn = fn
|
||||
console.log('[HooksApproval] broadcastFn SET (same process)')
|
||||
}
|
||||
|
||||
function broadcastMessage(message: string) {
|
||||
if (broadcastFn) {
|
||||
console.log('[HooksApproval] Broadcasting via direct fn')
|
||||
broadcastFn(message)
|
||||
return
|
||||
}
|
||||
// Cross-process fallback: POST to sync server /broadcast endpoint
|
||||
console.log('[HooksApproval] Broadcasting via HTTP to sync server')
|
||||
fetch(`http://localhost:${PORT_GIT}/broadcast`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: message
|
||||
}).then(() => {
|
||||
console.log('[HooksApproval] Broadcast via /broadcast OK')
|
||||
}).catch(e => {
|
||||
console.error('[HooksApproval] Broadcast failed:', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
// ── Permission (PreToolUse hook) ──
|
||||
|
||||
export async function handleHooksApprovalPermission(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as PermissionPayload
|
||||
const requestId = generateId('haperm')
|
||||
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id}`)
|
||||
|
||||
// Broadcast to UI
|
||||
broadcastMessage(JSON.stringify({
|
||||
type: 'hooks-approval-permission',
|
||||
requestId,
|
||||
tool_name: body.tool_name,
|
||||
tool_input: body.tool_input,
|
||||
agent_name: body.agent_name,
|
||||
session_id: body.session_id,
|
||||
cwd: body.cwd,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// Long-poll: wait for UI decision or timeout
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
resolve(null)
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
timer,
|
||||
payload: body as Record<string, unknown>,
|
||||
type: 'permission',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
// null = timeout, let Claude Code handle normally
|
||||
if (result === null) {
|
||||
console.log(`[HooksApproval] Permission ${requestId} timed out`)
|
||||
return jsonResponse({})
|
||||
}
|
||||
|
||||
console.log(`[HooksApproval] Permission ${requestId} resolved:`, JSON.stringify(result))
|
||||
return jsonResponse(result)
|
||||
} catch (e) {
|
||||
console.error(`[HooksApproval] Permission error:`, e)
|
||||
return errorResponse('Invalid JSON body', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan (Stop hook) ──
|
||||
|
||||
export async function handleHooksApprovalPlan(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as StopPayload
|
||||
const requestId = generateId('haplan')
|
||||
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode}`)
|
||||
|
||||
// Extract last assistant message for display
|
||||
let lastAssistantText = ''
|
||||
if (Array.isArray(body.transcript_messages)) {
|
||||
for (let i = body.transcript_messages.length - 1; i >= 0; i--) {
|
||||
const msg = body.transcript_messages[i] as any
|
||||
if (msg?.role === 'assistant') {
|
||||
if (typeof msg.content === 'string') {
|
||||
lastAssistantText = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
lastAssistantText = msg.content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to UI
|
||||
broadcastMessage(JSON.stringify({
|
||||
type: 'hooks-approval-plan',
|
||||
requestId,
|
||||
session_id: body.session_id,
|
||||
permission_mode: body.permission_mode,
|
||||
lastAssistantText,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// Long-poll
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
resolve(null)
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
timer,
|
||||
payload: body as Record<string, unknown>,
|
||||
type: 'plan',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
if (result === null) {
|
||||
console.log(`[HooksApproval] Plan ${requestId} timed out`)
|
||||
return jsonResponse({})
|
||||
}
|
||||
|
||||
console.log(`[HooksApproval] Plan ${requestId} resolved:`, JSON.stringify(result))
|
||||
return jsonResponse(result)
|
||||
} catch (e) {
|
||||
console.error(`[HooksApproval] Plan error:`, e)
|
||||
return errorResponse('Invalid JSON body', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Respond to permission ──
|
||||
|
||||
export async function handleHooksApprovalRespond(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as { requestId: string; decision: 'allow' | 'deny' }
|
||||
|
||||
if (!body.requestId || !body.decision) {
|
||||
return errorResponse('Missing requestId or decision', 400)
|
||||
}
|
||||
|
||||
if (!['allow', 'deny'].includes(body.decision)) {
|
||||
return errorResponse('Decision must be "allow" or "deny"', 400)
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(body.requestId)
|
||||
if (!pending || pending.type !== 'permission') {
|
||||
console.warn(`[HooksApproval] Respond permission: ${body.requestId} not found (expired?)`)
|
||||
return errorResponse('Permission request not found or expired', 404)
|
||||
}
|
||||
|
||||
console.log(`[HooksApproval] Responding permission ${body.requestId}: ${body.decision}`)
|
||||
clearTimeout(pending.timer)
|
||||
pendingRequests.delete(body.requestId)
|
||||
|
||||
// Build hookSpecificOutput per PermissionRequest docs:
|
||||
// hookSpecificOutput.decision.behavior = "allow" | "deny"
|
||||
const hookOutput: Record<string, unknown> = body.decision === 'allow'
|
||||
? {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PermissionRequest',
|
||||
decision: {
|
||||
behavior: 'allow'
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PermissionRequest',
|
||||
decision: {
|
||||
behavior: 'deny',
|
||||
message: 'Denied via UI'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pending.resolve(hookOutput)
|
||||
|
||||
return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision })
|
||||
} catch (e) {
|
||||
return errorResponse('Invalid JSON body', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Respond to plan ──
|
||||
|
||||
export async function handleHooksApprovalRespondPlan(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as { requestId: string; decision: 'approve' | 'reject' | 'edit'; reason?: string }
|
||||
|
||||
if (!body.requestId || !body.decision) {
|
||||
return errorResponse('Missing requestId or decision', 400)
|
||||
}
|
||||
|
||||
if (!['approve', 'reject', 'edit'].includes(body.decision)) {
|
||||
return errorResponse('Decision must be "approve", "reject", or "edit"', 400)
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(body.requestId)
|
||||
if (!pending || pending.type !== 'plan') {
|
||||
console.warn(`[HooksApproval] Respond plan: ${body.requestId} not found (expired?)`)
|
||||
return errorResponse('Plan request not found or expired', 404)
|
||||
}
|
||||
|
||||
console.log(`[HooksApproval] Responding plan ${body.requestId}: ${body.decision}`)
|
||||
clearTimeout(pending.timer)
|
||||
pendingRequests.delete(body.requestId)
|
||||
|
||||
// Build Stop hook output per docs:
|
||||
// "approve" plan = "block" stop (so Claude continues to implement)
|
||||
// "reject" plan = let Claude stop (empty response)
|
||||
// "edit" = "block" stop with user instructions as reason
|
||||
let stopResult: Record<string, unknown>
|
||||
|
||||
if (body.decision === 'approve') {
|
||||
stopResult = { decision: 'block', reason: 'Plan approved via UI. Proceed with implementation.' }
|
||||
} else if (body.decision === 'reject') {
|
||||
// Let Claude stop — empty response means no blocking
|
||||
stopResult = {}
|
||||
} else {
|
||||
// edit = block stop + supply user instructions
|
||||
stopResult = { decision: 'block', reason: body.reason || 'Continue with modifications.' }
|
||||
}
|
||||
|
||||
pending.resolve(stopResult)
|
||||
|
||||
return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision })
|
||||
} catch (e) {
|
||||
return errorResponse('Invalid JSON body', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// ── List pending (for state recovery) ──
|
||||
|
||||
export async function handleHooksApprovalList(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'GET') return null
|
||||
|
||||
const permissions: unknown[] = []
|
||||
const plans: unknown[] = []
|
||||
|
||||
for (const [id, p] of pendingRequests) {
|
||||
const base = {
|
||||
requestId: id,
|
||||
session_id: p.payload.session_id,
|
||||
createdAt: p.createdAt
|
||||
}
|
||||
|
||||
if (p.type === 'permission') {
|
||||
permissions.push({
|
||||
...base,
|
||||
tool_name: p.payload.tool_name,
|
||||
tool_input: p.payload.tool_input,
|
||||
agent_name: p.payload.agent_name
|
||||
})
|
||||
} else {
|
||||
plans.push({
|
||||
...base,
|
||||
permission_mode: p.payload.permission_mode,
|
||||
lastAssistantText: '' // Not stored, UI gets it from WS broadcast
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ permissions, plans })
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
||||
} from './agents'
|
||||
import { handleTranscript, handleTranscriptSessions, handleTranscriptActive, handleClaudeStats, handleClaudeUsage } from './transcript'
|
||||
import { handleTranscriptDebugSessions, handleTranscriptDebugRaw, handleTranscriptDebugSend, handleTranscriptDebugStatus } from './transcript-debug'
|
||||
import {
|
||||
handleHooksApprovalPermission, handleHooksApprovalPlan,
|
||||
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
|
||||
handleHooksApprovalList
|
||||
} from './hooks-approval'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -319,6 +325,52 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
return handleTranscript(req, url, transcriptMatch[1])
|
||||
}
|
||||
|
||||
// Transcript Debug
|
||||
if (path === '/api/transcript-debug/sessions' && req.method === 'GET') {
|
||||
return handleTranscriptDebugSessions(url)
|
||||
}
|
||||
|
||||
if (path === '/api/transcript-debug/send' && req.method === 'POST') {
|
||||
return handleTranscriptDebugSend(req)
|
||||
}
|
||||
|
||||
if (path === '/api/transcript-debug/status' && req.method === 'GET') {
|
||||
return handleTranscriptDebugStatus(url)
|
||||
}
|
||||
|
||||
const transcriptDebugRawMatch = path.match(/^\/api\/transcript-debug\/([a-f0-9-]+)\/raw$/)
|
||||
if (transcriptDebugRawMatch && req.method === 'GET') {
|
||||
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
|
||||
}
|
||||
|
||||
// Hooks Approval (long-poll for permission/plan decisions)
|
||||
if (path === '/api/hooks-approval') {
|
||||
if (req.method === 'GET') {
|
||||
const res = await handleHooksApprovalList(req)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/hooks-approval/permission' && req.method === 'POST') {
|
||||
const res = await handleHooksApprovalPermission(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
if (path === '/api/hooks-approval/plan' && req.method === 'POST') {
|
||||
const res = await handleHooksApprovalPlan(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
if (path === '/api/hooks-approval/respond' && req.method === 'POST') {
|
||||
const res = await handleHooksApprovalRespond(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
if (path === '/api/hooks-approval/respond-plan' && req.method === 'POST') {
|
||||
const res = await handleHooksApprovalRespondPlan(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Agents
|
||||
if (path === '/api/agents' && req.method === 'GET') {
|
||||
return handleAgents(req)
|
||||
|
||||
221
server/routes/transcript-debug.ts
Normal file
221
server/routes/transcript-debug.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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)}...`
|
||||
})
|
||||
}
|
||||
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