@myagentroam/node 0.9.165 → 0.9.167

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.
@@ -2,12 +2,15 @@ import WebSocket from 'ws';
2
2
  import { terminalDataFrameSchema } from '@myagentroam/protocol';
3
3
  import { nodeTerminalConnectEndpoint, isPlainRecord } from '../runner/codex/conversation-parser.js';
4
4
  import { TerminalRelayState } from './terminal-relay-state.js';
5
+ import { TerminalRelaySender } from '@myagentroam/marctl/src/terminal-relay-sender.mjs';
5
6
  export class NodeTerminalChannel {
6
7
  options;
7
8
  socket;
8
9
  reconnectTimer;
9
10
  attempts = 0;
10
11
  state = new TerminalRelayState();
12
+ sender;
13
+ pausedTerminals = new Set();
11
14
  constructor(options) {
12
15
  this.options = options;
13
16
  options.manager.onFrame((frame) => this.publish(frame));
@@ -35,6 +38,7 @@ export class NodeTerminalChannel {
35
38
  socket.once('error', () => undefined);
36
39
  }
37
40
  disconnect() {
41
+ this.releaseAttachments();
38
42
  this.socket?.close();
39
43
  }
40
44
  stop() {
@@ -124,6 +128,7 @@ export class NodeTerminalChannel {
124
128
  const attachment = this.state.attachments.get(message.ticket);
125
129
  if (attachment === undefined)
126
130
  return;
131
+ this.sender?.cancel(message.ticket);
127
132
  this.state.attachments.delete(message.ticket);
128
133
  if (message.type === 'detach')
129
134
  this.options.manager.detach(attachment.attachmentId);
@@ -155,18 +160,12 @@ export class NodeTerminalChannel {
155
160
  return;
156
161
  }
157
162
  void this.options.manager.acceptFrame(attachment.attachmentId, parsed.data).catch(() => {
158
- this.state.attachments.delete(message.ticket);
159
- this.options.manager.detach(attachment.attachmentId);
160
- this.sendFrame(message.ticket, {
161
- type: 'error',
162
- terminalId: attachment.terminalId,
163
- code: 'TERMINAL_FRAME_REJECTED'
164
- });
163
+ this.rejectAttachment(message.ticket, attachment, 'TERMINAL_FRAME_REJECTED');
165
164
  });
166
165
  return;
167
166
  }
168
167
  if (message.type === 'shutdown' && Object.keys(message).length === 1) {
169
- this.state.attachments.clear();
168
+ this.releaseAttachments();
170
169
  void this.options.manager.shutdown();
171
170
  return;
172
171
  }
@@ -177,12 +176,60 @@ export class NodeTerminalChannel {
177
176
  if (attachment.terminalId !== frame.terminalId)
178
177
  continue;
179
178
  this.sendFrame(ticket, frame);
180
- if (frame.type === 'exit')
181
- this.state.attachments.delete(ticket);
182
179
  }
183
180
  }
184
181
  sendFrame(ticket, frame) {
185
- this.send({ type: 'frame', ticket, frame });
182
+ const socket = this.socket;
183
+ if (socket === undefined || socket.readyState !== socket.OPEN)
184
+ return;
185
+ this.sender ??= new TerminalRelaySender(socket, {
186
+ onPendingChange: (keys) => this.updatePausedTerminals(keys)
187
+ });
188
+ const attachment = this.state.attachments.get(ticket);
189
+ void this.sender.send(JSON.stringify({ type: 'frame', ticket, frame }), ticket).then(() => {
190
+ if (frame.type === 'exit' && this.state.attachments.get(ticket) === attachment)
191
+ this.state.attachments.delete(ticket);
192
+ }, (error) => {
193
+ if (this.state.attachments.get(ticket) !== attachment || attachment === undefined)
194
+ return;
195
+ if (error?.code === 'TERMINAL_RELAY_BUFFER_EXCEEDED')
196
+ this.rejectAttachment(ticket, attachment, 'TERMINAL_BACKPRESSURE');
197
+ else
198
+ socket.close(1011, 'TERMINAL_SEND_FAILED');
199
+ });
200
+ }
201
+ updatePausedTerminals(keys) {
202
+ const pending = new Map();
203
+ for (const key of keys) {
204
+ const attachment = this.state.attachments.get(key);
205
+ if (attachment !== undefined) {
206
+ const ids = pending.get(attachment.terminalId) ?? new Set();
207
+ ids.add(attachment.attachmentId);
208
+ pending.set(attachment.terminalId, ids);
209
+ }
210
+ }
211
+ for (const terminalId of this.pausedTerminals) {
212
+ if (pending.has(terminalId))
213
+ continue;
214
+ this.pausedTerminals.delete(terminalId);
215
+ this.options.manager.setOutputBackpressure(terminalId, new Set());
216
+ }
217
+ for (const [terminalId, attachmentIds] of pending) {
218
+ this.pausedTerminals.add(terminalId);
219
+ this.options.manager.setOutputBackpressure(terminalId, attachmentIds);
220
+ }
221
+ }
222
+ rejectAttachment(ticket, attachment, code) {
223
+ if (this.state.attachments.get(ticket) !== attachment)
224
+ return;
225
+ this.state.attachments.delete(ticket);
226
+ this.sender?.cancel(ticket);
227
+ this.options.manager.detach(attachment.attachmentId);
228
+ this.send({
229
+ type: 'frame',
230
+ ticket,
231
+ frame: { type: 'error', terminalId: attachment.terminalId, code }
232
+ });
186
233
  }
187
234
  send(message) {
188
235
  const socket = this.socket;
@@ -202,5 +249,7 @@ export class NodeTerminalChannel {
202
249
  for (const attachment of this.state.attachments.values())
203
250
  this.options.manager.detach(attachment.attachmentId);
204
251
  this.state.attachments.clear();
252
+ this.sender?.close();
253
+ this.sender = undefined;
205
254
  }
206
255
  }
package/dist/terminal.js CHANGED
@@ -103,6 +103,8 @@ export class TerminalManager {
103
103
  if (terminal.binary) {
104
104
  const pipe = this.pipeFactory(deferred.shell, deferred.args, deferred.options);
105
105
  terminal.pipe = pipe;
106
+ if (terminal.outputPaused)
107
+ pipe.pause?.();
106
108
  pipe.onData((data) => this.recordBinaryOutput(terminal, data));
107
109
  pipe.onExit((exitCode) => this.exited(terminal, exitCode));
108
110
  }
@@ -204,6 +206,7 @@ export class TerminalManager {
204
206
  this.attachments.set(attachmentId, { terminalId: terminal.id, readonly });
205
207
  if (!readonly)
206
208
  terminal.writerAttachmentId = attachmentId;
209
+ this.updateOutputPause(terminal);
207
210
  terminal.updatedAt = this.now();
208
211
  terminal.lastUsedAt = terminal.updatedAt;
209
212
  terminal.detachedAt = undefined;
@@ -238,6 +241,8 @@ export class TerminalManager {
238
241
  const terminal = this.terminals.get(attachment.terminalId);
239
242
  if (terminal?.writerAttachmentId === attachmentId)
240
243
  terminal.writerAttachmentId = undefined;
244
+ if (terminal !== undefined)
245
+ this.updateOutputPause(terminal);
241
246
  if (terminal !== undefined && !this.hasAttachment(terminal.id)) {
242
247
  terminal.detachedAt = this.now();
243
248
  terminal.lastUsedAt = terminal.detachedAt;
@@ -324,6 +329,30 @@ export class TerminalManager {
324
329
  this.frameListeners.add(listener);
325
330
  return () => this.frameListeners.delete(listener);
326
331
  }
332
+ /** A relay can pause only its own pipe writer, never an unrelated direct writer or PTY. */
333
+ setOutputBackpressure(terminalId, attachmentIds) {
334
+ const terminal = this.terminals.get(terminalId);
335
+ if (terminal === undefined)
336
+ return;
337
+ if (attachmentIds.size === 0)
338
+ delete terminal.outputBackpressureAttachments;
339
+ else
340
+ terminal.outputBackpressureAttachments = new Set(attachmentIds);
341
+ this.updateOutputPause(terminal);
342
+ }
343
+ updateOutputPause(terminal) {
344
+ const paused = terminal.state === 'RUNNING' &&
345
+ terminal.binary &&
346
+ terminal.writerAttachmentId !== undefined &&
347
+ terminal.outputBackpressureAttachments?.has(terminal.writerAttachmentId) === true;
348
+ if (Boolean(terminal.outputPaused) === paused)
349
+ return;
350
+ terminal.outputPaused = paused;
351
+ if (paused)
352
+ terminal.pipe?.pause?.();
353
+ else
354
+ terminal.pipe?.resume?.();
355
+ }
327
356
  onSummary(listener) {
328
357
  this.summaryListeners.add(listener);
329
358
  return () => this.summaryListeners.delete(listener);
@@ -468,6 +497,8 @@ export class TerminalManager {
468
497
  this.attachments.delete(attachmentId);
469
498
  }
470
499
  terminal.writerAttachmentId = undefined;
500
+ delete terminal.outputBackpressureAttachments;
501
+ this.updateOutputPause(terminal);
471
502
  terminal.detachedAt = undefined;
472
503
  }
473
504
  emit(frame) {
@@ -498,6 +529,8 @@ function nodePipeFactory(shell, args, options) {
498
529
  return {
499
530
  pid: child.pid,
500
531
  kill: (signal) => child.kill(signal),
532
+ pause: () => child.stdout.pause(),
533
+ resume: () => child.stdout.resume(),
501
534
  write: (bytes) => {
502
535
  if (child.stdin.destroyed)
503
536
  throw new Error('TERMINAL_PIPE_CLOSED');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.165",
3
+ "version": "0.9.167",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "playwright": "1.62.1",
37
37
  "ws": "^8.21.3",
38
38
  "zod": "4.4.3",
39
- "@myagentroam/agent": "0.9.165",
39
+ "@myagentroam/agent": "0.9.167",
40
40
  "@myagentroam/code-navigation": "0.9.129",
41
- "@myagentroam/marctl": "0.9.120",
42
- "@myagentroam/protocol": "0.9.165"
41
+ "@myagentroam/protocol": "0.9.167",
42
+ "@myagentroam/marctl": "0.9.121"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/ws": "^8.18.1"