@myagentroam/node 0.13.1 → 0.13.3

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.1"}
1
+ {"type":"module","version":"0.13.3"}
@@ -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,13 @@ 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';
9
+ import { nodeLog } from './operational.js';
10
10
  import { terminateProcessTree } from './process-tree.js';
11
+ import { TerminalPtyProcess } from './terminal-pty.js';
12
+ import { TerminalPtyPool } from './terminal-pty-pool.js';
13
+ import { safeErrorCode } from './util/safe-error.js';
11
14
  const MAX_FRAME_CHARACTERS = 65_536;
12
15
  const TERMINAL_COMMAND_MAX_BYTES = 64 * 1024;
13
16
  const DEFAULT_MAX_TERMINALS = 10;
@@ -15,7 +18,10 @@ const DEFAULT_MAX_TERMINALS_PER_WORKSPACE = 5;
15
18
  const DEFAULT_MAX_REPLAY_BYTES = 512 * 1024;
16
19
  const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60_000;
17
20
  /**
18
- * Owns Node-local PTY/ConPTY processes and their bounded output replay.
21
+ * Owns terminal state, attachments and bounded output replay. PTY proxies own their
22
+ * isolated hosts; binary pipes keep their separate lifecycle. Native PTY details
23
+ * belong only to the host, never to this service or its browser relay callers.
24
+ * The owned standby pool activates at warmup and transfers each host to its proxy.
19
25
  * Browser relay transport intentionally lives outside this class: callers pass
20
26
  * only schema-validated TerminalDataFrame values after Server authorization.
21
27
  */
@@ -29,6 +35,8 @@ export class TerminalManager {
29
35
  idleTimeoutMs;
30
36
  now;
31
37
  ptyFactory;
38
+ ptyPool;
39
+ pooledPty;
32
40
  pipeFactory;
33
41
  terminate;
34
42
  terminals = new Map();
@@ -65,11 +73,26 @@ export class TerminalManager {
65
73
  throw new Error('TERMINAL_CONFIG_INVALID');
66
74
  }
67
75
  this.now = options.now ?? Date.now;
68
- this.ptyFactory = options.ptyFactory ?? nodePtyFactory;
76
+ this.pooledPty = options.ptyFactory === undefined;
77
+ this.ptyFactory =
78
+ options.ptyFactory ??
79
+ ((shell, args, spawnOptions) => this.requirePtyPool().open(shell, args, spawnOptions));
69
80
  this.pipeFactory = options.pipeFactory ?? nodePipeFactory;
70
81
  this.terminate =
71
82
  options.terminate ?? ((child, platform) => terminateProcessTree(child, platform));
72
- this.idleTimer = setInterval(() => void this.closeExpired(), Math.min(this.idleTimeoutMs, 60_000));
83
+ this.idleTimer = setInterval(() => {
84
+ void this.closeExpired().catch((error) => {
85
+ try {
86
+ nodeLog('terminal.cleanup.failed', {
87
+ source: 'idle',
88
+ errorCode: safeErrorCode(error, 'TERMINAL_CLEANUP_FAILED')
89
+ });
90
+ }
91
+ catch {
92
+ // Logging must not turn a handled background failure into another rejection.
93
+ }
94
+ });
95
+ }, Math.min(this.idleTimeoutMs, 60_000));
73
96
  this.idleTimer.unref();
74
97
  }
