pi-agent-flow 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -80,8 +81,8 @@ The result is faster, cheaper, and cleaner delegation: the main agent remains un
80
81
  | Flow | Purpose | Tools | Tier |
81
82
  |------|---------|-------|------|
82
83
  | `[scout]` | Discover files, trace code paths, map architecture | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
83
- | `[debug]` | Investigate logs, errors, stack traces, root causes | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
84
- | `[build]` | Implement features, fix bugs, write tests, ship | `batch`, `bash`, `find`, `grep`, `ls` | `flash` |
84
+ | `[debug]` | Investigate logs, errors, stack traces, root causes, and update relevant troubleshooting docs | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
85
+ | `[build]` | Implement features, fix bugs, write tests, update relevant docs, ship | `batch`, `bash`, `find`, `grep`, `ls` | `flash` |
85
86
  | `[craft]` | Plan structure, break down requirements, design solutions | `batch`, `bash`, `find`, `grep`, `ls` | `full` |
86
87
  | `[audit]` | Audit security, quality, correctness; fix issues autonomously | `batch`, `bash`, `find`, `grep`, `ls` | `flash` |
87
88
  | `[ideas]` | Generate ideas and explore possibilities with inherited context | `batch`, `bash` | `full` |
@@ -90,6 +91,35 @@ 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
+ > **Docs hygiene:** Bundled `build` and `debug` flows are instructed to update relevant documentation after their work when the findings or implementation change developer or operational knowledge. If no docs apply, they should state why in the final report.
95
+
96
+ ### Session modes
97
+
98
+ Each flow call may set `sessionMode` to choose the child-agent time budget:
99
+
100
+ | Mode | Budget | Recommended use |
101
+ |------|-------:|-----------------|
102
+ | `fast` | 300s | quick scouting, narrow checks, small design passes |
103
+ | `default` | 600s | normal flow work; this is the default |
104
+ | `long` | 900s | large builds, full test runs, broad refactors, complex debugging |
105
+
106
+ Example:
107
+
108
+ ```json
109
+ {
110
+ "flow": [
111
+ {
112
+ "type": "build",
113
+ "intent": "Run the full test suite and fix failures",
114
+ "aim": "Fix failing tests",
115
+ "sessionMode": "long"
116
+ }
117
+ ]
118
+ }
119
+ ```
120
+
121
+ The public interface is mode-based; arbitrary per-flow numeric timeouts are not exposed.
122
+
93
123
  ---
94
124
 
95
125
  ## Flow Definitions
@@ -259,6 +289,36 @@ Use `flowModelConfigs` in your Pi settings to define tiered model strategies. Ea
259
289
 
260
290
  Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`.
261
291
 
292
+ Switch the global active strategy quickly with `--flow-mode`:
293
+
294
+ ```bash
295
+ pi --flow-mode balance
296
+ pi --flow-mode quality
297
+ pi --flow-mode mimo
298
+ ```
299
+
300
+ `--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.
301
+
302
+ You can also set flow runtime defaults under `flowSettings`:
303
+
304
+ ```json
305
+ {
306
+ "flowSettings": {
307
+ "sessionMode": "default",
308
+ "maxConcurrency": 4,
309
+ "toolOptimize": true,
310
+ "structuredOutput": true,
311
+ "autoTransition": false
312
+ }
313
+ }
314
+ ```
315
+
316
+ `flowSettings.sessionMode` accepts `fast`, `default`, or `long`. Session mode precedence is:
317
+
318
+ ```txt
319
+ per-flow sessionMode > --flow-session-mode > PI_FLOW_SESSION_MODE > flowSettings.sessionMode > default
320
+ ```
321
+
262
322
  ### Flags
263
323
 
264
324
  | Flag | Description | Default |
@@ -266,10 +326,12 @@ Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/s
266
326
  | `--flow-max-depth [n]` | Maximum delegation depth | `3` |
267
327
  | `--flow-prevent-cycles` | Block cyclic delegation | `true` |
268
328
  | `--no-flow-prevent-cycles` | Disable cycle prevention | — |
