dsh-bash-terminal-ts 0.6.3 → 0.7.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 +12 -10
- package/README.md +24 -21
- package/cordis.patch.yml +2 -2
- package/dist/client.core.js +151 -151
- package/dist/client.js +160 -160
- package/dsh.plugin.json +22 -22
- package/lib/client.js +160 -160
- package/lib/dsh-types.d.ts +259 -259
- package/lib/dsh-types.js +9 -9
- package/lib/dsh.d.ts +35 -35
- package/lib/dsh.js +29 -29
- package/lib/index.d.ts +200 -200
- package/lib/index.js +765 -765
- package/lib/terminal.d.ts +110 -110
- package/lib/terminal.js +438 -438
- package/package.json +125 -119
- package/scripts/build-client.mjs +2 -2
- package/src/client.tsx +201 -207
- package/src/dsh-types.ts +1 -1
- package/src/index.ts +6 -6
- package/src/terminal.ts +2 -3
- package/template/README.md +82 -82
- package/template/terminal-adapter.ts +343 -343
package/lib/terminal.js
CHANGED
|
@@ -1,438 +1,438 @@
|
|
|
1
|
-
// dsh-bash-terminal: interactive terminal tool over the official PTY seam.
|
|
2
|
-
// ctx.subprocess.spawnTerminal (node-pty under the hood) allocates a real
|
|
3
|
-
// terminal; this module owns model-facing sessions: open / send / read /
|
|
4
|
-
// signal (Ctrl+C etc.) / close. The backend follows the user's default
|
|
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.
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { isAbsolute, resolve } from "node:path";
|
|
13
|
-
import { PassThrough } from "node:stream";
|
|
14
|
-
import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
|
|
15
|
-
// Single source of truth for backend env (msys2 MSYSTEM injection, WSL WSLENV).
|
|
16
|
-
// The cycle with ./index.js is safe: buildEnv is a hoisted function declaration
|
|
17
|
-
// and is only called at tool-execution time, never during module evaluation.
|
|
18
|
-
import { buildEnv } from "./index.js";
|
|
19
|
-
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
20
|
-
const DEFAULT_ROWS = 30;
|
|
21
|
-
const DEFAULT_COLS = 110;
|
|
22
|
-
const DEFAULT_IDLE_MS = 10 * 60 * 1000;
|
|
23
|
-
const MAX_SESSIONS = 8;
|
|
24
|
-
const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"];
|
|
25
|
-
function delay(ms) {
|
|
26
|
-
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Wait until the terminal output goes quiet (no new bytes for quietMs) or the
|
|
30
|
-
* cap elapses. Reads after interactive input then return the complete reply
|
|
31
|
-
* instead of a fixed-delay slice.
|
|
32
|
-
*/
|
|
33
|
-
async function waitSettled(buffer, quietMs = 300, timeoutMs = 5000) {
|
|
34
|
-
const start = Date.now();
|
|
35
|
-
let last = buffer.lastAppendAt();
|
|
36
|
-
while (Date.now() - start < timeoutMs) {
|
|
37
|
-
await delay(80);
|
|
38
|
-
const now = buffer.lastAppendAt();
|
|
39
|
-
if (now > last) {
|
|
40
|
-
last = now;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (Date.now() - last >= quietMs)
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
/** Interactive-shell argv (no -c: the terminal itself is the session). */
|
|
48
|
-
export function terminalArgv(shell, paths, distro) {
|
|
49
|
-
switch (shell) {
|
|
50
|
-
case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
|
|
51
|
-
case "gitbash": return [paths.gitbash, "-i"];
|
|
52
|
-
case "msys2":
|
|
53
|
-
// -l, NOT -lc: this argv carries no command (the PTY itself is the
|
|
54
|
-
// session), and `bash -lc` with no operand dies immediately with
|
|
55
|
-
// "-c: option requires an argument" (measured: exit 2). A login shell is
|
|
56
|
-
// also what sources /etc/profile, putting /usr/bin and /mingw64/bin on
|
|
57
|
-
// PATH for the interactive session.
|
|
58
|
-
return [paths.msys2, "-l"];
|
|
59
|
-
case "wsl": {
|
|
60
|
-
// Verified under ConPTY: 'wsl -e bash -i' on the DEFAULT distro fails with
|
|
61
|
-
// a WSL service RPC error (0x8007072c), while 'wsl -- bash -i' works;
|
|
62
|
-
// with an explicit -d distro, '-e bash -i' also works.
|
|
63
|
-
if (distro !== undefined && distro.trim().length > 0) {
|
|
64
|
-
return [paths.wsl, "-d", distro.trim(), "-e", "bash", "-i"];
|
|
65
|
-
}
|
|
66
|
-
return [paths.wsl, "--", "bash", "-i"];
|
|
67
|
-
}
|
|
68
|
-
default: throw new Error("invalid shell: " + JSON.stringify(shell));
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** In-memory output ring for one session (drop-oldest at the cap). */
|
|
72
|
-
function createBuffer() {
|
|
73
|
-
let text = "";
|
|
74
|
-
let truncated = false;
|
|
75
|
-
let lastAppendAt = Date.now();
|
|
76
|
-
return {
|
|
77
|
-
append(chunk) {
|
|
78
|
-
text += chunk;
|
|
79
|
-
lastAppendAt = Date.now();
|
|
80
|
-
if (text.length > MAX_BUFFER_BYTES) {
|
|
81
|
-
text = text.slice(text.length - MAX_BUFFER_BYTES);
|
|
82
|
-
truncated = true;
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
readFrom(offset) {
|
|
86
|
-
const delta = offset >= text.length ? "" : text.slice(offset);
|
|
87
|
-
return { delta, nextOffset: text.length, truncated };
|
|
88
|
-
},
|
|
89
|
-
snapshot: () => text,
|
|
90
|
-
wasTruncated: () => truncated,
|
|
91
|
-
lastAppendAt: () => lastAppendAt
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Direct node-pty handle with the same surface the official seam returns:
|
|
96
|
-
* output (stream), pid, done, write / signalForeground / terminate. Used on
|
|
97
|
-
* win32 where the official spawnTerminal's process inspector is unsupported.
|
|
98
|
-
*/
|
|
99
|
-
async function spawnPtyHandle(spec) {
|
|
100
|
-
const { argv, cwd, env, rows, cols } = spec;
|
|
101
|
-
const mod = (await import("node-pty"));
|
|
102
|
-
const nodePty = mod.default ?? mod;
|
|
103
|
-
const term = nodePty.spawn(argv[0], argv.slice(1), {
|
|
104
|
-
name: "dumb",
|
|
105
|
-
cols,
|
|
106
|
-
rows,
|
|
107
|
-
cwd,
|
|
108
|
-
env: { ...env, TERM: "dumb", NO_COLOR: "1" }
|
|
109
|
-
});
|
|
110
|
-
const output = new PassThrough();
|
|
111
|
-
term.onData((chunk) => output.write(Buffer.from(chunk, "utf8")));
|
|
112
|
-
const done = new Promise((resolveDone) => {
|
|
113
|
-
term.onExit(({ exitCode, signal }) => resolveDone({ exitCode, signal: typeof signal === "string" ? signal : null }));
|
|
114
|
-
});
|
|
115
|
-
return {
|
|
116
|
-
pid: term.pid,
|
|
117
|
-
output,
|
|
118
|
-
done,
|
|
119
|
-
write: (data) => term.write(data),
|
|
120
|
-
// node-pty accepts no named signals on Windows: Ctrl+C is the \x03 byte
|
|
121
|
-
// (ConPTY delivers it to the foreground process), other signals degrade
|
|
122
|
-
// to terminating the session.
|
|
123
|
-
signalForeground: (sig) => {
|
|
124
|
-
if (sig === "SIGINT") {
|
|
125
|
-
term.write("\x03");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
term.kill();
|
|
129
|
-
},
|
|
130
|
-
terminate: async () => { try {
|
|
131
|
-
term.kill();
|
|
132
|
-
}
|
|
133
|
-
catch { /* already dead */ } }
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Terminal-session registry owned by the plugin fiber: opens PTYs through
|
|
138
|
-
* the official seam, forwards output into a capped buffer, and tears every
|
|
139
|
-
* session down on plugin disposal.
|
|
140
|
-
*/
|
|
141
|
-
export function createTerminalRegistry(ctx) {
|
|
142
|
-
const sessions = new Map();
|
|
143
|
-
ctx.effect(() => () => {
|
|
144
|
-
for (const session of sessions.values()) {
|
|
145
|
-
void session.handle.terminate().catch(() => { });
|
|
146
|
-
}
|
|
147
|
-
sessions.clear();
|
|
148
|
-
}, "bash-terminal: terminal sessions teardown");
|
|
149
|
-
async function open(options) {
|
|
150
|
-
const { argv, shell, cwd, env, rows, cols, distro, initial, idleMs } = options;
|
|
151
|
-
if (sessions.size >= MAX_SESSIONS) {
|
|
152
|
-
throw new Error("terminal: too many open sessions (" + MAX_SESSIONS + "); close one before opening another");
|
|
153
|
-
}
|
|
154
|
-
let handle;
|
|
155
|
-
if (process.platform === "win32") {
|
|
156
|
-
handle = await spawnPtyHandle({ argv, cwd, env, rows, cols });
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
const spawnTerminal = ctx.subprocess?.spawnTerminal;
|
|
160
|
-
if (spawnTerminal === undefined) {
|
|
161
|
-
throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
|
|
162
|
-
}
|
|
163
|
-
handle = await spawnTerminal({ argv, cwd, env, rows, cols, graceMs: 3000 });
|
|
164
|
-
}
|
|
165
|
-
const buffer = createBuffer();
|
|
166
|
-
handle.output.on("data", (chunk) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
|
|
167
|
-
const session = {
|
|
168
|
-
id: randomUUID(),
|
|
169
|
-
handle,
|
|
170
|
-
buffer,
|
|
171
|
-
shell,
|
|
172
|
-
distro,
|
|
173
|
-
closed: false,
|
|
174
|
-
idleMs: idleMs ?? DEFAULT_IDLE_MS,
|
|
175
|
-
touch: () => { }
|
|
176
|
-
};
|
|
177
|
-
// Idle guard: any read/send/signal touches the timer; expiry terminates the
|
|
178
|
-
// session so an abandoned PTY never leaks a process tree.
|
|
179
|
-
const touch = () => {
|
|
180
|
-
if (session.closed)
|
|
181
|
-
return;
|
|
182
|
-
clearTimeout(session.idleTimer);
|
|
183
|
-
session.idleTimer = setTimeout(() => {
|
|
184
|
-
if (session.closed)
|
|
185
|
-
return;
|
|
186
|
-
session.closed = true;
|
|
187
|
-
sessions.delete(session.id);
|
|
188
|
-
void session.handle.terminate().catch(() => { });
|
|
189
|
-
}, session.idleMs);
|
|
190
|
-
};
|
|
191
|
-
session.touch = touch;
|
|
192
|
-
touch();
|
|
193
|
-
sessions.set(session.id, session);
|
|
194
|
-
if (initial !== undefined && initial.length > 0) {
|
|
195
|
-
await handle.write(initial);
|
|
196
|
-
await waitSettled(buffer);
|
|
197
|
-
}
|
|
198
|
-
return session;
|
|
199
|
-
}
|
|
200
|
-
function get(id) {
|
|
201
|
-
const session = sessions.get(id);
|
|
202
|
-
if (session === undefined)
|
|
203
|
-
throw new Error("terminal session not found: " + JSON.stringify(id));
|
|
204
|
-
if (session.closed)
|
|
205
|
-
throw new Error("terminal session is closed: " + JSON.stringify(id));
|
|
206
|
-
session.touch();
|
|
207
|
-
return session;
|
|
208
|
-
}
|
|
209
|
-
async function send(id, input) {
|
|
210
|
-
const session = get(id);
|
|
211
|
-
await session.handle.write(input);
|
|
212
|
-
await waitSettled(session.buffer);
|
|
213
|
-
return read(id);
|
|
214
|
-
}
|
|
215
|
-
async function signal(id, sig) {
|
|
216
|
-
const session = get(id);
|
|
217
|
-
if (!TERMINAL_SIGNALS.includes(sig)) {
|
|
218
|
-
throw new Error("invalid terminal signal: " + JSON.stringify(sig));
|
|
219
|
-
}
|
|
220
|
-
await session.handle.signalForeground(sig);
|
|
221
|
-
await waitSettled(session.buffer);
|
|
222
|
-
return read(id);
|
|
223
|
-
}
|
|
224
|
-
function read(id) {
|
|
225
|
-
const session = get(id);
|
|
226
|
-
const { delta, nextOffset, truncated } = session.buffer.readFrom(0);
|
|
227
|
-
return { delta, nextOffset, truncated };
|
|
228
|
-
}
|
|
229
|
-
async function close(id) {
|
|
230
|
-
const session = get(id);
|
|
231
|
-
session.closed = true;
|
|
232
|
-
clearTimeout(session.idleTimer);
|
|
233
|
-
sessions.delete(id);
|
|
234
|
-
await session.handle.terminate();
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
function list() {
|
|
238
|
-
return [...sessions.values()].map((session) => ({
|
|
239
|
-
sessionId: session.id,
|
|
240
|
-
shell: session.shell,
|
|
241
|
-
pid: session.handle.pid,
|
|
242
|
-
closed: session.closed
|
|
243
|
-
}));
|
|
244
|
-
}
|
|
245
|
-
return { open, get, send, read, signal, close, list };
|
|
246
|
-
}
|
|
247
|
-
export function validateArgs(args) {
|
|
248
|
-
const action = args.action;
|
|
249
|
-
if (typeof action !== "string" || ["open", "send", "read", "signal", "close", "list"].indexOf(action) === -1) {
|
|
250
|
-
throw new Error("invalid action: expected open, send, read, signal, close, or list");
|
|
251
|
-
}
|
|
252
|
-
if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
|
|
253
|
-
throw new Error("invalid sessionId: required for " + action);
|
|
254
|
-
}
|
|
255
|
-
if (action === "send" && (typeof args.input !== "string" || args.input.length === 0)) {
|
|
256
|
-
throw new Error("invalid input: expected a non-empty string for send");
|
|
257
|
-
}
|
|
258
|
-
if (action === "signal" && (typeof args.signal !== "string" || TERMINAL_SIGNALS.indexOf(args.signal) === -1)) {
|
|
259
|
-
throw new Error("invalid signal: expected one of " + TERMINAL_SIGNALS.join(", "));
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
export function terminalTool(ctx, registry, paths, defaultShell) {
|
|
263
|
-
return defineTool({
|
|
264
|
-
name: "terminal",
|
|
265
|
-
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.",
|
|
266
|
-
parameters: {
|
|
267
|
-
action: {
|
|
268
|
-
type: "string",
|
|
269
|
-
enum: ["open", "send", "read", "signal", "close", "list"],
|
|
270
|
-
required: true,
|
|
271
|
-
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."
|
|
272
|
-
},
|
|
273
|
-
sessionId: {
|
|
274
|
-
type: "string",
|
|
275
|
-
description: "Session id returned by open; required for send/read/signal/close."
|
|
276
|
-
},
|
|
277
|
-
command: {
|
|
278
|
-
type: "string",
|
|
279
|
-
description: "With action open: optional command to run immediately in the fresh shell (Enter appended). Default starts an interactive shell."
|
|
280
|
-
},
|
|
281
|
-
distro: {
|
|
282
|
-
type: "string",
|
|
283
|
-
description: "WSL distribution (only when the configured default terminal is wsl)."
|
|
284
|
-
},
|
|
285
|
-
input: {
|
|
286
|
-
type: "string",
|
|
287
|
-
description: "With action send: the input to write (no implicit newline; append \\n or \\r for Enter)."
|
|
288
|
-
},
|
|
289
|
-
signal: {
|
|
290
|
-
type: "string",
|
|
291
|
-
enum: TERMINAL_SIGNALS,
|
|
292
|
-
description: "With action signal: signal to the foreground process group (SIGINT = Ctrl+C, SIGKILL, SIGTERM, SIGTSTP, SIGHUP)."
|
|
293
|
-
},
|
|
294
|
-
workdir: {
|
|
295
|
-
type: "string",
|
|
296
|
-
description: "Working directory for the session (open only). Defaults to the session workspace."
|
|
297
|
-
},
|
|
298
|
-
idleMs: {
|
|
299
|
-
type: "number",
|
|
300
|
-
description: "Idle timeout in ms (open only): the session closes automatically after this long without send/read/signal. Default 600000 (10 min)."
|
|
301
|
-
}
|
|
302
|
-
},
|
|
303
|
-
output: {
|
|
304
|
-
schema: {
|
|
305
|
-
oneOf: [
|
|
306
|
-
{
|
|
307
|
-
type: "object",
|
|
308
|
-
additionalProperties: false,
|
|
309
|
-
properties: {
|
|
310
|
-
kind: { type: "string", required: true, const: "open" },
|
|
311
|
-
sessionId: { type: "string", required: true },
|
|
312
|
-
jobId: { type: "string" },
|
|
313
|
-
pid: { type: "integer", required: true },
|
|
314
|
-
shell: { type: "string", required: true },
|
|
315
|
-
output: { type: "string" }
|
|
316
|
-
}
|
|
317
|
-
},
|
|
318
|
-
{
|
|
319
|
-
type: "object",
|
|
320
|
-
additionalProperties: false,
|
|
321
|
-
properties: {
|
|
322
|
-
kind: { type: "string", required: true, const: "session" },
|
|
323
|
-
output: { type: "string", required: true },
|
|
324
|
-
truncated: { type: "boolean" }
|
|
325
|
-
}
|
|
326
|
-
},
|
|
327
|
-
{
|
|
328
|
-
type: "object",
|
|
329
|
-
additionalProperties: false,
|
|
330
|
-
properties: {
|
|
331
|
-
kind: { type: "string", required: true, const: "closed" },
|
|
332
|
-
sessionId: { type: "string", required: true }
|
|
333
|
-
}
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
type: "object",
|
|
337
|
-
additionalProperties: false,
|
|
338
|
-
properties: {
|
|
339
|
-
kind: { type: "string", required: true, const: "list" },
|
|
340
|
-
sessions: {
|
|
341
|
-
type: "array",
|
|
342
|
-
items: {
|
|
343
|
-
type: "object",
|
|
344
|
-
additionalProperties: false,
|
|
345
|
-
properties: {
|
|
346
|
-
sessionId: { type: "string", required: true },
|
|
347
|
-
shell: { type: "string", required: true },
|
|
348
|
-
pid: { type: "integer", required: true },
|
|
349
|
-
closed: { type: "boolean" }
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
]
|
|
356
|
-
},
|
|
357
|
-
render: (_args, value) => [{
|
|
358
|
-
type: "text",
|
|
359
|
-
text: value.kind === "open"
|
|
360
|
-
? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
|
|
361
|
-
: value.kind === "closed"
|
|
362
|
-
? "terminal session " + value.sessionId + " closed"
|
|
363
|
-
: value.kind === "list"
|
|
364
|
-
? (value.sessions.length === 0 ? "no open terminal sessions" : value.sessions.map((s) => s.sessionId + " (" + s.shell + ", pid " + s.pid + ")").join("\n"))
|
|
365
|
-
: value.truncated === true
|
|
366
|
-
? (value.output.length === 0 ? "[terminal buffer overflowed; oldest output dropped]" : value.output + "\n[terminal buffer overflowed; oldest output dropped]")
|
|
367
|
-
: value.output
|
|
368
|
-
}]
|
|
369
|
-
},
|
|
370
|
-
async execute(args, exec) {
|
|
371
|
-
validateArgs(args);
|
|
372
|
-
if (exec.signal.aborted) {
|
|
373
|
-
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
374
|
-
error.name = "AbortError";
|
|
375
|
-
throw error;
|
|
376
|
-
}
|
|
377
|
-
const headerCwd = exec.agent?.session.header.cwd;
|
|
378
|
-
switch (args.action) {
|
|
379
|
-
case "open": {
|
|
380
|
-
const shell = defaultShell();
|
|
381
|
-
const argv0 = terminalArgv(shell, paths, args.distro);
|
|
382
|
-
if (argv0[0] === undefined)
|
|
383
|
-
throw new Error("terminal: " + shell + " backend unavailable - executable not found");
|
|
384
|
-
// Only element 0 (the resolved executable) can be undefined; guarded above.
|
|
385
|
-
const argv = argv0;
|
|
386
|
-
const cwd = args.workdir !== undefined ? (headerCwd !== undefined && !isAbsolute(args.workdir) ? resolve(headerCwd, args.workdir) : args.workdir) : (headerCwd ?? process.cwd());
|
|
387
|
-
// buildEnv, not an inline duplicate: the msys2 backend needs its
|
|
388
|
-
// MSYSTEM=MINGW64 injection here too, or the login shell sources
|
|
389
|
-
// /etc/profile with the default MSYS environment and /mingw64/bin
|
|
390
|
-
// (gcc, make) never joins PATH. It also owns the WSL WSLENV layering,
|
|
391
|
-
// so a terminal session's env follows the shell tool exactly.
|
|
392
|
-
// process.env.WSLENV is passed explicitly: spawnTerminal replaces the
|
|
393
|
-
// child environment wholesale (childEnv(spec.env)), so the ambient
|
|
394
|
-
// allow-list is not otherwise visible and inherited entries such as
|
|
395
|
-
// WT_SESSION / WT_PROFILE_ID would be dropped.
|
|
396
|
-
const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
|
|
397
|
-
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 });
|
|
398
|
-
const jobs = ctx.get("jobs");
|
|
399
|
-
const jobId = jobs === undefined ? undefined : jobs.start({
|
|
400
|
-
kind: "terminal/session",
|
|
401
|
-
label: "terminal " + session.id.slice(0, 8) + " (" + shell + ")",
|
|
402
|
-
...(exec.agent ? { owner: exec.agent } : {}),
|
|
403
|
-
run: () => ({
|
|
404
|
-
cancel: () => { void session.handle.terminate().catch(() => { }); },
|
|
405
|
-
done: session.handle.done,
|
|
406
|
-
readOutput: () => registry.read(session.id).delta
|
|
407
|
-
})
|
|
408
|
-
});
|
|
409
|
-
return { kind: "open", sessionId: session.id, ...(jobId !== undefined ? { jobId } : {}), pid: session.handle.pid, shell, output: session.buffer.snapshot() };
|
|
410
|
-
}
|
|
411
|
-
case "send": {
|
|
412
|
-
const { delta, truncated } = await registry.send(args.sessionId, args.input);
|
|
413
|
-
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
414
|
-
}
|
|
415
|
-
case "read": {
|
|
416
|
-
const { delta, truncated } = registry.read(args.sessionId);
|
|
417
|
-
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
418
|
-
}
|
|
419
|
-
case "signal": {
|
|
420
|
-
const { delta, truncated } = await registry.signal(args.sessionId, args.signal);
|
|
421
|
-
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
422
|
-
}
|
|
423
|
-
case "close": {
|
|
424
|
-
await registry.close(args.sessionId);
|
|
425
|
-
return { kind: "closed", sessionId: args.sessionId };
|
|
426
|
-
}
|
|
427
|
-
case "list": {
|
|
428
|
-
return { kind: "list", sessions: registry.list() };
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
},
|
|
432
|
-
presentCall: (args) => ({
|
|
433
|
-
card: "terminal",
|
|
434
|
-
title: "terminal " + String(args.action) + (args.sessionId !== undefined ? " " + String(args.sessionId) : ""),
|
|
435
|
-
...(args.input !== undefined ? { description: args.input } : {})
|
|
436
|
-
})
|
|
437
|
-
});
|
|
438
|
-
}
|
|
1
|
+
// dsh-bash-terminal-ts: interactive terminal tool over the official PTY seam.
|
|
2
|
+
// ctx.subprocess.spawnTerminal (node-pty under the hood) allocates a real
|
|
3
|
+
// terminal; this module owns model-facing sessions: open / send / read /
|
|
4
|
+
// signal (Ctrl+C etc.) / close. The backend follows the user's default
|
|
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.
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
|
+
import { PassThrough } from "node:stream";
|
|
14
|
+
import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
|
|
15
|
+
// Single source of truth for backend env (msys2 MSYSTEM injection, WSL WSLENV).
|
|
16
|
+
// The cycle with ./index.js is safe: buildEnv is a hoisted function declaration
|
|
17
|
+
// and is only called at tool-execution time, never during module evaluation.
|
|
18
|
+
import { buildEnv } from "./index.js";
|
|
19
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
20
|
+
const DEFAULT_ROWS = 30;
|
|
21
|
+
const DEFAULT_COLS = 110;
|
|
22
|
+
const DEFAULT_IDLE_MS = 10 * 60 * 1000;
|
|
23
|
+
const MAX_SESSIONS = 8;
|
|
24
|
+
const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"];
|
|
25
|
+
function delay(ms) {
|
|
26
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wait until the terminal output goes quiet (no new bytes for quietMs) or the
|
|
30
|
+
* cap elapses. Reads after interactive input then return the complete reply
|
|
31
|
+
* instead of a fixed-delay slice.
|
|
32
|
+
*/
|
|
33
|
+
async function waitSettled(buffer, quietMs = 300, timeoutMs = 5000) {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
let last = buffer.lastAppendAt();
|
|
36
|
+
while (Date.now() - start < timeoutMs) {
|
|
37
|
+
await delay(80);
|
|
38
|
+
const now = buffer.lastAppendAt();
|
|
39
|
+
if (now > last) {
|
|
40
|
+
last = now;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (Date.now() - last >= quietMs)
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Interactive-shell argv (no -c: the terminal itself is the session). */
|
|
48
|
+
export function terminalArgv(shell, paths, distro) {
|
|
49
|
+
switch (shell) {
|
|
50
|
+
case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
|
|
51
|
+
case "gitbash": return [paths.gitbash, "-i"];
|
|
52
|
+
case "msys2":
|
|
53
|
+
// -l, NOT -lc: this argv carries no command (the PTY itself is the
|
|
54
|
+
// session), and `bash -lc` with no operand dies immediately with
|
|
55
|
+
// "-c: option requires an argument" (measured: exit 2). A login shell is
|
|
56
|
+
// also what sources /etc/profile, putting /usr/bin and /mingw64/bin on
|
|
57
|
+
// PATH for the interactive session.
|
|
58
|
+
return [paths.msys2, "-l"];
|
|
59
|
+
case "wsl": {
|
|
60
|
+
// Verified under ConPTY: 'wsl -e bash -i' on the DEFAULT distro fails with
|
|
61
|
+
// a WSL service RPC error (0x8007072c), while 'wsl -- bash -i' works;
|
|
62
|
+
// with an explicit -d distro, '-e bash -i' also works.
|
|
63
|
+
if (distro !== undefined && distro.trim().length > 0) {
|
|
64
|
+
return [paths.wsl, "-d", distro.trim(), "-e", "bash", "-i"];
|
|
65
|
+
}
|
|
66
|
+
return [paths.wsl, "--", "bash", "-i"];
|
|
67
|
+
}
|
|
68
|
+
default: throw new Error("invalid shell: " + JSON.stringify(shell));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** In-memory output ring for one session (drop-oldest at the cap). */
|
|
72
|
+
function createBuffer() {
|
|
73
|
+
let text = "";
|
|
74
|
+
let truncated = false;
|
|
75
|
+
let lastAppendAt = Date.now();
|
|
76
|
+
return {
|
|
77
|
+
append(chunk) {
|
|
78
|
+
text += chunk;
|
|
79
|
+
lastAppendAt = Date.now();
|
|
80
|
+
if (text.length > MAX_BUFFER_BYTES) {
|
|
81
|
+
text = text.slice(text.length - MAX_BUFFER_BYTES);
|
|
82
|
+
truncated = true;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
readFrom(offset) {
|
|
86
|
+
const delta = offset >= text.length ? "" : text.slice(offset);
|
|
87
|
+
return { delta, nextOffset: text.length, truncated };
|
|
88
|
+
},
|
|
89
|
+
snapshot: () => text,
|
|
90
|
+
wasTruncated: () => truncated,
|
|
91
|
+
lastAppendAt: () => lastAppendAt
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Direct node-pty handle with the same surface the official seam returns:
|
|
96
|
+
* output (stream), pid, done, write / signalForeground / terminate. Used on
|
|
97
|
+
* win32 where the official spawnTerminal's process inspector is unsupported.
|
|
98
|
+
*/
|
|
99
|
+
async function spawnPtyHandle(spec) {
|
|
100
|
+
const { argv, cwd, env, rows, cols } = spec;
|
|
101
|
+
const mod = (await import("node-pty"));
|
|
102
|
+
const nodePty = mod.default ?? mod;
|
|
103
|
+
const term = nodePty.spawn(argv[0], argv.slice(1), {
|
|
104
|
+
name: "dumb",
|
|
105
|
+
cols,
|
|
106
|
+
rows,
|
|
107
|
+
cwd,
|
|
108
|
+
env: { ...env, TERM: "dumb", NO_COLOR: "1" }
|
|
109
|
+
});
|
|
110
|
+
const output = new PassThrough();
|
|
111
|
+
term.onData((chunk) => output.write(Buffer.from(chunk, "utf8")));
|
|
112
|
+
const done = new Promise((resolveDone) => {
|
|
113
|
+
term.onExit(({ exitCode, signal }) => resolveDone({ exitCode, signal: typeof signal === "string" ? signal : null }));
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
pid: term.pid,
|
|
117
|
+
output,
|
|
118
|
+
done,
|
|
119
|
+
write: (data) => term.write(data),
|
|
120
|
+
// node-pty accepts no named signals on Windows: Ctrl+C is the \x03 byte
|
|
121
|
+
// (ConPTY delivers it to the foreground process), other signals degrade
|
|
122
|
+
// to terminating the session.
|
|
123
|
+
signalForeground: (sig) => {
|
|
124
|
+
if (sig === "SIGINT") {
|
|
125
|
+
term.write("\x03");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
term.kill();
|
|
129
|
+
},
|
|
130
|
+
terminate: async () => { try {
|
|
131
|
+
term.kill();
|
|
132
|
+
}
|
|
133
|
+
catch { /* already dead */ } }
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Terminal-session registry owned by the plugin fiber: opens PTYs through
|
|
138
|
+
* the official seam, forwards output into a capped buffer, and tears every
|
|
139
|
+
* session down on plugin disposal.
|
|
140
|
+
*/
|
|
141
|
+
export function createTerminalRegistry(ctx) {
|
|
142
|
+
const sessions = new Map();
|
|
143
|
+
ctx.effect(() => () => {
|
|
144
|
+
for (const session of sessions.values()) {
|
|
145
|
+
void session.handle.terminate().catch(() => { });
|
|
146
|
+
}
|
|
147
|
+
sessions.clear();
|
|
148
|
+
}, "bash-terminal: terminal sessions teardown");
|
|
149
|
+
async function open(options) {
|
|
150
|
+
const { argv, shell, cwd, env, rows, cols, distro, initial, idleMs } = options;
|
|
151
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
152
|
+
throw new Error("terminal: too many open sessions (" + MAX_SESSIONS + "); close one before opening another");
|
|
153
|
+
}
|
|
154
|
+
let handle;
|
|
155
|
+
if (process.platform === "win32") {
|
|
156
|
+
handle = await spawnPtyHandle({ argv, cwd, env, rows, cols });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const spawnTerminal = ctx.subprocess?.spawnTerminal;
|
|
160
|
+
if (spawnTerminal === undefined) {
|
|
161
|
+
throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
|
|
162
|
+
}
|
|
163
|
+
handle = await spawnTerminal({ argv, cwd, env, rows, cols, graceMs: 3000 });
|
|
164
|
+
}
|
|
165
|
+
const buffer = createBuffer();
|
|
166
|
+
handle.output.on("data", (chunk) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
|
|
167
|
+
const session = {
|
|
168
|
+
id: randomUUID(),
|
|
169
|
+
handle,
|
|
170
|
+
buffer,
|
|
171
|
+
shell,
|
|
172
|
+
distro,
|
|
173
|
+
closed: false,
|
|
174
|
+
idleMs: idleMs ?? DEFAULT_IDLE_MS,
|
|
175
|
+
touch: () => { }
|
|
176
|
+
};
|
|
177
|
+
// Idle guard: any read/send/signal touches the timer; expiry terminates the
|
|
178
|
+
// session so an abandoned PTY never leaks a process tree.
|
|
179
|
+
const touch = () => {
|
|
180
|
+
if (session.closed)
|
|
181
|
+
return;
|
|
182
|
+
clearTimeout(session.idleTimer);
|
|
183
|
+
session.idleTimer = setTimeout(() => {
|
|
184
|
+
if (session.closed)
|
|
185
|
+
return;
|
|
186
|
+
session.closed = true;
|
|
187
|
+
sessions.delete(session.id);
|
|
188
|
+
void session.handle.terminate().catch(() => { });
|
|
189
|
+
}, session.idleMs);
|
|
190
|
+
};
|
|
191
|
+
session.touch = touch;
|
|
192
|
+
touch();
|
|
193
|
+
sessions.set(session.id, session);
|
|
194
|
+
if (initial !== undefined && initial.length > 0) {
|
|
195
|
+
await handle.write(initial);
|
|
196
|
+
await waitSettled(buffer);
|
|
197
|
+
}
|
|
198
|
+
return session;
|
|
199
|
+
}
|
|
200
|
+
function get(id) {
|
|
201
|
+
const session = sessions.get(id);
|
|
202
|
+
if (session === undefined)
|
|
203
|
+
throw new Error("terminal session not found: " + JSON.stringify(id));
|
|
204
|
+
if (session.closed)
|
|
205
|
+
throw new Error("terminal session is closed: " + JSON.stringify(id));
|
|
206
|
+
session.touch();
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
209
|
+
async function send(id, input) {
|
|
210
|
+
const session = get(id);
|
|
211
|
+
await session.handle.write(input);
|
|
212
|
+
await waitSettled(session.buffer);
|
|
213
|
+
return read(id);
|
|
214
|
+
}
|
|
215
|
+
async function signal(id, sig) {
|
|
216
|
+
const session = get(id);
|
|
217
|
+
if (!TERMINAL_SIGNALS.includes(sig)) {
|
|
218
|
+
throw new Error("invalid terminal signal: " + JSON.stringify(sig));
|
|
219
|
+
}
|
|
220
|
+
await session.handle.signalForeground(sig);
|
|
221
|
+
await waitSettled(session.buffer);
|
|
222
|
+
return read(id);
|
|
223
|
+
}
|
|
224
|
+
function read(id) {
|
|
225
|
+
const session = get(id);
|
|
226
|
+
const { delta, nextOffset, truncated } = session.buffer.readFrom(0);
|
|
227
|
+
return { delta, nextOffset, truncated };
|
|
228
|
+
}
|
|
229
|
+
async function close(id) {
|
|
230
|
+
const session = get(id);
|
|
231
|
+
session.closed = true;
|
|
232
|
+
clearTimeout(session.idleTimer);
|
|
233
|
+
sessions.delete(id);
|
|
234
|
+
await session.handle.terminate();
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
function list() {
|
|
238
|
+
return [...sessions.values()].map((session) => ({
|
|
239
|
+
sessionId: session.id,
|
|
240
|
+
shell: session.shell,
|
|
241
|
+
pid: session.handle.pid,
|
|
242
|
+
closed: session.closed
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
return { open, get, send, read, signal, close, list };
|
|
246
|
+
}
|
|
247
|
+
export function validateArgs(args) {
|
|
248
|
+
const action = args.action;
|
|
249
|
+
if (typeof action !== "string" || ["open", "send", "read", "signal", "close", "list"].indexOf(action) === -1) {
|
|
250
|
+
throw new Error("invalid action: expected open, send, read, signal, close, or list");
|
|
251
|
+
}
|
|
252
|
+
if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
|
|
253
|
+
throw new Error("invalid sessionId: required for " + action);
|
|
254
|
+
}
|
|
255
|
+
if (action === "send" && (typeof args.input !== "string" || args.input.length === 0)) {
|
|
256
|
+
throw new Error("invalid input: expected a non-empty string for send");
|
|
257
|
+
}
|
|
258
|
+
if (action === "signal" && (typeof args.signal !== "string" || TERMINAL_SIGNALS.indexOf(args.signal) === -1)) {
|
|
259
|
+
throw new Error("invalid signal: expected one of " + TERMINAL_SIGNALS.join(", "));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export function terminalTool(ctx, registry, paths, defaultShell) {
|
|
263
|
+
return defineTool({
|
|
264
|
+
name: "terminal",
|
|
265
|
+
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.",
|
|
266
|
+
parameters: {
|
|
267
|
+
action: {
|
|
268
|
+
type: "string",
|
|
269
|
+
enum: ["open", "send", "read", "signal", "close", "list"],
|
|
270
|
+
required: true,
|
|
271
|
+
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."
|
|
272
|
+
},
|
|
273
|
+
sessionId: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description: "Session id returned by open; required for send/read/signal/close."
|
|
276
|
+
},
|
|
277
|
+
command: {
|
|
278
|
+
type: "string",
|
|
279
|
+
description: "With action open: optional command to run immediately in the fresh shell (Enter appended). Default starts an interactive shell."
|
|
280
|
+
},
|
|
281
|
+
distro: {
|
|
282
|
+
type: "string",
|
|
283
|
+
description: "WSL distribution (only when the configured default terminal is wsl)."
|
|
284
|
+
},
|
|
285
|
+
input: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "With action send: the input to write (no implicit newline; append \\n or \\r for Enter)."
|
|
288
|
+
},
|
|
289
|
+
signal: {
|
|
290
|
+
type: "string",
|
|
291
|
+
enum: TERMINAL_SIGNALS,
|
|
292
|
+
description: "With action signal: signal to the foreground process group (SIGINT = Ctrl+C, SIGKILL, SIGTERM, SIGTSTP, SIGHUP)."
|
|
293
|
+
},
|
|
294
|
+
workdir: {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "Working directory for the session (open only). Defaults to the session workspace."
|
|
297
|
+
},
|
|
298
|
+
idleMs: {
|
|
299
|
+
type: "number",
|
|
300
|
+
description: "Idle timeout in ms (open only): the session closes automatically after this long without send/read/signal. Default 600000 (10 min)."
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
output: {
|
|
304
|
+
schema: {
|
|
305
|
+
oneOf: [
|
|
306
|
+
{
|
|
307
|
+
type: "object",
|
|
308
|
+
additionalProperties: false,
|
|
309
|
+
properties: {
|
|
310
|
+
kind: { type: "string", required: true, const: "open" },
|
|
311
|
+
sessionId: { type: "string", required: true },
|
|
312
|
+
jobId: { type: "string" },
|
|
313
|
+
pid: { type: "integer", required: true },
|
|
314
|
+
shell: { type: "string", required: true },
|
|
315
|
+
output: { type: "string" }
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
type: "object",
|
|
320
|
+
additionalProperties: false,
|
|
321
|
+
properties: {
|
|
322
|
+
kind: { type: "string", required: true, const: "session" },
|
|
323
|
+
output: { type: "string", required: true },
|
|
324
|
+
truncated: { type: "boolean" }
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
type: "object",
|
|
329
|
+
additionalProperties: false,
|
|
330
|
+
properties: {
|
|
331
|
+
kind: { type: "string", required: true, const: "closed" },
|
|
332
|
+
sessionId: { type: "string", required: true }
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
type: "object",
|
|
337
|
+
additionalProperties: false,
|
|
338
|
+
properties: {
|
|
339
|
+
kind: { type: "string", required: true, const: "list" },
|
|
340
|
+
sessions: {
|
|
341
|
+
type: "array",
|
|
342
|
+
items: {
|
|
343
|
+
type: "object",
|
|
344
|
+
additionalProperties: false,
|
|
345
|
+
properties: {
|
|
346
|
+
sessionId: { type: "string", required: true },
|
|
347
|
+
shell: { type: "string", required: true },
|
|
348
|
+
pid: { type: "integer", required: true },
|
|
349
|
+
closed: { type: "boolean" }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
},
|
|
357
|
+
render: (_args, value) => [{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: value.kind === "open"
|
|
360
|
+
? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
|
|
361
|
+
: value.kind === "closed"
|
|
362
|
+
? "terminal session " + value.sessionId + " closed"
|
|
363
|
+
: value.kind === "list"
|
|
364
|
+
? (value.sessions.length === 0 ? "no open terminal sessions" : value.sessions.map((s) => s.sessionId + " (" + s.shell + ", pid " + s.pid + ")").join("\n"))
|
|
365
|
+
: value.truncated === true
|
|
366
|
+
? (value.output.length === 0 ? "[terminal buffer overflowed; oldest output dropped]" : value.output + "\n[terminal buffer overflowed; oldest output dropped]")
|
|
367
|
+
: value.output
|
|
368
|
+
}]
|
|
369
|
+
},
|
|
370
|
+
async execute(args, exec) {
|
|
371
|
+
validateArgs(args);
|
|
372
|
+
if (exec.signal.aborted) {
|
|
373
|
+
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
374
|
+
error.name = "AbortError";
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
const headerCwd = exec.agent?.session.header.cwd;
|
|
378
|
+
switch (args.action) {
|
|
379
|
+
case "open": {
|
|
380
|
+
const shell = defaultShell();
|
|
381
|
+
const argv0 = terminalArgv(shell, paths, args.distro);
|
|
382
|
+
if (argv0[0] === undefined)
|
|
383
|
+
throw new Error("terminal: " + shell + " backend unavailable - executable not found");
|
|
384
|
+
// Only element 0 (the resolved executable) can be undefined; guarded above.
|
|
385
|
+
const argv = argv0;
|
|
386
|
+
const cwd = args.workdir !== undefined ? (headerCwd !== undefined && !isAbsolute(args.workdir) ? resolve(headerCwd, args.workdir) : args.workdir) : (headerCwd ?? process.cwd());
|
|
387
|
+
// buildEnv, not an inline duplicate: the msys2 backend needs its
|
|
388
|
+
// MSYSTEM=MINGW64 injection here too, or the login shell sources
|
|
389
|
+
// /etc/profile with the default MSYS environment and /mingw64/bin
|
|
390
|
+
// (gcc, make) never joins PATH. It also owns the WSL WSLENV layering,
|
|
391
|
+
// so a terminal session's env follows the shell tool exactly.
|
|
392
|
+
// process.env.WSLENV is passed explicitly: spawnTerminal replaces the
|
|
393
|
+
// child environment wholesale (childEnv(spec.env)), so the ambient
|
|
394
|
+
// allow-list is not otherwise visible and inherited entries such as
|
|
395
|
+
// WT_SESSION / WT_PROFILE_ID would be dropped.
|
|
396
|
+
const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
|
|
397
|
+
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 });
|
|
398
|
+
const jobs = ctx.get("jobs");
|
|
399
|
+
const jobId = jobs === undefined ? undefined : jobs.start({
|
|
400
|
+
kind: "terminal/session",
|
|
401
|
+
label: "terminal " + session.id.slice(0, 8) + " (" + shell + ")",
|
|
402
|
+
...(exec.agent ? { owner: exec.agent } : {}),
|
|
403
|
+
run: () => ({
|
|
404
|
+
cancel: () => { void session.handle.terminate().catch(() => { }); },
|
|
405
|
+
done: session.handle.done,
|
|
406
|
+
readOutput: () => registry.read(session.id).delta
|
|
407
|
+
})
|
|
408
|
+
});
|
|
409
|
+
return { kind: "open", sessionId: session.id, ...(jobId !== undefined ? { jobId } : {}), pid: session.handle.pid, shell, output: session.buffer.snapshot() };
|
|
410
|
+
}
|
|
411
|
+
case "send": {
|
|
412
|
+
const { delta, truncated } = await registry.send(args.sessionId, args.input);
|
|
413
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
414
|
+
}
|
|
415
|
+
case "read": {
|
|
416
|
+
const { delta, truncated } = registry.read(args.sessionId);
|
|
417
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
418
|
+
}
|
|
419
|
+
case "signal": {
|
|
420
|
+
const { delta, truncated } = await registry.signal(args.sessionId, args.signal);
|
|
421
|
+
return { kind: "session", output: delta, ...(truncated ? { truncated } : {}) };
|
|
422
|
+
}
|
|
423
|
+
case "close": {
|
|
424
|
+
await registry.close(args.sessionId);
|
|
425
|
+
return { kind: "closed", sessionId: args.sessionId };
|
|
426
|
+
}
|
|
427
|
+
case "list": {
|
|
428
|
+
return { kind: "list", sessions: registry.list() };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
presentCall: (args) => ({
|
|
433
|
+
card: "terminal",
|
|
434
|
+
title: "terminal " + String(args.action) + (args.sessionId !== undefined ? " " + String(args.sessionId) : ""),
|
|
435
|
+
...(args.input !== undefined ? { description: args.input } : {})
|
|
436
|
+
})
|
|
437
|
+
});
|
|
438
|
+
}
|