src/vs/platform/agentHost/node/shared/shellCommandExecution.ts

372 LOC · 340 covered · 32 uncovered · 55 ranges · 2057 concepts · 14 introducers · 959 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- shellCommandExecution.ts ×12
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
7 > import * as platform from '../../../../base/common/platform.js';
8 > import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
9 > import { generateUuid } from '../../../../base/common/uuid.js';
10 > import { ILogService } from '../../../log/common/log.js';
11 > import { TerminalClaimKind } from '../../common/state/protocol/state.js';
12 > import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
13 >
14 > /**
15 > * Maximum scrollback content (in bytes) returned to the model / caller in
16 > * command results.
17 > */
18 > export const SHELL_COMMAND_MAX_OUTPUT_BYTES = 80_000;
19 >
20 > /**
21 > * Default command timeout in milliseconds (120 seconds).
22 > */
23 > export const DEFAULT_SHELL_COMMAND_TIMEOUT_MS = 120_000;
24 >
25 > /**
26 > * The sentinel prefix used to detect command completion in terminal output
27 > * when shell integration is unavailable. The full sentinel format is:
28 > * `<<<COPILOT_SENTINEL_<uuid>_EXIT_<code>>>`.
29 > */
30 > const SENTINEL_PREFIX = '<<<COPILOT_SENTINEL_';
31 >
32 > /**
33 > * The kind of shell a command runs in. Determines sentinel syntax, history
34 > * suppression and bracketed-paste heuristics.
35 > */
36 > export type ShellType = 'bash' | 'powershell';
37 >
38 > /**
39 > * Routes a resolved shell executable to a {@link ShellType}. Falls back to the
40 > * platform default for unknown shells.
41 > */
42 > export function shellTypeForExecutable(shellPath: string): ShellType {
43 > // Strip path on either separator and the .exe suffix. shellCommandExecution.ts ×3
44 > const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
45 > const base = shellPath.slice(lastSep + 1).toLowerCase().replace(/\.exe$/, '');
46 > switch (base) {
47 > // PowerShell
48 > case 'pwsh':
49 > case 'powershell':
50 > case 'pwsh-preview':
51 > return 'powershell'; shellCommandExecution.ts ×2
52 > // POSIX shells shellCommandExecution.ts ×3
53 > case 'bash':
54 > case 'sh':
55 > case 'zsh':
56 > case 'fish':
57 > case 'csh':
58 > case 'ksh':
59 > case 'nu':
60 > case 'xonsh':
61 > // Git for Windows bash entry points
62 > case 'git-cmd':
63 > // WSL launchers — bash inside, but invoked via these stubs
64 > case 'wsl':
65 > case 'ubuntu':
66 > case 'ubuntu1804':
67 > case 'kali':
68 > case 'debian':
69 > case 'opensuse-42':
70 > case 'sles-12':
71 > return 'bash';
72 > default:
73 > return platform.isWindows ? 'powershell' : 'bash'; shellCommandExecution.ts ×2
75 > }
77 > /**
78 > * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` /
79 > * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from
80 > * being recorded in shell history. The shell integration scripts opt these
81 > * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the
82 > * terminal is created with `preventShellHistory: true`). PowerShell
83 > * suppresses history through PSReadLine instead, so no prefix is needed.
84 > */
85 > export function prefixForHistorySuppression(shellType: ShellType): string {
86 > return shellType === 'powershell' ? '' : ' '; shellCommandExecution.ts ×1
87 > }
89 > export function isMultilineCommand(command: string): boolean {
90 > const normalized = command.replace(/\r\n|\r/g, '\n'); shellCommandExecution.ts ×1
91 > return /(?<!\\)\n/.test(normalized);
92 > }
94 > function shouldUseBracketedPasteMode(command: string): boolean { shellCommandExecution.ts ×5
95 > return platform.isMacintosh || isMultilineCommand(command);
96 > }
98 > function makeSentinelId(): string { shellCommandExecution.ts ×14
99 > return generateUuid().replace(/-/g, '');
100 > }
102 > function buildSentinelCommand(sentinelId: string, shellType: ShellType): string { shellCommandExecution.ts ×14
103 > if (shellType === 'powershell') {
104 return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`;
105 }
106 > return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; shellCommandExecution.ts ×14
107 > }
109 > function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { shellCommandExecution.ts ×14
110 > const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`;
111 > let markerIndex = content.lastIndexOf(marker);
112 > while (markerIndex !== -1) {
113 > const outputBeforeSentinel = content.substring(0, markerIndex); shellCommandExecution.ts ×3
114 > const afterMarker = content.substring(markerIndex + marker.length);
115 > const endIdx = afterMarker.indexOf('>>>');
116 > if (endIdx !== -1) {
117 > const exitCodeStr = afterMarker.substring(0, endIdx).trim();
118 > if (/^-?\d+$/.test(exitCodeStr)) {
119 > return {
120 > found: true,
121 > exitCode: parseInt(exitCodeStr, 10),
122 > outputBeforeSentinel,
123 > };
124 > }
125 > }
126 // Ignore echoed sentinel command text (for example `$?`) and continue
127 // scanning for the latest complete numeric sentinel marker.
128 markerIndex = content.lastIndexOf(marker, markerIndex - 1);
129 }
131 > return { found: false, exitCode: -1, outputBeforeSentinel: content };
132 > }
134 > /**
135 > * Strips ANSI escape codes and trims the terminal output to the last
136 > * {@link SHELL_COMMAND_MAX_OUTPUT_BYTES} bytes so it is safe to surface to a
137 > * model or the transcript.
138 > */
139 > export function prepareOutputForModel(rawOutput: string): string {
140 > let text = removeAnsiEscapeCodes(rawOutput).trim(); shellCommandExecution.ts ×2
141 > if (text.length > SHELL_COMMAND_MAX_OUTPUT_BYTES) {
142 text = text.substring(text.length - SHELL_COMMAND_MAX_OUTPUT_BYTES);
143 }
144 > return text; shellCommandExecution.ts ×2
145 > }
147 > /**
148 > * Terminal against which a shell command is executed.
149 > */
150 > export interface IShellCommandTarget {
151 > /** URI of the managed terminal the command runs in. */
152 > readonly terminalUri: string;
153 > /** The kind of shell backing the terminal. */
154 > readonly shellType: ShellType;
155 > }
156 >
157 > /**
158 > * How a shell command execution finished.
159 > *
160 > * - `completed` — the command finished; {@link IShellCommandResult.exitCode} holds the exit code.
161 > * - `timeout` — the command did not finish within the timeout; output is partial.
162 > * - `background` — the terminal claim was narrowed (user chose to continue in background).
163 > * - `altBuffer` — the command switched to the terminal's alternate buffer (interactive UI).
164 > * - `shellExited` — the shell process exited unexpectedly.
165 > */
166 > export type ShellCommandStatus = 'completed' | 'timeout' | 'background' | 'altBuffer' | 'shellExited';
167 >
168 > /**
169 > * Neutral, agent-agnostic result of executing a shell command. Callers map this
170 > * to their own result shape (e.g. an SDK `ToolResultObject` or an AHP tool call
171 > * completion).
172 > */
173 > export interface IShellCommandResult {
174 > /** How the command execution finished. */
175 > readonly status: ShellCommandStatus;
176 > /** Exit code, when known (`completed` and `shellExited`). */
177 > readonly exitCode?: number;
178 > /** Cleaned command output (empty for `background`/`altBuffer`). */
179 > readonly output: string;
180 > }
181 >
182 > /**
183 > * Execute a command on an already-created managed terminal, resolving once the
184 > * command finishes, times out, backgrounds, enters the alternate buffer, or the
185 > * shell exits. Uses shell integration (OSC 633) for completion detection when
186 > * available and falls back to a sentinel echo otherwise.
187 > *
188 > * This is the shared shell-integration primitive used by both the Copilot SDK
189 > * shell tools and the agent-host `!command` runner.
190 > */
191 > export function executeShellCommand(
192 > target: IShellCommandTarget, shellCommandExecution.ts ×5
193 > command: string,
194 > timeoutMs: number,
195 > terminalManager: IAgentHostTerminalManager,
196 > logService: ILogService,
197 > ): Promise<IShellCommandResult> {
198 > return terminalManager.supportsCommandDetection(target.terminalUri)
199 > ? executeCommandWithShellIntegration(target, command, timeoutMs, terminalManager, logService) shellCommandExecution.ts ×8
200 > : executeCommandWithSentinel(target, command, timeoutMs, terminalManager, logService); shellCommandExecution.ts ×14
203 > function registerAltBufferHandler( shellCommandExecution.ts ×5
204 > target: IShellCommandTarget,
205 > terminalManager: IAgentHostTerminalManager,
206 > logService: ILogService,
207 > disposables: DisposableStore,
208 > finish: (result: IShellCommandResult) => void,
209 > ): void {
210 > void terminalManager.createAltBufferPromise(target.terminalUri, disposables).then(() => {
211 > logService.info('[ShellCommand] Command entered alternate buffer'); copilotShellTools.ts ×1
212 > finish({ status: 'altBuffer', output: '' });
214 > }
216 > /**
217 > * Execute a command using shell integration (OSC 633) for completion detection.
218 > * No sentinel echo is injected — the shell's own command-finished signal
219 > * provides the exit code and cleanly delineated output.
220 > */
221 > async function executeCommandWithShellIntegration( shellCommandExecution.ts ×8
222 > target: IShellCommandTarget,
223 > command: string,
224 > timeoutMs: number,
225 > terminalManager: IAgentHostTerminalManager,
226 > logService: ILogService,
227 > ): Promise<IShellCommandResult> {
228 > const disposables = new DisposableStore();
229 >
230 > const result = new Promise<IShellCommandResult>(resolve => {
231 > let resolved = false;
232 > const finish = (result: IShellCommandResult) => {
233 > if (resolved) {
234 return;
235 }
236 > resolved = true; shellCommandExecution.ts ×8
237 > disposables.dispose();
238 > resolve(result);
239 > };
240 >
241 > disposables.add(terminalManager.onCommandFinished(target.terminalUri, event => {
242 > const output = prepareOutputForModel(event.output); shellCommandExecution.ts ×1
243 > const exitCode = event.exitCode ?? 0;
244 > logService.info(`[ShellCommand] Command completed (shell integration) with exit code ${exitCode}`);
245 > finish({ status: 'completed', exitCode, output });
247 >
248 > registerAltBufferHandler(target, terminalManager, logService, disposables, finish);
249 >
250 > disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => {
251 logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`);
252 const fullContent = terminalManager.getContent(target.terminalUri) ?? '';
253 finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(fullContent) });
255 >
256 > disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => {
257 > if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { copilotShellTools.ts ×1
258 > logService.info(`[ShellCommand] Continuing in background (claim narrowed)`);
259 > finish({ status: 'background', output: '' });
260 > }
262 >
263 > const timer = setTimeout(() => {
264 logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`);
265 const fullContent = terminalManager.getContent(target.terminalUri) ?? '';
266 finish({ status: 'timeout', output: prepareOutputForModel(fullContent) });
267 > }, timeoutMs); shellCommandExecution.ts ×8
268 > disposables.add(toDisposable(() => clearTimeout(timer)));
269 >
270 > });
271 >
272 > try {
273 > await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, {
274 > shouldExecute: true,
275 > bracketedPasteMode: shouldUseBracketedPasteMode(command),
276 > });
277 > } catch (err) {
278 disposables.dispose();
279 throw err;
280 }
282 > return result;
283 > }
285 > /**
286 > * Fallback: execute a command using a sentinel echo to detect completion.
287 > * Used when shell integration is not available.
288 > */
289 > async function executeCommandWithSentinel( shellCommandExecution.ts ×14
290 > target: IShellCommandTarget,
291 > command: string,
292 > timeoutMs: number,
293 > terminalManager: IAgentHostTerminalManager,
294 > logService: ILogService,
295 > ): Promise<IShellCommandResult> {
296 > const sentinelId = makeSentinelId();
297 > const sentinelCmd = buildSentinelCommand(sentinelId, target.shellType);
298 > const disposables = new DisposableStore();
299 >
300 > const contentBefore = terminalManager.getContent(target.terminalUri) ?? '';
301 > const offsetBefore = contentBefore.length;
302 >
303 > const result = new Promise<IShellCommandResult>(resolve => {
304 > let resolved = false;
305 > const finish = (result: IShellCommandResult) => {
306 > if (resolved) {
307 return;
308 }
309 > resolved = true; shellCommandExecution.ts ×14
310 > disposables.dispose();
311 > resolve(result);
312 > };
313 >
314 > const checkForSentinel = () => {
315 > const fullContent = terminalManager.getContent(target.terminalUri) ?? '';
316 > // Clamp offset: the terminal manager trims content when it exceeds
317 > // 100k chars (slices to last 80k). If trimming happened after we
318 > // captured offsetBefore, scan from the start of the current buffer.
319 > const clampedOffset = Math.min(offsetBefore, fullContent.length);
320 > const newContent = fullContent.substring(clampedOffset);
321 > const parsed = parseSentinel(newContent, sentinelId);
322 > if (parsed.found) {
323 > const output = prepareOutputForModel(parsed.outputBeforeSentinel); shellCommandExecution.ts ×3
324 > logService.info(`[ShellCommand] Command completed with exit code ${parsed.exitCode}`);
325 > finish({ status: 'completed', exitCode: parsed.exitCode, output });
326 > }
328 >
329 > disposables.add(terminalManager.onData(target.terminalUri, () => {
330 > checkForSentinel(); shellCommandExecution.ts ×3
332 >
333 > registerAltBufferHandler(target, terminalManager, logService, disposables, finish);
334 >
335 > disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => {
336 logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`);
337 const fullContent = terminalManager.getContent(target.terminalUri) ?? '';
338 const newContent = fullContent.substring(offsetBefore);
339 finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(newContent) });
341 >
342 > disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => {
343 if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
344 logService.info(`[ShellCommand] Continuing in background (claim narrowed)`);
345 finish({ status: 'background', output: '' });
346 }
348 >
349 > const timer = setTimeout(() => {
350 > logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`); copilotShellTools.ts ×1
351 > const fullContent = terminalManager.getContent(target.terminalUri) ?? '';
352 > const newContent = fullContent.substring(offsetBefore);
353 > finish({ status: 'timeout', output: prepareOutputForModel(newContent) });
354 > }, timeoutMs); shellCommandExecution.ts ×14
355 > disposables.add(toDisposable(() => clearTimeout(timer)));
356 >
357 > checkForSentinel();
358 > });
359 >
360 > try {
361 > await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, {
362 > shouldExecute: true,
363 > bracketedPasteMode: shouldUseBracketedPasteMode(command),
364 > });
365 > await terminalManager.sendText(target.terminalUri, sentinelCmd, { shouldExecute: true });
366 > } catch (err) {
367 disposables.dispose();
368 throw err;
369 }
371 > return result;
372 > }