@nia-teams/four-tools 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +143 -2
- package/README_CN.md +144 -0
- package/dist/core/contracts.d.ts +118 -0
- package/dist/core/contracts.js +125 -0
- package/dist/core/edit-engine.d.ts +11 -0
- package/dist/core/edit-engine.js +93 -0
- package/dist/core/env.d.ts +149 -0
- package/dist/core/env.js +1 -0
- package/dist/core/index.d.ts +23 -0
- package/dist/core/index.js +33 -0
- package/dist/core/mutation-queue.d.ts +6 -0
- package/dist/core/mutation-queue.js +24 -0
- package/dist/core/tools/edit.d.ts +4 -0
- package/dist/core/tools/edit.js +43 -0
- package/dist/core/tools/read.d.ts +10 -0
- package/dist/core/tools/read.js +45 -0
- package/dist/core/tools/shared.d.ts +20 -0
- package/dist/core/tools/shared.js +51 -0
- package/dist/core/tools/shell.d.ts +10 -0
- package/dist/core/tools/shell.js +166 -0
- package/dist/core/tools/write.d.ts +4 -0
- package/dist/core/tools/write.js +33 -0
- package/dist/core/types.d.ts +65 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.js +16 -0
- package/dist/node/posix-env.d.ts +46 -0
- package/dist/node/posix-env.js +239 -0
- package/dist/node/process-group.d.ts +2 -0
- package/dist/node/process-group.js +30 -0
- package/dist/node/process-runner.d.ts +43 -0
- package/dist/node/process-runner.js +221 -0
- package/dist/node/read-range.d.ts +7 -0
- package/dist/node/read-range.js +69 -0
- package/dist/node/terminal-manager.d.ts +36 -0
- package/dist/node/terminal-manager.js +228 -0
- package/examples/local-agent.ts +164 -0
- package/examples/quickstart.ts +30 -0
- package/examples/web-agent.ts +479 -0
- package/package.json +44 -4
- package/skills/four-tools/SKILL.md +21 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { openSync, writeSync, closeSync } from 'node:fs';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path/posix';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { constants } from 'node:os';
|
|
7
|
+
const signalNumbers = constants.signals;
|
|
8
|
+
import { signalProcessGroup } from './process-group.js';
|
|
9
|
+
export class OutputBuffer {
|
|
10
|
+
head = Buffer.alloc(0);
|
|
11
|
+
tail = Buffer.alloc(0);
|
|
12
|
+
total = 0;
|
|
13
|
+
max;
|
|
14
|
+
headMax;
|
|
15
|
+
constructor(max = 50 * 1024) {
|
|
16
|
+
this.max = Math.max(1, Math.trunc(max));
|
|
17
|
+
this.headMax = Math.ceil((this.max * 32) / 50);
|
|
18
|
+
}
|
|
19
|
+
add(chunk) {
|
|
20
|
+
this.total += chunk.length;
|
|
21
|
+
const headTake = Math.min(chunk.length, this.headMax - this.head.length);
|
|
22
|
+
if (headTake)
|
|
23
|
+
this.head = Buffer.concat([this.head, chunk.subarray(0, headTake)]);
|
|
24
|
+
const rest = chunk.subarray(headTake);
|
|
25
|
+
const tailMax = this.max - this.headMax;
|
|
26
|
+
if (rest.length && tailMax)
|
|
27
|
+
this.tail = Buffer.concat([this.tail, rest]).subarray(-tailMax);
|
|
28
|
+
}
|
|
29
|
+
get truncated() {
|
|
30
|
+
return this.total > this.max;
|
|
31
|
+
}
|
|
32
|
+
get text() {
|
|
33
|
+
return (this.head.toString('utf8') +
|
|
34
|
+
(this.truncated ? '\n[... output truncated; see spillPath ...]\n' : '') +
|
|
35
|
+
this.tail.toString('utf8'));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function startProcess(input) {
|
|
39
|
+
const options = input.options ?? {};
|
|
40
|
+
options.signal?.throwIfAborted();
|
|
41
|
+
await fs.mkdir(input.logDir, { recursive: true, mode: 0o700 });
|
|
42
|
+
const logPath = join(input.logDir, `${input.logId ?? `exec-${randomUUID()}`}.log`);
|
|
43
|
+
const fd = openSync(logPath, 'wx', 0o600);
|
|
44
|
+
let child;
|
|
45
|
+
try {
|
|
46
|
+
child = spawn(input.shell, ['-c', input.command], {
|
|
47
|
+
cwd: input.cwd,
|
|
48
|
+
env: { ...process.env, ...options.env },
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
await fs.unlink(logPath);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const output = new OutputBuffer(options.maxOutputBytes), stdout = new OutputBuffer(options.maxOutputBytes), stderr = new OutputBuffer(options.maxOutputBytes);
|
|
59
|
+
let timedOut = false, aborted = false, exited = false, finished = false;
|
|
60
|
+
let exitCode = null, exitSignal = null;
|
|
61
|
+
let drainTimer;
|
|
62
|
+
let timeoutTimer;
|
|
63
|
+
const kill = (signal = 'SIGKILL') => {
|
|
64
|
+
if (child.pid)
|
|
65
|
+
signalProcessGroup(child.pid, signal, input.platform);
|
|
66
|
+
};
|
|
67
|
+
let resolve;
|
|
68
|
+
let reject;
|
|
69
|
+
const done = new Promise((yes, no) => {
|
|
70
|
+
resolve = yes;
|
|
71
|
+
reject = no;
|
|
72
|
+
});
|
|
73
|
+
const abort = () => {
|
|
74
|
+
aborted = true;
|
|
75
|
+
kill();
|
|
76
|
+
};
|
|
77
|
+
const finish = (error) => {
|
|
78
|
+
if (finished)
|
|
79
|
+
return;
|
|
80
|
+
finished = true;
|
|
81
|
+
clearTimeout(drainTimer);
|
|
82
|
+
clearTimeout(timeoutTimer);
|
|
83
|
+
options.signal?.removeEventListener('abort', abort);
|
|
84
|
+
child.stdout?.destroy();
|
|
85
|
+
child.stderr?.destroy();
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
if (error)
|
|
88
|
+
reject(error);
|
|
89
|
+
else
|
|
90
|
+
resolve({
|
|
91
|
+
output: output.text,
|
|
92
|
+
stdout: stdout.text,
|
|
93
|
+
stderr: stderr.text,
|
|
94
|
+
exitCode,
|
|
95
|
+
signal: exitSignal,
|
|
96
|
+
timedOut,
|
|
97
|
+
aborted,
|
|
98
|
+
truncated: output.truncated,
|
|
99
|
+
...(output.truncated ? { spillPath: logPath } : {}),
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const armDrain = () => {
|
|
103
|
+
clearTimeout(drainTimer);
|
|
104
|
+
drainTimer = setTimeout(() => finish(), 150);
|
|
105
|
+
};
|
|
106
|
+
const consume = (chunk, stream) => {
|
|
107
|
+
try {
|
|
108
|
+
// Synchronous fd writes apply backpressure before another pipe chunk is accepted.
|
|
109
|
+
for (let at = 0; at < chunk.length;)
|
|
110
|
+
at += writeSync(fd, chunk, at, chunk.length - at);
|
|
111
|
+
output.add(chunk);
|
|
112
|
+
(stream === 'stdout' ? stdout : stderr).add(chunk);
|
|
113
|
+
options.onOutput?.(chunk, stream);
|
|
114
|
+
if (exited)
|
|
115
|
+
armDrain();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
kill();
|
|
119
|
+
finish(error);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
child.stdout?.on('data', (chunk) => consume(chunk, 'stdout'));
|
|
123
|
+
child.stderr?.on('data', (chunk) => consume(chunk, 'stderr'));
|
|
124
|
+
child.on('error', (error) => finish(error));
|
|
125
|
+
child.on('exit', (code, signal) => {
|
|
126
|
+
exited = true;
|
|
127
|
+
exitCode = signal ? 128 + (signalNumbers[signal] ?? 0) : code;
|
|
128
|
+
exitSignal = signal;
|
|
129
|
+
armDrain();
|
|
130
|
+
});
|
|
131
|
+
child.on('close', () => finish());
|
|
132
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
133
|
+
if (options.signal?.aborted)
|
|
134
|
+
abort();
|
|
135
|
+
if (options.timeoutMs !== undefined)
|
|
136
|
+
timeoutTimer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
kill();
|
|
139
|
+
}, Math.max(0, options.timeoutMs));
|
|
140
|
+
return { child, output, logPath, done, kill };
|
|
141
|
+
}
|
|
142
|
+
export async function startManagedProcess(input) {
|
|
143
|
+
await fs.mkdir(input.logDir, { recursive: true, mode: 0o700 });
|
|
144
|
+
const logPath = join(input.logDir, `${input.logId}.log`);
|
|
145
|
+
const fd = openSync(logPath, 'wx', 0o600);
|
|
146
|
+
let child;
|
|
147
|
+
try {
|
|
148
|
+
child = spawn(input.shell, ['-c', input.command], {
|
|
149
|
+
cwd: input.cwd,
|
|
150
|
+
env: { ...process.env, ...input.options?.env },
|
|
151
|
+
detached: true,
|
|
152
|
+
stdio: ['ignore', fd, fd],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
await fs.unlink(logPath).catch(() => { });
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
const snapshot = async () => {
|
|
163
|
+
const result = new OutputBuffer(input.options?.maxOutputBytes);
|
|
164
|
+
const handle = await fs.open(logPath, 'r');
|
|
165
|
+
try {
|
|
166
|
+
const { size } = await handle.stat();
|
|
167
|
+
const head = Buffer.alloc(Math.min(size, Math.ceil((result.max * 32) / 50)));
|
|
168
|
+
const h = await handle.read(head, 0, head.length, 0);
|
|
169
|
+
result.add(head.subarray(0, h.bytesRead));
|
|
170
|
+
const tailSize = Math.min(size - h.bytesRead, result.max - h.bytesRead);
|
|
171
|
+
if (tailSize > 0) {
|
|
172
|
+
const tail = Buffer.alloc(tailSize);
|
|
173
|
+
const t = await handle.read(tail, 0, tailSize, Math.max(h.bytesRead, size - tailSize));
|
|
174
|
+
result.add(tail.subarray(0, t.bytesRead));
|
|
175
|
+
}
|
|
176
|
+
result.total = size;
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await handle.close();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const groupAlive = () => {
|
|
184
|
+
if (!child.pid)
|
|
185
|
+
return false;
|
|
186
|
+
try {
|
|
187
|
+
process.kill(-child.pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error.code === 'ESRCH')
|
|
192
|
+
return false;
|
|
193
|
+
if (error.code === 'EPERM')
|
|
194
|
+
return true;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const done = new Promise((resolve, reject) => {
|
|
199
|
+
child.once('error', reject);
|
|
200
|
+
child.once('exit', (exitCode, signal) => {
|
|
201
|
+
if (signal)
|
|
202
|
+
exitCode = 128 + (signalNumbers[signal] ?? 0);
|
|
203
|
+
void snapshot().then((preview) => resolve({
|
|
204
|
+
output: preview.text,
|
|
205
|
+
stdout: preview.text,
|
|
206
|
+
stderr: '',
|
|
207
|
+
exitCode,
|
|
208
|
+
signal,
|
|
209
|
+
timedOut: false,
|
|
210
|
+
aborted: false,
|
|
211
|
+
truncated: preview.truncated,
|
|
212
|
+
...(preview.truncated ? { spillPath: logPath } : {}),
|
|
213
|
+
}), reject);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
const kill = (signal = 'SIGKILL') => {
|
|
217
|
+
if (child.pid)
|
|
218
|
+
signalProcessGroup(child.pid, signal, input.platform);
|
|
219
|
+
};
|
|
220
|
+
return { child, logPath, done, kill, snapshot, groupAlive };
|
|
221
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
export async function readRange(file, options = {}) {
|
|
3
|
+
const startLine = Math.max(1, Math.trunc(options.offsetLine ?? 1));
|
|
4
|
+
const limit = Math.max(1, Math.trunc(options.limitLines ?? 2000));
|
|
5
|
+
const maxBytes = Math.max(1, Math.trunc(options.maxBytes ?? 50 * 1024));
|
|
6
|
+
const handle = await fs.open(file, 'r');
|
|
7
|
+
const pieces = [];
|
|
8
|
+
let bytes = 0, line = 1, totalBytes = 0, lastByte = -1, endLine = startLine - 1, truncatedByBytes = false, singleLineExceeded = false;
|
|
9
|
+
let selectedLineBytes = 0;
|
|
10
|
+
try {
|
|
11
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
12
|
+
while (true) {
|
|
13
|
+
options.signal?.throwIfAborted();
|
|
14
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
15
|
+
if (!bytesRead)
|
|
16
|
+
break;
|
|
17
|
+
if (!totalBytes && chunk.subarray(0, bytesRead).includes(0))
|
|
18
|
+
return {
|
|
19
|
+
content: '',
|
|
20
|
+
startLine,
|
|
21
|
+
endLine: startLine - 1,
|
|
22
|
+
totalLines: 0,
|
|
23
|
+
truncatedByBytes: false,
|
|
24
|
+
isBinary: true,
|
|
25
|
+
};
|
|
26
|
+
totalBytes += bytesRead;
|
|
27
|
+
lastByte = chunk[bytesRead - 1];
|
|
28
|
+
let at = 0;
|
|
29
|
+
while (at < bytesRead) {
|
|
30
|
+
const newline = chunk.indexOf(10, at);
|
|
31
|
+
const stop = newline >= 0 && newline < bytesRead ? newline + 1 : bytesRead;
|
|
32
|
+
if (line >= startLine && line < startLine + limit) {
|
|
33
|
+
const size = stop - at;
|
|
34
|
+
selectedLineBytes += size;
|
|
35
|
+
const take = Math.min(size, maxBytes - bytes);
|
|
36
|
+
if (take > 0) {
|
|
37
|
+
pieces.push(Buffer.from(chunk.subarray(at, at + take)));
|
|
38
|
+
bytes += take;
|
|
39
|
+
endLine = line;
|
|
40
|
+
}
|
|
41
|
+
if (take < size)
|
|
42
|
+
truncatedByBytes = true;
|
|
43
|
+
if (selectedLineBytes > maxBytes)
|
|
44
|
+
singleLineExceeded = true;
|
|
45
|
+
}
|
|
46
|
+
if (stop > at && chunk[stop - 1] === 10) {
|
|
47
|
+
line++;
|
|
48
|
+
selectedLineBytes = 0;
|
|
49
|
+
}
|
|
50
|
+
at = stop;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Drop an incomplete UTF-8 sequence at the byte cap instead of emitting replacement text.
|
|
54
|
+
const data = Buffer.concat(pieces, bytes);
|
|
55
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
56
|
+
const content = decoder.decode(data, { stream: truncatedByBytes });
|
|
57
|
+
return {
|
|
58
|
+
content,
|
|
59
|
+
startLine,
|
|
60
|
+
endLine,
|
|
61
|
+
totalLines: totalBytes === 0 ? 0 : line - (lastByte === 10 ? 1 : 0),
|
|
62
|
+
truncatedByBytes,
|
|
63
|
+
...(singleLineExceeded ? { singleLineExceeded: true } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ManagedTerminalBackend, TerminalInfo, TerminalExitEvent, Platform } from '../core/env.js';
|
|
2
|
+
export interface TerminalManagerOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
platform: Platform;
|
|
5
|
+
terminalDir: string;
|
|
6
|
+
shell: () => Promise<string>;
|
|
7
|
+
onExit?: (event: TerminalExitEvent) => void | Promise<void>;
|
|
8
|
+
onError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare class PosixTerminalManager implements ManagedTerminalBackend {
|
|
11
|
+
private readonly options;
|
|
12
|
+
private readonly entries;
|
|
13
|
+
private closed;
|
|
14
|
+
constructor(options: TerminalManagerOptions);
|
|
15
|
+
private start;
|
|
16
|
+
private notify;
|
|
17
|
+
private get;
|
|
18
|
+
execWithHandoff(input: Parameters<ManagedTerminalBackend['execWithHandoff']>[0]): ReturnType<ManagedTerminalBackend['execWithHandoff']>;
|
|
19
|
+
create(input: Parameters<ManagedTerminalBackend['create']>[0]): Promise<TerminalInfo>;
|
|
20
|
+
list(): Promise<TerminalInfo[]>;
|
|
21
|
+
read(input: Parameters<ManagedTerminalBackend['read']>[0]): Promise<{
|
|
22
|
+
status: import("../core/env.js").TerminalStatus;
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
nextOffset?: number | undefined;
|
|
25
|
+
content: string;
|
|
26
|
+
startLine: number;
|
|
27
|
+
endLine: number;
|
|
28
|
+
totalLines: number;
|
|
29
|
+
}>;
|
|
30
|
+
signal(input: Parameters<ManagedTerminalBackend['signal']>[0]): Promise<TerminalInfo>;
|
|
31
|
+
destroy(input: {
|
|
32
|
+
id: string;
|
|
33
|
+
}): Promise<void>;
|
|
34
|
+
closeAll(): Promise<void>;
|
|
35
|
+
dispose(): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { startManagedProcess } from './process-runner.js';
|
|
4
|
+
import { readRange } from './read-range.js';
|
|
5
|
+
export class PosixTerminalManager {
|
|
6
|
+
options;
|
|
7
|
+
entries = new Map();
|
|
8
|
+
closed = false;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async start(input, notify, prefix = 'term') {
|
|
13
|
+
if (this.closed)
|
|
14
|
+
throw new Error('Terminal manager is disposed');
|
|
15
|
+
const id = `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
|
|
16
|
+
const running = await startManagedProcess({
|
|
17
|
+
command: input.command,
|
|
18
|
+
cwd: input.cwd ?? this.options.cwd,
|
|
19
|
+
shell: await this.options.shell(),
|
|
20
|
+
platform: this.options.platform,
|
|
21
|
+
logDir: this.options.terminalDir,
|
|
22
|
+
logId: id,
|
|
23
|
+
options: { env: input.env, maxOutputBytes: input.maxOutputBytes },
|
|
24
|
+
});
|
|
25
|
+
const info = {
|
|
26
|
+
id,
|
|
27
|
+
name: input.name ?? '',
|
|
28
|
+
command: input.command,
|
|
29
|
+
cwd: input.cwd ?? this.options.cwd,
|
|
30
|
+
status: 'running',
|
|
31
|
+
exitCode: null,
|
|
32
|
+
signal: null,
|
|
33
|
+
startedAt: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
const entry = {
|
|
36
|
+
info,
|
|
37
|
+
running,
|
|
38
|
+
done: running.done,
|
|
39
|
+
notify,
|
|
40
|
+
notified: false,
|
|
41
|
+
destroyed: false,
|
|
42
|
+
};
|
|
43
|
+
entry.done = running.done.then(async (result) => {
|
|
44
|
+
if (entry.completionSignal) {
|
|
45
|
+
result.exitCode = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137 }[entry.completionSignal];
|
|
46
|
+
result.signal = entry.completionSignal;
|
|
47
|
+
}
|
|
48
|
+
Object.assign(info, {
|
|
49
|
+
status: result.signal ? 'signaled' : 'exited',
|
|
50
|
+
exitCode: result.exitCode,
|
|
51
|
+
signal: result.signal,
|
|
52
|
+
endedAt: Date.now(),
|
|
53
|
+
});
|
|
54
|
+
await this.notify(entry);
|
|
55
|
+
return result;
|
|
56
|
+
}, (error) => {
|
|
57
|
+
Object.assign(info, { status: 'error', endedAt: Date.now() });
|
|
58
|
+
void this.notify(entry).catch((error) => this.options.onError?.(error));
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
// Background spawn errors are observed even if no foreground caller awaits done.
|
|
62
|
+
void entry.done.catch((error) => this.options.onError?.(error));
|
|
63
|
+
this.entries.set(id, entry);
|
|
64
|
+
if (this.closed) {
|
|
65
|
+
await this.destroy({ id });
|
|
66
|
+
throw new Error('Terminal manager is disposed');
|
|
67
|
+
}
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
async notify(entry) {
|
|
71
|
+
if (!entry.notify ||
|
|
72
|
+
entry.notified ||
|
|
73
|
+
entry.destroyed ||
|
|
74
|
+
entry.info.status === 'running')
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
const preview = await entry.running.snapshot();
|
|
78
|
+
if (entry.destroyed || entry.notified)
|
|
79
|
+
return;
|
|
80
|
+
entry.notified = true;
|
|
81
|
+
const info = entry.info;
|
|
82
|
+
const event = {
|
|
83
|
+
...info,
|
|
84
|
+
status: info.status,
|
|
85
|
+
durationMs: (info.endedAt ?? Date.now()) - info.startedAt,
|
|
86
|
+
outputPreview: preview.text,
|
|
87
|
+
logPath: entry.running.logPath,
|
|
88
|
+
};
|
|
89
|
+
void Promise.resolve(this.options.onExit?.(event)).catch((error) => this.options.onError?.(error));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!entry.destroyed)
|
|
93
|
+
this.options.onError?.(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
get(id) {
|
|
97
|
+
const entry = this.entries.get(id);
|
|
98
|
+
if (!entry)
|
|
99
|
+
throw new Error(`Unknown terminal: ${id}`);
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
async execWithHandoff(input) {
|
|
103
|
+
const entry = await this.start(input, false, 'exec');
|
|
104
|
+
let timer;
|
|
105
|
+
let abort;
|
|
106
|
+
const handoff = new Promise((resolve) => {
|
|
107
|
+
timer = setTimeout(() => resolve('handoff'), Math.max(0, input.handoffAfterMs));
|
|
108
|
+
abort = () => resolve('abort');
|
|
109
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
110
|
+
if (input.signal?.aborted)
|
|
111
|
+
abort();
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const winner = await Promise.race([entry.done, handoff]);
|
|
115
|
+
if (typeof winner !== 'string' && !entry.running.groupAlive()) {
|
|
116
|
+
if (!winner.truncated)
|
|
117
|
+
await fs.unlink(entry.running.logPath);
|
|
118
|
+
this.entries.delete(entry.info.id);
|
|
119
|
+
return {
|
|
120
|
+
completed: true,
|
|
121
|
+
output: winner.output,
|
|
122
|
+
stdout: winner.stdout,
|
|
123
|
+
stderr: winner.stderr,
|
|
124
|
+
exitCode: winner.exitCode,
|
|
125
|
+
signal: winner.signal,
|
|
126
|
+
truncated: winner.truncated,
|
|
127
|
+
spillPath: winner.spillPath,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (winner === 'abort' && input.onAbort === 'kill') {
|
|
131
|
+
entry.running.kill();
|
|
132
|
+
const result = await entry.done;
|
|
133
|
+
if (!result.truncated)
|
|
134
|
+
await fs.unlink(entry.running.logPath);
|
|
135
|
+
this.entries.delete(entry.info.id);
|
|
136
|
+
return {
|
|
137
|
+
completed: true,
|
|
138
|
+
output: result.output,
|
|
139
|
+
exitCode: result.exitCode,
|
|
140
|
+
signal: result.signal,
|
|
141
|
+
truncated: result.truncated,
|
|
142
|
+
spillPath: result.spillPath,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
entry.notify = true;
|
|
146
|
+
await this.notify(entry);
|
|
147
|
+
return {
|
|
148
|
+
completed: false,
|
|
149
|
+
terminal: { ...entry.info },
|
|
150
|
+
partialOutput: (await entry.running.snapshot()).text,
|
|
151
|
+
handoffReason: winner === 'abort' ? 'abort' : 'timeout',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
await fs.unlink(entry.running.logPath).catch(() => { });
|
|
156
|
+
this.entries.delete(entry.info.id);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
input.signal?.removeEventListener('abort', abort);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async create(input) {
|
|
165
|
+
return { ...(await this.start(input, input.notifyOnExit !== false)).info };
|
|
166
|
+
}
|
|
167
|
+
async list() {
|
|
168
|
+
return [...this.entries.values()].map((entry) => ({ ...entry.info }));
|
|
169
|
+
}
|
|
170
|
+
async read(input) {
|
|
171
|
+
const entry = this.get(input.id);
|
|
172
|
+
const offset = input.offset ?? 0, limit = input.limit;
|
|
173
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
174
|
+
throw new Error('Terminal read offset must be a non-negative safe integer');
|
|
175
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0))
|
|
176
|
+
throw new Error('Terminal read limit must be a non-negative safe integer');
|
|
177
|
+
const range = await readRange(entry.running.logPath, {
|
|
178
|
+
offsetLine: offset + 1,
|
|
179
|
+
limitLines: limit === 0 ? 1 : (limit ?? Number.MAX_SAFE_INTEGER),
|
|
180
|
+
});
|
|
181
|
+
const endLine = limit === 0 ? offset : range.endLine;
|
|
182
|
+
return {
|
|
183
|
+
content: limit === 0 ? '' : range.content,
|
|
184
|
+
startLine: range.startLine,
|
|
185
|
+
endLine,
|
|
186
|
+
totalLines: range.totalLines,
|
|
187
|
+
...(endLine < range.totalLines ? { nextOffset: endLine } : {}),
|
|
188
|
+
status: entry.info.status,
|
|
189
|
+
exitCode: entry.info.exitCode,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async signal(input) {
|
|
193
|
+
const entry = this.get(input.id);
|
|
194
|
+
if (!['SIGINT', 'SIGTERM', 'SIGKILL'].includes(input.signal))
|
|
195
|
+
throw new Error('Invalid signal');
|
|
196
|
+
if (entry.info.status !== 'running')
|
|
197
|
+
throw new Error('Terminal is not running');
|
|
198
|
+
const previous = entry.completionSignal;
|
|
199
|
+
entry.completionSignal = input.signal;
|
|
200
|
+
try {
|
|
201
|
+
entry.running.kill(input.signal);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
entry.completionSignal = previous;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
return { ...entry.info };
|
|
208
|
+
}
|
|
209
|
+
async destroy(input) {
|
|
210
|
+
const entry = this.get(input.id);
|
|
211
|
+
entry.destroyed = true;
|
|
212
|
+
// Kill the group even if its shell exited while descendants were still running.
|
|
213
|
+
entry.running.kill();
|
|
214
|
+
await entry.done.catch(() => { });
|
|
215
|
+
await fs.unlink(entry.running.logPath).catch((error) => {
|
|
216
|
+
if (error.code !== 'ENOENT')
|
|
217
|
+
throw error;
|
|
218
|
+
});
|
|
219
|
+
this.entries.delete(input.id);
|
|
220
|
+
}
|
|
221
|
+
async closeAll() {
|
|
222
|
+
await this.dispose();
|
|
223
|
+
}
|
|
224
|
+
async dispose() {
|
|
225
|
+
this.closed = true;
|
|
226
|
+
await Promise.all([...this.entries.keys()].map((id) => this.destroy({ id })));
|
|
227
|
+
}
|
|
228
|
+
}
|