9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
559 lines
23 KiB
JavaScript
559 lines
23 KiB
JavaScript
/**
|
|
* antigravity serve — Anthropic-compatible `/v1/messages` proxy server.
|
|
*
|
|
* Starts an HTTP server that accepts Anthropic Messages API requests,
|
|
* forwards them to a running Antigravity app via CDP, polls for the response,
|
|
* and returns it in Anthropic format.
|
|
*
|
|
* Usage:
|
|
* opencli antigravity serve --port 8082
|
|
* ANTHROPIC_BASE_URL=http://localhost:8082 claude
|
|
*/
|
|
import { createServer } from 'node:http';
|
|
import { CDPBridge } from '@jackwener/opencli/browser/cdp';
|
|
import { resolveElectronEndpoint } from '@jackwener/opencli/launcher';
|
|
import { EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors';
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
function generateMsgId() {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let id = 'msg_';
|
|
for (let i = 0; i < 24; i++)
|
|
id += chars[Math.floor(Math.random() * chars.length)];
|
|
return id;
|
|
}
|
|
function estimateTokens(text) {
|
|
// Rough approximation: ~4 chars per token for English, ~2 for CJK
|
|
return Math.max(1, Math.ceil(text.length / 3));
|
|
}
|
|
function extractTextContent(content) {
|
|
if (typeof content === 'string')
|
|
return content;
|
|
return content
|
|
.filter(b => b.type === 'text' && b.text)
|
|
.map(b => b.text)
|
|
.join('\n');
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
function jsonResponse(res, status, data) {
|
|
const body = JSON.stringify(data);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
|
|
});
|
|
res.end(body);
|
|
}
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
function parseTimeoutValue(val, label, fallback) {
|
|
if (val === undefined) {
|
|
return fallback;
|
|
}
|
|
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
|
|
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
|
|
return fallback;
|
|
}
|
|
return parsed;
|
|
}
|
|
function parseEnvTimeout(envVar, fallback) {
|
|
return parseTimeoutValue(process.env[envVar], envVar, fallback);
|
|
}
|
|
// ─── DOM helpers ─────────────────────────────────────────────────────
|
|
/**
|
|
* Click the 'New Conversation' button to reset context.
|
|
*/
|
|
async function startNewConversation(page) {
|
|
await page.evaluate(`
|
|
(() => {
|
|
const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
|
|
if (btn) btn.click();
|
|
})()
|
|
`);
|
|
await sleep(1000); // Give UI time to clear
|
|
}
|
|
/**
|
|
* Switch the active model in Antigravity UI.
|
|
*/
|
|
async function switchModel(page, anthropicModelId) {
|
|
// Map standard model IDs to Antigravity UI names based on actual UI
|
|
let targetName = 'claude sonnet 4.6'; // Default fallback
|
|
const id = anthropicModelId.toLowerCase();
|
|
if (id.includes('sonnet')) {
|
|
targetName = 'claude sonnet 4.6';
|
|
}
|
|
else if (id.includes('opus')) {
|
|
targetName = 'claude opus 4.6';
|
|
}
|
|
else if (id.includes('gemini') && id.includes('pro')) {
|
|
targetName = 'gemini 3.1 pro (high)';
|
|
}
|
|
else if (id.includes('gemini') && id.includes('flash')) {
|
|
targetName = 'gemini 3 flash';
|
|
}
|
|
else if (id.includes('gpt')) {
|
|
targetName = 'gpt-oss 120b';
|
|
}
|
|
try {
|
|
await page.evaluate(`
|
|
async () => {
|
|
const targetModelName = ${JSON.stringify(targetName)};
|
|
const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
|
|
if (!trigger) return; // Silent fail if UI changed
|
|
|
|
// Open dropdown only if not already selected
|
|
if (trigger.innerText.toLowerCase().includes(targetModelName)) return;
|
|
|
|
trigger.click();
|
|
await new Promise(r => setTimeout(r, 200));
|
|
|
|
const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
|
|
const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
|
|
if (target) {
|
|
const optionNode = target.closest('.cursor-pointer') || target;
|
|
optionNode.click();
|
|
} else {
|
|
// Close if not found
|
|
trigger.click();
|
|
}
|
|
}
|
|
`);
|
|
await sleep(500); // Wait for switch
|
|
}
|
|
catch (err) {
|
|
console.error(`[serve] Warning: Could not switch to model ${targetName}:`, err);
|
|
}
|
|
}
|
|
/**
|
|
* Check if the Antigravity UI is currently generating a response
|
|
* by looking for Stop/Cancel buttons or loading indicators.
|
|
*/
|
|
async function isGenerating(page) {
|
|
const result = await page.evaluate(`
|
|
(() => {
|
|
// Look for a cancel/stop button in the UI
|
|
const cancelBtn = document.querySelector('button[aria-label*="cancel" i], button[aria-label*="stop" i], button[title*="cancel" i], button[title*="stop" i]');
|
|
return !!cancelBtn;
|
|
})()
|
|
`);
|
|
return Boolean(result);
|
|
}
|
|
/**
|
|
* Walk from the scroll container and find the deepest element that
|
|
* has multiple non-empty children (our message container).
|
|
*/
|
|
function findMessageContainer(root, depth = 0) {
|
|
if (!root || depth > 12)
|
|
return null;
|
|
const nonEmpty = Array.from(root.children).filter(c => c.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) {
|
|
const text = await page.evaluate(`
|
|
(() => {
|
|
const container = document.getElementById('conversation');
|
|
if (!container) return '';
|
|
// Read only the first child div (actual chat content),
|
|
// skipping UI chrome like file change panels, model selectors, etc.
|
|
const chatContent = container.children[0];
|
|
return chatContent ? chatContent.innerText : container.innerText;
|
|
})()
|
|
`);
|
|
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.
|
|
*/
|
|
async function getLastAssistantReply(page, userText) {
|
|
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];
|
|
return last.innerText || '';
|
|
})()
|
|
`);
|
|
let reply = String(text ?? '').trim();
|
|
// Strip echoed user message from the top (Antigravity sometimes includes it)
|
|
if (userText && reply.startsWith(userText)) {
|
|
reply = reply.slice(userText.length).trim();
|
|
}
|
|
// Strip thinking block: "Thought for Xs\n..." at the start
|
|
reply = reply.replace(/^Thought for[^\n]*\n+/i, '').trim();
|
|
// Strip "Copy" button text at the end
|
|
reply = reply.replace(/\s*\bCopy\b\s*$/m, '').trim();
|
|
// De-duplicate trailing repeated content (e.g., "OK\n\nOK" → "OK")
|
|
const half = Math.floor(reply.length / 2);
|
|
const firstHalf = reply.slice(0, half).trim();
|
|
const secondHalf = reply.slice(half).trim();
|
|
if (firstHalf && firstHalf === secondHalf) {
|
|
reply = firstHalf;
|
|
}
|
|
return reply;
|
|
}
|
|
async function sendMessage(page, message, bridge) {
|
|
if (!bridge) {
|
|
// Fallback: use JS-based approach
|
|
await page.evaluate(`
|
|
(() => {
|
|
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
|
const editor = container?.querySelector('[data-lexical-editor="true"]');
|
|
if (!editor) throw new Error('Could not find input box');
|
|
editor.focus();
|
|
document.execCommand('insertText', false, ${JSON.stringify(message)});
|
|
})()
|
|
`);
|
|
await sleep(500);
|
|
await page.pressKey('Enter');
|
|
return;
|
|
}
|
|
// Get the bounding box of the Lexical editor for a physical mouse click
|
|
const rect = await page.evaluate(`
|
|
(() => {
|
|
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
|
if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
|
|
const editor = container.querySelector('[data-lexical-editor="true"]');
|
|
if (!editor) throw new Error('Could not find Antigravity input box');
|
|
const r = editor.getBoundingClientRect();
|
|
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
})()
|
|
`);
|
|
const { x, y } = JSON.parse(String(rect));
|
|
// Physical mouse click to give the element real browser focus
|
|
await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
await sleep(50);
|
|
await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
await sleep(200);
|
|
// Inject text at the CDP level (no deprecated execCommand)
|
|
await bridge.send('Input.insertText', { text: message });
|
|
await sleep(300);
|
|
// Send Enter via native CDP key event
|
|
await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
|
await sleep(50);
|
|
await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
|
}
|
|
async function waitForReply(page, beforeText, opts = {}) {
|
|
const timeout = opts.timeout ?? 120_000; // 2 minutes max
|
|
const pollInterval = opts.pollInterval ?? 500; // 500ms polling
|
|
const deadline = Date.now() + timeout;
|
|
// Wait a bit to ensure the UI transitions to "generating" state after we hit Enter
|
|
await sleep(1000);
|
|
let hasStartedGenerating = false;
|
|
let lastText = beforeText;
|
|
let stableCount = 0;
|
|
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
|
|
let reconnectCount = 0;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const generating = await isGenerating(page);
|
|
const currentText = await getConversationText(page);
|
|
const textChanged = currentText !== beforeText && currentText.length > 0;
|
|
if (generating) {
|
|
hasStartedGenerating = true;
|
|
stableCount = 0; // Reset stability while generating
|
|
}
|
|
else {
|
|
if (hasStartedGenerating) {
|
|
// It actively generated and now it stopped -> DONE
|
|
// Provide a small buffer to let React render the final message fully
|
|
await sleep(500);
|
|
return page;
|
|
}
|
|
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
|
|
if (textChanged) {
|
|
if (currentText === lastText) {
|
|
stableCount++;
|
|
if (stableCount >= stableThreshold) {
|
|
return page; // Text has been stable for 2 seconds -> DONE
|
|
}
|
|
}
|
|
else {
|
|
stableCount = 0;
|
|
lastText = currentText;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
const msg = err.message || String(err);
|
|
const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
|
|
if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
|
|
reconnectCount++;
|
|
console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
|
|
try {
|
|
page = await opts.reconnect();
|
|
// Reset stability tracking after reconnect
|
|
stableCount = 0;
|
|
lastText = beforeText;
|
|
continue;
|
|
}
|
|
catch (reconnectErr) {
|
|
console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
|
|
throw err; // Throw original error if reconnection itself fails
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
await sleep(pollInterval);
|
|
}
|
|
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
|
|
}
|
|
// ─── Request Handlers ────────────────────────────────────────────────
|
|
async function handleMessages(body, page, opts = {}) {
|
|
const { bridge, timeout, reconnect } = opts;
|
|
// Extract the last user message
|
|
const userMessages = body.messages.filter(m => m.role === 'user');
|
|
if (userMessages.length === 0) {
|
|
throw new Error('No user message found in request');
|
|
}
|
|
const lastUserMsg = userMessages[userMessages.length - 1];
|
|
const userText = extractTextContent(lastUserMsg.content);
|
|
if (!userText.trim()) {
|
|
throw new Error('Empty user message');
|
|
}
|
|
// Optimization 1: New conversation if this is the first message in the session
|
|
if (body.messages.length === 1) {
|
|
console.error(`[serve] New session detected (1 message). Starting new conversation in UI.`);
|
|
await startNewConversation(page);
|
|
}
|
|
// Optimization 3: Switch model if requested
|
|
if (body.model) {
|
|
await switchModel(page, body.model);
|
|
}
|
|
// Get conversation state before sending
|
|
const beforeText = await getConversationText(page);
|
|
// Send the message
|
|
console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`);
|
|
await sendMessage(page, userText, bridge);
|
|
// Poll for reply (change detection)
|
|
console.error('[serve] Waiting for reply...');
|
|
page = await waitForReply(page, beforeText, { timeout, reconnect });
|
|
// Extract the actual reply text precisely from the DOM
|
|
const replyText = await getLastAssistantReply(page, userText);
|
|
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
|
|
return {
|
|
id: generateMsgId(),
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: replyText }],
|
|
model: body.model ?? 'antigravity',
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: {
|
|
input_tokens: estimateTokens(userText),
|
|
output_tokens: estimateTokens(replyText),
|
|
},
|
|
};
|
|
}
|
|
// ─── Server ──────────────────────────────────────────────────────────
|
|
export async function startServe(opts = {}) {
|
|
const port = opts.port ?? 8082;
|
|
const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120);
|
|
const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
|
|
const effectiveTimeout = effectiveTimeoutSeconds * 1000;
|
|
console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
|
|
// Lazy CDP connection — connect when first request comes in
|
|
let cdp = null;
|
|
let page = null;
|
|
let requestInFlight = false;
|
|
async function ensureConnected() {
|
|
if (page) {
|
|
try {
|
|
await page.evaluate('1+1');
|
|
return page;
|
|
}
|
|
catch {
|
|
console.error('[serve] CDP connection lost, reconnecting...');
|
|
cdp?.close().catch(() => { });
|
|
cdp = null;
|
|
page = null;
|
|
}
|
|
}
|
|
const endpoint = await resolveElectronEndpoint('antigravity');
|
|
// Note: Antigravity chat panel lives inside editor windows, not in Launchpad.
|
|
// If multiple editor windows are open, set OPENCLI_CDP_TARGET to the window title.
|
|
if (process.env.OPENCLI_CDP_TARGET) {
|
|
console.error(`[serve] Using OPENCLI_CDP_TARGET=${process.env.OPENCLI_CDP_TARGET}`);
|
|
}
|
|
// List available targets for debugging
|
|
try {
|
|
const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
|
|
const targets = await res.json();
|
|
const pages = targets.filter(t => t.type === 'page');
|
|
console.error(`[serve] Available targets: ${pages.map(t => `"${t.title}"`).join(', ')}`);
|
|
}
|
|
catch { /* ignore */ }
|
|
console.error(`[serve] Connecting via CDP (target pattern: "${process.env.OPENCLI_CDP_TARGET}")...`);
|
|
cdp = new CDPBridge();
|
|
try {
|
|
page = await cdp.connect({ timeout: 15_000, cdpEndpoint: endpoint });
|
|
}
|
|
catch (err) {
|
|
cdp = null;
|
|
const errMsg = getErrorMessage(err);
|
|
const cause = err instanceof Error ? err.cause : undefined;
|
|
const isRefused = cause?.code === 'ECONNREFUSED' || errMsg.includes('ECONNREFUSED');
|
|
throw new Error(isRefused
|
|
? `Cannot connect to Antigravity at ${endpoint}.\n` +
|
|
' 1. Make sure Antigravity is running\n' +
|
|
' 2. Launch with: --remote-debugging-port=9234'
|
|
: `CDP connection failed: ${errMsg}`);
|
|
}
|
|
console.error('[serve] ✅ CDP connected.');
|
|
// Quick verification
|
|
const hasUI = await page.evaluate(`
|
|
(() => !!document.getElementById('conversation') || !!document.getElementById('antigravity.agentSidePanelInputBox'))()
|
|
`);
|
|
if (!hasUI) {
|
|
console.error('[serve] ⚠️ Warning: chat UI elements not found in this target. Try setting OPENCLI_CDP_TARGET to the correct window title.');
|
|
}
|
|
return page;
|
|
}
|
|
const server = createServer(async (req, res) => {
|
|
// CORS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
const url = req.url ?? '/';
|
|
const pathname = url.split('?')[0];
|
|
try {
|
|
// GET /v1/models — return available models
|
|
if (req.method === 'GET' && pathname === '/v1/models') {
|
|
jsonResponse(res, 200, {
|
|
data: [
|
|
{
|
|
id: 'antigravity',
|
|
object: 'model',
|
|
created: Math.floor(Date.now() / 1000),
|
|
owned_by: 'antigravity',
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
// POST /v1/messages — main endpoint
|
|
if (req.method === 'POST' && pathname === '/v1/messages') {
|
|
if (requestInFlight) {
|
|
jsonResponse(res, 429, {
|
|
type: 'error',
|
|
error: {
|
|
type: 'rate_limit_error',
|
|
message: 'Another request is currently being processed. Antigravity can only handle one request at a time.',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
requestInFlight = true;
|
|
try {
|
|
const rawBody = await readBody(req);
|
|
const body = JSON.parse(rawBody);
|
|
if (body.stream) {
|
|
jsonResponse(res, 400, {
|
|
type: 'error',
|
|
error: {
|
|
type: 'invalid_request_error',
|
|
message: 'Streaming is not supported. Set "stream": false.',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
// Lazy connect on first request
|
|
const activePage = await ensureConnected();
|
|
const response = await handleMessages(body, activePage, {
|
|
bridge: cdp,
|
|
timeout: effectiveTimeout,
|
|
reconnect: ensureConnected,
|
|
});
|
|
jsonResponse(res, 200, response);
|
|
}
|
|
finally {
|
|
requestInFlight = false;
|
|
}
|
|
return;
|
|
}
|
|
// Health check
|
|
if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
|
|
jsonResponse(res, 200, { ok: true, cdpConnected: page !== null });
|
|
return;
|
|
}
|
|
jsonResponse(res, 404, {
|
|
type: 'error',
|
|
error: { type: 'not_found_error', message: `Not found: ${pathname}` },
|
|
});
|
|
}
|
|
catch (err) {
|
|
console.error('[serve] Error:', err instanceof Error ? err.message : err);
|
|
jsonResponse(res, 500, {
|
|
type: 'error',
|
|
error: {
|
|
type: 'api_error',
|
|
message: err instanceof Error ? err.message : 'Internal server error',
|
|
},
|
|
});
|
|
}
|
|
});
|
|
server.listen(port, '127.0.0.1', () => {
|
|
console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`);
|
|
console.error(`[serve] Compatible with Anthropic /v1/messages API`);
|
|
console.error(`[serve] CDP connection will be established on first request.`);
|
|
console.error(`\n[serve] Usage with Claude Code:`);
|
|
console.error(` ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`);
|
|
});
|
|
// Graceful shutdown
|
|
const shutdown = () => {
|
|
console.error('\n[serve] Shutting down...');
|
|
cdp?.close().catch(() => { });
|
|
server.close();
|
|
process.exit(EXIT_CODES.SUCCESS);
|
|
};
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
// Keep alive
|
|
await new Promise(() => { });
|
|
}
|