@myagentroam/node 0.13.0 → 0.13.2

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/dist/connector.js CHANGED
@@ -1677,6 +1677,7 @@ export class NodeConnector {
1677
1677
  this.capabilities = await this.conversationHosting.project(this.capabilities);
1678
1678
  this.computerUseEnablement.initialize();
1679
1679
  this.directTransferService ??= await this.createDirectTransferService();
1680
+ await this.terminalManager.warmup();
1680
1681
  this.connectionLifecycle.start();
1681
1682
  this.controlChannel.connect();
1682
1683
  }
@@ -1 +1 @@
1
- {"type":"module","version":"0.13.0"}
1
+ {"type":"module","version":"0.13.2"}
@@ -84,28 +84,34 @@ export class MarAgentHostedConversationRuntime {
84
84
  let cursor = input.cursor === undefined
85
85
  ? undefined
86
86
  : decodeHostedHistoryCursor(input.cursor, input.sessionId);
87
- let entries = [];
88
- let turns = projectMarAgentHistory(historySession(input), entries);
87
+ const pages = [];
88
+ const turnIds = new Set();
89
89
  do {
90
90
  const page = await catalog.history(input.sessionId, {
91
91
  limit: Math.min(1000, Math.max(100, limit * 20)),
92
92
  ...(cursor === undefined ? {} : { cursor })
93
93
  });
94
- entries = [...page.entries, ...entries];
94
+ pages.push(page.entries);
95
+ for (const entry of page.entries) {
96
+ const turnId = historyEntryTurnId(entry);
97
+ if (turnId !== undefined)
98
+ turnIds.add(turnId);
99
+ }
95
100
  cursor = page.nextCursor;
96
- turns = projectMarAgentHistory(historySession(input), entries);
97
- } while (cursor !== undefined && turns.length <= limit);
101
+ } while (cursor !== undefined && turnIds.size <= limit);
102
+ // Pages arrive newest-first. Materialize and normalize their history only once.
103
+ const entries = pages.reverse().flat();
104
+ const turns = projectMarAgentHistory(historySession(input), entries, {
105
+ registerInputImages: (entry) => this.#registerInputImages(input, entry),
106
+ registerGeneratedImages: (sessionId, images) => this.#registerGeneratedImages(input, sessionId, images)
107
+ });
98
108
  const selected = turns.slice(-limit);
99
109
  const earliestTurnId = selected[0]?.id;
100
110
  const earliestSequence = earliestTurnId === undefined
101
111
  ? undefined
102
112
  : entries.find((entry) => historyEntryTurnId(entry) === earliestTurnId)?.sequence;
