@myagentroam/node 0.13.1 → 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 +1 -0
- package/dist/marctl/package.json +1 -1
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +16 -10
- package/dist/terminal-pty-host.mjs +103 -0
- package/dist/terminal-pty-pool.js +50 -0
- package/dist/terminal-pty-tree.mjs +49 -0
- package/dist/terminal-pty.js +278 -0
- package/dist/terminal.js +164 -24
- package/package.json +6 -4
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
|
}
|
package/dist/marctl/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"type":"module","version":"0.13.
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
46
|
-
"@myagentroam/protocol": "0.13.
|
|
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.
|
|
54
|
+
"@myagentroam/marctl": "0.13.2"
|
|
53
55
|
},
|
|
54
56
|
"exports": {
|
|
55
57
|
"./config": {
|