diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 208c927..4fd154f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,7 +8,8 @@ import FloatingTerminal from './components/FloatingTerminal.vue' import FloatingResponse from './components/FloatingResponse.vue' import FloatingVoice from './components/FloatingVoice.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue' -import { initWebMCP, getWebMCP, autoConnect, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp' +import { initWebMCP, getWebMCP } from './services/webmcp' +import { initTorch, destroyTorch } from './services/torch' import { endpoints } from './config/endpoints' import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry' import { setTerminalControls } from './services/tools/handlers/terminalHandlers' @@ -320,29 +321,12 @@ onMounted(async () => { }) } - // Auto-connect to WebMCP if not connected - const webmcp = getWebMCP() - if (!webmcp?.isConnected) { - // Try auto-connect (works in both dev and HTTPS via API proxy) - const connected = await autoConnect() - if (connected) { - canvasStore.showNotification('WebMCP connected!', 'success') - } else { - // Fallback to polling (for older WebMCP versions without /token endpoint) - console.log('[App] Auto-connect failed, falling back to token polling...') - startTokenPolling(async (token) => { - console.log('[App] Token received, connecting...') - const success = await connectWithToken(token) - if (success) { - canvasStore.showNotification('WebMCP connected!', 'success') - } - }) - } - } + // Initialize torch system (handles MCP connection based on torch state) + await initTorch() }) onUnmounted(() => { - stopTokenPolling() + destroyTorch() if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout) if (processingTimeout) clearTimeout(processingTimeout) if (sessionStartTimeout) clearTimeout(sessionStartTimeout) diff --git a/frontend/src/services/torch.ts b/frontend/src/services/torch.ts index d1f21ef..47cef9e 100644 --- a/frontend/src/services/torch.ts +++ b/frontend/src/services/torch.ts @@ -1,28 +1,106 @@ import { useTorchStore } from '../stores/torch' -import { getWebMCP } from './webmcp' +import { getWebMCP, autoConnect } from './webmcp' +import { endpoints } from '../config/endpoints' + +const API_BASE = endpoints.api + +let pollingInterval: number | null = null +let clientId: string | null = null /** - * Request the torch from the server + * Register this browser as a torch client + */ +export async function registerClient(): Promise { + try { + const res = await fetch(`${API_BASE}/torch/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userAgent: navigator.userAgent, + hostname: window.location.hostname + }) + }) + + if (!res.ok) { + console.error('[Torch] Failed to register:', res.status) + return null + } + + const data = await res.json() + clientId = data.id + + const torchStore = useTorchStore() + torchStore.setClientId(data.id) + + console.log(`[Torch] Registered as ${data.id}, hasTorch: ${data.hasTorch}`) + + // If we have the torch, connect to MCP + if (data.hasTorch) { + torchStore.setTorchState(data.id) + await connectToMCP() + } + + return data.id + } catch (e) { + console.error('[Torch] Error registering:', e) + return null + } +} + +/** + * Unregister this browser + */ +export async function unregisterClient(): Promise { + if (!clientId) return + + try { + await fetch(`${API_BASE}/torch/client/${clientId}`, { + method: 'DELETE' + }) + console.log('[Torch] Unregistered') + } catch (e) { + console.error('[Torch] Error unregistering:', e) + } + + clientId = null +} + +/** + * Request the torch */ export async function requestTorch(): Promise { - const torchStore = useTorchStore() - const webmcp = getWebMCP() - - if (!webmcp || !webmcp.isConnected) { - console.error('[Torch] WebMCP not connected') + if (!clientId) { + console.error('[Torch] Not registered') return false } + const torchStore = useTorchStore() torchStore.setRequesting(true) try { - // Send request to server via WebSocket - webmcp.socket?.send(JSON.stringify({ - type: 'requestTorch' - })) + const res = await fetch(`${API_BASE}/torch/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId }) + }) + + if (!res.ok) { + console.error('[Torch] Failed to request:', res.status) + torchStore.setRequesting(false) + return false + } + + const data = await res.json() + console.log('[Torch] Torch granted!') + + // Update state and connect to MCP + torchStore.setTorchState(clientId) + torchStore.setRequesting(false) + await connectToMCP() + return true } catch (e) { - console.error('[Torch] Error requesting torch:', e) + console.error('[Torch] Error requesting:', e) torchStore.setRequesting(false) return false } @@ -32,72 +110,161 @@ export async function requestTorch(): Promise { * Release the torch */ export async function releaseTorch(): Promise { - const torchStore = useTorchStore() - const webmcp = getWebMCP() - - if (!webmcp || !webmcp.isConnected) { - console.error('[Torch] WebMCP not connected') + if (!clientId) { + console.error('[Torch] Not registered') return false } + const torchStore = useTorchStore() + if (!torchStore.hasTorch) { console.warn('[Torch] Cannot release - do not have torch') return false } try { - webmcp.socket?.send(JSON.stringify({ - type: 'releaseTorch' - })) + const res = await fetch(`${API_BASE}/torch/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId }) + }) + + if (!res.ok) { + console.error('[Torch] Failed to release:', res.status) + return false + } + + console.log('[Torch] Torch released') + + // Update state and disconnect from MCP + torchStore.setTorchState(null) + disconnectFromMCP() + return true } catch (e) { - console.error('[Torch] Error releasing torch:', e) + console.error('[Torch] Error releasing:', e) return false } } /** - * Initialize torch event handlers - * Call this after WebMCP is connected + * Fetch current torch state from server */ -export function initTorchHandlers() { - const torchStore = useTorchStore() - const webmcp = getWebMCP() +export async function fetchTorchState(): Promise { + try { + const res = await fetch(`${API_BASE}/torch`) + if (!res.ok) return + const data = await res.json() + const torchStore = useTorchStore() + + torchStore.setClients(data.clients) + + // Check if torch holder changed + const hadTorch = torchStore.hasTorch + const hasTorchNow = data.holderId === clientId + + if (hadTorch && !hasTorchNow) { + // We lost the torch - disconnect + console.log('[Torch] Lost torch, disconnecting from MCP') + torchStore.setTorchState(data.holderId) + disconnectFromMCP() + } else if (!hadTorch && hasTorchNow) { + // We got the torch - connect + console.log('[Torch] Got torch, connecting to MCP') + torchStore.setTorchState(data.holderId) + await connectToMCP() + } else { + torchStore.setTorchState(data.holderId) + } + } catch (e) { + // Silently ignore polling errors + } +} + +/** + * Start polling for torch state changes + */ +export function startTorchPolling(intervalMs: number = 2000) { + if (pollingInterval) return + + console.log('[Torch] Starting polling...') + pollingInterval = window.setInterval(fetchTorchState, intervalMs) +} + +/** + * Stop polling + */ +export function stopTorchPolling() { + if (pollingInterval) { + window.clearInterval(pollingInterval) + pollingInterval = null + console.log('[Torch] Polling stopped') + } +} + +/** + * Connect to MCP (when we have the torch) + */ +async function connectToMCP(): Promise { + const webmcp = getWebMCP() if (!webmcp) { console.error('[Torch] WebMCP not initialized') return } - // Listen for torch state updates - webmcp.on('torchUpdate', (data: { holderId: string | null; clients: any[] }) => { - console.log('[Torch] State updated:', data) - torchStore.setClients(data.clients) - torchStore.setRequesting(false) - }) + if (webmcp.isConnected) { + console.log('[Torch] Already connected to MCP') + return + } - // Listen for client ID assignment - webmcp.on('clientId', (data: { id: string }) => { - console.log('[Torch] Client ID assigned:', data.id) - torchStore.setClientId(data.id) - }) - - // Listen for torch granted - webmcp.on('torchGranted', () => { - console.log('[Torch] Torch granted!') - torchStore.setRequesting(false) - }) - - // Listen for torch denied - webmcp.on('torchDenied', (data: { reason: string }) => { - console.warn('[Torch] Torch denied:', data.reason) - torchStore.setRequesting(false) - }) - - // Listen for torch released - webmcp.on('torchReleased', () => { - console.log('[Torch] Torch released') - }) - - console.log('[Torch] Handlers initialized') + console.log('[Torch] Connecting to MCP...') + const success = await autoConnect() + if (success) { + console.log('[Torch] Connected to MCP') + } else { + console.error('[Torch] Failed to connect to MCP') + } +} + +/** + * Disconnect from MCP (when we lose the torch) + */ +function disconnectFromMCP(): void { + const webmcp = getWebMCP() + if (!webmcp) return + + if (webmcp.isConnected && typeof webmcp.disconnect === 'function') { + console.log('[Torch] Disconnecting from MCP...') + webmcp.disconnect() + } +} + +/** + * Initialize torch system + */ +export async function initTorch(): Promise { + // Register this client + await registerClient() + + // Start polling for state changes + startTorchPolling() + + // Cleanup on page unload + window.addEventListener('beforeunload', () => { + stopTorchPolling() + // Note: unregisterClient is async, may not complete before unload + // Server should handle stale clients via timeout + }) +} + +/** + * Cleanup torch system + */ +export async function destroyTorch(): Promise { + stopTorchPolling() + await unregisterClient() + + const torchStore = useTorchStore() + torchStore.reset() } diff --git a/frontend/src/services/webmcp.ts b/frontend/src/services/webmcp.ts index 2eec823..42096d9 100644 --- a/frontend/src/services/webmcp.ts +++ b/frontend/src/services/webmcp.ts @@ -1,5 +1,4 @@ import { useCanvasStore } from '../stores/canvas' -import { useTorchStore } from '../stores/torch' import { endpoints, isSecure, wsProtocol, hostname } from '../config/endpoints' // WebMCP HTTP API base for direct token requests @@ -118,43 +117,6 @@ function setupEventHandlers() { updateConnectionInfo() }) ) - - // Torch events - const torchStore = useTorchStore() - - eventUnsubscribers.push( - webmcpInstance.on('clientId', (data: { id: string }) => { - console.log('[WebMCP] Client ID assigned:', data.id) - torchStore.setClientId(data.id) - }) - ) - - eventUnsubscribers.push( - webmcpInstance.on('torchUpdate', (data: { holderId: string | null; clients: any[] }) => { - console.log('[WebMCP] Torch state updated:', data.holderId) - torchStore.setClients(data.clients) - }) - ) - - eventUnsubscribers.push( - webmcpInstance.on('torchGranted', () => { - console.log('[WebMCP] Torch granted!') - torchStore.setRequesting(false) - }) - ) - - eventUnsubscribers.push( - webmcpInstance.on('torchDenied', (data: { reason: string }) => { - console.warn('[WebMCP] Torch denied:', data.reason) - torchStore.setRequesting(false) - }) - ) - - eventUnsubscribers.push( - webmcpInstance.on('torchReleased', () => { - console.log('[WebMCP] Torch released') - }) - ) } function updateConnectionInfo() { diff --git a/server/routes/index.ts b/server/routes/index.ts index 5fec066..bb8f32b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -11,6 +11,7 @@ import { handleWhisperRoutes } from './whisper' import { handleRecordingsRoutes } from './recordings' import { handleClaudeStatus } from './claude-status' import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git' +import { handleTorch } from './torch' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -50,6 +51,12 @@ export async function handleRequest(req: Request): Promise { if (res) return res } + // Torch (multi-browser MCP control) + if (path.startsWith('/api/torch')) { + const res = await handleTorch(req, url) + if (res) return res + } + // Claude Code status (thinking/idle) if (path === '/api/claude-status') { const res = await handleClaudeStatus(req) diff --git a/server/routes/torch.ts b/server/routes/torch.ts new file mode 100644 index 0000000..6fa7e60 --- /dev/null +++ b/server/routes/torch.ts @@ -0,0 +1,159 @@ +import { jsonResponse, errorResponse } from '../utils/cors' + +// Torch state - which client has control +interface TorchClient { + id: string + userAgent: string + hostname: string + connectedAt: Date +} + +let torchState: { + holderId: string | null + clients: Map +} = { + holderId: null, + clients: new Map() +} + +// Generate unique client ID +let clientIdCounter = 1 +function generateClientId(): string { + return `client_${clientIdCounter++}_${Date.now().toString(36)}` +} + +// GET /api/torch - Get current torch state +export async function handleTorchGet() { + const clients = Array.from(torchState.clients.values()).map(c => ({ + ...c, + connectedAt: c.connectedAt.toISOString(), + hasTorch: c.id === torchState.holderId + })) + + return jsonResponse({ + holderId: torchState.holderId, + clients + }) +} + +// POST /api/torch/register - Register a new client +export async function handleTorchRegister(req: Request) { + const body = await req.json() + const { userAgent, hostname } = body + + const id = generateClientId() + const client: TorchClient = { + id, + userAgent: userAgent || 'Unknown', + hostname: hostname || 'Unknown', + connectedAt: new Date() + } + + torchState.clients.set(id, client) + + // Auto-assign torch if no one has it + const shouldHaveTorch = !torchState.holderId + if (shouldHaveTorch) { + torchState.holderId = id + } + + console.log(`[Torch] Client registered: ${id} (torch: ${shouldHaveTorch})`) + + return jsonResponse({ + id, + hasTorch: shouldHaveTorch + }) +} + +// POST /api/torch/request - Request the torch +export async function handleTorchRequest(req: Request) { + const body = await req.json() + const { clientId } = body + + if (!clientId || !torchState.clients.has(clientId)) { + return errorResponse('Invalid client ID', 400) + } + + const previousHolder = torchState.holderId + torchState.holderId = clientId + + console.log(`[Torch] Torch transferred: ${previousHolder} -> ${clientId}`) + + return jsonResponse({ + success: true, + previousHolder + }) +} + +// POST /api/torch/release - Release the torch +export async function handleTorchRelease(req: Request) { + const body = await req.json() + const { clientId } = body + + if (torchState.holderId !== clientId) { + return errorResponse('You do not have the torch', 400) + } + + torchState.holderId = null + console.log(`[Torch] Torch released by: ${clientId}`) + + return jsonResponse({ success: true }) +} + +// DELETE /api/torch/client/:id - Unregister a client +export async function handleTorchUnregister(clientId: string) { + if (!torchState.clients.has(clientId)) { + return errorResponse('Client not found', 404) + } + + torchState.clients.delete(clientId) + + // If this client had the torch, release it + if (torchState.holderId === clientId) { + torchState.holderId = null + + // Auto-assign to next client if any + const nextClient = torchState.clients.keys().next().value + if (nextClient) { + torchState.holderId = nextClient + console.log(`[Torch] Auto-assigned to: ${nextClient}`) + } + } + + console.log(`[Torch] Client unregistered: ${clientId}`) + + return jsonResponse({ success: true }) +} + +// Main handler +export async function handleTorch(req: Request, url: URL) { + const path = url.pathname + + // GET /api/torch - Get state + if (req.method === 'GET' && path === '/api/torch') { + return handleTorchGet() + } + + // POST /api/torch/register - Register client + if (req.method === 'POST' && path === '/api/torch/register') { + return handleTorchRegister(req) + } + + // POST /api/torch/request - Request torch + if (req.method === 'POST' && path === '/api/torch/request') { + return handleTorchRequest(req) + } + + // POST /api/torch/release - Release torch + if (req.method === 'POST' && path === '/api/torch/release') { + return handleTorchRelease(req) + } + + // DELETE /api/torch/client/:id - Unregister + const unregisterMatch = path.match(/^\/api\/torch\/client\/(.+)$/) + if (req.method === 'DELETE' && unregisterMatch) { + return handleTorchUnregister(unregisterMatch[1]) + } + + return null +}