pi-agent-flow 1.0.8 → 1.2.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.
@@ -10,8 +10,8 @@ import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
- import { type FlowConfig, getFlowTier } from "./agents.js";
14
- import { parseFlowCliArgs } from "./runner-cli.js";
13
+ import { type FlowConfig } from "./agents.js";
14
+ import { parseFlowCliArgs } from "./cli-args.js";
15
15
  import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate, drainCtxEstimate, updateSmoothedTps, drainSmoothedTps } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
@@ -23,11 +23,14 @@ import {
23
23
 
24
24
  const isWindows = process.platform === "win32";
25
25
  const SIGKILL_TIMEOUT_MS = 5000;
26
- const AGENT_END_GRACE_MS = 250;
26
+ const AGENT_END_GRACE_MS = 2000;
27
+ const DEFAULT_FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
27
28
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
28
29
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
29
30
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
30
31
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
32
+ const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
33
+ export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
31
34
  const PI_OFFLINE_ENV = "PI_OFFLINE";
32
35
 
33
36
  type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
@@ -49,7 +52,8 @@ function mergeStreamingUsage(
49
52
  ...actual,
50
53
  ...(estimatedOutputTokens > 0 ? { output: Math.max(actual.output, estimatedOutputTokens) } : {}),
51
54
  ...(ctxEstimate > 0 ? { contextTokens: Math.max(actual.contextTokens, ctxEstimate) } : {}),
52
- ...(smoothedTps > 0 ? { smoothedTps: Math.max(actual.smoothedTps ?? 0, smoothedTps) } : {}),
55
+ // Show the live EMA value so the dashboard can rise and fall smoothly.
56
+ ...(smoothedTps > 0 ? { smoothedTps } : {}),
53
57
  };
54
58
  }
55
59
 
