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>, - submitQuery: (query: Part) => Promise, - getNextMessageId: () => number, - currentToolGroupIdRef: React.MutableRefObject, -): 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>, - 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 { - 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) ?? {}, - ); - 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 { - 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 { - 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 { - 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) ?? {}, - ); - - 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 { - return Promise.resolve(false); - } - - /** - * Delegates execution to the core logic - */ - async execute(params: GlobToolParams): Promise { - 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 { - 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) ?? {}, - ); - - 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 { - return Promise.resolve(false); - } - - /** - * Delegates execution to the core logic - */ - async execute(params: GrepToolParams): Promise { - 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 { - 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) ?? {}, - ); - - 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 { - return Promise.resolve(false); - } - - /** - * Delegates execution to the core logic - */ - async execute(params: LSToolParams): Promise { - // 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 { - 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) ?? {}, - ); - 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 { - return Promise.resolve(false); - } - - /** - * Delegates execution to the core logic - */ - execute(params: ReadFileToolParams): Promise { - 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 { - 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 = new Map(); - private shellReady: Promise; - 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, - 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 { - 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 { - 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 { - 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 { - let tempStdoutPath: string | null = null; - let tempStderrPath: string | null = null; - let originalResolve: (value: ToolResult | PromiseLike) => void; - let originalReject: (reason?: unknown) => void; - const promise = new Promise((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) => void, - ): Promise { - 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 { - 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 { - 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; } -/** - * Base implementation for tools with common functionality - */ -export abstract class BaseTool< - TParams = unknown, - TResult extends ToolResult = ToolResult, -> implements Tool -{ - /** - * 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, - ) {} - - /** - * 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 { - 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; -} - 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 { - 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) ?? {}, - ); - 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 { - return Promise.resolve(false); - } - - /** - * Delegates execution to the core logic. - */ - async execute(params: WebFetchToolParams): Promise { - 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 { - 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) ?? {}, - ); - 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 { - 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 { - 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, - ToolCallConfirmationDetails, - ToolEditConfirmationDetails, - ToolExecuteConfirmationDetails, -} from '../../types.js'; +import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; import { DiffRenderer } from './DiffRenderer.js'; import { FileDiff, ToolResultDisplay } from '../../../tools/tools.js'; import { Colors } from '../../colors.js'; +import { + ToolCallConfirmationDetails, + ToolEditConfirmationDetails, + ToolExecuteConfirmationDetails, +} from '@gemini-code/server'; export const ToolMessage: React.FC = ({ 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) => - 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, + ): Promise; execute(params: Record): Promise; } - -// Define a more specific type for tool arguments to replace 'any' -type ToolArgs = Record; 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; -} - -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 }; - }; +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; // 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): Promise; + shouldConfirmExecute( + params: Record, + ): Promise; // 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; } -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; // 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; + private buildFunctionResponse(outcome: ServerToolExecutionOutcome): Part { + const { name, result, error } = outcome; + let fnResponsePayload: Record; - 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 { +export class EditTool extends BaseTool { static readonly Name = 'replace'; // Keep static name + private shouldAlwaysEdit = false; private readonly rootDirectory: string; @@ -61,9 +69,9 @@ export class EditLogic extends BaseTool { // 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 { }; } - // 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 { + 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 { +export class GlobTool extends BaseTool { static readonly Name = 'glob'; // Keep static name /** @@ -43,9 +43,9 @@ export class GlobLogic extends BaseTool { */ 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 { +export class GrepTool extends BaseTool { static readonly Name = 'search_file_content'; // Keep static name private rootDirectory: string; @@ -62,9 +62,9 @@ export class GrepLogic extends BaseTool { */ 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 { +export class LSTool extends BaseTool { static readonly Name = 'list_directory'; /** @@ -73,9 +73,9 @@ export class LSLogic extends BaseTool { */ 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 { +export class ReadFileTool extends BaseTool { 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 { 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 { 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 { - static readonly Name = 'execute_bash_command'; - private readonly rootDirectory: string; +interface QueuedCommand { + params: TerminalToolParams; + resolve: (result: ToolResult) => void; + reject: (error: Error) => void; + confirmationDetails: ToolExecuteConfirmationDetails | false; +} - 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', - }, +export class TerminalTool extends BaseTool { + 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 = new Map(); + private shellReady: Promise; + 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'], }, + 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(); } - validateParams(params: TerminalToolParams): string | null { + 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 ( - this.schema.parameters && !SchemaValidator.validate( - this.schema.parameters as Record, + this.parameterSchema as Record, 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 { 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 { - const validationError = this.validateParams(params); + ): Promise { + 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 { + 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, - }; - let stdout = ''; - let stderr = ''; - let processError: Error | null = null; - let timedOut = false; - - 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(); - } - }); - child.stderr!.on('data', (data) => { - stderr += data.toString(); - if (stderr.length > MAX_OUTPUT_LENGTH) { - stderr = this.truncateOutput(stderr); - child.stderr!.pause(); - } - }); - child.on('error', (err) => { - processError = err; - console.error( - `TerminalLogic spawn error for "${params.command}":`, - err, - ); - }); - child.on('close', (code, signal) => { - const exitCode = code ?? (signal ? -1 : -2); - if (signal === 'SIGTERM' || signal === 'SIGKILL') { - if (child.killed && timeout > 0) timedOut = true; - } - 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) - displayOutput = `Success (no output)`; + const queuedItem: QueuedCommand = { + params, + resolve, + reject: (error) => resolve({ - llmContent, + 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 { + 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 { + let tempStdoutPath: string | null = null; + let tempStderrPath: string | null = null; + let originalResolve: (value: ToolResult | PromiseLike) => void; + let originalReject: (reason?: unknown) => void; + const promise = new Promise((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}`, }); - }); - } 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.`, + ), + ); + } + }); + return promise; + } + + private async inspectBackgroundProcess( + pid: number, + command: string, + cwd: string, + initialStdout: string, + initialStderr: string, + tempStdoutPath: string, + tempStderrPath: string, + resolve: (value: ToolResult | PromiseLike) => void, + ): Promise { + 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 { + 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 { + 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)}`), ); - resolve({ - llmContent: `Failed to start command: ${params.command}\nError: ${errMsg}`, - returnDisplay: `Error spawning command: ${errMsg}`, - }); } }); } - private truncateOutput( - output: string, - limit: number = MAX_OUTPUT_LENGTH, - ): string { - if (output.length > limit) { + 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; /** * 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 { + 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; +} + +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 { +export class WebFetchTool extends BaseTool { 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 { +export class WriteFileTool extends BaseTool { 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 { return `Writing to ${shortenPath(relativePath)}`; } + /** + * Handles the confirmation prompt for the WriteFile tool in the CLI. + */ + async shouldConfirmExecute( + params: WriteFileToolParams, + ): Promise { + 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 { const validationError = this.validateParams(params); if (validationError) { diff --git a/packages/cli/src/utils/BackgroundTerminalAnalyzer.ts b/packages/server/src/utils/BackgroundTerminalAnalyzer.ts similarity index 100% rename from packages/cli/src/utils/BackgroundTerminalAnalyzer.ts rename to packages/server/src/utils/BackgroundTerminalAnalyzer.ts