summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/core/history-updater.ts241
-rw-r--r--packages/cli/src/gemini.ts18
-rw-r--r--packages/cli/src/tools/edit.tool.ts146
-rw-r--r--packages/cli/src/tools/glob.tool.ts75
-rw-r--r--packages/cli/src/tools/grep.tool.ts79
-rw-r--r--packages/cli/src/tools/ls.tool.ts78
-rw-r--r--packages/cli/src/tools/read-file.tool.ts67
-rw-r--r--packages/cli/src/tools/terminal.tool.ts1000
-rw-r--r--packages/cli/src/tools/tools.ts81
-rw-r--r--packages/cli/src/tools/web-fetch.tool.ts64
-rw-r--r--packages/cli/src/tools/write-file.tool.ts111
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx10
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.tsx11
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts222
-rw-r--r--packages/cli/src/ui/types.ts25
-rw-r--r--packages/server/src/core/gemini-client.ts18
-rw-r--r--packages/server/src/core/turn.ts134
-rw-r--r--packages/server/src/tools/edit.ts95
-rw-r--r--packages/server/src/tools/glob.ts8
-rw-r--r--packages/server/src/tools/grep.ts8
-rw-r--r--packages/server/src/tools/ls.ts8
-rw-r--r--packages/server/src/tools/read-file.ts15
-rw-r--r--packages/server/src/tools/terminal.ts959
-rw-r--r--packages/server/src/tools/tools.ts45
-rw-r--r--packages/server/src/tools/web-fetch.ts8
-rw-r--r--packages/server/src/tools/write-file.ts68
-rw-r--r--packages/server/src/utils/BackgroundTerminalAnalyzer.ts (renamed from packages/cli/src/utils/BackgroundTerminalAnalyzer.ts)0
27 files changed, 1273 insertions, 2321 deletions
diff --git a/packages/cli/src/core/history-updater.ts b/packages/cli/src/core/history-updater.ts
deleted file mode 100644
index f56e76ca..00000000
--- a/packages/cli/src/core/history-updater.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { Part } from '@google/genai';
-import { toolRegistry } from '../tools/tool-registry.js';
-import {
- HistoryItem,
- IndividualToolCallDisplay,
- ToolCallEvent,
- ToolCallStatus,
- ToolConfirmationOutcome,
- ToolEditConfirmationDetails,
- ToolExecuteConfirmationDetails,
-} from '../ui/types.js';
-import { ToolResultDisplay } from '../tools/tools.js';
-
-/**
- * Processes a tool call chunk and updates the history state accordingly.
- * Manages adding new tool groups or updating existing ones.
- * Resides here as its primary effect is updating history based on tool events.
- */
-export const handleToolCallChunk = (
- chunk: ToolCallEvent,
- setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
- submitQuery: (query: Part) => Promise<void>,
- getNextMessageId: () => number,
- currentToolGroupIdRef: React.MutableRefObject<number | null>,
-): void => {
- const toolDefinition = toolRegistry.getTool(chunk.name);
- const description = toolDefinition?.getDescription
- ? toolDefinition.getDescription(chunk.args)
- : '';
- const toolDisplayName = toolDefinition?.displayName ?? chunk.name;
- let confirmationDetails = chunk.confirmationDetails;
- if (confirmationDetails) {
- const originalConfirmationDetails = confirmationDetails;
- const historyUpdatingConfirm = async (outcome: ToolConfirmationOutcome) => {
- originalConfirmationDetails.onConfirm(outcome);
-
- if (outcome === ToolConfirmationOutcome.Cancel) {
- let resultDisplay: ToolResultDisplay | undefined;
- if ('fileDiff' in originalConfirmationDetails) {
- resultDisplay = {
- fileDiff: (
- originalConfirmationDetails as ToolEditConfirmationDetails
- ).fileDiff,
- };
- } else {
- resultDisplay = `~~${(originalConfirmationDetails as ToolExecuteConfirmationDetails).command}~~`;
- }
- handleToolCallChunk(
- {
- ...chunk,
- status: ToolCallStatus.Error,
- confirmationDetails: undefined,
- resultDisplay: resultDisplay ?? 'Canceled by user.',
- },
- setHistory,
- submitQuery,
- getNextMessageId,
- currentToolGroupIdRef,
- );
- const functionResponse: Part = {
- functionResponse: {
- name: chunk.name,
- response: { error: 'User rejected function call.' },
- },
- };
- await submitQuery(functionResponse);
- } else {
- const tool = toolRegistry.getTool(chunk.name);
- if (!tool) {
- throw new Error(
- `Tool "${chunk.name}" not found or is not registered.`,
- );
- }
- handleToolCallChunk(
- {
- ...chunk,
- status: ToolCallStatus.Invoked,
- resultDisplay: 'Executing...',
- confirmationDetails: undefined,
- },
- setHistory,
- submitQuery,
- getNextMessageId,
- currentToolGroupIdRef,
- );
- const result = await tool.execute(chunk.args);
- handleToolCallChunk(
- {
- ...chunk,
- status: ToolCallStatus.Invoked,
- resultDisplay: result.returnDisplay,
- confirmationDetails: undefined,
- },
- setHistory,
- submitQuery,
- getNextMessageId,
- currentToolGroupIdRef,
- );
- const functionResponse: Part = {
- functionResponse: {
- name: chunk.name,
- id: chunk.callId,
- response: { output: result.llmContent },
- },
- };
- await submitQuery(functionResponse);
- }
- };
-
- confirmationDetails = {
- ...originalConfirmationDetails,
- onConfirm: historyUpdatingConfirm,
- };
- }
- const toolDetail: IndividualToolCallDisplay = {
- callId: chunk.callId,
- name: toolDisplayName,
- description,
- resultDisplay: chunk.resultDisplay,
- status: chunk.status,
- confirmationDetails,
- };
-
- const activeGroupId = currentToolGroupIdRef.current;
- setHistory((prev) => {
- if (chunk.status === ToolCallStatus.Pending) {
- if (activeGroupId === null) {
- // Start a new tool group
- const newGroupId = getNextMessageId();
- currentToolGroupIdRef.current = newGroupId;
- return [
- ...prev,
- {
- id: newGroupId,
- type: 'tool_group',
- tools: [toolDetail],
- } as HistoryItem,
- ];
- }
-
- // Add to existing tool group
- return prev.map((item) =>
- item.id === activeGroupId && item.type === 'tool_group'
- ? item.tools.some((t) => t.callId === toolDetail.callId)
- ? item // Tool already listed as pending
- : { ...item, tools: [...item.tools, toolDetail] }
- : item,
- );
- }
-
- // Update the status of a pending tool within the active group
- if (activeGroupId === null) {
- // Log if an invoked tool arrives without an active group context
- console.warn(
- 'Received invoked tool status without an active tool group ID:',
- chunk,
- );
- return prev;
- }
-
- return prev.map((item) =>
- item.id === activeGroupId && item.type === 'tool_group'
- ? {
- ...item,
- tools: item.tools.map((t) =>
- t.callId === toolDetail.callId
- ? { ...t, ...toolDetail, status: chunk.status } // Update details & status
- : t,
- ),
- }
- : item,
- );
- });
-};
-
-/**
- * Appends an error or informational message to the history, attempting to attach
- * it to the last non-user message or creating a new entry.
- */
-export const addErrorMessageToHistory = (
- error: DOMException | Error,
- setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
- getNextMessageId: () => number,
-): void => {
- const isAbort = error.name === 'AbortError';
- const errorType = isAbort ? 'info' : 'error';
- const errorText = isAbort
- ? '[Request cancelled by user]'
- : `[Error: ${error.message || 'Unknown error'}]`;
-
- setHistory((prev) => {
- const reversedHistory = [...prev].reverse();
- // Find the last message that isn't from the user to append the error/info to
- const lastBotMessageIndex = reversedHistory.findIndex(
- (item) => item.type !== 'user',
- );
- const originalIndex =
- lastBotMessageIndex !== -1 ? prev.length - 1 - lastBotMessageIndex : -1;
-
- if (originalIndex !== -1) {
- // Append error to the last relevant message
- return prev.map((item, index) => {
- if (index === originalIndex) {
- let baseText = '';
- // Determine base text based on item type
- if (item.type === 'gemini') baseText = item.text ?? '';
- else if (item.type === 'tool_group')
- baseText = `Tool execution (${item.tools.length} calls)`;
- else if (item.type === 'error' || item.type === 'info')
- baseText = item.text ?? '';
- // Safely handle potential undefined text
-
- const updatedText = (
- baseText +
- (baseText && !baseText.endsWith('\n') ? '\n' : '') +
- errorText
- ).trim();
- // Reuse existing ID, update type and text
- return { ...item, type: errorType, text: updatedText };
- }
- return item;
- });
- } else {
- // No previous message to append to, add a new error item
- return [
- ...prev,
- {
- id: getNextMessageId(),
- type: errorType,
- text: errorText,
- } as HistoryItem,
- ];
- }
- });
-};
diff --git a/packages/cli/src/gemini.ts b/packages/cli/src/gemini.ts
index c69810a5..60863c6a 100644
--- a/packages/cli/src/gemini.ts
+++ b/packages/cli/src/gemini.ts
@@ -8,15 +8,17 @@ import React from 'react';
import { render } from 'ink';
import { App } from './ui/App.js';
import { toolRegistry } from './tools/tool-registry.js';
-import { LSTool } from './tools/ls.tool.js';
-import { ReadFileTool } from './tools/read-file.tool.js';
-import { GrepTool } from './tools/grep.tool.js';
-import { GlobTool } from './tools/glob.tool.js';
-import { EditTool } from './tools/edit.tool.js';
-import { TerminalTool } from './tools/terminal.tool.js';
-import { WriteFileTool } from './tools/write-file.tool.js';
-import { WebFetchTool } from './tools/web-fetch.tool.js';
import { loadCliConfig } from './config/config.js';
+import {
+ LSTool,
+ ReadFileTool,
+ GrepTool,
+ GlobTool,
+ EditTool,
+ TerminalTool,
+ WriteFileTool,
+ WebFetchTool,
+} from '@gemini-code/server';
async function main() {
// Load configuration
diff --git a/packages/cli/src/tools/edit.tool.ts b/packages/cli/src/tools/edit.tool.ts
deleted file mode 100644
index 75bb59a8..00000000
--- a/packages/cli/src/tools/edit.tool.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import {
- EditLogic,
- EditToolParams,
- ToolResult,
- makeRelative,
- shortenPath,
- isNodeError,
-} from '@gemini-code/server';
-import { BaseTool } from './tools.js';
-import {
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolEditConfirmationDetails,
-} from '../ui/types.js';
-import * as Diff from 'diff';
-
-/**
- * CLI wrapper for the Edit tool.
- * Handles confirmation prompts and potentially UI-specific state like 'Always Edit'.
- */
-export class EditTool extends BaseTool<EditToolParams, ToolResult> {
- static readonly Name: string = EditLogic.Name;
- private coreLogic: EditLogic;
- private shouldAlwaysEdit = false;
-
- /**
- * Creates a new instance of the EditTool CLI wrapper
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(rootDirectory: string) {
- const coreLogicInstance = new EditLogic(rootDirectory);
- super(
- EditTool.Name,
- 'Edit',
- `Replaces a SINGLE, UNIQUE occurrence of text within a file. Requires providing significant context around the change to ensure uniqueness. For moving/renaming files, use the Bash tool with \`mv\`. For replacing entire file contents or creating new files use the WriteFile tool. Always use the ReadFile tool to examine the file before using this tool.`,
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
- this.coreLogic = coreLogicInstance;
- }
-
- /**
- * Delegates validation to the core logic
- */
- validateToolParams(params: EditToolParams): string | null {
- return this.coreLogic.validateParams(params);
- }
-
- /**
- * Delegates getting description to the core logic
- */
- getDescription(params: EditToolParams): string {
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Handles the confirmation prompt for the Edit tool in the CLI.
- * It needs to calculate the diff to show the user.
- */
- async shouldConfirmExecute(
- params: EditToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.shouldAlwaysEdit) {
- return false;
- }
- const validationError = this.validateToolParams(params);
- if (validationError) {
- console.error(
- `[EditTool Wrapper] Attempted confirmation with invalid parameters: ${validationError}`,
- );
- return false;
- }
- let currentContent: string | null = null;
- let fileExists = false;
- let newContent = '';
- try {
- currentContent = fs.readFileSync(params.file_path, 'utf8');
- fileExists = true;
- } catch (err: unknown) {
- if (isNodeError(err) && err.code === 'ENOENT') {
- fileExists = false;
- } else {
- console.error(`Error reading file for confirmation diff: ${err}`);
- return false;
- }
- }
- if (params.old_string === '' && !fileExists) {
- newContent = params.new_string;
- } else if (!fileExists) {
- return false;
- } else if (currentContent !== null) {
- const occurrences = this.coreLogic['countOccurrences'](
- currentContent,
- params.old_string,
- );
- const expectedReplacements =
- params.expected_replacements === undefined
- ? 1
- : params.expected_replacements;
- if (occurrences === 0 || occurrences !== expectedReplacements) {
- return false;
- }
- newContent = this.coreLogic['replaceAll'](
- currentContent,
- params.old_string,
- params.new_string,
- );
- } else {
- return false;
- }
- const fileName = path.basename(params.file_path);
- const fileDiff = Diff.createPatch(
- fileName,
- currentContent ?? '',
- newContent,
- 'Current',
- 'Proposed',
- { context: 3 },
- );
- const confirmationDetails: ToolEditConfirmationDetails = {
- title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.coreLogic['rootDirectory']))}`,
- fileName,
- fileDiff,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.shouldAlwaysEdit = true;
- }
- },
- };
- return confirmationDetails;
- }
-
- /**
- * Delegates execution to the core logic
- */
- async execute(params: EditToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-}
diff --git a/packages/cli/src/tools/glob.tool.ts b/packages/cli/src/tools/glob.tool.ts
deleted file mode 100644
index 8a56d51b..00000000
--- a/packages/cli/src/tools/glob.tool.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// Import core logic and types from the server package
-import { GlobLogic, GlobToolParams, ToolResult } from '@gemini-code/server';
-
-// Import CLI-specific base class and types
-import { BaseTool } from './tools.js';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
-
-/**
- * CLI wrapper for the Glob tool
- */
-export class GlobTool extends BaseTool<GlobToolParams, ToolResult> {
- static readonly Name: string = GlobLogic.Name; // Use name from logic
-
- // Core logic instance from the server package
- private coreLogic: GlobLogic;
-
- /**
- * Creates a new instance of the GlobTool CLI wrapper
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(rootDirectory: string) {
- // Instantiate the core logic from the server package
- const coreLogicInstance = new GlobLogic(rootDirectory);
-
- // Initialize the CLI BaseTool
- super(
- GlobTool.Name,
- 'FindFiles', // Define display name here
- 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.', // Define description here
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
-
- this.coreLogic = coreLogicInstance;
- }
-
- /**
- * Delegates validation to the core logic
- */
- validateToolParams(params: GlobToolParams): string | null {
- return this.coreLogic.validateToolParams(params);
- }
-
- /**
- * Delegates getting description to the core logic
- */
- getDescription(params: GlobToolParams): string {
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Define confirmation behavior (Glob likely doesn't need confirmation)
- */
- shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: GlobToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Delegates execution to the core logic
- */
- async execute(params: GlobToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-
- // Removed private methods (isWithinRoot)
- // as they are now part of GlobLogic in the server package.
-}
diff --git a/packages/cli/src/tools/grep.tool.ts b/packages/cli/src/tools/grep.tool.ts
deleted file mode 100644
index 50cff362..00000000
--- a/packages/cli/src/tools/grep.tool.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// Import core logic and types from the server package
-import { GrepLogic, GrepToolParams, ToolResult } from '@gemini-code/server';
-
-// Import CLI-specific base class and types
-import { BaseTool } from './tools.js';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
-
-// --- Interfaces (Params defined in server package) ---
-
-// --- GrepTool CLI Wrapper Class ---
-
-/**
- * CLI wrapper for the Grep tool
- */
-export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
- static readonly Name: string = GrepLogic.Name; // Use name from logic
-
- // Core logic instance from the server package
- private coreLogic: GrepLogic;
-
- /**
- * Creates a new instance of the GrepTool CLI wrapper
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(rootDirectory: string) {
- // Instantiate the core logic from the server package
- const coreLogicInstance = new GrepLogic(rootDirectory);
-
- // Initialize the CLI BaseTool
- super(
- GrepTool.Name,
- 'SearchText', // Define display name here
- 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', // Define description here
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
-
- this.coreLogic = coreLogicInstance;
- }
-
- /**
- * Delegates validation to the core logic
- */
- validateToolParams(params: GrepToolParams): string | null {
- return this.coreLogic.validateToolParams(params);
- }
-
- /**
- * Delegates getting description to the core logic
- */
- getDescription(params: GrepToolParams): string {
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Define confirmation behavior (Grep likely doesn't need confirmation)
- */
- shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: GrepToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Delegates execution to the core logic
- */
- async execute(params: GrepToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-
- // Removed private methods (resolveAndValidatePath, performGrepSearch, etc.)
- // as they are now part of GrepLogic in the server package.
-}
diff --git a/packages/cli/src/tools/ls.tool.ts b/packages/cli/src/tools/ls.tool.ts
deleted file mode 100644
index 6259f2fc..00000000
--- a/packages/cli/src/tools/ls.tool.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// Import core logic and types from the server package
-import { LSLogic, LSToolParams, ToolResult } from '@gemini-code/server';
-
-// Import CLI-specific base class and types
-import { BaseTool } from './tools.js';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
-
-/**
- * CLI wrapper for the LS tool
- */
-export class LSTool extends BaseTool<LSToolParams, ToolResult> {
- static readonly Name: string = LSLogic.Name; // Use name from logic
-
- // Core logic instance from the server package
- private coreLogic: LSLogic;
-
- /**
- * Creates a new instance of the LSTool CLI wrapper
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(rootDirectory: string) {
- // Instantiate the core logic from the server package
- const coreLogicInstance = new LSLogic(rootDirectory);
-
- // Initialize the CLI BaseTool
- super(
- LSTool.Name,
- 'ReadFolder', // Define display name here
- 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', // Define description here
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
-
- this.coreLogic = coreLogicInstance;
- }
-
- /**
- * Delegates validation to the core logic
- */
- validateToolParams(params: LSToolParams): string | null {
- return this.coreLogic.validateToolParams(params);
- }
-
- /**
- * Delegates getting description to the core logic
- */
- getDescription(params: LSToolParams): string {
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Define confirmation behavior (LS likely doesn't need confirmation)
- */
- shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: LSToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Delegates execution to the core logic
- */
- async execute(params: LSToolParams): Promise<ToolResult> {
- // The CLI wrapper could potentially modify the returnDisplay
- // from the core logic if needed, but for LS, the core logic's
- // display might be sufficient.
- return this.coreLogic.execute(params);
- }
-
- // Removed private methods (isWithinRoot, shouldIgnore, errorResult)
- // as they are now part of LSLogic in the server package.
-}
diff --git a/packages/cli/src/tools/read-file.tool.ts b/packages/cli/src/tools/read-file.tool.ts
deleted file mode 100644
index 206267be..00000000
--- a/packages/cli/src/tools/read-file.tool.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- ReadFileLogic,
- ReadFileToolParams,
- ToolResult,
-} from '@gemini-code/server';
-import { BaseTool } from './tools.js';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
-
-/**
- * CLI wrapper for the ReadFile tool
- */
-export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
- static readonly Name: string = ReadFileLogic.Name;
- private coreLogic: ReadFileLogic;
-
- /**
- * Creates a new instance of the ReadFileTool CLI wrapper
- * @param rootDirectory Root directory to ground this tool in.
- */
- constructor(rootDirectory: string) {
- const coreLogicInstance = new ReadFileLogic(rootDirectory);
- super(
- ReadFileTool.Name,
- 'ReadFile',
- 'Reads and returns the content of a specified file from the local filesystem. Handles large files by allowing reading specific line ranges.',
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
- this.coreLogic = coreLogicInstance;
- }
-
- /**
- * Delegates validation to the core logic
- */
- validateToolParams(_params: ReadFileToolParams): string | null {
- return this.coreLogic.validateToolParams(_params);
- }
-
- /**
- * Delegates getting description to the core logic
- */
- getDescription(_params: ReadFileToolParams): string {
- return this.coreLogic.getDescription(_params);
- }
-
- /**
- * Define confirmation behavior here in the CLI wrapper if needed
- * For ReadFile, we likely don't need confirmation.
- */
- shouldConfirmExecute(
- _params: ReadFileToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Delegates execution to the core logic
- */
- execute(params: ReadFileToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-}
diff --git a/packages/cli/src/tools/terminal.tool.ts b/packages/cli/src/tools/terminal.tool.ts
deleted file mode 100644
index 93e70953..00000000
--- a/packages/cli/src/tools/terminal.tool.ts
+++ /dev/null
@@ -1,1000 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- spawn,
- SpawnOptions,
- ChildProcessWithoutNullStreams,
-} from 'child_process';
-import path from 'path';
-import os from 'os';
-import crypto from 'crypto';
-import { promises as fs } from 'fs';
-import {
- SchemaValidator,
- getErrorMessage,
- isNodeError,
- Config,
-} from '@gemini-code/server';
-import { BaseTool, ToolResult } from './tools.js';
-import {
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolExecuteConfirmationDetails,
-} from '../ui/types.js';
-import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js';
-
-export interface TerminalToolParams {
- command: string;
- description?: string;
- timeout?: number;
- runInBackground?: boolean;
-}
-
-const MAX_OUTPUT_LENGTH = 10000;
-const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
-const MAX_TIMEOUT_OVERRIDE_MS = 10 * 60 * 1000;
-const BACKGROUND_LAUNCH_TIMEOUT_MS = 15 * 1000;
-const BACKGROUND_POLL_TIMEOUT_MS = 30000;
-
-const BANNED_COMMAND_ROOTS = [
- 'alias',
- 'bg',
- 'command',
- 'declare',
- 'dirs',
- 'disown',
- 'enable',
- 'eval',
- 'exec',
- 'exit',
- 'export',
- 'fc',
- 'fg',
- 'getopts',
- 'hash',
- 'history',
- 'jobs',
- 'kill',
- 'let',
- 'local',
- 'logout',
- 'popd',
- 'printf',
- 'pushd',
- 'read',
- 'readonly',
- 'set',
- 'shift',
- 'shopt',
- 'source',
- 'suspend',
- 'test',
- 'times',
- 'trap',
- 'type',
- 'typeset',
- 'ulimit',
- 'umask',
- 'unalias',
- 'unset',
- 'wait',
- 'curl',
- 'wget',
- 'nc',
- 'telnet',
- 'ssh',
- 'scp',
- 'ftp',
- 'sftp',
- 'http',
- 'https',
- 'rsync',
- 'lynx',
- 'w3m',
- 'links',
- 'elinks',
- 'httpie',
- 'xh',
- 'http-prompt',
- 'chrome',
- 'firefox',
- 'safari',
- 'edge',
- 'xdg-open',
- 'open',
-];
-
-interface QueuedCommand {
- params: TerminalToolParams;
- resolve: (result: ToolResult) => void;
- reject: (error: Error) => void;
- confirmationDetails: ToolExecuteConfirmationDetails | false;
-}
-
-export class TerminalTool extends BaseTool<TerminalToolParams, ToolResult> {
- static Name: string = 'execute_bash_command';
- private readonly rootDirectory: string;
- private readonly outputLimit: number;
- private bashProcess: ChildProcessWithoutNullStreams | null = null;
- private currentCwd: string;
- private isExecuting: boolean = false;
- private commandQueue: QueuedCommand[] = [];
- private currentCommandCleanup: (() => void) | null = null;
- private shouldAlwaysExecuteCommands: Map<string, boolean> = new Map();
- private shellReady: Promise<void>;
- private resolveShellReady: (() => void) | undefined;
- private rejectShellReady: ((reason?: unknown) => void) | undefined;
- private readonly backgroundTerminalAnalyzer: BackgroundTerminalAnalyzer;
- private readonly config: Config;
-
- constructor(
- rootDirectory: string,
- config: Config,
- outputLimit: number = MAX_OUTPUT_LENGTH,
- ) {
- const toolDisplayName = 'Terminal';
- const toolDescription = `Executes one or more bash commands sequentially in a secure and persistent interactive shell session. Can run commands in the foreground (waiting for completion) or background (returning after launch, with subsequent status polling).
-
-Core Functionality:
-* Starts in project root: '${path.basename(rootDirectory)}'. Current Directory starts as: ${rootDirectory} (will update based on 'cd' commands).
-* Persistent State: Environment variables and the current working directory (\`pwd\`) persist between calls to this tool.
-* **Execution Modes:**
- * **Foreground (default):** Waits for the command to complete. Captures stdout, stderr, and exit code. Output is truncated if it exceeds ${outputLimit} characters.
- * **Background (\`runInBackground: true\`):** Appends \`&\` to the command and redirects its output to temporary files. Returns *after* the command is launched, providing the Process ID (PID) and launch status. Subsequently, the tool **polls** for the background process status for up to ${BACKGROUND_POLL_TIMEOUT_MS / 1000} seconds. Once the process finishes or polling times out, the tool reads the captured stdout/stderr from the temporary files, runs an internal LLM analysis on the output, cleans up the files, and returns the final status, captured output, and analysis.
-* Timeout: Optional timeout per 'execute' call (default: ${DEFAULT_TIMEOUT_MS / 60000} min, max override: ${MAX_TIMEOUT_OVERRIDE_MS / 60000} min for foreground). Background *launch* has a fixed shorter timeout (${BACKGROUND_LAUNCH_TIMEOUT_MS / 1000}s) for the launch attempt itself. Background *polling* has its own timeout (${BACKGROUND_POLL_TIMEOUT_MS / 1000}s). Timeout attempts SIGINT for foreground commands.
-
-Usage Guidance & Restrictions:
-
-1. **Directory/File Verification (IMPORTANT):**
- * BEFORE executing commands that create files or directories (e.g., \`mkdir foo/bar\`, \`touch new/file.txt\`, \`git clone ...\`), use the dedicated File System tool (e.g., 'list_directory') to verify the target parent directory exists and is the correct location.
- * Example: Before running \`mkdir foo/bar\`, first use the File System tool to check that \`foo\` exists in the current directory (\`${rootDirectory}\` initially, check current CWD if it changed).
-
-2. **Use Specialized Tools (CRITICAL):**
- * Do NOT use this tool for filesystem searching (\`find\`, \`grep\`). Use the dedicated Search tool instead.
- * Do NOT use this tool for reading files (\`cat\`, \`head\`, \`tail\`, \`less\`, \`more\`). Use the dedicated File Reader tool instead.
- * Do NOT use this tool for listing files (\`ls\`). Use the dedicated File System tool ('list_directory') instead. Relying on this tool's output for directory structure is unreliable due to potential truncation and lack of structured data.
-
-3. **Security & Banned Commands:**
- * Certain commands are banned for security (e.g., network: ${BANNED_COMMAND_ROOTS.filter((c) => ['curl', 'wget', 'ssh'].includes(c)).join(', ')}; session: ${BANNED_COMMAND_ROOTS.filter((c) => ['exit', 'export', 'kill'].includes(c)).join(', ')}; etc.). The full list is extensive.
- * If you attempt a banned command, this tool will return an error explaining the restriction. You MUST relay this error clearly to the user.
-
-4. **Command Execution Notes:**
- * Chain multiple commands using shell operators like ';' or '&&'. Do NOT use newlines within the 'command' parameter string itself (newlines are fine inside quoted arguments).
- * The shell's current working directory is tracked internally. While \`cd\` is permitted if the user explicitly asks or it's necessary for a workflow, **strongly prefer** using absolute paths or paths relative to the *known* current working directory to avoid errors. Check the '(Executed in: ...)' part of the previous command's output for the CWD.
- * Good example (if CWD is /workspace/project): \`pytest tests/unit\` or \`ls /workspace/project/data\`
- * Less preferred: \`cd tests && pytest unit\` (only use if necessary or requested)
-
-5. **Background Tasks (\`runInBackground: true\`):**
- * Use this for commands that are intended to run continuously (e.g., \`node server.js\`, \`npm start\`).
- * The tool initially returns success if the process *launches* successfully, along with its PID.
- * **Polling & Final Result:** The tool then monitors the process. The *final* result (delivered after polling completes or times out) will include:
- * The final status (completed or timed out).
- * The complete stdout and stderr captured in temporary files (truncated if necessary).
- * An LLM-generated analysis/summary of the output.
- * The initial exit code (usually 0) signifies successful *launching*; the final status indicates completion or timeout after polling.
-
-Use this tool for running build steps (\`npm install\`, \`make\`), linters (\`eslint .\`), test runners (\`pytest\`, \`jest\`), code formatters (\`prettier --write .\`), package managers (\`pip install\`), version control operations (\`git status\`, \`git diff\`), starting background servers/services (\`node server.js --runInBackground true\`), or other safe, standard command-line operations within the project workspace.`;
- const toolParameterSchema = {
- type: 'object',
- properties: {
- command: {
- description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`,
- type: 'string',
- },
- description: {
- description: `Optional: A brief, user-centric explanation of what the command does and why it's being run. Used for logging and confirmation prompts. Example: 'Install project dependencies'`,
- type: 'string',
- },
- timeout: {
- description: `Optional execution time limit in milliseconds for FOREGROUND commands. Max ${MAX_TIMEOUT_OVERRIDE_MS}ms (${MAX_TIMEOUT_OVERRIDE_MS / 60000} min). Defaults to ${DEFAULT_TIMEOUT_MS}ms (${DEFAULT_TIMEOUT_MS / 60000} min) if not specified or invalid. Ignored if 'runInBackground' is true.`,
- type: 'number',
- },
- runInBackground: {
- description: `If true, execute the command in the background using '&'. Defaults to false. Use for servers or long tasks.`,
- type: 'boolean',
- },
- },
- required: ['command'],
- };
- super(
- TerminalTool.Name,
- toolDisplayName,
- toolDescription,
- toolParameterSchema,
- );
- this.config = config;
- this.rootDirectory = path.resolve(rootDirectory);
- this.currentCwd = this.rootDirectory;
- this.outputLimit = outputLimit;
- this.shellReady = new Promise((resolve, reject) => {
- this.resolveShellReady = resolve;
- this.rejectShellReady = reject;
- });
- this.backgroundTerminalAnalyzer = new BackgroundTerminalAnalyzer(config);
- this.initializeShell();
- }
-
- private initializeShell() {
- if (this.bashProcess) {
- try {
- this.bashProcess.kill();
- } catch {
- /* Ignore */
- }
- }
- const spawnOptions: SpawnOptions = {
- cwd: this.rootDirectory,
- shell: true,
- env: { ...process.env },
- stdio: ['pipe', 'pipe', 'pipe'],
- };
- try {
- const bashPath = os.platform() === 'win32' ? 'bash.exe' : 'bash';
- this.bashProcess = spawn(
- bashPath,
- ['-s'],
- spawnOptions,
- ) as ChildProcessWithoutNullStreams;
- this.currentCwd = this.rootDirectory;
- this.bashProcess.on('error', (err) => {
- console.error('Persistent Bash Error:', err);
- this.rejectShellReady?.(err);
- this.bashProcess = null;
- this.isExecuting = false;
- this.clearQueue(
- new Error(`Persistent bash process failed to start: ${err.message}`),
- );
- });
- this.bashProcess.on('close', (code, signal) => {
- this.bashProcess = null;
- this.isExecuting = false;
- this.rejectShellReady?.(
- new Error(
- `Persistent bash process exited (code: ${code}, signal: ${signal})`,
- ),
- );
- this.shellReady = new Promise((resolve, reject) => {
- this.resolveShellReady = resolve;
- this.rejectShellReady = reject;
- });
- this.clearQueue(
- new Error(
- `Persistent bash process exited unexpectedly (code: ${code}, signal: ${signal}). State is lost. Queued commands cancelled.`,
- ),
- );
- if (signal !== 'SIGINT') {
- setTimeout(() => this.initializeShell(), 1000);
- }
- });
- setTimeout(() => {
- if (this.bashProcess && !this.bashProcess.killed) {
- this.resolveShellReady?.();
- } else if (!this.bashProcess) {
- // Error likely handled
- } else {
- this.rejectShellReady?.(
- new Error('Shell killed during initialization'),
- );
- }
- }, 1000);
- } catch (error: unknown) {
- console.error('Failed to spawn persistent bash:', error);
- this.rejectShellReady?.(error);
- this.bashProcess = null;
- this.clearQueue(
- new Error(`Failed to spawn persistent bash: ${getErrorMessage(error)}`),
- );
- }
- }
-
- validateToolParams(params: TerminalToolParams): string | null {
- if (
- !SchemaValidator.validate(
- this.parameterSchema as Record<string, unknown>,
- params,
- )
- ) {
- return `Parameters failed schema validation.`;
- }
- const commandOriginal = params.command.trim();
- if (!commandOriginal) {
- return 'Command cannot be empty.';
- }
- const commandParts = commandOriginal.split(/[\s;&&|]+/);
- for (const part of commandParts) {
- if (!part) continue;
- const cleanPart =
- part
- .replace(/^[^a-zA-Z0-9]+/, '')
- .split(/[/\\]/)
- .pop() || part.replace(/^[^a-zA-Z0-9]+/, '');
- if (cleanPart && BANNED_COMMAND_ROOTS.includes(cleanPart.toLowerCase())) {
- return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`;
- }
- }
- if (
- params.timeout !== undefined &&
- (typeof params.timeout !== 'number' || params.timeout <= 0)
- ) {
- return 'Timeout must be a positive number of milliseconds.';
- }
- return null;
- }
-
- getDescription(params: TerminalToolParams): string {
- return params.description || params.command;
- }
-
- async shouldConfirmExecute(
- params: TerminalToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- const rootCommand =
- params.command
- .trim()
- .split(/[\s;&&|]+/)[0]
- ?.split(/[/\\]/)
- .pop() || 'unknown';
- if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
- return false;
- }
- const description = this.getDescription(params);
- const confirmationDetails: ToolExecuteConfirmationDetails = {
- title: 'Confirm Shell Command',
- command: params.command,
- rootCommand,
- description: `Execute in '${this.currentCwd}':\n${description}`,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.shouldAlwaysExecuteCommands.set(rootCommand, true);
- }
- },
- };
- return confirmationDetails;
- }
-
- async execute(params: TerminalToolParams): Promise<ToolResult> {
- const validationError = this.validateToolParams(params);
- if (validationError) {
- return {
- llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`,
- returnDisplay: `Error: ${validationError}`,
- };
- }
- return new Promise((resolve) => {
- const queuedItem: QueuedCommand = {
- params,
- resolve,
- reject: (error) =>
- resolve({
- llmContent: `Internal tool error for command: ${params.command}\nError: ${error.message}`,
- returnDisplay: `Internal Tool Error: ${error.message}`,
- }),
- confirmationDetails: false,
- };
- this.commandQueue.push(queuedItem);
- setImmediate(() => this.triggerQueueProcessing());
- });
- }
-
- private async triggerQueueProcessing(): Promise<void> {
- if (this.isExecuting || this.commandQueue.length === 0) {
- return;
- }
- this.isExecuting = true;
- const { params, resolve, reject } = this.commandQueue.shift()!;
- try {
- await this.shellReady;
- if (!this.bashProcess || this.bashProcess.killed) {
- throw new Error(
- 'Persistent bash process is not available or was killed.',
- );
- }
- const result = await this.executeCommandInShell(params);
- resolve(result);
- } catch (error: unknown) {
- console.error(`Error executing command "${params.command}":`, error);
- if (error instanceof Error) {
- reject(error);
- } else {
- reject(new Error('Unknown error occurred: ' + JSON.stringify(error)));
- }
- } finally {
- this.isExecuting = false;
- setImmediate(() => this.triggerQueueProcessing());
- }
- }
-
- private executeCommandInShell(
- params: TerminalToolParams,
- ): Promise<ToolResult> {
- let tempStdoutPath: string | null = null;
- let tempStderrPath: string | null = null;
- let originalResolve: (value: ToolResult | PromiseLike<ToolResult>) => void;
- let originalReject: (reason?: unknown) => void;
- const promise = new Promise<ToolResult>((resolve, reject) => {
- originalResolve = resolve;
- originalReject = reject;
- if (!this.bashProcess) {
- return reject(
- new Error('Bash process is not running. Cannot execute command.'),
- );
- }
- const isBackgroundTask = params.runInBackground ?? false;
- const commandUUID = crypto.randomUUID();
- const startDelimiter = `::START_CMD_${commandUUID}::`;
- const endDelimiter = `::END_CMD_${commandUUID}::`;
- const exitCodeDelimiter = `::EXIT_CODE_${commandUUID}::`;
- const pidDelimiter = `::PID_${commandUUID}::`;
- if (isBackgroundTask) {
- try {
- const tempDir = os.tmpdir();
- tempStdoutPath = path.join(tempDir, `term_out_${commandUUID}.log`);
- tempStderrPath = path.join(tempDir, `term_err_${commandUUID}.log`);
- } catch (err: unknown) {
- return reject(
- new Error(
- `Failed to determine temporary directory: ${getErrorMessage(err)}`,
- ),
- );
- }
- }
- let stdoutBuffer = '';
- let stderrBuffer = '';
- let commandOutputStarted = false;
- let exitCode: number | null = null;
- let backgroundPid: number | null = null;
- let receivedEndDelimiter = false;
- const effectiveTimeout = isBackgroundTask
- ? BACKGROUND_LAUNCH_TIMEOUT_MS
- : Math.min(
- params.timeout ?? DEFAULT_TIMEOUT_MS,
- MAX_TIMEOUT_OVERRIDE_MS,
- );
- let onStdoutData: ((data: Buffer) => void) | null = null;
- let onStderrData: ((data: Buffer) => void) | null = null;
- let launchTimeoutId: NodeJS.Timeout | null = null;
- launchTimeoutId = setTimeout(() => {
- const timeoutMessage = isBackgroundTask
- ? `Background command launch timed out after ${effectiveTimeout}ms.`
- : `Command timed out after ${effectiveTimeout}ms.`;
- if (!isBackgroundTask && this.bashProcess && !this.bashProcess.killed) {
- try {
- this.bashProcess.stdin.write('\x03');
- } catch (e: unknown) {
- console.error('Error writing SIGINT on timeout:', e);
- }
- }
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
- console.warn(
- `Error cleaning up temp files on timeout: ${err.message}`,
- );
- });
- }
- originalResolve({
- llmContent: `Command execution failed: ${timeoutMessage}\nCommand: ${params.command}\nExecuted in: ${this.currentCwd}\n${isBackgroundTask ? 'Mode: Background Launch' : `Mode: Foreground\nTimeout Limit: ${effectiveTimeout}ms`}\nPartial Stdout (Launch):\n${this.truncateOutput(stdoutBuffer)}\nPartial Stderr (Launch):\n${this.truncateOutput(stderrBuffer)}\nNote: ${isBackgroundTask ? 'Launch failed or took too long.' : 'Attempted interrupt (SIGINT). Shell state might be unpredictable if command ignored interrupt.'}`,
- returnDisplay: `Timeout: ${timeoutMessage}`,
- });
- }, effectiveTimeout);
- const processDataChunk = (chunk: string, isStderr: boolean): boolean => {
- let dataToProcess = chunk;
- if (!commandOutputStarted) {
- const startIndex = dataToProcess.indexOf(startDelimiter);
- if (startIndex !== -1) {
- commandOutputStarted = true;
- dataToProcess = dataToProcess.substring(
- startIndex + startDelimiter.length,
- );
- } else {
- return false;
- }
- }
- const pidIndex = dataToProcess.indexOf(pidDelimiter);
- if (pidIndex !== -1) {
- const pidMatch = dataToProcess
- .substring(pidIndex + pidDelimiter.length)
- .match(/^(\d+)/);
- if (pidMatch?.[1]) {
- backgroundPid = parseInt(pidMatch[1], 10);
- const pidEndIndex =
- pidIndex + pidDelimiter.length + pidMatch[1].length;
- const beforePid = dataToProcess.substring(0, pidIndex);
- if (isStderr) stderrBuffer += beforePid;
- else stdoutBuffer += beforePid;
- dataToProcess = dataToProcess.substring(pidEndIndex);
- } else {
- const beforePid = dataToProcess.substring(0, pidIndex);
- if (isStderr) stderrBuffer += beforePid;
- else stdoutBuffer += beforePid;
- dataToProcess = dataToProcess.substring(
- pidIndex + pidDelimiter.length,
- );
- }
- }
- const exitCodeIndex = dataToProcess.indexOf(exitCodeDelimiter);
- if (exitCodeIndex !== -1) {
- const exitCodeMatch = dataToProcess
- .substring(exitCodeIndex + exitCodeDelimiter.length)
- .match(/^(\d+)/);
- if (exitCodeMatch?.[1]) {
- exitCode = parseInt(exitCodeMatch[1], 10);
- const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
- if (isStderr) stderrBuffer += beforeExitCode;
- else stdoutBuffer += beforeExitCode;
- dataToProcess = dataToProcess.substring(
- exitCodeIndex +
- exitCodeDelimiter.length +
- exitCodeMatch[1].length,
- );
- } else {
- const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
- if (isStderr) stderrBuffer += beforeExitCode;
- else stdoutBuffer += beforeExitCode;
- dataToProcess = dataToProcess.substring(
- exitCodeIndex + exitCodeDelimiter.length,
- );
- }
- }
- const endDelimiterIndex = dataToProcess.indexOf(endDelimiter);
- if (endDelimiterIndex !== -1) {
- receivedEndDelimiter = true;
- const beforeEndDelimiter = dataToProcess.substring(
- 0,
- endDelimiterIndex,
- );
- if (isStderr) stderrBuffer += beforeEndDelimiter;
- else stdoutBuffer += beforeEndDelimiter;
- const afterEndDelimiter = dataToProcess.substring(
- endDelimiterIndex + endDelimiter.length,
- );
- const exitCodeEchoMatch = afterEndDelimiter.match(/^(\d+)/);
- dataToProcess = exitCodeEchoMatch
- ? afterEndDelimiter.substring(exitCodeEchoMatch[1].length)
- : afterEndDelimiter;
- }
- if (dataToProcess.length > 0) {
- if (isStderr) stderrBuffer += dataToProcess;
- else stdoutBuffer += dataToProcess;
- }
- if (receivedEndDelimiter && exitCode !== null) {
- setImmediate(cleanupAndResolve);
- return true;
- }
- return false;
- };
- onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false);
- onStderrData = (data: Buffer) => processDataChunk(data.toString(), true);
- const cleanupListeners = (listeners?: {
- onStdoutData: ((data: Buffer) => void) | null;
- onStderrData: ((data: Buffer) => void) | null;
- }) => {
- if (launchTimeoutId) clearTimeout(launchTimeoutId);
- launchTimeoutId = null;
- const stdoutListener = listeners?.onStdoutData ?? onStdoutData;
- const stderrListener = listeners?.onStderrData ?? onStderrData;
- if (this.bashProcess && !this.bashProcess.killed) {
- if (stdoutListener)
- this.bashProcess.stdout.removeListener('data', stdoutListener);
- if (stderrListener)
- this.bashProcess.stderr.removeListener('data', stderrListener);
- }
- if (this.currentCommandCleanup === cleanupListeners) {
- this.currentCommandCleanup = null;
- }
- onStdoutData = null;
- onStderrData = null;
- };
- this.currentCommandCleanup = cleanupListeners;
- const cleanupAndResolve = async () => {
- if (
- !this.currentCommandCleanup ||
- this.currentCommandCleanup !== cleanupListeners
- ) {
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
- (err) => {
- console.warn(
- `Error cleaning up temp files for superseded command: ${err.message}`,
- );
- },
- );
- }
- return;
- }
- const launchStdout = this.truncateOutput(stdoutBuffer);
- const launchStderr = this.truncateOutput(stderrBuffer);
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (exitCode === null) {
- console.error(
- `CRITICAL: Command "${params.command}" (background: ${isBackgroundTask}) finished delimiter processing but exitCode is null.`,
- );
- const errorMode = isBackgroundTask
- ? 'Background Launch'
- : 'Foreground';
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- }
- originalResolve({
- llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nMode: ${errorMode}\nExit Code: -2 (Internal Error: Exit code not captured)\nStdout (during setup):\n${launchStdout}\nStderr (during setup):\n${launchStderr}`,
- returnDisplay:
- `Internal Error: Failed to capture command exit code.\n${launchStdout}\nStderr: ${launchStderr}`.trim(),
- });
- return;
- }
- let cwdUpdateError = '';
- if (!isBackgroundTask) {
- const mightChangeCwd = params.command.trim().startsWith('cd ');
- if (exitCode === 0 || mightChangeCwd) {
- try {
- const latestCwd = await this.getCurrentShellCwd();
- if (this.currentCwd !== latestCwd) {
- this.currentCwd = latestCwd;
- }
- } catch (e: unknown) {
- if (exitCode === 0) {
- cwdUpdateError = `\nWarning: Failed to verify/update current working directory after command: ${getErrorMessage(e)}`;
- console.error(
- 'Failed to update CWD after successful command:',
- e,
- );
- }
- }
- }
- }
- if (isBackgroundTask) {
- const launchSuccess = exitCode === 0;
- const pidString =
- backgroundPid !== null ? backgroundPid.toString() : 'Not Captured';
- if (
- launchSuccess &&
- backgroundPid !== null &&
- tempStdoutPath &&
- tempStderrPath
- ) {
- this.inspectBackgroundProcess(
- backgroundPid,
- params.command,
- this.currentCwd,
- launchStdout,
- launchStderr,
- tempStdoutPath,
- tempStderrPath,
- originalResolve,
- );
- } else {
- const reason =
- backgroundPid === null
- ? 'PID not captured'
- : `Launch failed (Exit Code: ${exitCode})`;
- const displayMessage = `Failed to launch process in background (${reason})`;
- console.error(
- `Background launch failed for command: ${params.command}. Reason: ${reason}`,
- );
- if (tempStdoutPath && tempStderrPath) {
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- }
- originalResolve({
- llmContent: `Background Command Launch Failed: ${params.command}\nExecuted in: ${this.currentCwd}\nReason: ${reason}\nPID: ${pidString}\nExit Code (Launch): ${exitCode}\nStdout (During Launch):\n${launchStdout}\nStderr (During Launch):\n${launchStderr}`,
- returnDisplay: displayMessage,
- });
- }
- } else {
- let displayOutput = '';
- const stdoutTrimmed = launchStdout.trim();
- const stderrTrimmed = launchStderr.trim();
- if (stderrTrimmed) {
- displayOutput = stderrTrimmed;
- } else if (stdoutTrimmed) {
- displayOutput = stdoutTrimmed;
- }
- if (exitCode !== 0 && !displayOutput) {
- displayOutput = `Failed with exit code: ${exitCode}`;
- } else if (exitCode === 0 && !displayOutput) {
- displayOutput = `Success (no output)`;
- }
- originalResolve({
- llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nExit Code: ${exitCode}\nStdout:\n${launchStdout}\nStderr:\n${launchStderr}${cwdUpdateError}`,
- returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`,
- });
- }
- };
- if (!this.bashProcess || this.bashProcess.killed) {
- console.error(
- 'Bash process lost or killed before listeners could be attached.',
- );
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
- console.warn(
- `Error cleaning up temp files on attach failure: ${err.message}`,
- );
- });
- }
- return originalReject(
- new Error(
- 'Bash process lost or killed before listeners could be attached.',
- ),
- );
- }
- if (onStdoutData) this.bashProcess.stdout.on('data', onStdoutData);
- if (onStderrData) this.bashProcess.stderr.on('data', onStderrData);
- let commandToWrite: string;
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- commandToWrite = `echo "${startDelimiter}"; { { ${params.command} > "${tempStdoutPath}" 2> "${tempStderrPath}"; } & } 2>/dev/null; __LAST_PID=$!; echo "${pidDelimiter}$__LAST_PID" >&2; echo "${exitCodeDelimiter}$?" >&2; echo "${endDelimiter}$?" >&1\n`;
- } else if (!isBackgroundTask) {
- commandToWrite = `echo "${startDelimiter}"; ${params.command}; __EXIT_CODE=$?; echo "${exitCodeDelimiter}$__EXIT_CODE" >&2; echo "${endDelimiter}$__EXIT_CODE" >&1\n`;
- } else {
- return originalReject(
- new Error(
- 'Internal setup error: Missing temporary file paths for background execution.',
- ),
- );
- }
- try {
- if (this.bashProcess?.stdin?.writable) {
- this.bashProcess.stdin.write(commandToWrite, (err) => {
- if (err) {
- console.error(
- `Error writing command "${params.command}" to bash stdin (callback):`,
- err,
- );
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
- (e) => console.warn(`Cleanup failed: ${e.message}`),
- );
- }
- originalReject(
- new Error(
- `Shell stdin write error: ${err.message}. Command likely did not execute.`,
- ),
- );
- }
- });
- } else {
- throw new Error(
- 'Shell stdin is not writable or process closed when attempting to write command.',
- );
- }
- } catch (e: unknown) {
- console.error(
- `Error writing command "${params.command}" to bash stdin (sync):`,
- e,
- );
- const listenersToClean = { onStdoutData, onStderrData };
- cleanupListeners(listenersToClean);
- if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
- this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) =>
- console.warn(`Cleanup failed: ${err.message}`),
- );
- }
- originalReject(
- new Error(
- `Shell stdin write exception: ${getErrorMessage(e)}. Command likely did not execute.`,
- ),
- );
- }
- });
- return promise;
- }
-
- private async inspectBackgroundProcess(
- pid: number,
- command: string,
- cwd: string,
- initialStdout: string,
- initialStderr: string,
- tempStdoutPath: string,
- tempStderrPath: string,
- resolve: (value: ToolResult | PromiseLike<ToolResult>) => void,
- ): Promise<void> {
- let finalStdout = '';
- let finalStderr = '';
- let llmAnalysis = '';
- let fileReadError = '';
- try {
- const { status, summary } = await this.backgroundTerminalAnalyzer.analyze(
- pid,
- tempStdoutPath,
- tempStderrPath,
- command,
- );
- if (status === 'Unknown') llmAnalysis = `LLM analysis failed: ${summary}`;
- else llmAnalysis = summary;
- } catch (llmerror: unknown) {
- console.error(
- `LLM analysis failed for PID ${pid} command "${command}":`,
- llmerror,
- );
- llmAnalysis = `LLM analysis failed: ${getErrorMessage(llmerror)}`;
- }
- try {
- finalStdout = await fs.readFile(tempStdoutPath, 'utf-8');
- finalStderr = await fs.readFile(tempStderrPath, 'utf-8');
- } catch (err: unknown) {
- console.error(`Error reading temp output files for PID ${pid}:`, err);
- fileReadError = `\nWarning: Failed to read temporary output files (${getErrorMessage(err)}). Final output may be incomplete.`;
- }
- await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
- const truncatedFinalStdout = this.truncateOutput(finalStdout);
- const truncatedFinalStderr = this.truncateOutput(finalStderr);
- resolve({
- llmContent: `Background Command: ${command}\nLaunched in: ${cwd}\nPID: ${pid}\n--- LLM Analysis ---\n${llmAnalysis}\n--- Final Stdout (from ${path.basename(tempStdoutPath)}) ---\n${truncatedFinalStdout}\n--- Final Stderr (from ${path.basename(tempStderrPath)}) ---\n${truncatedFinalStderr}\n--- Launch Stdout ---\n${initialStdout}\n--- Launch Stderr ---\n${initialStderr}${fileReadError}`,
- returnDisplay: `(PID: ${pid}): ${this.truncateOutput(llmAnalysis, 200)}`,
- });
- }
-
- private async cleanupTempFiles(
- stdoutPath: string | null,
- stderrPath: string | null,
- ): Promise<void> {
- const unlinkQuietly = async (filePath: string | null) => {
- if (!filePath) return;
- try {
- await fs.unlink(filePath);
- } catch (err: unknown) {
- if (!isNodeError(err) || err.code !== 'ENOENT') {
- console.warn(
- `Failed to delete temporary file '${filePath}': ${getErrorMessage(err)}`,
- );
- }
- }
- };
- await Promise.all([unlinkQuietly(stdoutPath), unlinkQuietly(stderrPath)]);
- }
-
- private getCurrentShellCwd(): Promise<string> {
- return new Promise((resolve, reject) => {
- if (
- !this.bashProcess ||
- !this.bashProcess.stdin?.writable ||
- this.bashProcess.killed
- ) {
- return reject(
- new Error(
- 'Shell not running, stdin not writable, or killed for PWD check',
- ),
- );
- }
- const pwdUuid = crypto.randomUUID();
- const pwdDelimiter = `::PWD_${pwdUuid}::`;
- let pwdOutput = '';
- let onPwdData: ((data: Buffer) => void) | null = null;
- let onPwdError: ((data: Buffer) => void) | null = null;
- let pwdTimeoutId: NodeJS.Timeout | null = null;
- let finished = false;
- const cleanupPwdListeners = (err?: Error) => {
- if (finished) return;
- finished = true;
- if (pwdTimeoutId) clearTimeout(pwdTimeoutId);
- pwdTimeoutId = null;
- const stdoutListener = onPwdData;
- const stderrListener = onPwdError;
- onPwdData = null;
- onPwdError = null;
- if (this.bashProcess && !this.bashProcess.killed) {
- if (stdoutListener)
- this.bashProcess.stdout.removeListener('data', stdoutListener);
- if (stderrListener)
- this.bashProcess.stderr.removeListener('data', stderrListener);
- }
- if (err) {
- reject(err);
- } else {
- resolve(pwdOutput.trim());
- }
- };
- onPwdData = (data: Buffer) => {
- if (!onPwdData) return;
- const dataStr = data.toString();
- const delimiterIndex = dataStr.indexOf(pwdDelimiter);
- if (delimiterIndex !== -1) {
- pwdOutput += dataStr.substring(0, delimiterIndex);
- cleanupPwdListeners();
- } else {
- pwdOutput += dataStr;
- }
- };
- onPwdError = (data: Buffer) => {
- if (!onPwdError) return;
- const dataStr = data.toString();
- console.error(`Error during PWD check: ${dataStr}`);
- cleanupPwdListeners(
- new Error(
- `Stderr received during pwd check: ${this.truncateOutput(dataStr, 100)}`,
- ),
- );
- };
- this.bashProcess.stdout.on('data', onPwdData);
- this.bashProcess.stderr.on('data', onPwdError);
- pwdTimeoutId = setTimeout(() => {
- cleanupPwdListeners(new Error('Timeout waiting for pwd response'));
- }, 5000);
- try {
- const pwdCommand = `printf "%s" "$PWD"; printf "${pwdDelimiter}";\n`;
- if (this.bashProcess?.stdin?.writable) {
- this.bashProcess.stdin.write(pwdCommand, (err) => {
- if (err) {
- console.error('Error writing pwd command (callback):', err);
- cleanupPwdListeners(
- new Error(`Failed to write pwd command: ${err.message}`),
- );
- }
- });
- } else {
- throw new Error('Shell stdin not writable for pwd command.');
- }
- } catch (e: unknown) {
- console.error('Exception writing pwd command:', e);
- cleanupPwdListeners(
- new Error(`Exception writing pwd command: ${getErrorMessage(e)}`),
- );
- }
- });
- }
-
- private truncateOutput(output: string, limit?: number): string {
- const effectiveLimit = limit ?? this.outputLimit;
- if (output.length > effectiveLimit) {
- return (
- output.substring(0, effectiveLimit) +
- `\n... [Output truncated at ${effectiveLimit} characters]`
- );
- }
- return output;
- }
-
- private clearQueue(error: Error) {
- const queue = this.commandQueue;
- this.commandQueue = [];
- queue.forEach(({ resolve, params }) =>
- resolve({
- llmContent: `Command cancelled: ${params.command}\nReason: ${error.message}`,
- returnDisplay: `Command Cancelled: ${error.message}`,
- }),
- );
- }
-
- destroy() {
- this.rejectShellReady?.(
- new Error('BashTool destroyed during initialization or operation.'),
- );
- this.rejectShellReady = undefined;
- this.resolveShellReady = undefined;
- this.clearQueue(new Error('BashTool is being destroyed.'));
- try {
- this.currentCommandCleanup?.();
- } catch (e) {
- console.warn('Error during current command cleanup:', e);
- }
- if (this.bashProcess) {
- const proc = this.bashProcess;
- const pid = proc.pid;
- this.bashProcess = null;
- proc.stdout?.removeAllListeners();
- proc.stderr?.removeAllListeners();
- proc.removeAllListeners('error');
- proc.removeAllListeners('close');
- proc.stdin?.end();
- try {
- proc.kill('SIGTERM');
- setTimeout(() => {
- if (!proc.killed) {
- proc.kill('SIGKILL');
- }
- }, 500);
- } catch (e: unknown) {
- console.warn(
- `Error trying to kill bash process PID: ${pid}: ${getErrorMessage(e)}`,
- );
- }
- }
- }
-}
diff --git a/packages/cli/src/tools/tools.ts b/packages/cli/src/tools/tools.ts
index f8b22ff8..27306a56 100644
--- a/packages/cli/src/tools/tools.ts
+++ b/packages/cli/src/tools/tools.ts
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { FunctionDeclaration, Schema } from '@google/genai';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
+import { ToolCallConfirmationDetails } from '@gemini-code/server';
+import { FunctionDeclaration } from '@google/genai';
/**
* Interface representing the base Tool functionality
@@ -66,83 +66,6 @@ export interface Tool<
execute(params: TParams): Promise<TResult>;
}
-/**
- * Base implementation for tools with common functionality
- */
-export abstract class BaseTool<
- TParams = unknown,
- TResult extends ToolResult = ToolResult,
-> implements Tool<TParams, TResult>
-{
- /**
- * Creates a new instance of BaseTool
- * @param name Internal name of the tool (used for API calls)
- * @param displayName User-friendly display name of the tool
- * @param description Description of what the tool does
- * @param parameterSchema JSON Schema defining the parameters
- */
- constructor(
- readonly name: string,
- readonly displayName: string,
- readonly description: string,
- readonly parameterSchema: Record<string, unknown>,
- ) {}
-
- /**
- * Function declaration schema computed from name, description, and parameterSchema
- */
- get schema(): FunctionDeclaration {
- return {
- name: this.name,
- description: this.description,
- parameters: this.parameterSchema as Schema,
- };
- }
-
- /**
- * Validates the parameters for the tool
- * This is a placeholder implementation and should be overridden
- * @param params Parameters to validate
- * @returns An error message string if invalid, null otherwise
- */
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- validateToolParams(params: TParams): string | null {
- // Implementation would typically use a JSON Schema validator
- // This is a placeholder that should be implemented by derived classes
- return null;
- }
-
- /**
- * Gets a pre-execution description of the tool operation
- * Default implementation that should be overridden by derived classes
- * @param params Parameters for the tool execution
- * @returns A markdown string describing what the tool will do
- */
- getDescription(params: TParams): string {
- return JSON.stringify(params);
- }
-
- /**
- * Determines if the tool should prompt for confirmation before execution
- * @param params Parameters for the tool execution
- * @returns Whether or not execute should be confirmed by the user.
- */
- shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: TParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Abstract method to execute the tool with the given parameters
- * Must be implemented by derived classes
- * @param params Parameters for the tool execution
- * @returns Result of the tool execution
- */
- abstract execute(params: TParams): Promise<TResult>;
-}
-
export interface ToolResult {
/**
* Content meant to be included in LLM history.
diff --git a/packages/cli/src/tools/web-fetch.tool.ts b/packages/cli/src/tools/web-fetch.tool.ts
deleted file mode 100644
index b543dd90..00000000
--- a/packages/cli/src/tools/web-fetch.tool.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// Import core logic and types from the server package
-import {
- WebFetchLogic,
- WebFetchToolParams,
- ToolResult,
-} from '@gemini-code/server';
-
-// Import CLI-specific base class and UI types
-import { BaseTool } from './tools.js';
-import { ToolCallConfirmationDetails } from '../ui/types.js';
-
-/**
- * CLI wrapper for the WebFetch tool.
- */
-export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
- static readonly Name: string = WebFetchLogic.Name; // Use name from logic
-
- // Core logic instance from the server package
- private coreLogic: WebFetchLogic;
-
- constructor() {
- const coreLogicInstance = new WebFetchLogic();
- super(
- WebFetchTool.Name,
- 'WebFetch', // Define display name here
- 'Fetches text content from a given URL. Handles potential network errors and non-success HTTP status codes.', // Define description here
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
- this.coreLogic = coreLogicInstance;
- }
-
- validateToolParams(params: WebFetchToolParams): string | null {
- // Delegate validation to core logic
- return this.coreLogic.validateParams(params);
- }
-
- getDescription(params: WebFetchToolParams): string {
- // Delegate description generation to core logic
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Define confirmation behavior (WebFetch likely doesn't need confirmation)
- */
- async shouldConfirmExecute(
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- params: WebFetchToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- return Promise.resolve(false);
- }
-
- /**
- * Delegates execution to the core logic.
- */
- async execute(params: WebFetchToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-}
diff --git a/packages/cli/src/tools/write-file.tool.ts b/packages/cli/src/tools/write-file.tool.ts
deleted file mode 100644
index a55be8a0..00000000
--- a/packages/cli/src/tools/write-file.tool.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import fs from 'fs';
-import path from 'path';
-import * as Diff from 'diff';
-import {
- WriteFileLogic,
- WriteFileToolParams,
- ToolResult,
- makeRelative,
- shortenPath,
-} from '@gemini-code/server';
-import { BaseTool } from './tools.js';
-import {
- ToolCallConfirmationDetails,
- ToolConfirmationOutcome,
- ToolEditConfirmationDetails,
-} from '../ui/types.js';
-
-/**
- * CLI wrapper for the WriteFile tool.
- */
-export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
- static readonly Name: string = WriteFileLogic.Name;
- private shouldAlwaysWrite = false;
-
- private coreLogic: WriteFileLogic;
-
- constructor(rootDirectory: string) {
- const coreLogicInstance = new WriteFileLogic(rootDirectory);
- super(
- WriteFileTool.Name,
- 'WriteFile',
- 'Writes content to a specified file in the local filesystem.',
- (coreLogicInstance.schema.parameters as Record<string, unknown>) ?? {},
- );
- this.coreLogic = coreLogicInstance;
- }
-
- validateToolParams(params: WriteFileToolParams): string | null {
- return this.coreLogic.validateParams(params);
- }
-
- getDescription(params: WriteFileToolParams): string {
- return this.coreLogic.getDescription(params);
- }
-
- /**
- * Handles the confirmation prompt for the WriteFile tool in the CLI.
- */
- async shouldConfirmExecute(
- params: WriteFileToolParams,
- ): Promise<ToolCallConfirmationDetails | false> {
- if (this.shouldAlwaysWrite) {
- return false;
- }
-
- const validationError = this.validateToolParams(params);
- if (validationError) {
- console.error(
- `[WriteFile Wrapper] Attempted confirmation with invalid parameters: ${validationError}`,
- );
- return false;
- }
-
- const relativePath = makeRelative(
- params.file_path,
- this.coreLogic['rootDirectory'],
- );
- const fileName = path.basename(params.file_path);
-
- let currentContent = '';
- try {
- currentContent = fs.readFileSync(params.file_path, 'utf8');
- } catch {
- // File might not exist, that's okay for write/create
- }
-
- const fileDiff = Diff.createPatch(
- fileName,
- currentContent,
- params.content,
- 'Current',
- 'Proposed',
- { context: 3 },
- );
-
- const confirmationDetails: ToolEditConfirmationDetails = {
- title: `Confirm Write: ${shortenPath(relativePath)}`,
- fileName,
- fileDiff,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.shouldAlwaysWrite = true;
- }
- },
- };
- return confirmationDetails;
- }
-
- /**
- * Delegates execution to the core logic.
- */
- async execute(params: WriteFileToolParams): Promise<ToolResult> {
- return this.coreLogic.execute(params);
- }
-}
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index ee0b7ef7..2da045da 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -7,16 +7,16 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
+import { PartListUnion } from '@google/genai';
+import { DiffRenderer } from './DiffRenderer.js';
+import { UI_WIDTH } from '../../constants.js';
+import { Colors } from '../../colors.js';
import {
ToolCallConfirmationDetails,
ToolEditConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
-} from '../../types.js';
-import { PartListUnion } from '@google/genai';
-import { DiffRenderer } from './DiffRenderer.js';
-import { UI_WIDTH } from '../../constants.js';
-import { Colors } from '../../colors.js';
+} from '@gemini-code/server';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 372d5ffe..f33ed6cb 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -7,16 +7,15 @@
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
+import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
+import { DiffRenderer } from './DiffRenderer.js';
+import { FileDiff, ToolResultDisplay } from '../../../tools/tools.js';
+import { Colors } from '../../colors.js';
import {
- IndividualToolCallDisplay,
- ToolCallStatus,
ToolCallConfirmationDetails,
ToolEditConfirmationDetails,
ToolExecuteConfirmationDetails,
-} from '../../types.js';
-import { DiffRenderer } from './DiffRenderer.js';
-import { FileDiff, ToolResultDisplay } from '../../../tools/tools.js';
-import { Colors } from '../../colors.js';
+} from '@gemini-code/server';
export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
callId,
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 21a9f508..585554ee 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -15,17 +15,16 @@ import {
isNodeError,
ToolResult,
Config,
+ ToolCallConfirmationDetails,
+ ToolCallResponseInfo,
} from '@gemini-code/server';
import type { Chat, PartListUnion, FunctionDeclaration } from '@google/genai';
-// Import CLI types
import {
HistoryItem,
IndividualToolCallDisplay,
ToolCallStatus,
} from '../types.js';
-import { Tool } from '../../tools/tools.js'; // CLI Tool definition
import { StreamingState } from '../../core/gemini-stream.js';
-// Import CLI tool registry
import { toolRegistry } from '../../tools/tool-registry.js';
const addHistoryItem = (
@@ -112,7 +111,7 @@ export const useGeminiStream = (
// This just clears the *UI* history, not the model history.
// TODO: add a slash command for that.
setDebugMessage('Clearing terminal.');
- setHistory((prevHistory) => []);
+ setHistory((_) => []);
return;
} else if (config.getPassthroughCommands().includes(maybeCommand)) {
// Execute and capture output
@@ -188,14 +187,7 @@ export const useGeminiStream = (
const signal = abortControllerRef.current.signal;
// Get ServerTool descriptions for the server call
- const serverTools: ServerTool[] = toolRegistry
- .getAllTools()
- .map((cliTool: Tool) => ({
- name: cliTool.name,
- schema: cliTool.schema,
- execute: (args: Record<string, unknown>) =>
- cliTool.execute(args as ToolArgs), // Pass execution
- }));
+ const serverTools: ServerTool[] = toolRegistry.getAllTools();
const stream = client.sendMessageStream(
chat,
@@ -257,11 +249,18 @@ export const useGeminiStream = (
);
}
+ let description: string;
+ try {
+ description = cliTool.getDescription(args);
+ } catch (e) {
+ description = `Error: Unable to get description: ${getErrorMessage(e)}`;
+ }
+
// Create the UI display object matching IndividualToolCallDisplay
const toolCallDisplay: IndividualToolCallDisplay = {
callId,
name,
- description: cliTool.getDescription(args as ToolArgs),
+ description,
status: ToolCallStatus.Pending,
resultDisplay: undefined,
confirmationDetails: undefined,
@@ -286,143 +285,35 @@ export const useGeminiStream = (
return item;
}),
);
-
- // --- Tool Execution & Confirmation Logic ---
- const confirmationDetails = await cliTool.shouldConfirmExecute(
- args as ToolArgs,
+ } else if (event.type === ServerGeminiEventType.ToolCallResponse) {
+ updateFunctionResponseUI(event.value);
+ } else if (
+ event.type === ServerGeminiEventType.ToolCallConfirmation
+ ) {
+ setHistory((prevHistory) =>
+ prevHistory.map((item) => {
+ if (
+ item.id === currentToolGroupId &&
+ item.type === 'tool_group'
+ ) {
+ return {
+ ...item,
+ tools: item.tools.map((tool) =>
+ tool.callId === event.value.request.callId
+ ? {
+ ...tool,
+ status: ToolCallStatus.Confirming,
+ confirmationDetails: event.value.details,
+ }
+ : tool,
+ ),
+ };
+ }
+ return item;
+ }),
);
-
- if (confirmationDetails) {
- setHistory((prevHistory) =>
- prevHistory.map((item) => {
- if (
- item.id === currentToolGroupId &&
- item.type === 'tool_group'
- ) {
- return {
- ...item,
- tools: item.tools.map((tool) =>
- tool.callId === callId
- ? {
- ...tool,
- status: ToolCallStatus.Confirming,
- confirmationDetails,
- }
- : tool,
- ),
- };
- }
- return item;
- }),
- );
- setStreamingState(StreamingState.WaitingForConfirmation);
- return;
- }
-
- try {
- setHistory((prevHistory) =>
- prevHistory.map((item) => {
- if (
- item.id === currentToolGroupId &&
- item.type === 'tool_group'
- ) {
- return {
- ...item,
- tools: item.tools.map((tool) =>
- tool.callId === callId
- ? {
- ...tool,
- status:
- tool.status === ToolCallStatus.Error
- ? ToolCallStatus.Error
- : ToolCallStatus.Invoked,
- }
- : tool,
- ),
- };
- }
- return item;
- }),
- );
-
- const result: ToolResult = await cliTool.execute(
- args as ToolArgs,
- );
- const resultPart = {
- functionResponse: {
- name,
- id: callId,
- response: { output: result.llmContent },
- },
- };
-
- setHistory((prevHistory) =>
- prevHistory.map((item) => {
- if (
- item.id === currentToolGroupId &&
- item.type === 'tool_group'
- ) {
- return {
- ...item,
- tools: item.tools.map((tool) =>
- tool.callId === callId
- ? {
- ...tool,
- status:
- tool.status === ToolCallStatus.Error
- ? ToolCallStatus.Error
- : ToolCallStatus.Success,
- resultDisplay: result.returnDisplay,
- }
- : tool,
- ),
- };
- }
- return item;
- }),
- );
-
- // Execute the function and continue the stream
- await submitQuery(resultPart);
- return;
- } catch (execError: unknown) {
- const error = new Error(
- `Tool execution failed: ${execError instanceof Error ? execError.message : String(execError)}`,
- );
- const errorPart = {
- functionResponse: {
- name,
- id: callId,
- response: {
- error: `Tool execution failed: ${error.message}`,
- },
- },
- };
- setHistory((prevHistory) =>
- prevHistory.map((item) => {
- if (
- item.id === currentToolGroupId &&
- item.type === 'tool_group'
- ) {
- return {
- ...item,
- tools: item.tools.map((tool) =>
- tool.callId === callId
- ? {
- ...tool,
- status: ToolCallStatus.Error,
- resultDisplay: `Error: ${error.message}`,
- }
- : tool,
- ),
- };
- }
- return item;
- }),
- );
- await submitQuery(errorPart);
- return;
- }
+ setStreamingState(StreamingState.WaitingForConfirmation);
+ return;
}
}
} catch (error: unknown) {
@@ -445,6 +336,33 @@ export const useGeminiStream = (
setStreamingState(StreamingState.Idle);
}
}
+
+ function updateFunctionResponseUI(toolResponse: ToolCallResponseInfo) {
+ setHistory((prevHistory) =>
+ prevHistory.map((item) => {
+ if (item.id === currentToolGroupId && item.type === 'tool_group') {
+ return {
+ ...item,
+ tools: item.tools.map((tool) => {
+ if (tool.callId === toolResponse.callId) {
+ return {
+ ...tool,
+ // TODO: Do we surface the error here?
+ status: toolResponse.error
+ ? ToolCallStatus.Error
+ : ToolCallStatus.Success,
+ resultDisplay: toolResponse.resultDisplay,
+ };
+ } else {
+ return tool;
+ }
+ }),
+ };
+ }
+ return item;
+ }),
+ );
+ }
},
// Dependencies need careful review - including updateGeminiMessage
[
@@ -464,8 +382,8 @@ export const useGeminiStream = (
interface ServerTool {
name: string;
schema: FunctionDeclaration;
+ shouldConfirmExecute(
+ params: Record<string, unknown>,
+ ): Promise<ToolCallConfirmationDetails | false>;
execute(params: Record<string, unknown>): Promise<ToolResult>;
}
-
-// Define a more specific type for tool arguments to replace 'any'
-type ToolArgs = Record<string, unknown>;
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index b0959ce4..df8325b3 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { ToolCallConfirmationDetails } from '@gemini-code/server';
import { ToolResultDisplay } from '../tools/tools.js';
export enum ToolCallStatus {
@@ -46,27 +47,3 @@ export type HistoryItem = HistoryItemBase &
| { type: 'error'; text: string }
| { type: 'tool_group'; tools: IndividualToolCallDisplay[] }
);
-
-export interface ToolCallConfirmationDetails {
- title: string;
- onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
-}
-
-export interface ToolEditConfirmationDetails
- extends ToolCallConfirmationDetails {
- fileName: string;
- fileDiff: string;
-}
-
-export interface ToolExecuteConfirmationDetails
- extends ToolCallConfirmationDetails {
- command: string;
- rootCommand: string;
- description: string;
-}
-
-export enum ToolConfirmationOutcome {
- ProceedOnce,
- ProceedAlways,
- Cancel,
-}
diff --git a/packages/server/src/core/gemini-client.ts b/packages/server/src/core/gemini-client.ts
index d78a0559..b9b44534 100644
--- a/packages/server/src/core/gemini-client.ts
+++ b/packages/server/src/core/gemini-client.ts
@@ -18,15 +18,7 @@ import {
import { CoreSystemPrompt } from './prompts.js';
import process from 'node:process';
import { getFolderStructure } from '../utils/getFolderStructure.js';
-import { Turn, ServerTool, GeminiEventType } from './turn.js';
-
-// Import the ServerGeminiStreamEvent type
-type ServerGeminiStreamEvent =
- | { type: GeminiEventType.Content; value: string }
- | {
- type: GeminiEventType.ToolCallRequest;
- value: { callId: string; name: string; args: Record<string, unknown> };
- };
+import { Turn, ServerTool, ServerGeminiStreamEvent } from './turn.js';
export class GeminiClient {
private ai: GoogleGenAI;
@@ -112,6 +104,14 @@ export class GeminiClient {
for await (const event of resultStream) {
yield event;
}
+
+ const confirmations = turn.getConfirmationDetails();
+ if (confirmations.length > 0) {
+ break;
+ }
+
+ // What do we do when we have both function responses and confirmations?
+
const fnResponses = turn.getFunctionResponses();
if (fnResponses.length > 0) {
request = fnResponses;
diff --git a/packages/server/src/core/turn.ts b/packages/server/src/core/turn.ts
index 3d8c8c76..0a1c594c 100644
--- a/packages/server/src/core/turn.ts
+++ b/packages/server/src/core/turn.ts
@@ -13,7 +13,11 @@ import {
FunctionDeclaration,
} from '@google/genai';
// Removed UI type imports
-import { ToolResult } from '../tools/tools.js'; // Keep ToolResult for now
+import {
+ ToolCallConfirmationDetails,
+ ToolResult,
+ ToolResultDisplay,
+} from '../tools/tools.js'; // Keep ToolResult for now
// Removed gemini-stream import (types defined locally)
// --- Types for Server Logic ---
@@ -25,7 +29,7 @@ interface ServerToolExecutionOutcome {
args: Record<string, unknown>; // Use unknown for broader compatibility
result?: ToolResult;
error?: Error;
- // Confirmation details are handled by CLI, not server logic
+ confirmationDetails: ToolCallConfirmationDetails | undefined;
}
// Define a structure for tools passed to the server
@@ -34,6 +38,9 @@ export interface ServerTool {
schema: FunctionDeclaration; // Schema is needed
// The execute method signature might differ slightly or be wrapped
execute(params: Record<string, unknown>): Promise<ToolResult>;
+ shouldConfirmExecute(
+ params: Record<string, unknown>,
+ ): Promise<ToolCallConfirmationDetails | false>;
// validation and description might be handled differently or passed
}
@@ -41,17 +48,36 @@ export interface ServerTool {
export enum GeminiEventType {
Content = 'content',
ToolCallRequest = 'tool_call_request',
+ ToolCallResponse = 'tool_call_response',
+ ToolCallConfirmation = 'tool_call_confirmation',
}
-interface ToolCallRequestInfo {
+export interface ToolCallRequestInfo {
callId: string;
name: string;
args: Record<string, unknown>;
}
-type ServerGeminiStreamEvent =
+export interface ToolCallResponseInfo {
+ callId: string;
+ responsePart: Part;
+ resultDisplay: ToolResultDisplay | undefined;
+ error: Error | undefined;
+}
+
+export interface ServerToolCallConfirmationDetails {
+ request: ToolCallRequestInfo;
+ details: ToolCallConfirmationDetails;
+}
+
+export type ServerGeminiStreamEvent =
| { type: GeminiEventType.Content; value: string }
- | { type: GeminiEventType.ToolCallRequest; value: ToolCallRequestInfo };
+ | { type: GeminiEventType.ToolCallRequest; value: ToolCallRequestInfo }
+ | { type: GeminiEventType.ToolCallResponse; value: ToolCallResponseInfo }
+ | {
+ type: GeminiEventType.ToolCallConfirmation;
+ value: ServerToolCallConfirmationDetails;
+ };
// --- Turn Class (Refactored for Server) ---
@@ -65,6 +91,7 @@ export class Turn {
args: Record<string, unknown>; // Use unknown
}>;
private fnResponses: Part[];
+ private confirmationDetails: ToolCallConfirmationDetails[];
private debugResponses: GenerateContentResponse[];
constructor(chat: Chat, availableTools: ServerTool[]) {
@@ -72,6 +99,7 @@ export class Turn {
this.availableTools = new Map(availableTools.map((t) => [t.name, t]));
this.pendingToolCalls = [];
this.fnResponses = [];
+ this.confirmationDetails = [];
this.debugResponses = [];
}
@@ -113,19 +141,31 @@ export class Turn {
error: new Error(
`Tool "${pendingToolCall.name}" not found or not provided to Turn.`,
),
+ confirmationDetails: undefined,
};
}
- // No confirmation logic in the server Turn
+
try {
- // TODO: Add validation step if needed (tool.validateParams?)
- const result = await tool.execute(pendingToolCall.args);
- return { ...pendingToolCall, result };
+ const confirmationDetails = await tool.shouldConfirmExecute(
+ pendingToolCall.args,
+ );
+ if (confirmationDetails) {
+ return { ...pendingToolCall, confirmationDetails };
+ } else {
+ const result = await tool.execute(pendingToolCall.args);
+ return {
+ ...pendingToolCall,
+ result,
+ confirmationDetails: undefined,
+ };
+ }
} catch (execError: unknown) {
return {
...pendingToolCall,
error: new Error(
`Tool execution failed: ${execError instanceof Error ? execError.message : String(execError)}`,
),
+ confirmationDetails: undefined,
};
}
},
@@ -133,9 +173,37 @@ export class Turn {
const outcomes = await Promise.all(toolPromises);
// Process outcomes and prepare function responses
- this.fnResponses = this.buildFunctionResponses(outcomes);
this.pendingToolCalls = []; // Clear pending calls for this turn
+ for (let i = 0; i < outcomes.length; i++) {
+ const outcome = outcomes[i];
+ if (outcome.confirmationDetails) {
+ this.confirmationDetails.push(outcome.confirmationDetails);
+ const serverConfirmationetails: ServerToolCallConfirmationDetails = {
+ request: {
+ callId: outcome.callId,
+ name: outcome.name,
+ args: outcome.args,
+ },
+ details: outcome.confirmationDetails,
+ };
+ yield {
+ type: GeminiEventType.ToolCallConfirmation,
+ value: serverConfirmationetails,
+ };
+ } else {
+ const responsePart = this.buildFunctionResponse(outcome);
+ this.fnResponses.push(responsePart);
+ const responseInfo: ToolCallResponseInfo = {
+ callId: outcome.callId,
+ responsePart,
+ resultDisplay: outcome.result?.returnDisplay,
+ error: outcome.error,
+ };
+ yield { type: GeminiEventType.ToolCallResponse, value: responseInfo };
+ }
+ }
+
// If there were function responses, the caller (GeminiService) will loop
// and call run() again with these responses.
// If no function responses, the turn ends here.
@@ -160,31 +228,27 @@ export class Turn {
}
// Builds the Part array expected by the Google GenAI API
- private buildFunctionResponses(
- outcomes: ServerToolExecutionOutcome[],
- ): Part[] {
- return outcomes.map((outcome): Part => {
- const { name, result, error } = outcome;
- let fnResponsePayload: Record<string, unknown>;
+ private buildFunctionResponse(outcome: ServerToolExecutionOutcome): Part {
+ const { name, result, error } = outcome;
+ let fnResponsePayload: Record<string, unknown>;
- if (error) {
- // Format error for the LLM
- const errorMessage = error?.message || String(error);
- fnResponsePayload = { error: `Tool execution failed: ${errorMessage}` };
- console.error(`[Server Turn] Error executing tool ${name}:`, error);
- } else {
- // Pass successful tool result (content meant for LLM)
- fnResponsePayload = { output: result?.llmContent ?? '' }; // Default to empty string if no content
- }
+ if (error) {
+ // Format error for the LLM
+ const errorMessage = error?.message || String(error);
+ fnResponsePayload = { error: `Tool execution failed: ${errorMessage}` };
+ console.error(`[Server Turn] Error executing tool ${name}:`, error);
+ } else {
+ // Pass successful tool result (content meant for LLM)
+ fnResponsePayload = { output: result?.llmContent ?? '' }; // Default to empty string if no content
+ }
- return {
- functionResponse: {
- name,
- id: outcome.callId,
- response: fnResponsePayload,
- },
- };
- });
+ return {
+ functionResponse: {
+ name,
+ id: outcome.callId,
+ response: fnResponsePayload,
+ },
+ };
}
private abortError(): Error {
@@ -193,6 +257,10 @@ export class Turn {
return error; // Return instead of throw, let caller handle
}
+ getConfirmationDetails(): ToolCallConfirmationDetails[] {
+ return this.confirmationDetails;
+ }
+
// Allows the service layer to get the responses needed for the next API call
getFunctionResponses(): Part[] {
return this.fnResponses;
diff --git a/packages/server/src/tools/edit.ts b/packages/server/src/tools/edit.ts
index 67c5a37b..5dbeaf41 100644
--- a/packages/server/src/tools/edit.ts
+++ b/packages/server/src/tools/edit.ts
@@ -7,7 +7,14 @@
import fs from 'fs';
import path from 'path';
import * as Diff from 'diff';
-import { BaseTool, ToolResult, ToolResultDisplay } from './tools.js';
+import {
+ BaseTool,
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolEditConfirmationDetails,
+ ToolResult,
+ ToolResultDisplay,
+} from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
@@ -48,8 +55,9 @@ interface CalculatedEdit {
/**
* Implementation of the Edit tool logic (moved from CLI)
*/
-export class EditLogic extends BaseTool<EditToolParams, ToolResult> {
+export class EditTool extends BaseTool<EditToolParams, ToolResult> {
static readonly Name = 'replace'; // Keep static name
+ private shouldAlwaysEdit = false;
private readonly rootDirectory: string;
@@ -61,9 +69,9 @@ export class EditLogic extends BaseTool<EditToolParams, ToolResult> {
// Note: The description here mentions other tools like ReadFileTool/WriteFileTool
// by name. This might need updating if those tool names change.
super(
- EditLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ EditTool.Name,
+ 'Edit',
+ 'Replaces a SINGLE, UNIQUE occurrence of text within a file. Requires providing significant context around the change to ensure uniqueness. For moving/renaming files, use the Bash tool with `mv`. For replacing entire file contents or creating new files use the WriteFile tool. Always use the ReadFile tool to examine the file before using this tool.',
{
properties: {
file_path: {
@@ -225,7 +233,82 @@ export class EditLogic extends BaseTool<EditToolParams, ToolResult> {
};
}
- // Removed shouldConfirmExecute - Confirmation is handled by the CLI wrapper
+ /**
+ * Handles the confirmation prompt for the Edit tool in the CLI.
+ * It needs to calculate the diff to show the user.
+ */
+ async shouldConfirmExecute(
+ params: EditToolParams,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ if (this.shouldAlwaysEdit) {
+ return false;
+ }
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ console.error(
+ `[EditTool Wrapper] Attempted confirmation with invalid parameters: ${validationError}`,
+ );
+ return false;
+ }
+ let currentContent: string | null = null;
+ let fileExists = false;
+ let newContent = '';
+ try {
+ currentContent = fs.readFileSync(params.file_path, 'utf8');
+ fileExists = true;
+ } catch (err: unknown) {
+ if (isNodeError(err) && err.code === 'ENOENT') {
+ fileExists = false;
+ } else {
+ console.error(`Error reading file for confirmation diff: ${err}`);
+ return false;
+ }
+ }
+ if (params.old_string === '' && !fileExists) {
+ newContent = params.new_string;
+ } else if (!fileExists) {
+ return false;
+ } else if (currentContent !== null) {
+ const occurrences = this.countOccurrences(
+ currentContent,
+ params.old_string,
+ );
+ const expectedReplacements =
+ params.expected_replacements === undefined
+ ? 1
+ : params.expected_replacements;
+ if (occurrences === 0 || occurrences !== expectedReplacements) {
+ return false;
+ }
+ newContent = this.replaceAll(
+ currentContent,
+ params.old_string,
+ params.new_string,
+ );
+ } else {
+ return false;
+ }
+ const fileName = path.basename(params.file_path);
+ const fileDiff = Diff.createPatch(
+ fileName,
+ currentContent ?? '',
+ newContent,
+ 'Current',
+ 'Proposed',
+ { context: 3 },
+ );
+ const confirmationDetails: ToolEditConfirmationDetails = {
+ title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
+ fileName,
+ fileDiff,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.shouldAlwaysEdit = true;
+ }
+ },
+ };
+ return confirmationDetails;
+ }
getDescription(params: EditToolParams): string {
const relativePath = makeRelative(params.file_path, this.rootDirectory);
diff --git a/packages/server/src/tools/glob.ts b/packages/server/src/tools/glob.ts
index e81858c8..f51456c3 100644
--- a/packages/server/src/tools/glob.ts
+++ b/packages/server/src/tools/glob.ts
@@ -29,7 +29,7 @@ export interface GlobToolParams {
/**
* Implementation of the Glob tool logic (moved from CLI)
*/
-export class GlobLogic extends BaseTool<GlobToolParams, ToolResult> {
+export class GlobTool extends BaseTool<GlobToolParams, ToolResult> {
static readonly Name = 'glob'; // Keep static name
/**
@@ -43,9 +43,9 @@ export class GlobLogic extends BaseTool<GlobToolParams, ToolResult> {
*/
constructor(rootDirectory: string) {
super(
- GlobLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ GlobTool.Name,
+ 'FindFiles', // Display name handled by CLI wrapper
+ 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.', // Description handled by CLI wrapper
{
properties: {
pattern: {
diff --git a/packages/server/src/tools/grep.ts b/packages/server/src/tools/grep.ts
index b0d4637c..1873a794 100644
--- a/packages/server/src/tools/grep.ts
+++ b/packages/server/src/tools/grep.ts
@@ -51,7 +51,7 @@ interface GrepMatch {
/**
* Implementation of the Grep tool logic (moved from CLI)
*/
-export class GrepLogic extends BaseTool<GrepToolParams, ToolResult> {
+export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
static readonly Name = 'search_file_content'; // Keep static name
private rootDirectory: string;
@@ -62,9 +62,9 @@ export class GrepLogic extends BaseTool<GrepToolParams, ToolResult> {
*/
constructor(rootDirectory: string) {
super(
- GrepLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ GrepTool.Name,
+ 'SearchText',
+ 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
{
properties: {
pattern: {
diff --git a/packages/server/src/tools/ls.ts b/packages/server/src/tools/ls.ts
index 0e856e80..a646dd22 100644
--- a/packages/server/src/tools/ls.ts
+++ b/packages/server/src/tools/ls.ts
@@ -58,7 +58,7 @@ export interface FileEntry {
/**
* Implementation of the LS tool logic
*/
-export class LSLogic extends BaseTool<LSToolParams, ToolResult> {
+export class LSTool extends BaseTool<LSToolParams, ToolResult> {
static readonly Name = 'list_directory';
/**
@@ -73,9 +73,9 @@ export class LSLogic extends BaseTool<LSToolParams, ToolResult> {
*/
constructor(rootDirectory: string) {
super(
- LSLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ LSTool.Name,
+ 'ReadFolder',
+ 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
{
properties: {
path: {
diff --git a/packages/server/src/tools/read-file.ts b/packages/server/src/tools/read-file.ts
index 52856e42..6cd70302 100644
--- a/packages/server/src/tools/read-file.ts
+++ b/packages/server/src/tools/read-file.ts
@@ -33,7 +33,7 @@ export interface ReadFileToolParams {
/**
* Implementation of the ReadFile tool logic
*/
-export class ReadFileLogic extends BaseTool<ReadFileToolParams, ToolResult> {
+export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
static readonly Name: string = 'read_file';
private static readonly DEFAULT_MAX_LINES = 2000;
private static readonly MAX_LINE_LENGTH = 2000;
@@ -41,9 +41,9 @@ export class ReadFileLogic extends BaseTool<ReadFileToolParams, ToolResult> {
constructor(rootDirectory: string) {
super(
- ReadFileLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ ReadFileTool.Name,
+ 'ReadFile',
+ 'Reads and returns the content of a specified file from the local filesystem. Handles large files by allowing reading specific line ranges.',
{
properties: {
path: {
@@ -236,16 +236,15 @@ export class ReadFileLogic extends BaseTool<ReadFileToolParams, ToolResult> {
const startLine = params.offset || 0;
const endLine = params.limit
? startLine + params.limit
- : Math.min(startLine + ReadFileLogic.DEFAULT_MAX_LINES, lines.length);
+ : Math.min(startLine + ReadFileTool.DEFAULT_MAX_LINES, lines.length);
const selectedLines = lines.slice(startLine, endLine);
let truncated = false;
const formattedLines = selectedLines.map((line) => {
let processedLine = line;
- if (line.length > ReadFileLogic.MAX_LINE_LENGTH) {
+ if (line.length > ReadFileTool.MAX_LINE_LENGTH) {
processedLine =
- line.substring(0, ReadFileLogic.MAX_LINE_LENGTH) +
- '... [truncated]';
+ line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]';
truncated = true;
}
diff --git a/packages/server/src/tools/terminal.ts b/packages/server/src/tools/terminal.ts
index 6366106c..eab170ab 100644
--- a/packages/server/src/tools/terminal.ts
+++ b/packages/server/src/tools/terminal.ts
@@ -4,18 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { spawn, SpawnOptions } from 'child_process';
+import {
+ spawn,
+ SpawnOptions,
+ ChildProcessWithoutNullStreams,
+} from 'child_process';
import path from 'path';
-import { BaseTool, ToolResult } from './tools.js';
-import { SchemaValidator } from '../utils/schemaValidator.js';
-import { getErrorMessage } from '../utils/errors.js';
+import os from 'os';
+import crypto from 'crypto';
+import { promises as fs } from 'fs';
+import {
+ SchemaValidator,
+ getErrorMessage,
+ isNodeError,
+ Config,
+} from '@gemini-code/server';
+import {
+ BaseTool,
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolExecuteConfirmationDetails,
+ ToolResult,
+} from './tools.js';
+import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js';
export interface TerminalToolParams {
command: string;
+ description?: string;
+ timeout?: number;
+ runInBackground?: boolean;
}
const MAX_OUTPUT_LENGTH = 10000;
-const DEFAULT_EXEC_TIMEOUT_MS = 5 * 60 * 1000;
+const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
+const MAX_TIMEOUT_OVERRIDE_MS = 10 * 60 * 1000;
+const BACKGROUND_LAUNCH_TIMEOUT_MS = 15 * 1000;
+const BACKGROUND_POLL_TIMEOUT_MS = 30000;
const BANNED_COMMAND_ROOTS = [
'alias',
@@ -85,41 +109,197 @@ const BANNED_COMMAND_ROOTS = [
'open',
];
-/**
- * Simplified implementation of the Terminal tool logic for single command execution.
- */
-export class TerminalLogic extends BaseTool<TerminalToolParams, ToolResult> {
- static readonly Name = 'execute_bash_command';
+interface QueuedCommand {
+ params: TerminalToolParams;
+ resolve: (result: ToolResult) => void;
+ reject: (error: Error) => void;
+ confirmationDetails: ToolExecuteConfirmationDetails | false;
+}
+
+export class TerminalTool extends BaseTool<TerminalToolParams, ToolResult> {
+ static Name: string = 'execute_bash_command';
private readonly rootDirectory: string;
+ private readonly outputLimit: number;
+ private bashProcess: ChildProcessWithoutNullStreams | null = null;
+ private currentCwd: string;
+ private isExecuting: boolean = false;
+ private commandQueue: QueuedCommand[] = [];
+ private currentCommandCleanup: (() => void) | null = null;
+ private shouldAlwaysExecuteCommands: Map<string, boolean> = new Map();
+ private shellReady: Promise<void>;
+ private resolveShellReady: (() => void) | undefined;
+ private rejectShellReady: ((reason?: unknown) => void) | undefined;
+ private readonly backgroundTerminalAnalyzer: BackgroundTerminalAnalyzer;
+ private readonly config: Config;
- constructor(rootDirectory: string) {
- super(
- TerminalLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
- {
- type: 'object',
- properties: {
- command: {
- description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`,
- type: 'string',
- },
+ constructor(
+ rootDirectory: string,
+ config: Config,
+ outputLimit: number = MAX_OUTPUT_LENGTH,
+ ) {
+ const toolDisplayName = 'Terminal';
+ const toolDescription = `Executes one or more bash commands sequentially in a secure and persistent interactive shell session. Can run commands in the foreground (waiting for completion) or background (returning after launch, with subsequent status polling).
+
+Core Functionality:
+* Starts in project root: '${path.basename(rootDirectory)}'. Current Directory starts as: ${rootDirectory} (will update based on 'cd' commands).
+* Persistent State: Environment variables and the current working directory (\`pwd\`) persist between calls to this tool.
+* **Execution Modes:**
+ * **Foreground (default):** Waits for the command to complete. Captures stdout, stderr, and exit code. Output is truncated if it exceeds ${outputLimit} characters.
+ * **Background (\`runInBackground: true\`):** Appends \`&\` to the command and redirects its output to temporary files. Returns *after* the command is launched, providing the Process ID (PID) and launch status. Subsequently, the tool **polls** for the background process status for up to ${BACKGROUND_POLL_TIMEOUT_MS / 1000} seconds. Once the process finishes or polling times out, the tool reads the captured stdout/stderr from the temporary files, runs an internal LLM analysis on the output, cleans up the files, and returns the final status, captured output, and analysis.
+* Timeout: Optional timeout per 'execute' call (default: ${DEFAULT_TIMEOUT_MS / 60000} min, max override: ${MAX_TIMEOUT_OVERRIDE_MS / 60000} min for foreground). Background *launch* has a fixed shorter timeout (${BACKGROUND_LAUNCH_TIMEOUT_MS / 1000}s) for the launch attempt itself. Background *polling* has its own timeout (${BACKGROUND_POLL_TIMEOUT_MS / 1000}s). Timeout attempts SIGINT for foreground commands.
+
+Usage Guidance & Restrictions:
+
+1. **Directory/File Verification (IMPORTANT):**
+ * BEFORE executing commands that create files or directories (e.g., \`mkdir foo/bar\`, \`touch new/file.txt\`, \`git clone ...\`), use the dedicated File System tool (e.g., 'list_directory') to verify the target parent directory exists and is the correct location.
+ * Example: Before running \`mkdir foo/bar\`, first use the File System tool to check that \`foo\` exists in the current directory (\`${rootDirectory}\` initially, check current CWD if it changed).
+
+2. **Use Specialized Tools (CRITICAL):**
+ * Do NOT use this tool for filesystem searching (\`find\`, \`grep\`). Use the dedicated Search tool instead.
+ * Do NOT use this tool for reading files (\`cat\`, \`head\`, \`tail\`, \`less\`, \`more\`). Use the dedicated File Reader tool instead.
+ * Do NOT use this tool for listing files (\`ls\`). Use the dedicated File System tool ('list_directory') instead. Relying on this tool's output for directory structure is unreliable due to potential truncation and lack of structured data.
+
+3. **Security & Banned Commands:**
+ * Certain commands are banned for security (e.g., network: ${BANNED_COMMAND_ROOTS.filter((c) => ['curl', 'wget', 'ssh'].includes(c)).join(', ')}; session: ${BANNED_COMMAND_ROOTS.filter((c) => ['exit', 'export', 'kill'].includes(c)).join(', ')}; etc.). The full list is extensive.
+ * If you attempt a banned command, this tool will return an error explaining the restriction. You MUST relay this error clearly to the user.
+
+4. **Command Execution Notes:**
+ * Chain multiple commands using shell operators like ';' or '&&'. Do NOT use newlines within the 'command' parameter string itself (newlines are fine inside quoted arguments).
+ * The shell's current working directory is tracked internally. While \`cd\` is permitted if the user explicitly asks or it's necessary for a workflow, **strongly prefer** using absolute paths or paths relative to the *known* current working directory to avoid errors. Check the '(Executed in: ...)' part of the previous command's output for the CWD.
+ * Good example (if CWD is /workspace/project): \`pytest tests/unit\` or \`ls /workspace/project/data\`
+ * Less preferred: \`cd tests && pytest unit\` (only use if necessary or requested)
+
+5. **Background Tasks (\`runInBackground: true\`):**
+ * Use this for commands that are intended to run continuously (e.g., \`node server.js\`, \`npm start\`).
+ * The tool initially returns success if the process *launches* successfully, along with its PID.
+ * **Polling & Final Result:** The tool then monitors the process. The *final* result (delivered after polling completes or times out) will include:
+ * The final status (completed or timed out).
+ * The complete stdout and stderr captured in temporary files (truncated if necessary).
+ * An LLM-generated analysis/summary of the output.
+ * The initial exit code (usually 0) signifies successful *launching*; the final status indicates completion or timeout after polling.
+
+Use this tool for running build steps (\`npm install\`, \`make\`), linters (\`eslint .\`), test runners (\`pytest\`, \`jest\`), code formatters (\`prettier --write .\`), package managers (\`pip install\`), version control operations (\`git status\`, \`git diff\`), starting background servers/services (\`node server.js --runInBackground true\`), or other safe, standard command-line operations within the project workspace.`;
+ const toolParameterSchema = {
+ type: 'object',
+ properties: {
+ command: {
+ description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`,
+ type: 'string',
+ },
+ description: {
+ description: `Optional: A brief, user-centric explanation of what the command does and why it's being run. Used for logging and confirmation prompts. Example: 'Install project dependencies'`,
+ type: 'string',
+ },
+ timeout: {
+ description: `Optional execution time limit in milliseconds for FOREGROUND commands. Max ${MAX_TIMEOUT_OVERRIDE_MS}ms (${MAX_TIMEOUT_OVERRIDE_MS / 60000} min). Defaults to ${DEFAULT_TIMEOUT_MS}ms (${DEFAULT_TIMEOUT_MS / 60000} min) if not specified or invalid. Ignored if 'runInBackground' is true.`,
+ type: 'number',
+ },
+ runInBackground: {
+ description: `If true, execute the command in the background using '&'. Defaults to false. Use for servers or long tasks.`,
+ type: 'boolean',
},
- required: ['command'],
},
+ required: ['command'],
+ };
+ super(
+ TerminalTool.Name,
+ toolDisplayName,
+ toolDescription,
+ toolParameterSchema,
);
+ this.config = config;
this.rootDirectory = path.resolve(rootDirectory);
+ this.currentCwd = this.rootDirectory;
+ this.outputLimit = outputLimit;
+ this.shellReady = new Promise((resolve, reject) => {
+ this.resolveShellReady = resolve;
+ this.rejectShellReady = reject;
+ });
+ this.backgroundTerminalAnalyzer = new BackgroundTerminalAnalyzer(config);
+ this.initializeShell();
+ }
+
+ private initializeShell() {
+ if (this.bashProcess) {
+ try {
+ this.bashProcess.kill();
+ } catch {
+ /* Ignore */
+ }
+ }
+ const spawnOptions: SpawnOptions = {
+ cwd: this.rootDirectory,
+ shell: true,
+ env: { ...process.env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ };
+ try {
+ const bashPath = os.platform() === 'win32' ? 'bash.exe' : 'bash';
+ this.bashProcess = spawn(
+ bashPath,
+ ['-s'],
+ spawnOptions,
+ ) as ChildProcessWithoutNullStreams;
+ this.currentCwd = this.rootDirectory;
+ this.bashProcess.on('error', (err) => {
+ console.error('Persistent Bash Error:', err);
+ this.rejectShellReady?.(err);
+ this.bashProcess = null;
+ this.isExecuting = false;
+ this.clearQueue(
+ new Error(`Persistent bash process failed to start: ${err.message}`),
+ );
+ });
+ this.bashProcess.on('close', (code, signal) => {
+ this.bashProcess = null;
+ this.isExecuting = false;
+ this.rejectShellReady?.(
+ new Error(
+ `Persistent bash process exited (code: ${code}, signal: ${signal})`,
+ ),
+ );
+ this.shellReady = new Promise((resolve, reject) => {
+ this.resolveShellReady = resolve;
+ this.rejectShellReady = reject;
+ });
+ this.clearQueue(
+ new Error(
+ `Persistent bash process exited unexpectedly (code: ${code}, signal: ${signal}). State is lost. Queued commands cancelled.`,
+ ),
+ );
+ if (signal !== 'SIGINT') {
+ setTimeout(() => this.initializeShell(), 1000);
+ }
+ });
+ setTimeout(() => {
+ if (this.bashProcess && !this.bashProcess.killed) {
+ this.resolveShellReady?.();
+ } else if (!this.bashProcess) {
+ // Error likely handled
+ } else {
+ this.rejectShellReady?.(
+ new Error('Shell killed during initialization'),
+ );
+ }
+ }, 1000);
+ } catch (error: unknown) {
+ console.error('Failed to spawn persistent bash:', error);
+ this.rejectShellReady?.(error);
+ this.bashProcess = null;
+ this.clearQueue(
+ new Error(`Failed to spawn persistent bash: ${getErrorMessage(error)}`),
+ );
+ }
}
- validateParams(params: TerminalToolParams): string | null {
+ validateToolParams(params: TerminalToolParams): string | null {
if (
- this.schema.parameters &&
!SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
+ this.parameterSchema as Record<string, unknown>,
params,
)
) {
- return "Parameters failed schema validation (expecting only 'command').";
+ return `Parameters failed schema validation.`;
}
const commandOriginal = params.command.trim();
if (!commandOriginal) {
@@ -137,120 +317,685 @@ export class TerminalLogic extends BaseTool<TerminalToolParams, ToolResult> {
return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`;
}
}
+ if (
+ params.timeout !== undefined &&
+ (typeof params.timeout !== 'number' || params.timeout <= 0)
+ ) {
+ return 'Timeout must be a positive number of milliseconds.';
+ }
return null;
}
getDescription(params: TerminalToolParams): string {
- return params.command;
+ return params.description || params.command;
}
- async execute(
+ async shouldConfirmExecute(
params: TerminalToolParams,
- executionCwd?: string,
- timeout: number = DEFAULT_EXEC_TIMEOUT_MS,
- ): Promise<ToolResult> {
- const validationError = this.validateParams(params);
+ ): Promise<ToolCallConfirmationDetails | false> {
+ const rootCommand =
+ params.command
+ .trim()
+ .split(/[\s;&&|]+/)[0]
+ ?.split(/[/\\]/)
+ .pop() || 'unknown';
+ if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
+ return false;
+ }
+ const description = this.getDescription(params);
+ const confirmationDetails: ToolExecuteConfirmationDetails = {
+ title: 'Confirm Shell Command',
+ command: params.command,
+ rootCommand,
+ description: `Execute in '${this.currentCwd}':\n${description}`,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.shouldAlwaysExecuteCommands.set(rootCommand, true);
+ }
+ },
+ };
+ return confirmationDetails;
+ }
+
+ async execute(params: TerminalToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
if (validationError) {
return {
llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`,
returnDisplay: `Error: ${validationError}`,
};
}
-
- const cwd = executionCwd ? path.resolve(executionCwd) : this.rootDirectory;
- if (!cwd.startsWith(this.rootDirectory) && cwd !== this.rootDirectory) {
- const message = `Execution CWD validation failed: Attempted path "${cwd}" resolves outside the allowed root directory "${this.rootDirectory}".`;
- return {
- llmContent: `Command rejected: ${params.command}\nReason: ${message}`,
- returnDisplay: `Error: ${message}`,
- };
- }
-
return new Promise((resolve) => {
- const spawnOptions: SpawnOptions = {
- cwd,
- shell: true,
- env: { ...process.env },
- stdio: 'pipe',
- windowsHide: true,
- timeout: timeout,
+ const queuedItem: QueuedCommand = {
+ params,
+ resolve,
+ reject: (error) =>
+ resolve({
+ llmContent: `Internal tool error for command: ${params.command}\nError: ${error.message}`,
+ returnDisplay: `Internal Tool Error: ${error.message}`,
+ }),
+ confirmationDetails: false,
};
- let stdout = '';
- let stderr = '';
- let processError: Error | null = null;
- let timedOut = false;
+ this.commandQueue.push(queuedItem);
+ setImmediate(() => this.triggerQueueProcessing());
+ });
+ }
- try {
- const child = spawn(params.command, spawnOptions);
- child.stdout!.on('data', (data) => {
- stdout += data.toString();
- if (stdout.length > MAX_OUTPUT_LENGTH) {
- stdout = this.truncateOutput(stdout);
- child.stdout!.pause();
+ private async triggerQueueProcessing(): Promise<void> {
+ if (this.isExecuting || this.commandQueue.length === 0) {
+ return;
+ }
+ this.isExecuting = true;
+ const { params, resolve, reject } = this.commandQueue.shift()!;
+ try {
+ await this.shellReady;
+ if (!this.bashProcess || this.bashProcess.killed) {
+ throw new Error(
+ 'Persistent bash process is not available or was killed.',
+ );
+ }
+ const result = await this.executeCommandInShell(params);
+ resolve(result);
+ } catch (error: unknown) {
+ console.error(`Error executing command "${params.command}":`, error);
+ if (error instanceof Error) {
+ reject(error);
+ } else {
+ reject(new Error('Unknown error occurred: ' + JSON.stringify(error)));
+ }
+ } finally {
+ this.isExecuting = false;
+ setImmediate(() => this.triggerQueueProcessing());
+ }
+ }
+
+ private executeCommandInShell(
+ params: TerminalToolParams,
+ ): Promise<ToolResult> {
+ let tempStdoutPath: string | null = null;
+ let tempStderrPath: string | null = null;
+ let originalResolve: (value: ToolResult | PromiseLike<ToolResult>) => void;
+ let originalReject: (reason?: unknown) => void;
+ const promise = new Promise<ToolResult>((resolve, reject) => {
+ originalResolve = resolve;
+ originalReject = reject;
+ if (!this.bashProcess) {
+ return reject(
+ new Error('Bash process is not running. Cannot execute command.'),
+ );
+ }
+ const isBackgroundTask = params.runInBackground ?? false;
+ const commandUUID = crypto.randomUUID();
+ const startDelimiter = `::START_CMD_${commandUUID}::`;
+ const endDelimiter = `::END_CMD_${commandUUID}::`;
+ const exitCodeDelimiter = `::EXIT_CODE_${commandUUID}::`;
+ const pidDelimiter = `::PID_${commandUUID}::`;
+ if (isBackgroundTask) {
+ try {
+ const tempDir = os.tmpdir();
+ tempStdoutPath = path.join(tempDir, `term_out_${commandUUID}.log`);
+ tempStderrPath = path.join(tempDir, `term_err_${commandUUID}.log`);
+ } catch (err: unknown) {
+ return reject(
+ new Error(
+ `Failed to determine temporary directory: ${getErrorMessage(err)}`,
+ ),
+ );
+ }
+ }
+ let stdoutBuffer = '';
+ let stderrBuffer = '';
+ let commandOutputStarted = false;
+ let exitCode: number | null = null;
+ let backgroundPid: number | null = null;
+ let receivedEndDelimiter = false;
+ const effectiveTimeout = isBackgroundTask
+ ? BACKGROUND_LAUNCH_TIMEOUT_MS
+ : Math.min(
+ params.timeout ?? DEFAULT_TIMEOUT_MS,
+ MAX_TIMEOUT_OVERRIDE_MS,
+ );
+ let onStdoutData: ((data: Buffer) => void) | null = null;
+ let onStderrData: ((data: Buffer) => void) | null = null;
+ let launchTimeoutId: NodeJS.Timeout | null = null;
+ launchTimeoutId = setTimeout(() => {
+ const timeoutMessage = isBackgroundTask
+ ? `Background command launch timed out after ${effectiveTimeout}ms.`
+ : `Command timed out after ${effectiveTimeout}ms.`;
+ if (!isBackgroundTask && this.bashProcess && !this.bashProcess.killed) {
+ try {
+ this.bashProcess.stdin.write('\x03');
+ } catch (e: unknown) {
+ console.error('Error writing SIGINT on timeout:', e);
}
+ }
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
+ console.warn(
+ `Error cleaning up temp files on timeout: ${err.message}`,
+ );
+ });
+ }
+ originalResolve({
+ llmContent: `Command execution failed: ${timeoutMessage}\nCommand: ${params.command}\nExecuted in: ${this.currentCwd}\n${isBackgroundTask ? 'Mode: Background Launch' : `Mode: Foreground\nTimeout Limit: ${effectiveTimeout}ms`}\nPartial Stdout (Launch):\n${this.truncateOutput(stdoutBuffer)}\nPartial Stderr (Launch):\n${this.truncateOutput(stderrBuffer)}\nNote: ${isBackgroundTask ? 'Launch failed or took too long.' : 'Attempted interrupt (SIGINT). Shell state might be unpredictable if command ignored interrupt.'}`,
+ returnDisplay: `Timeout: ${timeoutMessage}`,
});
- child.stderr!.on('data', (data) => {
- stderr += data.toString();
- if (stderr.length > MAX_OUTPUT_LENGTH) {
- stderr = this.truncateOutput(stderr);
- child.stderr!.pause();
+ }, effectiveTimeout);
+ const processDataChunk = (chunk: string, isStderr: boolean): boolean => {
+ let dataToProcess = chunk;
+ if (!commandOutputStarted) {
+ const startIndex = dataToProcess.indexOf(startDelimiter);
+ if (startIndex !== -1) {
+ commandOutputStarted = true;
+ dataToProcess = dataToProcess.substring(
+ startIndex + startDelimiter.length,
+ );
+ } else {
+ return false;
}
- });
- child.on('error', (err) => {
- processError = err;
+ }
+ const pidIndex = dataToProcess.indexOf(pidDelimiter);
+ if (pidIndex !== -1) {
+ const pidMatch = dataToProcess
+ .substring(pidIndex + pidDelimiter.length)
+ .match(/^(\d+)/);
+ if (pidMatch?.[1]) {
+ backgroundPid = parseInt(pidMatch[1], 10);
+ const pidEndIndex =
+ pidIndex + pidDelimiter.length + pidMatch[1].length;
+ const beforePid = dataToProcess.substring(0, pidIndex);
+ if (isStderr) stderrBuffer += beforePid;
+ else stdoutBuffer += beforePid;
+ dataToProcess = dataToProcess.substring(pidEndIndex);
+ } else {
+ const beforePid = dataToProcess.substring(0, pidIndex);
+ if (isStderr) stderrBuffer += beforePid;
+ else stdoutBuffer += beforePid;
+ dataToProcess = dataToProcess.substring(
+ pidIndex + pidDelimiter.length,
+ );
+ }
+ }
+ const exitCodeIndex = dataToProcess.indexOf(exitCodeDelimiter);
+ if (exitCodeIndex !== -1) {
+ const exitCodeMatch = dataToProcess
+ .substring(exitCodeIndex + exitCodeDelimiter.length)
+ .match(/^(\d+)/);
+ if (exitCodeMatch?.[1]) {
+ exitCode = parseInt(exitCodeMatch[1], 10);
+ const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
+ if (isStderr) stderrBuffer += beforeExitCode;
+ else stdoutBuffer += beforeExitCode;
+ dataToProcess = dataToProcess.substring(
+ exitCodeIndex +
+ exitCodeDelimiter.length +
+ exitCodeMatch[1].length,
+ );
+ } else {
+ const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
+ if (isStderr) stderrBuffer += beforeExitCode;
+ else stdoutBuffer += beforeExitCode;
+ dataToProcess = dataToProcess.substring(
+ exitCodeIndex + exitCodeDelimiter.length,
+ );
+ }
+ }
+ const endDelimiterIndex = dataToProcess.indexOf(endDelimiter);
+ if (endDelimiterIndex !== -1) {
+ receivedEndDelimiter = true;
+ const beforeEndDelimiter = dataToProcess.substring(
+ 0,
+ endDelimiterIndex,
+ );
+ if (isStderr) stderrBuffer += beforeEndDelimiter;
+ else stdoutBuffer += beforeEndDelimiter;
+ const afterEndDelimiter = dataToProcess.substring(
+ endDelimiterIndex + endDelimiter.length,
+ );
+ const exitCodeEchoMatch = afterEndDelimiter.match(/^(\d+)/);
+ dataToProcess = exitCodeEchoMatch
+ ? afterEndDelimiter.substring(exitCodeEchoMatch[1].length)
+ : afterEndDelimiter;
+ }
+ if (dataToProcess.length > 0) {
+ if (isStderr) stderrBuffer += dataToProcess;
+ else stdoutBuffer += dataToProcess;
+ }
+ if (receivedEndDelimiter && exitCode !== null) {
+ setImmediate(cleanupAndResolve);
+ return true;
+ }
+ return false;
+ };
+ onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false);
+ onStderrData = (data: Buffer) => processDataChunk(data.toString(), true);
+ const cleanupListeners = (listeners?: {
+ onStdoutData: ((data: Buffer) => void) | null;
+ onStderrData: ((data: Buffer) => void) | null;
+ }) => {
+ if (launchTimeoutId) clearTimeout(launchTimeoutId);
+ launchTimeoutId = null;
+ const stdoutListener = listeners?.onStdoutData ?? onStdoutData;
+ const stderrListener = listeners?.onStderrData ?? onStderrData;
+ if (this.bashProcess && !this.bashProcess.killed) {
+ if (stdoutListener)
+ this.bashProcess.stdout.removeListener('data', stdoutListener);
+ if (stderrListener)
+ this.bashProcess.stderr.removeListener('data', stderrListener);
+ }
+ if (this.currentCommandCleanup === cleanupListeners) {
+ this.currentCommandCleanup = null;
+ }
+ onStdoutData = null;
+ onStderrData = null;
+ };
+ this.currentCommandCleanup = cleanupListeners;
+ const cleanupAndResolve = async () => {
+ if (
+ !this.currentCommandCleanup ||
+ this.currentCommandCleanup !== cleanupListeners
+ ) {
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
+ (err) => {
+ console.warn(
+ `Error cleaning up temp files for superseded command: ${err.message}`,
+ );
+ },
+ );
+ }
+ return;
+ }
+ const launchStdout = this.truncateOutput(stdoutBuffer);
+ const launchStderr = this.truncateOutput(stderrBuffer);
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (exitCode === null) {
console.error(
- `TerminalLogic spawn error for "${params.command}":`,
- err,
+ `CRITICAL: Command "${params.command}" (background: ${isBackgroundTask}) finished delimiter processing but exitCode is null.`,
);
- });
- child.on('close', (code, signal) => {
- const exitCode = code ?? (signal ? -1 : -2);
- if (signal === 'SIGTERM' || signal === 'SIGKILL') {
- if (child.killed && timeout > 0) timedOut = true;
+ const errorMode = isBackgroundTask
+ ? 'Background Launch'
+ : 'Foreground';
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ }
+ originalResolve({
+ llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nMode: ${errorMode}\nExit Code: -2 (Internal Error: Exit code not captured)\nStdout (during setup):\n${launchStdout}\nStderr (during setup):\n${launchStderr}`,
+ returnDisplay:
+ `Internal Error: Failed to capture command exit code.\n${launchStdout}\nStderr: ${launchStderr}`.trim(),
+ });
+ return;
+ }
+ let cwdUpdateError = '';
+ if (!isBackgroundTask) {
+ const mightChangeCwd = params.command.trim().startsWith('cd ');
+ if (exitCode === 0 || mightChangeCwd) {
+ try {
+ const latestCwd = await this.getCurrentShellCwd();
+ if (this.currentCwd !== latestCwd) {
+ this.currentCwd = latestCwd;
+ }
+ } catch (e: unknown) {
+ if (exitCode === 0) {
+ cwdUpdateError = `\nWarning: Failed to verify/update current working directory after command: ${getErrorMessage(e)}`;
+ console.error(
+ 'Failed to update CWD after successful command:',
+ e,
+ );
+ }
+ }
}
- const finalStdout = this.truncateOutput(stdout);
- const finalStderr = this.truncateOutput(stderr);
- let llmContent = `Command: ${params.command}\nExecuted in: ${cwd}\nExit Code: ${exitCode}\n`;
- if (timedOut) llmContent += `Status: Timed Out after ${timeout}ms\n`;
- if (processError)
- llmContent += `Process Error: ${processError.message}\n`;
- llmContent += `Stdout:\n${finalStdout}\nStderr:\n${finalStderr}`;
- let displayOutput = finalStderr.trim() || finalStdout.trim();
- if (timedOut)
- displayOutput = `Timeout: ${displayOutput || 'No output before timeout'}`;
- else if (exitCode !== 0 && !displayOutput)
- displayOutput = `Failed (Exit Code: ${exitCode})`;
- else if (exitCode === 0 && !displayOutput)
+ }
+ if (isBackgroundTask) {
+ const launchSuccess = exitCode === 0;
+ const pidString =
+ backgroundPid !== null ? backgroundPid.toString() : 'Not Captured';
+ if (
+ launchSuccess &&
+ backgroundPid !== null &&
+ tempStdoutPath &&
+ tempStderrPath
+ ) {
+ this.inspectBackgroundProcess(
+ backgroundPid,
+ params.command,
+ this.currentCwd,
+ launchStdout,
+ launchStderr,
+ tempStdoutPath,
+ tempStderrPath,
+ originalResolve,
+ );
+ } else {
+ const reason =
+ backgroundPid === null
+ ? 'PID not captured'
+ : `Launch failed (Exit Code: ${exitCode})`;
+ const displayMessage = `Failed to launch process in background (${reason})`;
+ console.error(
+ `Background launch failed for command: ${params.command}. Reason: ${reason}`,
+ );
+ if (tempStdoutPath && tempStderrPath) {
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ }
+ originalResolve({
+ llmContent: `Background Command Launch Failed: ${params.command}\nExecuted in: ${this.currentCwd}\nReason: ${reason}\nPID: ${pidString}\nExit Code (Launch): ${exitCode}\nStdout (During Launch):\n${launchStdout}\nStderr (During Launch):\n${launchStderr}`,
+ returnDisplay: displayMessage,
+ });
+ }
+ } else {
+ let displayOutput = '';
+ const stdoutTrimmed = launchStdout.trim();
+ const stderrTrimmed = launchStderr.trim();
+ if (stderrTrimmed) {
+ displayOutput = stderrTrimmed;
+ } else if (stdoutTrimmed) {
+ displayOutput = stdoutTrimmed;
+ }
+ if (exitCode !== 0 && !displayOutput) {
+ displayOutput = `Failed with exit code: ${exitCode}`;
+ } else if (exitCode === 0 && !displayOutput) {
displayOutput = `Success (no output)`;
- resolve({
- llmContent,
+ }
+ originalResolve({
+ llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nExit Code: ${exitCode}\nStdout:\n${launchStdout}\nStderr:\n${launchStderr}${cwdUpdateError}`,
returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`,
});
- });
- } catch (spawnError: unknown) {
- const errMsg = getErrorMessage(spawnError);
+ }
+ };
+ if (!this.bashProcess || this.bashProcess.killed) {
console.error(
- `TerminalLogic failed to spawn "${params.command}":`,
- spawnError,
+ 'Bash process lost or killed before listeners could be attached.',
+ );
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
+ console.warn(
+ `Error cleaning up temp files on attach failure: ${err.message}`,
+ );
+ });
+ }
+ return originalReject(
+ new Error(
+ 'Bash process lost or killed before listeners could be attached.',
+ ),
+ );
+ }
+ if (onStdoutData) this.bashProcess.stdout.on('data', onStdoutData);
+ if (onStderrData) this.bashProcess.stderr.on('data', onStderrData);
+ let commandToWrite: string;
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ commandToWrite = `echo "${startDelimiter}"; { { ${params.command} > "${tempStdoutPath}" 2> "${tempStderrPath}"; } & } 2>/dev/null; __LAST_PID=$!; echo "${pidDelimiter}$__LAST_PID" >&2; echo "${exitCodeDelimiter}$?" >&2; echo "${endDelimiter}$?" >&1\n`;
+ } else if (!isBackgroundTask) {
+ commandToWrite = `echo "${startDelimiter}"; ${params.command}; __EXIT_CODE=$?; echo "${exitCodeDelimiter}$__EXIT_CODE" >&2; echo "${endDelimiter}$__EXIT_CODE" >&1\n`;
+ } else {
+ return originalReject(
+ new Error(
+ 'Internal setup error: Missing temporary file paths for background execution.',
+ ),
+ );
+ }
+ try {
+ if (this.bashProcess?.stdin?.writable) {
+ this.bashProcess.stdin.write(commandToWrite, (err) => {
+ if (err) {
+ console.error(
+ `Error writing command "${params.command}" to bash stdin (callback):`,
+ err,
+ );
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
+ (e) => console.warn(`Cleanup failed: ${e.message}`),
+ );
+ }
+ originalReject(
+ new Error(
+ `Shell stdin write error: ${err.message}. Command likely did not execute.`,
+ ),
+ );
+ }
+ });
+ } else {
+ throw new Error(
+ 'Shell stdin is not writable or process closed when attempting to write command.',
+ );
+ }
+ } catch (e: unknown) {
+ console.error(
+ `Error writing command "${params.command}" to bash stdin (sync):`,
+ e,
+ );
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) =>
+ console.warn(`Cleanup failed: ${err.message}`),
+ );
+ }
+ originalReject(
+ new Error(
+ `Shell stdin write exception: ${getErrorMessage(e)}. Command likely did not execute.`,
+ ),
);
- resolve({
- llmContent: `Failed to start command: ${params.command}\nError: ${errMsg}`,
- returnDisplay: `Error spawning command: ${errMsg}`,
- });
}
});
+ return promise;
}
- private truncateOutput(
- output: string,
- limit: number = MAX_OUTPUT_LENGTH,
- ): string {
- if (output.length > limit) {
+ private async inspectBackgroundProcess(
+ pid: number,
+ command: string,
+ cwd: string,
+ initialStdout: string,
+ initialStderr: string,
+ tempStdoutPath: string,
+ tempStderrPath: string,
+ resolve: (value: ToolResult | PromiseLike<ToolResult>) => void,
+ ): Promise<void> {
+ let finalStdout = '';
+ let finalStderr = '';
+ let llmAnalysis = '';
+ let fileReadError = '';
+ try {
+ const { status, summary } = await this.backgroundTerminalAnalyzer.analyze(
+ pid,
+ tempStdoutPath,
+ tempStderrPath,
+ command,
+ );
+ if (status === 'Unknown') llmAnalysis = `LLM analysis failed: ${summary}`;
+ else llmAnalysis = summary;
+ } catch (llmerror: unknown) {
+ console.error(
+ `LLM analysis failed for PID ${pid} command "${command}":`,
+ llmerror,
+ );
+ llmAnalysis = `LLM analysis failed: ${getErrorMessage(llmerror)}`;
+ }
+ try {
+ finalStdout = await fs.readFile(tempStdoutPath, 'utf-8');
+ finalStderr = await fs.readFile(tempStderrPath, 'utf-8');
+ } catch (err: unknown) {
+ console.error(`Error reading temp output files for PID ${pid}:`, err);
+ fileReadError = `\nWarning: Failed to read temporary output files (${getErrorMessage(err)}). Final output may be incomplete.`;
+ }
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ const truncatedFinalStdout = this.truncateOutput(finalStdout);
+ const truncatedFinalStderr = this.truncateOutput(finalStderr);
+ resolve({
+ llmContent: `Background Command: ${command}\nLaunched in: ${cwd}\nPID: ${pid}\n--- LLM Analysis ---\n${llmAnalysis}\n--- Final Stdout (from ${path.basename(tempStdoutPath)}) ---\n${truncatedFinalStdout}\n--- Final Stderr (from ${path.basename(tempStderrPath)}) ---\n${truncatedFinalStderr}\n--- Launch Stdout ---\n${initialStdout}\n--- Launch Stderr ---\n${initialStderr}${fileReadError}`,
+ returnDisplay: `(PID: ${pid}): ${this.truncateOutput(llmAnalysis, 200)}`,
+ });
+ }
+
+ private async cleanupTempFiles(
+ stdoutPath: string | null,
+ stderrPath: string | null,
+ ): Promise<void> {
+ const unlinkQuietly = async (filePath: string | null) => {
+ if (!filePath) return;
+ try {
+ await fs.unlink(filePath);
+ } catch (err: unknown) {
+ if (!isNodeError(err) || err.code !== 'ENOENT') {
+ console.warn(
+ `Failed to delete temporary file '${filePath}': ${getErrorMessage(err)}`,
+ );
+ }
+ }
+ };
+ await Promise.all([unlinkQuietly(stdoutPath), unlinkQuietly(stderrPath)]);
+ }
+
+ private getCurrentShellCwd(): Promise<string> {
+ return new Promise((resolve, reject) => {
+ if (
+ !this.bashProcess ||
+ !this.bashProcess.stdin?.writable ||
+ this.bashProcess.killed
+ ) {
+ return reject(
+ new Error(
+ 'Shell not running, stdin not writable, or killed for PWD check',
+ ),
+ );
+ }
+ const pwdUuid = crypto.randomUUID();
+ const pwdDelimiter = `::PWD_${pwdUuid}::`;
+ let pwdOutput = '';
+ let onPwdData: ((data: Buffer) => void) | null = null;
+ let onPwdError: ((data: Buffer) => void) | null = null;
+ let pwdTimeoutId: NodeJS.Timeout | null = null;
+ let finished = false;
+ const cleanupPwdListeners = (err?: Error) => {
+ if (finished) return;
+ finished = true;
+ if (pwdTimeoutId) clearTimeout(pwdTimeoutId);
+ pwdTimeoutId = null;
+ const stdoutListener = onPwdData;
+ const stderrListener = onPwdError;
+ onPwdData = null;
+ onPwdError = null;
+ if (this.bashProcess && !this.bashProcess.killed) {
+ if (stdoutListener)
+ this.bashProcess.stdout.removeListener('data', stdoutListener);
+ if (stderrListener)
+ this.bashProcess.stderr.removeListener('data', stderrListener);
+ }
+ if (err) {
+ reject(err);
+ } else {
+ resolve(pwdOutput.trim());
+ }
+ };
+ onPwdData = (data: Buffer) => {
+ if (!onPwdData) return;
+ const dataStr = data.toString();
+ const delimiterIndex = dataStr.indexOf(pwdDelimiter);
+ if (delimiterIndex !== -1) {
+ pwdOutput += dataStr.substring(0, delimiterIndex);
+ cleanupPwdListeners();
+ } else {
+ pwdOutput += dataStr;
+ }
+ };
+ onPwdError = (data: Buffer) => {
+ if (!onPwdError) return;
+ const dataStr = data.toString();
+ console.error(`Error during PWD check: ${dataStr}`);
+ cleanupPwdListeners(
+ new Error(
+ `Stderr received during pwd check: ${this.truncateOutput(dataStr, 100)}`,
+ ),
+ );
+ };
+ this.bashProcess.stdout.on('data', onPwdData);
+ this.bashProcess.stderr.on('data', onPwdError);
+ pwdTimeoutId = setTimeout(() => {
+ cleanupPwdListeners(new Error('Timeout waiting for pwd response'));
+ }, 5000);
+ try {
+ const pwdCommand = `printf "%s" "$PWD"; printf "${pwdDelimiter}";\n`;
+ if (this.bashProcess?.stdin?.writable) {
+ this.bashProcess.stdin.write(pwdCommand, (err) => {
+ if (err) {
+ console.error('Error writing pwd command (callback):', err);
+ cleanupPwdListeners(
+ new Error(`Failed to write pwd command: ${err.message}`),
+ );
+ }
+ });
+ } else {
+ throw new Error('Shell stdin not writable for pwd command.');
+ }
+ } catch (e: unknown) {
+ console.error('Exception writing pwd command:', e);
+ cleanupPwdListeners(
+ new Error(`Exception writing pwd command: ${getErrorMessage(e)}`),
+ );
+ }
+ });
+ }
+
+ private truncateOutput(output: string, limit?: number): string {
+ const effectiveLimit = limit ?? this.outputLimit;
+ if (output.length > effectiveLimit) {
return (
- output.substring(0, limit) +
- `\n... [Output truncated at ${limit} characters]`
+ output.substring(0, effectiveLimit) +
+ `\n... [Output truncated at ${effectiveLimit} characters]`
);
}
return output;
}
+
+ private clearQueue(error: Error) {
+ const queue = this.commandQueue;
+ this.commandQueue = [];
+ queue.forEach(({ resolve, params }) =>
+ resolve({
+ llmContent: `Command cancelled: ${params.command}\nReason: ${error.message}`,
+ returnDisplay: `Command Cancelled: ${error.message}`,
+ }),
+ );
+ }
+
+ destroy() {
+ this.rejectShellReady?.(
+ new Error('BashTool destroyed during initialization or operation.'),
+ );
+ this.rejectShellReady = undefined;
+ this.resolveShellReady = undefined;
+ this.clearQueue(new Error('BashTool is being destroyed.'));
+ try {
+ this.currentCommandCleanup?.();
+ } catch (e) {
+ console.warn('Error during current command cleanup:', e);
+ }
+ if (this.bashProcess) {
+ const proc = this.bashProcess;
+ const pid = proc.pid;
+ this.bashProcess = null;
+ proc.stdout?.removeAllListeners();
+ proc.stderr?.removeAllListeners();
+ proc.removeAllListeners('error');
+ proc.removeAllListeners('close');
+ proc.stdin?.end();
+ try {
+ proc.kill('SIGTERM');
+ setTimeout(() => {
+ if (!proc.killed) {
+ proc.kill('SIGKILL');
+ }
+ }, 500);
+ } catch (e: unknown) {
+ console.warn(
+ `Error trying to kill bash process PID: ${pid}: ${getErrorMessage(e)}`,
+ );
+ }
+ }
+ }
}
diff --git a/packages/server/src/tools/tools.ts b/packages/server/src/tools/tools.ts
index 4851f164..ed7c017a 100644
--- a/packages/server/src/tools/tools.ts
+++ b/packages/server/src/tools/tools.ts
@@ -49,7 +49,14 @@ export interface Tool<
*/
getDescription(params: TParams): string;
- // Removed shouldConfirmExecute as it's UI-specific
+ /**
+ * Determines if the tool should prompt for confirmation before execution
+ * @param params Parameters for the tool execution
+ * @returns Whether execute should be confirmed.
+ */
+ shouldConfirmExecute(
+ params: TParams,
+ ): Promise<ToolCallConfirmationDetails | false>;
/**
* Executes the tool with the given parameters
@@ -115,7 +122,17 @@ export abstract class BaseTool<
return JSON.stringify(params);
}
- // Removed shouldConfirmExecute as it's UI-specific
+ /**
+ * Determines if the tool should prompt for confirmation before execution
+ * @param params Parameters for the tool execution
+ * @returns Whether or not execute should be confirmed by the user.
+ */
+ shouldConfirmExecute(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ params: TParams,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ return Promise.resolve(false);
+ }
/**
* Abstract method to execute the tool with the given parameters
@@ -148,3 +165,27 @@ export type ToolResultDisplay = string | FileDiff;
export interface FileDiff {
fileDiff: string;
}
+
+export interface ToolCallConfirmationDetails {
+ title: string;
+ onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
+}
+
+export interface ToolEditConfirmationDetails
+ extends ToolCallConfirmationDetails {
+ fileName: string;
+ fileDiff: string;
+}
+
+export interface ToolExecuteConfirmationDetails
+ extends ToolCallConfirmationDetails {
+ command: string;
+ rootCommand: string;
+ description: string;
+}
+
+export enum ToolConfirmationOutcome {
+ ProceedOnce,
+ ProceedAlways,
+ Cancel,
+}
diff --git a/packages/server/src/tools/web-fetch.ts b/packages/server/src/tools/web-fetch.ts
index 29e33fbe..415dc033 100644
--- a/packages/server/src/tools/web-fetch.ts
+++ b/packages/server/src/tools/web-fetch.ts
@@ -21,14 +21,14 @@ export interface WebFetchToolParams {
/**
* Implementation of the WebFetch tool logic (moved from CLI)
*/
-export class WebFetchLogic extends BaseTool<WebFetchToolParams, ToolResult> {
+export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
static readonly Name: string = 'web_fetch';
constructor() {
super(
- WebFetchLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ WebFetchTool.Name,
+ 'WebFetch',
+ 'Fetches text content from a given URL. Handles potential network errors and non-success HTTP status codes.',
{
properties: {
url: {
diff --git a/packages/server/src/tools/write-file.ts b/packages/server/src/tools/write-file.ts
index ce723061..814efa86 100644
--- a/packages/server/src/tools/write-file.ts
+++ b/packages/server/src/tools/write-file.ts
@@ -7,7 +7,14 @@
import fs from 'fs';
import path from 'path';
import * as Diff from 'diff'; // Keep for result generation
-import { BaseTool, ToolResult, FileDiff } from './tools.js'; // Updated import (Removed ToolResultDisplay)
+import {
+ BaseTool,
+ ToolResult,
+ FileDiff,
+ ToolEditConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolCallConfirmationDetails,
+} from './tools.js'; // Updated import (Removed ToolResultDisplay)
import { SchemaValidator } from '../utils/schemaValidator.js'; // Updated import
import { makeRelative, shortenPath } from '../utils/paths.js'; // Updated import
import { isNodeError } from '../utils/errors.js'; // Import isNodeError
@@ -30,16 +37,17 @@ export interface WriteFileToolParams {
/**
* Implementation of the WriteFile tool logic (moved from CLI)
*/
-export class WriteFileLogic extends BaseTool<WriteFileToolParams, ToolResult> {
+export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
static readonly Name: string = 'write_file';
+ private shouldAlwaysWrite = false;
private readonly rootDirectory: string;
constructor(rootDirectory: string) {
super(
- WriteFileLogic.Name,
- '', // Display name handled by CLI wrapper
- '', // Description handled by CLI wrapper
+ WriteFileTool.Name,
+ 'WriteFile',
+ 'Writes content to a specified file in the local filesystem.',
{
properties: {
file_path: {
@@ -98,6 +106,56 @@ export class WriteFileLogic extends BaseTool<WriteFileToolParams, ToolResult> {
return `Writing to ${shortenPath(relativePath)}`;
}
+ /**
+ * Handles the confirmation prompt for the WriteFile tool in the CLI.
+ */
+ async shouldConfirmExecute(
+ params: WriteFileToolParams,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ if (this.shouldAlwaysWrite) {
+ return false;
+ }
+
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ console.error(
+ `[WriteFile Wrapper] Attempted confirmation with invalid parameters: ${validationError}`,
+ );
+ return false;
+ }
+
+ const relativePath = makeRelative(params.file_path, this.rootDirectory);
+ const fileName = path.basename(params.file_path);
+
+ let currentContent = '';
+ try {
+ currentContent = fs.readFileSync(params.file_path, 'utf8');
+ } catch {
+ // File might not exist, that's okay for write/create
+ }
+
+ const fileDiff = Diff.createPatch(
+ fileName,
+ currentContent,
+ params.content,
+ 'Current',
+ 'Proposed',
+ { context: 3 },
+ );
+
+ const confirmationDetails: ToolEditConfirmationDetails = {
+ title: `Confirm Write: ${shortenPath(relativePath)}`,
+ fileName,
+ fileDiff,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.shouldAlwaysWrite = true;
+ }
+ },
+ };
+ return confirmationDetails;
+ }
+
async execute(params: WriteFileToolParams): Promise<ToolResult> {
const validationError = this.validateParams(params);
if (validationError) {
diff --git a/packages/cli/src/utils/BackgroundTerminalAnalyzer.ts b/packages/server/src/utils/BackgroundTerminalAnalyzer.ts
index 31cd91c6..31cd91c6 100644
--- a/packages/cli/src/utils/BackgroundTerminalAnalyzer.ts
+++ b/packages/server/src/utils/BackgroundTerminalAnalyzer.ts