import type { WSContext } from 'hono/ws' import { findAcpConnectionByAgentId, sendToAgentWs } from './acp-ws-handler' import { getAcpEventBus } from './event-bus' import type { SessionEvent } from './event-bus' import { log, error as logError } from '../logger' // Per-relay connection state interface RelayConnectionEntry { agentId: string unsub: (() => void) | null keepalive: ReturnType | null ws: WSContext openTime: number } const relayConnections = new Map() // key: relayWsId const RELAY_KEEPALIVE_INTERVAL_MS = 20_000 /** Send a JSON message to relay WS */ function sendToRelayWs(ws: WSContext, msg: object): void { if (ws.readyState !== 1) return try { ws.send(JSON.stringify(msg)) } catch (err) { logError('[ACP-Relay] send error:', err) } } /** Called from onOpen — finds target agent and bridges connection */ export function handleRelayOpen( ws: WSContext, relayWsId: string, agentId: string, ): void { log( `[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`, ) // Check if agent is online const agentConn = findAcpConnectionByAgentId(agentId) if (!agentConn) { log(`[ACP-Relay] Agent ${agentId} not found or offline`) sendToRelayWs(ws, { type: 'error', message: 'Agent not found or offline' }) ws.close(4004, 'agent not found') return } // Keepalive interval const keepalive = setInterval(() => { const entry = relayConnections.get(relayWsId) if (!entry || entry.ws.readyState !== 1) { clearInterval(keepalive) return } sendToRelayWs(entry.ws, { type: 'keep_alive' }) }, RELAY_KEEPALIVE_INTERVAL_MS) // Subscribe to channel group EventBus — forward agent responses to frontend const channelGroupId = agentConn.channelGroupId const bus = getAcpEventBus(channelGroupId) const unsub = bus.subscribe((event: SessionEvent) => { if (ws.readyState !== 1) return if (event.direction !== 'inbound') return // Handle agent disconnect specially: send status to frontend if (event.type === 'agent_disconnect') { sendToRelayWs(ws, { type: 'status', payload: { connected: false } }) return } // Forward agent responses to the frontend WebSocket sendToRelayWs(ws, event.payload as object) }) relayConnections.set(relayWsId, { agentId, unsub, keepalive, ws, openTime: Date.now(), }) // Don't send a synthetic status message here! // The frontend sends a "connect" command, which acp-link processes // and responds with a real status message including capabilities. // Sending a fake status would make the frontend think it's connected // before the agent process is actually ready. log( `[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`, ) } /** Called from onMessage — forwards frontend messages to acp-link */ export function handleRelayMessage( ws: WSContext, relayWsId: string, data: string, ): void { const entry = relayConnections.get(relayWsId) if (!entry) return const lines = data.split('\n').filter(l => l.trim()) for (const line of lines) { let msg: Record try { msg = JSON.parse(line) } catch { logError('[ACP-Relay] parse error:', line) continue } // Ignore keepalive responses if (msg.type === 'keep_alive') continue // Forward to acp-link agent const sent = sendToAgentWs(entry.agentId, msg) if (!sent) { sendToRelayWs(ws, { type: 'error', message: 'Agent connection lost' }) return } } } /** Called from onClose — cleans up relay connection */ export function handleRelayClose( ws: WSContext, relayWsId: string, code?: number, reason?: string, ): void { const entry = relayConnections.get(relayWsId) if (!entry) return const duration = Math.round((Date.now() - entry.openTime) / 1000) log( `[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`, ) if (entry.unsub) { entry.unsub() } if (entry.keepalive) { clearInterval(entry.keepalive) } relayConnections.delete(relayWsId) } /** Close all relay connections (for graceful shutdown) */ export function closeAllRelayConnections(): void { if (relayConnections.size === 0) return log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`) for (const [relayWsId, entry] of relayConnections) { try { if (entry.unsub) entry.unsub() if (entry.keepalive) clearInterval(entry.keepalive) if (entry.ws.readyState === 1) { entry.ws.close(1001, 'server_shutdown') } } catch { // ignore errors during shutdown } } relayConnections.clear() log('[ACP-Relay] All relay connections closed') }