Persistent shell support

This commit is contained in:
jacob314
2026-03-06 13:31:54 -08:00
parent 3d4956aa57
commit 39d1a1bf2f
19 changed files with 1119 additions and 47 deletions
+7
View File
@@ -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;
}
@@ -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');
});
});
@@ -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<void> {
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<void> {
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<void> {
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<PersistentShellResult> {
return new Promise((resolve, reject) => {
this.queue.push({ command, cwd, onOutput, signal, resolve, reject });
if (!this.isProcessing) {
void this.processQueue();
}
});
}
private async processQueue(): Promise<void> {
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();
}
}
@@ -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<number, ActivePty>();
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
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<ShellExecutionHandle> {
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<void>((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;
}
+4 -3
View File
@@ -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 },
);
});
+18 -10
View File
@@ -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);
@@ -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']);
+1 -1
View File
@@ -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.