75
98
  availableProfiles() {
@@ -113,8 +136,7 @@ export class TerminalManager {
113
136
  else {
114
137
  const pty = this.ptyFactory(deferred.shell, deferred.args, deferred.options);
115
138
  terminal.pty = pty;
116
- pty.onData((data) => this.recordOutput(terminal, data));
117
- pty.onExit((event) => this.exited(terminal, event.exitCode));
139
+ this.listenToPty(terminal, pty);
118
140
  }
119
141
  }
120
142
  catch {
@@ -160,6 +182,7 @@ export class TerminalManager {
160
182
  profile,
161
183
  pty,
162
184
  pipe: undefined,
185
+ subscriptions: [],
163
186
  binary,
164
187
  ...(commandArgs === undefined
165
188
  ? {}
@@ -178,8 +201,8 @@ export class TerminalManager {
178
201
  detachedAt: createdAt
179
202
  };
180
203
  this.terminals.set(terminal.id, terminal);
181
- pty?.onData((data) => this.recordOutput(terminal, data));
182
- pty?.onExit((event) => this.exited(terminal, event.exitCode));
204
+ if (pty !== undefined)
205
+ this.listenToPty(terminal, pty);
183
206
  return this.notifySummary(terminal);
184
207
  }
185
208
  list() {
@@ -327,8 +350,7 @@ export class TerminalManager {
327
350
  });
328
351
  this.closing.set(terminalId, closing);
329
352
  void (async () => {
330
- this.detachAll(terminal);
331
- const summary = this.notifySummary(terminal);
353
+ const { summary, failures } = this.detachAndNotify(terminal);
332
354
  try {
333
355
  if (terminal.pty !== undefined) {
334
356
  // Native PTY hangup reaches POSIX foreground jobs in a separate process group
@@ -337,15 +359,31 @@ export class TerminalManager {
337
359
  terminal.pty.kill();
338
360
  }
339
361
  catch {
340
- await this.terminate(terminal.pty, this.platform);
362
+ try {
363
+ await this.terminate(terminal.pty, this.platform);
364
+ }
365
+ catch (error) {
366
+ failures.push(error);
367
+ }
341
368
  }
369
+ await terminal.pty.closed;
342
370
  }
343
371
  if (terminal.pipe !== undefined)
344
372
  await this.terminate(terminal.pipe, this.platform);
345
373
  }
374
+ catch (error) {
375
+ failures.push(error);
376
+ }
346
377
  finally {
347
- this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
378
+ this.releaseTerminalResources(terminal);
379
+ try {
380
+ this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
381
+ }
382
+ catch (error) {
383
+ failures.push(error);
384
+ }
348
385
  }
386
+ this.throwCleanupFailures(failures);
349
387
  return summary;
350
388
  })().then((summary) => {
351
389
  this.closing.delete(terminalId);
@@ -358,15 +396,30 @@ export class TerminalManager {
358
396
  }
359
397
  /** Ends current terminals without disposing the manager's idle collection. */
360
398
  async closeAll() {
361
- await Promise.all([...this.terminals.values()]
399
+ const results = await Promise.allSettled([...this.terminals.values()]
362
400
  .filter((terminal) => terminal.state === 'RUNNING' || this.closing.has(terminal.id))
363
401
  .map((terminal) => this.close(terminal.id)));
402
+ this.throwCleanupFailures(results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])));
403
+ }
404
+ /** Activates the standby only at Node startup, never during connector composition. */
405
+ warmup() {
406
+ return this.pooledPty ? this.requirePtyPool().warmup() : Promise.resolve();
407
+ }
408
+ requirePtyPool() {
409
+ repairPtySpawnHelper();
410
+ return (this.ptyPool ??= new TerminalPtyPool());
364
411
  }
365
412
  async shutdown() {
366
413
  if (this.idleTimer !== undefined)
367
414
  clearInterval(this.idleTimer);
368
415
  this.idleTimer = undefined;
369
- await this.closeAll();
416
+ const pool = this.ptyPool;
417
+ const standbyDrain = pool?.close();
418
+ const results = await Promise.allSettled([this.closeAll(), standbyDrain]);
419
+ // Concurrent shutdown callers must keep joining the same owned standby.
420
+ if (this.ptyPool === pool)
421
+ this.ptyPool = undefined;
422
+ this.throwCleanupFailures(results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])));
370
423
  }
