start-command 0.24.8 → 0.25.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/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # start-command
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c0b3a03: fix: use screenrc-based logging for all screen versions (issue #96)
8
+
9
+ The previous fix (v0.24.9) used `-L -Logfile` for screen >= 4.5.1 and tee fallback
10
+ for older versions. The tee fallback failed on macOS with screen 4.00.03 because
11
+ tee's write buffers weren't flushed before the session ended.
12
+
13
+ The new approach uses screenrc directives (`logfile`, `logfile flush 0`, `deflog on`)
14
+ that work on ALL screen versions, eliminating both the version-dependent branching
15
+ and the unreliable tee fallback entirely.
16
+
17
+ Additional improvements:
18
+ - **Exit code capture**: Commands now report their actual exit code via a sidecar
19
+ file, instead of always reporting 0.
20
+ - **Enhanced retry logic**: 3 retries with increasing delays (50/100/200ms) instead
21
+ of a single 50ms retry.
22
+ - **Better debug output**: Screen isolation debug messages respond to both
23
+ `START_DEBUG` and `START_VERBOSE` environment variables.
24
+ - **New tests**: Exit code capture, stderr capture, and multi-line output verification
25
+ in both JavaScript and Rust.
26
+
27
+ Fixes #96
28
+
29
+ ## 0.24.9
30
+
31
+ ### Patch Changes
32
+
33
+ - 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
34
+
35
+ When running a short-lived command like `agent --version` through screen isolation:
36
+
37
+ ```
38
+ $ --isolated screen -- agent --version
39
+ ```
40
+
41
+ the version output was silently lost — the command exited cleanly (exit code 0)
42
+ but no output was displayed.
43
+
44
+ **Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
45
+ (`log_flush = 10`). For commands that complete faster than this, the buffer may not
46
+ be flushed to the log file before the screen session terminates.
47
+
48
+ **Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
49
+ the `-c` option. This forces screen to flush the log buffer after every write,
50
+ eliminating the 10-second flush delay for quick-completing commands.
51
+
52
+ A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
53
+ to handle the TOCTOU race where the log file appears empty when first read
54
+ immediately after session completion.
55
+
56
+ Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
57
+ with equivalent test coverage added.
58
+
3
59
  ## 0.24.8
4
60
 
5
61
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.24.8",
3
+ "version": "0.25.0",
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": {
@@ -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('-')) {
@@ -1,94 +1,20 @@
1
1
  /** Isolation Runners for start-command (screen, tmux, docker, ssh) */
2
2
 
3
3
  const { execSync, spawn, spawnSync } = require('child_process');
4
- const fs = require('fs');
5
- const os = require('os');
6
4
  const path = require('path');
7
5
  const { generateSessionName } = require('./args-parser');
8
6
  const outputBlocks = require('./output-blocks');
9
7
 
10
- const setTimeout = globalThis.setTimeout;
11
-
12
8
  // Debug mode from environment
13
9
  const DEBUG =
14
10
  process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
15
11
 
16
- // Cache for screen version detection
17
- let cachedScreenVersion = null;
18
- let screenVersionChecked = false;
19
-
20
- /**
21
- * Get the installed screen version
22
- * @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
23
- */
24
- function getScreenVersion() {
25
- if (screenVersionChecked) {
26
- return cachedScreenVersion;
27
- }
28
-
29
- screenVersionChecked = true;
30
-
31
- try {
32
- const output = execSync('screen --version', {
33
- encoding: 'utf8',
34
- stdio: ['pipe', 'pipe', 'pipe'],
35
- });
36
- // Match patterns like "4.09.01", "4.00.03", "4.5.1"
37
- const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
38
- if (match) {
39
- cachedScreenVersion = {
40
- major: parseInt(match[1], 10),
41
- minor: parseInt(match[2], 10),
42
- patch: parseInt(match[3], 10),
43
- };
44
-
45
- if (DEBUG) {
46
- console.log(
47
- `[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
48
- );
49
- }
50
-
51
- return cachedScreenVersion;
52
- }
53
- } catch {
54
- if (DEBUG) {
55
- console.log('[DEBUG] Could not detect screen version');
56
- }
57
- }
58
-
59
- return null;
60
- }
61
-
62
- /**
63
- * Check if screen supports the -Logfile option
64
- * The -Logfile option was introduced in GNU Screen 4.5.1
65
- * @returns {boolean} True if -Logfile is supported
66
- */
67
- function supportsLogfileOption() {
68
- const version = getScreenVersion();
69
- if (!version) {
70
- // If we can't detect version, assume older version and use fallback
71
- return false;
72
- }
73
-
74
- // -Logfile was added in 4.5.1
75
- // Compare: version >= 4.5.1
76
- if (version.major > 4) {
77
- return true;
78
- }
79
- if (version.major < 4) {
80
- return false;
81
- }
82
- // major === 4
83
- if (version.minor > 5) {
84
- return true;
85
- }
86
- if (version.minor < 5) {
87
- return false;
88
- }
89
- // minor === 5
90
- return version.patch >= 1;
91
- }
12
+ const {
13
+ getScreenVersion,
14
+ supportsLogfileOption,
15
+ runScreenWithLogCapture: _runScreenWithLogCapture,
16
+ resetScreenVersionCache,
17
+ } = require('./screen-isolation');
92
18
 
93
19
  /**
94
20
  * Check if a command is available on the system
@@ -245,159 +171,14 @@ function wrapCommandWithUser(command, user) {
245
171
  * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
246
172
  */
247
173
  function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
248
- const { shell, shellArg } = shellInfo;
249
- const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
250
-
251
- // Check if screen supports -Logfile option (added in 4.5.1)
252
- const useNativeLogging = supportsLogfileOption();
253
-
254
- return new Promise((resolve) => {
255
- try {
256
- let screenArgs;
257
- // Wrap command with user switch if specified
258
- let effectiveCommand = wrapCommandWithUser(command, user);
259
-
260
- if (useNativeLogging) {
261
- // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
262
- // screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
263
- const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
264
- screenArgs = isInteractiveShellCommand(command)
265
- ? [...logArgs, ...command.trim().split(/\s+/)]
266
- : [...logArgs, shell, shellArg, effectiveCommand];
267
-
268
- if (DEBUG) {
269
- console.log(
270
- `[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
271
- );
272
- }
273
- } else {
274
- // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
275
- // The parentheses ensure proper grouping of the command and its stderr
276
- const isBareShell = isInteractiveShellCommand(command);
277
- if (!isBareShell) {
278
- effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
279
- }
280
- screenArgs = isBareShell
281
- ? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
282
- : ['-dmS', sessionName, shell, shellArg, effectiveCommand];
283
-
284
- if (DEBUG) {
285
- console.log(
286
- `[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
287
- );
288
- }
289
- }
290
-
291
- // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
292
- const result = spawnSync('screen', screenArgs, {
293
- stdio: 'inherit',
294
- });
295
-
296
- if (result.error) {
297
- throw result.error;
298
- }
299
-
300
- // Poll for session completion
301
- const checkInterval = 100; // ms
302
- const maxWait = 300000; // 5 minutes max
303
- let waited = 0;
304
-
305
- const checkCompletion = () => {
306
- try {
307
- // Check if session still exists
308
- const sessions = execSync('screen -ls', {
309
- encoding: 'utf8',
310
- stdio: ['pipe', 'pipe', 'pipe'],
311
- });
312
-
313
- if (!sessions.includes(sessionName)) {
314
- // Session ended, read output
315
- let output = '';
316
- try {
317
- output = fs.readFileSync(logFile, 'utf8');
318
- // Display the output with surrounding empty lines for consistency
319
- if (output.trim()) {
320
- process.stdout.write(output);
321
- // Add trailing newline if output doesn't end with one
322
- if (!output.endsWith('\n')) {
323
- process.stdout.write('\n');
324
- }
325
- }
326
- } catch {
327
- // Log file might not exist if command was very quick
328
- }
329
-
330
- // Clean up log file
331
- try {
332
- fs.unlinkSync(logFile);
333
- } catch {
334
- // Ignore cleanup errors
335
- }
336
-
337
- resolve({
338
- success: true,
339
- sessionName,
340
- message: `Screen session "${sessionName}" exited with code 0`,
341
- exitCode: 0,
342
- output,
343
- });
344
- return;
345
- }
346
-
347
- waited += checkInterval;
348
- if (waited >= maxWait) {
349
- resolve({
350
- success: false,
351
- sessionName,
352
- message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
353
- exitCode: 1,
354
- });
355
- return;
356
- }
357
-
358
- setTimeout(checkCompletion, checkInterval);
359
- } catch {
360
- // screen -ls failed, session probably ended
361
- let output = '';
362
- try {
363
- output = fs.readFileSync(logFile, 'utf8');
364
- if (output.trim()) {
365
- process.stdout.write(output);
366
- // Add trailing newline if output doesn't end with one
367
- if (!output.endsWith('\n')) {
368
- process.stdout.write('\n');
369
- }
370
- }
371
- } catch {
372
- // Ignore
373
- }
374
-
375
- try {
376
- fs.unlinkSync(logFile);
377
- } catch {
378
- // Ignore
379
- }
380
-
381
- resolve({
382
- success: true,
383
- sessionName,
384
- message: `Screen session "${sessionName}" exited with code 0`,
385
- exitCode: 0,
386
- output,
387
- });
388
- }
389
- };
390
-
391
- // Start checking after a brief delay
392
- setTimeout(checkCompletion, checkInterval);
393
- } catch (err) {
394
- resolve({
395
- success: false,
396
- sessionName,
397
- message: `Failed to run in screen: ${err.message}`,
398
- });
399
- }
400
- });
174
+ return _runScreenWithLogCapture(
175
+ command,
176
+ sessionName,
177
+ shellInfo,
178
+ user,
179
+ wrapCommandWithUser,
180
+ isInteractiveShellCommand
181
+ );
401
182
  }
402
183
 
403
184
  /**
@@ -934,12 +715,6 @@ function runIsolated(backend, command, options = {}) {
934
715
  }
935
716
  }
936
717
 
937
- /** Reset screen version cache (useful for testing) */
938
- function resetScreenVersionCache() {
939
- cachedScreenVersion = null;
940
- screenVersionChecked = false;
941
- }
942
-
943
718
  const {
944
719
  getTimestamp,
945
720
  generateLogFilename,
@@ -0,0 +1,406 @@
1
+ /** Screen-specific isolation helpers extracted from isolation.js */
2
+
3
+ const { execSync, spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const setTimeout = globalThis.setTimeout;
9
+
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
+ }
20
+
21
+ // Cache for screen version detection
22
+ let cachedScreenVersion = null;
23
+ let screenVersionChecked = false;
24
+
25
+ /**
26
+ * Get the installed screen version
27
+ * @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
28
+ */
29
+ function getScreenVersion() {
30
+ if (screenVersionChecked) {
31
+ return cachedScreenVersion;
32
+ }
33
+
34
+ screenVersionChecked = true;
35
+
36
+ try {
37
+ const output = execSync('screen --version', {
38
+ encoding: 'utf8',
39
+ stdio: ['pipe', 'pipe', 'pipe'],
40
+ });
41
+ // Match patterns like "4.09.01", "4.00.03", "4.5.1"
42
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
43
+ if (match) {
44
+ cachedScreenVersion = {
45
+ major: parseInt(match[1], 10),
46
+ minor: parseInt(match[2], 10),
47
+ patch: parseInt(match[3], 10),
48
+ };
49
+
50
+ if (isDebug()) {
51
+ console.error(
52
+ `[screen-isolation] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
53
+ );
54
+ }
55
+
56
+ return cachedScreenVersion;
57
+ }
58
+ } catch {
59
+ if (isDebug()) {
60
+ console.error('[screen-isolation] Could not detect screen version');
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Check if screen supports the -Logfile option
69
+ * The -Logfile option was introduced in GNU Screen 4.5.1
70
+ * @returns {boolean} True if -Logfile is supported
71
+ */
72
+ function supportsLogfileOption() {
73
+ const version = getScreenVersion();
74
+ if (!version) {
75
+ // If we can't detect version, assume older version and use fallback
76
+ return false;
77
+ }
78
+
79
+ // -Logfile was added in 4.5.1
80
+ // Compare: version >= 4.5.1
81
+ if (version.major > 4) {
82
+ return true;
83
+ }
84
+ if (version.major < 4) {
85
+ return false;
86
+ }
87
+ // major === 4
88
+ if (version.minor > 5) {
89
+ return true;
90
+ }
91
+ if (version.minor < 5) {
92
+ return false;
93
+ }
94
+ // minor === 5
95
+ return version.patch >= 1;
96
+ }
97
+
98
+ /**
99
+ * Run command in GNU Screen using detached mode with log capture.
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
+ *
120
+ * @param {string} command - Command to execute
121
+ * @param {string} sessionName - Session name
122
+ * @param {object} shellInfo - Shell info from getShell()
123
+ * @param {string|null} user - Username to run command as (optional)
124
+ * @param {Function} wrapCommandWithUser - Function to wrap command with user
125
+ * @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
126
+ * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
127
+ */
128
+ function runScreenWithLogCapture(
129
+ command,
130
+ sessionName,
131
+ shellInfo,
132
+ user = null,
133
+ wrapCommandWithUser,
134
+ isInteractiveShellCommand
135
+ ) {
136
+ const { shell, shellArg } = shellInfo;
137
+ const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
138
+ const exitCodeFile = path.join(
139
+ os.tmpdir(),
140
+ `screen-exit-${sessionName}.code`
141
+ );
142
+
143
+ return new Promise((resolve) => {
144
+ try {
145
+ // Wrap command with user switch if specified
146
+ let effectiveCommand = wrapCommandWithUser(command, user);
147
+
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
+ }
155
+
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}`
176
+ );
177
+ }
178
+ resolve({
179
+ success: false,
180
+ sessionName,
181
+ message: `Failed to create screenrc for logging: ${err.message}`,
182
+ });
183
+ return;
184
+ }
185
+
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}`);
225
+ }
226
+
227
+ // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
228
+ const result = spawnSync('screen', screenArgs, {
229
+ stdio: 'inherit',
230
+ });
231
+
232
+ if (result.error) {
233
+ throw result.error;
234
+ }
235
+
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).
240
+ const readAndDisplayOutput = (retryCount = 0) => {
241
+ const MAX_RETRIES = 3;
242
+ const RETRY_DELAYS = [50, 100, 200]; // ms
243
+
244
+ let output = '';
245
+ try {
246
+ output = fs.readFileSync(logFile, 'utf8');
247
+ } catch {
248
+ // Log file might not exist if command produced no output
249
+ }
250
+
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
+ }
259
+ return new Promise((resolveRetry) => {
260
+ setTimeout(() => {
261
+ resolveRetry(readAndDisplayOutput(retryCount + 1));
262
+ }, delay);
263
+ });
264
+ }
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
+
281
+ // Display the output
282
+ if (output.trim()) {
283
+ process.stdout.write(output);
284
+ // Add trailing newline if output doesn't end with one
285
+ if (!output.endsWith('\n')) {
286
+ process.stdout.write('\n');
287
+ }
288
+ }
289
+ return Promise.resolve(output);
290
+ };
291
+
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
+ }
297
+ try {
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;
304
+ } catch {
305
+ if (isDebug()) {
306
+ console.error(
307
+ `[screen-isolation] Could not read exit code file, defaulting to 0`
308
+ );
309
+ }
310
+ return 0;
311
+ }
312
+ };
313
+
314
+ // Clean up temp files
315
+ const cleanupTempFiles = () => {
316
+ for (const f of [logFile, screenrcFile, exitCodeFile]) {
317
+ try {
318
+ fs.unlinkSync(f);
319
+ } catch {
320
+ // Ignore cleanup errors
321
+ }
322
+ }
323
+ };
324
+
325
+ // Poll for session completion
326
+ const checkInterval = 100; // ms
327
+ const maxWait = 300000; // 5 minutes max
328
+ let waited = 0;
329
+
330
+ const checkCompletion = () => {
331
+ try {
332
+ // Check if session still exists
333
+ const sessions = execSync('screen -ls', {
334
+ encoding: 'utf8',
335
+ stdio: ['pipe', 'pipe', 'pipe'],
336
+ });
337
+
338
+ if (!sessions.includes(sessionName)) {
339
+ // Session ended, read output and exit code
340
+ readAndDisplayOutput().then((output) => {
341
+ const exitCode = readExitCode();
342
+ cleanupTempFiles();
343
+ resolve({
344
+ success: exitCode === 0,
345
+ sessionName,
346
+ message: `Screen session "${sessionName}" exited with code ${exitCode}`,
347
+ exitCode,
348
+ output,
349
+ });
350
+ });
351
+ return;
352
+ }
353
+
354
+ waited += checkInterval;
355
+ if (waited >= maxWait) {
356
+ cleanupTempFiles();
357
+ resolve({
358
+ success: false,
359
+ sessionName,
360
+ message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
361
+ exitCode: 1,
362
+ });
363
+ return;
364
+ }
365
+
366
+ setTimeout(checkCompletion, checkInterval);
367
+ } catch {
368
+ // screen -ls failed, session probably ended
369
+ readAndDisplayOutput().then((output) => {
370
+ const exitCode = readExitCode();
371
+ cleanupTempFiles();
372
+ resolve({
373
+ success: exitCode === 0,
374
+ sessionName,
375
+ message: `Screen session "${sessionName}" exited with code ${exitCode}`,
376
+ exitCode,
377
+ output,
378
+ });
379
+ });
380
+ }
381
+ };
382
+
383
+ // Start checking after a brief delay
384
+ setTimeout(checkCompletion, checkInterval);
385
+ } catch (err) {
386
+ resolve({
387
+ success: false,
388
+ sessionName,
389
+ message: `Failed to run in screen: ${err.message}`,
390
+ });
391
+ }
392
+ });
393
+ }
394
+
395
+ /** Reset screen version cache (useful for testing) */
396
+ function resetScreenVersionCache() {
397
+ cachedScreenVersion = null;
398
+ screenVersionChecked = false;
399
+ }
400
+
401
+ module.exports = {
402
+ getScreenVersion,
403
+ supportsLogfileOption,
404
+ runScreenWithLogCapture,
405
+ resetScreenVersionCache,
406
+ };
@@ -525,160 +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
- });
528
+ // Screen integration tests moved to screen-integration.test.js
529
+ // to keep file under the 1000-line limit.
682
530
 
683
531
  describe('runInTmux (if available)', () => {
684
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
+ });