103
- const projected = projectMarAgentHistory(historySession(input), entries, {
104
- registerInputImages: (entry) => this.#registerInputImages(input, entry),
105
- registerGeneratedImages: (sessionId, images) => this.#registerGeneratedImages(input, sessionId, images)
106
- }).slice(-limit);
107
113
  return {
108
- turns: projected,
114
+ turns: selected,
109
115
  nextCursor: turns.length > selected.length && earliestSequence !== undefined
110
116
  ? encodeHostedHistoryCursor(input.sessionId, earliestSequence)
111
117
  : null
@@ -0,0 +1,103 @@
1
+ import { spawn } from 'node-pty';
2
+ import { writeSync } from 'node:fs';
3
+ import { terminatePtyTree } from './terminal-pty-tree.mjs';
4
+
5
+ /** Owns exactly one native PTY. No replay, attachment or Node service state belongs here. */
6
+ let pty;
7
+ let finishing = false;
8
+ let stopping = false;
9
+ let orphaning = false;
10
+ const pipeWait = new Int32Array(new SharedArrayBuffer(4));
11
+
12
+ function output(data) {
13
+ if (orphaning) return;
14
+ const bytes = Buffer.from(data);
15
+ let offset = 0;
16
+ // Kernel backpressure blocks only this isolated host, not the main Node. Unlike
17
+ // async stdout.write, it cannot accumulate an unbounded queue or run PTY exit
18
+ // timers while output is paused. Interactive writes normally finish immediately.
19
+ while (offset < bytes.length) {
20
+ try {
21
+ offset += writeSync(1, bytes, offset, bytes.length - offset);
22
+ } catch (error) {
23
+ if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') Atomics.wait(pipeWait, 0, 0, 1);
24
+ else {
25
+ orphaned();
26
+ return;
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ function send(message, complete = () => {}) {
33
+ if (process.connected) process.send(message, complete);
34
+ else complete();
35
+ }
36
+
37
+ function finish(event) {
38
+ if (finishing || orphaning) return;
39
+ finishing = true;
40
+ // Explicit exit also releases native addon threads/handles after normal PTY cleanup.
41
+ // Synchronous output is already flushed; parent close joins the pipe before exit.
42
+ send({ type: 'exit', event }, () => process.exit(0));
43
+ }
44
+
45
+ function stop() {
46
+ if (stopping || finishing) return;
47
+ stopping = true;
48
+ // The parent has its own deadline even if a native kill blocks this event loop.
49
+ try {
50
+ if (pty === undefined) finish({ exitCode: 127 });
51
+ else pty.kill();
52
+ } catch {
53
+ send({ type: 'error', code: 'TERMINAL_PTY_HOST_FAILED' });
54
+ // Parent force-close owns final process-tree recovery.
55
+ }
56
+ }
57
+
58
+ function orphaned() {
59
+ if (finishing || orphaning) return;
60
+ orphaning = true;
61
+ stopping = true;
62
+ // No synchronous addon call here: there is no parent left to enforce its deadline.
63
+ void terminatePtyTree(pty?.pid).then(
64
+ () => process.exit(0),
65
+ () => process.exit(1)
66
+ );
67
+ }
68
+
69
+ process.on('disconnect', orphaned);
70
+ process.on('SIGTERM', orphaned);
71
+ process.on('SIGINT', orphaned);
72
+ process.on('message', (message) => {
73
+ if (finishing || orphaning) return;
74
+ try {
75
+ switch (message.type) {
76
+ case 'start':
77
+ pty = spawn(message.shell, message.args, message.options);
78
+ // Keep draining native output, including its final chunks. Network backpressure
79
+ // belongs to the main Node's existing bounded relay, not this local pipe.
80
+ pty.onData(output);
81
+ pty.onExit(finish);
82
+ send({ type: 'started', pid: pty.pid });
83
+ break;
84
+ case 'input':
85
+ if (!stopping) pty.write(message.data);
86
+ break;
87
+ case 'resize':
88
+ if (!stopping) pty.resize(message.cols, message.rows);
89
+ break;
90
+ case 'kill':
91
+ stop();
92
+ break;
93
+ }
94
+ } catch {
95
+ send({
96
+ type: 'error',
97
+ code: message.type === 'start' ? 'TERMINAL_PTY_START_FAILED' : 'TERMINAL_PTY_HOST_FAILED'
98
+ });
99
+ if (pty === undefined) finish({ exitCode: 127 });
100
+ else stop();
101
+ }
102
+ });
103
+ send({ type: 'ready' });
@@ -0,0 +1,50 @@
1
+ import { TerminalPtyProcess } from './terminal-pty.js';
2
+ /** Owns one warm, unassigned host. Active hosts transfer to TerminalManager's proxies. */
3
+ export class TerminalPtyPool {
4
+ standby;
5
+ refillTimer;
6
+ closing;
7
+ warmup() {
8
+ this.refill();
9
+ if (this.standby === undefined)
10
+ return Promise.reject(new Error('TERMINAL_PTY_CLOSED'));
11
+ return this.standby.ready;
12
+ }
13
+ open(shell, args, options) {
14
+ if (this.closing !== undefined)
15
+ throw new Error('TERMINAL_PTY_CLOSED');
16
+ clearTimeout(this.refillTimer);
17
+ const host = this.standby ?? new TerminalPtyProcess();
18
+ // A rejected handoff remains owned here until its physical close settles.
19
+ this.standby = host;
20
+ const active = host.start(shell, args, options);
21
+ this.standby = undefined;
22
+ // Handoff changes ownership; ending an active host must never refill the pool.
23
+ this.refill();
24
+ return active;
25
+ }
26
+ close() {
27
+ if (this.closing !== undefined)
28
+ return this.closing;
29
+ clearTimeout(this.refillTimer);
30
+ const host = this.standby;
31
+ this.standby = undefined;
32
+ this.closing = host?.closed ?? Promise.resolve();
33
+ host?.kill();
34
+ return this.closing;
35
+ }
36
+ refill() {
37
+ if (this.closing !== undefined || this.standby !== undefined)
38
+ return;
39
+ const host = new TerminalPtyProcess();
40
+ this.standby = host;
41
+ const ended = () => {
42
+ if (this.standby !== host)
43
+ return;
44
+ this.standby = undefined;
45
+ // Avoid a hot spawn loop when the runtime or native installation is broken.
46
+ this.refillTimer = setTimeout(() => this.refill(), 1_000);
47
+ };
48
+ void host.closed.then(ended, ended);
49
+ }
50
+ }
@@ -0,0 +1,49 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execute = promisify(execFile);
5
+
6
+ /** Reaps the native PTY tree without entering the potentially blocked addon. */
7
+ export async function terminatePtyTree(pid) {
8
+ if (pid === undefined) return;
9
+ if (process.platform === 'win32') {
10
+ try {
11
+ await execute('taskkill', ['/pid', String(pid), '/T', '/F'], {
12
+ windowsHide: true,
13
+ timeout: 2_000
14
+ });
15
+ } catch (error) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ } catch (probe) {
19
+ if (probe.code === 'ESRCH') return;
20
+ throw probe;
21
+ }
22
+ throw error;
23
+ }
24
+ return;
25
+ }
26
+ // Foreground jobs can have a different group from their shell, on both POSIX platforms.
27
+ const { stdout } = await execute('ps', ['-A', '-o', 'pid=,ppid='], { timeout: 1_000 });
28
+ const rows = stdout
29
+ .trim()
30
+ .split('\n')
31
+ .map((line) => line.trim().split(/\s+/u).map(Number));
32
+ const tree = [pid];
33
+ const seen = new Set(tree);
34
+ for (let index = 0; index < tree.length; index++) {
35
+ for (const [child, parent] of rows) {
36
+ if (parent === tree[index] && !seen.has(child)) {
37
+ tree.push(child);
38
+ seen.add(child);
39
+ }
40
+ }
41
+ }
42
+ for (const target of tree.reverse()) {
43
+ try {
44
+ process.kill(target, 'SIGKILL');
45
+ } catch (error) {
46
+ if (error.code !== 'ESRCH') throw error;
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,278 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { terminatePtyTree } from './terminal-pty-tree.mjs';
4
+ const CLOSE_TIMEOUT_MS = 2_000;
5
+ const START_TIMEOUT_MS = 10_000;
6
+ const MAX_PENDING_INPUT_BYTES = 1024 * 1024;
7
+ /** Owns one PTY host and its IPC, not terminal state, attachments or replay. */
8
+ export class TerminalPtyProcess {
9
+ pid;
10
+ closed;
11
+ ready;
12
+ child;
13
+ dataListeners = new Set();
14
+ exitListeners = new Set();
15
+ errorListeners = new Set();
16
+ closeFailures = [];
17
+ closing = false;
18
+ closeTimer;
19
+ startTimer;
20
+ nativePid;
21
+ exit;
22
+ failure;
23
+ pendingInputBytes = 0;
24
+ started = false;
25
+ forcing = false;
26
+ nativeCleanup;
27
+ rejectReadiness;
28
+ constructor(child = spawn(process.execPath, [fileURLToPath(new URL('./terminal-pty-host.mjs', import.meta.url))], {
29
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
30
+ windowsHide: true
31
+ })) {
32
+ this.child = child;
33
+ this.pid = child.pid;
34
+ child.stdout.setEncoding('utf8');
35
+ child.stdout.on('data', (data) => {
36
+ for (const listener of this.dataListeners)
37
+ listener(data);
38
+ });
39
+ // Native diagnostics must not block the host or expose its spawn environment.
40
+ child.stderr.resume();
41
+ let resolveReady;
42
+ this.ready = new Promise((resolve, reject) => {
43
+ resolveReady = resolve;
44
+ this.rejectReadiness = reject;
45
+ });
46
+ // A standby can be closed without a caller waiting for warmup.
47
+ void this.ready.catch(() => undefined);
48
+ child.on('message', (message) => {
49
+ if (message.type === 'ready') {
50
+ if (this.closing || this.failure !== undefined)
51
+ return;
52
+ if (!this.started)
53
+ clearTimeout(this.startTimer);
54
+ resolveReady();
55
+ this.rejectReadiness = undefined;
56
+ }
57
+ if (message.type === 'started') {
58
+ this.nativePid = message.pid;
59
+ clearTimeout(this.startTimer);
60
+ }
61
+ if (message.type === 'exit') {
62
+ this.exit = message.event;
63
+ // The native process is reaped; never signal this PID after it can be reused.
64
+ this.nativePid = undefined;
65
+ }
66
+ else if (message.type === 'error') {
67
+ this.reportError(message.code);
68
+ this.kill();
69
+ }
70
+ });
71
+ child.on('error', () => {
72
+ this.reportError('TERMINAL_PTY_HOST_FAILED');
73
+ this.kill();
74
+ });
75
+ this.closed = new Promise((resolve, reject) => {
76
+ // Unlike exit, close also joins stdout: no terminal exit can overtake tail output.
77
+ child.once('close', async (code, signal) => {
78
+ clearTimeout(this.closeTimer);
79
+ clearTimeout(this.startTimer);
80
+ this.closeTimer = undefined;
81
+ this.startTimer = undefined;
82
+ this.child = undefined;
83
+ const abnormal = this.exit === undefined || code !== 0 || signal !== null;
84
+ const unexpected = abnormal && !this.forcing;
85
+ let cleanupFailed = false;
86
+ if (abnormal || this.nativeCleanup !== undefined) {
87
+ // A crashed host cannot run its disconnect cleanup; finish its native lease here.
88
+ try {
89
+ await this.reapNative();
90
+ }
91
+ catch (error) {
92
+ cleanupFailed = true;
93
+ this.closeFailures.push(error);
94
+ }
95
+ }
96
+ this.failReadiness(this.failure ?? 'TERMINAL_PTY_HOST_FAILED');
97
+ try {
98
+ if (unexpected || cleanupFailed)
99
+ this.reportError('TERMINAL_PTY_HOST_FAILED');
100
+ const event = unexpected || this.failure !== undefined
101
+ ? { exitCode: 127 }
102
+ : (this.exit ?? { exitCode: 127 });
103
+ for (const listener of this.exitListeners) {
104
+ try {
105
+ listener(event);
106
+ }
107
+ catch (error) {
108
+ this.closeFailures.push(error);
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ this.dataListeners.clear();
114
+ this.exitListeners.clear();
115
+ this.errorListeners.clear();
116
+ child.removeAllListeners();
117
+ child.stdout.removeAllListeners();
118
+ this.nativeCleanup = undefined;
119
+ const failures = this.closeFailures.splice(0);
120
+ if (failures.length === 0)
121
+ resolve();
122
+ else {
123
+ const error = failures.length === 1
124
+ ? failures[0]
125
+ : new AggregateError(failures, 'TERMINAL_PTY_CLOSE_FAILED');
126
+ for (const failure of [...failures, error]) {
127
+ if (failure instanceof Error) {
128
+ try {
129
+ void failure.stack;
130
+ }
131
+ catch {
132
+ /* Formatters must not prevent cleanup settlement. */
133
+ }
134
+ }
135
+ }
136
+ reject(error);
137
+ }
138
+ }
139
+ });
140
+ });
141
+ // Natural exit reports cleanup failures through onError even without a join caller.
142
+ void this.closed.catch(() => undefined);
143
+ this.watchStartup();
144
+ }
145
+ start(shell, args, options) {
146
+ if (this.started || this.closing || this.child === undefined)
147
+ throw new Error('TERMINAL_PTY_CLOSED');
148
+ this.started = true;
149
+ this.watchStartup();
150
+ // Private IPC keeps command arguments and secret environment values out of argv.
151
+ this.send({ type: 'start', shell, args, options });
152
+ return this;
153
+ }
154
+ write(data) {
155
+ const bytes = Buffer.byteLength(data);
156
+ if (this.pendingInputBytes + bytes > MAX_PENDING_INPUT_BYTES)
157
+ throw new Error('TERMINAL_BACKPRESSURE');
158
+ this.pendingInputBytes += bytes;
159
+ try {
160
+ this.send({ type: 'input', data }, () => {
161
+ this.pendingInputBytes -= bytes;
162
+ });
163
+ }
164
+ catch (error) {
165
+ this.pendingInputBytes -= bytes;
166
+ throw error;
167
+ }
168
+ }
169
+ resize(cols, rows) {
170
+ this.send({ type: 'resize', cols, rows });
171
+ }
172
+ kill() {
173
+ if (this.child === undefined)
174
+ return false;
175
+ if (this.closing)
176
+ return true;
177
+ this.closing = true;
178
+ this.closeTimer = setTimeout(() => {
179
+ void this.forceClose();
180
+ }, CLOSE_TIMEOUT_MS);
181
+ this.send({ type: 'kill' });
182
+ return true;
183
+ }
184
+ onData(listener) {
185
+ this.dataListeners.add(listener);
186
+ return {
187
+ dispose: () => {
188
+ this.dataListeners.delete(listener);
189
+ }
190
+ };
191
+ }
192
+ onExit(listener) {
193
+ this.exitListeners.add(listener);
194
+ return {
195
+ dispose: () => {
196
+ this.exitListeners.delete(listener);
197
+ }
198
+ };
199
+ }
200
+ onError(listener) {
201
+ this.errorListeners.add(listener);
202
+ return {
203
+ dispose: () => {
204
+ this.errorListeners.delete(listener);
205
+ }
206
+ };
207
+ }
208
+ send(message, complete) {
209
+ const child = this.child;
210
+ if (child === undefined)
211
+ throw new Error('TERMINAL_PTY_CLOSED');
212
+ if (!child.connected) {
213
+ complete?.();
214
+ return;
215
+ }
216
+ child.send(message, (error) => {
217
+ complete?.();
218
+ if (error !== null && !this.closing) {
219
+ this.reportError('TERMINAL_PTY_HOST_FAILED');
220
+ this.kill();
221
+ }
222
+ });
223
+ }
224
+ reportError(code) {
225
+ if (this.failure !== undefined)
226
+ return;
227
+ this.failure = code;
228
+ for (const listener of this.errorListeners) {
229
+ try {
230
+ listener(code);
231
+ }
232
+ catch (error) {
233
+ this.closeFailures.push(error);
234
+ }
235
+ }
236
+ }
237
+ watchStartup() {
238
+ clearTimeout(this.startTimer);
239
+ this.startTimer = setTimeout(() => {
240
+ this.failReadiness('TERMINAL_PTY_START_FAILED');
241
+ this.reportError('TERMINAL_PTY_START_FAILED');
242
+ this.kill();
243
+ }, START_TIMEOUT_MS);
244
+ }
245
+ failReadiness(code) {
246
+ if (this.rejectReadiness === undefined)
247
+ return;
248
+ const error = new Error(code);
249
+ try {
250
+ void error.stack;
251
+ }
252
+ catch {
253
+ /* Keep the original startup error even if a custom formatter fails. */
254
+ }
255
+ this.rejectReadiness(error);
256
+ this.rejectReadiness = undefined;
257
+ }
258
+ reapNative(root = this.nativePid) {
259
+ return (this.nativeCleanup ??= terminatePtyTree(root));
260
+ }
261
+ async forceClose() {
262
+ const child = this.child;
263
+ if (child === undefined)
264
+ return;
265
+ this.forcing = true;
266
+ try {
267
+ await this.reapNative(this.nativePid ?? child.pid);
268
+ }
269
+ catch {
270
+ if (this.child === child)
271
+ this.reportError('TERMINAL_PTY_HOST_FAILED');
272
+ }
273
+ finally {
274
+ if (this.child === child)
275
+ child.kill('SIGKILL');
276
+ }
277
+ }
278
+ }
package/dist/terminal.js CHANGED
@@ -4,10 +4,11 @@ import { chmodSync, existsSync, statSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
- import { spawn } from 'node-pty';
8
7
  import { terminalDataFrameSchema } from '@myagentroam/protocol';
9
8
  import { nodeExecutionEnvironment } from './node-execution-environment.js';
10
9
  import { terminateProcessTree } from './process-tree.js';
10
+ import { TerminalPtyProcess } from './terminal-pty.js';
11
+ import { TerminalPtyPool } from './terminal-pty-pool.js';
11
12
  const MAX_FRAME_CHARACTERS = 65_536;
12
13
  const TERMINAL_COMMAND_MAX_BYTES = 64 * 1024;
13
14
  const DEFAULT_MAX_TERMINALS = 10;
@@ -15,7 +16,10 @@ const DEFAULT_MAX_TERMINALS_PER_WORKSPACE = 5;
15
16
  const DEFAULT_MAX_REPLAY_BYTES = 512 * 1024;
16
17
  const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60_000;
17
18
  /**
18
- * Owns Node-local PTY/ConPTY processes and their bounded output replay.
19
+ * Owns terminal state, attachments and bounded output replay. PTY proxies own their
20
+ * isolated hosts; binary pipes keep their separate lifecycle. Native PTY details
21
+ * belong only to the host, never to this service or its browser relay callers.
22
+ * The owned standby pool activates at warmup and transfers each host to its proxy.
19
23
  * Browser relay transport intentionally lives outside this class: callers pass
20
24
  * only schema-validated TerminalDataFrame values after Server authorization.
21
25
  */
@@ -29,6 +33,8 @@ export class TerminalManager {
29
33
  idleTimeoutMs;
30
34
  now;
31
35
  ptyFactory;
36
+ ptyPool;
37
+ pooledPty;
32
38
  pipeFactory;
33
39
  terminate;
34
40
  terminals = new Map();
@@ -65,7 +71,10 @@ export class TerminalManager {
65
71
  throw new Error('TERMINAL_CONFIG_INVALID');
66
72
  }
67
73
  this.now = options.now ?? Date.now;
68
- this.ptyFactory = options.ptyFactory ?? nodePtyFactory;
74
+ this.pooledPty = options.ptyFactory === undefined;
75
+ this.ptyFactory =
76
+ options.ptyFactory ??
77
+ ((shell, args, spawnOptions) => this.requirePtyPool().open(shell, args, spawnOptions));
69
78
  this.pipeFactory = options.pipeFactory ?? nodePipeFactory;
70
79
  this.terminate =
71
80
  options.terminate ?? ((child, platform) => terminateProcessTree(child, platform));
@@ -113,8 +122,7 @@ export class TerminalManager {
113
122
  else {
114
123
  const pty = this.ptyFactory(deferred.shell, deferred.args, deferred.options);
115
124
  terminal.pty = pty;
116
- pty.onData((data) => this.recordOutput(terminal, data));
117
- pty.onExit((event) => this.exited(terminal, event.exitCode));
125
+ this.listenToPty(terminal, pty);
118
126
  }
119
127
  }
120
128
  catch {
@@ -160,6 +168,7 @@ export class TerminalManager {
160
168
  profile,
161
169
  pty,
162
170
  pipe: undefined,
171
+ subscriptions: [],
163
172
  binary,
164
173
  ...(commandArgs === undefined
165
174
  ? {}
@@ -178,8 +187,8 @@ export class TerminalManager {
178
187
  detachedAt: createdAt
179
188
  };
180
189
  this.terminals.set(terminal.id, terminal);
181
- pty?.onData((data) => this.recordOutput(terminal, data));
182
- pty?.onExit((event) => this.exited(terminal, event.exitCode));
190
+ if (pty !== undefined)
191
+ this.listenToPty(terminal, pty);
183
192
  return this.notifySummary(terminal);
184
193
  }
185
194
  list() {
@@ -327,8 +336,7 @@ export class TerminalManager {
327
336
  });
328
337
  this.closing.set(terminalId, closing);
329
338
  void (async () => {
330
- this.detachAll(terminal);
331
- const summary = this.notifySummary(terminal);
339
+ const { summary, failures } = this.detachAndNotify(terminal);
332
340
  try {
333
341
  if (terminal.pty !== undefined) {
334
342
  // Native PTY hangup reaches POSIX foreground jobs in a separate process group
@@ -337,15 +345,31 @@ export class TerminalManager {
337
345
  terminal.pty.kill();
338
346
  }
339
347
  catch {
340
- await this.terminate(terminal.pty, this.platform);
348
+ try {
349
+ await this.terminate(terminal.pty, this.platform);
350
+ }
351
+ catch (error) {
352
+ failures.push(error);
353
+ }
341
354
  }
355
+ await terminal.pty.closed;
342
356
  }
343
357
  if (terminal.pipe !== undefined)
344
358
  await this.terminate(terminal.pipe, this.platform);
345
359
  }
360
+ catch (error) {
361
+ failures.push(error);
362
+ }
346
363
  finally {
347
- this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
364
+ this.releaseTerminalResources(terminal);
365
+ try {
366
+ this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
367
+ }
368
+ catch (error) {
369
+ failures.push(error);
370
+ }
348
371
  }
372
+ this.throwCleanupFailures(failures);
349
373
  return summary;
350
374
  })().then((summary) => {
351
375
  this.closing.delete(terminalId);
@@ -358,15 +382,30 @@ export class TerminalManager {
358
382
  }
359
383
  /** Ends current terminals without disposing the manager's idle collection. */
360
384
  async closeAll() {
361
- await Promise.all([...this.terminals.values()]
385
+ const results = await Promise.allSettled([...this.terminals.values()]
362
386
  .filter((terminal) => terminal.state === 'RUNNING' || this.closing.has(terminal.id))
363
387
  .map((terminal) => this.close(terminal.id)));
388
+ this.throwCleanupFailures(results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])));
389
+ }
390
+ /** Activates the standby only at Node startup, never during connector composition. */
391
+ warmup() {
392
+ return this.pooledPty ? this.requirePtyPool().warmup() : Promise.resolve();
393
+ }
394
+ requirePtyPool() {
395
+ repairPtySpawnHelper();
396
+ return (this.ptyPool ??= new TerminalPtyPool());
364
397
  }
365
398
  async shutdown() {
366
399
  if (this.idleTimer !== undefined)
367
400
  clearInterval(this.idleTimer);
368
401
  this.idleTimer = undefined;
369
- await this.closeAll();
402
+ const pool = this.ptyPool;
403
+ const standbyDrain = pool?.close();
404
+ const results = await Promise.allSettled([this.closeAll(), standbyDrain]);
405
+ // Concurrent shutdown callers must keep joining the same owned standby.
406
+ if (this.ptyPool === pool)
407
+ this.ptyPool = undefined;
408
+ this.throwCleanupFailures(results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])));
370
409
  }
371
410
  onFrame(listener) {
372
411
  this.frameListeners.add(listener);
@@ -461,9 +500,117 @@ export class TerminalManager {
461
500
  terminal.state = 'EXITED';
462
501
  terminal.exitCode = exitCode;
463
502
  terminal.updatedAt = this.now();
464
- this.detachAll(terminal);
465
- this.notifySummary(terminal);
466
- this.emit({ type: 'exit', terminalId: terminal.id, exitCode });
503
+ const { failures } = this.detachAndNotify(terminal);
504
+ try {
505
+ this.releaseTerminalResources(terminal);
506
+ this.emit({ type: 'exit', terminalId: terminal.id, exitCode });
507
+ }
508
+ catch (error) {
509
+ failures.push(error);
510
+ }
511
+ this.throwCleanupFailures(failures);
512
+ }
513
+ throwCleanupFailures(failures) {
514
+ if (failures.length === 0)
515
+ return;
516
+ const failure = failures.length === 1
517
+ ? failures[0]
518
+ : new AggregateError(failures, 'Terminal cleanup did not complete.');
519
+ const pending = [failure];
520
+ const seen = new Set();
521
+ while (pending.length > 0) {
522
+ const error = pending.pop();
523
+ if (seen.has(error))
524
+ continue;
525
+ seen.add(error);
526
+ try {
527
+ if (!(error instanceof Error))
528
+ continue;
529
+ try {
530
+ // Lazy V8 stacks can retain native receivers even after all subscriptions are disposed.
531
+ void error.stack;
532
+ }
533
+ catch {
534
+ // A custom stack formatter must not replace the original cleanup failure.
535
+ }
536
+ pending.push(error.cause);
537
+ if (error instanceof AggregateError && Array.isArray(error.errors)) {
538
+ for (const nested of error.errors)
539
+ pending.push(nested);
540
+ }
541
+ }
542
+ catch {
543
+ // Preserve errors with custom cause/errors accessors without masking the original failure.
544
+ }
545
+ }
546
+ throw failure;
547
+ }
548
+ detachAndNotify(terminal) {
549
+ const failures = [];
550
+ let summary = this.summary(terminal);
551
+ try {
552
+ this.detachAll(terminal);
553
+ }
554
+ catch (error) {
555
+ failures.push(error);
556
+ }
557
+ try {
558
+ summary = this.notifySummary(terminal);
559
+ }
560
+ catch (error) {
561
+ failures.push(error);
562
+ }
563
+ return { summary, failures };
564
+ }
565
+ listenToPty(terminal, pty) {
566
+ const track = (subscription) => {
567
+ if (terminal.state === 'RUNNING')
568
+ terminal.subscriptions.push(subscription);
569
+ else
570
+ this.disposeSubscription(terminal, subscription);
571
+ };
572
+ track(pty.onData((data) => this.recordOutput(terminal, data)));
573
+ if (pty.onError !== undefined)
574
+ track(pty.onError((code) => this.emit({ type: 'error', terminalId: terminal.id, code })));
575
+ if (terminal.state !== 'RUNNING')
576
+ return;
577
+ let subscribing = true;
578
+ let failure;
579
+ const subscription = pty.onExit((event) => {
580
+ try {
581
+ this.exited(terminal, event.exitCode);
582
+ }
583
+ catch (error) {
584
+ if (!subscribing)
585
+ throw error;
586
+ failure = { error };
587
+ }
588
+ });
589
+ subscribing = false;
590
+ track(subscription);
591
+ if (failure !== undefined)
592
+ throw failure.error;
593
+ }
594
+ disposeSubscription(terminal, subscription) {
595
+ try {
596
+ subscription.dispose();
597
+ }
598
+ catch {
599
+ try {
600
+ this.emit({ type: 'error', terminalId: terminal.id, code: 'TERMINAL_CLEANUP_FAILED' });
601
+ }
602
+ catch {
603
+ // A failing error observer must not prevent the remaining resources from being released.
604
+ }
605
+ }
606
+ }
607
+ releaseTerminalResources(terminal) {
608
+ // Historical summaries and replay must not keep native handles or spawn environments alive.
609
+ terminal.pty = undefined;
610
+ terminal.pipe = undefined;
611
+ delete terminal.deferredCommand;
612
+ for (const subscription of terminal.subscriptions.splice(0))
613
+ this.disposeSubscription(terminal, subscription);
467
614
  }
468
615
  replayFrames(terminal, afterSequence) {
469
616
  const first = terminal.replay[0]?.sequence;
@@ -551,14 +698,7 @@ export class TerminalManager {
551
698
  }
552
699
  export function nodePtyFactory(shell, args, options) {
553
700
  repairPtySpawnHelper();
554
- const pty = spawn(shell, [...args], {
555
- name: options.name,
556
- cols: options.cols,
557
- rows: options.rows,
558
- cwd: options.cwd,
559
- env: options.env
560
- });
561
- return pty;
701
+ return new TerminalPtyProcess().start(shell, args, options);
562
702
  }
563
703
  export function repairPtySpawnHelper(platform = process.platform, architecture = process.arch, packageRoot) {
564
704
  if (platform !== 'darwin')
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
7
7
  "files": [
8
8
  "dist/**/*.js",
9
+ "dist/terminal-pty-host.mjs",
10
+ "dist/terminal-pty-tree.mjs",
9
11
  "dist/marctl/**/*.mjs",
10
12
  "dist/marctl/**/*.cjs",
11
13
  "dist/marctl/package.json",
@@ -42,14 +44,14 @@
42
44
  "turndown-plugin-gfm": "^1.0.2",
43
45
  "ws": "^8.21.3",
44
46
  "zod": "4.4.3",
45
- "@myagentroam/agent": "0.13.0",
46
- "@myagentroam/protocol": "0.13.0"
47
+ "@myagentroam/agent": "0.13.2",
48
+ "@myagentroam/protocol": "0.13.2"
47
49
  },
48
50
  "devDependencies": {
49
51
  "@types/turndown": "^5.0.6",
50
52
  "@types/ws": "^8.18.1",
51
53
  "@myagentroam/code-navigation": "0.11.1",
52
- "@myagentroam/marctl": "0.13.0"
54
+ "@myagentroam/marctl": "0.13.2"
53
55
  },
54
56
  "exports": {
55
57
  "./config": {