@myagentroam/marctl 0.10.3 → 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.3",
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,
@@ -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,6 +141,7 @@ export function executableArgs(executable, args) {
110
141
  }
111
142
 
112
143
  export function parseArgs(argv, translate = createTranslator()) {
144
+ if (argv[0] === 'sessions') return parseSessionArgs(argv.slice(1), translate);
113
145
  let command;
114
146
  let nodeId;
115
147
  const remoteArgs = [];
@@ -141,6 +173,7 @@ export function parseArgs(argv, translate = createTranslator()) {
141
173
  argument !== 'nodes' &&
142
174
  argument !== 'ssh' &&
143
175
  argument !== 'scp' &&
176
+ argument !== 'sessions' &&
144
177
  argument !== 'version'
145
178
  )
146
179
  throw unsupportedSshOption(argument, translate);
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
@@ -4,6 +4,11 @@ export class MarctlError extends Error {
4
4
  constructor(message, options = {}) {
5
5
  super(message, options);
6
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;
7
12
  }
8
13
  }
9
14
 
@@ -16,7 +21,10 @@ export async function loadConfig({ env = process.env, translate = createTranslat
16
21
  token === undefined ||
17
22
  !/^marctl_user_[A-Za-z0-9_-]{43}$/u.test(token)
18
23
  )
19
- throw new MarctlError(translate('errors.userContextUnavailable'));
24
+ throw new MarctlError(translate('errors.userContextUnavailable'), {
25
+ code: 'UNAUTHENTICATED',
26
+ statusCode: 401
27
+ });
20
28
  return Object.freeze({ serverUrl: validateServerUrl(serverUrl, translate), key: token });
21
29
  }
22
30
 
@@ -11,6 +11,7 @@ Usage:
11
11
  marctl help [command]
12
12
 
13
13
  Commands:
14
+ sessions Inspect and coordinate Sessions in the current Workspace.
14
15
  nodes List accessible Nodes and their connection status.
15
16
  ssh Open a Node terminal or execute a remote command.
16
17
  scp Copy a regular file between this machine and Nodes, or between Nodes.
@@ -41,6 +42,8 @@ Notes:
41
42
  Each alias shows only its own command help. Use marctl help <command> for details.
42
43
  Remote access uses authorized Node Terminal transport, not system SSH or SCP.`,
43
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.',
44
47
  nodes: `{program} — List accessible Nodes
45
48
 
46
49
  Usage:
@@ -148,6 +151,16 @@ or Server connection is required.`
148
151
  connected: 'connected',
149
152
  disconnected: 'disconnected',
150
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
+
151
164
  userContextUnavailable: 'This Node session has no valid marctl user authorization.',
152
165
  serverUrlInvalid: 'MAR_CTL_SERVER_URL is invalid.',
153
166
  serverUrlTransport: 'MAR_CTL_SERVER_URL must use HTTPS or loopback HTTP.',
@@ -10,6 +10,7 @@ export default Object.freeze({
10
10
  marctl help [子命令]
11
11
 
12
12
  子命令:
13
+ sessions 查看和协调当前工作区中的会话。
13
14
  nodes 列出有权限的节点及连接状态。
14
15
  ssh 打开节点终端或执行远程命令。
15
16
  scp 在当前机器与节点之间、节点之间复制普通文件。
@@ -40,6 +41,8 @@ ${configurationHelp}
40
41
  快捷命令仅显示对应操作的帮助。用 marctl help <子命令> 查看详细说明。
41
42
  远程访问使用授权的 Node Terminal 传输,不是系统 SSH 或 SCP 协议。`,
42
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 只输出一个结果或错误对象。',
43
46
  nodes: `{program} — 列出有权限的节点
44
47
 
45
48
  用法:
@@ -142,6 +145,14 @@ ${configurationHelp}`,
142
145
  connected: '在线',
143
146
  disconnected: '离线',
144
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
+
145
156
  userContextUnavailable: '此 Node 会话没有有效的 marctl 用户授权。',
146
157
  serverUrlInvalid: 'MAR_CTL_SERVER_URL 无效。',
147
158
  serverUrlTransport: 'MAR_CTL_SERVER_URL 必须使用 HTTPS 或回环 HTTP。',
@@ -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
+ }