mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-01 19:03:42 +00:00
feat: Implement background shell commands (#14849)
This commit is contained in:
@@ -253,6 +253,7 @@ export class ToolExecutor {
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength: typeof content === 'string' ? content.length : undefined,
|
||||
data: toolResult.data,
|
||||
};
|
||||
|
||||
const startTime = 'startTime' in call ? call.startTime : undefined;
|
||||
|
||||
@@ -38,6 +38,10 @@ export interface ToolCallResponseInfo {
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
contentLength?: number;
|
||||
/**
|
||||
* Optional data payload for passing structured information back to the caller.
|
||||
*/
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ValidatingToolCall = {
|
||||
|
||||
@@ -76,7 +76,13 @@ vi.mock('../utils/getPty.js', () => ({
|
||||
getPty: mockGetPty,
|
||||
}));
|
||||
vi.mock('../utils/terminalSerializer.js', () => ({
|
||||
serializeTerminalToObject: mockSerializeTerminalToObject,
|
||||
// Avoid passing the heavy Terminal object to the spy to prevent OOM
|
||||
serializeTerminalToObject: (
|
||||
_terminal: unknown,
|
||||
...args: [number | undefined, number | undefined]
|
||||
) => mockSerializeTerminalToObject(...args),
|
||||
convertColorToHex: () => '#000000',
|
||||
ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 },
|
||||
}));
|
||||
vi.mock('../utils/systemEncoding.js', () => ({
|
||||
getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
|
||||
@@ -318,6 +324,7 @@ describe('ShellExecutionService', () => {
|
||||
}
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
},
|
||||
{ ...shellExecutionConfig, maxSerializedLines: 100 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
@@ -675,7 +682,7 @@ describe('ShellExecutionService', () => {
|
||||
expect(result.rawOutput).toEqual(
|
||||
Buffer.concat([binaryChunk1, binaryChunk2]),
|
||||
);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(3);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
});
|
||||
@@ -687,6 +694,11 @@ describe('ShellExecutionService', () => {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: 8,
|
||||
});
|
||||
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
|
||||
type: 'exit',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit data events after binary is detected', async () => {
|
||||
@@ -705,6 +717,7 @@ describe('ShellExecutionService', () => {
|
||||
'binary_detected',
|
||||
'binary_progress',
|
||||
'binary_progress',
|
||||
'exit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -763,9 +776,7 @@ describe('ShellExecutionService', () => {
|
||||
coloredShellExecutionConfig,
|
||||
);
|
||||
|
||||
expect(mockSerializeTerminalToObject).toHaveBeenCalledWith(
|
||||
expect.anything(), // The terminal object
|
||||
);
|
||||
expect(mockSerializeTerminalToObject).toHaveBeenCalled();
|
||||
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -932,11 +943,20 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.output).toBe('file1.txt\na warning');
|
||||
expect(handle.pid).toBe(undefined);
|
||||
expect(handle.pid).toBe(12345);
|
||||
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'data',
|
||||
chunk: 'file1.txt\na warning',
|
||||
chunk: 'file1.txt\n',
|
||||
});
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'data',
|
||||
chunk: 'a warning',
|
||||
});
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'exit',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,12 +968,15 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
expect(result.output.trim()).toBe('aredword');
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'data',
|
||||
chunk: 'aredword',
|
||||
}),
|
||||
);
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'data',
|
||||
chunk: 'a\u001b[31mred\u001b[0mword',
|
||||
});
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'exit',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly decode multi-byte characters split across chunks', async () => {
|
||||
@@ -974,10 +997,14 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
expect(result.output.trim()).toBe('');
|
||||
expect(onOutputEventMock).not.toHaveBeenCalled();
|
||||
expect(onOutputEventMock).toHaveBeenCalledWith({
|
||||
type: 'exit',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('should truncate stdout using a sliding window and show a warning', async () => {
|
||||
it('should truncate stdout using a sliding window and show a warning', async () => {
|
||||
const MAX_SIZE = 16 * 1024 * 1024;
|
||||
const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5);
|
||||
const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5);
|
||||
@@ -1173,26 +1200,44 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expect(result.rawOutput).toEqual(
|
||||
Buffer.concat([binaryChunk1, binaryChunk2]),
|
||||
);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
});
|
||||
expect(onOutputEventMock.mock.calls[1][0]).toEqual({
|
||||
type: 'binary_progress',
|
||||
bytesReceived: 4,
|
||||
});
|
||||
expect(onOutputEventMock.mock.calls[2][0]).toEqual({
|
||||
type: 'binary_progress',
|
||||
bytesReceived: 8,
|
||||
});
|
||||
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
|
||||
type: 'exit',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit data events after binary is detected', async () => {
|
||||
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
|
||||
|
||||
await simulateExecution('cat mixed_file', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from('some text'));
|
||||
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
|
||||
cp.stdout?.emit('data', Buffer.from('more text'));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
const eventTypes = onOutputEventMock.mock.calls.map(
|
||||
(call: [ShellOutputEvent]) => call[0].type,
|
||||
);
|
||||
expect(eventTypes).toEqual(['binary_detected']);
|
||||
expect(eventTypes).toEqual([
|
||||
'binary_detected',
|
||||
'binary_progress',
|
||||
'binary_progress',
|
||||
'exit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { PtyImplementation } from '../utils/getPty.js';
|
||||
import { getPty } from '../utils/getPty.js';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
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';
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { killProcessGroup } from '../utils/process-utils.js';
|
||||
const { Terminal } = pkg;
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200;
|
||||
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
|
||||
|
||||
// We want to allow shell outputs that are close to the context window in size.
|
||||
@@ -71,6 +71,8 @@ export interface ShellExecutionResult {
|
||||
pid: number | undefined;
|
||||
/** The method used to execute the shell command. */
|
||||
executionMethod: 'lydell-node-pty' | 'node-pty' | 'child_process' | 'none';
|
||||
/** Whether the command was moved to the background. */
|
||||
backgrounded?: boolean;
|
||||
}
|
||||
|
||||
/** A handle for an ongoing shell execution. */
|
||||
@@ -92,6 +94,7 @@ export interface ShellExecutionConfig {
|
||||
// Used for testing
|
||||
disableDynamicLineTrimming?: boolean;
|
||||
scrollback?: number;
|
||||
maxSerializedLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,11 +116,29 @@ export type ShellOutputEvent =
|
||||
type: 'binary_progress';
|
||||
/** The total number of bytes received so far. */
|
||||
bytesReceived: number;
|
||||
}
|
||||
| {
|
||||
/** Signals that the process has exited. */
|
||||
type: 'exit';
|
||||
/** The exit code of the process, if any. */
|
||||
exitCode: number | null;
|
||||
/** The signal that terminated the process, if any. */
|
||||
signal: number | null;
|
||||
};
|
||||
|
||||
interface ActivePty {
|
||||
ptyProcess: IPty;
|
||||
headlessTerminal: pkg.Terminal;
|
||||
maxSerializedLines?: number;
|
||||
}
|
||||
|
||||
interface ActiveChildProcess {
|
||||
process: ChildProcess;
|
||||
state: {
|
||||
output: string;
|
||||
truncated: boolean;
|
||||
outputChunks: Buffer[];
|
||||
};
|
||||
}
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
@@ -165,6 +186,19 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
|
||||
export class ShellExecutionService {
|
||||
private static activePtys = new Map<number, ActivePty>();
|
||||
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
|
||||
private static exitedPtyInfo = new Map<
|
||||
number,
|
||||
{ exitCode: number; signal?: number }
|
||||
>();
|
||||
private static activeResolvers = new Map<
|
||||
number,
|
||||
(res: ShellExecutionResult) => void
|
||||
>();
|
||||
private static activeListeners = new Map<
|
||||
number,
|
||||
Set<(event: ShellOutputEvent) => void>
|
||||
>();
|
||||
/**
|
||||
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
|
||||
*
|
||||
@@ -240,6 +274,13 @@ export class ShellExecutionService {
|
||||
return { newBuffer: truncatedBuffer + chunk, truncated: true };
|
||||
}
|
||||
|
||||
private static emitEvent(pid: number, event: ShellOutputEvent): void {
|
||||
const listeners = this.activeListeners.get(pid);
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
}
|
||||
}
|
||||
|
||||
private static childProcessFallback(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
@@ -268,15 +309,26 @@ export class ShellExecutionService {
|
||||
},
|
||||
});
|
||||
|
||||
const state = {
|
||||
output: '',
|
||||
truncated: false,
|
||||
outputChunks: [] as Buffer[],
|
||||
};
|
||||
|
||||
if (child.pid) {
|
||||
this.activeChildProcesses.set(child.pid, {
|
||||
process: child,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
if (child.pid) {
|
||||
this.activeResolvers.set(child.pid, resolve);
|
||||
}
|
||||
|
||||
let stdoutDecoder: TextDecoder | null = null;
|
||||
let stderrDecoder: TextDecoder | null = null;
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const outputChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let exited = false;
|
||||
|
||||
@@ -296,14 +348,17 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
outputChunks.push(data);
|
||||
state.outputChunks.push(data);
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
const sniffBuffer = Buffer.concat(state.outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
const event: ShellOutputEvent = { type: 'binary_detected' };
|
||||
onOutputEvent(event);
|
||||
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,27 +366,35 @@ export class ShellExecutionService {
|
||||
const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder;
|
||||
const decodedChunk = decoder.decode(data, { stream: true });
|
||||
|
||||
if (stream === 'stdout') {
|
||||
const { newBuffer, truncated } = this.appendAndTruncate(
|
||||
stdout,
|
||||
decodedChunk,
|
||||
MAX_CHILD_PROCESS_BUFFER_SIZE,
|
||||
);
|
||||
stdout = newBuffer;
|
||||
if (truncated) {
|
||||
stdoutTruncated = true;
|
||||
}
|
||||
} else {
|
||||
const { newBuffer, truncated } = this.appendAndTruncate(
|
||||
stderr,
|
||||
decodedChunk,
|
||||
MAX_CHILD_PROCESS_BUFFER_SIZE,
|
||||
);
|
||||
stderr = newBuffer;
|
||||
if (truncated) {
|
||||
stderrTruncated = true;
|
||||
}
|
||||
const { newBuffer, truncated } = this.appendAndTruncate(
|
||||
state.output,
|
||||
decodedChunk,
|
||||
MAX_CHILD_PROCESS_BUFFER_SIZE,
|
||||
);
|
||||
state.output = newBuffer;
|
||||
if (truncated) {
|
||||
state.truncated = true;
|
||||
}
|
||||
|
||||
if (decodedChunk) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: decodedChunk,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
|
||||
}
|
||||
} else {
|
||||
const totalBytes = state.outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,12 +403,10 @@ export class ShellExecutionService {
|
||||
signal: NodeJS.Signals | null,
|
||||
) => {
|
||||
const { finalBuffer } = cleanup();
|
||||
// Ensure we don't add an extra newline if stdout already ends with one.
|
||||
const separator = stdout.endsWith('\n') ? '' : '\n';
|
||||
let combinedOutput =
|
||||
stdout + (stderr ? (stdout ? separator : '') + stderr : '');
|
||||
|
||||
if (stdoutTruncated || stderrTruncated) {
|
||||
let combinedOutput = state.output;
|
||||
|
||||
if (state.truncated) {
|
||||
const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${
|
||||
MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024)
|
||||
}MB.]`;
|
||||
@@ -353,23 +414,31 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
|
||||
const exitCode = code;
|
||||
const exitSignal = signal ? os.constants.signals[signal] : null;
|
||||
|
||||
if (isStreamingRawContent) {
|
||||
if (finalStrippedOutput) {
|
||||
onOutputEvent({ type: 'data', chunk: finalStrippedOutput });
|
||||
}
|
||||
} else {
|
||||
onOutputEvent({ type: 'binary_detected' });
|
||||
if (child.pid) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: exitSignal,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
|
||||
this.activeChildProcesses.delete(child.pid);
|
||||
this.activeResolvers.delete(child.pid);
|
||||
this.activeListeners.delete(child.pid);
|
||||
}
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: finalStrippedOutput,
|
||||
exitCode: code,
|
||||
signal: signal ? os.constants.signals[signal] : null,
|
||||
exitCode,
|
||||
signal: exitSignal,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
pid: undefined,
|
||||
pid: child.pid,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
};
|
||||
@@ -383,28 +452,17 @@ export class ShellExecutionService {
|
||||
|
||||
const abortHandler = async () => {
|
||||
if (child.pid && !exited) {
|
||||
if (isWindows) {
|
||||
cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!exited) {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
if (!exited) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
await killProcessGroup({
|
||||
pid: child.pid,
|
||||
escalate: true,
|
||||
isExited: () => exited,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (child.pid) {
|
||||
this.activePtys.delete(child.pid);
|
||||
}
|
||||
handleExit(code, signal);
|
||||
});
|
||||
|
||||
@@ -414,23 +472,43 @@ export class ShellExecutionService {
|
||||
if (stdoutDecoder) {
|
||||
const remaining = stdoutDecoder.decode();
|
||||
if (remaining) {
|
||||
stdout += remaining;
|
||||
state.output += remaining;
|
||||
// If there's remaining output, we should technically emit it too,
|
||||
// but it's rare to have partial utf8 chars at the very end of stream.
|
||||
if (isStreamingRawContent && remaining) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid)
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stderrDecoder) {
|
||||
const remaining = stderrDecoder.decode();
|
||||
if (remaining) {
|
||||
stderr += remaining;
|
||||
state.output += remaining;
|
||||
if (isStreamingRawContent && remaining) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid)
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
const finalBuffer = Buffer.concat(state.outputChunks);
|
||||
|
||||
return { stdout, stderr, finalBuffer };
|
||||
return { finalBuffer };
|
||||
}
|
||||
});
|
||||
|
||||
return { pid: undefined, result };
|
||||
return { pid: child.pid, result };
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
return {
|
||||
@@ -495,6 +573,8 @@ export class ShellExecutionService {
|
||||
});
|
||||
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
this.activeResolvers.set(ptyProcess.pid, resolve);
|
||||
|
||||
const headlessTerminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols,
|
||||
@@ -503,7 +583,11 @@ export class ShellExecutionService {
|
||||
});
|
||||
headlessTerminal.scrollToTop();
|
||||
|
||||
this.activePtys.set(ptyProcess.pid, { ptyProcess, headlessTerminal });
|
||||
this.activePtys.set(ptyProcess.pid, {
|
||||
ptyProcess,
|
||||
headlessTerminal,
|
||||
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
||||
});
|
||||
|
||||
let processingChain = Promise.resolve();
|
||||
let decoder: TextDecoder | null = null;
|
||||
@@ -537,17 +621,29 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
const buffer = headlessTerminal.buffer.active;
|
||||
const endLine = buffer.length;
|
||||
const startLine = Math.max(
|
||||
0,
|
||||
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
||||
);
|
||||
|
||||
let newOutput: AnsiOutput;
|
||||
if (shellExecutionConfig.showColor) {
|
||||
newOutput = serializeTerminalToObject(headlessTerminal);
|
||||
newOutput = serializeTerminalToObject(
|
||||
headlessTerminal,
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
} else {
|
||||
newOutput = (serializeTerminalToObject(headlessTerminal) || []).map(
|
||||
(line) =>
|
||||
line.map((token) => {
|
||||
token.fg = '';
|
||||
token.bg = '';
|
||||
return token;
|
||||
}),
|
||||
newOutput = (
|
||||
serializeTerminalToObject(headlessTerminal, startLine, endLine) ||
|
||||
[]
|
||||
).map((line) =>
|
||||
line.map((token) => {
|
||||
token.fg = '';
|
||||
token.bg = '';
|
||||
return token;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -565,8 +661,11 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.cursorY > lastNonEmptyLine) {
|
||||
lastNonEmptyLine = buffer.cursorY;
|
||||
const absoluteCursorY = buffer.baseY + buffer.cursorY;
|
||||
const cursorRelativeIndex = absoluteCursorY - startLine;
|
||||
|
||||
if (cursorRelativeIndex > lastNonEmptyLine) {
|
||||
lastNonEmptyLine = cursorRelativeIndex;
|
||||
}
|
||||
|
||||
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
|
||||
@@ -575,13 +674,14 @@ export class ShellExecutionService {
|
||||
? newOutput
|
||||
: trimmedOutput;
|
||||
|
||||
// Using stringify for a quick deep comparison.
|
||||
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
|
||||
if (output !== finalOutput) {
|
||||
output = finalOutput;
|
||||
onOutputEvent({
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: finalOutput,
|
||||
});
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -631,7 +731,9 @@ export class ShellExecutionService {
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
onOutputEvent({ type: 'binary_detected' });
|
||||
const event: ShellOutputEvent = { type: 'binary_detected' };
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,10 +754,12 @@ export class ShellExecutionService {
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
onOutputEvent({
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
});
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
resolve();
|
||||
}
|
||||
}),
|
||||
@@ -681,6 +785,28 @@ export class ShellExecutionService {
|
||||
|
||||
const finalize = () => {
|
||||
render(true);
|
||||
|
||||
// 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();
|
||||
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.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 finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
@@ -720,25 +846,12 @@ export class ShellExecutionService {
|
||||
|
||||
const abortHandler = async () => {
|
||||
if (ptyProcess.pid && !exited) {
|
||||
if (os.platform() === 'win32') {
|
||||
ptyProcess.kill();
|
||||
} else {
|
||||
try {
|
||||
// Kill the entire process group
|
||||
process.kill(-ptyProcess.pid, 'SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!exited) {
|
||||
process.kill(-ptyProcess.pid, 'SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to killing just the process if the group kill fails
|
||||
ptyProcess.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!exited) {
|
||||
ptyProcess.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
await killProcessGroup({
|
||||
pid: ptyProcess.pid,
|
||||
escalate: true,
|
||||
isExited: () => exited,
|
||||
pty: ptyProcess,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -780,6 +893,14 @@ export class ShellExecutionService {
|
||||
* @param input The string to write to the terminal.
|
||||
*/
|
||||
static writeToPty(pid: number, input: string): void {
|
||||
if (this.activeChildProcesses.has(pid)) {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (activeChild) {
|
||||
activeChild.process.stdin?.write(input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPtyActive(pid)) {
|
||||
return;
|
||||
}
|
||||
@@ -791,6 +912,14 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
static isPtyActive(pid: number): boolean {
|
||||
if (this.activeChildProcesses.has(pid)) {
|
||||
try {
|
||||
return process.kill(pid, 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// process.kill with signal 0 is a way to check for the existence of a process.
|
||||
// It doesn't actually send a signal.
|
||||
@@ -800,6 +929,162 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback to be invoked when the process with the given PID exits.
|
||||
* This attaches directly to the PTY's exit event.
|
||||
*
|
||||
* @param pid The process ID to watch.
|
||||
* @param callback The function to call on exit.
|
||||
* @returns An unsubscribe function.
|
||||
*/
|
||||
static onExit(
|
||||
pid: number,
|
||||
callback: (exitCode: number, signal?: number) => void,
|
||||
): () => void {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
if (activePty) {
|
||||
const disposable = activePty.ptyProcess.onExit(
|
||||
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
callback(exitCode, signal);
|
||||
disposable.dispose();
|
||||
},
|
||||
);
|
||||
return () => disposable.dispose();
|
||||
} else if (this.activeChildProcesses.has(pid)) {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const listener = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
let signalNumber: number | undefined;
|
||||
if (signal) {
|
||||
signalNumber = os.constants.signals[signal];
|
||||
}
|
||||
callback(code ?? 0, signalNumber);
|
||||
};
|
||||
activeChild?.process.on('exit', listener);
|
||||
return () => {
|
||||
activeChild?.process.removeListener('exit', listener);
|
||||
};
|
||||
} else {
|
||||
// Check if it already exited recently
|
||||
const exitedInfo = this.exitedPtyInfo.get(pid);
|
||||
if (exitedInfo) {
|
||||
callback(exitedInfo.exitCode, exitedInfo.signal);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills a process by its PID.
|
||||
*
|
||||
* @param pid The process ID to kill.
|
||||
*/
|
||||
static kill(pid: number): void {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
if (activeChild) {
|
||||
killProcessGroup({ pid }).catch(() => {});
|
||||
this.activeChildProcesses.delete(pid);
|
||||
} else if (activePty) {
|
||||
killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {});
|
||||
this.activePtys.delete(pid);
|
||||
}
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
this.activeListeners.delete(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a running shell command to the background.
|
||||
* This resolves the execution promise but keeps the PTY active.
|
||||
*
|
||||
* @param pid The process ID of the target PTY.
|
||||
*/
|
||||
static background(pid: number): void {
|
||||
const resolve = this.activeResolvers.get(pid);
|
||||
if (resolve) {
|
||||
let output = '';
|
||||
const rawOutput = Buffer.from('');
|
||||
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
if (activePty) {
|
||||
output = getFullBufferText(activePty.headlessTerminal);
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'node-pty',
|
||||
backgrounded: true,
|
||||
});
|
||||
} else if (activeChild) {
|
||||
output = activeChild.state.output;
|
||||
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'child_process',
|
||||
backgrounded: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
}
|
||||
}
|
||||
|
||||
static subscribe(
|
||||
pid: number,
|
||||
listener: (event: ShellOutputEvent) => void,
|
||||
): () => void {
|
||||
if (!this.activeListeners.has(pid)) {
|
||||
this.activeListeners.set(pid, new Set());
|
||||
}
|
||||
this.activeListeners.get(pid)?.add(listener);
|
||||
|
||||
// Send current buffer content immediately
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
if (activePty) {
|
||||
// Use serializeTerminalToObject to preserve colors and structure
|
||||
const endLine = activePty.headlessTerminal.buffer.active.length;
|
||||
const startLine = Math.max(
|
||||
0,
|
||||
endLine - (activePty.maxSerializedLines ?? 2000),
|
||||
);
|
||||
const bufferData = serializeTerminalToObject(
|
||||
activePty.headlessTerminal,
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
if (bufferData && bufferData.length > 0) {
|
||||
listener({ type: 'data', chunk: bufferData });
|
||||
}
|
||||
} else if (activeChild) {
|
||||
const output = activeChild.state.output;
|
||||
if (output) {
|
||||
listener({ type: 'data', chunk: output });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.activeListeners.get(pid)?.delete(listener);
|
||||
if (this.activeListeners.get(pid)?.size === 0) {
|
||||
this.activeListeners.delete(pid);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the pseudo-terminal (PTY) of a running process.
|
||||
*
|
||||
@@ -835,6 +1120,25 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force emit the new state after resize
|
||||
if (activePty) {
|
||||
const endLine = activePty.headlessTerminal.buffer.active.length;
|
||||
const startLine = Math.max(
|
||||
0,
|
||||
endLine - (activePty.maxSerializedLines ?? 2000),
|
||||
);
|
||||
const bufferData = serializeTerminalToObject(
|
||||
activePty.headlessTerminal,
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
|
||||
const listeners = ShellExecutionService.activeListeners.get(pid);
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,8 +18,13 @@ import {
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockShellExecutionService = vi.hoisted(() => vi.fn());
|
||||
const mockShellBackground = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../services/shellExecutionService.js', () => ({
|
||||
ShellExecutionService: { execute: mockShellExecutionService },
|
||||
ShellExecutionService: {
|
||||
execute: mockShellExecutionService,
|
||||
background: mockShellBackground,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -38,6 +43,7 @@ vi.mock('../utils/summarizer.js');
|
||||
|
||||
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||||
import { ShellTool } from './shell.js';
|
||||
import { debugLogger } from '../index.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import {
|
||||
type ShellExecutionResult,
|
||||
@@ -168,6 +174,20 @@ describe('ShellTool', () => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
mockShellBackground.mockImplementation(() => {
|
||||
resolveExecutionPromise({
|
||||
output: '',
|
||||
rawOutput: Buffer.from(''),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
backgrounded: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -305,6 +325,25 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle is_background parameter by calling ShellExecutionService.background', async () => {
|
||||
vi.useFakeTimers();
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 10',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
// We need to provide a PID for the background logic to trigger
|
||||
resolveShellExecution({ pid: 12345 });
|
||||
|
||||
// Advance time to trigger the background timeout
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(12345);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
itWindowsOnly(
|
||||
'should not wrap command on windows',
|
||||
async () => {
|
||||
@@ -430,8 +469,6 @@ describe('ShellTool', () => {
|
||||
// We can also verify that setTimeout was NOT called for the inactivity timeout.
|
||||
// However, since we don't have direct access to the internal `resetTimeout`,
|
||||
// we can infer success by the fact it didn't abort.
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should clean up the temp file on synchronous execution error', async () => {
|
||||
@@ -450,10 +487,28 @@ describe('ShellTool', () => {
|
||||
expect(fs.existsSync(tmpFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not log "missing pgrep output" when process is backgrounded', async () => {
|
||||
vi.useFakeTimers();
|
||||
const debugErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 10',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
// Advance time to trigger backgrounding
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(debugErrorSpy).not.toHaveBeenCalledWith('missing pgrep output');
|
||||
});
|
||||
|
||||
describe('Streaming to `updateOutput`', () => {
|
||||
let updateOutputMock: Mock;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
|
||||
updateOutputMock = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -503,6 +558,27 @@ describe('ShellTool', () => {
|
||||
});
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should NOT call updateOutput if the command is backgrounded', async () => {
|
||||
const invocation = shellTool.build({
|
||||
command: 'sleep 10',
|
||||
is_background: true,
|
||||
});
|
||||
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
||||
|
||||
mockShellOutputCallback({ type: 'data', chunk: 'some output' });
|
||||
expect(updateOutputMock).not.toHaveBeenCalled();
|
||||
|
||||
// We need to provide a PID for the background logic to trigger
|
||||
resolveShellExecution({ pid: 12345 });
|
||||
|
||||
// Advance time to trigger the background timeout
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(12345);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,10 +46,14 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
|
||||
// Delay so user does not see the output of the process before the process is moved to the background.
|
||||
const BACKGROUND_DELAY_MS = 200;
|
||||
|
||||
export interface ShellToolParams {
|
||||
command: string;
|
||||
description?: string;
|
||||
dir_path?: string;
|
||||
is_background?: boolean;
|
||||
}
|
||||
|
||||
export class ShellToolInvocation extends BaseToolInvocation<
|
||||
@@ -79,6 +83,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
if (this.params.description) {
|
||||
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
||||
}
|
||||
if (this.params.is_background) {
|
||||
description += ' [background]';
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
@@ -249,12 +256,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
shouldUpdate = true;
|
||||
}
|
||||
break;
|
||||
case 'exit':
|
||||
break;
|
||||
default: {
|
||||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
if (shouldUpdate && !this.params.is_background) {
|
||||
updateOutput(cumulativeOutput);
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
@@ -270,8 +279,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
},
|
||||
);
|
||||
|
||||
if (pid && setPidCallback) {
|
||||
setPidCallback(pid);
|
||||
if (pid) {
|
||||
if (setPidCallback) {
|
||||
setPidCallback(pid);
|
||||
}
|
||||
|
||||
// If the model requested to run in the background, do so after a short delay.
|
||||
if (this.params.is_background) {
|
||||
setTimeout(() => {
|
||||
ShellExecutionService.background(pid);
|
||||
}, BACKGROUND_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
@@ -299,12 +317,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!signal.aborted) {
|
||||
if (!signal.aborted && !result.backgrounded) {
|
||||
debugLogger.error('missing pgrep output');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: Record<string, unknown> | undefined;
|
||||
|
||||
let llmContent = '';
|
||||
let timeoutMessage = '';
|
||||
if (result.aborted) {
|
||||
@@ -322,6 +342,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
} 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.
|
||||
@@ -356,7 +383,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
if (this.config.getDebugMode()) {
|
||||
returnDisplayMessage = llmContent;
|
||||
} else {
|
||||
if (result.output.trim()) {
|
||||
if (this.params.is_background || result.backgrounded) {
|
||||
returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
} else if (result.output.trim()) {
|
||||
returnDisplayMessage = result.output;
|
||||
} else {
|
||||
if (result.aborted) {
|
||||
@@ -406,6 +435,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
return {
|
||||
llmContent,
|
||||
returnDisplay: returnDisplayMessage,
|
||||
data,
|
||||
...executionError,
|
||||
};
|
||||
} finally {
|
||||
@@ -421,7 +451,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
|
||||
function getShellToolDescription(): string {
|
||||
function getShellToolDescription(enableInteractiveShell: boolean): string {
|
||||
const returnedInfo = `
|
||||
|
||||
The following information is returned:
|
||||
@@ -434,9 +464,15 @@ function getShellToolDescription(): string {
|
||||
Process Group PGID: Only included if available.`;
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`;
|
||||
const backgroundInstructions = enableInteractiveShell
|
||||
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.'
|
||||
: 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.';
|
||||
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. ${backgroundInstructions}${returnedInfo}`;
|
||||
} else {
|
||||
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. 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\`.${returnedInfo}`;
|
||||
const backgroundInstructions = enableInteractiveShell
|
||||
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.'
|
||||
: 'Command can start background processes using `&`.';
|
||||
return `This tool executes a given shell command as \`bash -c <command>\`. ${backgroundInstructions} 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\`.${returnedInfo}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +500,7 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
super(
|
||||
ShellTool.Name,
|
||||
'Shell',
|
||||
getShellToolDescription(),
|
||||
getShellToolDescription(config.getEnableInteractiveShell()),
|
||||
Kind.Execute,
|
||||
{
|
||||
type: 'object',
|
||||
@@ -483,6 +519,11 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
description:
|
||||
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
|
||||
},
|
||||
is_background: {
|
||||
type: 'boolean',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
|
||||
@@ -550,6 +550,11 @@ export interface ToolResult {
|
||||
message: string; // raw error message
|
||||
type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND').
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional data payload for passing structured information back to the caller.
|
||||
*/
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
134
packages/core/src/utils/process-utils.test.ts
Normal file
134
packages/core/src/utils/process-utils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:child_process');
|
||||
|
||||
describe('process-utils', () => {
|
||||
const mockProcessKill = vi
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation(() => true);
|
||||
const mockSpawn = vi.mocked(cpSpawn);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('killProcessGroup', () => {
|
||||
it('should use taskkill on Windows', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('taskkill', [
|
||||
'/pid',
|
||||
'1234',
|
||||
'/f',
|
||||
'/t',
|
||||
]);
|
||||
expect(mockProcessKill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use pty.kill() on Windows if pty is provided', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should kill the process group on Unix with SIGKILL by default', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use escalation on Unix if requested', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const exited = false;
|
||||
const isExited = () => exited;
|
||||
|
||||
const killPromise = killProcessGroup({
|
||||
pid: 1234,
|
||||
escalate: true,
|
||||
isExited,
|
||||
});
|
||||
|
||||
// First call should be SIGTERM
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Advance time
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
// Second call should be SIGKILL
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should skip SIGKILL if isExited returns true after SIGTERM', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
let exited = false;
|
||||
const isExited = vi.fn().mockImplementation(() => exited);
|
||||
|
||||
const killPromise = killProcessGroup({
|
||||
pid: 1234,
|
||||
escalate: true,
|
||||
isExited,
|
||||
});
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Simulate process exiting
|
||||
exited = true;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should fallback to specific process kill if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
// Failed group kill
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
// Fallback individual kill
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use pty fallback on Unix if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
});
|
||||
});
|
||||
98
packages/core/src/utils/process-utils.ts
Normal file
98
packages/core/src/utils/process-utils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
|
||||
/** Default timeout for SIGKILL escalation on Unix systems. */
|
||||
export const SIGKILL_TIMEOUT_MS = 200;
|
||||
|
||||
/** Configuration for process termination. */
|
||||
export interface KillOptions {
|
||||
/** The process ID to terminate. */
|
||||
pid: number;
|
||||
/** Whether to attempt SIGTERM before SIGKILL on Unix systems. */
|
||||
escalate?: boolean;
|
||||
/** Initial signal to use (defaults to SIGTERM if escalate is true, else SIGKILL). */
|
||||
signal?: NodeJS.Signals | number;
|
||||
/** Callback to check if the process has already exited. */
|
||||
isExited?: () => boolean;
|
||||
/** Optional PTY object for PTY-specific kill methods. */
|
||||
pty?: { kill: (signal?: string) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Robustly terminates a process or process group across platforms.
|
||||
*
|
||||
* On Windows, it uses `taskkill /f /t` to ensure the entire tree is terminated,
|
||||
* or the PTY's built-in kill method.
|
||||
*
|
||||
* On Unix, it attempts to kill the process group (using -pid) with escalation
|
||||
* from SIGTERM to SIGKILL if requested.
|
||||
*/
|
||||
export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
const { pid, escalate = false, isExited = () => false, pty } = options;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
if (pty) {
|
||||
try {
|
||||
pty.kill();
|
||||
} catch {
|
||||
// Ignore errors for dead processes
|
||||
}
|
||||
} else {
|
||||
cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix logic
|
||||
try {
|
||||
const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL');
|
||||
|
||||
// Try killing the process group first (-pid)
|
||||
process.kill(-pid, initialSignal);
|
||||
|
||||
if (escalate && !isExited()) {
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to specific process kill if group kill fails or on error
|
||||
if (!isExited()) {
|
||||
if (pty) {
|
||||
if (escalate) {
|
||||
try {
|
||||
pty.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ export const enum ColorMode {
|
||||
}
|
||||
|
||||
class Cell {
|
||||
private readonly cell: IBufferCell | null;
|
||||
private readonly x: number;
|
||||
private readonly y: number;
|
||||
private readonly cursorX: number;
|
||||
private readonly cursorY: number;
|
||||
private readonly attributes: number = 0;
|
||||
private cell: IBufferCell | null = null;
|
||||
private x = 0;
|
||||
private y = 0;
|
||||
private cursorX = 0;
|
||||
private cursorY = 0;
|
||||
private attributes: number = 0;
|
||||
fg = 0;
|
||||
bg = 0;
|
||||
fgColorMode: ColorMode = ColorMode.DEFAULT;
|
||||
@@ -51,12 +51,23 @@ class Cell {
|
||||
y: number,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
) {
|
||||
this.update(cell, x, y, cursorX, cursorY);
|
||||
}
|
||||
|
||||
update(
|
||||
cell: IBufferCell | null,
|
||||
x: number,
|
||||
y: number,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
) {
|
||||
this.cell = cell;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.cursorX = cursorX;
|
||||
this.cursorY = cursorY;
|
||||
this.attributes = 0;
|
||||
|
||||
if (!cell) {
|
||||
return;
|
||||
@@ -131,7 +142,11 @@ class Cell {
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
export function serializeTerminalToObject(
|
||||
terminal: Terminal,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): AnsiOutput {
|
||||
const buffer = terminal.buffer.active;
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY;
|
||||
@@ -140,22 +155,30 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
|
||||
const result: AnsiOutput = [];
|
||||
|
||||
for (let y = 0; y < terminal.rows; y++) {
|
||||
const line = buffer.getLine(buffer.viewportY + y);
|
||||
// Reuse cell instances
|
||||
const lastCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
const currentCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
|
||||
const effectiveStart = startLine ?? buffer.viewportY;
|
||||
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
|
||||
|
||||
for (let y = effectiveStart; y < effectiveEnd; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
const currentLine: AnsiLine = [];
|
||||
if (!line) {
|
||||
result.push(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
// Reset lastCell for new line
|
||||
lastCell.update(null, -1, -1, cursorX, cursorY);
|
||||
let currentText = '';
|
||||
|
||||
for (let x = 0; x < terminal.cols; x++) {
|
||||
const cellData = line.getCell(x);
|
||||
const cell = new Cell(cellData || null, x, y, cursorX, cursorY);
|
||||
currentCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
|
||||
if (x > 0 && !cell.equals(lastCell)) {
|
||||
if (x > 0 && !currentCell.equals(lastCell)) {
|
||||
if (currentText) {
|
||||
const token: AnsiToken = {
|
||||
text: currentText,
|
||||
@@ -172,8 +195,10 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
}
|
||||
currentText = '';
|
||||
}
|
||||
currentText += cell.getChars();
|
||||
lastCell = cell;
|
||||
currentText += currentCell.getChars();
|
||||
// Copy state from currentCell to lastCell. Since we can't easily deep copy
|
||||
// without allocating, we just update lastCell with the same data.
|
||||
lastCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
|
||||
Reference in New Issue
Block a user