command-stream 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/$.mjs CHANGED
@@ -5,9 +5,7 @@
5
5
  // 3. EventEmitter: $`command`.on('data', chunk => ...).on('end', result => ...)
6
6
  // 4. Stream access: $`command`.stdout, $`command`.stderr
7
7
 
8
- import { createRequire } from 'module';
9
8
  import cp from 'child_process';
10
- import fs from 'fs';
11
9
  import path from 'path';
12
10
 
13
11
  const isBun = typeof globalThis.Bun !== 'undefined';
@@ -54,8 +52,203 @@ function isInteractiveCommand(command) {
54
52
  let parentStreamsMonitored = false;
55
53
  const activeProcessRunners = new Set();
56
54
 
55
+ // Track if SIGINT handler has been installed
56
+ let sigintHandlerInstalled = false;
57
+ let sigintHandler = null; // Store reference to remove it later
58
+
59
+ function installSignalHandlers() {
60
+ // Check if our handler is actually installed (not just the flag)
61
+ // This is more robust against test cleanup that manually removes listeners
62
+ const currentListeners = process.listeners('SIGINT');
63
+ const hasOurHandler = currentListeners.some(l => {
64
+ const str = l.toString();
65
+ return str.includes('activeProcessRunners') &&
66
+ str.includes('ProcessRunner') &&
67
+ str.includes('activeChildren');
68
+ });
69
+
70
+ if (sigintHandlerInstalled && hasOurHandler) {
71
+ trace('SignalHandler', () => 'SIGINT handler already installed, skipping');
72
+ return;
73
+ }
74
+
75
+ // Reset flag if handler was removed externally
76
+ if (sigintHandlerInstalled && !hasOurHandler) {
77
+ trace('SignalHandler', () => 'SIGINT handler flag was set but handler missing, resetting');
78
+ sigintHandlerInstalled = false;
79
+ sigintHandler = null;
80
+ }
81
+
82
+ trace('SignalHandler', () => `Installing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}`);
83
+ sigintHandlerInstalled = true;
84
+
85
+ // Forward SIGINT to all active child processes
86
+ // The parent process continues running - it's up to the parent to decide what to do
87
+ sigintHandler = () => {
88
+ // Check for other handlers immediately at the start, before doing any processing
89
+ const currentListeners = process.listeners('SIGINT');
90
+ const hasOtherHandlers = currentListeners.length > 1;
91
+
92
+ trace('ProcessRunner', () => `SIGINT handler triggered - checking active processes`);
93
+
94
+ // Count active processes (both child processes and virtual commands)
95
+ const activeChildren = [];
96
+ for (const runner of activeProcessRunners) {
97
+ if (!runner.finished) {
98
+ // Real child process
99
+ if (runner.child && runner.child.pid) {
100
+ activeChildren.push(runner);
101
+ trace('ProcessRunner', () => `Found active child: PID ${runner.child.pid}, command: ${runner.spec?.command || 'unknown'}`);
102
+ }
103
+ // Virtual command (no child process but still active)
104
+ else if (!runner.child) {
105
+ activeChildren.push(runner);
106
+ trace('ProcessRunner', () => `Found active virtual command: ${runner.spec?.command || 'unknown'}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ trace('ProcessRunner', () => `Parent received SIGINT | ${JSON.stringify({
112
+ activeChildrenCount: activeChildren.length,
113
+ hasOtherHandlers,
114
+ platform: process.platform,
115
+ pid: process.pid,
116
+ ppid: process.ppid,
117
+ activeCommands: activeChildren.map(r => ({
118
+ hasChild: !!r.child,
119
+ childPid: r.child?.pid,
120
+ hasVirtualGenerator: !!r._virtualGenerator,
121
+ finished: r.finished,
122
+ command: r.spec?.command?.slice(0, 30)
123
+ }))
124
+ }, null, 2)}`);
125
+
126
+ // Only handle SIGINT if we have active child processes
127
+ // Otherwise, let other handlers or default behavior handle it
128
+ if (activeChildren.length === 0) {
129
+ trace('ProcessRunner', () => `No active children - skipping SIGINT forwarding, letting other handlers handle it`);
130
+ return; // Let other handlers or default behavior handle it
131
+ }
132
+
133
+ trace('ProcessRunner', () => `Beginning SIGINT forwarding to ${activeChildren.length} active processes`);
134
+
135
+ // Forward signal to all active processes (child processes and virtual commands)
136
+ for (const runner of activeChildren) {
137
+ try {
138
+ if (runner.child && runner.child.pid) {
139
+ // Real child process - send SIGINT to it
140
+ trace('ProcessRunner', () => `Sending SIGINT to child process | ${JSON.stringify({
141
+ pid: runner.child.pid,
142
+ killed: runner.child.killed,
143
+ runtime: isBun ? 'Bun' : 'Node.js',
144
+ command: runner.spec?.command?.slice(0, 50)
145
+ }, null, 2)}`);
146
+
147
+ if (isBun) {
148
+ runner.child.kill('SIGINT');
149
+ trace('ProcessRunner', () => `Bun: SIGINT sent to PID ${runner.child.pid}`);
150
+ } else {
151
+ // Send to process group if detached, otherwise to process directly
152
+ try {
153
+ process.kill(-runner.child.pid, 'SIGINT');
154
+ trace('ProcessRunner', () => `Node.js: SIGINT sent to process group -${runner.child.pid}`);
155
+ } catch (err) {
156
+ trace('ProcessRunner', () => `Node.js: Process group kill failed, trying direct: ${err.message}`);
157
+ process.kill(runner.child.pid, 'SIGINT');
158
+ trace('ProcessRunner', () => `Node.js: SIGINT sent directly to PID ${runner.child.pid}`);
159
+ }
160
+ }
161
+ } else {
162
+ // Virtual command - cancel it using the runner's kill method
163
+ trace('ProcessRunner', () => `Cancelling virtual command | ${JSON.stringify({
164
+ hasChild: !!runner.child,
165
+ hasVirtualGenerator: !!runner._virtualGenerator,
166
+ finished: runner.finished,
167
+ cancelled: runner._cancelled,
168
+ command: runner.spec?.command?.slice(0, 50)
169
+ }, null, 2)}`);
170
+ runner.kill('SIGINT');
171
+ trace('ProcessRunner', () => `Virtual command kill() called`);
172
+ }
173
+ } catch (err) {
174
+ trace('ProcessRunner', () => `Error in SIGINT handler for runner | ${JSON.stringify({
175
+ error: err.message,
176
+ stack: err.stack?.slice(0, 300),
177
+ hasPid: !!(runner.child && runner.child.pid),
178
+ pid: runner.child?.pid,
179
+ command: runner.spec?.command?.slice(0, 50)
180
+ }, null, 2)}`);
181
+ }
182
+ }
183
+
184
+ // We've forwarded SIGINT to all active processes/commands
185
+ // Use the hasOtherHandlers flag we calculated at the start (before any processing)
186
+ trace('ProcessRunner', () => `SIGINT forwarded to ${activeChildren.length} active processes, other handlers: ${hasOtherHandlers}`);
187
+
188
+ if (!hasOtherHandlers) {
189
+ // No other handlers - we should exit like a proper shell
190
+ trace('ProcessRunner', () => `No other SIGINT handlers, exiting with code 130`);
191
+ // Ensure stdout/stderr are flushed before exiting
192
+ if (process.stdout && typeof process.stdout.write === 'function') {
193
+ process.stdout.write('', () => {
194
+ process.exit(130); // 128 + 2 (SIGINT)
195
+ });
196
+ } else {
197
+ process.exit(130); // 128 + 2 (SIGINT)
198
+ }
199
+ } else {
200
+ // Other handlers exist - let them handle the exit completely
201
+ // Do NOT call process.exit() ourselves when other handlers are present
202
+ trace('ProcessRunner', () => `Other SIGINT handlers present, letting them handle the exit completely`);
203
+ }
204
+ };
205
+
206
+ process.on('SIGINT', sigintHandler);
207
+ }
208
+
209
+ function uninstallSignalHandlers() {
210
+ if (!sigintHandlerInstalled || !sigintHandler) {
211
+ trace('SignalHandler', () => 'SIGINT handler not installed or missing, skipping removal');
212
+ return;
213
+ }
214
+
215
+ trace('SignalHandler', () => `Removing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}`);
216
+ process.removeListener('SIGINT', sigintHandler);
217
+ sigintHandlerInstalled = false;
218
+ sigintHandler = null;
219
+ }
220
+
221
+ // Force cleanup of all command-stream SIGINT handlers and state - for testing
222
+ function forceCleanupAll() {
223
+ // Remove all command-stream SIGINT handlers
224
+ const sigintListeners = process.listeners('SIGINT');
225
+ const commandStreamListeners = sigintListeners.filter(l => {
226
+ const str = l.toString();
227
+ return str.includes('activeProcessRunners') ||
228
+ str.includes('ProcessRunner') ||
229
+ str.includes('activeChildren');
230
+ });
231
+
232
+ commandStreamListeners.forEach(listener => {
233
+ process.removeListener('SIGINT', listener);
234
+ });
235
+
236
+ // Clear activeProcessRunners
237
+ activeProcessRunners.clear();
238
+
239
+ // Reset flags
240
+ sigintHandlerInstalled = false;
241
+ sigintHandler = null;
242
+
243
+ trace('SignalHandler', () => `Force cleanup completed - removed ${commandStreamListeners.length} handlers`);
244
+ }
245
+
57
246
  function monitorParentStreams() {
58
- if (parentStreamsMonitored) return;
247
+ if (parentStreamsMonitored) {
248
+ trace('StreamMonitor', () => 'Parent streams already monitored, skipping');
249
+ return;
250
+ }
251
+ trace('StreamMonitor', () => 'Setting up parent stream monitoring');
59
252
  parentStreamsMonitored = true;
60
253
 
61
254
  const checkParentStream = (stream, name) => {
@@ -279,78 +472,6 @@ const StreamUtils = {
279
472
  }
280
473
  };
281
474
 
282
- // Virtual command utility functions for consistent behavior and error handling
283
- const VirtualUtils = {
284
- /**
285
- * Create standardized error response for missing operands
286
- */
287
- missingOperandError(commandName, customMessage = null) {
288
- const message = customMessage || `${commandName}: missing operand`;
289
- return { stderr: message, code: 1 };
290
- },
291
-
292
- /**
293
- * Create standardized error response for invalid arguments
294
- */
295
- invalidArgumentError(commandName, message) {
296
- return { stderr: `${commandName}: ${message}`, code: 1 };
297
- },
298
-
299
- /**
300
- * Create standardized success response
301
- */
302
- success(stdout = '', code = 0) {
303
- return { stdout, stderr: '', code };
304
- },
305
-
306
- /**
307
- * Create standardized error response
308
- */
309
- error(stderr = '', code = 1) {
310
- return { stdout: '', stderr, code };
311
- },
312
-
313
- /**
314
- * Validate that command has required number of arguments
315
- */
316
- validateArgs(args, minCount, commandName) {
317
- if (args.length < minCount) {
318
- if (minCount === 1) {
319
- return this.missingOperandError(commandName);
320
- } else {
321
- return this.invalidArgumentError(commandName, `requires at least ${minCount} arguments`);
322
- }
323
- }
324
- return null; // No error
325
- },
326
-
327
- /**
328
- * Resolve file path with optional cwd parameter
329
- */
330
- resolvePath(filePath, cwd = null) {
331
- const basePath = cwd || process.cwd();
332
- return path.isAbsolute(filePath) ? filePath : path.resolve(basePath, filePath);
333
- },
334
-
335
- /**
336
- * Safe file system operation wrapper
337
- */
338
- async safeFsOperation(operation, errorPrefix) {
339
- try {
340
- return await operation();
341
- } catch (error) {
342
- return this.error(`${errorPrefix}: ${error.message}`);
343
- }
344
- },
345
-
346
- /**
347
- * Create async wrapper for Promise-based operations
348
- */
349
- createAsyncWrapper(promiseFactory) {
350
- return new Promise(promiseFactory);
351
- }
352
- };
353
-
354
475
  let globalShellSettings = {
355
476
  errexit: false, // set -e equivalent: exit on error
356
477
  verbose: false, // set -v equivalent: print commands
@@ -392,10 +513,25 @@ class StreamEmitter {
392
513
  return this;
393
514
  }
394
515
 
516
+ once(event, listener) {
517
+ const onceWrapper = (...args) => {
518
+ this.off(event, onceWrapper);
519
+ listener(...args);
520
+ };
521
+ return this.on(event, onceWrapper);
522
+ }
523
+
395
524
  emit(event, ...args) {
396
525
  const eventListeners = this.listeners.get(event);
526
+ trace('StreamEmitter', () => `Emitting event | ${JSON.stringify({
527
+ event,
528
+ hasListeners: !!eventListeners,
529
+ listenerCount: eventListeners?.length || 0
530
+ })}`);
397
531
  if (eventListeners) {
398
- for (const listener of eventListeners) {
532
+ // Create a copy to avoid issues if listeners modify the array
533
+ const listenersToCall = [...eventListeners];
534
+ for (const listener of listenersToCall) {
399
535
  listener(...args);
400
536
  }
401
537
  }
@@ -498,20 +634,355 @@ class ProcessRunner extends StreamEmitter {
498
634
  this._mode = null; // 'async' or 'sync'
499
635
 
500
636
  this._cancelled = false;
637
+ this._cancellationSignal = null; // Track which signal caused cancellation
501
638
  this._virtualGenerator = null;
502
639
  this._abortController = new AbortController();
503
640
 
504
641
  activeProcessRunners.add(this);
642
+
643
+ // Ensure parent stream monitoring is set up for all ProcessRunners
644
+ monitorParentStreams();
645
+
646
+ trace('ProcessRunner', () => `Added to activeProcessRunners | ${JSON.stringify({
647
+ command: this.spec?.command || 'unknown',
648
+ totalActive: activeProcessRunners.size
649
+ }, null, 2)}`);
650
+ installSignalHandlers();
505
651
 
506
- // Track finished state changes to trigger cleanup
507
- this._finished = false;
652
+ this.finished = false;
508
653
  }
509
654
 
510
- get finished() {
511
- return this._finished;
655
+ // Stream property getters for child process streams (null for virtual commands)
656
+ get stdout() {
657
+ return this.child ? this.child.stdout : null;
658
+ }
659
+
660
+ get stderr() {
661
+ return this.child ? this.child.stderr : null;
662
+ }
663
+
664
+ get stdin() {
665
+ return this.child ? this.child.stdin : null;
666
+ }
667
+
668
+ // Issue #33: New streaming interfaces
669
+ _autoStartIfNeeded(reason) {
670
+ if (!this.started && !this.finished) {
671
+ trace('ProcessRunner', () => `Auto-starting process due to ${reason}`);
672
+ this.start({ mode: 'async', stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
673
+ }
674
+ }
675
+
676
+ get streams() {
677
+ const self = this;
678
+ return {
679
+ get stdin() {
680
+ trace('ProcessRunner.streams', () => `stdin access | ${JSON.stringify({
681
+ hasChild: !!self.child,
682
+ hasStdin: !!(self.child && self.child.stdin),
683
+ started: self.started,
684
+ finished: self.finished,
685
+ hasPromise: !!self.promise,
686
+ command: self.spec?.command?.slice(0, 50)
687
+ }, null, 2)}`);
688
+
689
+ self._autoStartIfNeeded('streams.stdin access');
690
+
691
+ // Streams are available immediately after spawn, or null if not piped
692
+ // Return the stream directly if available, otherwise ensure process starts
693
+ if (self.child && self.child.stdin) {
694
+ trace('ProcessRunner.streams', () => 'stdin: returning existing stream');
695
+ return self.child.stdin;
696
+ }
697
+ if (self.finished) {
698
+ trace('ProcessRunner.streams', () => 'stdin: process finished, returning null');
699
+ return null;
700
+ }
701
+
702
+ // For virtual commands, there's no child process
703
+ // Exception: virtual commands with stdin: "pipe" will fallback to real commands
704
+ const isVirtualCommand = self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]));
705
+ const willFallbackToReal = isVirtualCommand && self.options.stdin === 'pipe';
706
+
707
+ if (isVirtualCommand && !willFallbackToReal) {
708
+ trace('ProcessRunner.streams', () => 'stdin: virtual command, returning null');
709
+ return null;
710
+ }
711
+
712
+ // If not started, start it and wait for child to be created (not for completion!)
713
+ if (!self.started) {
714
+ trace('ProcessRunner.streams', () => 'stdin: not started, starting and waiting for child');
715
+ // Start the process
716
+ self._startAsync();
717
+ // Wait for child to be created using async iteration
718
+ return new Promise((resolve) => {
719
+ const checkForChild = () => {
720
+ if (self.child && self.child.stdin) {
721
+ resolve(self.child.stdin);
722
+ } else if (self.finished || self._virtualGenerator) {
723
+ resolve(null);
724
+ } else {
725
+ // Use setImmediate to check again in next event loop iteration
726
+ setImmediate(checkForChild);
727
+ }
728
+ };
729
+ setImmediate(checkForChild);
730
+ });
731
+ }
732
+
733
+ // Process is starting - wait for child to appear
734
+ if (self.promise && !self.child) {
735
+ trace('ProcessRunner.streams', () => 'stdin: process starting, waiting for child');
736
+ return new Promise((resolve) => {
737
+ const checkForChild = () => {
738
+ if (self.child && self.child.stdin) {
739
+ resolve(self.child.stdin);
740
+ } else if (self.finished || self._virtualGenerator) {
741
+ resolve(null);
742
+ } else {
743
+ setImmediate(checkForChild);
744
+ }
745
+ };
746
+ setImmediate(checkForChild);
747
+ });
748
+ }
749
+
750
+ trace('ProcessRunner.streams', () => 'stdin: returning null (no conditions met)');
751
+ return null;
752
+ },
753
+ get stdout() {
754
+ trace('ProcessRunner.streams', () => `stdout access | ${JSON.stringify({
755
+ hasChild: !!self.child,
756
+ hasStdout: !!(self.child && self.child.stdout),
757
+ started: self.started,
758
+ finished: self.finished,
759
+ hasPromise: !!self.promise,
760
+ command: self.spec?.command?.slice(0, 50)
761
+ }, null, 2)}`);
762
+
763
+ self._autoStartIfNeeded('streams.stdout access');
764
+
765
+ if (self.child && self.child.stdout) {
766
+ trace('ProcessRunner.streams', () => 'stdout: returning existing stream');
767
+ return self.child.stdout;
768
+ }
769
+ if (self.finished) {
770
+ trace('ProcessRunner.streams', () => 'stdout: process finished, returning null');
771
+ return null;
772
+ }
773
+
774
+ // For virtual commands, there's no child process
775
+ if (self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]))) {
776
+ trace('ProcessRunner.streams', () => 'stdout: virtual command, returning null');
777
+ return null;
778
+ }
779
+
780
+ if (!self.started) {
781
+ trace('ProcessRunner.streams', () => 'stdout: not started, starting and waiting for child');
782
+ self._startAsync();
783
+ return new Promise((resolve) => {
784
+ const checkForChild = () => {
785
+ if (self.child && self.child.stdout) {
786
+ resolve(self.child.stdout);
787
+ } else if (self.finished || self._virtualGenerator) {
788
+ resolve(null);
789
+ } else {
790
+ setImmediate(checkForChild);
791
+ }
792
+ };
793
+ setImmediate(checkForChild);
794
+ });
795
+ }
796
+
797
+ if (self.promise && !self.child) {
798
+ trace('ProcessRunner.streams', () => 'stdout: process starting, waiting for child');
799
+ return new Promise((resolve) => {
800
+ const checkForChild = () => {
801
+ if (self.child && self.child.stdout) {
802
+ resolve(self.child.stdout);
803
+ } else if (self.finished || self._virtualGenerator) {
804
+ resolve(null);
805
+ } else {
806
+ setImmediate(checkForChild);
807
+ }
808
+ };
809
+ setImmediate(checkForChild);
810
+ });
811
+ }
812
+
813
+ trace('ProcessRunner.streams', () => 'stdout: returning null (no conditions met)');
814
+ return null;
815
+ },
816
+ get stderr() {
817
+ trace('ProcessRunner.streams', () => `stderr access | ${JSON.stringify({
818
+ hasChild: !!self.child,
819
+ hasStderr: !!(self.child && self.child.stderr),
820
+ started: self.started,
821
+ finished: self.finished,
822
+ hasPromise: !!self.promise,
823
+ command: self.spec?.command?.slice(0, 50)
824
+ }, null, 2)}`);
825
+
826
+ self._autoStartIfNeeded('streams.stderr access');
827
+
828
+ if (self.child && self.child.stderr) {
829
+ trace('ProcessRunner.streams', () => 'stderr: returning existing stream');
830
+ return self.child.stderr;
831
+ }
832
+ if (self.finished) {
833
+ trace('ProcessRunner.streams', () => 'stderr: process finished, returning null');
834
+ return null;
835
+ }
836
+
837
+ // For virtual commands, there's no child process
838
+ if (self._virtualGenerator || (self.spec && self.spec.command && virtualCommands.has(self.spec.command.split(' ')[0]))) {
839
+ trace('ProcessRunner.streams', () => 'stderr: virtual command, returning null');
840
+ return null;
841
+ }
842
+
843
+ if (!self.started) {
844
+ trace('ProcessRunner.streams', () => 'stderr: not started, starting and waiting for child');
845
+ self._startAsync();
846
+ return new Promise((resolve) => {
847
+ const checkForChild = () => {
848
+ if (self.child && self.child.stderr) {
849
+ resolve(self.child.stderr);
850
+ } else if (self.finished || self._virtualGenerator) {
851
+ resolve(null);
852
+ } else {
853
+ setImmediate(checkForChild);
854
+ }
855
+ };
856
+ setImmediate(checkForChild);
857
+ });
858
+ }
859
+
860
+ if (self.promise && !self.child) {
861
+ trace('ProcessRunner.streams', () => 'stderr: process starting, waiting for child');
862
+ return new Promise((resolve) => {
863
+ const checkForChild = () => {
864
+ if (self.child && self.child.stderr) {
865
+ resolve(self.child.stderr);
866
+ } else if (self.finished || self._virtualGenerator) {
867
+ resolve(null);
868
+ } else {
869
+ setImmediate(checkForChild);
870
+ }
871
+ };
872
+ setImmediate(checkForChild);
873
+ });
874
+ }
875
+
876
+ trace('ProcessRunner.streams', () => 'stderr: returning null (no conditions met)');
877
+ return null;
878
+ }
879
+ };
880
+ }
881
+
882
+ get buffers() {
883
+ const self = this;
884
+ return {
885
+ get stdin() {
886
+ self._autoStartIfNeeded('buffers.stdin access');
887
+ if (self.finished && self.result) {
888
+ return Buffer.from(self.result.stdin || '', 'utf8');
889
+ }
890
+ // Return promise if not finished
891
+ return self.then ? self.then(result => Buffer.from(result.stdin || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
892
+ },
893
+ get stdout() {
894
+ self._autoStartIfNeeded('buffers.stdout access');
895
+ if (self.finished && self.result) {
896
+ return Buffer.from(self.result.stdout || '', 'utf8');
897
+ }
898
+ // Return promise if not finished
899
+ return self.then ? self.then(result => Buffer.from(result.stdout || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
900
+ },
901
+ get stderr() {
902
+ self._autoStartIfNeeded('buffers.stderr access');
903
+ if (self.finished && self.result) {
904
+ return Buffer.from(self.result.stderr || '', 'utf8');
905
+ }
906
+ // Return promise if not finished
907
+ return self.then ? self.then(result => Buffer.from(result.stderr || '', 'utf8')) : Promise.resolve(Buffer.alloc(0));
908
+ }
909
+ };
910
+ }
911
+
912
+ get strings() {
913
+ const self = this;
914
+ return {
915
+ get stdin() {
916
+ self._autoStartIfNeeded('strings.stdin access');
917
+ if (self.finished && self.result) {
918
+ return self.result.stdin || '';
919
+ }
920
+ // Return promise if not finished
921
+ return self.then ? self.then(result => result.stdin || '') : Promise.resolve('');
922
+ },
923
+ get stdout() {
924
+ self._autoStartIfNeeded('strings.stdout access');
925
+ if (self.finished && self.result) {
926
+ return self.result.stdout || '';
927
+ }
928
+ // Return promise if not finished
929
+ return self.then ? self.then(result => result.stdout || '') : Promise.resolve('');
930
+ },
931
+ get stderr() {
932
+ self._autoStartIfNeeded('strings.stderr access');
933
+ if (self.finished && self.result) {
934
+ return self.result.stderr || '';
935
+ }
936
+ // Return promise if not finished
937
+ return self.then ? self.then(result => result.stderr || '') : Promise.resolve('');
938
+ }
939
+ };
940
+ }
941
+
942
+
943
+ // Centralized method to properly finish a process with correct event emission order
944
+ finish(result) {
945
+ trace('ProcessRunner', () => `finish() called | ${JSON.stringify({
946
+ alreadyFinished: this.finished,
947
+ resultCode: result?.code,
948
+ hasStdout: !!result?.stdout,
949
+ hasStderr: !!result?.stderr,
950
+ command: this.spec?.command?.slice(0, 50)
951
+ }, null, 2)}`);
952
+
953
+ // Make finish() idempotent - safe to call multiple times
954
+ if (this.finished) {
955
+ trace('ProcessRunner', () => `Already finished, returning existing result`);
956
+ return this.result || result;
957
+ }
958
+
959
+ // Store result
960
+ this.result = result;
961
+ trace('ProcessRunner', () => `Result stored, about to emit events`);
962
+
963
+ // Emit completion events BEFORE setting finished to prevent _cleanup() from clearing listeners
964
+ this.emit('end', result);
965
+ trace('ProcessRunner', () => `'end' event emitted`);
966
+ this.emit('exit', result.code);
967
+ trace('ProcessRunner', () => `'exit' event emitted with code ${result.code}`);
968
+
969
+ // Set finished after events are emitted
970
+ this.finished = true;
971
+ trace('ProcessRunner', () => `Marked as finished, calling cleanup`);
972
+
973
+ // Trigger cleanup now that process is finished
974
+ this._cleanup();
975
+ trace('ProcessRunner', () => `Cleanup completed`);
976
+
977
+ return result;
512
978
  }
513
979
 
514
980
  _emitProcessedData(type, buf) {
981
+ // Don't emit data if we've been cancelled
982
+ if (this._cancelled) {
983
+ trace('ProcessRunner', () => 'Skipping data emission - process cancelled');
984
+ return;
985
+ }
515
986
  const processedBuf = processOutput(buf, this.options.ansi);
516
987
  this.emit(type, processedBuf);
517
988
  this.emit('data', { type, data: processedBuf });
@@ -531,6 +1002,36 @@ class ProcessRunner extends StreamEmitter {
531
1002
 
532
1003
  // Forward stdin data to child process
533
1004
  const onData = (chunk) => {
1005
+ // Check for CTRL+C (ASCII code 3)
1006
+ if (chunk[0] === 3) {
1007
+ trace('ProcessRunner', () => 'CTRL+C detected, sending SIGINT to child process');
1008
+ // Send SIGINT to the child process
1009
+ if (this.child && this.child.pid) {
1010
+ try {
1011
+ if (isBun) {
1012
+ this.child.kill('SIGINT');
1013
+ } else {
1014
+ // In Node.js, send SIGINT to the process group if detached
1015
+ // or to the process directly if not
1016
+ if (this.child.pid > 0) {
1017
+ try {
1018
+ // Try process group first if detached
1019
+ process.kill(-this.child.pid, 'SIGINT');
1020
+ } catch (err) {
1021
+ // Fall back to direct process
1022
+ process.kill(this.child.pid, 'SIGINT');
1023
+ }
1024
+ }
1025
+ }
1026
+ } catch (err) {
1027
+ trace('ProcessRunner', () => `Error sending SIGINT: ${err.message}`);
1028
+ }
1029
+ }
1030
+ // Don't forward CTRL+C to stdin, just handle the signal
1031
+ return;
1032
+ }
1033
+
1034
+ // Forward other input to child stdin
534
1035
  if (this.child.stdin) {
535
1036
  if (isBun && this.child.stdin.write) {
536
1037
  this.child.stdin.write(chunk);
@@ -564,17 +1065,15 @@ class ProcessRunner extends StreamEmitter {
564
1065
  }
565
1066
  }
566
1067
 
567
- set finished(value) {
568
- if (value === true && this._finished === false) {
569
- this._finished = true;
570
- this._cleanup(); // Trigger cleanup when process finishes
571
- } else {
572
- this._finished = value;
573
- }
574
- }
575
1068
 
576
1069
  _handleParentStreamClosure() {
577
- if (this.finished || this._cancelled) return;
1070
+ if (this.finished || this._cancelled) {
1071
+ trace('ProcessRunner', () => `Parent stream closure ignored | ${JSON.stringify({
1072
+ finished: this.finished,
1073
+ cancelled: this._cancelled
1074
+ })}`);
1075
+ return;
1076
+ }
578
1077
 
579
1078
  trace('ProcessRunner', () => `Handling parent stream closure | ${JSON.stringify({
580
1079
  started: this.started,
@@ -600,32 +1099,140 @@ class ProcessRunner extends StreamEmitter {
600
1099
  writer.close().catch(() => { }); // Ignore close errors
601
1100
  }
602
1101
 
603
- setTimeout(() => {
1102
+ // Use setImmediate for deferred termination instead of setTimeout
1103
+ setImmediate(() => {
604
1104
  if (this.child && !this.finished) {
605
1105
  trace('ProcessRunner', () => 'Terminating child process after parent stream closure');
606
1106
  if (typeof this.child.kill === 'function') {
607
1107
  this.child.kill('SIGTERM');
608
1108
  }
609
1109
  }
610
- }, 100);
1110
+ });
611
1111
 
612
1112
  } catch (error) {
613
1113
  trace('ProcessRunner', () => `Error during graceful shutdown | ${JSON.stringify({ error: error.message }, null, 2)}`);
614
1114
  }
615
1115
  }
616
1116
 
617
- activeProcessRunners.delete(this);
1117
+ this._cleanup();
618
1118
  }
619
1119
 
620
1120
  _cleanup() {
1121
+ trace('ProcessRunner', () => `_cleanup() called | ${JSON.stringify({
1122
+ wasActiveBeforeCleanup: activeProcessRunners.has(this),
1123
+ totalActiveBefore: activeProcessRunners.size,
1124
+ finished: this.finished,
1125
+ hasChild: !!this.child,
1126
+ command: this.spec?.command?.slice(0, 50)
1127
+ }, null, 2)}`);
1128
+
1129
+ const wasActive = activeProcessRunners.has(this);
621
1130
  activeProcessRunners.delete(this);
1131
+
1132
+ if (wasActive) {
1133
+ trace('ProcessRunner', () => `Removed from activeProcessRunners | ${JSON.stringify({
1134
+ command: this.spec?.command || 'unknown',
1135
+ totalActiveAfter: activeProcessRunners.size,
1136
+ remainingCommands: Array.from(activeProcessRunners).map(r => r.spec?.command?.slice(0, 30))
1137
+ }, null, 2)}`);
1138
+ } else {
1139
+ trace('ProcessRunner', () => `Was not in activeProcessRunners (already cleaned up)`);
1140
+ }
1141
+
1142
+ // If this is a pipeline runner, also clean up the source and destination
1143
+ if (this.spec?.mode === 'pipeline') {
1144
+ trace('ProcessRunner', () => 'Cleaning up pipeline components');
1145
+ if (this.spec.source && typeof this.spec.source._cleanup === 'function') {
1146
+ this.spec.source._cleanup();
1147
+ }
1148
+ if (this.spec.destination && typeof this.spec.destination._cleanup === 'function') {
1149
+ this.spec.destination._cleanup();
1150
+ }
1151
+ }
1152
+
1153
+ // If no more active ProcessRunners, remove the SIGINT handler
1154
+ if (activeProcessRunners.size === 0) {
1155
+ uninstallSignalHandlers();
1156
+ }
1157
+
1158
+ // Clean up event listeners from StreamEmitter
1159
+ if (this.listeners) {
1160
+ this.listeners.clear();
1161
+ }
1162
+
1163
+ // Clean up abort controller
1164
+ if (this._abortController) {
1165
+ trace('ProcessRunner', () => `Cleaning up abort controller during cleanup | ${JSON.stringify({
1166
+ wasAborted: this._abortController?.signal?.aborted
1167
+ }, null, 2)}`);
1168
+ try {
1169
+ this._abortController.abort();
1170
+ trace('ProcessRunner', () => `Abort controller aborted successfully during cleanup`);
1171
+ } catch (e) {
1172
+ trace('ProcessRunner', () => `Error aborting controller during cleanup: ${e.message}`);
1173
+ }
1174
+ this._abortController = null;
1175
+ trace('ProcessRunner', () => `Abort controller reference cleared during cleanup`);
1176
+ } else {
1177
+ trace('ProcessRunner', () => `No abort controller to clean up during cleanup`);
1178
+ }
1179
+
1180
+ // Clean up child process reference
1181
+ if (this.child) {
1182
+ trace('ProcessRunner', () => `Cleaning up child process reference | ${JSON.stringify({
1183
+ hasChild: true,
1184
+ childPid: this.child.pid,
1185
+ childKilled: this.child.killed
1186
+ }, null, 2)}`);
1187
+ try {
1188
+ this.child.removeAllListeners?.();
1189
+ trace('ProcessRunner', () => `Child process listeners removed successfully`);
1190
+ } catch (e) {
1191
+ trace('ProcessRunner', () => `Error removing child process listeners: ${e.message}`);
1192
+ }
1193
+ this.child = null;
1194
+ trace('ProcessRunner', () => `Child process reference cleared`);
1195
+ } else {
1196
+ trace('ProcessRunner', () => `No child process reference to clean up`);
1197
+ }
1198
+
1199
+ // Clean up virtual generator
1200
+ if (this._virtualGenerator) {
1201
+ trace('ProcessRunner', () => `Cleaning up virtual generator | ${JSON.stringify({
1202
+ hasReturn: !!this._virtualGenerator.return
1203
+ }, null, 2)}`);
1204
+ try {
1205
+ if (this._virtualGenerator.return) {
1206
+ this._virtualGenerator.return();
1207
+ trace('ProcessRunner', () => `Virtual generator return() called successfully`);
1208
+ }
1209
+ } catch (e) {
1210
+ trace('ProcessRunner', () => `Error calling virtual generator return(): ${e.message}`);
1211
+ }
1212
+ this._virtualGenerator = null;
1213
+ trace('ProcessRunner', () => `Virtual generator reference cleared`);
1214
+ } else {
1215
+ trace('ProcessRunner', () => `No virtual generator to clean up`);
1216
+ }
1217
+
1218
+ trace('ProcessRunner', () => `_cleanup() completed | ${JSON.stringify({
1219
+ totalActiveAfter: activeProcessRunners.size,
1220
+ sigintListenerCount: process.listeners('SIGINT').length
1221
+ }, null, 2)}`);
622
1222
  }
623
1223
 
624
1224
  // Unified start method that can work in both async and sync modes
625
1225
  start(options = {}) {
626
1226
  const mode = options.mode || 'async';
627
1227
 
628
- trace('ProcessRunner', () => `start ENTER | ${JSON.stringify({ mode, options, started: this.started }, null, 2)}`);
1228
+ trace('ProcessRunner', () => `start ENTER | ${JSON.stringify({
1229
+ mode,
1230
+ options,
1231
+ started: this.started,
1232
+ hasPromise: !!this.promise,
1233
+ hasChild: !!this.child,
1234
+ command: this.spec?.command?.slice(0, 50)
1235
+ }, null, 2)}`);
629
1236
 
630
1237
  // Merge new options with existing options before starting
631
1238
  if (Object.keys(options).length > 0 && !this.started) {
@@ -637,6 +1244,79 @@ class ProcessRunner extends StreamEmitter {
637
1244
  // Create a new options object merging the current ones with the new ones
638
1245
  this.options = { ...this.options, ...options };
639
1246
 
1247
+ // Handle external abort signal
1248
+ if (this.options.signal && typeof this.options.signal.addEventListener === 'function') {
1249
+ trace('ProcessRunner', () => `Setting up external abort signal listener | ${JSON.stringify({
1250
+ hasSignal: !!this.options.signal,
1251
+ signalAborted: this.options.signal.aborted,
1252
+ hasInternalController: !!this._abortController,
1253
+ internalAborted: this._abortController?.signal.aborted
1254
+ }, null, 2)}`);
1255
+
1256
+ this.options.signal.addEventListener('abort', () => {
1257
+ trace('ProcessRunner', () => `External abort signal triggered | ${JSON.stringify({
1258
+ externalSignalAborted: this.options.signal.aborted,
1259
+ hasInternalController: !!this._abortController,
1260
+ internalAborted: this._abortController?.signal.aborted,
1261
+ command: this.spec?.command?.slice(0, 50)
1262
+ }, null, 2)}`);
1263
+
1264
+ // Kill the process when abort signal is triggered
1265
+ trace('ProcessRunner', () => `External abort signal received - killing process | ${JSON.stringify({
1266
+ hasChild: !!this.child,
1267
+ childPid: this.child?.pid,
1268
+ finished: this.finished,
1269
+ command: this.spec?.command?.slice(0, 50)
1270
+ }, null, 2)}`);
1271
+ this.kill('SIGTERM');
1272
+ trace('ProcessRunner', () => 'Process kill initiated due to external abort signal');
1273
+
1274
+ if (this._abortController && !this._abortController.signal.aborted) {
1275
+ trace('ProcessRunner', () => 'Aborting internal controller due to external signal');
1276
+ this._abortController.abort();
1277
+ trace('ProcessRunner', () => `Internal controller aborted | ${JSON.stringify({
1278
+ internalAborted: this._abortController?.signal?.aborted
1279
+ }, null, 2)}`);
1280
+ } else {
1281
+ trace('ProcessRunner', () => `Cannot abort internal controller | ${JSON.stringify({
1282
+ hasInternalController: !!this._abortController,
1283
+ internalAlreadyAborted: this._abortController?.signal?.aborted
1284
+ }, null, 2)}`);
1285
+ }
1286
+ });
1287
+
1288
+ // If the external signal is already aborted, abort immediately
1289
+ if (this.options.signal.aborted) {
1290
+ trace('ProcessRunner', () => `External signal already aborted, killing process and aborting internal controller | ${JSON.stringify({
1291
+ hasInternalController: !!this._abortController,
1292
+ internalAborted: this._abortController?.signal.aborted
1293
+ }, null, 2)}`);
1294
+
1295
+ // Kill the process immediately since signal is already aborted
1296
+ trace('ProcessRunner', () => `Signal already aborted - killing process immediately | ${JSON.stringify({
1297
+ hasChild: !!this.child,
1298
+ childPid: this.child?.pid,
1299
+ finished: this.finished,
1300
+ command: this.spec?.command?.slice(0, 50)
1301
+ }, null, 2)}`);
1302
+ this.kill('SIGTERM');
1303
+ trace('ProcessRunner', () => 'Process kill initiated due to pre-aborted signal');
1304
+
1305
+ if (this._abortController && !this._abortController.signal.aborted) {
1306
+ this._abortController.abort();
1307
+ trace('ProcessRunner', () => `Internal controller aborted immediately | ${JSON.stringify({
1308
+ internalAborted: this._abortController?.signal?.aborted
1309
+ }, null, 2)}`);
1310
+ }
1311
+ }
1312
+ } else {
1313
+ trace('ProcessRunner', () => `No external signal to handle | ${JSON.stringify({
1314
+ hasSignal: !!this.options.signal,
1315
+ signalType: typeof this.options.signal,
1316
+ hasAddEventListener: !!(this.options.signal && typeof this.options.signal.addEventListener === 'function')
1317
+ }, null, 2)}`);
1318
+ }
1319
+
640
1320
  // Reinitialize chunks based on updated capture option
641
1321
  if ('capture' in options) {
642
1322
  trace('ProcessRunner', () => `BRANCH: capture => REINIT_CHUNKS | ${JSON.stringify({
@@ -699,6 +1379,9 @@ class ProcessRunner extends StreamEmitter {
699
1379
  this.started = true;
700
1380
  this._mode = 'async';
701
1381
 
1382
+ // Ensure cleanup happens even if execution fails
1383
+ try {
1384
+
702
1385
  const { cwd, env, stdin } = this.options;
703
1386
 
704
1387
  if (this.spec.mode === 'pipeline') {
@@ -725,26 +1408,57 @@ class ProcessRunner extends StreamEmitter {
725
1408
  commandCount: parsed.commands?.length
726
1409
  }, null, 2)}`);
727
1410
  return await this._runPipeline(parsed.commands);
728
- } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd)) {
729
- trace('ProcessRunner', () => `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify({
730
- isVirtual: true,
731
- args: parsed.args
732
- }, null, 2)}`);
733
- return await this._runVirtual(parsed.cmd, parsed.args, this.spec.command);
1411
+ } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual) {
1412
+ // For built-in virtual commands that have real counterparts (like sleep),
1413
+ // skip the virtual version when custom stdin is provided to ensure proper process handling
1414
+ const hasCustomStdin = this.options.stdin &&
1415
+ this.options.stdin !== 'inherit' &&
1416
+ this.options.stdin !== 'ignore';
1417
+
1418
+ // Only bypass for commands that truly need real process behavior with custom stdin
1419
+ // Most commands like 'echo' work fine with virtual implementations even with stdin
1420
+ const commandsThatNeedRealStdin = ['sleep', 'cat']; // Only these really need real processes for stdin
1421
+ const shouldBypassVirtual = hasCustomStdin && commandsThatNeedRealStdin.includes(parsed.cmd);
1422
+
1423
+ if (shouldBypassVirtual) {
1424
+ trace('ProcessRunner', () => `Bypassing built-in virtual command due to custom stdin | ${JSON.stringify({
1425
+ cmd: parsed.cmd,
1426
+ stdin: typeof this.options.stdin
1427
+ }, null, 2)}`);
1428
+ // Fall through to run as real command
1429
+ } else {
1430
+ trace('ProcessRunner', () => `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify({
1431
+ isVirtual: true,
1432
+ args: parsed.args
1433
+ }, null, 2)}`);
1434
+ trace('ProcessRunner', () => `Executing virtual command | ${JSON.stringify({
1435
+ cmd: parsed.cmd,
1436
+ argsLength: parsed.args.length,
1437
+ command: this.spec.command
1438
+ }, null, 2)}`);
1439
+ return await this._runVirtual(parsed.cmd, parsed.args, this.spec.command);
1440
+ }
734
1441
  }
735
1442
  }
736
1443
  }
737
1444
 
738
1445
  const argv = this.spec.mode === 'shell' ? ['sh', '-lc', this.spec.command] : [this.spec.file, ...this.spec.args];
1446
+ trace('ProcessRunner', () => `Constructed argv | ${JSON.stringify({
1447
+ mode: this.spec.mode,
1448
+ argv: argv,
1449
+ originalCommand: this.spec.command
1450
+ }, null, 2)}`);
739
1451
 
740
1452
  if (globalShellSettings.xtrace) {
741
1453
  const traceCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
742
1454
  console.log(`+ ${traceCmd}`);
1455
+ trace('ProcessRunner', () => `xtrace output displayed: + ${traceCmd}`);
743
1456
  }
744
1457
 
745
1458
  if (globalShellSettings.verbose) {
746
1459
  const verboseCmd = this.spec.mode === 'shell' ? this.spec.command : argv.join(' ');
747
1460
  console.log(verboseCmd);
1461
+ trace('ProcessRunner', () => `verbose output displayed: ${verboseCmd}`);
748
1462
  }
749
1463
 
750
1464
  // Detect if this is an interactive command that needs direct TTY access
@@ -754,28 +1468,154 @@ class ProcessRunner extends StreamEmitter {
754
1468
  process.stdout.isTTY === true &&
755
1469
  process.stderr.isTTY === true &&
756
1470
  (this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file));
1471
+
1472
+ trace('ProcessRunner', () => `Interactive command detection | ${JSON.stringify({
1473
+ isInteractive,
1474
+ stdinInherit: stdin === 'inherit',
1475
+ stdinTTY: process.stdin.isTTY,
1476
+ stdoutTTY: process.stdout.isTTY,
1477
+ stderrTTY: process.stderr.isTTY,
1478
+ commandCheck: this.spec.mode === 'shell' ? isInteractiveCommand(this.spec.command) : isInteractiveCommand(this.spec.file)
1479
+ }, null, 2)}`);
757
1480
 
758
1481
  const spawnBun = (argv) => {
1482
+ trace('ProcessRunner', () => `spawnBun: Creating process | ${JSON.stringify({
1483
+ command: argv[0],
1484
+ args: argv.slice(1),
1485
+ isInteractive,
1486
+ cwd,
1487
+ platform: process.platform
1488
+ }, null, 2)}`);
1489
+
759
1490
  if (isInteractive) {
760
1491
  // For interactive commands, use inherit to provide direct TTY access
761
- return Bun.spawn(argv, { cwd, env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
1492
+ trace('ProcessRunner', () => `spawnBun: Using interactive mode with inherited stdio`);
1493
+ const child = Bun.spawn(argv, { cwd, env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
1494
+ trace('ProcessRunner', () => `spawnBun: Interactive process created | ${JSON.stringify({
1495
+ pid: child.pid,
1496
+ killed: child.killed
1497
+ }, null, 2)}`);
1498
+ return child;
762
1499
  }
763
- return Bun.spawn(argv, { cwd, env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
1500
+ // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1501
+ // This allows us to send signals to the entire process group, killing shell and all its children
1502
+ trace('ProcessRunner', () => `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}`);
1503
+ const child = Bun.spawn(argv, {
1504
+ cwd,
1505
+ env,
1506
+ stdin: 'pipe',
1507
+ stdout: 'pipe',
1508
+ stderr: 'pipe',
1509
+ detached: process.platform !== 'win32' // Create process group on Unix-like systems
1510
+ });
1511
+ trace('ProcessRunner', () => `spawnBun: Non-interactive process created | ${JSON.stringify({
1512
+ pid: child.pid,
1513
+ killed: child.killed,
1514
+ hasStdout: !!child.stdout,
1515
+ hasStderr: !!child.stderr,
1516
+ hasStdin: !!child.stdin
1517
+ }, null, 2)}`);
1518
+ return child;
764
1519
  };
765
1520
  const spawnNode = async (argv) => {
1521
+ trace('ProcessRunner', () => `spawnNode: Creating process | ${JSON.stringify({
1522
+ command: argv[0],
1523
+ args: argv.slice(1),
1524
+ isInteractive,
1525
+ cwd,
1526
+ platform: process.platform
1527
+ })}`);
1528
+
766
1529
  if (isInteractive) {
767
1530
  // For interactive commands, use inherit to provide direct TTY access
768
1531
  return cp.spawn(argv[0], argv.slice(1), { cwd, env, stdio: 'inherit' });
769
1532
  }
770
- return cp.spawn(argv[0], argv.slice(1), { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
1533
+ // For non-interactive commands, spawn with detached to create process group (for proper signal handling)
1534
+ // This allows us to send signals to the entire process group
1535
+ const child = cp.spawn(argv[0], argv.slice(1), {
1536
+ cwd,
1537
+ env,
1538
+ stdio: ['pipe', 'pipe', 'pipe'],
1539
+ detached: process.platform !== 'win32' // Create process group on Unix-like systems
1540
+ });
1541
+
1542
+ trace('ProcessRunner', () => `spawnNode: Process created | ${JSON.stringify({
1543
+ pid: child.pid,
1544
+ killed: child.killed,
1545
+ hasStdout: !!child.stdout,
1546
+ hasStderr: !!child.stderr,
1547
+ hasStdin: !!child.stdin
1548
+ })}`);
1549
+
1550
+ return child;
771
1551
  };
772
1552
 
773
1553
  const needsExplicitPipe = stdin !== 'inherit' && stdin !== 'ignore';
774
1554
  const preferNodeForInput = isBun && needsExplicitPipe;
1555
+ trace('ProcessRunner', () => `About to spawn process | ${JSON.stringify({
1556
+ needsExplicitPipe,
1557
+ preferNodeForInput,
1558
+ runtime: isBun ? 'Bun' : 'Node',
1559
+ command: argv[0],
1560
+ args: argv.slice(1)
1561
+ }, null, 2)}`);
775
1562
  this.child = preferNodeForInput ? await spawnNode(argv) : (isBun ? spawnBun(argv) : await spawnNode(argv));
1563
+
1564
+ // Add detailed logging for CI debugging
1565
+ if (this.child) {
1566
+ trace('ProcessRunner', () => `Child process created | ${JSON.stringify({
1567
+ pid: this.child.pid,
1568
+ detached: this.child.options?.detached,
1569
+ killed: this.child.killed,
1570
+ exitCode: this.child.exitCode,
1571
+ signalCode: this.child.signalCode,
1572
+ hasStdout: !!this.child.stdout,
1573
+ hasStderr: !!this.child.stderr,
1574
+ hasStdin: !!this.child.stdin,
1575
+ platform: process.platform,
1576
+ command: this.spec?.command?.slice(0, 100)
1577
+ }, null, 2)}`);
1578
+
1579
+ // Add event listeners with detailed tracing (only for Node.js child processes)
1580
+ if (this.child && typeof this.child.on === 'function') {
1581
+ this.child.on('spawn', () => {
1582
+ trace('ProcessRunner', () => `Child process spawned successfully | ${JSON.stringify({
1583
+ pid: this.child.pid,
1584
+ command: this.spec?.command?.slice(0, 50)
1585
+ }, null, 2)}`);
1586
+ });
1587
+
1588
+ this.child.on('error', (error) => {
1589
+ trace('ProcessRunner', () => `Child process error event | ${JSON.stringify({
1590
+ pid: this.child?.pid,
1591
+ error: error.message,
1592
+ code: error.code,
1593
+ errno: error.errno,
1594
+ syscall: error.syscall,
1595
+ command: this.spec?.command?.slice(0, 50)
1596
+ }, null, 2)}`);
1597
+ });
1598
+ } else {
1599
+ trace('ProcessRunner', () => `Skipping event listeners - child does not support .on() method (likely Bun process)`);
1600
+ }
1601
+ } else {
1602
+ trace('ProcessRunner', () => `No child process created | ${JSON.stringify({
1603
+ spec: this.spec,
1604
+ hasVirtualGenerator: !!this._virtualGenerator
1605
+ }, null, 2)}`);
1606
+ }
776
1607
 
777
1608
  // For interactive commands with stdio: 'inherit', stdout/stderr will be null
1609
+ const childPid = this.child?.pid; // Capture PID once at the start
778
1610
  const outPump = this.child.stdout ? pumpReadable(this.child.stdout, async (buf) => {
1611
+ trace('ProcessRunner', () => `stdout data received | ${JSON.stringify({
1612
+ pid: childPid,
1613
+ bufferLength: buf.length,
1614
+ capture: this.options.capture,
1615
+ mirror: this.options.mirror,
1616
+ preview: buf.toString().slice(0, 100)
1617
+ })}`);
1618
+
779
1619
  if (this.options.capture) this.outChunks.push(buf);
780
1620
  if (this.options.mirror) safeWrite(process.stdout, buf);
781
1621
 
@@ -784,6 +1624,14 @@ class ProcessRunner extends StreamEmitter {
784
1624
  }) : Promise.resolve();
785
1625
 
786
1626
  const errPump = this.child.stderr ? pumpReadable(this.child.stderr, async (buf) => {
1627
+ trace('ProcessRunner', () => `stderr data received | ${JSON.stringify({
1628
+ pid: childPid,
1629
+ bufferLength: buf.length,
1630
+ capture: this.options.capture,
1631
+ mirror: this.options.mirror,
1632
+ preview: buf.toString().slice(0, 100)
1633
+ })}`);
1634
+
787
1635
  if (this.options.capture) this.errChunks.push(buf);
788
1636
  if (this.options.mirror) safeWrite(process.stderr, buf);
789
1637
 
@@ -792,28 +1640,82 @@ class ProcessRunner extends StreamEmitter {
792
1640
  }) : Promise.resolve();
793
1641
 
794
1642
  let stdinPumpPromise = Promise.resolve();
1643
+ trace('ProcessRunner', () => `Setting up stdin handling | ${JSON.stringify({
1644
+ stdinType: typeof stdin,
1645
+ stdin: stdin === 'inherit' ? 'inherit' : stdin === 'ignore' ? 'ignore' : (typeof stdin === 'string' ? `string(${stdin.length})` : 'other'),
1646
+ isInteractive,
1647
+ hasChildStdin: !!this.child?.stdin,
1648
+ processTTY: process.stdin.isTTY
1649
+ }, null, 2)}`);
1650
+
795
1651
  if (stdin === 'inherit') {
796
1652
  if (isInteractive) {
797
1653
  // For interactive commands with stdio: 'inherit', stdin is handled automatically
1654
+ trace('ProcessRunner', () => `stdin: Using inherit mode for interactive command`);
798
1655
  stdinPumpPromise = Promise.resolve();
799
1656
  } else {
800
1657
  const isPipedIn = process.stdin && process.stdin.isTTY === false;
1658
+ trace('ProcessRunner', () => `stdin: Non-interactive inherit mode | ${JSON.stringify({
1659
+ isPipedIn,
1660
+ stdinTTY: process.stdin.isTTY
1661
+ }, null, 2)}`);
801
1662
  if (isPipedIn) {
1663
+ trace('ProcessRunner', () => `stdin: Pumping piped input to child process`);
802
1664
  stdinPumpPromise = this._pumpStdinTo(this.child, this.options.capture ? this.inChunks : null);
803
1665
  } else {
804
1666
  // For TTY (interactive terminal), forward stdin directly for non-interactive commands
1667
+ trace('ProcessRunner', () => `stdin: Forwarding TTY stdin for non-interactive command`);
805
1668
  stdinPumpPromise = this._forwardTTYStdin();
806
1669
  }
807
1670
  }
808
1671
  } else if (stdin === 'ignore') {
809
- if (this.child.stdin && typeof this.child.stdin.end === 'function') this.child.stdin.end();
1672
+ trace('ProcessRunner', () => `stdin: Ignoring and closing stdin`);
1673
+ if (this.child.stdin && typeof this.child.stdin.end === 'function') {
1674
+ this.child.stdin.end();
1675
+ trace('ProcessRunner', () => `stdin: Child stdin closed successfully`);
1676
+ }
1677
+ } else if (stdin === 'pipe') {
1678
+ trace('ProcessRunner', () => `stdin: Using pipe mode - leaving stdin open for manual control`);
1679
+ // Leave stdin open for manual writing via streams.stdin
1680
+ stdinPumpPromise = Promise.resolve();
810
1681
  } else if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) {
811
1682
  const buf = Buffer.isBuffer(stdin) ? stdin : Buffer.from(stdin);
1683
+ trace('ProcessRunner', () => `stdin: Writing buffer to child | ${JSON.stringify({
1684
+ bufferLength: buf.length,
1685
+ willCapture: this.options.capture && !!this.inChunks
1686
+ }, null, 2)}`);
812
1687
  if (this.options.capture && this.inChunks) this.inChunks.push(Buffer.from(buf));
813
1688
  stdinPumpPromise = this._writeToStdin(buf);
1689
+ } else {
1690
+ trace('ProcessRunner', () => `stdin: Unhandled stdin type: ${typeof stdin}`);
814
1691
  }
815
1692
 
816
- const exited = isBun ? this.child.exited : new Promise((resolve) => this.child.on('close', resolve));
1693
+ const exited = isBun ? this.child.exited : new Promise((resolve) => {
1694
+ trace('ProcessRunner', () => `Setting up child process event listeners for PID ${this.child.pid}`);
1695
+ this.child.on('close', (code, signal) => {
1696
+ trace('ProcessRunner', () => `Child process close event | ${JSON.stringify({
1697
+ pid: this.child.pid,
1698
+ code,
1699
+ signal,
1700
+ killed: this.child.killed,
1701
+ exitCode: this.child.exitCode,
1702
+ signalCode: this.child.signalCode,
1703
+ command: this.command
1704
+ }, null, 2)}`);
1705
+ resolve(code);
1706
+ });
1707
+ this.child.on('exit', (code, signal) => {
1708
+ trace('ProcessRunner', () => `Child process exit event | ${JSON.stringify({
1709
+ pid: this.child.pid,
1710
+ code,
1711
+ signal,
1712
+ killed: this.child.killed,
1713
+ exitCode: this.child.exitCode,
1714
+ signalCode: this.child.signalCode,
1715
+ command: this.command
1716
+ }, null, 2)}`);
1717
+ });
1718
+ });
817
1719
  const code = await exited;
818
1720
  await Promise.all([outPump, errPump, stdinPumpPromise]);
819
1721
 
@@ -825,35 +1727,126 @@ class ProcessRunner extends StreamEmitter {
825
1727
  isBun
826
1728
  }, null, 2)}`);
827
1729
 
1730
+ // When a process is killed, it may not have an exit code
1731
+ // If cancelled and no exit code, assume it was killed with SIGTERM
1732
+ let finalExitCode = code;
1733
+ trace('ProcessRunner', () => `Processing exit code | ${JSON.stringify({
1734
+ rawCode: code,
1735
+ cancelled: this._cancelled,
1736
+ childKilled: this.child?.killed,
1737
+ childExitCode: this.child?.exitCode,
1738
+ childSignalCode: this.child?.signalCode
1739
+ }, null, 2)}`);
1740
+
1741
+ if (finalExitCode === undefined || finalExitCode === null) {
1742
+ if (this._cancelled) {
1743
+ // Process was killed, use SIGTERM exit code
1744
+ finalExitCode = 143; // 128 + 15 (SIGTERM)
1745
+ trace('ProcessRunner', () => `Process was killed, using SIGTERM exit code 143`);
1746
+ } else {
1747
+ // Process exited without a code, default to 0
1748
+ finalExitCode = 0;
1749
+ trace('ProcessRunner', () => `Process exited without code, defaulting to 0`);
1750
+ }
1751
+ }
1752
+
828
1753
  const resultData = {
829
- code: code ?? 0, // Default to 0 if exit code is null/undefined
1754
+ code: finalExitCode,
830
1755
  stdout: this.options.capture ? (this.outChunks && this.outChunks.length > 0 ? Buffer.concat(this.outChunks).toString('utf8') : '') : undefined,
831
1756
  stderr: this.options.capture ? (this.errChunks && this.errChunks.length > 0 ? Buffer.concat(this.errChunks).toString('utf8') : '') : undefined,
832
1757
  stdin: this.options.capture && this.inChunks ? Buffer.concat(this.inChunks).toString('utf8') : undefined,
833
1758
  child: this.child
834
1759
  };
1760
+
1761
+ trace('ProcessRunner', () => `Process completed | ${JSON.stringify({
1762
+ command: this.command,
1763
+ finalExitCode,
1764
+ captured: this.options.capture,
1765
+ hasStdout: !!resultData.stdout,
1766
+ hasStderr: !!resultData.stderr,
1767
+ stdoutLength: resultData.stdout?.length || 0,
1768
+ stderrLength: resultData.stderr?.length || 0,
1769
+ stdoutPreview: resultData.stdout?.slice(0, 100),
1770
+ stderrPreview: resultData.stderr?.slice(0, 100),
1771
+ childPid: this.child?.pid,
1772
+ cancelled: this._cancelled,
1773
+ cancellationSignal: this._cancellationSignal,
1774
+ platform: process.platform,
1775
+ runtime: isBun ? 'Bun' : 'Node.js'
1776
+ }, null, 2)}`);
835
1777
 
836
- this.result = {
1778
+ const result = {
837
1779
  ...resultData,
838
1780
  async text() {
839
1781
  return resultData.stdout || '';
840
1782
  }
841
1783
  };
842
1784
 
843
- this.finished = true;
844
- this.emit('end', this.result);
845
- this.emit('exit', this.result.code);
1785
+ trace('ProcessRunner', () => `About to finish process with result | ${JSON.stringify({
1786
+ exitCode: result.code,
1787
+ finished: this.finished
1788
+ }, null, 2)}`);
1789
+
1790
+ // Finish the process with proper event emission order
1791
+ this.finish(result);
1792
+
1793
+ trace('ProcessRunner', () => `Process finished, result set | ${JSON.stringify({
1794
+ finished: this.finished,
1795
+ resultCode: this.result?.code
1796
+ }, null, 2)}`);
846
1797
 
847
1798
  if (globalShellSettings.errexit && this.result.code !== 0) {
1799
+ trace('ProcessRunner', () => `Errexit mode: throwing error for non-zero exit code | ${JSON.stringify({
1800
+ exitCode: this.result.code,
1801
+ errexit: globalShellSettings.errexit,
1802
+ hasStdout: !!this.result.stdout,
1803
+ hasStderr: !!this.result.stderr
1804
+ }, null, 2)}`);
1805
+
848
1806
  const error = new Error(`Command failed with exit code ${this.result.code}`);
849
1807
  error.code = this.result.code;
850
1808
  error.stdout = this.result.stdout;
851
1809
  error.stderr = this.result.stderr;
852
1810
  error.result = this.result;
1811
+
1812
+ trace('ProcessRunner', () => `About to throw errexit error`);
853
1813
  throw error;
854
1814
  }
1815
+
1816
+ trace('ProcessRunner', () => `Returning result successfully | ${JSON.stringify({
1817
+ exitCode: this.result.code,
1818
+ errexit: globalShellSettings.errexit
1819
+ }, null, 2)}`);
855
1820
 
856
1821
  return this.result;
1822
+ } catch (error) {
1823
+ trace('ProcessRunner', () => `Caught error in _doStartAsync | ${JSON.stringify({
1824
+ errorMessage: error.message,
1825
+ errorCode: error.code,
1826
+ isCommandError: error.isCommandError,
1827
+ hasResult: !!error.result,
1828
+ command: this.spec?.command?.slice(0, 100)
1829
+ }, null, 2)}`);
1830
+
1831
+ // Ensure cleanup happens even if execution fails
1832
+ trace('ProcessRunner', () => `_doStartAsync caught error: ${error.message}`);
1833
+
1834
+ if (!this.finished) {
1835
+ // Create a result from the error
1836
+ const errorResult = createResult({
1837
+ code: error.code ?? 1,
1838
+ stdout: error.stdout ?? '',
1839
+ stderr: error.stderr ?? error.message ?? '',
1840
+ stdin: ''
1841
+ });
1842
+
1843
+ // Finish to trigger cleanup
1844
+ this.finish(errorResult);
1845
+ }
1846
+
1847
+ // Re-throw the error after cleanup
1848
+ throw error;
1849
+ }
857
1850
  }
858
1851
 
859
1852
  async _pumpStdinTo(child, captureChunks) {
@@ -980,7 +1973,23 @@ class ProcessRunner extends StreamEmitter {
980
1973
  try {
981
1974
  // Prepare stdin
982
1975
  let stdinData = '';
983
- if (this.options.stdin && typeof this.options.stdin === 'string') {
1976
+
1977
+ // Special handling for streaming mode (stdin: "pipe")
1978
+ if (this.options.stdin === 'pipe') {
1979
+ // For streaming interfaces, virtual commands should fallback to real commands
1980
+ // because virtual commands don't support true streaming
1981
+ trace('ProcessRunner', () => `Virtual command fallback for streaming | ${JSON.stringify({ cmd }, null, 2)}`);
1982
+
1983
+ // Create a new ProcessRunner for the real command with properly merged options
1984
+ // Preserve main options but use appropriate stdin for the real command
1985
+ const modifiedOptions = {
1986
+ ...this.options,
1987
+ stdin: 'pipe', // Keep pipe but ensure it doesn't trigger virtual command fallback
1988
+ _bypassVirtual: true // Flag to prevent virtual command recursion
1989
+ };
1990
+ const realRunner = new ProcessRunner({ mode: 'shell', command: originalCommand || cmd }, modifiedOptions);
1991
+ return await realRunner._doStartAsync();
1992
+ } else if (this.options.stdin && typeof this.options.stdin === 'string') {
984
1993
  stdinData = this.options.stdin;
985
1994
  } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) {
986
1995
  stdinData = this.options.stdin.toString('utf8');
@@ -1003,12 +2012,28 @@ class ProcessRunner extends StreamEmitter {
1003
2012
  const chunks = [];
1004
2013
 
1005
2014
  const commandOptions = {
1006
- ...this.options,
1007
- isCancelled: () => this._cancelled,
1008
- signal: this._abortController.signal
2015
+ // Commonly used options at top level for convenience
2016
+ cwd: this.options.cwd,
2017
+ env: this.options.env,
2018
+ // All original options (built-in + custom) in options object
2019
+ options: this.options,
2020
+ isCancelled: () => this._cancelled
1009
2021
  };
2022
+
2023
+ trace('ProcessRunner', () => `_runVirtual signal details | ${JSON.stringify({
2024
+ cmd,
2025
+ hasAbortController: !!this._abortController,
2026
+ signalAborted: this._abortController?.signal?.aborted,
2027
+ optionsSignalExists: !!this.options.signal,
2028
+ optionsSignalAborted: this.options.signal?.aborted
2029
+ }, null, 2)}`);
1010
2030
 
1011
- const generator = handler({ args: argValues, stdin: stdinData, ...commandOptions });
2031
+ const generator = handler({
2032
+ args: argValues,
2033
+ stdin: stdinData,
2034
+ abortSignal: this._abortController?.signal,
2035
+ ...commandOptions
2036
+ });
1012
2037
  this._virtualGenerator = generator;
1013
2038
 
1014
2039
  const cancelPromise = new Promise(resolve => {
@@ -1020,12 +2045,27 @@ class ProcessRunner extends StreamEmitter {
1020
2045
  let done = false;
1021
2046
 
1022
2047
  while (!done && !this._cancelled) {
2048
+ trace('ProcessRunner', () => `Virtual command iteration starting | ${JSON.stringify({
2049
+ cancelled: this._cancelled,
2050
+ streamBreaking: this._streamBreaking
2051
+ }, null, 2)}`);
2052
+
1023
2053
  const result = await Promise.race([
1024
2054
  iterator.next(),
1025
2055
  cancelPromise.then(() => ({ done: true, cancelled: true }))
1026
2056
  ]);
1027
2057
 
2058
+ trace('ProcessRunner', () => `Virtual command iteration result | ${JSON.stringify({
2059
+ hasValue: !!result.value,
2060
+ done: result.done,
2061
+ cancelled: result.cancelled || this._cancelled
2062
+ }, null, 2)}`);
2063
+
1028
2064
  if (result.cancelled || this._cancelled) {
2065
+ trace('ProcessRunner', () => `Virtual command cancelled - closing generator | ${JSON.stringify({
2066
+ resultCancelled: result.cancelled,
2067
+ thisCancelled: this._cancelled
2068
+ }, null, 2)}`);
1029
2069
  // Cancelled - close the generator
1030
2070
  if (iterator.return) {
1031
2071
  await iterator.return();
@@ -1036,18 +2076,35 @@ class ProcessRunner extends StreamEmitter {
1036
2076
  done = result.done;
1037
2077
 
1038
2078
  if (!done) {
2079
+ // Check cancellation again before processing the chunk
2080
+ if (this._cancelled) {
2081
+ trace('ProcessRunner', () => 'Skipping chunk processing - cancelled during iteration');
2082
+ break;
2083
+ }
2084
+
1039
2085
  const chunk = result.value;
1040
2086
  const buf = Buffer.from(chunk);
2087
+
2088
+ // Check cancelled flag once more before any output
2089
+ if (this._cancelled || this._streamBreaking) {
2090
+ trace('ProcessRunner', () => `Cancelled or stream breaking before output - skipping | ${JSON.stringify({
2091
+ cancelled: this._cancelled,
2092
+ streamBreaking: this._streamBreaking
2093
+ }, null, 2)}`);
2094
+ break;
2095
+ }
2096
+
1041
2097
  chunks.push(buf);
1042
2098
 
1043
- // Only output if not cancelled
1044
- if (!this._cancelled) {
1045
- if (this.options.mirror) {
1046
- safeWrite(process.stdout, buf);
1047
- }
1048
-
1049
- this._emitProcessedData('stdout', buf);
2099
+ // Only output if not cancelled and stream not breaking
2100
+ if (!this._cancelled && !this._streamBreaking && this.options.mirror) {
2101
+ trace('ProcessRunner', () => `Mirroring virtual command output | ${JSON.stringify({
2102
+ chunkSize: buf.length
2103
+ }, null, 2)}`);
2104
+ safeWrite(process.stdout, buf);
1050
2105
  }
2106
+
2107
+ this._emitProcessedData('stdout', buf);
1051
2108
  }
1052
2109
  }
1053
2110
  } finally {
@@ -1063,8 +2120,59 @@ class ProcessRunner extends StreamEmitter {
1063
2120
  stdin: this.options.capture ? stdinData : undefined
1064
2121
  };
1065
2122
  } else {
1066
- // Regular async function
1067
- result = await handler({ args: argValues, stdin: stdinData, ...this.options });
2123
+ // Regular async function - race with abort signal
2124
+ const commandOptions = {
2125
+ // Commonly used options at top level for convenience
2126
+ cwd: this.options.cwd,
2127
+ env: this.options.env,
2128
+ // All original options (built-in + custom) in options object
2129
+ options: this.options,
2130
+ isCancelled: () => this._cancelled
2131
+ };
2132
+
2133
+ trace('ProcessRunner', () => `_runVirtual signal details (non-generator) | ${JSON.stringify({
2134
+ cmd,
2135
+ hasAbortController: !!this._abortController,
2136
+ signalAborted: this._abortController?.signal?.aborted,
2137
+ optionsSignalExists: !!this.options.signal,
2138
+ optionsSignalAborted: this.options.signal?.aborted
2139
+ }, null, 2)}`);
2140
+
2141
+ const handlerPromise = handler({
2142
+ args: argValues,
2143
+ stdin: stdinData,
2144
+ abortSignal: this._abortController?.signal,
2145
+ ...commandOptions
2146
+ });
2147
+
2148
+ // Create an abort promise that rejects when cancelled
2149
+ const abortPromise = new Promise((_, reject) => {
2150
+ if (this._abortController && this._abortController.signal.aborted) {
2151
+ reject(new Error('Command cancelled'));
2152
+ }
2153
+ if (this._abortController) {
2154
+ this._abortController.signal.addEventListener('abort', () => {
2155
+ reject(new Error('Command cancelled'));
2156
+ });
2157
+ }
2158
+ });
2159
+
2160
+ try {
2161
+ result = await Promise.race([handlerPromise, abortPromise]);
2162
+ } catch (err) {
2163
+ if (err.message === 'Command cancelled') {
2164
+ // Command was cancelled, return appropriate exit code based on signal
2165
+ const exitCode = this._cancellationSignal === 'SIGINT' ? 130 : 143; // 130 for SIGINT, 143 for SIGTERM
2166
+ trace('ProcessRunner', () => `Virtual command cancelled with signal ${this._cancellationSignal}, exit code: ${exitCode}`);
2167
+ result = {
2168
+ code: exitCode,
2169
+ stdout: '',
2170
+ stderr: ''
2171
+ };
2172
+ } else {
2173
+ throw err;
2174
+ }
2175
+ }
1068
2176
 
1069
2177
  result = {
1070
2178
  ...result,
@@ -1092,13 +2200,8 @@ class ProcessRunner extends StreamEmitter {
1092
2200
  }
1093
2201
  }
1094
2202
 
1095
- // Store result
1096
- this.result = result;
1097
- this.finished = true;
1098
-
1099
- // Emit completion events
1100
- this.emit('end', result);
1101
- this.emit('exit', result.code);
2203
+ // Finish the process with proper event emission order
2204
+ this.finish(result);
1102
2205
 
1103
2206
  if (globalShellSettings.errexit && result.code !== 0) {
1104
2207
  const error = new Error(`Command failed with exit code ${result.code}`);
@@ -1118,9 +2221,6 @@ class ProcessRunner extends StreamEmitter {
1118
2221
  stdin: ''
1119
2222
  };
1120
2223
 
1121
- this.result = result;
1122
- this.finished = true;
1123
-
1124
2224
  if (result.stderr) {
1125
2225
  const buf = Buffer.from(result.stderr);
1126
2226
  if (this.options.mirror) {
@@ -1129,8 +2229,7 @@ class ProcessRunner extends StreamEmitter {
1129
2229
  this._emitProcessedData('stderr', buf);
1130
2230
  }
1131
2231
 
1132
- this.emit('end', result);
1133
- this.emit('exit', result.code);
2232
+ this.finish(result);
1134
2233
 
1135
2234
  if (globalShellSettings.errexit) {
1136
2235
  error.result = result;
@@ -1321,11 +2420,8 @@ class ProcessRunner extends StreamEmitter {
1321
2420
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1322
2421
  });
1323
2422
 
1324
- this.result = result;
1325
- this.finished = true;
1326
-
1327
- this.emit('end', result);
1328
- this.emit('exit', result.code);
2423
+ // Finish the process with proper event emission order
2424
+ this.finish(result);
1329
2425
 
1330
2426
  if (globalShellSettings.errexit && result.code !== 0) {
1331
2427
  const error = new Error(`Pipeline failed with exit code ${result.code}`);
@@ -1499,11 +2595,8 @@ class ProcessRunner extends StreamEmitter {
1499
2595
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1500
2596
  });
1501
2597
 
1502
- this.result = result;
1503
- this.finished = true;
1504
-
1505
- this.emit('end', result);
1506
- this.emit('exit', result.code);
2598
+ // Finish the process with proper event emission order
2599
+ this.finish(result);
1507
2600
 
1508
2601
  if (globalShellSettings.errexit && result.code !== 0) {
1509
2602
  const error = new Error(`Pipeline failed with exit code ${result.code}`);
@@ -1728,11 +2821,8 @@ class ProcessRunner extends StreamEmitter {
1728
2821
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1729
2822
  });
1730
2823
 
1731
- this.result = result;
1732
- this.finished = true;
1733
-
1734
- this.emit('end', result);
1735
- this.emit('exit', result.code);
2824
+ // Finish the process with proper event emission order
2825
+ this.finish(result);
1736
2826
 
1737
2827
  return result;
1738
2828
  }
@@ -1835,12 +2925,8 @@ class ProcessRunner extends StreamEmitter {
1835
2925
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1836
2926
  });
1837
2927
 
1838
- this.result = finalResult;
1839
- this.finished = true;
1840
-
1841
- // Emit completion events
1842
- this.emit('end', finalResult);
1843
- this.emit('exit', finalResult.code);
2928
+ // Finish the process with proper event emission order
2929
+ this.finish(finalResult);
1844
2930
 
1845
2931
  if (globalShellSettings.errexit && finalResult.code !== 0) {
1846
2932
  const error = new Error(`Pipeline failed with exit code ${finalResult.code}`);
@@ -1871,9 +2957,6 @@ class ProcessRunner extends StreamEmitter {
1871
2957
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
1872
2958
  });
1873
2959
 
1874
- this.result = result;
1875
- this.finished = true;
1876
-
1877
2960
  if (result.stderr) {
1878
2961
  const buf = Buffer.from(result.stderr);
1879
2962
  if (this.options.mirror) {
@@ -1882,8 +2965,7 @@ class ProcessRunner extends StreamEmitter {
1882
2965
  this._emitProcessedData('stderr', buf);
1883
2966
  }
1884
2967
 
1885
- this.emit('end', result);
1886
- this.emit('exit', result.code);
2968
+ this.finish(result);
1887
2969
 
1888
2970
  if (globalShellSettings.errexit) {
1889
2971
  throw error;
@@ -1928,17 +3010,47 @@ class ProcessRunner extends StreamEmitter {
1928
3010
  const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => {
1929
3011
 
1930
3012
  return new Promise((resolve, reject) => {
3013
+ trace('ProcessRunner', () => `spawnNodeAsync: Creating child process | ${JSON.stringify({
3014
+ command: argv[0],
3015
+ args: argv.slice(1),
3016
+ cwd: this.options.cwd,
3017
+ isLastCommand
3018
+ })}`);
3019
+
1931
3020
  const proc = cp.spawn(argv[0], argv.slice(1), {
1932
3021
  cwd: this.options.cwd,
1933
3022
  env: this.options.env,
1934
3023
  stdio: ['pipe', 'pipe', 'pipe']
1935
3024
  });
1936
3025
 
3026
+ trace('ProcessRunner', () => `spawnNodeAsync: Child process created | ${JSON.stringify({
3027
+ pid: proc.pid,
3028
+ killed: proc.killed,
3029
+ hasStdout: !!proc.stdout,
3030
+ hasStderr: !!proc.stderr
3031
+ })}`);
3032
+
1937
3033
  let stdout = '';
1938
3034
  let stderr = '';
3035
+ let stdoutChunks = 0;
3036
+ let stderrChunks = 0;
1939
3037
 
3038
+ const procPid = proc.pid; // Capture PID once to avoid null reference
3039
+
1940
3040
  proc.stdout.on('data', (chunk) => {
1941
- stdout += chunk.toString();
3041
+ const chunkStr = chunk.toString();
3042
+ stdout += chunkStr;
3043
+ stdoutChunks++;
3044
+
3045
+ trace('ProcessRunner', () => `spawnNodeAsync: stdout chunk received | ${JSON.stringify({
3046
+ pid: procPid,
3047
+ chunkNumber: stdoutChunks,
3048
+ chunkLength: chunk.length,
3049
+ totalStdoutLength: stdout.length,
3050
+ isLastCommand,
3051
+ preview: chunkStr.slice(0, 100)
3052
+ })}`);
3053
+
1942
3054
  // If this is the last command, emit streaming data
1943
3055
  if (isLastCommand) {
1944
3056
  if (this.options.mirror) {
@@ -1949,7 +3061,19 @@ class ProcessRunner extends StreamEmitter {
1949
3061
  });
1950
3062
 
1951
3063
  proc.stderr.on('data', (chunk) => {
1952
- stderr += chunk.toString();
3064
+ const chunkStr = chunk.toString();
3065
+ stderr += chunkStr;
3066
+ stderrChunks++;
3067
+
3068
+ trace('ProcessRunner', () => `spawnNodeAsync: stderr chunk received | ${JSON.stringify({
3069
+ pid: procPid,
3070
+ chunkNumber: stderrChunks,
3071
+ chunkLength: chunk.length,
3072
+ totalStderrLength: stderr.length,
3073
+ isLastCommand,
3074
+ preview: chunkStr.slice(0, 100)
3075
+ })}`);
3076
+
1953
3077
  // If this is the last command, emit streaming data
1954
3078
  if (isLastCommand) {
1955
3079
  if (this.options.mirror) {
@@ -1960,6 +3084,15 @@ class ProcessRunner extends StreamEmitter {
1960
3084
  });
1961
3085
 
1962
3086
  proc.on('close', (code) => {
3087
+ trace('ProcessRunner', () => `spawnNodeAsync: Process closed | ${JSON.stringify({
3088
+ pid: procPid,
3089
+ code,
3090
+ stdoutLength: stdout.length,
3091
+ stderrLength: stderr.length,
3092
+ stdoutChunks,
3093
+ stderrChunks
3094
+ })}`);
3095
+
1963
3096
  resolve({
1964
3097
  status: code,
1965
3098
  stdout,
@@ -2040,12 +3173,8 @@ class ProcessRunner extends StreamEmitter {
2040
3173
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2041
3174
  });
2042
3175
 
2043
- this.result = finalResult;
2044
- this.finished = true;
2045
-
2046
- // Emit completion events
2047
- this.emit('end', finalResult);
2048
- this.emit('exit', finalResult.code);
3176
+ // Finish the process with proper event emission order
3177
+ this.finish(finalResult);
2049
3178
 
2050
3179
  if (globalShellSettings.errexit && finalResult.code !== 0) {
2051
3180
  const error = new Error(`Pipeline failed with exit code ${finalResult.code}`);
@@ -2068,9 +3197,6 @@ class ProcessRunner extends StreamEmitter {
2068
3197
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2069
3198
  });
2070
3199
 
2071
- this.result = result;
2072
- this.finished = true;
2073
-
2074
3200
  if (result.stderr) {
2075
3201
  const buf = Buffer.from(result.stderr);
2076
3202
  if (this.options.mirror) {
@@ -2079,8 +3205,7 @@ class ProcessRunner extends StreamEmitter {
2079
3205
  this._emitProcessedData('stderr', buf);
2080
3206
  }
2081
3207
 
2082
- this.emit('end', result);
2083
- this.emit('exit', result.code);
3208
+ this.finish(result);
2084
3209
 
2085
3210
  if (globalShellSettings.errexit) {
2086
3211
  throw error;
@@ -2163,17 +3288,13 @@ class ProcessRunner extends StreamEmitter {
2163
3288
  this.options.stdin && Buffer.isBuffer(this.options.stdin) ? this.options.stdin.toString('utf8') : ''
2164
3289
  });
2165
3290
 
2166
- this.result = result;
2167
- this.finished = true;
2168
-
2169
3291
  const buf = Buffer.from(result.stderr);
2170
3292
  if (this.options.mirror) {
2171
3293
  safeWrite(process.stderr, buf);
2172
3294
  }
2173
3295
  this._emitProcessedData('stderr', buf);
2174
3296
 
2175
- this.emit('end', result);
2176
- this.emit('exit', result.code);
3297
+ this.finish(result);
2177
3298
 
2178
3299
  return result;
2179
3300
  }
@@ -2194,12 +3315,16 @@ class ProcessRunner extends StreamEmitter {
2194
3315
  let resolve, reject;
2195
3316
  let ended = false;
2196
3317
  let cleanedUp = false;
3318
+ let killed = false;
2197
3319
 
2198
3320
  const onData = (chunk) => {
2199
- buffer.push(chunk);
2200
- if (resolve) {
2201
- resolve();
2202
- resolve = reject = null;
3321
+ // Don't buffer more data if we're being killed
3322
+ if (!killed) {
3323
+ buffer.push(chunk);
3324
+ if (resolve) {
3325
+ resolve();
3326
+ resolve = reject = null;
3327
+ }
2203
3328
  }
2204
3329
  };
2205
3330
 
@@ -2216,8 +3341,18 @@ class ProcessRunner extends StreamEmitter {
2216
3341
 
2217
3342
  try {
2218
3343
  while (!ended || buffer.length > 0) {
3344
+ // Check if we've been killed and should stop immediately
3345
+ if (killed) {
3346
+ trace('ProcessRunner', () => 'Stream killed, stopping iteration');
3347
+ break;
3348
+ }
2219
3349
  if (buffer.length > 0) {
2220
- yield buffer.shift();
3350
+ const chunk = buffer.shift();
3351
+ // Set a flag that we're about to yield - if the consumer breaks,
3352
+ // we'll know not to process any more data
3353
+ this._streamYielding = true;
3354
+ yield chunk;
3355
+ this._streamYielding = false;
2221
3356
  } else if (!ended) {
2222
3357
  await new Promise((res, rej) => {
2223
3358
  resolve = res;
@@ -2232,41 +3367,97 @@ class ProcessRunner extends StreamEmitter {
2232
3367
 
2233
3368
  // This happens when breaking from a for-await loop
2234
3369
  if (!this.finished) {
3370
+ killed = true;
3371
+ buffer = []; // Clear any buffered data
3372
+ this._streamBreaking = true; // Signal that stream is breaking
2235
3373
  this.kill();
2236
3374
  }
2237
3375
  }
2238
3376
  }
2239
3377
 
2240
- kill() {
3378
+ kill(signal = 'SIGTERM') {
2241
3379
  trace('ProcessRunner', () => `kill ENTER | ${JSON.stringify({
3380
+ signal,
2242
3381
  cancelled: this._cancelled,
2243
3382
  finished: this.finished,
2244
3383
  hasChild: !!this.child,
2245
- hasVirtualGenerator: !!this._virtualGenerator
3384
+ hasVirtualGenerator: !!this._virtualGenerator,
3385
+ command: this.spec?.command?.slice(0, 50) || 'unknown'
2246
3386
  }, null, 2)}`);
2247
3387
 
2248
- // Mark as cancelled for virtual commands
3388
+ if (this.finished) {
3389
+ trace('ProcessRunner', () => 'Already finished, skipping kill');
3390
+ return;
3391
+ }
3392
+
3393
+ // Mark as cancelled for virtual commands and store the signal
3394
+ trace('ProcessRunner', () => `Marking as cancelled | ${JSON.stringify({
3395
+ signal,
3396
+ previouslyCancelled: this._cancelled,
3397
+ previousSignal: this._cancellationSignal
3398
+ }, null, 2)}`);
2249
3399
  this._cancelled = true;
3400
+ this._cancellationSignal = signal;
3401
+
3402
+ // If this is a pipeline runner, also kill the source and destination
3403
+ if (this.spec?.mode === 'pipeline') {
3404
+ trace('ProcessRunner', () => 'Killing pipeline components');
3405
+ if (this.spec.source && typeof this.spec.source.kill === 'function') {
3406
+ this.spec.source.kill(signal);
3407
+ }
3408
+ if (this.spec.destination && typeof this.spec.destination.kill === 'function') {
3409
+ this.spec.destination.kill(signal);
3410
+ }
3411
+ }
2250
3412
 
2251
3413
  if (this._cancelResolve) {
2252
3414
  trace('ProcessRunner', () => 'Resolving cancel promise');
2253
3415
  this._cancelResolve();
3416
+ trace('ProcessRunner', () => 'Cancel promise resolved');
3417
+ } else {
3418
+ trace('ProcessRunner', () => 'No cancel promise to resolve');
2254
3419
  }
2255
3420
 
2256
3421
  // Abort any async operations
2257
3422
  if (this._abortController) {
2258
- trace('ProcessRunner', () => 'Aborting controller');
3423
+ trace('ProcessRunner', () => `Aborting internal controller | ${JSON.stringify({
3424
+ wasAborted: this._abortController?.signal?.aborted
3425
+ }, null, 2)}`);
2259
3426
  this._abortController.abort();
3427
+ trace('ProcessRunner', () => `Internal controller aborted | ${JSON.stringify({
3428
+ nowAborted: this._abortController?.signal?.aborted
3429
+ }, null, 2)}`);
3430
+ } else {
3431
+ trace('ProcessRunner', () => 'No abort controller to abort');
2260
3432
  }
2261
3433
 
2262
3434
  // If it's a virtual generator, try to close it
2263
- if (this._virtualGenerator && this._virtualGenerator.return) {
2264
- trace('ProcessRunner', () => 'Closing virtual generator');
2265
- try {
2266
- this._virtualGenerator.return();
2267
- } catch (err) {
2268
- trace('ProcessRunner', () => `Error closing generator | ${JSON.stringify({ error: err.message }, null, 2)}`);
3435
+ if (this._virtualGenerator) {
3436
+ trace('ProcessRunner', () => `Virtual generator found for cleanup | ${JSON.stringify({
3437
+ hasReturn: typeof this._virtualGenerator.return === 'function',
3438
+ hasThrow: typeof this._virtualGenerator.throw === 'function',
3439
+ cancelled: this._cancelled,
3440
+ signal
3441
+ }, null, 2)}`);
3442
+
3443
+ if (this._virtualGenerator.return) {
3444
+ trace('ProcessRunner', () => 'Closing virtual generator with return()');
3445
+ try {
3446
+ this._virtualGenerator.return();
3447
+ trace('ProcessRunner', () => 'Virtual generator closed successfully');
3448
+ } catch (err) {
3449
+ trace('ProcessRunner', () => `Error closing generator | ${JSON.stringify({
3450
+ error: err.message,
3451
+ stack: err.stack?.slice(0, 200)
3452
+ }, null, 2)}`);
3453
+ }
3454
+ } else {
3455
+ trace('ProcessRunner', () => 'Virtual generator has no return() method');
2269
3456
  }
3457
+ } else {
3458
+ trace('ProcessRunner', () => `No virtual generator to cleanup | ${JSON.stringify({
3459
+ hasVirtualGenerator: !!this._virtualGenerator
3460
+ }, null, 2)}`);
2270
3461
  }
2271
3462
 
2272
3463
  // Kill child process if it exists
@@ -2276,14 +3467,112 @@ class ProcessRunner extends StreamEmitter {
2276
3467
  if (this.child.pid) {
2277
3468
  if (isBun) {
2278
3469
  trace('ProcessRunner', () => `Killing Bun process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
2279
- this.child.kill();
3470
+
3471
+ // For Bun, use the same enhanced kill logic as Node.js for CI reliability
3472
+ const killOperations = [];
3473
+
3474
+ // Try SIGTERM first
3475
+ try {
3476
+ process.kill(this.child.pid, 'SIGTERM');
3477
+ trace('ProcessRunner', () => `Sent SIGTERM to Bun process ${this.child.pid}`);
3478
+ killOperations.push('SIGTERM to process');
3479
+ } catch (err) {
3480
+ trace('ProcessRunner', () => `Error sending SIGTERM to Bun process: ${err.message}`);
3481
+ }
3482
+
3483
+ // Try process group SIGTERM
3484
+ try {
3485
+ process.kill(-this.child.pid, 'SIGTERM');
3486
+ trace('ProcessRunner', () => `Sent SIGTERM to Bun process group -${this.child.pid}`);
3487
+ killOperations.push('SIGTERM to group');
3488
+ } catch (err) {
3489
+ trace('ProcessRunner', () => `Bun process group SIGTERM failed: ${err.message}`);
3490
+ }
3491
+
3492
+ // Immediately follow with SIGKILL for both process and group
3493
+ try {
3494
+ process.kill(this.child.pid, 'SIGKILL');
3495
+ trace('ProcessRunner', () => `Sent SIGKILL to Bun process ${this.child.pid}`);
3496
+ killOperations.push('SIGKILL to process');
3497
+ } catch (err) {
3498
+ trace('ProcessRunner', () => `Error sending SIGKILL to Bun process: ${err.message}`);
3499
+ }
3500
+
3501
+ try {
3502
+ process.kill(-this.child.pid, 'SIGKILL');
3503
+ trace('ProcessRunner', () => `Sent SIGKILL to Bun process group -${this.child.pid}`);
3504
+ killOperations.push('SIGKILL to group');
3505
+ } catch (err) {
3506
+ trace('ProcessRunner', () => `Bun process group SIGKILL failed: ${err.message}`);
3507
+ }
3508
+
3509
+ trace('ProcessRunner', () => `Bun kill operations attempted: ${killOperations.join(', ')}`);
3510
+
3511
+ // Also call the original Bun kill method as backup
3512
+ try {
3513
+ this.child.kill();
3514
+ trace('ProcessRunner', () => `Called child.kill() for Bun process ${this.child.pid}`);
3515
+ } catch (err) {
3516
+ trace('ProcessRunner', () => `Error calling child.kill(): ${err.message}`);
3517
+ }
3518
+
3519
+ // Force cleanup of child reference
3520
+ if (this.child) {
3521
+ this.child.removeAllListeners?.();
3522
+ this.child = null;
3523
+ }
2280
3524
  } else {
2281
- // In Node.js, kill the process group
2282
- trace('ProcessRunner', () => `Killing Node process group | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
2283
- process.kill(-this.child.pid, 'SIGTERM');
3525
+ // In Node.js, use a more robust approach for CI environments
3526
+ trace('ProcessRunner', () => `Killing Node process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}`);
3527
+
3528
+ // Use immediate and aggressive termination for CI environments
3529
+ const killOperations = [];
3530
+
3531
+ // Try SIGTERM to the process directly
3532
+ try {
3533
+ process.kill(this.child.pid, 'SIGTERM');
3534
+ trace('ProcessRunner', () => `Sent SIGTERM to process ${this.child.pid}`);
3535
+ killOperations.push('SIGTERM to process');
3536
+ } catch (err) {
3537
+ trace('ProcessRunner', () => `Error sending SIGTERM to process: ${err.message}`);
3538
+ }
3539
+
3540
+ // Try process group if detached (negative PID)
3541
+ try {
3542
+ process.kill(-this.child.pid, 'SIGTERM');
3543
+ trace('ProcessRunner', () => `Sent SIGTERM to process group -${this.child.pid}`);
3544
+ killOperations.push('SIGTERM to group');
3545
+ } catch (err) {
3546
+ trace('ProcessRunner', () => `Process group SIGTERM failed: ${err.message}`);
3547
+ }
3548
+
3549
+ // Immediately follow up with SIGKILL for CI reliability
3550
+ try {
3551
+ process.kill(this.child.pid, 'SIGKILL');
3552
+ trace('ProcessRunner', () => `Sent SIGKILL to process ${this.child.pid}`);
3553
+ killOperations.push('SIGKILL to process');
3554
+ } catch (err) {
3555
+ trace('ProcessRunner', () => `Error sending SIGKILL to process: ${err.message}`);
3556
+ }
3557
+
3558
+ try {
3559
+ process.kill(-this.child.pid, 'SIGKILL');
3560
+ trace('ProcessRunner', () => `Sent SIGKILL to process group -${this.child.pid}`);
3561
+ killOperations.push('SIGKILL to group');
3562
+ } catch (err) {
3563
+ trace('ProcessRunner', () => `Process group SIGKILL failed: ${err.message}`);
3564
+ }
3565
+
3566
+ trace('ProcessRunner', () => `Kill operations attempted: ${killOperations.join(', ')}`);
3567
+
3568
+ // Force cleanup of child reference to prevent hanging awaits
3569
+ if (this.child) {
3570
+ this.child.removeAllListeners?.();
3571
+ this.child = null;
3572
+ }
2284
3573
  }
2285
3574
  }
2286
- this.finished = true;
3575
+ // finished will be set by the main cleanup below
2287
3576
  } catch (err) {
2288
3577
  // Process might already be dead
2289
3578
  trace('ProcessRunner', () => `Error killing process | ${JSON.stringify({ error: err.message }, null, 2)}`);
@@ -2291,8 +3580,14 @@ class ProcessRunner extends StreamEmitter {
2291
3580
  }
2292
3581
  }
2293
3582
 
2294
- // Mark as finished
2295
- this.finished = true;
3583
+ // Mark as finished and emit completion events
3584
+ const result = createResult({
3585
+ code: signal === 'SIGKILL' ? 137 : signal === 'SIGTERM' ? 143 : 130,
3586
+ stdout: '',
3587
+ stderr: `Process killed with ${signal}`,
3588
+ stdin: ''
3589
+ });
3590
+ this.finish(result);
2296
3591
 
2297
3592
  trace('ProcessRunner', () => `kill EXIT | ${JSON.stringify({
2298
3593
  cancelled: this._cancelled,
@@ -2353,7 +3648,20 @@ class ProcessRunner extends StreamEmitter {
2353
3648
  if (!this.promise) {
2354
3649
  this.promise = this._startAsync();
2355
3650
  }
2356
- return this.promise.finally(onFinally);
3651
+ return this.promise.finally(() => {
3652
+ // Ensure cleanup happened
3653
+ if (!this.finished) {
3654
+ trace('ProcessRunner', () => 'Finally handler ensuring cleanup');
3655
+ const fallbackResult = createResult({
3656
+ code: 1,
3657
+ stdout: '',
3658
+ stderr: 'Process terminated unexpectedly',
3659
+ stdin: ''
3660
+ });
3661
+ this.finish(fallbackResult);
3662
+ }
3663
+ if (onFinally) onFinally();
3664
+ });
2357
3665
  }
2358
3666
 
2359
3667
  // Internal sync execution
@@ -2438,9 +3746,6 @@ class ProcessRunner extends StreamEmitter {
2438
3746
  this.outChunks = result.stdout ? [Buffer.from(result.stdout)] : [];
2439
3747
  this.errChunks = result.stderr ? [Buffer.from(result.stderr)] : [];
2440
3748
 
2441
- this.result = result;
2442
- this.finished = true;
2443
-
2444
3749
  // Emit batched events after completion
2445
3750
  if (result.stdout) {
2446
3751
  const stdoutBuf = Buffer.from(result.stdout);
@@ -2452,8 +3757,7 @@ class ProcessRunner extends StreamEmitter {
2452
3757
  this._emitProcessedData('stderr', stderrBuf);
2453
3758
  }
2454
3759
 
2455
- this.emit('end', result);
2456
- this.emit('exit', result.code);
3760
+ this.finish(result);
2457
3761
 
2458
3762
  if (globalShellSettings.errexit && result.code !== 0) {
2459
3763
  const error = new Error(`Command failed with exit code ${result.code}`);
@@ -2467,18 +3771,6 @@ class ProcessRunner extends StreamEmitter {
2467
3771
  return result;
2468
3772
  }
2469
3773
 
2470
- // Stream properties
2471
- get stdout() {
2472
- return this.child?.stdout;
2473
- }
2474
-
2475
- get stderr() {
2476
- return this.child?.stderr;
2477
- }
2478
-
2479
- get stdin() {
2480
- return this.child?.stdin;
2481
- }
2482
3774
  }
2483
3775
 
2484
3776
  // Public APIs
@@ -2526,6 +3818,28 @@ async function run(commandOrTokens, options = {}) {
2526
3818
  }
2527
3819
 
2528
3820
  function $tagged(strings, ...values) {
3821
+ // Check if called as a function with options object: $({ options })
3822
+ if (!Array.isArray(strings) && typeof strings === 'object' && strings !== null) {
3823
+ const options = strings;
3824
+ trace('API', () => `$tagged called with options | ${JSON.stringify({ options }, null, 2)}`);
3825
+
3826
+ // Return a new tagged template function with those options
3827
+ return (innerStrings, ...innerValues) => {
3828
+ trace('API', () => `$tagged.withOptions ENTER | ${JSON.stringify({
3829
+ stringsLength: innerStrings.length,
3830
+ valuesLength: innerValues.length,
3831
+ options
3832
+ }, null, 2)}`);
3833
+
3834
+ const cmd = buildShellCommand(innerStrings, innerValues);
3835
+ const runner = new ProcessRunner({ mode: 'shell', command: cmd }, { mirror: true, capture: true, ...options });
3836
+
3837
+ trace('API', () => `$tagged.withOptions EXIT | ${JSON.stringify({ command: cmd }, null, 2)}`);
3838
+ return runner;
3839
+ };
3840
+ }
3841
+
3842
+ // Normal tagged template literal usage
2529
3843
  trace('API', () => `$tagged ENTER | ${JSON.stringify({
2530
3844
  stringsLength: strings.length,
2531
3845
  valuesLength: values.length
@@ -2779,6 +4093,7 @@ export {
2779
4093
  AnsiUtils,
2780
4094
  configureAnsi,
2781
4095
  getAnsiConfig,
2782
- processOutput
4096
+ processOutput,
4097
+ forceCleanupAll
2783
4098
  };
2784
4099
  export default $tagged;