start-command 0.26.0 → 0.27.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +1 -1
  3. package/bunfig.toml +2 -2
  4. package/eslint.config.mjs +1 -1
  5. package/package.json +2 -2
  6. package/src/bin/cli.js +30 -0
  7. package/src/lib/args-parser.js +72 -6
  8. package/src/lib/execution-control.js +317 -0
  9. package/src/lib/isolation.js +22 -4
  10. package/src/lib/status-formatter.js +46 -2
  11. package/src/lib/usage.js +5 -1
  12. package/test/args-parser-control.js +71 -0
  13. package/test/{args-parser.test.js → args-parser.js} +1 -1
  14. package/test/cli.js +260 -0
  15. package/test/docker-autoremove.js +175 -0
  16. package/test/execution-control.js +253 -0
  17. package/test/{isolation.test.js → isolation.js} +4 -2
  18. package/test/merge-changesets.mjs +154 -0
  19. package/test/release-name.mjs +117 -0
  20. package/test/{screen-integration.test.js → screen-integration.js} +1 -1
  21. package/test/{ssh-integration.test.js → ssh-integration.js} +1 -1
  22. package/test/{status-query.test.js → status-query.js} +2 -0
  23. package/test/{substitution.test.js → substitution.js} +1 -2
  24. package/test/{user-manager.test.js → user-manager.js} +17 -0
  25. package/test/cli.test.js +0 -218
  26. package/test/docker-autoremove.test.js +0 -164
  27. package/test/release-name.test.mjs +0 -34
  28. /package/test/{args-parser-shell.test.js → args-parser-shell.js} +0 -0
  29. /package/test/{echo-integration.test.js → echo-integration.js} +0 -0
  30. /package/test/{execution-store.test.js → execution-store.js} +0 -0
  31. /package/test/{failure-handler.test.js → failure-handler.js} +0 -0
  32. /package/test/{isolation-cleanup.test.js → isolation-cleanup.js} +0 -0
  33. /package/test/{isolation-log-utils.test.js → isolation-log-utils.js} +0 -0
  34. /package/test/{isolation-stacking.test.js → isolation-stacking.js} +0 -0
  35. /package/test/{output-blocks.test.js → output-blocks.js} +0 -0
  36. /package/test/{public-exports.test.js → public-exports.js} +0 -0
  37. /package/test/{regression-84.test.js → regression-84.js} +0 -0
  38. /package/test/{regression-89.test.js → regression-89.js} +0 -0
  39. /package/test/{regression-91.test.js → regression-91.js} +0 -0
  40. /package/test/{sequence-parser.test.js → sequence-parser.js} +0 -0
  41. /package/test/{session-name-status.test.js → session-name-status.js} +0 -0
  42. /package/test/{version.test.js → version.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # start-command
2
2
 
3
+ ## 0.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `--stop` and `--terminate` controls for detached isolated executions and include best-effort process IDs in status output.
8
+
9
+ Fix CI release note generation so language-prefixed GitHub Releases read the package changelog and include exact-version badges.
10
+
11
+ Fix release pipeline so multi-changeset merges and JS GitHub Releases work end-to-end. The `merge-changesets.mjs` helper now reads the package name from `package.json` and accepts a `--working-dir` flag, fixing the `ENOENT: no such file or directory, scandir '.changeset'` failure on the JS release workflow when more than one changeset is pending. JS GitHub Releases now also include the exact-version npm badge alongside the existing Rust crates badge.
12
+
3
13
  ## 0.26.0
4
14
 
5
15
  ### Minor Changes
@@ -15,7 +25,7 @@
15
25
  releases are now tagged `js-v<version>` with title `[JavaScript] <version>`,
16
26
  matching the `rust-v<version>` / `[Rust] <version>` convention needed for the
17
27
  mono-repo. Extracted the tag/title construction into `scripts/release-name.mjs`
18
- with unit tests in `test/release-name.test.mjs`. See `docs/case-studies/issue-108/`
28
+ with unit tests in `test/release-name.mjs`. See `docs/case-studies/issue-108/`
19
29
  for the full analysis.
20
30
 
21
31
  ## 0.25.4
package/README.md CHANGED
@@ -32,7 +32,7 @@ and [docs/PIPES.md](../docs/PIPES.md) for the full user-facing guide.
32
32
  ```bash
33
33
  cd js
34
34
  bun install
35
- bun test
35
+ bun run test
36
36
  bun run lint
37
37
  ```
38
38
 
package/bunfig.toml CHANGED
@@ -1,3 +1,3 @@
1
1
  # Bun configuration
2
- # Note: Bun doesn't support excluding test files directly in config
3
- # Use CLI flags or file naming patterns instead
2
+ # Tests use repository naming without conventional suffixes, so package scripts pass
3
+ # the test files explicitly instead of relying on Bun's default discovery.
package/eslint.config.mjs CHANGED
@@ -101,8 +101,8 @@ export default [
101
101
  // Test files and experiments have different requirements
102
102
  files: [
103
103
  'test/**/*.js',
104
+ 'test/**/*.mjs',
104
105
  'tests/**/*.js',
105
- '**/*.test.js',
106
106
  'experiments/**/*.js',
107
107
  ],
108
108
  languageOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.26.0",
3
+ "version": "0.27.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": {
@@ -11,7 +11,7 @@
11
11
  "$": "./src/bin/cli.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "bun test test/",
14
+ "test": "bun ../scripts/run-js-tests.mjs",
15
15
  "lint": "eslint .",
16
16
  "lint:fix": "eslint . --fix",
17
17
  "format": "prettier --write .",
package/src/bin/cli.js CHANGED
@@ -32,6 +32,7 @@ const {
32
32
  const { handleFailure } = require('../lib/failure-handler');
33
33
  const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
34
34
  const { queryStatus, listExecutions } = require('../lib/status-formatter');
35
+ const { ControlAction, controlExecution } = require('../lib/execution-control');
35
36
  const { printVersion } = require('../lib/version');
36
37
  const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
37
38
  const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
@@ -200,6 +201,21 @@ if (wrapperOptions.list) {
200
201
  process.exit(0);
201
202
  }
202
203
 
204
+ // Handle --stop flag
205
+ if (wrapperOptions.stop !== null && wrapperOptions.stop !== undefined) {
206
+ handleControlQuery(wrapperOptions.stop, ControlAction.STOP);
207
+ process.exit(0);
208
+ }
209
+
210
+ // Handle --terminate flag
211
+ if (
212
+ wrapperOptions.terminate !== null &&
213
+ wrapperOptions.terminate !== undefined
214
+ ) {
215
+ handleControlQuery(wrapperOptions.terminate, ControlAction.TERMINATE);
216
+ process.exit(0);
217
+ }
218
+
203
219
  // Handle --cleanup flag
204
220
  if (wrapperOptions.cleanup) {
205
221
  handleCleanup(wrapperOptions.cleanupDryRun);
@@ -284,6 +300,16 @@ function handleListQuery(outputFormat) {
284
300
  }
285
301
  }
286
302
 
303
+ function handleControlQuery(identifier, action) {
304
+ const result = controlExecution(getExecutionStore(), identifier, action);
305
+ if (result.success) {
306
+ console.log(result.output);
307
+ } else {
308
+ console.error(`Error: ${result.error}`);
309
+ process.exit(1);
310
+ }
311
+ }
312
+
287
313
  /**
288
314
  * Handle --cleanup flag
289
315
  * Cleans up stale "executing" records (processes that crashed or were killed)
@@ -445,6 +471,7 @@ async function runWithIsolation(
445
471
  uuid: sessionId, // Use the provided session ID
446
472
  command: cmd,
447
473
  logPath: logFilePath,
474
+ pid: process.pid,
448
475
  shell,
449
476
  workingDirectory: process.cwd(),
450
477
  options: {
@@ -556,6 +583,9 @@ async function runWithIsolation(
556
583
 
557
584
  // Update execution record: detached keeps "executing" (resolved at query time)
558
585
  if (executionRecord && store) {
586
+ if (result.containerId) {
587
+ executionRecord.options.containerId = result.containerId;
588
+ }
559
589
  if (mode !== 'detached') {
560
590
  executionRecord.complete(exitCode);
561
591
  }
@@ -22,6 +22,8 @@
22
22
  * --status <uuid> Show status of a previous command execution by UUID
23
23
  * --list List all tracked command executions
24
24
  * --output-format <format> Output format for status/list (links-notation, json, text)
25
+ * --stop <uuid-or-session-name> Send CTRL+C/SIGINT to a detached execution
26
+ * --terminate <uuid-or-session-name> Terminate a detached execution immediately
25
27
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
26
28
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
27
29
  */
@@ -53,6 +55,10 @@ const MAX_ISOLATION_DEPTH = 7;
53
55
  */
54
56
  const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
55
57
 
58
+ function hasValue(value) {
59
+ return value !== null && value !== undefined;
60
+ }
61
+
56
62
  /**
57
63
  * UUID v4 regex pattern for validation
58
64
  */
@@ -172,6 +178,8 @@ function parseArgs(args) {
172
178
  status: null, // UUID to show status for
173
179
  list: false, // List all tracked execution records
174
180
  outputFormat: null, // Output format for status/list (links-notation, json, text)
181
+ stop: null, // UUID/session name to stop gracefully
182
+ terminate: null, // UUID/session name to terminate immediately
175
183
  cleanup: false, // Clean up stale "executing" records
176
184
  cleanupDryRun: false, // Show what would be cleaned without actually cleaning
177
185
  };
@@ -432,7 +440,55 @@ function parseOption(args, index, options) {
432
440
 
433
441
  // --status=<value>
434
442
  if (arg.startsWith('--status=')) {
435
- options.status = arg.split('=')[1];
443
+ const value = arg.slice('--status='.length);
444
+ if (!value) {
445
+ throw new Error(
446
+ `Option --status requires a UUID or session name argument`
447
+ );
448
+ }
449
+ options.status = value;
450
+ return 1;
451
+ }
452
+
453
+ // --stop <uuid-or-session-name>
454
+ if (arg === '--stop') {
455
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
456
+ options.stop = args[index + 1];
457
+ return 2;
458
+ } else {
459
+ throw new Error(`Option ${arg} requires a UUID or session name argument`);
460
+ }
461
+ }
462
+
463
+ // --stop=<value>
464
+ if (arg.startsWith('--stop=')) {
465
+ const value = arg.slice('--stop='.length);
466
+ if (!value) {
467
+ throw new Error(`Option --stop requires a UUID or session name argument`);
468
+ }
469
+ options.stop = value;
470
+ return 1;
471
+ }
472
+
473
+ // --terminate <uuid-or-session-name>
474
+ if (arg === '--terminate') {
475
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
476
+ options.terminate = args[index + 1];
477
+ return 2;
478
+ } else {
479
+ throw new Error(`Option ${arg} requires a UUID or session name argument`);
480
+ }
481
+ }
482
+
483
+ // --terminate=<value>
484
+ if (arg.startsWith('--terminate=')) {
485
+ const value = arg.slice('--terminate='.length);
486
+ if (!value) {
487
+ throw new Error(
488
+ `Option --terminate requires a UUID or session name argument`
489
+ );
490
+ }
491
+ options.terminate = value;
436
492
  return 1;
437
493
  }
438
494
 
@@ -656,7 +712,7 @@ function validateOptions(options) {
656
712
  }
657
713
 
658
714
  // Validate output format
659
- if (options.outputFormat !== null && options.outputFormat !== undefined) {
715
+ if (hasValue(options.outputFormat)) {
660
716
  if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
661
717
  throw new Error(
662
718
  `Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
@@ -664,12 +720,22 @@ function validateOptions(options) {
664
720
  }
665
721
  }
666
722
 
667
- // Single-record and list query modes are mutually exclusive
668
- if (options.status && options.list) {
669
- throw new Error('Cannot use both --status and --list at the same time');
723
+ // Query/control modes are mutually exclusive
724
+ const queryModes = [
725
+ hasValue(options.status),
726
+ options.list,
727
+ hasValue(options.stop),
728
+ hasValue(options.terminate),
729
+ options.cleanup,
730
+ ].filter(Boolean).length;
731
+
732
+ if (queryModes > 1) {
733
+ throw new Error(
734
+ 'Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation'
735
+ );
670
736
  }
671
737
 
672
- // Output format is only valid with query modes
738
+ // Output format is only valid with read-only query modes
673
739
  if (options.outputFormat && !options.status && !options.list) {
674
740
  throw new Error(
675
741
  '--output-format option is only valid with --status or --list'
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Detached execution control helpers.
3
+ *
4
+ * Maps start-command execution records back to their native isolation backend
5
+ * controls so `--stop` and `--terminate` can target the stored session name.
6
+ */
7
+
8
+ const { spawnSync } = require('child_process');
9
+ const {
10
+ escapeForLinksNotation,
11
+ formatAsNestedLinksNotation,
12
+ } = require('./output-blocks');
13
+
14
+ const ControlAction = {
15
+ STOP: 'stop',
16
+ TERMINATE: 'terminate',
17
+ };
18
+
19
+ function runCommand(command, args) {
20
+ const result = spawnSync(command, args, {
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ });
24
+
25
+ return {
26
+ success: !result.error && result.status === 0,
27
+ stdout: result.stdout || '',
28
+ stderr: result.stderr || '',
29
+ status: result.status,
30
+ error: result.error ? result.error.message : null,
31
+ };
32
+ }
33
+
34
+ function parsePid(value) {
35
+ const pid = Number.parseInt(String(value).trim(), 10);
36
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
37
+ }
38
+
39
+ function parsePids(output) {
40
+ return output
41
+ .split(/\s+/)
42
+ .map(parsePid)
43
+ .filter((pid) => pid !== null);
44
+ }
45
+
46
+ function parseScreenPid(screenListOutput, sessionName) {
47
+ for (const line of screenListOutput.split('\n')) {
48
+ const firstColumn = line.trim().split(/\s+/)[0] || '';
49
+ const match = firstColumn.match(/^(\d+)\.(.+)$/);
50
+ if (match && match[2] === sessionName) {
51
+ return parsePid(match[1]);
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function collectDescendantPids(rootPid, runner = runCommand) {
58
+ const descendants = [];
59
+ const seen = new Set([rootPid]);
60
+ const queue = [rootPid];
61
+
62
+ while (queue.length > 0) {
63
+ const parentPid = queue.shift();
64
+ const result = runner('pgrep', ['-P', String(parentPid)]);
65
+ if (!result.success && !result.stdout) {
66
+ continue;
67
+ }
68
+
69
+ for (const childPid of parsePids(result.stdout)) {
70
+ if (seen.has(childPid)) {
71
+ continue;
72
+ }
73
+ seen.add(childPid);
74
+ descendants.push(childPid);
75
+ queue.push(childPid);
76
+ }
77
+ }
78
+
79
+ return descendants;
80
+ }
81
+
82
+ function addIfPresent(target, key, value) {
83
+ if (value !== null && value !== undefined) {
84
+ if (!Array.isArray(value) || value.length > 0) {
85
+ target[key] = value;
86
+ }
87
+ }
88
+ }
89
+
90
+ function collectProcessIds(record, runner = runCommand) {
91
+ if (!record) {
92
+ return null;
93
+ }
94
+
95
+ const processIds = {};
96
+ const opts = record.options || {};
97
+ const sessionName = opts.sessionName;
98
+ const isolated = opts.isolated;
99
+
100
+ addIfPresent(processIds, 'wrapperPid', record.pid);
101
+
102
+ if (!sessionName || !isolated) {
103
+ return Object.keys(processIds).length > 0 ? processIds : null;
104
+ }
105
+
106
+ if (isolated === 'screen') {
107
+ const result = runner('screen', ['-ls']);
108
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
109
+ const screenPid = parseScreenPid(output, sessionName);
110
+ addIfPresent(processIds, 'screenPid', screenPid);
111
+ if (screenPid) {
112
+ addIfPresent(
113
+ processIds,
114
+ 'commandPids',
115
+ collectDescendantPids(screenPid, runner)
116
+ );
117
+ }
118
+ } else if (isolated === 'tmux') {
119
+ const tmuxPidResult = runner('tmux', [
120
+ 'display-message',
121
+ '-p',
122
+ '-t',
123
+ sessionName,
124
+ '#{pid}',
125
+ ]);
126
+ addIfPresent(processIds, 'tmuxPid', parsePid(tmuxPidResult.stdout));
127
+
128
+ const panePidResult = runner('tmux', [
129
+ 'list-panes',
130
+ '-t',
131
+ sessionName,
132
+ '-F',
133
+ '#{pane_pid}',
134
+ ]);
135
+ const panePids = parsePids(panePidResult.stdout);
136
+ addIfPresent(processIds, 'panePids', panePids);
137
+
138
+ const commandPids = [
139
+ ...new Set(
140
+ panePids.flatMap((panePid) => collectDescendantPids(panePid, runner))
141
+ ),
142
+ ];
143
+ addIfPresent(processIds, 'commandPids', commandPids);
144
+ } else if (isolated === 'docker') {
145
+ addIfPresent(processIds, 'containerId', opts.containerId);
146
+ const result = runner('docker', [
147
+ 'inspect',
148
+ '-f',
149
+ '{{.Id}} {{.State.Pid}}',
150
+ sessionName,
151
+ ]);
152
+ if (result.success && result.stdout.trim()) {
153
+ const [containerId, pidValue] = result.stdout.trim().split(/\s+/);
154
+ addIfPresent(processIds, 'containerId', containerId);
155
+ addIfPresent(processIds, 'containerPid', parsePid(pidValue));
156
+ }
157
+ } else if (isolated === 'ssh') {
158
+ addIfPresent(processIds, 'remotePid', opts.remotePid);
159
+ }
160
+
161
+ return Object.keys(processIds).length > 0 ? processIds : null;
162
+ }
163
+
164
+ function getControlCommand(record, action) {
165
+ const opts = record.options || {};
166
+ const backend = opts.isolated;
167
+ const sessionName = opts.sessionName;
168
+
169
+ if (!sessionName) {
170
+ return {
171
+ error: 'Execution record does not contain an isolation session name.',
172
+ };
173
+ }
174
+
175
+ if (opts.isolationMode !== 'detached') {
176
+ return {
177
+ error: 'Only detached isolated executions can be stopped or terminated.',
178
+ };
179
+ }
180
+
181
+ if (action === ControlAction.STOP) {
182
+ switch (backend) {
183
+ case 'screen':
184
+ return {
185
+ command: 'screen',
186
+ args: ['-S', sessionName, '-X', 'stuff', '\x03'],
187
+ method: 'CTRL_C',
188
+ message: `Sent CTRL+C to detached screen session: ${sessionName}`,
189
+ };
190
+ case 'tmux':
191
+ return {
192
+ command: 'tmux',
193
+ args: ['send-keys', '-t', sessionName, 'C-c'],
194
+ method: 'CTRL_C',
195
+ message: `Sent CTRL+C to detached tmux session: ${sessionName}`,
196
+ };
197
+ case 'docker':
198
+ return {
199
+ command: 'docker',
200
+ args: ['kill', '--signal=SIGINT', sessionName],
201
+ method: 'SIGINT',
202
+ message: `Sent SIGINT to detached docker container: ${sessionName}`,
203
+ };
204
+ default:
205
+ return {
206
+ error: `Stopping detached ${backend || 'unknown'} executions is not supported.`,
207
+ };
208
+ }
209
+ }
210
+
211
+ if (action === ControlAction.TERMINATE) {
212
+ switch (backend) {
213
+ case 'screen':
214
+ return {
215
+ command: 'screen',
216
+ args: ['-S', sessionName, '-X', 'quit'],
217
+ method: 'SCREEN_QUIT',
218
+ message: `Terminated detached screen session: ${sessionName}`,
219
+ };
220
+ case 'tmux':
221
+ return {
222
+ command: 'tmux',
223
+ args: ['kill-session', '-t', sessionName],
224
+ method: 'KILL_SESSION',
225
+ message: `Terminated detached tmux session: ${sessionName}`,
226
+ };
227
+ case 'docker':
228
+ return {
229
+ command: 'docker',
230
+ args: ['kill', sessionName],
231
+ method: 'SIGKILL',
232
+ message: `Terminated detached docker container: ${sessionName}`,
233
+ };
234
+ default:
235
+ return {
236
+ error: `Terminating detached ${backend || 'unknown'} executions is not supported.`,
237
+ };
238
+ }
239
+ }
240
+
241
+ return { error: `Unknown control action: ${action}` };
242
+ }
243
+
244
+ function formatControlResultAsLinksNotation(result) {
245
+ const lines = [
246
+ 'executionControl',
247
+ ` action ${escapeForLinksNotation(result.action)}`,
248
+ ` identifier ${escapeForLinksNotation(result.identifier)}`,
249
+ ` uuid ${escapeForLinksNotation(result.uuid)}`,
250
+ ` status ${escapeForLinksNotation(result.status)}`,
251
+ ` backend ${escapeForLinksNotation(result.backend)}`,
252
+ ` sessionName ${escapeForLinksNotation(result.sessionName)}`,
253
+ ` method ${escapeForLinksNotation(result.method)}`,
254
+ ];
255
+
256
+ if (result.processIds) {
257
+ lines.push(' processIds');
258
+ lines.push(formatAsNestedLinksNotation(result.processIds, 2, 2));
259
+ }
260
+
261
+ lines.push(` message ${escapeForLinksNotation(result.message)}`);
262
+ return lines.join('\n');
263
+ }
264
+
265
+ function controlExecution(store, identifier, action, runner = runCommand) {
266
+ if (!store) {
267
+ return { success: false, error: 'Execution tracking is disabled.' };
268
+ }
269
+
270
+ const record = store.get(identifier);
271
+ if (!record) {
272
+ return {
273
+ success: false,
274
+ error: `No execution found with UUID or session name: ${identifier}`,
275
+ };
276
+ }
277
+
278
+ const control = getControlCommand(record, action);
279
+ if (control.error) {
280
+ return { success: false, error: control.error };
281
+ }
282
+
283
+ const result = runner(control.command, control.args);
284
+ if (!result.success) {
285
+ const detail =
286
+ result.stderr || result.error || `exit code ${result.status}`;
287
+ return {
288
+ success: false,
289
+ error: `Failed to ${action} ${record.options.isolated} session "${record.options.sessionName}": ${detail}`,
290
+ };
291
+ }
292
+
293
+ const output = formatControlResultAsLinksNotation({
294
+ action,
295
+ identifier,
296
+ uuid: record.uuid,
297
+ status: action === ControlAction.STOP ? 'signal-sent' : 'terminated',
298
+ backend: record.options.isolated,
299
+ sessionName: record.options.sessionName,
300
+ method: control.method,
301
+ processIds: collectProcessIds(record, runner),
302
+ message: control.message,
303
+ });
304
+
305
+ return { success: true, output };
306
+ }
307
+
308
+ module.exports = {
309
+ ControlAction,
310
+ collectDescendantPids,
311
+ collectProcessIds,
312
+ controlExecution,
313
+ formatControlResultAsLinksNotation,
314
+ getControlCommand,
315
+ parseScreenPid,
316
+ runCommand,
317
+ };
@@ -545,9 +545,13 @@ function runInDocker(command, options = {}) {
545
545
 
546
546
  try {
547
547
  if (options.detached) {
548
- const dockerArgs = ['run', '-d', '--name', containerName];
548
+ const dockerArgs = ['run', '-d'];
549
+ if (options.keepAlive) {
550
+ dockerArgs.push('-i', '-t');
551
+ }
552
+ dockerArgs.push('--name', containerName);
549
553
  if (options.autoRemoveDockerContainer) {
550
- dockerArgs.splice(2, 0, '--rm');
554
+ dockerArgs.push('--rm');
551
555
  }
552
556
 
553
557
  if (options.user) {
@@ -576,9 +580,23 @@ function runInDocker(command, options = {}) {
576
580
  );
577
581
  }
578
582
 
579
- const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
583
+ const dockerResult = spawnSync('docker', dockerArgs, {
580
584
  encoding: 'utf8',
581
- }).trim();
585
+ stdio: ['pipe', 'pipe', 'pipe'],
586
+ });
587
+
588
+ if (dockerResult.error) {
589
+ throw dockerResult.error;
590
+ }
591
+ if (dockerResult.status !== 0) {
592
+ const dockerError =
593
+ dockerResult.stderr.trim() ||
594
+ dockerResult.stdout.trim() ||
595
+ `docker exited with code ${dockerResult.status}`;
596
+ throw new Error(dockerError);
597
+ }
598
+
599
+ const containerId = dockerResult.stdout.trim();
582
600
 
583
601
  if (options.logPath) {
584
602
  const loggerScript = [
@@ -13,6 +13,7 @@ const {
13
13
  escapeForLinksNotation,
14
14
  formatAsNestedLinksNotation,
15
15
  } = require('./output-blocks');
16
+ const { collectProcessIds } = require('./execution-control');
16
17
 
17
18
  /**
18
19
  * Check if a detached isolation session is still running
@@ -165,6 +166,47 @@ function attachCurrentTime(record) {
165
166
  return wrapped;
166
167
  }
167
168
 
169
+ /**
170
+ * Wrap a record so its serialized form includes best-effort process IDs.
171
+ * The original record is not mutated.
172
+ * @param {Object} record - Execution record
173
+ * @returns {Object} Record-like object with processIds in toObject()
174
+ */
175
+ function attachProcessIds(record) {
176
+ const processIds = collectProcessIds(record);
177
+ if (!processIds) {
178
+ return record;
179
+ }
180
+
181
+ const wrapped = Object.create(Object.getPrototypeOf(record));
182
+ Object.assign(wrapped, record);
183
+ wrapped.processIds = processIds;
184
+
185
+ const sourceToObject =
186
+ typeof record.toObject === 'function'
187
+ ? () => record.toObject()
188
+ : () => ({ ...record });
189
+
190
+ wrapped.toObject = function () {
191
+ const base = sourceToObject();
192
+ const ordered = {};
193
+ let inserted = false;
194
+ for (const [key, value] of Object.entries(base)) {
195
+ ordered[key] = value;
196
+ if (key === 'pid') {
197
+ ordered.processIds = processIds;
198
+ inserted = true;
199
+ }
200
+ }
201
+ if (!inserted) {
202
+ ordered.processIds = processIds;
203
+ }
204
+ return ordered;
205
+ };
206
+
207
+ return wrapped;
208
+ }
209
+
168
210
  /**
169
211
  * Format execution record as Links Notation (indented style)
170
212
  * Uses nested Links notation for object values (like options) instead of JSON
@@ -288,7 +330,7 @@ function indentBlock(block, spaces) {
288
330
  }
289
331
 
290
332
  function prepareRecordForList(record) {
291
- return attachCurrentTime(enrichDetachedStatus(record));
333
+ return attachCurrentTime(attachProcessIds(enrichDetachedStatus(record)));
292
334
  }
293
335
 
294
336
  /**
@@ -374,8 +416,9 @@ function queryStatus(store, identifier, outputFormat) {
374
416
  try {
375
417
  // Enrich detached execution status with live session check
376
418
  const enrichedRecord = enrichDetachedStatus(record);
419
+ const withProcessIds = attachProcessIds(enrichedRecord);
377
420
  // Attach currentTime so callers can see how long an executing command has been running
378
- const withCurrentTime = attachCurrentTime(enrichedRecord);
421
+ const withCurrentTime = attachCurrentTime(withProcessIds);
379
422
  return {
380
423
  success: true,
381
424
  output: formatRecord(withCurrentTime, outputFormat || 'links-notation'),
@@ -421,4 +464,5 @@ module.exports = {
421
464
  isDetachedSessionAlive,
422
465
  enrichDetachedStatus,
423
466
  attachCurrentTime,
467
+ attachProcessIds,
424
468
  };