start-command 0.24.8 → 0.24.9

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,35 @@
1
1
  # start-command
2
2
 
3
+ ## 0.24.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
8
+
9
+ When running a short-lived command like `agent --version` through screen isolation:
10
+
11
+ ```
12
+ $ --isolated screen -- agent --version
13
+ ```
14
+
15
+ the version output was silently lost — the command exited cleanly (exit code 0)
16
+ but no output was displayed.
17
+
18
+ **Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
19
+ (`log_flush = 10`). For commands that complete faster than this, the buffer may not
20
+ be flushed to the log file before the screen session terminates.
21
+
22
+ **Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
23
+ the `-c` option. This forces screen to flush the log buffer after every write,
24
+ eliminating the 10-second flush delay for quick-completing commands.
25
+
26
+ A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
27
+ to handle the TOCTOU race where the log file appears empty when first read
28
+ immediately after session completion.
29
+
30
+ Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
31
+ with equivalent test coverage added.
32
+
3
33
  ## 0.24.8
4
34
 
5
35
  ### 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.24.9",
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": {
@@ -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,309 @@
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
11
+ const DEBUG =
12
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
13
+
14
+ // Cache for screen version detection
15
+ let cachedScreenVersion = null;
16
+ let screenVersionChecked = false;
17
+
18
+ /**
19
+ * Get the installed screen version
20
+ * @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
21
+ */
22
+ function getScreenVersion() {
23
+ if (screenVersionChecked) {
24
+ return cachedScreenVersion;
25
+ }
26
+
27
+ screenVersionChecked = true;
28
+
29
+ try {
30
+ const output = execSync('screen --version', {
31
+ encoding: 'utf8',
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ // Match patterns like "4.09.01", "4.00.03", "4.5.1"
35
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
36
+ if (match) {
37
+ cachedScreenVersion = {
38
+ major: parseInt(match[1], 10),
39
+ minor: parseInt(match[2], 10),
40
+ patch: parseInt(match[3], 10),
41
+ };
42
+
43
+ if (DEBUG) {
44
+ console.log(
45
+ `[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
46
+ );
47
+ }
48
+
49
+ return cachedScreenVersion;
50
+ }
51
+ } catch {
52
+ if (DEBUG) {
53
+ console.log('[DEBUG] Could not detect screen version');
54
+ }
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Check if screen supports the -Logfile option
62
+ * The -Logfile option was introduced in GNU Screen 4.5.1
63
+ * @returns {boolean} True if -Logfile is supported
64
+ */
65
+ function supportsLogfileOption() {
66
+ const version = getScreenVersion();
67
+ if (!version) {
68
+ // If we can't detect version, assume older version and use fallback
69
+ return false;
70
+ }
71
+
72
+ // -Logfile was added in 4.5.1
73
+ // Compare: version >= 4.5.1
74
+ if (version.major > 4) {
75
+ return true;
76
+ }
77
+ if (version.major < 4) {
78
+ return false;
79
+ }
80
+ // major === 4
81
+ if (version.minor > 5) {
82
+ return true;
83
+ }
84
+ if (version.minor < 5) {
85
+ return false;
86
+ }
87
+ // minor === 5
88
+ return version.patch >= 1;
89
+ }
90
+
91
+ /**
92
+ * Run command in GNU Screen using detached mode with log capture.
93
+ * Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
94
+ * @param {string} command - Command to execute
95
+ * @param {string} sessionName - Session name
96
+ * @param {object} shellInfo - Shell info from getShell()
97
+ * @param {string|null} user - Username to run command as (optional)
98
+ * @param {Function} wrapCommandWithUser - Function to wrap command with user
99
+ * @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
100
+ * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
101
+ */
102
+ function runScreenWithLogCapture(
103
+ command,
104
+ sessionName,
105
+ shellInfo,
106
+ user = null,
107
+ wrapCommandWithUser,
108
+ isInteractiveShellCommand
109
+ ) {
110
+ const { shell, shellArg } = shellInfo;
111
+ 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();
115
+
116
+ return new Promise((resolve) => {
117
+ try {
118
+ let screenArgs;
119
+ // Wrap command with user switch if specified
120
+ let effectiveCommand = wrapCommandWithUser(command, user);
121
+
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
+ }
139
+
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(' ')}`
151
+ );
152
+ }
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];
163
+
164
+ if (DEBUG) {
165
+ console.log(
166
+ `[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
167
+ );
168
+ }
169
+ }
170
+
171
+ // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
172
+ const result = spawnSync('screen', screenArgs, {
173
+ stdio: 'inherit',
174
+ });
175
+
176
+ if (result.error) {
177
+ throw result.error;
178
+ }
179
+
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)
184
+ const readAndDisplayOutput = (retryCount = 0) => {
185
+ let output = '';
186
+ try {
187
+ output = fs.readFileSync(logFile, 'utf8');
188
+ } catch {
189
+ // Log file might not exist if command produced no output
190
+ }
191
+
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) {
196
+ return new Promise((resolveRetry) => {
197
+ setTimeout(() => {
198
+ resolveRetry(readAndDisplayOutput(1));
199
+ }, 50);
200
+ });
201
+ }
202
+
203
+ // Display the output
204
+ if (output.trim()) {
205
+ process.stdout.write(output);
206
+ // Add trailing newline if output doesn't end with one
207
+ if (!output.endsWith('\n')) {
208
+ process.stdout.write('\n');
209
+ }
210
+ }
211
+ return Promise.resolve(output);
212
+ };
213
+
214
+ // Clean up temp files
215
+ const cleanupTempFiles = () => {
216
+ try {
217
+ fs.unlinkSync(logFile);
218
+ } catch {
219
+ // Ignore cleanup errors
220
+ }
221
+ if (screenrcFile) {
222
+ try {
223
+ fs.unlinkSync(screenrcFile);
224
+ } catch {
225
+ // Ignore cleanup errors
226
+ }
227
+ }
228
+ };
229
+
230
+ // Poll for session completion
231
+ const checkInterval = 100; // ms
232
+ const maxWait = 300000; // 5 minutes max
233
+ let waited = 0;
234
+
235
+ const checkCompletion = () => {
236
+ try {
237
+ // Check if session still exists
238
+ const sessions = execSync('screen -ls', {
239
+ encoding: 'utf8',
240
+ stdio: ['pipe', 'pipe', 'pipe'],
241
+ });
242
+
243
+ if (!sessions.includes(sessionName)) {
244
+ // Session ended, read output (with retry for tee path race condition)
245
+ readAndDisplayOutput().then((output) => {
246
+ cleanupTempFiles();
247
+ resolve({
248
+ success: true,
249
+ sessionName,
250
+ message: `Screen session "${sessionName}" exited with code 0`,
251
+ exitCode: 0,
252
+ output,
253
+ });
254
+ });
255
+ return;
256
+ }
257
+
258
+ waited += checkInterval;
259
+ if (waited >= maxWait) {
260
+ cleanupTempFiles();
261
+ resolve({
262
+ success: false,
263
+ sessionName,
264
+ message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
265
+ exitCode: 1,
266
+ });
267
+ return;
268
+ }
269
+
270
+ setTimeout(checkCompletion, checkInterval);
271
+ } catch {
272
+ // screen -ls failed, session probably ended
273
+ readAndDisplayOutput().then((output) => {
274
+ cleanupTempFiles();
275
+ resolve({
276
+ success: true,
277
+ sessionName,
278
+ message: `Screen session "${sessionName}" exited with code 0`,
279
+ exitCode: 0,
280
+ output,
281
+ });
282
+ });
283
+ }
284
+ };
285
+
286
+ // Start checking after a brief delay
287
+ setTimeout(checkCompletion, checkInterval);
288
+ } catch (err) {
289
+ resolve({
290
+ success: false,
291
+ sessionName,
292
+ message: `Failed to run in screen: ${err.message}`,
293
+ });
294
+ }
295
+ });
296
+ }
297
+
298
+ /** Reset screen version cache (useful for testing) */
299
+ function resetScreenVersionCache() {
300
+ cachedScreenVersion = null;
301
+ screenVersionChecked = false;
302
+ }
303
+
304
+ module.exports = {
305
+ getScreenVersion,
306
+ supportsLogfileOption,
307
+ runScreenWithLogCapture,
308
+ resetScreenVersionCache,
309
+ };
@@ -678,6 +678,39 @@ describe('Isolation Runner with Available Backends', () => {
678
678
  );
679
679
  console.log(` Verified output capture: "${result.output.trim()}"`);
680
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
+ });
681
714
  });
682
715
 
683
716
  describe('runInTmux (if available)', () => {