start-command 0.25.5 → 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 +17 -1
  2. package/README.md +2 -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 +48 -67
  7. package/src/lib/args-parser.js +88 -7
  8. package/src/lib/execution-control.js +317 -0
  9. package/src/lib/isolation.js +22 -4
  10. package/src/lib/status-formatter.js +153 -1
  11. package/src/lib/usage.js +74 -0
  12. package/test/args-parser-control.js +71 -0
  13. package/test/{args-parser.test.js → args-parser.js} +28 -4
  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} +73 -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,21 @@
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
+
13
+ ## 0.26.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 01fbd90: Add `--list` to show all tracked command executions in Links Notation, JSON, or text format.
18
+
3
19
  ## 0.25.5
4
20
 
5
21
  ### Patch Changes
@@ -9,7 +25,7 @@
9
25
  releases are now tagged `js-v<version>` with title `[JavaScript] <version>`,
10
26
  matching the `rust-v<version>` / `[Rust] <version>` convention needed for the
11
27
  mono-repo. Extracted the tag/title construction into `scripts/release-name.mjs`
12
- 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/`
13
29
  for the full analysis.
14
30
 
15
31
  ## 0.25.4
package/README.md CHANGED
@@ -21,6 +21,7 @@ npm install -g start-command
21
21
  $ ls -la
22
22
  $ npm test
23
23
  $ git status
24
+ $ --list
24
25
  ```
25
26
 
26
27
  See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
@@ -31,7 +32,7 @@ and [docs/PIPES.md](../docs/PIPES.md) for the full user-facing guide.
31
32
  ```bash
32
33
  cd js
33
34
  bun install
34
- bun test
35
+ bun run test
35
36
  bun run lint
36
37
  ```
37
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.25.5",
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
@@ -31,10 +31,12 @@ const {
31
31
  } = require('../lib/user-manager');
32
32
  const { handleFailure } = require('../lib/failure-handler');
33
33
  const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
34
- const { queryStatus } = require('../lib/status-formatter');
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');
39
+ const { printUsage } = require('../lib/usage');
38
40
 
39
41
  // Configuration from environment variables
