From 39d1a1bf2f42a90b121d2d744db9efc70441801f Mon Sep 17 00:00:00 2001 From: jacob314 Date: Fri, 6 Mar 2026 13:31:54 -0800 Subject: [PATCH] Persistent shell support --- docs/cli/settings.md | 1 + docs/reference/configuration.md | 6 + packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 11 + .../prompt-processors/shellProcessor.test.ts | 1 + .../prompt-processors/shellProcessor.ts | 1 + packages/cli/src/ui/commands/clearCommand.ts | 2 + .../ui/hooks/shellCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/shellCommandProcessor.ts | 12 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 1 - packages/core/src/config/config.ts | 7 + .../services/persistentShellSession.test.ts | 415 +++++++++++++++ .../src/services/persistentShellSession.ts | 482 ++++++++++++++++++ .../src/services/shellExecutionService.ts | 179 +++++-- packages/core/src/tools/shell.test.ts | 7 +- packages/core/src/tools/shell.ts | 28 +- packages/core/src/utils/shell-utils.test.ts | 2 + packages/core/src/utils/shell-utils.ts | 2 +- schemas/settings.schema.json | 7 + 19 files changed, 1119 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/services/persistentShellSession.test.ts create mode 100644 packages/core/src/services/persistentShellSession.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index d2680d65ad..d32e195f12 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -114,6 +114,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | +| Enable Persistent Shell | `tools.shell.enablePersistentShell` | Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1f1299072b..1582fe0e3a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -768,6 +768,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`tools.shell.enablePersistentShell`** (boolean): + - **Description:** Maintain a persistent shell session across commands to + preserve environment variables, directory changes, and aliases. + - **Default:** `true` + - **Requires restart:** Yes + - **`tools.shell.pager`** (string): - **Description:** The pager command to use for shell output. Defaults to `cat`. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4c8094b4d9..e04ca4a8d4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -783,6 +783,7 @@ export async function loadCliConfig( useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, + enablePersistentShell: settings.tools?.shell?.enablePersistentShell, 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 fbc50e8b39..4c8c7230b6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1282,6 +1282,17 @@ const SETTINGS_SCHEMA = { `, showInDialog: true, }, + enablePersistentShell: { + type: 'boolean', + label: 'Enable Persistent Shell', + category: 'Tools', + requiresRestart: true, + default: true, + description: oneLine` + Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases. + `, + showInDialog: true, + }, pager: { type: 'string', label: 'Pager', diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 0f6fb562a8..b9f14207ce 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -77,6 +77,7 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getEnablePersistentShell: vi.fn().mockReturnValue(false), getShellExecutionConfig: vi.fn().mockReturnValue({}), getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 4c8369f664..f4fdf435bd 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -167,6 +167,7 @@ export class ShellProcessor implements IPromptProcessor { ...config.getShellExecutionConfig(), defaultFg: activeTheme.colors.Foreground, defaultBg: activeTheme.colors.Background, + persistent: config.getEnablePersistentShell(), }; const { result } = await ShellExecutionService.execute( injection.resolvedCommand, diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 385d3f9540..5bd006962d 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -9,6 +9,7 @@ import { SessionEndReason, SessionStartSource, flushTelemetry, + ShellExecutionService, } from '@google/gemini-cli-core'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; @@ -70,6 +71,7 @@ export const clearCommand: SlashCommand = { } uiTelemetryService.setLastPromptTokenCount(0); + ShellExecutionService.clearPersistentSession(); context.ui.clear(); if (result?.systemMessage) { diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 377cac9b7c..6a176507ed 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -106,6 +106,7 @@ describe('useShellCommandProcessor', () => { mockConfig = { getTargetDir: () => '/test/dir', getEnableInteractiveShell: () => false, + getEnablePersistentShell: () => false, getShellExecutionConfig: () => ({ terminalHeight: 20, terminalWidth: 80, diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 364b395876..ba75f92830 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -283,6 +283,7 @@ export const useShellCommandProcessor = ( let commandToExecute = rawQuery; let pwdFilePath: string | undefined; + const isPersistent = config.getEnablePersistentShell(); // On non-windows, wrap the command to capture the final working directory. if (!isWindows) { let command = rawQuery.trim(); @@ -292,7 +293,11 @@ export const useShellCommandProcessor = ( if (!command.endsWith(';') && !command.endsWith('&')) { command += ';'; } - commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`; + if (isPersistent) { + commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; (exit $__code)`; + } else { + commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`; + } } const executeCommand = async () => { @@ -324,7 +329,9 @@ export const useShellCommandProcessor = ( }; abortSignal.addEventListener('abort', abortHandler, { once: true }); - onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`); + onDebugMessage( + `Executing in ${targetDir} (persistent=${isPersistent}): ${commandToExecute}`, + ); try { const activeTheme = themeManager.getActiveTheme(); @@ -334,6 +341,7 @@ export const useShellCommandProcessor = ( terminalHeight, defaultFg: activeTheme.colors.Foreground, defaultBg: activeTheme.colors.Background, + persistent: isPersistent, }; const { pid, result: resultPromise } = diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 3066d1c173..630566090b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1799,7 +1799,6 @@ export const useGeminiStream = ( addItem, registerBackgroundShell, consumeUserHint, - config, isLowErrorVerbosity, maybeAddSuppressedToolErrorNote, maybeAddLowVerbosityFailureNote, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8c341073eb..b0e3afd96b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -542,6 +542,7 @@ export interface ConfigParameters { useAlternateBuffer?: boolean; useRipgrep?: boolean; enableInteractiveShell?: boolean; + enablePersistentShell?: boolean; skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; @@ -726,6 +727,7 @@ export class Config implements McpContext { private readonly directWebFetch: boolean; private readonly useRipgrep: boolean; private readonly enableInteractiveShell: boolean; + private readonly enablePersistentShell: boolean; private readonly skipNextSpeakerCheck: boolean; private readonly useBackgroundColor: boolean; private readonly useAlternateBuffer: boolean; @@ -936,6 +938,7 @@ export class Config implements McpContext { this.useBackgroundColor = params.useBackgroundColor ?? true; this.useAlternateBuffer = params.useAlternateBuffer ?? false; this.enableInteractiveShell = params.enableInteractiveShell ?? false; + this.enablePersistentShell = params.enablePersistentShell ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -2613,6 +2616,10 @@ export class Config implements McpContext { return this.enableInteractiveShell; } + getEnablePersistentShell(): boolean { + return this.enablePersistentShell; + } + getSkipNextSpeakerCheck(): boolean { return this.skipNextSpeakerCheck; } diff --git a/packages/core/src/services/persistentShellSession.test.ts b/packages/core/src/services/persistentShellSession.test.ts new file mode 100644 index 0000000000..5f2322b27c --- /dev/null +++ b/packages/core/src/services/persistentShellSession.test.ts @@ -0,0 +1,415 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { PersistentShellSession } from './persistentShellSession.js'; +import { getPty } from '../utils/getPty.js'; +import { EventEmitter } from 'node:events'; + +vi.mock('../utils/getPty.js'); +vi.mock('node:child_process'); +vi.mock('../utils/shell-utils.js', async () => { + const actual = await vi.importActual('../utils/shell-utils.js'); + return { + ...actual, + getShellConfiguration: vi.fn().mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }), + resolveExecutable: vi.fn().mockResolvedValue('/bin/bash'), + }; +}); + +describe('PersistentShellSession', () => { + let mockPtyProcess: { + pid: number; + write: Mock; + onData: Mock; + onExit: Mock; + kill: Mock; + emit: (event: string, ...args: unknown[]) => boolean; + on: (event: string, cb: (...args: unknown[]) => void) => unknown; + removeListener: ( + event: string, + cb: (...args: unknown[]) => void, + ) => unknown; + }; + let onOutputEventMock: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + onOutputEventMock = vi.fn(); + + // @ts-expect-error - EventEmitter is used as a base but we add more properties + mockPtyProcess = new EventEmitter(); + mockPtyProcess.pid = 123; + mockPtyProcess.write = vi.fn(); + mockPtyProcess.onData = vi.fn((cb) => { + mockPtyProcess.on('data', cb); + return { dispose: () => mockPtyProcess.removeListener('data', cb) }; + }); + mockPtyProcess.onExit = vi.fn((cb) => { + mockPtyProcess.on('exit', cb); + return { dispose: () => mockPtyProcess.removeListener('exit', cb) }; + }); + mockPtyProcess.kill = vi.fn(); + + (getPty as Mock).mockResolvedValue({ + module: { + spawn: vi.fn().mockReturnValue(mockPtyProcess), + }, + }); + }); + + it('should initialize and run a command', async () => { + const session = new PersistentShellSession({ + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }); + + const abortController = new AbortController(); + + // Start execution + const executePromise = session.execute( + 'ls', + process.cwd(), + onOutputEventMock, + abortController.signal, + ); + + // 1. Wait for bootstrap write + await vi.waitFor(() => { + if (mockPtyProcess.write.mock.calls.length === 0) + throw new Error('No write yet'); + }); + const bootstrapCall = mockPtyProcess.write.mock.calls[0][0]; + expect(bootstrapCall).toContain('echo INIT_'); + const initMarker = bootstrapCall.match(/INIT_[a-z0-9]+/)[0]; + + // 2. Resolve bootstrap + mockPtyProcess.emit('data', `${initMarker}\n`); + + // 3. Wait for command write + await vi.waitFor(() => { + if (mockPtyProcess.write.mock.calls.length < 2) + throw new Error('No command write yet'); + }); + const commandCall = mockPtyProcess.write.mock.calls[1][0]; + expect(commandCall).toContain('ls'); + expect(commandCall).toContain('echo "___GEMINI""_EXIT_CODE_'); + + const startMarkerMatch = commandCall.match( + /"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/, + ); + const startMarker = startMarkerMatch + ? `___GEMINI${startMarkerMatch[1]}` + : '___GEMINI_START_MARKER___'; + + // 4. Send command output and exit marker + mockPtyProcess.emit( + 'data', + `${startMarker}\nfile1.txt\n___GEMINI_EXIT_CODE_0___\n`, + ); + + const result = await executePromise; + expect(result.output).toBe('file1.txt'); + expect(result.exitCode).toBe(0); + }); + + it('should persist state between commands', async () => { + const session = new PersistentShellSession({ + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }); + + const p1 = session.execute( + 'export FOO=bar', + process.cwd(), + vi.fn(), + new AbortController().signal, + ); + + // Bootstrap + await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled()); + const initMarker = + mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker}\n`); + + // Cmd 1 + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(2), + ); + let commandCall = mockPtyProcess.write.mock.calls[1][0]; + let startMarkerMatch = commandCall.match( + /"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/, + ); + let startMarker = startMarkerMatch + ? `___GEMINI${startMarkerMatch[1]}` + : '___GEMINI_START_MARKER___'; + mockPtyProcess.emit('data', `${startMarker}\n___GEMINI_EXIT_CODE_0___\n`); + await p1; + + // Cmd 2 + const p2 = session.execute( + 'echo $FOO', + process.cwd(), + onOutputEventMock, + new AbortController().signal, + ); + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(3), + ); + commandCall = mockPtyProcess.write.mock.calls[2][0]; + startMarkerMatch = commandCall.match( + /"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/, + ); + startMarker = startMarkerMatch + ? `___GEMINI${startMarkerMatch[1]}` + : '___GEMINI_START_MARKER___'; + mockPtyProcess.emit( + 'data', + `${startMarker}\nbar\n___GEMINI_EXIT_CODE_0___\n`, + ); + + const result = await p2; + expect(result.output).toBe('bar'); + }); + + it('should handle abort and successfully run the next command', async () => { + const session = new PersistentShellSession({ + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }); + + const abortController1 = new AbortController(); + const p1 = session.execute( + 'sleep 10', + process.cwd(), + vi.fn(), + abortController1.signal, + ); + + // Bootstrap first PTY + await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled()); + const initMarker1 = + mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker1}\n`); + + // Command 1 write + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(2), + ); + + // Now abort! + abortController1.abort(); + + // The abortHandler will wait 1000ms, then call kill() and resolve. + // We need to wait for this to happen. + await vi.waitFor( + () => { + // Check if kill was called + if (mockPtyProcess.kill.mock.calls.length === 0) + throw new Error('Not killed yet'); + }, + { timeout: 2000 }, + ); + + // Resolve the promise from execution + const res1 = await p1; + expect(res1.aborted).toBe(true); + + // Now execute command 2 + const abortController2 = new AbortController(); + const onOutputMock2 = vi.fn(); + const p2 = session.execute( + 'ls -l', + process.cwd(), + onOutputMock2, + abortController2.signal, + ); + + // Bootstrap second PTY (triggered by ensuring initialization after kill) + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(4), + ); + const initMarker2 = + mockPtyProcess.write.mock.calls[3][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker2}\n`); + + // Command 2 write + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(5), + ); + + const commandCall2 = mockPtyProcess.write.mock.calls[4][0]; + const startMarkerMatch2 = commandCall2.match( + /"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/, + ); + const startMarker2 = startMarkerMatch2 + ? `___GEMINI${startMarkerMatch2[1]}` + : '___GEMINI_START_MARKER___'; + + mockPtyProcess.emit( + 'data', + `${startMarker2}\noutput of ls\n___GEMINI_EXIT_CODE_0___\n`, + ); + + const res2 = await p2; + expect(res2.output).toBe('output of ls'); + expect(res2.exitCode).toBe(0); + expect(onOutputMock2).toHaveBeenCalledWith('output of ls\n'); + }); + + it('should reject queued commands if the shell is killed during abort', async () => { + const session = new PersistentShellSession({ + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }); + + const abortController1 = new AbortController(); + const p1 = session.execute( + 'sleep 10', + process.cwd(), + vi.fn(), + abortController1.signal, + ); + + // Bootstrap + await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled()); + const initMarker1 = + mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker1}\n`); + + // Command 1 write + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(2), + ); + + // Now abort! + abortController1.abort(); + + // While it is aborting (waiting for the 1000ms timeout), queue another command + const p2 = session.execute( + 'ls', + process.cwd(), + vi.fn(), + new AbortController().signal, + ); + + // Fast-forward timeout + await new Promise((resolve) => setTimeout(resolve, 1100)); + + // p1 should be aborted + const res1 = await p1; + expect(res1.aborted).toBe(true); + + // p2 should be REJECTED because kill() clears the queue + await expect(p2).rejects.toThrow( + 'Persistent shell process was terminated.', + ); + + // Clean up p1 promise to avoid unhandled rejection if it were to reject (though it resolves in this test) + await p1; + }); + + it('should reset sentOutputLength between commands even after abort', async () => { + const session = new PersistentShellSession({ + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }); + + const onOutputMock1 = vi.fn(); + const abortController1 = new AbortController(); + const p1 = session.execute( + 'ls', + process.cwd(), + onOutputMock1, + abortController1.signal, + ); + + // Bootstrap + await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled()); + const initMarker1 = + mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker1}\n`); + + // Cmd 1 + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(2), + ); + const commandCall1 = mockPtyProcess.write.mock.calls[1][0]; + const startMarker1 = commandCall1 + .match(/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/)[0] + .replace(/"/g, '') + .replace(/___GEMINI/, '___GEMINI'); + + // Send some output + mockPtyProcess.emit( + 'data', + `${startMarker1}\nLong output that is more than 10 characters\n`, + ); + expect(onOutputMock1).toHaveBeenCalled(); + + // Now ABORT + abortController1.abort(); + await new Promise((resolve) => setTimeout(resolve, 1100)); // Wait for kill() + await p1; + + // Now run command 2 + const onOutputMock2 = vi.fn(); + const p2 = session.execute( + 'ls', + process.cwd(), + onOutputMock2, + new AbortController().signal, + ); + + // Bootstrap (new PTY because kill() was called) + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(4), + ); // SIGINT + Bootstrap 2 + const initMarker2 = + mockPtyProcess.write.mock.calls[3][0].match(/INIT_[a-z0-9]+/)[0]; + mockPtyProcess.emit('data', `${initMarker2}\n`); + + // Cmd 2 + await vi.waitFor(() => + expect(mockPtyProcess.write).toHaveBeenCalledTimes(5), + ); + const commandCall2 = mockPtyProcess.write.mock.calls[4][0]; + const startMarker2 = commandCall2 + .match(/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/)[0] + .replace(/"/g, '') + .replace(/___GEMINI/, '___GEMINI'); + + // Send SHORT output + mockPtyProcess.emit( + 'data', + `${startMarker2}\nShort\n___GEMINI_EXIT_CODE_0___\n`, + ); + const res2 = await p2; + expect(res2.output).toBe('Short'); + + // IF sentOutputLength was NOT reset, onOutputMock2 would NOT have been called! + expect(onOutputMock2).toHaveBeenCalledWith('Short\n'); + }); +}); diff --git a/packages/core/src/services/persistentShellSession.ts b/packages/core/src/services/persistentShellSession.ts new file mode 100644 index 0000000000..bcc3bdf081 --- /dev/null +++ b/packages/core/src/services/persistentShellSession.ts @@ -0,0 +1,482 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getPty } from '../utils/getPty.js'; +import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; +import { TextDecoder } from 'node:util'; +import os from 'node:os'; +import type { IPty } from '@lydell/node-pty'; +import { + getShellConfiguration, + resolveExecutable, + type ShellType, +} from '../utils/shell-utils.js'; +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface PersistentShellConfig { + sanitizationConfig: EnvironmentSanitizationConfig; +} + +export interface PersistentShellResult { + output: string; + exitCode: number | null; + signal: number | null; + aborted: boolean; + error: Error | null; +} + +/** + * Manages a persistent shell session (PTY or child_process) that remains active + * across multiple command executions, preserving environment variables, + * aliases, and the current working directory. + */ +export class PersistentShellSession { + private pty: IPty | null = null; + private child: ChildProcess | null = null; + private shellType: ShellType = 'bash'; + + private queue: Array<{ + command: string; + cwd: string; + onOutput: (data: string) => void; + signal: AbortSignal; + resolve: (res: PersistentShellResult) => void; + reject: (err: Error) => void; + }> = []; + + private isProcessing = false; + private currentOutput = ''; + private currentExitCode: number | null = null; + private currentResolver: ((res: PersistentShellResult) => void) | null = null; + private currentRejecter: ((err: Error) => void) | null = null; + private currentOutputCallback: ((data: string) => void) | null = null; + private sentOutputLength = 0; + + private endMarkerPrefix = '___GEMINI_EXIT_CODE_'; + private endMarkerSuffix = '___'; + private startMarker = ''; + private hasSeenStartMarker = false; + + constructor(private config: PersistentShellConfig) {} + + get pid(): number | undefined { + return this.pty?.pid || this.child?.pid; + } + + async init(): Promise { + await this.ensureInitialized(); + } + + write(data: string): void { + if (this.pty) { + this.pty.write(data); + } else if (this.child?.stdin) { + this.child.stdin.write(data); + } + } + + resize(cols: number, rows: number): void { + if (this.pty) { + this.pty.resize(cols, rows); + } + } + + private async ensureInitialized(): Promise { + if (this.pty || this.child) { + debugLogger.log('Reusing existing persistent shell session.'); + return; + } + + const { executable, shell } = getShellConfiguration(); + this.shellType = shell; + + // For persistent shells, we want interactive login shells on Unix + const userShell = process.env['SHELL'] || executable; + const isUnix = os.platform() !== 'win32'; + const args = isUnix ? ['-i', '-l'] : []; // login shell for aliases + + const resolvedExecutable = await resolveExecutable(userShell); + if (!resolvedExecutable) { + throw new Error(`Shell executable "${userShell}" not found.`); + } + + // If the user's shell is zsh, don't treat it strictly as bash + if (resolvedExecutable.endsWith('zsh')) { + this.shellType = 'zsh'; + } + + debugLogger.log( + `Initializing PersistentShellSession with executable: ${resolvedExecutable} args: ${args.join(' ')}`, + ); + + const env = { + ...sanitizeEnvironment(process.env, this.config.sanitizationConfig), + GEMINI_CLI: '1', + TERM: 'xterm-256color', + PAGER: 'cat', + GIT_PAGER: 'cat', + }; + + const ptyInfo = await getPty(); + if (ptyInfo) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.pty = ptyInfo.module.spawn(resolvedExecutable, args, { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env, + }) as IPty; + + this.pty.onData((data) => this.handleRawOutput(data)); + const currentPty = this.pty; + this.pty.onExit((e) => { + debugLogger.log( + `Persistent shell PTY exited with code ${e.exitCode} and signal ${e.signal}`, + ); + if (this.pty === currentPty) { + this.pty = null; + this.handleProcessEnd(); + } + }); + } else { + // Fallback to child_process + this.child = cpSpawn(resolvedExecutable, args, { + cwd: process.cwd(), + env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const decoder = new TextDecoder(); + this.child.stdout?.on('data', (data: Buffer) => + this.handleRawOutput(decoder.decode(data)), + ); + this.child.stderr?.on('data', (data: Buffer) => + this.handleRawOutput(decoder.decode(data)), + ); + const currentChild = this.child; + this.child.on('exit', (code, signal) => { + debugLogger.log( + `Persistent shell child process exited with code ${code} and signal ${signal}`, + ); + if (this.child === currentChild) { + this.child = null; + this.handleProcessEnd(); + } + }); + } + + // Initial silent bootstrap + await this.bootstrapShell(); + } + + private async bootstrapShell(): Promise { + debugLogger.log('Bootstrapping persistent shell...'); + // Send a sequence to clear initialization noise and ensure we are ready + const marker = `INIT_${Math.random().toString(36).substring(2)}`; + + let bootstrapCmd = ''; + if (this.shellType === 'bash') { + // Explicitly source .bashrc as some systems don't do it automatically for login shells, + // or aliases might be defined there instead of .bash_profile. + // Also enable alias expansion which is often disabled in non-interactive modes. + bootstrapCmd = `unset npm_config_prefix; [ -f ~/.bashrc ] && . ~/.bashrc; shopt -s expand_aliases; echo ${marker}\n`; + } else if (this.shellType === 'powershell') { + bootstrapCmd = `Write-Output ${marker}\n`; + } else if (this.shellType === 'zsh') { + // zsh usually has aliases expanded in interactive mode + bootstrapCmd = `unset npm_config_prefix; [ -f ~/.zshrc ] && . ~/.zshrc; echo ${marker}\n`; + } else { + bootstrapCmd = `echo ${marker}\n`; + } + + return new Promise((resolve) => { + let buffer = ''; + const timeout = setTimeout(() => { + debugLogger.error( + 'Persistent shell bootstrap timed out. Buffer content:', + buffer, + ); + resolve(); + }, 5000); + + const onData = (data: string) => { + buffer += data; + if (buffer.includes(marker)) { + debugLogger.log('Persistent shell bootstrap complete.'); + clearTimeout(timeout); + resolve(); + } + }; + + if (this.pty) { + const disposable = this.pty.onData(onData); + this.pty.write(bootstrapCmd); + const originalResolve = resolve; + resolve = () => { + disposable.dispose(); + originalResolve(); + }; + } else if (this.child) { + const listener = (data: Buffer) => + onData(new TextDecoder().decode(data)); + this.child.stdout?.on('data', listener); + this.child.stdin?.write(bootstrapCmd); + const originalResolve = resolve; + resolve = () => { + this.child?.stdout?.removeListener('data', listener); + originalResolve(); + }; + } + }); + } + + private handleRawOutput(data: string): void { + if (!this.isProcessing) { + debugLogger.log( + `Persistent shell received output while NOT processing: ${JSON.stringify(data)}`, + ); + return; + } + + this.currentOutput += data; + + if (!this.hasSeenStartMarker) { + const startIndex = this.currentOutput.indexOf(this.startMarker); + if (startIndex !== -1) { + let startMarkerEnd = startIndex + this.startMarker.length; + if (this.currentOutput.startsWith('\r\n', startMarkerEnd)) { + startMarkerEnd += 2; + } else if ( + this.currentOutput.startsWith('\n', startMarkerEnd) || + this.currentOutput.startsWith('\r', startMarkerEnd) + ) { + startMarkerEnd += 1; + } + + this.currentOutput = this.currentOutput.substring(startMarkerEnd); + this.hasSeenStartMarker = true; + } else { + // Fallback if we see the end marker before the start marker + const endIndex = this.currentOutput.indexOf(this.endMarkerPrefix); + if (endIndex !== -1) { + this.hasSeenStartMarker = true; + } else { + // Still waiting for start marker + return; + } + } + } + + // Check for end marker + const endIndex = this.currentOutput.indexOf(this.endMarkerPrefix); + if (endIndex !== -1) { + const remaining = this.currentOutput.substring( + endIndex + this.endMarkerPrefix.length, + ); + const suffixIndex = remaining.indexOf(this.endMarkerSuffix); + if (suffixIndex !== -1) { + const exitCodeStr = remaining.substring(0, suffixIndex); + this.currentExitCode = parseInt(exitCodeStr, 10); + + // Strip marker from output + const finalOutput = this.currentOutput.substring(0, endIndex); + + // Stream the remaining valid part before the marker + if ( + this.currentOutputCallback && + finalOutput.length > this.sentOutputLength + ) { + const chunk = finalOutput.substring(this.sentOutputLength); + if (chunk) { + this.currentOutputCallback(chunk); + } + } + + this.currentOutput = ''; // Reset for next command + this.sentOutputLength = 0; + this.hasSeenStartMarker = false; + + if (this.currentResolver) { + this.currentResolver({ + output: finalOutput.trim(), + exitCode: this.currentExitCode, + signal: null, + aborted: false, + error: null, + }); + } + this.isProcessing = false; + void this.processQueue(); + return; + } + } + + if (this.currentOutputCallback) { + // Find the safe length to stream without including parts of the end marker + let safeLength = this.currentOutput.length; + for (let i = this.endMarkerPrefix.length; i >= 1; i--) { + const prefixCheck = this.endMarkerPrefix.substring(0, i); + if (this.currentOutput.endsWith(prefixCheck)) { + safeLength = this.currentOutput.length - i; + break; // Found the longest matching suffix that is a prefix of the marker + } + } + + if (safeLength > this.sentOutputLength) { + const chunk = this.currentOutput.substring( + this.sentOutputLength, + safeLength, + ); + this.currentOutputCallback(chunk); + this.sentOutputLength = safeLength; + } + } + } + + private handleProcessEnd(): void { + debugLogger.log( + `Persistent shell process ended. isProcessing=${this.isProcessing}, queueLength=${this.queue.length}`, + ); + if (this.isProcessing && this.currentRejecter) { + debugLogger.log( + `Persistent shell process exited unexpectedly while processing a command. Pending output: ${JSON.stringify(this.currentOutput)}`, + ); + this.currentRejecter( + new Error('Persistent shell process exited unexpectedly.'), + ); + } + this.isProcessing = false; + this.hasSeenStartMarker = false; + this.currentOutput = ''; + this.sentOutputLength = 0; + + const pendingQueue = this.queue; + this.queue = []; + for (const item of pendingQueue) { + item.reject(new Error('Persistent shell process was terminated.')); + } + } + + async execute( + command: string, + cwd: string, + onOutput: (data: string) => void, + signal: AbortSignal, + ): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ command, cwd, onOutput, signal, resolve, reject }); + if (!this.isProcessing) { + void this.processQueue(); + } + }); + } + + private async processQueue(): Promise { + if (this.queue.length === 0 || this.isProcessing) return; + + this.isProcessing = true; + const item = this.queue.shift(); + if (!item) { + this.isProcessing = false; + return; + } + const { command, cwd, onOutput, signal, resolve, reject } = item; + + try { + await this.ensureInitialized(); + + this.currentResolver = resolve; + this.currentRejecter = reject; + this.currentOutputCallback = onOutput; + this.currentOutput = ''; + this.sentOutputLength = 0; + this.currentExitCode = null; + this.startMarker = `___GEMINI_START_MARKER_${Math.random().toString(36).substring(2)}___`; + this.hasSeenStartMarker = false; + + // Construct wrapped command + let wrappedCmd = ''; + const prefix1 = this.endMarkerPrefix.substring(0, 9); + const prefix2 = this.endMarkerPrefix.substring(9); + + const start1 = this.startMarker.substring(0, 9); + const start2 = this.startMarker.substring(9); + + if (this.shellType === 'powershell') { + wrappedCmd = `Set-Location "${cwd}"; Write-Output ("${start1}" + "${start2}"); try { ${command} } finally { Write-Output ("${prefix1}" + "${prefix2}$LASTEXITCODE${this.endMarkerSuffix}") }\r\n`; + } else if (this.shellType === 'cmd') { + wrappedCmd = `call echo ${start1}^${start2} & pushd "${cwd}" && ${command} & set __code=%errorlevel% & popd & call echo ${prefix1}^${prefix2}%__code%${this.endMarkerSuffix}\r\n`; + } else { + // bash/zsh + // Use stty sane and tput rmcup to restore terminal state if a previous command (like vim) left it in a bad state + wrappedCmd = `stty sane 2>/dev/null; tput rmcup 2>/dev/null; tput sgr0 2>/dev/null; echo "${start1}""${start2}"; cd "${cwd}" && { ${command.trim().replace(/;$/, '')}; }; echo "${prefix1}""${prefix2}$?${this.endMarkerSuffix}"\n`; + } + + const abortHandler = () => { + if (this.pty) { + this.pty.write('\x03'); // Send SIGINT + } else if (this.child) { + this.child.kill('SIGINT'); + } + // We don't resolve yet, wait for the prompt to return or a timeout + setTimeout(() => { + if (this.isProcessing) { + this.isProcessing = false; + this.kill(); + resolve({ + output: this.currentOutput, + exitCode: null, + signal: null, + aborted: true, + error: null, + }); + this.hasSeenStartMarker = false; + void this.processQueue(); + } + }, 1000); + }; + + signal.addEventListener('abort', abortHandler, { once: true }); + + debugLogger.log( + `Executing persistent command in ${this.shellType}: ${wrappedCmd.trim()}`, + ); + + if (this.pty) { + this.pty.write(wrappedCmd); + } else if (this.child) { + this.child.stdin?.write(wrappedCmd); + } + } catch (err) { + this.isProcessing = false; + reject(err instanceof Error ? err : new Error(String(err))); + void this.processQueue(); + } + } + + kill(): void { + if (this.pty) { + try { + (this.pty as IPty & { destroy?: () => void }).destroy?.(); + this.pty.kill(); + } catch { + /* ignore */ + } + this.pty = null; + } + if (this.child) { + this.child.kill(); + this.child = null; + } + this.handleProcessEnd(); + } +} diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index fdb2ca79b5..3b5eafc7c6 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -27,6 +27,7 @@ import { type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; import { killProcessGroup } from '../utils/process-utils.js'; +import { PersistentShellSession } from './persistentShellSession.js'; const { Terminal } = pkg; const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB @@ -106,6 +107,7 @@ export interface ShellExecutionConfig { disableDynamicLineTrimming?: boolean; scrollback?: number; maxSerializedLines?: number; + persistent?: boolean; } /** @@ -196,6 +198,7 @@ const getFullBufferText = (terminal: pkg.Terminal): string => { */ export class ShellExecutionService { + private static persistentSession: PersistentShellSession | null = null; private static activePtys = new Map(); private static activeChildProcesses = new Map(); private static exitedPtyInfo = new Map< @@ -210,6 +213,13 @@ export class ShellExecutionService { number, Set<(event: ShellOutputEvent) => void> >(); + static clearPersistentSession(): void { + if (this.persistentSession) { + this.persistentSession.kill(); + this.persistentSession = null; + } + } + /** * Executes a shell command using `node-pty`, capturing all output and lifecycle events. * @@ -228,6 +238,98 @@ export class ShellExecutionService { shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { + if (shellExecutionConfig.persistent) { + if (!this.persistentSession) { + this.persistentSession = new PersistentShellSession({ + sanitizationConfig: shellExecutionConfig.sanitizationConfig, + }); + } + + await this.persistentSession.init(); + const pid = this.persistentSession.pid; + + const cols = shellExecutionConfig.terminalWidth ?? 80; + const rows = shellExecutionConfig.terminalHeight ?? 30; + + this.persistentSession.resize(cols, rows); + + const headlessTerminal = new Terminal({ + allowProposedApi: true, + cols, + rows, + scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT, + }); + headlessTerminal.scrollToTop(); + + let writePromise = Promise.resolve(); + let renderTimeout: NodeJS.Timeout | null = null; + + const renderFn = () => { + renderTimeout = null; + const endLine = headlessTerminal.buffer.active.length; + const startLine = Math.max( + 0, + endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), + ); + const serializedData = serializeTerminalToObject( + headlessTerminal, + startLine, + endLine, + ); + const event: ShellOutputEvent = { type: 'data', chunk: serializedData }; + onOutputEvent(event); + if (pid !== undefined) { + ShellExecutionService.emitEvent(pid, event); + } + }; + + const render = (final?: boolean) => { + if (final) { + if (renderTimeout) clearTimeout(renderTimeout); + renderFn(); + return; + } + if (!renderTimeout) { + renderTimeout = setTimeout(renderFn, 100); + } + }; + + const result = this.persistentSession + .execute( + commandToExecute, + cwd, + (chunk) => { + const bufferData = Buffer.from(chunk); + writePromise = writePromise.then( + () => + new Promise((resolve) => { + headlessTerminal.write(bufferData, () => { + render(); + resolve(); + }); + }), + ); + }, + abortSignal, + ) + .then(async (res) => { + await writePromise; + render(true); + return { + ...res, + rawOutput: Buffer.from(res.output), + output: getFullBufferText(headlessTerminal), + executionMethod: 'node-pty' as const, + pid, + }; + }); + + return { + pid, + result, + }; + } + if (shouldUseNodePty) { const ptyInfo = await getPty(); if (ptyInfo) { @@ -481,6 +583,7 @@ export class ShellExecutionService { function cleanup() { exited = true; abortSignal.removeEventListener('abort', abortHandler); + if (stdoutDecoder) { const remaining = stdoutDecoder.decode(); if (remaining) { @@ -801,41 +904,47 @@ export class ShellExecutionService { const finalize = () => { render(true); + finish(); - // Store exit info for late subscribers (e.g. backgrounding race condition) - this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal }); - setTimeout( - () => { - this.exitedPtyInfo.delete(ptyProcess.pid); - }, - 5 * 60 * 1000, - ).unref(); + function finish() { + // Store exit info for late subscribers (e.g. backgrounding race condition) + ShellExecutionService.exitedPtyInfo.set(ptyProcess.pid, { + exitCode, + signal, + }); + setTimeout( + () => { + ShellExecutionService.exitedPtyInfo.delete(ptyProcess.pid); + }, + 5 * 60 * 1000, + ).unref(); - this.activePtys.delete(ptyProcess.pid); - this.activeResolvers.delete(ptyProcess.pid); + ShellExecutionService.activePtys.delete(ptyProcess.pid); + ShellExecutionService.activeResolvers.delete(ptyProcess.pid); - const event: ShellOutputEvent = { - type: 'exit', - exitCode, - signal: signal ?? null, - }; - onOutputEvent(event); - ShellExecutionService.emitEvent(ptyProcess.pid, event); - this.activeListeners.delete(ptyProcess.pid); + const event: ShellOutputEvent = { + type: 'exit', + exitCode, + signal: signal ?? null, + }; + onOutputEvent(event); + ShellExecutionService.emitEvent(ptyProcess.pid, event); + ShellExecutionService.activeListeners.delete(ptyProcess.pid); - const finalBuffer = Buffer.concat(outputChunks); + const finalBuffer = Buffer.concat(outputChunks); - resolve({ - rawOutput: finalBuffer, - output: getFullBufferText(headlessTerminal), - exitCode, - signal: signal ?? null, - error, - aborted: abortSignal.aborted, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - pid: ptyProcess.pid, - executionMethod: ptyInfo?.name ?? 'node-pty', - }); + resolve({ + rawOutput: finalBuffer, + output: getFullBufferText(headlessTerminal), + exitCode, + signal: signal ?? null, + error, + aborted: abortSignal.aborted, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + pid: ptyProcess.pid, + executionMethod: ptyInfo?.name ?? 'node-pty', + }); + } }; if (abortSignal.aborted) { @@ -914,6 +1023,11 @@ export class ShellExecutionService { * @param input The string to write to the terminal. */ static writeToPty(pid: number, input: string): void { + if (this.persistentSession?.pid === pid) { + this.persistentSession.write(input); + return; + } + if (this.activeChildProcesses.has(pid)) { const activeChild = this.activeChildProcesses.get(pid); if (activeChild) { @@ -1114,6 +1228,11 @@ export class ShellExecutionService { * @param rows The new number of rows. */ static resizePty(pid: number, cols: number, rows: number): void { + if (this.persistentSession?.pid === pid) { + this.persistentSession.resize(cols, rows); + return; + } + if (!this.isPtyActive(pid)) { return; } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d3e47de17f..7581bb8964 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -128,6 +128,7 @@ describe('ShellTool', () => { getGeminiClient: vi.fn().mockReturnValue({}), getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000), getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getEnablePersistentShell: vi.fn().mockReturnValue(false), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), sanitizationConfig: {}, } as unknown as Config; @@ -274,7 +275,7 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + { pager: 'cat', sanitizationConfig: {}, persistent: false }, ); expect(result.llmContent).toContain('Background PIDs: 54322'); // The file should be deleted by the tool @@ -299,7 +300,7 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + { pager: 'cat', sanitizationConfig: {}, persistent: false }, ); }); @@ -320,7 +321,7 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + { pager: 'cat', sanitizationConfig: {}, persistent: false }, ); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 4ea83b0af4..ee7f202ac5 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -176,16 +176,21 @@ export class ShellToolInvocation extends BaseToolInvocation< const onAbort = () => combinedController.abort(); + const isPersistent = + this.config.getEnablePersistentShell() && !this.params.is_background; + try { - // pgrep is not available on Windows, so we can't get background PIDs - const commandToExecute = isWindows - ? strippedCommand - : (() => { - // wrap command to append subprocess pids (via pgrep) to temporary file - let command = strippedCommand.trim(); - if (!command.endsWith('&')) command += ';'; - return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; - })(); + // pgrep is not available on Windows, so we can't get background PIDs. + // Also skip wrapping for persistent shells to avoid 'exit' killing the session. + const commandToExecute = + isWindows || isPersistent + ? strippedCommand + : (() => { + // wrap command to append subprocess pids (via pgrep) to temporary file + let command = strippedCommand.trim(); + if (!command.endsWith('&')) command += ';'; + return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + })(); const cwd = this.params.dir_path ? path.resolve(this.config.getTargetDir(), this.params.dir_path) @@ -277,6 +282,9 @@ export class ShellToolInvocation extends BaseToolInvocation< sanitizationConfig: shellExecutionConfig?.sanitizationConfig ?? this.config.sanitizationConfig, + persistent: + this.config.getEnablePersistentShell() && + !this.params.is_background, }, ); @@ -296,7 +304,7 @@ export class ShellToolInvocation extends BaseToolInvocation< const result = await resultPromise; const backgroundPIDs: number[] = []; - if (os.platform() !== 'win32') { + if (os.platform() !== 'win32' && !isPersistent) { let tempFileExists = false; try { await fsPromises.access(tempFilePath); diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 81b43abf50..4d0c9c4b62 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -406,6 +406,7 @@ describe('getShellConfiguration', () => { it('should return bash configuration on Linux', () => { mockPlatform.mockReturnValue('linux'); + process.env['SHELL'] = '/bin/bash'; const config = getShellConfiguration(); expect(config.executable).toBe('bash'); expect(config.argsPrefix).toEqual(['-c']); @@ -414,6 +415,7 @@ describe('getShellConfiguration', () => { it('should return bash configuration on macOS (darwin)', () => { mockPlatform.mockReturnValue('darwin'); + delete process.env['SHELL']; const config = getShellConfiguration(); expect(config.executable).toBe('bash'); expect(config.argsPrefix).toEqual(['-c']); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 00b3533400..caf6af8fc0 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -23,7 +23,7 @@ export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; /** * An identifier for the shell type. */ -export type ShellType = 'cmd' | 'powershell' | 'bash'; +export type ShellType = 'cmd' | 'powershell' | 'bash' | 'zsh'; /** * Defines the configuration required to execute a command string within a specific shell. diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 36816079ca..8a8e5fd34b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1305,6 +1305,13 @@ "default": true, "type": "boolean" }, + "enablePersistentShell": { + "title": "Enable Persistent Shell", + "description": "Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases.", + "markdownDescription": "Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, "pager": { "title": "Pager", "description": "The pager command to use for shell output. Defaults to `cat`.",