pi-agent-flow 1.7.2 → 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,443 @@
1
+ /**
2
+ * batch bash -- parallel bash execution and polling.
3
+ *
4
+ * Runs multiple shell commands concurrently with a soft timeout,
5
+ * tracks running/pending processes, and exposes a polling interface
6
+ * so the agent can retrieve results of long-running commands later.
7
+ *
8
+ * The soft timeout (default 20s) does NOT kill commands. It sets the maximum
9
+ * wait time before the batch tool returns partial results. Commands that
10
+ * haven't finished continue running in the background and can be polled
11
+ * via the `batch_bash_poll` tool.
12
+ */
13
+
14
+ import { spawn, type ChildProcess } from "node:child_process";
15
+ import { Type } from "@sinclair/typebox";
16
+ import {
17
+ type BashOpResult,
18
+ type PendingBashResult,
19
+ BASH_SOFT_TIMEOUT_MS,
20
+ BASH_POLL_TAIL_LINES,
21
+ } from "./constants.js";
22
+ import { classifyDuration } from "../timed-bash.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Process tracker -- shared between batch and batch_bash_poll tools
26
+ // ---------------------------------------------------------------------------
27
+
28
+ interface RunningProcess {
29
+ proc: ChildProcess;
30
+ command: string;
31
+ startedAt: number;
32
+ stdoutChunks: string[];
33
+ stderrChunks: string[];
34
+ abortController: AbortController;
35
+ }
36
+
37
+ export interface TrackedBashResult {
38
+ id: string;
39
+ command: string;
40
+ status: "ok" | "error" | "aborted";
41
+ exitCode?: number;
42
+ stdout: string;
43
+ stderr: string;
44
+ duration: number;
45
+ timingTier: string;
46
+ }
47
+
48
+ /**
49
+ * Process tracker for batch bash operations.
50
+ * Both the `batch` tool (launch side) and `batch_bash_poll` tool (read side)
51
+ * share the same instance.
52
+ */
53
+ export class BashProcessTracker {
54
+ private running = new Map<string, RunningProcess>();
55
+ private completed = new Map<string, TrackedBashResult>();
56
+
57
+ /** Launch a bash command. Returns immediately; the process runs in the background. */
58
+ launch(
59
+ id: string,
60
+ command: string,
61
+ cwd: string,
62
+ signal?: AbortSignal,
63
+ ): void {
64
+ // Abort any existing process with the same id
65
+ this.abortById(id);
66
+ this.completed.delete(id);
67
+
68
+ const ac = new AbortController();
69
+ const child = spawn(command, [], {
70
+ cwd,
71
+ shell: true,
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ signal: ac.signal,
74
+ });
75
+
76
+ const rp: RunningProcess = {
77
+ proc: child,
78
+ command,
79
+ startedAt: Date.now(),
80
+ stdoutChunks: [],
81
+ stderrChunks: [],
82
+ abortController: ac,
83
+ };
84
+
85
+ child.stdout?.on("data", (chunk: Buffer) => {
86
+ rp.stdoutChunks.push(chunk.toString());
87
+ });
88
+
89
+ child.stderr?.on("data", (chunk: Buffer) => {
90
+ rp.stderrChunks.push(chunk.toString());
91
+ });
92
+
93
+ // Wire parent signal
94
+ if (signal) {
95
+ const onParentAbort = () => this.abortById(id);
96
+ if (signal.aborted) {
97
+ this.abortById(id);
98
+ } else {
99
+ signal.addEventListener("abort", onParentAbort, { once: true });
100
+ child.on("close", () => signal.removeEventListener("abort", onParentAbort));
101
+ }
102
+ }
103
+
104
+ child.on("close", (code) => {
105
+ this.running.delete(id);
106
+
107
+ const stdout = rp.stdoutChunks.join("");
108
+ const stderr = rp.stderrChunks.join("");
109
+ const duration = Date.now() - rp.startedAt;
110
+ const report = classifyDuration(duration);
111
+
112
+ this.completed.set(id, {
113
+ id,
114
+ command,
115
+ status: code === 0 ? "ok" : "error",
116
+ exitCode: code ?? undefined,
117
+ stdout,
118
+ stderr,
119
+ duration,
120
+ timingTier: report.label,
121
+ });
122
+ });
123
+
124
+ child.on("error", (err) => {
125
+ this.running.delete(id);
126
+
127
+ const duration = Date.now() - rp.startedAt;
128
+ const report = classifyDuration(duration);
129
+ const stdout = rp.stdoutChunks.join("");
130
+ const stderr = rp.stderrChunks.join("") || err.message;
131
+
132
+ this.completed.set(id, {
133
+ id,
134
+ command,
135
+ status: "aborted",
136
+ exitCode: undefined,
137
+ stdout,
138
+ stderr,
139
+ duration,
140
+ timingTier: report.label,
141
+ });
142
+ });
143
+
144
+ this.running.set(id, rp);
145
+ }
146
+
147
+ /** Check if a command is still running. */
148
+ isRunning(id: string): boolean {
149
+ return this.running.has(id);
150
+ }
151
+
152
+ /** Get the last N lines of a running process's stdout. */
153
+ getRunningTail(id: string): string {
154
+ const rp = this.running.get(id);
155
+ if (!rp) return "";
156
+ const stdout = rp.stdoutChunks.join("");
157
+ return tailLines(stdout, BASH_POLL_TAIL_LINES);
158
+ }
159
+
160
+ /** Get the result of a completed/aborted command. Does NOT remove from cache. */
161
+ peekCompleted(id: string): TrackedBashResult | undefined {
162
+ return this.completed.get(id);
163
+ }
164
+
165
+ /** Get the result of a completed/aborted command. Removes from completed cache. */
166
+ popCompleted(id: string): TrackedBashResult | undefined {
167
+ const result = this.completed.get(id);
168
+ if (result) this.completed.delete(id);
169
+ return result;
170
+ }
171
+
172
+ /** Get the start time of a running process. */
173
+ getStartedAt(id: string): number | undefined {
174
+ return this.running.get(id)?.startedAt;
175
+ }
176
+
177
+ /** Check if a command has completed. */
178
+ hasCompleted(id: string): boolean {
179
+ return this.completed.has(id);
180
+ }
181
+
182
+ /** Abort a running process by id. */
183
+ private abortById(id: string): void {
184
+ const rp = this.running.get(id);
185
+ if (!rp) return;
186
+ try {
187
+ rp.abortController.abort();
188
+ rp.proc.kill("SIGTERM");
189
+ } catch {
190
+ /* already dead */
191
+ }
192
+ }
193
+
194
+ /** Abort all running processes (cleanup). */
195
+ abortAll(): void {
196
+ for (const [id] of this.running) {
197
+ this.abortById(id);
198
+ }
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Helpers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function tailLines(text: string, n: number): string {
207
+ const lines = text.split("\n");
208
+ return lines.slice(-n).join("\n");
209
+ }
210
+
211
+ /** Generate a short random ID for bash ops that don't provide one. */
212
+ export function generateBashId(): string {
213
+ return Math.random().toString(36).slice(2, 10);
214
+ }
215
+
216
+ /** Normalize a bash op from prepareArguments into a canonical form. */
217
+ export function normalizeBashOp(raw: Record<string, unknown>): Record<string, unknown> {
218
+ return {
219
+ o: "bash",
220
+ c: raw.c ?? raw.command,
221
+ i: raw.i ?? raw.id ?? generateBashId(),
222
+ t: raw.t ?? raw.timeout,
223
+ h: raw.h ?? raw.cwdPath ?? raw.cwd,
224
+ p: raw.p ?? "bash",
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Execute a batch of bash ops concurrently.
230
+ *
231
+ * Launches all commands in parallel, then waits until all finish or the
232
+ * soft timeout expires. The soft timeout is the max of per-op `t` values
233
+ * (if any), or the global default. Commands that haven't finished within
234
+ * the timeout are returned as "pending" with the last N lines of output.
235
+ * They continue running in the background and can be polled via batch_bash_poll.
236
+ */
237
+ export async function executeBatchBash(
238
+ ops: Array<{ i: string; c: string; t?: number; h?: string }>,
239
+ defaultCwd: string,
240
+ tracker: BashProcessTracker,
241
+ signal?: AbortSignal,
242
+ softTimeoutMs: number = BASH_SOFT_TIMEOUT_MS,
243
+ ): Promise<BashOpResult[]> {
244
+ if (ops.length === 0) return [];
245
+
246
+ // Compute the effective batch soft timeout: if per-op timeouts are specified,
247
+ // use the minimum of those; otherwise fall back to the global default.
248
+ const perOpTimeouts = ops.filter((op) => typeof op.t === "number" && op.t! > 0).map((op) => op.t!);
249
+ const effectiveTimeout = perOpTimeouts.length > 0
250
+ ? Math.min(...perOpTimeouts)
251
+ : softTimeoutMs;
252
+
253
+ // Launch all commands in parallel
254
+ for (const op of ops) {
255
+ const cwd = op.h ? op.h : defaultCwd;
256
+ tracker.launch(op.i, op.c, cwd, signal);
257
+ }
258
+
259
+ // Wait up to effectiveTimeout for all to finish
260
+ await waitForSettle(ops, tracker, effectiveTimeout, signal);
261
+
262
+ // Collect results
263
+ const results: BashOpResult[] = [];
264
+ for (const op of ops) {
265
+ const completed = tracker.popCompleted(op.i);
266
+ if (completed) {
267
+ results.push({
268
+ op: "bash",
269
+ path: op.i,
270
+ id: op.i,
271
+ command: op.c,
272
+ status: completed.status === "ok" ? "ok" : "error",
273
+ exitCode: completed.exitCode,
274
+ stdout: completed.stdout,
275
+ stderr: completed.stderr,
276
+ duration: completed.duration,
277
+ timingTier: completed.timingTier,
278
+ });
279
+ } else {
280
+ // Still running -- return pending with tail
281
+ const tail = tracker.getRunningTail(op.i);
282
+ results.push({
283
+ op: "bash",
284
+ path: op.i,
285
+ id: op.i,
286
+ command: op.c,
287
+ status: "pending",
288
+ stdout: tail,
289
+ stderr: "",
290
+ duration: Date.now() - getStartTime(tracker, op.i),
291
+ });
292
+ }
293
+ }
294
+
295
+ return results;
296
+ }
297
+
298
+ /** Get the start time of a running process, or Date.now() if not found. */
299
+ function getStartTime(tracker: BashProcessTracker, id: string): number {
300
+ return tracker.getStartedAt(id) ?? Date.now();
301
+ }
302
+
303
+ /**
304
+ * Wait until all ops have completed or the soft timeout expires.
305
+ * Uses a polling check every 100ms.
306
+ */
307
+ function waitForSettle(
308
+ ops: Array<{ i: string }>,
309
+ tracker: BashProcessTracker,
310
+ timeoutMs: number,
311
+ signal?: AbortSignal,
312
+ ): Promise<void> {
313
+ return new Promise<void>((resolve) => {
314
+ const deadline = Date.now() + timeoutMs;
315
+
316
+ const check = () => {
317
+ if (signal?.aborted) {
318
+ resolve();
319
+ return;
320
+ }
321
+ const allDone = ops.every((op) => !tracker.isRunning(op.i));
322
+ if (allDone || Date.now() >= deadline) {
323
+ resolve();
324
+ return;
325
+ }
326
+ setTimeout(check, 100);
327
+ };
328
+ check();
329
+ });
330
+ }
331
+
332
+ /** Poll for completed bash results. */
333
+ export function pollBatchBashResults(
334
+ ids: string[],
335
+ tracker: BashProcessTracker,
336
+ ): PendingBashResult[] {
337
+ const results: PendingBashResult[] = [];
338
+ for (const id of ids) {
339
+ if (tracker.hasCompleted(id)) {
340
+ const r = tracker.popCompleted(id)!;
341
+ results.push({
342
+ id,
343
+ command: r.command,
344
+ status: "completed",
345
+ exitCode: r.exitCode,
346
+ stdout: r.stdout,
347
+ stderr: r.stderr,
348
+ duration: r.duration,
349
+ timingTier: r.timingTier,
350
+ });
351
+ } else if (tracker.isRunning(id)) {
352
+ results.push({
353
+ id,
354
+ command: "",
355
+ status: "pending",
356
+ stdout: tracker.getRunningTail(id),
357
+ });
358
+ } else {
359
+ results.push({
360
+ id,
361
+ command: "",
362
+ status: "pending",
363
+ stdout: "",
364
+ });
365
+ }
366
+ }
367
+ return results;
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Poll tool schema & factory
372
+ // ---------------------------------------------------------------------------
373
+
374
+ export const BatchBashPollParams = Type.Object({
375
+ i: Type.Array(Type.String(), {
376
+ description: "Array of bash operation IDs to poll for results.",
377
+ minItems: 1,
378
+ }),
379
+ });
380
+
381
+ export function createBatchBashPollTool(tracker: BashProcessTracker) {
382
+ return {
383
+ name: "batch_bash_poll",
384
+ label: "batch_bash_poll",
385
+ description: [
386
+ "Poll for results of pending bash commands from a previous batch call.",
387
+ "Pass the IDs of pending commands to check their status.",
388
+ "Returns completed results with full stdout/stderr, or indicates still-pending with last output lines.",
389
+ ].join("\n"),
390
+ promptSnippet: "Poll pending bash commands for results",
391
+ promptGuidelines: [
392
+ "Use batch_bash_poll to check on bash commands that returned pending from a batch call.",
393
+ "Pass the `i` (id) values from the pending results.",
394
+ ],
395
+ parameters: BatchBashPollParams,
396
+
397
+ prepareArguments(input: unknown): unknown {
398
+ if (!input || typeof input !== "object") return { i: [] };
399
+ const args = input as Record<string, unknown>;
400
+ const ids = args.i ?? args.ids;
401
+ if (!Array.isArray(ids)) return { i: [] };
402
+ return { i: ids };
403
+ },
404
+
405
+ async execute(
406
+ _toolCallId: string,
407
+ input: unknown,
408
+ _signal?: AbortSignal,
409
+ _onUpdate?: unknown,
410
+ ) {
411
+ const args = (input ?? {}) as Record<string, unknown>;
412
+ const ids = Array.isArray(args.i) ? (args.i as string[]) : Array.isArray(args.ids) ? (args.ids as string[]) : [];
413
+
414
+ if (ids.length === 0) {
415
+ return {
416
+ content: [{ type: "text", text: "Error: i (ids) array is required and must not be empty." }],
417
+ isError: true,
418
+ };
419
+ }
420
+
421
+ const results = pollBatchBashResults(ids, tracker);
422
+
423
+ const lines: string[] = [];
424
+ for (const r of results) {
425
+ if (r.status === "completed") {
426
+ const exitInfo = r.exitCode !== undefined ? `exit ${r.exitCode}` : "interrupted";
427
+ lines.push(`--- [${r.id}] ${exitInfo} (${r.timingTier ?? "unknown"}) ---`);
428
+ if (r.stdout?.trim()) lines.push(r.stdout.trimEnd());
429
+ if (r.stderr?.trim()) lines.push(`[stderr]\n${r.stderr.trimEnd()}`);
430
+ } else {
431
+ lines.push(`--- [${r.id}] still running ---`);
432
+ if (r.stdout?.trim()) lines.push(`[output so far]\n${r.stdout.trimEnd()}`);
433
+ }
434
+ lines.push("");
435
+ }
436
+
437
+ return {
438
+ content: [{ type: "text", text: lines.join("\n").trimEnd() }],
439
+ details: { results },
440
+ };
441
+ },
442
+ };
443
+ }
@@ -12,6 +12,8 @@ export const SAFE_FULL_READ_LIMIT = 300;
12
12
  export const TARGETED_READ_LINE_LIMIT = 1000;
13
13
  export const MAX_CONTEXT_MAP_ENTRIES = 100;
14
14
  export const MAX_TOTAL_RESULT_LINES = 1500;
15
+ export const BASH_SOFT_TIMEOUT_MS = 20_000;
16
+ export const BASH_POLL_TAIL_LINES = 50;
15
17
 
16
18
  // ---------------------------------------------------------------------------
17
19
  // Types
@@ -23,12 +25,15 @@ export interface EditReplacement {
23
25
  }
24
26
 
25
27
  export interface FileOpInput {
26
- o: "read" | "write" | "edit" | "delete";
28
+ o: "read" | "write" | "edit" | "delete" | "bash";
27
29
  p: string;
28
30
  c?: string;
29
31
  e?: EditReplacement[];
30
32
  s?: number;
31
33
  l?: number;
34
+ i?: string;
35
+ t?: number;
36
+ h?: string;
32
37
  }
33
38
 
34
39
  export interface ContextMapEntry {
@@ -40,9 +45,9 @@ export interface ContextMapEntry {
40
45
  }
41
46
 
42
47
  export interface OpResult {
43
- op: "read" | "write" | "edit" | "delete";
48
+ op: "read" | "write" | "edit" | "delete" | "bash";
44
49
  path: string;
45
- status: "ok" | "error" | "skipped";
50
+ status: "ok" | "error" | "skipped" | "pending";
46
51
  content?: string;
47
52
  bytes?: number;
48
53
  blocksChanged?: number;
@@ -56,6 +61,13 @@ export interface OpResult {
56
61
  nextOffset?: number;
57
62
  error?: string;
58
63
  hint?: string;
64
+ id?: string;
65
+ command?: string;
66
+ exitCode?: number;
67
+ stdout?: string;
68
+ stderr?: string;
69
+ duration?: number;
70
+ timingTier?: string;
59
71
  }
60
72
 
61
73
  export interface ReadTruncationResult {
@@ -101,3 +113,16 @@ export type BatchTheme = {
101
113
  bold: (s: string) => string;
102
114
  bg: (color: string, text: string) => string;
103
115
  };
116
+
117
+ export interface PendingBashResult {
118
+ id: string;
119
+ command: string;
120
+ status: "pending" | "completed";
121
+ exitCode?: number;
122
+ stdout?: string;
123
+ stderr?: string;
124
+ duration?: number;
125
+ timingTier?: string;
126
+ }
127
+
128
+ export type BashOpResult = OpResult;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import type { BatchTheme, FileOpInput } from "./constants.js";
10
- import { SAFE_FULL_READ_LIMIT, TARGETED_READ_LINE_LIMIT } from "./constants.js";
10
+ import { SAFE_FULL_READ_LIMIT, TARGETED_READ_LINE_LIMIT, BASH_SOFT_TIMEOUT_MS } from "./constants.js";
11
11
  import { executeOperations, suggestSimilarFiles } from "./execute.js";
12
12
  import { expandTilde, isWithinDirectory } from "./fuzzy-edit.js";
13
13
  import {
@@ -15,6 +15,15 @@ import {
15
15
  renderBatchReadCall,
16
16
  renderBatchResult,
17
17
  } from "./render.js";
18
+ import {
19
+ type BashProcessTracker,
20
+ generateBashId,
21
+ normalizeBashOp,
22
+ executeBatchBash,
23
+ } from "./batch-bash.js";
24
+
25
+ // Re-export polling tool factory and tracker from batch-bash
26
+ export { BashProcessTracker, createBatchBashPollTool, pollBatchBashResults } from "./batch-bash.js";
18
27
 
19
28
  // ---------------------------------------------------------------------------
20
29
  // Schema
@@ -34,12 +43,13 @@ const FileOp = Type.Object({
34
43
  Type.Literal("write"),
35
44
  Type.Literal("edit"),
36
45
  Type.Literal("delete"),
46
+ Type.Literal("bash"),
37
47
  ]),
38
- p: Type.String({ description: "Path to the file (relative or absolute)" }),
48
+ p: Type.String({ description: "Path to the file (relative or absolute). Use 'bash' or any string for o: 'bash'." }),
39
49
  c: Type.Optional(
40
50
  Type.String({
41
51
  description:
42
- "Full file content. Creates if new, overwrites if exists. Auto-creates parent dirs. Used with o: 'write'.",
52
+ "File content for o: 'write'. Shell command for o: 'bash'.",
43
53
  }),
44
54
  ),
45
55
  e: Type.Optional(
@@ -62,12 +72,28 @@ const FileOp = Type.Object({
62
72
  "Maximum number of lines to read (limit). Used with o: 'read'.",
63
73
  }),
64
74
  ),
75
+ i: Type.Optional(
76
+ Type.String({
77
+ description: "Unique ID for this bash operation. Auto-generated if omitted. Used with o: 'bash'.",
78
+ }),
79
+ ),
80
+ t: Type.Optional(
81
+ Type.Number({
82
+ minimum: 1,
83
+ description: `Soft timeout in ms. Default: ${BASH_SOFT_TIMEOUT_MS}. Command keeps running after timeout; returns partial output with pending status. Used with o: 'bash'.`,
84
+ }),
85
+ ),
86
+ h: Type.Optional(
87
+ Type.String({
88
+ description: "Working directory override for this command. Used with o: 'bash'.",
89
+ }),
90
+ ),
65
91
  });
66
92
 
67
93
  export const WeavePatchParams = Type.Object({
68
94
  o: Type.Array(FileOp, {
69
95
  description:
70
- "Ordered list of file operations. Executed sequentially. On failure, remaining operations are skipped.",
96
+ "Ordered list of operations. File ops (read/write/edit/delete) execute sequentially on failure, remaining file ops are skipped. Bash ops (bash) run in parallel after file ops complete and do not skip each other on failure.",
71
97
  }),
72
98
  });
73
99
 
@@ -108,6 +134,11 @@ function normalizeOp(raw: Record<string, unknown>): Record<string, unknown> {
108
134
  // Map operation type
109
135
  op.o = raw.o ?? raw.op ?? (raw.c != null || raw.content != null ? "write" : (raw.e != null || raw.edits != null ? "edit" : "read"));
110
136
 
137
+ // Bash ops use a separate normalizer
138
+ if (op.o === "bash") {
139
+ return normalizeBashOp(raw);
140
+ }
141
+
111
142
  // Map path
112
143
  op.p = raw.p ?? raw.path;
113
144
 
@@ -298,22 +329,32 @@ export function createBatchReadTool() {
298
329
  };
299
330
  }
300
331
 
301
- export function createBatchTool() {
332
+ /**
333
+ * Create the batch tool.
334
+ *
335
+ * @param bashTracker Optional BashProcessTracker for executing bash operations.
336
+ * When omitted, bash ops return an error. Both the batch tool and the
337
+ * batch_bash_poll tool must share the same tracker instance.
338
+ */
339
+ export function createBatchTool(bashTracker?: BashProcessTracker) {
302
340
  return {
303
341
  name: "batch",
304
342
  label: "batch",
305
343
  description: [
306
- "Batch file operations — run multiple read, write, edit, or delete ops in a single call.",
307
- "Each operation is independent: edits are matched against the current on-disk file, not against prior operations in the same call.",
308
- "Operations execute sequentially in array order; on failure, remaining operations are skipped.",
344
+ "Batch operations — run multiple file ops (read/write/edit/delete) and bash commands in a single call.",
345
+ "Each file operation is independent: edits are matched against the current on-disk file, not against prior operations in the same call.",
346
+ "File operations execute sequentially in array order; on failure, remaining file operations are skipped.",
347
+ "Bash operations (o: 'bash') run in parallel after all file ops complete. Bash ops do NOT skip each other on failure.",
348
+ `Bash ops use c (command), i (id), t (timeout, default ${BASH_SOFT_TIMEOUT_MS}ms), h (cwd). Commands exceeding the soft timeout return "pending" status with last 50 lines of output; poll with batch_bash_poll.`,
309
349
  "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
310
350
  "Best for cross-cutting changes, multi-file refactors, or mixing reads with writes across several files.",
311
351
  ].join("\n"),
312
- promptSnippet: "Batch file operations — run multiple read/write/edit/delete ops in one call",
352
+ promptSnippet: "Batch operations — run multiple file ops and bash commands in one call",
313
353
  promptGuidelines: [
314
- "Use batch to perform multiple file operations in a single call rather than separate tool calls.",
315
- "Prefer batch when touching 2+ files or mixing creates, edits, reads, and deletes.",
316
- "Each operation is independent — edits match the on-disk file, not prior ops in the same call.",
354
+ "Use batch to perform multiple file operations and bash commands in a single call.",
355
+ "Prefer batch when touching 2+ files, mixing creates/edits/reads/deletes, or running shell commands.",
356
+ "Each file operation is independent — edits match the on-disk file, not prior ops in the same call.",
357
+ "Bash ops run in parallel. Use i (id) to track them. Use batch_bash_poll to check on pending commands.",
317
358
  "For single-file edits, the edit tool is fine; batch shines for cross-cutting and multi-file work.",
318
359
  ],
319
360
  parameters: WeavePatchParams,
@@ -349,11 +390,100 @@ export function createBatchTool() {
349
390
  };
350
391
  }
351
392
 
352
- const { contentText, results } = await executeOperations(ops, ctx.cwd, signal);
393
+ // Split ops into file ops and bash ops
394
+ const fileOps: FileOpInput[] = [];
395
+ const bashOps: FileOpInput[] = [];
396
+ for (const op of ops) {
397
+ if (op.o === "bash") {
398
+ bashOps.push(op);
399
+ } else {
400
+ fileOps.push(op);
401
+ }
402
+ }
403
+
404
+ // Execute file ops first (sequential, skip-on-failure)
405
+ let fileContentText = "";
406
+ let fileResults: import("./constants.js").OpResult[] = [];
407
+ let fileFailed = false;
408
+
409
+ if (fileOps.length > 0) {
410
+ const fileOutput = await executeOperations(fileOps, ctx.cwd, signal);
411
+ fileContentText = fileOutput.contentText;
412
+ fileResults = fileOutput.results;
413
+ fileFailed = fileOutput.results.some((r) => r.status === "error");
414
+ }
415
+
416
+ // Execute bash ops in parallel (independent of file failures,
417
+ // unless ALL ops are file+bash and a file op failed before any bash op)
418
+ // Per spec: file op failure skips bash ops
419
+ let bashResults: import("./constants.js").OpResult[] = [];
420
+ let bashContentText = "";
421
+
422
+ if (bashOps.length > 0 && !fileFailed && bashTracker) {
423
+ const normalizedBashOps = bashOps.map((op) => ({
424
+ i: op.i ?? generateBashId(),
425
+ c: op.c ?? "",
426
+ t: op.t,
427
+ h: op.h,
428
+ }));
429
+
430
+ const bashOutput = await executeBatchBash(
431
+ normalizedBashOps,
432
+ ctx.cwd,
433
+ bashTracker,
434
+ signal,
435
+ );
436
+
437
+ bashResults = bashOutput;
438
+
439
+ // Format bash results into content text
440
+ const bashLines: string[] = [];
441
+ for (const r of bashOutput) {
442
+ const timingInfo = r.timingTier ? ` (${r.timingTier})` : "";
443
+ if (r.status === "ok") {
444
+ bashLines.push(`\n--- bash [${r.id}] exit ${r.exitCode}${timingInfo} ---`);
445
+ if (r.stdout?.trim()) bashLines.push(r.stdout.trimEnd());
446
+ } else if (r.status === "pending") {
447
+ bashLines.push(`\n--- bash [${r.id}] pending${timingInfo} ---`);
448
+ if (r.stdout?.trim()) bashLines.push(`[partial output]\n${r.stdout.trimEnd()}`);
449
+ bashLines.push(`[Use batch_bash_poll with i: ["${r.id}"] to check results]`);
450
+ } else {
451
+ bashLines.push(`\n--- bash [${r.id}] error${timingInfo} ---`);
452
+ if (r.stdout?.trim()) bashLines.push(r.stdout.trimEnd());
453
+ if (r.stderr?.trim()) bashLines.push(`[stderr]\n${r.stderr.trimEnd()}`);
454
+ }
455
+ }
456
+ bashContentText = bashLines.join("\n");
457
+ } else if (bashOps.length > 0 && fileFailed) {
458
+ // File ops failed — mark bash ops as skipped
459
+ bashResults = bashOps.map((op) => ({
460
+ op: "bash" as const,
461
+ path: op.p,
462
+ status: "skipped" as const,
463
+ id: op.i,
464
+ command: op.c,
465
+ error: "Skipped: a file operation failed.",
466
+ }));
467
+ bashContentText = `\n--- bash: ${bashOps.length} command(s) skipped (file op failed) ---`;
468
+ } else if (bashOps.length > 0 && !bashTracker) {
469
+ bashResults = bashOps.map((op) => ({
470
+ op: "bash" as const,
471
+ path: op.p,
472
+ status: "error" as const,
473
+ id: op.i,
474
+ command: op.c,
475
+ error: "Bash tracker not available.",
476
+ }));
477
+ bashContentText = "\n--- bash: tracker not available ---";
478
+ }
479
+
480
+ // Combine results
481
+ const allResults = [...fileResults, ...bashResults];
482
+ const contentText = [fileContentText, bashContentText].filter(Boolean).join("\n");
353
483
 
354
484
  return {
355
485
  content: [{ type: "text", text: contentText }],
356
- details: { results },
486
+ details: { results: allResults },
357
487
  };
358
488
  },
359
489
 
@@ -11,7 +11,7 @@ function shortenPath(p: string): string {
11
11
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
12
12
  }
13
13
 
14
- function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[] }> {
14
+ function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[]; c?: string }> {
15
15
  let rawOps: unknown[];
16
16
  if (Array.isArray(args.o)) rawOps = args.o;
17
17
  else if (Array.isArray(args.op)) rawOps = args.op;
@@ -25,7 +25,8 @@ function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: s
25
25
  const opName = String(op.o ?? op.op ?? "?");
26
26
  const opPath = String(op.p ?? op.path ?? "?");
27
27
  const edits = Array.isArray(op.e) ? op.e : Array.isArray(op.edits) ? op.edits : undefined;
28
- return { o: opName, p: opPath, e: edits };
28
+ const cmd = typeof op.c === "string" ? op.c : typeof op.command === "string" ? op.command : undefined;
29
+ return { o: opName, p: opPath, e: edits, c: cmd };
29
30
  });
30
31
  }
31
32
 
@@ -35,11 +36,17 @@ function formatBatchCall(args: Record<string, unknown>): string {
35
36
 
36
37
  const parts: string[] = [];
37
38
  for (const op of ops) {
38
- const shortPath = shortenPath(op.p);
39
- if (op.o === "edit" && op.e && op.e.length > 1) {
40
- parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
39
+ if (op.o === "bash") {
40
+ const cmd = op.c ?? "?";
41
+ const display = cmd.length > 40 ? cmd.slice(0, 37) + "..." : cmd;
42
+ parts.push(`bash: ${display}`);
41
43
  } else {
42
- parts.push(`${op.o} ${shortPath}`);
44
+ const shortPath = shortenPath(op.p);
45
+ if (op.o === "edit" && op.e && op.e.length > 1) {
46
+ parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
47
+ } else {
48
+ parts.push(`${op.o} ${shortPath}`);
49
+ }
43
50
  }
44
51
  }
45
52
 
package/src/batch.ts CHANGED
@@ -11,5 +11,18 @@ export {
11
11
  createBatchReadTool,
12
12
  } from "./batch/index.js";
13
13
 
14
+ export {
15
+ BashProcessTracker,
16
+ createBatchBashPollTool,
17
+ pollBatchBashResults,
18
+ } from "./batch/index.js";
19
+
14
20
  export { suggestSimilarFiles } from "./batch/execute.js";
15
21
  export { isWithinDirectory } from "./batch/fuzzy-edit.js";
22
+
23
+ export type {
24
+ BashOpResult,
25
+ PendingBashResult,
26
+ OpResult,
27
+ FileOpInput,
28
+ } from "./batch/constants.js";
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  getFlowOutput,
42
42
  } from "./types.js";
43
43
  import { extractStructuredOutput } from "./structured-output.js";
44
- import { createBatchTool, createBatchReadTool } from "./batch.js";
44
+ import { createBatchTool, createBatchReadTool, BashProcessTracker, createBatchBashPollTool } from "./batch.js";
45
45
  import {
46
46
  createWebTool,
47
47
  looksLikeUrlPrompt,
@@ -634,7 +634,7 @@ function sanitizeForkSnapshot(snapshot: string | null, cache: Map<string, Compre
634
634
 
635
635
  function computeActiveTools(optimize: boolean): string[] {
636
636
  return optimize
637
- ? ["batch_read", "flow"]
637
+ ? ["batch_read", "batch", "batch_bash_poll", "flow"]
638
638
  : ["read", "write", "edit", "batch", "bash", "flow", "web"];
639
639
  }
640
640
 
@@ -842,9 +842,13 @@ export default function (pi: ExtensionAPI) {
842
842
  }
843
843
 
844
844
  // Register batch and batch_read so they are available for main agent and child flows.
845
+ // The bashProcessTracker is shared between the batch tool (launches bash ops)
846
+ // and the batch_bash_poll tool (checks on pending bash ops).
845
847
  if (toolOptimize) {
848
+ const bashTracker = new BashProcessTracker();
846
849
  pi.registerTool(createBatchReadTool());
847
- pi.registerTool(createBatchTool());
850
+ pi.registerTool(createBatchTool(bashTracker));
851
+ pi.registerTool(createBatchBashPollTool(bashTracker));
848
852
  }
849
853
 
850
854
  // Override built-in bash with timed wrapper so the LLM sees execution-time classification.