@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.
@@ -0,0 +1,235 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { DirectFallbackError, DirectUnsafeError, directControlError } from './direct-errors.mjs';
3
+ import { cancelSession, withTimeout, rawToString, isRecord } from './direct-control.mjs';
4
+
5
+ export class DirectTerminalTransport extends EventEmitter {
6
+ #channel;
7
+ #live;
8
+ #peer;
9
+ #transportSessionId;
10
+ #terminalSessionId;
11
+ #terminalId;
12
+ #cleanupNative;
13
+ #heartbeatTimer;
14
+ #unsubscribeLive;
15
+ #closed = false;
16
+ #attached = false;
17
+ #active = false;
18
+ #pendingEvents = [];
19
+ #attachmentResolve;
20
+ #attachmentReject;
21
+ #openSent = false;
22
+ #cancelSent = false;
23
+
24
+ constructor({
25
+ channel,
26
+ live,
27
+ peer,
28
+ transportSessionId,
29
+ terminalSessionId,
30
+ terminalId,
31
+ heartbeatMs,
32
+ cleanupNative
33
+ }) {
34
+ super();
35
+ this.#channel = channel;
36
+ this.#live = live;
37
+ this.#peer = peer;
38
+ this.#transportSessionId = transportSessionId;
39
+ this.#terminalSessionId = terminalSessionId;
40
+ this.#terminalId = terminalId;
41
+ this.#cleanupNative = cleanupNative;
42
+ this.#channel.onMessage?.((raw) => this.#handleMessage(raw));
43
+ this.#channel.onClosed?.(() => this.#fail('TERMINAL_DIRECT_CLOSED'));
44
+ this.#channel.onError?.(() => this.#fail('TERMINAL_CONNECTION_FAILED'));
45
+ this.#unsubscribeLive = this.#live.onControl((message) => this.#handleControl(message));
46
+ this.#heartbeatTimer = setInterval(() => {
47
+ if (
48
+ !this.#live.send({
49
+ type: 'direct.heartbeat',
50
+ directSessionId: this.#transportSessionId
51
+ }) ||
52
+ !this.#live.send({
53
+ type: 'terminal.direct.heartbeat',
54
+ directSessionId: this.#terminalSessionId
55
+ })
56
+ )
57
+ this.#fail('TERMINAL_CONNECTION_LOST');
58
+ }, heartbeatMs);
59
+ this.#heartbeatTimer.unref?.();
60
+ }
61
+
62
+ get openSent() {
63
+ return this.#openSent;
64
+ }
65
+
66
+ activate() {
67
+ if (this.#active) return;
68
+ this.#active = true;
69
+ const pendingEvents = this.#pendingEvents;
70
+ this.#pendingEvents = [];
71
+ for (const { type, args } of pendingEvents) this.#emit(type, ...args);
72
+ }
73
+
74
+ waitForAttachment(grant) {
75
+ return new Promise((resolve, reject) => {
76
+ if (this.#closed) {
77
+ reject(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
78
+ return;
79
+ }
80
+ this.#attachmentResolve = resolve;
81
+ this.#attachmentReject = reject;
82
+ let opened = false;
83
+ const onOpen = () => {
84
+ if (opened || this.#closed) return;
85
+ opened = true;
86
+ try {
87
+ this.#openSent = true;
88
+ if (
89
+ !this.#channel.sendMessage(
90
+ JSON.stringify({
91
+ type: 'OPEN',
92
+ directSessionId: this.#terminalSessionId,
93
+ grant
94
+ })
95
+ )
96
+ )
97
+ throw new Error('TERMINAL_ATTACH_FAILED');
98
+ } catch {
99
+ this.#fail('TERMINAL_ATTACH_FAILED');
100
+ }
101
+ };
102
+ this.#channel.onOpen?.(onOpen);
103
+ if (this.#channel.isOpen?.() === true) onOpen();
104
+ });
105
+ }
106
+
107
+ send(frame) {
108
+ if (this.#closed || this.#channel.isOpen?.() !== true)
109
+ throw new Error('TERMINAL_CONNECTION_LOST');
110
+ const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
111
+ if (!this.#channel.sendMessage(value)) throw new Error('TERMINAL_INPUT_FAILED');
112
+ }
113
+
114
+ close() {
115
+ this.dispose();
116
+ }
117
+
118
+ dispose({ skipCancel = false } = {}) {
119
+ if (this.#closed) return;
120
+ this.#closed = true;
121
+ clearInterval(this.#heartbeatTimer);
122
+ if (!skipCancel) this.#sendCancel();
123
+ this.#attachmentReject?.(new DirectFallbackError('TERMINAL_DIRECT_CLOSED'));
124
+ this.#attachmentResolve = undefined;
125
+ this.#attachmentReject = undefined;
126
+ this.#unsubscribeLive?.();
127
+ this.#unsubscribeLive = undefined;
128
+ this.#channel.close?.();
129
+ this.#peer.close?.();
130
+ this.#live.close();
131
+ this.#cleanupNative?.();
132
+ this.#emit('close');
133
+ }
134
+
135
+ async cancelAndDispose() {
136
+ const terminalClosed = this.#live.waitFor(
137
+ (message) =>
138
+ message.type === 'terminal.direct.closed' &&
139
+ message.directSessionId === this.#terminalSessionId,
140
+ undefined,
141
+ 'DIRECT_CANCEL_TIMEOUT',
142
+ 1000
143
+ );
144
+ const transportClosed = this.#live.waitFor(
145
+ (message) =>
146
+ message.type === 'direct.closed' && message.directSessionId === this.#transportSessionId,
147
+ undefined,
148
+ 'DIRECT_CANCEL_TIMEOUT',
149
+ 1000
150
+ );
151
+ this.#sendCancel();
152
+ await Promise.allSettled([terminalClosed, transportClosed]);
153
+ this.dispose({ skipCancel: true });
154
+ }
155
+
156
+ #handleMessage(raw) {
157
+ if (this.#closed) return;
158
+ if (!this.#attached) {
159
+ let frame;
160
+ try {
161
+ frame = JSON.parse(rawToString(raw));
162
+ } catch {
163
+ this.#fail('TERMINAL_FRAME_INVALID');
164
+ return;
165
+ }
166
+ if (
167
+ !isRecord(frame) ||
168
+ frame.type !== 'ATTACHED' ||
169
+ frame.terminalId !== this.#terminalId ||
170
+ !Number.isSafeInteger(frame.afterSequence) ||
171
+ frame.afterSequence < 0
172
+ ) {
173
+ this.#fail('TERMINAL_ATTACH_FAILED');
174
+ return;
175
+ }
176
+ this.#attached = true;
177
+ this.#attachmentResolve?.();
178
+ this.#attachmentResolve = undefined;
179
+ this.#attachmentReject = undefined;
180
+ return;
181
+ }
182
+ this.#emit(
183
+ 'message',
184
+ raw,
185
+ typeof raw === 'string'
186
+ ? false
187
+ : Buffer.isBuffer(raw) || raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)
188
+ );
189
+ }
190
+
191
+ #handleControl(message) {
192
+ if (
193
+ (message.type === 'terminal.direct.error' || message.type === 'terminal.direct.closed') &&
194
+ message.directSessionId === this.#terminalSessionId
195
+ )
196
+ this.#fail(message.code);
197
+ if (
198
+ (message.type === 'direct.closed' || message.type === 'direct.error') &&
199
+ message.directSessionId === this.#transportSessionId
200
+ )
201
+ this.#fail(message.code);
202
+ }
203
+
204
+ #fail(code) {
205
+ if (this.#closed) return;
206
+ const error = directControlError(code);
207
+ if (!this.#attached) this.#attachmentReject?.(error);
208
+ this.#attachmentResolve = undefined;
209
+ this.#attachmentReject = undefined;
210
+ this.#emit('error', error);
211
+ this.dispose();
212
+ }
213
+
214
+ #emit(type, ...args) {
215
+ if (!this.#active) {
216
+ this.#pendingEvents.push({ type, args });
217
+ return;
218
+ }
219
+ if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, ...args);
220
+ }
221
+
222
+ #sendCancel() {
223
+ if (this.#cancelSent) return;
224
+ this.#cancelSent = true;
225
+ this.#live.send({
226
+ type: 'terminal.direct.cancel',
227
+ directSessionId: this.#terminalSessionId
228
+ });
229
+ this.#live.send({
230
+ type: 'direct.cancel',
231
+ directSessionId: this.#transportSessionId,
232
+ reason: 'CLI_CLOSED'
233
+ });
234
+ }
235
+ }