fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input
- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug - Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable - Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat - Guard xterm dispose against unloaded addons to prevent errors on agent switch - Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
@@ -10,6 +10,23 @@ interface TerminalSession {
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Agent terminal state tracking
|
||||
interface AgentTerminalState {
|
||||
agentId: string
|
||||
sessionId: string
|
||||
command: string
|
||||
startedAt: Date | null
|
||||
isAgentRunning: boolean
|
||||
}
|
||||
|
||||
export const agentSessions = new Map<string, AgentTerminalState>()
|
||||
|
||||
const AGENT_COMMANDS: Record<string, string> = {
|
||||
'main': 'claude',
|
||||
'ejecutor': 'ejecutor',
|
||||
'nucleo000': 'nucleo000'
|
||||
}
|
||||
|
||||
// Store active terminal sessions by ID (persistent across reconnections)
|
||||
const sessions = new Map<string, TerminalSession>()
|
||||
|
||||
@@ -65,6 +82,16 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
sessions.delete(sessionId)
|
||||
|
||||
// Mark agent as not running if this is an agent session
|
||||
if (sessionId.startsWith('agent-')) {
|
||||
const agentId = sessionId.replace('agent-', '')
|
||||
const state = agentSessions.get(agentId)
|
||||
if (state) {
|
||||
state.isAgentRunning = false
|
||||
console.log(`[Terminal] Agent ${agentId} marked as stopped (exit code ${exitCode})`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sessions.set(sessionId, session)
|
||||
@@ -74,6 +101,60 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
return session
|
||||
}
|
||||
|
||||
// Kill an existing session's PTY process
|
||||
export function killSession(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session) return false
|
||||
|
||||
console.log(`[Terminal] Killing session: ${sessionId} (PID: ${session.pty.pid})`)
|
||||
|
||||
// Notify clients before killing
|
||||
for (const ws of session.clients) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'session-restart', sessionId }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
session.pty.kill()
|
||||
} catch (e) {
|
||||
console.error(`[Terminal] Error killing PTY for ${sessionId}:`, e)
|
||||
}
|
||||
|
||||
sessions.delete(sessionId)
|
||||
return true
|
||||
}
|
||||
|
||||
// Start an agent command in its dedicated session
|
||||
export async function startAgentInSession(agentId: string, force = false): Promise<AgentTerminalState> {
|
||||
const sessionId = `agent-${agentId}`
|
||||
const command = AGENT_COMMANDS[agentId] || agentId
|
||||
|
||||
// If force restart, kill existing session first
|
||||
if (force && sessions.has(sessionId)) {
|
||||
killSession(sessionId)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
const session = getOrCreateSession(sessionId)
|
||||
|
||||
// Write the agent command to the PTY
|
||||
session.pty.write(command + '\r')
|
||||
|
||||
const state: AgentTerminalState = {
|
||||
agentId,
|
||||
sessionId,
|
||||
command,
|
||||
startedAt: new Date(),
|
||||
isAgentRunning: true
|
||||
}
|
||||
|
||||
agentSessions.set(agentId, state)
|
||||
console.log(`[Terminal] Agent ${agentId} started in session ${sessionId} with command: ${command}`)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function startTerminalServer() {
|
||||
const server = Bun.serve({
|
||||
port: PORT_TERMINAL,
|
||||
@@ -146,6 +227,66 @@ export function startTerminalServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Agent sessions info
|
||||
if (url.pathname === '/agent-sessions' && req.method === 'GET') {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [id, state] of agentSessions) {
|
||||
const session = sessions.get(state.sessionId)
|
||||
result[id] = {
|
||||
...state,
|
||||
pid: session?.pty.pid ?? null,
|
||||
bufferSize: session?.outputBuffer.length ?? 0,
|
||||
clientCount: session?.clients.size ?? 0,
|
||||
sessionExists: !!session
|
||||
}
|
||||
}
|
||||
return Response.json(result, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Start agent in session
|
||||
if (url.pathname === '/start-agent' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as { agentId: string; force?: boolean }
|
||||
if (!body.agentId) {
|
||||
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
const state = await startAgentInSession(body.agentId, body.force)
|
||||
return Response.json({ success: true, state }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Stop agent session
|
||||
if (url.pathname === '/stop-agent' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as { agentId: string }
|
||||
if (!body.agentId) {
|
||||
return Response.json({ error: 'agentId required' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
const sessionId = `agent-${body.agentId}`
|
||||
const killed = killSession(sessionId)
|
||||
if (killed) {
|
||||
const state = agentSessions.get(body.agentId)
|
||||
if (state) state.isAgentRunning = false
|
||||
}
|
||||
return Response.json({ success: true, killed }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 500, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Transcript update broadcast endpoint
|
||||
if (url.pathname === '/transcript-update' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json()
|
||||
broadcastTranscriptUpdate(body as Record<string, unknown>)
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const upgradeHeader = req.headers.get('upgrade')
|
||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
||||
@@ -269,11 +410,22 @@ type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' |
|
||||
|
||||
// Broadcast Claude status to ALL clients across ALL sessions
|
||||
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
|
||||
const agentName = agent || 'main'
|
||||
|
||||
// Track agent running state from sessionStart
|
||||
if (status === 'sessionStart') {
|
||||
const state = agentSessions.get(agentName)
|
||||
if (state) {
|
||||
state.isAgentRunning = true
|
||||
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
|
||||
}
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'claude-status',
|
||||
status,
|
||||
tool,
|
||||
agent: agent || 'main',
|
||||
agent: agentName,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
@@ -337,3 +489,24 @@ export function broadcastPermissionRequest(data: Record<string, unknown>) {
|
||||
|
||||
console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`)
|
||||
}
|
||||
|
||||
// Broadcast transcript updates to ALL clients
|
||||
export function broadcastTranscriptUpdate(data: Record<string, unknown>) {
|
||||
const message = JSON.stringify({
|
||||
type: 'transcript-update',
|
||||
...data,
|
||||
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] Transcript update: ${data.hookEvent || 'fetch'} (${(data.messages as any[])?.length || 0} msgs) → ${clientCount} clients`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user