start-command 0.24.9 → 0.25.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # start-command
2
2
 
3
+ ## 0.25.1
4
+
5
+ ### Patch Changes
6
+
7
+ - da6df0e: fix: correct license field from MIT to Unlicense (public domain)
8
+
9
+ Updated `package.json` to correctly reflect the Unlicense (public domain) license instead of MIT. The project's `LICENSE` file has always contained the Unlicense text; this change aligns the metadata with the actual license.
10
+
11
+ Fixes #99
12
+
13
+ ## 0.25.0
14
+
15
+ ### Minor Changes
16
+
17
+ - c0b3a03: fix: use screenrc-based logging for all screen versions (issue #96)
18
+
19
+ The previous fix (v0.24.9) used `-L -Logfile` for screen >= 4.5.1 and tee fallback
20
+ for older versions. The tee fallback failed on macOS with screen 4.00.03 because
21
+ tee's write buffers weren't flushed before the session ended.
22
+
23
+ The new approach uses screenrc directives (`logfile`, `logfile flush 0`, `deflog on`)
24
+ that work on ALL screen versions, eliminating both the version-dependent branching
25
+ and the unreliable tee fallback entirely.
26
+
27
+ Additional improvements:
28
+ - **Exit code capture**: Commands now report their actual exit code via a sidecar
29
+ file, instead of always reporting 0.
30
+ - **Enhanced retry logic**: 3 retries with increasing delays (50/100/200ms) instead
31
+ of a single 50ms retry.
32
+ - **Better debug output**: Screen isolation debug messages respond to both
33
+ `START_DEBUG` and `START_VERBOSE` environment variables.
34
+ - **New tests**: Exit code capture, stderr capture, and multi-line output verification
35
+ in both JavaScript and Rust.
36
+
37
+ Fixes #96
38
+
3
39
  ## 0.24.9
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.9",
3
+ "version": "0.25.1",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -32,7 +32,7 @@
32
32
  "automation"
33
33
  ],
34
34
  "author": "",
35
- "license": "MIT",
35
+ "license": "Unlicense",
36
36
  "engines": {
37
37
  "bun": ">=1.0.0"
38
38
  },
@@ -18,6 +18,7 @@
18
18
  * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
19
19
  * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
20
20
  * --use-command-stream Use command-stream library for command execution (experimental)
21
+ * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
21
22
  * --status <uuid> Show status of a previous command execution by UUID
22
23
  * --output-format <format> Output format for status (links-notation, json, text)
23
24
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
@@ -395,6 +396,12 @@ function parseOption(args, index, options) {
395
396
  return 1;
396
397
  }
397
398
 
399
+ // --verbose (enable verbose/debug output, sets START_VERBOSE env var)
400
+ if (arg === '--verbose') {
401
+ process.env.START_VERBOSE = '1';
402
+ return 1;
403
+ }
404
+
398
405
  // --session-id or --session-name (alias) <uuid>
399
406
  if (arg === '--session-id' || arg === '--session-name') {
400
407
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -7,9 +7,16 @@ const path = require('path');
7
7
 
8
8
  const setTimeout = globalThis.setTimeout;
9
9
 
10
- // Debug mode from environment
11
- const DEBUG =
12
- process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
10
+ // Debug mode from environment (START_DEBUG or START_VERBOSE).
11
+ // Evaluated as a function so that env vars set after module load (e.g., by --verbose flag) are respected.
12
+ function isDebug() {
13
+ return (
14
+ process.env.START_DEBUG === '1' ||
15
+ process.env.START_DEBUG === 'true' ||
16
+ process.env.START_VERBOSE === '1' ||
17
+ process.env.START_VERBOSE === 'true'
18
+ );
19
+ }
13
20
 
14
21
  // Cache for screen version detection
15
22
  let cachedScreenVersion = null;
@@ -40,17 +47,17 @@ function getScreenVersion() {
40
47
  patch: parseInt(match[3], 10),
41
48
  };
42
49
 
43
- if (DEBUG) {
44
- console.log(
45
- `[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
50
+ if (isDebug()) {
51
+ console.error(
52
+ `[screen-isolation] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
46
53
  );
47
54
  }
48
55
 
49
56
  return cachedScreenVersion;
50
57
  }
