dsh-bash-terminal-ts 0.6.4 → 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/lib/terminal.js CHANGED
@@ -1,438 +1,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
- }
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
+ }