start-command 0.32.0 → 0.33.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 (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. package/test/user-manager.js +23 -0
@@ -28,11 +28,15 @@
28
28
  * --use-command-stream Use command-stream library for command execution (experimental)
29
29
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
30
30
  * --status <uuid> Show status of a previous command execution by UUID
31
- * --list List all tracked command executions
31
+ * --list [--running] List all (or only running) tracked command executions
32
32
  * --upload-log <uuid-or-session> Upload the stored log for a tracked execution
33
33
  * --output-format <format> Output format for status/list (links-notation, json, text)
34
34
  * --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
35
35
  * --terminate <uuid-or-session-name> Terminate a detached execution immediately
36
+ * --attach <uuid-or-session-name> Attach to a running detached session terminal
37
+ * --read-only Follow --attach output without forwarding stdin
38
+ * --resume <uuid-or-session> [-- cmd] Resume a stored execution (optionally with a new command)
39
+ * --resume-all Re-attach or reconcile every execution still marked running
36
40
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
37
41
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
38
42
  */
@@ -40,6 +44,13 @@
40
44
  const { getDefaultDockerImage } = require('./docker-utils');
41
45
  const dockerNetworkOptions = require('./docker-network-options');
42
46
  const { parseSequence, isSequence } = require('./sequence-parser');
47
+ const { buildCommandString } = require('./shell-utils');
48
+ const {
49
+ VALID_OUTPUT_FORMATS,
50
+ createQueryOptionDefaults,
51
+ parseQueryOption,
52
+ validateQueryOptions,
53
+ } = require('./args-parser-queries');
43
54
 
44
55
  // Debug mode from environment
45
56
  const DEBUG =
@@ -60,15 +71,6 @@ const VALID_SHELLS = ['auto', 'bash', 'zsh', 'sh'];
60
71
  */
61
72
  const MAX_ISOLATION_DEPTH = 7;
62
73
 
63
- /**
64
- * Valid output formats for --status
65
- */
66
- const VALID_OUTPUT_FORMATS = ['links-notation', 'json', 'text'];
67
-
68
- function hasValue(value) {
69
- return value !== null && value !== undefined;
70
- }
71
-
72
74
  /**
73
75
  * UUID v4 regex pattern for validation
74
76
  */
@@ -89,18 +91,25 @@ function isValidUUID(str) {
89
91
  * @returns {string} A new UUID v4 string
90
92
  */
91
93
  function generateUUID() {
92
- // Try to use Node.js/Bun crypto module
93
- try {
94
- const crypto = require('crypto');
94
+ const crypto = require('crypto');
95
+ if (typeof crypto.randomUUID === 'function') {
95
96
  return crypto.randomUUID();
96
- } catch {
97
- // Fallback for environments without crypto.randomUUID
98
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
99
- const r = (Math.random() * 16) | 0;
100
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
101
- return v.toString(16);
102
- });
103
97
  }
98
+
99
+ // Fallback for runtimes without crypto.randomUUID (Node < 14.17). Still a
100
+ // CSPRNG: session ids name isolation sessions and users, so a predictable
101
+ // generator is a security bug, not a cosmetic one (CodeQL js/insecure-randomness).
102
+ const bytes = crypto.randomBytes(16);
103
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
104
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
105
+ const hex = bytes.toString('hex');
106
+ return [
107
+ hex.slice(0, 8),
108
+ hex.slice(8, 12),
109
+ hex.slice(12, 16),
110
+ hex.slice(16, 20),
111
+ hex.slice(20),
112
+ ].join('-');
104
113
  }
105
114
 
106
115
  /**
@@ -164,6 +173,8 @@ function parseEndpointValue(value, options) {
164
173
  * Parse command line arguments into wrapper options and command
165
174
  * @param {string[]} args - Array of command line arguments
166
175
  * @returns {{wrapperOptions: object, command: string, rawCommand: string[]}}
176
+ * `command` re-quotes each argv element so the inner shell sees the argument
177
+ * boundaries the user typed (issue #164); `rawCommand` keeps the raw argv.
167
178
  */
168
179
  function parseArgs(args) {
169
180
  const wrapperOptions = {
@@ -195,14 +206,7 @@ function parseArgs(args) {
195
206
  keepContainerOnFail: false, // Keep docker container filesystem when command fails or OOM-kills
196
207
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
197
208
  useCommandStream: false, // Use command-stream library for command execution
198
- status: null, // UUID to show status for
199
- list: false, // List all tracked execution records
200
- uploadLog: null, // UUID/session name whose stored log should be uploaded
201
- outputFormat: null, // Output format for status/list (links-notation, json, text)
202
- stop: null, // UUID/session name to stop gracefully
203
- terminate: null, // UUID/session name to terminate immediately
204
- cleanup: false, // Clean up stale "executing" records
205
- cleanupDryRun: false, // Show what would be cleaned without actually cleaning
209
+ ...createQueryOptionDefaults(),
206
210
  };
207
211
 
208
212
  let commandArgs = [];
@@ -241,7 +245,7 @@ function parseArgs(args) {
241
245
 
242
246
  return {
243
247
  wrapperOptions,
244
- command: commandArgs.join(' '),
248
+ command: buildCommandString(commandArgs),
245
249
  rawCommand: commandArgs,
246
250
  };
247
251
  }
@@ -529,125 +533,10 @@ function parseOption(args, index, options) {
529
533
  return 1;
530
534
  }
531
535
 
532
- // --status <uuid-or-session-name>
533
- if (arg === '--status') {
534
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
535
- options.status = args[index + 1];
536
- return 2;
537
- } else {
538
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
539
- }
540
- }
541
-
542
- // --status=<value>
543
- if (arg.startsWith('--status=')) {
544
- const value = arg.slice('--status='.length);
545
- if (!value) {
546
- throw new Error(
547
- `Option --status requires a UUID or session name argument`
548
- );
549
- }
550
- options.status = value;
551
- return 1;
552
- }
553
-
554
- // --upload-log <uuid-or-session-name>
555
- if (arg === '--upload-log') {
556
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
557
- options.uploadLog = args[index + 1];
558
- return 2;
559
- } else {
560
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
561
- }
562
- }
563
-
564
- // --upload-log=<value>
565
- if (arg.startsWith('--upload-log=')) {
566
- const value = arg.slice('--upload-log='.length);
567
- if (!value) {
568
- throw new Error(
569
- `Option --upload-log requires a UUID or session name argument`
570
- );
571
- }
572
- options.uploadLog = value;
573
- return 1;
574
- }
575
-
576
- // --stop <uuid-or-session-name>
577
- if (arg === '--stop') {
578
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
579
- options.stop = args[index + 1];
580
- return 2;
581
- } else {
582
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
583
- }
584
- }
585
-
586
- // --stop=<value>
587
- if (arg.startsWith('--stop=')) {
588
- const value = arg.slice('--stop='.length);
589
- if (!value) {
590
- throw new Error(`Option --stop requires a UUID or session name argument`);
591
- }
592
- options.stop = value;
593
- return 1;
594
- }
595
-
596
- // --terminate <uuid-or-session-name>
597
- if (arg === '--terminate') {
598
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
599
- options.terminate = args[index + 1];
600
- return 2;
601
- } else {
602
- throw new Error(`Option ${arg} requires a UUID or session name argument`);
603
- }
604
- }
605
-
606
- // --terminate=<value>
607
- if (arg.startsWith('--terminate=')) {
608
- const value = arg.slice('--terminate='.length);
609
- if (!value) {
610
- throw new Error(
611
- `Option --terminate requires a UUID or session name argument`
612
- );
613
- }
614
- options.terminate = value;
615
- return 1;
616
- }
617
-
618
- // --list
619
- if (arg === '--list') {
620
- options.list = true;
621
- return 1;
622
- }
623
-
624
- // --output-format <format>
625
- if (arg === '--output-format') {
626
- if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
627
- options.outputFormat = args[index + 1].toLowerCase();
628
- return 2;
629
- } else {
630
- throw new Error(`Option ${arg} requires a format argument`);
631
- }
632
- }
633
-
634
- // --output-format=<value>
635
- if (arg.startsWith('--output-format=')) {
636
- options.outputFormat = arg.split('=')[1].toLowerCase();
637
- return 1;
638
- }
639
-
640
- // --cleanup
641
- if (arg === '--cleanup') {
642
- options.cleanup = true;
643
- return 1;
644
- }
645
-
646
- // --cleanup-dry-run
647
- if (arg === '--cleanup-dry-run') {
648
- options.cleanup = true;
649
- options.cleanupDryRun = true;
650
- return 1;
536
+ // Query/control options (--status, --list, --attach, --resume, ...)
537
+ const queryConsumed = parseQueryOption(args, index, options);
538
+ if (queryConsumed > 0) {
539
+ return queryConsumed;
651
540
  }
652
541
 
653
542
  // Not a recognized wrapper option
@@ -889,37 +778,7 @@ function validateOptions(options) {
889
778
  throw new Error('--keep-user option is only valid with --isolated-user');
890
779
  }
891
780
 
892
- // Validate output format
893
- if (hasValue(options.outputFormat)) {
894
- if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
895
- throw new Error(
896
- `Invalid output format: "${options.outputFormat}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`
897
- );
898
- }
899
- }
900
-
901
- // Query/control modes are mutually exclusive
902
- const queryModes = [
903
- hasValue(options.status),
904
- options.list,
905
- hasValue(options.uploadLog),
906
- hasValue(options.stop),
907
- hasValue(options.terminate),
908
- options.cleanup,
909
- ].filter(Boolean).length;
910
-
911
- if (queryModes > 1) {
912
- throw new Error(
913
- 'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
914
- );
915
- }
916
-
917
- // Output format is only valid with read-only query modes
918
- if (options.outputFormat && !options.status && !options.list) {
919
- throw new Error(
920
- '--output-format option is only valid with --status or --list'
921
- );
922
- }
781
+ validateQueryOptions(options);
923
782
 