371
424
  onFrame(listener) {
372
425
  this.frameListeners.add(listener);
@@ -461,9 +514,117 @@ export class TerminalManager {
461
514
  terminal.state = 'EXITED';
462
515
  terminal.exitCode = exitCode;
463
516
  terminal.updatedAt = this.now();
464
- this.detachAll(terminal);
465
- this.notifySummary(terminal);
466
- this.emit({ type: 'exit', terminalId: terminal.id, exitCode });
517
+ const { failures } = this.detachAndNotify(terminal);
518
+ try {
519
+ this.releaseTerminalResources(terminal);
520
+ this.emit({ type: 'exit', terminalId: terminal.id, exitCode });
521
+ }
522
+ catch (error) {
523
+ failures.push(error);
524
+ }
525
+ this.throwCleanupFailures(failures);
526
+ }
527
+ throwCleanupFailures(failures) {
528
+ if (failures.length === 0)
529
+ return;
530
+ const failure = failures.length === 1
531
+ ? failures[0]
532
+ : new AggregateError(failures, 'Terminal cleanup did not complete.');
533
+ const pending = [failure];
534
+ const seen = new Set();
535
+ while (pending.length > 0) {
536
+ const error = pending.pop();
537
+ if (seen.has(error))
538
+ continue;
539
+ seen.add(error);
540
+ try {
541
+ if (!(error instanceof Error))
542
+ continue;
543
+ try {
544
+ // Lazy V8 stacks can retain native receivers even after all subscriptions are disposed.
545
+ void error.stack;
546
+ }
547
+ catch {
548
+ // A custom stack formatter must not replace the original cleanup failure.
549
+ }
550
+ pending.push(error.cause);
551
+ if (error instanceof AggregateError && Array.isArray(error.errors)) {
552
+ for (const nested of error.errors)
553
+ pending.push(nested);
554
+ }
555
+ }
556
+ catch {
557
+ // Preserve errors with custom cause/errors accessors without masking the original failure.
558
+ }
559
+ }
560
+ throw failure;
561
+ }
562
+ detachAndNotify(terminal) {
563
+ const failures = [];
564
+ let summary = this.summary(terminal);
565
+ try {
566
+ this.detachAll(terminal);
567
+ }
568
+ catch (error) {
569
+ failures.push(error);
570
+ }
571
+ try {
572
+ summary = this.notifySummary(terminal);
573
+ }
574
+ catch (error) {
575
+ failures.push(error);
576
+ }
577
+ return { summary, failures };
578
+ }
579
+ listenToPty(terminal, pty) {
580
+ const track = (subscription) => {
581
+ if (terminal.state === 'RUNNING')
582
+ terminal.subscriptions.push(subscription);
583
+ else
584
+ this.disposeSubscription(terminal, subscription);
585
+ };
586
+ track(pty.onData((data) => this.recordOutput(terminal, data)));
587
+ if (pty.onError !== undefined)
588
+ track(pty.onError((code) => this.emit({ type: 'error', terminalId: terminal.id, code })));
589
+ if (terminal.state !== 'RUNNING')
590
+ return;
591
+ let subscribing = true;
592
+ let failure;
593
+ const subscription = pty.onExit((event) => {
594
+ try {
595
+ this.exited(terminal, event.exitCode);
596
+ }
597
+ catch (error) {
598
+ if (!subscribing)
599
+ throw error;
600
+ failure = { error };
601
+ }
602
+ });
603
+ subscribing = false;
604
+ track(subscription);
605
+ if (failure !== undefined)
606
+ throw failure.error;
607
+ }
608
+ disposeSubscription(terminal, subscription) {
609
+ try {
610
+ subscription.dispose();
611
+ }
612
+ catch {
613
+ try {
614
+ this.emit({ type: 'error', terminalId: terminal.id, code: 'TERMINAL_CLEANUP_FAILED' });
615
+ }
616
+ catch {
617
+ // A failing error observer must not prevent the remaining resources from being released.
618
+ }
619
+ }
620
+ }
621
+ releaseTerminalResources(terminal) {
622
+ // Historical summaries and replay must not keep native handles or spawn environments alive.
623
+ terminal.pty = undefined;
624
+ terminal.pipe = undefined;
625
+ delete terminal.deferredCommand;
626
+ for (const subscription of terminal.subscriptions.splice(0))
627
+ this.disposeSubscription(terminal, subscription);
467
628
  }
468
629
  replayFrames(terminal, afterSequence) {
469
630
  const first = terminal.replay[0]?.sequence;
@@ -526,7 +687,18 @@ export class TerminalManager {
526
687
  .sort((left, right) => left.lastUsedAt - right.lastUsedAt || left.createdAt - right.createdAt)[0];
527
688
  if (candidate === undefined)
528
689
  throw new Error('TERMINAL_LIMIT_REACHED');
529
- void this.close(candidate.id);
690
+ void this.close(candidate.id).catch((error) => {
691
+ try {
692
+ nodeLog('terminal.cleanup.failed', {
693
+ source: 'capacity',
694
+ terminalId: candidate.id,
695
+ errorCode: safeErrorCode(error, 'TERMINAL_CLEANUP_FAILED')
696
+ });
697
+ }
698
+ catch {
699
+ // Logging must not turn a handled background failure into another rejection.
700
+ }
701
+ });
530
702
  }
531
703
  runningCountForWorkspace(workspaceId) {
532
704
  return [...this.terminals.values()].filter((terminal) => terminal.state === 'RUNNING' && terminal.workspaceId === workspaceId).length;
@@ -551,14 +723,7 @@ export class TerminalManager {
551
723
  }
552
724
  export function nodePtyFactory(shell, args, options) {
553
725
  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;
726
+ return new TerminalPtyProcess().start(shell, args, options);
562
727
  }
563
728
  export function repairPtySpawnHelper(platform = process.platform, architecture = process.arch, packageRoot) {
564
729
  if (platform !== 'darwin')
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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.1",
46
- "@myagentroam/protocol": "0.13.1"
47
+ "@myagentroam/agent": "0.13.3",
48
+ "@myagentroam/protocol": "0.13.3"
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.1"
54
+ "@myagentroam/marctl": "0.13.3"
53
55
  },
54
56
  "exports": {
55
57
  "./config": {