mini-coder 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * First-class delegated-subagent tool.
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
+ reserveToolDelegation,
12
+ type ShellDelegationContext,
13
+ } from "./delegation.ts";
14
+ import { textResult, validateBuiltinToolArgs } from "./tool-common.ts";
15
+
16
+ const delegateToolParameters = Type.Object({
17
+ task: Type.String({
18
+ description:
19
+ "Bounded subtask prompt for the delegated subagent. Include the specific goal, constraints, and expected deliverable.",
20
+ }),
21
+ });
22
+
23
+ /** Arguments for the `delegate` tool. */
24
+ export type DelegateArgs = Static<typeof delegateToolParameters>;
25
+
26
+ /** Structured details preserved on a persisted `delegate` tool result. */
27
+ export interface DelegateResultDetails {
28
+ /** How the delegated subagent run ended. */
29
+ stopReason: "stop" | "length" | "error" | "aborted";
30
+ }
31
+
32
+ /** Summarized output returned from one delegated subagent run. */
33
+ export interface DelegateRunResult {
34
+ /** How the delegated subagent run ended. */
35
+ stopReason: DelegateResultDetails["stopReason"];
36
+ /** Final assistant text emitted by the subagent. */
37
+ finalText: string;
38
+ /** Terminal assistant error text when the subagent failed. */
39
+ errorText: string | null;
40
+ }
41
+
42
+ /** pi-ai tool definition for `delegate`. */
43
+ export const delegateTool: Tool<typeof delegateToolParameters> = {
44
+ name: "delegate",
45
+ description:
46
+ "Run a bounded subtask in an isolated subagent session using the current model, prompt context, and tools. " +
47
+ "Use this when a focused second agent pass will help, and prefer it over shelling out to `mc -p` unless you are explicitly testing the CLI itself.",
48
+ parameters: delegateToolParameters,
49
+ };
50
+
51
+ /** Options for a `delegate` handler that enforces delegation safeguards. */
52
+ export interface CreateDelegateToolHandlerOpts {
53
+ /** Return the current subagent-delegation context for the active run. */
54
+ getDelegationContext: () => ShellDelegationContext;
55
+ /** Persist the updated delegation context after a launch reservation. */
56
+ setDelegationContext: (context: ShellDelegationContext) => void;
57
+ /** Execute one delegated subagent run. */
58
+ runSubagent: (
59
+ task: string,
60
+ context: ShellDelegationContext,
61
+ signal?: AbortSignal,
62
+ onUpdate?: ToolUpdateCallback,
63
+ ) => Promise<DelegateRunResult>;
64
+ }
65
+
66
+ /**
67
+ * Format the model-visible text payload returned from a delegated subagent run.
68
+ *
69
+ * @param result - Delegated subagent result.
70
+ * @returns Text content suitable for the parent tool result.
71
+ */
72
+ export function formatDelegateResultText(result: DelegateRunResult): string {
73
+ const lines = [`Subagent stop reason: ${result.stopReason}`];
74
+
75
+ if (result.finalText.length > 0) {
76
+ lines.push("", "Final answer:", result.finalText);
77
+ } else {
78
+ lines.push("", "Final answer:", "(no final text)");
79
+ }
80
+
81
+ if (result.errorText) {
82
+ lines.push("", "Terminal error:", result.errorText);
83
+ }
84
+
85
+ return lines.join("\n");
86
+ }
87
+
88
+ /**
89
+ * Create a `delegate` tool handler that enforces delegation limits.
90
+ *
91
+ * @param opts - Delegation-context accessors plus the delegated-run executor.
92
+ * @returns A tool handler for first-class delegated subagent runs.
93
+ */
94
+ export function createDelegateToolHandler(
95
+ opts: CreateDelegateToolHandlerOpts,
96
+ ): ToolHandler {
97
+ return async (args, _cwd, signal, onUpdate) => {
98
+ const validatedArgs = validateBuiltinToolArgs(delegateTool, args);
99
+ const reservation = reserveToolDelegation(opts.getDelegationContext());
100
+ if (!reservation.ok) {
101
+ return textResult(reservation.error, true);
102
+ }
103
+
104
+ opts.setDelegationContext(reservation.reservation.updatedContext);
105
+ const result = await opts.runSubagent(
106
+ validatedArgs.task,
107
+ reservation.reservation.childContext,
108
+ signal,
109
+ onUpdate,
110
+ );
111
+
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: formatDelegateResultText(result),
117
+ },
118
+ ],
119
+ details: {
120
+ stopReason: result.stopReason,
121
+ } satisfies DelegateResultDetails,
122
+ isError: result.stopReason === "error" || result.stopReason === "aborted",
123
+ };
124
+ };
125
+ }
package/src/tool-shell.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  import type { Static, Tool } from "@mariozechner/pi-ai";
8
8
  import { Type } from "@mariozechner/pi-ai";