@@ -99,13 +103,35 @@ function cleanupFlowTempDir(dir: string | null): void {
99
103
 
100
104
  const inheritedCliArgs = parseFlowCliArgs(process.argv);
101
105
 
106
+ /**
107
+ * Transform a flow's tool list when toolOptimize is enabled.
108
+ * Replaces separate read/write/edit tools with the unified batch tool.
109
+ */
110
+ export function getOptimizedTools(
111
+ flowTools: string[] | undefined,
112
+ toolOptimize: boolean,
113
+ ): string[] | undefined {
114
+ if (!toolOptimize || !flowTools) return flowTools;
115
+ const hasLegacyTools = flowTools.some(
116
+ (t) => t === "read" || t === "write" || t === "edit",
117
+ );
118
+ if (!hasLegacyTools) return flowTools;
119
+ const filtered = flowTools.filter(
120
+ (t) => t !== "read" && t !== "write" && t !== "edit" && t !== "batch" && t !== "batch_read",
121
+ );
122
+ return filtered.includes("batch")
123
+ ? filtered
124
+ : [...filtered, "batch"];
125
+ }
126
+
102
127
  function buildFlowArgs(
103
128
  flow: FlowConfig,
104
129
  intent: string,
105
130
  forkSessionPath: string | null,
106
- tieredModels?: { lite?: string; flash?: string; full?: string },
131
+ model?: string,
107
132
  parentDepth: number = 0,
108
133
  maxDepth: number = 0,
134
+ toolOptimize: boolean = false,
109
135
  ): string[] {
110
136
  const args: string[] = [
111
137
  "--mode",
@@ -119,23 +145,41 @@ function buildFlowArgs(
119
145
  args.push("--session", forkSessionPath);
120
146
  }
121
147
 
122
- const tier = getFlowTier(flow.name);
123
- const tierModel = tieredModels?.[tier] ?? inheritedCliArgs.tieredModels?.[tier];
124
- const model = flow.model ?? tierModel ?? inheritedCliArgs.fallbackModel;
125
- if (model) args.push("--model", model);
148
+ if (inheritedCliArgs.flowModelConfig) {
149
+ args.push("--flow-model-config", inheritedCliArgs.flowModelConfig);
150
+ }
151
+ if (inheritedCliArgs.tieredModels?.lite) {
152
+ args.push("--flow-lite-model", inheritedCliArgs.tieredModels.lite);
153
+ }
154
+ if (inheritedCliArgs.tieredModels?.flash) {
155
+ args.push("--flow-flash-model", inheritedCliArgs.tieredModels.flash);
156
+ }
157
+ if (inheritedCliArgs.tieredModels?.full) {
158
+ args.push("--flow-full-model", inheritedCliArgs.tieredModels.full);
159
+ }
160
+
161
+ const resolvedModel = model ?? flow.model ?? inheritedCliArgs.fallbackModel;
162
+ if (resolvedModel) args.push("--model", resolvedModel);
126
163
 
127
164
  const thinking = flow.thinking ?? inheritedCliArgs.fallbackThinking;
128
165
  if (thinking) args.push("--thinking", thinking);
129
166
 
130
- if (flow.tools && flow.tools.length > 0) {
131
- args.push("--tools", flow.tools.join(","));
132
- } else if (flow.tools === undefined) {
133
- if (inheritedCliArgs.fallbackTools !== undefined) {
134
- args.push("--tools", inheritedCliArgs.fallbackTools);
135
- } else if (inheritedCliArgs.fallbackNoTools) {
136
- args.push("--no-tools");
137
- }
167
+ // Child flows get their configured tools from flow.tools, optimized by
168
+ // getOptimizedTools, with web explicitly filtered out.
169
+ // When flow.tools is undefined and toolOptimize=true, default to batch+bash+web
170
+ // (flow is unnecessary since the child is already inside a flow).
171
+ // When toolOptimize=false, include batch and web alongside legacy tools.
172
+ const defaultTools = toolOptimize
173
+ ? ["batch", "bash", "web"]
174
+ : ["read", "write", "edit", "batch", "bash", "flow", "web"];
175
+ const optimizedTools = getOptimizedTools(flow.tools, toolOptimize) ?? defaultTools;
176
+ let harnessTools = optimizedTools.filter((t) => t !== "web");
177
+ // If the flow explicitly listed only "web" (or nothing after filtering),
178
+ // fall back to defaultTools so the child isn't orphaned with zero tools.
179
+ if (harnessTools.length === 0) {
180
+ harnessTools = defaultTools.filter((t) => t !== "web");
138
181
  }
182
+ args.push("--tools", harnessTools.join(","));
139
183
 
140
184
  // No --append-system-prompt: child inherits parent's system prompt for cache hits.
141
185
  // Flow instructions go in the intent message instead.
@@ -143,7 +187,7 @@ function buildFlowArgs(
143
187
  const currentDepth = Math.max(0, Math.floor(parentDepth)) + 1;
144
188
  const effectiveMaxDepth = Math.max(0, Math.floor(maxDepth));
145
189
  const canDelegate = currentDepth < effectiveMaxDepth;
146
- const availableTools = flow.tools?.join(", ") ?? "all";
190
+ const availableTools = harnessTools.join(", ");
147
191
 
148
192
  // Phase 1: Context seal — sharp boundary declaring history sealed
149
193
  const contextSeal =
@@ -196,6 +240,8 @@ export interface RunFlowOptions {
196
240
  flowName: string;
197
241
  /** Intent description. */
198
242
  intent: string;
243
+ /** Short headline for display. */
244
+ aim: string;
199
245
  /** Optional override working directory. */
200
246
  taskCwd?: string;
201
247
  /** Serialized parent session snapshot for fork mode. Null when the flow starts with a clean slate. */
@@ -208,14 +254,18 @@ export interface RunFlowOptions {
208
254
  maxDepth: number;
209
255
  /** Whether cycle prevention should be enforced in child processes. */
210
256
  preventCycles: boolean;
211
- /** Tiered model overrides (lite/flash/full). */
212
- tieredModels?: { lite?: string; flash?: string; full?: string };
257
+ /** Whether to transform tool lists to use batch. */
258
+ toolOptimize?: boolean;
259
+ /** Explicit model to use for this flow execution. */
260
+ model?: string;
213
261
  /** Abort signal for cancellation. */
214
262
  signal?: AbortSignal;
215
263
  /** Streaming update callback. */
216
264
  onUpdate?: FlowUpdateCallback;
217
265
  /** Factory to wrap results into FlowDetails. */
218
266
  makeDetails: (results: SingleResult[]) => FlowDetails;
267
+ /** Max execution time in ms before child is terminated. Default: 10 minutes. */
268
+ timeoutMs?: number;
219
269
  }
220
270
 
221
271
  /**
@@ -229,12 +279,15 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
229
279
  flows,
230
280
  flowName,
231
281
  intent,
282
+ aim,
232
283
  taskCwd,
233
284
  forkSessionSnapshotJsonl,
234
285
  parentDepth,
235
286
  parentFlowStack,
236
287
  maxDepth,
237
288
  preventCycles,
289
+ toolOptimize = false,
290
+ model,
238
291
  signal,
239
292
  onUpdate,
240
293
  makeDetails,
@@ -248,6 +301,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
248
301
  type: normalizedFlowName,
249
302
  agentSource: "unknown",
250
303
  intent,
304
+ aim,
251
305
  exitCode: 1,
252
306
  messages: [],
253
307
  stderr: `Unknown flow: "${flowName}". Available flows: ${available}.`,
@@ -255,11 +309,12 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
255
309
  };
256
310
  }
257
311
 
258
- const resolvedModel = flow.model ?? inheritedCliArgs.fallbackModel;
312
+ const resolvedModel = model ?? flow.model ?? inheritedCliArgs.fallbackModel;
259
313
  const result: SingleResult = {
260
314
  type: normalizedFlowName,
261
315
  agentSource: flow.source,
262
316
  intent,
317
+ aim,
263
318
  exitCode: -1,
264
319
  messages: [],
265
320
  stderr: "",
@@ -267,21 +322,30 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
267
322
  model: resolvedModel,
268
323
  };
269
324
 
325
+ let liveStreamingText = "";
326
+ let liveEstimatedOutputTokens = 0;
327
+ let lastActualOutputTokens = result.usage.output;
270
328
  const emitUpdate = () => {
271
- const streaming = drainStreamingText(result);
329
+ const streamingDelta = drainStreamingText(result);
330
+ if (streamingDelta) liveStreamingText += streamingDelta;
272
331
  const estimatedTokens = drainStreamingEstimate(result);
332
+ if (result.usage.output !== lastActualOutputTokens) {
333
+ lastActualOutputTokens = result.usage.output;
334
+ liveEstimatedOutputTokens = result.usage.output;
335
+ }
336
+ liveEstimatedOutputTokens += estimatedTokens;
273
337
  const ctxEst = drainCtxEstimate(result);
274
338
  updateSmoothedTps(result, estimatedTokens);
275
339
  const smoothedTps = drainSmoothedTps(result);
276
- const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens, ctxEst, smoothedTps);
340
+ const mergedUsage = mergeStreamingUsage(result.usage, liveEstimatedOutputTokens, ctxEst, smoothedTps);
277
341
  onUpdate?.({
278
342
  content: [
279
343
  {
280
344
  type: "text",
281
- text: streaming || getFlowOutput(result.messages) || "(running...)",
345
+ text: liveStreamingText || getFlowOutput(result.messages) || "(running...)",
282
346
  },
283
347
  ],
284
- details: makeDetails([{ ...result, usage: mergedUsage }]),
348
+ details: makeDetails([{ ...result, usage: mergedUsage, streamingText: liveStreamingText || undefined }]),
285
349
  });
286
350
  };
287
351
 
@@ -299,12 +363,21 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
299
363
  flow,
300
364
  intent,
301
365
  forkSessionTmpPath,
302
- opts.tieredModels,
366
+ model,
303
367
  parentDepth,
304
368
  maxDepth,
369
+ toolOptimize,
305
370
  );
306
371
  let wasAborted = false;
307
372
 
373
+ // Resolve timeout: explicit option > env var > default (10 min)
374
+ const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
375
+ const envTimeout = envTimeoutRaw !== undefined ? (() => {
376
+ const n = Number(envTimeoutRaw);
377
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
378
+ })() : null;
379
+ const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
380
+
308
381
  const exitCode = await new Promise<number>((resolve) => {
309
382
  const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
310
383
  const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
@@ -314,12 +387,15 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
314
387
  cwd: taskCwd ?? cwd,
315
388
  shell: false,
316
389
  stdio: ["pipe", "pipe", "pipe"],
390
+ // Process group on Unix so we can kill all descendants on timeout/abort.
391
+ detached: !isWindows,
317
392
  env: {
318
393
  ...process.env,
319
394
  [FLOW_DEPTH_ENV]: String(nextDepth),
320
395
  [FLOW_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
321
396
  [FLOW_STACK_ENV]: JSON.stringify(propagatedStack),
322
397
  [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
398
+ [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
323
399
  [PI_OFFLINE_ENV]: "1",
324
400
  },
325
401
  });
@@ -353,9 +429,12 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
353
429
  return;
354
430
  }
355
431
 
356
- proc.kill("SIGTERM");
432
+ // Kill the entire process group (negative PID).
433
+ if (proc.pid === undefined) { proc.kill("SIGTERM"); } else { try { process.kill(-proc.pid, "SIGTERM"); } catch { proc.kill("SIGTERM"); } }
357
434
  const sigkillTimer = setTimeout(() => {
358
- if (!didClose) proc.kill("SIGKILL");
435
+ if (!didClose) {
436
+ if (proc.pid === undefined) { proc.kill("SIGKILL"); } else { try { process.kill(-proc.pid, "SIGKILL"); } catch { proc.kill("SIGKILL"); } }
437
+ }
359
438
  }, SIGKILL_TIMEOUT_MS);
360
439
  sigkillTimer.unref();
361
440
  };
@@ -381,19 +460,17 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
381
460
  }
382
461
  };
383
462
 
463
+ let semanticCompletionTimerArmed = false;
384
464
  const maybeFinishFromAgentEnd = () => {
385
- if (!result.sawAgentEnd || didClose || settled) return;
386
- clearSemanticCompletionTimer();
465
+ if (!result.sawAgentEnd || didClose || settled || semanticCompletionTimerArmed) return;
466
+ semanticCompletionTimerArmed = true;
387
467
  semanticCompletionTimer = setTimeout(() => {
388
468
  if (didClose || settled || !result.sawAgentEnd) return;
389
469
  if (buffer.trim()) {
390
470
  flushBufferedLines(buffer);
391
471
  buffer = "";
392
472
  }
393
- proc.stdout.removeListener("data", onStdoutData);
394
- proc.stderr.removeListener("data", onStderrData);
395
473
  finish(0);
396
- terminateChild();
397
474
  }, AGENT_END_GRACE_MS);
398
475
  semanticCompletionTimer.unref();
399
476
  };
@@ -433,6 +510,16 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
433
510
  if (signal.aborted) abortHandler();
434
511
  else signal.addEventListener("abort", abortHandler, { once: true });
435
512
  }
513
+
514
+ // Execution timeout — kill child if it runs too long
515
+ if (effectiveTimeout > 0) {
516
+ const timeoutTimer = setTimeout(() => {
517
+ if (didClose || settled) return;
518
+ result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
519
+ terminateChild();
520
+ }, effectiveTimeout);
521
+ timeoutTimer.unref();
522
+ }
436
523
  });
437
524
 
438
525
  result.exitCode = exitCode;
@@ -68,38 +68,56 @@ export function clearHooks(): void {
68
68
  // Built-in hooks
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
- /** Suggest review flow after a successful code flow. */
71
+ /** Suggest audit flow after a successful build flow. */
72
72
  registerHook({
73
- name: "pi-agent-flow/code-to-review",
74
- trigger: { flowTypes: ["code"], onlyOnSuccess: true },
73
+ name: "pi-agent-flow/build-to-audit",
74
+ trigger: { flowTypes: ["build"], onlyOnSuccess: true },
75
75
  action: (ctx) => {
76
- const reviewWasRequested = ctx.params.some(
77
- (p) => p.type.toLowerCase() === "review",
76
+ const auditWasRequested = ctx.params.some(
77
+ (p) => p.type.toLowerCase() === "audit",
78
78
  );
79
- if (reviewWasRequested) return null;
79
+ if (auditWasRequested) return null;
80
80
 
81
81
  return {
82
82
  content:
83
- "Consider running a [review] flow to audit the changes for security, correctness, and code quality.",
83
+ "Consider running a [audit] flow to audit the changes for security, correctness, and code quality.",
84
84
  priority: 10,
85
85
  };
86
86
  },
87
87
  });
88
88
 
89
- /** Suggest code flow after a successful debug flow. */
89
+ /** Suggest build flow after a successful debug flow. */
90
90
  registerHook({
91
- name: "pi-agent-flow/debug-to-code",
91
+ name: "pi-agent-flow/debug-to-build",
92
92
  trigger: { flowTypes: ["debug"], onlyOnSuccess: true },
93
93
  action: (ctx) => {
94
- const codeWasRequested = ctx.params.some(
95
- (p) => p.type.toLowerCase() === "code",
94
+ const buildWasRequested = ctx.params.some(
95
+ (p) => p.type.toLowerCase() === "build",
96
96
  );
97
- if (codeWasRequested) return null;
97
+ if (buildWasRequested) return null;
98
98
 
99
99
  return {
100
100
  content:
101
- "The root cause has been identified. Consider running a [code] flow to implement the fix.",
101
+ "The root cause has been identified. Consider running a [build] flow to implement the fix.",
102
102
  priority: 10,
103
103
  };
104
104
  },
105
105
  });
106
+
107
+ /** Suggest scout flow after a successful audit flow. */
108
+ registerHook({
109
+ name: "pi-agent-flow/audit-to-scout",
110
+ trigger: { flowTypes: ["audit"], onlyOnSuccess: true },
111
+ action: (ctx) => {
112
+ const scoutWasRequested = ctx.params.some(
113
+ (p) => p.type.toLowerCase() === "scout",
114
+ );
115
+ if (scoutWasRequested) return null;
116
+
117
+ return {
118
+ content:
119
+ "Audit complete. Consider running a [scout] flow to trace the audit findings across the codebase.",
120
+ priority: 15,
121
+ };
122
+ },
123
+ });