pi-agent-flow 1.4.2 → 1.4.4

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/README.md CHANGED
@@ -47,6 +47,7 @@ pi install .
47
47
  - **Parallel execution** — batch independent flows into one call with bounded concurrency
48
48
  - **Structured reports** — every flow returns `[Summary]`, `[Done]`, `[Not Done]`, `[Next Steps]`
49
49
  - **Depth guards** — configurable max delegation depth (default: `3`)
50
+ - **Session timeout modes** — child flows use controlled budgets: `fast` (300s), `default` (600s), or `long` (900s)
50
51
  - **Cycle prevention** — blocks re-entering flows already in the ancestor stack
51
52
  - **Model tiering & failover** — flows map to `lite` / `flash` / `full` tiers with primary + failover model chains
52
53
  - **Unified batch tools** — `batch` (read/write/edit/delete) and `batch_read` replace separate file tools for cross-cutting work
@@ -90,6 +91,33 @@ The result is faster, cheaper, and cleaner delegation: the main agent remains un
90
91
 
91
92
  > **Clean slate:** Set `inheritContext: false` in a custom flow's front-matter so it receives only the intent, ideal for unbiased creative work.
92
93
 
94
+ ### Session modes
95
+
96
+ Each flow call may set `sessionMode` to choose the child-agent time budget:
97
+
98
+ | Mode | Budget | Recommended use |
99
+ |------|-------:|-----------------|
100
+ | `fast` | 300s | quick scouting, narrow checks, small design passes |
101
+ | `default` | 600s | normal flow work; this is the default |
102
+ | `long` | 900s | large builds, full test runs, broad refactors, complex debugging |
103
+
104
+ Example:
105
+
106
+ ```json
107
+ {
108
+ "flow": [
109
+ {
110
+ "type": "build",
111
+ "intent": "Run the full test suite and fix failures",
112
+ "aim": "Fix failing tests",
113
+ "sessionMode": "long"
114
+ }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ The public interface is mode-based; arbitrary per-flow numeric timeouts are not exposed.
120
+
93
121
  ---
94
122
 
95
123
  ## Flow Definitions
@@ -259,6 +287,36 @@ Use `flowModelConfigs` in your Pi settings to define tiered model strategies. Ea
259
287
 
260
288
  Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`.
261
289
 
290
+ Switch the global active strategy quickly with `--flow-mode`:
291
+
292
+ ```bash
293
+ pi --flow-mode balance
294
+ pi --flow-mode quality
295
+ pi --flow-mode mimo
296
+ ```
297
+
298
+ `--flow-mode` updates `flowModelConfig` in global `~/.pi/agent/settings.json` (or `$PI_CODING_AGENT_DIR/settings.json`) and applies the mode immediately for the current invocation. The mode must already exist in the merged `flowModelConfigs`; project `.pi/settings.json` can still override global settings on later no-flag runs.
299
+
300
+ You can also set flow runtime defaults under `flowSettings`:
301
+
302
+ ```json
303
+ {
304
+ "flowSettings": {
305
+ "sessionMode": "default",
306
+ "maxConcurrency": 4,
307
+ "toolOptimize": true,
308
+ "structuredOutput": true,
309
+ "autoTransition": false
310
+ }
311
+ }
312
+ ```
313
+
314
+ `flowSettings.sessionMode` accepts `fast`, `default`, or `long`. Session mode precedence is:
315
+
316
+ ```txt
317
+ per-flow sessionMode > --flow-session-mode > PI_FLOW_SESSION_MODE > flowSettings.sessionMode > default
318
+ ```
319
+
262
320
  ### Flags
263
321
 
264
322
  | Flag | Description | Default |
@@ -266,10 +324,12 @@ Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/s
266
324
  | `--flow-max-depth [n]` | Maximum delegation depth | `3` |
267
325
  | `--flow-prevent-cycles` | Block cyclic delegation | `true` |
268
326
  | `--no-flow-prevent-cycles` | Disable cycle prevention | — |
269
- | `--flow-model-config [name]` | Select a named model strategy | `balance` |
327
+ | `--flow-model-config [name]` | Select a named model strategy for this invocation | `balance` |
328
+ | `--flow-mode [name]` | Persistently switch the global model strategy and apply it immediately | — |
270
329
  | `--flow-lite-model [model]` | Override the lite-tier model | — |
271
330
  | `--flow-flash-model [model]` | Override the flash-tier model | — |
272
331
  | `--flow-full-model [model]` | Override the full-tier model | — |
332
+ | `--flow-session-mode [mode]` | Default child-flow session mode: `fast`, `default`, or `long` | `default` |
273
333
  | `--tool-optimize` | Use unified `batch`/`batch_read` instead of separate read/write/edit | `true` |
274
334
  | `--no-tool-optimize` | Disable tool optimization; use legacy read/write/edit tools | — |
275
335
 
@@ -282,7 +342,7 @@ Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/s
282
342
  | `PI_FLOW_STACK` | JSON array of ancestor flow names |
283
343
  | `PI_FLOW_PREVENT_CYCLES` | `"1"` or `"0"` |
284
344
  | `PI_FLOW_TOOL_OPTIMIZE` | `"1"` or `"0"` (overrides default tool optimization) |
285
- | `PI_FLOW_TIMEOUT_MS` | Per-flow timeout in milliseconds (default: 10 minutes) |
345
+ | `PI_FLOW_SESSION_MODE` | Default child-flow session mode: `fast`, `default`, or `long` |
286
346
 
287
347
  ---
288
348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/ambient.d.ts CHANGED
@@ -46,6 +46,10 @@ declare module "@mariozechner/pi-coding-agent" {
46
46
  renderCall?: (...args: any[]) => any;
47
47
  renderResult?: (...args: any[]) => any;
48
48
  };
49
+ /** Test-only exports provided by tests/__mocks__/pi-coding-agent.ts. */
50
+ export const bashToolExecuteCalls: any[][];
51
+ export function __setBashToolExecuteImpl(fn: (...args: any[]) => Promise<any>): void;
52
+ export function __resetBashToolMock(): void;
49
53
  }
50
54
 
