pi-agent-flow 1.3.0 → 1.4.1

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.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/flow.ts CHANGED
@@ -26,11 +26,15 @@ const isWindows = process.platform === "win32";
26
26
  const SIGKILL_TIMEOUT_MS = 5000;
27
27
  const AGENT_END_GRACE_MS = 2000;
28
28
  const DEFAULT_FLOW_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
29
+ const FLOW_TIME_BUDGET_WARNING_MS = 2 * 60 * 1000; // warn 2 min before kill
30
+ const FLOW_FINAL_URGE_MS = 30 * 1000; // final urge 30 s before kill
31
+ const REPORTING_GRACE_MS = 10_000; // grace period after timeout for agent to report findings
29
32
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
30
33
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
31
34
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
32
35
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
33
36
  const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
37
+ const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
34
38
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
35
39
  const PI_OFFLINE_ENV = "PI_OFFLINE";
36
40
 
@@ -140,6 +144,7 @@ function buildFlowArgs(
140
144
  maxDepth: number = 0,
141
145
  toolOptimize: boolean = false,
142
146
  structuredOutput: boolean = true,
147
+ timeoutMs?: number,
143
148
  ): string[] {
144
149
  const args: string[] = [
145
150
  "--mode",
@@ -210,11 +215,17 @@ function buildFlowArgs(
210
215
  ? `You may delegate to sub-flows (depth ${currentDepth}/${effectiveMaxDepth}).`
211
216
  : `You may NOT delegate to sub-flows (depth limit reached).`;
212
217
 
218
+ const timeBudgetHint =
219
+ timeoutMs && timeoutMs > 0
220
+ ? `Time budget: ${Math.round(timeoutMs / 1000)}s total. You will be hard-killed when time expires — plan and prioritize accordingly.\n`
221
+ : "";
222
+
213
223
  const activation =
214
224
  `\n\n<activation flow="${flow.name}" depth="${currentDepth}" tools="${availableTools}">\n` +
215
225
  `You are a [${flow.name}] agent operating at depth ${currentDepth}.\n` +
216
226
  `Available tools: ${availableTools}.\n` +
217
227
  `${delegationRule}\n` +
228
+ `${timeBudgetHint}` +
218
229
  `Do not attempt to use any tool outside the available set — it will fail.\n` +
219
230
  `</activation>`;
220
231
 
@@ -241,7 +252,7 @@ function buildFlowArgs(
241
252
  ` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
242
253
  ` ],\n` +
243
254
  ` "commands": [\n` +
244
- ` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash" }\n` +
255
+ ` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash", "executionTime": "1.2s (normal)" }\n` +
245
256
  ` ],\n` +
246
257
  ` "notDone": [\n` +
247
258
  ` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
@@ -406,6 +417,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
406
417
  forkSessionTmpPath = forkTmp.filePath;
407
418
  }
408
419
 
420
+ // Resolve timeout: explicit option > env var > default (10 min)
421
+ const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
422
+ const envTimeout = envTimeoutRaw !== undefined ? (() => {
423
+ const n = Number(envTimeoutRaw);
424
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
425
+ })() : null;
426
+ const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
427
+
409
428
  try {
410
429
  const piArgs = buildFlowArgs(
411
430
  flow,
@@ -416,17 +435,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
416
435
  maxDepth,
417
436
  toolOptimize,
418
437
  structuredOutput,
438
+ effectiveTimeout,
419
439
  );
420
440
  let wasAborted = false;
421
441
 
422
- // Resolve timeout: explicit option > env var > default (10 min)
423
- const envTimeoutRaw = process.env[FLOW_TIMEOUT_ENV];
424
- const envTimeout = envTimeoutRaw !== undefined ? (() => {
425
- const n = Number(envTimeoutRaw);
426
- return Number.isSafeInteger(n) && n >= 0 ? n : null;
427
- })() : null;
428
- const effectiveTimeout = opts.timeoutMs ?? envTimeout ?? DEFAULT_FLOW_TIMEOUT_MS;
429
-
430
442
  const exitCode = await new Promise<number>((resolve) => {
431
443
  const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
432
444
  const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
@@ -446,18 +458,25 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
446
458
  [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
447
459
  [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
448
460
  [PI_OFFLINE_ENV]: "1",
461
+ [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
449
462
  },
450
463
  });
451
464
 
465
+ let stdinEnded = false;
466
+ const endStdin = () => {
467
+ if (stdinEnded) return;
468
+ stdinEnded = true;
469
+ try { proc.stdin.end(); } catch { /* ignore */ }
470
+ };
452
471
  proc.stdin.on("error", () => {
453
472
  /* ignore broken pipe on fast exits */
454
473
  });
455
- proc.stdin.end();
456
474
 
475
+ let abortHandler: (() => void) | undefined;
457
476
  let buffer = "";
458
477
  let didClose = false;
459
478
  let settled = false;
460
- let abortHandler: (() => void) | undefined;
479
+ let timeoutFired = false;
461
480
  let semanticCompletionTimer: NodeJS.Timeout | undefined;
462
481
 
463
482
  const clearSemanticCompletionTimer = () => {
@@ -468,6 +487,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
468
487
  };
469
488
 
470
489
  const terminateChild = () => {
490
+ endStdin();
471
491
  if (isWindows) {
472
492
  if (proc.pid !== undefined) {
473
493
  const killer = spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)], {
@@ -491,6 +511,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
491
511
  const finish = (code: number) => {
492
512
  if (settled) return;
493
513
  settled = true;
514
+ endStdin();
494
515
  clearSemanticCompletionTimer();
495
516
  if (signal && abortHandler) {
496
517
  signal.removeEventListener("abort", abortHandler);
@@ -554,18 +575,73 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
554
575
  abortHandler = () => {
555
576
  if (didClose || settled) return;
556
577
  wasAborted = true;
578
+ endStdin();
557
579
  terminateChild();
558
580
  };
559
581
  if (signal.aborted) abortHandler();
560
582
  else signal.addEventListener("abort", abortHandler, { once: true });
561
583
  }
562
584
 
563
- // Execution timeout — kill child if it runs too long
585
+ // Execution timeout — two-stage: urge message, then stdin injection, then grace, then hard kill
564
586
  if (effectiveTimeout > 0) {
587
+ // Warning timer: notify the parent UI that the child is about to be killed.
588
+ // NOTE: True mid-flight injection into the child's context requires pi-core
589
+ // support for out-of-band messages. Until then, the agent only knows its
590
+ // budget from the initial prompt; this warning is parent-side only.
591
+ const warningMs = effectiveTimeout - FLOW_TIME_BUDGET_WARNING_MS;
592
+ if (warningMs > 0) {
593
+ const warnTimer = setTimeout(() => {
594
+ if (didClose || settled) return;
595
+ const remainingSec = Math.round(FLOW_TIME_BUDGET_WARNING_MS / 1000);
596
+ const warnMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. The agent should wrap up now.`;
597
+ result.stderr += warnMsg;
598
+ // Force an update so the parent UI shows the warning immediately.
599
+ emitUpdate();
600
+ }, warningMs);
601
+ warnTimer.unref();
602
+ }
603
+
604
+ // Final urge timer: stronger warning 30 s before hard timeout
605
+ const urgeMs = effectiveTimeout - FLOW_FINAL_URGE_MS;
606
+ if (urgeMs > 0) {
607
+ const urgeTimer = setTimeout(() => {
608
+ if (didClose || settled) return;
609
+ const remainingSec = Math.round(FLOW_FINAL_URGE_MS / 1000);
610
+ const urgeMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. Stop all work and output your structured findings.`;
611
+ result.stderr += urgeMsg;
612
+ emitUpdate();
613
+ }, urgeMs);
614
+ urgeTimer.unref();
615
+ }
616
+
565
617
  const timeoutTimer = setTimeout(() => {
566
618
  if (didClose || settled) return;
619
+ timeoutFired = true;
567
620
  result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
568
- terminateChild();
621
+ emitUpdate();
622
+
623
+ // Stage 1: Send timeout instruction to child via stdin
624
+ try {
625
+ if (!stdinEnded) {
626
+ proc.stdin.write(
627
+ JSON.stringify({
628
+ type: "flow_timeout",
629
+ instruction: "stop and report all findings immediately",
630
+ }) + "\n",
631
+ );
632
+ }
633
+ } catch {
634
+ /* ignore broken pipe */
635
+ }
636
+
637
+ // Stage 2: Grace period before hard kill
638
+ const graceTimer = setTimeout(() => {
639
+ if (didClose || settled) return;
640
+ result.stopReason = "timeout";
641
+ result.errorMessage = `Flow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
642
+ terminateChild();
643
+ }, REPORTING_GRACE_MS);
644
+ graceTimer.unref();
569
645
  }, effectiveTimeout);
