serve.js
1 /** 2 * antigravity serve — Anthropic-compatible `/v1/messages` proxy server. 3 * 4 * Starts an HTTP server that accepts Anthropic Messages API requests, 5 * forwards them to a running Antigravity app via CDP, polls for the response, 6 * and returns it in Anthropic format. 7 * 8 * Usage: 9 * opencli antigravity serve --port 8082 10 * ANTHROPIC_BASE_URL=http://localhost:8082 claude 11 */ 12 import { createServer } from 'node:http'; 13 import { CDPBridge } from '@jackwener/opencli/browser/cdp'; 14 import { resolveElectronEndpoint } from '@jackwener/opencli/launcher'; 15 import { EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors'; 16 // ─── Helpers ───────────────────────────────────────────────────────── 17 function generateMsgId() { 18 const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 19 let id = 'msg_'; 20 for (let i = 0; i < 24; i++) 21 id += chars[Math.floor(Math.random() * chars.length)]; 22 return id; 23 } 24 function estimateTokens(text) { 25 // Rough approximation: ~4 chars per token for English, ~2 for CJK 26 return Math.max(1, Math.ceil(text.length / 3)); 27 } 28 function extractTextContent(content) { 29 if (typeof content === 'string') 30 return content; 31 return content 32 .filter(b => b.type === 'text' && b.text) 33 .map(b => b.text) 34 .join('\n'); 35 } 36 function readBody(req) { 37 return new Promise((resolve, reject) => { 38 const chunks = []; 39 req.on('data', (c) => chunks.push(c)); 40 req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); 41 req.on('error', reject); 42 }); 43 } 44 function jsonResponse(res, status, data) { 45 const body = JSON.stringify(data); 46 res.writeHead(status, { 47 'Content-Type': 'application/json', 48 'Access-Control-Allow-Origin': '*', 49 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 50 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization', 51 }); 52 res.end(body); 53 } 54 function sleep(ms) { 55 return new Promise(resolve => setTimeout(resolve, ms)); 56 } 57 function parseTimeoutValue(val, label, fallback) { 58 if (val === undefined) { 59 return fallback; 60 } 61 const parsed = typeof val === 'number' ? val : parseInt(String(val), 10); 62 if (Number.isNaN(parsed) || parsed <= 0) { 63 console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`); 64 return fallback; 65 } 66 return parsed; 67 } 68 function parseEnvTimeout(envVar, fallback) { 69 return parseTimeoutValue(process.env[envVar], envVar, fallback); 70 } 71 // ─── DOM helpers ───────────────────────────────────────────────────── 72 /** 73 * Click the 'New Conversation' button to reset context. 74 */ 75 async function startNewConversation(page) { 76 await page.evaluate(` 77 (() => { 78 const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]'); 79 if (btn) btn.click(); 80 })() 81 `); 82 await sleep(1000); // Give UI time to clear 83 } 84 /** 85 * Switch the active model in Antigravity UI. 86 */ 87 async function switchModel(page, anthropicModelId) { 88 // Map standard model IDs to Antigravity UI names based on actual UI 89 let targetName = 'claude sonnet 4.6'; // Default fallback 90 const id = anthropicModelId.toLowerCase(); 91 if (id.includes('sonnet')) { 92 targetName = 'claude sonnet 4.6'; 93 } 94 else if (id.includes('opus')) { 95 targetName = 'claude opus 4.6'; 96 } 97 else if (id.includes('gemini') && id.includes('pro')) { 98 targetName = 'gemini 3.1 pro (high)'; 99 } 100 else if (id.includes('gemini') && id.includes('flash')) { 101 targetName = 'gemini 3 flash'; 102 } 103 else if (id.includes('gpt')) { 104 targetName = 'gpt-oss 120b'; 105 } 106 try { 107 await page.evaluate(` 108 async () => { 109 const targetModelName = ${JSON.stringify(targetName)}; 110 const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]'); 111 if (!trigger) return; // Silent fail if UI changed 112 113 // Open dropdown only if not already selected 114 if (trigger.innerText.toLowerCase().includes(targetModelName)) return; 115 116 trigger.click(); 117 await new Promise(r => setTimeout(r, 200)); 118 119 const spans = Array.from(document.querySelectorAll('[role="dialog"] span')); 120 const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName)); 121 if (target) { 122 const optionNode = target.closest('.cursor-pointer') || target; 123 optionNode.click(); 124 } else { 125 // Close if not found 126 trigger.click(); 127 } 128 } 129 `); 130 await sleep(500); // Wait for switch 131 } 132 catch (err) { 133 console.error(`[serve] Warning: Could not switch to model ${targetName}:`, err); 134 } 135 } 136 /** 137 * Check if the Antigravity UI is currently generating a response 138 * by looking for Stop/Cancel buttons or loading indicators. 139 */ 140 async function isGenerating(page) { 141 const result = await page.evaluate(` 142 (() => { 143 // Look for a cancel/stop button in the UI 144 const cancelBtn = document.querySelector('button[aria-label*="cancel" i], button[aria-label*="stop" i], button[title*="cancel" i], button[title*="stop" i]'); 145 return !!cancelBtn; 146 })() 147 `); 148 return Boolean(result); 149 } 150 /** 151 * Walk from the scroll container and find the deepest element that 152 * has multiple non-empty children (our message container). 153 */ 154 function findMessageContainer(root, depth = 0) { 155 if (!root || depth > 12) 156 return null; 157 const nonEmpty = Array.from(root.children).filter(c => c.innerText?.trim().length > 5); 158 if (nonEmpty.length >= 2) 159 return root; 160 if (nonEmpty.length === 1) 161 return findMessageContainer(nonEmpty[0], depth + 1); 162 return root; 163 } 164 // ─── Antigravity CDP Operations ────────────────────────────────────── 165 /** 166 * Get the full chat text for change-detection polling. 167 */ 168 async function getConversationText(page) { 169 const text = await page.evaluate(` 170 (() => { 171 const container = document.getElementById('conversation'); 172 if (!container) return ''; 173 // Read only the first child div (actual chat content), 174 // skipping UI chrome like file change panels, model selectors, etc. 175 const chatContent = container.children[0]; 176 return chatContent ? chatContent.innerText : container.innerText; 177 })() 178 `); 179 return String(text ?? ''); 180 } 181 /** 182 * Get the text of the last assistant reply by navigating to the message container 183 * and extracting the last non-empty message block. 184 */ 185 async function getLastAssistantReply(page, userText) { 186 const text = await page.evaluate(` 187 (() => { 188 const conv = document.getElementById('conversation')?.children[0]; 189 const scroll = conv?.querySelector('.overflow-y-auto'); 190 191 // Walk down until we find a container with multiple message siblings 192 function findMsgContainer(el, depth) { 193 if (!el || depth > 12) return null; 194 const nonEmpty = Array.from(el.children).filter(c => c.innerText && c.innerText.trim().length > 5); 195 if (nonEmpty.length >= 2) return el; 196 if (nonEmpty.length === 1) return findMsgContainer(nonEmpty[0], depth + 1); 197 return null; 198 } 199 200 const container = findMsgContainer(scroll || conv, 0); 201 if (!container) return ''; 202 203 // Get all non-empty children (skip trailing empty UI divs) 204 const msgs = Array.from(container.children).filter( 205 c => c.innerText && c.innerText.trim().length > 5 206 ); 207 208 if (msgs.length === 0) return ''; 209 210 // The last element is the last assistant reply 211 const last = msgs[msgs.length - 1]; 212 return last.innerText || ''; 213 })() 214 `); 215 let reply = String(text ?? '').trim(); 216 // Strip echoed user message from the top (Antigravity sometimes includes it) 217 if (userText && reply.startsWith(userText)) { 218 reply = reply.slice(userText.length).trim(); 219 } 220 // Strip thinking block: "Thought for Xs\n..." at the start 221 reply = reply.replace(/^Thought for[^\n]*\n+/i, '').trim(); 222 // Strip "Copy" button text at the end 223 reply = reply.replace(/\s*\bCopy\b\s*$/m, '').trim(); 224 // De-duplicate trailing repeated content (e.g., "OK\n\nOK" → "OK") 225 const half = Math.floor(reply.length / 2); 226 const firstHalf = reply.slice(0, half).trim(); 227 const secondHalf = reply.slice(half).trim(); 228 if (firstHalf && firstHalf === secondHalf) { 229 reply = firstHalf; 230 } 231 return reply; 232 } 233 async function sendMessage(page, message, bridge) { 234 if (!bridge) { 235 // Fallback: use JS-based approach 236 await page.evaluate(` 237 (() => { 238 const container = document.getElementById('antigravity.agentSidePanelInputBox'); 239 const editor = container?.querySelector('[data-lexical-editor="true"]'); 240 if (!editor) throw new Error('Could not find input box'); 241 editor.focus(); 242 document.execCommand('insertText', false, ${JSON.stringify(message)}); 243 })() 244 `); 245 await sleep(500); 246 await page.pressKey('Enter'); 247 return; 248 } 249 // Get the bounding box of the Lexical editor for a physical mouse click 250 const rect = await page.evaluate(` 251 (() => { 252 const container = document.getElementById('antigravity.agentSidePanelInputBox'); 253 if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox'); 254 const editor = container.querySelector('[data-lexical-editor="true"]'); 255 if (!editor) throw new Error('Could not find Antigravity input box'); 256 const r = editor.getBoundingClientRect(); 257 return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); 258 })() 259 `); 260 const { x, y } = JSON.parse(String(rect)); 261 // Physical mouse click to give the element real browser focus 262 await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }); 263 await sleep(50); 264 await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); 265 await sleep(200); 266 // Inject text at the CDP level (no deprecated execCommand) 267 await bridge.send('Input.insertText', { text: message }); 268 await sleep(300); 269 // Send Enter via native CDP key event 270 await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); 271 await sleep(50); 272 await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }); 273 } 274 async function waitForReply(page, beforeText, opts = {}) { 275 const timeout = opts.timeout ?? 120_000; // 2 minutes max 276 const pollInterval = opts.pollInterval ?? 500; // 500ms polling 277 const deadline = Date.now() + timeout; 278 // Wait a bit to ensure the UI transitions to "generating" state after we hit Enter 279 await sleep(1000); 280 let hasStartedGenerating = false; 281 let lastText = beforeText; 282 let stableCount = 0; 283 const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback 284 let reconnectCount = 0; 285 while (Date.now() < deadline) { 286 try { 287 const generating = await isGenerating(page); 288 const currentText = await getConversationText(page); 289 const textChanged = currentText !== beforeText && currentText.length > 0; 290 if (generating) { 291 hasStartedGenerating = true; 292 stableCount = 0; // Reset stability while generating 293 } 294 else { 295 if (hasStartedGenerating) { 296 // It actively generated and now it stopped -> DONE 297 // Provide a small buffer to let React render the final message fully 298 await sleep(500); 299 return page; 300 } 301 // Fallback: If it never showed "Generating/Cancel", but text changed and is stable 302 if (textChanged) { 303 if (currentText === lastText) { 304 stableCount++; 305 if (stableCount >= stableThreshold) { 306 return page; // Text has been stable for 2 seconds -> DONE 307 } 308 } 309 else { 310 stableCount = 0; 311 lastText = currentText; 312 } 313 } 314 } 315 } 316 catch (err) { 317 const msg = err.message || String(err); 318 const isSessionLoss = /closed|lost|not open|websocket/i.test(msg); 319 if (opts.reconnect && isSessionLoss && reconnectCount < 2) { 320 reconnectCount++; 321 console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`); 322 try { 323 page = await opts.reconnect(); 324 // Reset stability tracking after reconnect 325 stableCount = 0; 326 lastText = beforeText; 327 continue; 328 } 329 catch (reconnectErr) { 330 console.error(`[serve] Reconnection failed: ${reconnectErr.message}`); 331 throw err; // Throw original error if reconnection itself fails 332 } 333 } 334 throw err; 335 } 336 await sleep(pollInterval); 337 } 338 throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`); 339 } 340 // ─── Request Handlers ──────────────────────────────────────────────── 341 async function handleMessages(body, page, opts = {}) { 342 const { bridge, timeout, reconnect } = opts; 343 // Extract the last user message 344 const userMessages = body.messages.filter(m => m.role === 'user'); 345 if (userMessages.length === 0) { 346 throw new Error('No user message found in request'); 347 } 348 const lastUserMsg = userMessages[userMessages.length - 1]; 349 const userText = extractTextContent(lastUserMsg.content); 350 if (!userText.trim()) { 351 throw new Error('Empty user message'); 352 } 353 // Optimization 1: New conversation if this is the first message in the session 354 if (body.messages.length === 1) { 355 console.error(`[serve] New session detected (1 message). Starting new conversation in UI.`); 356 await startNewConversation(page); 357 } 358 // Optimization 3: Switch model if requested 359 if (body.model) { 360 await switchModel(page, body.model); 361 } 362 // Get conversation state before sending 363 const beforeText = await getConversationText(page); 364 // Send the message 365 console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`); 366 await sendMessage(page, userText, bridge); 367 // Poll for reply (change detection) 368 console.error('[serve] Waiting for reply...'); 369 page = await waitForReply(page, beforeText, { timeout, reconnect }); 370 // Extract the actual reply text precisely from the DOM 371 const replyText = await getLastAssistantReply(page, userText); 372 console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`); 373 return { 374 id: generateMsgId(), 375 type: 'message', 376 role: 'assistant', 377 content: [{ type: 'text', text: replyText }], 378 model: body.model ?? 'antigravity', 379 stop_reason: 'end_turn', 380 stop_sequence: null, 381 usage: { 382 input_tokens: estimateTokens(userText), 383 output_tokens: estimateTokens(replyText), 384 }, 385 }; 386 } 387 // ─── Server ────────────────────────────────────────────────────────── 388 export async function startServe(opts = {}) { 389 const port = opts.port ?? 8082; 390 const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120); 391 const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds); 392 const effectiveTimeout = effectiveTimeoutSeconds * 1000; 393 console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`); 394 // Lazy CDP connection — connect when first request comes in 395 let cdp = null; 396 let page = null; 397 let requestInFlight = false; 398 async function ensureConnected() { 399 if (page) { 400 try { 401 await page.evaluate('1+1'); 402 return page; 403 } 404 catch { 405 console.error('[serve] CDP connection lost, reconnecting...'); 406 cdp?.close().catch(() => { }); 407 cdp = null; 408 page = null; 409 } 410 } 411 const endpoint = await resolveElectronEndpoint('antigravity'); 412 // Note: Antigravity chat panel lives inside editor windows, not in Launchpad. 413 // If multiple editor windows are open, set OPENCLI_CDP_TARGET to the window title. 414 if (process.env.OPENCLI_CDP_TARGET) { 415 console.error(`[serve] Using OPENCLI_CDP_TARGET=${process.env.OPENCLI_CDP_TARGET}`); 416 } 417 // List available targets for debugging 418 try { 419 const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`); 420 const targets = await res.json(); 421 const pages = targets.filter(t => t.type === 'page'); 422 console.error(`[serve] Available targets: ${pages.map(t => `"${t.title}"`).join(', ')}`); 423 } 424 catch { /* ignore */ } 425 console.error(`[serve] Connecting via CDP (target pattern: "${process.env.OPENCLI_CDP_TARGET}")...`); 426 cdp = new CDPBridge(); 427 try { 428 page = await cdp.connect({ timeout: 15_000, cdpEndpoint: endpoint }); 429 } 430 catch (err) { 431 cdp = null; 432 const errMsg = getErrorMessage(err); 433 const cause = err instanceof Error ? err.cause : undefined; 434 const isRefused = cause?.code === 'ECONNREFUSED' || errMsg.includes('ECONNREFUSED'); 435 throw new Error(isRefused 436 ? `Cannot connect to Antigravity at ${endpoint}.\n` + 437 ' 1. Make sure Antigravity is running\n' + 438 ' 2. Launch with: --remote-debugging-port=9234' 439 : `CDP connection failed: ${errMsg}`); 440 } 441 console.error('[serve] ✅ CDP connected.'); 442 // Quick verification 443 const hasUI = await page.evaluate(` 444 (() => !!document.getElementById('conversation') || !!document.getElementById('antigravity.agentSidePanelInputBox'))() 445 `); 446 if (!hasUI) { 447 console.error('[serve] ⚠️ Warning: chat UI elements not found in this target. Try setting OPENCLI_CDP_TARGET to the correct window title.'); 448 } 449 return page; 450 } 451 const server = createServer(async (req, res) => { 452 // CORS preflight 453 if (req.method === 'OPTIONS') { 454 res.writeHead(204, { 455 'Access-Control-Allow-Origin': '*', 456 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 457 'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization', 458 }); 459 res.end(); 460 return; 461 } 462 const url = req.url ?? '/'; 463 const pathname = url.split('?')[0]; 464 try { 465 // GET /v1/models — return available models 466 if (req.method === 'GET' && pathname === '/v1/models') { 467 jsonResponse(res, 200, { 468 data: [ 469 { 470 id: 'antigravity', 471 object: 'model', 472 created: Math.floor(Date.now() / 1000), 473 owned_by: 'antigravity', 474 }, 475 ], 476 }); 477 return; 478 } 479 // POST /v1/messages — main endpoint 480 if (req.method === 'POST' && pathname === '/v1/messages') { 481 if (requestInFlight) { 482 jsonResponse(res, 429, { 483 type: 'error', 484 error: { 485 type: 'rate_limit_error', 486 message: 'Another request is currently being processed. Antigravity can only handle one request at a time.', 487 }, 488 }); 489 return; 490 } 491 requestInFlight = true; 492 try { 493 const rawBody = await readBody(req); 494 const body = JSON.parse(rawBody); 495 if (body.stream) { 496 jsonResponse(res, 400, { 497 type: 'error', 498 error: { 499 type: 'invalid_request_error', 500 message: 'Streaming is not supported. Set "stream": false.', 501 }, 502 }); 503 return; 504 } 505 // Lazy connect on first request 506 const activePage = await ensureConnected(); 507 const response = await handleMessages(body, activePage, { 508 bridge: cdp, 509 timeout: effectiveTimeout, 510 reconnect: ensureConnected, 511 }); 512 jsonResponse(res, 200, response); 513 } 514 finally { 515 requestInFlight = false; 516 } 517 return; 518 } 519 // Health check 520 if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) { 521 jsonResponse(res, 200, { ok: true, cdpConnected: page !== null }); 522 return; 523 } 524 jsonResponse(res, 404, { 525 type: 'error', 526 error: { type: 'not_found_error', message: `Not found: ${pathname}` }, 527 }); 528 } 529 catch (err) { 530 console.error('[serve] Error:', err instanceof Error ? err.message : err); 531 jsonResponse(res, 500, { 532 type: 'error', 533 error: { 534 type: 'api_error', 535 message: err instanceof Error ? err.message : 'Internal server error', 536 }, 537 }); 538 } 539 }); 540 server.listen(port, '127.0.0.1', () => { 541 console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`); 542 console.error(`[serve] Compatible with Anthropic /v1/messages API`); 543 console.error(`[serve] CDP connection will be established on first request.`); 544 console.error(`\n[serve] Usage with Claude Code:`); 545 console.error(` ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`); 546 }); 547 // Graceful shutdown 548 const shutdown = () => { 549 console.error('\n[serve] Shutting down...'); 550 cdp?.close().catch(() => { }); 551 server.close(); 552 process.exit(EXIT_CODES.SUCCESS); 553 }; 554 process.on('SIGTERM', shutdown); 555 process.on('SIGINT', shutdown); 556 // Keep alive 557 await new Promise(() => { }); 558 }