mini-coder 0.5.10 → 0.5.12
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/BENCHMARK.md +408 -0
- package/PROGRESS.md +5 -0
- package/README.md +14 -1
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-loop.sh +19 -0
- package/package.json +1 -1
- package/src/agent.ts +5 -1
- package/src/headless.ts +62 -21
- package/src/index.ts +35 -56
- package/src/prompt.ts +8 -0
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +19 -15
- package/src/shared.ts +39 -0
- package/src/submit.ts +3 -25
- package/src/text.ts +71 -0
- package/src/tool-common.ts +91 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +869 -0
- package/src/tools.ts +186 -995
- package/src/ui/agent.ts +199 -110
- package/src/ui/commands.test.ts +16 -302
- package/src/ui/commands.ts +21 -47
- package/src/ui/conversation.test.ts +263 -1389
- package/src/ui/conversation.ts +496 -151
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +196 -114
- package/src/ui/agent.test.ts +0 -49
- package/src/ui/help.test.ts +0 -65
- package/src/ui/overlay.test.ts +0 -42
- package/src/ui/render-performance.test.ts +0 -444
- package/src/ui/status.test.ts +0 -489
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-tool implementation and shell-specific helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Static, Tool } from "@mariozechner/pi-ai";
|
|
8
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
|
|
10
|
+
import {
|
|
11
|
+
detectLineEnding,
|
|
12
|
+
normalizeLineEndings,
|
|
13
|
+
type ToolExecResult,
|
|
14
|
+
textResult,
|
|
15
|
+
validateBuiltinToolArgs,
|
|
16
|
+
} from "./tool-common.ts";
|
|
17
|
+
|
|
18
|
+
const shellToolParameters = Type.Object({
|
|
19
|
+
command: Type.String({ description: "The shell command to execute" }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Arguments for the `shell` tool. */
|
|
23
|
+
export type ShellArgs = Static<typeof shellToolParameters>;
|
|
24
|
+
|
|
25
|
+
/** Options for shell execution. */
|
|
26
|
+
export interface ShellOpts {
|
|
27
|
+
/** Maximum output lines before truncation. Default: 1000. */
|
|
28
|
+
maxLines?: number;
|
|
29
|
+
/** Maximum UTF-8 bytes before truncation. Default: 50_000. */
|
|
30
|
+
maxBytes?: number;
|
|
31
|
+
/** Abort signal to cancel the command. */
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
/** Callback for progressive output updates while the command is running. */
|
|
34
|
+
onUpdate?: ToolUpdateCallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** pi-ai tool definition for `shell`. */
|
|
38
|
+
export const shellTool: Tool<typeof shellToolParameters> = {
|
|
39
|
+
name: "shell",
|
|
40
|
+
description:
|
|
41
|
+
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
42
|
+
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands. " +
|
|
43
|
+
"Commands mutate the real working directory, so direct verification outputs to temporary paths or clean them up before finishing.",
|
|
44
|
+
parameters: shellToolParameters,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tool handler that validates shell arguments before execution.
|
|
49
|
+
*
|
|
50
|
+
* @param args - Raw parsed tool-call arguments.
|
|
51
|
+
* @param cwd - Working directory for command execution.
|
|
52
|
+
* @param signal - Optional abort signal.
|
|
53
|
+
* @param onUpdate - Optional progressive output callback.
|
|
54
|
+
* @returns The shell tool result.
|
|
55
|
+
*/
|
|
56
|
+
export const shellToolHandler: ToolHandler = (args, cwd, signal, onUpdate) =>
|
|
57
|
+
executeShell(validateBuiltinToolArgs(shellTool, args), cwd, {
|
|
58
|
+
...(signal ? { signal } : {}),
|
|
59
|
+
...(onUpdate ? { onUpdate } : {}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
type ShellProcess = ReturnType<typeof Bun.spawn>;
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MAX_LINES = 1000;
|
|
65
|
+
const DEFAULT_MAX_BYTES = 50_000;
|
|
66
|
+
const SHELL_UPDATE_INTERVAL_MS = 75;
|
|
67
|
+
const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
|
|
68
|
+
|
|
69
|
+
/** Format combined stdout/stderr for display in tool results. */
|
|
70
|
+
function formatShellOutput(stdout: string, stderr: string): string {
|
|
71
|
+
if (stdout && stderr) {
|
|
72
|
+
return `${stdout}\n\n[stderr]\n${stderr}`;
|
|
73
|
+
}
|
|
74
|
+
if (stdout) {
|
|
75
|
+
return stdout;
|
|
76
|
+
}
|
|
77
|
+
if (stderr) {
|
|
78
|
+
return `[stderr]\n${stderr}`;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ShellCommandLines {
|
|
84
|
+
lines: string[];
|
|
85
|
+
lineEnding: "\n" | "\r\n";
|
|
86
|
+
hasTrailingLineEnding: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface PendingHeredoc {
|
|
90
|
+
startLineIndex: number;
|
|
91
|
+
delimiter: string;
|
|
92
|
+
stripLeadingTabs: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ShellQuoteState {
|
|
96
|
+
quote: "'" | '"' | null;
|
|
97
|
+
escaped: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitShellCommandLines(command: string): ShellCommandLines {
|
|
101
|
+
const lineEnding = detectLineEnding(command) ?? "\n";
|
|
102
|
+
const normalized = normalizeLineEndings(command, "\n");
|
|
103
|
+
const hasTrailingLineEnding = normalized.endsWith("\n");
|
|
104
|
+
const lines = normalized.split("\n");
|
|
105
|
+
if (hasTrailingLineEnding) {
|
|
106
|
+
lines.pop();
|
|
107
|
+
}
|
|
108
|
+
return { lines, lineEnding, hasTrailingLineEnding };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function joinShellCommandLines(parts: ShellCommandLines): string {
|
|
112
|
+
const joined = parts.lines.join(parts.lineEnding);
|
|
113
|
+
if (parts.hasTrailingLineEnding) {
|
|
114
|
+
return joined + parts.lineEnding;
|
|
115
|
+
}
|
|
116
|
+
return joined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function advanceShellQuoteState(char: string, state: ShellQuoteState): boolean {
|
|
120
|
+
if (state.quote === "'") {
|
|
121
|
+
if (char === "'") {
|
|
122
|
+
state.quote = null;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (state.quote === '"') {
|
|
128
|
+
if (state.escaped) {
|
|
129
|
+
state.escaped = false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (char === "\\") {
|
|
133
|
+
state.escaped = true;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (char === '"') {
|
|
137
|
+
state.quote = null;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (char === "'") {
|
|
143
|
+
state.quote = "'";
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (char === '"') {
|
|
147
|
+
state.quote = '"';
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isHeredocPrefixCharacter(char: string): boolean {
|
|
155
|
+
return (
|
|
156
|
+
char === "" ||
|
|
157
|
+
char === " " ||
|
|
158
|
+
char === "\t" ||
|
|
159
|
+
char === ";" ||
|
|
160
|
+
char === "(" ||
|
|
161
|
+
char === "&" ||
|
|
162
|
+
char === "|"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getHeredocStartAt(
|
|
167
|
+
line: string,
|
|
168
|
+
index: number,
|
|
169
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
170
|
+
if (line[index] !== "<" || line[index + 1] !== "<") {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const previousChar = index === 0 ? "" : (line[index - 1] ?? "");
|
|
175
|
+
if (!isHeredocPrefixCharacter(previousChar)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
index,
|
|
181
|
+
stripLeadingTabs: line[index + 2] === "-",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function findUnquotedHeredocStart(
|
|
186
|
+
line: string,
|
|
187
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
188
|
+
const quoteState: ShellQuoteState = { quote: null, escaped: false };
|
|
189
|
+
let heredocStart: { index: number; stripLeadingTabs: boolean } | null = null;
|
|
190
|
+
|
|
191
|
+
for (let index = 0; index < line.length - 1; index++) {
|
|
192
|
+
const char = line[index];
|
|
193
|
+
if (char === undefined || advanceShellQuoteState(char, quoteState)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const nextHeredocStart = getHeredocStartAt(line, index);
|
|
198
|
+
if (!nextHeredocStart) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (heredocStart) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
heredocStart = nextHeredocStart;
|
|
206
|
+
index += heredocStart.stripLeadingTabs ? 2 : 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return heredocStart;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function skipHeredocDelimiterWhitespace(line: string, cursor: number): number {
|
|
213
|
+
let nextCursor = cursor;
|
|
214
|
+
while (line[nextCursor] === " " || line[nextCursor] === "\t") {
|
|
215
|
+
nextCursor++;
|
|
216
|
+
}
|
|
217
|
+
return nextCursor;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readQuotedHeredocDelimiter(
|
|
221
|
+
line: string,
|
|
222
|
+
cursor: number,
|
|
223
|
+
): string | null {
|
|
224
|
+
const quote = line[cursor];
|
|
225
|
+
if (quote !== "'" && quote !== '"') {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const endQuoteIndex = line.indexOf(quote, cursor + 1);
|
|
230
|
+
if (endQuoteIndex === -1) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return line.slice(cursor + 1, endQuoteIndex);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isHeredocDelimiterStopCharacter(char: string): boolean {
|
|
237
|
+
return (
|
|
238
|
+
char === " " ||
|
|
239
|
+
char === "\t" ||
|
|
240
|
+
char === "<" ||
|
|
241
|
+
char === ">" ||
|
|
242
|
+
char === "&" ||
|
|
243
|
+
char === "|" ||
|
|
244
|
+
char === ";" ||
|
|
245
|
+
char === "(" ||
|
|
246
|
+
char === ")"
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function readBareHeredocDelimiter(line: string, cursor: number): string | null {
|
|
251
|
+
const startChar = line[cursor];
|
|
252
|
+
if (startChar === undefined || !/[A-Za-z_]/.test(startChar)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let endIndex = cursor;
|
|
257
|
+
while (endIndex < line.length) {
|
|
258
|
+
const currentChar = line[endIndex];
|
|
259
|
+
if (
|
|
260
|
+
currentChar === undefined ||
|
|
261
|
+
isHeredocDelimiterStopCharacter(currentChar)
|
|
262
|
+
) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
endIndex++;
|
|
266
|
+
}
|
|
267
|
+
return line.slice(cursor, endIndex);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function findUnquotedHeredoc(
|
|
271
|
+
line: string,
|
|
272
|
+
startLineIndex: number,
|
|
273
|
+
): PendingHeredoc | null {
|
|
274
|
+
const heredocStart = findUnquotedHeredocStart(line);
|
|
275
|
+
if (!heredocStart) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const cursor = skipHeredocDelimiterWhitespace(
|
|
280
|
+
line,
|
|
281
|
+
heredocStart.index + 2 + (heredocStart.stripLeadingTabs ? 1 : 0),
|
|
282
|
+
);
|
|
283
|
+
const delimiter =
|
|
284
|
+
readQuotedHeredocDelimiter(line, cursor) ??
|
|
285
|
+
readBareHeredocDelimiter(line, cursor);
|
|
286
|
+
if (!delimiter) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
startLineIndex,
|
|
292
|
+
delimiter,
|
|
293
|
+
stripLeadingTabs: heredocStart.stripLeadingTabs,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getHeredocLineBody(line: string, stripLeadingTabs: boolean): string {
|
|
298
|
+
if (!stripLeadingTabs) {
|
|
299
|
+
return line;
|
|
300
|
+
}
|
|
301
|
+
return line.replace(/^\t+/, "");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getSupportedHeredocTrailer(rest: string): string | null {
|
|
305
|
+
const trimmedRest = rest.trimStart();
|
|
306
|
+
if (!trimmedRest) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (trimmedRest.startsWith("&&")) {
|
|
310
|
+
return trimmedRest.slice(2).trim() ? rest : null;
|
|
311
|
+
}
|
|
312
|
+
if (trimmedRest.startsWith("||")) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (trimmedRest.startsWith("|")) {
|
|
316
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
317
|
+
}
|
|
318
|
+
if (trimmedRest.startsWith(">")) {
|
|
319
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rewritePendingHeredocTrailer(
|
|
325
|
+
parts: ShellCommandLines,
|
|
326
|
+
line: string,
|
|
327
|
+
lineIndex: number,
|
|
328
|
+
pendingHeredoc: PendingHeredoc,
|
|
329
|
+
): PendingHeredoc | null {
|
|
330
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
331
|
+
if (body === pendingHeredoc.delimiter) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (!body.startsWith(pendingHeredoc.delimiter)) {
|
|
335
|
+
return pendingHeredoc;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const trailer = getSupportedHeredocTrailer(
|
|
339
|
+
body.slice(pendingHeredoc.delimiter.length),
|
|
340
|
+
);
|
|
341
|
+
if (!trailer) {
|
|
342
|
+
return pendingHeredoc;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const startLine = parts.lines[pendingHeredoc.startLineIndex];
|
|
346
|
+
if (startLine === undefined) {
|
|
347
|
+
return pendingHeredoc;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
parts.lines[pendingHeredoc.startLineIndex] = startLine + trailer;
|
|
351
|
+
const leadingTabs = pendingHeredoc.stripLeadingTabs
|
|
352
|
+
? (line.match(/^\t*/) ?? [""])[0]
|
|
353
|
+
: "";
|
|
354
|
+
parts.lines[lineIndex] = `${leadingTabs}${pendingHeredoc.delimiter}`;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function normalizeHeredocTrailingContinuations(command: string): string {
|
|
359
|
+
const parts = splitShellCommandLines(command);
|
|
360
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
361
|
+
|
|
362
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
363
|
+
if (pendingHeredoc) {
|
|
364
|
+
pendingHeredoc = rewritePendingHeredocTrailer(
|
|
365
|
+
parts,
|
|
366
|
+
line,
|
|
367
|
+
index,
|
|
368
|
+
pendingHeredoc,
|
|
369
|
+
);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
pendingHeredoc = findUnquotedHeredoc(line, index);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return joinShellCommandLines(parts);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeLeadingDashPrintf(command: string): string {
|
|
380
|
+
const parts = splitShellCommandLines(command);
|
|
381
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
382
|
+
|
|
383
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
384
|
+
if (pendingHeredoc) {
|
|
385
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
386
|
+
if (body === pendingHeredoc.delimiter) {
|
|
387
|
+
pendingHeredoc = null;
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
parts.lines[index] = line.replace(
|
|
393
|
+
/^(\s*)printf(\s+)(['"])-/,
|
|
394
|
+
"$1printf$2-- $3-",
|
|
395
|
+
);
|
|
396
|
+
pendingHeredoc = findUnquotedHeredoc(parts.lines[index] || "", index);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return joinShellCommandLines(parts);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function normalizeShellCommand(command: string): string {
|
|
403
|
+
try {
|
|
404
|
+
return normalizeLeadingDashPrintf(
|
|
405
|
+
normalizeHeredocTrailingContinuations(command),
|
|
406
|
+
);
|
|
407
|
+
} catch {
|
|
408
|
+
return command;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
interface ShellStreamCapture {
|
|
413
|
+
done: Promise<void>;
|
|
414
|
+
getOutput: () => string;
|
|
415
|
+
isFinished: () => boolean;
|
|
416
|
+
close: () => Promise<void>;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function startShellStreamCapture(
|
|
420
|
+
stream: ReadableStream<Uint8Array>,
|
|
421
|
+
onChunk: (chunk: string) => void,
|
|
422
|
+
): ShellStreamCapture {
|
|
423
|
+
const reader = stream.getReader();
|
|
424
|
+
const decoder = new TextDecoder();
|
|
425
|
+
let output = "";
|
|
426
|
+
let closed = false;
|
|
427
|
+
let finished = false;
|
|
428
|
+
|
|
429
|
+
const done = (async (): Promise<void> => {
|
|
430
|
+
try {
|
|
431
|
+
while (true) {
|
|
432
|
+
const { done, value } = await reader.read();
|
|
433
|
+
if (done) {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
438
|
+
output += chunk;
|
|
439
|
+
onChunk(chunk);
|
|
440
|
+
}
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (!closed) {
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
} finally {
|
|
446
|
+
const trailing = decoder.decode();
|
|
447
|
+
output += trailing;
|
|
448
|
+
onChunk(trailing);
|
|
449
|
+
finished = true;
|
|
450
|
+
}
|
|
451
|
+
})();
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
done,
|
|
455
|
+
getOutput: () => output,
|
|
456
|
+
isFinished: () => finished,
|
|
457
|
+
close: async (): Promise<void> => {
|
|
458
|
+
if (!finished) {
|
|
459
|
+
closed = true;
|
|
460
|
+
try {
|
|
461
|
+
await reader.cancel();
|
|
462
|
+
} catch {
|
|
463
|
+
// Ignore cancellation errors while closing the pipe after exit/abort.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
await done;
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function finalizeShellStreamCaptures(
|
|
472
|
+
captures: readonly ShellStreamCapture[],
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
const pending = captures
|
|
475
|
+
.filter((capture) => !capture.isFinished())
|
|
476
|
+
.map((capture) => capture.done);
|
|
477
|
+
|
|
478
|
+
if (pending.length > 0) {
|
|
479
|
+
await new Promise<void>((resolve) => {
|
|
480
|
+
const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
|
|
481
|
+
void Promise.allSettled(pending).then(() => {
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
resolve();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await Promise.all(captures.map((capture) => capture.close()));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
|
|
492
|
+
return {
|
|
493
|
+
cwd,
|
|
494
|
+
stdout: "pipe",
|
|
495
|
+
stderr: "pipe",
|
|
496
|
+
...(process.platform === "win32" ? {} : { detached: true }),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function abortShellProcess(proc: ShellProcess): void {
|
|
501
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (process.platform !== "win32") {
|
|
506
|
+
try {
|
|
507
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
508
|
+
return;
|
|
509
|
+
} catch {
|
|
510
|
+
// Fall through to a direct kill when the process group is unavailable.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
proc.kill("SIGTERM");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function registerShellAbort(
|
|
518
|
+
signal: AbortSignal | undefined,
|
|
519
|
+
proc: ShellProcess,
|
|
520
|
+
): (() => void) | null {
|
|
521
|
+
if (!signal) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const abortListener = (): void => {
|
|
526
|
+
abortShellProcess(proc);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
if (signal.aborted) {
|
|
530
|
+
abortShellProcess(proc);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
535
|
+
return () => {
|
|
536
|
+
signal.removeEventListener("abort", abortListener);
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Run a command in the user's shell.
|
|
542
|
+
*
|
|
543
|
+
* Executes via `$SHELL -c` (falling back to `/bin/sh`). Returns combined
|
|
544
|
+
* stdout/stderr and the exit code. Large output is truncated to keep
|
|
545
|
+
* head + tail lines with a middle marker.
|
|
546
|
+
*
|
|
547
|
+
* @param args - Shell arguments (command).
|
|
548
|
+
* @param cwd - Working directory to run the command in.
|
|
549
|
+
* @param opts - Optional execution options (maxLines, signal, onUpdate).
|
|
550
|
+
* @returns A {@link ToolExecResult} with the command output.
|
|
551
|
+
*/
|
|
552
|
+
export async function executeShell(
|
|
553
|
+
args: ShellArgs,
|
|
554
|
+
cwd: string,
|
|
555
|
+
opts?: ShellOpts,
|
|
556
|
+
): Promise<ToolExecResult> {
|
|
557
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
558
|
+
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
|
|
559
|
+
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
560
|
+
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
561
|
+
let cleanupAbort: (() => void) | null = null;
|
|
562
|
+
let lastReportedOutput = "";
|
|
563
|
+
let lastReportAt = 0;
|
|
564
|
+
let stdoutCapture: ShellStreamCapture | null = null;
|
|
565
|
+
let stderrCapture: ShellStreamCapture | null = null;
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const clearPendingUpdate = (): void => {
|
|
569
|
+
if (updateTimer) {
|
|
570
|
+
clearTimeout(updateTimer);
|
|
571
|
+
updateTimer = null;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const buildOutput = (trimEnd: boolean): string => {
|
|
576
|
+
const stdout = stdoutCapture?.getOutput() ?? "";
|
|
577
|
+
const stderr = stderrCapture?.getOutput() ?? "";
|
|
578
|
+
return truncateOutput(
|
|
579
|
+
formatShellOutput(
|
|
580
|
+
trimEnd ? stdout.trimEnd() : stdout,
|
|
581
|
+
trimEnd ? stderr.trimEnd() : stderr,
|
|
582
|
+
),
|
|
583
|
+
maxLines,
|
|
584
|
+
maxBytes,
|
|
585
|
+
);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const emitUpdate = (): void => {
|
|
589
|
+
clearPendingUpdate();
|
|
590
|
+
if (!opts?.onUpdate) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const output = buildOutput(false);
|
|
595
|
+
if (!output || output === lastReportedOutput) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
lastReportedOutput = output;
|
|
600
|
+
lastReportAt = Date.now();
|
|
601
|
+
opts.onUpdate(textResult(output, false));
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const scheduleUpdate = (): void => {
|
|
605
|
+
if (!opts?.onUpdate) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const elapsed = Date.now() - lastReportAt;
|
|
610
|
+
if (elapsed >= SHELL_UPDATE_INTERVAL_MS) {
|
|
611
|
+
emitUpdate();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (updateTimer) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
updateTimer = setTimeout(() => {
|
|
619
|
+
emitUpdate();
|
|
620
|
+
}, SHELL_UPDATE_INTERVAL_MS - elapsed);
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const command = normalizeShellCommand(args.command);
|
|
624
|
+
const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
|
|
625
|
+
cleanupAbort = registerShellAbort(opts?.signal, proc);
|
|
626
|
+
stdoutCapture = startShellStreamCapture(
|
|
627
|
+
proc.stdout as ReadableStream<Uint8Array>,
|
|
628
|
+
() => {
|
|
629
|
+
scheduleUpdate();
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
stderrCapture = startShellStreamCapture(
|
|
633
|
+
proc.stderr as ReadableStream<Uint8Array>,
|
|
634
|
+
() => {
|
|
635
|
+
scheduleUpdate();
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const exitCode = await proc.exited;
|
|
640
|
+
cleanupAbort?.();
|
|
641
|
+
cleanupAbort = null;
|
|
642
|
+
|
|
643
|
+
await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
|
|
644
|
+
clearPendingUpdate();
|
|
645
|
+
const output = buildOutput(true);
|
|
646
|
+
if (opts?.onUpdate && output && output !== lastReportedOutput) {
|
|
647
|
+
lastReportedOutput = output;
|
|
648
|
+
opts.onUpdate(textResult(output, false));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const isError = exitCode !== 0;
|
|
652
|
+
const body = output || "(no output)";
|
|
653
|
+
return textResult(`Exit code: ${exitCode}\n${body}`, isError);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
cleanupAbort?.();
|
|
656
|
+
cleanupAbort = null;
|
|
657
|
+
const captures = [stdoutCapture, stderrCapture].filter(
|
|
658
|
+
(capture): capture is ShellStreamCapture => capture !== null,
|
|
659
|
+
);
|
|
660
|
+
if (captures.length > 0) {
|
|
661
|
+
await Promise.allSettled(captures.map((capture) => capture.close()));
|
|
662
|
+
}
|
|
663
|
+
if (updateTimer) {
|
|
664
|
+
clearTimeout(updateTimer);
|
|
665
|
+
updateTimer = null;
|
|
666
|
+
}
|
|
667
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
668
|
+
return textResult(`Shell error: ${message}`, true);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Build line-limited head/tail segments and their truncation marker. */
|
|
673
|
+
function buildLineTruncation(
|
|
674
|
+
output: string,
|
|
675
|
+
maxLines: number,
|
|
676
|
+
): {
|
|
677
|
+
head: string;
|
|
678
|
+
tail: string;
|
|
679
|
+
marker: string;
|
|
680
|
+
} | null {
|
|
681
|
+
const lines = output.split("\n");
|
|
682
|
+
if (lines.length <= maxLines) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const headCount = Math.ceil(maxLines / 2);
|
|
687
|
+
const tailCount = Math.floor(maxLines / 2);
|
|
688
|
+
const omitted = lines.length - headCount - tailCount;
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
head: lines.slice(0, headCount).join("\n"),
|
|
692
|
+
tail: lines.slice(lines.length - tailCount).join("\n"),
|
|
693
|
+
marker: `\n… truncated ${omitted} lines …\n`,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function isHighSurrogate(codeUnit: number): boolean {
|
|
698
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function isLowSurrogate(codeUnit: number): boolean {
|
|
702
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function findUtf8SliceLength(
|
|
706
|
+
input: string,
|
|
707
|
+
maxBytes: number,
|
|
708
|
+
getCandidate: (length: number) => string,
|
|
709
|
+
): number {
|
|
710
|
+
if (maxBytes <= 0 || input === "") {
|
|
711
|
+
return 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let low = 0;
|
|
715
|
+
let high = input.length;
|
|
716
|
+
while (low < high) {
|
|
717
|
+
const mid = Math.ceil((low + high) / 2);
|
|
718
|
+
const candidate = getCandidate(mid);
|
|
719
|
+
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
|
|
720
|
+
low = mid;
|
|
721
|
+
} else {
|
|
722
|
+
high = mid - 1;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return low;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function normalizeUtf8PrefixEnd(input: string, end: number): number {
|
|
730
|
+
if (end <= 0 || end >= input.length) {
|
|
731
|
+
return end;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const previousCodeUnit = input.charCodeAt(end - 1);
|
|
735
|
+
const nextCodeUnit = input.charCodeAt(end);
|
|
736
|
+
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
737
|
+
return end - 1;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return end;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function normalizeUtf8SuffixStart(input: string, start: number): number {
|
|
744
|
+
if (start <= 0 || start >= input.length) {
|
|
745
|
+
return start;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const previousCodeUnit = input.charCodeAt(start - 1);
|
|
749
|
+
const nextCodeUnit = input.charCodeAt(start);
|
|
750
|
+
if (isHighSurrogate(previousCodeUnit) && isLowSurrogate(nextCodeUnit)) {
|
|
751
|
+
return start + 1;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return start;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Slice the largest UTF-8 prefix that fits within `maxBytes`. */
|
|
758
|
+
function sliceUtf8Prefix(input: string, maxBytes: number): string {
|
|
759
|
+
const end = normalizeUtf8PrefixEnd(
|
|
760
|
+
input,
|
|
761
|
+
findUtf8SliceLength(input, maxBytes, (length) => input.slice(0, length)),
|
|
762
|
+
);
|
|
763
|
+
return input.slice(0, end);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Slice the largest UTF-8 suffix that fits within `maxBytes`. */
|
|
767
|
+
function sliceUtf8Suffix(input: string, maxBytes: number): string {
|
|
768
|
+
const start = normalizeUtf8SuffixStart(
|
|
769
|
+
input,
|
|
770
|
+
input.length -
|
|
771
|
+
findUtf8SliceLength(input, maxBytes, (length) =>
|
|
772
|
+
input.slice(input.length - length),
|
|
773
|
+
),
|
|
774
|
+
);
|
|
775
|
+
return input.slice(start);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Fit disjoint head/tail segments plus a marker within a UTF-8 byte budget. */
|
|
779
|
+
function fitSegmentsWithinBytes(
|
|
780
|
+
headSource: string,
|
|
781
|
+
tailSource: string,
|
|
782
|
+
marker: string,
|
|
783
|
+
maxBytes: number,
|
|
784
|
+
): string {
|
|
785
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
786
|
+
if (markerBytes >= maxBytes) {
|
|
787
|
+
return sliceUtf8Prefix(headSource, maxBytes);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const availableBytes = maxBytes - markerBytes;
|
|
791
|
+
const headBudget = Math.ceil(availableBytes / 2);
|
|
792
|
+
const tailBudget = Math.floor(availableBytes / 2);
|
|
793
|
+
|
|
794
|
+
let head = sliceUtf8Prefix(headSource, headBudget);
|
|
795
|
+
let tail = sliceUtf8Suffix(tailSource, tailBudget);
|
|
796
|
+
|
|
797
|
+
const usedBytes =
|
|
798
|
+
Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
|
|
799
|
+
let remainingBytes = availableBytes - usedBytes;
|
|
800
|
+
|
|
801
|
+
if (remainingBytes > 0) {
|
|
802
|
+
const headBytes = Buffer.byteLength(head, "utf8");
|
|
803
|
+
const expandedHead = sliceUtf8Prefix(
|
|
804
|
+
headSource,
|
|
805
|
+
headBytes + remainingBytes,
|
|
806
|
+
);
|
|
807
|
+
remainingBytes -= Buffer.byteLength(expandedHead, "utf8") - headBytes;
|
|
808
|
+
head = expandedHead;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (remainingBytes > 0) {
|
|
812
|
+
const tailBytes = Buffer.byteLength(tail, "utf8");
|
|
813
|
+
tail = sliceUtf8Suffix(tailSource, tailBytes + remainingBytes);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return head + marker + tail;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Truncate output by UTF-8 byte size, preserving head and tail text. */
|
|
820
|
+
function truncateOutputByBytes(output: string, maxBytes: number): string {
|
|
821
|
+
if (Buffer.byteLength(output, "utf8") <= maxBytes) {
|
|
822
|
+
return output;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return fitSegmentsWithinBytes(
|
|
826
|
+
output,
|
|
827
|
+
output,
|
|
828
|
+
"\n… truncated for size …\n",
|
|
829
|
+
maxBytes,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Truncate output to keep useful head and tail content within line and byte budgets.
|
|
835
|
+
*
|
|
836
|
+
* The line budget avoids flooding the model with very tall outputs, while the
|
|
837
|
+
* byte budget prevents context explosions caused by a small number of very long
|
|
838
|
+
* lines.
|
|
839
|
+
*
|
|
840
|
+
* @param output - The full output string.
|
|
841
|
+
* @param maxLines - Maximum number of content lines to keep.
|
|
842
|
+
* @param maxBytes - Maximum UTF-8 bytes to keep.
|
|
843
|
+
* @returns The (possibly truncated) output string.
|
|
844
|
+
*/
|
|
845
|
+
export function truncateOutput(
|
|
846
|
+
output: string,
|
|
847
|
+
maxLines: number,
|
|
848
|
+
maxBytes: number,
|
|
849
|
+
): string {
|
|
850
|
+
if (!output) return output;
|
|
851
|
+
|
|
852
|
+
const lineTruncation = buildLineTruncation(output, maxLines);
|
|
853
|
+
if (!lineTruncation) {
|
|
854
|
+
return truncateOutputByBytes(output, maxBytes);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const lineLimited =
|
|
858
|
+
lineTruncation.head + lineTruncation.marker + lineTruncation.tail;
|
|
859
|
+
if (Buffer.byteLength(lineLimited, "utf8") <= maxBytes) {
|
|
860
|
+
return lineLimited;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return fitSegmentsWithinBytes(
|
|
864
|
+
lineTruncation.head,
|
|
865
|
+
lineTruncation.tail,
|
|
866
|
+
lineTruncation.marker,
|
|
867
|
+
maxBytes,
|
|
868
|
+
);
|
|
869
|
+
}
|