@myagentroam/marctl 0.11.3 → 0.11.5
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/package.json +1 -1
- package/src/cli.mjs +58 -33
- package/src/client.mjs +26 -6
- package/src/config.mjs +1 -11
- package/src/direct-control.mjs +225 -0
- package/src/direct-errors.mjs +48 -0
- package/src/direct-process.mjs +40 -17
- package/src/direct-transport.mjs +235 -0
- package/src/direct.mjs +102 -494
- package/src/errors.mjs +11 -0
- package/src/i18n/en-US.mjs +62 -119
- package/src/i18n/zh-CN.mjs +57 -111
- package/src/scp-channel.mjs +99 -0
- package/src/scp-copy.mjs +46 -0
- package/src/scp-endpoint.mjs +29 -0
- package/src/scp-protocol.mjs +2 -0
- package/src/scp-workers.mjs +179 -0
- package/src/scp.mjs +26 -344
- package/src/session-args.mjs +247 -0
- package/src/session-input.mjs +88 -0
- package/src/session-prepare.mjs +99 -0
- package/src/session-target.mjs +26 -0
- package/src/session-wait.mjs +232 -0
- package/src/sessions.mjs +45 -573
- package/src/terminal-frame.mjs +48 -0
- package/src/terminal-interaction.mjs +177 -0
- package/src/terminal-race.mjs +117 -0
- package/src/terminal-relay.mjs +305 -0
- package/src/terminal.mjs +50 -603
package/src/sessions.mjs
CHANGED
|
@@ -1,277 +1,61 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { MarctlError } from './errors.mjs';
|
|
2
|
+
import { sessionTarget } from './session-target.mjs';
|
|
3
|
+
import {
|
|
4
|
+
isAgentNetworkAction,
|
|
5
|
+
prepareAgentNetworkRequest,
|
|
6
|
+
prepareSessionRequest
|
|
7
|
+
} from './session-prepare.mjs';
|
|
8
|
+
import { waitForSessionRun } from './session-wait.mjs';
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'schedule.create',
|
|
19
|
-
'schedule.update'
|
|
20
|
-
]);
|
|
21
|
-
const commands = {
|
|
22
|
-
current: [],
|
|
23
|
-
list: ['cursor', 'limit', 'query', 'parent'],
|
|
24
|
-
graph: ['limit'],
|
|
25
|
-
show: [],
|
|
26
|
-
history: ['cursor', 'limit'],
|
|
27
|
-
start: [
|
|
28
|
-
'runner',
|
|
29
|
-
'model',
|
|
30
|
-
'effort',
|
|
31
|
-
'title',
|
|
32
|
-
'message',
|
|
33
|
-
'message-file',
|
|
34
|
-
'image',
|
|
35
|
-
'client-message-id'
|
|
36
|
-
],
|
|
37
|
-
config: ['model', 'effort'],
|
|
38
|
-
rename: ['title'],
|
|
39
|
-
'prompt.get': [],
|
|
40
|
-
'prompt.set': ['message', 'message-file'],
|
|
41
|
-
'prompt.clear': [],
|
|
42
|
-
'approval.decide': ['run', 'approval', 'decision'],
|
|
43
|
-
'schedule.list': ['cursor', 'limit'],
|
|
44
|
-
'schedule.get': [],
|
|
45
|
-
'schedule.create': ['cron', 'message', 'message-file'],
|
|
46
|
-
'schedule.update': ['cron', 'message', 'message-file', 'expected-revision'],
|
|
47
|
-
send: ['message', 'message-file', 'image', 'steer', 'queue', 'client-message-id'],
|
|
48
|
-
stop: ['run'],
|
|
49
|
-
'goal.get': [],
|
|
50
|
-
'goal.set': ['message', 'message-file'],
|
|
51
|
-
'goal.clear': [],
|
|
52
|
-
wait: ['run', 'timeout', 'until']
|
|
53
|
-
};
|
|
54
|
-
const fieldNames = {
|
|
55
|
-
parent: 'parentSessionId',
|
|
56
|
-
'expected-revision': 'expectedRevision',
|
|
57
|
-
'message-file': 'messageFile',
|
|
58
|
-
'client-message-id': 'clientMessageId',
|
|
59
|
-
run: 'runId',
|
|
60
|
-
approval: 'approvalId',
|
|
61
|
-
timeout: 'timeoutMs',
|
|
62
|
-
image: 'images'
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export function parseSessionArgs(argv, translate = createTranslator()) {
|
|
66
|
-
const invalid = (key = 'errors.invalidArguments', details) =>
|
|
67
|
-
new MarctlError(translate(key), {
|
|
68
|
-
code: 'MARCTL_SESSION_INPUT_INVALID',
|
|
69
|
-
details:
|
|
70
|
-
details ??
|
|
71
|
-
(argv[0] === 'prompt'
|
|
72
|
-
? 'Use prompt get|clear [session-id], or prompt set [session-id] with exactly one of -m/--message or --message-file.'
|
|
73
|
-
: argv[0] === 'approval'
|
|
74
|
-
? 'Use approval decide <session-id> --run <run-id> --approval <approval-id> --decision approve|reject.'
|
|
75
|
-
: undefined)
|
|
76
|
-
});
|
|
77
|
-
if (
|
|
78
|
-
argv.length === 0 ||
|
|
79
|
-
argv.some(
|
|
80
|
-
(value, index) =>
|
|
81
|
-
(value === '--help' || value === '-h') && !['-m', '--message'].includes(argv[index - 1])
|
|
82
|
-
)
|
|
83
|
-
)
|
|
84
|
-
return { command: 'sessions', help: true };
|
|
85
|
-
const input = [...argv];
|
|
86
|
-
let action = input.shift();
|
|
87
|
-
if (['goal', 'schedule', 'prompt', 'approval'].includes(action)) action += '.' + input.shift();
|
|
88
|
-
if (!Object.hasOwn(commands, action)) throw invalid();
|
|
89
|
-
const args = { command: 'sessions', action };
|
|
90
|
-
if (!['current', 'list', 'start', 'schedule.list'].includes(action)) {
|
|
91
|
-
if ((input[0] === undefined || input[0].startsWith('-')) && !currentSessionActions.has(action))
|
|
92
|
-
throw invalid();
|
|
93
|
-
if (input[0] !== undefined && !input[0].startsWith('-')) {
|
|
94
|
-
const sessionId = input.shift();
|
|
95
|
-
if (!sessionId || sessionId.length > 512) throw invalid();
|
|
96
|
-
args.sessionId = sessionId;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
while (input.length > 0) {
|
|
100
|
-
const rawFlag = input.shift();
|
|
101
|
-
const flag = rawFlag === '-m' ? '--message' : rawFlag;
|
|
102
|
-
if (flag === '--json' && args.json === undefined) {
|
|
103
|
-
args.json = true;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
if (action === 'send' && (flag === '--steer' || flag === '--queue')) {
|
|
107
|
-
const key = flag.slice(2);
|
|
108
|
-
if (Object.hasOwn(args, key)) throw invalid();
|
|
109
|
-
args[key] = true;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
const name = flag.startsWith('--') ? flag.slice(2) : '';
|
|
113
|
-
const key = fieldNames[name] ?? name;
|
|
114
|
-
if (!commands[action].includes(name) || (name !== 'image' && Object.hasOwn(args, key)))
|
|
115
|
-
throw invalid();
|
|
116
|
-
const value = input.shift();
|
|
117
|
-
if (
|
|
118
|
-
value === undefined ||
|
|
119
|
-
(value === '' && name !== 'effort') ||
|
|
120
|
-
(name !== 'message' && value.startsWith('--'))
|
|
121
|
-
)
|
|
122
|
-
throw invalid();
|
|
123
|
-
if (name === 'image') {
|
|
124
|
-
(args.images ??= []).push(value);
|
|
125
|
-
if (args.images.length > 5) throw invalid();
|
|
126
|
-
} else if (name === 'limit' || name === 'expected-revision') {
|
|
127
|
-
if (
|
|
128
|
-
!/^[1-9][0-9]*$/u.test(value) ||
|
|
129
|
-
!Number.isSafeInteger(Number(value)) ||
|
|
130
|
-
(name === 'limit' && Number(value) > 100)
|
|
131
|
-
)
|
|
132
|
-
throw invalid();
|
|
133
|
-
args[key] = Number(value);
|
|
134
|
-
} else if (name === 'timeout') {
|
|
135
|
-
const match = /^([1-9][0-9]*)(ms|s|m)$/u.exec(value);
|
|
136
|
-
const ms = match === null ? 0 : Number(match[1]) * { ms: 1, s: 1000, m: 60000 }[match[2]];
|
|
137
|
-
if (!Number.isSafeInteger(ms) || ms < 1 || ms > 3600000) throw invalid();
|
|
138
|
-
args[key] = ms;
|
|
139
|
-
} else args[key] = value;
|
|
140
|
-
}
|
|
141
|
-
if (action === 'send' && (args.steer === true) === (args.queue === true))
|
|
142
|
-
throw invalid('errors.sessionDeliveryRequired');
|
|
143
|
-
if (args.message !== undefined && args.messageFile !== undefined) throw invalid();
|
|
144
|
-
if (
|
|
145
|
-
['start', 'send', 'goal.set', 'schedule.create', 'prompt.set'].includes(action) &&
|
|
146
|
-
args.messageFile === undefined &&
|
|
147
|
-
args.message === undefined &&
|
|
148
|
-
!args.images?.length
|
|
149
|
-
)
|
|
150
|
-
throw invalid();
|
|
151
|
-
if (
|
|
152
|
-
action === 'start' &&
|
|
153
|
-
!['mar-agent', 'codex', 'claude-code', 'opencode'].includes(args.runner)
|
|
154
|
-
)
|
|
155
|
-
throw invalid();
|
|
156
|
-
if (action === 'wait' && (args.runId === undefined || args.timeoutMs === undefined))
|
|
157
|
-
throw invalid();
|
|
158
|
-
if (args.until !== undefined && args.until !== 'approval')
|
|
159
|
-
throw invalid(undefined, '--until: expected approval.');
|
|
160
|
-
if (
|
|
161
|
-
action === 'approval.decide' &&
|
|
162
|
-
(!args.runId || !args.approvalId || !['approve', 'reject'].includes(args.decision))
|
|
163
|
-
)
|
|
164
|
-
throw invalid(
|
|
165
|
-
undefined,
|
|
166
|
-
'Approval decisions require --run, --approval and --decision approve|reject.'
|
|
167
|
-
);
|
|
168
|
-
if (action === 'rename' && args.title !== undefined) args.title = args.title.trim();
|
|
169
|
-
if (args.title !== undefined && args.title.length > 160) throw invalid();
|
|
170
|
-
if (args.model !== undefined && (args.model.trim().length === 0 || args.model.length > 120))
|
|
171
|
-
throw invalid();
|
|
172
|
-
if (args.effort !== undefined && args.effort.length > 120) throw invalid();
|
|
173
|
-
if (action === 'config' && args.model === undefined && args.effort === undefined) throw invalid();
|
|
174
|
-
if (action === 'rename') {
|
|
175
|
-
if (args.title === undefined || args.title.trim().length === 0) throw invalid();
|
|
176
|
-
}
|
|
177
|
-
if (action === 'schedule.create' && args.cron === undefined) throw invalid();
|
|
178
|
-
if (args.cron !== undefined && (args.cron.trim().length === 0 || args.cron.length > 512))
|
|
179
|
-
throw invalid();
|
|
180
|
-
if (
|
|
181
|
-
action === 'schedule.update' &&
|
|
182
|
-
(args.expectedRevision === undefined ||
|
|
183
|
-
(args.cron === undefined && args.message === undefined && args.messageFile === undefined))
|
|
184
|
-
)
|
|
185
|
-
throw invalid();
|
|
186
|
-
if (args.query !== undefined && args.query.length > 160) throw invalid();
|
|
187
|
-
if (args.parentSessionId !== undefined && args.parentSessionId.length > 512) throw invalid();
|
|
188
|
-
return args;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export async function runSessionCommand({ args, client, env, stdin, WebSocketClass, translate }) {
|
|
192
|
-
const targeted = !['current', 'list', 'start', 'schedule.list'].includes(args.action);
|
|
10
|
+
export async function runSessionCommand({
|
|
11
|
+
args,
|
|
12
|
+
client,
|
|
13
|
+
env,
|
|
14
|
+
stdin,
|
|
15
|
+
WebSocketClass,
|
|
16
|
+
translate,
|
|
17
|
+
signal
|
|
18
|
+
}) {
|
|
19
|
+
args = { ...args, ...(args.images === undefined ? {} : { images: [...args.images] }) };
|
|
20
|
+
const targeted = sessionTarget[args.action] !== 'workspace';
|
|
193
21
|
const sessionId =
|
|
194
|
-
args.sessionId ??
|
|
195
|
-
|
|
22
|
+
args.sessionId ??
|
|
23
|
+
(sessionTarget[args.action] === 'current' ? env.MAR_CTL_SESSION_ID : undefined);
|
|
24
|
+
if (targeted && !sessionId && sessionTarget[args.action] !== 'optional')
|
|
196
25
|
throw new MarctlError(translate('errors.sessionContextRequired'), {
|
|
197
26
|
code: 'MARCTL_SESSION_CONTEXT_REQUIRED'
|
|
198
27
|
});
|
|
199
|
-
if (args.action === 'wait')
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sessionId,
|
|
208
|
-
runId: args.runId,
|
|
209
|
-
timeoutMs: args.timeoutMs,
|
|
210
|
-
until: args.until,
|
|
211
|
-
WebSocketClass,
|
|
212
|
-
translate,
|
|
213
|
-
signal: controller.signal
|
|
214
|
-
});
|
|
215
|
-
} finally {
|
|
216
|
-
process.off('SIGINT', cancel);
|
|
217
|
-
process.off('SIGTERM', cancel);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const body = { action: args.action };
|
|
221
|
-
for (const name of [
|
|
222
|
-
'sessionId',
|
|
223
|
-
'parentSessionId',
|
|
224
|
-
'runner',
|
|
225
|
-
'model',
|
|
226
|
-
'effort',
|
|
227
|
-
'title',
|
|
228
|
-
'cursor',
|
|
229
|
-
'limit',
|
|
230
|
-
'query',
|
|
231
|
-
'runId',
|
|
232
|
-
'approvalId',
|
|
233
|
-
'cron',
|
|
234
|
-
'expectedRevision'
|
|
235
|
-
])
|
|
236
|
-
if (args[name] !== undefined) body[name] = args[name];
|
|
237
|
-
if (targeted) body.sessionId = sessionId;
|
|
238
|
-
if (args.action === 'current' && env.MAR_CTL_SESSION_ID) body.sessionId = env.MAR_CTL_SESSION_ID;
|
|
239
|
-
if (args.action === 'start' && env.MAR_CTL_SESSION_ID)
|
|
240
|
-
body.parentSessionId = env.MAR_CTL_SESSION_ID;
|
|
241
|
-
if (['start', 'send'].includes(args.action) && env.MAR_CTL_SESSION_ID)
|
|
242
|
-
body.sourceSessionId = env.MAR_CTL_SESSION_ID;
|
|
243
|
-
if (args.messageFile !== undefined || args.message !== undefined)
|
|
244
|
-
body.content = await messageContent(
|
|
245
|
-
args,
|
|
246
|
-
stdin,
|
|
247
|
-
args.action === 'goal.set' ||
|
|
248
|
-
args.action.startsWith('schedule.') ||
|
|
249
|
-
args.action === 'prompt.set'
|
|
250
|
-
? 20000
|
|
251
|
-
: 100000,
|
|
28
|
+
if (args.action === 'wait' && args.runId !== undefined)
|
|
29
|
+
return waitForSessionRun({
|
|
30
|
+
client,
|
|
31
|
+
sessionId,
|
|
32
|
+
runId: args.runId,
|
|
33
|
+
timeoutMs: args.timeoutMs ?? 30000,
|
|
34
|
+
until: args.until,
|
|
35
|
+
WebSocketClass,
|
|
252
36
|
translate,
|
|
253
|
-
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
if (args.action === 'start' || args.action === 'send')
|
|
262
|
-
body.clientMessageId = args.clientMessageId ?? randomUUID();
|
|
263
|
-
if (args.action === 'send')
|
|
264
|
-
body.deliveryIntent = args.steer === true ? 'REPLACE_CURRENT' : 'QUEUE';
|
|
37
|
+
signal
|
|
38
|
+
});
|
|
39
|
+
const network = isAgentNetworkAction(args.action);
|
|
40
|
+
const body = network
|
|
41
|
+
? await prepareAgentNetworkRequest({ args, stdin, translate, sessionId })
|
|
42
|
+
: await prepareSessionRequest({ args, env, stdin, translate, sessionId, targeted });
|
|
43
|
+
const clientMessageId = network ? body.command.input.clientMessageId : body.clientMessageId;
|
|
265
44
|
try {
|
|
266
|
-
const
|
|
45
|
+
const requestedWait = args.timeoutMs ?? 30000;
|
|
46
|
+
const effectiveWait = requestedWait === 0 ? 0 : Math.max(10000, requestedWait);
|
|
47
|
+
const result = await client.sessionCommand(
|
|
48
|
+
body,
|
|
49
|
+
args.action === 'wait' ? { timeoutMs: effectiveWait + 10000, signal } : undefined
|
|
50
|
+
);
|
|
267
51
|
return {
|
|
268
52
|
...result,
|
|
269
53
|
...(typeof result?.session?.id === 'string' ? { sessionId: result.session.id } : {}),
|
|
270
54
|
...(typeof result?.run?.id === 'string' ? { runId: result.run.id } : {}),
|
|
271
|
-
...(
|
|
55
|
+
...(clientMessageId === undefined ? {} : { clientMessageId })
|
|
272
56
|
};
|
|
273
57
|
} catch (error) {
|
|
274
|
-
if (
|
|
58
|
+
if (clientMessageId !== undefined) error.clientMessageId = clientMessageId;
|
|
275
59
|
if (
|
|
276
60
|
[
|
|
277
61
|
'start',
|
|
@@ -298,318 +82,6 @@ export async function runSessionCommand({ args, client, env, stdin, WebSocketCla
|
|
|
298
82
|
}
|
|
299
83
|
}
|
|
300
84
|
|
|
301
|
-
async function messageContent(
|
|
302
|
-
{ message, messageFile: path },
|
|
303
|
-
stdin,
|
|
304
|
-
limit,
|
|
305
|
-
translate,
|
|
306
|
-
diagnostic = false
|
|
307
|
-
) {
|
|
308
|
-
let file;
|
|
309
|
-
try {
|
|
310
|
-
let content = message;
|
|
311
|
-
if (content === undefined) {
|
|
312
|
-
const input =
|
|
313
|
-
path === '-'
|
|
314
|
-
? stdin
|
|
315
|
-
: (file = await open(path, 'r')).createReadStream({ autoClose: false });
|
|
316
|
-
const parts = [];
|
|
317
|
-
let bytes = 0;
|
|
318
|
-
for await (const part of input) {
|
|
319
|
-
const chunk = Buffer.from(part);
|
|
320
|
-
bytes += chunk.length;
|
|
321
|
-
if (bytes > limit * 4) throw new Error('MESSAGE_TOO_LONG');
|
|
322
|
-
parts.push(chunk);
|
|
323
|
-
}
|
|
324
|
-
content = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
|
|
325
|
-
}
|
|
326
|
-
if (content.trim().length === 0 || content.length > limit) throw new Error('MESSAGE_INVALID');
|
|
327
|
-
return content;
|
|
328
|
-
} catch (error) {
|
|
329
|
-
throw new MarctlError(translate('errors.sessionMessageInvalid'), {
|
|
330
|
-
code: 'MARCTL_SESSION_INPUT_INVALID',
|
|
331
|
-
...(diagnostic
|
|
332
|
-
? {
|
|
333
|
-
details: `${path === undefined ? 'message' : path === '-' ? 'stdin' : 'message-file'}: provide non-blank UTF-8 text of at most ${limit} characters.${typeof error.code === 'string' && /^[A-Z_]+$/u.test(error.code) ? ' File error: ' + error.code + '.' : ''}`
|
|
334
|
-
}
|
|
335
|
-
: {})
|
|
336
|
-
});
|
|
337
|
-
} finally {
|
|
338
|
-
await file?.close();
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async function imageAttachments(paths, translate) {
|
|
343
|
-
const attachments = [];
|
|
344
|
-
let total = 0;
|
|
345
|
-
for (const path of paths) {
|
|
346
|
-
let file;
|
|
347
|
-
try {
|
|
348
|
-
file = await open(path, 'r');
|
|
349
|
-
const info = await file.stat();
|
|
350
|
-
if (!info.isFile() || info.size === 0 || info.size > 5 * 1024 * 1024)
|
|
351
|
-
throw new Error('IMAGE_INVALID');
|
|
352
|
-
const parts = [];
|
|
353
|
-
let size = 0;
|
|
354
|
-
for await (const part of file.createReadStream({ autoClose: false })) {
|
|
355
|
-
size += part.length;
|
|
356
|
-
total += part.length;
|
|
357
|
-
if (size > 5 * 1024 * 1024 || total > 10 * 1024 * 1024) throw new Error('IMAGE_TOO_LARGE');
|
|
358
|
-
parts.push(part);
|
|
359
|
-
}
|
|
360
|
-
const data = Buffer.concat(parts);
|
|
361
|
-
const mime = imageMime(data);
|
|
362
|
-
if (mime === undefined) throw new Error('IMAGE_INVALID');
|
|
363
|
-
attachments.push({ name: basename(path), mime, dataBase64: data.toString('base64') });
|
|
364
|
-
} catch {
|
|
365
|
-
throw new MarctlError(translate('errors.sessionImageInvalid'), {
|
|
366
|
-
code: 'MARCTL_SESSION_INPUT_INVALID'
|
|
367
|
-
});
|
|
368
|
-
} finally {
|
|
369
|
-
await file?.close();
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
return attachments;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function imageMime(data) {
|
|
376
|
-
if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
|
|
377
|
-
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg';
|
|
378
|
-
if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii'))) return 'image/gif';
|
|
379
|
-
if (
|
|
380
|
-
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
381
|
-
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
382
|
-
)
|
|
383
|
-
return 'image/webp';
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** One CLI-owned Workbench connection and one existing Session Watch, never history polling. */
|
|
387
|
-
export async function waitForSessionRun({
|
|
388
|
-
client,
|
|
389
|
-
sessionId,
|
|
390
|
-
runId,
|
|
391
|
-
timeoutMs,
|
|
392
|
-
until,
|
|
393
|
-
WebSocketClass,
|
|
394
|
-
translate = createTranslator(),
|
|
395
|
-
signal
|
|
396
|
-
}) {
|
|
397
|
-
let socket;
|
|
398
|
-
const watchId = randomUUID();
|
|
399
|
-
let connectionId;
|
|
400
|
-
let renewal;
|
|
401
|
-
let settled = false;
|
|
402
|
-
let subscribed = false;
|
|
403
|
-
let progress;
|
|
404
|
-
let accept;
|
|
405
|
-
let rejectResult;
|
|
406
|
-
const result = new Promise((resolve, fail) => {
|
|
407
|
-
accept = resolve;
|
|
408
|
-
rejectResult = fail;
|
|
409
|
-
});
|
|
410
|
-
const reject = (error, stage) => {
|
|
411
|
-
if (settled) return;
|
|
412
|
-
settled = true;
|
|
413
|
-
Object.assign(error, { sessionId, runId, stage });
|
|
414
|
-
rejectResult(error);
|
|
415
|
-
};
|
|
416
|
-
const fail = (code, key, { stage, upstreamCode, ...options } = {}) => {
|
|
417
|
-
const error = new MarctlError(translate(key), { ...options, code });
|
|
418
|
-
if (upstreamCode !== undefined) error.upstreamCode = upstreamCode;
|
|
419
|
-
if (stage === 'timeout' && progress !== undefined) error.progress = progress;
|
|
420
|
-
reject(error, stage);
|
|
421
|
-
};
|
|
422
|
-
const unknown = (stage, detail, source) =>
|
|
423
|
-
fail('MARCTL_WAIT_UNKNOWN', 'errors.sessionWaitUnknown', {
|
|
424
|
-
stage,
|
|
425
|
-
upstreamCode:
|
|
426
|
-
typeof source?.code === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,127}$/u.test(source.code)
|
|
427
|
-
? source.code
|
|
428
|
-
: undefined,
|
|
429
|
-
requestId: typeof source?.requestId === 'string' ? source.requestId.slice(0, 160) : undefined,
|
|
430
|
-
upstreamRequestId:
|
|
431
|
-
typeof source?.upstreamRequestId === 'string'
|
|
432
|
-
? source.upstreamRequestId.slice(0, 160)
|
|
433
|
-
: undefined,
|
|
434
|
-
details: [detail, source?.message, source?.details]
|
|
435
|
-
.filter((value) => typeof value === 'string')
|
|
436
|
-
.join(' ')
|
|
437
|
-
.slice(0, 4000)
|
|
438
|
-
});
|
|
439
|
-
const command = (mode) =>
|
|
440
|
-
client.sessionCommand({
|
|
441
|
-
action: 'watch',
|
|
442
|
-
sessionId,
|
|
443
|
-
workbenchConnectionId: connectionId,
|
|
444
|
-
watchId,
|
|
445
|
-
mode
|
|
446
|
-
});
|
|
447
|
-
const scheduleRenewal = (expiresAt) => {
|
|
448
|
-
clearTimeout(renewal);
|
|
449
|
-
const remaining = expiresAt - Date.now();
|
|
450
|
-
if (!Number.isFinite(remaining) || remaining <= 0) {
|
|
451
|
-
unknown('lease', 'The Watch lease is missing or expired.');
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
renewal = setTimeout(
|
|
455
|
-
() => {
|
|
456
|
-
command('renew')
|
|
457
|
-
.then((ack) => {
|
|
458
|
-
if (!settled) scheduleRenewal(ack?.expiresAt);
|
|
459
|
-
})
|
|
460
|
-
.catch((error) => {
|
|
461
|
-
if ([401, 403].includes(error.statusCode)) reject(error, 'renew');
|
|
462
|
-
else
|
|
463
|
-
unknown(
|
|
464
|
-
'renew',
|
|
465
|
-
'The Watch lease could not be renewed.',
|
|
466
|
-
error instanceof MarctlError ? error : undefined
|
|
467
|
-
);
|
|
468
|
-
});
|
|
469
|
-
},
|
|
470
|
-
Math.max(50, Math.floor(remaining / 2))
|
|
471
|
-
);
|
|
472
|
-
};
|
|
473
|
-
const check = (run) => {
|
|
474
|
-
if (run?.id === runId && run.sessionId === sessionId && terminal.has(run.status) && !settled) {
|
|
475
|
-
settled = true;
|
|
476
|
-
accept({ run });
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
const checkApproval = () => {
|
|
480
|
-
if (settled || until !== 'approval' || progress === undefined) return;
|
|
481
|
-
if (!Array.isArray(progress.pendingApprovals)) {
|
|
482
|
-
fail('NODE_OPERATION_UNSUPPORTED', 'errors.sessionApprovalUnsupported', {
|
|
483
|
-
stage: 'approval'
|
|
484
|
-
});
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const pendingApprovals = progress.pendingApprovals.filter(
|
|
488
|
-
(approval) => approval.runId === runId
|
|
489
|
-
);
|
|
490
|
-
if (pendingApprovals.length === 0) return;
|
|
491
|
-
settled = true;
|
|
492
|
-
accept({
|
|
493
|
-
sessionId,
|
|
494
|
-
runId,
|
|
495
|
-
stopReason: 'APPROVAL_REQUIRED',
|
|
496
|
-
pendingApprovals,
|
|
497
|
-
...(progress.pendingApprovalsTruncated ? { pendingApprovalsTruncated: true } : {})
|
|
498
|
-
});
|
|
499
|
-
};
|
|
500
|
-
const onMessage = (raw) => {
|
|
501
|
-
if (settled) return;
|
|
502
|
-
let message;
|
|
503
|
-
try {
|
|
504
|
-
message = JSON.parse(String(raw));
|
|
505
|
-
} catch {
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
if (message?.type === 'watch.event') message = message.event;
|
|
509
|
-
if (!message || typeof message !== 'object') return;
|
|
510
|
-
if (
|
|
511
|
-
message.type === 'workbench.ready' &&
|
|
512
|
-
connectionId === undefined &&
|
|
513
|
-
typeof message.workbenchConnectionId === 'string'
|
|
514
|
-
) {
|
|
515
|
-
connectionId = message.workbenchConnectionId;
|
|
516
|
-
subscribed = true;
|
|
517
|
-
command('initial').catch((error) => reject(error, 'subscribe'));
|
|
518
|
-
}
|
|
519
|
-
if (message.watchId !== watchId) return;
|
|
520
|
-
if (message.type === 'watch.failed') {
|
|
521
|
-
unknown('watch', 'The Session Watch failed.', message);
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
if (message.type === 'watch.session.snapshot') {
|
|
525
|
-
const runs = message.payload?.runs;
|
|
526
|
-
if (!Array.isArray(runs)) {
|
|
527
|
-
unknown('snapshot', 'The Watch snapshot did not contain a Run list.');
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const run = runs.find((value) => value?.id === runId && value.sessionId === sessionId);
|
|
531
|
-
if (run === undefined) {
|
|
532
|
-
unknown('snapshot', 'The requested Run was absent from the Watch snapshot.');
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (message.payload?.session?.id === sessionId && message.payload?.progress?.runId === runId)
|
|
536
|
-
progress = message.payload.progress;
|
|
537
|
-
check(run);
|
|
538
|
-
if (!settled && until === 'approval' && progress === undefined) {
|
|
539
|
-
fail('NODE_OPERATION_UNSUPPORTED', 'errors.sessionApprovalUnsupported', {
|
|
540
|
-
stage: 'approval'
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
checkApproval();
|
|
544
|
-
} else if (message.type === 'watch.session.event') {
|
|
545
|
-
const payload = message.event?.payload;
|
|
546
|
-
if (
|
|
547
|
-
(payload?.sessionId ?? payload?.run?.sessionId) === sessionId &&
|
|
548
|
-
payload?.progress?.runId === runId
|
|
549
|
-
)
|
|
550
|
-
progress = payload.progress;
|
|
551
|
-
check(payload?.run);
|
|
552
|
-
checkApproval();
|
|
553
|
-
}
|
|
554
|
-
if (!settled && message.expiresAt !== undefined) scheduleRenewal(message.expiresAt);
|
|
555
|
-
};
|
|
556
|
-
const onClose = (code, reason) => {
|
|
557
|
-
if (code === 1008 && String(reason) === 'UNAUTHENTICATED')
|
|
558
|
-
fail('UNAUTHENTICATED', 'errors.userContextUnavailable', {
|
|
559
|
-
statusCode: 401,
|
|
560
|
-
stage: 'connection'
|
|
561
|
-
});
|
|
562
|
-
else
|
|
563
|
-
unknown(
|
|
564
|
-
'connection',
|
|
565
|
-
Number.isInteger(code)
|
|
566
|
-
? `The Watch connection closed (code ${code}).`
|
|
567
|
-
: 'The Watch connection closed.'
|
|
568
|
-
);
|
|
569
|
-
};
|
|
570
|
-
const onError = (error) =>
|
|
571
|
-
unknown('connection', 'The Watch connection failed.', { code: error?.code });
|
|
572
|
-
const onOpen = () => {
|
|
573
|
-
try {
|
|
574
|
-
socket.send(JSON.stringify({ type: 'resume' }));
|
|
575
|
-
} catch (error) {
|
|
576
|
-
onError(error);
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
const onAbort = () =>
|
|
580
|
-
fail('MARCTL_WAIT_CANCELLED', 'errors.sessionWaitCancelled', { stage: 'cancel' });
|
|
581
|
-
try {
|
|
582
|
-
socket = client.openWorkbenchWebSocket(WebSocketClass ?? (await import('ws')).default);
|
|
583
|
-
} catch (error) {
|
|
584
|
-
if (error instanceof MarctlError) reject(error, 'connection');
|
|
585
|
-
else unknown('connection', 'The Watch connection could not be opened.');
|
|
586
|
-
return await result;
|
|
587
|
-
}
|
|
588
|
-
const timer = setTimeout(
|
|
589
|
-
() => fail('MARCTL_WAIT_TIMEOUT', 'errors.sessionWaitTimeout', { stage: 'timeout' }),
|
|
590
|
-
timeoutMs
|
|
591
|
-
);
|
|
592
|
-
socket.on('open', onOpen);
|
|
593
|
-
socket.on('message', onMessage);
|
|
594
|
-
socket.on('error', onError);
|
|
595
|
-
socket.on('close', onClose);
|
|
596
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
597
|
-
if (signal?.aborted) onAbort();
|
|
598
|
-
try {
|
|
599
|
-
return await result;
|
|
600
|
-
} finally {
|
|
601
|
-
settled = true;
|
|
602
|
-
clearTimeout(timer);
|
|
603
|
-
clearTimeout(renewal);
|
|
604
|
-
signal?.removeEventListener('abort', onAbort);
|
|
605
|
-
if (subscribed) await command('stop').catch(() => undefined);
|
|
606
|
-
socket.off('open', onOpen);
|
|
607
|
-
socket.off('message', onMessage);
|
|
608
|
-
socket.off('close', onClose);
|
|
609
|
-
socket.close();
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
85
|
export function sessionExitCode(error) {
|
|
614
86
|
if (error.code === 'MARCTL_SESSION_INPUT_INVALID') return 2;
|
|
615
87
|
if ([401, 403].includes(error.statusCode)) return 3;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { MarctlError } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_INPUT_BYTES = 65536;
|
|
4
|
+
|
|
5
|
+
export function parseFrame(raw, isBinary, terminalId, translate) {
|
|
6
|
+
if (isBinary) throw new MarctlError(translate('errors.terminalBinaryFrame'));
|
|
7
|
+
let frame;
|
|
8
|
+
try {
|
|
9
|
+
frame = JSON.parse(rawToString(raw));
|
|
10
|
+
} catch {
|
|
11
|
+
throw new MarctlError(translate('errors.terminalInvalidJson'));
|
|
12
|
+
}
|
|
13
|
+
if (frame === null || typeof frame !== 'object' || frame.terminalId !== terminalId)
|
|
14
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
15
|
+
|
|
16
|
+
if (frame.type === 'attach') {
|
|
17
|
+
if (!Number.isSafeInteger(frame.afterSequence) || frame.afterSequence < 0)
|
|
18
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
19
|
+
return frame;
|
|
20
|
+
}
|
|
21
|
+
if (frame.type === 'output') {
|
|
22
|
+
if (
|
|
23
|
+
!Number.isSafeInteger(frame.sequence) ||
|
|
24
|
+
frame.sequence < 1 ||
|
|
25
|
+
typeof frame.data !== 'string' ||
|
|
26
|
+
Buffer.byteLength(frame.data) > MAX_INPUT_BYTES
|
|
27
|
+
)
|
|
28
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
29
|
+
return frame;
|
|
30
|
+
}
|
|
31
|
+
if (frame.type === 'exit') {
|
|
32
|
+
if (frame.exitCode !== null && !Number.isSafeInteger(frame.exitCode))
|
|
33
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
34
|
+
return frame;
|
|
35
|
+
}
|
|
36
|
+
if (frame.type === 'error' && typeof frame.code === 'string' && frame.code.length > 0)
|
|
37
|
+
return frame;
|
|
38
|
+
throw new MarctlError(translate('errors.terminalFrameInvalid'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rawToString(raw) {
|
|
42
|
+
if (typeof raw === 'string') return raw;
|
|
43
|
+
if (Buffer.isBuffer(raw)) return raw.toString('utf8');
|
|
44
|
+
if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
|
|
45
|
+
if (ArrayBuffer.isView(raw))
|
|
46
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
|
47
|
+
return String(raw);
|
|
48
|
+
}
|