/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import WebSocket from 'ws'; import { initActivityLogger, addNetworkTransport, ActivityLogger, } from './activityLogger.js'; interface IDevTools { start(): Promise; stop(): Promise; getPort(): number; } const DEVTOOLS_PKG = 'gemini-cli-devtools'; const DEFAULT_DEVTOOLS_PORT = 25417; const DEFAULT_DEVTOOLS_HOST = '127.0.0.1'; const MAX_PROMOTION_ATTEMPTS = 3; let promotionAttempts = 0; let serverStartPromise: Promise | null = null; let connectedUrl: string | null = null; /** * Probe whether a DevTools server is already listening on the given host:port. * Returns true if a WebSocket handshake succeeds within a short timeout. */ function probeDevTools(host: string, port: number): Promise { return new Promise((resolve) => { const ws = new WebSocket(`ws://${host}:${port}/ws`); const timer = setTimeout(() => { ws.close(); resolve(false); }, 500); ws.on('open', () => { clearTimeout(timer); ws.close(); resolve(true); }); ws.on('error', () => { clearTimeout(timer); ws.close(); resolve(false); }); }); } /** * Start a DevTools server, then check if we won the default port. * If another instance grabbed it first (race), stop ours and connect as client. * Returns { host, port } of the DevTools to connect to. */ async function startOrJoinDevTools( defaultHost: string, defaultPort: number, ): Promise<{ host: string; port: number }> { const mod = await import(DEVTOOLS_PKG); const devtools: IDevTools = mod.DevTools.getInstance(); const url = await devtools.start(); const actualPort = devtools.getPort(); if (actualPort === defaultPort) { // We won the port — we are the server debugLogger.log(`DevTools available at: ${url}`); return { host: defaultHost, port: actualPort }; } // Lost the race — someone else has the default port. // Verify the winner is actually alive, then stop ours and connect to theirs. const winnerAlive = await probeDevTools(defaultHost, defaultPort); if (winnerAlive) { await devtools.stop(); debugLogger.log( `DevTools (existing) at: http://${defaultHost}:${defaultPort}`, ); return { host: defaultHost, port: defaultPort }; } // Winner isn't responding (maybe also racing and failed) — keep ours debugLogger.log(`DevTools available at: ${url}`); return { host: defaultHost, port: actualPort }; } /** * Handle promotion: when reconnect fails, start or join a DevTools server * and add a new network transport for the logger. */ async function handlePromotion(config: Config) { promotionAttempts++; if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) { debugLogger.debug( `Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`, ); return; } try { const result = await startOrJoinDevTools( DEFAULT_DEVTOOLS_HOST, DEFAULT_DEVTOOLS_PORT, ); addNetworkTransport(config, result.host, result.port, () => handlePromotion(config), ); } catch (err) { debugLogger.debug('Failed to promote to DevTools server:', err); } } /** * Initializes the activity logger. * Interception starts immediately in buffering mode. * If an existing DevTools server is found, attaches transport eagerly. */ export async function setupInitialActivityLogger(config: Config) { const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']; if (target) { if (!config.storage) return; initActivityLogger(config, { mode: 'file', filePath: target }); } else { // Start in buffering mode (no transport attached yet) initActivityLogger(config, { mode: 'buffer' }); // Eagerly probe for an existing DevTools server try { const existing = await probeDevTools( DEFAULT_DEVTOOLS_HOST, DEFAULT_DEVTOOLS_PORT, ); if (existing) { const onReconnectFailed = () => handlePromotion(config); addNetworkTransport( config, DEFAULT_DEVTOOLS_HOST, DEFAULT_DEVTOOLS_PORT, onReconnectFailed, ); ActivityLogger.getInstance().enableNetworkLogging(); connectedUrl = `http://localhost:${DEFAULT_DEVTOOLS_PORT}`; debugLogger.log(`DevTools (existing) at startup: ${connectedUrl}`); } } catch { // Probe failed silently — stay in buffer mode } } } /** * Starts the DevTools server and opens the UI in the browser. * Returns the URL to the DevTools UI. * Deduplicates concurrent calls — returns the same promise if already in flight. */ export function startDevToolsServer(config: Config): Promise { if (connectedUrl) return Promise.resolve(connectedUrl); if (serverStartPromise) return serverStartPromise; serverStartPromise = startDevToolsServerImpl(config).catch((err) => { serverStartPromise = null; throw err; }); return serverStartPromise; } async function startDevToolsServerImpl(config: Config): Promise { const onReconnectFailed = () => handlePromotion(config); // Probe for an existing DevTools server const existing = await probeDevTools( DEFAULT_DEVTOOLS_HOST, DEFAULT_DEVTOOLS_PORT, ); let host = DEFAULT_DEVTOOLS_HOST; let port = DEFAULT_DEVTOOLS_PORT; if (existing) { debugLogger.log( `DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`, ); } else { // No existing server — start (or join if we lose the race) try { const result = await startOrJoinDevTools( DEFAULT_DEVTOOLS_HOST, DEFAULT_DEVTOOLS_PORT, ); host = result.host; port = result.port; } catch (err) { debugLogger.debug('Failed to start DevTools:', err); throw err; } } // Promote the activity logger to use the network transport addNetworkTransport(config, host, port, onReconnectFailed); const capture = ActivityLogger.getInstance(); capture.enableNetworkLogging(); const url = `http://localhost:${port}`; connectedUrl = url; return url; } /** Reset module-level state — test only. */ export function resetForTesting() { promotionAttempts = 0; serverStartPromise = null; connectedUrl = null; }