feat: server-first terminal creation, broadcast-only WS clients

- Add POST /create-terminal endpoint with MAX_TERMINALS=5 limit
- Server creates PTY, runs command, registers and broadcasts atomically
- Frontend startTerminal() calls server first, connects in reconnect mode
- Remove registerTerminalOnServer() — server handles registration
- Separate broadcast-only WS clients from PTY clients (no phantom "main" PTY)
- All broadcast functions use broadcastToAll() helper
- Fix resume existing flow to create terminal with --resume flag
This commit is contained in:
2026-02-21 00:17:24 -06:00
parent a6c68f1b9e
commit 2aec892f62
4 changed files with 171 additions and 125 deletions

View File

@@ -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)
}
// ============================================================================

View File

@@ -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,

View File

@@ -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'

View File

@@ -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<string, string> = {
// Store active terminal sessions by ID (persistent across reconnections)
const sessions = new Map<string, TerminalSession>()
// Map WebSocket to sessionId
// Map WebSocket to sessionId (only for PTY-connected clients)
const wsToSession = new Map<any, string>()
// Broadcast-only clients (no PTY, just receive state updates)
const broadcastClients = new Set<any>()
// ── 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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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`)
}