File size: 11,569 Bytes
40e575e |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 |
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'child_process';
import { StringDecoder } from 'string_decoder';
import type { HistoryItemWithoutId } from '../types.js';
import { useCallback } from 'react';
import { Config, GeminiClient } from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import { formatMemoryUsage } from '../utils/formatters.js';
import { isBinary } from '../utils/textUtils.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import crypto from 'crypto';
import path from 'path';
import os from 'os';
import fs from 'fs';
import stripAnsi from 'strip-ansi';
const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const MAX_OUTPUT_LENGTH = 10000;
/**
* A structured result from a shell command execution.
*/
interface ShellExecutionResult {
rawOutput: Buffer;
output: string;
exitCode: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
aborted: boolean;
}
/**
* Executes a shell command using `spawn`, capturing all output and lifecycle events.
* This is the single, unified implementation for shell execution.
*
* @param commandToExecute The exact command string to run.
* @param cwd The working directory to execute the command in.
* @param abortSignal An AbortSignal to terminate the process.
* @param onOutputChunk A callback for streaming real-time output.
* @param onDebugMessage A callback for logging debug information.
* @returns A promise that resolves with the complete execution result.
*/
function executeShellCommand(
commandToExecute: string,
cwd: string,
abortSignal: AbortSignal,
onOutputChunk: (chunk: string) => void,
onDebugMessage: (message: string) => void,
): Promise<ShellExecutionResult> {
return new Promise((resolve) => {
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const shellArgs = isWindows
? ['/c', commandToExecute]
: ['-c', commandToExecute];
const child = spawn(shell, shellArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: !isWindows, // Use process groups on non-Windows for robust killing
});
// Use decoders to handle multi-byte characters safely (for streaming output).
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
let stdout = '';
let stderr = '';
const outputChunks: Buffer[] = [];
let error: Error | null = null;
let exited = false;
let streamToUi = true;
const MAX_SNIFF_SIZE = 4096;
let sniffedBytes = 0;
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
outputChunks.push(data);
if (streamToUi && sniffedBytes < MAX_SNIFF_SIZE) {
// Use a limited-size buffer for the check to avoid performance issues.
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
streamToUi = false;
// Overwrite any garbled text that may have streamed with a clear message.
onOutputChunk('[Binary output detected. Halting stream...]');
}
}
const decodedChunk =
stream === 'stdout'
? stdoutDecoder.write(data)
: stderrDecoder.write(data);
if (stream === 'stdout') {
stdout += stripAnsi(decodedChunk);
} else {
stderr += stripAnsi(decodedChunk);
}
if (!exited && streamToUi) {
// Send only the new chunk to avoid re-rendering the whole output.
const combinedOutput = stdout + (stderr ? `\n${stderr}` : '');
onOutputChunk(combinedOutput);
} else if (!exited && !streamToUi) {
// Send progress updates for the binary stream
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
onOutputChunk(
`[Receiving binary output... ${formatMemoryUsage(totalBytes)} received]`,
);
}
};
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
child.on('error', (err) => {
error = err;
});
const abortHandler = async () => {
if (child.pid && !exited) {
onDebugMessage(`Aborting shell command (PID: ${child.pid})`);
if (isWindows) {
spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
} else {
try {
// Kill the entire process group (negative PID).
// SIGTERM first, then SIGKILL if it doesn't die.
process.kill(-child.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, 200));
if (!exited) {
process.kill(-child.pid, 'SIGKILL');
}
} catch (_e) {
// Fallback to killing just the main process if group kill fails.
if (!exited) child.kill('SIGKILL');
}
}
}
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
child.on('exit', (code, signal) => {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
// Handle any final bytes lingering in the decoders
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
const finalBuffer = Buffer.concat(outputChunks);
resolve({
rawOutput: finalBuffer,
output: stdout + (stderr ? `\n${stderr}` : ''),
exitCode: code,
signal,
error,
aborted: abortSignal.aborted,
});
});
});
}
function addShellCommandToGeminiHistory(
geminiClient: GeminiClient,
rawQuery: string,
resultText: string,
) {
const modelContent =
resultText.length > MAX_OUTPUT_LENGTH
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
: resultText;
geminiClient.addHistory({
role: 'user',
parts: [
{
text: `I ran the following shell command:
\`\`\`sh
${rawQuery}
\`\`\`
This produced the following result:
\`\`\`
${modelContent}
\`\`\``,
},
],
});
}
/**
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
export const useShellCommandProcessor = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
>,
onExec: (command: Promise<void>) => void,
onDebugMessage: (message: string) => void,
config: Config,
geminiClient: GeminiClient,
) => {
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
return false;
}
const userMessageTimestamp = Date.now();
addItemToHistory(
{ type: 'user_shell', text: rawQuery },
userMessageTimestamp,
);
const isWindows = os.platform() === 'win32';
const targetDir = config.getTargetDir();
let commandToExecute = rawQuery;
let pwdFilePath: string | undefined;
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
// Ensure command ends with a separator before adding our own.
if (!command.endsWith(';') && !command.endsWith('&')) {
command += ';';
}
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const execPromise = new Promise<void>((resolve) => {
let lastUpdateTime = 0;
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
executeShellCommand(
commandToExecute,
targetDir,
abortSignal,
(streamedOutput) => {
// Throttle pending UI updates to avoid excessive re-renders.
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
setPendingHistoryItem({ type: 'info', text: streamedOutput });
lastUpdateTime = Date.now();
}
},
onDebugMessage,
)
.then((result) => {
// TODO(abhipatel12) - Consider updating pending item and using timeout to ensure
// there is no jump where intermediate output is skipped.
setPendingHistoryItem(null);
let historyItemType: HistoryItemWithoutId['type'] = 'info';
let mainContent: string;
// The context sent to the model utilizes a text tokenizer which means raw binary data is
// cannot be parsed and understood and thus would only pollute the context window and waste
// tokens.
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let finalOutput = mainContent;
if (result.error) {
historyItemType = 'error';
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.signal) {
historyItemType = 'error';
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
historyItemType = 'error';
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
// Add the complete, contextual result to the local UI history.
addItemToHistory(
{ type: historyItemType, text: finalOutput },
userMessageTimestamp,
);
// Add the same complete, contextual result to the LLM's history.
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
})
.catch((err) => {
setPendingHistoryItem(null);
const errorMessage =
err instanceof Error ? err.message : String(err);
addItemToHistory(
{
type: 'error',
text: `An unexpected error occurred: ${errorMessage}`,
},
userMessageTimestamp,
);
})
.finally(() => {
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
resolve();
});
});
onExec(execPromise);
return true; // Command was initiated
},
[
config,
onDebugMessage,
addItemToHistory,
setPendingHistoryItem,
onExec,
geminiClient,
],
);
return { handleShellCommand };
};
|