@@ -86,7 +86,10 @@ export class TerminalProcess extends BaseTerminalProcess {
8686 return
8787 }
8888
89- // Create a promise that resolves when the stream becomes available
89+ // Create a promise that resolves when the stream becomes available.
90+ // cancelStreamWait() lets the early-completion race path abort the pending
91+ // timeout so it doesn't fire (and reject) after we've already returned.
92+ let cancelStreamWait : ( ) => void = ( ) => { }
9093 const streamAvailable = new Promise < AsyncIterable < string > > ( ( resolve , reject ) => {
9194 const timeoutId = setTimeout ( ( ) => {
9295 // Remove event listener to prevent memory leaks
@@ -106,6 +109,11 @@ export class TerminalProcess extends BaseTerminalProcess {
106109 )
107110 } , Terminal . getShellIntegrationTimeout ( ) )
108111
112+ cancelStreamWait = ( ) => {
113+ clearTimeout ( timeoutId )
114+ this . removeAllListeners ( "stream_available" )
115+ }
116+
109117 // Clean up timeout if stream becomes available
110118 this . once ( "stream_available" , ( stream : AsyncIterable < string > ) => {
111119 clearTimeout ( timeoutId )
@@ -127,6 +135,16 @@ export class TerminalProcess extends BaseTerminalProcess {
127135 } )
128136 } )
129137
138+ // Register shell_execution_started listener BEFORE awaiting streamAvailable.
139+ // BaseTerminal.setActiveStream emits shell_execution_started and stream_available
140+ // on the same synchronous tick (in that order). If we register after the await,
141+ // we miss the event and shellExecutionStarted stays false, causing the idle-timeout
142+ // guard to incorrectly treat a running-but-silent command as stalled.
143+ let shellExecutionStarted = false
144+ this . once ( "shell_execution_started" , ( ) => {
145+ shellExecutionStarted = true
146+ } )
147+
130148 // Execute command.
131149 // Determine whether the active shell is PowerShell so we can apply the
132150 // PS-specific counter/sleep workarounds. Prefer the Zoo Code profile
@@ -167,16 +185,39 @@ export class TerminalProcess extends BaseTerminalProcess {
167185 // VSCode doesn't buffer retroactively, and zero chunks arrive.
168186 } catch ( error ) {
169187 this . terminal . activeShellExecution = undefined
188+ this . cleanupScriptFile ( )
170189 throw error
171190 }
172191
173192 this . isHot = true
174193
175- // Wait for stream to be available
194+ // Wait for stream to be available, but also race against shellExecutionComplete.
195+ // If the end event fires before the stream arrives (e.g. a zero-output command
196+ // where onDidEndTerminalShellExecution beats onDidStartTerminalShellExecution),
197+ // we would otherwise block until the stream timeout fires. Resolving early on
198+ // completion produces a clean empty-output result instead.
176199 let stream : AsyncIterable < string >
177200
178201 try {
179- stream = await streamAvailable
202+ const COMPLETED_BEFORE_STREAM = Symbol ( "completed_before_stream" )
203+ const result = await Promise . race ( [
204+ streamAvailable ,
205+ shellExecutionComplete . then ( ( ) => COMPLETED_BEFORE_STREAM as typeof COMPLETED_BEFORE_STREAM ) ,
206+ ] )
207+
208+ if ( result === COMPLETED_BEFORE_STREAM ) {
209+ console . info ( "[Terminal Process] shell execution completed before stream arrived — finishing cleanly" )
210+ cancelStreamWait ( )
211+ this . terminal . activeShellExecution = undefined
212+ this . terminal . busy = false
213+ this . isHot = false
214+ this . cleanupScriptFile ( )
215+ this . emit ( "completed" , "" )
216+ this . emit ( "continue" )
217+ return
218+ }
219+
220+ stream = result as AsyncIterable < string >
180221 } catch ( error ) {
181222 // Stream timeout or other error occurred
182223 console . error ( "[Terminal Process] Stream error:" , error . message )
@@ -188,6 +229,7 @@ export class TerminalProcess extends BaseTerminalProcess {
188229 )
189230
190231 this . terminal . busy = false
232+ this . cleanupScriptFile ( )
191233
192234 // Emit continue event to allow execution to proceed
193235 this . emit ( "continue" )
@@ -231,19 +273,6 @@ export class TerminalProcess extends BaseTerminalProcess {
231273 let idleTimedOut = false
232274 const streamStartedAt = Date . now ( )
233275
234- // Guard against the idle timeout misfiring during shell initialization.
235- // On cold shells (e.g. zsh with a heavy .zshrc), waitForShellIntegration
236- // resolves when ]633;A is emitted, but the shell may still be loading by
237- // the time executeCommand() is called. onDidStartTerminalShellExecution
238- // (→ "shell_execution_started") fires only once the command actually starts
239- // running. If it hasn't arrived yet when the 3s idle timer fires, the shell
240- // is still initializing — self-finalizing would silently drop the command's
241- // output, so we wait.
242- let shellExecutionStarted = false
243- this . once ( "shell_execution_started" , ( ) => {
244- shellExecutionStarted = true
245- } )
246-
247276 // VSCode's execution.read() AsyncIterable can stay open indefinitely even after
248277 // the command finishes — it has no built-in cancellation. We need to be able to
249278 // break out of the loop when onDidEndTerminalShellExecution fires. We do this by
@@ -291,31 +320,33 @@ export class TerminalProcess extends BaseTerminalProcess {
291320 }
292321
293322 if ( raceResult === IDLE_SENTINEL ) {
294- if ( ! shellExecutionStarted ) {
295- const elapsedMs = Date . now ( ) - streamStartedAt
296- const shellInitTimeout = Terminal . getShellIntegrationTimeout ( )
297-
298- if ( elapsedMs < shellInitTimeout ) {
299- // onDidStartTerminalShellExecution hasn't fired yet — the shell is
300- // still initializing. Don't self-finalize; re-arm the idle timer
301- // and keep waiting (the DONE_SENTINEL or a real chunk will break
302- // us out once the command actually starts running).
303- console . info (
304- `[Terminal Process] idle timeout fired but shell execution not started yet — waiting for shell init (${ elapsedMs } ms elapsed)` ,
305- )
306- continue
307- }
308-
309- // Shell integration timeout exceeded and onDidStartTerminalShellExecution
310- // never fired — something went wrong during shell init. Fall through to
311- // self-finalize so we don't wait forever.
323+ if ( shellExecutionStarted ) {
324+ // The command is confirmed running (shell_execution_started fired). A
325+ // silent command like `sleep 5` can legitimately produce zero output —
326+ // elapsed time alone is not proof of completion. Re-arm the idle timer
327+ // and keep waiting for a real chunk, the D marker, or the end event.
312328 console . info (
313- `[Terminal Process] shell execution never started after ${ elapsedMs } ms — self-finalizing ` ,
329+ `[Terminal Process] idle timeout fired but shell execution is running — re-arming ( ${ chunkCount } chunks so far) ` ,
314330 )
331+ continue
315332 }
316333
317- // No data arrived within the idle window and onDidEndTerminalShellExecution
318- // hasn't fired either — VSCode is not going to tell us. Self-finalize.
334+ const elapsedMs = Date . now ( ) - streamStartedAt
335+ const shellInitTimeout = Terminal . getShellIntegrationTimeout ( )
336+
337+ if ( elapsedMs < shellInitTimeout ) {
338+ // onDidStartTerminalShellExecution hasn't fired yet — the shell is
339+ // still initializing. Don't self-finalize; re-arm the idle timer
340+ // and keep waiting.
341+ console . info (
342+ `[Terminal Process] idle timeout fired but shell execution not started yet — waiting for shell init (${ elapsedMs } ms elapsed)` ,
343+ )
344+ continue
345+ }
346+
347+ // Shell integration timeout exceeded and onDidStartTerminalShellExecution
348+ // never fired — something went wrong during shell init. Self-finalize.
349+ console . info ( `[Terminal Process] shell execution never started after ${ elapsedMs } ms — self-finalizing` )
319350 idleTimedOut = true
320351 console . info (
321352 `[Terminal Process] idle timeout (${ IDLE_TIMEOUT_MS } ms) after ${ chunkCount } chunk(s) — self-finalizing` ,
@@ -421,15 +452,7 @@ export class TerminalProcess extends BaseTerminalProcess {
421452
422453 this . terminal . activeShellExecution = undefined
423454
424- // Clean up the temp script file if one was written for this command.
425- if ( this . scriptPath ) {
426- try {
427- fs . unlinkSync ( this . scriptPath )
428- } catch {
429- // Best-effort: if it's already gone, that's fine.
430- }
431- this . scriptPath = undefined
432- }
455+ this . cleanupScriptFile ( )
433456
434457 this . isHot = false
435458
@@ -493,7 +516,21 @@ export class TerminalProcess extends BaseTerminalProcess {
493516 // We need a known shell executable to run it — if we can't determine one,
494517 // fall back to { ... } wrapping (accepts the VSCode zero-chunk bug as a
495518 // lesser evil than invoking a non-existent "sh" on Windows).
496- const shellExe = Terminal . getProfileShell ( ) ?. shellPath
519+ //
520+ // Try the Zoo Code profile first; if unset, fall back to the VS Code default
521+ // profile so users who haven't configured a Zoo Code profile override still
522+ // get the temp-file path instead of { ... } wrapping.
523+ let shellExe = Terminal . getProfileShell ( ) ?. shellPath
524+ if ( ! shellExe ) {
525+ const defaultProfileName = Terminal . getConfiguredDefaultProfileName ( )
526+ if ( defaultProfileName ) {
527+ const profiles = Terminal . getConfiguredProfiles ( )
528+ const profile = profiles ?. [ defaultProfileName ] as { path ?: string | string [ ] } | null | undefined
529+ if ( profile ) {
530+ shellExe = Terminal . resolveProfilePath ( profile . path )
531+ }
532+ }
533+ }
497534 if ( ! shellExe ) {
498535 return `{\n${ command } \n}`
499536 }
@@ -509,6 +546,17 @@ export class TerminalProcess extends BaseTerminalProcess {
509546
510547 private scriptPath : string | undefined
511548
549+ private cleanupScriptFile ( ) {
550+ if ( this . scriptPath ) {
551+ try {
552+ fs . unlinkSync ( this . scriptPath )
553+ } catch {
554+ // Best-effort: if it's already gone, that's fine.
555+ }
556+ this . scriptPath = undefined
557+ }
558+ }
559+
512560 public override continue ( ) {
513561 this . emitRemainingBufferIfListening ( )
514562 this . isListening = false
0 commit comments