diff --git a/.gitignore b/.gitignore
index afacf2a947..a2a6553cd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ packages/*/coverage/
# Generated files
packages/cli/src/generated/
packages/core/src/generated/
+packages/devtools/src/_client-assets.ts
.integration-tests/
packages/vscode-ide-companion/*.vsix
packages/cli/download-ripgrep*/
diff --git a/esbuild.config.js b/esbuild.config.js
index b2d33770cc..45e17d0b2f 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -63,7 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
- 'gemini-cli-devtools',
+ '@google/gemini-cli-devtools',
];
const baseConfig = {
diff --git a/package-lock.json b/package-lock.json
index 1f280f503b..5659d5f16f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -58,6 +58,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
+ "react-dom": "^19.2.0",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
@@ -76,7 +77,6 @@
"@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"
}
@@ -1389,6 +1389,10 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@google/gemini-cli-devtools": {
+ "resolved": "packages/devtools",
+ "link": true
+ },
"node_modules/@google/gemini-cli-sdk": {
"resolved": "packages/sdk",
"link": true
@@ -9067,18 +9071,6 @@
"node": ">=14"
}
},
- "node_modules/gemini-cli-devtools": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz",
- "integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==",
- "optional": true,
- "dependencies": {
- "ws": "^8.16.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/gemini-cli-vscode-ide-companion": {
"resolved": "packages/vscode-ide-companion",
"link": true
@@ -13722,9 +13714,9 @@
}
},
"node_modules/react": {
- "version": "19.2.0",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
- "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -13765,6 +13757,26 @@
}
}
},
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-dom/node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -17598,6 +17610,21 @@
"uuid": "dist-node/bin/uuid"
}
},
+ "packages/devtools": {
+ "name": "@google/gemini-cli-devtools",
+ "version": "0.30.0-nightly.20260210.a2174751d",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "ws": "^8.16.0"
+ },
+ "devDependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.29.0-nightly.20260203.71f46f116",
diff --git a/package.json b/package.json
index 820ae04826..1750b0d99e 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,7 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
- "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
+ "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
@@ -117,6 +117,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
+ "react-dom": "^19.2.0",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
@@ -138,7 +139,6 @@
"@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"
},
diff --git a/packages/cli/src/utils/devtoolsService.test.ts b/packages/cli/src/utils/devtoolsService.test.ts
index cb3b907d97..981d121ffe 100644
--- a/packages/cli/src/utils/devtoolsService.test.ts
+++ b/packages/cli/src/utils/devtoolsService.test.ts
@@ -90,7 +90,7 @@ vi.mock('ws', () => ({
default: MockWebSocket,
}));
-vi.mock('gemini-cli-devtools', () => ({
+vi.mock('@google/gemini-cli-devtools', () => ({
DevTools: {
getInstance: () => mockDevToolsInstance,
},
diff --git a/packages/cli/src/utils/devtoolsService.ts b/packages/cli/src/utils/devtoolsService.ts
index 5e4b7710c4..401e33de88 100644
--- a/packages/cli/src/utils/devtoolsService.ts
+++ b/packages/cli/src/utils/devtoolsService.ts
@@ -19,7 +19,6 @@ interface IDevTools {
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;
@@ -62,7 +61,7 @@ async function startOrJoinDevTools(
defaultHost: string,
defaultPort: number,
): Promise<{ host: string; port: number }> {
- const mod = await import(DEVTOOLS_PKG);
+ const mod = await import('@google/gemini-cli-devtools');
const devtools: IDevTools = mod.DevTools.getInstance();
const url = await devtools.start();
const actualPort = devtools.getPort();
diff --git a/packages/devtools/GEMINI.md b/packages/devtools/GEMINI.md
new file mode 100644
index 0000000000..9da1828a25
--- /dev/null
+++ b/packages/devtools/GEMINI.md
@@ -0,0 +1,85 @@
+# Gemini CLI DevTools
+
+Integrated Developer Tools for Gemini CLI, providing a Chrome DevTools-like
+interface for Network and Console inspection. Launched automatically when the
+`general.devtools` setting is enabled.
+
+## Features
+
+- **Network Inspector**: Real-time request/response logging with streaming
+ chunks and duration tracking
+- **Console Inspector**: Real-time console log viewing
+ (log/warn/error/debug/info)
+- **Session Management**: Multiple CLI session support with live connection
+ status
+- **Import/Export**: Import JSONL log files, export current session logs
+
+## How It Works
+
+When `general.devtools` is enabled, the CLI's `devtoolsService` automatically:
+
+1. Probes port 25417 for an existing DevTools instance
+2. If found, connects as a WebSocket client
+3. If not, starts a new DevTools server and connects to it
+4. If another instance races for the port, the loser connects to the winner
+
+No environment variables needed for normal use.
+
+## Architecture
+
+```
+gemini.tsx / nonInteractiveCli.ts
+ │ (dynamic import)
+ ▼
+ devtoolsService.ts ← orchestration + DevTools lifecycle
+ │ (imports)
+ ▼
+ activityLogger.ts ← pure logging (capture, file, WebSocket transport)
+ │ (events)
+ ▼
+ DevTools server (:25417) ← this package (HTTP + WebSocket + SSE)
+ │ (SSE /events)
+ ▼
+ DevTools UI (React) ← client/ compiled by esbuild
+```
+
+## Environment Variables
+
+| Variable | Description |
+| -------------------------------- | --------------------------------------------- |
+| `GEMINI_CLI_ACTIVITY_LOG_TARGET` | File path for JSONL mode (optional, fallback) |
+
+## API Endpoints
+
+| Endpoint | Method | Description |
+| --------- | --------- | --------------------------------------------------------------------------- |
+| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) |
+| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events |
+
+## Development
+
+```bash
+# Build everything (client + server)
+npm run build
+
+# Rebuild client only after UI changes
+npm run build:client
+```
+
+### Project Structure
+
+```
+packages/devtools/
+├── src/
+│ └── index.ts # DevTools server (HTTP, WebSocket, SSE)
+├── client/
+│ ├── index.html
+│ └── src/
+│ ├── main.tsx # React entry
+│ ├── App.tsx # DevTools UI
+│ └── hooks.ts # Data fetching hooks
+├── esbuild.client.js # Client build script
+└── dist/ # Build output
+ ├── src/index.js # Compiled server
+ └── client/ # Bundled client assets
+```
diff --git a/packages/devtools/client/index.html b/packages/devtools/client/index.html
new file mode 100644
index 0000000000..6c786b94cd
--- /dev/null
+++ b/packages/devtools/client/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Gemini CLI DevTools
+
+
+
+
+
+
+
diff --git a/packages/devtools/client/src/App.tsx b/packages/devtools/client/src/App.tsx
new file mode 100644
index 0000000000..bb5509b38e
--- /dev/null
+++ b/packages/devtools/client/src/App.tsx
@@ -0,0 +1,1875 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks';
+
+type ThemeMode = 'light' | 'dark' | null; // null means follow system
+
+interface ThemeColors {
+ bg: string;
+ bgSecondary: string;
+ bgHover: string;
+ border: string;
+ text: string;
+ textSecondary: string;
+ accent: string;
+ consoleBg: string;
+ rowBorder: string;
+ errorBg: string;
+ warnBg: string;
+}
+
+export default function App() {
+ const [activeTab, setActiveTab] = useState<'console' | 'network'>('console');
+ const { networkLogs, consoleLogs, connectedSessions } = useDevToolsData();
+ const [selectedSessionId, setSelectedSessionId] = useState(
+ null,
+ );
+ const [importedLogs, setImportedLogs] = useState<{
+ network: NetworkLog[];
+ console: ConsoleLog[];
+ } | null>(null);
+ const [importedSessionId, setImportedSessionId] = useState(
+ null,
+ );
+
+ // --- Theme Logic ---
+ const [themeMode, setThemeMode] = useState(() => {
+ const saved = localStorage.getItem('devtools-theme');
+ if (!saved) return null; // Default: follow system
+ return saved as ThemeMode;
+ });
+
+ const [systemIsDark, setSystemIsDark] = useState(
+ window.matchMedia('(prefers-color-scheme: dark)').matches,
+ );
+
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+ const listener = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
+ media.addEventListener('change', listener);
+ return () => media.removeEventListener('change', listener);
+ }, []);
+
+ const isDark = themeMode === null ? systemIsDark : themeMode === 'dark';
+
+ const t = useMemo(
+ () => ({
+ bg: isDark ? '#202124' : '#ffffff',
+ bgSecondary: isDark ? '#292a2d' : '#f3f3f3',
+ bgHover: isDark ? '#35363a' : '#e8f0fe',
+ border: isDark ? '#3c4043' : '#ccc',
+ text: isDark ? '#e8eaed' : '#333',
+ textSecondary: isDark ? '#9aa0a6' : '#666',
+ accent: isDark ? '#8ab4f8' : '#1a73e8',
+ consoleBg: isDark ? '#1e1e1e' : '#fff',
+ rowBorder: isDark ? '#303134' : '#f0f0f0',
+ errorBg: isDark ? '#3c1e1e' : '#fff0f0',
+ warnBg: isDark ? '#302a10' : '#fff3cd',
+ }),
+ [isDark],
+ );
+
+ const toggleTheme = () => {
+ const nextMode = isDark ? 'light' : 'dark';
+ setThemeMode(nextMode);
+ localStorage.setItem('devtools-theme', nextMode);
+ };
+
+ // --- Import Logic ---
+ const handleImport = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const content = event.target?.result as string;
+ try {
+ const networkMap = new Map();
+ const consoleLogs: ConsoleLog[] = [];
+
+ content
+ .split('\n')
+ .filter((l) => l.trim())
+ .forEach((l) => {
+ const parsed = JSON.parse(l);
+ const payload = parsed.payload || {};
+ const type = parsed.type;
+ const timestamp = parsed.timestamp;
+
+ if (type === 'console') {
+ consoleLogs.push({
+ ...payload,
+ type,
+ timestamp,
+ id: payload.id || Math.random().toString(36).substring(2, 11),
+ });
+ } else if (type === 'network') {
+ const id = payload.id;
+ if (!id) return;
+
+ if (!networkMap.has(id)) {
+ networkMap.set(id, {
+ ...payload,
+ type,
+ timestamp,
+ id,
+ } as NetworkLog);
+ } else {
+ // It's likely a response update
+ const existing = networkMap.get(id)!;
+ networkMap.set(id, {
+ ...existing,
+ ...payload,
+ // Ensure we don't overwrite the original timestamp or type
+ type: existing.type,
+ timestamp: existing.timestamp,
+ } as NetworkLog);
+ }
+ }
+ });
+
+ const importId = `[Imported] ${file.name}`;
+ const networkLogs = Array.from(networkMap.values()).sort(
+ (a, b) => a.timestamp - b.timestamp,
+ );
+
+ setImportedLogs({ network: networkLogs, console: consoleLogs });
+ setImportedSessionId(importId);
+ setSelectedSessionId(importId);
+ } catch (err) {
+ console.error('Import error:', err);
+ alert('Failed to parse session file. Ensure it is a valid JSONL file.');
+ }
+ };
+ reader.readAsText(file);
+ e.target.value = '';
+ };
+
+ // --- Export Logic ---
+ const handleExport = () => {
+ if (!selectedSessionId) return;
+
+ // Collect entries with timestamps, then sort before serializing
+ const entries: Array<{ timestamp: number; data: object }> = [];
+
+ // Export console logs
+ filteredConsoleLogs.forEach((log) => {
+ entries.push({
+ timestamp: log.timestamp,
+ data: {
+ type: 'console',
+ payload: { type: log.type, content: log.content },
+ sessionId: log.sessionId,
+ timestamp: log.timestamp,
+ },
+ });
+ });
+
+ // Export network logs
+ filteredNetworkLogs.forEach((log) => {
+ entries.push({
+ timestamp: log.timestamp,
+ data: {
+ type: 'network',
+ payload: log,
+ sessionId: log.sessionId,
+ timestamp: log.timestamp,
+ },
+ });
+ });
+
+ // Sort by timestamp, then serialize
+ entries.sort((a, b) => a.timestamp - b.timestamp);
+
+ const content = entries.map((e) => JSON.stringify(e.data)).join('\n');
+ const blob = new Blob([content], { type: 'application/jsonl' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `session-${selectedSessionId}.jsonl`;
+ a.click();
+
+ URL.revokeObjectURL(url);
+ };
+
+ // --- Session Discovery ---
+ const sessions = useMemo(() => {
+ const sessionMap = new Map();
+ const updateMap = (l: { sessionId?: string; timestamp: number }) => {
+ if (!l.sessionId) return;
+ const currentMax = sessionMap.get(l.sessionId) || 0;
+ if (l.timestamp > currentMax) sessionMap.set(l.sessionId, l.timestamp);
+ };
+ networkLogs.forEach(updateMap);
+ consoleLogs.forEach(updateMap);
+
+ const discovered = Array.from(sessionMap.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map((entry) => entry[0]);
+
+ if (importedSessionId) {
+ return [importedSessionId, ...discovered];
+ }
+ return discovered;
+ }, [networkLogs, consoleLogs, importedSessionId]);
+
+ useEffect(() => {
+ if (sessions.length > 0 && selectedSessionId === null) {
+ setSelectedSessionId(sessions[0]);
+ }
+ }, [sessions, selectedSessionId]);
+
+ const filteredConsoleLogs = useMemo(() => {
+ if (!selectedSessionId) return [];
+ if (selectedSessionId === importedSessionId && importedLogs) {
+ return importedLogs.console;
+ }
+ return consoleLogs.filter((l) => l.sessionId === selectedSessionId);
+ }, [consoleLogs, selectedSessionId, importedSessionId, importedLogs]);
+
+ const filteredNetworkLogs = useMemo(() => {
+ if (!selectedSessionId) return [];
+ if (selectedSessionId === importedSessionId && importedLogs) {
+ return importedLogs.network;
+ }
+ return networkLogs.filter((l) => l.sessionId === selectedSessionId);
+ }, [networkLogs, selectedSessionId, importedSessionId, importedLogs]);
+
+ return (
+
+
+
+ {/* Toolbar */}
+
+
+ setActiveTab('console')}
+ label="Console"
+ t={t}
+ />
+ setActiveTab('network')}
+ label="Network"
+ t={t}
+ />
+
+
+
+ {selectedSessionId &&
+ connectedSessions.includes(selectedSessionId) && (
+
+ )}
+
+
+
+
+
+ Session:
+
+ {sessions.length > 0 ? (
+
+ ) : (
+
+ No Sessions
+
+ )}
+ {selectedSessionId &&
+ !selectedSessionId.startsWith('[Imported]') && (
+
+
+
+ {connectedSessions.includes(selectedSessionId)
+ ? 'Connected'
+ : 'Disconnected'}
+
+
+ )}
+
+
+
+
+
+
+ {/* Content */}
+
+ {selectedSessionId ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+ Please start Gemini CLI to begin debugging
+
+ )}
+
+
+ );
+}
+
+function TabButton({
+ active,
+ onClick,
+ label,
+ t,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ t: ThemeColors;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+// --- Console Components ---
+
+function ConsoleLogEntry({ log, t }: { log: ConsoleLog; t: ThemeColors }) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const content = log.content || '';
+ const lines = content.split('\n');
+ const CHAR_LIMIT = 500;
+ const LINE_LIMIT = 5;
+
+ const isTooLong = content.length > CHAR_LIMIT;
+ const isTooManyLines = lines.length > LINE_LIMIT;
+ const needsCollapse = isTooLong || isTooManyLines;
+
+ const isError = log.type === 'error';
+ const isWarn = log.type === 'warn';
+ const bg = isError ? t.errorBg : isWarn ? t.warnBg : 'transparent';
+ const color = isError ? '#f28b82' : isWarn ? '#fdd663' : t.text;
+ const icon = isError ? '❌' : isWarn ? '⚠️' : ' ';
+
+ let displayContent = content;
+ if (needsCollapse && !isExpanded) {
+ if (isTooManyLines) {
+ displayContent = lines.slice(0, LINE_LIMIT).join('\n') + '\n...';
+ } else {
+ displayContent = content.substring(0, CHAR_LIMIT) + '...';
+ }
+ }
+
+ return (
+
+
+ {icon}
+
+
+
+
+
+ {needsCollapse && (
+
setIsExpanded(!isExpanded)}
+ style={{
+ fontSize: '12px',
+
+ color: t.text,
+
+ cursor: 'pointer',
+
+ fontWeight: 'bold',
+
+ userSelect: 'none',
+
+ width: '20px',
+
+ height: '20px',
+
+ display: 'flex',
+
+ alignItems: 'center',
+
+ justifyContent: 'center',
+
+ borderRadius: '4px',
+
+ border: `1px solid ${t.border}`,
+
+ background: t.bgSecondary,
+
+ transition: 'all 0.1s',
+ }}
+ onMouseOver={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background = t.bgHover;
+ }}
+ onMouseOut={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background =
+ t.bgSecondary;
+ }}
+ title={isExpanded ? 'Collapse' : 'Expand'}
+ >
+ {isExpanded ? '−' : '+'}
+
+ )}
+
+
+ {new Date(log.timestamp).toLocaleTimeString([], {
+ hour12: false,
+
+ hour: '2-digit',
+
+ minute: '2-digit',
+
+ second: '2-digit',
+ })}
+
+
+
+ );
+}
+
+function ConsoleView({ logs, t }: { logs: ConsoleLog[]; t: ThemeColors }) {
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs.length]);
+
+ if (logs.length === 0) {
+ return (
+
+ No console logs in this session
+
+ );
+ }
+
+ return (
+
+ {logs.map((log) => (
+
+ ))}
+
+
+
+ );
+}
+
+// --- Network Components ---
+
+function NetworkView({
+ logs,
+ t,
+ isDark,
+}: {
+ logs: NetworkLog[];
+ t: ThemeColors;
+ isDark: boolean;
+}) {
+ const [selectedId, setSelectedId] = useState(null);
+ const [filter, setFilter] = useState('');
+ const [groupByDomain, setGroupByDomain] = useState(true);
+ const [expandedGroups, setExpandedGroups] = useState>(
+ {},
+ );
+ const [sidebarWidth, setSidebarWidth] = useState(400);
+ const isResizing = useRef(false);
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isResizing.current) return;
+ const newWidth = Math.max(
+ 200,
+ Math.min(e.clientX, window.innerWidth - 200),
+ );
+ setSidebarWidth(newWidth);
+ };
+ const handleMouseUp = () => {
+ isResizing.current = false;
+ document.body.style.cursor = 'default';
+ document.body.style.userSelect = 'auto';
+ };
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ const startResizing = () => {
+ isResizing.current = true;
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ };
+
+ const filteredLogs = useMemo(() => {
+ let result = logs;
+ if (filter) {
+ const lower = filter.toLowerCase();
+ result = logs.filter((l) => l.url.toLowerCase().includes(lower));
+ }
+ return result;
+ }, [logs, filter]);
+
+ const groupedLogs = useMemo(() => {
+ if (!groupByDomain) return null;
+ const groups: Record = {};
+ filteredLogs.forEach((log) => {
+ let groupKey = 'Other';
+ try {
+ const url = new URL(log.url);
+ const lastSlashIndex = url.pathname.lastIndexOf('/');
+ const basePath =
+ lastSlashIndex !== -1
+ ? url.pathname.substring(0, lastSlashIndex + 1)
+ : '/';
+ groupKey = url.hostname + basePath;
+ } catch {
+ /* ignore */
+ }
+ if (!groups[groupKey]) groups[groupKey] = [];
+ groups[groupKey].push(log);
+ });
+ return groups;
+ }, [filteredLogs, groupByDomain]);
+
+ useEffect(() => {
+ if (groupedLogs) {
+ setExpandedGroups((prev) => {
+ const next = { ...prev };
+ Object.keys(groupedLogs).forEach((key) => {
+ if (next[key] === undefined) {
+ // Collapse play.googleapis.com by default
+ next[key] = !key.includes('play.googleapis.com');
+ }
+ });
+ return next;
+ });
+ }
+ }, [groupedLogs]);
+
+ const toggleGroup = (key: string) => {
+ setExpandedGroups((prev) => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ // --- Context Menu --- (reserved for future actions)
+
+ const selectedLog = logs.find((l) => l.id === selectedId);
+
+ const renderLogItem = (log: NetworkLog, nameOverride?: string) => {
+ const isPending = log.pending;
+ const status = log.response
+ ? log.response.status
+ : log.error
+ ? 'ERR'
+ : '...';
+ const isError = log.error || (log.response && log.response.status >= 400);
+
+ let name = nameOverride || log.url;
+ if (!nameOverride) {
+ try {
+ const urlObj = new URL(log.url);
+ name = urlObj.pathname + urlObj.search;
+ } catch {
+ /* ignore */
+ }
+ }
+
+ const isSelected = log.id === selectedId;
+
+ return (
+ setSelectedId(log.id)}
+ style={{
+ padding: '8px 12px',
+ cursor: 'pointer',
+ borderBottom: `1px solid ${t.rowBorder}`,
+ display: 'flex',
+ flexDirection: 'column',
+ fontSize: '12px',
+ backgroundColor: isSelected ? t.bgHover : 'transparent',
+ color: isError ? '#f28b82' : t.text,
+ paddingLeft: nameOverride ? '24px' : '12px',
+ }}
+ >
+
+
+ {log.method}
+
+
+ {name}
+
+
+ {isPending ? '⏳' : status}
+
+
+
+ );
+ };
+
+ if (logs.length === 0) {
+ return (
+
+ No network activity in this session
+
+ );
+ }
+
+ return (
+
+ {/* List */}
+
+
+ setFilter(e.target.value)}
+ style={{
+ flex: 1,
+ boxSizing: 'border-box',
+ padding: '4px 10px',
+ background: t.bg,
+ color: t.text,
+ border: `1px solid ${t.border}`,
+ borderRadius: '4px',
+ fontSize: '12px',
+ }}
+ />
+
+
+
+ {groupByDomain && groupedLogs
+ ? Object.keys(groupedLogs).map((groupKey) => (
+
+
toggleGroup(groupKey)}
+ style={{
+ padding: '6px 12px',
+ background: t.bgSecondary,
+ fontWeight: 'bold',
+ fontSize: '11px',
+ borderBottom: `1px solid ${t.rowBorder}`,
+ wordBreak: 'break-all',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ userSelect: 'none',
+ }}
+ >
+
+ {expandedGroups[groupKey] ? '▼' : '▶'}
+
+ {groupKey}
+
+ {groupedLogs[groupKey].length}
+
+
+ {expandedGroups[groupKey] &&
+ groupedLogs[groupKey].map((log) => {
+ let displayName = log.url;
+ try {
+ const url = new URL(log.url);
+ const lastSlashIndex = url.pathname.lastIndexOf('/');
+ const suffix = url.pathname.substring(
+ lastSlashIndex + 1,
+ );
+ displayName = (suffix || '/') + url.search;
+ } catch {
+ /* ignore */
+ }
+ return renderLogItem(log, displayName);
+ })}
+
+ ))
+ : filteredLogs.map((log) => renderLogItem(log))}
+
+
+
+ {/* Resizer */}
+
+
+ {/* Detail */}
+
+ {selectedLog ? (
+
+ ) : (
+
+ Select a request to view details
+
+ )}
+
+
+ );
+}
+
+type Tab = 'headers' | 'payload' | 'response';
+
+function NetworkDetail({ log, t }: { log: NetworkLog; t: ThemeColors }) {
+ const [activeTab, setActiveTab] = useState('headers');
+ const status = log.response
+ ? log.pending
+ ? '⏳'
+ : log.response.status
+ : log.error
+ ? 'Error'
+ : '⏳';
+
+ return (
+
+
+
+ {log.url}
+
+
+
+ {log.method}
+
+ •
+
+ {status}
+
+ •
+ {new Date(log.timestamp).toLocaleTimeString()}
+ {log.response && (
+ <>
+ •
+
+ {log.response.durationMs}ms
+
+ >
+ )}
+
+
+
+ {(['headers', 'payload', 'response'] as const).map((tab) => (
+
setActiveTab(tab)}
+ style={{
+ padding: '8px 16px',
+ cursor: 'pointer',
+ fontWeight: 600,
+ fontSize: '12px',
+ textTransform: 'capitalize',
+ borderBottom:
+ activeTab === tab
+ ? `2px solid ${t.accent}`
+ : '2px solid transparent',
+ color: activeTab === tab ? t.accent : t.textSecondary,
+ transition: 'all 0.2s',
+ }}
+ >
+ {tab}
+
+ ))}
+
+
+ {activeTab === 'headers' && (
+
+
+
+
+
+ {log.error && (
+
+ )}
+
+
+ {log.response ? (
+
+ ) : (
+
+ (no response yet)
+
+ )}
+
+
+
+ )}
+ {activeTab === 'payload' &&
}
+ {activeTab === 'response' && (
+
+ )}
+
+
+ );
+}
+
+function Section({
+ title,
+ children,
+ t,
+}: {
+ title: string;
+ children: React.ReactNode;
+ t: ThemeColors;
+}) {
+ const [collapsed, setCollapsed] = useState(false);
+ return (
+
+
setCollapsed(!collapsed)}
+ style={{
+ padding: '8px 12px',
+ background: t.bgSecondary,
+ fontWeight: 'bold',
+ fontSize: '11px',
+ cursor: 'pointer',
+ userSelect: 'none',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ }}
+ >
+
+ {collapsed ? '▶' : '▼'}
+
+ {title}
+
+ {!collapsed && (
+
{children}
+ )}
+
+ );
+}
+
+function Pair({
+ k,
+ v,
+ color,
+ t,
+}: {
+ k: string;
+ v: string;
+ color?: string;
+ t: ThemeColors;
+}) {
+ return (
+
+ );
+}
+
+function HeadersMap({
+ headers,
+ t,
+}: {
+ headers: Record | undefined;
+ t: ThemeColors;
+}) {
+ if (!headers) return (none)
;
+ return (
+ <>
+ {Object.entries(headers).map(([k, v]) => (
+
+ ))}
+ >
+ );
+}
+
+function BodyView({
+ content,
+ chunks,
+ t,
+}: {
+ content?: string;
+ chunks?: Array<{ index: number; data: string; timestamp: number }>;
+ t: ThemeColors;
+}) {
+ const [mode, setMode] = useState<'json' | 'raw'>('json');
+ const hasChunks = chunks && chunks.length > 0;
+ const safeContent = hasChunks
+ ? chunks.map((c) => c.data).join('')
+ : content || '';
+ const getFormattedJson = () => {
+ try {
+ return JSON.stringify(JSON.parse(safeContent), null, 2);
+ } catch {
+ return safeContent;
+ }
+ };
+
+ const copyJson = () => {
+ navigator.clipboard.writeText(getFormattedJson()).catch(() => {
+ // Clipboard API unavailable — silently ignore
+ });
+ };
+
+ const downloadJson = () => {
+ const blob = new Blob([getFormattedJson()], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'body.json';
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ if (!safeContent && !hasChunks)
+ return (
+
+ (No content)
+
+ );
+
+ const iconBtn = {
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ color: t.textSecondary,
+ padding: '2px',
+ borderRadius: '4px',
+ display: 'flex',
+ alignItems: 'center',
+ } as const;
+
+ return (
+
+
+ {(['json', 'raw'] as const).map((m) => (
+
+ ))}
+
+
+
+
+
+ {hasChunks && mode === 'raw' ? (
+
+ {chunks.map((chunk, i) => (
+
+
+ [
+ {new Date(chunk.timestamp).toLocaleTimeString('en-US', {
+ hour12: false,
+ })}
+ .{String(chunk.timestamp % 1000).padStart(3, '0')}]
+
+
+ {chunk.data}
+
+
+ ))}
+
+ ) : mode === 'raw' ? (
+
+ {safeContent}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function JsonViewer({ content, t }: { content: string; t: ThemeColors }) {
+ const safeContent = content || '';
+ if (safeContent.includes('data:')) {
+ const chunks = safeContent
+ .split(/\n\s*\n/)
+ .map((eventBlock, i) => ({
+ index: i + 1,
+ jsonStr: eventBlock
+ .split('\n')
+ .filter((line) => line.trim().startsWith('data:'))
+ .map((line) => line.trim().substring(5).trim())
+ .join(''),
+ }))
+ .filter((c) => c.jsonStr);
+ if (chunks.length > 0) {
+ return (
+
+ {chunks.map((chunk) => (
+
+
+ CHUNK {chunk.index}
+
+
+
+ ))}
+
+ );
+ }
+ }
+ return ;
+}
+
+function tryParse(str: string) {
+ try {
+ return JSON.parse(str);
+ } catch {
+ return str;
+ }
+}
+
+interface JsonLine {
+ text: string;
+ foldStart: boolean;
+ foldEnd: number; // -1 if not a fold start
+ closingBracket: string; // '}' or ']' for fold starts
+}
+
+function jsonToLines(data: unknown): JsonLine[] {
+ const str =
+ typeof data === 'string' && !data.startsWith('{') && !data.startsWith('[')
+ ? data
+ : JSON.stringify(data, null, 2);
+ if (str == null)
+ return [
+ { text: 'undefined', foldStart: false, foldEnd: -1, closingBracket: '' },
+ ];
+ const raw = str.split('\n');
+ const lines: JsonLine[] = raw.map((text) => ({
+ text,
+ foldStart: false,
+ foldEnd: -1,
+ closingBracket: '',
+ }));
+ // Match opening brackets to closing brackets using a stack
+ const stack: Array<{ index: number; bracket: string }> = [];
+ for (let i = 0; i < raw.length; i++) {
+ const trimmed = raw[i].trimEnd();
+ const last = trimmed[trimmed.length - 1];
+ if (last === '{' || last === '[') {
+ stack.push({ index: i, bracket: last === '{' ? '}' : ']' });
+ }
+ // Check for closing bracket (with or without trailing comma)
+ const stripped = trimmed.replace(/,\s*$/, '');
+ const closeChar = stripped[stripped.length - 1];
+ if (
+ (closeChar === '}' || closeChar === ']') &&
+ stack.length > 0 &&
+ stack[stack.length - 1].bracket === closeChar
+ ) {
+ const open = stack.pop()!;
+ lines[open.index].foldStart = true;
+ lines[open.index].foldEnd = i;
+ lines[open.index].closingBracket = open.bracket;
+ }
+ }
+ return lines;
+}
+
+// Tokenize a JSON line for syntax highlighting
+const TOKEN_RE =
+ /("(?:[^"\\]|\\.)*")\s*(?=:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}[\]:,])/g;
+
+function highlightLine(text: string, t: ThemeColors): React.ReactNode {
+ const parts: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ TOKEN_RE.lastIndex = 0;
+
+ while ((match = TOKEN_RE.exec(text)) !== null) {
+ // Push any unmatched text before this token (whitespace/other)
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+ const [full, key, str, num, bool, nul] = match;
+ if (key) {
+ parts.push(
+
+ {full}
+ ,
+ );
+ } else if (str) {
+ // Unescape JSON string escapes so \n renders as actual newlines.
+ // Use JSON.parse to handle all escape sequences correctly in one pass
+ // (manual chained .replace() can double-unescape e.g. \\n → \).
+ let unescaped: string;
+ try {
+ unescaped = JSON.parse(full) as string;
+ } catch {
+ unescaped = full.slice(1, -1);
+ }
+ const strLines = unescaped.split('\n');
+ if (strLines.length <= 1) {
+ parts.push(
+
+ {full}
+ ,
+ );
+ } else {
+ const indent = ' '.repeat(match.index + 1);
+ parts.push(
+ ,
+ );
+ }
+ } else if (num) {
+ parts.push(
+
+ {full}
+ ,
+ );
+ } else if (bool) {
+ parts.push(
+
+ {full}
+ ,
+ );
+ } else if (nul) {
+ parts.push(
+
+ {full}
+ ,
+ );
+ } else {
+ // punctuation
+ parts.push({full});
+ }
+ lastIndex = match.index + full.length;
+ }
+ // Remaining text
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+ return parts;
+}
+
+const STRING_LINE_THRESHOLD = 20;
+
+function CollapsibleString({
+ lines,
+ indent,
+ t,
+}: {
+ lines: string[];
+ indent: string;
+ t: ThemeColors;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const needsTruncation = lines.length > STRING_LINE_THRESHOLD;
+ const displayLines =
+ needsTruncation && !expanded
+ ? lines.slice(0, STRING_LINE_THRESHOLD)
+ : lines;
+
+ return (
+
+ "{displayLines[0]}
+ {displayLines.slice(1).map((sl, si) => (
+
+ {'\n'}
+ {indent}
+ {sl}
+
+ ))}
+ {needsTruncation && (
+ <>
+ {'\n'}
+ {indent}
+ setExpanded(!expanded)}
+ style={{
+ color: t.accent,
+ cursor: 'pointer',
+ fontStyle: 'italic',
+ userSelect: 'none',
+ }}
+ >
+ {expanded
+ ? '▲ collapse'
+ : `... ${lines.length - STRING_LINE_THRESHOLD} more lines`}
+
+ >
+ )}
+ "
+
+ );
+}
+
+function CodeView({ data, t }: { data: unknown; t: ThemeColors }) {
+ const lines = useMemo(() => jsonToLines(data), [data]);
+ const [collapsed, setCollapsed] = useState>(() => new Set());
+ const contentRef = useRef(null);
+
+ const toggleFold = (lineIndex: number) => {
+ setCollapsed((prev) => {
+ const next = new Set(prev);
+ if (next.has(lineIndex)) {
+ next.delete(lineIndex);
+ } else {
+ next.add(lineIndex);
+ }
+ return next;
+ });
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
+ e.preventDefault();
+ const sel = window.getSelection();
+ if (sel && contentRef.current) {
+ sel.removeAllRanges();
+ const range = document.createRange();
+ range.selectNodeContents(contentRef.current);
+ sel.addRange(range);
+ }
+ }
+ };
+
+ // Build visible lines, skipping folded regions
+ const visibleLines: Array<{
+ index: number;
+ content: React.ReactNode;
+ foldable: boolean;
+ isCollapsed: boolean;
+ }> = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ const isCollapsed = collapsed.has(i);
+ if (line.foldStart && isCollapsed) {
+ // Show the opening line with collapsed indicator
+ const indent = line.text.match(/^(\s*)/)?.[1] || '';
+ const trimmed = line.text.trimStart();
+ visibleLines.push({
+ index: i,
+ content: (
+ <>
+ {indent.length > 0 && {indent}}
+ {highlightLine(trimmed, t)}
+
+ {' '}
+ ... {line.closingBracket}
+
+ >
+ ),
+ foldable: true,
+ isCollapsed: true,
+ });
+ // Skip to the line after foldEnd
+ i = line.foldEnd + 1;
+ } else {
+ visibleLines.push({
+ index: i,
+ content: highlightLine(line.text, t),
+ foldable: line.foldStart,
+ isCollapsed: false,
+ });
+ i++;
+ }
+ }
+
+ return (
+
+ {visibleLines.map((vl) => (
+
+ {/* Gutter cell */}
+ toggleFold(vl.index) : undefined}
+ >
+ {vl.foldable ? (
+ {vl.isCollapsed ? '▶' : '▼'}
+ ) : (
+ ''
+ )}
+
+ {/* Content cell */}
+
+ {vl.content}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/devtools/client/src/hooks.ts b/packages/devtools/client/src/hooks.ts
new file mode 100644
index 0000000000..09c94bff62
--- /dev/null
+++ b/packages/devtools/client/src/hooks.ts
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect } from 'react';
+import type {
+ NetworkLog,
+ InspectorConsoleLog as ConsoleLog,
+} from '../../src/types.js';
+
+export type { NetworkLog };
+export type { InspectorConsoleLog as ConsoleLog } from '../../src/types.js';
+
+export function useDevToolsData() {
+ const [networkLogs, setNetworkLogs] = useState([]);
+ const [consoleLogs, setConsoleLogs] = useState([]);
+ const [isConnected, setIsConnected] = useState(false);
+ const [connectedSessions, setConnectedSessions] = useState([]);
+
+ useEffect(() => {
+ const evtSource = new EventSource('/events');
+
+ evtSource.onopen = () => setIsConnected(true);
+ evtSource.onerror = () => setIsConnected(false);
+
+ evtSource.addEventListener('snapshot', (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ // Merge with existing data to preserve logs across server restarts
+ setNetworkLogs((prev) => {
+ if (data.networkLogs.length === 0) return prev;
+ const merged = new Map(prev.map((l: NetworkLog) => [l.id, l]));
+ for (const log of data.networkLogs) merged.set(log.id, log);
+ return Array.from(merged.values());
+ });
+ setConsoleLogs((prev) => {
+ if (data.consoleLogs.length === 0) return prev;
+ const existingIds = new Set(prev.map((l: ConsoleLog) => l.id));
+ const newLogs = data.consoleLogs.filter(
+ (l: ConsoleLog) => !existingIds.has(l.id),
+ );
+ const merged = [...prev, ...newLogs];
+ return merged.length > 5000 ? merged.slice(-5000) : merged;
+ });
+ setConnectedSessions(data.sessions);
+ } catch {
+ // Malformed snapshot — ignore
+ }
+ });
+
+ evtSource.addEventListener('network', (e) => {
+ try {
+ const log = JSON.parse(e.data) as NetworkLog;
+ setNetworkLogs((prev) => {
+ const idx = prev.findIndex((l) => l.id === log.id);
+ if (idx > -1) {
+ const next = [...prev];
+ next[idx] = log;
+ return next;
+ }
+ return [...prev, log];
+ });
+ } catch {
+ // Malformed network event — ignore
+ }
+ });
+
+ evtSource.addEventListener('console', (e) => {
+ try {
+ const log = JSON.parse(e.data) as ConsoleLog;
+ setConsoleLogs((prev) => {
+ const next = [...prev, log];
+ return next.length > 5000 ? next.slice(-5000) : next;
+ });
+ } catch {
+ // Malformed console event — ignore
+ }
+ });
+
+ evtSource.addEventListener('session', (e) => {
+ try {
+ setConnectedSessions(JSON.parse(e.data));
+ } catch {
+ // Malformed session event — ignore
+ }
+ });
+
+ return () => evtSource.close();
+ }, []);
+
+ return { networkLogs, consoleLogs, isConnected, connectedSessions };
+}
diff --git a/packages/devtools/client/src/main.tsx b/packages/devtools/client/src/main.tsx
new file mode 100644
index 0000000000..a0698aa77d
--- /dev/null
+++ b/packages/devtools/client/src/main.tsx
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/packages/devtools/esbuild.client.js b/packages/devtools/esbuild.client.js
new file mode 100644
index 0000000000..2ff1a6f2d4
--- /dev/null
+++ b/packages/devtools/esbuild.client.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import esbuild from 'esbuild';
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+
+mkdirSync('dist/client', { recursive: true });
+
+await esbuild.build({
+ entryPoints: ['client/src/main.tsx'],
+ bundle: true,
+ minify: true,
+ format: 'esm',
+ target: 'es2020',
+ jsx: 'automatic',
+ outfile: 'dist/client/main.js',
+});
+
+// Embed client assets as string constants so the devtools server can be
+// bundled into the CLI without needing readFileSync + __dirname at runtime.
+const indexHtml = readFileSync('client/index.html', 'utf-8');
+const clientJs = readFileSync('dist/client/main.js', 'utf-8');
+
+writeFileSync(
+ 'src/_client-assets.ts',
+ `/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Auto-generated by esbuild.client.js — do not edit\nexport const INDEX_HTML = ${JSON.stringify(indexHtml)};\nexport const CLIENT_JS = ${JSON.stringify(clientJs)};\n`,
+);
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
new file mode 100644
index 0000000000..03b874ffad
--- /dev/null
+++ b/packages/devtools/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@google/gemini-cli-devtools",
+ "version": "0.30.0-nightly.20260210.a2174751d",
+ "license": "Apache-2.0",
+ "type": "module",
+ "main": "dist/src/index.js",
+ "types": "dist/src/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/src/index.d.ts",
+ "default": "./dist/src/index.js"
+ }
+ },
+ "scripts": {
+ "build": "npm run build:client && tsc -p tsconfig.build.json",
+ "build:client": "node esbuild.client.js"
+ },
+ "files": [
+ "dist",
+ "client/index.html"
+ ],
+ "engines": {
+ "node": ">=20"
+ },
+ "devDependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "dependencies": {
+ "ws": "^8.16.0"
+ }
+}
diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts
new file mode 100644
index 0000000000..81cb909957
--- /dev/null
+++ b/packages/devtools/src/index.ts
@@ -0,0 +1,359 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import http from 'node:http';
+import { randomUUID } from 'node:crypto';
+import { EventEmitter } from 'node:events';
+import { WebSocketServer, type WebSocket } from 'ws';
+import type {
+ NetworkLog,
+ ConsoleLogPayload,
+ InspectorConsoleLog,
+} from './types.js';
+import { INDEX_HTML, CLIENT_JS } from './_client-assets.js';
+
+export type {
+ NetworkLog,
+ ConsoleLogPayload,
+ InspectorConsoleLog,
+} from './types.js';
+
+interface IncomingNetworkPayload extends Partial {
+ chunk?: {
+ index: number;
+ data: string;
+ timestamp: number;
+ };
+}
+
+export interface SessionInfo {
+ sessionId: string;
+ ws: WebSocket;
+ lastPing: number;
+}
+
+/**
+ * DevTools Viewer
+ *
+ * Receives logs via WebSocket from CLI sessions.
+ */
+export class DevTools extends EventEmitter {
+ private static instance: DevTools | undefined;
+ private logs: NetworkLog[] = [];
+ private consoleLogs: InspectorConsoleLog[] = [];
+ private server: http.Server | null = null;
+ private wss: WebSocketServer | null = null;
+ private sessions = new Map();
+ private heartbeatTimer: NodeJS.Timeout | null = null;
+ private port = 25417;
+ private static readonly DEFAULT_PORT = 25417;
+ private static readonly MAX_PORT_RETRIES = 10;
+
+ private constructor() {
+ super();
+ // Each SSE client adds 3 listeners; raise the limit to avoid warnings
+ this.setMaxListeners(50);
+ }
+
+ static getInstance(): DevTools {
+ if (!DevTools.instance) {
+ DevTools.instance = new DevTools();
+ }
+ return DevTools.instance;
+ }
+
+ addInternalConsoleLog(
+ payload: ConsoleLogPayload,
+ sessionId?: string,
+ timestamp?: number,
+ ) {
+ const entry: InspectorConsoleLog = {
+ ...payload,
+ id: randomUUID(),
+ sessionId,
+ timestamp: timestamp || Date.now(),
+ };
+ this.consoleLogs.push(entry);
+ if (this.consoleLogs.length > 5000) this.consoleLogs.shift();
+ this.emit('console-update', entry);
+ }
+
+ addInternalNetworkLog(
+ payload: IncomingNetworkPayload,
+ sessionId?: string,
+ timestamp?: number,
+ ) {
+ if (!payload.id) return;
+ const existingIndex = this.logs.findIndex((l) => l.id === payload.id);
+ if (existingIndex > -1) {
+ const existing = this.logs[existingIndex];
+
+ // Handle chunk accumulation
+ if (payload.chunk) {
+ const chunks = existing.chunks || [];
+ chunks.push(payload.chunk);
+ this.logs[existingIndex] = {
+ ...existing,
+ chunks,
+ sessionId: sessionId || existing.sessionId,
+ };
+ } else {
+ this.logs[existingIndex] = {
+ ...existing,
+ ...payload,
+ sessionId: sessionId || existing.sessionId,
+ // Drop chunks once we have the full response body — the data
+ // is redundant and keeping both can blow past V8's string limit
+ // when serializing the snapshot.
+ chunks: payload.response?.body ? undefined : existing.chunks,
+ response: payload.response
+ ? { ...existing.response, ...payload.response }
+ : existing.response,
+ } as NetworkLog;
+ }
+ this.emit('update', this.logs[existingIndex]);
+ } else if (payload.url) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const entry = {
+ ...payload,
+ sessionId,
+ timestamp: timestamp || Date.now(),
+ chunks: payload.chunk ? [payload.chunk] : undefined,
+ } as NetworkLog;
+ this.logs.push(entry);
+ if (this.logs.length > 2000) this.logs.shift();
+ this.emit('update', entry);
+ }
+ }
+
+ getUrl(): string {
+ return `http://127.0.0.1:${this.port}`;
+ }
+
+ getPort(): number {
+ return this.port;
+ }
+
+ stop(): Promise {
+ return new Promise((resolve) => {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ if (this.wss) {
+ this.wss.close();
+ this.wss = null;
+ }
+ if (this.server) {
+ this.server.close(() => resolve());
+ this.server = null;
+ } else {
+ resolve();
+ }
+ // Reset singleton so a fresh start() is possible
+ DevTools.instance = undefined;
+ });
+ }
+
+ start(): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.server) {
+ resolve(this.getUrl());
+ return;
+ }
+ this.server = http.createServer((req, res) => {
+ // Only allow same-origin requests — the client is served from this
+ // server so cross-origin access is unnecessary and would let arbitrary
+ // websites exfiltrate logs (which may contain API keys/headers).
+ const origin = req.headers.origin;
+ if (origin) {
+ const allowed = `http://127.0.0.1:${this.port}`;
+ if (origin === allowed) {
+ res.setHeader('Access-Control-Allow-Origin', allowed);
+ }
+ }
+
+ // API routes
+ if (req.url === '/events') {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ });
+
+ // Send full snapshot on connect
+ const snapshot = JSON.stringify({
+ networkLogs: this.logs,
+ consoleLogs: this.consoleLogs,
+ sessions: Array.from(this.sessions.keys()),
+ });
+ res.write(`event: snapshot\ndata: ${snapshot}\n\n`);
+
+ // Incremental updates
+ const onNetwork = (log: NetworkLog) => {
+ res.write(`event: network\ndata: ${JSON.stringify(log)}\n\n`);
+ };
+ const onConsole = (log: InspectorConsoleLog) => {
+ res.write(`event: console\ndata: ${JSON.stringify(log)}\n\n`);
+ };
+ const onSession = () => {
+ const sessions = Array.from(this.sessions.keys());
+ res.write(`event: session\ndata: ${JSON.stringify(sessions)}\n\n`);
+ };
+ this.on('update', onNetwork);
+ this.on('console-update', onConsole);
+ this.on('session-update', onSession);
+ req.on('close', () => {
+ this.off('update', onNetwork);
+ this.off('console-update', onConsole);
+ this.off('session-update', onSession);
+ });
+ } else if (req.url === '/' || req.url === '/index.html') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(INDEX_HTML);
+ } else if (req.url === '/assets/main.js') {
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
+ res.end(CLIENT_JS);
+ } else {
+ res.writeHead(404);
+ res.end('Not Found');
+ }
+ });
+ this.server.on('error', (e: unknown) => {
+ if (
+ typeof e === 'object' &&
+ e !== null &&
+ 'code' in e &&
+ e.code === 'EADDRINUSE'
+ ) {
+ if (this.port - DevTools.DEFAULT_PORT >= DevTools.MAX_PORT_RETRIES) {
+ reject(
+ new Error(
+ `DevTools: all ports ${DevTools.DEFAULT_PORT}–${this.port} in use`,
+ ),
+ );
+ return;
+ }
+ this.port++;
+ this.server?.listen(this.port, '127.0.0.1');
+ } else {
+ reject(e instanceof Error ? e : new Error(String(e)));
+ }
+ });
+ this.server.listen(this.port, '127.0.0.1', () => {
+ this.setupWebSocketServer();
+ resolve(this.getUrl());
+ });
+ });
+ }
+
+ private setupWebSocketServer() {
+ if (!this.server) return;
+
+ this.wss = new WebSocketServer({ server: this.server, path: '/ws' });
+
+ this.wss.on('connection', (ws: WebSocket) => {
+ let sessionId: string | null = null;
+
+ ws.on('message', (data: Buffer) => {
+ try {
+ const message = JSON.parse(data.toString());
+
+ // Handle registration first
+ if (message.type === 'register') {
+ sessionId = String(message.sessionId);
+ if (!sessionId) return;
+
+ this.sessions.set(sessionId, {
+ sessionId,
+ ws,
+ lastPing: Date.now(),
+ });
+
+ // Notify session update
+ this.emit('session-update');
+
+ // Send registration acknowledgement
+ ws.send(
+ JSON.stringify({
+ type: 'registered',
+ sessionId,
+ timestamp: Date.now(),
+ }),
+ );
+ } else if (sessionId) {
+ this.handleWebSocketMessage(sessionId, message);
+ }
+ } catch {
+ // Invalid WebSocket message
+ }
+ });
+
+ ws.on('close', () => {
+ if (sessionId) {
+ this.sessions.delete(sessionId);
+ this.emit('session-update');
+ }
+ });
+
+ ws.on('error', () => {
+ // WebSocket error — no action needed
+ });
+ });
+
+ // Heartbeat mechanism
+ this.heartbeatTimer = setInterval(() => {
+ const now = Date.now();
+ this.sessions.forEach((session, sessionId) => {
+ if (now - session.lastPing > 30000) {
+ session.ws.close();
+ this.sessions.delete(sessionId);
+ } else {
+ // Send ping
+ session.ws.send(JSON.stringify({ type: 'ping', timestamp: now }));
+ }
+ });
+ }, 10000);
+ this.heartbeatTimer.unref();
+ }
+
+ private handleWebSocketMessage(
+ sessionId: string,
+ message: Record,
+ ) {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ switch (message['type']) {
+ case 'pong':
+ session.lastPing = Date.now();
+ break;
+
+ case 'console':
+ this.addInternalConsoleLog(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ message['payload'] as ConsoleLogPayload,
+ sessionId,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ message['timestamp'] as number,
+ );
+ break;
+
+ case 'network':
+ this.addInternalNetworkLog(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ message['payload'] as IncomingNetworkPayload,
+ sessionId,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ message['timestamp'] as number,
+ );
+ break;
+
+ default:
+ break;
+ }
+ }
+}
diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts
new file mode 100644
index 0000000000..ffaf038d67
--- /dev/null
+++ b/packages/devtools/src/types.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export interface NetworkLog {
+ id: string;
+ sessionId?: string;
+ timestamp: number;
+ method: string;
+ url: string;
+ headers: Record;
+ body?: string;
+ pending?: boolean;
+ chunks?: Array<{
+ index: number;
+ data: string;
+ timestamp: number;
+ }>;
+ response?: {
+ status: number;
+ headers: Record;
+ body?: string;
+ durationMs: number;
+ };
+ error?: string;
+}
+
+export interface ConsoleLogPayload {
+ type: 'log' | 'warn' | 'error' | 'debug' | 'info';
+ content: string;
+}
+
+export interface InspectorConsoleLog extends ConsoleLogPayload {
+ id: string;
+ sessionId?: string;
+ timestamp: number;
+}
diff --git a/packages/devtools/tsconfig.build.json b/packages/devtools/tsconfig.build.json
new file mode 100644
index 0000000000..f90fcb9cac
--- /dev/null
+++ b/packages/devtools/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/src",
+ "declaration": true,
+ "sourceMap": true,
+ "noEmit": false,
+ "composite": false,
+ "incremental": false,
+ "verbatimModuleSyntax": false
+ },
+ "include": ["src"]
+}
diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json
new file mode 100644
index 0000000000..fe76dc95a8
--- /dev/null
+++ b/packages/devtools/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
+ "jsx": "react-jsx",
+ "allowImportingTsExtensions": true,
+ "noEmit": true
+ },
+ "include": ["src", "client/src"]
+}