924
783
  // Validate shell option
925
784
  if (options.shell !== null && options.shell !== undefined) {
@@ -2,8 +2,11 @@ const { spawn, spawnSync } = require('child_process');
2
2
  const {
3
3
  appendLogFile,
4
4
  createShellLogFooterSnippet,
5
+ FATAL_MARKER_TAIL_BYTES,
6
+ readLogTail,
5
7
  shellQuote,
6
8
  } = require('./isolation-log-utils');
9
+ const { resolveMemoryExhaustion } = require('./exit-reason');
7
10
 
8
11
  const DOCKER_CONTAINER_CLEANUP_POLICY = {
9
12
  DEFAULT: 'default',
@@ -56,7 +59,9 @@ function shouldCleanupDockerContainer(policy, exitCode, oomKilled = false) {
56
59
  function getDockerContainerCleanupInstructions(containerName) {
57
60
  return [
58
61
  `Container kept for investigation: ${containerName}`,
59
- `Inspect: docker exec -it ${containerName} sh (if running) or docker start -ai ${containerName}`,
62
+ `Re-enter while running: $ --attach ${containerName}`,
63
+ `Continue the stored command: $ --resume ${containerName}`,
64
+ `Run another command in the same container: $ --resume ${containerName} -- <command>`,
60
65
  `Remove when done: docker rm -f ${containerName}`,
61
66
  ].join('\n');
62
67
  }
@@ -142,14 +147,80 @@ function removeDockerContainer(containerName, logPath = null) {
142
147
  return !result.error && result.status === 0;
143
148
  }
144
149
 
150
+ /**
151
+ * Exit codes of a process that aborted itself: SIGABRT (134) and SIGSEGV (139).
152
+ * These are exactly the codes a runtime produces when it dies on its own memory
153
+ * limit — Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts — long
154
+ * before the container limit is reached, so the kernel never OOM-kills anything
155
+ * and `State.OOMKilled` stays `false` (issue #165).
156
+ */
157
+ const SELF_ABORT_EXIT_CODES = [134, 139];
158
+
159
+ /**
160
+ * Appended to the kept-container reason for a self-abort exit code, so the
161
+ * footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
162
+ * above it. The footer is the string downstream tooling greps.
163
+ */
164
+ const OOM_FLAG_BLIND_NOTE =
165
+ 'a runtime self-abort on its own memory limit is invisible to this flag - ' +
166
+ 'check the log above for a fatal memory marker';
167
+
168
+ /**
169
+ * Shell fragment computing `$__start_command_reason` for the kept footer.
170
+ * @returns {string} Shell command
171
+ */
172
+ function buildDockerKeptReasonSnippet() {
173
+ return (
174
+ '__start_command_reason="exitCode=$__start_command_exit oomKilled=$__start_command_oom"; ' +
175
+ `case "$__start_command_exit" in ${SELF_ABORT_EXIT_CODES.join('|')}) ` +
176
+ '[ "$__start_command_oom" = true ] || ' +
177
+ `__start_command_reason="$__start_command_reason (${OOM_FLAG_BLIND_NOTE})";; ` +
178
+ 'esac'
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Lines appended to an attached session's message when the container is kept
184
+ * because the command failed. A runtime that aborts on its own memory limit
185
+ * never trips `State.OOMKilled`, so a bare `oomKilled false` would contradict
186
+ * the `FATAL ERROR` the runtime just printed into this very log (issue #165).
187
+ * Best effort: the tail is read right after the child exits.
188
+ *
189
+ * @param {object} params - Container name, exit code, OOM flag and log path
190
+ * @returns {string} Message lines to append
191
+ */
192
+ function buildAttachedDockerKeptMessage({
193
+ containerName,
194
+ exitCode,
195
+ oomKilled,
196
+ logPath,
197
+ }) {
198
+ let message =
199
+ oomKilled === true
200
+ ? `\nContainer kept because Docker reports it was OOM-killed.`
201
+ : `\nContainer kept because the command failed.`;
202
+ const memory = resolveMemoryExhaustion({
203
+ exitCode,
204
+ logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
205
+ oomKilled,
206
+ });
207
+ if (memory) {
208
+ message += `\nMemory exhaustion detected in the log: ${memory.memoryExhaustedReason}`;
209
+ }
210
+ return `${message}\nRemove when done: docker rm -f ${containerName}`;
211
+ }
212
+
145
213
  function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
146
214
  const quotedName = shellQuote(containerName);
147
215
  return (
148
- `printf '\\nContainer kept for investigation: %s\\nReason: exitCode=%s oomKilled=%s\\n` +
149
- `Inspect: docker exec -it %s sh (if running) or docker start -ai %s\\n` +
216
+ `${buildDockerKeptReasonSnippet()}; ` +
217
+ `printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
218
+ `Re-enter while running: $ --attach %s\\n` +
219
+ `Continue the stored command: $ --resume %s\\n` +
220
+ `Run another command in the same container: $ --resume %s -- <command>\\n` +
150
221
  `Remove when done: docker rm -f %s\\n' ` +
151
- `${quotedName} "$__start_command_exit" "$__start_command_oom" ` +
152
- `${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
222
+ `${quotedName} "$__start_command_reason" ` +
223
+ `${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
153
224
  );
154
225
  }
155
226
 
@@ -244,7 +315,11 @@ function spawnAttachedDocker(dockerArgs, logPath) {
244
315
  }
245
316
 
246
317
  module.exports = {
318
+ buildAttachedDockerKeptMessage,
247
319
  DOCKER_CONTAINER_CLEANUP_POLICY,
320
+ SELF_ABORT_EXIT_CODES,
321
+ OOM_FLAG_BLIND_NOTE,
322
+ buildDockerKeptReasonSnippet,
248
323
  getDockerCommand,
249
324
  getDockerSpawnOptions,
250
325
  getDockerContainerCleanupPolicy,
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Attach to a tracked detached execution (issue #162).
3
+ *
4
+ * `--stop`/`--terminate` can already address a stored session name; `--attach`
5
+ * completes the set by re-entering the session's terminal instead of only
6
+ * tailing its log. `--attach --read-only` follows the output without
7
+ * forwarding stdin, which is the safe default for supervisors that must not
8
+ * accidentally type into a long-running agent session.
9
+ */
10
+
11
+ const { spawnSync } = require('child_process');
12
+ const { escapeForLinksNotation } = require('./output-blocks');
13
+ const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
14
+ const { runCommand } = require('./execution-control');
15
+ const { SessionState, probeSession } = require('./session-probe');
16
+
17
+ /**
18
+ * Run a command with the parent terminal attached (stdin included unless the
19
+ * plan is read-only). Used for `screen -r`, `tmux attach-session`,
20
+ * `docker attach`, and log following.
21
+ * @param {string} command - Executable
22
+ * @param {string[]} args - Arguments
23
+ * @param {object} plan - Attach plan (interactive flag)
24
+ * @returns {{success: boolean, status: ?number, error: ?string}}
25
+ */
26
+ function runInteractive(command, args, plan = {}) {
27
+ const options = {
28
+ stdio: plan.interactive ? 'inherit' : ['ignore', 'inherit', 'inherit'],
29
+ };
30
+ const result = spawnSync(
31
+ command,
32
+ args,
33
+ command === getDockerCommand() ? getDockerSpawnOptions(options) : options
34
+ );
35
+ return {
36
+ success: !result.error && result.status === 0,
37
+ status: result.status,
38
+ error: result.error ? result.error.message : null,
39
+ };
40
+ }
41
+
42
+ function logFollowPlan(record, backend, sessionName) {
43
+ if (!record.logPath) {
44
+ return {
45
+ error: `Execution "${record.uuid}" has no stored log to follow.`,
46
+ };
47
+ }
48
+ return {
49
+ backend,
50
+ sessionName,
51
+ command: 'tail',
52
+ args: ['-f', record.logPath],
53
+ interactive: false,
54
+ method: 'LOG_FOLLOW',
55
+ message: `Following stored log for ${backend} session: ${sessionName}`,
56
+ };
57
+ }
58
+
59
+ function notRunningError(record, probe) {
60
+ const sessionName = probe.sessionName;
61
+ const uuid = record.uuid;
62
+ if (probe.state === SessionState.MISSING) {
63
+ return {
64
+ error:
65
+ `Session "${sessionName}" no longer exists, so there is nothing to attach to. ` +
66
+ `Use \`$ --resume ${uuid}\` to start it again, or \`$ --status ${uuid}\` to inspect the recorded result.`,
67
+ };
68
+ }
69
+ return {
70
+ error:
71
+ `Session "${sessionName}" is not running (state: ${probe.containerStatus || probe.state}). ` +
72
+ `Use \`$ --resume ${uuid}\` to continue it in the same container, or \`$ --status ${uuid}\` to inspect the recorded result.`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Build the attach plan for an execution record.
78
+ * @param {object} record - Execution record
79
+ * @param {object} options - {readOnly: boolean, probe: object}
80
+ * @returns {object} Plan with command/args/interactive/method/message, or {error}
81
+ */
82
+ function buildAttachPlan(record, options = {}) {
83
+ const readOnly = options.readOnly === true;
84
+ const probe = options.probe || {};
85
+ const opts = (record && record.options) || {};
86
+ const backend = opts.isolated;
87
+ const sessionName = opts.sessionName;
88
+
89
+ if (!sessionName) {
90
+ return {
91
+ error: 'Execution record does not contain an isolation session name.',
92
+ };
93
+ }
94
+
95
+ if (opts.isolationMode !== 'detached') {
96
+ return {
97
+ error: 'Only detached isolated executions can be attached to.',
98
+ };
99
+ }
100
+
101
+ // ssh sessions cannot be probed or re-entered locally: the stored log is the
102
+ // only channel back into them.
103
+ if (backend === 'ssh') {
104
+ return logFollowPlan(record, backend, sessionName);
105
+ }
106
+
107
+ if (!probe.alive) {
108
+ return notRunningError(record, { ...probe, sessionName });
109
+ }
110
+
111
+ switch (backend) {
112
+ case 'screen':
113
+ return readOnly
114
+ ? logFollowPlan(record, backend, sessionName)
115
+ : {
116
+ backend,
117
+ sessionName,
118
+ command: 'screen',
119
+ args: ['-r', sessionName],
120
+ interactive: true,
121
+ method: 'SCREEN_ATTACH',
122
+ message: `Attaching to detached screen session: ${sessionName}`,
123
+ };
124
+ case 'tmux':
125
+ return {
126
+ backend,
127
+ sessionName,
128
+ command: 'tmux',
129
+ args: readOnly
130
+ ? ['attach-session', '-r', '-t', sessionName]
131
+ : ['attach-session', '-t', sessionName],
132
+ interactive: true,
133
+ method: readOnly ? 'TMUX_ATTACH_READONLY' : 'TMUX_ATTACH',
134
+ message: `Attaching to detached tmux session: ${sessionName}${readOnly ? ' (read-only)' : ''}`,
135
+ };
136
+ case 'docker':
137
+ return readOnly
138
+ ? {
139
+ backend,
140
+ sessionName,
141
+ command: getDockerCommand(),
142
+ args: ['logs', '-f', sessionName],
143
+ interactive: false,
144
+ method: 'DOCKER_LOG_FOLLOW',
145
+ message: `Following logs of detached docker container: ${sessionName}`,
146
+ }
147
+ : {
148
+ backend,
149
+ sessionName,
150
+ command: getDockerCommand(),
151
+ args: ['attach', sessionName],
152
+ interactive: true,
153
+ method: 'DOCKER_ATTACH',
154
+ message: `Attaching to detached docker container: ${sessionName}`,
155
+ };
156
+ default:
157
+ return {
158
+ error: `Attaching to detached ${backend || 'unknown'} executions is not supported.`,
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Format an attach result as links notation.
165
+ * @param {object} result - Attach result fields
166
+ * @returns {string} Links notation block
167
+ */
168
+ function formatAttachResultAsLinksNotation(result) {
169
+ return [
170
+ 'executionAttach',
171
+ ` identifier ${escapeForLinksNotation(result.identifier)}`,
172
+ ` uuid ${escapeForLinksNotation(result.uuid)}`,
173
+ ` backend ${escapeForLinksNotation(result.backend)}`,
174
+ ` sessionName ${escapeForLinksNotation(result.sessionName)}`,
175
+ ` method ${escapeForLinksNotation(result.method)}`,
176
+ ` readOnly ${result.readOnly === true}`,
177
+ ` command ${escapeForLinksNotation(result.command)}`,
178
+ ` message ${escapeForLinksNotation(result.message)}`,
179
+ ].join('\n');
180
+ }
181
+
182
+ /**
183
+ * Attach to a tracked execution by UUID or session name.
184
+ * @param {?object} store - Execution store
185
+ * @param {string} identifier - UUID or session name
186
+ * @param {object} deps - {readOnly, probe, interactiveRunner, runner}
187
+ * @returns {{success: boolean, output?: string, error?: string, exitCode?: number}}
188
+ */
189
+ function attachExecution(store, identifier, deps = {}) {
190
+ if (!store) {
191
+ return { success: false, error: 'Execution tracking is disabled.' };
192
+ }
193
+
194
+ const record = store.get(identifier);
195
+ if (!record) {
196
+ return {
197
+ success: false,
198
+ error: `No execution found with UUID or session name: ${identifier}`,
199
+ };
200
+ }
201
+
202
+ const runner = deps.runner || runCommand;
203
+ const probeFn = deps.probe || ((r) => probeSession(r, runner));
204
+ const plan = buildAttachPlan(record, {
205
+ readOnly: deps.readOnly === true,
206
+ probe: probeFn(record),
207
+ });
208
+
209
+ if (plan.error) {
210
+ return { success: false, error: plan.error };
211
+ }
212
+
213
+ const interactiveRunner = deps.interactiveRunner || runInteractive;
214
+ const commandLine = [plan.command, ...plan.args].join(' ');
215
+ const runResult = interactiveRunner(plan.command, plan.args, plan);
216
+
217
+ const output = formatAttachResultAsLinksNotation({
218
+ identifier,
219
+ uuid: record.uuid,
220
+ backend: plan.backend,
221
+ sessionName: plan.sessionName,
222
+ method: plan.method,
223
+ readOnly: deps.readOnly === true,
224
+ command: commandLine,
225
+ message: plan.message,
226
+ });
227
+
228
+ if (!runResult.success) {
229
+ const detail = runResult.error || `exit code ${runResult.status}`;
230
+ return {
231
+ success: false,
232
+ error: `Failed to attach to ${plan.backend} session "${plan.sessionName}": ${detail}`,
233
+ exitCode: typeof runResult.status === 'number' ? runResult.status : 1,
234
+ };
235
+ }
236
+
237
+ return { success: true, output };
238
+ }
239
+
240
+ module.exports = {
241
+ attachExecution,
242
+ buildAttachPlan,
243
+ formatAttachResultAsLinksNotation,
244
+ runInteractive,
245
+ };