9
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";
10
16
  import {
11
17
  detectLineEnding,
12
18
  normalizeLineEndings,
@@ -32,6 +38,18 @@ export interface ShellOpts {
32
38
  signal?: AbortSignal;
33
39
  /** Callback for progressive output updates while the command is running. */
34
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;
35
53
  }
36
54
 
37
55
  /** pi-ai tool definition for `shell`. */
@@ -59,27 +77,178 @@ export const shellToolHandler: ToolHandler = (args, cwd, signal, onUpdate) =>
59
77
  ...(onUpdate ? { onUpdate } : {}),
60
78
  });
61
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
+
62
116
  type ShellProcess = ReturnType<typeof Bun.spawn>;
63
117
 
64
118
  const DEFAULT_MAX_LINES = 1000;
65
119
  const DEFAULT_MAX_BYTES = 50_000;
66
120
  const SHELL_UPDATE_INTERVAL_MS = 75;
67
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}`;
68
124
 
69
- /** Format combined stdout/stderr for display in tool results. */
125
+ /** Format combined stdout/stderr for the legacy text payload preserved for model context. */
70
126
  function formatShellOutput(stdout: string, stderr: string): string {
71
127
  if (stdout && stderr) {
72
- return `${stdout}\n\n[stderr]\n${stderr}`;
128
+ return `${stdout}${LEGACY_SHELL_STDERR_SEPARATOR}${stderr}`;
73
129
  }
74
130
  if (stdout) {
75
131
  return stdout;
76
132
  }
77
133
  if (stderr) {
78
- return `[stderr]\n${stderr}`;
134
+ return `${LEGACY_SHELL_STDERR_PREFIX}${stderr}`;
79
135
  }
80
136
  return "";
81
137
  }
82
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
+
83
252
  interface ShellCommandLines {
84
253
  lines: string[];
85
254
  lineEnding: "\n" | "\r\n";
@@ -488,9 +657,13 @@ async function finalizeShellStreamCaptures(
488
657
  await Promise.all(captures.map((capture) => capture.close()));
489
658
  }
490
659
 
491
- function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
660
+ function buildShellSpawnOptions(
661
+ cwd: string,
662
+ env?: Record<string, string>,
663
+ ): Parameters<typeof Bun.spawn>[1] {
492
664
  return {
493
665
  cwd,
666
+ ...(env ? { env: { ...process.env, ...env } } : {}),
494
667
  stdout: "pipe",
495
668
  stderr: "pipe",
496
669
  ...(process.platform === "win32" ? {} : { detached: true }),
@@ -621,7 +794,10 @@ export async function executeShell(
621
794
  };
622
795
 
623
796
  const command = normalizeShellCommand(args.command);
624
- const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
797
+ const proc = Bun.spawn(
798
+ [shell, "-c", command],
799
+ buildShellSpawnOptions(cwd, opts?.env),
800
+ );
625
801
  cleanupAbort = registerShellAbort(opts?.signal, proc);
626
802
  stdoutCapture = startShellStreamCapture(
627
803
  proc.stdout as ReadableStream<Uint8Array>,
@@ -648,9 +824,15 @@ export async function executeShell(
648
824
  opts.onUpdate(textResult(output, false));
649
825
  }
650
826
 
651
- const isError = exitCode !== 0;
652
- const body = output || "(no output)";
653
- return textResult(`Exit code: ${exitCode}\n${body}`, isError);
827
+ return buildShellToolResult(
828
+ {
829
+ stdout: stdoutCapture?.getOutput().trimEnd() ?? "",
830
+ stderr: stderrCapture?.getOutput().trimEnd() ?? "",
831
+ exitCode,
832
+ },
833
+ maxLines,
834
+ maxBytes,
835
+ );
654
836
  } catch (err) {
655
837
  cleanupAbort?.();
656
838
  cleanupAbort = null;