dsh-bash-terminal-ts 0.2.5

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.
@@ -0,0 +1,368 @@
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
+ import { randomUUID } from "node:crypto";
8
+ import { isAbsolute, resolve } from "node:path";
9
+ import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
10
+ import { buildEnv } from "./index.js";
11
+ import type {
12
+ BashTerminalContext,
13
+ ResolvedPaths,
14
+ TerminalHandle
15
+ } from "./dsh-types.js";
16
+
17
+ const MAX_BUFFER_BYTES = 1024 * 1024;
18
+ const READ_SETTLE_MS = 800;
19
+ const DEFAULT_ROWS = 30;
20
+ const DEFAULT_COLS = 110;
21
+ const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"] as const;
22
+
23
+ export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
24
+
25
+ function delay(ms: number): Promise<void> {
26
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
27
+ }
28
+
29
+ interface TerminalBufferRead {
30
+ delta: string;
31
+ nextOffset: number;
32
+ }
33
+
34
+ interface TerminalBuffer {
35
+ append(chunk: string): void;
36
+ readFrom(offset: number): TerminalBufferRead;
37
+ snapshot(): string;
38
+ }
39
+
40
+ /** Interactive-shell argv (no -c: the terminal itself is the session). */
41
+ export function terminalArgv(
42
+ shell: string,
43
+ paths: ResolvedPaths,
44
+ distro?: string
45
+ ): Array<string | undefined> {
46
+ switch (shell) {
47
+ case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
48
+ case "gitbash": return [paths.gitbash, "-i"];
49
+ case "msys2": return [paths.msys2, "-l"];
50
+ case "wsl": {
51
+ const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
52
+ return [paths.wsl, ...distroArg, "-e", "bash", "-i"];
53
+ }
54
+ default: throw new Error("invalid shell: " + JSON.stringify(shell));
55
+ }
56
+ }
57
+
58
+ /** In-memory output ring for one session (drop-oldest at the cap). */
59
+ function createBuffer(): TerminalBuffer {
60
+ let text = "";
61
+ return {
62
+ append(chunk: string) {
63
+ text += chunk;
64
+ if (text.length > MAX_BUFFER_BYTES) text = text.slice(text.length - MAX_BUFFER_BYTES);
65
+ },
66
+ readFrom(offset: number): TerminalBufferRead {
67
+ const delta = offset >= text.length ? "" : text.slice(offset);
68
+ return { delta, nextOffset: text.length };
69
+ },
70
+ snapshot: () => text
71
+ };
72
+ }
73
+
74
+ interface TerminalSession {
75
+ id: string;
76
+ handle: TerminalHandle;
77
+ buffer: TerminalBuffer;
78
+ shell: string;
79
+ distro?: string;
80
+ closed: boolean;
81
+ }
82
+
83
+ export interface TerminalOpenOptions {
84
+ argv: string[];
85
+ shell: string;
86
+ cwd: string;
87
+ env: Record<string, string | undefined>;
88
+ rows: number;
89
+ cols: number;
90
+ distro?: string;
91
+ initial?: string;
92
+ }
93
+
94
+ export interface TerminalRegistry {
95
+ open(options: TerminalOpenOptions): Promise<TerminalSession>;
96
+ get(id: string): TerminalSession;
97
+ send(id: string, input: string): Promise<TerminalBufferRead>;
98
+ read(id: string): TerminalBufferRead;
99
+ signal(id: string, sig: string): Promise<TerminalBufferRead>;
100
+ close(id: string): Promise<boolean>;
101
+ }
102
+
103
+ /**
104
+ * Terminal-session registry owned by the plugin fiber: opens PTYs through
105
+ * the official seam, forwards output into a capped buffer, and tears every
106
+ * session down on plugin disposal.
107
+ */
108
+ export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegistry {
109
+ const sessions = new Map<string, TerminalSession>();
110
+ ctx.effect(() => () => {
111
+ for (const session of sessions.values()) {
112
+ void session.handle.terminate().catch(() => { /* already dead */ });
113
+ }
114
+ sessions.clear();
115
+ }, "bash-terminal: terminal sessions teardown");
116
+
117
+ async function open(options: TerminalOpenOptions): Promise<TerminalSession> {
118
+ const { argv, shell, cwd, env, rows, cols, distro, initial } = options;
119
+ const spawnTerminal = ctx.subprocess?.spawnTerminal;
120
+ if (spawnTerminal === undefined) {
121
+ throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
122
+ }
123
+ const handle = await spawnTerminal({
124
+ argv,
125
+ cwd,
126
+ env,
127
+ rows,
128
+ cols,
129
+ graceMs: 3000
130
+ });
131
+ const buffer = createBuffer();
132
+ handle.output.on("data", (chunk: unknown) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
133
+ const session: TerminalSession = {
134
+ id: randomUUID(),
135
+ handle,
136
+ buffer,
137
+ shell,
138
+ distro,
139
+ closed: false
140
+ };
141
+ sessions.set(session.id, session);
142
+ if (initial !== undefined && initial.length > 0) {
143
+ await handle.write(initial);
144
+ await delay(READ_SETTLE_MS);
145
+ }
146
+ return session;
147
+ }
148
+
149
+ function get(id: string): TerminalSession {
150
+ const session = sessions.get(id);
151
+ if (session === undefined) throw new Error("terminal session not found: " + JSON.stringify(id));
152
+ if (session.closed) throw new Error("terminal session is closed: " + JSON.stringify(id));
153
+ return session;
154
+ }
155
+
156
+ async function send(id: string, input: string): Promise<TerminalBufferRead> {
157
+ const session = get(id);
158
+ await session.handle.write(input);
159
+ await delay(READ_SETTLE_MS);
160
+ return read(id);
161
+ }
162
+
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
+ async function signal(id: string, sig: string): Promise<TerminalBufferRead> {
170
+ const session = get(id);
171
+ if (!(TERMINAL_SIGNALS as readonly string[]).includes(sig)) {
172
+ throw new Error("invalid terminal signal: " + JSON.stringify(sig));
173
+ }
174
+ await session.handle.signalForeground(sig);
175
+ await delay(READ_SETTLE_MS);
176
+ return read(id);
177
+ }
178
+
179
+ async function close(id: string): Promise<boolean> {
180
+ const session = get(id);
181
+ session.closed = true;
182
+ sessions.delete(id);
183
+ await session.handle.terminate();
184
+ return true;
185
+ }
186
+
187
+ return { open, get, send, read, signal, close };
188
+ }
189
+
190
+ /** The terminal tool's arguments after runtime validation (discriminated by action). */
191
+ export type ValidatedTerminalArgs =
192
+ | { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string }
193
+ | { action: "send"; sessionId: string; input: string }
194
+ | { action: "read"; sessionId: string }
195
+ | { action: "signal"; sessionId: string; signal: TerminalSignal }
196
+ | { action: "close"; sessionId: string };
197
+
198
+ export function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs {
199
+ 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 close");
202
+ }
203
+ if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
204
+ throw new Error("invalid sessionId: required for " + action);
205
+ }
206
+ if (action === "send" && (typeof args.input !== "string" || args.input.length === 0)) {
207
+ throw new Error("invalid input: expected a non-empty string for send");
208
+ }
209
+ if (action === "signal" && (typeof args.signal !== "string" || (TERMINAL_SIGNALS as readonly string[]).indexOf(args.signal) === -1)) {
210
+ throw new Error("invalid signal: expected one of " + TERMINAL_SIGNALS.join(", "));
211
+ }
212
+ }
213
+
214
+ export interface TerminalOpenResult {
215
+ kind: "open";
216
+ sessionId: string;
217
+ pid: number;
218
+ shell: string;
219
+ output: string;
220
+ }
221
+
222
+ export interface TerminalSessionResult {
223
+ kind: "session";
224
+ output: string;
225
+ }
226
+
227
+ export interface TerminalClosedResult {
228
+ kind: "closed";
229
+ sessionId: string;
230
+ }
231
+
232
+ export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult;
233
+
234
+ export function terminalTool(
235
+ ctx: BashTerminalContext,
236
+ registry: TerminalRegistry,
237
+ paths: ResolvedPaths,
238
+ defaultShell: () => string
239
+ ) {
240
+ return defineTool<TerminalToolResult>({
241
+ 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.",
243
+ parameters: {
244
+ action: {
245
+ type: "string",
246
+ enum: ["open", "send", "read", "signal", "close"],
247
+ 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."
249
+ },
250
+ sessionId: {
251
+ type: "string",
252
+ description: "Session id returned by open; required for send/read/signal/close."
253
+ },
254
+ command: {
255
+ type: "string",
256
+ description: "With action open: optional command to run immediately in the fresh shell (Enter appended). Default starts an interactive shell."
257
+ },
258
+ distro: {
259
+ type: "string",
260
+ description: "WSL distribution (only when the configured default terminal is wsl)."
261
+ },
262
+ input: {
263
+ type: "string",
264
+ description: "With action send: the input to write (no implicit newline; append \\n or \\r for Enter)."
265
+ },
266
+ signal: {
267
+ type: "string",
268
+ enum: TERMINAL_SIGNALS,
269
+ description: "With action signal: signal to the foreground process group (SIGINT = Ctrl+C, SIGKILL, SIGTERM, SIGTSTP, SIGHUP)."
270
+ },
271
+ workdir: {
272
+ type: "string",
273
+ description: "Working directory for the session (open only). Defaults to the session workspace."
274
+ }
275
+ },
276
+ output: {
277
+ schema: {
278
+ oneOf: [
279
+ {
280
+ type: "object",
281
+ additionalProperties: false,
282
+ properties: {
283
+ kind: { type: "string", required: true, const: "open" },
284
+ sessionId: { type: "string", required: true },
285
+ pid: { type: "integer", required: true },
286
+ shell: { type: "string", required: true },
287
+ output: { type: "string" }
288
+ }
289
+ },
290
+ {
291
+ type: "object",
292
+ additionalProperties: false,
293
+ properties: {
294
+ kind: { type: "string", required: true, const: "session" },
295
+ output: { type: "string", required: true }
296
+ }
297
+ },
298
+ {
299
+ type: "object",
300
+ additionalProperties: false,
301
+ properties: {
302
+ kind: { type: "string", required: true, const: "closed" },
303
+ sessionId: { type: "string", required: true }
304
+ }
305
+ }
306
+ ]
307
+ },
308
+ render: (_args, value) => [{
309
+ type: "text",
310
+ text: value.kind === "open"
311
+ ? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
312
+ : value.kind === "closed"
313
+ ? "terminal session " + value.sessionId + " closed"
314
+ : value.output
315
+ }]
316
+ },
317
+ async execute(args, exec): Promise<TerminalToolResult> {
318
+ validateArgs(args);
319
+ if (exec.signal.aborted) {
320
+ const error = new HarnessError("tool call aborted", TOOL_ABORTED);
321
+ error.name = "AbortError";
322
+ throw error;
323
+ }
324
+ const headerCwd = exec.agent?.session.header.cwd;
325
+ switch (args.action) {
326
+ case "open": {
327
+ const shell = defaultShell();
328
+ const argv0 = terminalArgv(shell, paths, args.distro);
329
+ if (argv0[0] === undefined) throw new Error("terminal: " + shell + " backend unavailable - executable not found");
330
+ // Only element 0 (the resolved executable) can be undefined; guarded above.
331
+ const argv = argv0 as string[];
332
+ const cwd = args.workdir !== undefined ? (headerCwd !== undefined && !isAbsolute(args.workdir) ? resolve(headerCwd, args.workdir) : args.workdir) : (headerCwd ?? process.cwd());
333
+ // buildEnv, not an inline duplicate: the msys2 backend needs its
334
+ // MSYSTEM=MINGW64 injection here too, or the login shell sources
335
+ // /etc/profile with the default MSYS environment and /mingw64/bin
336
+ // (gcc, make) never joins PATH. DSH's spawnTerminal replaces the child
337
+ // environment with exactly this object (childEnv(spec.env)), so the
338
+ // inherited WSLENV is passed through explicitly for the WSL allow-list;
339
+ // for every other backend buildEnv ignores the third argument.
340
+ 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
+ return { kind: "open", sessionId: session.id, pid: session.handle.pid, shell, output: session.buffer.snapshot() };
343
+ }
344
+ case "send": {
345
+ const { delta } = await registry.send(args.sessionId, args.input);
346
+ return { kind: "session", output: delta };
347
+ }
348
+ case "read": {
349
+ const { delta } = registry.read(args.sessionId);
350
+ return { kind: "session", output: delta };
351
+ }
352
+ case "signal": {
353
+ const { delta } = await registry.signal(args.sessionId, args.signal);
354
+ return { kind: "session", output: delta };
355
+ }
356
+ case "close": {
357
+ await registry.close(args.sessionId);
358
+ return { kind: "closed", sessionId: args.sessionId };
359
+ }
360
+ }
361
+ },
362
+ presentCall: (args) => ({
363
+ card: "terminal",
364
+ title: "terminal " + String(args.action) + (args.sessionId !== undefined ? " " + String(args.sessionId) : ""),
365
+ ...(args.input !== undefined ? { description: args.input } : {})
366
+ })
367
+ });
368
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "ESNext", "DOM"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "noEmit": true,
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "types": []
15
+ },
16
+ "include": ["src/client.tsx", "src/client-types.d.ts"]
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "ESNext"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "rootDir": "src",
8
+ "outDir": "lib",
9
+ "declaration": true,
10
+ "declarationMap": false,
11
+ "sourceMap": false,
12
+ "strict": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noImplicitOverride": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "esModuleInterop": true,
18
+ "skipLibCheck": true,
19
+ "types": ["node"]
20
+ },
21
+ "include": ["src/**/*.ts"],
22
+ "exclude": ["src/client.tsx"]
23
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "test",
5
+ "outDir": "test-dist",
6
+ "noEmit": false,
7
+ "noUncheckedIndexedAccess": false,
8
+ "declaration": false
9
+ },
10
+ "include": ["test/**/*.ts"]
11
+ }