Path: blob/main/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as fs from 'fs';6import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js';7import { Emitter } from '../../../base/common/event.js';8import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';9import { dirname, parse as pathParse } from '../../../base/common/path.js';10import * as platform from '../../../base/common/platform.js';11import { getSystemShell } from '../../../base/node/shell.js';12import { URI } from '../../../base/common/uri.js';13import { generateUuid } from '../../../base/common/uuid.js';14import { createDecorator } from '../../instantiation/common/instantiation.js';15import { ILogService } from '../../log/common/log.js';16import { IProductService } from '../../product/common/productService.js';17import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js';18import { ActionType } from '../common/state/protocol/actions.js';19import type { CreateTerminalParams } from '../common/state/protocol/commands.js';20import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js';21import { isTerminalAction } from '../common/state/sessionActions.js';22import type { AgentHostStateManager } from './agentHostStateManager.js';23import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js';2425const WAIT_FOR_PROMPT_TIMEOUT = 10_000;2627export const IAgentHostTerminalManager = createDecorator<IAgentHostTerminalManager>('agentHostTerminalManager');2829export interface ICommandFinishedEvent {30commandId: string;31exitCode: number | undefined;32command: string;33output: string;34}3536/**37* Service interface for terminal management in the agent host.38*/39export interface IAgentHostTerminalManager {40readonly _serviceBrand: undefined;41createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void>;42writeInput(uri: string, data: string): void;43onData(uri: string, cb: (data: string) => void): IDisposable;44onExit(uri: string, cb: (exitCode: number) => void): IDisposable;45onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable;46onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable;47getContent(uri: string): string | undefined;48getClaim(uri: string): TerminalClaim | undefined;49hasTerminal(uri: string): boolean;50getExitCode(uri: string): number | undefined;51supportsCommandDetection(uri: string): boolean;52disposeTerminal(uri: string): void;53getTerminalInfos(): TerminalInfo[];54getTerminalState(uri: string): TerminalState | undefined;55}5657// node-pty is loaded dynamically to avoid bundling issues in non-node environments58let nodePtyModule: typeof import('node-pty') | undefined;59async function getNodePty(): Promise<typeof import('node-pty')> {60if (!nodePtyModule) {61nodePtyModule = await import('node-pty');62}63return nodePtyModule;64}6566/** Per-terminal command detection tracking state. */67interface ICommandTracker {68readonly parser: Osc633Parser;69readonly nonce: string;70commandCounter: number;71detectionAvailableEmitted: boolean;72pendingCommandLine?: string;73activeCommandId?: string;74activeCommandTimestamp?: number;75}7677/** Represents a single managed terminal with its PTY process. */78interface IManagedTerminal {79readonly uri: string;80readonly store: DisposableStore;81readonly pty: import('node-pty').IPty;82readonly onDataEmitter: Emitter<string>;83readonly onExitEmitter: Emitter<number>;84readonly onClaimChangedEmitter: Emitter<TerminalClaim>;85readonly onCommandFinishedEmitter: Emitter<ICommandFinishedEvent>;86title: string;87cwd: string;88cols: number;89rows: number;90content: TerminalContentPart[];91contentSize: number;92claim: TerminalClaim;93exitCode?: number;94commandTracker?: ICommandTracker;95}9697/**98* Manages terminal processes for the agent host. Each terminal is backed by99* a node-pty instance and identified by a protocol URI.100*101* Listens to the {@link AgentHostStateManager} for client-dispatched terminal102* actions (input, resize, claim changes) and dispatches server-originated103* PTY output back through the state manager.104*/105export class AgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager {106declare readonly _serviceBrand: undefined;107108private readonly _terminals = new Map<string, IManagedTerminal>();109110constructor(111private readonly _stateManager: AgentHostStateManager,112@ILogService private readonly _logService: ILogService,113@IProductService private readonly _productService: IProductService,114) {115super();116117// React to client-dispatched terminal actions flowing through the state manager118this._register(this._stateManager.onDidEmitEnvelope(envelope => {119const action = envelope.action;120if (!isTerminalAction(action)) {121return;122}123switch (action.type) {124case ActionType.TerminalInput:125this._writeInput(action.terminal, action.data);126break;127case ActionType.TerminalResized:128this._resize(action.terminal, action.cols, action.rows);129break;130case ActionType.TerminalClaimed:131this._setClaim(action.terminal, action.claim);132break;133case ActionType.TerminalTitleChanged:134this._setTitle(action.terminal, action.title);135break;136case ActionType.TerminalCleared:137this._clearContent(action.terminal);138break;139}140}));141}142143/** Get metadata for all active terminals (for root state). */144getTerminalInfos(): TerminalInfo[] {145return [...this._terminals.values()].map(t => ({146resource: t.uri,147title: t.title,148claim: t.claim,149exitCode: t.exitCode,150}));151}152153/** Get the full state for a terminal (for subscribe snapshots). */154getTerminalState(uri: string): TerminalState | undefined {155const terminal = this._terminals.get(uri);156if (!terminal) {157return undefined;158}159return {160title: terminal.title,161cwd: terminal.cwd,162cols: terminal.cols,163rows: terminal.rows,164content: terminal.content,165exitCode: terminal.exitCode,166claim: terminal.claim,167supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted,168};169}170171/**172* Create a new terminal backed by node-pty.173* Spawns the user's default shell.174*/175async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void> {176const uri = params.terminal;177if (this._terminals.has(uri)) {178throw new Error(`Terminal already exists: ${uri}`);179}180181const nodePty = await getNodePty();182183const cwd = await this._resolveCwd(params.cwd, uri);184const cols = params.cols ?? 80;185const rows = params.rows ?? 24;186187const shell = options?.shell ?? await this._getDefaultShell();188const name = platform.isWindows ? 'cmd' : 'xterm-256color';189190this._logService.info(`[TerminalManager] Creating terminal ${uri}: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`);191192// Shell integration — inject scripts so the shell emits OSC 633 sequences193const nonce = generateUuid();194const env: Record<string, string> = { ...process.env as Record<string, string> };195if (options?.preventShellHistory) {196// Picked up by the shell integration scripts to set HISTCONTROL=ignorespace197// (bash) / HIST_IGNORE_SPACE (zsh), or suppress PSReadLine history (pwsh).198// Combined with the leading-space prefix applied at command-write time, this199// prevents agent-executed commands from polluting the user's shell history.200env['VSCODE_PREVENT_SHELL_HISTORY'] = '1';201}202if (options?.nonInteractive) {203// Suppress paging and interactive prompts so that tool-spawned204// terminals produce clean, machine-friendly output. An empty205// string disables paging in git, less, and most CLI tools and206// is safe on all platforms (unlike 'cat' which isn't on Windows PATH).207env['LC_ALL'] = 'C.UTF-8';208env['PAGER'] = '';209env['GIT_PAGER'] = '';210env['GH_PAGER'] = '';211env['GIT_TERMINAL_PROMPT'] = '0';212env['DEBIAN_FRONTEND'] = 'noninteractive';213}214let shellArgs: string[] = [];215if (platform.isMacintosh) {216const shellName = pathParse(shell).name;217if (shellName.match(/(zsh|bash)/)) {218shellArgs = ['--login'];219}220}221222const injection = await getShellIntegrationInjection(223{ executable: shell, args: shellArgs, forceShellIntegration: true },224{225shellIntegration: { enabled: true, suggestEnabled: false, nonce },226windowsUseConptyDll: false,227environmentVariableCollections: undefined,228workspaceFolder: undefined,229isScreenReaderOptimized: false,230},231undefined,232this._logService,233this._productService,234);235236let commandTracker: ICommandTracker | undefined;237238if (injection.type === 'injection') {239this._logService.info(`[TerminalManager] Shell integration injected for ${uri}`);240if (injection.envMixin) {241for (const [key, value] of Object.entries(injection.envMixin)) {242if (value !== undefined) {243env[key] = value;244}245}246}247if (injection.newArgs) {248shellArgs = injection.newArgs;249}250if (injection.filesToCopy) {251for (const f of injection.filesToCopy) {252try {253await fs.promises.mkdir(dirname(f.dest), { recursive: true });254await fs.promises.copyFile(f.source, f.dest);255} catch {256// Swallow — another process may be using the same temp dir257}258}259}260commandTracker = {261parser: new Osc633Parser(),262nonce,263commandCounter: 0,264detectionAvailableEmitted: false,265};266} else {267this._logService.info(`[TerminalManager] Shell integration not available for ${uri}: ${injection.reason}`);268}269270const ptyProcess = nodePty.spawn(shell, shellArgs, {271name,272cwd,273env,274cols,275rows,276});277278const store = new DisposableStore();279const claim: TerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' };280281const onDataEmitter = store.add(new Emitter<string>());282const onExitEmitter = store.add(new Emitter<number>());283const onClaimChangedEmitter = store.add(new Emitter<TerminalClaim>());284const onCommandFinishedEmitter = store.add(new Emitter<ICommandFinishedEvent>());285286const managed: IManagedTerminal = {287uri,288store,289pty: ptyProcess,290onDataEmitter,291onExitEmitter,292onClaimChangedEmitter,293onCommandFinishedEmitter,294title: params.name ?? shell,295cwd,296cols,297rows,298content: [],299contentSize: 0,300claim,301commandTracker,302};303304this._terminals.set(uri, managed);305306// Wire PTY events → protocol events307store.add(toDisposable(() => {308try { ptyProcess.kill(); } catch { /* already dead */ }309}));310311const onFirstData = new DeferredPromise<void>();312const dataListener = ptyProcess.onData(rawData => {313this._handlePtyData(managed, rawData);314onFirstData.complete();315});316store.add(toDisposable(() => dataListener.dispose()));317318const exitListener = ptyProcess.onExit(e => {319managed.exitCode = e.exitCode;320managed.onExitEmitter.fire(e.exitCode);321onFirstData.complete();322this._stateManager.dispatchServerAction({323type: ActionType.TerminalExited,324terminal: uri,325exitCode: e.exitCode,326});327this._broadcastTerminalList();328});329store.add(toDisposable(() => exitListener.dispose()));330331// Poll for title changes (non-Windows)332if (!platform.isWindows) {333const titleInterval = setInterval(() => {334const newTitle = ptyProcess.process;335if (newTitle && newTitle !== managed.title) {336managed.title = newTitle;337this._stateManager.dispatchServerAction({338type: ActionType.TerminalTitleChanged,339terminal: uri,340title: newTitle,341});342this._broadcastTerminalList();343}344}, 200);345store.add(toDisposable(() => clearInterval(titleInterval)));346}347348await raceCancellablePromises([onFirstData.p, timeout(WAIT_FOR_PROMPT_TIMEOUT)]);349350this._broadcastTerminalList();351}352353/** Send input data to a terminal's PTY process (from client-dispatched actions). */354private _writeInput(uri: string, data: string): void {355this.writeInput(uri, data);356}357358/** Send input data to a terminal's PTY process. */359writeInput(uri: string, data: string): void {360const terminal = this._terminals.get(uri);361if (terminal && terminal.exitCode === undefined) {362terminal.pty.write(data);363}364}365366/** Register a callback for PTY data events on a terminal. */367onData(uri: string, cb: (data: string) => void): IDisposable {368const terminal = this._terminals.get(uri);369if (!terminal) {370return toDisposable(() => { });371}372return terminal.onDataEmitter.event(cb);373}374375/** Register a callback for PTY exit events on a terminal. */376onExit(uri: string, cb: (exitCode: number) => void): IDisposable {377const terminal = this._terminals.get(uri);378if (!terminal) {379return toDisposable(() => { });380}381return terminal.onExitEmitter.event(cb);382}383384/** Register a callback for terminal claim changes. */385onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable {386const terminal = this._terminals.get(uri);387if (!terminal) {388return toDisposable(() => { });389}390return terminal.onClaimChangedEmitter.event(cb);391}392393/** Register a callback for command completion events (requires shell integration). */394onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable {395const terminal = this._terminals.get(uri);396if (!terminal) {397return toDisposable(() => { });398}399return terminal.onCommandFinishedEmitter.event(cb);400}401402/** Get accumulated scrollback content for a terminal as raw text. */403getContent(uri: string): string | undefined {404const terminal = this._terminals.get(uri);405if (!terminal) {406return undefined;407}408return terminal.content.map(p => p.type === 'command' ? p.output : p.value).join('');409}410411/** Get the current claim for a terminal. */412getClaim(uri: string): TerminalClaim | undefined {413return this._terminals.get(uri)?.claim;414}415416/** Check whether a terminal exists. */417hasTerminal(uri: string): boolean {418return this._terminals.has(uri);419}420421/** Whether the terminal has shell integration active for command detection. */422supportsCommandDetection(uri: string): boolean {423const terminal = this._terminals.get(uri);424return terminal?.commandTracker?.detectionAvailableEmitted ?? false;425}426427/** Get the exit code for a terminal, or undefined if still running. */428getExitCode(uri: string): number | undefined {429return this._terminals.get(uri)?.exitCode;430}431432/** Resize a terminal. */433private _resize(uri: string, cols: number, rows: number): void {434const terminal = this._terminals.get(uri);435if (terminal && terminal.exitCode === undefined) {436terminal.cols = cols;437terminal.rows = rows;438terminal.pty.resize(cols, rows);439}440}441442/** Update a terminal's claim. */443private _setClaim(uri: string, claim: TerminalClaim): void {444const terminal = this._terminals.get(uri);445if (terminal) {446terminal.claim = claim;447terminal.onClaimChangedEmitter.fire(claim);448this._broadcastTerminalList();449}450}451452/** Update a terminal's title. */453private _setTitle(uri: string, title: string): void {454const terminal = this._terminals.get(uri);455if (terminal) {456terminal.title = title;457this._broadcastTerminalList();458}459}460461/** Clear a terminal's scrollback buffer. */462private _clearContent(uri: string): void {463const terminal = this._terminals.get(uri);464if (terminal) {465terminal.content = [];466terminal.contentSize = 0;467}468}469470/** Process raw PTY output: parse OSC 633 sequences, dispatch actions, track content. */471private _handlePtyData(managed: IManagedTerminal, rawData: string): void {472const tracker = managed.commandTracker;473let cleanedData: string;474475if (tracker) {476const parseResult = tracker.parser.parse(rawData);477cleanedData = parseResult.cleanedData;478479for (const event of parseResult.events) {480this._handleOsc633Event(managed, tracker, event);481}482} else {483cleanedData = rawData;484}485486// Append to structured content487if (cleanedData.length > 0) {488this._appendToContent(managed, cleanedData);489}490491// Trim content if too large492this._trimContent(managed);493494// Fire data event and dispatch to protocol (cleaned, without OSC 633)495if (cleanedData.length > 0) {496managed.onDataEmitter.fire(cleanedData);497this._stateManager.dispatchServerAction({498type: ActionType.TerminalData,499terminal: managed.uri,500data: cleanedData,501});502}503}504505/** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */506private _handleOsc633Event(managed: IManagedTerminal, tracker: ICommandTracker, event: Osc633Event): void {507// Emit TerminalCommandDetectionAvailable on first sequence508if (!tracker.detectionAvailableEmitted) {509tracker.detectionAvailableEmitted = true;510this._stateManager.dispatchServerAction({511type: ActionType.TerminalCommandDetectionAvailable,512terminal: managed.uri,513});514}515516switch (event.type) {517case Osc633EventType.CommandLine: {518// Only trust command lines with a valid nonce519if (event.nonce === tracker.nonce) {520tracker.pendingCommandLine = event.commandLine;521}522break;523}524525case Osc633EventType.CommandExecuted: {526const commandId = `cmd-${++tracker.commandCounter}`;527const commandLine = tracker.pendingCommandLine ?? '';528const timestamp = Date.now();529tracker.pendingCommandLine = undefined;530tracker.activeCommandId = commandId;531tracker.activeCommandTimestamp = timestamp;532533// Push a new command content part534managed.content.push({535type: 'command',536commandId,537commandLine,538output: '',539timestamp,540isComplete: false,541});542543this._stateManager.dispatchServerAction({544type: ActionType.TerminalCommandExecuted,545terminal: managed.uri,546commandId,547commandLine,548timestamp,549});550break;551}552553case Osc633EventType.CommandFinished: {554const finishedCommandId = tracker.activeCommandId;555if (!finishedCommandId) {556break;557}558const durationMs = tracker.activeCommandTimestamp !== undefined559? Date.now() - tracker.activeCommandTimestamp560: undefined;561562// Mark the command content part as complete and collect output563let commandLine = '';564let commandOutput = '';565for (const part of managed.content) {566if (part.type === 'command' && part.commandId === finishedCommandId) {567part.isComplete = true;568part.exitCode = event.exitCode;569part.durationMs = durationMs;570commandLine = part.commandLine;571commandOutput = part.output;572break;573}574}575576tracker.activeCommandId = undefined;577tracker.activeCommandTimestamp = undefined;578579managed.onCommandFinishedEmitter.fire({580commandId: finishedCommandId,581exitCode: event.exitCode,582command: commandLine,583output: commandOutput,584});585586this._stateManager.dispatchServerAction({587type: ActionType.TerminalCommandFinished,588terminal: managed.uri,589commandId: finishedCommandId,590exitCode: event.exitCode,591durationMs,592});593break;594}595596case Osc633EventType.Property: {597if (event.key === 'Cwd') {598managed.cwd = event.value;599this._stateManager.dispatchServerAction({600type: ActionType.TerminalCwdChanged,601terminal: managed.uri,602cwd: event.value,603});604}605break;606}607}608}609610/** Append cleaned data to the terminal's structured content array. */611private _appendToContent(managed: IManagedTerminal, data: string): void {612const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined;613614if (tail && tail.type === 'command' && !tail.isComplete) {615// Active command — append to its output616tail.output += data;617managed.contentSize += data.length;618} else if (tail && tail.type === 'unclassified') {619// Extend the existing unclassified part620tail.value += data;621managed.contentSize += data.length;622} else {623// Start a new unclassified part624managed.content.push({ type: 'unclassified', value: data });625managed.contentSize += data.length;626}627}628629private _getContentPartSize(part: TerminalContentPart): number {630return part.type === 'command' ? part.output.length : part.value.length;631}632633/** Trim content parts to stay within the rolling buffer limit. */634private _trimContent(managed: IManagedTerminal): void {635const maxSize = 100_000;636const targetSize = 80_000;637if (managed.contentSize <= maxSize) {638return;639}640// Drop whole parts from the front while possible641while (managed.contentSize > targetSize && managed.content.length > 1) {642const removed = managed.content.shift()!;643managed.contentSize -= this._getContentPartSize(removed);644}645// If the single remaining (or first) part is still over budget, trim its text646if (managed.contentSize > targetSize && managed.content.length > 0) {647const head = managed.content[0];648const excess = managed.contentSize - targetSize;649if (head.type === 'command') {650head.output = head.output.slice(excess);651} else {652head.value = head.value.slice(excess);653}654managed.contentSize -= excess;655}656}657658/** Dispose a terminal: kill the process and remove it. */659disposeTerminal(uri: string): void {660const terminal = this._terminals.get(uri);661if (terminal) {662this._terminals.delete(uri);663terminal.store.dispose();664this._broadcastTerminalList();665}666}667668private _getDefaultShell(): Promise<string> {669return getSystemShell(platform.OS, process.env);670}671672/**673* Resolves the cwd string from {@link CreateTerminalParams} to an674* accessible filesystem path, falling back to $HOME if the requested675* directory is missing (otherwise node-pty exits silently with code 1).676* Accepts either a `file://` URI string or a raw absolute filesystem path.677*/678private async _resolveCwd(cwd: string | undefined, terminalURI: string): Promise<string> {679let resolved = cwd;680if (cwd) {681const parsed = URI.parse(cwd);682if (parsed.scheme === 'file' && parsed.fsPath && parsed.fsPath !== '/') {683resolved = parsed.fsPath;684} else {685this._logService.warn(`[TerminalManager] Ignoring non-file cwd for ${terminalURI}: ${cwd}`);686}687}688689try {690if (resolved) {691const stat = await fs.promises.stat(resolved);692if (stat.isDirectory()) {693return resolved;694}695}696} catch {697// fall through to fallback698}699700const fallback = process.env['HOME'] || process.env['USERPROFILE'] || process.cwd();701this._logService.warn(`[TerminalManager] cwd '${resolved}' is not accessible, falling back to ${fallback}`);702return fallback;703}704705/** Dispatch root/terminalsChanged with the current terminal list. */706private _broadcastTerminalList(): void {707this._stateManager.dispatchServerAction({708type: ActionType.RootTerminalsChanged,709terminals: this.getTerminalInfos(),710});711}712713override dispose(): void {714for (const terminal of this._terminals.values()) {715terminal.store.dispose();716}717this._terminals.clear();718super.dispose();719}720}721722723