Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gemini/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"enabled": true
},
"plan": true
},
"general": {
"devtools": true
}
}
4 changes: 4 additions & 0 deletions docs/get-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable Vim keybindings
- **Default:** `false`

- **`general.devtools`** (boolean):
- **Description:** Enable DevTools inspector on launch.
- **Default:** `false`

- **`general.enableAutoUpdate`** (boolean):
- **Description:** Enable automatic updates.
- **Default:** `true`
Expand Down
1 change: 1 addition & 0 deletions esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
];

const baseConfig = {
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
devtools: {
type: 'boolean',
label: 'DevTools',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable DevTools inspector on launch.',
showInDialog: false,
},
enableAutoUpdate: {
type: 'boolean',
label: 'Enable Auto Update',
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,11 @@ export async function main() {

adminControlsListner.setConfig(config);

if (config.isInteractive() && config.getDebugMode()) {
if (config.isInteractive() && settings.merged.general.devtools) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await registerActivityLogger(config);
}

// Register config for telemetry shutdown
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { LoadedSettings } from './config/settings.js';
vi.mock('./ui/hooks/atCommandProcessor.js');

const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/activityLogger.js', () => ({
vi.mock('./utils/devtoolsService.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
}));

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ export async function runNonInteractive({

if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { registerActivityLogger } = await import(
'./utils/activityLogger.js'
'./utils/devtoolsService.js'
);
registerActivityLogger(config);
await registerActivityLogger(config);
}

const { stdout: workingStdout } = createWorkingStdio();
Expand Down
116 changes: 65 additions & 51 deletions packages/cli/src/utils/activityLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,6 @@ import WebSocket from 'ws';
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
const MAX_BUFFER_SIZE = 100;

/**
* Parse a host:port string into its components.
* Uses the URL constructor for robust handling of IPv4, IPv6, and hostnames.
* Returns null for file paths or values without a valid port.
*/
function parseHostPort(value: string): { host: string; port: number } | null {
if (value.startsWith('/') || value.startsWith('.')) return null;

try {
const url = new URL(`ws://${value}`);
if (!url.port) return null;

const port = parseInt(url.port, 10);
if (url.hostname && !isNaN(port) && port > 0 && port <= 65535) {
return { host: url.hostname, port };
}
} catch {
// Not a valid host:port
}

return null;
}

export interface NetworkLog {
id: string;
timestamp: number;
Expand Down Expand Up @@ -494,19 +471,23 @@ function setupNetworkLogging(
host: string,
port: number,
config: Config,
onReconnectFailed?: () => void,
) {
const buffer: Array<Record<string, unknown>> = [];
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let sessionId: string | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 2;

const connect = () => {
try {
ws = new WebSocket(`ws://${host}:${port}/ws`);
Comment thread
SandyTao520 marked this conversation as resolved.

ws.on('open', () => {
debugLogger.debug(`WebSocket connected to ${host}:${port}`);
reconnectAttempts = 0;
// Register with CLI's session ID
sendMessage({
type: 'register',
Expand Down Expand Up @@ -620,11 +601,20 @@ function setupNetworkLogging(
const scheduleReconnect = () => {
if (reconnectTimer) return;

reconnectAttempts++;
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) {
debugLogger.debug(
`WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`,
);
onReconnectFailed();
return;
}

reconnectTimer = setTimeout(() => {
reconnectTimer = null;
debugLogger.debug('Reconnecting WebSocket...');
connect();
}, 5000);
}, 1000);
};

// Initial connection
Expand All @@ -645,41 +635,65 @@ function setupNetworkLogging(
});
}

let bridgeAttached = false;

/**
* Registers the activity logger if debug mode and interactive session are enabled.
* Captures network and console logs to a session-specific JSONL file or sends to network.
*
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
* - host:port format (e.g., "localhost:25417") → network mode (auto-enabled)
* - file path (e.g., "/tmp/logs.jsonl") → file mode (immediate)
* - not set → uses default file location in project temp logs dir
*
* @param config The CLI configuration
* Bridge coreEvents to the ActivityLogger singleton (guarded — only once).
*/
export function registerActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
const hostPort = target ? parseHostPort(target) : null;

// Network mode doesn't need storage; file mode does
if (!hostPort && !config.storage) {
return;
}
function bridgeCoreEvents(capture: ActivityLogger) {
if (bridgeAttached) return;
bridgeAttached = true;
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
capture.logConsole(payload);
});
}

/**
* Initialize the activity logger with a specific transport mode.
*
* @param config CLI configuration
* @param options Transport configuration: network (WebSocket) or file (JSONL)
*/
export function initActivityLogger(
config: Config,
options:
| {
mode: 'network';
host: string;
port: number;
onReconnectFailed?: () => void;
}
| { mode: 'file'; filePath?: string },
): void {
const capture = ActivityLogger.getInstance();
capture.enable();

if (hostPort) {
// Network mode: send logs via WebSocket
setupNetworkLogging(capture, hostPort.host, hostPort.port, config);
// Auto-enable network logging when target is explicitly configured
if (options.mode === 'network') {
setupNetworkLogging(
capture,
options.host,
options.port,
config,
options.onReconnectFailed,
);
capture.enableNetworkLogging();
} else {
// File mode: write to JSONL file
setupFileLogging(capture, config, target);
setupFileLogging(capture, config, options.filePath);
}

// Bridge CoreEvents to local capture
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
capture.logConsole(payload);
});
bridgeCoreEvents(capture);
}

/**
* Add a network (WebSocket) transport to the existing ActivityLogger singleton.
* Used for promotion re-entry without re-bridging coreEvents.
*/
export function addNetworkTransport(
config: Config,
host: string,
port: number,
onReconnectFailed?: () => void,
): void {
const capture = ActivityLogger.getInstance();
setupNetworkLogging(capture, host, port, config, onReconnectFailed);
}
Loading
Loading