dsh-bash-terminal-ts 0.4.1 → 0.4.2

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