feat: add antigravity serve command — Anthropic API proxy

- New command: opencli antigravity serve --port 8082
- Starts HTTP server compatible with Anthropic /v1/messages API
- Connects to Antigravity via CDP (OPENCLI_CDP_ENDPOINT)
- Uses Input.dispatchKeyEvent for reliable Enter key submission
- Polls for reply with text-change detection + 3s stability check
- Precise DOM walker for extracting last assistant reply
- Lazy CDP connection (connects on first request)
- Auto-reconnect on CDP connection loss
- CORS headers for Claude Code compatibility

Usage:
  OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve
  ANTHROPIC_BASE_URL=http://localhost:8082 claude
This commit is contained in:
jackwener committed 2026-03-20 18:16:41 +08:00
1 parent 593436e4cb
commit 35a0fed8a0
1 file changed
+70 -2
+70 -2
View File
@@ -81,8 +81,27 @@ function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ─── DOM helpers ─────────────────────────────────────────────────────
/**
* Walk from the scroll container and find the deepest element that
* has multiple non-empty children (our message container).
*/
function findMessageContainer(root: Element | null, depth = 0): Element | null {
if (!root || depth > 12) return null;
const nonEmpty = Array.from(root.children).filter(
c => (c as HTMLElement).innerText?.trim().length > 5
);
if (nonEmpty.length >= 2) return root;
if (nonEmpty.length === 1) return findMessageContainer(nonEmpty[0], depth + 1);
return root;
}
// ─── Antigravity CDP Operations ──────────────────────────────────────
/**
* Get the full chat text for change-detection polling.
*/
async function getConversationText(page: IPage): Promise<string> {
const text = await page.evaluate(`
(() => {
@@ -97,6 +116,52 @@ async function getConversationText(page: IPage): Promise<string> {
return String(text ?? '');
}
/**
* Get the text of the last assistant reply by navigating to the message container
* and extracting the last non-empty message block.
* Based on DOM structure: messages are siblings inside a container with empty className.
*/
async function getLastAssistantReply(page: IPage): Promise<string> {
const text = await page.evaluate(`
(() => {
const conv = document.getElementById('conversation')?.children[0];
const scroll = conv?.querySelector('.overflow-y-auto');
// Walk down until we find a container with multiple message siblings
function findMsgContainer(el, depth) {
if (!el || depth > 12) return null;
const nonEmpty = Array.from(el.children).filter(c => c.innerText && c.innerText.trim().length > 5);
if (nonEmpty.length >= 2) return el;
if (nonEmpty.length === 1) return findMsgContainer(nonEmpty[0], depth + 1);
return null;
}
const container = findMsgContainer(scroll || conv, 0);
if (!container) return '';
// Get all non-empty children (skip trailing empty UI divs)
const msgs = Array.from(container.children).filter(
c => c.innerText && c.innerText.trim().length > 5
);
if (msgs.length === 0) return '';
// The last element is the last assistant reply
const last = msgs[msgs.length - 1];
// Strip leading "Thought for Xs" thinking block if present
let text = last.innerText || '';
text = text.replace(/^Thought for [^\\n]*\\n+/i, '').trim();
// Strip "Copy" button text at the end (it's often appended by Antigravity UI)
text = text.replace(/\\s*Copy\\s*$/, '').trim();
return text;
})()
`);
return String(text ?? '');
}
async function sendMessage(page: IPage, message: string, bridge?: CDPBridge): Promise<void> {
await page.evaluate(`
(async () => {
@@ -238,9 +303,12 @@ async function handleMessages(
console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`);
await sendMessage(page, userText, bridge);
// Poll for reply
// Poll for reply (change detection)
console.error('[serve] Waiting for reply...');
const replyText = await waitForReply(page, beforeText);
await waitForReply(page, beforeText);
// Extract the actual reply text precisely from the DOM
const replyText = await getLastAssistantReply(page);
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
return {