51
58
  } catch {
52
- if (DEBUG) {
53
- console.log('[DEBUG] Could not detect screen version');
59
+ if (isDebug()) {
60
+ console.error('[screen-isolation] Could not detect screen version');
54
61
  }
55
62
  }
56
63
 
@@ -90,14 +97,33 @@ function supportsLogfileOption() {
90
97
 
91
98
  /**
92
99
  * Run command in GNU Screen using detached mode with log capture.
93
- * Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
100
+ *
101
+ * Uses a unified approach combining the `-L` flag with screenrc directives:
102
+ * - `-L` flag enables logging for the initial window (available on ALL screen versions)
103
+ * - `logfile <path>` in screenrc sets the log file path (replaces `-Logfile` CLI option)
104
+ * - `logfile flush 0` forces immediate flushing (no 10-second delay)
105
+ * - `deflog on` enables logging for any additional windows
106
+ *
107
+ * Key insight: `deflog on` only applies to windows created AFTER screenrc processing,
108
+ * but the default window is created BEFORE screenrc is processed. The `-L` flag is
109
+ * needed to enable logging for that initial window. Without it, output is silently
110
+ * lost on macOS screen 4.00.03 (issue #96).
111
+ *
112
+ * This replaces the previous version-dependent approach that used:
113
+ * - `-L -Logfile` for screen >= 4.5.1 (native logging)
114
+ * - `tee` fallback for screen < 4.5.1 (e.g., macOS bundled 4.0.3)
115
+ *
116
+ * The tee fallback had reliability issues on macOS because:
117
+ * - tee's write buffers may not be flushed before the session ends
118
+ * - The TOCTOU race between session detection and file read was hard to mitigate
119
+ *
94
120
  * @param {string} command - Command to execute
95
121
  * @param {string} sessionName - Session name
96
122
  * @param {object} shellInfo - Shell info from getShell()
97
123
  * @param {string|null} user - Username to run command as (optional)
98
124
  * @param {Function} wrapCommandWithUser - Function to wrap command with user
99
125
  * @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
100
- * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
126
+ * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
101
127
  */
102
128
  function runScreenWithLogCapture(
103
129
  command,
@@ -109,63 +135,93 @@ function runScreenWithLogCapture(
109
135
  ) {
110
136
  const { shell, shellArg } = shellInfo;
111
137
  const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
112
-
113
- // Check if screen supports -Logfile option (added in 4.5.1)
114
- const useNativeLogging = supportsLogfileOption();
138
+ const exitCodeFile = path.join(
139
+ os.tmpdir(),
140
+ `screen-exit-${sessionName}.code`
141
+ );
115
142
 
116
143
  return new Promise((resolve) => {
117
144
  try {
118
- let screenArgs;
119
145
  // Wrap command with user switch if specified
120
146
  let effectiveCommand = wrapCommandWithUser(command, user);
121
147
 
122
- // Temporary screenrc file for native logging path (issue #96)
123
- // Setting logfile flush 0 forces screen to flush its log buffer after every write,
124
- // preventing output loss for quick-completing commands like `agent --version`.
125
- // Without this, screen buffers log writes and flushes every 10 seconds by default.
126
- let screenrcFile = null;
127
-
128
- if (useNativeLogging) {
129
- // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
130
- // Use a temporary screenrc with `logfile flush 0` to force immediate log flushing
131
- // (issue #96: quick commands like `agent --version` lose output without this)
132
- screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
133
- try {
134
- fs.writeFileSync(screenrcFile, 'logfile flush 0\n');
135
- } catch {
136
- // If we can't create the screenrc, proceed without it (best effort)
137
- screenrcFile = null;
138
- }
148
+ // Wrap command to capture exit code in a sidecar file.
149
+ // We save $? after the command completes so we can report the real exit code
150
+ // instead of always assuming 0 (previous behavior).
151
+ const isBareShell = isInteractiveShellCommand(command);
152
+ if (!isBareShell) {
153
+ effectiveCommand = `${effectiveCommand}; echo $? > "${exitCodeFile}"`;
154
+ }
139
155
 
140
- // screen -dmS <session> -c <screenrc> -L -Logfile <logfile> <shell> -c '<command>'
141
- const logArgs = screenrcFile
142
- ? ['-dmS', sessionName, '-c', screenrcFile, '-L', '-Logfile', logFile]
143
- : ['-dmS', sessionName, '-L', '-Logfile', logFile];
144
- screenArgs = isInteractiveShellCommand(command)
145
- ? [...logArgs, ...command.trim().split(/\s+/)]
146
- : [...logArgs, shell, shellArg, effectiveCommand];
147
-
148
- if (DEBUG) {
149
- console.log(
150
- `[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
156
+ // Create temporary screenrc with logging configuration.
157
+ // Combined with the -L flag (which enables logging for the initial window),
158
+ // these directives work on ALL screen versions (including macOS 4.00.03):
159
+ // - `logfile <path>` sets the output log path (replaces -Logfile CLI option)
160
+ // - `logfile flush 0` forces immediate buffer flush (prevents output loss)
161
+ // - `deflog on` enables logging for any subsequently created windows
162
+ const screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
163
+ const screenrcContent = [
164
+ `logfile ${logFile}`,
165
+ 'logfile flush 0',
166
+ 'deflog on',
167
+ '',
168
+ ].join('\n');
169
+
170
+ try {
171
+ fs.writeFileSync(screenrcFile, screenrcContent);
172
+ } catch (err) {
173
+ if (isDebug()) {
174
+ console.error(
175
+ `[screen-isolation] Failed to create screenrc: ${err.message}`
151
176
  );
152
177
  }
153
- } else {
154
- // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
155
- // The parentheses ensure proper grouping of the command and its stderr
156
- const isBareShell = isInteractiveShellCommand(command);
157
- if (!isBareShell) {
158
- effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
159
- }
160
- screenArgs = isBareShell
161
- ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
162
- : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
178
+ resolve({
179
+ success: false,
180
+ sessionName,
181
+ message: `Failed to create screenrc for logging: ${err.message}`,
182
+ });
183
+ return;
184
+ }
163
185
 
164
- if (DEBUG) {
165
- console.log(
166
- `[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
167
- );
168
- }
186
+ // Build screen arguments:
187
+ // screen -dmS <session> -L -c <screenrc> <shell> -c '<command>'
188
+ //
189
+ // The -L flag explicitly enables logging for the initial window.
190
+ // Without -L, `deflog on` in screenrc only applies to windows created
191
+ // AFTER the screenrc is processed — but the default window is created
192
+ // BEFORE screenrc processing. This caused output to be silently lost
193
+ // on macOS screen 4.00.03 (issue #96).
194
+ //
195
+ // The -L flag is available on ALL screen versions (including 4.00.03).
196
+ // Combined with `logfile <path>` in screenrc, -L logs to our custom path
197
+ // instead of the default `screenlog.0`.
198
+ const screenArgs = isBareShell
199
+ ? [
200
+ '-dmS',
201
+ sessionName,
202
+ '-L',
203
+ '-c',
204
+ screenrcFile,
205
+ ...command.trim().split(/\s+/),
206
+ ]
207
+ : [
208
+ '-dmS',
209
+ sessionName,
210
+ '-L',
211
+ '-c',
212
+ screenrcFile,
213
+ shell,
214
+ shellArg,
215
+ effectiveCommand,
216
+ ];
217
+
218
+ if (isDebug()) {
219
+ console.error(
220
+ `[screen-isolation] Running: screen ${screenArgs.join(' ')}`
221
+ );
222
+ console.error(`[screen-isolation] screenrc: ${screenrcContent.trim()}`);
223
+ console.error(`[screen-isolation] Log file: ${logFile}`);
224
+ console.error(`[screen-isolation] Exit code file: ${exitCodeFile}`);
169
225
  }
170
226
 
171
227
  // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
@@ -177,11 +233,14 @@ function runScreenWithLogCapture(
177
233
  throw result.error;
178
234
  }
179
235
 
180
- // Helper to read log file output and write to stdout
181
- // Includes a short retry for the tee fallback path to handle the TOCTOU race
182
- // condition where the session appears gone but the log file isn't fully written yet
183
- // (issue #96)
236
+ // Helper to read log file output and write to stdout.
237
+ // Uses multiple retries with increasing delays to handle the race condition
238
+ // where the screen session disappears from `screen -ls` but the log file
239
+ // hasn't been fully flushed yet (issue #96).
184
240
  const readAndDisplayOutput = (retryCount = 0) => {
241
+ const MAX_RETRIES = 3;
242
+ const RETRY_DELAYS = [50, 100, 200]; // ms
243
+
185
244
  let output = '';
186
245
  try {
187
246
  output = fs.readFileSync(logFile, 'utf8');
@@ -189,17 +248,36 @@ function runScreenWithLogCapture(
189
248
  // Log file might not exist if command produced no output
190
249
  }
191
250
 
192
- // If output is empty and we haven't retried yet, wait briefly and retry once.
193
- // This handles the race where tee's write hasn't been flushed to disk yet
194
- // when the screen session appears done in `screen -ls` (issue #96).
195
- if (!output.trim() && retryCount === 0) {
251
+ // If output is empty and we haven't exhausted retries, wait and retry.
252
+ if (!output.trim() && retryCount < MAX_RETRIES) {
253
+ const delay = RETRY_DELAYS[retryCount] || 200;
254
+ if (isDebug()) {
255
+ console.error(
256
+ `[screen-isolation] Log file empty, retry ${retryCount + 1}/${MAX_RETRIES} after ${delay}ms`
257
+ );
258
+ }
196
259
  return new Promise((resolveRetry) => {
197
260
  setTimeout(() => {
198
- resolveRetry(readAndDisplayOutput(1));
199
- }, 50);
261
+ resolveRetry(readAndDisplayOutput(retryCount + 1));
262
+ }, delay);
200
263
  });
201
264
  }
202
265
 
266
+ if (isDebug() && !output.trim()) {
267
+ console.error(
268
+ `[screen-isolation] Log file still empty after ${MAX_RETRIES} retries`
269
+ );
270
+ // Check if log file exists at all
271
+ try {
272
+ const stats = fs.statSync(logFile);
273
+ console.error(
274
+ `[screen-isolation] Log file exists, size: ${stats.size} bytes`
275
+ );
276
+ } catch {
277
+ console.error(`[screen-isolation] Log file does not exist`);
278
+ }
279
+ }
280
+
203
281
  // Display the output
204
282
  if (output.trim()) {
205
283
  process.stdout.write(output);
@@ -211,16 +289,33 @@ function runScreenWithLogCapture(
211
289
  return Promise.resolve(output);
212
290
  };
213
291
 
214
- // Clean up temp files
215
- const cleanupTempFiles = () => {
292
+ // Read exit code from sidecar file
293
+ const readExitCode = () => {
294
+ if (isBareShell) {
295
+ return 0; // Can't capture exit code for interactive shells
296
+ }
216
297
  try {
217
- fs.unlinkSync(logFile);
298
+ const content = fs.readFileSync(exitCodeFile, 'utf8').trim();
299
+ const code = parseInt(content, 10);
300
+ if (isDebug()) {
301
+ console.error(`[screen-isolation] Captured exit code: ${code}`);
302
+ }
303
+ return isNaN(code) ? 0 : code;
218
304
  } catch {
219
- // Ignore cleanup errors
305
+ if (isDebug()) {
306
+ console.error(
307
+ `[screen-isolation] Could not read exit code file, defaulting to 0`
308
+ );
309
+ }
310
+ return 0;
220
311
  }
221
- if (screenrcFile) {
312
+ };
313
+
314
+ // Clean up temp files
315
+ const cleanupTempFiles = () => {
316
+ for (const f of [logFile, screenrcFile, exitCodeFile]) {
222
317
  try {
223
- fs.unlinkSync(screenrcFile);
318
+ fs.unlinkSync(f);
224
319
  } catch {
225
320
  // Ignore cleanup errors
226
321
  }
@@ -241,14 +336,15 @@ function runScreenWithLogCapture(
241
336
  });
242
337
 
243
338
  if (!sessions.includes(sessionName)) {
244
- // Session ended, read output (with retry for tee path race condition)
339
+ // Session ended, read output and exit code
245
340
  readAndDisplayOutput().then((output) => {
341
+ const exitCode = readExitCode();
246
342
  cleanupTempFiles();
247
343
  resolve({
248
- success: true,
344
+ success: exitCode === 0,
249
345
  sessionName,
250
- message: `Screen session "${sessionName}" exited with code 0`,
251
- exitCode: 0,
346
+ message: `Screen session "${sessionName}" exited with code ${exitCode}`,
347
+ exitCode,
252
348
  output,
253
349
  });
254
350
  });
@@ -271,12 +367,13 @@ function runScreenWithLogCapture(
271
367
  } catch {
272
368
  // screen -ls failed, session probably ended
273
369
  readAndDisplayOutput().then((output) => {
370
+ const exitCode = readExitCode();
274
371
  cleanupTempFiles();
275
372
  resolve({
276
- success: true,
373
+ success: exitCode === 0,
277
374
  sessionName,
278
- message: `Screen session "${sessionName}" exited with code 0`,
279
- exitCode: 0,
375
+ message: `Screen session "${sessionName}" exited with code ${exitCode}`,
376
+ exitCode,
280
377
  output,
281
378
  });
282
379
  });
@@ -525,193 +525,8 @@ describe('Isolation Runner with Available Backends', () => {
525
525
  } = require('../src/lib/isolation');
526
526
  const { execSync } = require('child_process');
527
527
 
528
- describe('runInScreen (if available)', () => {
529
- it('should run command in detached screen session', async () => {
530
- if (!isCommandAvailable('screen')) {
531
- console.log(' Skipping: screen not installed');
532
- return;
533
- }
534
-
535
- const result = await runInScreen('echo "test from screen"', {
536
- session: `test-session-${Date.now()}`,
537
- detached: true,
538
- });
539
-
540
- assert.strictEqual(result.success, true);
541
- assert.ok(result.sessionName);
542
- assert.ok(result.message.includes('screen'));
543
- assert.ok(result.message.includes('Reattach with'));
544
-
545
- // Clean up the session
546
- try {
547
- execSync(`screen -S ${result.sessionName} -X quit`, {
548
- stdio: 'ignore',
549
- });
550
- } catch {
551
- // Session may have already exited
552
- }
553
- });
554
-
555
- it('should run command in attached mode and capture output (issue #15)', async () => {
556
- if (!isCommandAvailable('screen')) {
557
- console.log(' Skipping: screen not installed');
558
- return;
559
- }
560
-
561
- // Test attached mode - this should work without TTY using log capture fallback
562
- const result = await runInScreen('echo hello', {
563
- session: `test-attached-${Date.now()}`,
564
- detached: false,
565
- });
566
-
567
- assert.strictEqual(result.success, true);
568
- assert.ok(result.sessionName);
569
- assert.ok(result.message.includes('exited with code 0'));
570
- // The output property should exist when using log capture
571
- if (result.output !== undefined) {
572
- console.log(` Captured output: "${result.output.trim()}"`);
573
- assert.ok(
574
- result.output.includes('hello'),
575
- 'Output should contain the expected message'
576
- );
577
- }
578
- });
579
-
580
- it('should handle multi-line output in attached mode', async () => {
581
- if (!isCommandAvailable('screen')) {
582
- console.log(' Skipping: screen not installed');
583
- return;
584
- }
585
-
586
- const result = await runInScreen(
587
- "echo 'line1'; echo 'line2'; echo 'line3'",
588
- {
589
- session: `test-multiline-${Date.now()}`,
590
- detached: false,
591
- }
592
- );
593
-
594
- assert.strictEqual(result.success, true);
595
- if (result.output !== undefined) {
596
- console.log(
597
- ` Captured multi-line output: "${result.output.trim().replace(/\n/g, '\\n')}"`
598
- );
599
- assert.ok(result.output.includes('line1'));
600
- assert.ok(result.output.includes('line2'));
601
- assert.ok(result.output.includes('line3'));
602
- }
603
- });
604
-
605
- it('should capture output from commands with quoted strings (issue #25)', async () => {
606
- if (!isCommandAvailable('screen')) {
607
- console.log(' Skipping: screen not installed');
608
- return;
609
- }
610
-
611
- // This is the exact scenario from issue #25:
612
- // $ --isolated screen --verbose -- echo "hello"
613
- // Previously failed because of shell quoting issues with execSync
614
- const result = await runInScreen('echo "hello"', {
615
- session: `test-quoted-${Date.now()}`,
616
- detached: false,
617
- });
618
-
619
- assert.strictEqual(result.success, true);
620
- assert.ok(result.sessionName);
621
- assert.ok(result.message.includes('exited with code 0'));
622
- if (result.output !== undefined) {
623
- console.log(` Captured quoted output: "${result.output.trim()}"`);
624
- assert.ok(
625
- result.output.includes('hello'),
626
- 'Output should contain "hello" (issue #25 regression test)'
627
- );
628
- }
629
- });
630
-
631
- it('should capture output from commands with complex quoted strings', async () => {
632
- if (!isCommandAvailable('screen')) {
633
- console.log(' Skipping: screen not installed');
634
- return;
635
- }
636
-
637
- // Test more complex quoting scenarios
638
- const result = await runInScreen('echo "hello from attached mode"', {
639
- session: `test-complex-quote-${Date.now()}`,
640
- detached: false,
641
- });
642
-
643
- assert.strictEqual(result.success, true);
644
- if (result.output !== undefined) {
645
- console.log(
646
- ` Captured complex quote output: "${result.output.trim()}"`
647
- );
648
- assert.ok(
649
- result.output.includes('hello from attached mode'),
650
- 'Output should contain the full message with spaces'
651
- );
652
- }
653
- });
654
-
655
- it('should always return output property in attached mode (issue #25 fix verification)', async () => {
656
- if (!isCommandAvailable('screen')) {
657
- console.log(' Skipping: screen not installed');
658
- return;
659
- }
660
-
661
- // This test verifies that attached mode always uses log capture,
662
- // ensuring output is never lost even for quick commands.
663
- // This is the core fix for issue #25 where output was lost on macOS
664
- // because screen's virtual terminal was destroyed before output could be seen.
665
- const result = await runInScreen('echo "quick command output"', {
666
- session: `test-output-guaranteed-${Date.now()}`,
667
- detached: false,
668
- });
669
-
670
- assert.strictEqual(result.success, true);
671
- assert.ok(
672
- result.output !== undefined,
673
- 'Attached mode should always return output property'
674
- );
675
- assert.ok(
676
- result.output.includes('quick command output'),
677
- 'Output should be captured (issue #25 fix verification)'
678
- );
679
- console.log(` Verified output capture: "${result.output.trim()}"`);
680
- });
681
-
682
- it('should capture output from version-flag commands (issue #96)', async () => {
683
- if (!isCommandAvailable('screen')) {
684
- console.log(' Skipping: screen not installed');
685
- return;
686
- }
687
-
688
- // This test verifies that screen isolation captures output from quick-completing
689
- // commands like `agent --version` or `node --version`.
690
- // Issue #96: output was silently lost because screen's internal log buffer was
691
- // not flushed before the session terminated (default 10s flush interval).
692
- // Fix: use a temporary screenrc with `logfile flush 0` to force immediate flushing.
693
- const result = await runInScreen('node --version', {
694
- session: `test-version-flag-${Date.now()}`,
695
- detached: false,
696
- });
697
-
698
- assert.strictEqual(result.success, true, 'Command should succeed');
699
- assert.ok(
700
- result.output !== undefined,
701
- 'Attached mode should always return output property'
702
- );
703
- assert.ok(
704
- result.output.trim().length > 0,
705
- 'Output should not be empty (issue #96: version output was silently lost)'
706
- );
707
- // node --version outputs something like "v20.0.0"
708
- assert.ok(
709
- result.output.includes('v') || /\d+\.\d+/.test(result.output),
710
- 'Output should contain version string'
711
- );
712
- console.log(` Captured version output: "${result.output.trim()}"`);
713
- });
714
- });
528
+ // Screen integration tests moved to screen-integration.test.js
529
+ // to keep file under the 1000-line limit.
715
530
 
716
531
  describe('runInTmux (if available)', () => {
717
532
  it('should run command in detached tmux session', async () => {
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Integration tests for screen isolation
4
+ * Tests actual screen session behavior including output capture, exit codes, and edge cases.
5
+ * Extracted from isolation.test.js to keep file sizes under the 1000-line limit.
6
+ */
7
+
8
+ const { describe, it } = require('node:test');
9
+ const assert = require('assert');
10
+ const { execSync } = require('child_process');
11
+ const { isCommandAvailable, runInScreen } = require('../src/lib/isolation');
12
+
13
+ describe('Screen Integration Tests', () => {
14
+ describe('runInScreen (if available)', () => {
15
+ it('should run command in detached screen session', async () => {
16
+ if (!isCommandAvailable('screen')) {
17
+ console.log(' Skipping: screen not installed');
18
+ return;
19
+ }
20
+
21
+ const result = await runInScreen('echo "test from screen"', {
22
+ session: `test-session-${Date.now()}`,
23
+ detached: true,
24
+ });
25
+
26
+ assert.strictEqual(result.success, true);
27
+ assert.ok(result.sessionName);
28
+ assert.ok(result.message.includes('screen'));
29
+ assert.ok(result.message.includes('Reattach with'));
30
+
31
+ // Clean up the session
32
+ try {
33
+ execSync(`screen -S ${result.sessionName} -X quit`, {
34
+ stdio: 'ignore',
35
+ });
36
+ } catch {
37
+ // Session may have already exited
38
+ }
39
+ });
40
+
41
+ it('should run command in attached mode and capture output (issue #15)', async () => {
42
+ if (!isCommandAvailable('screen')) {
43
+ console.log(' Skipping: screen not installed');
44
+ return;
45
+ }
46
+
47
+ // Test attached mode - this should work without TTY using log capture fallback
48
+ const result = await runInScreen('echo hello', {
49
+ session: `test-attached-${Date.now()}`,
50
+ detached: false,
51
+ });
52
+
53
+ assert.strictEqual(result.success, true);
54
+ assert.ok(result.sessionName);
55
+ assert.ok(result.message.includes('exited with code 0'));
56
+ // The output property should exist when using log capture
57
+ if (result.output !== undefined) {
58
+ console.log(` Captured output: "${result.output.trim()}"`);
59
+ assert.ok(
60
+ result.output.includes('hello'),
61
+ 'Output should contain the expected message'
62
+ );
63
+ }
64
+ });
65
+
66
+ it('should handle multi-line output in attached mode', async () => {
67
+ if (!isCommandAvailable('screen')) {
68
+ console.log(' Skipping: screen not installed');
69
+ return;
70
+ }
71
+
72
+ const result = await runInScreen(
73
+ "echo 'line1'; echo 'line2'; echo 'line3'",
74
+ {
75
+ session: `test-multiline-${Date.now()}`,
76
+ detached: false,
77
+ }
78
+ );
79
+
80
+ assert.strictEqual(result.success, true);
81
+ if (result.output !== undefined) {
82
+ console.log(
83
+ ` Captured multi-line output: "${result.output.trim().replace(/\n/g, '\\n')}"`
84
+ );
85
+ assert.ok(result.output.includes('line1'));
86
+ assert.ok(result.output.includes('line2'));
87
+ assert.ok(result.output.includes('line3'));
88
+ }
89
+ });
90
+
91
+ it('should capture output from commands with quoted strings (issue #25)', async () => {
92
+ if (!isCommandAvailable('screen')) {
93
+ console.log(' Skipping: screen not installed');
94
+ return;
95
+ }
96
+
97
+ const result = await runInScreen('echo "hello"', {
98
+ session: `test-quoted-${Date.now()}`,
99
+ detached: false,
100
+ });
101
+
102
+ assert.strictEqual(result.success, true);
103
+ assert.ok(result.sessionName);
104
+ assert.ok(result.message.includes('exited with code 0'));
105
+ if (result.output !== undefined) {
106
+ console.log(` Captured quoted output: "${result.output.trim()}"`);
107
+ assert.ok(
108
+ result.output.includes('hello'),
109
+ 'Output should contain "hello" (issue #25 regression test)'
110
+ );
111
+ }
112
+ });
113
+
114
+ it('should capture output from commands with complex quoted strings', async () => {
115
+ if (!isCommandAvailable('screen')) {
116
+ console.log(' Skipping: screen not installed');
117
+ return;
118
+ }
119
+
120
+ const result = await runInScreen('echo "hello from attached mode"', {
121
+ session: `test-complex-quote-${Date.now()}`,
122
+ detached: false,
123
+ });
124
+
125
+ assert.strictEqual(result.success, true);
126
+ if (result.output !== undefined) {
127
+ console.log(
128
+ ` Captured complex quote output: "${result.output.trim()}"`
129
+ );
130
+ assert.ok(
131
+ result.output.includes('hello from attached mode'),
132
+ 'Output should contain the full message with spaces'
133
+ );
134
+ }
135
+ });
136
+
137
+ it('should always return output property in attached mode (issue #25 fix verification)', async () => {
138
+ if (!isCommandAvailable('screen')) {
139
+ console.log(' Skipping: screen not installed');
140
+ return;
141
+ }
142
+
143
+ const result = await runInScreen('echo "quick command output"', {
144
+ session: `test-output-guaranteed-${Date.now()}`,
145
+ detached: false,
146
+ });
147
+
148
+ assert.strictEqual(result.success, true);
149
+ assert.ok(
150
+ result.output !== undefined,
151
+ 'Attached mode should always return output property'
152
+ );
153
+ assert.ok(
154
+ result.output.includes('quick command output'),
155
+ 'Output should be captured (issue #25 fix verification)'
156
+ );
157
+ console.log(` Verified output capture: "${result.output.trim()}"`);
158
+ });
159
+
160
+ it('should capture output from version-flag commands (issue #96)', async () => {
161
+ if (!isCommandAvailable('screen')) {
162
+ console.log(' Skipping: screen not installed');
163
+ return;
164
+ }
165
+
166
+ const result = await runInScreen('node --version', {
167
+ session: `test-version-flag-${Date.now()}`,
168
+ detached: false,
169
+ });
170
+
171
+ assert.strictEqual(result.success, true, 'Command should succeed');
172
+ assert.ok(
173
+ result.output !== undefined,
174
+ 'Attached mode should always return output property'
175
+ );
176
+ assert.ok(
177
+ result.output.trim().length > 0,
178
+ 'Output should not be empty (issue #96: version output was silently lost)'
179
+ );
180
+ assert.ok(
181
+ result.output.includes('v') || /\d+\.\d+/.test(result.output),
182
+ 'Output should contain version string'
183
+ );
184
+ console.log(` Captured version output: "${result.output.trim()}"`);
185
+ });
186
+
187
+ it('should capture exit code from failed commands (issue #96)', async () => {
188
+ if (!isCommandAvailable('screen')) {
189
+ console.log(' Skipping: screen not installed');
190
+ return;
191
+ }
192
+
193
+ const result = await runInScreen('nonexistent_command_12345', {
194
+ session: `test-exit-code-${Date.now()}`,
195
+ detached: false,
196
+ });
197
+
198
+ assert.strictEqual(
199
+ result.success,
200
+ false,
201
+ 'Command should fail (command not found)'
202
+ );
203
+ assert.ok(result.exitCode !== undefined, 'Exit code should be captured');
204
+ assert.ok(
205
+ result.exitCode !== 0,
206
+ `Exit code should be non-zero for failed command, got: ${result.exitCode}`
207
+ );
208
+ console.log(` Captured exit code: ${result.exitCode}`);
209
+ });
210
+
211
+ it('should capture stderr output in screen isolation (issue #96)', async () => {
212
+ if (!isCommandAvailable('screen')) {
213
+ console.log(' Skipping: screen not installed');
214
+ return;
215
+ }
216
+
217
+ const result = await runInScreen('echo "stderr-test" >&2', {
218
+ session: `test-stderr-${Date.now()}`,
219
+ detached: false,
220
+ });
221
+
222
+ assert.strictEqual(result.success, true, 'Command should succeed');
223
+ assert.ok(result.output !== undefined, 'Output should be captured');
224
+ assert.ok(
225
+ result.output.includes('stderr-test'),
226
+ 'stderr output should be captured via screen logging'
227
+ );
228
+ console.log(` Captured stderr output: "${result.output.trim()}"`);
229
+ });
230
+
231
+ it('should capture multi-line output with correct exit code (issue #96)', async () => {
232
+ if (!isCommandAvailable('screen')) {
233
+ console.log(' Skipping: screen not installed');
234
+ return;
235
+ }
236
+
237
+ const result = await runInScreen(
238
+ 'echo "line1" && echo "line2" && echo "line3"',
239
+ {
240
+ session: `test-multiline-exit-${Date.now()}`,
241
+ detached: false,
242
+ }
243
+ );
244
+
245
+ assert.strictEqual(result.success, true, 'Command should succeed');
246
+ assert.strictEqual(
247
+ result.exitCode,
248
+ 0,
249
+ 'Exit code should be 0 for successful command'
250
+ );
251
+ assert.ok(result.output.includes('line1'), 'Should contain line1');
252
+ assert.ok(result.output.includes('line2'), 'Should contain line2');
253
+ assert.ok(result.output.includes('line3'), 'Should contain line3');
254
+ console.log(` Multi-line output with exit code 0: verified`);
255
+ });
256
+ });
257
+ });