Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 67 additions & 34 deletions clis/antigravity/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
*/

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { CDPBridge } from '@jackwener/opencli/browser/cdp';
import type { IPage } from '@jackwener/opencli/types';
import { resolveElectronEndpoint } from '@jackwener/opencli/launcher';
import { EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors';
import { CDPBridge } from '../../src/browser/cdp.js';
import type { IPage } from '../../src/types.js';
import { resolveElectronEndpoint } from '../../src/launcher.js';
import { EXIT_CODES, getErrorMessage } from '../../src/errors.js';
import { parseEnvTimeout, parseTimeoutValue } from '../../src/runtime.js';

// ─── Types ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -303,8 +304,8 @@ async function sendMessage(page: IPage, message: string, bridge?: CDPBridge): Pr
async function waitForReply(
page: IPage,
beforeText: string,
opts: { timeout?: number; pollInterval?: number } = {},
): Promise<void> {
opts: { timeout?: number; pollInterval?: number; reconnect?: () => Promise<IPage> } = {},
): Promise<IPage> {
const timeout = opts.timeout ?? 120_000; // 2 minutes max
const pollInterval = opts.pollInterval ?? 500; // 500ms polling

Expand All @@ -318,49 +319,72 @@ async function waitForReply(
let stableCount = 0;
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback

let reconnectCount = 0;
while (Date.now() < deadline) {
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;
}

// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
if (textChanged) {
if (currentText === lastText) {
stableCount++;
if (stableCount >= stableThreshold) {
return; // Text has been stable for 2 seconds -> DONE
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;
}
} else {
}
}
} catch (err: any) {
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 = currentText;
lastText = beforeText;
continue;
} catch (reconnectErr: any) {
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');
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
}

// ─── Request Handlers ────────────────────────────────────────────────

async function handleMessages(
body: AnthropicRequest,
page: IPage,
bridge?: CDPBridge,
opts: { bridge?: CDPBridge; timeout?: number; reconnect?: () => Promise<IPage> } = {},
): Promise<AnthropicResponse> {
const { bridge, timeout, reconnect } = opts;
// Extract the last user message
const userMessages = body.messages.filter(m => m.role === 'user');
if (userMessages.length === 0) {
Expand Down Expand Up @@ -393,7 +417,7 @@ async function handleMessages(

// Poll for reply (change detection)
console.error('[serve] Waiting for reply...');
await waitForReply(page, beforeText);
page = await waitForReply(page, beforeText, { timeout, reconnect });

// Extract the actual reply text precisely from the DOM
const replyText = await getLastAssistantReply(page, userText);
Expand All @@ -416,8 +440,13 @@ async function handleMessages(

// ─── Server ──────────────────────────────────────────────────────────

export async function startServe(opts: { port?: number } = {}): Promise<void> {
export async function startServe(opts: { port?: number; timeout?: number } = {}): Promise<void> {
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: CDPBridge | null = null;
Expand Down Expand Up @@ -546,7 +575,11 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {

// Lazy connect on first request
const activePage = await ensureConnected();
const response = await handleMessages(body, activePage, cdp ?? undefined);
const response = await handleMessages(body, activePage, {
bridge: cdp!,
timeout: effectiveTimeout,
reconnect: ensureConnected,
});
jsonResponse(res, 200, response);
} finally {
requestInFlight = false;
Expand Down
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,9 +1002,14 @@ cli({
.command('serve')
.description('Start Anthropic-compatible API proxy for Antigravity')
.option('--port <port>', 'Server port (default: 8082)', '8082')
.option('--timeout <seconds>', 'Maximum time to wait for a reply (default: 120s)')
.action(async (opts) => {
const { startServe } = await import('../clis/antigravity/serve.js');
await startServe({ port: parseInt(opts.port) });
const { parseTimeoutValue } = await import('./runtime.js');
await startServe({
port: parseInt(opts.port, 10),
timeout: opts.timeout ? parseTimeoutValue(opts.timeout, '--timeout', 120) : undefined,
});
});

// ── Dynamic adapter commands ──────────────────────────────────────────────
Expand Down
16 changes: 11 additions & 5 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ export function getBrowserFactory(site?: string): new () => IBrowserFactory {
return BrowserBridge;
}

function parseEnvTimeout(envVar: string, fallback: number): number {
const raw = process.env[envVar];
if (raw === undefined) return fallback;
const parsed = parseInt(raw, 10);
/**
* Validates and parses a timeout value (seconds).
*/
export function parseTimeoutValue(val: string | number | undefined, label: string, fallback: number): number {
if (val === undefined) return fallback;
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.error(`[runtime] Invalid ${envVar}="${raw}", using default ${fallback}s`);
console.error(`[runtime] Invalid ${label}="${val}", using default ${fallback}s`);
return fallback;
}
return parsed;
}

export function parseEnvTimeout(envVar: string, fallback: number): number {
return parseTimeoutValue(process.env[envVar], envVar, fallback);
}

export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_CONNECT_TIMEOUT', 30);
export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_COMMAND_TIMEOUT', 60);
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_EXPLORE_TIMEOUT', 120);
Expand Down
Loading