/ clis / antigravity / serve.js
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  }