/** * server.ts — Hermes Web Gateway (Bun) * Port: 3003 | Env: PORT, GATEWAY_HOST, GATEWAY_PORT, GATEWAY_TOKEN */ import * as fs from 'fs'; import * as path from 'path'; import { pushEventAll, setBroadcastFn } from './mcp/events.ts'; import { createSessionWatcher, checkSessionOnDisk, findPreviousSessionFiles, parseSessionFile } from './session-watcher'; import { createChannelSM, createConnectionSM, type ChannelState, type ConnectionState, type StateMachine } from './session-sm'; import { userDefaultAgent, userAllowedAgents, userViewerRoots, getTokenForUser, getUserForToken, getAgentList, getAgentListWithModels, issueSessionToken, getUserForSessionToken, revokeSessionToken, issueAuthNonce, consumeAuthNonce, verifyOtpChallenge, getAgentSegment, getSessionPrompt, } from './auth'; import { connectToGateway, gatewayRequest, isConnected, setBrowserSessions, disconnectGateway, setOnTurnDone, setOnGatewayDisconnect, } from './gateway'; import { filterValue } from './message-filter'; import { hud } from './hud-builder'; import { setupMcp, handleMcpRequest, refreshMcpKeyForUser } from './mcp/index.ts'; import { setNotifyFn, getPendingRequests, approveRequest, denyRequest, type PendingRequest } from './system-access'; const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')); const BUILD_ID = Date.now().toString(36); const VERSION = `${pkg.version as string}-${BUILD_ID}`; const PORT = parseInt(process.env.PORT || '3003', 10); const BRAND_NAME = process.env.BRAND_NAME || 'Titan'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ViewerEntry { watcher: ReturnType | null; users: Set; } interface FstokenEntry { user: string; expiresAt: number; } interface SessionStateEntry { handoverActive: boolean; lastIdleAt: number; activeTurnId: string | null; activeTurnTs: number; lastDoneTurnId: string | null; lastDoneTurnTs: number; promptInjected: boolean; } interface SharedSM { sm: StateMachine; connections: number; // ref count of active WS connections using this SM } interface BrowserSession { user: string | null; sessionKey: string | null; clientId: string; sm: StateMachine; // channel SM (shared) connSm: StateMachine; // connection SM (per-WS) watcher: ReturnType | null; turnId?: string; _handoverResolve?: (() => void) | null; _handoverHandler?: ((eventName: string, payload: any) => void) | null; _lastDeltaText?: string; _lastDeltaOffset?: number; _lastThinkOffset?: number; _hudTurnStarted?: boolean; _hudTurnTs?: number | null; _hudThinkId?: string | null; _hudThinkTs?: number | null; _hudPendingTools?: Map; _hudLiveToolSeen?: boolean; _hudDeferredTurnEnd?: { turnId: string; turnTs: number; hadTurnStarted: boolean } | null; } interface PathError extends Error { status?: number; } // --------------------------------------------------------------------------- // Viewer config // --------------------------------------------------------------------------- const VIEWER_ROOTS: Record = { shared: '/home/openclaw/.openclaw/shared', titan: '/home/openclaw/.openclaw/workspace-titan', 'workspace-titan': '/home/openclaw/.openclaw/workspace-titan', adoree: '/home/openclaw/.openclaw/workspace-adoree', 'workspace-adoree': '/home/openclaw/.openclaw/workspace-adoree', alfred: '/home/openclaw/.openclaw/workspace-alfred', 'workspace-alfred': '/home/openclaw/.openclaw/workspace-alfred', ash: '/home/openclaw/.openclaw/workspace-ash', 'workspace-ash': '/home/openclaw/.openclaw/workspace-ash', eras: '/home/openclaw/.openclaw/workspace-eras', 'workspace-eras': '/home/openclaw/.openclaw/workspace-eras', willi: '/home/openclaw/.openclaw/workspace-willi', 'workspace-willi': '/home/openclaw/.openclaw/workspace-willi', }; const VIEWER_SKIP_EXT = new Set(['.png','.jpg','.jpeg','.gif','.bmp','.ico','.svg','.webp','.mp3','.mp4','.wav','.ogg','.zip','.gz','.tar','.7z','.rar','.exe','.dll','.so','.dylib','.woff','.woff2','.ttf','.otf','.eot']); const VIEWER_MAX_BYTES = 2 * 1024 * 1024; function resolveViewerPath(rel: string): string { const [prefix, ...rest] = rel.split('/'); const root = VIEWER_ROOTS[prefix]; if (!root) { const e: PathError = new Error('Unknown root'); e.status = 400; throw e; } if (!rest.length || rest.join('/') === '') return root; const abs = path.resolve(root, rest.join('/')); if (!abs.startsWith(root + path.sep) && abs !== root) { const e: PathError = new Error('Path traversal denied'); e.status = 400; throw e; } return abs; } function absToViewerPath(absPath: string): string | null { for (const [prefix, root] of Object.entries(VIEWER_ROOTS)) { if (absPath.startsWith(root + '/') || absPath === root) return prefix + (absPath === root ? '' : '/' + absPath.slice(root.length + 1)); } return null; } // --------------------------------------------------------------------------- // fstoken // --------------------------------------------------------------------------- const fstokenMap = new Map(); const FSTOKEN_TTL_MS = 60 * 60 * 1000; function issueFstoken(user: string): string { for (const [tok, entry] of fstokenMap) if (entry.user === user && entry.expiresAt > Date.now()) return tok; const tok = crypto.randomUUID(); fstokenMap.set(tok, { user, expiresAt: Date.now() + FSTOKEN_TTL_MS }); return tok; } function getUserForFstoken(fstoken: string): string | null { const entry = fstokenMap.get(fstoken); if (!entry) return null; if (entry.expiresAt < Date.now()) { fstokenMap.delete(fstoken); return null; } return entry.user; } function viewerTokenFromReq(req: Request): string | null { const u = new URL(req.url); return u.searchParams.get('token') || (req.headers.get('authorization') || '').replace(/^Bearer /, '') || null; } function getUserForViewerToken(token: string): string | null { return getUserForFstoken(token) || getUserForSessionToken(token) || getUserForToken(token); } // --------------------------------------------------------------------------- // Viewer watchers — file content (fs.watchFile) + directory changes (fs.watch) // --------------------------------------------------------------------------- const viewerWatchers = new Map(); // Directory watchers: detect new/deleted files in open directories interface DirWatcherEntry { watcher: ReturnType | null; users: Set; } const viewerDirWatchers = new Map(); function attachFsWatcher(absPath: string): void { const entry = viewerWatchers.get(absPath); if (!entry) return; try { // fs.watchFile (stat-based polling) for content changes on existing files. // Bun's fs.watch misses rename events from atomic writes (sed -i, editors). fs.watchFile(absPath, { persistent: false, interval: 2000 }, (curr, prev) => { if (curr.mtimeMs !== prev.mtimeMs || curr.ino !== prev.ino) { broadcastViewerChange(absPath); } }); entry.watcher = absPath as any; // store path for unwatchFile cleanup } catch (e: any) { console.warn('[viewer] fs.watchFile failed for', absPath, e.message); } } function watchViewerDir(absDir: string, user: string): void { if (!viewerDirWatchers.has(absDir)) { try { const w = fs.watch(absDir, { persistent: false }, (_event, _filename) => { broadcastDirChange(absDir); }); viewerDirWatchers.set(absDir, { watcher: w, users: new Set() }); } catch (e: any) { console.warn('[viewer] fs.watch dir failed for', absDir, e.message); viewerDirWatchers.set(absDir, { watcher: null, users: new Set() }); } } viewerDirWatchers.get(absDir)!.users.add(user); } function unwatchDirUser(user: string): void { for (const [absDir, entry] of viewerDirWatchers) { entry.users.delete(user); if (entry.users.size === 0) { if (entry.watcher) { try { entry.watcher.close(); } catch (_) {} } viewerDirWatchers.delete(absDir); } } } function broadcastDirChange(absDir: string): void { const viewerPath = absToViewerPath(absDir); if (!viewerPath) return; const entry = viewerDirWatchers.get(absDir); if (!entry) return; for (const [ws, s] of browserSessions) { if (s.user && entry.users.has(s.user) && ws.readyState === 1) ws.send(JSON.stringify({ type: 'viewer_tree_changed', path: viewerPath })); } } function watchViewerPath(absPath: string, user: string): void { if (!viewerWatchers.has(absPath)) { viewerWatchers.set(absPath, { watcher: null, users: new Set() }); attachFsWatcher(absPath); } viewerWatchers.get(absPath)!.users.add(user); } function unwatchViewerUser(user: string): void { for (const [absPath, entry] of viewerWatchers) { entry.users.delete(user); if (entry.users.size === 0) { try { fs.unwatchFile(absPath); } catch (_) {} viewerWatchers.delete(absPath); } } unwatchDirUser(user); } function broadcastViewerChange(absPath: string): void { const viewerPath = absToViewerPath(absPath); if (!viewerPath) return; const entry = viewerWatchers.get(absPath); if (!entry) return; for (const [ws, s] of browserSessions) { if (s.user && entry.users.has(s.user) && ws.readyState === 1) ws.send(JSON.stringify({ type: 'viewer_file_changed', path: viewerPath })); } } // --------------------------------------------------------------------------- // Session state + helpers // --------------------------------------------------------------------------- const browserSessions = new Map(); /** Broadcast a JSON message to all authenticated browser WS sessions */ export function broadcastToBrowsers(data: Record) { const payload = JSON.stringify(data); for (const [ws, s] of browserSessions) if (s.user && ws.readyState === 1) ws.send(payload); } setBroadcastFn(broadcastToBrowsers); // Register system-access WS notifier setNotifyFn((req: PendingRequest) => { broadcastToBrowsers({ type: 'system_access_request', ...req }); }); // Shared SMs — one SM per sessionKey, shared across all WS connections for same user+agent const sharedSMs = new Map(); // Shared watchers — one watcher per sessionKey, broadcasts to all connections const sharedWatchers = new Map; connections: number }>(); // Dev takeover — maps token → { ws, pending eval resolvers } const TAKEOVER_TOKEN_FILE = path.join(import.meta.dir, '.takeover-tokens.json'); const takeoverTokens = new Map void; timer: ReturnType }> }>(); function persistTakeoverTokens() { try { const obj: Record = {}; for (const [tok] of takeoverTokens) obj[tok] = { createdAt: Date.now() }; fs.writeFileSync(TAKEOVER_TOKEN_FILE, JSON.stringify(obj)); } catch (e: any) { console.error('Failed to persist takeover tokens:', e.message); } } // Load persisted tokens (ws=null until browser reconnects) try { const raw = JSON.parse(fs.readFileSync(TAKEOVER_TOKEN_FILE, 'utf8')); for (const tok of Object.keys(raw)) { takeoverTokens.set(tok, { ws: null as any, pending: new Map() }); } if (takeoverTokens.size > 0) console.log(`Loaded ${takeoverTokens.size} takeover token(s) from disk.`); } catch { /* no file yet */ } function getOrCreateSharedSM(sessionKey: string, initial?: ChannelState): StateMachine { if (!sharedSMs.has(sessionKey)) { const sm = createChannelSM(sessionKey, (event: Record) => { // SM timeout from AGENT_RUNNING — clear activeTurnId so next /new isn't blocked if (event.reason === 'timeout' && event.prev === 'AGENT_RUNNING') { handleTurnDone(sessionKey); } // Broadcast channel state to all users on this session const st = getSessionState(sessionKey); broadcastToSession(sessionKey, { ...event, handoverActive: st.handoverActive }); }, initial ?? 'NO_SESSION'); sharedSMs.set(sessionKey, { sm, connections: 0 }); } const entry = sharedSMs.get(sessionKey)!; entry.connections++; return entry.sm; } function releaseSharedSM(sessionKey: string): void { const entry = sharedSMs.get(sessionKey); if (!entry) return; entry.connections--; if (entry.connections <= 0) sharedSMs.delete(sessionKey); } setBrowserSessions(browserSessions); // Clear activeTurnId immediately when gateway reports turn done (prevents reconnect → stuck AGENT_RUNNING) function handleTurnDone(sessionKey: string) { const st = sessionState.get(sessionKey); if (!st) return; st.lastDoneTurnId = st.activeTurnId; st.lastDoneTurnTs = Date.now(); st.activeTurnId = null; st.lastIdleAt = Date.now(); const sharedSm = sharedSMs.get(sessionKey); if (sharedSm?.sm.get() === 'AGENT_RUNNING') { transitionChannel(sessionKey, 'READY', { reason: 'agent_done' }); } } setOnTurnDone(handleTurnDone); // On gateway disconnect, abort all sessions with an active turn — onTurnDone will never arrive setOnGatewayDisconnect(() => { for (const [sessionKey, st] of sessionState) { if (st.activeTurnId) { console.warn(`[gateway-disconnect] aborting in-flight turn for ${sessionKey}`); handleTurnDone(sessionKey); } } }); const sessionState = new Map(); function getSessionState(sessionKey: string): SessionStateEntry { if (!sessionState.has(sessionKey)) sessionState.set(sessionKey, { handoverActive: false, lastIdleAt: 0, activeTurnId: null, activeTurnTs: 0, lastDoneTurnId: null, lastDoneTurnTs: 0, promptInjected: false }); return sessionState.get(sessionKey)!; } function broadcastToSession(sessionKey: string, data: object): void { const payload = JSON.stringify(data); for (const [ws, s] of browserSessions) if (s.sessionKey === sessionKey && ws.readyState === 1) ws.send(payload); } // Channel SM transition — broadcasts to all users on the session. // The channel SM's onStateChange callback handles broadcasting automatically. function transitionChannel(sessionKey: string, newState: ChannelState, extra: Record = {}): void { const shared = sharedSMs.get(sessionKey); if (shared) shared.sm.transition(newState, { reason: extra.reason ?? newState, ...extra }); } // Connection SM transition — sends to one user only. function transitionConn(ws: any, session: BrowserSession, newState: ConnectionState, extra: Record = {}): void { session.connSm.transition(newState, { reason: extra.reason ?? newState, ...extra }); // Send connection state directly to this user send(ws, { type: 'connection_state', state: newState, ...extra }); } // Legacy compatibility — used during migration, maps old IDLE calls to new states function transitionSM(sessionKey: string, newState: string, extra: Record = {}): void { transitionChannel(sessionKey, newState as ChannelState, extra); } function send(ws: any, data: object): void { if (ws.readyState === 1) ws.send(JSON.stringify(data)); } function readHandoverMd(agentId: string): string | null { const p = path.join('/home/openclaw/.openclaw', `workspace-${agentId}`, 'HANDOVER.md'); try { return fs.readFileSync(p, 'utf8').trim() || null; } catch (_) { return null; } } // --------------------------------------------------------------------------- // Pending messages (dedup across tabs) // --------------------------------------------------------------------------- const pendingMsgs = new Map>(); function trackPendingMsg(sessionKey: string, msgId: string, ws: any, content: string): void { if (!pendingMsgs.has(sessionKey)) pendingMsgs.set(sessionKey, new Map()); pendingMsgs.get(sessionKey)!.set(msgId, { ws, content, ts: Date.now() }); } function resolvePendingMsg(sessionKey: string, contentOrMsgId: string): { msgId: string; originWs: any } | null { const map = pendingMsgs.get(sessionKey); if (!map) return null; const now = Date.now(); // Try exact msgId match first const byId = map.get(contentOrMsgId); if (byId && now - byId.ts < 30000) { map.delete(contentOrMsgId); return { msgId: contentOrMsgId, originWs: byId.ws }; } // Fall back to content match (backward compat, 2s window) for (const [msgId, entry] of map) { if (entry.content === contentOrMsgId && now - entry.ts < 2000) { map.delete(msgId); return { msgId, originWs: entry.ws }; } } return null; } // --------------------------------------------------------------------------- // OpenRouter stats cache // --------------------------------------------------------------------------- let cachedStats: any = null; let lastStatsFetch = 0; const STATS_TTL = 60_000; async function fetchOpenRouterStats(): Promise { if (cachedStats && Date.now() - lastStatsFetch < STATS_TTL) return cachedStats; let apiKey: string | null = null; try { const cfg = JSON.parse(fs.readFileSync('/home/openclaw/.openclaw/openclaw.json', 'utf8')); apiKey = cfg.env?.OPENROUTER_API_KEY || null; } catch (_) {} if (!apiKey) return { error: 'No OpenRouter API key configured' }; const orGet = async (p: string) => { const res = await fetch(`https://openrouter.ai${p}`, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, }); return res.json(); }; const [creditsData, modelsData] = await Promise.all([orGet('/api/v1/credits'), orGet('/api/v1/models')]); const modelMap: Record = {}; for (const m of (modelsData.data || [])) modelMap[m.id] = { name: m.name, contextLength: m.context_length, promptPrice: parseFloat(m.pricing?.prompt || 0) * 1_000_000, completionPrice: parseFloat(m.pricing?.completion || 0) * 1_000_000 }; const agents = getAgentListWithModels().map((a: any) => { const modelId = a.modelFull?.replace(/^openrouter\//, '') ?? null; const info = modelId ? modelMap[modelId] : null; return { ...a, modelId, modelName: info?.name || a.model || '?', contextLength: info?.contextLength ?? null, promptPrice: info?.promptPrice ?? null, completionPrice: info?.completionPrice ?? null }; }); const total = creditsData.data?.total_credits ?? creditsData.total_credits ?? null; const used = creditsData.data?.total_usage ?? creditsData.total_usage ?? null; cachedStats = { credits: { total, used, remaining: total != null && used != null ? +(total - used).toFixed(4) : null }, agents }; lastStatsFetch = Date.now(); return cachedStats; } async function makeReady(user: string, sessionKey: string): Promise { let agents = getAgentListWithModels(user); try { const stats = await fetchOpenRouterStats(); if (stats && !stats.error && Array.isArray(stats.agents)) { // Merge pricing from stats into agents (preserve segment/role from getAgentListWithModels) agents = agents.map(a => { const s = stats.agents.find((sa: any) => sa.id === a.id); return s ? { ...a, promptPrice: s.promptPrice, completionPrice: s.completionPrice, contextLength: s.contextLength } : a; }); } } catch (e: any) { console.error('[makeReady]', e.message); } return { type: 'ready', sessionId: sessionKey, user, version: VERSION, brandName: BRAND_NAME, defaultAgent: (userDefaultAgent as any)[user] ?? 'titan', allowedAgents: (userAllowedAgents as any)[user] ?? [], agents, }; } // --------------------------------------------------------------------------- // Session watcher attachment // --------------------------------------------------------------------------- function attachWatcher(ws: any, session: BrowserSession, sessionKey: string): void { // Detach previous watcher if switching sessions detachWatcher(session); // Reuse existing shared watcher for this sessionKey if one exists if (sharedWatchers.has(sessionKey)) { const existing = sharedWatchers.get(sessionKey)!; existing.connections++; session.watcher = existing.watcher; // Re-send history directly to this new connection (watcher won't replay it) const historyCount = existing.watcher.sendHistoryTo((data: object) => send(ws, data)); // Set channel state based on history (if not already set by active turn) const channelNow = session.sm.get(); if (channelNow === 'NO_SESSION' || channelNow === 'RESETTING') { session.sm.transition(historyCount > 0 ? 'READY' : 'FRESH', { reason: 'history_loaded' }); } // Connection: history done, synced transitionConn(ws, session, 'SYNCED', { reason: 'history_complete', historyCount }); return; } const agentId = sessionKey.split(':')[1]; let lastFloorProjection = 0; const watcher = createSessionWatcher(sessionKey, agentId, async (entry: any) => { // Handover resolve — find the session that owns it if (entry.entry_type === 'assistant_text') { for (const [, s] of browserSessions) if (s.sessionKey === sessionKey && s._handoverResolve) { s._handoverResolve(); break; } } // SM: agent_done transition (shared SM — only need to do once) if (entry.entry_type === 'assistant_text') { const sharedSm = sharedSMs.get(sessionKey); if (sharedSm?.sm.get() === 'AGENT_RUNNING') { const st = getSessionState(sessionKey); const entryTs = entry.ts ? new Date(entry.ts).getTime() : 0; if (!(entryTs > 0 && entryTs < st.activeTurnTs - 1000)) { st.lastDoneTurnId = st.activeTurnId; st.lastDoneTurnTs = Date.now(); st.activeTurnId = null; sharedSm.sm.transition('READY', { reason: 'agent_done' }); st.lastIdleAt = Date.now(); } } } if (entry.entry_type === 'usage') { try { const stats = await fetchOpenRouterStats(); const agent = stats.agents?.find((a: any) => a.id === agentId); if (agent?.promptPrice != null && agent?.completionPrice != null) { const inn = entry.input_tokens || 0; const out = entry.output_tokens || 0; const cost = (inn / 1_000_000) * agent.promptPrice + (out / 1_000_000) * agent.completionPrice; const nextFloor = (entry.total_tokens / 1_000_000) * agent.promptPrice * 0.1; const projectionDelta = lastFloorProjection > 0 ? cost - lastFloorProjection : 0; lastFloorProjection = nextFloor; broadcastToSession(sessionKey, { type: 'finance_update', sessionKey, nextTurnFloor: nextFloor, projectionDelta, currentContextTokens: entry.total_tokens, lastTurnCost: cost, pricing: { prompt: agent.promptPrice, completion: agent.completionPrice } }); } } catch (e: any) { console.warn(`[${sessionKey}] Finance:`, e.message); } } if (entry.type === 'session_entry' && entry.entry_type === 'tool_call') { broadcastToSession(sessionKey, { type: 'tool', action: 'call', tool: entry.tool, args: filterValue(entry.args) }); return; } if (entry.type === 'session_entry' && entry.entry_type === 'tool_result') { const raw = filterValue(entry.result) || ''; const first = raw.split('\n')[0].trim(); broadcastToSession(sessionKey, { type: 'tool', action: 'result', tool: entry.tool, result: first.length > 80 ? first.slice(0, 77) + '…' : first }); return; } if (entry.type === 'session_entry' && entry.entry_type === 'assistant_text') { const st = getSessionState(sessionKey); const entryTs = entry.ts ? new Date(entry.ts).getTime() : 0; if (st.lastDoneTurnTs && entryTs <= st.lastDoneTurnTs + 500) return; } if (entry.type === 'session_entry' && entry.entry_type === 'user_message') { const resolved = resolvePendingMsg(sessionKey, entry.content); if (resolved) { const payload = JSON.stringify({ ...entry, msgId: resolved.msgId }); for (const [otherWs, s] of browserSessions) if (s.sessionKey === sessionKey && otherWs !== resolved.originWs && otherWs.readyState === 1) otherWs.send(payload); } else { broadcastToSession(sessionKey, entry); } return; } // Intercept session_status to drive SM transitions if ((entry as any).type === 'session_status') { if ((entry as any).status === 'no_session') { const sm = sharedSMs.get(sessionKey); const cur = sm?.sm.get(); // Don't override RESETTING — that's a deliberate new session in progress if (cur && cur !== 'NO_SESSION' && cur !== 'RESETTING') sm?.sm.transition('NO_SESSION', { reason: (entry as any).reason || 'no_session' }); // Also transition connections to SYNCED — no history to load for (const [otherWs, s] of browserSessions) { if (s.sessionKey === sessionKey && s.connSm.get() === 'LOADING_HISTORY') { transitionConn(otherWs, s, 'SYNCED', { reason: 'no_session' }); } } } else if ((entry as any).status === 'watching') { const sm = sharedSMs.get(sessionKey); const watchCur = sm?.sm.get(); if (watchCur === 'NO_SESSION' || watchCur === 'RESETTING') { const count = (entry as any).entries || 0; sm?.sm.transition(count > 0 ? 'READY' : 'FRESH', { reason: 'session_found' }); } // Transition all connections on this channel to SYNCED for (const [otherWs, s] of browserSessions) { if (s.sessionKey === sessionKey && s.connSm.get() === 'LOADING_HISTORY') { transitionConn(otherWs, s, 'SYNCED', { reason: 'history_complete' }); } } } } broadcastToSession(sessionKey, entry); }, (data: object) => broadcastToSession(sessionKey, data), gatewayRequest); sharedWatchers.set(sessionKey, { watcher, connections: 1 }); session.watcher = watcher; watcher.start(); } function detachWatcher(session: BrowserSession): void { if (!session.watcher || !session.sessionKey) return; const entry = sharedWatchers.get(session.sessionKey); if (!entry) return; entry.connections--; if (entry.connections <= 0) { entry.watcher.stop(); sharedWatchers.delete(session.sessionKey); } session.watcher = null; } // --------------------------------------------------------------------------- // WebSocket message handlers // --------------------------------------------------------------------------- async function handleConnect(ws: any, session: BrowserSession, msg: any): Promise { const user = msg.user; if (!getTokenForUser(user)) { ws.close(4001, 'Invalid user'); return; } const agentId = msg.agent; if (!agentId) { Object.assign(session, { user }); send(ws, await makeReady(user, '')); return; } const mode = msg.mode ?? 'private'; const publicKey = `agent:${agentId}:web:public`; const sessionKey = mode === 'public' ? publicKey : `agent:${agentId}:web:${user}`; const sharedSm = getOrCreateSharedSM(sessionKey); Object.assign(session, { user, sessionKey, sm: sharedSm }); send(ws, await makeReady(user, sessionKey)); const st = getSessionState(sessionKey); send(ws, hud.received('reconnect', 'reconnected -- replaying history', { sessionKey, agent: agentId })); // Channel state: if agent is mid-turn, channel is AGENT_RUNNING if (st.activeTurnId && sharedSm.get() !== 'AGENT_RUNNING') { session.turnId = st.activeTurnId; sharedSm.transition('AGENT_RUNNING', { reason: 'reconnect' }); } // Connection: start loading history transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'connect' }); // Send current channel state to this client send(ws, { type: 'channel_state', state: sharedSm.get(), handoverActive: st.handoverActive, turnId: st.activeTurnId || undefined }); const hc = readHandoverMd(agentId); if (hc) send(ws, { type: 'handover_context', content: hc }); attachWatcher(ws, session, sessionKey); // History sent by attachWatcher → sendHistoryTo; after that we transition to SYNCED } async function handleAuth(ws: any, session: BrowserSession, msg: any): Promise { const user = getUserForSessionToken(msg.token) || getUserForToken(msg.token); if (!user) { ws.close(4001, 'Invalid token'); return; } const agentId = msg.agent; if (!agentId) { // No agent — send ready with agent list, user will pick from sidebar Object.assign(session, { user }); send(ws, await makeReady(user, '')); return; } const mode = msg.mode ?? 'private'; const publicKey = `agent:${agentId}:web:public`; const sessionKey = mode === 'public' ? publicKey : `agent:${agentId}:web:${user}`; const sharedSm = getOrCreateSharedSM(sessionKey); Object.assign(session, { user, sessionKey, sm: sharedSm }); send(ws, await makeReady(user, sessionKey)); const st = getSessionState(sessionKey); // Channel state: if agent is mid-turn, channel is AGENT_RUNNING if (st.activeTurnId && sharedSm.get() !== 'AGENT_RUNNING') { session.turnId = st.activeTurnId; sharedSm.transition('AGENT_RUNNING', { reason: 'reconnect' }); } // Connection: start loading history transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'auth' }); // Send current channel state to this client send(ws, { type: 'channel_state', state: sharedSm.get(), handoverActive: st.handoverActive, turnId: st.activeTurnId || undefined }); const hc = readHandoverMd(agentId); if (hc) send(ws, { type: 'handover_context', content: hc }); attachWatcher(ws, session, sessionKey); // History sent by attachWatcher → sendHistoryTo; after that we transition to SYNCED } async function handleMessage(ws: any, session: BrowserSession, msg: any): Promise { if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } const state = session.sm.get(); if (state !== 'READY' && state !== 'FRESH') { const code = (state === 'HANDOVER_DONE') ? 'SESSION_TERMINATED' : 'DISCARDED_NOT_READY'; console.warn(`[handleMessage] DISCARDED code=${code} state=${state} sessionKey=${session.sessionKey} clientId=${session.clientId}`); send(ws, { type: 'error', code, message: `Message discarded: agent state is ${state}` }); return; } const msgId = msg.msgId || crypto.randomUUID(); trackPendingMsg(session.sessionKey, msgId, ws, msg.content); const turnId = crypto.randomUUID(); const st = getSessionState(session.sessionKey); st.activeTurnId = turnId; st.activeTurnTs = Date.now(); session.turnId = turnId; session.sm.transition('AGENT_RUNNING', { reason: 'message' }); // Inject session context prompt on first message if not yet done let messageToSend = msg.content; if (!st.promptInjected) { const agentId = session.sessionKey.split(':')[1]; const sessionPrompt = getSessionPrompt(agentId, session.sessionKey, session.user!); messageToSend = `${sessionPrompt}\n\n${msg.content}`; st.promptInjected = true; } // Pass attachments through to gateway if present const rawAttachments = Array.isArray(msg.attachments) ? msg.attachments.filter( (a: any) => typeof a?.content === 'string' && typeof a?.mimeType === 'string' ) : []; // Split attachments by type — all go as native content blocks to Sonnet const imageAttachments = rawAttachments.filter((a: any) => a.mimeType?.startsWith('image/')); const audioAttachments = rawAttachments.filter((a: any) => a.mimeType?.startsWith('audio/')); const pdfAttachments = rawAttachments.filter((a: any) => a.mimeType === 'application/pdf'); const otherAttachments = rawAttachments.filter((a: any) => !a.mimeType?.startsWith('image/') && !a.mimeType?.startsWith('audio/') && a.mimeType !== 'application/pdf'); // Save audio + PDFs + other files to disk (for playback/download in chat) const savedFilePaths: string[] = []; const filesToSave = [ ...audioAttachments.map((a: any) => ({ ...a, label: 'audio' })), ...pdfAttachments.map((a: any) => ({ ...a, label: 'document' })), ...otherAttachments.map((a: any) => ({ ...a, label: 'file' })), ]; for (const doc of filesToSave) { try { const AUDIO_EXT: Record = { 'audio/webm': 'webm', 'audio/mp4': 'm4a', 'audio/ogg': 'ogg', 'audio/mpeg': 'mp3', 'audio/wav': 'wav' }; const ext = AUDIO_EXT[doc.mimeType] || (doc.mimeType === 'application/pdf' ? 'pdf' : 'bin'); const safeName = (doc.fileName || `${doc.label}.${ext}`).replace(/[^a-zA-Z0-9._-]/g, '_'); const dir = '/home/openclaw/.openclaw/workspace-titan/media/inbound'; const { mkdirSync } = await import('fs'); mkdirSync(dir, { recursive: true }); const filePath = `${dir}/${Date.now()}-${safeName}`; const buffer = Buffer.from(doc.content, 'base64'); await Bun.write(filePath, buffer); savedFilePaths.push(filePath); console.log(`[handleMessage] saved ${doc.label}: ${filePath} (${buffer.length} bytes)`); } catch (err: any) { console.error(`[handleMessage] failed to save ${doc.label}: ${err.message}`); } } // Transcribe audio via ElevenLabs STT — append transcript to message const audioPaths = savedFilePaths.slice(0, audioAttachments.length); for (let ai = 0; ai < audioAttachments.length; ai++) { const audio = audioAttachments[ai]; const audioPath = audioPaths[ai]; try { const apiKey = process.env.ELEVENLABS_API_KEY; if (!apiKey) { console.warn('[stt] ELEVENLABS_API_KEY not set, skipping transcription'); continue; } send(ws, { type: 'hud', event: 'think_start', content: 'Transcribing audio...' }); console.log(`[stt] transcribing ${audio.fileName} (${audio.content.length} b64 chars)`); const buffer = Buffer.from(audio.content, 'base64'); const mimeBase = audio.mimeType.split(';')[0] || 'audio/webm'; const blob = new Blob([buffer], { type: mimeBase }); const form = new FormData(); form.append('model_id', 'scribe_v2'); form.append('file', blob, audio.fileName || 'recording.webm'); const sttRes = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { method: 'POST', headers: { 'xi-api-key': apiKey }, body: form, }); if (!sttRes.ok) { const errText = await sttRes.text(); console.error(`[stt] ElevenLabs error ${sttRes.status}: ${errText}`); send(ws, { type: 'hud', event: 'think_end' }); continue; } const sttData = await sttRes.json() as { text?: string; language_code?: string }; const transcript = sttData.text?.trim(); console.log(`[stt] transcript (${sttData.language_code}): ${transcript?.slice(0, 200)}`); send(ws, { type: 'hud', event: 'think_end' }); // Always patch — clear pending state even if transcript is empty broadcastToSession(session.sessionKey, { type: 'message_update', msgId, patch: { content: transcript || '(no speech detected)', voiceAudioUrl: audioPath ? `/api/files${audioPath}` : null, pending: false, transcript: transcript || '', }, }); if (transcript) { const userText = messageToSend.trim(); messageToSend = userText ? `${userText}\n\n[voice transcript]: ${transcript}` : `[voice transcript]: ${transcript}`; } } catch (err: any) { console.error(`[stt] transcription failed: ${err.message}`); send(ws, { type: 'hud', event: 'think_end' }); } } // Append disk paths for PDFs + other files so the agent can access them via tools const nonAudioPaths = savedFilePaths.slice(audioAttachments.length); if (nonAudioPaths.length > 0) { const pathList = nonAudioPaths.map(p => `[attached file: ${p}]`).join('\n'); messageToSend = `${messageToSend}\n\n${pathList}`; } // Send images as native content blocks (gateway supports images only for now) const attachments = [...imageAttachments]; if (attachments.length) { console.log(`[handleMessage] ${attachments.length} native attachment(s): ${attachments.map((a: any) => `type=${a.type} mime=${a.mimeType} file=${a.fileName} b64len=${a.content.length}`).join(', ')}`); } else { console.log(`[handleMessage] no attachments`); } try { const params: Record = { sessionKey: session.sessionKey, message: messageToSend, idempotencyKey: msgId }; if (attachments?.length) params.attachments = attachments; const gwRes = await gatewayRequest('chat.send', params); console.log(`[handleMessage] chat.send response: ${JSON.stringify(gwRes)}`); send(ws, { type: 'sent', msgId, turnId }); } catch (err: any) { console.error('[handleMessage] chat.send failed:', err.message, err); session.sm.transition('READY', { reason: 'send_error' }); send(ws, { type: 'error', code: 'SEND_ERROR', message: err.message }); } } async function handleStopKill(ws: any, session: BrowserSession, type: 'stop' | 'kill'): Promise { if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } try { send(ws, hud.received(type === 'kill' ? 'kill' : 'stop', type === 'kill' ? 'kill received — terminating turn' : 'stop received — aborting turn', { state: session.sm.get() })); await gatewayRequest(type === 'kill' ? 'sessions.kill' : 'sessions.stop', { sessionKey: session.sessionKey }); transitionChannel(session.sessionKey, 'READY', { reason: type }); broadcastToSession(session.sessionKey, { type: type === 'kill' ? 'killed' : 'stopped' }); } catch (err: any) { send(ws, { type: 'error', code: 'STOP_FAILED', message: err.message }); } } async function handleSwitchAgent(ws: any, session: BrowserSession, msg: any): Promise { if (!session.user) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } // Use per-WS flag to guard against double-switch (avoid touching shared SM) if ((session as any)._switching) { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Already switching' }); return; } const newAgentId = msg.agent; if (!newAgentId) { send(ws, { type: 'error', code: 'BAD_REQUEST', message: 'agent required' }); return; } const allowed = (userAllowedAgents as any)[session.user] ?? []; if (!allowed.includes(newAgentId)) { send(ws, { type: 'error', code: 'FORBIDDEN', message: `Agent ${newAgentId} not allowed` }); return; } const switchMode = msg.mode ?? 'private'; const publicKey = `agent:${newAgentId}:web:public`; const newKey = switchMode === 'public' ? publicKey : `agent:${newAgentId}:web:${session.user}`; if (newKey === session.sessionKey) { send(ws, { type: 'switch_ok', agent: newAgentId, sessionKey: newKey }); return; } const prevAgentId = session.sessionKey?.split(':')[1] || null; (session as any)._switching = true; // Connection SM: SYNCED → SWITCHING transitionConn(ws, session, 'SWITCHING', { reason: 'agent_switch' }); send(ws, hud.received('agent_switch', `switch -> ${newAgentId}`, { from: prevAgentId, to: newAgentId })); // Release old SM without broadcasting — switch is per-WS, not shared state releaseSharedSM(session.sessionKey!); session.sessionKey = newKey; // Attach to new agent's shared SM (unicast state to this WS only) const newSharedSm = getOrCreateSharedSM(newKey); session.sm = newSharedSm; (session as any)._switching = false; // Connection: SWITCHING → LOADING_HISTORY (attachWatcher will send SYNCED when done) transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'switch' }); // Send switch_ok + current channel state BEFORE attachWatcher send(ws, { type: 'switch_ok', agent: newAgentId, sessionKey: newKey }); const newSt = getSessionState(newKey); send(ws, { type: 'channel_state', state: newSharedSm.get(), clear_history: true, handoverActive: newSt.handoverActive }); const hc = readHandoverMd(newAgentId); if (hc) send(ws, { type: 'handover_context', content: hc }); attachWatcher(ws, session, newKey); } async function handleHandoverRequest(ws: any, session: BrowserSession): Promise { if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } if (session.sessionKey.includes(':web:public')) { send(ws, { type: 'error', code: 'HANDOVER_NOT_ALLOWED', message: 'Handover not available in public mode' }); return; } const cs = session.sm.get(); if (cs === 'SWITCHING' || cs === 'HANDOVER_DONE') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Handover request discarded' }); return; } const sk = session.sessionKey; const agentId = sk.split(':')[1]; const st = getSessionState(sk); if (st.handoverActive) { send(ws, { type: 'error', code: 'HANDOVER_IN_PROGRESS', message: 'Handover already in progress' }); return; } st.handoverActive = true; transitionChannel(sk, 'HANDOVER_PENDING', { reason: 'handover_request' }); try { await gatewayRequest('chat.send', { sessionKey: sk, message: 'Write HANDOVER.md to your workspace. Capture whatever a fresh version of you would need to pick up without losing context. Keep it brief. Reply when done.', idempotencyKey: crypto.randomUUID() }); await new Promise((resolve) => { const timer = setTimeout(() => { session._handoverResolve = null; resolve(); }, 45000); session._handoverResolve = () => { clearTimeout(timer); session._handoverResolve = null; resolve(); }; }); const content = readHandoverMd(agentId); st.handoverActive = false; transitionChannel(sk, 'HANDOVER_DONE', { reason: 'handover_written' }); broadcastToSession(sk, { type: 'handover_done', content }); } catch (err: any) { st.handoverActive = false; transitionChannel(sk, 'READY', { reason: 'handover_error' }); send(ws, { type: 'error', code: 'HANDOVER_ERROR', message: err.message }); } } async function handleNew(ws: any, session: BrowserSession, withHandover: boolean): Promise { if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } const cs = session.sm.get(); if (cs === 'SWITCHING') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Reset already in progress' }); return; } if (cs === 'HANDOVER_PENDING') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Handover in progress, please wait...' }); return; } const st = getSessionState(session.sessionKey); if ((cs === 'READY' || cs === 'FRESH') && Date.now() - st.lastIdleAt < 2000) { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Session reset ready, wait a moment...' }); return; } const agentId = session.sessionKey.split(':')[1]; const sessionPrompt = getSessionPrompt(agentId, session.sessionKey, session.user!); const GREETING = `${sessionPrompt}\n\nYou're starting fresh. Read HANDOVER.md in your workspace if it exists for your own context, then greet appropriately based on the session context above. Briefly mention what you were last working on and ask what to do next.`; const doReset = async () => { send(ws, hud.received('new_session', '/new received — resetting session', { previousAgent: session.sessionKey?.split(':')[1] || null, previousSessionKey: session.sessionKey })); transitionChannel(session.sessionKey!, 'RESETTING', { reason: 'new' }); await gatewayRequest('chat.send', { sessionKey: session.sessionKey!, message: `/new\n\n${GREETING}`, idempotencyKey: crypto.randomUUID() }); st.promptInjected = true; detachWatcher(session); setTimeout(() => { if (ws.readyState !== 1) return; attachWatcher(ws, session, session.sessionKey!); session.sm.transition('AGENT_RUNNING', { reason: 'new_greeting' }); setTimeout(() => { if (session.sm.get() === 'AGENT_RUNNING') { session.sm.transition('READY', { reason: 'new_greeting_done' }); getSessionState(session.sessionKey!).lastIdleAt = Date.now(); } }, 30000); }, 1000); broadcastToSession(session.sessionKey!, { type: 'new_ok' }); }; try { if (withHandover) { st.handoverActive = true; broadcastToSession(session.sessionKey, { type: 'handover_writing' }); await gatewayRequest('chat.send', { sessionKey: session.sessionKey, message: 'Write HANDOVER.md to your workspace. Capture whatever a fresh version of you would need to pick up without losing context. Keep it brief. Reply when done.', idempotencyKey: crypto.randomUUID() }); await new Promise((resolve) => { const timer = setTimeout(() => resolve(), 30000); session._handoverHandler = (ev: string, payload: any) => { if (ev === 'chat' && payload.state === 'final') { clearTimeout(timer); session._handoverHandler = null; resolve(); } }; }); const agentId = session.sessionKey.split(':')[1]; let handoverContent: string | null = null; try { handoverContent = fs.readFileSync(`/home/openclaw/.openclaw/workspace-${agentId}/HANDOVER.md`, 'utf8').trim(); } catch (_) {} st.handoverActive = false; broadcastToSession(session.sessionKey, { type: 'handover_done', content: handoverContent }); } await doReset(); } catch (err: any) { send(ws, { type: 'error', code: 'RESET_ERROR', message: err.message }); } } // --------------------------------------------------------------------------- // HTTP route handlers // --------------------------------------------------------------------------- const CORS: Record = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; function corsHeaders() { return new Headers(CORS); } function jsonResp(data: unknown, status = 200): Response { const h = corsHeaders(); h.set('Content-Type', 'application/json'); return new Response(JSON.stringify(data), { status, headers: h }); } function textResp(text: string, status = 200): Response { return new Response(text, { status, headers: corsHeaders() }); } // Takeover command sender — used by MCP bridge async function executeTakeoverCmd(token: string, cmd: string, args: any, timeoutMs = 10000): Promise { const entry = takeoverTokens.get(token); if (!entry) throw new Error('invalid or expired token'); if (!entry.ws) throw new Error('token registered but browser not connected — reload /dev in browser'); const cmdId = Math.random().toString(36).slice(2, 10); const clampedTimeout = Math.min(Math.max(timeoutMs, 1000), 60000); return new Promise((resolve) => { const timer = setTimeout(() => { entry.pending.delete(cmdId); resolve({ error: `timeout (${clampedTimeout / 1000}s)` }); }, clampedTimeout); entry.pending.set(cmdId, { resolve, timer }); send(entry.ws, { type: 'dev_cmd', cmdId, cmd, args: args || {} }); }); } // MCP server setup — direct bridge to takeoverTokens (no HTTP self-call) setupMcp({ sendCmd: executeTakeoverCmd }); async function handleHttp(req: Request): Promise { const url = new URL(req.url); const method = req.method; if (method === 'OPTIONS') return new Response(null, { status: 204, headers: corsHeaders() }); // MCP endpoint — streamable HTTP transport (auth via MCP API key in handleMcpRequest) if (url.pathname === '/mcp') { return handleMcpRequest(req); } // File download — serve files from agent workspace if (url.pathname.startsWith('/api/files/') && method === 'GET') { const token = url.searchParams.get('token') || req.headers.get('authorization')?.replace('Bearer ', '') || ''; const user = getUserForSessionToken(token); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); const filePath = decodeURIComponent(url.pathname.slice('/api/files'.length)); const WORKSPACE_ROOT = '/home/openclaw/.openclaw/workspace-titan/'; const resolved = require('path').resolve(filePath); if (!resolved.startsWith(WORKSPACE_ROOT)) return jsonResp({ error: 'Access denied' }, 403); const file = Bun.file(resolved); if (!await file.exists()) return jsonResp({ error: 'Not found' }, 404); const name = require('path').basename(resolved); return new Response(file, { headers: { 'Content-Type': file.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${name}"`, }, }); } // TTS — text-to-speech via ElevenLabs if (url.pathname === '/api/tts' && method === 'POST') { const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? ''; const user = getUserForSessionToken(token); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); try { const body = await req.json() as { text?: string }; const text = (body.text || '').trim(); if (!text) return jsonResp({ error: 'text is required' }, 400); if (text.length > 4096) return jsonResp({ error: 'text too long (max 4096 chars)' }, 400); const apiKey = process.env.ELEVENLABS_API_KEY; if (!apiKey) return jsonResp({ error: 'TTS not configured' }, 503); const VOICE_ID = 'pMsXgVXv3BLzUgSXRplE'; // Daniel — warm, clear, multilingual const MODEL_ID = 'eleven_multilingual_v2'; // Cache: hash text to reuse existing files const { createHash } = await import('crypto'); const hash = createHash('sha256').update(text).digest('hex').slice(0, 16); const ttsDir = '/home/openclaw/.openclaw/workspace-titan/media/tts'; const { mkdirSync } = await import('fs'); mkdirSync(ttsDir, { recursive: true }); const audioPath = `${ttsDir}/${hash}.mp3`; const file = Bun.file(audioPath); if (await file.exists()) { console.log(`[tts] cache hit: ${audioPath}`); return jsonResp({ url: `/api/files${audioPath}` }); } console.log(`[tts] generating: ${text.length} chars, voice=${VOICE_ID}`); const ttsRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, { method: 'POST', headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg', }, body: JSON.stringify({ text, model_id: MODEL_ID, voice_settings: { stability: 0.5, similarity_boost: 0.75 }, }), }); if (!ttsRes.ok) { const errText = await ttsRes.text(); console.error(`[tts] ElevenLabs error ${ttsRes.status}: ${errText}`); return jsonResp({ error: `TTS failed: ${ttsRes.status}` }, 502); } const audioBuffer = Buffer.from(await ttsRes.arrayBuffer()); await Bun.write(audioPath, audioBuffer); console.log(`[tts] saved: ${audioPath} (${audioBuffer.length} bytes)`); return jsonResp({ url: `/api/files${audioPath}` }); } catch (err: any) { console.error('[tts] error:', err.message); return jsonResp({ error: err.message }, 500); } } // Health if (url.pathname === '/health' && method === 'GET') return jsonResp({ status: 'ok', version: VERSION }); // Agents if (url.pathname === '/agents' && method === 'GET') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); try { return jsonResp(getAgentListWithModels()); } catch (err: any) { return jsonResp({ error: err.message }, 500); } } // Channel state for a single agent — private + public if (url.pathname.startsWith('/api/channels/') && method === 'GET') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); const agentId = url.pathname.split('/')[3]; if (!agentId) return jsonResp({ error: 'Missing agent ID' }, 400); function channelInfo(sessionKey: string): any { // 1. Active SM — authoritative const entry = sharedSMs.get(sessionKey); if (entry) { const st = sessionState.get(sessionKey); return { state: entry.sm.get(), connections: entry.connections, handoverActive: st?.handoverActive ?? false, activeTurnId: st?.activeTurnId ?? null, }; } // 2. Disk check — same logic as session-watcher.start() return { state: checkSessionOnDisk(agentId, sessionKey), connections: 0 }; } const privateKey = `agent:${agentId}:web:${user}`; // Public web channel — always web:public, never :main (main = internal/heartbeats) const publicKey = `agent:${agentId}:web:public`; return jsonResp({ agent: agentId, private: channelInfo(privateKey), public: channelInfo(publicKey), }); } // Auth — login with static token if (url.pathname === '/api/auth/nonce' && method === 'GET') { return jsonResp({ nonce: issueAuthNonce() }); } if (url.pathname === '/api/auth' && method === 'POST') { try { const { token: loginToken, nonce } = await req.json() as any; if (!nonce || !consumeAuthNonce(nonce)) return jsonResp({ error: 'Missing or expired nonce' }, 400); const user = getUserForToken(loginToken); if (!user) return jsonResp({ error: 'Invalid token' }, 401); return jsonResp({ sessionToken: issueSessionToken(user), user }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // Auth — verify OTP if (url.pathname === '/api/auth/verify' && method === 'POST') { try { const { challengeId, otp } = await req.json() as any; const user = verifyOtpChallenge(challengeId, String(otp)); if (!user) return jsonResp({ error: 'Invalid or expired OTP' }, 401); return jsonResp({ sessionToken: issueSessionToken(user), user }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // Auth — logout if (url.pathname === '/api/auth/logout' && method === 'POST') { try { const { sessionToken } = await req.json() as any; if (sessionToken) revokeSessionToken(sessionToken); return jsonResp({ ok: true }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // System access — pending requests (for /dev UI) if (url.pathname === '/api/system/pending' && method === 'GET') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); return jsonResp({ pending: getPendingRequests() }); } // System access — approve if (url.pathname === '/api/system/approve' && method === 'POST') { try { const { sessionToken, requestId } = await req.json() as any; const user = getUserForSessionToken(sessionToken); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); const systemToken = approveRequest(requestId, user); if (!systemToken) return jsonResp({ error: 'Request not found or expired' }, 404); return jsonResp({ ok: true }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // System access — deny if (url.pathname === '/api/system/deny' && method === 'POST') { try { const { sessionToken, requestId } = await req.json() as any; const user = getUserForSessionToken(sessionToken); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); denyRequest(requestId); return jsonResp({ ok: true }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // Dev broadcast — push state to browser via WS (used by MCP and direct HTTP) if (url.pathname === '/api/dev/broadcast' && method === 'POST') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); try { const payload = await req.json() as any; if (!payload.type) return jsonResp({ error: 'type required' }, 400); broadcastToBrowsers(payload); return jsonResp({ ok: true }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // Dev counter — push event to MCP subscriber (requires authenticated session) if (url.pathname === '/api/dev/counter' && method === 'POST') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); try { const { action, pick } = await req.json() as any; if (!['increment', 'decrement', 'timeout', 'pick'].includes(action)) return jsonResp({ error: 'Invalid action' }, 400); // Push to ALL active MCP keys (broadcast) pushEventAll({ type: 'counter', data: { action, ...(pick ? { pick } : {}) } }); return jsonResp({ ok: true }); } catch { return jsonResp({ error: 'Bad request' }, 400); } } // DB query proxy — execute SQL on local MariaDB (read-only) if (url.pathname === '/api/db/query' && method === 'POST') { try { const { query, database } = await req.json() as any; if (!query || !database) return jsonResp({ error: 'query and database required' }, 400); // Safety: only allow SELECT and DESCRIBE/SHOW const trimmed = query.trim().toUpperCase(); if (!trimmed.startsWith('SELECT') && !trimmed.startsWith('DESCRIBE') && !trimmed.startsWith('SHOW')) { return jsonResp({ error: 'Only SELECT/DESCRIBE/SHOW queries allowed' }, 400); } const proc = Bun.spawn(['mariadb', '-u', 'root', '-proot', database, '-e', query, '--batch'], { stdout: 'pipe', stderr: 'pipe', }); const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); await proc.exited; if (proc.exitCode !== 0) return jsonResp({ error: stderr.trim() || 'Query failed' }); return jsonResp({ result: stdout.trim() }); } catch (e: any) { return jsonResp({ error: e.message || 'DB error' }, 500); } } // Viewer — issue fstoken if (url.pathname === '/api/viewer/token' && method === 'POST') { const tok = viewerTokenFromReq(req); const user = tok && getUserForViewerToken(tok); if (!user) return textResp('Unauthorized', 401); return jsonResp({ fstoken: issueFstoken(user) }); } // Viewer — tree if (url.pathname === '/api/viewer/tree' && method === 'GET') { const tok = viewerTokenFromReq(req); if (!tok || !getUserForViewerToken(tok)) return textResp('Unauthorized', 401); const root = url.searchParams.get('root') || ''; try { if (!root) { const user = getUserForViewerToken(tok)!; const roots = (userViewerRoots as any)[user] || ['shared', 'titan']; return jsonResp({ dirs: roots, files: [] }); } const absDir = resolveViewerPath(root); const entries = fs.readdirSync(absDir, { withFileTypes: true }); const SKIP = new Set(['node_modules', '.git']); const dirs: string[] = []; const files: any[] = []; for (const e of entries) { if (e.name.startsWith('.') || SKIP.has(e.name)) continue; if (e.isDirectory()) { dirs.push(e.name); } else if (e.isFile()) { const ext = path.extname(e.name).toLowerCase(); if (!VIEWER_SKIP_EXT.has(ext)) { const abs = path.join(absDir, e.name); let mtime = 0; try { mtime = fs.statSync(abs).mtimeMs; } catch (_) {} files.push({ name: e.name, path: absToViewerPath(abs), mtime }); } } } dirs.sort(); files.sort((a, b) => a.name.localeCompare(b.name)); // Watch this directory for new/deleted files const treeUser = getUserForViewerToken(tok); if (treeUser) watchViewerDir(absDir, treeUser); return jsonResp({ dirs, files }); } catch (err: any) { return textResp(err.message, err.status || 500); } } // Viewer — file if (url.pathname === '/api/viewer/file' && (method === 'GET' || method === 'HEAD')) { const tok = viewerTokenFromReq(req); if (!tok || !getUserForViewerToken(tok)) return textResp('Unauthorized', 401); const rel = url.searchParams.get('path'); if (!rel) return textResp('Missing path param', 400); try { const abs = resolveViewerPath(rel); const ext = path.extname(abs).toLowerCase(); if (VIEWER_SKIP_EXT.has(ext)) return textResp('Binary file type not supported', 415); const stat = fs.statSync(abs); if (stat.size > VIEWER_MAX_BYTES) return textResp('File too large', 413); watchViewerPath(abs, getUserForViewerToken(tok)!); const ct = ext === '.pdf' ? 'application/pdf' : 'text/plain; charset=utf-8'; const h = corsHeaders(); h.set('Content-Type', ct); if (url.searchParams.has('dl')) { const fname = path.basename(abs); h.set('Content-Disposition', `attachment; filename="${fname}"`); } if (method === 'HEAD') return new Response(null, { status: 200, headers: h }); return new Response(Bun.file(abs), { headers: h }); } catch (err: any) { return textResp(err.message, err.status || 500); } } // ── Previous session history (REST) ── if (url.pathname === '/api/session-history' && method === 'GET') { const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); if (!user) return jsonResp({ error: 'Unauthorized' }, 401); const agent = url.searchParams.get('agent'); const mode = url.searchParams.get('mode') || 'private'; if (!agent) return jsonResp({ error: 'missing agent param' }, 400); const skip = parseInt(url.searchParams.get('skip') || '0') || 0; const count = Math.min(parseInt(url.searchParams.get('count') || '1') || 1, 10); const files = findPreviousSessionFiles(agent, count, skip); if (!files.length) return jsonResp({ sessions: [], hasMore: false }); const sessions = files.map(f => ({ entries: parseSessionFile(f.path), resetTimestamp: f.resetTimestamp, })); // Check if there are more sessions beyond this batch const moreFiles = findPreviousSessionFiles(agent, 1, skip + count); return jsonResp({ sessions, hasMore: moreFiles.length > 0 }); } return textResp('Not Found', 404); } // --------------------------------------------------------------------------- // Bun.serve — HTTP + WebSocket // --------------------------------------------------------------------------- Bun.serve({ port: PORT, tls: (() => { const keyPath = process.env.SSL_KEY || '../web-frontend/ssl/key.pem'; const certPath = process.env.SSL_CERT || '../web-frontend/ssl/cert.pem'; if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { console.log('🔒 TLS enabled'); return { key: Bun.file(keyPath), cert: Bun.file(certPath) }; } return undefined; })(), fetch(req: Request, server: any): Response | Promise { const url = new URL(req.url); if (url.pathname === '/ws') { const ok = server.upgrade(req); if (ok) return undefined as any; return textResp('WebSocket upgrade failed', 400); } return handleHttp(req); }, websocket: { open(ws: any) { const clientId = Math.random().toString(36).slice(2, 10); // Connection SM is per-WS; channel SM is assigned on auth/connect const connSm = createConnectionSM(clientId, (event: object) => send(ws, event)); // Placeholder channel SM — replaced when joining a channel const sm = createChannelSM(`pending-${clientId}`, (event: object) => send(ws, event)); browserSessions.set(ws, { user: null, sessionKey: null, clientId, sm, connSm, watcher: null }); }, async message(ws: any, data: string | Buffer) { const session = browserSessions.get(ws); if (!session) return; let msg: any; try { msg = JSON.parse(typeof data === 'string' ? data : data.toString()); } catch (err: any) { send(ws, { type: 'error', code: 'PARSE_ERROR', message: err.message }); return; } try { switch (msg.type) { case 'connect': await handleConnect(ws, session, msg); break; case 'auth': await handleAuth(ws, session, msg); break; case 'message': await handleMessage(ws, session, msg); break; case 'stop': await handleStopKill(ws, session, 'stop'); break; case 'kill': await handleStopKill(ws, session, 'kill'); break; case 'switch_agent': await handleSwitchAgent(ws, session, msg); break; case 'handover_request': await handleHandoverRequest(ws, session); break; case 'new': await handleNew(ws, session, false); break; case 'new_with_handover': await handleNew(ws, session, true); break; case 'cancel_handover': { const cs = session.sm.get(); if (cs === 'HANDOVER_DONE' || cs === 'HANDOVER_PENDING') { const st = getSessionState(session.sessionKey!); st.handoverActive = false; transitionChannel(session.sessionKey!, 'READY', { reason: 'handover_cancelled' }); } break; } case 'dev_takeover': { const token = msg.token; if (!token) { send(ws, { type: 'error', code: 'MISSING_TOKEN' }); break; } // Re-attach WS to existing token or create new const existing = takeoverTokens.get(token); if (existing) { existing.ws = ws; // Resolve any pending commands that were waiting for (const [, p] of existing.pending) { clearTimeout(p.timer); p.resolve({ error: 'reconnected' }); } existing.pending.clear(); } else { takeoverTokens.set(token, { ws, pending: new Map() }); } persistTakeoverTokens(); if (session.user) refreshMcpKeyForUser(session.user, token); send(ws, { type: 'dev_takeover_ok', token }); break; } case 'dev_cmd_result': { const { cmdId, result, error } = msg; for (const [, entry] of takeoverTokens) { const p = entry.pending.get(cmdId); if (p) { clearTimeout(p.timer); entry.pending.delete(cmdId); p.resolve(error ? { error } : { result }); break; } } break; } case 'disco_request': disconnectGateway(); send(ws, { type: 'disco_ok' }); break; case 'disco_chat_request': send(ws, { type: 'disco_chat_ok' }); setTimeout(() => ws.close(1001, 'disco_chat'), 50); break; case 'ping': send(ws, { type: 'pong' }); break; case 'stats_request': fetchOpenRouterStats().then(stats => send(ws, { type: 'stats', ...stats })).catch(err => send(ws, { type: 'stats', error: err.message })); break; default: send(ws, { type: 'error', code: 'UNKNOWN_MESSAGE', message: `Unknown type: ${msg.type}` }); } } catch (err: any) { send(ws, { type: 'error', code: 'HANDLER_ERROR', message: err.message }); } }, close(ws: any) { const session = browserSessions.get(ws); if (session) detachWatcher(session); if (session?.sessionKey) releaseSharedSM(session.sessionKey); else if (session?.sm) session.sm.destroy(); // pre-auth placeholder SM if (session?.user) unwatchViewerUser(session.user); browserSessions.delete(ws); // Detach WS from takeover tokens but keep tokens persisted for (const [, entry] of takeoverTokens) { if (entry.ws === ws) { for (const [, p] of entry.pending) { clearTimeout(p.timer); p.resolve({ error: 'disconnected' }); } entry.pending.clear(); entry.ws = null; } } }, }, }); // --------------------------------------------------------------------------- // Gateway connect with retry // --------------------------------------------------------------------------- (async function connectWithRetry() { while (true) { try { await connectToGateway(); console.log('🤝 Gateway connected'); break; } catch (err: any) { console.error('Gateway connection failed:', err.message, '— retrying in 5s...'); await Bun.sleep(5000); } } })(); console.log(`🌐 Hermes Gateway listening on port ${PORT}`); console.log(` Health: http://localhost:${PORT}/health`); console.log(` WS: ws://localhost:${PORT}/ws`);