diff --git a/frontend/src/components/FloatingTranscriptDebug.vue b/frontend/src/components/FloatingTranscriptDebug.vue index 19f2b15..06a4324 100644 --- a/frontend/src/components/FloatingTranscriptDebug.vue +++ b/frontend/src/components/FloatingTranscriptDebug.vue @@ -40,6 +40,9 @@ const { switchAgent, selectSession, createNewSession, + startTerminal, + parkCurrentTerminal, + fetchSessionContent, switchToTerminal, closeTerminal, disconnectRealtime, @@ -468,7 +471,11 @@ async function handleModalResume(sessionId: string, agent: AgentName) { if (agent !== selectedAgent.value) { await switchAgent(agent) } - selectSession(sessionId) + // Load transcript + create terminal with --resume + parkCurrentTerminal() + selectedSessionId.value = sessionId + await fetchSessionContent(sessionId) + await startTerminal(sessionId) } // ============================================================================ diff --git a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts index 3ac78b3..05746a7 100644 --- a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts +++ b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts @@ -92,30 +92,6 @@ export function useTranscriptDebug() { // ── Server registry HTTP helpers ── - async function registerTerminalOnServer( - ephemeralSessionId: string, - transcriptSessionId: string, - agent: AgentName, - label: string, - command: string - ) { - try { - await fetch(terminalApiUrl('/register-terminal'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ephemeralSessionId, - transcriptSessionId, - agent, - label, - command, - createdAt: new Date().toISOString() - }) - }) - // Server broadcasts change → all clients pick it up via WS - } catch { /* best effort */ } - } - async function updateTerminalOnServer( ephemeralSessionId: string, updates: { transcriptSessionId?: string; label?: string } @@ -150,26 +126,44 @@ export function useTranscriptDebug() { return sessionId.slice(0, 12) + '...' } - function startTerminal(sessionId?: string) { + async function startTerminal(sessionId?: string) { const key = sessionId || '__new__' const cmd = sessionId ? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"` : AGENT_CMD[selectedAgent.value] - const term = useEphemeralTerminal(cmd) - localTerminals.set(key, term) - activeTerminalSessionId.value = key + console.log(`[TranscriptDebug] startTerminal called — key=${key} cmd=${cmd} url=${terminalApiUrl('/create-terminal')}`) - term.start() + try { + // Server creates PTY, runs command, registers in registry, broadcasts + const res = await fetch(terminalApiUrl('/create-terminal'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent: selectedAgent.value, + transcriptSessionId: key, + label: sessionId ? getSessionLabel(sessionId) : 'New session', + command: cmd + }) + }) - // Register on server - registerTerminalOnServer( - term.ephemeralSessionId, - key, - selectedAgent.value, - sessionId ? getSessionLabel(sessionId) : 'New session', - cmd - ) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })) + console.error(`[TranscriptDebug] Failed to create terminal: ${err.error}`) + return + } + + const { ephemeralSessionId } = await res.json() + + // Connect to the existing PTY in reconnect mode + const term = useEphemeralTerminal(cmd, ephemeralSessionId) + localTerminals.set(key, term) + activeTerminalSessionId.value = key + + term.start() + } catch (e: any) { + console.error('[TranscriptDebug] Terminal creation failed:', e.message) + } } function parkCurrentTerminal() { @@ -281,7 +275,7 @@ export function useTranscriptDebug() { pendingPrompt.value = initialPrompt?.trim() || null awaitingNewSession.value = true - startTerminal() // no sessionId → brand new session + await startTerminal() // no sessionId → brand new session } // ── WebSocket realtime ── @@ -949,6 +943,9 @@ export function useTranscriptDebug() { switchAgent, selectSession, createNewSession, + startTerminal, + parkCurrentTerminal, + fetchSessionContent, switchToTerminal, closeTerminal, connectRealtime, diff --git a/server/config.ts b/server/config.ts index 9e1c878..e834f45 100644 --- a/server/config.ts +++ b/server/config.ts @@ -9,6 +9,7 @@ export const SHELL = process.platform === 'win32' ? 'powershell.exe' : 'bash' export const SHELL_ARGS = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : [] export const DEFAULT_SESSION_ID = 'main' export const MAX_BUFFER_LINES = 10000 +export const MAX_TERMINALS = 5 // Database export const DB_PATH = 'agent-ui.db' diff --git a/server/services/terminal.ts b/server/services/terminal.ts index bac5de8..9034819 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -1,5 +1,5 @@ import { spawn, type IPty } from '@skitee3000/bun-pty' -import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config' +import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config' import { sessionState, type SessionStatePatch } from './session-state' interface TerminalSession { @@ -31,9 +31,12 @@ const AGENT_COMMANDS: Record = { // Store active terminal sessions by ID (persistent across reconnections) const sessions = new Map() -// Map WebSocket to sessionId +// Map WebSocket to sessionId (only for PTY-connected clients) const wsToSession = new Map() +// Broadcast-only clients (no PTY, just receive state updates) +const broadcastClients = new Set() + // ── Global terminal registry ── // Tracks metadata about transcript-debug terminals so all clients can see/connect to them @@ -60,16 +63,25 @@ function getRegistrySnapshot() { }) } -// Broadcast session state patch to ALL clients across ALL sessions -function broadcastSessionStatePatch(patch: SessionStatePatch) { - const message = JSON.stringify(patch) - let clientCount = 0 +// Send a message to ALL clients: broadcast-only + PTY-connected +function broadcastToAll(message: string): number { + let count = 0 + for (const ws of broadcastClients) { + try { ws.send(message); count++ } catch { /* skip */ } + } for (const [, session] of sessions) { for (const ws of session.clients) { - try { ws.send(message); clientCount++ } catch { /* skip */ } + try { ws.send(message); count++ } catch { /* skip */ } } } - console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${clientCount} clients`) + return count +} + +// Broadcast session state patch to ALL clients +function broadcastSessionStatePatch(patch: SessionStatePatch) { + const message = JSON.stringify(patch) + const count = broadcastToAll(message) + console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${count} clients`) } function broadcastRegistryChange() { @@ -78,13 +90,8 @@ function broadcastRegistryChange() { registry: getRegistrySnapshot(), timestamp: Date.now() }) - let clientCount = 0 - for (const [, session] of sessions) { - for (const ws of session.clients) { - try { ws.send(message); clientCount++ } catch { /* skip */ } - } - } - console.log(`[Terminal] Registry broadcast → ${clientCount} clients (${terminalRegistry.size} entries)`) + const count = broadcastToAll(message) + console.log(`[Terminal] Registry broadcast → ${count} clients (${terminalRegistry.size} entries)`) } function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession { @@ -370,6 +377,59 @@ export function startTerminalServer() { // ── Terminal Registry endpoints ── + // Create a new terminal (server-first flow: PTY + registry + broadcast) + if (url.pathname === '/create-terminal' && req.method === 'POST') { + try { + const body = await req.json() as { + agent: string + transcriptSessionId?: string + label?: string + command: string + } + + if (!body.command) { + return Response.json({ error: 'command required' }, { status: 400, headers: corsHeaders }) + } + + // Enforce terminal limit + if (terminalRegistry.size >= MAX_TERMINALS) { + console.error(`[Terminal] Cannot create terminal: limit reached (${terminalRegistry.size}/${MAX_TERMINALS})`) + return Response.json( + { error: `Terminal limit reached (max ${MAX_TERMINALS})` }, + { status: 429, headers: corsHeaders } + ) + } + + // Generate ephemeralSessionId server-side + const ephemeralSessionId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + // Create PTY session + const session = getOrCreateSession(ephemeralSessionId) + + // Write command to PTY (with delay for shell init) + setTimeout(() => { + session.pty.write(body.command + '\r') + }, 300) + + // Register in terminal registry + terminalRegistry.set(ephemeralSessionId, { + ephemeralSessionId, + transcriptSessionId: body.transcriptSessionId || '__new__', + agent: body.agent || '', + label: body.label || 'New session', + command: body.command, + createdAt: new Date().toISOString() + }) + + console.log(`[Terminal] Created terminal: ${ephemeralSessionId} → ${body.transcriptSessionId || '__new__'} (${body.agent})`) + broadcastRegistryChange() + + return Response.json({ success: true, ephemeralSessionId }, { headers: corsHeaders }) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) + } + } + // List all registered terminals (global, for all clients) if (url.pathname === '/terminal-registry' && req.method === 'GET') { return Response.json({ registry: getRegistrySnapshot() }, { headers: corsHeaders }) @@ -485,9 +545,10 @@ export function startTerminalServer() { console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) if (upgradeHeader?.toLowerCase() === 'websocket') { - const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID - const success = server.upgrade(req, { data: { sessionId } }) - console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`) + const sessionId = url.searchParams.get('session') || null + const isBroadcast = !sessionId + const success = server.upgrade(req, { data: { sessionId, broadcast: isBroadcast } }) + console.log(`[Terminal] WebSocket upgrade ${isBroadcast ? '(broadcast-only)' : `for session "${sessionId}"`}: ${success ? 'success' : 'failed'}`) if (success) { return undefined } @@ -501,29 +562,14 @@ export function startTerminalServer() { }, websocket: { open(ws) { - const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID - console.log(`[Terminal] Client connecting to session: ${sessionId}`) + const data = ws.data as any + const isBroadcast = data?.broadcast === true - try { - const session = getOrCreateSession(sessionId) - session.clients.add(ws) - wsToSession.set(ws, sessionId) + if (isBroadcast) { + // Broadcast-only client — no PTY, just receives state updates + broadcastClients.add(ws) - // Send connection info (include buffer size so client knows if replay is needed) - ws.send(JSON.stringify({ - type: 'connected', - sessionId: session.id, - isNew: session.outputBuffer.length === 0, - hasHistory: session.outputBuffer.length > 0, - bufferSize: session.outputBuffer.length - })) - - // DON'T auto-replay here! - // Client will request replay when terminal is visible and ready. - // This fixes xterm.js rendering issues with hidden containers. - console.log(`[Terminal] Client connected, buffer has ${session.outputBuffer.length} chunks (client will request replay)`) - - // Send centralized session state snapshot + // Send session state snapshot + terminal registry const snapshot = sessionState.getSnapshot() if (Object.keys(snapshot).length > 0) { ws.send(JSON.stringify({ @@ -532,6 +578,34 @@ export function startTerminalServer() { })) } + // Send current terminal registry + ws.send(JSON.stringify({ + type: 'terminal-registry-change', + registry: getRegistrySnapshot(), + timestamp: Date.now() + })) + + console.log(`[Terminal] Broadcast client connected (${broadcastClients.size} broadcast clients)`) + return + } + + // PTY client — connect to an existing session + const sessionId = data?.sessionId || DEFAULT_SESSION_ID + console.log(`[Terminal] Client connecting to session: ${sessionId}`) + + try { + const session = getOrCreateSession(sessionId) + session.clients.add(ws) + wsToSession.set(ws, sessionId) + + ws.send(JSON.stringify({ + type: 'connected', + sessionId: session.id, + isNew: session.outputBuffer.length === 0, + hasHistory: session.outputBuffer.length > 0, + bufferSize: session.outputBuffer.length + })) + console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`) } catch (e: any) { console.error('[Terminal] Error:', e) @@ -539,6 +613,9 @@ export function startTerminalServer() { } }, message(ws, message) { + // Broadcast-only clients don't have a PTY — ignore their messages + if (broadcastClients.has(ws)) return + try { const msg = JSON.parse(message as string) const sessionId = wsToSession.get(ws) @@ -589,13 +666,19 @@ export function startTerminalServer() { } }, close(ws) { + // Check if this was a broadcast-only client + if (broadcastClients.delete(ws)) { + console.log(`[Terminal] Broadcast client disconnected (${broadcastClients.size} remaining)`) + return + } + + // PTY client cleanup const sessionId = wsToSession.get(ws) if (sessionId) { const session = sessions.get(sessionId) if (session) { session.clients.delete(ws) console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`) - // Don't kill PTY - session persists } wsToSession.delete(ws) } @@ -637,18 +720,7 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent timestamp: Date.now() }) - let clientCount = 0 - for (const [, session] of sessions) { - for (const ws of session.clients) { - try { - ws.send(message) - clientCount++ - } catch { - // Client disconnected, ignore - } - } - } - + const clientCount = broadcastToAll(message) console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`) // Note: session state is updated via broadcastClaudeHook which has full payload context. @@ -669,18 +741,7 @@ export function broadcastClaudeHook(data: Record) { timestamp: Date.now() }) - let clientCount = 0 - for (const [, session] of sessions) { - for (const ws of session.clients) { - try { - ws.send(message) - clientCount++ - } catch { - // Client disconnected, ignore - } - } - } - + const clientCount = broadcastToAll(message) console.log(`[Terminal] Claude hook broadcast: ${data.hook_event_name || 'unknown'}${data.tool_name ? ` (${data.tool_name})` : ''} → ${clientCount} clients`) } @@ -692,18 +753,7 @@ export function broadcastPermissionRequest(data: Record) { timestamp: Date.now() }) - let clientCount = 0 - for (const [, session] of sessions) { - for (const ws of session.clients) { - try { - ws.send(message) - clientCount++ - } catch { - // Client disconnected, ignore - } - } - } - + const clientCount = broadcastToAll(message) console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`) } @@ -715,15 +765,6 @@ export function broadcastTranscriptUpdate(data: Record) { timestamp: Date.now() }) - let clientCount = 0 - for (const [, session] of sessions) { - for (const ws of session.clients) { - try { - ws.send(message) - clientCount++ - } catch { /* skip */ } - } - } - + const clientCount = broadcastToAll(message) console.log(`[Terminal] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`) }