diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4e7e1db6f2..499b57b522 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1009,6 +1009,7 @@ export async function loadCliConfig( enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellBackgroundCompletionBehavior: settings.tools?.shell ?.backgroundCompletionBehavior as string | undefined, + interactiveShellMode: settings.tools?.shell?.interactiveShellMode, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, enableShellOutputEfficiency: settings.tools?.shell?.enableShellOutputEfficiency ?? true, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c041aaa8c3..e654391566 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1512,6 +1512,26 @@ const SETTINGS_SCHEMA = { { label: 'Notify', value: 'notify' }, ], }, + interactiveShellMode: { + type: 'enum', + label: 'Interactive Shell Mode', + category: 'Tools', + requiresRestart: true, + default: undefined as 'human' | 'ai' | 'off' | undefined, + description: oneLine` + Controls who can interact with backgrounded shell processes. + "human": user can Tab-focus and type into shells (default). + "ai": model gets write_to_shell/read_shell tools for TUI interaction. + "off": no interactive shell. + When set, overrides enableInteractiveShell. + `, + showInDialog: true, + options: [ + { value: 'human', label: 'Human (Tab to focus)' }, + { value: 'ai', label: 'AI (model-driven tools)' }, + { value: 'off', label: 'Off' }, + ], + }, pager: { type: 'string', label: 'Pager', diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts index 0e9307259d..ea467fc327 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -92,7 +92,23 @@ export function shellReducer( nextTasks.delete(action.pid); } nextTasks.set(action.pid, updatedTask); - return { ...state, backgroundTasks: nextTasks }; + + // Auto-hide panel when all tasks have exited + let nextVisible = state.isBackgroundTaskVisible; + if (action.update.status === 'exited') { + const hasRunning = Array.from(nextTasks.values()).some( + (s) => s.status === 'running', + ); + if (!hasRunning) { + nextVisible = false; + } + } + + return { + ...state, + backgroundTasks: nextTasks, + isBackgroundTaskVisible: nextVisible, + }; } case 'APPEND_TASK_OUTPUT': { const task = state.backgroundTasks.get(action.pid); diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts new file mode 100644 index 0000000000..eb43ae1cfb --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo, useRef } from 'react'; +import { type BackgroundTask } from './shellReducer.js'; + +export interface BackgroundShellManagerProps { + backgroundTasks: Map; + backgroundTaskCount: number; + isBackgroundTaskVisible: boolean; + activePtyId: number | null | undefined; + embeddedShellFocused: boolean; + setEmbeddedShellFocused: (focused: boolean) => void; + terminalHeight: number; +} + +export function useBackgroundShellManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, +}: BackgroundShellManagerProps) { + const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] = + useState(false); + const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState< + number | null + >(null); + + const prevShellCountRef = useRef(backgroundTaskCount); + + useEffect(() => { + if (backgroundTasks.size === 0) { + if (activeBackgroundShellPid !== null) { + setActiveBackgroundShellPid(null); + } + if (isBackgroundShellListOpen) { + setIsBackgroundShellListOpen(false); + } + } else if ( + activeBackgroundShellPid === null || + !backgroundTasks.has(activeBackgroundShellPid) + ) { + // If active shell is closed or none selected, select the first one + setActiveBackgroundShellPid(backgroundTasks.keys().next().value ?? null); + } else if (backgroundTaskCount > prevShellCountRef.current) { + // A new shell was added — auto-switch to the newest one (last in the map) + const pids = Array.from(backgroundTasks.keys()); + const newestPid = pids[pids.length - 1]; + if (newestPid !== undefined && newestPid !== activeBackgroundShellPid) { + setActiveBackgroundShellPid(newestPid); + } + } + prevShellCountRef.current = backgroundTaskCount; + }, [ + backgroundTasks, + activeBackgroundShellPid, + backgroundTaskCount, + isBackgroundShellListOpen, + ]); + + useEffect(() => { + if (embeddedShellFocused) { + const hasActiveForegroundShell = !!activePtyId; + const hasVisibleBackgroundShell = + isBackgroundTaskVisible && backgroundTasks.size > 0; + + if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) { + setEmbeddedShellFocused(false); + } + } + }, [ + isBackgroundTaskVisible, + backgroundTasks, + embeddedShellFocused, + backgroundTaskCount, + activePtyId, + setEmbeddedShellFocused, + ]); + + const backgroundShellHeight = useMemo( + () => + isBackgroundTaskVisible && backgroundTasks.size > 0 + ? Math.max(Math.floor(terminalHeight * 0.3), 5) + : 0, + [isBackgroundTaskVisible, backgroundTasks.size, terminalHeight], + ); + + return { + isBackgroundShellListOpen, + setIsBackgroundShellListOpen, + activeBackgroundShellPid, + setActiveBackgroundShellPid, + backgroundShellHeight, + }; +} diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts index 2e80bf8f95..02e9e88cf5 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -661,6 +661,10 @@ export const useExecutionLifecycle = ( (s: BackgroundTask) => s.status === 'running', ).length; + const showBackgroundShell = useCallback(() => { + dispatch({ type: 'SET_VISIBILITY', visible: true }); + }, [dispatch]); + return { handleShellCommand, activeShellPtyId: state.activeShellPtyId, @@ -668,6 +672,7 @@ export const useExecutionLifecycle = ( backgroundTaskCount, isBackgroundTaskVisible: state.isBackgroundTaskVisible, toggleBackgroundTasks, + showBackgroundShell, backgroundCurrentExecution, registerBackgroundTask, dismissBackgroundTask, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a2621c4546..c4a9c58d5e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -390,6 +390,7 @@ export const useGeminiStream = ( backgroundTaskCount, isBackgroundTaskVisible, toggleBackgroundTasks, + showBackgroundShell, backgroundCurrentExecution, registerBackgroundTask, dismissBackgroundTask, @@ -1917,6 +1918,7 @@ export const useGeminiStream = ( backgroundedTool.command, backgroundedTool.initialOutput, ); + showBackgroundShell(); } } @@ -2056,6 +2058,7 @@ export const useGeminiStream = ( modelSwitchedFromQuotaError, addItem, registerBackgroundTask, + showBackgroundShell, consumeUserHint, isLowErrorVerbosity, maybeAddSuppressedToolErrorNote, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0edd4af7b0..c82cc315b7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -36,6 +36,8 @@ import { GlobTool } from '../tools/glob.js'; import { ActivateSkillTool } from '../tools/activate-skill.js'; import { EditTool } from '../tools/edit.js'; import { ShellTool } from '../tools/shell.js'; +import { WriteToShellTool } from '../tools/write-to-shell.js'; +import { ReadShellTool } from '../tools/read-shell.js'; import { WriteFileTool } from '../tools/write-file.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; @@ -656,6 +658,7 @@ export interface ConfigParameters { useRipgrep?: boolean; enableInteractiveShell?: boolean; shellBackgroundCompletionBehavior?: string; + interactiveShellMode?: 'human' | 'ai' | 'off'; skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; @@ -868,6 +871,7 @@ export class Config implements McpContext, AgentLoopContext { | 'inject' | 'notify' | 'silent'; + private readonly interactiveShellMode: 'human' | 'ai' | 'off'; private readonly skipNextSpeakerCheck: boolean; private readonly useBackgroundColor: boolean; private readonly useAlternateBuffer: boolean; @@ -1235,6 +1239,14 @@ export class Config implements McpContext, AgentLoopContext { this.shellBackgroundCompletionBehavior = 'silent'; } + // interactiveShellMode takes precedence over enableInteractiveShell. + // If not set, derive from enableInteractiveShell for backward compat. + if (params.interactiveShellMode) { + this.interactiveShellMode = params.interactiveShellMode; + } else { + this.interactiveShellMode = this.enableInteractiveShell ? 'human' : 'off'; + } + this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -3211,10 +3223,14 @@ export class Config implements McpContext, AgentLoopContext { return ( this.interactive && this.ptyInfo !== 'child_process' && - this.enableInteractiveShell + this.interactiveShellMode !== 'off' ); } + getInteractiveShellMode(): 'human' | 'ai' | 'off' { + return this.interactiveShellMode; + } + isSkillsSupportEnabled(): boolean { return this.skillsSupport; } @@ -3575,6 +3591,15 @@ export class Config implements McpContext, AgentLoopContext { new ReadBackgroundOutputTool(this, this.messageBus), ), ); + // Register AI-driven interactive shell tools when mode is 'ai' + if (this.getInteractiveShellMode() === 'ai') { + maybeRegister(WriteToShellTool, () => + registry.registerTool(new WriteToShellTool(this.messageBus)), + ); + maybeRegister(ReadShellTool, () => + registry.registerTool(new ReadShellTool(this.messageBus)), + ); + } if (!this.isMemoryManagerEnabled()) { maybeRegister(MemoryTool, () => registry.registerTool(new MemoryTool(this.messageBus, this.storage)), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 0036dae560..c4077afc95 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -200,6 +200,7 @@ export class PromptProvider { enableShellEfficiency: context.config.getEnableShellOutputEfficiency(), interactiveShellEnabled: context.config.isInteractiveShellEnabled(), + interactiveShellMode: context.config.getInteractiveShellMode(), topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(), memoryManagerEnabled: context.config.isMemoryManagerEnabled(), diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 59315e1ca6..b049ddf58e 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -18,6 +18,8 @@ import { MEMORY_TOOL_NAME, READ_FILE_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, + READ_SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, WRITE_TODOS_TOOL_NAME, GREP_PARAM_TOTAL_MAX_MATCHES, @@ -81,6 +83,7 @@ export interface PrimaryWorkflowsOptions { export interface OperationalGuidelinesOptions { interactive: boolean; interactiveShellEnabled: boolean; + interactiveShellMode?: 'human' | 'ai' | 'off'; topicUpdateNarration: boolean; memoryManagerEnabled: boolean; } @@ -391,7 +394,7 @@ export function renderOperationalGuidelines( - **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive( options.interactive, options.interactiveShellEnabled, - )}${toolUsageRememberingFacts(options)} + )}${toolUsageRememberingFacts(options)}${toolUsageAiShell(options)} - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. ## Interaction Details @@ -800,6 +803,17 @@ function toolUsageInteractive( - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`; } +function toolUsageAiShell(options: OperationalGuidelinesOptions): string { + if (options.interactiveShellMode !== 'ai') return ''; + return ` +- **AI-Driven Interactive Shell:** Commands using \`wait_for_output_seconds\` auto-promote to background when they stall. Once promoted, use ${formatToolName(READ_SHELL_TOOL_NAME)} to see the terminal screen, then ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} to send text input and/or special keys (arrows, Enter, Ctrl-C, etc.). + - Set \`wait_for_output_seconds\` **low (2-5)** for commands that prompt for input (npx, installers, REPLs). Set **high (60+)** for long builds. Omit for instant commands. + - **Always read the screen before writing input.** The screen state tells you what the process is waiting for. + - When waiting for a command to finish (e.g. npm install), use ${formatToolName(READ_SHELL_TOOL_NAME)} with \`wait_seconds\` to delay before reading. Do NOT poll in a tight loop. + - **Clean up when done:** when your task is complete, kill background processes with ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} sending Ctrl-C, or note the PID for the user to clean up. + - You are the sole operator of promoted shells — the user cannot type into them.`; +} + function toolUsageRememberingFacts( options: OperationalGuidelinesOptions, ): string { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index dfbb3a5033..95b3f2d17b 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -105,6 +105,7 @@ export interface ShellExecutionConfig { backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; originalCommand?: string; sessionId?: string; + autoPromoteTimeoutMs?: number; } /** @@ -889,6 +890,21 @@ export class ShellExecutionService { sessionId: shellExecutionConfig.sessionId, }); + let autoPromoteTimer: NodeJS.Timeout | undefined; + const resetAutoPromoteTimer = () => { + if (shellExecutionConfig.autoPromoteTimeoutMs !== undefined) { + if (autoPromoteTimer) clearTimeout(autoPromoteTimer); + autoPromoteTimer = setTimeout(() => { + ShellExecutionService.background( + ptyPid, + shellExecutionConfig.sessionId, + ); + }, shellExecutionConfig.autoPromoteTimeoutMs); + } + }; + + resetAutoPromoteTimer(); + const result = ExecutionLifecycleService.attachExecution(ptyPid, { executionMethod: ptyInfo?.name ?? 'node-pty', writeInput: (input) => { @@ -1066,6 +1082,7 @@ export class ShellExecutionService { }); const handleOutput = (data: Buffer) => { + resetAutoPromoteTimer(); processingChain = processingChain.then( () => new Promise((resolveChunk) => { @@ -1135,6 +1152,7 @@ export class ShellExecutionService { ptyProcess.onExit( ({ exitCode, signal }: { exitCode: number; signal?: number }) => { + if (autoPromoteTimer) clearTimeout(autoPromoteTimer); exited = true; abortSignal.removeEventListener('abort', abortHandler); // Attempt to destroy the PTY to ensure FD is closed @@ -1220,6 +1238,7 @@ export class ShellExecutionService { ); const abortHandler = async () => { + if (autoPromoteTimer) clearTimeout(autoPromoteTimer); if (ptyProcess.pid && !exited) { await killProcessGroup({ pid: ptyPid, @@ -1398,6 +1417,28 @@ export class ShellExecutionService { return ExecutionLifecycleService.subscribe(pid, listener); } + /** + * Reads the current rendered screen state of a running process. + * Returns the full terminal buffer text for PTY processes, + * or the accumulated output for child processes. + * + * @param pid The process ID of the target process. + * @returns The screen text, or null if the process is not found. + */ + static readScreen(pid: number): string | null { + const activePty = this.activePtys.get(pid); + if (activePty) { + return getFullBufferText(activePty.headlessTerminal); + } + + const activeChild = this.activeChildProcesses.get(pid); + if (activeChild) { + return activeChild.state.output; + } + + return null; + } + /** * Resizes the pseudo-terminal (PTY) of a running process. * diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index 89a5aa1614..e1575966af 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -56,6 +56,18 @@ export const READ_FILE_PARAM_END_LINE = 'end_line'; export const SHELL_TOOL_NAME = 'run_shell_command'; export const SHELL_PARAM_COMMAND = 'command'; export const SHELL_PARAM_IS_BACKGROUND = 'is_background'; +export const SHELL_PARAM_WAIT_SECONDS = 'wait_for_output_seconds'; + +// -- write_to_shell -- +export const WRITE_TO_SHELL_TOOL_NAME = 'write_to_shell'; +export const WRITE_TO_SHELL_PARAM_PID = 'pid'; +export const WRITE_TO_SHELL_PARAM_INPUT = 'input'; +export const WRITE_TO_SHELL_PARAM_SPECIAL_KEYS = 'special_keys'; + +// -- read_shell -- +export const READ_SHELL_TOOL_NAME = 'read_shell'; +export const READ_SHELL_PARAM_PID = 'pid'; +export const READ_SHELL_PARAM_WAIT_SECONDS = 'wait_seconds'; // -- write_file -- export const WRITE_FILE_TOOL_NAME = 'write_file'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index d1b81a6e99..a70ed1a33c 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -27,6 +27,8 @@ export { LS_TOOL_NAME, READ_FILE_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, + READ_SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, WEB_SEARCH_TOOL_NAME, @@ -73,6 +75,12 @@ export { LS_PARAM_IGNORE, SHELL_PARAM_COMMAND, SHELL_PARAM_IS_BACKGROUND, + SHELL_PARAM_WAIT_SECONDS, + WRITE_TO_SHELL_PARAM_PID, + WRITE_TO_SHELL_PARAM_INPUT, + WRITE_TO_SHELL_PARAM_SPECIAL_KEYS, + READ_SHELL_PARAM_PID, + READ_SHELL_PARAM_WAIT_SECONDS, WEB_SEARCH_PARAM_QUERY, WEB_FETCH_PARAM_PROMPT, READ_MANY_PARAM_INCLUDE, @@ -249,18 +257,21 @@ export function getShellDefinition( enableInteractiveShell: boolean, enableEfficiency: boolean, enableToolSandboxing: boolean = false, + interactiveShellMode?: string, ): ToolDefinition { return { base: getShellDeclaration( enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ), overrides: (modelId) => getToolSet(modelId).run_shell_command( enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ), }; } diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 29da313bf4..6f001c7459 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -22,6 +22,7 @@ import { PARAM_DIR_PATH, SHELL_PARAM_IS_BACKGROUND, EXIT_PLAN_PARAM_PLAN_FILENAME, + SHELL_PARAM_WAIT_SECONDS, SKILL_PARAM_NAME, PARAM_ADDITIONAL_PERMISSIONS, UPDATE_TOPIC_TOOL_NAME, @@ -36,7 +37,9 @@ import { export function getShellToolDescription( enableInteractiveShell: boolean, enableEfficiency: boolean, + interactiveShellMode?: string, ): string { + const isAiMode = interactiveShellMode === 'ai'; const efficiencyGuidelines = enableEfficiency ? ` @@ -56,6 +59,11 @@ export function getShellToolDescription( Background PIDs: Only included if background processes were started. Process Group PGID: Only included if available.`; + if (isAiMode) { + const autoPromoteInstructions = `Commands that do not complete within \`${SHELL_PARAM_WAIT_SECONDS}\` seconds are automatically promoted to background. Once promoted, use \`write_to_shell\` and \`read_shell\` to interact with the process. Do NOT use \`&\` to background commands.`; + return `This tool executes a given shell command as \`bash -c \`. ${autoPromoteInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${efficiencyGuidelines}${returnedInfo}`; + } + if (os.platform() === 'win32') { const backgroundInstructions = enableInteractiveShell ? `To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. Do NOT use PowerShell background constructs.` @@ -86,12 +94,33 @@ export function getShellDeclaration( enableInteractiveShell: boolean, enableEfficiency: boolean, enableToolSandboxing: boolean = false, + interactiveShellMode?: string, ): FunctionDeclaration { + const isAiMode = interactiveShellMode === 'ai'; + + // In AI mode, use wait_for_output_seconds instead of is_background + const backgroundParam = isAiMode + ? { + [SHELL_PARAM_WAIT_SECONDS]: { + type: 'number' as const, + description: + 'Max seconds to wait for command to complete before auto-promoting to background (default: 5). Set low (2-5) for commands likely to prompt for input (npx, installers, REPLs). Set high (60-300) for long builds or installs. Once promoted, use write_to_shell/read_shell to interact.', + }, + } + : { + [SHELL_PARAM_IS_BACKGROUND]: { + type: 'boolean' as const, + description: + 'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.', + }, + }; + return { name: SHELL_TOOL_NAME, description: getShellToolDescription( enableInteractiveShell, enableEfficiency, + interactiveShellMode, ), parametersJsonSchema: { type: 'object', @@ -120,6 +149,7 @@ export function getShellDeclaration( description: 'Optional. Delay in milliseconds to wait after starting the process in the background. Useful to allow the process to start and generate initial output before returning.', }, + ...backgroundParam, ...(enableToolSandboxing ? { [PARAM_ADDITIONAL_PERMISSIONS]: { diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 60a52fc6ad..5441c39d09 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -337,11 +337,13 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ) => getShellDeclaration( enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ), replace: { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a86a20378e..f29f9e6814 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -344,11 +344,13 @@ export const GEMINI_3_SET: CoreToolSet = { enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ) => getShellDeclaration( enableInteractiveShell, enableEfficiency, enableToolSandboxing, + interactiveShellMode, ), replace: { diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index 42c0cc7028..d4f532f513 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -38,6 +38,7 @@ export interface CoreToolSet { enableInteractiveShell: boolean, enableEfficiency: boolean, enableToolSandboxing: boolean, + interactiveShellMode?: string, ) => FunctionDeclaration; replace: FunctionDeclaration; google_web_search: FunctionDeclaration; diff --git a/packages/core/src/tools/read-shell.ts b/packages/core/src/tools/read-shell.ts new file mode 100644 index 0000000000..4e74cbbfa5 --- /dev/null +++ b/packages/core/src/tools/read-shell.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, +} from './tools.js'; +import { ShellExecutionService } from '../services/shellExecutionService.js'; +import { + READ_SHELL_TOOL_NAME, + READ_SHELL_PARAM_PID, + READ_SHELL_PARAM_WAIT_SECONDS, +} from './tool-names.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; + +export interface ReadShellParams { + pid: number; + wait_seconds?: number; +} + +export class ReadShellToolInvocation extends BaseToolInvocation< + ReadShellParams, + ToolResult +> { + constructor( + params: ReadShellParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super(params, messageBus, _toolName, _toolDisplayName); + } + + getDescription(): string { + const waitPart = + this.params.wait_seconds !== undefined + ? ` (after ${this.params.wait_seconds}s)` + : ''; + return `read shell screen PID ${this.params.pid}${waitPart}`; + } + + async execute(signal: AbortSignal): Promise { + const { pid, wait_seconds } = this.params; + + // Wait before reading if requested + if (wait_seconds !== undefined && wait_seconds > 0) { + const waitMs = Math.min(wait_seconds, 30) * 1000; // Cap at 30s + await new Promise((resolve) => { + const timer = setTimeout(resolve, waitMs); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + // Validate the PID is active + if (!ShellExecutionService.isPtyActive(pid)) { + return { + llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`, + returnDisplay: `No active process with PID ${pid}.`, + }; + } + + const screen = ShellExecutionService.readScreen(pid); + if (screen === null) { + return { + llmContent: `Error: Could not read screen for PID ${pid}. The process may have exited.`, + returnDisplay: `Could not read screen for PID ${pid}.`, + }; + } + + return { + llmContent: screen, + returnDisplay: `Screen read from PID ${pid} (${screen.split('\n').length} lines).`, + }; + } +} + +export class ReadShellTool extends BaseDeclarativeTool< + ReadShellParams, + ToolResult +> { + static readonly Name = READ_SHELL_TOOL_NAME; + + constructor(messageBus: MessageBus) { + super( + ReadShellTool.Name, + 'ReadShell', + 'Reads the current screen state of a running background shell process. Returns the rendered terminal screen as text, preserving the visual layout. Use after write_to_shell to see updated output, or to check progress of a running command.', + Kind.Read, + { + type: 'object', + properties: { + [READ_SHELL_PARAM_PID]: { + type: 'number', + description: + 'The PID of the background process to read from. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.', + }, + [READ_SHELL_PARAM_WAIT_SECONDS]: { + type: 'number', + description: + 'Seconds to wait before reading the screen. Use this to let the process run for a while before checking output (e.g. wait for a build to finish). Max 30 seconds.', + }, + }, + required: [READ_SHELL_PARAM_PID], + }, + messageBus, + false, // output is not markdown + ); + } + + protected override validateToolParamValues( + params: ReadShellParams, + ): string | null { + if (!params.pid || params.pid <= 0) { + return 'PID must be a positive number.'; + } + if ( + params.wait_seconds !== undefined && + (params.wait_seconds < 0 || params.wait_seconds > 30) + ) { + return 'wait_seconds must be between 0 and 30.'; + } + return null; + } + + protected createInvocation( + params: ReadShellParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation { + return new ReadShellToolInvocation( + params, + messageBus, + _toolName, + _toolDisplayName, + ); + } +} diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 9551fd9638..8ed78ba464 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -149,6 +149,8 @@ describe('ShellTool', () => { getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getSandboxEnabled: vi.fn().mockReturnValue(false), + getInteractiveShellMode: vi.fn().mockReturnValue('off'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), sanitizationConfig: {}, get sandboxManager() { return mockSandboxManager; @@ -422,7 +424,7 @@ describe('ShellTool', () => { expect(mockShellBackground).toHaveBeenCalledWith( 12345, - 'default', + 'test-session-id', 'sleep 10', ); @@ -666,7 +668,7 @@ describe('ShellTool', () => { expect(mockShellBackground).toHaveBeenCalledWith( 12345, - 'default', + 'test-session-id', 'sleep 10', ); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 3ea29474c6..0407cb99bf 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -33,6 +33,7 @@ import { import { getErrorMessage } from '../utils/errors.js'; import { summarizeToolOutput } from '../utils/summarizer.js'; +import { formatShellOutput } from './shellOutputFormatter.js'; import { ShellExecutionService, type ShellOutputEvent, @@ -71,6 +72,7 @@ export interface ShellToolParams { is_background?: boolean; delay_ms?: number; [PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions; + wait_for_output_seconds?: number; } export class ShellToolInvocation extends BaseToolInvocation< @@ -78,6 +80,7 @@ export class ShellToolInvocation extends BaseToolInvocation< ToolResult > { private proactivePermissionsConfirmed?: SandboxPermissions; + private _autoPromoteTimer?: NodeJS.Timeout; constructor( private readonly context: AgentLoopContext, @@ -223,7 +226,12 @@ export class ShellToolInvocation extends BaseToolInvocation< } override getExplanation(): string { - return this.getContextualDetails().trim(); + let explanation = this.getContextualDetails().trim(); + const isAiMode = this.context.config.getInteractiveShellMode() === 'ai'; + if (this.params.wait_for_output_seconds !== undefined || isAiMode) { + explanation += ` [auto-background after ${this.params.wait_for_output_seconds ?? 5}s]`; + } + return explanation; } override getPolicyUpdateOptions( @@ -497,6 +505,21 @@ export class ShellToolInvocation extends BaseToolInvocation< }, timeoutMs); }; + let currentPid: number | undefined; + const isAiMode = this.context.config.getInteractiveShellMode() === 'ai'; + const shouldAutoPromote = + this.params.wait_for_output_seconds !== undefined || isAiMode; + const waitMs = (this.params.wait_for_output_seconds ?? 5) * 1000; + + const resetAutoPromoteTimer = () => { + if (shouldAutoPromote && currentPid) { + if (this._autoPromoteTimer) clearTimeout(this._autoPromoteTimer); + this._autoPromoteTimer = setTimeout(() => { + ShellExecutionService.background(currentPid!); + }, waitMs); + } + }; + signal.addEventListener('abort', onAbort, { once: true }); timeoutController.signal.addEventListener('abort', onAbort, { once: true, @@ -511,6 +534,7 @@ export class ShellToolInvocation extends BaseToolInvocation< cwd, (event: ShellOutputEvent) => { resetTimeout(); // Reset timeout on any event + resetAutoPromoteTimer(); // Reset auto-promote on any event if (!updateOutput) { return; } @@ -582,6 +606,7 @@ export class ShellToolInvocation extends BaseToolInvocation< backgroundCompletionBehavior: this.context.config.getShellBackgroundCompletionBehavior(), originalCommand: strippedCommand, + autoPromoteTimeoutMs: shouldAutoPromote ? waitMs : undefined, }, ); @@ -618,6 +643,11 @@ export class ShellToolInvocation extends BaseToolInvocation< }; } } + + // In AI mode with wait_for_output_seconds, set up auto-promotion timer. + // When the timer fires, promote to background instead of cancelling. + currentPid = pid; + resetAutoPromoteTimer(); } const result = await resultPromise; @@ -658,97 +688,75 @@ export class ShellToolInvocation extends BaseToolInvocation< } } - let data: BackgroundExecutionData | undefined; - - let llmContent = ''; let timeoutMessage = ''; if (result.aborted) { if (timeoutController.signal.aborted) { timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${( timeoutMs / 60000 ).toFixed(1)} minutes without output.`; - llmContent = timeoutMessage; - } else { - llmContent = - 'Command was cancelled by user before it could complete.'; } - if (result.output.trim()) { - llmContent += ` Below is the output before it was cancelled:\n${result.output}`; - } else { - llmContent += ' There was no output before it was cancelled.'; - } - } else if (this.params.is_background || result.backgrounded) { - llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; - data = { - pid: result.pid, - command: this.params.command, - initialOutput: result.output, - }; - } else { - // Create a formatted error string for display, replacing the wrapper command - // with the user-facing command. - const llmContentParts = [`Output: ${result.output || '(empty)'}`]; - - if (result.error) { - const finalError = result.error.message.replaceAll( - commandToExecute, - this.params.command, - ); - llmContentParts.push(`Error: ${finalError}`); - } - - if (result.exitCode !== null && result.exitCode !== 0) { - llmContentParts.push(`Exit Code: ${result.exitCode}`); - data = { - exitCode: result.exitCode, - isError: true, - }; - } - - if (result.signal) { - llmContentParts.push(`Signal: ${result.signal}`); - } - if (backgroundPIDs.length) { - llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`); - } - if (result.pid) { - llmContentParts.push(`Process Group PGID: ${result.pid}`); - } - - llmContent = llmContentParts.join('\n'); } - let returnDisplay: string | AnsiOutput = ''; - if (this.context.config.getDebugMode()) { - returnDisplay = llmContent; - } else { - if (this.params.is_background || result.backgrounded) { - returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; - } else if (result.aborted) { - const cancelMsg = timeoutMessage || 'Command cancelled by user.'; - if (result.output.trim()) { - returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`; + const formatterOutput = formatShellOutput({ + params: this.params, + result, + debugMode: this.context.config.getDebugMode(), + backgroundPIDs, + isAiMode, + timeoutMessage, + }); + + let data: BackgroundExecutionData | undefined; + data = formatterOutput.data as BackgroundExecutionData | undefined; + let returnDisplay: string | AnsiOutput = formatterOutput.returnDisplay; + let llmContent = formatterOutput.llmContent; + + if (!this.context.config.getDebugMode()) { + if ( + !this.params.is_background && + !result.backgrounded && + !result.aborted + ) { + if (result.output.trim() || result.ansiOutput) { + returnDisplay = + result.ansiOutput && result.ansiOutput.length > 0 + ? result.ansiOutput + : result.output; } else { - returnDisplay = cancelMsg; + if (result.signal) { + returnDisplay = `Command terminated by signal: ${result.signal}`; + } else if (result.error) { + returnDisplay = `Command failed: ${getErrorMessage(result.error)}`; + } else if (result.exitCode !== null && result.exitCode !== 0) { + returnDisplay = `Command exited with code: ${result.exitCode}`; + } } - } else if (result.output.trim() || result.ansiOutput) { - returnDisplay = - result.ansiOutput && result.ansiOutput.length > 0 - ? result.ansiOutput - : result.output; - } else { - if (result.signal) { - returnDisplay = `Command terminated by signal: ${result.signal}`; - } else if (result.error) { - returnDisplay = `Command failed: ${getErrorMessage(result.error)}`; - } else if (result.exitCode !== null && result.exitCode !== 0) { - returnDisplay = `Command exited with code: ${result.exitCode}`; - } - // If output is empty and command succeeded (code 0, no error/signal/abort), - // returnDisplay will remain empty, which is fine. } } + // Replace wrapper command with actual command in error messages + if (result.error && !result.aborted) { + llmContent = llmContent.replaceAll( + commandToExecute, + this.params.command, + ); + } + + // Update data with specific things needed by ShellTool + if (this.params.is_background || result.backgrounded) { + data = { + ...data, + initialOutput: result.output, + pid: result.pid!, + command: this.params.command, + }; + } else if (result.exitCode !== null && result.exitCode !== 0) { + data = { + exitCode: result.exitCode, + isError: true, + } as BackgroundExecutionData; + } + // Heuristic Sandbox Denial Detection if ( !!result.error || @@ -929,6 +937,8 @@ export class ShellToolInvocation extends BaseToolInvocation< }; } finally { if (timeoutTimer) clearTimeout(timeoutTimer); + const autoTimer = this._autoPromoteTimer; + if (autoTimer) clearTimeout(autoTimer); signal.removeEventListener('abort', onAbort); timeoutController.signal.removeEventListener('abort', onAbort); try { @@ -1007,6 +1017,7 @@ export class ShellTool extends BaseDeclarativeTool< this.context.config.getEnableInteractiveShell(), this.context.config.getEnableShellOutputEfficiency(), this.context.config.getSandboxEnabled(), + this.context.config.getInteractiveShellMode(), ); return resolveToolDeclaration(definition, modelId); } diff --git a/packages/core/src/tools/shellOutputFormatter.ts b/packages/core/src/tools/shellOutputFormatter.ts new file mode 100644 index 0000000000..04d16fb42e --- /dev/null +++ b/packages/core/src/tools/shellOutputFormatter.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type ShellExecutionResult } from '../services/shellExecutionService.js'; +import { type ShellToolParams } from './shell.js'; + +export interface FormatShellOutputOptions { + params: ShellToolParams; + result: ShellExecutionResult; + debugMode: boolean; + timeoutMessage?: string; + backgroundPIDs: number[]; + summarizedOutput?: string; + isAiMode: boolean; +} + +export interface FormattedShellOutput { + llmContent: string; + returnDisplay: string; + data: Record; +} + +export function formatShellOutput( + options: FormatShellOutputOptions, +): FormattedShellOutput { + const { + params, + result, + debugMode, + timeoutMessage, + backgroundPIDs, + summarizedOutput, + } = options; + + let llmContent = ''; + let data: Record = {}; + + if (result.aborted) { + llmContent = timeoutMessage || 'Command cancelled by user.'; + if (result.output.trim()) { + llmContent += ` Below is the output before it was cancelled:\n${result.output}`; + } else { + llmContent += ' There was no output before it was cancelled.'; + } + } else if (params.is_background || result.backgrounded) { + const isAutoPromoted = result.backgrounded && !params.is_background; + if (isAutoPromoted) { + llmContent = `Command auto-promoted to background (PID: ${result.pid}). The process is still running. To check its screen state, call the read_shell tool with pid ${result.pid}. To send input or keystrokes, call the write_to_shell tool with pid ${result.pid}. If the process does not exit on its own when done, kill it with write_to_shell using special_keys=["Ctrl-C"].`; + } else { + llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + } + data = { + pid: result.pid, + command: params.command, + directory: params.dir_path, + backgrounded: true, + }; + } else { + const llmContentParts: string[] = []; + + let content = summarizedOutput ?? result.output.trim(); + if (!content) { + content = '(empty)'; + } + + llmContentParts.push(`Output: ${content}`); + + if (result.error) { + llmContentParts.push(`Error: ${result.error.message}`); + } + + if (result.exitCode !== null && result.exitCode !== 0) { + llmContentParts.push(`Exit Code: ${result.exitCode}`); + } + if (result.signal !== null) { + llmContentParts.push(`Signal: ${result.signal}`); + } + if (backgroundPIDs.length) { + llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`); + } + if (result.pid) { + llmContentParts.push(`Process Group PGID: ${result.pid}`); + } + + llmContent = llmContentParts.join('\n'); + } + + let returnDisplay = ''; + if (debugMode) { + returnDisplay = llmContent; + } else { + if (params.is_background || result.backgrounded) { + const isAutoPromotedDisplay = + result.backgrounded && !params.is_background; + if (isAutoPromotedDisplay) { + returnDisplay = `Command auto-promoted to background (PID: ${result.pid}).`; + } else { + returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + } + } else if (result.aborted) { + const cancelMsg = timeoutMessage || 'Command cancelled by user.'; + if (result.output.trim()) { + returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`; + } else { + returnDisplay = cancelMsg; + } + } else if (result.error) { + returnDisplay = `Command failed: ${result.error.message}`; + } else if (result.exitCode !== 0 && result.exitCode !== null) { + returnDisplay = `Command exited with code ${result.exitCode}`; + if (result.output.trim()) { + returnDisplay += `\n\n${result.output}`; + } + } else if (summarizedOutput) { + returnDisplay = `Command succeeded. Output summarized:\n${summarizedOutput}`; + } else { + returnDisplay = `Command succeeded.`; + if (result.output.trim()) { + returnDisplay += `\n\n${result.output}`; + } + } + } + + return { llmContent, returnDisplay, data }; +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 224f2ab0d5..47cc906c27 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -10,6 +10,8 @@ import { LS_TOOL_NAME, READ_FILE_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, + READ_SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, WEB_SEARCH_TOOL_NAME, @@ -52,6 +54,12 @@ import { LS_PARAM_IGNORE, SHELL_PARAM_COMMAND, SHELL_PARAM_IS_BACKGROUND, + SHELL_PARAM_WAIT_SECONDS, + WRITE_TO_SHELL_PARAM_PID, + WRITE_TO_SHELL_PARAM_INPUT, + WRITE_TO_SHELL_PARAM_SPECIAL_KEYS, + READ_SHELL_PARAM_PID, + READ_SHELL_PARAM_WAIT_SECONDS, WEB_SEARCH_PARAM_QUERY, WEB_FETCH_PARAM_PROMPT, READ_MANY_PARAM_INCLUDE, @@ -90,6 +98,8 @@ export { LS_TOOL_NAME, READ_FILE_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, + READ_SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, WEB_SEARCH_TOOL_NAME, @@ -136,6 +146,12 @@ export { LS_PARAM_IGNORE, SHELL_PARAM_COMMAND, SHELL_PARAM_IS_BACKGROUND, + SHELL_PARAM_WAIT_SECONDS, + WRITE_TO_SHELL_PARAM_PID, + WRITE_TO_SHELL_PARAM_INPUT, + WRITE_TO_SHELL_PARAM_SPECIAL_KEYS, + READ_SHELL_PARAM_PID, + READ_SHELL_PARAM_WAIT_SECONDS, WEB_SEARCH_PARAM_QUERY, WEB_FETCH_PARAM_PROMPT, READ_MANY_PARAM_INCLUDE, @@ -179,6 +195,7 @@ export const TOOLS_REQUIRING_NARROWING = new Set([ WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, ]); export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task'; @@ -251,6 +268,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [ WEB_FETCH_TOOL_NAME, EDIT_TOOL_NAME, SHELL_TOOL_NAME, + WRITE_TO_SHELL_TOOL_NAME, + READ_SHELL_TOOL_NAME, GREP_TOOL_NAME, READ_MANY_FILES_TOOL_NAME, READ_FILE_TOOL_NAME, diff --git a/packages/core/src/tools/write-to-shell.ts b/packages/core/src/tools/write-to-shell.ts new file mode 100644 index 0000000000..652cb31bf5 --- /dev/null +++ b/packages/core/src/tools/write-to-shell.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ToolConfirmationOutcome, + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, + type ToolCallConfirmationDetails, + type ToolExecuteConfirmationDetails, +} from './tools.js'; +import { ShellExecutionService } from '../services/shellExecutionService.js'; +import { + WRITE_TO_SHELL_TOOL_NAME, + WRITE_TO_SHELL_PARAM_PID, + WRITE_TO_SHELL_PARAM_INPUT, + WRITE_TO_SHELL_PARAM_SPECIAL_KEYS, +} from './tool-names.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; + +/** + * Mapping of named special keys to their ANSI escape sequences. + */ +const SPECIAL_KEY_MAP: Record = { + Enter: '\r', + Tab: '\t', + Up: '\x1b[A', + Down: '\x1b[B', + Left: '\x1b[D', + Right: '\x1b[C', + Escape: '\x1b', + Backspace: '\x7f', + 'Ctrl-C': '\x03', + 'Ctrl-D': '\x04', + 'Ctrl-Z': '\x1a', + Space: ' ', + Delete: '\x1b[3~', + Home: '\x1b[H', + End: '\x1b[F', +}; + +const VALID_SPECIAL_KEYS = Object.keys(SPECIAL_KEY_MAP); + +/** Delay in ms to wait after writing input for the process to react. */ +const POST_INPUT_DELAY_MS = 150; + +export interface WriteToShellParams { + pid: number; + input?: string; + special_keys?: string[]; +} + +export class WriteToShellToolInvocation extends BaseToolInvocation< + WriteToShellParams, + ToolResult +> { + constructor( + params: WriteToShellParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super(params, messageBus, _toolName, _toolDisplayName); + } + + getDescription(): string { + const parts: string[] = [`write to shell PID ${this.params.pid}`]; + if (this.params.input) { + const display = + this.params.input.length > 50 + ? `${this.params.input.substring(0, 50)}...` + : this.params.input; + parts.push(`input: "${display}"`); + } + if (this.params.special_keys?.length) { + parts.push(`keys: [${this.params.special_keys.join(', ')}]`); + } + return parts.join(' '); + } + + protected override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + const confirmationDetails: ToolExecuteConfirmationDetails = { + type: 'exec', + title: 'Confirm Shell Input', + command: this.getDescription(), + rootCommand: 'write_to_shell', + rootCommands: ['write_to_shell'], + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates handled centrally + }, + }; + return confirmationDetails; + } + + async execute(_signal: AbortSignal): Promise { + const { pid, input, special_keys } = this.params; + + // Validate the PID is active + if (!ShellExecutionService.isPtyActive(pid)) { + return { + llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`, + returnDisplay: `No active process with PID ${pid}.`, + }; + } + + // Validate special keys + if (special_keys?.length) { + const invalidKeys = special_keys.filter( + (k) => !VALID_SPECIAL_KEYS.includes(k), + ); + if (invalidKeys.length > 0) { + return { + llmContent: `Error: Invalid special keys: ${invalidKeys.join(', ')}. Valid keys are: ${VALID_SPECIAL_KEYS.join(', ')}`, + returnDisplay: `Invalid special keys: ${invalidKeys.join(', ')}`, + }; + } + } + + // Send text input + if (input) { + ShellExecutionService.writeToPty(pid, input); + } + + // Send special keys + if (special_keys?.length) { + for (const key of special_keys) { + const sequence = SPECIAL_KEY_MAP[key]; + if (sequence) { + ShellExecutionService.writeToPty(pid, sequence); + } + } + } + + // Wait briefly for the process to react + await new Promise((resolve) => setTimeout(resolve, POST_INPUT_DELAY_MS)); + + // Read the screen after writing + const screen = ShellExecutionService.readScreen(pid); + if (screen === null) { + return { + llmContent: `Input sent, but the process (PID ${pid}) has exited.`, + returnDisplay: `Process exited after input.`, + }; + } + + return { + llmContent: `Input sent to PID ${pid}. Current screen:\n${screen}`, + returnDisplay: `Input sent to PID ${pid}.`, + }; + } +} + +export class WriteToShellTool extends BaseDeclarativeTool< + WriteToShellParams, + ToolResult +> { + static readonly Name = WRITE_TO_SHELL_TOOL_NAME; + + constructor(messageBus: MessageBus) { + super( + WriteToShellTool.Name, + 'WriteToShell', + 'Sends input to a running background shell process. Use this to interact with TUI applications, REPLs, and interactive commands. After writing, the current screen state is returned. Works with processes that were auto-promoted to background via wait_for_output_seconds or started with is_background=true.', + Kind.Execute, + { + type: 'object', + properties: { + [WRITE_TO_SHELL_PARAM_PID]: { + type: 'number', + description: + 'The PID of the background process to write to. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.', + }, + [WRITE_TO_SHELL_PARAM_INPUT]: { + type: 'string', + description: + '(OPTIONAL) Text to send to the process. This is literal text typed into the terminal.', + }, + [WRITE_TO_SHELL_PARAM_SPECIAL_KEYS]: { + type: 'array', + items: { + type: 'string', + enum: VALID_SPECIAL_KEYS, + }, + description: + '(OPTIONAL) Named special keys to send after the input text. Each key is sent in sequence. Examples: ["Enter"], ["Tab"], ["Up", "Enter"], ["Ctrl-C"].', + }, + }, + required: [WRITE_TO_SHELL_PARAM_PID], + }, + messageBus, + false, // output is not markdown + ); + } + + protected override validateToolParamValues( + params: WriteToShellParams, + ): string | null { + if (!params.pid || params.pid <= 0) { + return 'PID must be a positive number.'; + } + if ( + !params.input && + (!params.special_keys || !params.special_keys.length) + ) { + return 'At least one of input or special_keys must be provided.'; + } + return null; + } + + protected createInvocation( + params: WriteToShellParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation { + return new WriteToShellToolInvocation( + params, + messageBus, + _toolName, + _toolDisplayName, + ); + } +}