269
- | `--flow-model-config [name]` | Select a named model strategy | `balance` |
329
+ | `--flow-model-config [name]` | Select a named model strategy for this invocation | `balance` |
330
+ | `--flow-mode [name]` | Persistently switch the global model strategy and apply it immediately | — |
270
331
  | `--flow-lite-model [model]` | Override the lite-tier model | — |
271
332
  | `--flow-flash-model [model]` | Override the flash-tier model | — |
272
333
  | `--flow-full-model [model]` | Override the full-tier model | — |
334
+ | `--flow-session-mode [mode]` | Default child-flow session mode: `fast`, `default`, or `long` | `default` |
273
335
  | `--tool-optimize` | Use unified `batch`/`batch_read` instead of separate read/write/edit | `true` |
274
336
  | `--no-tool-optimize` | Disable tool optimization; use legacy read/write/edit tools | — |
275
337
 
@@ -282,7 +344,7 @@ Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/s
282
344
  | `PI_FLOW_STACK` | JSON array of ancestor flow names |
283
345
  | `PI_FLOW_PREVENT_CYCLES` | `"1"` or `"0"` |
284
346
  | `PI_FLOW_TOOL_OPTIMIZE` | `"1"` or `"0"` (overrides default tool optimization) |
285
- | `PI_FLOW_TIMEOUT_MS` | Per-flow timeout in milliseconds (default: 10 minutes) |
347
+ | `PI_FLOW_SESSION_MODE` | Default child-flow session mode: `fast`, `default`, or `long` |
286
348
 
287
349
  ---
288
350
 
package/agents/build.md CHANGED
@@ -17,8 +17,9 @@ During this build flow — your mission is to implement and verify changes. Be a
17
17
  4. Execute — implement changes following core principles.
18
18
  5. Refactor — clean up only if the change is working.
19
19
  6. Verify — run tests and relevant checks before considering the work done.
20
- 7. Shipcommit, push, monitor CI/CD, and fix failures until green when shipping is in scope.
21
- 8. Finalizeconfirm implementation, tests, and CI/CD status.
20
+ 7. Documentupdate relevant docs after the implementation and verification are settled; if no docs apply, state why.
21
+ 8. Shipcommit, push, monitor CI/CD, and fix failures until green when shipping is in scope.
22
+ 9. Finalize — confirm implementation, docs, tests, and CI/CD status.
22
23
 
23
24
  ## Rules
24
25
 
@@ -28,6 +29,7 @@ During this build flow — your mission is to implement and verify changes. Be a
28
29
  - If already on a feature/fix branch, continue on it.
29
30
  - Commit with a clear conventional message such as `feat:`, `fix:`, or `refactor:` when committing is in scope.
30
31
  - Push only after local verification passes when shipping is in scope.
32
+ - Update relevant documentation after finishing the work; if no docs changed, explain why in the final report.
31
33
  - If CI/CD fails, diagnose, fix, commit, push, and repeat until green.
32
34
  - If an unexpected error or trace is needed, recommend [debug] rather than guessing.
33
35
 
package/agents/debug.md CHANGED
@@ -16,6 +16,8 @@ During this debug flow — your mission is to investigate root cause. Be forensi
16
16
  3. Check changes — inspect recent diffs, configuration, dependencies, and environment differences.
17
17
  4. Identify root cause — state exactly what is broken and why.
18
18
  5. Recommend fix — propose the smallest safe correction only after evidence confirms the cause.
19
+ 6. Document — update relevant docs, runbooks, or troubleshooting notes after finishing the investigation; if no docs apply, state why.
20
+ 7. Finalize — confirm root cause, evidence, documentation updates, and recommended next steps.
19
21
 
20
22
  ## Rules
21
23
 
@@ -23,7 +25,8 @@ During this debug flow — your mission is to investigate root cause. Be forensi
23
25
  - Read logs and symptoms before reading broad code areas.
24
26
  - Use `batch` with `o: "read"`, `s: <offset>`, and `l: <limit>` for targeted file reading instead of bash `sed`/`head`/`tail`.
25
27
  - Do not suggest fixes until root cause is confirmed.
26
- - Do not implement changes from this flow unless explicitly requested.
28
+ - Documentation-only updates are required after finishing the work when relevant and safe; if no docs changed, explain why in the final report.
29
+ - Do not implement product-code changes from this flow unless explicitly requested.
27
30
 
