pi-agent-flow 1.0.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1002 @@
1
+ /**
2
+ * Pi Flow Extension (fork-only)
3
+ *
4
+ * Delegates tasks to specialized flow states running as isolated pi processes.
5
+ * Each flow receives a forked snapshot of the current session context.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+ import { type FlowConfig, discoverFlows, getFlowTier } from "./agents.js";
11
+ import {
12
+ loadFlowModelConfigs,
13
+ loadFlowSettings,
14
+ resolveFlowModelCandidates,
15
+ selectFlowModelStrategy,
16
+ type LoadedFlowModelConfigs,
17
+ } from "./config.js";
18
+ import { parseFlowCliArgs } from "./cli-args.js";
19
+ import { renderFlowCall, renderFlowResult } from "./render.js";
20
+ import { getFlowSummaryText } from "./runner-events.js";
21
+ import { runHooks } from "./hooks.js";
22
+ import { mapFlowConcurrent, runFlow } from "./flow.js";
23
+ import {
24
+ type SingleResult,
25
+ type FlowDetails,
26
+ emptyFlowUsage,
27
+ isFlowError,
28
+ isFlowSuccess,
29
+ } from "./types.js";
30
+ import { createBatchTool, createBatchReadTool } from "./batch.js";
31
+ import {
32
+ createWebTool,
33
+ looksLikeUrlPrompt,
34
+ looksLikeWebSearchPrompt,
35
+ } from "./web-tool.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Limits
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const DEFAULT_MAX_DELEGATION_DEPTH = 3;
42
+ const DEFAULT_PREVENT_CYCLE_DELEGATION = true;
43
+ const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
44
+ const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
45
+ const FLOW_STACK_ENV = "PI_FLOW_STACK";
46
+ const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
47
+ export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Tool parameter schema
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const FlowItem = Type.Object({
54
+ type: Type.String({
55
+ description: "Flow type. Matching is case-insensitive. Must correspond to an available flow name such as scout, debug, build, craft, audit, or ideas.",
56
+ }),
57
+ intent: Type.String({
58
+ description: "Clear, specific mission for this flow.",
59
+ }),
60
+ aim: Type.String({
61
+ description: "Extreme short intent — one sentence, 5-7 words, headline-style summary of what this flow does.",
62
+ }),
63
+ cwd: Type.Optional(
64
+ Type.String({ description: "Working directory override for this flow." }),
65
+ ),
66
+ });
67
+
68
+ const FlowParams = Type.Object({
69
+ flow: Type.Array(FlowItem, {
70
+ description:
71
+ "Array of flow tasks to execute. Each runs in its own forked process. " +
72
+ '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" }] }',
73
+ minItems: 1,
74
+ }),
75
+ confirmProjectFlows: Type.Optional(
76
+ Type.Boolean({
77
+ description: "Whether to prompt the user before running project-local flows. Default: true.",
78
+ default: true,
79
+ }),
80
+ ),
81
+ });
82
+
83
+ const inheritedCliArgs = parseFlowCliArgs(process.argv);
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ interface FlowDepthConfig {
90
+ currentDepth: number;
91
+ maxDepth: number;
92
+ canDelegate: boolean;
93
+ ancestorFlowStack: string[];
94
+ preventCycles: boolean;
95
+ }
96
+
97
+ interface SessionSnapshotSource {
98
+ getHeader: () => unknown;
99
+ getBranch: () => unknown[];
100
+ }
101
+
102
+ function buildForkSessionSnapshotJsonl(
103
+ sessionManager: SessionSnapshotSource,
104
+ ): string | null {
105
+ const header = sessionManager.getHeader();
106
+ if (!header || typeof header !== "object") return null;
107
+
108
+ const branchEntries = sessionManager.getBranch();
109
+ const lines = [JSON.stringify(header)];
110
+ for (const entry of branchEntries) lines.push(JSON.stringify(entry));
111
+ return `${lines.join("\n")}\n`;
112
+ }
113
+
114
+ function parseNonNegativeInt(raw: unknown): number | null {
115
+ if (typeof raw !== "string") return null;
116
+ const trimmed = raw.trim();
117
+ if (!/^\d+$/.test(trimmed)) return null;
118
+ const parsed = Number(trimmed);
119
+ return Number.isSafeInteger(parsed) ? parsed : null;
120
+ }
121
+
122
+ function parseBoolean(raw: unknown): boolean | null {
123
+ if (typeof raw === "boolean") return raw;
124
+ if (typeof raw !== "string") return null;
125
+ const normalized = raw.trim().toLowerCase();
126
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
127
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
128
+ return null;
129
+ }
130
+
131
+ function parseFlowStack(raw: unknown): string[] | null {
132
+ if (raw === undefined) return [];
133
+ if (typeof raw !== "string") return null;
134
+ if (!raw.trim()) return [];
135
+
136
+ let parsed: unknown;
137
+ try {
138
+ parsed = JSON.parse(raw);
139
+ } catch {
140
+ return null;
141
+ }
142
+
143
+ if (!Array.isArray(parsed)) return null;
144
+ if (!parsed.every((value) => typeof value === "string")) return null;
145
+ return parsed
146
+ .map((value) => value.trim().toLowerCase())
147
+ .filter((value) => value.length > 0);
148
+ }
149
+
150
+ function getMaxDepthFlagFromArgv(argv: string[]): string | null {
151
+ for (let i = 2; i < argv.length; i++) {
152
+ const arg = argv[i];
153
+ if (arg === "--flow-max-depth") {
154
+ return argv[i + 1] ?? "";
155
+ }
156
+ if (arg.startsWith("--flow-max-depth=")) {
157
+ return arg.slice("--flow-max-depth=".length);
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function getPreventCyclesFlagFromArgv(
164
+ argv: string[],
165
+ ): string | boolean | null {
166
+ for (let i = 2; i < argv.length; i++) {
167
+ const arg = argv[i];
168
+ if (arg === "--flow-prevent-cycles") {
169
+ const maybeValue = argv[i + 1];
170
+ if (maybeValue !== undefined && !maybeValue.startsWith("--")) {
171
+ return maybeValue;
172
+ }
173
+ return true;
174
+ }
175
+ if (arg === "--no-flow-prevent-cycles") return false;
176
+ if (arg.startsWith("--flow-prevent-cycles=")) {
177
+ return arg.slice("--flow-prevent-cycles=".length);
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function resolveFlowDepthConfig(pi: ExtensionAPI): FlowDepthConfig {
184
+ const depthRaw = process.env[FLOW_DEPTH_ENV];
185
+ const parsedDepth = parseNonNegativeInt(depthRaw);
186
+ if (depthRaw !== undefined && parsedDepth === null) {
187
+ console.warn(
188
+ `[pi-agent-flow] Ignoring invalid ${FLOW_DEPTH_ENV}="${depthRaw}". Expected a non-negative integer.`,
189
+ );
190
+ }
191
+ const currentDepth = parsedDepth ?? 0;
192
+
193
+ const stackRaw = process.env[FLOW_STACK_ENV];
194
+ const ancestorFlowStack = parseFlowStack(stackRaw);
195
+ if (stackRaw !== undefined && ancestorFlowStack === null) {
196
+ console.warn(
197
+ `[pi-agent-flow] Ignoring invalid ${FLOW_STACK_ENV} value. Expected a JSON array of flow names.`,
198
+ );
199
+ }
200
+
201
+ const envMaxDepthRaw = process.env[FLOW_MAX_DEPTH_ENV];
202
+ const envMaxDepth = parseNonNegativeInt(envMaxDepthRaw);
203
+ if (envMaxDepthRaw !== undefined && envMaxDepth === null) {
204
+ console.warn(
205
+ `[pi-agent-flow] Ignoring invalid ${FLOW_MAX_DEPTH_ENV}="${envMaxDepthRaw}". Expected a non-negative integer.`,
206
+ );
207
+ }
208
+
209
+ const argvFlagRaw = getMaxDepthFlagFromArgv(process.argv);
210
+ const argvFlagMaxDepth =
211
+ argvFlagRaw !== null ? parseNonNegativeInt(argvFlagRaw) : null;
212
+ if (argvFlagRaw !== null && argvFlagMaxDepth === null) {
213
+ console.warn(
214
+ `[pi-agent-flow] Ignoring invalid --flow-max-depth value "${argvFlagRaw}". Expected a non-negative integer.`,
215
+ );
216
+ }
217
+
218
+ const runtimeFlagValue = pi.getFlag("flow-max-depth");
219
+ const runtimeFlagMaxDepth =
220
+ typeof runtimeFlagValue === "string"
221
+ ? parseNonNegativeInt(runtimeFlagValue)
222
+ : null;
223
+ if (
224
+ argvFlagRaw === null &&
225
+ typeof runtimeFlagValue === "string" &&
226
+ runtimeFlagMaxDepth === null
227
+ ) {
228
+ console.warn(
229
+ `[pi-agent-flow] Ignoring invalid --flow-max-depth value "${runtimeFlagValue}". Expected a non-negative integer.`,
230
+ );
231
+ }
232
+
233
+ const envPreventCyclesRaw = process.env[FLOW_PREVENT_CYCLES_ENV];
234
+ const envPreventCycles = parseBoolean(envPreventCyclesRaw);
235
+ if (envPreventCyclesRaw !== undefined && envPreventCycles === null) {
236
+ console.warn(
237
+ `[pi-agent-flow] Ignoring invalid ${FLOW_PREVENT_CYCLES_ENV}="${envPreventCyclesRaw}". Expected true/false.`,
238
+ );
239
+ }
240
+
241
+ const argvPreventCyclesRaw = getPreventCyclesFlagFromArgv(process.argv);
242
+ const argvPreventCycles =
243
+ typeof argvPreventCyclesRaw === "boolean"
244
+ ? argvPreventCyclesRaw
245
+ : parseBoolean(argvPreventCyclesRaw);
246
+ if (
247
+ typeof argvPreventCyclesRaw === "string" &&
248
+ argvPreventCycles === null
249
+ ) {
250
+ console.warn(
251
+ `[pi-agent-flow] Ignoring invalid --flow-prevent-cycles value "${argvPreventCyclesRaw}". Expected true/false.`,
252
+ );
253
+ }
254
+
255
+ const runtimePreventCyclesRaw = pi.getFlag("flow-prevent-cycles");
256
+ const runtimePreventCycles = parseBoolean(runtimePreventCyclesRaw);
257
+ if (
258
+ argvPreventCyclesRaw === null &&
259
+ runtimePreventCyclesRaw !== undefined &&
260
+ runtimePreventCycles === null
261
+ ) {
262
+ console.warn(
263
+ `[pi-agent-flow] Ignoring invalid --flow-prevent-cycles value "${String(runtimePreventCyclesRaw)}". Expected true/false.`,
264
+ );
265
+ }
266
+
267
+ const flagMaxDepth = argvFlagMaxDepth ?? runtimeFlagMaxDepth;
268
+ const maxDepth = flagMaxDepth ?? envMaxDepth ?? DEFAULT_MAX_DELEGATION_DEPTH;
269
+ const preventCycles =
270
+ argvPreventCycles ??
271
+ runtimePreventCycles ??
272
+ envPreventCycles ??
273
+ DEFAULT_PREVENT_CYCLE_DELEGATION;
274
+
275
+ return {
276
+ currentDepth,
277
+ maxDepth,
278
+ canDelegate: currentDepth < maxDepth,
279
+ ancestorFlowStack: ancestorFlowStack ?? [],
280
+ preventCycles,
281
+ };
282
+ }
283
+
284
+ function makeFlowDetailsFactory(projectFlowsDir: string | null) {
285
+ return (results: SingleResult[]): FlowDetails => ({
286
+ mode: "flow",
287
+ delegationMode: "fork",
288
+ projectAgentsDir: projectFlowsDir,
289
+ results,
290
+ });
291
+ }
292
+
293
+ function getFlowCycleViolations(
294
+ requestedNames: Set<string>,
295
+ ancestorFlowStack: string[],
296
+ ): string[] {
297
+ if (requestedNames.size === 0 || ancestorFlowStack.length === 0) return [];
298
+ const stackSet = new Set(ancestorFlowStack);
299
+ return Array.from(requestedNames).filter((name) => stackSet.has(name));
300
+ }
301
+
302
+ /** Get project-local flows referenced by the current request. */
303
+ function getRequestedProjectFlows(
304
+ flows: FlowConfig[],
305
+ requestedNames: Set<string>,
306
+ ): FlowConfig[] {
307
+ return Array.from(requestedNames)
308
+ .map((name) => flows.find((f) => f.name === name.toLowerCase()))
309
+ .filter((f): f is FlowConfig => f?.source === "project");
310
+ }
311
+
312
+ /**
313
+ * Prompt the user to confirm project-local flows if needed.
314
+ * Returns false if the user declines.
315
+ */
316
+ async function confirmProjectFlowsIfNeeded(
317
+ projectFlows: FlowConfig[],
318
+ projectFlowsDir: string | null,
319
+ ctx: { ui: { confirm: (title: string, body: string) => Promise<boolean> } },
320
+ ): Promise<boolean> {
321
+ if (projectFlows.length === 0) return true;
322
+
323
+ const names = projectFlows.map((f) => f.name).join(", ");
324
+ const dir = projectFlowsDir ?? "(unknown)";
325
+ return ctx.ui.confirm(
326
+ "Run project-local flows?",
327
+ `Flows: ${names}\nSource: ${dir}\n\nProject flows are repo-controlled. Only continue for trusted repositories.`,
328
+ );
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Sliding system prompt (short, inserted before latest user message)
333
+ // Always active for root flows. Appended to the static system prompt in
334
+ // before_agent_start, and inserted as a separate system message immediately
335
+ // before the latest user message by the context handler.
336
+ // ---------------------------------------------------------------------------
337
+
338
+ const SLIDING_PROMPT_OPEN_TAG = "<pi-flow-sliding-system>";
339
+ const SLIDING_PROMPT_CLOSE_TAG = "</pi-flow-sliding-system>";
340
+
341
+ const SLIDING_PROMPT =
342
+ `${SLIDING_PROMPT_OPEN_TAG}\n` +
343
+ `You are operating with pi-agent-flow routing.\n` +
344
+ `If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
345
+ `${SLIDING_PROMPT_CLOSE_TAG}`;
346
+
347
+ const SLIDING_PROMPT_RE = new RegExp(
348
+ SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
349
+ "[\\s\\S]*?" +
350
+ SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
351
+ "g",
352
+ );
353
+
354
+ /** Strip any old sliding system prompt tags from a string. */
355
+ function stripSlidingPromptText(text: string): string {
356
+ return text.replace(SLIDING_PROMPT_RE, "");
357
+ }
358
+
359
+ /** Strip sliding prompt tags from content (string or text-part array). */
360
+ function stripSlidingPromptFromContent(
361
+ content: string | { type: string; text?: string }[],
362
+ ): string | { type: string; text?: string }[] {
363
+ if (typeof content === "string") {
364
+ return stripSlidingPromptText(content);
365
+ }
366
+ return content.map((c) => {
367
+ if (c.type === "text" && typeof c.text === "string") {
368
+ return { ...c, text: stripSlidingPromptText(c.text) };
369
+ }
370
+ return c;
371
+ });
372
+ }
373
+
374
+ /** Check whether content (string or text-part array) contains the sliding tag. */
375
+ function contentContainsSlidingTag(content: any): boolean {
376
+ if (typeof content === "string") {
377
+ return content.includes(SLIDING_PROMPT_OPEN_TAG);
378
+ }
379
+ if (Array.isArray(content)) {
380
+ return content.some(
381
+ (part: any) =>
382
+ part.type === "text" &&
383
+ typeof part.text === "string" &&
384
+ part.text.includes(SLIDING_PROMPT_OPEN_TAG),
385
+ );
386
+ }
387
+ return false;
388
+ }
389
+
390
+ /** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
391
+ * Returns the sanitized messages and a flag indicating whether anything changed.
392
+ */
393
+ function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
394
+ let changed = false;
395
+ const result = messages
396
+ .filter((msg) => {
397
+ // Remove dedicated sliding system prompt messages
398
+ if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
399
+ changed = true;
400
+ return false;
401
+ }
402
+ return true;
403
+ })
404
+ .map((msg) => {
405
+ // Also strip stray tags embedded in user/assistant messages
406
+ if (!("content" in msg)) return msg;
407
+ const stripped = stripSlidingPromptFromContent(msg.content);
408
+ if (isJsonEqual(stripped, msg.content)) return msg;
409
+ changed = true;
410
+ return { ...msg, content: stripped };
411
+ });
412
+ return { messages: result, changed };
413
+ }
414
+
415
+ /** Build a system message containing the sliding prompt. */
416
+ function makeSlidingPromptMessage(referenceMessage?: any): any {
417
+ return {
418
+ role: "system",
419
+ content: SLIDING_PROMPT,
420
+ timestamp: referenceMessage?.timestamp,
421
+ };
422
+ }
423
+
424
+ const REASONING_PART_TYPES = new Set([
425
+ "thinking",
426
+ "reasoning",
427
+ "reasoning_content",
428
+ "reasoningContent",
429
+ ]);
430
+
431
+ const REASONING_FIELDS = [
432
+ "thinking",
433
+ "thinkingSignature",
434
+ "thinking_signature",
435
+ "reasoning",
436
+ "reasoningContent",
437
+ "reasoning_content",
438
+ "reasoningSignature",
439
+ "reasoning_signature",
440
+ ];
441
+
442
+ function stripReasoningFromAssistantMessage(message: any): {
443
+ message: any;
444
+ changed: boolean;
445
+ } {
446
+ let next = message;
447
+ let changed = false;
448
+
449
+ for (const field of REASONING_FIELDS) {
450
+ if (field in next) {
451
+ if (next === message) next = { ...message };
452
+ delete next[field];
453
+ changed = true;
454
+ }
455
+ }
456
+
457
+ if (Array.isArray(message.content)) {
458
+ const filteredContent = message.content.filter(
459
+ (part: any) => !REASONING_PART_TYPES.has(part?.type),
460
+ );
461
+ if (filteredContent.length !== message.content.length) {
462
+ if (next === message) next = { ...message };
463
+ next.content = filteredContent;
464
+ changed = true;
465
+ }
466
+ }
467
+
468
+ return { message: next, changed };
469
+ }
470
+
471
+ function isJsonEqual(a: any, b: any): boolean {
472
+ return JSON.stringify(a) === JSON.stringify(b);
473
+ }
474
+
475
+ /**
476
+ * Sanitize a fork session snapshot JSONL to remove only non-inheritable
477
+ * artifacts before passing full parent context to child flows: sliding system
478
+ * prompts, legacy reminders, and assistant reasoning/thinking.
479
+ */
480
+ function sanitizeForkSnapshot(snapshot: string | null): string | null {
481
+ if (!snapshot) return snapshot;
482
+
483
+ const lines = snapshot.trimEnd().split("\n");
484
+ const sanitizedLines: string[] = [];
485
+
486
+ for (const line of lines) {
487
+ let entry: any;
488
+ try {
489
+ entry = JSON.parse(line);
490
+ } catch {
491
+ sanitizedLines.push(line);
492
+ continue;
493
+ }
494
+
495
+ let changed = false;
496
+
497
+ // Drop sliding system prompt messages entirely.
498
+ if (
499
+ entry?.type === "message" &&
500
+ entry.message?.role === "system" &&
501
+ contentContainsSlidingTag(entry.message?.content)
502
+ ) {
503
+ continue;
504
+ }
505
+
506
+ if (entry?.type === "message" && entry.message) {
507
+ let message = entry.message;
508
+
509
+ if (message.role === "assistant") {
510
+ const stripped = stripReasoningFromAssistantMessage(message);
511
+ message = stripped.message;
512
+ changed ||= stripped.changed;
513
+ }
514
+
515
+ if ("content" in message) {
516
+ const originalContent = message.content;
517
+ const strippedContent = stripSlidingPromptFromContent(originalContent);
518
+
519
+ if (!isJsonEqual(strippedContent, originalContent)) {
520
+ message = {
521
+ ...message,
522
+ content: strippedContent,
523
+ };
524
+ changed = true;
525
+ }
526
+ }
527
+
528
+ if (changed) {
529
+ entry = { ...entry, message };
530
+ }
531
+ }
532
+
533
+ sanitizedLines.push(changed ? JSON.stringify(entry) : line);
534
+ }
535
+
536
+ return `${sanitizedLines.join("\n")}\n`;
537
+ }
538
+
539
+ function computeActiveTools(optimize: boolean): string[] {
540
+ return optimize
541
+ ? ["batch_read", "bash", "flow"]
542
+ : ["read", "write", "edit", "batch", "bash", "flow", "web"];
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Extension entry point
547
+ // ---------------------------------------------------------------------------
548
+
549
+ export default function (pi: ExtensionAPI) {
550
+ pi.registerFlag("flow-max-depth", {
551
+ description: "Maximum allowed flow delegation depth (default: 3).",
552
+ type: "string",
553
+ });
554
+ pi.registerFlag("flow-prevent-cycles", {
555
+ description: "Block delegating to flows already in the current delegation stack (default: true).",
556
+ type: "boolean",
557
+ });
558
+ pi.registerFlag("flow-model-config", {
559
+ description: "Named flow model strategy from settings.json flowModelConfigs.",
560
+ type: "string",
561
+ });
562
+ pi.registerFlag("flow-lite-model", {
563
+ description: "Model for lite-tier flows (scout, debug).",
564
+ type: "string",
565
+ });
566
+ pi.registerFlag("flow-flash-model", {
567
+ description: "Model for flash-tier flows (build, audit).",
568
+ type: "string",
569
+ });
570
+ pi.registerFlag("flow-full-model", {
571
+ description: "Model for full-tier flows (ideas, craft).",
572
+ type: "string",
573
+ });
574
+ pi.registerFlag("tool-optimize", {
575
+ description: "Use the unified batch tool instead of separate read/write/edit tools (default: true).",
576
+ type: "boolean",
577
+ });
578
+
579
+ const depthConfig = resolveFlowDepthConfig(pi);
580
+ const { currentDepth, maxDepth, canDelegate, ancestorFlowStack, preventCycles } =
581
+ depthConfig;
582
+
583
+ // toolOptimize: CLI flag > env var > settings.json > default (true)
584
+ let toolOptimize = true;
585
+ const envToolOptimize = process.env[FLOW_TOOL_OPTIMIZE_ENV];
586
+ if (envToolOptimize !== undefined) {
587
+ const parsed = parseBoolean(envToolOptimize);
588
+ if (parsed !== null) toolOptimize = parsed;
589
+ }
590
+
591
+ let discoveredFlows: FlowConfig[] = [];
592
+ let loadedFlowModelConfigs: LoadedFlowModelConfigs = {
593
+ selectedName: "default",
594
+ configs: { default: {} },
595
+ strategy: {},
596
+ };
597
+
598
+ // Auto-discover flows on session start
599
+ pi.on("session_start", async (_event, ctx) => {
600
+ const discovery = discoverFlows(ctx.cwd, "all");
601
+ discoveredFlows = discovery.flows;
602
+ loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
603
+
604
+ // Resolve toolOptimize: CLI flag > env var > settings.json > default
605
+ const cliFlag = pi.getFlag("tool-optimize");
606
+ if (typeof cliFlag === "boolean") {
607
+ toolOptimize = cliFlag;
608
+ } else if (typeof cliFlag === "string") {
609
+ const parsed = parseBoolean(cliFlag);
610
+ if (parsed !== null) toolOptimize = parsed;
611
+ } else {
612
+ const flowSettings = loadFlowSettings(ctx.cwd);
613
+ if (typeof flowSettings.toolOptimize === "boolean") {
614
+ toolOptimize = flowSettings.toolOptimize;
615
+ }
616
+ }
617
+
618
+ // Only restrict tools for the main orchestrator (depth 0).
619
+ // Child flows (depth > 0) receive their tools via --tools CLI arg;
620
+ // overriding them here would strip bash/batch from children.
621
+ if (currentDepth === 0) {
622
+ pi.setActiveTools(computeActiveTools(toolOptimize));
623
+ }
624
+
625
+ // Register batch and batch_read so they are available for main agent and child flows.
626
+ if (toolOptimize) {
627
+ pi.registerTool(createBatchReadTool());
628
+ pi.registerTool(createBatchTool());
629
+ }
630
+ });
631
+
632
+ // Re-apply active tools every turn to survive registry refreshes.
633
+ // Skip for child flows — they get tools from --tools CLI arg.
634
+ pi.on("turn_start", () => {
635
+ if (currentDepth > 0) return;
636
+ pi.setActiveTools(computeActiveTools(toolOptimize));
637
+ });
638
+ // Inject available flows into the system prompt.
639
+ // Skip entirely for child flows (depth > 0) — they get their instructions
640
+ // from the 4-part prompt structure in buildFlowArgs and have no web tool.
641
+ pi.on("before_agent_start", async (event) => {
642
+ if (currentDepth > 0) return undefined;
643
+
644
+ const prompt = event.prompt;
645
+ const hasUrl = looksLikeUrlPrompt(prompt);
646
+ const likelyNeedsWeb = looksLikeWebSearchPrompt(prompt);
647
+
648
+
649
+ const webInstructions: string[] = [];
650
+ if (hasUrl) {
651
+ webInstructions.push(
652
+ "The prompt includes a URL. Use web tool with op: { o: 'fetch', u: '<url>' } before answering about that page.",
653
+ );
654
+ }
655
+ if (likelyNeedsWeb) {
656
+ webInstructions.push(
657
+ "The prompt likely needs external or current info. Prefer web tool with op: [{ o: 'search', q: '<query>' }] over memory.",
658
+ );
659
+ }
660
+
661
+ let systemPrompt = event.systemPrompt;
662
+ if (!toolOptimize && webInstructions.length > 0) {
663
+ systemPrompt +=
664
+ "\n\n## pi-web steering\n" +
665
+ webInstructions.map((line) => `- ${line}`).join("\n");
666
+ }
667
+
668
+ // Append sliding prompt to static system prompt unconditionally.
669
+ systemPrompt += "\n\n" + SLIDING_PROMPT;
670
+
671
+ if (!canDelegate || discoveredFlows.length === 0) {
672
+ return { systemPrompt };
673
+ }
674
+
675
+ return {
676
+ systemPrompt:
677
+ systemPrompt +
678
+ `\n\n## Flows
679
+
680
+ Before acting, reason about whether to dive into a flow:
681
+
682
+ - [scout] — when you need to understand first. Find files, trace code paths, map architecture.
683
+ - [debug] — when something is broken. Investigate logs, errors, stack traces to find root cause.
684
+ - [build] — when you are ready to build. Implement features, fix bugs, write tests.
685
+ - [craft] — when you need a plan. Design structure, break down requirements before building.
686
+ - [audit] — when you need to verify and remediate. Audit security, quality, and correctness; fix safe issues directly.
687
+ - [ideas] — when you need fresh ideas. Use inherited context as background while exploring alternatives.
688
+
689
+ Multiple independent flows? Batch them into one call:
690
+
691
+ ✅ { "flow": [{ "type": "scout", "intent": "..." }, { "type": "audit", "intent": "..." }] }
692
+ ❌ Two separate calls — wastes time
693
+
694
+ Each call renders as:
695
+
696
+ • flow [scout] — Map the full directory structure...
697
+ • flow [audit] — Audit security and quality, then fix safe issues...
698
+
699
+ Each flow returns:
700
+
701
+ flow [type] accomplished
702
+
703
+ [Summary] — what happened and current status
704
+ [Done] — completed work with file:line references and verification results
705
+ [Not Done] — incomplete items, skipped checks, blockers, and reasons
706
+ [Next Steps] — specific recommended follow-up or next flow
707
+
708
+ ### Guards
709
+ - Depth: ${currentDepth}/${maxDepth} | Cycles: ${preventCycles ? "blocked" : "off"} | Stack: ${ancestorFlowStack.length > 0 ? ancestorFlowStack.join(" -> ") : "(root)"}
710
+ `,
711
+ };
712
+ });
713
+
714
+ // Sliding system prompt: insert as a separate system message immediately
715
+ // before the latest user message each turn. Strips from the static
716
+ // systemPrompt to avoid duplication, then inserts separately.
717
+ // Skipped for child flows (depth > 0) — they have explicit <mission> directives.
718
+ pi.on("context", async (event) => {
719
+ if (currentDepth > 0) return undefined;
720
+
721
+ // Always strip old sliding prompt messages to prevent accumulation
722
+ const { messages, changed: messagesChanged } = stripSlidingPromptsFromMessages(event.messages);
723
+
724
+ // Find latest user message
725
+ const userIndices = messages
726
+ .map((m: any, i: number) => (m.role === "user" ? i : -1))
727
+ .filter((i: number) => i !== -1);
728
+
729
+ if (userIndices.length === 0) {
730
+ // No user message yet: keep sliding prompt in the static system prompt only.
731
+ return messagesChanged ? { messages } : undefined;
732
+ }
733
+
734
+ // Strip sliding from the static systemPrompt so it only appears once,
735
+ // as a separate message right before the latest user message.
736
+ let systemPrompt = event.systemPrompt;
737
+ let systemPromptChanged = false;
738
+ if (typeof systemPrompt === "string") {
739
+ const stripped = stripSlidingPromptText(systemPrompt);
740
+ if (stripped !== systemPrompt) {
741
+ systemPrompt = stripped;
742
+ systemPromptChanged = true;
743
+ }
744
+ }
745
+
746
+ const lastUserIndex = userIndices[userIndices.length - 1];
747
+ const modified = [
748
+ ...messages.slice(0, lastUserIndex),
749
+ makeSlidingPromptMessage(messages[lastUserIndex]),
750
+ ...messages.slice(lastUserIndex),
751
+ ];
752
+
753
+ const result: any = { messages: modified };
754
+ if (systemPromptChanged) {
755
+ result.systemPrompt = systemPrompt;
756
+ }
757
+ return result;
758
+ });
759
+
760
+ // Register the web tool
761
+ pi.registerTool(createWebTool());
762
+
763
+ // Register the flow tool
764
+ if (canDelegate) {
765
+ pi.registerTool({
766
+ name: "flow",
767
+ label: "Flow",
768
+ description: [
769
+ "If you cannot answer from your current context, you are forbidden from guessing.",
770
+ "You MUST enter to the following flow states, with tool call method.",
771
+ "",
772
+ "Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
773
+ 'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
774
+ "States: scout, debug, build, craft, audit, ideas.",
775
+ "Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
776
+ ].join("\n"),
777
+ parameters: FlowParams,
778
+
779
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
780
+ const discovery = discoverFlows(ctx.cwd, "all");
781
+ const { flows } = discovery;
782
+ const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
783
+
784
+ const cliFlowModelConfig =
785
+ typeof pi.getFlag("flow-model-config") === "string"
786
+ ? (pi.getFlag("flow-model-config") as string)
787
+ : inheritedCliArgs.flowModelConfig;
788
+ const selectedFlowModelConfig = selectFlowModelStrategy(
789
+ loadedFlowModelConfigs.configs,
790
+ cliFlowModelConfig ?? loadedFlowModelConfigs.selectedName,
791
+ );
792
+
793
+ const getTierOverride = (tier: "lite" | "flash" | "full"): string | undefined => {
794
+ const flagName =
795
+ tier === "lite"
796
+ ? "flow-lite-model"
797
+ : tier === "flash"
798
+ ? "flow-flash-model"
799
+ : "flow-full-model";
800
+ const runtimeValue = pi.getFlag(flagName);
801
+ if (typeof runtimeValue === "string" && runtimeValue.trim()) return runtimeValue.trim();
802
+ const inheritedValue = inheritedCliArgs.tieredModels?.[tier];
803
+ return typeof inheritedValue === "string" && inheritedValue.trim() ? inheritedValue.trim() : undefined;
804
+ };
805
+
806
+ const shouldFailover = (result: SingleResult): boolean => {
807
+ if (result.stopReason === "aborted") return false;
808
+ const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
809
+ if (!text.trim()) return false;
810
+ if (text.includes("permission") || text.includes("invalid tool") || text.includes("bad settings")) {
811
+ return false;
812
+ }
813
+ return result.exitCode > 0;
814
+ };
815
+
816
+ // Build the full fork session snapshot and sanitize only non-inheritable
817
+ // artifacts before passing it to child flows.
818
+ const forkSessionSnapshotJsonl = sanitizeForkSnapshot(
819
+ buildForkSessionSnapshotJsonl(ctx.sessionManager),
820
+ );
821
+
822
+ // Collect all requested flow names
823
+ const requested = new Set<string>(params.flow.map((f: any) => f.type.toLowerCase()));
824
+
825
+ // Cycle check
826
+ if (preventCycles) {
827
+ const violations = getFlowCycleViolations(requested, ancestorFlowStack);
828
+ if (violations.length > 0) {
829
+ const stack = ancestorFlowStack.join(" -> ") || "(root)";
830
+ return {
831
+ content: [{
832
+ type: "text",
833
+ text: `Blocked: cycle detected. Flow(s) in stack: ${violations.join(", ")}\nStack: ${stack}`,
834
+ }],
835
+ details: makeDetails([]),
836
+ isError: true,
837
+ };
838
+ }
839
+ }
840
+
841
+ // Project flow confirmation
842
+ const shouldConfirm = params.confirmProjectFlows ?? true;
843
+ if (shouldConfirm) {
844
+ const projectFlows = getRequestedProjectFlows(flows, requested);
845
+ if (projectFlows.length > 0) {
846
+ if (ctx.hasUI) {
847
+ const ok = await confirmProjectFlowsIfNeeded(projectFlows, discovery.projectFlowsDir, ctx);
848
+ if (!ok) {
849
+ return {
850
+ content: [{ type: "text", text: "Canceled: project-local flows not approved." }],
851
+ details: makeDetails([]),
852
+ };
853
+ }
854
+ } else {
855
+ const names = projectFlows.map((f) => f.name).join(", ");
856
+ return {
857
+ content: [{
858
+ type: "text",
859
+ text: `Blocked: project-local flow confirmation required in non-UI mode.\nFlows: ${names}\nRe-run with confirmProjectFlows: false if trusted.`,
860
+ }],
861
+ details: makeDetails([]),
862
+ isError: true,
863
+ };
864
+ }
865
+ }
866
+ }
867
+
868
+ // Run all flows in parallel
869
+ const allResults: SingleResult[] = new Array(params.flow.length);
870
+ for (let i = 0; i < params.flow.length; i++) {
871
+ allResults[i] = {
872
+ type: params.flow[i].type,
873
+ agentSource: "unknown",
874
+ intent: params.flow[i].intent,
875
+ aim: params.flow[i].aim,
876
+ exitCode: -1,
877
+ messages: [],
878
+ stderr: "",
879
+ usage: emptyFlowUsage(),
880
+ };
881
+ }
882
+
883
+ let lastStreamingText = "";
884
+ let lastEmittedSignature: string | undefined;
885
+ const emitProgress = (streamingText?: string) => {
886
+ if (!onUpdate) return;
887
+ if (streamingText !== undefined) lastStreamingText = streamingText;
888
+ const text = lastStreamingText || "";
889
+ const signature =
890
+ text +
891
+ "|" +
892
+ allResults
893
+ .map(
894
+ (r) =>
895
+ `${r.messages.length}:${r.usage.toolCalls}:${r.usage.input}:${r.usage.output}:${r.usage.contextTokens}:${r.usage.smoothedTps ?? 0}:${r.errorMessage ?? ""}`,
896
+ )
897
+ .join(";");
898
+ if (signature === lastEmittedSignature) return;
899
+ lastEmittedSignature = signature;
900
+ onUpdate({
901
+ content: [{ type: "text", text }],
902
+ details: makeDetails([...allResults]),
903
+ });
904
+ };
905
+
906
+ if (onUpdate) emitProgress();
907
+
908
+ const results = await mapFlowConcurrent(params.flow, 4, async (item: any, index: number) => {
909
+ const normalizedType = item.type.toLowerCase();
910
+ const targetFlow = flows.find((f) => f.name === normalizedType);
911
+ const effectiveMaxDepth =
912
+ targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
913
+
914
+ const shouldInheritContext = targetFlow?.inheritContext !== false;
915
+ const tier = getFlowTier(normalizedType);
916
+ const { candidates } = resolveFlowModelCandidates({
917
+ tier,
918
+ flowModel: targetFlow?.model,
919
+ cliTierOverride: getTierOverride(tier),
920
+ strategy: selectedFlowModelConfig.strategy,
921
+ fallbackModel: inheritedCliArgs.fallbackModel,
922
+ });
923
+ const attemptModels = candidates.length > 0 ? candidates : [undefined];
924
+ const attemptedModels: string[] = [];
925
+ let result = allResults[index];
926
+
927
+ for (let attempt = 0; attempt < attemptModels.length; attempt++) {
928
+ const candidateModel = attemptModels[attempt];
929
+ if (candidateModel) attemptedModels.push(candidateModel);
930
+ result = await runFlow({
931
+ cwd: ctx.cwd,
932
+ flows,
933
+ flowName: normalizedType,
934
+ intent: item.intent,
935
+ aim: item.aim,
936
+ taskCwd: item.cwd,
937
+ forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
938
+ parentDepth: currentDepth,
939
+ parentFlowStack: ancestorFlowStack,
940
+ maxDepth: effectiveMaxDepth,
941
+ preventCycles,
942
+ toolOptimize,
943
+ model: candidateModel,
944
+ signal,
945
+ onUpdate: (partial) => {
946
+ if (partial.details?.results[0]) {
947
+ allResults[index] = partial.details.results[0];
948
+ emitProgress(partial.content?.[0]?.text);
949
+ }
950
+ },
951
+ makeDetails,
952
+ });
953
+ allResults[index] = result;
954
+ emitProgress();
955
+ if (isFlowSuccess(result) || signal?.aborted) break;
956
+ if (attempt < attemptModels.length - 1 && shouldFailover(result)) {
957
+ continue;
958
+ }
959
+ break;
960
+ }
961
+
962
+ if (result && !isFlowSuccess(result) && attemptedModels.length > 1) {
963
+ const summary = `Model failover attempts: ${attemptedModels.join(" -> ")}`;
964
+ const baseStderr = result.stderr.trim();
965
+ result.stderr = baseStderr ? `${baseStderr}\n\n${summary}` : summary;
966
+ allResults[index] = result;
967
+ emitProgress();
968
+ }
969
+
970
+ return result;
971
+ });
972
+
973
+ // Build tool result with FULL flow output — no truncation
974
+ const successCount = results.filter((r) => isFlowSuccess(r)).length;
975
+ const flowReports = results.map((r) => {
976
+ const output = getFlowSummaryText(r);
977
+ const status = isFlowError(r) ? "failed" : "accomplished";
978
+ return `flow [${r.type}] ${status}\n\n${output}`;
979
+ });
980
+
981
+ // Post-flow hooks — inject advisory messages
982
+ const advisors = runHooks(params.flow, results);
983
+ const advisorBlock = advisors.length > 0
984
+ ? "\n\n---\n\n💡 " + advisors.join("\n💡 ")
985
+ : "";
986
+
987
+ return {
988
+ content: [{
989
+ type: "text" as const,
990
+ text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}${advisorBlock}`,
991
+ }],
992
+ details: makeDetails(results),
993
+ };
994
+ },
995
+
996
+ renderCall: (args, theme) => renderFlowCall(args, theme),
997
+ renderResult: (result, { expanded }, theme, args) =>
998
+ renderFlowResult(result, expanded, theme, args),
999
+ });
1000
+ }
1001
+
1002
+ }