Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
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 * as fs from 'fs';
7
import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js';
8
import { Emitter } from '../../../base/common/event.js';
9
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
10
import { dirname, parse as pathParse } from '../../../base/common/path.js';
11
import * as platform from '../../../base/common/platform.js';
12
import { getSystemShell } from '../../../base/node/shell.js';
13
import { URI } from '../../../base/common/uri.js';
14
import { generateUuid } from '../../../base/common/uuid.js';
15
import { createDecorator } from '../../instantiation/common/instantiation.js';
16
import { ILogService } from '../../log/common/log.js';
17
import { IProductService } from '../../product/common/productService.js';
18
import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js';
19
import { ActionType } from '../common/state/protocol/actions.js';
20
import type { CreateTerminalParams } from '../common/state/protocol/commands.js';
21
import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js';
22
import { isTerminalAction } from '../common/state/sessionActions.js';
23
import type { AgentHostStateManager } from './agentHostStateManager.js';
24
import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js';
25
26
const WAIT_FOR_PROMPT_TIMEOUT = 10_000;
27
28
export const IAgentHostTerminalManager = createDecorator<IAgentHostTerminalManager>('agentHostTerminalManager');
29
30
export interface ICommandFinishedEvent {
31
commandId: string;
32
exitCode: number | undefined;
33
command: string;
34
output: string;
35
}
36
37
/**
38
* Service interface for terminal management in the agent host.
39
*/
40
export interface IAgentHostTerminalManager {
41
readonly _serviceBrand: undefined;
42
createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void>;
43
writeInput(uri: string, data: string): void;
44
onData(uri: string, cb: (data: string) => void): IDisposable;
45
onExit(uri: string, cb: (exitCode: number) => void): IDisposable;
46
onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable;
47
onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable;
48
getContent(uri: string): string | undefined;
49
getClaim(uri: string): TerminalClaim | undefined;
50
hasTerminal(uri: string): boolean;
51
getExitCode(uri: string): number | undefined;
52
supportsCommandDetection(uri: string): boolean;
53
disposeTerminal(uri: string): void;
54
getTerminalInfos(): TerminalInfo[];
55
getTerminalState(uri: string): TerminalState | undefined;
56
}
57
58
// node-pty is loaded dynamically to avoid bundling issues in non-node environments
59
let nodePtyModule: typeof import('node-pty') | undefined;
60
async function getNodePty(): Promise<typeof import('node-pty')> {
61
if (!nodePtyModule) {
62
nodePtyModule = await import('node-pty');
63
}
64
return nodePtyModule;
65
}
66
67
/** Per-terminal command detection tracking state. */
68
interface ICommandTracker {
69
readonly parser: Osc633Parser;
70
readonly nonce: string;
71
commandCounter: number;
72
detectionAvailableEmitted: boolean;
73
pendingCommandLine?: string;
74
activeCommandId?: string;
75
activeCommandTimestamp?: number;
76
}
77
78
/** Represents a single managed terminal with its PTY process. */
79
interface IManagedTerminal {
80
readonly uri: string;
81
readonly store: DisposableStore;
82
readonly pty: import('node-pty').IPty;
83
readonly onDataEmitter: Emitter<string>;
84
readonly onExitEmitter: Emitter<number>;
85
readonly onClaimChangedEmitter: Emitter<TerminalClaim>;
86
readonly onCommandFinishedEmitter: Emitter<ICommandFinishedEvent>;
87
title: string;
88
cwd: string;
89
cols: number;
90
rows: number;
91
content: TerminalContentPart[];
92
contentSize: number;
93
claim: TerminalClaim;
94
exitCode?: number;
95
commandTracker?: ICommandTracker;
96
}
97
98
/**
99
* Manages terminal processes for the agent host. Each terminal is backed by
100
* a node-pty instance and identified by a protocol URI.
101
*
102
* Listens to the {@link AgentHostStateManager} for client-dispatched terminal
103
* actions (input, resize, claim changes) and dispatches server-originated
104
* PTY output back through the state manager.
105
*/
106
export class AgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager {
107
declare readonly _serviceBrand: undefined;
108
109
private readonly _terminals = new Map<string, IManagedTerminal>();
110
111
constructor(
112
private readonly _stateManager: AgentHostStateManager,
113
@ILogService private readonly _logService: ILogService,
114
@IProductService private readonly _productService: IProductService,
115
) {
116
super();
117
118
// React to client-dispatched terminal actions flowing through the state manager
119
this._register(this._stateManager.onDidEmitEnvelope(envelope => {
120
const action = envelope.action;
121
if (!isTerminalAction(action)) {
122
return;
123
}
124
switch (action.type) {
125
case ActionType.TerminalInput:
126
this._writeInput(action.terminal, action.data);
127
break;
128
case ActionType.TerminalResized:
129
this._resize(action.terminal, action.cols, action.rows);
130
break;
131
case ActionType.TerminalClaimed:
132
this._setClaim(action.terminal, action.claim);
133
break;
134
case ActionType.TerminalTitleChanged:
135
this._setTitle(action.terminal, action.title);
136
break;
137
case ActionType.TerminalCleared:
138
this._clearContent(action.terminal);
139
break;
140
}
141
}));
142
}
143
144
/** Get metadata for all active terminals (for root state). */
145
getTerminalInfos(): TerminalInfo[] {
146
return [...this._terminals.values()].map(t => ({
147
resource: t.uri,
148
title: t.title,
149
claim: t.claim,
150
exitCode: t.exitCode,
151
}));
152
}
153
154
/** Get the full state for a terminal (for subscribe snapshots). */
155
getTerminalState(uri: string): TerminalState | undefined {
156
const terminal = this._terminals.get(uri);
157
if (!terminal) {
158
return undefined;
159
}
160
return {
161
title: terminal.title,
162
cwd: terminal.cwd,
163
cols: terminal.cols,
164
rows: terminal.rows,
165
content: terminal.content,
166
exitCode: terminal.exitCode,
167
claim: terminal.claim,
168
supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted,
169
};
170
}
171
172
/**
173
* Create a new terminal backed by node-pty.
174
* Spawns the user's default shell.
175
*/
176
async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void> {
177
const uri = params.terminal;
178
if (this._terminals.has(uri)) {
179
throw new Error(`Terminal already exists: ${uri}`);
180
}
181
182
const nodePty = await getNodePty();
183
184
const cwd = await this._resolveCwd(params.cwd, uri);
185
const cols = params.cols ?? 80;
186
const rows = params.rows ?? 24;
187
188
const shell = options?.shell ?? await this._getDefaultShell();
189
const name = platform.isWindows ? 'cmd' : 'xterm-256color';
190
191
this._logService.info(`[TerminalManager] Creating terminal ${uri}: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`);
192
193
// Shell integration — inject scripts so the shell emits OSC 633 sequences
194
const nonce = generateUuid();
195
const env: Record<string, string> = { ...process.env as Record<string, string> };
196
if (options?.preventShellHistory) {
197
// Picked up by the shell integration scripts to set HISTCONTROL=ignorespace
198
// (bash) / HIST_IGNORE_SPACE (zsh), or suppress PSReadLine history (pwsh).
199
// Combined with the leading-space prefix applied at command-write time, this
200
// prevents agent-executed commands from polluting the user's shell history.
201
env['VSCODE_PREVENT_SHELL_HISTORY'] = '1';
202
}
203
if (options?.nonInteractive) {
204
// Suppress paging and interactive prompts so that tool-spawned
205
// terminals produce clean, machine-friendly output. An empty
206
// string disables paging in git, less, and most CLI tools and
207
// is safe on all platforms (unlike 'cat' which isn't on Windows PATH).
208
env['LC_ALL'] = 'C.UTF-8';
209
env['PAGER'] = '';
210
env['GIT_PAGER'] = '';
211
env['GH_PAGER'] = '';
212
env['GIT_TERMINAL_PROMPT'] = '0';
213
env['DEBIAN_FRONTEND'] = 'noninteractive';
214
}
215
let shellArgs: string[] = [];
216
if (platform.isMacintosh) {
217
const shellName = pathParse(shell).name;
218
if (shellName.match(/(zsh|bash)/)) {
219
shellArgs = ['--login'];
220
}
221
}
222
223
const injection = await getShellIntegrationInjection(
224
{ executable: shell, args: shellArgs, forceShellIntegration: true },
225
{
226
shellIntegration: { enabled: true, suggestEnabled: false, nonce },
227
windowsUseConptyDll: false,
228
environmentVariableCollections: undefined,
229
workspaceFolder: undefined,
230
isScreenReaderOptimized: false,
231
},
232
undefined,
233
this._logService,
234
this._productService,
235
);
236
237
let commandTracker: ICommandTracker | undefined;
238
239
if (injection.type === 'injection') {
240
this._logService.info(`[TerminalManager] Shell integration injected for ${uri}`);
241
if (injection.envMixin) {
242
for (const [key, value] of Object.entries(injection.envMixin)) {
243
if (value !== undefined) {
244
env[key] = value;
245
}
246
}
247
}
248
if (injection.newArgs) {
249
shellArgs = injection.newArgs;
250
}
251
if (injection.filesToCopy) {
252
for (const f of injection.filesToCopy) {
253
try {
254
await fs.promises.mkdir(dirname(f.dest), { recursive: true });
255
await fs.promises.copyFile(f.source, f.dest);
256
} catch {
257
// Swallow — another process may be using the same temp dir
258
}
259
}
260
}
261
commandTracker = {
262
parser: new Osc633Parser(),
263
nonce,
264
commandCounter: 0,
265
detectionAvailableEmitted: false,
266
};
267
} else {
268
this._logService.info(`[TerminalManager] Shell integration not available for ${uri}: ${injection.reason}`);
269
}
270
271
const ptyProcess = nodePty.spawn(shell, shellArgs, {
272
name,
273
cwd,
274
env,
275
cols,
276
rows,
277
});
278
279
const store = new DisposableStore();
280
const claim: TerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' };
281
282
const onDataEmitter = store.add(new Emitter<string>());
283
const onExitEmitter = store.add(new Emitter<number>());
284
const onClaimChangedEmitter = store.add(new Emitter<TerminalClaim>());
285
const onCommandFinishedEmitter = store.add(new Emitter<ICommandFinishedEvent>());
286
287
const managed: IManagedTerminal = {
288
uri,
289
store,
290
pty: ptyProcess,
291
onDataEmitter,
292
onExitEmitter,
293
onClaimChangedEmitter,
294
onCommandFinishedEmitter,
295
title: params.name ?? shell,
296
cwd,
297
cols,
298
rows,
299
content: [],
300
contentSize: 0,
301
claim,
302
commandTracker,
303
};
304
305
this._terminals.set(uri, managed);
306
307
// Wire PTY events → protocol events
308
store.add(toDisposable(() => {
309
try { ptyProcess.kill(); } catch { /* already dead */ }
310
}));
311
312
const onFirstData = new DeferredPromise<void>();
313
const dataListener = ptyProcess.onData(rawData => {
314
this._handlePtyData(managed, rawData);
315
onFirstData.complete();
316
});
317
store.add(toDisposable(() => dataListener.dispose()));
318
319
const exitListener = ptyProcess.onExit(e => {
320
managed.exitCode = e.exitCode;
321
managed.onExitEmitter.fire(e.exitCode);
322
onFirstData.complete();
323
this._stateManager.dispatchServerAction({
324
type: ActionType.TerminalExited,
325
terminal: uri,
326
exitCode: e.exitCode,
327
});
328
this._broadcastTerminalList();
329
});
330
store.add(toDisposable(() => exitListener.dispose()));
331
332
// Poll for title changes (non-Windows)
333
if (!platform.isWindows) {
334
const titleInterval = setInterval(() => {
335
const newTitle = ptyProcess.process;
336
if (newTitle && newTitle !== managed.title) {
337
managed.title = newTitle;
338
this._stateManager.dispatchServerAction({
339
type: ActionType.TerminalTitleChanged,
340
terminal: uri,
341
title: newTitle,
342
});
343
this._broadcastTerminalList();
344
}
345
}, 200);
346
store.add(toDisposable(() => clearInterval(titleInterval)));
347
}
348
349
await raceCancellablePromises([onFirstData.p, timeout(WAIT_FOR_PROMPT_TIMEOUT)]);
350
351
this._broadcastTerminalList();
352
}
353
354
/** Send input data to a terminal's PTY process (from client-dispatched actions). */
355
private _writeInput(uri: string, data: string): void {
356
this.writeInput(uri, data);
357
}
358
359
/** Send input data to a terminal's PTY process. */
360
writeInput(uri: string, data: string): void {
361
const terminal = this._terminals.get(uri);
362
if (terminal && terminal.exitCode === undefined) {
363
terminal.pty.write(data);
364
}
365
}
366
367
/** Register a callback for PTY data events on a terminal. */
368
onData(uri: string, cb: (data: string) => void): IDisposable {
369
const terminal = this._terminals.get(uri);
370
if (!terminal) {
371
return toDisposable(() => { });
372
}
373
return terminal.onDataEmitter.event(cb);
374
}
375
376
/** Register a callback for PTY exit events on a terminal. */
377
onExit(uri: string, cb: (exitCode: number) => void): IDisposable {
378
const terminal = this._terminals.get(uri);
379
if (!terminal) {
380
return toDisposable(() => { });
381
}
382
return terminal.onExitEmitter.event(cb);
383
}
384
385
/** Register a callback for terminal claim changes. */
386
onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable {
387
const terminal = this._terminals.get(uri);
388
if (!terminal) {
389
return toDisposable(() => { });
390
}
391
return terminal.onClaimChangedEmitter.event(cb);
392
}
393
394
/** Register a callback for command completion events (requires shell integration). */
395
onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable {
396
const terminal = this._terminals.get(uri);
397
if (!terminal) {
398
return toDisposable(() => { });
399
}
400
return terminal.onCommandFinishedEmitter.event(cb);
401
}
402
403
/** Get accumulated scrollback content for a terminal as raw text. */
404
getContent(uri: string): string | undefined {
405
const terminal = this._terminals.get(uri);
406
if (!terminal) {
407
return undefined;
408
}
409
return terminal.content.map(p => p.type === 'command' ? p.output : p.value).join('');
410
}
411
412
/** Get the current claim for a terminal. */
413
getClaim(uri: string): TerminalClaim | undefined {
414
return this._terminals.get(uri)?.claim;
415
}
416
417
/** Check whether a terminal exists. */
418
hasTerminal(uri: string): boolean {
419
return this._terminals.has(uri);
420
}
421
422
/** Whether the terminal has shell integration active for command detection. */
423
supportsCommandDetection(uri: string): boolean {
424
const terminal = this._terminals.get(uri);
425
return terminal?.commandTracker?.detectionAvailableEmitted ?? false;
426
}
427
428
/** Get the exit code for a terminal, or undefined if still running. */
429
getExitCode(uri: string): number | undefined {
430
return this._terminals.get(uri)?.exitCode;
431
}
432
433
/** Resize a terminal. */
434
private _resize(uri: string, cols: number, rows: number): void {
435
const terminal = this._terminals.get(uri);
436
if (terminal && terminal.exitCode === undefined) {
437
terminal.cols = cols;
438
terminal.rows = rows;
439
terminal.pty.resize(cols, rows);
440
}
441
}
442
443
/** Update a terminal's claim. */
444
private _setClaim(uri: string, claim: TerminalClaim): void {
445
const terminal = this._terminals.get(uri);
446
if (terminal) {
447
terminal.claim = claim;
448
terminal.onClaimChangedEmitter.fire(claim);
449
this._broadcastTerminalList();
450
}
451
}
452
453
/** Update a terminal's title. */
454
private _setTitle(uri: string, title: string): void {
455
const terminal = this._terminals.get(uri);
456
if (terminal) {
457
terminal.title = title;
458
this._broadcastTerminalList();
459
}
460
}
461
462
/** Clear a terminal's scrollback buffer. */
463
private _clearContent(uri: string): void {
464
const terminal = this._terminals.get(uri);
465
if (terminal) {
466
terminal.content = [];
467
terminal.contentSize = 0;
468
}
469
}
470
471
/** Process raw PTY output: parse OSC 633 sequences, dispatch actions, track content. */
472
private _handlePtyData(managed: IManagedTerminal, rawData: string): void {
473
const tracker = managed.commandTracker;
474
let cleanedData: string;
475
476
if (tracker) {
477
const parseResult = tracker.parser.parse(rawData);
478
cleanedData = parseResult.cleanedData;
479
480
for (const event of parseResult.events) {
481
this._handleOsc633Event(managed, tracker, event);
482
}
483
} else {
484
cleanedData = rawData;
485
}
486
487
// Append to structured content
488
if (cleanedData.length > 0) {
489
this._appendToContent(managed, cleanedData);
490
}
491
492
// Trim content if too large
493
this._trimContent(managed);
494
495
// Fire data event and dispatch to protocol (cleaned, without OSC 633)
496
if (cleanedData.length > 0) {
497
managed.onDataEmitter.fire(cleanedData);
498
this._stateManager.dispatchServerAction({
499
type: ActionType.TerminalData,
500
terminal: managed.uri,
501
data: cleanedData,
502
});
503
}
504
}
505
506
/** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */
507
private _handleOsc633Event(managed: IManagedTerminal, tracker: ICommandTracker, event: Osc633Event): void {
508
// Emit TerminalCommandDetectionAvailable on first sequence
509
if (!tracker.detectionAvailableEmitted) {
510
tracker.detectionAvailableEmitted = true;
511
this._stateManager.dispatchServerAction({
512
type: ActionType.TerminalCommandDetectionAvailable,
513
terminal: managed.uri,
514
});
515
}
516
517
switch (event.type) {
518
case Osc633EventType.CommandLine: {
519
// Only trust command lines with a valid nonce
520
if (event.nonce === tracker.nonce) {
521
tracker.pendingCommandLine = event.commandLine;
522
}
523
break;
524
}
525
526
case Osc633EventType.CommandExecuted: {
527
const commandId = `cmd-${++tracker.commandCounter}`;
528
const commandLine = tracker.pendingCommandLine ?? '';
529
const timestamp = Date.now();
530
tracker.pendingCommandLine = undefined;
531
tracker.activeCommandId = commandId;
532
tracker.activeCommandTimestamp = timestamp;
533
534
// Push a new command content part
535
managed.content.push({
536
type: 'command',
537
commandId,
538
commandLine,
539
output: '',
540
timestamp,
541
isComplete: false,
542
});
543
544
this._stateManager.dispatchServerAction({
545
type: ActionType.TerminalCommandExecuted,
546
terminal: managed.uri,
547
commandId,
548
commandLine,
549
timestamp,
550
});
551
break;
552
}
553
554
case Osc633EventType.CommandFinished: {
555
const finishedCommandId = tracker.activeCommandId;
556
if (!finishedCommandId) {
557
break;
558
}
559
const durationMs = tracker.activeCommandTimestamp !== undefined
560
? Date.now() - tracker.activeCommandTimestamp
561
: undefined;
562
563
// Mark the command content part as complete and collect output
564
let commandLine = '';
565
let commandOutput = '';
566
for (const part of managed.content) {
567
if (part.type === 'command' && part.commandId === finishedCommandId) {
568
part.isComplete = true;
569
part.exitCode = event.exitCode;
570
part.durationMs = durationMs;
571
commandLine = part.commandLine;
572
commandOutput = part.output;
573
break;
574
}
575
}
576
577
tracker.activeCommandId = undefined;
578
tracker.activeCommandTimestamp = undefined;
579
580
managed.onCommandFinishedEmitter.fire({
581
commandId: finishedCommandId,
582
exitCode: event.exitCode,
583
command: commandLine,
584
output: commandOutput,
585
});
586
587
this._stateManager.dispatchServerAction({
588
type: ActionType.TerminalCommandFinished,
589
terminal: managed.uri,
590
commandId: finishedCommandId,
591
exitCode: event.exitCode,
592
durationMs,
593
});
594
break;
595
}
596
597
case Osc633EventType.Property: {
598
if (event.key === 'Cwd') {
599
managed.cwd = event.value;
600
this._stateManager.dispatchServerAction({
601
type: ActionType.TerminalCwdChanged,
602
terminal: managed.uri,
603
cwd: event.value,
604
});
605
}
606
break;
607
}
608
}
609
}
610
611
/** Append cleaned data to the terminal's structured content array. */
612
private _appendToContent(managed: IManagedTerminal, data: string): void {
613
const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined;
614
615
if (tail && tail.type === 'command' && !tail.isComplete) {
616
// Active command — append to its output
617
tail.output += data;
618
managed.contentSize += data.length;
619
} else if (tail && tail.type === 'unclassified') {
620
// Extend the existing unclassified part
621
tail.value += data;
622
managed.contentSize += data.length;
623
} else {
624
// Start a new unclassified part
625
managed.content.push({ type: 'unclassified', value: data });
626
managed.contentSize += data.length;
627
}
628
}
629
630
private _getContentPartSize(part: TerminalContentPart): number {
631
return part.type === 'command' ? part.output.length : part.value.length;
632
}
633
634
/** Trim content parts to stay within the rolling buffer limit. */
635
private _trimContent(managed: IManagedTerminal): void {
636
const maxSize = 100_000;
637
const targetSize = 80_000;
638
if (managed.contentSize <= maxSize) {
639
return;
640
}
641
// Drop whole parts from the front while possible
642
while (managed.contentSize > targetSize && managed.content.length > 1) {
643
const removed = managed.content.shift()!;
644
managed.contentSize -= this._getContentPartSize(removed);
645
}
646
// If the single remaining (or first) part is still over budget, trim its text
647
if (managed.contentSize > targetSize && managed.content.length > 0) {
648
const head = managed.content[0];
649
const excess = managed.contentSize - targetSize;
650
if (head.type === 'command') {
651
head.output = head.output.slice(excess);
652
} else {
653
head.value = head.value.slice(excess);
654
}
655
managed.contentSize -= excess;
656
}
657
}
658
659
/** Dispose a terminal: kill the process and remove it. */
660
disposeTerminal(uri: string): void {
661
const terminal = this._terminals.get(uri);
662
if (terminal) {
663
this._terminals.delete(uri);
664
terminal.store.dispose();
665
this._broadcastTerminalList();
666
}
667
}
668
669
private _getDefaultShell(): Promise<string> {
670
return getSystemShell(platform.OS, process.env);
671
}
672
673
/**
674
* Resolves the cwd string from {@link CreateTerminalParams} to an
675
* accessible filesystem path, falling back to $HOME if the requested
676
* directory is missing (otherwise node-pty exits silently with code 1).
677
* Accepts either a `file://` URI string or a raw absolute filesystem path.
678
*/
679
private async _resolveCwd(cwd: string | undefined, terminalURI: string): Promise<string> {
680
let resolved = cwd;
681
if (cwd) {
682
const parsed = URI.parse(cwd);
683
if (parsed.scheme === 'file' && parsed.fsPath && parsed.fsPath !== '/') {
684
resolved = parsed.fsPath;
685
} else {
686
this._logService.warn(`[TerminalManager] Ignoring non-file cwd for ${terminalURI}: ${cwd}`);
687
}
688
}
689
690
try {
691
if (resolved) {
692
const stat = await fs.promises.stat(resolved);
693
if (stat.isDirectory()) {
694
return resolved;
695
}
696
}
697
} catch {
698
// fall through to fallback
699
}
700
701
const fallback = process.env['HOME'] || process.env['USERPROFILE'] || process.cwd();
702
this._logService.warn(`[TerminalManager] cwd '${resolved}' is not accessible, falling back to ${fallback}`);
703
return fallback;
704
}
705
706
/** Dispatch root/terminalsChanged with the current terminal list. */
707
private _broadcastTerminalList(): void {
708
this._stateManager.dispatchServerAction({
709
type: ActionType.RootTerminalsChanged,
710
terminals: this.getTerminalInfos(),
711
});
712
}
713
714
override dispose(): void {
715
for (const terminal of this._terminals.values()) {
716
terminal.store.dispose();
717
}
718
this._terminals.clear();
719
super.dispose();
720
}
721
}
722
723