@myagentroam/marctl 0.10.2 → 0.10.4

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/NOTICE CHANGED
@@ -9,3 +9,29 @@ Author and licensing contact: aCHAOS (achaosxyz@gmail.com)
9
9
  Licensed under MyAgentRoam Enterprise Source-Available License 1.0; see
10
10
  ee/LICENSE and LICENSING.md. Independent core and third-party notices remain
11
11
  applicable; this attribution does not transfer any contributor's copyright.
12
+
13
+ Third-party supplement: http_ece@1.2.0 (Web Push encryption dependency)
14
+ The npm archive omits its license file; the upstream v1.2.0 text follows.
15
+ Source: https://github.com/martinthomson/encrypted-content-encoding/blob/0562510a30819f52424724a6fd5504becacd98a1/LICENSE
16
+
17
+ The MIT License (MIT)
18
+
19
+ Copyright (c) 2015 Martin Thomson
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/marctl",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { MarctlError, loadConfig } from './config.mjs';
4
4
  import { createTranslator } from './i18n/index.mjs';
5
5
  import { runTerminalSession } from './terminal.mjs';
6
6
  import { runScp } from './scp.mjs';
7
+ import { parseSessionArgs, runSessionCommand, sessionExitCode } from './sessions.mjs';
7
8
 
