@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 +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 +191 -26
- 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.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
|
-
|
|
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,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
|
|
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.
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
46
|
-
"@myagentroam/protocol": "0.13.
|
|
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.
|
|
54
|
+
"@myagentroam/marctl": "0.13.3"
|
|
53
55
|
},
|
|
54
56
|
"exports": {
|
|
55
57
|
"./config": {
|