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,87 @@
1
+ import type { BashTerminalContext, ResolvedPaths, TerminalHandle } from "./dsh-types.js";
2
+ declare const TERMINAL_SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"];
3
+ export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
4
+ interface TerminalBufferRead {
5
+ delta: string;
6
+ nextOffset: number;
7
+ }
8
+ interface TerminalBuffer {
9
+ append(chunk: string): void;
10
+ readFrom(offset: number): TerminalBufferRead;
11
+ snapshot(): string;
12
+ }
13
+ /** Interactive-shell argv (no -c: the terminal itself is the session). */
14
+ export declare function terminalArgv(shell: string, paths: ResolvedPaths, distro?: string): Array<string | undefined>;
15
+ interface TerminalSession {
16
+ id: string;
17
+ handle: TerminalHandle;
18
+ buffer: TerminalBuffer;
19
+ shell: string;
20
+ distro?: string;
21
+ closed: boolean;
22
+ }
23
+ export interface TerminalOpenOptions {
24
+ argv: string[];
25
+ shell: string;
26
+ cwd: string;
27
+ env: Record<string, string | undefined>;
28
+ rows: number;
29
+ cols: number;
30
+ distro?: string;
31
+ initial?: string;
32
+ }
33
+ export interface TerminalRegistry {
34
+ open(options: TerminalOpenOptions): Promise<TerminalSession>;
35
+ get(id: string): TerminalSession;
36
+ send(id: string, input: string): Promise<TerminalBufferRead>;
37
+ read(id: string): TerminalBufferRead;
38
+ signal(id: string, sig: string): Promise<TerminalBufferRead>;
39
+ close(id: string): Promise<boolean>;
40
+ }
41
+ /**
42
+ * Terminal-session registry owned by the plugin fiber: opens PTYs through
43
+ * the official seam, forwards output into a capped buffer, and tears every
44
+ * session down on plugin disposal.
45
+ */
46
+ export declare function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegistry;
47
+ /** The terminal tool's arguments after runtime validation (discriminated by action). */
48
+ export type ValidatedTerminalArgs = {
49
+ action: "open";
50
+ sessionId?: string;
51
+ command?: string;
52
+ distro?: string;
53
+ workdir?: string;
54
+ } | {
55
+ action: "send";
56
+ sessionId: string;
57
+ input: string;
58
+ } | {
59
+ action: "read";
60
+ sessionId: string;
61
+ } | {
62
+ action: "signal";
63
+ sessionId: string;
64
+ signal: TerminalSignal;
65
+ } | {
66
+ action: "close";
67
+ sessionId: string;
68
+ };
69
+ export declare function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs;
70
+ export interface TerminalOpenResult {
71
+ kind: "open";
72
+ sessionId: string;
73
+ pid: number;
74
+ shell: string;
75
+ output: string;
76
+ }
77
+ export interface TerminalSessionResult {
78
+ kind: "session";
79
+ output: string;
80
+ }
81
+ export interface TerminalClosedResult {
82
+ kind: "closed";
83
+ sessionId: string;
84
+ }
85
+ export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult;
86
+ export declare function terminalTool(ctx: BashTerminalContext, registry: TerminalRegistry, paths: ResolvedPaths, defaultShell: () => string): import("./dsh-types.js").ToolDefinition;
87
+ export {};
@@ -0,0 +1,273 @@
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
+ import { randomUUID } from "node:crypto";
7
+ import { isAbsolute, resolve } from "node:path";
8
+ import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
9
+ import { buildEnv } from "./index.js";
10
+ const MAX_BUFFER_BYTES = 1024 * 1024;
11
+ const READ_SETTLE_MS = 800;
12
+ const DEFAULT_ROWS = 30;
13
+ const DEFAULT_COLS = 110;
14
+ const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"];
15
+ function delay(ms) {
16
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
17
+ }
18
+ /** Interactive-shell argv (no -c: the terminal itself is the session). */
19
+ export function terminalArgv(shell, paths, distro) {
20
+ switch (shell) {
21
+ case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
22
+ case "gitbash": return [paths.gitbash, "-i"];
23
+ case "msys2": return [paths.msys2, "-l"];
24
+ case "wsl": {
25
+ const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
26
+ return [paths.wsl, ...distroArg, "-e", "bash", "-i"];
27
+ }
28
+ default: throw new Error("invalid shell: " + JSON.stringify(shell));
29
+ }
30
+ }
31
+ /** In-memory output ring for one session (drop-oldest at the cap). */
32
+ function createBuffer() {
33
+ let text = "";
34
+ return {
35
+ append(chunk) {
36
+ text += chunk;
37
+ if (text.length > MAX_BUFFER_BYTES)
38
+ text = text.slice(text.length - MAX_BUFFER_BYTES);
39
+ },
40
+ readFrom(offset) {
41
+ const delta = offset >= text.length ? "" : text.slice(offset);
42
+ return { delta, nextOffset: text.length };
43
+ },
44
+ snapshot: () => text
45
+ };
46
+ }
47
+ /**
48
+ * Terminal-session registry owned by the plugin fiber: opens PTYs through
49
+ * the official seam, forwards output into a capped buffer, and tears every
50
+ * session down on plugin disposal.
51
+ */
52
+ export function createTerminalRegistry(ctx) {
53
+ const sessions = new Map();
54
+ ctx.effect(() => () => {
55
+ for (const session of sessions.values()) {
56
+ void session.handle.terminate().catch(() => { });
57
+ }
58
+ sessions.clear();
59
+ }, "bash-terminal: terminal sessions teardown");
60
+ async function open(options) {
61
+ const { argv, shell, cwd, env, rows, cols, distro, initial } = options;
62
+ const spawnTerminal = ctx.subprocess?.spawnTerminal;
63
+ if (spawnTerminal === undefined) {
64
+ throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
65
+ }
66
+ const handle = await spawnTerminal({
67
+ argv,
68
+ cwd,
69
+ env,
70
+ rows,
71
+ cols,
72
+ graceMs: 3000
73
+ });
74
+ const buffer = createBuffer();
75
+ handle.output.on("data", (chunk) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
76
+ const session = {
77
+ id: randomUUID(),
78
+ handle,
79
+ buffer,
80
+ shell,
81
+ distro,
82
+ closed: false
83
+ };
84
+ sessions.set(session.id, session);
85
+ if (initial !== undefined && initial.length > 0) {
86
+ await handle.write(initial);
87
+ await delay(READ_SETTLE_MS);
88
+ }
89
+ return session;
90
+ }
91
+ function get(id) {
92
+ const session = sessions.get(id);
93
+ if (session === undefined)
94
+ throw new Error("terminal session not found: " + JSON.stringify(id));
95
+ if (session.closed)
96
+ throw new Error("terminal session is closed: " + JSON.stringify(id));
97
+ return session;
98
+ }
99
+ async function send(id, input) {
100
+ const session = get(id);
101
+ await session.handle.write(input);
102
+ await delay(READ_SETTLE_MS);
103
+ return read(id);
104
+ }
105
+ function read(id) {
106
+ const session = get(id);
107
+ const { delta, nextOffset } = session.buffer.readFrom(0);
108
+ return { delta, nextOffset };
109
+ }
110
+ async function signal(id, sig) {
111
+ const session = get(id);
112
+ if (!TERMINAL_SIGNALS.includes(sig)) {
113
+ throw new Error("invalid terminal signal: " + JSON.stringify(sig));
114
+ }
115
+ await session.handle.signalForeground(sig);
116
+ await delay(READ_SETTLE_MS);
117
+ return read(id);
118
+ }
119
+ async function close(id) {
120
+ const session = get(id);
121
+ session.closed = true;
122
+ sessions.delete(id);
123
+ await session.handle.terminate();
124
+ return true;
125
+ }
126
+ return { open, get, send, read, signal, close };
127
+ }
128
+ export function validateArgs(args) {
129
+ const action = args.action;
130
+ if (typeof action !== "string" || ["open", "send", "read", "signal", "close"].indexOf(action) === -1) {
131
+ throw new Error("invalid action: expected open, send, read, signal, or close");
132
+ }
133
+ if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
134
+ throw new Error("invalid sessionId: required for " + action);
135
+ }
136
+ if (action === "send" && (typeof args.input !== "string" || args.input.length === 0)) {
137
+ throw new Error("invalid input: expected a non-empty string for send");
138
+ }
139
+ if (action === "signal" && (typeof args.signal !== "string" || TERMINAL_SIGNALS.indexOf(args.signal) === -1)) {
140
+ throw new Error("invalid signal: expected one of " + TERMINAL_SIGNALS.join(", "));
141
+ }
142
+ }
143
+ export function terminalTool(ctx, registry, paths, defaultShell) {
144
+ return defineTool({
145
+ name: "terminal",
146
+ 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.",
147
+ parameters: {
148
+ action: {
149
+ type: "string",
150
+ enum: ["open", "send", "read", "signal", "close"],
151
+ required: true,
152
+ 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."
153
+ },
154
+ sessionId: {
155
+ type: "string",
156
+ description: "Session id returned by open; required for send/read/signal/close."
157
+ },
158
+ command: {
159
+ type: "string",
160
+ description: "With action open: optional command to run immediately in the fresh shell (Enter appended). Default starts an interactive shell."
161
+ },
162
+ distro: {
163
+ type: "string",
164
+ description: "WSL distribution (only when the configured default terminal is wsl)."
165
+ },
166
+ input: {
167
+ type: "string",
168
+ description: "With action send: the input to write (no implicit newline; append \\n or \\r for Enter)."
169
+ },
170
+ signal: {
171
+ type: "string",
172
+ enum: TERMINAL_SIGNALS,
173
+ description: "With action signal: signal to the foreground process group (SIGINT = Ctrl+C, SIGKILL, SIGTERM, SIGTSTP, SIGHUP)."
174
+ },
175
+ workdir: {
176
+ type: "string",
177
+ description: "Working directory for the session (open only). Defaults to the session workspace."
178
+ }
179
+ },
180
+ output: {
181
+ schema: {
182
+ oneOf: [
183
+ {
184
+ type: "object",
185
+ additionalProperties: false,
186
+ properties: {
187
+ kind: { type: "string", required: true, const: "open" },
188
+ sessionId: { type: "string", required: true },
189
+ pid: { type: "integer", required: true },
190
+ shell: { type: "string", required: true },
191
+ output: { type: "string" }
192
+ }
193
+ },
194
+ {
195
+ type: "object",
196
+ additionalProperties: false,
197
+ properties: {
198
+ kind: { type: "string", required: true, const: "session" },
199
+ output: { type: "string", required: true }
200
+ }
201
+ },
202
+ {
203
+ type: "object",
204
+ additionalProperties: false,
205
+ properties: {
206
+ kind: { type: "string", required: true, const: "closed" },
207
+ sessionId: { type: "string", required: true }
208
+ }
209
+ }
210
+ ]
211
+ },
212
+ render: (_args, value) => [{
213
+ type: "text",
214
+ text: value.kind === "open"
215
+ ? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
216
+ : value.kind === "closed"
217
+ ? "terminal session " + value.sessionId + " closed"
218
+ : value.output
219
+ }]
220
+ },
221
+ async execute(args, exec) {
222
+ validateArgs(args);
223
+ if (exec.signal.aborted) {
224
+ const error = new HarnessError("tool call aborted", TOOL_ABORTED);
225
+ error.name = "AbortError";
226
+ throw error;
227
+ }
228
+ const headerCwd = exec.agent?.session.header.cwd;
229
+ switch (args.action) {
230
+ case "open": {
231
+ const shell = defaultShell();
232
+ const argv0 = terminalArgv(shell, paths, args.distro);
233
+ if (argv0[0] === undefined)
234
+ throw new Error("terminal: " + shell + " backend unavailable - executable not found");
235
+ // Only element 0 (the resolved executable) can be undefined; guarded above.
236
+ const argv = argv0;
237
+ const cwd = args.workdir !== undefined ? (headerCwd !== undefined && !isAbsolute(args.workdir) ? resolve(headerCwd, args.workdir) : args.workdir) : (headerCwd ?? process.cwd());
238
+ // buildEnv, not an inline duplicate: the msys2 backend needs its
239
+ // MSYSTEM=MINGW64 injection here too, or the login shell sources
240
+ // /etc/profile with the default MSYS environment and /mingw64/bin
241
+ // (gcc, make) never joins PATH. DSH's spawnTerminal replaces the child
242
+ // environment with exactly this object (childEnv(spec.env)), so the
243
+ // inherited WSLENV is passed through explicitly for the WSL allow-list;
244
+ // for every other backend buildEnv ignores the third argument.
245
+ const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
246
+ 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 });
247
+ return { kind: "open", sessionId: session.id, pid: session.handle.pid, shell, output: session.buffer.snapshot() };
248
+ }
249
+ case "send": {
250
+ const { delta } = await registry.send(args.sessionId, args.input);
251
+ return { kind: "session", output: delta };
252
+ }
253
+ case "read": {
254
+ const { delta } = registry.read(args.sessionId);
255
+ return { kind: "session", output: delta };
256
+ }
257
+ case "signal": {
258
+ const { delta } = await registry.signal(args.sessionId, args.signal);
259
+ return { kind: "session", output: delta };
260
+ }
261
+ case "close": {
262
+ await registry.close(args.sessionId);
263
+ return { kind: "closed", sessionId: args.sessionId };
264
+ }
265
+ }
266
+ },
267
+ presentCall: (args) => ({
268
+ card: "terminal",
269
+ title: "terminal " + String(args.action) + (args.sessionId !== undefined ? " " + String(args.sessionId) : ""),
270
+ ...(args.input !== undefined ? { description: args.input } : {})
271
+ })
272
+ });
273
+ }
package/package.json ADDED
@@ -0,0 +1,122 @@
1
+ {
2
+ "name": "dsh-bash-terminal-ts",
3
+ "version": "0.2.5",
4
+ "description": "DSH plugin: one shell tool that runs commands through PowerShell, Git Bash, MSYS2, or WSL on Windows, with a user-chosen default terminal in the Web UI settings. TypeScript rewrite of MAXeaglet/dsh-bash-terminal with working MSYS2/MINGW64 support.",
5
+ "author": "drscrewdriver",
6
+ "contributors": [
7
+ "MAXeaglet (original dsh-bash-terminal)"
8
+ ],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/drscrewdriver/dsh-bash-terminal-ts.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/drscrewdriver/dsh-bash-terminal-ts/issues"
16
+ },
17
+ "homepage": "https://github.com/drscrewdriver/dsh-bash-terminal-ts#readme",
18
+ "keywords": [
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "plugin",
22
+ "terminal",
23
+ "shell",
24
+ "powershell",
25
+ "git-bash",
26
+ "msys2",
27
+ "mingw64",
28
+ "wsl",
29
+ "windows",
30
+ "typescript"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20",
34
+ "dsh": ">=0.1.2-rc.1 <0.2.0-0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "type": "module",
40
+ "main": "lib/index.js",
41
+ "exports": {
42
+ ".": "./lib/index.js",
43
+ "./client": "./dist/client.js",
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "lib",
48
+ "dist",
49
+ "src",
50
+ "scripts",
51
+ "tsconfig.json",
52
+ "tsconfig.client.json",
53
+ "tsconfig.test.json",
54
+ "README.md",
55
+ "README.en.md",
56
+ "README.ja.md",
57
+ "README.ko.md",
58
+ "LICENSE",
59
+ "cordis.patch.yml",
60
+ "dsh.plugin.json"
61
+ ],
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/build-client.mjs && tsc -p tsconfig.test.json",
64
+ "build:client": "node scripts/build-client.mjs",
65
+ "test": "node test-dist/unit.js && node test-dist/apply.js && node test-dist/client.js"
66
+ },
67
+ "dsh": {
68
+ "bundle": {
69
+ "patch": "./cordis.patch.yml"
70
+ },
71
+ "client": {
72
+ "platform": "web",
73
+ "immediately": true,
74
+ "inject": [
75
+ "@deepseek-ai/dsh-client-store",
76
+ "@deepseek-ai/dsh-client-locale",
77
+ "@deepseek-ai/dsh-client-ui-settings",
78
+ "@deepseek-ai/dsh-client-ui-slots"
79
+ ]
80
+ }
81
+ },
82
+ "peerDependencies": {
83
+ "@deepseek-ai/cordis": "^4.0.2",
84
+ "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
85
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
86
+ "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
87
+ "@deepseek-ai/dsh-shell": "^0.1.2-rc.1",
88
+ "@deepseek-ai/dsh-timeout": "^0.1.2-rc.1",
89
+ "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
90
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
91
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
92
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
93
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
94
+ "@deepseek-ai/dsh-sandbox": "^0.1.2-rc.1",
95
+ "react": "^18.2.0"
96
+ },
97
+ "peerDependenciesMeta": {
98
+ "@deepseek-ai/cordis": { "optional": true },
99
+ "@deepseek-ai/dsh-tools": { "optional": true },
100
+ "@deepseek-ai/dsh-settings": { "optional": true },
101
+ "@deepseek-ai/dsh-llm": { "optional": true },
102
+ "@deepseek-ai/dsh-shell": { "optional": true },
103
+ "@deepseek-ai/dsh-timeout": { "optional": true },
104
+ "@deepseek-ai/dsh-client-store": { "optional": true },
105
+ "@deepseek-ai/dsh-client-locale": { "optional": true },
106
+ "@deepseek-ai/dsh-client-ui-settings": { "optional": true },
107
+ "@deepseek-ai/dsh-client-ui-slots": { "optional": true },
108
+ "@deepseek-ai/dsh-client-ui-primitives": { "optional": true },
109
+ "@deepseek-ai/dsh-sandbox": { "optional": true },
110
+ "react": { "optional": true }
111
+ },
112
+ "dependencies": {
113
+ "@deepseek-ai/schemastery": "^3.18.1"
114
+ },
115
+ "devDependencies": {
116
+ "@types/node": "^22.10.0",
117
+ "@types/react": "^18.2.0",
118
+ "@types/react-dom": "^18.2.0",
119
+ "esbuild": "^0.28.2",
120
+ "typescript": "^5.9.0"
121
+ }
122
+ }
@@ -0,0 +1,46 @@
1
+ // Build the browser client bundle for dsh-bash-terminal-ts.
2
+ // Output: dist/client.js — a __ModuleLoader__.load({ id, factory }) wrapper
3
+ // around the esbuild CJS bundle; shared deps (react, @deepseek-ai/*) resolve
4
+ // through the loader's require.
5
+
6
+ import { build } from "esbuild";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+
11
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
12
+ mkdirSync(join(root, "dist"), { recursive: true });
13
+
14
+ await build({
15
+ entryPoints: [join(root, "src", "client.tsx")],
16
+ bundle: true,
17
+ format: "cjs",
18
+ platform: "browser",
19
+ outfile: join(root, "dist", "client.core.js"),
20
+ external: ["react", "react/jsx-runtime", "react-dom", "@deepseek-ai/*"],
21
+ jsx: "automatic",
22
+ // Pin the TS options instead of letting esbuild discover a tsconfig by walking
23
+ // up from the entry point. The repo root has a tsconfig.json, so in-repo builds
24
+ // silently inherited it and emitted a leading "use strict"; while a byte-for-byte
25
+ // identical build from a worktree outside the repo (no tsconfig above it) did not
26
+ // — same source, two different committed artifacts. These values are what the
27
+ // root tsconfig actually supplied. The one observable delta is that stray
28
+ // "use strict"; (inert: the core is wrapped in a function expression by the
29
+ // loader shim below, so it was never a directive prologue), now gone everywhere.
30
+ tsconfigRaw: { compilerOptions: { target: "ES2022", useDefineForClassFields: true } },
31
+ logLevel: "warning"
32
+ });
33
+
34
+ const core = readFileSync(join(root, "dist", "client.core.js"), "utf8");
35
+ const wrapper = `window.__ModuleLoader__.load({
36
+ id: "dsh-bash-terminal-ts",
37
+ factory: (require) => {
38
+ var module = { exports: {} };
39
+ var exports = module.exports;
40
+ ${core}
41
+ return module.exports;
42
+ }
43
+ });
44
+ `;
45
+ writeFileSync(join(root, "dist", "client.js"), wrapper);
46
+ console.log("built dist/client.js (" + wrapper.length + " bytes)");