pi-agent-flow 1.1.0 → 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.
@@ -7,8 +7,15 @@
7
7
 
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
- import { type FlowConfig, discoverFlows } from "./agents.js";
11
- import { loadFlowModels, loadFlowSettings, type FlowModelConfig } from "./config.js";
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";
12
19
  import { renderFlowCall, renderFlowResult } from "./render.js";
13
20
  import { getFlowSummaryText } from "./runner-events.js";
14
21
  import { runHooks } from "./hooks.js";
@@ -20,7 +27,7 @@ import {
20
27
  isFlowError,
21
28
  isFlowSuccess,
22
29
  } from "./types.js";
23
- import { createBatchTool } from "./batch.js";
30
+ import { createBatchTool, createBatchReadTool } from "./batch.js";
24
31
  import {
25
32
  createWebTool,
26
33
  looksLikeUrlPrompt,
@@ -73,6 +80,8 @@ const FlowParams = Type.Object({
73
80
  ),
74
81
  });
75
82
 
83
+ const inheritedCliArgs = parseFlowCliArgs(process.argv);
84
+
76
85
  // ---------------------------------------------------------------------------
77
86
  // Helpers
78
87
  // ---------------------------------------------------------------------------
@@ -97,23 +106,8 @@ function buildForkSessionSnapshotJsonl(
97
106
  if (!header || typeof header !== "object") return null;
98
107
 
99
108
  const branchEntries = sessionManager.getBranch();
100
-
101
- // Trim the current conversation turn from the fork.
102
- let trimFrom = branchEntries.length;
103
- for (let i = branchEntries.length - 1; i >= 0; i--) {
104
- const entry = branchEntries[i] as Record<string, unknown>;
105
- if (entry?.type === "message") {
106
- const msg = entry.message as Record<string, unknown> | undefined;
107
- if (msg?.role === "user") {
108
- trimFrom = i;
109
- break;
110
- }
111
- }
112
- }
113
-
114
- const trimmedEntries = branchEntries.slice(0, trimFrom);
115
109
  const lines = [JSON.stringify(header)];
116
- for (const entry of trimmedEntries) lines.push(JSON.stringify(entry));
110
+ for (const entry of branchEntries) lines.push(JSON.stringify(entry));
117
111
  return `${lines.join("\n")}\n`;
118
112
  }
119
113
 
@@ -335,46 +329,216 @@ async function confirmProjectFlowsIfNeeded(
335
329
  }
336
330
 
337
331
  // ---------------------------------------------------------------------------
338
- // Reminder flow injection (sliding always on latest user message)
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.
339
336
  // ---------------------------------------------------------------------------
340
337
 
341
- const REMINDER_TEXT =
342
- "\n\n[reminder_flow: If the answer is in context, reply; otherwise, delegate to the appropriate flow.]";
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
+ }
343
358
 
