@myagentroam/marctl 0.9.120 → 0.9.122

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/marctl",
3
- "version": "0.9.120",
3
+ "version": "0.9.122",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/direct.mjs CHANGED
@@ -27,14 +27,17 @@ export async function openDirectTerminal({
27
27
  WebSocketClass,
28
28
  nodeId,
29
29
  terminalId,
30
+ manifest,
30
31
  afterSequence = 0,
31
32
  timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
32
33
  heartbeatMs = DIRECT_HEARTBEAT_MS,
33
34
  peerConnectionFactory,
35
+ onPrepared,
36
+ onIceConnected,
34
37
  onTransportReady,
35
38
  signal
36
39
  }) {
37
- if (typeof client.getTerminalManifest !== 'function')
40
+ if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
38
41
  throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
39
42
 
40
43
  let live;
@@ -49,7 +52,7 @@ export async function openDirectTerminal({
49
52
  let transportNegotiationActive = true;
50
53
  let cleanupNative;
51
54
  try {
52
- const manifest = await client.getTerminalManifest(nodeId);
55
+ manifest ??= await client.getTerminalManifest(nodeId);
53
56
  if (
54
57
  typeof manifest?.nodeGeneration !== 'string' ||
55
58
  manifest.nodeGeneration.length < 8 ||
@@ -84,7 +87,12 @@ export async function openDirectTerminal({
84
87
  peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
85
88
  peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
86
89
 
90
+ // Start negotiation only after both paths finish their control-plane preparation.
91
+ if (onPrepared) await onPrepared();
92
+ if (signal?.aborted) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
93
+
87
94
  const transportReady = new Promise((resolve, reject) => {
95
+ let iceConnected = false;
88
96
  let connected = false;
89
97
  let serverOpened = false;
90
98
  let settled = false;
@@ -162,6 +170,17 @@ export async function openDirectTerminal({
162
170
  )
163
171
  finish(new DirectFallbackError('DIRECT_CONTROL_UNAVAILABLE'));
164
172
  });
173
+ peer.onIceStateChange((state) => {
174
+ if (!transportNegotiationActive || settled || iceConnected) return;
175
+ const normalizedState = String(state).toLowerCase();
176
+ if (normalizedState !== 'connected' && normalizedState !== 'completed') return;
177
+ iceConnected = true;
178
+ try {
179
+ onIceConnected?.();
180
+ } catch (error) {
181
+ finish(error);
182
+ }
183
+ });
165
184
  peer.onStateChange((state) => {
166
185
  if (!transportNegotiationActive || settled) return;
167
186
  const normalizedState = String(state).toLowerCase();
package/src/scp.mjs CHANGED
@@ -61,13 +61,29 @@ function workerCommand(mode, file, size, metadata, preserve, sourceBasename) {
61
61
  class ByteChannel {
62
62
  #buffer = Buffer.alloc(0);
63
63
  #waiting = [];
64
+ #sending = new Map();
64
65
  #ended;
65
66
  #failure;
66
67
 
67
68
  constructor(send, signal) {
68
69
  this.send = (bytes) => {
69
70
  if (this.#failure) throw this.#failure;
70
- return send(bytes);
71
+ if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
72
+ return new Promise((resolve, reject) => {
73
+ this.#sending.set(reject, resolve);
74
+ const settle = (complete, value) => {
75
+ this.#sending.delete(reject);
76
+ complete(value);
77
+ };
78
+ try {
79
+ Promise.resolve(send(bytes)).then(
80
+ (value) => settle(resolve, value),
81
+ (error) => settle(reject, error)
82
+ );
83
+ } catch (error) {
84
+ settle(reject, error);
85
+ }
86
+ });
71
87
  };
72
88
  if (signal) {
73
89
  if (signal.aborted) this.fail(new MarctlError('Copy was interrupted.'));
@@ -92,11 +108,21 @@ class ByteChannel {
92
108
 
93
109
  finish(code) {
94
110
  this.#ended = code;
111
+ if (code !== 0) return this.fail(new MarctlError('Remote copy command failed.'));
112
+ // A successful exit confirms the worker consumed its final protocol input.
113
+ for (const resolve of this.#sending.values()) resolve();
114
+ this.#sending.clear();
95
115
  this.#wake();
96
116
  }
97
117
 
118
+ connectionEnded(error) {
119
+ if (this.#ended === undefined) this.fail(error);
120
+ }
121
+
98
122
  fail(error) {
99
123
  this.#failure ??= error;
124
+ for (const reject of this.#sending.keys()) reject(this.#failure);
125
+ this.#sending.clear();
100
126
  this.#wake();
101
127
  }
102
128
 
@@ -231,9 +257,9 @@ async function withRemoteWorker(
231
257
  }
232
258
  };
233
259
  const onError = () =>
234
- channel.fail(new MarctlError(translate('errors.terminalConnectionFailed')));
260
+ channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionFailed')));
235
261
  const onClose = () =>
236
- channel.fail(new MarctlError(translate('errors.terminalConnectionLost')));
262
+ channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionLost')));
237
263
  transport.on('message', onMessage);
238
264
  transport.on('error', onError);
239
265
  transport.on('close', onClose);
@@ -0,0 +1,17 @@
1
+ interface RelaySocket {
2
+ readonly readyState: number;
3
+ readonly bufferedAmount?: number;
4
+ send(value: string, callback: (error?: Error) => void): void;
5
+ }
6
+
7
+ export class TerminalRelaySender {
8
+ constructor(
9
+ socket: RelaySocket,
10
+ options?: {
11
+ readonly onPendingChange?: (keys: ReadonlySet<string>) => void;
12
+ }
13
+ );
14
+ send(value: string, key?: string): Promise<void>;
15
+ cancel(key: string): void;
16
+ close(error?: Error): void;
17
+ }
@@ -0,0 +1,129 @@
1
+ const MAX_PENDING_BYTES = 2 * 1024 * 1024;
2
+ const MAX_SOCKET_BYTES = 256 * 1024;
3
+
4
+ /** Shared sender for CLI input and the Node's aggregate Terminal relay output. */
5
+ export class TerminalRelaySender {
6
+ #socket;
7
+ #onPendingChange;
8
+ #pending = [];
9
+ #pendingBytes = 0;
10
+ #inFlight;
11
+ #timer;
12
+ #immediate;
13
+ #closed = false;
14
+
15
+ constructor(socket, { onPendingChange = () => {} } = {}) {
16
+ this.#socket = socket;
17
+ this.#onPendingChange = onPendingChange;
18
+ }
19
+
20
+ send(value, key = '') {
21
+ if (this.#closed || this.#socket.readyState !== 1)
22
+ return Promise.reject(new Error('TERMINAL_CONNECTION_LOST'));
23
+ const bytes = Buffer.byteLength(value, 'utf8');
24
+ if (this.#pendingBytes + bytes > MAX_PENDING_BYTES)
25
+ return Promise.reject(
26
+ Object.assign(new Error('TERMINAL_RELAY_BUFFER_EXCEEDED'), {
27
+ code: 'TERMINAL_RELAY_BUFFER_EXCEEDED'
28
+ })
29
+ );
30
+ const result = new Promise((resolve, reject) => {
31
+ this.#pending.push({ value, key, bytes, resolve, reject });
32
+ this.#pendingBytes += bytes;
33
+ });
34
+ this.#pump();
35
+ this.#notify();
36
+ return result;
37
+ }
38
+
39
+ cancel(key) {
40
+ if (this.#inFlight?.key === key) {
41
+ this.#inFlight.cancelled = true;
42
+ this.#inFlight.reject(new Error('TERMINAL_ATTACH_INVALID'));
43
+ }
44
+ const kept = [];
45
+ for (const item of this.#pending) {
46
+ if (item.key !== key) kept.push(item);
47
+ else {
48
+ this.#pendingBytes -= item.bytes;
49
+ item.reject(new Error('TERMINAL_ATTACH_INVALID'));
50
+ }
51
+ }
52
+ this.#pending = kept;
53
+ if (kept.length === 0) this.#clearScheduled();
54
+ this.#notify();
55
+ }
56
+
57
+ close(error = new Error('TERMINAL_CONNECTION_LOST')) {
58
+ if (this.#closed) return;
59
+ this.#closed = true;
60
+ this.#clearScheduled();
61
+ this.#inFlight?.reject(error);
62
+ this.#inFlight = undefined;
63
+ for (const item of this.#pending) item.reject(error);
64
+ this.#pending = [];
65
+ this.#pendingBytes = 0;
66
+ this.#notify();
67
+ }
68
+
69
+ #pump() {
70
+ if (
71
+ this.#closed ||
72
+ this.#inFlight !== undefined ||
73
+ this.#timer !== undefined ||
74
+ this.#immediate !== undefined ||
75
+ this.#pending.length === 0
76
+ )
77
+ return;
78
+ if (this.#socket.readyState !== 1) {
79
+ this.close();
80
+ return;
81
+ }
82
+ const buffered = this.#socket.bufferedAmount ?? 0;
83
+ if (buffered > 0 && buffered + this.#pending[0].bytes > MAX_SOCKET_BYTES) {
84
+ // Poll only while the transport is congested; there is no throughput timer.
85
+ this.#timer = setTimeout(() => {
86
+ this.#timer = undefined;
87
+ this.#pump();
88
+ this.#notify();
89
+ }, 20);
90
+ return;
91
+ }
92
+ const item = this.#pending.shift();
93
+ this.#inFlight = item;
94
+ try {
95
+ this.#socket.send(item.value, (error) => {
96
+ if (this.#inFlight !== item) return;
97
+ if (error) {
98
+ this.close(error);
99
+ return;
100
+ }
101
+ this.#inFlight = undefined;
102
+ this.#pendingBytes -= item.bytes;
103
+ // Even a synchronous write callback must yield before the next frame.
104
+ this.#immediate = setImmediate(() => {
105
+ this.#immediate = undefined;
106
+ this.#pump();
107
+ this.#notify();
108
+ });
109
+ item.resolve();
110
+ this.#notify();
111
+ });
112
+ } catch (error) {
113
+ this.close(error);
114
+ }
115
+ }
116
+
117
+ #notify() {
118
+ const keys = new Set(this.#pending.map((item) => item.key));
119
+ if (this.#inFlight !== undefined && !this.#inFlight.cancelled) keys.add(this.#inFlight.key);
120
+ this.#onPendingChange(keys);
121
+ }
122
+
123
+ #clearScheduled() {
124
+ clearTimeout(this.#timer);
125
+ this.#timer = undefined;
126
+ clearImmediate(this.#immediate);
127
+ this.#immediate = undefined;
128
+ }
129
+ }
package/src/terminal.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { MarctlError } from './config.mjs';
3
3
  import { createTranslator } from './i18n/index.mjs';
4
+ import { TerminalRelaySender } from './terminal-relay-sender.mjs';
4
5
 
5
6
  const MAX_INPUT_BYTES = 65536;
6
7
  const DEFAULT_ROWS = 24;
@@ -27,13 +28,16 @@ export async function runTerminalSession({
27
28
  let transport;
28
29
  let result;
29
30
  let primaryError;
31
+ let manifest;
30
32
 
31
33
  try {
32
- if (
33
- binary &&
34
- (command === undefined || (await client.getTerminalManifest(nodeId))?.binaryTerminal !== true)
35
- )
36
- throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
34
+ if (binary) {
35
+ if (command === undefined)
36
+ throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
37
+ manifest = await client.getTerminalManifest(nodeId);
38
+ if (manifest?.binaryTerminal !== true)
39
+ throw new MarctlError(translate('errors.terminalBinaryUnavailable'));
40
+ }
37
41
  const created = await client.createTerminal({
38
42
  nodeId,
39
43
  ...dimensions,
@@ -46,17 +50,18 @@ export async function runTerminalSession({
46
50
  try {
47
51
  const openDirectTerminal =
48
52
  openDirectTerminalImpl ?? (await import('./direct.mjs')).openDirectTerminal;
53
+ if (raceConnections && manifest === undefined)
54
+ manifest = await client.getTerminalManifest(nodeId).catch(() => undefined);
49
55
  const directOptions = {
50
56
  client,
51
57
  WebSocketClass,
52
58
  nodeId,
53
59
  terminalId,
60
+ manifest,
54
61
  afterSequence: 0,
55
62
  timeoutMs: connectionTimeoutMs
56
63
  };
57
- const canRace =
58
- raceConnections &&
59
- (await client.getTerminalManifest(nodeId).catch(() => undefined))?.deferredRelay === true;
64
+ const canRace = raceConnections && manifest?.deferredRelay === true;
60
65
  transport = canRace
61
66
  ? await raceTerminalConnections({
62
67
  openDirectTerminal,
@@ -136,6 +141,16 @@ async function raceTerminalConnections({
136
141
  }) {
137
142
  const abort = new AbortController();
138
143
  const relayAbort = new AbortController();
144
+ const preparing = new Set(['direct', 'relay']);
145
+ let start;
146
+ const preparation = new Promise((resolve) => {
147
+ start = resolve;
148
+ });
149
+ const prepared = (candidate) => {
150
+ preparing.delete(candidate);
151
+ if (preparing.size === 0) start();
152
+ return preparation;
153
+ };
139
154
  let selected;
140
155
  let announce;
141
156
  const first = new Promise((resolve) => {
@@ -152,12 +167,19 @@ async function raceTerminalConnections({
152
167
  const direct = openDirectTerminal({
153
168
  ...directOptions,
154
169
  signal: abort.signal,
155
- onTransportReady: () => {
170
+ onPrepared: () => prepared('direct'),
171
+ onIceConnected: () => {
156
172
  if (!select('direct')) throw new Error('DIRECT_RACE_CANCELLED');
173
+ },
174
+ onTransportReady: () => {
175
+ if (selected !== 'direct') throw new Error('DIRECT_RACE_CANCELLED');
157
176
  }
158
177
  }).then(
159
178
  (value) => ({ value }),
160
- (error) => ({ error })
179
+ (error) => {
180
+ prepared('direct');
181
+ return { error };
182
+ }
161
183
  );
162
184
  const relay = prepareRelayTransport({
163
185
  client,
@@ -166,13 +188,17 @@ async function raceTerminalConnections({
166
188
  terminalId,
167
189
  translate,
168
190
  connectionTimeoutMs,
169
- signal: relayAbort.signal
191
+ signal: relayAbort.signal,
192
+ onPrepared: () => prepared('relay')
170
193
  }).then(
171
194
  (value) => {
172
195
  if (!select('relay')) value.close();
173
196
  return { value };
174
197
  },
175
- (error) => ({ error })
198
+ (error) => {
199
+ prepared('relay');
200
+ return { error };
201
+ }
176
202
  );
177
203
  const winner = await Promise.race([
178
204
  first,
@@ -252,9 +278,11 @@ async function prepareRelayTransport({
252
278
  terminalId,
253
279
  translate,
254
280
  connectionTimeoutMs,
255
- signal
281
+ signal,
282
+ onPrepared
256
283
  }) {
257
284
  const ticket = await client.createAttachTicket({ nodeId, terminalId });
285
+ if (onPrepared) await onPrepared();
258
286
  if (signal?.aborted) throw new MarctlError(translate('errors.terminalAttachConnection'));
259
287
  const socket = client.openTerminalWebSocket(WebSocketClass, {
260
288
  terminalId,
@@ -328,6 +356,7 @@ async function prepareRelayTransport({
328
356
 
329
357
  class BufferedRelayTransport extends EventEmitter {
330
358
  #socket;
359
+ #sender;
331
360
  #terminalId;
332
361
  #translate;
333
362
  #attached = false;
@@ -346,6 +375,7 @@ class BufferedRelayTransport extends EventEmitter {
346
375
  constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
347
376
  super();
348
377
  this.#socket = socket;
378
+ this.#sender = new TerminalRelaySender(socket);
349
379
  this.#terminalId = terminalId;
350
380
  this.#translate = translate;
351
381
  this.#onOpen = () => {
@@ -388,6 +418,7 @@ class BufferedRelayTransport extends EventEmitter {
388
418
  else this.#fail(error);
389
419
  };
390
420
  this.#onClose = () => {
421
+ this.#sender.close();
391
422
  const error = new MarctlError(
392
423
  this.#translate(
393
424
  this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
@@ -439,12 +470,14 @@ class BufferedRelayTransport extends EventEmitter {
439
470
 
440
471
  send(value) {
441
472
  if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
473
+ if (JSON.parse(value).type === 'input') return this.#sender.send(value);
442
474
  this.#socket.send(value);
443
475
  }
444
476
 
445
477
  close() {
446
478
  if (this.#closed) return;
447
479
  this.#closed = true;
480
+ this.#sender.close();
448
481
  clearTimeout(this.#openTimer);
449
482
  clearTimeout(this.#attachTimer);
450
483
  this.#socket.removeListener?.('open', this.#onOpen);
@@ -499,6 +532,7 @@ function runAttachedTerminal({
499
532
  let settled = false;
500
533
  let inputEnabled = false;
501
534
  let rawModeEnabled = false;
535
+ let inputTasks = 0;
502
536
 
503
537
  const cleanup = () => {
504
538
  transport.removeListener?.('message', onMessage);
@@ -529,13 +563,21 @@ function runAttachedTerminal({
529
563
  };
530
564
  const fail = (key) => finish(new MarctlError(translate(key)));
531
565
  const send = (frame) => {
532
- transport.send(JSON.stringify(frame));
566
+ return transport.send(JSON.stringify(frame));
533
567
  };
534
- const onInput = (data) => {
568
+ const onInput = async (data) => {
569
+ inputTasks += 1;
570
+ stdin.pause?.();
535
571
  try {
536
- for (const chunk of splitInput(data)) send({ type: 'input', terminalId, data: chunk });
572
+ for (const chunk of splitInput(data)) {
573
+ if (settled) return;
574
+ await send({ type: 'input', terminalId, data: chunk });
575
+ }
537
576
  } catch {
538
577
  fail('errors.terminalInputFailed');
578
+ } finally {
579
+ inputTasks -= 1;
580
+ if (!settled && inputTasks === 0) stdin.resume?.();
539
581
  }
540
582
  };
541
583
  const onResize = () => {