pi-agent-flow 1.4.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.4.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
@@ -27,6 +27,8 @@ 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
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
30
32
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
31
33
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
32
34
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
@@ -250,7 +252,7 @@ function buildFlowArgs(
250
252
  ` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
251
253
  ` ],\n` +
252
254
  ` "commands": [\n` +
253
- ` { "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` +
254
256
  ` ],\n` +
255
257
  ` "notDone": [\n` +
256
258
  ` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
@@ -460,15 +462,21 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
460
462
  },
461
463
  });
462
464
 
465
+ let stdinEnded = false;
466
+ const endStdin = () => {
467
+ if (stdinEnded) return;
468
+ stdinEnded = true;
469
+ try { proc.stdin.end(); } catch { /* ignore */ }
470
+ };
463
471
  proc.stdin.on("error", () => {
464
472
  /* ignore broken pipe on fast exits */
465
473
  });
466
- proc.stdin.end();
467
474
 
475
+ let abortHandler: (() => void) | undefined;
468
476
  let buffer = "";
469
477
  let didClose = false;
470
478
  let settled = false;
471
- let abortHandler: (() => void) | undefined;
479
+ let timeoutFired = false;
472
480
  let semanticCompletionTimer: NodeJS.Timeout | undefined;
473
481
 
474
482
  const clearSemanticCompletionTimer = () => {
@@ -479,6 +487,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
479
487
  };
480
488
 
481
489
  const terminateChild = () => {
490
+ endStdin();
482
491
  if (isWindows) {
483
492
  if (proc.pid !== undefined) {
484
493
  const killer = spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)], {
@@ -502,6 +511,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
502
511
  const finish = (code: number) => {
503
512
  if (settled) return;
504
513
  settled = true;
514
+ endStdin();
505
515
  clearSemanticCompletionTimer();
506
516
  if (signal && abortHandler) {
507
517
  signal.removeEventListener("abort", abortHandler);
@@ -565,13 +575,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
565
575
  abortHandler = () => {
566
576
  if (didClose || settled) return;
567
577
  wasAborted = true;
578
+ endStdin();
568
579
  terminateChild();
569
580
  };
570
581
  if (signal.aborted) abortHandler();
571
582
  else signal.addEventListener("abort", abortHandler, { once: true });
572
583
  }
573
584
 
574
- // Execution timeout — kill child if it runs too long
585
+ // Execution timeout — two-stage: urge message, then stdin injection, then grace, then hard kill
575
586
  if (effectiveTimeout > 0) {
576
587
  // Warning timer: notify the parent UI that the child is about to be killed.
577
588
  // NOTE: True mid-flight injection into the child's context requires pi-core
@@ -590,10 +601,47 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
590
601
  warnTimer.unref();
591
602
  }
592
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
+
593
617
  const timeoutTimer = setTimeout(() => {
594
618
  if (didClose || settled) return;
619
+ timeoutFired = true;
595
620
  result.stderr += `\nFlow timed out after ${Math.round(effectiveTimeout / 1000)}s.`;
596
- 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();
597
645
  }, effectiveTimeout);
598
646
  timeoutTimer.unref();
599
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. */