src/vs/platform/agentHost/node/agentHostTerminalManager.ts
961 LOC · 780 covered · 181 uncovered · 140 ranges · 2232 concepts · 28 introducers · 1038 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.
/*---------------------------------------------------------------------------------------------
agentHostTerminalManager.ts ×41
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { dirname, parse as pathParse } from '../../../base/common/path.js';
import * as platform from '../../../base/common/platform.js';
import { getSystemShell } from '../../../base/node/shell.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { AiAgentEnvValue, AiAgentEnvVar } from '../../chat/common/aiAgentEnv.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
import { IProductService } from '../../product/common/productService.js';
import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js';
import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../common/agentHostCustomizationConfig.js';
import { ActionType } from '../common/state/protocol/actions.js';
import type { CreateTerminalParams } from '../common/state/protocol/commands.js';
import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js';
import { isTerminalAction } from '../common/state/sessionActions.js';
import { ROOT_STATE_URI } from '../common/state/sessionState.js';
import { IAgentConfigurationService } from './agentConfigurationService.js';
import { AgentHostHeadlessTerminal } from './agentHostHeadlessTerminal.js';
import { isZsh } from './agentHostShellUtils.js';
import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
import { Osc633Event, Osc633EventType, Osc633ParseSegment, Osc633Parser } from './osc633Parser.js';
const WAIT_FOR_PROMPT_TIMEOUT = 10_000;
const HEADLESS_TERMINAL_SCROLLBACK = 0;
const DSR_CURSOR_POSITION_QUERY = '\x1b[6n';
const DEC_DSR_CURSOR_POSITION_QUERY = '\x1b[?6n';
const SERVER_HANDLED_QUERY_PREFIXES = ['\x1b[?6', '\x1b[?', '\x1b[6', '\x1b[', '\x1b'];
export const IAgentHostTerminalManager = createDecorator<IAgentHostTerminalManager>('agentHostTerminalManager');
export interface ICommandFinishedEvent {
commandId: string;
exitCode: number | undefined;
command: string;
output: string;
}
export interface ITerminalQueryFilterState {
pendingData: string;
}
export interface ISendTextOptions {
shouldExecute: boolean;
/**
* Match workbench terminal sendText: wrap in bracketed paste markers only
* when requested by the caller and enabled by the terminal.
*/
bracketedPasteMode?: boolean;
}
export interface IFormatTerminalTextOptions {
shouldExecute: boolean;
forceBracketedPasteMode?: boolean;
}
export function removeServerHandledTerminalQueries(data: string, state: ITerminalQueryFilterState): string {
!state.pendingData
&& !data.includes(DSR_CURSOR_POSITION_QUERY)
&& !getServerHandledTerminalQueryPrefix(data)
}
const combinedData = state.pendingData + data;
const pendingData = getServerHandledTerminalQueryPrefix(combinedData);
const dataToFilter = pendingData ? combinedData.substring(0, combinedData.length - pendingData.length) : combinedData;
agentHostTerminalManager.ts ×5
state.pendingData = pendingData;
if (!dataToFilter.includes(DSR_CURSOR_POSITION_QUERY) && !dataToFilter.includes(DEC_DSR_CURSOR_POSITION_QUERY)) {
}
.replaceAll(DEC_DSR_CURSOR_POSITION_QUERY, '')
.replaceAll(DSR_CURSOR_POSITION_QUERY, '');
}
function getServerHandledTerminalQueryPrefix(data: string): string {
agentHostTerminalManager.ts ×5
for (const prefix of SERVER_HANDLED_QUERY_PREFIXES) {
if (data.endsWith(prefix)) {
}
return '';
}
export function formatTerminalText(data: string, options: IFormatTerminalTextOptions): string {
}
if (options.shouldExecute && !data.endsWith('\r')) {
}
}
/**
* Service interface for terminal management in the agent host.
*/
export interface IAgentHostTerminalManager {
readonly _serviceBrand: undefined;
createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void>;
writeInput(uri: string, data: string): void;
sendText(uri: string, data: string, options: ISendTextOptions): Promise<void>;
onData(uri: string, cb: (data: string) => void): IDisposable;
onExit(uri: string, cb: (exitCode: number) => void): IDisposable;
onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable;
onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable;
createAltBufferPromise(uri: string, store: DisposableStore): Promise<void>;
getContent(uri: string): string | undefined;
getClaim(uri: string): TerminalClaim | undefined;
hasTerminal(uri: string): boolean;
getExitCode(uri: string): number | undefined;
supportsCommandDetection(uri: string): boolean;
disposeTerminal(uri: string): void;
getTerminalInfos(): TerminalInfo[];
getTerminalState(uri: string): TerminalState | undefined;
getDefaultShell(): Promise<string>;
createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void;
appendOutputTerminalData(uri: string, data: string): void;
resetOutputTerminal(uri: string): void;
finalizeOutputTerminal(uri: string, exitCode: number | undefined): void;
}
// node-pty is loaded dynamically to avoid bundling issues in non-node environments
let nodePtyModule: typeof import('node-pty') | undefined;
async function getNodePty(): Promise<typeof import('node-pty')> {
if (!nodePtyModule) {
nodePtyModule = await import('node-pty');
}
return nodePtyModule;
}
/** Per-terminal command detection tracking state. */
interface ICommandTracker {
readonly parser: Osc633Parser;
readonly nonce: string;
commandCounter: number;
detectionAvailableEmitted: boolean;
pendingCommandLine?: string;
activeCommandId?: string;
activeCommandTimestamp?: number;
}
/** Represents a single managed terminal with its PTY process. */
interface IManagedTerminal {
readonly uri: string;
readonly store: DisposableStore;
readonly pty: import('node-pty').IPty;
readonly onDataEmitter: Emitter<string>;
readonly onExitEmitter: Emitter<number>;
readonly onClaimChangedEmitter: Emitter<TerminalClaim>;
readonly onCommandFinishedEmitter: Emitter<ICommandFinishedEvent>;
title: string;
cwd: string;
cols: number;
rows: number;
content: TerminalContentPart[];
contentSize: number;
claim: TerminalClaim;
exitCode?: number;
commandTracker?: ICommandTracker;
headlessTerminal?: AgentHostHeadlessTerminal;
terminalQueryFilterState: ITerminalQueryFilterState;
}
/**
* A lightweight output-only terminal channel: no PTY behind it, plain-text
* content appended by its owner (e.g. runtime-executed shell tools). Served
* to subscribers with `isPty: false` so clients skip VT parsing.
*/
interface IOutputTerminal {
title: string;
content: TerminalContentPart[];
contentSize: number;
claim: TerminalClaim;
exitCode?: number;
}
/**
* Manages terminal processes for the agent host. Each terminal is backed by
* a node-pty instance and identified by a protocol URI.
*
* Listens to the {@link AgentHostStateManager} for client-dispatched terminal
* actions (input, resize, claim changes) and dispatches server-originated
* PTY output back through the state manager.
*/
export class AgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager {
declare readonly _serviceBrand: undefined;
private readonly _terminals = new Map<string, IManagedTerminal>();
private readonly _outputTerminals = new Map<string, IOutputTerminal>();
constructor(
@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
agentHostTerminalManager.ts ×4
@ILogService private readonly _logService: ILogService,
@IProductService private readonly _productService: IProductService,
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
) {
super();
// React to client-dispatched terminal actions flowing through the state manager
this._register(this._stateManager.onDidEmitEnvelope(envelope => {
if (!isTerminalAction(action)) {
}
switch (action.type) {
case ActionType.TerminalInput:
this._writeInput(channel, action.data);
break;
this._resize(channel, action.cols, action.rows);
break;
this._setClaim(channel, action.claim);
break;
this._setTitle(channel, action.title);
break;
break;
}
/** Get metadata for all active terminals (for root state). */
getTerminalInfos(): TerminalInfo[] {
title: t.title,
claim: t.claim,
exitCode: t.exitCode,
}
/** Get the full state for a terminal (for subscribe snapshots). */
getTerminalState(uri: string): TerminalState | undefined {
if (outputTerminal) {
title: outputTerminal.title,
content: outputTerminal.content,
exitCode: outputTerminal.exitCode,
claim: outputTerminal.claim,
isPty: false,
};
}
if (!terminal) {
return undefined;
}
return {
title: terminal.title,
cwd: terminal.cwd,
cols: terminal.cols,
rows: terminal.rows,
content: terminal.content,
exitCode: terminal.exitCode,
claim: terminal.claim,
supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted,
};
}
/**
* Create a new terminal backed by node-pty.
* Spawns the user's default shell.
*/
async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void> {
if (this._terminals.has(uri)) {
throw new Error(`Terminal already exists: ${uri}`);
}
const cwd = await this._resolveCwd(params.cwd, uri);
const cols = params.cols ?? 80;
const rows = params.rows ?? 24;
const shell = options?.shell ?? await this.getDefaultShell();
const name = platform.isWindows ? 'cmd' : 'xterm-256color';
this._logService.info(`[TerminalManager] Creating terminal ${uri}: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`);
// Shell integration — inject scripts so the shell emits OSC 633 sequences
const nonce = generateUuid();
const env: Record<string, string> = { ...process.env as Record<string, string> };
// Attribute these commands to VS Code. Already inherited from the agent
// host process; set here as defense in depth.
env[AiAgentEnvVar] = AiAgentEnvValue;
if (options?.preventShellHistory) {
// Picked up by the shell integration scripts to set HISTCONTROL=ignorespace
agentHostTerminalManager.ts ×5
// (bash) / HIST_IGNORE_SPACE (zsh), or suppress PSReadLine history (pwsh).
// Combined with the leading-space prefix applied at command-write time, this
// prevents agent-executed commands from polluting the user's shell history.
env['VSCODE_PREVENT_SHELL_HISTORY'] = '1';
}
// Zsh-specific fixups for agent tool terminals: disable bang history
agentHostTerminalManager.ts ×21
// expansion and enable inline # comments.
if (params.claim?.kind === TerminalClaimKind.Session && isZsh(shell)) {
}
// Suppress paging and interactive prompts so that tool-spawned
agentHostTerminalManager.ts ×5
// terminals produce clean, machine-friendly output. An empty
// string disables paging in git, less, and most CLI tools and
// is safe on all platforms (unlike 'cat' which isn't on Windows PATH).
env['LC_ALL'] = 'C.UTF-8';
env['PAGER'] = '';
env['GIT_PAGER'] = '';
env['GH_PAGER'] = '';
env['GIT_TERMINAL_PROMPT'] = '0';
env['DEBIAN_FRONTEND'] = 'noninteractive';
}
if (platform.isMacintosh) {
const shellName = pathParse(shell).name;
if (shellName.match(/(zsh|bash)/)) {
shellArgs = ['--login'];
}
}
const injection = await getShellIntegrationInjection(
{ executable: shell, args: shellArgs, forceShellIntegration: true },
{
shellIntegration: { enabled: true, suggestEnabled: false, nonce },
windowsUseConptyDll: false,
environmentVariableCollections: undefined,
workspaceFolder: undefined,
isScreenReaderOptimized: false,
},
undefined,
this._logService,
this._productService,
);
let commandTracker: ICommandTracker | undefined;
if (injection.type === 'injection') {
this._logService.info(`[TerminalManager] Shell integration injected for ${uri}`);
if (injection.envMixin) {
for (const [key, value] of Object.entries(injection.envMixin)) {
if (value !== undefined) {
env[key] = value;
}
}
}
if (injection.newArgs) {
shellArgs = injection.newArgs;
}
if (injection.filesToCopy) {
try {
await fs.promises.mkdir(dirname(f.dest), { recursive: true });
await fs.promises.copyFile(f.source, f.dest);
} catch {
// Swallow — another process may be using the same temp dir
}
}
parser: new Osc633Parser(),
nonce,
commandCounter: 0,
detectionAvailableEmitted: false,
};
} else {
this._logService.info(`[TerminalManager] Shell integration not available for ${uri}: ${injection.reason}`);
}
const ptyProcess = await this._spawnPty(shell, shellArgs, {
name,
cwd,
env,
cols,
rows,
});
const store = new DisposableStore();
const claim: TerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' };
const onDataEmitter = store.add(new Emitter<string>());
const onExitEmitter = store.add(new Emitter<number>());
const onClaimChangedEmitter = store.add(new Emitter<TerminalClaim>());
const onCommandFinishedEmitter = store.add(new Emitter<ICommandFinishedEvent>());
const headlessTerminal = store.add(new AgentHostHeadlessTerminal({
cols,
rows,
scrollback: HEADLESS_TERMINAL_SCROLLBACK,
logService: this._logService,
}));
const managed: IManagedTerminal = {
uri,
store,
pty: ptyProcess,
onDataEmitter,
onExitEmitter,
onClaimChangedEmitter,
onCommandFinishedEmitter,
title: params.name ?? shell,
cwd,
cols,
rows,
content: [],
contentSize: 0,
claim,
commandTracker,
headlessTerminal,
terminalQueryFilterState: { pendingData: '' },
};
this._terminals.set(uri, managed);
store.add(headlessTerminal.onResponseData(data => {
this._logService.debug(`[TerminalManager] Writing headless terminal response for ${uri}: ${JSON.stringify(data)}`);
agentHostTerminalManager.ts ×1
try {
ptyProcess.write(data);
} catch (err) {
this._logService.debug(`[TerminalManager] Failed to write headless terminal response for ${uri}: ${err instanceof Error ? err.message : String(err)}`);
}
// Wire PTY events → protocol events
store.add(toDisposable(() => {
try { ptyProcess.kill(); } catch { /* already dead */ }
}));
const onFirstData = new DeferredPromise<void>();
const dataListener = ptyProcess.onData(rawData => {
void managed.headlessTerminal?.writePtyData(rawData);
this._handlePtyData(managed, rawData);
onFirstData.complete();
});
store.add(toDisposable(() => dataListener.dispose()));
const exitListener = ptyProcess.onExit(e => {
managed.exitCode = e.exitCode;
managed.onExitEmitter.fire(e.exitCode);
onFirstData.complete();
this._stateManager.dispatchServerAction(uri, {
type: ActionType.TerminalExited,
exitCode: e.exitCode,
});
this._broadcastTerminalList();
store.add(toDisposable(() => exitListener.dispose()));
// Poll for title changes (non-Windows)
if (!platform.isWindows) {
const titleInterval = setInterval(() => {
const newTitle = ptyProcess.process;
if (newTitle && newTitle !== managed.title) {
managed.title = newTitle;
this._stateManager.dispatchServerAction(uri, {
type: ActionType.TerminalTitleChanged,
title: newTitle,
});
this._broadcastTerminalList();
}
store.add(toDisposable(() => clearInterval(titleInterval)));
}
await raceCancellablePromises([onFirstData.p, timeout(WAIT_FOR_PROMPT_TIMEOUT)]);
this._broadcastTerminalList();
}
protected async _spawnPty(file: string, args: string[], options: import('node-pty').IPtyForkOptions | import('node-pty').IWindowsPtyForkOptions): Promise<import('node-pty').IPty> {
const nodePty = await getNodePty();
return nodePty.spawn(file, args, options);
}
/** Send input data to a terminal's PTY process (from client-dispatched actions). */
private _writeInput(uri: string, data: string): void {
this.writeInput(uri, data);
}
/** Send input data to a terminal's PTY process. */
writeInput(uri: string, data: string): void {
if (terminal && terminal.exitCode === undefined) {
terminal.pty.write(data);
}
}
/** Send formatted text to a terminal's PTY process. */
async sendText(uri: string, data: string, options: ISendTextOptions): Promise<void> {
let forceBracketedPasteMode = false;
if (options.bracketedPasteMode) {
forceBracketedPasteMode = !!terminal?.headlessTerminal?.isBracketedPasteMode();
}
this.writeInput(uri, formatTerminalText(data, { shouldExecute: options.shouldExecute, forceBracketedPasteMode }));
agentHostTerminalManager.ts ×3
}
/** Register a callback for PTY data events on a terminal. */
onData(uri: string, cb: (data: string) => void): IDisposable {
const terminal = this._terminals.get(uri);
if (!terminal) {
return toDisposable(() => { });
}
return terminal.onDataEmitter.event(cb);
}
/** Register a callback for PTY exit events on a terminal. */
onExit(uri: string, cb: (exitCode: number) => void): IDisposable {
const terminal = this._terminals.get(uri);
if (!terminal) {
return toDisposable(() => { });
}
return terminal.onExitEmitter.event(cb);
}
/** Register a callback for terminal claim changes. */
onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable {
const terminal = this._terminals.get(uri);
if (!terminal) {
return toDisposable(() => { });
}
return terminal.onClaimChangedEmitter.event(cb);
}
/** Register a callback for command completion events (requires shell integration). */
onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable {
if (!terminal) {
return toDisposable(() => { });
}
}
createAltBufferPromise(uri: string, store: DisposableStore): Promise<void> {
if (!terminal?.headlessTerminal) {
return new Promise(() => { });
}
return terminal.headlessTerminal.createAltBufferPromise(store);
agentHostTerminalManager.ts ×2
}
/** Get accumulated scrollback content for a terminal as raw text. */
getContent(uri: string): string | undefined {
const terminal = this._terminals.get(uri);
if (!terminal) {
return undefined;
}
return terminal.content.map(p => p.type === 'command' ? p.output : p.value).join('');
}
/** Get the current claim for a terminal. */
getClaim(uri: string): TerminalClaim | undefined {
return this._terminals.get(uri)?.claim;
}
/** Check whether a terminal exists. */
hasTerminal(uri: string): boolean {
}
/** Whether the terminal has shell integration active for command detection. */
supportsCommandDetection(uri: string): boolean {
const terminal = this._terminals.get(uri);
return terminal?.commandTracker?.detectionAvailableEmitted ?? false;
}
/** Get the exit code for a terminal, or undefined if still running. */
getExitCode(uri: string): number | undefined {
return this._terminals.get(uri)?.exitCode;
}
/** Resize a terminal. */
private _resize(uri: string, cols: number, rows: number): void {
const terminal = this._terminals.get(uri);
if (terminal && terminal.exitCode === undefined) {
terminal.cols = cols;
terminal.rows = rows;
terminal.pty.resize(cols, rows);
terminal.headlessTerminal?.resize(cols, rows);
}
}
/** Update a terminal's claim. */
private _setClaim(uri: string, claim: TerminalClaim): void {
const terminal = this._terminals.get(uri);
if (terminal) {
terminal.claim = claim;
terminal.onClaimChangedEmitter.fire(claim);
this._broadcastTerminalList();
}
}
/** Update a terminal's title. */
private _setTitle(uri: string, title: string): void {
const terminal = this._terminals.get(uri);
if (terminal) {
terminal.title = title;
this._broadcastTerminalList();
}
}
/** Clear a terminal's scrollback buffer. */
private _clearContent(uri: string): void {
if (terminal) {
terminal.content = [];
terminal.contentSize = 0;
terminal.headlessTerminal?.clear();
}
/** Process raw PTY output: parse OSC 633 sequences, dispatch actions, track content. */
private _handlePtyData(managed: IManagedTerminal, rawData: string): void {
// Without command detection there are no OSC 633 sequences to
// interleave — the whole chunk is command output. With a tracker,
// process cleaned-data and events in stream order so that output which
// arrives before a CommandFinished marker (commonly in the same PTY
// read for fast commands) is appended to the command's output BEFORE the
// finished event snapshots it. Handling all events first would emit
// CommandFinished with the not-yet-appended output missing.
const segments: Osc633ParseSegment[] = tracker
? tracker.parser.parseSegments(rawData)
: (rawData.length > 0 ? [{ kind: 'data', data: rawData }] : []);
// Preserve OSC 633 stream order when emitting AHP actions: command data must remain between
// TerminalCommandExecuted and TerminalCommandFinished, matching the AHP contract and xterm.
let pendingClientData = '';
const flushClientData = (): void => {
if (pendingClientData.length === 0) {
}
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalData,
data: pendingClientData,
});
pendingClientData = '';
};
for (const segment of segments) {
if (segment.kind === 'event') {
this._handleOsc633Event(managed, tracker!, segment.event);
continue;
}
// Agent Host's server-side headless terminal answers CPR so terminals
// work without an attached client. Hide those queries from client xterms
// to avoid a second CPR response flowing back through AgentHostPty.input.
const cleanedData = removeServerHandledTerminalQueries(segment.data, managed.terminalQueryFilterState);
if (cleanedData.length > 0) {
this._appendToContent(managed, cleanedData);
pendingClientData += cleanedData;
}
}
flushClientData();
// Trim content if too large
this._trimContent(managed);
}
/** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */
private _handleOsc633Event(managed: IManagedTerminal, tracker: ICommandTracker, event: Osc633Event): void {
if (!tracker.detectionAvailableEmitted) {
tracker.detectionAvailableEmitted = true;
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalCommandDetectionAvailable,
});
}
switch (event.type) {
case Osc633EventType.CommandLine: {
// Only trust command lines with a valid nonce
if (event.nonce === tracker.nonce) {
tracker.pendingCommandLine = event.commandLine;
}
break;
}
case Osc633EventType.CommandExecuted: {
const commandId = `cmd-${++tracker.commandCounter}`;
const commandLine = tracker.pendingCommandLine ?? '';
const timestamp = Date.now();
tracker.pendingCommandLine = undefined;
tracker.activeCommandId = commandId;
tracker.activeCommandTimestamp = timestamp;
// Push a new command content part
managed.content.push({
type: 'command',
commandId,
commandLine,
output: '',
timestamp,
isComplete: false,
});
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalCommandExecuted,
commandId,
commandLine,
timestamp,
});
break;
}
case Osc633EventType.CommandFinished: {
const finishedCommandId = tracker.activeCommandId;
if (!finishedCommandId) {
break;
}
const durationMs = tracker.activeCommandTimestamp !== undefined
agentHostTerminalManager.ts ×10
? Date.now() - tracker.activeCommandTimestamp
: undefined;
// Mark the command content part as complete and collect output
let commandLine = '';
let commandOutput = '';
for (const part of managed.content) {
if (part.type === 'command' && part.commandId === finishedCommandId) {
part.isComplete = true;
part.exitCode = event.exitCode;
part.durationMs = durationMs;
commandLine = part.commandLine;
commandOutput = part.output;
break;
}
}
tracker.activeCommandId = undefined;
tracker.activeCommandTimestamp = undefined;
managed.onCommandFinishedEmitter.fire({
commandId: finishedCommandId,
exitCode: event.exitCode,
command: commandLine,
output: commandOutput,
});
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalCommandFinished,
commandId: finishedCommandId,
exitCode: event.exitCode,
durationMs,
});
break;
}
case Osc633EventType.Property: {
if (event.key === 'Cwd') {
managed.cwd = event.value;
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalCwdChanged,
cwd: event.value,
});
}
break;
}
}
/** Append cleaned data to the terminal's structured content array. */
private _appendToContent(managed: { content: TerminalContentPart[]; contentSize: number }, data: string): void {
const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined;
agentHostTerminalManager.ts ×8
if (tail?.type === 'command' && !tail.isComplete) {
tail.output += data;
managed.contentSize += data.length;
tail.value += data;
managed.contentSize += data.length;
// Start a new unclassified part
managed.content.push({ type: 'unclassified', value: data });
managed.contentSize += data.length;
}
private _getContentPartSize(part: TerminalContentPart): number {
return part.type === 'command' ? part.output.length : part.value.length;
}
/** Trim content parts to stay within the rolling buffer limit. */
private _trimContent(managed: { content: TerminalContentPart[]; contentSize: number }): void {
const targetSize = 80_000;
if (managed.contentSize <= maxSize) {
return;
}
// Drop whole parts from the front while possible
while (managed.contentSize > targetSize && managed.content.length > 1) {
agentHostTerminalManager.ts ×8
const removed = managed.content.shift()!;
managed.contentSize -= this._getContentPartSize(removed);
}
// If the single remaining (or first) part is still over budget, trim its text
if (managed.contentSize > targetSize && managed.content.length > 0) {
agentHostTerminalManager.ts ×8
const head = managed.content[0];
const excess = managed.contentSize - targetSize;
if (head.type === 'command') {
head.output = head.output.slice(excess);
} else {
head.value = head.value.slice(excess);
}
managed.contentSize -= excess;
}
/**
* Create an output-only terminal channel. Unlike {@link createTerminal}
* there is no PTY behind it: the owner appends plain-text output via
* {@link appendOutputTerminalData}. The channel is not announced on the
* root terminal list — clients discover it through the tool result's
* terminal content block and subscribe to its URI.
*/
createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void {
if (this._terminals.has(uri) || this._outputTerminals.has(uri)) {
agentHostTerminalManager.ts ×6
throw new Error(`Terminal already exists: ${uri}`);
}
title: options.title,
content: [],
contentSize: 0,
claim: options.claim,
});
}
/** Append plain-text data to an output-only terminal and stream it to subscribers. */
appendOutputTerminalData(uri: string, data: string): void {
if (!terminal || data.length === 0) {
return;
}
this._trimContent(terminal);
this._stateManager.dispatchServerAction(uri, {
type: ActionType.TerminalData,
data,
});
}
/** Clear an output-only terminal's content (e.g. when cumulative source output was rewritten). */
resetOutputTerminal(uri: string): void {
if (!terminal) {
return;
}
terminal.contentSize = 0;
this._stateManager.dispatchServerAction(uri, {
type: ActionType.TerminalCleared,
});
}
/** Record the command's exit on an output-only terminal and notify subscribers. */
finalizeOutputTerminal(uri: string, exitCode: number | undefined): void {
if (!terminal || terminal.exitCode !== undefined) {
return;
}
if (exitCode !== undefined) {
terminal.exitCode = exitCode;
this._stateManager.dispatchServerAction(uri, {
type: ActionType.TerminalExited,
exitCode,
});
}
}
/** Dispose a terminal: kill the process and remove it. */
disposeTerminal(uri: string): void {
return;
}
const terminal = this._terminals.get(uri);
if (terminal) {
this._terminals.delete(uri);
terminal.store.dispose();
this._broadcastTerminalList();
}
async getDefaultShell(): Promise<string> {
const configured = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.DefaultShell);
if (configured) {
try {
await fs.promises.access(configured, fs.constants.X_OK);
return configured;
} catch (err) {
this._logService.warn(`[TerminalManager] Configured defaultShell '${configured}' is not accessible, falling back to system shell: ${err instanceof Error ? err.message : String(err)}`);
}
}
return getSystemShell(platform.OS, process.env);
}
/**
* Resolves the cwd string from {@link CreateTerminalParams} to an
* accessible filesystem path, falling back to $HOME if the requested
* directory is missing (otherwise node-pty exits silently with code 1).
* Accepts either a `file://` URI string or a raw absolute filesystem path.
*/
private async _resolveCwd(cwd: string | undefined, terminalURI: string): Promise<string> {
if (cwd) {
const parsed = URI.parse(cwd);
if (parsed.scheme === 'file' && parsed.fsPath && parsed.fsPath !== '/') {
resolved = parsed.fsPath;
} else {
this._logService.warn(`[TerminalManager] Ignoring non-file cwd for ${terminalURI}: ${cwd}`);
}
try {
if (resolved) {
const stat = await fs.promises.stat(resolved);
if (stat.isDirectory()) {
return resolved;
}
}
} catch {
// fall through to fallback
}
const fallback = process.env['HOME'] || process.env['USERPROFILE'] || process.cwd();
agentHostTerminalManager.ts ×21
this._logService.warn(`[TerminalManager] cwd '${resolved}' is not accessible, falling back to ${fallback}`);
return fallback;
}
/** Dispatch root/terminalsChanged with the current terminal list. */
private _broadcastTerminalList(): void {
type: ActionType.RootTerminalsChanged,
terminals: this.getTerminalInfos(),
});
}
override dispose(): void {
}
super.dispose();
}