570
646
  timeoutTimer.unref();
571
647
  }
@@ -110,8 +110,30 @@ export function enrichStructuredOutputCommands(
110
110
  structuredOutput: FlowStructuredOutput,
111
111
  messages: Message[],
112
112
  ): FlowStructuredOutput {
113
+ // Build a map of toolCallId -> execution time string from tool result messages.
114
+ const timingMap = new Map<string, string>();
115
+ for (const msg of messages) {
116
+ if (msg.role !== "tool" || !Array.isArray(msg.content)) continue;
117
+ const id =
118
+ (msg as unknown as { toolCallId?: string }).toolCallId ||
119
+ (msg as unknown as { tool_call_id?: string }).tool_call_id ||
120
+ "";
121
+ if (!id) continue;
122
+ const text = msg.content
123
+ .filter((c: { type: string; text?: string }) => c.type === "text" && typeof c.text === "string")
124
+ .map((c: { text: string }) => c.text)
125
+ .join("");
126
+ const match = text.match(/\[Execution time: ([^\]]+)\]/);
127
+ if (match) timingMap.set(id, match[1]);
128
+ }
129
+
113
130
  // Collect actual verbatim bash commands (including those inside batch ops)
114
- const actualBashCommands: string[] = [];
131
+ // alongside their toolCallId so we can look up execution time.
132
+ interface BashEntry {
133
+ command: string;
134
+ toolCallId?: string;
135
+ }
136
+ const actualBashEntries: BashEntry[] = [];
115
137
  for (const msg of messages) {
116
138
  if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
117
139
  for (const part of msg.content) {
@@ -121,10 +143,14 @@ export function enrichStructuredOutputCommands(
121
143
  (part as unknown as { toolName?: string }).toolName ||
122
144
  "";
123
145
  const args = part.arguments || part.input || {};
146
+ const toolCallId =
147
+ (part as unknown as { toolCallId?: string }).toolCallId ||
148
+ (part as unknown as { tool_call_id?: string }).tool_call_id ||
149
+ "";
124
150
 
125
151
  if (name === "bash") {
126
152
  const cmd = (args.command as string) || "";
127
- if (cmd) actualBashCommands.push(cmd);
153
+ if (cmd) actualBashEntries.push({ command: cmd, toolCallId });
128
154
  } else if (name === "batch") {
129
155
  const ops = Array.isArray(args.o)
130
156
  ? args.o
@@ -137,20 +163,30 @@ export function enrichStructuredOutputCommands(
137
163
  if (!op) continue;
138
164
  const opType = (op.o ?? op.op) as string;
139
165
  if (opType === "bash" && op.command) {
140
- actualBashCommands.push(op.command as string);
166
+ // Batch-nested bash ops don't have individual toolCallIds,
167
+ // so execution time can't be correlated.
168
+ actualBashEntries.push({ command: op.command as string });
141
169
  }
142
170
  }
143
171
  }
144
172
  }
145
173
  }
146
174
 
147
- if (actualBashCommands.length === 0) return structuredOutput;
175
+ if (actualBashEntries.length === 0) return structuredOutput;
148
176
 
149
177
  // Replace paraphrased bash commands with actual verbatim ones, in order.
150
178
  let bashIdx = 0;
151
179
  const enrichedCommands = structuredOutput.commands.map((cmd) => {
152
- if (cmd.tool === "bash" && bashIdx < actualBashCommands.length) {
153
- return { command: actualBashCommands[bashIdx++], tool: "bash" };
180
+ if (cmd.tool === "bash" && bashIdx < actualBashEntries.length) {
181
+ const entry = actualBashEntries[bashIdx++];
182
+ const executionTime = entry.toolCallId
183
+ ? timingMap.get(entry.toolCallId)
184
+ : undefined;
185
+ return {
186
+ command: entry.command,
187
+ tool: "bash",
188
+ ...(executionTime !== undefined ? { executionTime } : {}),
189
+ };
154
190
  }
155
191
  return { command: cmd.command, tool: cmd.tool };
156
192
  });
package/src/types.ts CHANGED
@@ -43,6 +43,8 @@ export interface CommandEntry {
43
43
  command: string;
44
44
  /** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
45
45
  tool?: string;
46
+ /** Execution time classification from the timed bash wrapper (e.g. "3.5s (normal)"). */
47
+ executionTime?: string;
46
48
  }
47
49
 
48
50
  /** Compressed representation of a flow result for child context inheritance. */
@@ -176,7 +178,7 @@ export function isFlowComplete(r: Pick<SingleResult, "messages" | "sawAgentEnd">
176
178
  export function isFlowSuccess(r: SingleResult): boolean {
177
179
  if (r.exitCode === -1) return false;
178
180
  if (isFlowComplete(r)) return true;
179
- return r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted";
181
+ return r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted" && r.stopReason !== "timeout";
180
182
  }
181
183
 
182
184
  /** Whether a result represents an error. */