8
9
  export async function main(
9
10
  argv = process.argv.slice(2),
@@ -11,14 +12,16 @@ export async function main(
11
12
  env = process.env,
12
13
  stdout = process.stdout,
13
14
  stderr = process.stderr,
15
+ stdin = process.stdin,
14
16
  program = 'marctl',
15
17
  client,
16
18
  WebSocketClass
17
19
  } = {}
18
20
  ) {
19
21
  const translate = createTranslator(env);
22
+ let args;
20
23
  try {
21
- const args = parseArgs(argv, translate);
24
+ args = parseArgs(argv, translate);
22
25
  if (args.help) {
23
26
  writeLine(
24
27
  stdout,
@@ -38,7 +41,7 @@ export async function main(
38
41
  return 0;
39
42
  }
40
43
 
41
- const config = await loadConfig({ env, envFile: args.envFile, translate });
44
+ const config = await loadConfig({ env, translate });
42
45
  const marctlClient = client ?? new MarctlClient({ ...config, translate });
43
46
  if (args.command === 'nodes') {
44
47
  const nodes = await marctlClient.listNodes();
@@ -56,6 +59,18 @@ export async function main(
56
59
  }
57
60
  return 0;
58
61
  }
62
+ if (args.command === 'sessions') {
63
+ const result = await runSessionCommand({
64
+ args,
65
+ client: marctlClient,
66
+ env,
67
+ stdin,
68
+ WebSocketClass,
69
+ translate
70
+ });
71
+ writeLine(stdout, JSON.stringify(result, null, args.json ? undefined : 2));
72
+ return 0;
73
+ }
59
74
  if (args.command === 'scp') {
60
75
  const socketClass = WebSocketClass ?? (await import('ws')).default;
61
76
  return await runScp({
@@ -82,6 +97,22 @@ export async function main(
82
97
  translate
83
98
  });
84
99
  } catch (error) {
100
+ if (argv[0] === 'sessions') {
101
+ const failure = {
102
+ code: error.code ?? 'MARCTL_COMMAND_FAILED',
103
+ message: error instanceof MarctlError ? error.message : translate('errors.commandFailed'),
104
+ ...(error.requestId === undefined ? {} : { requestId: error.requestId }),
105
+ ...(error.upstreamRequestId === undefined
106
+ ? {}
107
+ : { upstreamRequestId: error.upstreamRequestId }),
108
+ ...(error.details === undefined ? {} : { details: error.details }),
109
+ ...(error.clientMessageId === undefined ? {} : { clientMessageId: error.clientMessageId })
110
+ };
111
+ if (args?.json === true || (args === undefined && argv.includes('--json')))
112
+ writeLine(stdout, JSON.stringify({ error: failure }));
113
+ else writeLine(stderr, program + ': ' + failure.message + ' [' + failure.code + ']');
114
+ return sessionExitCode(error);
115
+ }
85
116
  writeLine(
86
117
  stderr,
87
118
  `${program}: ${error instanceof MarctlError ? error.message : translate('errors.commandFailed')}`
@@ -110,7 +141,7 @@ export function executableArgs(executable, args) {
110
141
  }
111
142
 
112
143
  export function parseArgs(argv, translate = createTranslator()) {
113
- let envFile;
144
+ if (argv[0] === 'sessions') return parseSessionArgs(argv.slice(1), translate);
114
145
  let command;
115
146
  let nodeId;
116
147
  const remoteArgs = [];
@@ -133,18 +164,6 @@ export function parseArgs(argv, translate = createTranslator()) {
133
164
  version = true;
134
165
  continue;
135
166
  }
136
- if (!separator && argument === '--env-file') {
137
- envFile = argv[index + 1];
138
- if (!envFile || envFile.startsWith('-'))
139
- throw new MarctlError(translate('errors.envFileValueRequired'));
140
- index += 1;
141
- continue;
142
- }
143
- if (!separator && argument.startsWith('--env-file=')) {
144
- envFile = argument.slice('--env-file='.length);
145
- if (!envFile) throw new MarctlError(translate('errors.envFileValueRequired'));
146
- continue;
147
- }
148
167
  if (!separator && argument === '--') {
149
168
  separator = true;
150
169
  continue;
@@ -154,6 +173,7 @@ export function parseArgs(argv, translate = createTranslator()) {
154
173
  argument !== 'nodes' &&
155
174
  argument !== 'ssh' &&
156
175
  argument !== 'scp' &&
176
+ argument !== 'sessions' &&
157
177
  argument !== 'version'
158
178
  )
159
179
  throw unsupportedSshOption(argument, translate);
@@ -186,12 +206,10 @@ export function parseArgs(argv, translate = createTranslator()) {
186
206
 
187
207
  if (help) return { help: true, ...(command === undefined ? {} : { command }) };
188
208
  if (version || command === 'version') return { version: true };
189
- if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0)
190
- return { command, ...(envFile === undefined ? {} : { envFile }) };
209
+ if (command === 'nodes' && nodeId === undefined && remoteArgs.length === 0) return { command };
191
210
  if (command === 'scp' && nodeId !== undefined && remoteArgs.length === 1)
192
211
  return {
193
212
  command,
194
- ...(envFile === undefined ? {} : { envFile }),
195
213
  source: nodeId,
196
214
  destination: remoteArgs[0],
197
215
  preserve
@@ -199,7 +217,6 @@ export function parseArgs(argv, translate = createTranslator()) {
199
217
  if (command === 'ssh' && nodeId !== undefined && nodeId !== '')
200
218
  return {
201
219
  command,
202
- ...(envFile === undefined ? {} : { envFile }),
203
220
  nodeId,
204
221
  ...(remoteArgs.length === 0
205
222
  ? {}
package/src/client.mjs CHANGED
@@ -49,6 +49,19 @@ export class MarctlClient {
49
49
  });
50
50
  }
51
51
 
52
+ async sessionCommand(body) {
53
+ const result = await this.#requestJson('/api/marctl/sessions', {
54
+ method: 'POST',
55
+ body,
56
+ preserveError: true
57
+ });
58
+ if (!isRecord(result))
59
+ throw new MarctlError(this.#translate('errors.serverInvalidJson'), {
60
+ code: 'MARCTL_RESPONSE_INVALID'
61
+ });
62
+ return result;
63
+ }
64
+
52
65
  async createTerminal({ nodeId, rows, cols, command, binary = false }) {
53
66
  validateNodeId(nodeId, this.#translate);
54
67
  validateDimensions(rows, cols, this.#translate);
@@ -158,7 +171,7 @@ export class MarctlClient {
158
171
  return this.#request(pathname, { ...options, json: true });
159
172
  }
160
173
 
161
- async #request(pathname, { method = 'GET', body, json = false } = {}) {
174
+ async #request(pathname, { method = 'GET', body, json = false, preserveError = false } = {}) {
162
175
  const headers = {
163
176
  Accept: 'application/json',
164
177
  Authorization: `Bearer ${this.#key}`
@@ -173,7 +186,35 @@ export class MarctlClient {
173
186
  const timer = setTimeout(() => controller.abort(), this.#requestTimeoutMs);
174
187
  try {
175
188
  const response = await this.#fetch(new URL(pathname, `${this.#serverUrl}/`), request);
176
- if (!response?.ok) throw new MarctlError(this.#translate('errors.serverRejected'));
189
+ if (!response?.ok) {
190
+ if (preserveError) {
191
+ const payload = await response?.json().catch(() => undefined);
192
+ const failure = payload?.error;
193
+ if (
194
+ isRecord(failure) &&
195
+ typeof failure.code === 'string' &&
196
+ /^[A-Za-z][A-Za-z0-9_]{0,127}$/u.test(failure.code) &&
197
+ typeof failure.message === 'string'
198
+ ) {
199
+ throw new MarctlError(failure.message.slice(0, 2000), {
200
+ code: failure.code,
201
+ statusCode: response.status,
202
+ requestId:
203
+ typeof failure.requestId === 'string' ? failure.requestId.slice(0, 160) : undefined,
204
+ upstreamRequestId:
205
+ typeof failure.upstreamRequestId === 'string'
206
+ ? failure.upstreamRequestId.slice(0, 160)
207
+ : undefined,
208
+ details:
209
+ typeof failure.details === 'string' ? failure.details.slice(0, 4000) : undefined
210
+ });
211
+ }
212
+ }
213
+ throw new MarctlError(this.#translate('errors.serverRejected'), {
214
+ code: 'MARCTL_SERVER_REJECTED',
215
+ statusCode: response?.status
216
+ });
217
+ }
177
218
  if (!json) return response;
178
219
  try {
179
220
  return await response.json();
@@ -181,7 +222,8 @@ export class MarctlClient {
181
222
  throw new MarctlError(
182
223
  this.#translate(
183
224
  controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverInvalidJson'
184
- )
225
+ ),
226
+ { code: controller.signal.aborted ? 'MARCTL_REQUEST_TIMEOUT' : 'MARCTL_RESPONSE_INVALID' }
185
227
  );
186
228
  }
187
229
  } catch (error) {
@@ -189,7 +231,8 @@ export class MarctlClient {
189
231
  throw new MarctlError(
190
232
  this.#translate(
191
233
  controller.signal.aborted ? 'errors.serverTimeout' : 'errors.serverUnreachable'
192
- )
234
+ ),
235
+ { code: controller.signal.aborted ? 'MARCTL_REQUEST_TIMEOUT' : 'MARCTL_SERVER_UNREACHABLE' }
193
236
  );
194
237
  } finally {
195
238
  clearTimeout(timer);
package/src/config.mjs CHANGED
@@ -1,67 +1,31 @@
1
- import { lstat, readFile } from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
1
  import { createTranslator } from './i18n/index.mjs';
5
2
 
6
- export const CONFIG_KEYS = Object.freeze(['MAR_CTL_SERVER_URL', 'MAR_CTL_KEY']);
7
-
8
3
  export class MarctlError extends Error {
9
4
  constructor(message, options = {}) {
10
5
  super(message, options);
11
6
  this.name = 'MarctlError';
7
+ this.code = options.code;
8
+ this.requestId = options.requestId;
9
+ this.upstreamRequestId = options.upstreamRequestId;
10
+ this.details = options.details;
11
+ this.statusCode = options.statusCode;
12
12
  }
13
13
  }
14
14
 
15
- export function defaultConfigPath({
16
- env = process.env,
17
- platform = process.platform,
18
- homeDirectory = os.homedir()
19
- } = {}) {
20
- const configuredHome =
21
- nonEmptyString(env.HOME) ??
22
- (platform === 'win32' ? nonEmptyString(env.USERPROFILE) : undefined) ??
23
- homeDirectory;
24
- return path.join(configuredHome, '.marctl', 'default.env');
25
- }
26
-
27
- export async function loadConfig({
28
- env = process.env,
29
- envFile,
30
- platform = process.platform,
31
- homeDirectory = os.homedir(),
32
- translate = createTranslator(env)
33
- } = {}) {
34
- if (env.MAR_CTL_CONTEXT_REQUIRED === '1') {
35
- const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
36
- const token = nonEmptyString(env.MAR_CTL_USER_TOKEN);
37
- if (
38
- serverUrl === undefined ||
39
- token === undefined ||
40
- !/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
41
- )
42
- throw new MarctlError(translate('errors.userContextUnavailable'));
43
- return Object.freeze({
44
- serverUrl: validateServerUrl(serverUrl, translate),
45
- key: token
15
+ export async function loadConfig({ env = process.env, translate = createTranslator(env) } = {}) {
16
+ const serverUrl = nonEmptyString(env.MAR_CTL_SERVER_URL);
17
+ const token = nonEmptyString(env.MAR_CTL_USER_TOKEN);
18
+ if (
19
+ env.MAR_CTL_CONTEXT_REQUIRED !== '1' ||
20
+ serverUrl === undefined ||
21
+ token === undefined ||
22
+ !/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
23
+ )
24
+ throw new MarctlError(translate('errors.userContextUnavailable'), {
25
+ code: 'UNAUTHENTICATED',
26
+ statusCode: 401
46
27
  });
47
- }
48
- const configPath = envFile ?? defaultConfigPath({ env, platform, homeDirectory });
49
- const values = await readConfigFile(configPath, { platform, translate });
50
-
51
- const serverUrlValue = nonEmptyString(values.MAR_CTL_SERVER_URL);
52
- if (serverUrlValue === undefined)
53
- throw new MarctlError(translate('errors.configServerUrlMissing'));
54
-
55
- const keyValue = nonEmptyString(values.MAR_CTL_KEY);
56
- if (keyValue === undefined) throw new MarctlError(translate('errors.configKeyMissing'));
57
- // eslint-disable-next-line no-control-regex -- Reject control characters explicitly at the input boundary.
58
- if (/[\u0000-\u001f\u007f]/u.test(keyValue))
59
- throw new MarctlError(translate('errors.configKeyInvalid'));
60
-
61
- return Object.freeze({
62
- serverUrl: validateServerUrl(serverUrlValue, translate),
63
- key: keyValue
64
- });
28
+ return Object.freeze({ serverUrl: validateServerUrl(serverUrl, translate), key: token });
65
29
  }
66
30
 
67
31
  export function validateServerUrl(value, translate = createTranslator()) {
@@ -86,80 +50,6 @@ export function validateServerUrl(value, translate = createTranslator()) {
86
50
  return url.origin;
87
51
  }
88
52
 
89
- async function readConfigFile(filePath, { platform, translate }) {
90
- let before;
91
- try {
92
- before = await lstat(filePath);
93
- } catch (error) {
94
- if (error?.code === 'ENOENT') throw new MarctlError(translate('errors.envFileMissing'));
95
- throw new MarctlError(translate('errors.envFileRead'));
96
- }
97
- assertSecureConfigFile(before, platform, translate);
98
-
99
- let content;
100
- try {
101
- content = await readFile(filePath, 'utf8');
102
- } catch {
103
- throw new MarctlError(translate('errors.envFileRead'));
104
- }
105
-
106
- let after;
107
- try {
108
- after = await lstat(filePath);
109
- } catch {
110
- throw new MarctlError(translate('errors.envFileRead'));
111
- }
112
- assertSecureConfigFile(after, platform, translate);
113
- if (before.dev !== after.dev || before.ino !== after.ino)
114
- throw new MarctlError(translate('errors.envFileRead'));
115
-
116
- const values = {};
117
- for (const rawLine of content.replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
118
- const line = rawLine.trim();
119
- if (line === '' || line.startsWith('#')) continue;
120
-
121
- const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
122
- if (match === null) throw new MarctlError(translate('errors.envFileEntry'));
123
-
124
- const [, name, rawValue] = match;
125
- if (!CONFIG_KEYS.includes(name)) throw new MarctlError(translate('errors.envFileUnsupported'));
126
- values[name] = parseEnvValue(rawValue, translate);
127
- }
128
- return values;
129
- }
130
-
131
- function parseEnvValue(rawValue, translate) {
132
- const value = rawValue.trim();
133
- if (value.startsWith('"') || value.startsWith("'")) {
134
- const quote = value[0];
135
- if (!value.endsWith(quote) || value.length < 2)
136
- throw new MarctlError(translate('errors.envFileQuoted'));
137
- const body = value.slice(1, -1);
138
- if (quote === "'") return body;
139
- return body.replace(/\\(["\\nrt])/gu, (_match, escaped) => {
140
- if (escaped === 'n') return '\n';
141
- if (escaped === 'r') return '\r';
142
- if (escaped === 't') return '\t';
143
- return escaped;
144
- });
145
- }
146
- return value;
147
- }
148
-
149
- function assertSecureConfigFile(stats, platform, translate) {
150
- if (stats.isSymbolicLink()) throw new MarctlError(translate('errors.envFileSymlink'));
151
- if (!stats.isFile()) throw new MarctlError(translate('errors.envFileType'));
152
- if (platform === 'win32') return;
153
-
154
- const permissions = stats.mode & 0o7777;
155
- const ownerUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
156
- if (
157
- (ownerUid !== undefined && stats.uid !== ownerUid) ||
158
- (permissions !== 0o600 && permissions !== 0o400)
159
- )
160
- throw new MarctlError(translate('errors.envFilePermissions'));
161
- }
162
-
163
53
  function nonEmptyString(value) {
164
54
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
165
55
  }
@@ -1,9 +1,7 @@
1
- const configurationHelp = `Configuration:
2
- MAR-launched terminals and Workspace Agent Runs use the current user's Node permissions;
3
- scheduled runs use the task owner. No management key is needed in these contexts.
4
- Outside MAR, use ~/.marctl/default.env (%HOME%\\.marctl\\default.env on Windows;
5
- the system home directory is used when HOME is unset), or select a file with --env-file.
6
- The file must contain MAR_CTL_SERVER_URL and MAR_CTL_KEY. Never pass a key as an argument.`;
1
+ const configurationHelp = `Authorization:
2
+ Node access is available only in MAR-launched terminals and Workspace Agent Runs;
3
+ scheduled runs use the task owner. MAR supplies short-lived user credentials.
4
+ External terminals and persistent administrator keys are not supported.`;
7
5
 
8
6
  export default Object.freeze({
9
7
  help: `marctl — MyAgentRoam command-line interface
@@ -13,6 +11,7 @@ Usage:
13
11
  marctl help [command]
14
12
 
15
13
  Commands:
14
+ sessions Inspect and coordinate Sessions in the current Workspace.
16
15
  nodes List accessible Nodes and their connection status.
17
16
  ssh Open a Node terminal or execute a remote command.
18
17
  scp Copy a regular file between this machine and Nodes, or between Nodes.
@@ -20,7 +19,6 @@ Commands:
20
19
  help Show help for the CLI or a command.
21
20
 
22
21
  Options:
23
- --env-file PATH Use the specified management configuration file.
24
22
  -h, --help Show help and exit.
25
23
  -V, --version Print the CLI package version and exit.
26
24
 
@@ -40,23 +38,23 @@ ${configurationHelp}
40
38
 
41
39
  Notes:
42
40
  Help and version work offline without configuration or authorization.
43
- Put local help/version options before a Node or file endpoint; --env-file may also follow a command.
41
+ Put local help/version options before a Node or file endpoint.
44
42
  Each alias shows only its own command help. Use marctl help <command> for details.
45
43
  Remote access uses authorized Node Terminal transport, not system SSH or SCP.`,
46
44
  commandHelp: Object.freeze({
45
+ sessions:
46
+ '{program} — Coordinate Sessions in the current MAR Workspace\n\nUsage:\n {program} current [--json]\n {program} list [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} show <session-id> [--json]\n {program} history <session-id> [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} start --runner <runner> [-m <text> | --message-file <file|->] [--image <path> ...] [--model <id>] [--title <name>] [--client-message-id <id>] [--json]\n {program} send <session-id> [-m <text> | --message-file <file|->] [--image <path> ...] [--delivery send|queue|replace-current] [--client-message-id <id>] [--json]\n {program} stop <session-id> [--run <run-id>] [--json]\n {program} goal get|clear <session-id> [--json]\n {program} goal set <session-id> (-m <text> | --message-file <file|->) [--json]\n {program} wait <session-id> --run <run-id> --timeout <30s|5m> [--json]\n\nRunners: mar-agent, codex, claude-code, opencode; actual capabilities apply.\n-m / --message supplies text; it is mutually exclusive with --message-file.\nFiles use UTF-8; - reads stdin. Repeat --image for PNG/JPEG/GIF/WebP files.\nStart/send allow image-only input: up to 5 images, 5 MiB each, 10 MiB total.\nGoal set accepts text only. Start/send return a clientMessageId.\nCredentials expire 24 hours after issue without automatic renewal. Scope is bound\nby MAR to the current Node/Workspace; there are no scope override options.\nSend defaults to non-interrupting delivery. Queued runId may identify another\nactive input: correlate queueItemId/clientMessageId with visible history.\nWait observes the specified Run through Watch. Timeout does not stop it;\nRun success is not proof of task success. Completion never wakes a Coordinator.\nOn an unknown delivery outcome, inspect state/history; do not blindly resend.\nCreate workers as needed, e.g. --title "fixer#42 Handle empty totals" or\n--title "reviewer#42 Review empty totals". Titles are not routing identities;\nrecord returned Session IDs. Put task/role instructions in the first message.\nSessions share files and the Git index; no automatic worktree or worker pool.\n\nExit codes: 1 failure, 2 input, 3 authorization, 4 unsupported, 5 timeout,\n6 unknown outcome, 130 cancelled wait. --json prints one result/error object.',
47
47
  nodes: `{program} — List accessible Nodes
48
48
 
49
49
  Usage:
50
- {program} [--env-file PATH] [options]
50
+ {program} [options]
51
51
 
52
52
  Options:
53
- --env-file PATH Use the specified management configuration file.
54
53
  -h, --help Show help and exit.
55
54
  -V, --version Print the CLI package version and exit.
56
55
 
57
56
  Examples:
58
57
  {program}
59
- {program} --env-file ~/.marctl/production.env
60
58
 
61
59
  Output:
62
60
  Tab-separated columns: ID, NAME, STATUS, PLATFORM.
@@ -66,14 +64,13 @@ ${configurationHelp}`,
66
64
  ssh: `{program} — Open a terminal or execute a remote command
67
65
 
68
66
  Usage:
69
- {program} [--env-file PATH] [options] <node> [command [arg...]]
67
+ {program} [options] <node> [command [arg...]]
70
68
 
71
69
  Arguments:
72
70
  <node> An authorized Node ID or an exact, unique Node name.
73
71
  [command ...] A command for the remote shell; omit it for an interactive shell.
74
72
 
75
73
  Options (before <node>):
76
- --env-file PATH Use the specified management configuration file.
77
74
  -h, --help Show help and exit.
78
75
  -V, --version Print the CLI package version and exit.
79
76
 
@@ -96,7 +93,7 @@ ${configurationHelp}`,
96
93
  scp: `{program} — Copy a regular file
97
94
 
98
95
  Usage:
99
- {program} [--env-file PATH] [options] <source> <destination>
96
+ {program} [options] <source> <destination>
100
97
 
101
98
  Arguments:
102
99
  <source> A local file or an authorized Node file endpoint.
@@ -108,7 +105,6 @@ Arguments:
108
105
  Options:
109
106
  -p Preserve source permissions and access/modification times.
110
107
  -q Suppress progress output (already the default).
111
- --env-file PATH Use the specified management configuration file.
112
108
  -h, --help Show help and exit; place before the first endpoint.
113
109
  -V, --version Print the CLI package version and exit; place before the first endpoint.
114
110
  -- Treat remaining arguments as literal paths.
@@ -155,22 +151,20 @@ or Server connection is required.`
155
151
  connected: 'connected',
156
152
  disconnected: 'disconnected',
157
153
  errors: Object.freeze({
154
+ sessionOutcomeUnknown:
155
+ 'The delivery outcome is unknown. Inspect Session state/history before retrying.',
156
+ sessionMessageInvalid:
157
+ 'The message must be non-empty and within the length limit; input files must be readable UTF-8.',
158
+ sessionImageInvalid:
159
+ 'Images must be readable PNG, JPEG, GIF or WebP files: at most 5 images, 5 MiB each and 10 MiB total.',
160
+ sessionWaitUnknown: 'The specified Run could not be confirmed. Inspect Session state/history.',
161
+ sessionWaitCancelled: 'Waiting was cancelled; the target Run was not stopped.',
162
+ sessionWaitTimeout: 'Waiting timed out; the target Run was not stopped.',
163
+
158
164
  userContextUnavailable: 'This Node session has no valid marctl user authorization.',
159
- configServerUrlMissing: 'MAR_CTL_SERVER_URL is not configured.',
160
- configKeyMissing: 'MAR_CTL_KEY is not configured.',
161
- configKeyInvalid: 'MAR_CTL_KEY contains invalid characters.',
162
165
  serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
163
166
  serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTPS or loopback HTTP.',
164
167
  serverUrlOrigin: 'MAR_CTL_SERVER_URL must be an origin without credentials or query data.',
165
- envFileMissing: 'The requested environment file was not found.',
166
- envFileRead: 'The environment file could not be read.',
167
- envFileSymlink: 'The environment file must not be a symbolic link. Use chmod 600.',
168
- envFileType: 'The environment file must be a regular file. Use chmod 600.',
169
- envFilePermissions:
170
- 'The environment file must be owned by the current user and have mode 0600 or 0400. Use chmod 600.',
171
- envFileEntry: 'Invalid environment entry.',
172
- envFileUnsupported: 'Unsupported environment variable.',
173
- envFileQuoted: 'Invalid quoted environment value.',
174
168
  fetchUnavailable: 'Fetch is unavailable in this Node.js runtime.',
175
169
  serverInvalidNodes: 'The server returned an invalid node list.',
176
170
  serverInvalidManifest: 'The server returned an invalid node manifest.',
@@ -212,7 +206,6 @@ or Server connection is required.`
212
206
  terminalDirectUnsafeFallback:
213
207
  'Direct terminal setup could not be safely canceled after OPEN; relay fallback was skipped.',
214
208
  invalidArguments: 'Invalid command arguments.',
215
- envFileValueRequired: 'A value is required for --env-file.',
216
209
  sshOptionsUnsupported: 'SSH options are not supported; use marssh <host>.',
217
210
  scpOptionsUnsupported: 'This SCP option is not supported by the Node Terminal transport.',
218
211
  commandFailed: 'The command failed.'
@@ -1,9 +1,6 @@
1
- const configurationHelp = `配置:
2
- MAR 打开的终端和普通 Workspace Agent Run 使用当前用户的节点权限,定时任务使用任务 owner 的权限;
3
- 这些上下文无需管理密钥。
4
- 独立使用时读取 ~/.marctl/default.env(Windows 为 %HOME%\\.marctl\\default.env,
5
- HOME 未设置时使用系统主目录),也可用 --env-file 指定文件。
6
- 文件须包含 MAR_CTL_SERVER_URL 和 MAR_CTL_KEY。不要通过命令参数传递密钥。`;
1
+ const configurationHelp = `授权:
2
+ 仅 MAR 打开的终端和普通 Workspace Agent Run 可访问节点,定时任务使用任务 owner 的权限。
3
+ MAR 自动提供短期用户凭证;不支持外部终端或持久管理员密钥。`;
7
4
 
8
5
  export default Object.freeze({
9
6
  help: `marctl — MyAgentRoam 命令行工具
@@ -13,6 +10,7 @@ export default Object.freeze({
13
10
  marctl help [子命令]
14
11
 
15
12
  子命令:
13
+ sessions 查看和协调当前工作区中的会话。
16
14
  nodes 列出有权限的节点及连接状态。
17
15
  ssh 打开节点终端或执行远程命令。
18
16
  scp 在当前机器与节点之间、节点之间复制普通文件。
@@ -20,7 +18,6 @@ export default Object.freeze({
20
18
  help 显示工具或指定子命令的帮助。
21
19
 
22
20
  选项:
23
- --env-file PATH 使用指定的管理配置文件。
24
21
  -h, --help 显示帮助后退出。
25
22
  -V, --version 显示 CLI 包版本后退出。
26
23
 
@@ -40,23 +37,23 @@ ${configurationHelp}
40
37
 
41
38
  说明:
42
39
  帮助和版本查询离线可用,无需配置或授权。
43
- 本机帮助、版本选项须放在节点或文件端点前;--env-file 也可放在子命令后。
40
+ 本机帮助、版本选项须放在节点或文件端点前。
44
41
  快捷命令仅显示对应操作的帮助。用 marctl help <子命令> 查看详细说明。
45
42
  远程访问使用授权的 Node Terminal 传输,不是系统 SSH 或 SCP 协议。`,
46
43
  commandHelp: Object.freeze({
44
+ sessions:
45
+ '{program} — 协调当前 MAR 工作区中的会话\n\n用法:\n {program} current [--json]\n {program} list [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} show <session-id> [--json]\n {program} history <session-id> [--cursor <cursor>] [--limit <1..100>] [--json]\n {program} start --runner <runner> [-m <text> | --message-file <file|->] [--image <path> ...] [--model <id>] [--title <name>] [--client-message-id <id>] [--json]\n {program} send <session-id> [-m <text> | --message-file <file|->] [--image <path> ...] [--delivery send|queue|replace-current] [--client-message-id <id>] [--json]\n {program} stop <session-id> [--run <run-id>] [--json]\n {program} goal get|clear <session-id> [--json]\n {program} goal set <session-id> (-m <text> | --message-file <file|->) [--json]\n {program} wait <session-id> --run <run-id> --timeout <30s|5m> [--json]\n\nRunner:mar-agent、codex、claude-code、opencode,操作以实际能力为准。\n-m / --message 直接传文本,与 --message-file 互斥;文件用 UTF-8,- 从 stdin 读取。\nstart/send 可重复 --image 传 PNG/JPEG/GIF/WebP 图片,也可只发送图片。\n最多 5 张,单张 5 MiB、合计 10 MiB;goal set 只接受文本。\nstart/send 返回 clientMessageId。\n凭据自签发起 24 小时到期,不自动续签。MAR 绑定当前 Node/Workspace,\n不提供覆盖范围的参数。send 默认不打断;排队回执的 runId 可能属于\n其他活动输入,需按 queueItemId/clientMessageId 与可见历史核对。\nwait 通过 Watch 等待指定 Run;超时不会停止任务,Run 成功不等于业务完成。\nWorker 完成不自动唤醒 Coordinator。投递结果未知时先查状态/历史,不盲目重发。\n按任务需要创建会话,例如 --title "fixer#42 Handle empty totals" 或\n--title "reviewer#42 Review empty totals";标题不是寻址身份,保存返回的会话 ID。\n角色和任务要求放入首条普通消息。会话共享文件与 Git 索引,不自动隔离工作目录或维护池。\n\n退出码:1 失败,2 参数,3 授权,4 不支持,5 超时,6 结果未知,130 取消等待。\n--json 只输出一个结果或错误对象。',
47
46
  nodes: `{program} — 列出有权限的节点
48
47
 
49
48
  用法:
50
- {program} [--env-file PATH] [选项]
49
+ {program} [选项]
51
50
 
52
51
  选项:
53
- --env-file PATH 使用指定的管理配置文件。
54
52
  -h, --help 显示帮助后退出。
55
53
  -V, --version 显示 CLI 包版本后退出。
56
54
 
57
55
  示例:
58
56
  {program}
59
- {program} --env-file ~/.marctl/production.env
60
57
 
61
58
  输出:
62
59
  以制表符分隔的节点 ID、名称、状态、平台列。
@@ -66,14 +63,13 @@ ${configurationHelp}`,
66
63
  ssh: `{program} — 打开终端或执行远程命令
67
64
 
68
65
  用法:
69
- {program} [--env-file PATH] [选项] <节点> [command [arg...]]
66
+ {program} [选项] <节点> [command [arg...]]
70
67
 
71
68
  参数:
72
69
  <节点> 有权限的节点 ID,或精确、唯一的节点名称。
73
70
  [command ...] 交给远端 Shell 的命令;省略时打开交互 Shell。
74
71
 
75
72
  选项(放在节点前):
76
- --env-file PATH 使用指定的管理配置文件。
77
73
  -h, --help 显示帮助后退出。
78
74
  -V, --version 显示 CLI 包版本后退出。
79
75
 
@@ -94,7 +90,7 @@ ${configurationHelp}`,
94
90
  scp: `{program} — 复制普通文件
95
91
 
96
92
  用法:
97
- {program} [--env-file PATH] [选项] <源> <目标>
93
+ {program} [选项] <源> <目标>
98
94
 
99
95
  参数:
100
96
  <源> 本机文件或有权限的节点文件端点。
@@ -106,7 +102,6 @@ ${configurationHelp}`,
106
102
  选项:
107
103
  -p 保留源文件权限及访问、修改时间。
108
104
  -q 不显示进度(默认已不显示)。
109
- --env-file PATH 使用指定的管理配置文件。
110
105
  -h, --help 显示帮助后退出,须放在第一个文件端点前。
111
106
  -V, --version 显示 CLI 包版本后退出,须放在第一个文件端点前。
112
107
  -- 后续参数按字面路径处理。
@@ -150,21 +145,18 @@ ${configurationHelp}`,
150
145
  connected: '在线',
151
146
  disconnected: '离线',
152
147
  errors: Object.freeze({
148
+ sessionOutcomeUnknown: '投递结果未知,请先查看会话状态和历史,不要盲目重试。',
149
+ sessionMessageInvalid: '消息不能为空或超过长度限制;输入文件必须是可读取的 UTF-8 文本。',
150
+ sessionImageInvalid:
151
+ '图片必须是可读取的 PNG、JPEG、GIF 或 WebP 文件:最多 5 张,单张 5 MiB,合计 10 MiB。',
152
+ sessionWaitUnknown: '无法确认指定 Run,请查看会话状态和历史。',
153
+ sessionWaitCancelled: '已取消等待,未停止目标 Run。',
154
+ sessionWaitTimeout: '等待超时,未停止目标 Run。',
155
+
153
156
  userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
154
- configServerUrlMissing: '未配置 MAR_CTL_SERVER_URL。',
155
- configKeyMissing: '未配置 MAR_CTL_KEY。',
156
- configKeyInvalid: 'MAR_CTL_KEY 包含无效字符。',
157
157
  serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
158
158
  serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
159
159
  serverUrlOrigin: 'MAR_CTL_SERVER_URL 必须是不含凭据或查询参数的 Origin。',
160
- envFileMissing: '指定的环境文件不存在。',
161
- envFileRead: '无法读取环境文件。',
162
- envFileSymlink: '环境文件不能是符号链接。请使用 chmod 600。',
163
- envFileType: '环境文件必须是普通文件。请使用 chmod 600。',
164
- envFilePermissions: '环境文件必须归当前用户所有,权限必须为 0600 或 0400。请使用 chmod 600。',
165
- envFileEntry: '环境文件条目无效。',
166
- envFileUnsupported: '环境变量不受支持。',
167
- envFileQuoted: '带引号的环境变量值无效。',
168
160
  fetchUnavailable: '当前 Node.js 运行时没有 Fetch。',
169
161
  serverInvalidNodes: 'Server 返回的节点列表无效。',
170
162
  serverInvalidManifest: 'Server 返回的节点清单无效。',
@@ -203,7 +195,6 @@ ${configurationHelp}`,
203
195
  terminalSessionFailed: '终端会话失败。',
204
196
  terminalDirectUnsafeFallback: 'OPEN 后无法安全取消直连终端,已跳过 relay 回退。',
205
197
  invalidArguments: '命令参数无效。',
206
- envFileValueRequired: '--env-file 必须提供值。',
207
198
  sshOptionsUnsupported: '不支持 SSH 选项,请使用 marssh <节点>。',
208
199
  scpOptionsUnsupported: 'Node Terminal 传输不支持此 SCP 选项。',
209
200
  terminalBinaryUnavailable: '二进制复制需要支持 pipe 终端的 Server 与 Node。',
@@ -0,0 +1,386 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { open } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
4
+ import { MarctlError } from './config.mjs';
5
+ import { createTranslator } from './i18n/index.mjs';
6
+
7
+ const terminal = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED']);
8
+ const commands = {
9
+ current: [],
10
+ list: ['cursor', 'limit'],
11
+ show: [],
12
+ history: ['cursor', 'limit'],
13
+ start: ['runner', 'model', 'title', 'message', 'message-file', 'image', 'client-message-id'],
14
+ send: ['message', 'message-file', 'image', 'delivery', 'client-message-id'],
15
+ stop: ['run'],
16
+ 'goal.get': [],
17
+ 'goal.set': ['message', 'message-file'],
18
+ 'goal.clear': [],
19
+ wait: ['run', 'timeout']
20
+ };
21
+ const fieldNames = {
22
+ 'message-file': 'messageFile',
23
+ 'client-message-id': 'clientMessageId',
24
+ run: 'runId',
25
+ timeout: 'timeoutMs',
26
+ image: 'images'
27
+ };
28
+
29
+ export function parseSessionArgs(argv, translate = createTranslator()) {
30
+ const invalid = () =>
31
+ new MarctlError(translate('errors.invalidArguments'), { code: 'MARCTL_SESSION_INPUT_INVALID' });
32
+ if (
33
+ argv.length === 0 ||
34
+ argv.some(
35
+ (value, index) =>
36
+ (value === '--help' || value === '-h') && !['-m', '--message'].includes(argv[index - 1])
37
+ )
38
+ )
39
+ return { command: 'sessions', help: true };
40
+ const input = [...argv];
41
+ let action = input.shift();
42
+ if (action === 'goal') action += '.' + input.shift();
43
+ if (!Object.hasOwn(commands, action)) throw invalid();
44
+ const args = { command: 'sessions', action };
45
+ if (!['current', 'list', 'start'].includes(action)) {
46
+ const sessionId = input.shift();
47
+ if (!sessionId || sessionId.startsWith('-') || sessionId.length > 512) throw invalid();
48
+ args.sessionId = sessionId;
49
+ }
50
+ while (input.length > 0) {
51
+ const rawFlag = input.shift();
52
+ const flag = rawFlag === '-m' ? '--message' : rawFlag;
53
+ if (flag === '--json' && args.json === undefined) {
54
+ args.json = true;
55
+ continue;
56
+ }
57
+ const name = flag.startsWith('--') ? flag.slice(2) : '';
58
+ const key = fieldNames[name] ?? name;
59
+ if (!commands[action].includes(name) || (name !== 'image' && Object.hasOwn(args, key)))
60
+ throw invalid();
61
+ const value = input.shift();
62
+ if (value === undefined || value === '' || (name !== 'message' && value.startsWith('--')))
63
+ throw invalid();
64
+ if (name === 'image') {
65
+ (args.images ??= []).push(value);
66
+ if (args.images.length > 5) throw invalid();
67
+ } else if (name === 'limit') {
68
+ if (!/^[1-9][0-9]*$/u.test(value) || Number(value) > 100) throw invalid();
69
+ args[key] = Number(value);
70
+ } else if (name === 'timeout') {
71
+ const match = /^([1-9][0-9]*)(ms|s|m)$/u.exec(value);
72
+ const ms = match === null ? 0 : Number(match[1]) * { ms: 1, s: 1000, m: 60000 }[match[2]];
73
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > 3600000) throw invalid();
74
+ args[key] = ms;
75
+ } else args[key] = value;
76
+ }
77
+ if (args.message !== undefined && args.messageFile !== undefined) throw invalid();
78
+ if (
79
+ ['start', 'send', 'goal.set'].includes(action) &&
80
+ args.messageFile === undefined &&
81
+ args.message === undefined &&
82
+ !args.images?.length
83
+ )
84
+ throw invalid();
85
+ if (
86
+ action === 'start' &&
87
+ !['mar-agent', 'codex', 'claude-code', 'opencode'].includes(args.runner)
88
+ )
89
+ throw invalid();
90
+ if (action === 'wait' && (args.runId === undefined || args.timeoutMs === undefined))
91
+ throw invalid();
92
+ if (args.delivery !== undefined && !['send', 'queue', 'replace-current'].includes(args.delivery))
93
+ throw invalid();
94
+ if (args.title !== undefined && args.title.length > 160) throw invalid();
95
+ return args;
96
+ }
97
+
98
+ export async function runSessionCommand({ args, client, env, stdin, WebSocketClass, translate }) {
99
+ if (args.action === 'wait') {
100
+ const controller = new AbortController();
101
+ const cancel = () => controller.abort();
102
+ process.once('SIGINT', cancel);
103
+ process.once('SIGTERM', cancel);
104
+ try {
105
+ return await waitForSessionRun({
106
+ client,
107
+ sessionId: args.sessionId,
108
+ runId: args.runId,
109
+ timeoutMs: args.timeoutMs,
110
+ WebSocketClass,
111
+ translate,
112
+ signal: controller.signal
113
+ });
114
+ } finally {
115
+ process.off('SIGINT', cancel);
116
+ process.off('SIGTERM', cancel);
117
+ }
118
+ }
119
+ const body = { action: args.action };
120
+ for (const name of ['sessionId', 'runner', 'model', 'title', 'cursor', 'limit', 'runId'])
121
+ if (args[name] !== undefined) body[name] = args[name];
122
+ if (args.action === 'current' && env.MAR_CTL_SESSION_ID) body.sessionId = env.MAR_CTL_SESSION_ID;
123
+ if (args.messageFile !== undefined || args.message !== undefined)
124
+ body.content = await messageContent(
125
+ args,
126
+ stdin,
127
+ args.action === 'goal.set' ? 20000 : 100000,
128
+ translate
129
+ );
130
+ if (args.images !== undefined) {
131
+ body.attachments = await imageAttachments(args.images, translate);
132
+ body.content ??= '';
133
+ }
134
+ if (args.action === 'start' || args.action === 'send')
135
+ body.clientMessageId = args.clientMessageId ?? randomUUID();
136
+ if (args.action === 'send')
137
+ body.deliveryIntent = { send: 'SEND', queue: 'QUEUE', 'replace-current': 'REPLACE_CURRENT' }[
138
+ args.delivery ?? 'send'
139
+ ];
140
+ try {
141
+ const result = await client.sessionCommand(body);
142
+ return {
143
+ ...result,
144
+ ...(typeof result?.session?.id === 'string' ? { sessionId: result.session.id } : {}),
145
+ ...(typeof result?.run?.id === 'string' ? { runId: result.run.id } : {}),
146
+ ...(body.clientMessageId === undefined ? {} : { clientMessageId: body.clientMessageId })
147
+ };
148
+ } catch (error) {
149
+ if (body.clientMessageId !== undefined) error.clientMessageId = body.clientMessageId;
150
+ if (
151
+ ['start', 'send', 'stop', 'goal.set', 'goal.clear'].includes(args.action) &&
152
+ ['MARCTL_REQUEST_TIMEOUT', 'MARCTL_SERVER_UNREACHABLE', 'MARCTL_RESPONSE_INVALID'].includes(
153
+ error.code
154
+ )
155
+ ) {
156
+ error.code = 'MARCTL_OUTCOME_UNKNOWN';
157
+ error.message = translate('errors.sessionOutcomeUnknown');
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ async function messageContent({ message, messageFile: path }, stdin, limit, translate) {
164
+ let file;
165
+ try {
166
+ let content = message;
167
+ if (content === undefined) {
168
+ const input =
169
+ path === '-'
170
+ ? stdin
171
+ : (file = await open(path, 'r')).createReadStream({ autoClose: false });
172
+ const parts = [];
173
+ let bytes = 0;
174
+ for await (const part of input) {
175
+ const chunk = Buffer.from(part);
176
+ bytes += chunk.length;
177
+ if (bytes > limit * 4) throw new Error('MESSAGE_TOO_LONG');
178
+ parts.push(chunk);
179
+ }
180
+ content = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
181
+ }
182
+ if (content.trim().length === 0 || content.length > limit) throw new Error('MESSAGE_INVALID');
183
+ return content;
184
+ } catch {
185
+ throw new MarctlError(translate('errors.sessionMessageInvalid'), {
186
+ code: 'MARCTL_SESSION_INPUT_INVALID'
187
+ });
188
+ } finally {
189
+ await file?.close();
190
+ }
191
+ }
192
+
193
+ async function imageAttachments(paths, translate) {
194
+ const attachments = [];
195
+ let total = 0;
196
+ for (const path of paths) {
197
+ let file;
198
+ try {
199
+ file = await open(path, 'r');
200
+ const info = await file.stat();
201
+ if (!info.isFile() || info.size === 0 || info.size > 5 * 1024 * 1024)
202
+ throw new Error('IMAGE_INVALID');
203
+ const parts = [];
204
+ let size = 0;
205
+ for await (const part of file.createReadStream({ autoClose: false })) {
206
+ size += part.length;
207
+ total += part.length;
208
+ if (size > 5 * 1024 * 1024 || total > 10 * 1024 * 1024) throw new Error('IMAGE_TOO_LARGE');
209
+ parts.push(part);
210
+ }
211
+ const data = Buffer.concat(parts);
212
+ const mime = imageMime(data);
213
+ if (mime === undefined) throw new Error('IMAGE_INVALID');
214
+ attachments.push({ name: basename(path), mime, dataBase64: data.toString('base64') });
215
+ } catch {
216
+ throw new MarctlError(translate('errors.sessionImageInvalid'), {
217
+ code: 'MARCTL_SESSION_INPUT_INVALID'
218
+ });
219
+ } finally {
220
+ await file?.close();
221
+ }
222
+ }
223
+ return attachments;
224
+ }
225
+
226
+ function imageMime(data) {
227
+ if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
228
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg';
229
+ if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii'))) return 'image/gif';
230
+ if (
231
+ data.subarray(0, 4).toString('ascii') === 'RIFF' &&
232
+ data.subarray(8, 12).toString('ascii') === 'WEBP'
233
+ )
234
+ return 'image/webp';
235
+ }
236
+
237
+ /** One CLI-owned Workbench connection and one existing Session Watch, never history polling. */
238
+ export async function waitForSessionRun({
239
+ client,
240
+ sessionId,
241
+ runId,
242
+ timeoutMs,
243
+ WebSocketClass,
244
+ translate = createTranslator(),
245
+ signal
246
+ }) {
247
+ const socket = client.openWorkbenchWebSocket(WebSocketClass ?? (await import('ws')).default);
248
+ const watchId = randomUUID();
249
+ let connectionId;
250
+ let renewal;
251
+ let settled = false;
252
+ let subscribed = false;
253
+ let accept;
254
+ let rejectResult;
255
+ const result = new Promise((resolve, fail) => {
256
+ accept = resolve;
257
+ rejectResult = fail;
258
+ });
259
+ const reject = (error) => {
260
+ if (settled) return;
261
+ settled = true;
262
+ rejectResult(error);
263
+ };
264
+ const fail = (code, key, options = {}) =>
265
+ reject(new MarctlError(translate(key), { ...options, code }));
266
+ const command = (mode) =>
267
+ client.sessionCommand({
268
+ action: 'watch',
269
+ sessionId,
270
+ workbenchConnectionId: connectionId,
271
+ watchId,
272
+ mode
273
+ });
274
+ const scheduleRenewal = (expiresAt) => {
275
+ clearTimeout(renewal);
276
+ const remaining = expiresAt - Date.now();
277
+ if (!Number.isFinite(remaining) || remaining <= 0) {
278
+ fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
279
+ return;
280
+ }
281
+ renewal = setTimeout(
282
+ () => {
283
+ command('renew')
284
+ .then((ack) => {
285
+ if (!settled) scheduleRenewal(ack?.expiresAt);
286
+ })
287
+ .catch((error) => {
288
+ if ([401, 403].includes(error.statusCode)) reject(error);
289
+ else fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
290
+ });
291
+ },
292
+ Math.max(50, Math.floor(remaining / 2))
293
+ );
294
+ };
295
+ const check = (run) => {
296
+ if (run?.id === runId && run.sessionId === sessionId && terminal.has(run.status) && !settled) {
297
+ settled = true;
298
+ accept({ run });
299
+ }
300
+ };
301
+ const onMessage = (raw) => {
302
+ if (settled) return;
303
+ let message;
304
+ try {
305
+ message = JSON.parse(String(raw));
306
+ } catch {
307
+ return;
308
+ }
309
+ if (message?.type === 'watch.event') message = message.event;
310
+ if (!message || typeof message !== 'object') return;
311
+ if (
312
+ message.type === 'workbench.ready' &&
313
+ connectionId === undefined &&
314
+ typeof message.workbenchConnectionId === 'string'
315
+ ) {
316
+ connectionId = message.workbenchConnectionId;
317
+ subscribed = true;
318
+ command('initial').catch(reject);
319
+ }
320
+ if (message.watchId !== watchId) return;
321
+ if (message.type === 'watch.failed') {
322
+ fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
323
+ return;
324
+ }
325
+ if (message.type === 'watch.session.snapshot') {
326
+ const runs = message.payload?.runs;
327
+ if (!Array.isArray(runs)) {
328
+ fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
329
+ return;
330
+ }
331
+ const run = runs.find((value) => value.id === runId && value.sessionId === sessionId);
332
+ if (run === undefined) {
333
+ fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
334
+ return;
335
+ }
336
+ check(run);
337
+ } else if (message.type === 'watch.session.event') check(message.event?.payload?.run);
338
+ if (!settled && message.expiresAt !== undefined) scheduleRenewal(message.expiresAt);
339
+ };
340
+ const onClose = (code, reason) => {
341
+ if (code === 1008 && String(reason) === 'UNAUTHENTICATED')
342
+ fail('UNAUTHENTICATED', 'errors.userContextUnavailable', { statusCode: 401 });
343
+ else fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown');
344
+ };
345
+ const onOpen = () => {
346
+ try {
347
+ socket.send(JSON.stringify({ type: 'resume' }));
348
+ } catch {
349
+ onClose();
350
+ }
351
+ };
352
+ const onAbort = () => fail('MARCTL_WAIT_CANCELLED', 'errors.sessionWaitCancelled');
353
+ const timer = setTimeout(
354
+ () => fail('MARCTL_WAIT_TIMEOUT', 'errors.sessionWaitTimeout'),
355
+ timeoutMs
356
+ );
357
+ socket.on('open', onOpen);
358
+ socket.on('message', onMessage);
359
+ socket.on('error', onClose);
360
+ socket.on('close', onClose);
361
+ signal?.addEventListener('abort', onAbort, { once: true });
362
+ if (signal?.aborted) onAbort();
363
+ try {
364
+ return await result;
365
+ } finally {
366
+ settled = true;
367
+ clearTimeout(timer);
368
+ clearTimeout(renewal);
369
+ signal?.removeEventListener('abort', onAbort);
370
+ if (subscribed) await command('stop').catch(() => undefined);
371
+ socket.off('open', onOpen);
372
+ socket.off('message', onMessage);
373
+ socket.off('close', onClose);
374
+ socket.close();
375
+ }
376
+ }
377
+
378
+ export function sessionExitCode(error) {
379
+ if (error.code === 'MARCTL_SESSION_INPUT_INVALID') return 2;
380
+ if ([401, 403].includes(error.statusCode)) return 3;
381
+ if (error.code?.includes('UNSUPPORTED')) return 4;
382
+ if (error.code === 'MARCTL_WAIT_TIMEOUT' || error.code === 'MARCTL_REQUEST_TIMEOUT') return 5;
383
+ if (['MARCTL_OUTCOME_UNKNOWN', 'MARCTL_WAIT_UNKNOWN'].includes(error.code)) return 6;
384
+ if (error.code === 'MARCTL_WAIT_CANCELLED') return 130;
385
+ return 1;
386
+ }