@myagentroam/marmgr 0.9.112 → 0.9.113

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/marmgr",
3
- "version": "0.9.112",
3
+ "version": "0.9.113",
4
4
  "description": "MyAgentRoam Node terminal manager CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -46,6 +46,7 @@ export async function main(
46
46
  nodeId: args.nodeId,
47
47
  command: args.remoteCommand,
48
48
  forcePty: args.forcePty,
49
+ raceConnections: true,
49
50
  stdout,
50
51
  stderr,
51
52
  translate
package/src/client.mjs CHANGED
@@ -76,7 +76,9 @@ export class MarmgrClient {
76
76
  !isNonEmptyString(payload.manifest.nodeGeneration)
77
77
  )
78
78
  throw new MarmgrError(this.#translate('errors.serverInvalidManifest'));
79
- return payload.manifest;
79
+ return payload.deferredRelay === true
80
+ ? { ...payload.manifest, deferredRelay: true }
81
+ : payload.manifest;
80
82
  }
81
83
 
82
84
  async createAttachTicket({ nodeId, terminalId, afterSequence }) {
@@ -105,7 +107,7 @@ export class MarmgrClient {
105
107
  });
106
108
  }
107
109
 
108
- openTerminalWebSocket(WebSocketClass, { terminalId, ticket }) {
110
+ openTerminalWebSocket(WebSocketClass, { terminalId, ticket, deferred = false }) {
109
111
  validateTerminalId(terminalId, this.#translate);
110
112
  if (!isNonEmptyString(ticket))
111
113
  throw new MarmgrError(this.#translate('errors.terminalTicketInvalid'));
@@ -115,6 +117,7 @@ export class MarmgrClient {
115
117
  `${this.#serverUrl}/`
116
118
  );
117
119
  url.searchParams.set('ticket', ticket);
120
+ if (deferred) url.searchParams.set('deferred', '1');
118
121
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
119
122
 
120
123
  try {
package/src/direct.mjs CHANGED
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
 
4
4
  export const DIRECT_PREPARE_TIMEOUT_MS = 5000;
5
5
  export const DIRECT_SETUP_TIMEOUT_MS = 15000;
6
+ export const DIRECT_ICE_TIMEOUT_MS = 3000;
6
7
  export const DIRECT_HEARTBEAT_MS = 10000;
7
8
 
8
9
  export class DirectFallbackError extends Error {
@@ -29,7 +30,9 @@ export async function openDirectTerminal({
29
30
  afterSequence = 0,
30
31
  timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
31
32
  heartbeatMs = DIRECT_HEARTBEAT_MS,
32
- peerConnectionFactory
33
+ peerConnectionFactory,
34
+ onTransportReady,
35
+ signal
33
36
  }) {
34
37
  if (typeof client.getTerminalManifest !== 'function')
35
38
  throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
@@ -183,13 +186,19 @@ export async function openDirectTerminal({
183
186
  }
184
187
  });
185
188
  try {
186
- await withTimeout(transportReady, timeoutMs, 'DIRECT_SETUP_TIMEOUT');
189
+ await withTimeout(
190
+ transportReady,
191
+ Math.min(timeoutMs, DIRECT_ICE_TIMEOUT_MS),
192
+ 'DIRECT_SETUP_TIMEOUT',
193
+ signal
194
+ );
187
195
  } finally {
188
196
  transportNegotiationActive = false;
189
197
  transportControlCleanup?.();
190
198
  transportControlCleanup = undefined;
191
199
  }
192
200
  bootstrapChannel?.close?.();
201
+ if (onTransportReady) await onTransportReady();
193
202
 
194
203
  const terminalPrepared = await live.request(
195
204
  {
@@ -665,14 +674,27 @@ async function cancelSession(live, type, directSessionId) {
665
674
  await closed.catch(() => undefined);
666
675
  }
667
676
 
668
- function withTimeout(promise, timeoutMs, code) {
677
+ function withTimeout(promise, timeoutMs, code, signal) {
669
678
  let timer;
679
+ let onAbort;
670
680
  return Promise.race([
671
681
  promise,
672
682
  new Promise((_, reject) => {
673
683
  timer = setTimeout(() => reject(new DirectFallbackError(code)), timeoutMs);
674
- })
675
- ]).finally(() => clearTimeout(timer));
684
+ }),
685
+ ...(signal
686
+ ? [
687
+ new Promise((_, reject) => {
688
+ onAbort = () => reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
689
+ if (signal.aborted) onAbort();
690
+ else signal.addEventListener('abort', onAbort, { once: true });
691
+ })
692
+ ]
693
+ : [])
694
+ ]).finally(() => {
695
+ clearTimeout(timer);
696
+ if (onAbort) signal.removeEventListener('abort', onAbort);
697
+ });
676
698
  }
677
699
 
678
700
  function requestId() {
package/src/terminal.mjs CHANGED
@@ -17,7 +17,8 @@ export async function runTerminalSession({
17
17
  signals = process,
18
18
  translate = createTranslator(),
19
19
  connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS,
20
- openDirectTerminal: openDirectTerminalImpl
20
+ openDirectTerminal: openDirectTerminalImpl,
21
+ raceConnections = false
21
22
  }) {
22
23
  const dimensions = getDimensions(stdout);
23
24
  let terminalId;
@@ -34,14 +35,29 @@ export async function runTerminalSession({
34
35
  try {
35
36
  const openDirectTerminal =
36
37
  openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
37
- transport = await openDirectTerminal({
38
+ const directOptions = {
38
39
  client,
39
40
  WebSocketClass,
40
41
  nodeId,
41
42
  terminalId,
42
43
  afterSequence: 0,
43
44
  timeoutMs: connectionTimeoutMs
44
- });
45
+ };
46
+ const canRace =
47
+ raceConnections &&
48
+ (await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
49
+ transport = canRace
50
+ ? await raceTerminalConnections({
51
+ openDirectTerminal,
52
+ directOptions,
53
+ client,
54
+ WebSocketClass,
55
+ nodeId,
56
+ terminalId,
57
+ translate,
58
+ connectionTimeoutMs
59
+ })
60
+ : await openDirectTerminal(directOptions);
45
61
  result = await runAttachedTerminal({
46
62
  transport,
47
63
  terminalId,
@@ -91,6 +107,74 @@ export async function runTerminalSession({
91
107
  return result;
92
108
  }
93
109
 
110
+ async function raceTerminalConnections({
111
+ openDirectTerminal,
112
+ directOptions,
113
+ client,
114
+ WebSocketClass,
115
+ nodeId,
116
+ terminalId,
117
+ translate,
118
+ connectionTimeoutMs
119
+ }) {
120
+ const abort = new AbortController();
121
+ const relayAbort = new AbortController();
122
+ let selected;
123
+ let announce;
124
+ const first = new Promise((resolve) => {
125
+ announce = resolve;
126
+ });
127
+ const select = (candidate) => {
128
+ if (selected) return false;
129
+ selected = candidate;
130
+ announce(candidate);
131
+ if (candidate === 'relay') abort.abort();
132
+ else relayAbort.abort();
133
+ return true;
134
+ };
135
+ const direct = openDirectTerminal({
136
+ ...directOptions,
137
+ signal: abort.signal,
138
+ onTransportReady: () => {
139
+ if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
140
+ }
141
+ }).then(
142
+ (value) => ({ value }),
143
+ (error) => ({ error })
144
+ );
145
+ const relay = prepareRelayTransport({
146
+ client,
147
+ WebSocketClass,
148
+ nodeId,
149
+ terminalId,
150
+ translate,
151
+ connectionTimeoutMs,
152
+ signal: relayAbort.signal
153
+ }).then(
154
+ (value) => {
155
+ if (!select('relay')) value.close();
156
+ return { value };
157
+ },
158
+ (error) => ({ error })
159
+ );
160
+ const winner = await Promise.race([
161
+ first,
162
+ Promise.all([direct, relay]).then(() => selected ?? 'none')
163
+ ]);
164
+ if (winner === 'direct') {
165
+ const result = await direct;
166
+ if (result.error) throw result.error;
167
+ return result.value;
168
+ }
169
+ if (winner === 'relay') {
170
+ const result = await relay;
171
+ if (result.error) throw result.error;
172
+ return result.value.activate();
173
+ }
174
+ const [directResult, relayResult] = await Promise.all([direct, relay]);
175
+ throw relayResult.error ?? directResult.error;
176
+ }
177
+
94
178
  export function splitInput(text, maxBytes = MAX_INPUT_BYTES) {
95
179
  const value = Buffer.isBuffer(text) ? text.toString('utf8') : String(text);
96
180
  const chunks = [];
@@ -144,6 +228,87 @@ async function openRelayTransport({
144
228
  }
145
229
  }
146
230
 
231
+ async function prepareRelayTransport({
232
+ client,
233
+ WebSocketClass,
234
+ nodeId,
235
+ terminalId,
236
+ translate,
237
+ connectionTimeoutMs,
238
+ signal
239
+ }) {
240
+ const ticket = await client.createAttachTicket({ nodeId, terminalId });
241
+ if (signal?.aborted) throw new MarmgrError(translate('errors.terminalAttachConnection'));
242
+ const socket = client.openTerminalWebSocket(WebSocketClass, {
243
+ terminalId,
244
+ ticket,
245
+ deferred: true
246
+ });
247
+ // Keep an error listener after readiness until the socket is discarded.
248
+ socket.on('error', () => {});
249
+ let activated = false;
250
+ const ready = new Promise((resolve, reject) => {
251
+ const timer = setTimeout(() => fail(), connectionTimeoutMs);
252
+ const cleanup = () => {
253
+ clearTimeout(timer);
254
+ socket.removeListener?.('message', onMessage);
255
+ socket.removeListener?.('error', fail);
256
+ socket.removeListener?.('close', fail);
257
+ signal?.removeEventListener('abort', fail);
258
+ };
259
+ const fail = () => {
260
+ cleanup();
261
+ try {
262
+ socket.close();
263
+ } catch {
264
+ /* Connection not yet established. */
265
+ }
266
+ reject(new MarmgrError(translate('errors.terminalAttachConnection')));
267
+ };
268
+ const onMessage = (raw) => {
269
+ let frame;
270
+ try {
271
+ frame = JSON.parse(String(raw));
272
+ } catch {
273
+ return fail();
274
+ }
275
+ if (frame?.type !== 'ready' || frame.terminalId !== terminalId) return fail();
276
+ cleanup();
277
+ resolve();
278
+ };
279
+ socket.on('message', onMessage);
280
+ socket.on('error', fail);
281
+ socket.on('close', fail);
282
+ signal?.addEventListener('abort', fail, { once: true });
283
+ if (signal?.aborted) fail();
284
+ });
285
+ await ready;
286
+ return {
287
+ async activate() {
288
+ if (activated || socket.readyState !== 1)
289
+ throw new MarmgrError(translate('errors.terminalAttachConnection'));
290
+ activated = true;
291
+ const transport = new BufferedRelayTransport({
292
+ socket,
293
+ terminalId,
294
+ translate,
295
+ connectionTimeoutMs
296
+ });
297
+ try {
298
+ socket.send(JSON.stringify({ type: 'activate', terminalId }));
299
+ await transport.waitForAttachment();
300
+ return transport;
301
+ } catch (error) {
302
+ transport.close();
303
+ throw error;
304
+ }
305
+ },
306
+ close() {
307
+ socket.close();
308
+ }
309
+ };
310
+ }
311
+
147
312
  class BufferedRelayTransport extends EventEmitter {
148
313
  #socket;
149
314
  #terminalId;