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/README.en.md +42 -164
- package/README.md +86 -92
- package/dist/client.core.js +48 -43
- package/dist/client.js +48 -43
- package/lib/client.js +146 -0
- package/lib/index.js +622 -608
- package/lib/terminal.js +389 -235
- package/package.json +29 -78
- package/scripts/build-client.mjs +14 -13
- package/src/client.jsx +136 -0
- package/README.ja.md +0 -196
- package/README.ko.md +0 -194
- package/dsh.plugin.json +0 -22
- package/lib/dsh-types.d.ts +0 -260
- package/lib/dsh-types.js +0 -9
- package/lib/dsh.d.ts +0 -33
- package/lib/dsh.js +0 -29
- package/lib/index.d.ts +0 -201
- package/lib/terminal.d.ts +0 -87
- package/src/client.tsx +0 -161
- package/src/dsh-types.ts +0 -313
- package/src/dsh.ts +0 -68
- package/src/index.ts +0 -952
- package/src/terminal.ts +0 -368
- package/tsconfig.client.json +0 -17
- package/tsconfig.json +0 -23
- package/tsconfig.test.json +0 -11
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 {
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
}
|