28
31
  ## Note
29
32
  Treat this as a clean-slate system rewrite, unless explicitly mentioned in the requirements. Perform a comprehensive migration with zero requirements for backwards compatibility. You must ensure that all residual code, variable names, test suites, and documentation are fully refactored and perfectly aligned with the new architecture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/agents.ts CHANGED
@@ -56,6 +56,18 @@ export function getFlowTier(flowName: string): FlowTier {
56
56
  }
57
57
  }
58
58
 
59
+ /** Return the bundled flow names associated with a given tier. */
60
+ export function getTierFlowNames(tier: FlowTier): string[] {
61
+ switch (tier) {
62
+ case "lite":
63
+ return ["scout", "debug"];
64
+ case "flash":
65
+ return ["build", "audit"];
66
+ case "full":
67
+ return ["ideas", "craft"];
68
+ }
69
+ }
70
+
59
71
  // ---------------------------------------------------------------------------
60
72
  // Internal helpers
61
73
  // ---------------------------------------------------------------------------
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,8 +8,9 @@
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";
12
+ import { type FlowTier } from "./agents.js";
11
13
 
12
- export type FlowTier = "lite" | "flash" | "full";
13
14
 
14
15
  export interface FlowModelTierConfig {
15
16
  primary?: string;
@@ -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 };
@@ -318,3 +369,23 @@ export function resolveFlowModelCandidates(opts: {
318
369
 
319
370
  return { primary: candidates[0], candidates };
320
371
  }
372
+
373
+ export function formatFlowModelStrategy(modeName: string, strategy: FlowModelStrategy): string {
374
+ const tiers: FlowTier[] = ["lite", "flash", "full"];
375
+ const parts: string[] = [];
376
+ for (const tier of tiers) {
377
+ const config = strategy[tier];
378
+ const hasPrimary = Boolean(config?.primary);
379
+ const hasFailover = config?.failover && config.failover.length > 0;
380
+ let value: string;
381
+ if (hasPrimary) {
382
+ value = config!.primary!;
383
+ } else if (hasFailover) {
384
+ value = `failover: ${config!.failover!.join(", ")}`;
385
+ } else {
386
+ value = "(default)";
387
+ }
388
+ parts.push(`${tier}: ${value}`);
389
+ }
390
+ return `mode: ${modeName} | ${parts.join(" · ")}`;
391
+ }
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,11 +21,11 @@ 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
@@ -34,7 +34,6 @@ const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
34
34
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
35
35
  const FLOW_STACK_ENV = "PI_FLOW_STACK";
36
36
  const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
37
- const FLOW_TIMEOUT_ENV = "PI_FLOW_TIMEOUT_MS";
38
37
  const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
39
38
  const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
40
39
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
@@ -146,7 +145,8 @@ function buildFlowArgs(
146
145
  maxDepth: number = 0,
147
146
  toolOptimize: boolean = false,
148
147
  structuredOutput: boolean = true,
149
- timeoutMs?: number,
148
+ sessionMode: AgentSessionMode = DEFAULT_AGENT_SESSION_MODE,
149
+ sessionTimeoutMs: number = getAgentSessionTimeoutMs(sessionMode),
150
150
  ): string[] {
151
151
  const args: string[] = [
152
152
  "--mode",
@@ -163,6 +163,9 @@ function buildFlowArgs(
163
163
  if (inheritedCliArgs.flowModelConfig) {
164
164
  args.push("--flow-model-config", inheritedCliArgs.flowModelConfig);
165
165
  }
166
+ if (inheritedCliArgs.flowSessionMode) {
167
+ args.push("--flow-session-mode", inheritedCliArgs.flowSessionMode);
168
+ }
166
169
  if (inheritedCliArgs.tieredModels?.lite) {
167
170
  args.push("--flow-lite-model", inheritedCliArgs.tieredModels.lite);
168
171
  }
@@ -218,8 +221,8 @@ function buildFlowArgs(
218
221
  : `You may NOT delegate to sub-flows (depth limit reached).`;
219
222
 
220
223
  const timeBudgetHint =
221
- timeoutMs && timeoutMs > 0
222
- ? `Time budget: ${Math.round(timeoutMs / 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`
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`
223
226
  : "";
224
227
 
225
228
  const activation =
@@ -324,8 +327,8 @@ export interface RunFlowOptions {
324
327
  onUpdate?: FlowUpdateCallback;
325
328
  /** Factory to wrap results into FlowDetails. */
326
329
  makeDetails: (results: SingleResult[]) => FlowDetails;
327
- /** Max execution time in ms before child is terminated. Default: 10 minutes. */
328
- timeoutMs?: number;
330
+ /** Child-flow session mode. Default: "default" (600s). */
331
+ sessionMode?: AgentSessionMode;
329
332
  }
330
333
 
331
334
  /**
@@ -370,6 +373,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
370
373
  };
371
374
  }
372
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;
373
380
  const resolvedModel = model ?? flow.model ?? inheritedCliArgs.fallbackModel;
374
381
  const result: SingleResult = {
375
382
  type: normalizedFlowName,
@@ -381,6 +388,8 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
381
388
  stderr: "",
382
389
  usage: emptyFlowUsage(),
383
390
  model: resolvedModel,
391
+ startedAtMs,
392
+ ...(deadlineAtMs !== undefined ? { deadlineAtMs } : {}),
384
393
  };
385
394
 
386
395
  let liveStreamingText = "";
@@ -419,14 +428,6 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
419
428
  forkSessionTmpPath = forkTmp.filePath;
420
429
  }
421
430
 
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
431
  try {
431
432
  const piArgs = buildFlowArgs(
432
433
  flow,
@@ -437,6 +438,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
437
438
  maxDepth,
438
439
  toolOptimize,
439
440
  structuredOutput,
441
+ effectiveSessionMode,
440
442
  effectiveTimeout,
441
443
  );
442
444
  let wasAborted = false;
@@ -468,7 +470,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
468
470
  [FLOW_TOOL_OPTIMIZE_ENV]: toolOptimize ? "1" : "0",
469
471
  [PI_OFFLINE_ENV]: "1",
470
472
  ...(effectiveTimeout > 0 ? {
471
- [FLOW_DEADLINE_ENV]: String(Date.now() + effectiveTimeout),
473
+ [FLOW_DEADLINE_ENV]: String(deadlineAtMs),
472
474
  [FLOW_TOOL_SUMMARY_GRACE_ENV]: String(toolSummaryGraceMs),
473
475
  } : {
474
476
  [FLOW_DEADLINE_ENV]: "",
@@ -494,6 +496,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
494
496
  let settled = false;
495
497
  let timeoutFired = false;
496
498
  let semanticCompletionTimer: NodeJS.Timeout | undefined;
499
+ let countdownTimer: NodeJS.Timeout | undefined;
497
500
 
498
501
  const clearSemanticCompletionTimer = () => {
499
502
  if (semanticCompletionTimer) {
@@ -502,6 +505,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
502
505
  }
503
506
  };
504
507
 
508
+ const clearCountdownTimer = () => {
509
+ if (countdownTimer) {
510
+ clearInterval(countdownTimer);
511
+ countdownTimer = undefined;
512
+ }
513
+ };
514
+
505
515
  const terminateChild = () => {
506
516
  endStdin();
507
517
  if (isWindows) {
@@ -529,6 +539,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
529
539
  settled = true;
530
540
  endStdin();
531
541
  clearSemanticCompletionTimer();
542
+ clearCountdownTimer();
532
543
  if (signal && abortHandler) {
533
544
  signal.removeEventListener("abort", abortHandler);
534
545
  }
@@ -575,6 +586,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
575
586
  proc.stdout.on("data", onStdoutData);
576
587
  proc.stderr.on("data", onStderrData);
577
588
 
589
+ if (onUpdate && effectiveTimeout > 0) {
590
+ countdownTimer = setInterval(() => {
591
+ if (didClose || settled) return;
592
+ emitUpdate();
593
+ }, 1000);
594
+ countdownTimer.unref();
595
+ }
596
+
578
597
  proc.on("close", (code) => {
579
598
  didClose = true;
580
599
  if (buffer.trim()) flushBufferedLines(buffer);
package/src/index.ts CHANGED
@@ -12,8 +12,12 @@ 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,
20
+ formatFlowModelStrategy,
17
21
  type LoadedFlowModelConfigs,
18
22
  } from "./config.js";
19
23
  import { getInheritedCliArgs } from "./cli-args.js";
@@ -55,6 +59,12 @@ import {
55
59
  } from "./sliding-prompt.js";
56
60
  import { DEFAULT_TRANSITIONS, buildTransitionHooks } from "./transitions.js";
57
61
  import { createTimedBashToolDefinition } from "./timed-bash.js";
62
+ import {
63
+ DEFAULT_AGENT_SESSION_MODE,
64
+ PI_FLOW_SESSION_MODE_ENV,
65
+ parseAgentSessionMode,
66
+ type AgentSessionMode,
67
+ } from "./session-mode.js";
58
68
 
59
69
  // ---------------------------------------------------------------------------
60
70
  // Limits
@@ -85,13 +95,23 @@ const FlowItem = Type.Object({
85
95
  cwd: Type.Optional(
86
96
  Type.String({ description: "Working directory override for this flow." }),
87
97
  ),
98
+ sessionMode: Type.Optional(
99
+ Type.Union([
100
+ Type.Literal("fast"),
101
+ Type.Literal("default"),
102
+ Type.Literal("long"),
103
+ ], {
104
+ 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.",
105
+ }),
106
+ ),
88
107
  });
89
108
 
90
109
  const FlowParams = Type.Object({
91
110
  flow: Type.Array(FlowItem, {
92
111
  description:
93
112
  "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" }] }',
113
+ "Optional sessionMode selects the child-agent budget: fast=300s, default=600s, long=900s. " +
114
+ '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
115
  minItems: 1,
96
116
  }),
97
117
  confirmProjectFlows: Type.Optional(
@@ -634,6 +654,10 @@ export default function (pi: ExtensionAPI) {
634
654
  description: "Named flow model strategy from settings.json flowModelConfigs.",
635
655
  type: "string",
636
656
  });
657
+ pi.registerFlag("flow-mode", {
658
+ description: "Persistently switch the global flow model strategy in ~/.pi/agent/settings.json.",
659
+ type: "string",
660
+ });
637
661
  pi.registerFlag("flow-lite-model", {
638
662
  description: "Model for lite-tier flows (scout, debug).",
639
663
  type: "string",
@@ -650,6 +674,10 @@ export default function (pi: ExtensionAPI) {
650
674
  description: "Maximum number of flows to execute in parallel (default: 4).",
651
675
  type: "string",
652
676
  });
677
+ pi.registerFlag("flow-session-mode", {
678
+ description: "Default child-flow session mode: fast (300s), default (600s), or long (900s).",
679
+ type: "string",
680
+ });
653
681
  pi.registerFlag("auto-transition", {
654
682
  description: "Automatically queue follow-up flows based on hook transitions (default: false).",
655
683
  type: "boolean",
@@ -669,6 +697,7 @@ export default function (pi: ExtensionAPI) {
669
697
  let structuredOutput = true;
670
698
  let maxConcurrency = 4;
671
699
  let autoTransition = false;
700
+ let defaultSessionMode: AgentSessionMode = DEFAULT_AGENT_SESSION_MODE;
672
701
  const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
673
702
  if (envToolOptimize !== undefined) {
674
703
  const parsed = parseBoolean(envToolOptimize);
@@ -681,39 +710,98 @@ export default function (pi: ExtensionAPI) {
681
710
  configs: { default: {} },
682
711
  strategy: {},
683
712
  };
713
+ let activeRuntimeFlowMode: string | undefined;
684
714
 
685
715
  // Auto-discover flows on session start
686
716
  pi.on("session_start", async (_event, ctx) => {
687
717
  const discovery = discoverFlows(ctx.cwd, "all");
688
718
  discoveredFlows = discovery.flows;
689
719
  loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
720
+ activeRuntimeFlowMode = undefined;
721
+
722
+ const requestedFlowMode = normalizeFlowModeName(pi.getFlag("flow-mode"));
723
+ if (requestedFlowMode !== undefined) {
724
+ if (!Object.prototype.hasOwnProperty.call(loadedFlowModelConfigs.configs, requestedFlowMode)) {
725
+ const availableModes = Object.keys(loadedFlowModelConfigs.configs).sort().join(", ") || "(none)";
726
+ console.warn(
727
+ `[pi-agent-flow] Cannot switch flow mode to "${requestedFlowMode}"; no flowModelConfigs.${requestedFlowMode} strategy was found. Available modes: ${availableModes}.`,
728
+ );
729
+ } else {
730
+ try {
731
+ writeGlobalFlowMode(requestedFlowMode);
732
+ const strategy = loadedFlowModelConfigs.configs[requestedFlowMode] ?? {};
733
+ const strategyDescription = formatFlowModelStrategy(requestedFlowMode, strategy);
734
+ console.warn(strategyDescription);
735
+ } catch (error) {
736
+ const message = error instanceof Error ? error.message : String(error);
737
+ console.warn(`[pi-agent-flow] ${message}`);
738
+ }
739
+
740
+ const projectFlowModelConfig = loadProjectFlowModelConfigName(ctx.cwd);
741
+ if (projectFlowModelConfig !== undefined && projectFlowModelConfig !== requestedFlowMode) {
742
+ console.warn(
743
+ `[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.`,
744
+ );
745
+ }
746
+
747
+ activeRuntimeFlowMode = requestedFlowMode;
748
+ loadedFlowModelConfigs = selectFlowModelStrategy(loadedFlowModelConfigs.configs, requestedFlowMode);
749
+ }
750
+ }
690
751
 
691
752
  // Register declarative transition hooks from the transition matrix
692
753
  for (const hook of buildTransitionHooks(DEFAULT_TRANSITIONS)) {
693
754
  registerHook(hook);
694
755
  }
695
756
 
757
+ const flowSettings = loadFlowSettings(ctx.cwd);
758
+ if (typeof flowSettings.structuredOutput === "boolean") {
759
+ structuredOutput = flowSettings.structuredOutput;
760
+ }
761
+ if (typeof flowSettings.maxConcurrency === "number") {
762
+ maxConcurrency = flowSettings.maxConcurrency;
763
+ }
764
+ if (typeof flowSettings.autoTransition === "boolean") {
765
+ autoTransition = flowSettings.autoTransition;
766
+ }
767
+
696
768
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
697
769
  const cliFlag = pi.getFlag("tool-optimize");
770
+ if (typeof flowSettings.toolOptimize === "boolean") {
771
+ toolOptimize = flowSettings.toolOptimize;
772
+ }
773
+ if (envToolOptimize !== undefined) {
774
+ const parsed = parseBoolean(envToolOptimize);
775
+ if (parsed !== null) toolOptimize = parsed;
776
+ }
698
777
  if (typeof cliFlag === "boolean") {
699
778
  toolOptimize = cliFlag;
700
779
  } else if (typeof cliFlag === "string") {
701
780
  const parsed = parseBoolean(cliFlag);
702
781
  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;
782
+ }
783
+
784
+ // Resolve sessionMode: CLI flag > env var > settings.json > default
785
+ defaultSessionMode = flowSettings.sessionMode ?? DEFAULT_AGENT_SESSION_MODE;
786
+ const envSessionModeRaw = process.env[PI_FLOW_SESSION_MODE_ENV];
787
+ if (envSessionModeRaw !== undefined) {
788
+ const envSessionMode = parseAgentSessionMode(envSessionModeRaw);
789
+ if (envSessionMode !== undefined) {
790
+ defaultSessionMode = envSessionMode;
791
+ } else {
792
+ console.warn(`[pi-agent-flow] Ignoring invalid ${PI_FLOW_SESSION_MODE_ENV}="${envSessionModeRaw}". Expected fast, default, or long.`);
713
793
  }
714
- if (typeof flowSettings.autoTransition === "boolean") {
715
- autoTransition = flowSettings.autoTransition;
794
+ }
795
+ const cliSessionModeRaw = pi.getFlag("flow-session-mode");
796
+ if (typeof cliSessionModeRaw === "string") {
797
+ const cliSessionMode = parseAgentSessionMode(cliSessionModeRaw);
798
+ if (cliSessionMode !== undefined) {
799
+ defaultSessionMode = cliSessionMode;
800
+ } else {
801
+ console.warn(`[pi-agent-flow] Ignoring invalid --flow-session-mode value "${cliSessionModeRaw}". Expected fast, default, or long.`);
716
802
  }
803
+ } else if (inheritedCliArgs.flowSessionMode !== undefined) {
804
+ defaultSessionMode = inheritedCliArgs.flowSessionMode;
717
805
  }
718
806
 
719
807
  // Resolve maxConcurrency: CLI flag > env var > settings.json > default
@@ -908,7 +996,8 @@ flow [type] accomplished
908
996
  "You MUST enter to the following flow states, with tool call method.",
909
997
  "",
910
998
  "Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
911
- 'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
999
+ 'Invoke: { "flow": [{ "type": "scout", "intent": "...", "aim": "...", "sessionMode": "default" }, ...] }',
1000
+ "Session modes: fast=300s, default=600s, long=900s. Use long only when the work genuinely needs the larger budget.",
912
1001
  "States: scout, debug, build, craft, audit, ideas.",
913
1002
  "Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
914
1003
  ].join("\n"),
@@ -953,10 +1042,11 @@ flow [type] accomplished
953
1042
  loadedFlowModelConfigs,
954
1043
  maxConcurrency,
955
1044
  autoTransition,
1045
+ defaultSessionMode,
956
1046
  signal,
957
1047
  onUpdate,
958
1048
  makeDetails,
959
- getFlag: (name: string) => pi.getFlag(name),
1049
+ getFlag: (name: string) => name === "flow-mode" ? activeRuntimeFlowMode : pi.getFlag(name),
960
1050
  tierOverrideResolver: getTierOverride,
961
1051
  fallbackModel: inheritedCliArgs.fallbackModel,
962
1052
  forkSessionSnapshotJsonl,
@@ -968,7 +1058,7 @@ flow [type] accomplished
968
1058
  onFlowMetrics: (metrics) => { if (typeof pi.emit === "function") pi.emit("pi-agent-flow:complete", metrics); },
969
1059
  confirmProjectFlows: params.confirmProjectFlows,
970
1060
  },
971
- params.flow.map((f: any) => ({ type: f.type, intent: f.intent, aim: f.aim, cwd: f.cwd })),
1061
+ params.flow.map((f: any) => ({ type: f.type, intent: f.intent, aim: f.aim, cwd: f.cwd, sessionMode: f.sessionMode })),
972
1062
  toolCallId,
973
1063
  );
974
1064
 
@@ -1004,7 +1094,12 @@ flow [type] accomplished
1004
1094
  pi.emit("pi-agent-flow:ready", pluginApi);
1005
1095
  }
1006
1096
 
1007
- // Register cleanup on process exit (once)
1097
+ // Register cleanup on process exit (once).
1098
+ // We intentionally do NOT hook SIGINT/SIGTERM here. A plugin must not
1099
+ // override the host process signal handling; doing so can cut off the
1100
+ // host's terminal-cleanup logic and leave escape sequences or status
1101
+ // lines visible on Ctrl+C. The 'exit' event fires for normal exits,
1102
+ // including when the host handles the signal and calls process.exit().
1008
1103
  if (!(globalThis as any).__pi_agent_flow_shutdown_registered) {
1009
1104
  (globalThis as any).__pi_agent_flow_shutdown_registered = true;
1010
1105
  const emitShutdown = () => {
@@ -1013,8 +1108,6 @@ flow [type] accomplished
1013
1108
  }
1014
1109
  };
1015
1110
  process.on("exit", emitShutdown);
1016
- process.on("SIGINT", () => { emitShutdown(); process.exit(130); });
1017
- process.on("SIGTERM", () => { emitShutdown(); process.exit(143); });
1018
1111
  }
1019
1112
 
1020
1113
  }
@@ -43,27 +43,34 @@ function formatTps(value: number | undefined): string {
43
43
  return value.toFixed(1).padStart(5);
44
44
  }
45
45
 
46
- export function formatCompactStats(usage: Partial<UsageStats>, model?: string, maxWidth?: number): string {
47
- const parts: string[] = [];
48
- parts.push(`↑ ${formatFixedTokens(usage.input || 0)}`);
49
- parts.push(`↓ ${formatFixedTokens(usage.output || 0)}`);
50
- parts.push(`tps: ${formatTps(usage.smoothedTps)}`);
51
- parts.push(`ctx: ${formatFixedTokens(usage.contextTokens || 0)}`);
46
+ export function formatCompactTokenPair(usage: Partial<UsageStats>): string {
47
+ return `↑ ${formatFixedTokens(usage.input || 0)} · ↓ ${formatFixedTokens(usage.output || 0)}`;
48
+ }
49
+
50
+ export function formatCompactStats(
51
+ usage: Partial<UsageStats>,
52
+ model?: string,
53
+ maxWidth?: number,
54
+ options: { skipTokens?: boolean } = {},
55
+ ): string {
56
+ const tokenParts = [`↑ ${formatFixedTokens(usage.input || 0)}`, `↓ ${formatFixedTokens(usage.output || 0)}`];
57
+ const runtimeParts = [`tps: ${formatTps(usage.smoothedTps)}`, `ctx: ${formatFixedTokens(usage.contextTokens || 0)}`];
58
+ const parts = options.skipTokens ? runtimeParts : [...tokenParts, ...runtimeParts];
52
59
 
53
60
  const displayModel = model ? model.replace(/^[^/]+\//, "") : undefined;
54
61
  let result = parts.join(" · ") + (displayModel ? ` · ${displayModel}` : "");
55
62
  if (maxWidth && visibleLength(result) > maxWidth) {
56
- // Drop model first
63
+ // Drop model first.
57
64
  let narrow = parts.join(" · ");
58
65
  if (visibleLength(narrow) <= maxWidth) return narrow;
59
66
 
60
- // Drop context tokens
61
- const narrowParts = parts.slice(0, 3); // up to tps
62
- narrow = narrowParts.join(" · ");
67
+ // Drop context tokens next.
68
+ const withoutContext = parts.filter((part) => !part.startsWith("ctx:"));
69
+ narrow = withoutContext.join(" · ");
63
70
  if (visibleLength(narrow) <= maxWidth) return narrow;
64
71
 
65
- // Bare minimum (just input/output)
66
- narrow = `${parts[0]} · ${parts[1]}`;
72
+ // Bare minimum: token pair for normal stats, tps for token-free headers.
73
+ narrow = options.skipTokens ? runtimeParts[0] : tokenParts.join(" · ");
67
74
  if (visibleLength(narrow) <= maxWidth) return narrow;
68
75
 
69
76
  return truncateChars(result, maxWidth);
@@ -72,6 +79,13 @@ export function formatCompactStats(usage: Partial<UsageStats>, model?: string, m
72
79
  return result;
73
80
  }
74
81
 
82
+ export function formatCountdown(ms: number): string {
83
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
84
+ const minutes = Math.floor(totalSeconds / 60);
85
+ const seconds = totalSeconds % 60;
86
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
87
+ }
88
+
75
89
  /** Regex matching ANSI escape sequences. */
76
90
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
77
91
 
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
  // ---------------------------------------------------------------------------
@@ -305,7 +319,7 @@ function renderFlowCollapsed(
305
319
  ): Container {
306
320
  const container = new Container();
307
321
  const maxWidth = process.stdout.columns ?? 80;
308
- const stats = formatCompactStats(r.usage, r.model, maxWidth);
322
+ const stats = formatCompactStats(r.usage, r.model, maxWidth, { skipTokens: true });
309
323
  const typeName = formatFlowTypeName(r.type);
310
324
  let header = `${theme.fg("accent", theme.bold(typeName))} ${theme.fg("dim", stats)}`;
311
325
  if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
@@ -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;
@@ -434,7 +451,7 @@ function renderActivityPanel(
434
451
  for (let i = 0; i < results.length; i++) {
435
452
  const r = results[i];
436
453
  const isLast = i === results.length - 1;
437
- const stats = formatCompactStats(r.usage, r.model, maxWidth);
454
+ const stats = formatCompactStats(r.usage, r.model, maxWidth, { skipTokens: true });
438
455
  const error = isFlowError(r);
439
456
  const typeName = formatFlowTypeName(r.type);
440
457
 
@@ -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/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. */