344
- function stripReminder(
359
+ /** Strip sliding prompt tags from content (string or text-part array). */
360
+ function stripSlidingPromptFromContent(
345
361
  content: string | { type: string; text?: string }[],
346
362
  ): string | { type: string; text?: string }[] {
347
363
  if (typeof content === "string") {
348
- return content.replace(REMINDER_TEXT, "");
364
+ return stripSlidingPromptText(content);
349
365
  }
350
366
  return content.map((c) => {
351
367
  if (c.type === "text" && typeof c.text === "string") {
352
- return { ...c, text: c.text.replace(REMINDER_TEXT, "") };
368
+ return { ...c, text: stripSlidingPromptText(c.text) };
353
369
  }
354
370
  return c;
355
371
  });
356
372
  }
357
373
 
358
- function appendReminder(
359
- content: string | { type: string; text?: string }[],
360
- ): string | { type: string; text?: string }[] {
374
+ /** Check whether content (string or text-part array) contains the sliding tag. */
375
+ function contentContainsSlidingTag(content: any): boolean {
361
376
  if (typeof content === "string") {
362
- return content + REMINDER_TEXT;
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
+ }
363
455
  }
364
- const copy = [...content];
365
- const lastTextIdx = copy.map((c, i) => (c.type === "text" ? i : -1)).filter((i) => i !== -1).pop();
366
- if (lastTextIdx === undefined) {
367
- copy.push({ type: "text", text: REMINDER_TEXT });
368
- } else {
369
- const block = copy[lastTextIdx] as { type: "text"; text: string };
370
- copy[lastTextIdx] = { ...block, text: block.text + REMINDER_TEXT };
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
+ }
371
466
  }
372
- return copy;
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`;
373
537
  }
374
538
 
375
539
  function computeActiveTools(optimize: boolean): string[] {
376
540
  return optimize
377
- ? ["batch", "bash", "flow", "web"]
541
+ ? ["batch_read", "bash", "flow"]
378
542
  : ["read", "write", "edit", "batch", "bash", "flow", "web"];
379
543
  }
380
544
 
@@ -391,6 +555,10 @@ export default function (pi: ExtensionAPI) {
391
555
  description: "Block delegating to flows already in the current delegation stack (default: true).",
392
556
  type: "boolean",
393
557
  });
558
+ pi.registerFlag("flow-model-config", {
559
+ description: "Named flow model strategy from settings.json flowModelConfigs.",
560
+ type: "string",
561
+ });
394
562
  pi.registerFlag("flow-lite-model", {
395
563
  description: "Model for lite-tier flows (scout, debug).",
396
564
  type: "string",
@@ -421,13 +589,17 @@ export default function (pi: ExtensionAPI) {
421
589
  }
422
590
 
423
591
  let discoveredFlows: FlowConfig[] = [];
424
- let flowModelConfig: FlowModelConfig = {};
592
+ let loadedFlowModelConfigs: LoadedFlowModelConfigs = {
593
+ selectedName: "default",
594
+ configs: { default: {} },
595
+ strategy: {},
596
+ };
425
597
 
426
598
  // Auto-discover flows on session start
427
599
  pi.on("session_start", async (_event, ctx) => {
428
600
  const discovery = discoverFlows(ctx.cwd, "all");
429
601
  discoveredFlows = discovery.flows;
430
- flowModelConfig = loadFlowModels(ctx.cwd);
602
+ loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
431
603
 
432
604
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
433
605
  const cliFlag = pi.getFlag("tool-optimize");
@@ -450,8 +622,9 @@ export default function (pi: ExtensionAPI) {
450
622
  pi.setActiveTools(computeActiveTools(toolOptimize));
451
623
  }
452
624
 
453
- // Register batch so it is available for both main agent and child flows.
625
+ // Register batch and batch_read so they are available for main agent and child flows.
454
626
  if (toolOptimize) {
627
+ pi.registerTool(createBatchReadTool());
455
628
  pi.registerTool(createBatchTool());
456
629
  }
457
630
  });
@@ -486,14 +659,17 @@ export default function (pi: ExtensionAPI) {
486
659
  }
487
660
 
488
661
  let systemPrompt = event.systemPrompt;
489
- if (webInstructions.length > 0) {
662
+ if (!toolOptimize && webInstructions.length > 0) {
490
663
  systemPrompt +=
491
664
  "\n\n## pi-web steering\n" +
492
665
  webInstructions.map((line) => `- ${line}`).join("\n");
493
666
  }
494
667
 
668
+ // Append sliding prompt to static system prompt unconditionally.
669
+ systemPrompt += "\n\n" + SLIDING_PROMPT;
670
+
495
671
  if (!canDelegate || discoveredFlows.length === 0) {
496
- return webInstructions.length > 0 ? { systemPrompt } : undefined;
672
+ return { systemPrompt };
497
673
  }
498
674
 
499
675
  return {
@@ -507,8 +683,8 @@ Before acting, reason about whether to dive into a flow:
507
683
  - [debug] — when something is broken. Investigate logs, errors, stack traces to find root cause.
508
684
  - [build] — when you are ready to build. Implement features, fix bugs, write tests.
509
685
  - [craft] — when you need a plan. Design structure, break down requirements before building.
510
- - [audit] — when you need to verify. Audit security, quality, correctness.
511
- - [ideas] — when you need fresh ideas. Start from a clean slate with only the intent.
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.
512
688
 
513
689
  Multiple independent flows? Batch them into one call:
514
690
 
@@ -518,16 +694,16 @@ Multiple independent flows? Batch them into one call:
518
694
  Each call renders as:
519
695
 
520
696
  • flow [scout] — Map the full directory structure...
521
- • flow [audit] — Audit security and quality...
697
+ • flow [audit] — Audit security and quality, then fix safe issues...
522
698
 
523
699
  Each flow returns:
524
700
 
525
701
  flow [type] accomplished
526
702
 
527
- [Summary] — what happened
528
- [Done] — completed with file:line references
529
- [Not Done] — incomplete items and reasons
530
- [Next Steps] — recommended follow-up
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
531
707
 
532
708
  ### Guards
533
709
  - Depth: ${currentDepth}/${maxDepth} | Cycles: ${preventCycles ? "blocked" : "off"} | Stack: ${ancestorFlowStack.length > 0 ? ancestorFlowStack.join(" -> ") : "(root)"}
@@ -535,32 +711,50 @@ flow [type] accomplished
535
711
  };
536
712
  });
537
713
 
538
- // Sliding reminder: strip from all earlier user messages, append to latest.
539
- // Skip for child flows they have explicit <mission> instructions and
540
- // injecting the reminder is noise.
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.
541
718
  pi.on("context", async (event) => {
542
719
  if (currentDepth > 0) return undefined;
543
720
 
544
- const messages = event.messages;
721
+ // Always strip old sliding prompt messages to prevent accumulation
722
+ const { messages, changed: messagesChanged } = stripSlidingPromptsFromMessages(event.messages);
723
+
724
+ // Find latest user message
545
725
  const userIndices = messages
546
726
  .map((m: any, i: number) => (m.role === "user" ? i : -1))
547
727
  .filter((i: number) => i !== -1);
548
728
 
549
- if (userIndices.length === 0) return undefined;
550
-
551
- const lastUserIndex = userIndices[userIndices.length - 1];
552
-
553
- const modified = messages.map((msg: any, idx: number) => {
554
- if (msg.role !== "user") return msg;
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
+ }
555
733
 
556
- const content = stripReminder(msg.content);
557
- if (idx === lastUserIndex) {
558
- return { ...msg, content: appendReminder(content) };
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;
559
743
  }
560
- return { ...msg, content };
561
- });
744
+ }
562
745
 
563
- return { messages: modified };
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;
564
758
  });
565
759
 
566
760
  // Register the web tool
@@ -577,7 +771,7 @@ flow [type] accomplished
577
771
  "",
578
772
  "Flow states are isolated \u03c0 processes with forked session snapshots. They run in parallel.",
579
773
  'Invoke: { "flow": [{ "type": "scout", "intent": "..." }, ...] }',
580
- "States: scout (tanken), debug (kensh\u014d), build (shokunin), craft (keikaku), audit (kanshi), ideas (mushin).",
774
+ "States: scout, debug, build, craft, audit, ideas.",
581
775
  "Custom states configs in (create if not exists): .md files in .pi/agents/ or ~/.pi/agent/agents/.",
582
776
  ].join("\n"),
583
777
  parameters: FlowParams,
@@ -587,16 +781,42 @@ flow [type] accomplished
587
781
  const { flows } = discovery;
588
782
  const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
589
783
 
590
- // Resolve tiered models: CLI flags override settings.json overrides parent --model
591
- const tieredModels = {
592
- lite: (pi.getFlag("flow-lite-model") as string | undefined) ?? flowModelConfig.lite,
593
- flash: (pi.getFlag("flow-flash-model") as string | undefined) ?? flowModelConfig.flash,
594
- full: (pi.getFlag("flow-full-model") as string | undefined) ?? flowModelConfig.full,
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;
595
804
  };
596
805
 
597
- // Build fork session snapshot (shared across all flows that inherit context)
598
- const forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
599
- ctx.sessionManager,
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),
600
820
  );
601
821
 
602
822
  // Collect all requested flow names
@@ -661,13 +881,22 @@ flow [type] accomplished
661
881
  }
662
882
 
663
883
  let lastStreamingText = "";
664
- let lastEmittedText: string | undefined;
884
+ let lastEmittedSignature: string | undefined;
665
885
  const emitProgress = (streamingText?: string) => {
666
886
  if (!onUpdate) return;
667
887
  if (streamingText !== undefined) lastStreamingText = streamingText;
668
888
  const text = lastStreamingText || "";
669
- if (text === lastEmittedText) return;
670
- lastEmittedText = text;
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;
671
900
  onUpdate({
672
901
  content: [{ type: "text", text }],
673
902
  details: makeDetails([...allResults]),
@@ -683,31 +912,61 @@ flow [type] accomplished
683
912
  targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
684
913
 
685
914
  const shouldInheritContext = targetFlow?.inheritContext !== false;
686
- const result = await runFlow({
687
- cwd: ctx.cwd,
688
- flows,
689
- flowName: normalizedType,
690
- intent: item.intent,
691
- aim: item.aim,
692
- taskCwd: item.cwd,
693
- forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
694
- parentDepth: currentDepth,
695
- parentFlowStack: ancestorFlowStack,
696
- maxDepth: effectiveMaxDepth,
697
- preventCycles,
698
- toolOptimize,
699
- tieredModels,
700
- signal,
701
- onUpdate: (partial) => {
702
- if (partial.details?.results[0]) {
703
- allResults[index] = partial.details.results[0];
704
- emitProgress(partial.content?.[0]?.text);
705
- }
706
- },
707
- makeDetails,
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,
708
922
  });
709
- allResults[index] = result;
710
- emitProgress();
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
+
711
970
  return result;
712
971
  });
713
972
 
@@ -50,8 +50,8 @@ export function formatCompactStats(usage: Partial<UsageStats>, model?: string, m
50
50
  parts.push(`tps: ${formatTps(usage.smoothedTps)}`);
51
51
  parts.push(`ctx: ${formatFixedTokens(usage.contextTokens || 0)}`);
52
52
 
53
- let result = parts.join(" · ") + (model ? ` · ${model}` : "");
54
-
53
+ const displayModel = model ? model.replace(/^[^/]+\//, "") : undefined;
54
+ let result = parts.join(" · ") + (displayModel ? ` · ${displayModel}` : "");
55
55
  if (maxWidth && visibleLength(result) > maxWidth) {
56
56
  // Drop model first
57
57
  let narrow = parts.join(" · ");