dsh-bash-terminal-ts 0.2.6 → 0.5.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/README.en.md +82 -197
- package/README.md +197 -194
- package/cordis.patch.yml +5 -5
- package/dist/client.core.js +46 -39
- package/dist/client.js +47 -40
- package/dsh.plugin.json +22 -22
- package/lib/client.js +148 -0
- package/lib/dsh-types.d.ts +1 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +47 -47
- package/lib/terminal.d.ts +24 -1
- package/lib/terminal.js +214 -49
- package/package.json +38 -57
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +14 -13
- package/src/client.tsx +70 -46
- package/src/dsh-types.ts +1 -1
- package/src/index.ts +47 -47
- package/src/terminal.ts +254 -54
- package/README.ja.md +0 -196
- package/README.ko.md +0 -194
package/src/terminal.ts
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
|
-
// dsh-bash-terminal
|
|
1
|
+
// dsh-bash-terminal: interactive terminal tool over the official PTY seam.
|
|
2
2
|
// ctx.subprocess.spawnTerminal (node-pty under the hood) allocates a real
|
|
3
3
|
// terminal; this module owns model-facing sessions: open / send / read /
|
|
4
4
|
// signal (Ctrl+C etc.) / close. The backend follows the user's default
|
|
5
5
|
// terminal setting, exactly like the shell tool.
|
|
6
|
+
//
|
|
7
|
+
// Windows note: the official seam's process inspector is POSIX-only
|
|
8
|
+
// (createProcessInspector throws on win32), so on Windows the PTY is
|
|
9
|
+
// allocated directly through node-pty. Session readiness here is
|
|
10
|
+
// output-quiet based (waitSettled), which needs no process inspection.
|
|
6
11
|
|
|
7
12
|
import { randomUUID } from "node:crypto";
|
|
8
13
|
import { isAbsolute, resolve } from "node:path";
|
|
14
|
+
import { PassThrough } from "node:stream";
|
|
9
15
|
import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
|
|
16
|
+
// Single source of truth for backend env (msys2 MSYSTEM injection, WSL WSLENV).
|
|
17
|
+
// The cycle with ./index.js is safe: buildEnv is a hoisted function declaration
|
|
18
|
+
// and is only called at tool-execution time, never during module evaluation.
|
|
10
19
|
import { buildEnv } from "./index.js";
|
|
11
20
|
import type {
|
|
12
21
|
BashTerminalContext,
|
|
22
|
+
JobsRegistry,
|
|
13
23
|
ResolvedPaths,
|
|
14
|
-
TerminalHandle
|
|
24
|
+
TerminalHandle,
|
|
25
|
+
TerminalSpawnSpec,
|
|
26
|
+
ToolRunContext
|
|
15
27
|
} from "./dsh-types.js";
|
|
16
28
|
|
|
17
29
|
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
18
|
-
const READ_SETTLE_MS = 800;
|
|
19
30
|
const DEFAULT_ROWS = 30;
|
|
20
31
|
const DEFAULT_COLS = 110;
|
|
32
|
+
const DEFAULT_IDLE_MS = 10 * 60 * 1000;
|
|
33
|
+
const MAX_SESSIONS = 8;
|
|
21
34
|
const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"] as const;
|
|
22
35
|
|
|
23
36
|
export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
|
|
@@ -29,12 +42,34 @@ function delay(ms: number): Promise<void> {
|
|
|
29
42
|
interface TerminalBufferRead {
|
|
30
43
|
delta: string;
|
|
31
44
|
nextOffset: number;
|
|
45
|
+
truncated: boolean;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
interface TerminalBuffer {
|
|
35
49
|
append(chunk: string): void;
|
|
36
50
|
readFrom(offset: number): TerminalBufferRead;
|
|
37
51
|
snapshot(): string;
|
|
52
|
+
wasTruncated(): boolean;
|
|
53
|
+
lastAppendAt(): number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wait until the terminal output goes quiet (no new bytes for quietMs) or the
|
|
58
|
+
* cap elapses. Reads after interactive input then return the complete reply
|
|
59
|
+
* instead of a fixed-delay slice.
|
|
60
|
+
*/
|
|
61
|
+
async function waitSettled(buffer: TerminalBuffer, quietMs = 300, timeoutMs = 5000): Promise<void> {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
let last = buffer.lastAppendAt();
|
|
64
|
+
while (Date.now() - start < timeoutMs) {
|
|
65
|
+
await delay(80);
|
|
66
|
+
const now = buffer.lastAppendAt();
|
|
67
|
+
if (now > last) {
|
|
68
|
+
last = now;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (Date.now() - last >= quietMs) return;
|
|
72
|
+
}
|
|
38
73
|
}
|
|
39
74
|
|
|
40
75
|
/** Interactive-shell argv (no -c: the terminal itself is the session). */
|
|
@@ -46,10 +81,21 @@ export function terminalArgv(
|
|
|
46
81
|
switch (shell) {
|
|
47
82
|
case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
|
|
48
83
|
case "gitbash": return [paths.gitbash, "-i"];
|
|
49
|
-
case "msys2":
|
|
84
|
+
case "msys2":
|
|
85
|
+
// -l, NOT -lc: this argv carries no command (the PTY itself is the
|
|
86
|
+
// session), and `bash -lc` with no operand dies immediately with
|
|
87
|
+
// "-c: option requires an argument" (measured: exit 2). A login shell is
|
|
88
|
+
// also what sources /etc/profile, putting /usr/bin and /mingw64/bin on
|
|
89
|
+
// PATH for the interactive session.
|
|
90
|
+
return [paths.msys2, "-l"];
|
|
50
91
|
case "wsl": {
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
// Verified under ConPTY: 'wsl -e bash -i' on the DEFAULT distro fails with
|
|
93
|
+
// a WSL service RPC error (0x8007072c), while 'wsl -- bash -i' works;
|
|
94
|
+
// with an explicit -d distro, '-e bash -i' also works.
|
|
95
|
+
if (distro !== undefined && distro.trim().length > 0) {
|
|
96
|
+
return [paths.wsl, "-d", distro.trim(), "-e", "bash", "-i"];
|
|
97
|
+
}
|
|
98
|
+
return [paths.wsl, "--", "bash", "-i"];
|
|
53
99
|
}
|
|
54
100
|
default: throw new Error("invalid shell: " + JSON.stringify(shell));
|
|
55
101
|
}
|
|
@@ -58,19 +104,86 @@ export function terminalArgv(
|
|
|
58
104
|
/** In-memory output ring for one session (drop-oldest at the cap). */
|
|
59
105
|
function createBuffer(): TerminalBuffer {
|
|
60
106
|
let text = "";
|
|
107
|
+
let truncated = false;
|
|
108
|
+
let lastAppendAt = Date.now();
|
|
61
109
|
return {
|
|
62
110
|
append(chunk: string) {
|
|
63
111
|
text += chunk;
|
|
64
|
-
|
|
112
|
+
lastAppendAt = Date.now();
|
|
113
|
+
if (text.length > MAX_BUFFER_BYTES) {
|
|
114
|
+
text = text.slice(text.length - MAX_BUFFER_BYTES);
|
|
115
|
+
truncated = true;
|
|
116
|
+
}
|
|
65
117
|
},
|
|
66
118
|
readFrom(offset: number): TerminalBufferRead {
|
|
67
119
|
const delta = offset >= text.length ? "" : text.slice(offset);
|
|
68
|
-
return { delta, nextOffset: text.length };
|
|
120
|
+
return { delta, nextOffset: text.length, truncated };
|
|
69
121
|
},
|
|
70
|
-
snapshot: () => text
|
|
122
|
+
snapshot: () => text,
|
|
123
|
+
wasTruncated: () => truncated,
|
|
124
|
+
lastAppendAt: () => lastAppendAt
|
|
71
125
|
};
|
|
72
126
|
}
|
|
73
127
|
|
|
128
|
+
/** Minimal node-pty face used by the direct-win32 path (shape-cast at import). */
|
|
129
|
+
interface NodePtyTerm {
|
|
130
|
+
pid: number;
|
|
131
|
+
write(data: string): void;
|
|
132
|
+
kill(signal?: string): void;
|
|
133
|
+
onData(callback: (chunk: string) => void): void;
|
|
134
|
+
onExit(callback: (event: { exitCode: number; signal: unknown }) => void): void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type NodePtySpawn = (
|
|
138
|
+
file: string,
|
|
139
|
+
args: readonly string[],
|
|
140
|
+
options: { name: string; cols: number; rows: number; cwd: string; env: Record<string, string | undefined> }
|
|
141
|
+
) => NodePtyTerm;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Direct node-pty handle with the same surface the official seam returns:
|
|
145
|
+
* output (stream), pid, done, write / signalForeground / terminate. Used on
|
|
146
|
+
* win32 where the official spawnTerminal's process inspector is unsupported.
|
|
147
|
+
*/
|
|
148
|
+
async function spawnPtyHandle(spec: TerminalSpawnSpec): Promise<TerminalHandle> {
|
|
149
|
+
const { argv, cwd, env, rows, cols } = spec;
|
|
150
|
+
const mod = (await import("node-pty")) as unknown as { default?: { spawn: NodePtySpawn }; spawn: NodePtySpawn };
|
|
151
|
+
const nodePty = mod.default ?? mod;
|
|
152
|
+
const term = nodePty.spawn(argv[0]!, argv.slice(1), {
|
|
153
|
+
name: "dumb",
|
|
154
|
+
cols,
|
|
155
|
+
rows,
|
|
156
|
+
cwd,
|
|
157
|
+
env: { ...env, TERM: "dumb", NO_COLOR: "1" }
|
|
158
|
+
});
|
|
159
|
+
const output = new PassThrough();
|
|
160
|
+
term.onData((chunk) => output.write(Buffer.from(chunk, "utf8")));
|
|
161
|
+
const done = new Promise<{ exitCode: number; signal: string | null }>((resolveDone) => {
|
|
162
|
+
term.onExit(({ exitCode, signal }) => resolveDone({ exitCode, signal: typeof signal === "string" ? signal : null }));
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
pid: term.pid,
|
|
166
|
+
output,
|
|
167
|
+
done,
|
|
168
|
+
write: (data) => term.write(data),
|
|
169
|
+
// node-pty accepts no named signals on Windows: Ctrl+C is the \x03 byte
|
|
170
|
+
// (ConPTY delivers it to the foreground process), other signals degrade
|
|
171
|
+
// to terminating the session.
|
|
172
|
+
signalForeground: (sig) => {
|
|
173
|
+
if (sig === "SIGINT") { term.write("\x03"); return; }
|
|
174
|
+
term.kill();
|
|
175
|
+
},
|
|
176
|
+
terminate: async () => { try { term.kill(); } catch { /* already dead */ } }
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface TerminalSessionInfo {
|
|
181
|
+
sessionId: string;
|
|
182
|
+
shell: string;
|
|
183
|
+
pid: number;
|
|
184
|
+
closed: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
74
187
|
interface TerminalSession {
|
|
75
188
|
id: string;
|
|
76
189
|
handle: TerminalHandle;
|
|
@@ -78,6 +191,9 @@ interface TerminalSession {
|
|
|
78
191
|
shell: string;
|
|
79
192
|
distro?: string;
|
|
80
193
|
closed: boolean;
|
|
194
|
+
idleMs: number;
|
|
195
|
+
idleTimer?: NodeJS.Timeout;
|
|
196
|
+
touch: () => void;
|
|
81
197
|
}
|
|
82
198
|
|
|
83
199
|
export interface TerminalOpenOptions {
|
|
@@ -89,6 +205,7 @@ export interface TerminalOpenOptions {
|
|
|
89
205
|
cols: number;
|
|
90
206
|
distro?: string;
|
|
91
207
|
initial?: string;
|
|
208
|
+
idleMs?: number;
|
|
92
209
|
}
|
|
93
210
|
|
|
94
211
|
export interface TerminalRegistry {
|
|
@@ -98,6 +215,7 @@ export interface TerminalRegistry {
|
|
|
98
215
|
read(id: string): TerminalBufferRead;
|
|
99
216
|
signal(id: string, sig: string): Promise<TerminalBufferRead>;
|
|
100
217
|
close(id: string): Promise<boolean>;
|
|
218
|
+
list(): TerminalSessionInfo[];
|
|
101
219
|
}
|
|
102
220
|
|
|
103
221
|
/**
|
|
@@ -115,19 +233,20 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
|
|
|
115
233
|
}, "bash-terminal: terminal sessions teardown");
|
|
116
234
|
|
|
117
235
|
async function open(options: TerminalOpenOptions): Promise<TerminalSession> {
|
|
118
|
-
const { argv, shell, cwd, env, rows, cols, distro, initial } = options;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
236
|
+
const { argv, shell, cwd, env, rows, cols, distro, initial, idleMs } = options;
|
|
237
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
238
|
+
throw new Error("terminal: too many open sessions (" + MAX_SESSIONS + "); close one before opening another");
|
|
239
|
+
}
|
|
240
|
+
let handle: TerminalHandle;
|
|
241
|
+
if (process.platform === "win32") {
|
|
242
|
+
handle = await spawnPtyHandle({ argv, cwd, env, rows, cols });
|
|
243
|
+
} else {
|
|
244
|
+
const spawnTerminal = ctx.subprocess?.spawnTerminal;
|
|
245
|
+
if (spawnTerminal === undefined) {
|
|
246
|
+
throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
|
|
247
|
+
}
|
|
248
|
+
handle = await spawnTerminal({ argv, cwd, env, rows, cols, graceMs: 3000 });
|
|
122
249
|
}
|
|
123
|
-
const handle = await spawnTerminal({
|
|
124
|
-
argv,
|
|
125
|
-
cwd,
|
|
126
|
-
env,
|
|
127
|
-
rows,
|
|
128
|
-
cols,
|
|
129
|
-
graceMs: 3000
|
|
130
|
-
});
|
|
131
250
|
const buffer = createBuffer();
|
|
132
251
|
handle.output.on("data", (chunk: unknown) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
|
|
133
252
|
const session: TerminalSession = {
|
|
@@ -136,12 +255,28 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
|
|
|
136
255
|
buffer,
|
|
137
256
|
shell,
|
|
138
257
|
distro,
|
|
139
|
-
closed: false
|
|
258
|
+
closed: false,
|
|
259
|
+
idleMs: idleMs ?? DEFAULT_IDLE_MS,
|
|
260
|
+
touch: () => { /* replaced below */ }
|
|
140
261
|
};
|
|
262
|
+
// Idle guard: any read/send/signal touches the timer; expiry terminates the
|
|
263
|
+
// session so an abandoned PTY never leaks a process tree.
|
|
264
|
+
const touch = (): void => {
|
|
265
|
+
if (session.closed) return;
|
|
266
|
+
clearTimeout(session.idleTimer);
|
|
267
|
+
session.idleTimer = setTimeout(() => {
|
|
268
|
+
if (session.closed) return;
|
|
269
|
+
session.closed = true;
|
|
270
|
+
sessions.delete(session.id);
|
|
271
|
+
void session.handle.terminate().catch(() => { /* already dead */ });
|
|
272
|
+
}, session.idleMs);
|
|
273
|
+
};
|
|
274
|
+
session.touch = touch;
|
|
275
|
+
touch();
|
|
141
276
|
sessions.set(session.id, session);
|
|
142
277
|
if (initial !== undefined && initial.length > 0) {
|
|
143
278
|
await handle.write(initial);
|
|
144
|
-
await
|
|
279
|
+
await waitSettled(buffer);
|
|
145
280
|
}
|
|
146
281
|
return session;
|
|
147
282
|
}
|
|
@@ -150,55 +285,67 @@ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegist
|
|
|
150
285
|
const session = sessions.get(id);
|
|
151
286
|
if (session === undefined) throw new Error("terminal session not found: " + JSON.stringify(id));
|
|
152
287
|
if (session.closed) throw new Error("terminal session is closed: " + JSON.stringify(id));
|
|
288
|
+
session.touch();
|
|
153
289
|
return session;
|
|
154
290
|
}
|
|
155
291
|
|
|
156
292
|
async function send(id: string, input: string): Promise<TerminalBufferRead> {
|
|
157
293
|
const session = get(id);
|
|
158
294
|
await session.handle.write(input);
|
|
159
|
-
await
|
|
295
|
+
await waitSettled(session.buffer);
|
|
160
296
|
return read(id);
|
|
161
297
|
}
|
|
162
298
|
|
|
163
|
-
function read(id: string): TerminalBufferRead {
|
|
164
|
-
const session = get(id);
|
|
165
|
-
const { delta, nextOffset } = session.buffer.readFrom(0);
|
|
166
|
-
return { delta, nextOffset };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
299
|
async function signal(id: string, sig: string): Promise<TerminalBufferRead> {
|
|
170
300
|
const session = get(id);
|
|
171
301
|
if (!(TERMINAL_SIGNALS as readonly string[]).includes(sig)) {
|
|
172
302
|
throw new Error("invalid terminal signal: " + JSON.stringify(sig));
|
|
173
303
|
}
|
|
174
304
|
await session.handle.signalForeground(sig);
|
|
175
|
-
await
|
|
305
|
+
await waitSettled(session.buffer);
|
|
176
306
|
return read(id);
|
|
177
307
|
}
|
|
178
308
|
|
|
309
|
+
function read(id: string): TerminalBufferRead {
|
|
310
|
+
const session = get(id);
|
|
311
|
+
const { delta, nextOffset, truncated } = session.buffer.readFrom(0);
|
|
312
|
+
return { delta, nextOffset, truncated };
|
|
313
|
+
}
|
|
314
|
+
|
|
179
315
|
async function close(id: string): Promise<boolean> {
|
|
180
316
|
const session = get(id);
|
|
181
317
|
session.closed = true;
|
|
318
|
+
clearTimeout(session.idleTimer);
|
|
182
319
|
sessions.delete(id);
|
|
183
320
|
await session.handle.terminate();
|
|
184
321
|
return true;
|
|
185
322
|
}
|
|
186
323
|
|
|
187
|
-
|
|
324
|
+
function list(): TerminalSessionInfo[] {
|
|
325
|
+
return [...sessions.values()].map((session) => ({
|
|
326
|
+
sessionId: session.id,
|
|
327
|
+
shell: session.shell,
|
|
328
|
+
pid: session.handle.pid,
|
|
329
|
+
closed: session.closed
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { open, get, send, read, signal, close, list };
|
|
188
334
|
}
|
|
189
335
|
|
|
190
336
|
/** The terminal tool's arguments after runtime validation (discriminated by action). */
|
|
191
337
|
export type ValidatedTerminalArgs =
|
|
192
|
-
| { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string }
|
|
338
|
+
| { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string; idleMs?: number }
|
|
193
339
|
| { action: "send"; sessionId: string; input: string }
|
|
194
340
|
| { action: "read"; sessionId: string }
|
|
195
341
|
| { action: "signal"; sessionId: string; signal: TerminalSignal }
|
|
196
|
-
| { action: "close"; sessionId: string }
|
|
342
|
+
| { action: "close"; sessionId: string }
|
|
343
|
+
| { action: "list" };
|
|
197
344
|
|
|
198
345
|
export function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs {
|
|
199
346
|
const action = args.action;
|
|
200
|
-
if (typeof action !== "string" || ["open", "send", "read", "signal", "close"].indexOf(action) === -1) {
|
|
201
|
-
throw new Error("invalid action: expected open, send, read, signal, or
|
|
347
|
+
if (typeof action !== "string" || ["open", "send", "read", "signal", "close", "list"].indexOf(action) === -1) {
|
|
348
|
+
throw new Error("invalid action: expected open, send, read, signal, close, or list");
|
|
202
349
|
}
|
|
203
350
|
if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
|
|
204
351
|
throw new Error("invalid sessionId: required for " + action);
|
|
@@ -214,6 +361,7 @@ export function validateArgs(args: Record<string, unknown>): asserts args is Val
|
|
|
214
361
|
export interface TerminalOpenResult {
|
|
215
362
|
kind: "open";
|
|
216
363
|
sessionId: string;
|
|
364
|
+
jobId?: string;
|
|
217
365
|
pid: number;
|
|
218
366
|
shell: string;
|
|
219
367
|
output: string;
|
|
@@ -222,6 +370,7 @@ export interface TerminalOpenResult {
|
|
|
222
370
|
export interface TerminalSessionResult {
|
|
223
371
|
kind: "session";
|
|
224
372
|
output: string;
|
|
373
|
+
truncated?: boolean;
|
|
225
374
|
}
|
|
226
375
|
|
|
227
376
|
export interface TerminalClosedResult {
|
|
@@ -229,7 +378,12 @@ export interface TerminalClosedResult {
|
|
|
229
378
|
sessionId: string;
|
|
230
379
|
}
|
|
231
380
|
|
|
232
|
-
export
|
|
381
|
+
export interface TerminalListResult {
|
|
382
|
+
kind: "list";
|
|
383
|
+
sessions: TerminalSessionInfo[];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult | TerminalListResult;
|
|
233
387
|
|
|
234
388
|
export function terminalTool(
|
|
235
389
|
ctx: BashTerminalContext,
|
|
@@ -239,13 +393,13 @@ export function terminalTool(
|
|
|
239
393
|
) {
|
|
240
394
|
return defineTool<TerminalToolResult>({
|
|
241
395
|
name: "terminal",
|
|
242
|
-
description: "Interactive terminal session over the user's default terminal (Settings -> General -> Default terminal: powershell / gitbash / msys2 / wsl). A real PTY hosts a persistent shell: open a session, send input and read output across turns, deliver signals (Ctrl+C = SIGINT) to the foreground process, and close when done. Backend and env follow the shell tool exactly; the session survives between calls until closed. Use this for interactive programs (REPLs, ssh, databases, TUI tools) or when you need shell state (cwd, variables, aliases) to persist across calls.",
|
|
396
|
+
description: "Interactive terminal session over the user's default terminal (Settings -> General -> Default terminal: powershell / gitbash / msys2 / wsl). A real PTY hosts a persistent shell: open a session, send input and read output across turns, deliver signals (Ctrl+C = SIGINT) to the foreground process, and close when done. Backend and env follow the shell tool exactly; the session survives between calls until closed and is managed as a background job (job_kill / job_output work on it); idle sessions close automatically. Use this for interactive programs (REPLs, ssh, databases, TUI tools) or when you need shell state (cwd, variables, aliases) to persist across calls.",
|
|
243
397
|
parameters: {
|
|
244
398
|
action: {
|
|
245
399
|
type: "string",
|
|
246
|
-
enum: ["open", "send", "read", "signal", "close"],
|
|
400
|
+
enum: ["open", "send", "read", "signal", "close", "list"],
|
|
247
401
|
required: true,
|
|
248
|
-
description: "open: create a session and return its id. send: write input and read new output. read: read new output without writing. signal: send a signal to the foreground process (SIGINT for Ctrl+C). close: terminate the session."
|
|
402
|
+
description: "open: create a session and return its id. send: write input and read new output. read: read new output without writing. signal: send a signal to the foreground process (SIGINT for Ctrl+C). close: terminate the session. list: list live sessions."
|
|
249
403
|
},
|
|
250
404
|
sessionId: {
|
|
251
405
|
type: "string",
|
|
@@ -271,6 +425,10 @@ export function terminalTool(
|
|
|
271
425
|
workdir: {
|
|
272
426
|
type: "string",
|
|
273
427
|
description: "Working directory for the session (open only). Defaults to the session workspace."
|
|
428
|
+
},
|
|
429
|
+
idleMs: {
|
|
430
|
+
type: "number",
|
|
431
|
+
description: "Idle timeout in ms (open only): the session closes automatically after this long without send/read/signal. Default 600000 (10 min)."
|
|
274
432
|
}
|
|
275
433
|
},
|
|
276
434
|
output: {
|
|
@@ -282,6 +440,7 @@ export function terminalTool(
|
|
|
282
440
|
properties: {
|
|
283
441
|
kind: { type: "string", required: true, const: "open" },
|
|
284
442
|
sessionId: { type: "string", required: true },
|
|
443
|
+
jobId: { type: "string" },
|
|
285
444
|
pid: { type: "integer", required: true },
|
|
286
445
|
shell: { type: "string", required: true },
|
|
287
446
|
output: { type: "string" }
|
|
@@ -292,7 +451,8 @@ export function terminalTool(
|
|
|
292
451
|
additionalProperties: false,
|
|
293
452
|
properties: {
|
|
294
453
|
kind: { type: "string", required: true, const: "session" },
|
|
295
|
-
output: { type: "string", required: true }
|
|
454
|
+
output: { type: "string", required: true },
|
|
455
|
+
truncated: { type: "boolean" }
|
|
296
456
|
}
|
|
297
457
|
},
|
|
298
458
|
{
|
|
@@ -302,6 +462,26 @@ export function terminalTool(
|
|
|
302
462
|
kind: { type: "string", required: true, const: "closed" },
|
|
303
463
|
sessionId: { type: "string", required: true }
|
|
304
464
|
}
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
type: "object",
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
properties: {
|
|
470
|
+
kind: { type: "string", required: true, const: "list" },
|
|
471
|
+
sessions: {
|
|
472
|
+
type: "array",
|
|
473
|
+
items: {
|
|
474
|
+
type: "object",
|
|
475
|
+
additionalProperties: false,
|
|
476
|
+
properties: {
|
|
477
|
+
sessionId: { type: "string", required: true },
|
|
478
|
+
shell: { type: "string", required: true },
|
|
479
|
+
pid: { type: "integer", required: true },
|
|
480
|
+
closed: { type: "boolean" }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
305
485
|
}
|
|
306
486
|
]
|
|
307
487
|
},
|
|
@@ -311,7 +491,11 @@ export function terminalTool(
|
|
|
311
491
|
? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
|
|
312
492
|
: value.kind === "closed"
|
|
313
493
|
? "terminal session " + value.sessionId + " closed"
|
|
314
|
-
: value.
|
|
494
|
+
: value.kind === "list"
|
|
495
|
+
? (value.sessions.length === 0 ? "no open terminal sessions" : value.sessions.map((s) => s.sessionId + " (" + s.shell + ", pid " + s.pid + ")").join("\n"))
|
|
496
|
+
: value.truncated === true
|
|
497
|
+
? (value.output.length === 0 ? "[terminal buffer overflowed; oldest output dropped]" : value.output + "\n[terminal buffer overflowed; oldest output dropped]")
|
|
498
|
+
: value.output
|
|
315
499
|
}]
|
|
316
500
|
},
|
|
317
501
|
async execute(args, exec): Promise<TerminalToolResult> {
|
|
@@ -333,30 +517,46 @@ export function terminalTool(
|
|
|
333
517
|
// buildEnv, not an inline duplicate: the msys2 backend needs its
|
|
334
518
|
// MSYSTEM=MINGW64 injection here too, or the login shell sources
|
|
335
519
|
// /etc/profile with the default MSYS environment and /mingw64/bin
|
|
336
|
-
// (gcc, make) never joins PATH.
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
520
|
+
// (gcc, make) never joins PATH. It also owns the WSL WSLENV layering,
|
|
521
|
+
// so a terminal session's env follows the shell tool exactly.
|
|
522
|
+
// process.env.WSLENV is passed explicitly: spawnTerminal replaces the
|
|
523
|
+
// child environment wholesale (childEnv(spec.env)), so the ambient
|
|
524
|
+
// allow-list is not otherwise visible and inherited entries such as
|
|
525
|
+
// WT_SESSION / WT_PROFILE_ID would be dropped.
|
|
340
526
|
const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
|
|
341
|
-
const session = await registry.open({ argv, shell, cwd, env, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, distro: args.distro, initial: args.command !== undefined ? args.command + "\r" : undefined });
|
|
342
|
-
|
|
527
|
+
const session = await registry.open({ argv, shell, cwd, env, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, distro: args.distro, initial: args.command !== undefined ? args.command + "\r" : undefined, idleMs: args.idleMs });
|
|
528
|
+
const jobs = ctx.get("jobs") as JobsRegistry | undefined;
|
|
529
|
+
const jobId = jobs === undefined ? undefined : jobs.start({
|
|
530
|
+
kind: "terminal/session",
|
|
531
|
+
label: "terminal " + session.id.slice(0, 8) + " (" + shell + ")",
|
|
532
|
+
...(exec.agent ? { owner: exec.agent } : {}),
|
|
533
|
+
run: () => ({
|
|
534
|
+
cancel: () => { void session.handle.terminate().catch(() => { /* already dead */ }); },
|
|
535
|
+
done: session.handle.done,
|
|
536
|
+
readOutput: () => registry.read(session.id).delta
|
|
537
|
+
})
|
|
538
|
+
});
|
|
539
|
+
return { kind: "open", sessionId: session.id, ...(jobId !== undefined ? { jobId } : {}), pid: session.handle.pid, shell, output: session.buffer.snapshot() };
|
|
343
540
|
}
|
|
344
541
|
case "send": {
|
|
345
|
-
const { delta } = await registry.send(args.sessionId, args.input);
|
|
346
|
-
return { kind: "session", output: delta };
|
|
542
|
+
const { delta, truncated } = await registry.send(args.sessionId, args.input);
|
|
543
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
347
544
|
}
|
|
348
545
|
case "read": {
|
|
349
|
-
const { delta } = registry.read(args.sessionId);
|
|
350
|
-
return { kind: "session", output: delta };
|
|
546
|
+
const { delta, truncated } = registry.read(args.sessionId);
|
|
547
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
351
548
|
}
|
|
352
549
|
case "signal": {
|
|
353
|
-
const { delta } = await registry.signal(args.sessionId, args.signal);
|
|
354
|
-
return { kind: "session", output: delta };
|
|
550
|
+
const { delta, truncated } = await registry.signal(args.sessionId, args.signal);
|
|
551
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
355
552
|
}
|
|
356
553
|
case "close": {
|
|
357
554
|
await registry.close(args.sessionId);
|
|
358
555
|
return { kind: "closed", sessionId: args.sessionId };
|
|
359
556
|
}
|
|
557
|
+
case "list": {
|
|
558
|
+
return { kind: "list", sessions: registry.list() };
|
|
559
|
+
}
|
|
360
560
|
}
|
|
361
561
|
},
|
|
362
562
|
presentCall: (args) => ({
|