@myagentroam/marctl 0.9.120 → 0.9.121

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.121",
4
4
  "description": "MyAgentRoam command-line interface for authorized Node access.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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;
@@ -328,6 +329,7 @@ async function prepareRelayTransport({
328
329
 
329
330
  class BufferedRelayTransport extends EventEmitter {
330
331
  #socket;
332
+ #sender;
331
333
  #terminalId;
332
334
  #translate;
333
335
  #attached = false;
@@ -346,6 +348,7 @@ class BufferedRelayTransport extends EventEmitter {
346
348
  constructor({ socket, terminalId, translate, connectionTimeoutMs }) {
347
349
  super();
348
350
  this.#socket = socket;
351
+ this.#sender = new TerminalRelaySender(socket);
349
352
  this.#terminalId = terminalId;
350
353
  this.#translate = translate;
351
354
  this.#onOpen = () => {
@@ -388,6 +391,7 @@ class BufferedRelayTransport extends EventEmitter {
388
391
  else this.#fail(error);
389
392
  };
390
393
  this.#onClose = () => {
394
+ this.#sender.close();
391
395
  const error = new MarctlError(
392
396
  this.#translate(
393
397
  this.#attached ? 'errors.terminalConnectionLost' : 'errors.terminalAttachLost'
@@ -439,12 +443,14 @@ class BufferedRelayTransport extends EventEmitter {
439
443
 
440
444
  send(value) {
441
445
  if (this.#closed) throw new Error('TERMINAL_CONNECTION_LOST');
446
+ if (JSON.parse(value).type === 'input') return this.#sender.send(value);
442
447
  this.#socket.send(value);
443
448
  }
444
449
 
445
450
  close() {
446
451
  if (this.#closed) return;
447
452
  this.#closed = true;
453
+ this.#sender.close();
448
454
  clearTimeout(this.#openTimer);
449
455
  clearTimeout(this.#attachTimer);
450
456
  this.#socket.removeListener?.('open', this.#onOpen);
@@ -499,6 +505,7 @@ function runAttachedTerminal({
499
505
  let settled = false;
500
506
  let inputEnabled = false;
501
507
  let rawModeEnabled = false;
508
+ let inputTasks = 0;
502
509
 
503
510
  const cleanup = () => {
504
511
  transport.removeListener?.('message', onMessage);
@@ -529,13 +536,21 @@ function runAttachedTerminal({
529
536
  };
530
537
  const fail = (key) => finish(new MarctlError(translate(key)));
531
538
  const send = (frame) => {
532
- transport.send(JSON.stringify(frame));
539
+ return transport.send(JSON.stringify(frame));
533
540
  };
534
- const onInput = (data) => {
541
+ const onInput = async (data) => {
542
+ inputTasks += 1;
543
+ stdin.pause?.();
535
544
  try {
536
- for (const chunk of splitInput(data)) send({ type: 'input', terminalId, data: chunk });
545
+ for (const chunk of splitInput(data)) {
546
+ if (settled) return;
547
+ await send({ type: 'input', terminalId, data: chunk });
548
+ }
537
549
  } catch {
538
550
  fail('errors.terminalInputFailed');
551
+ } finally {
552
+ inputTasks -= 1;
553
+ if (!settled && inputTasks === 0) stdin.resume?.();
539
554
  }
540
555
  };
541
556
  const onResize = () => {