51
55
  declare module "@mariozechner/pi-tui" {
package/src/cli-args.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { parseAgentSessionMode, type AgentSessionMode } from "./session-mode.js";
8
9
 
9
10
  function looksLikeExplicitRelativePath(value: string): boolean {
10
11
  return (
@@ -47,6 +48,8 @@ export interface ParsedFlowCliArgs {
47
48
  fallbackTools?: string;
48
49
  fallbackNoTools: boolean;
49
50
  flowModelConfig?: string;
51
+ flowMode?: string;
52
+ flowSessionMode?: AgentSessionMode;
50
53
  tieredModels: {
51
54
  lite?: string;
52
55
  flash?: string;
@@ -80,6 +83,8 @@ export function parseFlowCliArgs(argv: string[]): ParsedFlowCliArgs {
80
83
  let fallbackTools: string | undefined;
81
84
  let fallbackNoTools = false;
82
85
  let flowModelConfig: string | undefined;
86
+ let flowMode: string | undefined;
87
+ let flowSessionMode: AgentSessionMode | undefined;
83
88
  let tieredLiteModel: string | undefined;
84
89
  let tieredFlashModel: string | undefined;
85
90
  let tieredFullModel: string | undefined;
@@ -112,6 +117,20 @@ export function parseFlowCliArgs(argv: string[]): ParsedFlowCliArgs {
112
117
  continue;
113
118
  }
114
119
 
120
+ if (flagName === "--flow-mode") {
121
+ const [value, skip] = getValue();
122
+ if (value !== undefined) flowMode = value;
123
+ i += skip;
124
+ continue;
125
+ }
126
+
127
+ if (flagName === "--flow-session-mode") {
128
+ const [value, skip] = getValue();
129
+ flowSessionMode = parseAgentSessionMode(value);
130
+ i += skip;
131
+ continue;
132
+ }
133
+
115
134
  if (
116
135
  [
117
136
  "--mode",
@@ -285,6 +304,8 @@ export function parseFlowCliArgs(argv: string[]): ParsedFlowCliArgs {
285
304
  fallbackTools,
286
305
  fallbackNoTools,
287
306
  flowModelConfig,
307
+ flowMode,
308
+ flowSessionMode,
288
309
  tieredModels: {
289
310
  lite: tieredLiteModel,
290
311
  flash: tieredFlashModel,
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
+ import { parseAgentSessionMode, type AgentSessionMode } from "./session-mode.js";
11
12
 
12
13
  export type FlowTier = "lite" | "flash" | "full";
13
14
 
@@ -33,6 +34,8 @@ export interface FlowSettings {
33
34
  maxConcurrency?: number;
34
35
  /** Whether to automatically queue follow-up flows based on hook transitions. Default: false. */
35
36
  autoTransition?: boolean;
37
+ /** Default child-flow session mode. Default: "default" (600s). */
38
+ sessionMode?: AgentSessionMode;
36
39
  }
37
40
 
38
41
  const BUILTIN_FLOW_MODEL_CONFIGS: FlowModelConfigs = {
@@ -54,7 +57,7 @@ function readSettingsJson(filePath: string): Record<string, unknown> | null {
54
57
  }
55
58
  }
56
59
 
57
- function getGlobalSettingsPath(): string {
60
+ export function getGlobalSettingsPath(): string {
58
61
  const agentDir = process.env["PI_CODING_AGENT_DIR"]?.trim() || path.join(os.homedir(), ".pi", "agent");
59
62
  return path.join(agentDir, "settings.json");
60
63
  }
@@ -63,12 +66,56 @@ function getProjectSettingsPath(cwd: string): string {
63
66
  return path.join(cwd, ".pi", "settings.json");
64
67
  }
65
68
 
69
+ export function normalizeFlowModeName(value: unknown): string | undefined {
70
+ if (typeof value !== "string") return undefined;
71
+ const normalized = value.trim();
72
+ return normalized.length > 0 ? normalized : undefined;
73
+ }
74
+
66
75
  function extractSelectedFlowModelConfigName(settings: Record<string, unknown> | null): string | undefined {
67
76
  if (!isPlainObject(settings)) return undefined;
68
- const raw = settings.flowModelConfig;
69
- if (typeof raw !== "string") return undefined;
70
- const normalized = raw.trim();
71
- return normalized.length > 0 ? normalized : undefined;
77
+ return normalizeFlowModeName(settings.flowModelConfig);
78
+ }
79
+
80
+ export function loadProjectFlowModelConfigName(cwd: string): string | undefined {
81
+ return extractSelectedFlowModelConfigName(readSettingsJson(getProjectSettingsPath(cwd)));
82
+ }
83
+
84
+ export function writeGlobalFlowMode(mode: string): { path: string; previous?: string } {
85
+ const normalized = normalizeFlowModeName(mode);
86
+ if (!normalized) {
87
+ throw new Error("Cannot update flow mode. Expected a non-empty mode name.");
88
+ }
89
+
90
+ const filePath = getGlobalSettingsPath();
91
+ let settings: Record<string, unknown> = {};
92
+
93
+ if (fs.existsSync(filePath)) {
94
+ let parsed: unknown;
95
+ try {
96
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
97
+ } catch (error) {
98
+ throw new Error(`Cannot update flow mode because ${filePath} contains invalid JSON.`);
99
+ }
100
+ if (!isPlainObject(parsed)) {
101
+ throw new Error(`Cannot update flow mode because ${filePath} must contain a JSON object.`);
102
+ }
103
+ settings = { ...parsed };
104
+ }
105
+
106
+ const previous = extractSelectedFlowModelConfigName(settings);
107
+ settings.flowModelConfig = normalized;
108
+
109
+ const dir = path.dirname(filePath);
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ const tmpPath = path.join(dir, `.settings.json.${process.pid}.${Date.now()}.tmp`);
112
+ fs.writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
113
+ fs.renameSync(tmpPath, filePath);
114
+
115
+ return {
116
+ path: filePath,
117
+ ...(previous !== undefined ? { previous } : {}),
118
+ };
72
119
  }
73
120
 
74
121
  function normalizeFailoverList(
@@ -186,6 +233,10 @@ function extractFlowSettings(settings: Record<string, unknown> | null): FlowSett
186
233
  if (typeof obj.autoTransition === "boolean") {
187
234
  result.autoTransition = obj.autoTransition;
188
235
  }
236
+ const sessionMode = parseAgentSessionMode(obj.sessionMode);
237
+ if (sessionMode !== undefined) {
238
+ result.sessionMode = sessionMode;
239
+ }
189
240
  return result;
190
241
  }
191
242
 
@@ -245,7 +296,7 @@ export function selectFlowModelStrategy(
245
296
  configs: FlowModelConfigs,
246
297
  requestedName?: string,
247
298
  ): LoadedFlowModelConfigs {
248
- const normalizedRequested = requestedName?.trim() || "default";
299
+ const normalizedRequested = normalizeFlowModeName(requestedName) ?? "default";
249
300
  const strategy = configs[normalizedRequested];
250
301
  if (strategy) {
251
302
  return { selectedName: normalizedRequested, configs, strategy };
package/src/executor.ts CHANGED
@@ -18,7 +18,8 @@ import { extractStructuredOutput } from "./structured-output.js";
18
18
  import { runHooksDetailed, type RunHooksResult } from "./hooks.js";
19
19
  import { mapFlowConcurrent, runFlow } from "./flow.js";
20
20
  import { getFlowSummaryText } from "./runner-events.js";
21
- import { resolveFlowModelCandidates, selectFlowModelStrategy, type LoadedFlowModelConfigs, type FlowModelStrategy } from "./config.js";
21
+ import { normalizeFlowModeName, resolveFlowModelCandidates, selectFlowModelStrategy, type LoadedFlowModelConfigs, type FlowModelStrategy } from "./config.js";
22
+ import { getAgentSessionTimeoutMs, resolveAgentSessionMode, type AgentSessionMode } from "./session-mode.js";
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Types
@@ -47,6 +48,8 @@ export interface FlowExecutorDeps {
47
48
  maxConcurrency: number;
48
49
  /** Whether auto-transition is enabled. */
49
50
  autoTransition: boolean;
51
+ /** Default child-flow session mode. */
52
+ defaultSessionMode: AgentSessionMode;
50
53
  /** Abort signal. */
51
54
  signal?: AbortSignal;
52
55
  /** Streaming update callback. */
@@ -82,6 +85,7 @@ export interface ExecuteFlowParams {
82
85
  intent: string;
83
86
  aim: string;
84
87
  cwd?: string;
88
+ sessionMode?: AgentSessionMode;
85
89
  }
86
90
 
87
91
  export interface ExecuteFlowResult {
@@ -184,7 +188,7 @@ export async function executeFlows(
184
188
  const {
185
189
  flows, currentDepth, maxDepth, ancestorFlowStack, preventCycles,
186
190
  toolOptimize, structuredOutput, cwd, loadedFlowModelConfigs,
187
- maxConcurrency, autoTransition, signal, onUpdate, makeDetails,
191
+ maxConcurrency, autoTransition, defaultSessionMode, signal, onUpdate, makeDetails,
188
192
  getFlag, tierOverrideResolver, fallbackModel, forkSessionSnapshotJsonl,
189
193
  flowResultCache, projectFlowsDir, hasUI, uiConfirm, onFlowMetrics,
190
194
  confirmProjectFlows,
@@ -222,13 +226,16 @@ export async function executeFlows(
222
226
  }
223
227
 
224
228
  // Resolve model strategy
225
- const cliFlowModelConfig =
226
- typeof getFlag("flow-model-config") === "string"
227
- ? (getFlag("flow-model-config") as string)
228
- : undefined;
229
+ const cliFlowMode = normalizeFlowModeName(getFlag("flow-mode"));
230
+ const cliFlowModelConfig = normalizeFlowModeName(getFlag("flow-model-config"));
231
+ if (cliFlowMode !== undefined && cliFlowModelConfig !== undefined && cliFlowMode !== cliFlowModelConfig) {
232
+ console.warn(
233
+ `[pi-agent-flow] Both --flow-mode "${cliFlowMode}" and --flow-model-config "${cliFlowModelConfig}" were provided. Using --flow-mode.`,
234
+ );
235
+ }
229
236
  const selectedFlowModelConfig = selectFlowModelStrategy(
230
237
  loadedFlowModelConfigs.configs,
231
- cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
238
+ cliFlowMode ?? cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
232
239
  );
233
240
 
234
241
  // Pre-allocate results array
@@ -257,10 +264,12 @@ export async function executeFlows(
257
264
  text +
258
265
  "|" +
259
266
  allResults
260
- .map(
261
- (r) =>
262
- `${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.errorMessage ?? ""}`,
263
- )
267
+ .map((r) => {
268
+ const remainingSeconds = r.exitCode === -1 && typeof r.deadlineAtMs === "number"
269
+ ? Math.max(0, Math.ceil((r.deadlineAtMs - Date.now()) / 1000))
270
+ : "";
271
+ return `${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.startedAtMs ?? ""}:${r.deadlineAtMs ?? ""}:${remainingSeconds}:${r.errorMessage ?? ""}`;
272
+ })
264
273
  .join(";");
265
274
  if (signature === lastEmittedSignature) return;
266
275
  lastEmittedSignature = signature;
@@ -276,6 +285,7 @@ export async function executeFlows(
276
285
  const executionStart = Date.now();
277
286
  const results = await mapFlowConcurrent(params, maxConcurrency, async (item, index) => {
278
287
  const normalizedType = item.type.toLowerCase();
288
+ const sessionMode = resolveAgentSessionMode(item.sessionMode, defaultSessionMode);
279
289
  const targetFlow = flows.find((f) => f.name === normalizedType);
280
290
  const effectiveMaxDepth =
281
291
  targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
@@ -297,6 +307,22 @@ export async function executeFlows(
297
307
  for (let attempt = 0; attempt < attemptModels.length; attempt++) {
298
308
  const candidateModel = attemptModels[attempt];
299
309
  if (candidateModel) attemptedModels.push(candidateModel);
310
+ const attemptStartMs = Date.now();
311
+ const attemptTimeoutMs = getAgentSessionTimeoutMs(sessionMode);
312
+ allResults[index] = {
313
+ type: normalizedType,
314
+ agentSource: targetFlow?.source ?? "unknown",
315
+ intent: item.intent,
316
+ aim: item.aim,
317
+ exitCode: -1,
318
+ messages: [],
319
+ stderr: "",
320
+ usage: emptyFlowUsage(),
321
+ model: candidateModel,
322
+ startedAtMs: attemptStartMs,
323
+ deadlineAtMs: attemptStartMs + attemptTimeoutMs,
324
+ };
325
+ emitProgress();
300
326
  result = await runFlow({
301
327
  cwd,
302
328
  flows,
@@ -311,6 +337,7 @@ export async function executeFlows(
311
337
  preventCycles,
312
338
  toolOptimize,
313
339
  structuredOutput,
340
+ sessionMode,
314
341
  model: candidateModel,
315
342
  signal,
316
343
  onUpdate: (partial) => {
package/src/flow.ts CHANGED
@@ -21,20 +21,21 @@ import {
21
21
  normalizeFlowResult,
22
22
  } from "./types.js";
23
23
  import { extractStructuredOutput, enrichStructuredOutputCommands } from "./structured-output.js";
24
+ import { DEFAULT_AGENT_SESSION_MODE, getAgentSessionTimeoutMs, type AgentSessionMode } from "./session-mode.js";
24
25
 
25
26
  const isWindows = process.platform === "win32";
26
27
  const SIGKILL_TIMEOUT_MS = 5000;
27
28
  const AGENT_END_GRACE_MS = 2000;
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
30
  const FLOW_FINAL_URGE_MS = 30 * 1000; // final urge 30 s before kill
31
31
  const REPORTING_GRACE_MS = 10_000; // grace period after timeout for agent to report findings
32
+ const FLOW_TOOL_SUMMARY_GRACE_MS = FLOW_FINAL_URGE_MS; // bash/tool abort lead time so the agent can summarize
32
33
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
33
34
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
34
35
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
35
36
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
36
- const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
37
37
  const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
38
+ const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
38
39
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
39
40
  const PI_OFFLINE_ENV = "PI_OFFLINE";
40
41
 
@@ -144,7 +145,8 @@ function buildFlowArgs(
144
145
  maxDepth: number = 0,
145
146
  toolOptimize: boolean = false,
146
147
  structuredOutput: boolean = true,
147
- timeoutMs?: number,
148
+ sessionMode: AgentSessionMode = DEFAULT_AGENT_SESSION_MODE,
149
+ sessionTimeoutMs: number = getAgentSessionTimeoutMs(sessionMode),
148
150
  ): string[] {
149
151
  const args: string[] = [
150
152
  "--mode",
@@ -161,6 +163,9 @@ function buildFlowArgs(
161
163
  if (inheritedCliArgs.flowModelConfig) {
162
164
  args.push("--flow-model-config", inheritedCliArgs.flowModelConfig);
163
165
  }
166
+ if (inheritedCliArgs.flowSessionMode) {
167
+ args.push("--flow-session-mode", inheritedCliArgs.flowSessionMode);
168
+ }
164
169
  if (inheritedCliArgs.tieredModels?.lite) {
165
170
  args.push("--flow-lite-model", inheritedCliArgs.tieredModels.lite);
166
171
  }
@@ -216,8 +221,8 @@ function buildFlowArgs(
216
221
  : `You may NOT delegate to sub-flows (depth limit reached).`;
217
222
 
218
223
  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`
224
+ sessionTimeoutMs > 0
225
+ ? `Session mode: ${sessionMode}. Time budget: ${Math.round(sessionTimeoutMs / 1000)}s total. Long-running tools may be interrupted near the deadline to preserve final-summary time; if a tool reports [Flow timeout], stop tool use and output structured findings immediately.\n`
221
226
  : "";
222
227
 
223
228
  const activation =
@@ -322,8 +327,8 @@ export interface RunFlowOptions {
322
327
  onUpdate?: FlowUpdateCallback;
323
328
  /** Factory to wrap results into FlowDetails. */
324
329
  makeDetails: (results: SingleResult[]) => FlowDetails;
325
- /** Max execution time in ms before child is terminated. Default: 10 minutes. */
326
- timeoutMs?: number;
330
+ /** Child-flow session mode. Default: "default" (600s). */
331
+ sessionMode?: AgentSessionMode;
327
332
  }
328
333
 
329
334
  /**
@@ -368,6 +373,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
368
373
  };
369
374
  }
370
375
 
376
+ const effectiveSessionMode = opts.sessionMode ?? DEFAULT_AGENT_SESSION_MODE;
377
+ const effectiveTimeout = getAgentSessionTimeoutMs(effectiveSessionMode);
378
+ const startedAtMs = Date.now();
379
+ const deadlineAtMs = effectiveTimeout > 0 ? startedAtMs + effectiveTimeout : undefined;
371
380
  const resolvedModel = model ?? flow.model ?? inheritedCliArgs.fallbackModel;
372
381
  const result: SingleResult = {
373
382
  type: normalizedFlowName,
@@ -379,6 +388,8 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
379
388
  stderr: "",
380
389
  usage: emptyFlowUsage(),
381
390
  model: resolvedModel,
391
+ startedAtMs,
392
+ ...(deadlineAtMs !== undefined ? { deadlineAtMs } : {}),
382
393
  };
383
394
 
384
395
  let liveStreamingText = "";
@@ -417,14 +428,6 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
417
428
  forkSessionTmpPath = forkTmp.filePath;
418
429
  }
419
430
 
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
-
428
431
  try {
429
432
  const piArgs = buildFlowArgs(
430
433
  flow,
@@ -435,6 +438,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
435
438
  maxDepth,
436
439
  toolOptimize,
437
440
  structuredOutput,
441
+ effectiveSessionMode,
438
442
  effectiveTimeout,
439
443
  );
440
444
  let wasAborted = false;
@@ -443,6 +447,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
443
447
  const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
444
448
  const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
445
449
  const propagatedStack = [...parentFlowStack, normalizedFlowName];
450
+ const proportionalGraceMs = Math.floor(effectiveTimeout * 0.1);
451
+ const minimumGraceMs = effectiveTimeout >= 10_000 ? 1_000 : Math.floor(effectiveTimeout / 2);
452
+ const toolSummaryGraceMs = Math.min(
453
+ FLOW_TOOL_SUMMARY_GRACE_MS,
454
+ Math.max(0, effectiveTimeout),
455
+ Math.max(minimumGraceMs, proportionalGraceMs),
456
+ );
446
457
  const { command, prefixArgs } = resolveFlowSpawn();
447
458
  const proc = spawn(command, [...prefixArgs, ...piArgs], {
448
459
  cwd: taskCwd ?? cwd,
@@ -458,7 +469,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
458
469
  [FLOW_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
459
470
  [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
460
471
  [PI_OFFLINE_ENV]: "1",
461
- [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
472
+ ...(effectiveTimeout > 0 ? {
473
+ [FLOW_DEADLINE_ENV]: String(deadlineAtMs),
474
+ [FLOW_TOOL_SUMMARY_GRACE_ENV]: String(toolSummaryGraceMs),
475
+ } : {
476
+ [FLOW_DEADLINE_ENV]: "",
477
+ [FLOW_TOOL_SUMMARY_GRACE_ENV]: "0",
478
+ }),
462
479
  },
463
480
  });
464
481
 
@@ -479,6 +496,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
479
496
  let settled = false;
480
497
  let timeoutFired = false;
481
498
  let semanticCompletionTimer: NodeJS.Timeout | undefined;
499
+ let countdownTimer: NodeJS.Timeout | undefined;
482
500
 
483
501
  const clearSemanticCompletionTimer = () => {
484
502
  if (semanticCompletionTimer) {
@@ -487,6 +505,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
487
505
  }
488
506
  };
489
507
 
508
+ const clearCountdownTimer = () => {
509
+ if (countdownTimer) {
510
+ clearInterval(countdownTimer);
511
+ countdownTimer = undefined;
512
+ }
513
+ };
514
+
490
515
  const terminateChild = () => {
491
516
  endStdin();
492
517
  if (isWindows) {
@@ -514,6 +539,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
514
539
  settled = true;
515
540
  endStdin();
516
541
  clearSemanticCompletionTimer();
542
+ clearCountdownTimer();
517
543
  if (signal && abortHandler) {
518
544
  signal.removeEventListener("abort", abortHandler);
519
545
  }
@@ -560,6 +586,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
560
586
  proc.stdout.on("data", onStdoutData);
561
587
  proc.stderr.on("data", onStderrData);
562
588
 
589
+ if (onUpdate && effectiveTimeout > 0) {
590
+ countdownTimer = setInterval(() => {
591
+ if (didClose || settled) return;
592
+ emitUpdate();
593
+ }, 1000);
594
+ countdownTimer.unref();
595
+ }
596
+
563
597
  proc.on("close", (code) => {
564
598
  didClose = true;
565
599
  if (buffer.trim()) flushBufferedLines(buffer);
@@ -583,7 +617,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
583
617
  else signal.addEventListener("abort", abortHandler, { once: true });
584
618
  }
585
619
 
586
- // Execution timeout — two-stage: urge message, then stdin injection, then grace, then hard kill
620
+ // Execution timeout — two-stage: parent-side warnings, tool-level deadline abort, then grace, then hard kill
587
621
  if (effectiveTimeout > 0) {
588
622
  // Warning timer: notify the parent UI that the child is about to be killed.
589
623
  // NOTE: True mid-flight injection into the child's context requires pi-core
package/src/index.ts CHANGED
@@ -12,8 +12,11 @@ import { type FlowConfig, discoverFlows, getFlowTier } from "./agents.js";
12
12
  import {
13
13
  loadFlowModelConfigs,
14
14
  loadFlowSettings,
15
+ loadProjectFlowModelConfigName,
16
+ normalizeFlowModeName,
15
17
  resolveFlowModelCandidates,
16
18
  selectFlowModelStrategy,
19
+ writeGlobalFlowMode,
17
20
  type LoadedFlowModelConfigs,
18
21
  } from "./config.js";
19
22
  import { getInheritedCliArgs } from "./cli-args.js";
@@ -55,6 +58,12 @@ import {
55
58
  } from "./sliding-prompt.js";
56
59
  import { DEFAULT_TRANSITIONS, buildTransitionHooks } from "./transitions.js";
57
60
  import { createTimedBashToolDefinition } from "./timed-bash.js";
61
+ import {
62
+ DEFAULT_AGENT_SESSION_MODE,
63
+ PI_FLOW_SESSION_MODE_ENV,
64
+ parseAgentSessionMode,
65
+ type AgentSessionMode,
66
+ } from "./session-mode.js";
58
67
 
59
68
  // ---------------------------------------------------------------------------
60
69
  // Limits
@@ -85,13 +94,23 @@ const FlowItem = Type.Object({
85
94
  cwd: Type.Optional(
86
95
  Type.String({ description: "Working directory override for this flow." }),
87
96
  ),
97
+ sessionMode: Type.Optional(
98
+ Type.Union([
99
+ Type.Literal("fast"),
100
+ Type.Literal("default"),
101
+ Type.Literal("long"),
102
+ ], {
103
+ description: "Agent session budget for this flow: fast=300s, default=600s, long=900s. Use long only for large builds, broad refactors, full test runs, or complex debugging.",
104
+ }),
105
+ ),
88
106
  });
89
107
 
90
108
  const FlowParams = Type.Object({
91
109
  flow: Type.Array(FlowItem, {
92
110
  description:
93
111
  "Array of flow tasks to execute. Each runs in its own forked process. " +
94
- 'Example: { flow: [{ type: "scout", "intent": "Find all authentication-related code and trace JWT validation", "aim": "Find auth code and trace JWT" }, { type: "build", "intent": "Fix the bug in user registration", "aim": "Fix registration bug" }] }',
112
+ "Optional sessionMode selects the child-agent budget: fast=300s, default=600s, long=900s. " +
113
+ 'Example: { flow: [{ type: "scout", "intent": "Find all authentication-related code and trace JWT validation", "aim": "Find auth code and trace JWT", "sessionMode": "fast" }, { type: "build", "intent": "Fix the bug in user registration", "aim": "Fix registration bug", "sessionMode": "long" }] }',
95
114
  minItems: 1,
96
115
  }),
97
116
  confirmProjectFlows: Type.Optional(
@@ -634,6 +653,10 @@ export default function (pi: ExtensionAPI) {
634
653
  description: "Named flow model strategy from settings.json flowModelConfigs.",
635
654
  type: "string",
636
655
  });
656
+ pi.registerFlag("flow-mode", {
657
+ description: "Persistently switch the global flow model strategy in ~/.pi/agent/settings.json.",
658
+ type: "string",
659
+ });
637
660
  pi.registerFlag("flow-lite-model", {
638
661
  description: "Model for lite-tier flows (scout, debug).",
639
662
  type: "string",
@@ -650,6 +673,10 @@ export default function (pi: ExtensionAPI) {
650
673
  description: "Maximum number of flows to execute in parallel (default: 4).",
651
674
  type: "string",
652
675
  });
676
+ pi.registerFlag("flow-session-mode", {
677
+ description: "Default child-flow session mode: fast (300s), default (600s), or long (900s).",
678
+ type: "string",
679
+ });
653
680
  pi.registerFlag("auto-transition", {
654
681
  description: "Automatically queue follow-up flows based on hook transitions (default: false).",
655
682
  type: "boolean",
@@ -669,6 +696,7 @@ export default function (pi: ExtensionAPI) {
669
696
  let structuredOutput = true;
670
697
  let maxConcurrency = 4;
671
698
  let autoTransition = false;
699
+ let defaultSessionMode: AgentSessionMode = DEFAULT_AGENT_SESSION_MODE;
672
700
  const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
673
701
  if (envToolOptimize !== undefined) {
674
702
  const parsed = parseBoolean(envToolOptimize);
@@ -681,39 +709,96 @@ export default function (pi: ExtensionAPI) {
681
709
  configs: { default: {} },
682
710
  strategy: {},
683
711
  };
712
+ let activeRuntimeFlowMode: string | undefined;
684
713
 
685
714
  // Auto-discover flows on session start
686
715
  pi.on("session_start", async (_event, ctx) => {
687
716
  const discovery = discoverFlows(ctx.cwd, "all");
688
717
  discoveredFlows = discovery.flows;
689
718
  loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
719
+ activeRuntimeFlowMode = undefined;
720
+
721
+ const requestedFlowMode = normalizeFlowModeName(pi.getFlag("flow-mode"));
722
+ if (requestedFlowMode !== undefined) {
723
+ if (!Object.prototype.hasOwnProperty.call(loadedFlowModelConfigs.configs, requestedFlowMode)) {
724
+ const availableModes = Object.keys(loadedFlowModelConfigs.configs).sort().join(", ") || "(none)";
725
+ console.warn(
726
+ `[pi-agent-flow] Cannot switch flow mode to "${requestedFlowMode}"; no flowModelConfigs.${requestedFlowMode} strategy was found. Available modes: ${availableModes}.`,
727
+ );
728
+ } else {
729
+ try {
730
+ const writeResult = writeGlobalFlowMode(requestedFlowMode);
731
+ console.warn(`[pi-agent-flow] Flow mode switched to "${requestedFlowMode}" in ${writeResult.path}.`);
732
+ } catch (error) {
733
+ const message = error instanceof Error ? error.message : String(error);
734
+ console.warn(`[pi-agent-flow] ${message}`);
735
+ }
736
+
737
+ const projectFlowModelConfig = loadProjectFlowModelConfigName(ctx.cwd);
738
+ if (projectFlowModelConfig !== undefined && projectFlowModelConfig !== requestedFlowMode) {
739
+ console.warn(
740
+ `[pi-agent-flow] Switched global flow mode to "${requestedFlowMode}"; this project selects "${projectFlowModelConfig}" in .pi/settings.json, so future runs in this project may still use "${projectFlowModelConfig}" unless project settings are changed.`,
741
+ );
742
+ }
743
+
744
+ activeRuntimeFlowMode = requestedFlowMode;
745
+ loadedFlowModelConfigs = selectFlowModelStrategy(loadedFlowModelConfigs.configs, requestedFlowMode);
746
+ }
747
+ }
690
748
 
691
749
  // Register declarative transition hooks from the transition matrix
692
750
  for (const hook of buildTransitionHooks(DEFAULT_TRANSITIONS)) {
693
751
  registerHook(hook);
694
752
  }
695
753
 
754
+ const flowSettings = loadFlowSettings(ctx.cwd);
755
+ if (typeof flowSettings.structuredOutput === "boolean") {
756
+ structuredOutput = flowSettings.structuredOutput;
757
+ }
758
+ if (typeof flowSettings.maxConcurrency === "number") {
759
+ maxConcurrency = flowSettings.maxConcurrency;
760
+ }
761
+ if (typeof flowSettings.autoTransition === "boolean") {
762
+ autoTransition = flowSettings.autoTransition;
763
+ }
764
+
696
765
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
697
766
  const cliFlag = pi.getFlag("tool-optimize");
767
+ if (typeof flowSettings.toolOptimize === "boolean") {
768
+ toolOptimize = flowSettings.toolOptimize;
769
+ }
770
+ if (envToolOptimize !== undefined) {
771
+ const parsed = parseBoolean(envToolOptimize);
772
+ if (parsed !== null) toolOptimize = parsed;
773
+ }
698
774
  if (typeof cliFlag === "boolean") {
699
775
  toolOptimize = cliFlag;
700
776
  } else if (typeof cliFlag === "string") {
701
777
  const parsed = parseBoolean(cliFlag);
702
778
  if (parsed !== null) toolOptimize = parsed;
703
- } else {
704
- const flowSettings = loadFlowSettings(ctx.cwd);
705
- if (typeof flowSettings.toolOptimize === "boolean") {
706
- toolOptimize = flowSettings.toolOptimize;
707
- }
708
- if (typeof flowSettings.structuredOutput === "boolean") {
709
- structuredOutput = flowSettings.structuredOutput;
710
- }
711
- if (typeof flowSettings.maxConcurrency === "number") {
712
- maxConcurrency = flowSettings.maxConcurrency;
779
+ }
780
+
781
+ // Resolve sessionMode: CLI flag > env var > settings.json > default
782
+ defaultSessionMode = flowSettings.sessionMode ?? DEFAULT_AGENT_SESSION_MODE;
783
+ const envSessionModeRaw = process.env[PI_FLOW_SESSION_MODE_ENV];
784
+ if (envSessionModeRaw !== undefined) {
785
+ const envSessionMode = parseAgentSessionMode(envSessionModeRaw);
786
+ if (envSessionMode !== undefined) {
787
+ defaultSessionMode = envSessionMode;
788
+ } else {
789
+ console.warn(`[pi-agent-flow] Ignoring invalid ${PI_FLOW_SESSION_MODE_ENV}="${envSessionModeRaw}". Expected fast, default, or long.`);
713
790
  }
714
- if (typeof flowSettings.autoTransition === "boolean") {
715
- autoTransition = flowSettings.autoTransition;
791
+ }
792
+ const cliSessionModeRaw = pi.getFlag("flow-session-mode");
793
+ if (typeof cliSessionModeRaw === "string") {
794
+ const cliSessionMode = parseAgentSessionMode(cliSessionModeRaw);
795
+ if (cliSessionMode !== undefined) {
796
+ defaultSessionMode = cliSessionMode;
797
+ } else {
798
+ console.warn(`[pi-agent-flow] Ignoring invalid --flow-session-mode value "${cliSessionModeRaw}". Expected fast, default, or long.`);
716
799
  }
800
+ } else if (inheritedCliArgs.flowSessionMode !== undefined) {
801
+ defaultSessionMode = inheritedCliArgs.flowSessionMode;
717
802
  }
718
803
 
719
804
  // Resolve maxConcurrency: CLI flag > env var > settings.json > default
@@ -908,7 +993,8 @@ flow [type] accomplished
908
993
  "You MUST enter to the following flow states, with tool call method.",
909
994
  "",
910
995
  "Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
911
- 'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
996
+ 'Invoke: { "flow": [{ "type": "scout", "intent": "...", "aim": "...", "sessionMode": "default" }, ...] }',
997
+ "Session modes: fast=300s, default=600s, long=900s. Use long only when the work genuinely needs the larger budget.",
912
998
  "States: scout, debug, build, craft, audit, ideas.",
913
999
  "Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
914
1000
  ].join("\n"),
@@ -953,10 +1039,11 @@ flow [type] accomplished
953
1039
  loadedFlowModelConfigs,
954
1040
  maxConcurrency,
955
1041
  autoTransition,
1042
+ defaultSessionMode,
956
1043
  signal,
957
1044
  onUpdate,
958
1045
  makeDetails,
959
- getFlag: (name: string) => pi.getFlag(name),
1046
+ getFlag: (name: string) => name === "flow-mode" ? activeRuntimeFlowMode : pi.getFlag(name),
960
1047
  tierOverrideResolver: getTierOverride,
961
1048
  fallbackModel: inheritedCliArgs.fallbackModel,
962
1049
  forkSessionSnapshotJsonl,
@@ -968,7 +1055,7 @@ flow [type] accomplished
968
1055
  onFlowMetrics: (metrics) => { if (typeof pi.emit === "function") pi.emit("pi-agent-flow:complete", metrics); },
969
1056
  confirmProjectFlows: params.confirmProjectFlows,
970
1057
  },
971
- params.flow.map((f: any) => ({ type: f.type, intent: f.intent, aim: f.aim, cwd: f.cwd })),
1058
+ params.flow.map((f: any) => ({ type: f.type, intent: f.intent, aim: f.aim, cwd: f.cwd, sessionMode: f.sessionMode })),
972
1059
  toolCallId,
973
1060
  );
974
1061
 
@@ -43,6 +43,10 @@ function formatTps(value: number | undefined): string {
43
43
  return value.toFixed(1).padStart(5);
44
44
  }
45
45
 
46
+ export function formatCompactTokenPair(usage: Partial<UsageStats>): string {
47
+ return `↑ ${formatFixedTokens(usage.input || 0)} · ↓ ${formatFixedTokens(usage.output || 0)}`;
48
+ }
49
+
46
50
  export function formatCompactStats(usage: Partial<UsageStats>, model?: string, maxWidth?: number): string {
47
51
  const parts: string[] = [];
48
52
  parts.push(`↑ ${formatFixedTokens(usage.input || 0)}`);
@@ -72,6 +76,13 @@ export function formatCompactStats(usage: Partial<UsageStats>, model?: string, m
72
76
  return result;
73
77
  }
74
78
 
79
+ export function formatCountdown(ms: number): string {
80
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
81
+ const minutes = Math.floor(totalSeconds / 60);
82
+ const seconds = totalSeconds % 60;
83
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
84
+ }
85
+
75
86
  /** Regex matching ANSI escape sequences. */
76
87
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
77
88
 
package/src/render.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  isFlowError,
23
23
  isFlowSuccess,
24
24
  } from "./types.js";
25
- import { formatCompactStats, formatFlowTypeName, truncateChars, tailText, contentBudget } from "./render-utils.js";
25
+ import { formatCompactStats, formatCompactTokenPair, formatCountdown, formatFlowTypeName, truncateChars, tailText, contentBudget, visibleLength } from "./render-utils.js";
26
26
 
27
27
  function shortenPath(p: string): string {
28
28
  const home = os.homedir();
@@ -138,6 +138,20 @@ function sectionHeader(label: string): string {
138
138
  return `${left} ${label} ${right}`;
139
139
  }
140
140
 
141
+ function getLiveCountdown(r: SingleResult): string | undefined {
142
+ if (r.exitCode !== -1 || typeof r.deadlineAtMs !== "number") return undefined;
143
+ return formatCountdown(r.deadlineAtMs - Date.now());
144
+ }
145
+
146
+ function formatAimLinePrefix(treePrefix: string, r: SingleResult): string {
147
+ const countdown = getLiveCountdown(r);
148
+ return countdown ? `${treePrefix} aim: [${countdown}] - ` : `${treePrefix} aim: `;
149
+ }
150
+
151
+ function formatMsgLinePrefix(treePrefix: string, r: SingleResult): string {
152
+ return `${treePrefix} msg: [${formatCompactTokenPair(r.usage)}] - `;
153
+ }
154
+
141
155
  // ---------------------------------------------------------------------------
142
156
  // renderFlowCall — shown while the flow is being invoked
143
157
  // ---------------------------------------------------------------------------
@@ -313,37 +327,40 @@ function renderFlowCollapsed(
313
327
 
314
328
  // aim: line (short headline)
315
329
  if (r.aim) {
316
- const dirContent = truncateChars(r.aim, contentBudget(10));
317
- container.addChild(new TruncatedText(`${theme.fg("dim", "├─ aim:")} ${theme.fg("dim", dirContent)}`, 0, 0));
330
+ const aimPrefix = formatAimLinePrefix("├─", r);
331
+ const dirContent = truncateChars(r.aim, contentBudget(visibleLength(aimPrefix)));
332
+ container.addChild(new TruncatedText(`${theme.fg("dim", aimPrefix)}${theme.fg("dim", dirContent)}`, 0, 0));
318
333
  }
319
334
 
320
335
  // act: line (last tool call with count)
321
336
  const lastTool = getLastToolCall(r.messages);
322
337
  if (lastTool) {
323
338
  const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
324
- const actPrefix = `act: [${r.usage.toolCalls}] - `;
325
- const actContent = truncateChars(actStr, contentBudget(10));
326
- container.addChild(new TruncatedText(`${theme.fg("dim", "├─ " + actPrefix)}${actContent}`, 0, 0));
339
+ const actPrefix = `├─ act: [${r.usage.toolCalls}] - `;
340
+ const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix)));
341
+ container.addChild(new TruncatedText(`${theme.fg("dim", actPrefix)}${actContent}`, 0, 0));
327
342
  }
328
343
 
329
344
  // msg: line (last assistant text or streaming)
345
+ const msgPrefix = formatMsgLinePrefix("└─", r);
346
+ const msgBudget = contentBudget(visibleLength(msgPrefix));
330
347
  if (r.exitCode === -1 && streamingText) {
331
- const logContent = tailText(streamingText, contentBudget(10));
332
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
348
+ const logContent = tailText(streamingText, msgBudget);
349
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
333
350
  } else if (r.structuredOutput?.summary) {
334
- const logContent = truncateChars(r.structuredOutput.summary, contentBudget(10));
335
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
351
+ const logContent = truncateChars(r.structuredOutput.summary, msgBudget);
352
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
336
353
  } else if (flowOutput) {
337
- const logContent = tailText(flowOutput, contentBudget(10));
338
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
354
+ const logContent = tailText(flowOutput, msgBudget);
355
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
339
356
  } else if (streamingText) {
340
- const logContent = tailText(streamingText, contentBudget(10));
341
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
357
+ const logContent = tailText(streamingText, msgBudget);
358
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
342
359
  } else if (error && r.errorMessage) {
343
- const logContent = truncateChars(r.errorMessage, contentBudget(10));
344
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("error", logContent)}`, 0, 0));
360
+ const logContent = truncateChars(r.errorMessage, msgBudget);
361
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("error", logContent)}`, 0, 0));
345
362
  } else {
346
- container.addChild(new TruncatedText(`${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", "[n/a]")}`, 0, 0));
363
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", "[n/a]")}`, 0, 0));
347
364
  }
348
365
 
349
366
  return container;
@@ -451,30 +468,33 @@ function renderActivityPanel(
451
468
 
452
469
  // aim: line (short headline)
453
470
  if (r.aim) {
454
- const dirContent = truncateChars(r.aim, contentBudget(10));
455
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ aim:")} ${theme.fg("dim", dirContent)}`, 0, 0));
471
+ const aimPrefix = formatAimLinePrefix(indent + "├─", r);
472
+ const dirContent = truncateChars(r.aim, contentBudget(visibleLength(aimPrefix)));
473
+ container.addChild(new TruncatedText(`${theme.fg("dim", aimPrefix)}${theme.fg("dim", dirContent)}`, 0, 0));
456
474
  }
457
475
 
458
476
  // act: line (last tool call with count)
459
477
  const lastTool = getLastToolCall(r.messages);
460
478
  if (lastTool) {
461
479
  const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
462
- const actPrefix = `act: [${r.usage.toolCalls}] - `;
463
- const actContent = truncateChars(actStr, contentBudget(10));
464
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ " + actPrefix)}${actContent}`, 0, 0));
480
+ const actPrefix = `${indent}├─ act: [${r.usage.toolCalls}] - `;
481
+ const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix)));
482
+ container.addChild(new TruncatedText(`${theme.fg("dim", actPrefix)}${actContent}`, 0, 0));
465
483
  }
466
484
 
467
485
  // msg: line (live streaming text or last assistant text)
486
+ const msgPrefix = formatMsgLinePrefix(indent + "└─", r);
487
+ const msgBudget = contentBudget(visibleLength(msgPrefix));
468
488
  const liveText = r.exitCode === -1 ? r.streamingText : undefined;
469
489
  const lastText = liveText || getLastAssistantText(r.messages);
470
490
  if (lastText) {
471
- const logContent = tailText(lastText, contentBudget(10));
472
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("dim", logContent)}`, 0, 0));
491
+ const logContent = tailText(lastText, msgBudget);
492
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
473
493
  } else if (error && r.errorMessage) {
474
- const logContent = truncateChars(r.errorMessage, contentBudget(10));
475
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("error", logContent)}`, 0, 0));
494
+ const logContent = truncateChars(r.errorMessage, msgBudget);
495
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("error", logContent)}`, 0, 0));
476
496
  } else {
477
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("dim", "[n/a]")}`, 0, 0));
497
+ container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", "[n/a]")}`, 0, 0));
478
498
  }
479
499
 
480
500
  // Add blank line separator between flows (with continuation pipe)
@@ -0,0 +1,32 @@
1
+ export const AGENT_SESSION_MODES = ["fast", "default", "long"] as const;
2
+
3
+ export type AgentSessionMode = typeof AGENT_SESSION_MODES[number];
4
+
5
+ export const DEFAULT_AGENT_SESSION_MODE: AgentSessionMode = "default";
6
+ export const MAX_AGENT_SESSION_TIMEOUT_MS = 900_000;
7
+ export const PI_FLOW_SESSION_MODE_ENV = "PI_FLOW_SESSION_MODE";
8
+
9
+ export const AGENT_SESSION_TIMEOUTS_MS: Record<AgentSessionMode, number> = {
10
+ fast: 300_000,
11
+ default: 600_000,
12
+ long: MAX_AGENT_SESSION_TIMEOUT_MS,
13
+ };
14
+
15
+ export function parseAgentSessionMode(value: unknown): AgentSessionMode | undefined {
16
+ if (typeof value !== "string") return undefined;
17
+ const normalized = value.trim().toLowerCase();
18
+ return (AGENT_SESSION_MODES as readonly string[]).includes(normalized)
19
+ ? normalized as AgentSessionMode
20
+ : undefined;
21
+ }
22
+
23
+ export function resolveAgentSessionMode(
24
+ value: unknown,
25
+ fallback: AgentSessionMode = DEFAULT_AGENT_SESSION_MODE,
26
+ ): AgentSessionMode {
27
+ return parseAgentSessionMode(value) ?? fallback;
28
+ }
29
+
30
+ export function getAgentSessionTimeoutMs(mode: AgentSessionMode): number {
31
+ return AGENT_SESSION_TIMEOUTS_MS[mode];
32
+ }
package/src/timed-bash.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  * Wraps the built-in bash tool to append execution-time classification
5
5
  * to every result. This gives the LLM concrete feedback to self-correct
6
6
  * strategy (e.g. switch from bash grep to grep tool, batch commands, etc.).
7
+ *
8
+ * Child flows also receive a hard deadline from the parent runner. When a
9
+ * bash command is still running near that deadline, this wrapper aborts just
10
+ * the bash tool and returns an explicit instruction to stop using tools and
11
+ * summarize. That preserves the child agent process long enough to produce
12
+ * its final structured report instead of being killed while a shell command is
13
+ * still active.
7
14
  */
8
15
 
9
16
  import { createBashToolDefinition } from "@mariozechner/pi-coding-agent";
@@ -21,6 +28,10 @@ export interface TimingReport {
21
28
  label: string;
22
29
  }
23
30
 
31
+ const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
32
+ const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
33
+ const DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS = 30_000;
34
+
24
35
  /** Classify duration into user-defined tiers with actionable feedback. */
25
36
  export function classifyDuration(ms: number): TimingReport {
26
37
  const s = ms / 1000;
@@ -56,6 +67,90 @@ export function formatTimingAppendix(report: TimingReport): string {
56
67
  return `\n\n[Execution time: ${report.label}]`;
57
68
  }
58
69
 
70
+ function parsePositiveSafeInteger(raw: unknown): number | null {
71
+ if (typeof raw !== "string" || !raw.trim()) return null;
72
+ const parsed = Number(raw);
73
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
74
+ }
75
+
76
+ function parseNonNegativeSafeInteger(raw: unknown): number | null {
77
+ if (typeof raw !== "string" || !raw.trim()) return null;
78
+ const parsed = Number(raw);
79
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
80
+ }
81
+
82
+ function getFlowDeadlineMs(): number | null {
83
+ return parsePositiveSafeInteger(process.env[FLOW_DEADLINE_ENV]);
84
+ }
85
+
86
+ function getFlowToolSummaryGraceMs(): number {
87
+ return parseNonNegativeSafeInteger(process.env[FLOW_TOOL_SUMMARY_GRACE_ENV]) ?? DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS;
88
+ }
89
+
90
+ function formatDeadlineAppendix(): string {
91
+ return "\n\n[Flow timeout] Bash command was interrupted to preserve time for the final flow summary. Stop running tools and return structured findings now.";
92
+ }
93
+
94
+ function appendTextToToolResult(result: any, text: string): void {
95
+ const textItem = result?.content?.find?.((c: any) => c.type === "text");
96
+ if (textItem && typeof textItem.text === "string") {
97
+ textItem.text += text;
98
+ } else if (Array.isArray(result?.content)) {
99
+ result.content.push({ type: "text", text: text.trim() });
100
+ }
101
+ }
102
+
103
+ function createDeadlineSignal(parentSignal: AbortSignal | undefined): {
104
+ signal: AbortSignal | undefined;
105
+ cleanup: () => void;
106
+ wasDeadlineAbort: () => boolean;
107
+ } {
108
+ const deadlineMs = getFlowDeadlineMs();
109
+ if (!deadlineMs) {
110
+ return { signal: parentSignal, cleanup: () => undefined, wasDeadlineAbort: () => false };
111
+ }
112
+
113
+ const summaryGraceMs = getFlowToolSummaryGraceMs();
114
+ const abortAtMs = deadlineMs - summaryGraceMs;
115
+ const delayMs = abortAtMs - Date.now();
116
+
117
+ const controller = new AbortController();
118
+ let deadlineAbort = false;
119
+ let timer: NodeJS.Timeout | undefined;
120
+ let relayParentAbort: (() => void) | undefined;
121
+
122
+ const abortForDeadline = () => {
123
+ if (controller.signal.aborted) return;
124
+ deadlineAbort = true;
125
+ controller.abort(new Error("Flow deadline reached while bash command was running."));
126
+ };
127
+
128
+ if (parentSignal?.aborted) {
129
+ controller.abort(parentSignal.reason);
130
+ } else if (parentSignal) {
131
+ relayParentAbort = () => controller.abort(parentSignal.reason);
132
+ parentSignal.addEventListener("abort", relayParentAbort, { once: true });
133
+ }
134
+
135
+ if (delayMs <= 0) {
136
+ abortForDeadline();
137
+ } else {
138
+ timer = setTimeout(abortForDeadline, delayMs);
139
+ timer.unref?.();
140
+ }
141
+
142
+ return {
143
+ signal: controller.signal,
144
+ cleanup: () => {
145
+ if (timer) clearTimeout(timer);
146
+ if (parentSignal && relayParentAbort) {
147
+ parentSignal.removeEventListener("abort", relayParentAbort);
148
+ }
149
+ },
150
+ wasDeadlineAbort: () => deadlineAbort,
151
+ };
152
+ }
153
+
59
154
  /**
60
155
  * Create a timed bash tool definition that wraps the built-in one.
61
156
  * Extensions override built-in tools by name, so this replaces the
@@ -93,11 +188,12 @@ export function createTimedBashToolDefinition(
93
188
  ctx: any,
94
189
  ) {
95
190
  const start = Date.now();
191
+ const deadlineSignal = createDeadlineSignal(signal);
96
192
  try {
97
193
  const result = await original.execute(
98
194
  toolCallId,
99
195
  params,
100
- signal,
196
+ deadlineSignal.signal,
101
197
  onUpdate,
102
198
  ctx,
103
199
  );
@@ -105,13 +201,10 @@ export function createTimedBashToolDefinition(
105
201
  const report = classifyDuration(duration);
106
202
  const appendix = formatTimingAppendix(report);
107
203
 
108
- const textItem = result.content?.find(
109
- (c: any) => c.type === "text",
110
- );
111
- if (textItem && typeof textItem.text === "string") {
112
- textItem.text += appendix;
113
- } else if (result.content) {
114
- result.content.push({ type: "text", text: appendix.trim() });
204
+ appendTextToToolResult(result, appendix);
205
+ if (deadlineSignal.wasDeadlineAbort()) {
206
+ appendTextToToolResult(result, formatDeadlineAppendix());
207
+ result.isError = true;
115
208
  }
116
209
  return result;
117
210
  } catch (err: any) {
@@ -119,10 +212,22 @@ export function createTimedBashToolDefinition(
119
212
  const report = classifyDuration(duration);
120
213
  const appendix = formatTimingAppendix(report);
121
214
 
215
+ if (deadlineSignal.wasDeadlineAbort()) {
216
+ const message = typeof err?.message === "string" && err.message.trim()
217
+ ? `${err.message}${appendix}${formatDeadlineAppendix()}`
218
+ : `${appendix.trim()}${formatDeadlineAppendix()}`;
219
+ return {
220
+ content: [{ type: "text", text: message }],
221
+ isError: true,
222
+ };
223
+ }
224
+
122
225
  if (err?.message && typeof err.message === "string") {
123
226
  err.message += appendix;
124
227
  }
125
228
  throw err;
229
+ } finally {
230
+ deadlineSignal.cleanup();
126
231
  }
127
232
  },
128
233
  };
package/src/types.ts CHANGED
@@ -122,6 +122,10 @@ export interface SingleResult {
122
122
  stopReason?: string;
123
123
  errorMessage?: string;
124
124
  sawAgentEnd?: boolean;
125
+ /** Epoch ms when flow execution started; used for live countdown rendering. */
126
+ startedAtMs?: number;
127
+ /** Epoch ms when the flow hard timeout occurs; used for live countdown rendering. */
128
+ deadlineAtMs?: number;
125
129
  /** Live in-progress text for status rendering; not part of the final flow report. */
126
130
  streamingText?: string;
127
131
  /** Structured JSON output parsed from the flow's final response. */