40
42
  const config = {
@@ -193,6 +195,27 @@ if (wrapperOptions.status) {
193
195
  process.exit(0);
194
196
  }
195
197
 
198
+ // Handle --list flag
199
+ if (wrapperOptions.list) {
200
+ handleListQuery(wrapperOptions.outputFormat);
201
+ process.exit(0);
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
+
196
219
  // Handle --cleanup flag
197
220
  if (wrapperOptions.cleanup) {
198
221
  handleCleanup(wrapperOptions.cleanupDryRun);
@@ -267,6 +290,26 @@ function handleStatusQuery(uuid, outputFormat) {
267
290
  }
268
291
  }
269
292
 
293
+ function handleListQuery(outputFormat) {
294
+ const result = listExecutions(getExecutionStore(), outputFormat);
295
+ if (result.success) {
296
+ console.log(result.output);
297
+ } else {
298
+ console.error(`Error: ${result.error}`);
299
+ process.exit(1);
300
+ }
301
+ }
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
+
270
313
  /**
271
314
  * Handle --cleanup flag
272
315
  * Cleans up stale "executing" records (processes that crashed or were killed)
@@ -314,72 +357,6 @@ function handleCleanup(dryRun) {
314
357
  }
315
358
  }
316
359
 
317
- /** Print usage information */
318
- function printUsage() {
319
- console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>]
320
-
321
- Options:
322
- --isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
323
- --attached, -a Run in attached mode (foreground)
324
- --detached, -d Run in detached mode (background)
325
- --session, -s <name> Session name for isolation
326
- --session-id <uuid> Session UUID for tracking (auto-generated if not provided)
327
- --session-name <uuid> Alias for --session-id
328
- --image <image> Docker image (required for docker isolation)
329
- --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
330
- --isolated-user, -u [name] Create isolated user with same permissions
331
- --keep-user Keep isolated user after command completes
332
- --keep-alive, -k Keep isolation environment alive after command exits
333
- --auto-remove-docker-container Auto-remove docker container after exit
334
- --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
335
- --use-command-stream Use command-stream library for execution (experimental)
336
- --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
337
- --cleanup Clean up stale "executing" records (crashed/killed processes)
338
- --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
339
- --version, -v Show version information
340
-
341
- Examples:
342
- $ echo "Hello World"
343
- $ bun test
344
- $ --isolated tmux -- bun start
345
- $ -i screen -d bun start
346
- $ --isolated docker --image oven/bun:latest -- bun install
347
- $ --isolated ssh --endpoint user@remote.server -- ls -la
348
- $ --isolated-user -- npm test # Create isolated user
349
- $ -u myuser -- npm start # Custom username
350
- $ -i screen --isolated-user -- npm test # Combine with process isolation
351
- $ --isolated-user --keep-user -- npm start
352
- $ --use-command-stream echo "Hello" # Use command-stream library`);
353
- console.log('');
354
- console.log('Piping with $:');
355
- console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
356
- console.log(
357
- ' $ \'echo "hi" | agent\' # Alternative - quote entire pipeline'
358
- );
359
- console.log('');
360
- console.log('Quoting for special characters:');
361
- console.log(" $ 'npm test && npm build' # Wrap for logical operators");
362
- console.log(" $ 'cat file > output.txt' # Wrap for redirections");
363
- console.log('');
364
- console.log('Features:');
365
- console.log(' - Logs all output to temporary directory');
366
- console.log(' - Displays timestamps and exit codes');
367
- console.log(
368
- ' - Auto-reports failures for NPM packages (when gh is available)'
369
- );
370
- console.log(' - Natural language command aliases (via substitutions.lino)');
371
- console.log(' - Process isolation via screen, tmux, or docker');
372
- console.log('');
373
- console.log('Alias examples:');
374
- console.log(' $ install lodash npm package -> npm install lodash');
375
- console.log(
376
- ' $ install 4.17.21 version of lodash npm package -> npm install lodash@4.17.21'
377
- );
378
- console.log(
379
- ' $ clone https://github.com/user/repo repository -> git clone https://github.com/user/repo'
380
- );
381
- }
382
-
383
360
  /**
384
361
  * Run command in isolation mode
385
362
  * @param {object} options - Wrapper options
@@ -494,6 +471,7 @@ async function runWithIsolation(
494
471
  uuid: sessionId, // Use the provided session ID
495
472
  command: cmd,
496
473
  logPath: logFilePath,
474
+ pid: process.pid,
497
475
  shell,
498
476
  workingDirectory: process.cwd(),
499
477
  options: {
@@ -605,6 +583,9 @@ async function runWithIsolation(
605
583
 
606
584
  // Update execution record: detached keeps "executing" (resolved at query time)
607
585
  if (executionRecord && store) {
586
+ if (result.containerId) {
587
+ executionRecord.options.containerId = result.containerId;
588
+ }
608
589
  if (mode !== 'detached') {
609
590
  executionRecord.complete(exitCode);
610
591
  }
@@ -20,7 +20,10 @@
20
20
  * --use-command-stream Use command-stream library for command execution (experimental)
21
21
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
22
22
  * --status <uuid> Show status of a previous command execution by UUID
23
- * --output-format <format> Output format for status (links-notation, json, text)
23
+ * --list List all tracked command executions
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
24
27
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
25
28
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
26
29
  */
@@ -52,6 +55,10 @@ const MAX_ISOLATION_DEPTH = 7;
52
55
  */
53
56
  const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
54
57
 
58
+ function hasValue(value) {
59
+ return value !== null && value !== undefined;
60
+ }
61
+
55
62
  /**
56
63
  * UUID v4 regex pattern for validation
57
64
  */
@@ -169,7 +176,10 @@ function parseArgs(args) {
169
176
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
170
177
  useCommandStream: false, // Use command-stream library for command execution
171
178
  status: null, // UUID to show status for
172
- outputFormat: null, // Output format for status (links-notation, json, text)
179
+ list: false, // List all tracked execution records
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
173
183
  cleanup: false, // Clean up stale "executing" records
174
184
  cleanupDryRun: false, // Show what would be cleaned without actually cleaning
175
185
  };
@@ -430,7 +440,61 @@ function parseOption(args, index, options) {
430
440
 
431
441
  // --status=<value>
432
442
  if (arg.startsWith('--status=')) {
433
- 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;
492
+ return 1;
493
+ }
494
+
495
+ // --list
496
+ if (arg === '--list') {
497
+ options.list = true;
434
498
  return 1;
435
499
  }
436
500
 
@@ -648,7 +712,7 @@ function validateOptions(options) {
648
712
  }
649
713
 
650
714
  // Validate output format
651
- if (options.outputFormat !== null && options.outputFormat !== undefined) {
715
+ if (hasValue(options.outputFormat)) {
652
716
  if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
653
717
  throw new Error(
654
718
  `Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
@@ -656,9 +720,26 @@ function validateOptions(options) {
656
720
  }
657
721
  }
658
722
 
659
- // Output format is only valid with --status
660
- if (options.outputFormat && !options.status) {
661
- throw new Error('--output-format option is only valid with --status');
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
+ );
736
+ }
737
+
738
+ // Output format is only valid with read-only query modes
739
+ if (options.outputFormat && !options.status && !options.list) {
740
+ throw new Error(
741
+ '--output-format option is only valid with --status or --list'
742
+ );
662
743
  }
663
744
 
664
745
  // Validate shell option
@@ -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
+ };