2026-02-09 14:03:10 -08:00
|
|
|
/**
|
|
|
|
|
* @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';
|
2026-02-10 08:54:23 -08:00
|
|
|
import {
|
|
|
|
|
initActivityLogger,
|
|
|
|
|
addNetworkTransport,
|
|
|
|
|
ActivityLogger,
|
|
|
|
|
} from './activityLogger.js';
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
interface IDevTools {
|
|
|
|
|
start(): Promise<string>;
|
|
|
|
|
stop(): Promise<void>;
|
|
|
|
|
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;
|
2026-02-10 08:54:23 -08:00
|
|
|
let serverStartPromise: Promise<string> | null = null;
|
|
|
|
|
let connectedUrl: string | null = null;
|
2026-02-09 14:03:10 -08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<boolean> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-10 08:54:23 -08:00
|
|
|
* Initializes the activity logger.
|
|
|
|
|
* Interception starts immediately in buffering mode.
|
|
|
|
|
* If an existing DevTools server is found, attaches transport eagerly.
|
2026-02-09 14:03:10 -08:00
|
|
|
*/
|
2026-02-10 08:54:23 -08:00
|
|
|
export async function setupInitialActivityLogger(config: Config) {
|
2026-02-09 14:03:10 -08:00
|
|
|
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
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' });
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Eagerly probe for an existing DevTools server
|
|
|
|
|
try {
|
|
|
|
|
const existing = await probeDevTools(
|
|
|
|
|
DEFAULT_DEVTOOLS_HOST,
|
|
|
|
|
DEFAULT_DEVTOOLS_PORT,
|
2026-02-09 14:03:10 -08:00
|
|
|
);
|
2026-02-10 08:54:23 -08:00
|
|
|
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
|
2026-02-09 14:03:10 -08:00
|
|
|
}
|
2026-02-10 08:54:23 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
/**
|
|
|
|
|
* 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<string> {
|
|
|
|
|
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<string> {
|
|
|
|
|
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 {
|
2026-02-09 14:03:10 -08:00
|
|
|
// No existing server — start (or join if we lose the race)
|
|
|
|
|
try {
|
|
|
|
|
const result = await startOrJoinDevTools(
|
|
|
|
|
DEFAULT_DEVTOOLS_HOST,
|
|
|
|
|
DEFAULT_DEVTOOLS_PORT,
|
|
|
|
|
);
|
2026-02-10 08:54:23 -08:00
|
|
|
host = result.host;
|
|
|
|
|
port = result.port;
|
2026-02-09 14:03:10 -08:00
|
|
|
} catch (err) {
|
2026-02-10 08:54:23 -08:00
|
|
|
debugLogger.debug('Failed to start DevTools:', err);
|
|
|
|
|
throw err;
|
2026-02-09 14:03:10 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
// Promote the activity logger to use the network transport
|
|
|
|
|
addNetworkTransport(config, host, port, onReconnectFailed);
|
|
|
|
|
const capture = ActivityLogger.getInstance();
|
|
|
|
|
capture.enableNetworkLogging();
|
2026-02-09 14:03:10 -08:00
|
|
|
|
2026-02-10 08:54:23 -08:00
|
|
|
const url = `http://localhost:${port}`;
|
|
|
|
|
connectedUrl = url;
|
|
|
|
|
return url;
|
2026-02-09 14:03:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Reset module-level state — test only. */
|
|
|
|
|
export function resetForTesting() {
|
|
|
|
|
promotionAttempts = 0;
|
2026-02-10 08:54:23 -08:00
|
|
|
serverStartPromise = null;
|
|
|
|
|
connectedUrl = null;
|
2026-02-09 14:03:10 -08:00
|
|
|
}
|