mini-coder 0.5.14 → 0.6.0

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