pi-prompt-template-model 0.6.5 → 0.6.6

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/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.6.6] - 2026-03-28
4
+
5
+ ### Added
6
+ - Added `--model=provider/model-id` runtime flag to override a template's model for a single invocation. Works with single execution, loops, and delegation.
7
+ - Added `--fork` runtime flag to enable `inheritContext` (forked context) at invocation time. Implies `--subagent` if not already set.
8
+ - Inline loop iterations now include a `[Loop 2/5]` prefix so the agent knows it's in a loop and which iteration it's on. Delegated (subagent) loops are unaffected.
9
+ - Added `loop: unlimited` (and `loop: true`) frontmatter for open-ended loops that run until convergence, user interrupt, or the 999-iteration safety cap.
10
+ - Added model rotation for loop iterations via `rotate: true` frontmatter. Cycles through comma-separated models and thinking levels each iteration instead of using fallback semantics.
11
+
12
+ ### Fixed
13
+ - Pressing Escape during a loop or chain iteration now stops the loop. Previously, aborted inline turns were treated as "no changes" and the loop continued.
14
+ - Delegation errors during loop iterations no longer abort the entire loop. The error is reported and the loop continues to the next iteration (useful with model rotation where one model may fail but others succeed).
15
+ - Per-step bare `--loop` in chain declarations (e.g., `double-check --loop -> deslop`) now correctly runs unlimited iterations instead of running once.
16
+
17
+ ### Changed
18
+ - Unlimited loops (`--loop` bare or `loop: unlimited`) no longer force convergence on. Convergence follows the `converge` field like bounded loops. Safety cap raised from 50 to 999.
4
19
 
5
20
  ## [0.6.5] - 2026-03-24
6
21
 
package/README.md CHANGED
@@ -69,7 +69,8 @@ All fields are optional. Templates that don't use any extension features (no `mo
69
69
  | Field | Default | What it does |
70
70
  |-------|---------|--------------|
71
71
  | `restore` | `true` | After the command finishes, switch back to whatever model and thinking level were active before. Set `false` to stay on the new model. |
72
- | `loop` | — | Run this template multiple times by default (1–999). CLI `--loop` overrides this. See [Loop Execution](#loop-execution). |
72
+ | `loop` | — | Run this template multiple times by default (1–999, `true`, or `unlimited`). CLI `--loop` overrides this. See [Loop Execution](#loop-execution). |
73
+ | `rotate` | `false` | When `true` and looping, cycle through models in the `model` list instead of using fallback semantics. Thinking levels can also be comma-separated to pair with each model. |
73
74
  | `fresh` | `false` | When looping, collapse the conversation between iterations to a brief summary instead of carrying the full context forward. Saves tokens on long loops. |
74
75
  | `converge` | `true` | When looping, stop early if an iteration makes no file changes. Set `false` to always run every iteration. |
75
76
 
@@ -241,6 +242,17 @@ During execution, a live progress widget appears above the editor showing elapse
241
242
 
242
243
  You can override delegation at runtime per invocation with `--subagent`, `--subagent=<name>`, `--subagent:<name>`, or `--cwd=<path>`. `--cwd=<path>` must be absolute after optional `~/...` expansion. Runtime flags take precedence for that invocation only.
243
244
 
245
+ Two additional runtime flags work for any prompt (not just delegated ones):
246
+
247
+ - `--model=provider/model-id` — override the template's `model` for this invocation. Works with single execution, loops, and delegation.
248
+ - `--fork` — run with `inheritContext` (forked context). Implies `--subagent` if not already set.
249
+
250
+ ```
251
+ /double-check --model=anthropic/claude-opus-4-6
252
+ /double-check --fork --subagent:worker
253
+ /deslop --model=openai/gpt-5.4 --loop 3
254
+ ```
255
+
244
256
  ## Loop Execution
245
257
 
246
258
  Run a template multiple times with `--loop`:
@@ -248,7 +260,7 @@ Run a template multiple times with `--loop`:
248
260
  ```
249
261
  /deslop --loop 5
250
262
  /deslop --loop=5
251
- /deslop --loop # unlimited — runs until convergence (50-iteration cap)
263
+ /deslop --loop # unlimited — runs until convergence or cap (999)
252
264
  ```
253
265
 
254
266
  You can also set a default in frontmatter. CLI `--loop` always overrides:
@@ -259,11 +271,22 @@ loop: 5
259
271
  ---
260
272
  ```
261
273
 
274
+ Use `loop: unlimited` (or `loop: true`) for open-ended loops that run until convergence, user interrupt, or the safety cap of 999 iterations:
275
+
276
+ ```markdown
277
+ ---
278
+ loop: unlimited
279
+ converge: false
280
+ fresh: true
281
+ subagent: true
282
+ ---
283
+ ```
284
+
262
285
  ### How looping works
263
286
 
264
287
  Each iteration runs the same prompt. By default, context accumulates — iteration 3 sees the full conversation from iterations 1 and 2 and builds on that work.
265
288
 
266
- **Convergence**: If an iteration makes no file changes (no `write` or `edit` tool calls), the loop stops early. This is on by default. Use `--no-converge` or `converge: false` to always run every iteration. Bare `--loop` (unlimited) always forces convergence on, since its whole purpose is "run until nothing changes."
289
+ **Convergence**: If an iteration makes no file changes (no `write` or `edit` tool calls), the loop stops early. This is on by default. Use `--no-converge` or `converge: false` to always run every iteration.
267
290
 
268
291
  **Fresh context**: Add `--fresh` (or `fresh: true` in frontmatter) to collapse the conversation between iterations. Each iteration gets a clean slate with only brief summaries of what previous iterations did. Good for long loops where accumulated context would blow up the token count.
269
292
 
@@ -271,6 +294,60 @@ Each iteration runs the same prompt. By default, context accumulates — iterati
271
294
 
272
295
  Model, thinking level, and skill are maintained throughout. If `restore: true` (the default), everything is restored after the final iteration.
273
296
 
297
+ ## Model Rotation
298
+
299
+ `rotate: true` turns a comma-separated `model` list from a fallback chain into a cycling list. Each loop iteration uses the next model in the list, wrapping around:
300
+
301
+ ```markdown
302
+ ---
303
+ model: claude-opus-4-6, gpt-5.4, gpt-5.3-codex
304
+ thinking: high, xhigh, off
305
+ loop: 9
306
+ rotate: true
307
+ fresh: true
308
+ ---
309
+ Review and fix issues in this codebase.
310
+ ```
311
+
312
+ Iteration 1 runs Opus + `high`, iteration 2 runs GPT-5.4 + `xhigh`, iteration 3 runs Codex + `off`, then wraps back to Opus. The status bar shows which model is active: `loop 2/9 · gpt-5.4 xhigh`.
313
+
314
+ This is especially useful for [ralph-style loops](https://ghuntley.com/ralph/) where different models catch different things. The `subagent` examples below require [pi-subagents](https://github.com/nicobailon/pi-subagents/). A single-model ralph loop that delegates with fresh context each iteration:
315
+
316
+ ```markdown
317
+ ---
318
+ model: claude-sonnet-4-20250514
319
+ subagent: true
320
+ inheritContext: true
321
+ loop: 5
322
+ fresh: true
323
+ ---
324
+ Simplify this code: $@
325
+ ```
326
+
327
+ Add `rotate` and multiple models to cycle different perspectives on each pass:
328
+
329
+ ```markdown
330
+ ---
331
+ model: claude-opus-4-6, gpt-5.4, gpt-5.3-codex
332
+ thinking: xhigh, high, high
333
+ loop: 9
334
+ rotate: true
335
+ fresh: true
336
+ subagent: true
337
+ ---
338
+ Review and fix issues in this codebase.
339
+ ```
340
+
341
+ Each iteration gets fresh context, a different model, and its own thinking level. Convergence stops the loop when an iteration makes no file changes — use `converge: false` to guarantee every model gets at least one shot.
342
+
343
+ `thinking` pairing with `rotate: true`:
344
+
345
+ - Single value (`thinking: high`) — applied to every model.
346
+ - Comma-separated (`thinking: high, xhigh, off`) — positional, must match the number of models.
347
+ - Omitted — each iteration inherits the session default.
348
+
349
+ Without `loop`, `rotate` has no effect and comma-separated `model` keeps normal fallback behavior.
350
+
274
351
  ## Chaining Templates
275
352
 
276
353
  `/chain-prompts` runs multiple templates in sequence. Each step uses its own model, skill, and thinking level, while conversation context flows between them:
package/args.ts CHANGED
@@ -20,6 +20,8 @@ export interface SubagentOverrideExtraction {
20
20
  args: string;
21
21
  override?: SubagentOverride;
22
22
  cwd?: string;
23
+ model?: string;
24
+ fork?: boolean;
23
25
  }
24
26
 
25
27
  export function extractLoopCount(argsString: string): LoopExtraction | null {
@@ -113,7 +115,7 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
113
115
  cleaned = cleaned.slice(0, start) + cleaned.slice(end);
114
116
  }
115
117
 
116
- const converge = loopCount === null ? true : !noConverge;
118
+ const converge = !noConverge;
117
119
  return { args: cleaned.trim(), loopCount, fresh, converge };
118
120
  }
119
121
 
@@ -210,6 +212,8 @@ export function extractChainContextFlag(argsString: string): { args: string; cha
210
212
  export function extractSubagentOverride(argsString: string): SubagentOverrideExtraction {
211
213
  let override: SubagentOverride | undefined;
212
214
  let cwdRaw: string | undefined;
215
+ let modelRaw: string | undefined;
216
+ let fork = false;
213
217
  const tokensToRemove: Array<{ start: number; end: number }> = [];
214
218
 
215
219
  let i = 0;
@@ -250,6 +254,20 @@ export function extractSubagentOverride(argsString: string): SubagentOverrideExt
250
254
  tokensToRemove.push({ start: tokenStart, end: i });
251
255
  const value = token.slice("--cwd=".length);
252
256
  cwdRaw = value || undefined;
257
+ continue;
258
+ }
259
+
260
+ if (token.startsWith("--model=")) {
261
+ tokensToRemove.push({ start: tokenStart, end: i });
262
+ const value = token.slice("--model=".length);
263
+ modelRaw = value || undefined;
264
+ continue;
265
+ }
266
+
267
+ if (token === "--fork") {
268
+ tokensToRemove.push({ start: tokenStart, end: i });
269
+ fork = true;
270
+ continue;
253
271
  }
254
272
  }
255
273
 
@@ -261,10 +279,14 @@ export function extractSubagentOverride(argsString: string): SubagentOverrideExt
261
279
  cleaned = cleaned.slice(0, start) + cleaned.slice(end);
262
280
  }
263
281
 
282
+ if (fork && !override) override = { enabled: true };
283
+
264
284
  return {
265
285
  args: cleaned.trim(),
266
286
  ...(override ? { override } : {}),
267
287
  ...(cwdRaw !== undefined ? { cwd: cwdRaw } : {}),
288
+ ...(modelRaw !== undefined ? { model: modelRaw } : {}),
289
+ ...(fork ? { fork: true } : {}),
268
290
  };
269
291
  }
270
292
 
package/chain-parser.ts CHANGED
@@ -3,7 +3,7 @@ import { parseCommandArgs } from "./args.js";
3
3
  export interface ChainStep {
4
4
  name: string;
5
5
  args: string[];
6
- loopCount?: number;
6
+ loopCount?: number | null;
7
7
  withContext?: boolean;
8
8
  }
9
9
 
@@ -81,11 +81,11 @@ function scanSegmentTokens(segment: string): SegmentToken[] {
81
81
  return tokens;
82
82
  }
83
83
 
84
- function extractStepFlags(segment: string): { cleanedSegment: string; loopCount?: number; withContext: boolean } {
84
+ function extractStepFlags(segment: string): { cleanedSegment: string; loopCount?: number | null; withContext: boolean } {
85
85
  const tokens = scanSegmentTokens(segment);
86
86
  const loopTokenRanges: Array<{ start: number; end: number }> = [];
87
87
  const withContextTokenRanges: Array<{ start: number; end: number }> = [];
88
- let loopCount: number | undefined;
88
+ let loopCount: number | null | undefined;
89
89
  let withContext = false;
90
90
 
91
91
  for (let i = 1; i < tokens.length; i++) {
@@ -109,17 +109,24 @@ function extractStepFlags(segment: string): { cleanedSegment: string; loopCount?
109
109
  continue;
110
110
  }
111
111
 
112
- if (token.value === "--loop" && i + 1 < tokens.length) {
113
- const next = tokens[i + 1];
114
- if (!next.quoted && /^\d+$/.test(next.value)) {
115
- loopTokenRanges.push({ start: token.start, end: token.end }, { start: next.start, end: next.end });
116
- const parsed = parseInt(next.value, 10);
117
- if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
118
- loopCount = parsed;
112
+ if (token.value === "--loop") {
113
+ loopTokenRanges.push({ start: token.start, end: token.end });
114
+ if (i + 1 < tokens.length) {
115
+ const next = tokens[i + 1];
116
+ if (!next.quoted && /^\d+$/.test(next.value)) {
117
+ loopTokenRanges.push({ start: next.start, end: next.end });
118
+ const parsed = parseInt(next.value, 10);
119
+ if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
120
+ loopCount = parsed;
121
+ }
122
+ i++;
123
+ continue;
119
124
  }
120
- i++;
121
- continue;
122
125
  }
126
+ if (loopCount === undefined) {
127
+ loopCount = null;
128
+ }
129
+ continue;
123
130
  }
124
131
  }
125
132
 
package/index.ts CHANGED
@@ -3,7 +3,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@m
3
3
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
4
4
  import { extractChainContextFlag, extractLoopCount, extractLoopFlags, extractSubagentOverride, parseCommandArgs, type SubagentOverride } from "./args.js";
5
5
  import { parseChainSteps, parseChainDeclaration, type ChainStep, type ChainStepOrParallel, type ParallelChainStep } from "./chain-parser.js";
6
- import { generateChainStepSummary, generateIterationSummary, didIterationMakeChanges, getIterationEntries } from "./loop-utils.js";
6
+ import { generateChainStepSummary, generateIterationSummary, didIterationMakeChanges, getIterationEntries, wasIterationAborted } from "./loop-utils.js";
7
7
  import { notify, summarizePromptDiagnostics, diagnosticsFingerprint } from "./notifications.js";
8
8
  import { preparePromptExecution } from "./prompt-execution.js";
9
9
  import { buildPromptCommandDescription, expandCwdPath, loadPromptsWithModel, readSkillContent, resolveSkillPath, type PromptWithModel } from "./prompt-loader.js";
@@ -16,6 +16,7 @@ import { renderDelegatedSubagentResult } from "./subagent-renderer.js";
16
16
  interface LoopState {
17
17
  currentIteration: number;
18
18
  totalIterations: number | null;
19
+ rotationLabel?: string;
19
20
  }
20
21
 
21
22
  interface FreshCollapse {
@@ -58,7 +59,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
58
59
  let accumulatedSummaries: string[] = [];
59
60
  let lastDiagnostics = "";
60
61
  let storedCommandCtx: ExtensionCommandContext | null = null;
61
- const UNLIMITED_LOOP_CAP = 50;
62
+ const UNLIMITED_LOOP_CAP = 999;
62
63
 
63
64
  const toolManager = createToolManager(pi, {
64
65
  isActive: () => !!(loopState || chainActive),
@@ -196,6 +197,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
196
197
  override?: SubagentOverride,
197
198
  inheritedModel?: Model<any>,
198
199
  taskPreamble?: string,
200
+ loopContext?: string,
199
201
  ): Promise<PromptStepResult | "aborted"> {
200
202
  if (shouldDelegatePrompt(prompt, override)) {
201
203
  try {
@@ -216,7 +218,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
216
218
  return { changed: delegated.changed };
217
219
  } catch (error) {
218
220
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
219
- return "aborted";
221
+ return { changed: false };
220
222
  }
221
223
  }
222
224
 
@@ -258,10 +260,14 @@ export default function promptModelExtension(pi: ExtensionAPI) {
258
260
  pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
259
261
 
260
262
  const startId = ctx.sessionManager.getLeafId();
261
- pi.sendUserMessage(prepared.content);
263
+ const content = loopContext ? `[${loopContext}]\n\n${prepared.content}` : prepared.content;
264
+ pi.sendUserMessage(content);
262
265
  await waitForTurnStart(ctx);
263
266
  await ctx.waitForIdle();
264
- return { changed: didIterationMakeChanges(getIterationEntries(ctx, startId)) };
267
+
268
+ const entries = getIterationEntries(ctx, startId);
269
+ if (wasIterationAborted(entries)) return "aborted";
270
+ return { changed: didIterationMakeChanges(entries) };
265
271
  }
266
272
 
267
273
  async function restoreSessionState(
@@ -353,10 +359,11 @@ export default function promptModelExtension(pi: ExtensionAPI) {
353
359
  function updateLoopStatus(ctx: ExtensionContext) {
354
360
  if (!ctx.hasUI) return;
355
361
  if (loopState) {
362
+ const suffix = loopState.rotationLabel ? ` · ${loopState.rotationLabel}` : "";
356
363
  const label =
357
364
  loopState.totalIterations !== null
358
- ? `loop ${loopState.currentIteration}/${loopState.totalIterations}`
359
- : `loop ${loopState.currentIteration}`;
365
+ ? `loop ${loopState.currentIteration}/${loopState.totalIterations}${suffix}`
366
+ : `loop ${loopState.currentIteration}${suffix}`;
360
367
  ctx.ui.setStatus("prompt-loop", ctx.ui.theme.fg("warning", label));
361
368
  } else {
362
369
  ctx.ui.setStatus("prompt-loop", undefined);
@@ -385,6 +392,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
385
392
  ctx: ExtensionCommandContext,
386
393
  subagentOverride?: SubagentOverride,
387
394
  cwdOverride?: string,
395
+ promptOverrides?: Partial<Pick<PromptWithModel, "models" | "inheritContext">>,
388
396
  ) {
389
397
  refreshPrompts(ctx.cwd, ctx);
390
398
  const initialPrompt = prompts.get(name);
@@ -401,7 +409,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
401
409
  const useFresh = freshFlag || initialPrompt.fresh === true;
402
410
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
403
411
  const isUnlimited = totalIterations === null;
404
- const useConverge = isUnlimited ? true : converge && initialPrompt.converge !== false;
412
+ const useConverge = converge && initialPrompt.converge !== false;
405
413
  const anchorId = useFresh ? ctx.sessionManager.getLeafId() : null;
406
414
 
407
415
  loopState = { currentIteration: 1, totalIterations };
@@ -414,9 +422,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
414
422
  try {
415
423
  for (let i = 0; i < effectiveMax; i++) {
416
424
  loopState.currentIteration = i + 1;
417
- updateLoopStatus(ctx);
418
425
  const iterationLabel = totalIterations !== null ? `${i + 1}/${totalIterations}` : `${i + 1}`;
419
- notify(ctx, `Loop ${iterationLabel}: ${name}`, "info");
420
426
 
421
427
  refreshPrompts(ctx.cwd, ctx);
422
428
  const prompt = prompts.get(name);
@@ -424,15 +430,40 @@ export default function promptModelExtension(pi: ExtensionAPI) {
424
430
  notify(ctx, `Prompt "${name}" no longer exists`, "error");
425
431
  break;
426
432
  }
427
- const effectivePrompt = cwdOverride ? { ...prompt, cwd: cwdOverride } : prompt;
433
+ const effectivePrompt = { ...prompt, ...(cwdOverride ? { cwd: cwdOverride } : {}), ...promptOverrides };
434
+ let iterationPrompt = effectivePrompt;
435
+ loopState!.rotationLabel = undefined;
436
+ if (effectivePrompt.rotate && effectivePrompt.models.length > 1) {
437
+ const rotationIndex = i % effectivePrompt.models.length;
438
+ const rotatedThinking = effectivePrompt.thinkingLevels
439
+ ? effectivePrompt.thinkingLevels[rotationIndex]
440
+ : effectivePrompt.thinking;
441
+ iterationPrompt = {
442
+ ...effectivePrompt,
443
+ models: [effectivePrompt.models[rotationIndex]],
444
+ thinking: rotatedThinking,
445
+ };
446
+ const shortModel = effectivePrompt.models[rotationIndex].split("/").pop() || effectivePrompt.models[rotationIndex];
447
+ const thinkingLabel = rotatedThinking ? ` ${rotatedThinking}` : "";
448
+ loopState!.rotationLabel = `${shortModel}${thinkingLabel}`;
449
+ }
450
+ updateLoopStatus(ctx);
451
+ const rotationSuffix = loopState!.rotationLabel ? ` [${loopState!.rotationLabel}]` : "";
452
+ notify(ctx, `Loop ${iterationLabel}: ${name}${rotationSuffix}`, "info");
428
453
 
454
+ const loopContext = loopState!.rotationLabel
455
+ ? `Loop ${iterationLabel} · ${loopState!.rotationLabel}`
456
+ : `Loop ${iterationLabel}`;
429
457
  const iterationStartId = ctx.sessionManager.getLeafId();
430
458
  const stepResult = await executePromptStep(
431
- effectivePrompt,
459
+ iterationPrompt,
432
460
  parseCommandArgs(cleanedArgs),
433
461
  ctx,
434
462
  currentModel,
435
463
  subagentOverride,
464
+ undefined,
465
+ undefined,
466
+ loopContext,
436
467
  );
437
468
  if (stepResult === "aborted") break;
438
469
 
@@ -440,7 +471,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
440
471
  currentThinking = pi.getThinkingLevel();
441
472
  completedIterations++;
442
473
 
443
- const iterationChanged = shouldDelegatePrompt(effectivePrompt, subagentOverride)
474
+ const iterationChanged = shouldDelegatePrompt(iterationPrompt, subagentOverride)
444
475
  ? stepResult.changed
445
476
  : didIterationMakeChanges(getIterationEntries(ctx, iterationStartId));
446
477
  if (useConverge && (isUnlimited || effectiveMax > 1) && !iterationChanged) {
@@ -567,7 +598,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
567
598
  pendingSkillMessage = undefined;
568
599
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
569
600
  const isUnlimited = totalIterations === null;
570
- const useConverge = isUnlimited ? true : converge;
601
+ const useConverge = converge;
571
602
 
572
603
  const anchorId = fresh ? ctx.sessionManager.getLeafId() : null;
573
604
  const chainStepNames = steps
@@ -612,7 +643,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
612
643
  ...(cwdOverride ? { cwd: cwdOverride } : {}),
613
644
  },
614
645
  stepArgs: step.args,
615
- stepLoop: step.loopCount ?? 1,
646
+ stepLoop: step.loopCount !== undefined ? step.loopCount : 1,
616
647
  stepWithContext: step.withContext === true,
617
648
  },
618
649
  },
@@ -676,7 +707,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
676
707
  }
677
708
 
678
709
  const singleStep = stepTemplate.singleStep;
679
- const stepLoop = singleStep.stepLoop;
710
+ const stepLoopTotal = singleStep.stepLoop;
711
+ const stepLoopMax = stepLoopTotal ?? UNLIMITED_LOOP_CAP;
712
+ const isStepLooping = stepLoopMax > 1;
680
713
  const effectiveArgs = singleStep.stepArgs.length > 0 ? singleStep.stepArgs : sharedArgs;
681
714
  const shouldInjectSummary =
682
715
  shouldDelegatePrompt(singleStep.prompt, subagentOverride) &&
@@ -684,19 +717,23 @@ export default function promptModelExtension(pi: ExtensionAPI) {
684
717
  (chainContextEnabled || singleStep.stepWithContext === true);
685
718
  const outerLoopState = loopState ? { ...loopState } : null;
686
719
  const stepStartId = ctx.sessionManager.getLeafId();
687
- if (stepLoop > 1) {
688
- loopState = { currentIteration: 1, totalIterations: stepLoop };
720
+ if (isStepLooping) {
721
+ loopState = { currentIteration: 1, totalIterations: stepLoopTotal };
689
722
  updateLoopStatus(ctx);
690
723
  }
691
724
 
692
725
  try {
693
- for (let stepIteration = 0; stepIteration < stepLoop; stepIteration++) {
694
- if (stepLoop > 1) {
695
- loopState = { currentIteration: stepIteration + 1, totalIterations: stepLoop };
726
+ for (let stepIteration = 0; stepIteration < stepLoopMax; stepIteration++) {
727
+ if (isStepLooping) {
728
+ loopState = { currentIteration: stepIteration + 1, totalIterations: stepLoopTotal };
696
729
  updateLoopStatus(ctx);
697
730
  }
698
731
 
699
- const iterSuffix = stepLoop > 1 ? ` (iter ${stepIteration + 1}/${stepLoop})` : "";
732
+ const iterSuffix = isStepLooping
733
+ ? stepLoopTotal !== null
734
+ ? ` (iter ${stepIteration + 1}/${stepLoopTotal})`
735
+ : ` (iter ${stepIteration + 1})`
736
+ : "";
700
737
  notify(
701
738
  ctx,
702
739
  `${loopPrefix}Step ${stepNumber}/${templates.length}: ${singleStep.prompt.name}${iterSuffix} ${buildPromptCommandDescription(singleStep.prompt)}`,
@@ -709,6 +746,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
709
746
  ? `[Previous chain steps]\n\n${chainStepSummaries.join("\n\n")}`
710
747
  : undefined;
711
748
 
749
+ const stepLoopContext = isStepLooping
750
+ ? `Step ${stepNumber}/${templates.length}: ${singleStep.prompt.name}${iterSuffix}`
751
+ : undefined;
712
752
  const stepIterationStartId = ctx.sessionManager.getLeafId();
713
753
  const stepResult = await executePromptStep(
714
754
  singleStep.prompt,
@@ -718,6 +758,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
718
758
  subagentOverride,
719
759
  chainInheritedModel,
720
760
  taskPreamble,
761
+ stepLoopContext,
721
762
  );
722
763
  if (stepResult === "aborted") {
723
764
  aborted = true;
@@ -729,12 +770,12 @@ export default function promptModelExtension(pi: ExtensionAPI) {
729
770
 
730
771
  const stepIterationEntries = getIterationEntries(ctx, stepIterationStartId);
731
772
  const stepIterationChanged = didIterationMakeChanges(stepIterationEntries);
732
- if (stepLoop > 1 && singleStep.prompt.converge !== false && !stepIterationChanged) {
773
+ if (isStepLooping && singleStep.prompt.converge !== false && !stepIterationChanged) {
733
774
  break;
734
775
  }
735
776
  }
736
777
  } finally {
737
- if (stepLoop > 1) {
778
+ if (isStepLooping) {
738
779
  loopState = outerLoopState ? { ...outerLoopState } : null;
739
780
  updateLoopStatus(ctx);
740
781
  }
@@ -817,10 +858,12 @@ export default function promptModelExtension(pi: ExtensionAPI) {
817
858
  const argsWithoutSubagent = subagent.args;
818
859
 
819
860
  if (prompt.chain) {
861
+ if (subagent.model) notify(ctx, `--model is not supported on chain prompts (ignored)`, "warning");
862
+ if (subagent.fork) notify(ctx, `--fork is not supported on chain prompts (ignored)`, "warning");
820
863
  const extracted = extractChainContextFlag(argsWithoutSubagent);
821
864
  const chainContextEnabled = extracted.chainContext || prompt.chainContext === "summary";
822
865
  const loop = extractLoopCount(extracted.args);
823
- let totalIterations: number | null = prompt.loop ?? 1;
866
+ let totalIterations: number | null = prompt.loop !== undefined ? prompt.loop : 1;
824
867
  let fresh = false;
825
868
  let converge = true;
826
869
  let cleanedArgs = extracted.args;
@@ -863,19 +906,24 @@ export default function promptModelExtension(pi: ExtensionAPI) {
863
906
  return;
864
907
  }
865
908
 
909
+ const promptOverrides: Partial<Pick<PromptWithModel, "models" | "inheritContext">> = {
910
+ ...(subagent.model ? { models: [subagent.model] } : {}),
911
+ ...(subagent.fork ? { inheritContext: true } : {}),
912
+ };
913
+
866
914
  const loop = extractLoopCount(argsWithoutSubagent);
867
915
  if (loop) {
868
- await runPromptLoop(name, loop.args, loop.loopCount, loop.fresh, loop.converge, ctx, subagent.override, runtimeCwd);
916
+ await runPromptLoop(name, loop.args, loop.loopCount, loop.fresh, loop.converge, ctx, subagent.override, runtimeCwd, promptOverrides);
869
917
  return;
870
918
  }
871
919
 
872
920
  if (prompt.loop !== undefined) {
873
921
  const flags = extractLoopFlags(argsWithoutSubagent);
874
- await runPromptLoop(name, flags.args, prompt.loop, flags.fresh, flags.converge, ctx, subagent.override, runtimeCwd);
922
+ await runPromptLoop(name, flags.args, prompt.loop, flags.fresh, flags.converge, ctx, subagent.override, runtimeCwd, promptOverrides);
875
923
  return;
876
924
  }
877
925
 
878
- const effectivePrompt = runtimeCwd ? { ...prompt, cwd: runtimeCwd } : prompt;
926
+ const effectivePrompt = { ...prompt, ...(runtimeCwd ? { cwd: runtimeCwd } : {}), ...promptOverrides };
879
927
  const savedModel = getCurrentModel(ctx);
880
928
  const savedThinking = pi.getThinkingLevel();
881
929
  const stepResult = await executePromptStep(
package/loop-utils.ts CHANGED
@@ -153,3 +153,11 @@ export function getIterationEntries(ctx: Pick<ExtensionContext, "sessionManager"
153
153
  if (startIdx < 0) return branch;
154
154
  return branch.slice(startIdx + 1);
155
155
  }
156
+
157
+ export function wasIterationAborted(entries: SessionEntry[]): boolean {
158
+ for (const entry of entries) {
159
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
160
+ if ((entry.message as AssistantMessage).stopReason === "aborted") return true;
161
+ }
162
+ return false;
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-prompt-template-model",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "type": "module",
5
5
  "description": "Prompt template model selector extension for pi coding agent",
6
6
  "author": "Nico Bailon",
package/prompt-loader.ts CHANGED
@@ -42,8 +42,10 @@ export interface PromptWithModel {
42
42
  restore: boolean;
43
43
  skill?: string;
44
44
  thinking?: ThinkingLevel;
45
+ thinkingLevels?: ThinkingLevel[];
46
+ rotate?: boolean;
45
47
  fresh?: boolean;
46
- loop?: number;
48
+ loop?: number | null;
47
49
  converge?: boolean;
48
50
  subagent?: true | string;
49
51
  inheritContext?: boolean;
@@ -243,14 +245,43 @@ function normalizeFresh(
243
245
  return false;
244
246
  }
245
247
 
248
+ function normalizeRotate(
249
+ value: unknown,
250
+ filePath: string,
251
+ source: PromptSource,
252
+ diagnostics: PromptLoaderDiagnostic[],
253
+ ): boolean {
254
+ if (value === undefined) return false;
255
+ if (typeof value === "boolean") return value;
256
+ if (typeof value === "string") {
257
+ const normalized = value.trim().toLowerCase();
258
+ if (normalized === "true") return true;
259
+ if (normalized === "false") return false;
260
+ }
261
+
262
+ diagnostics.push(
263
+ createDiagnostic(
264
+ "invalid-rotate",
265
+ filePath,
266
+ source,
267
+ `Using default rotate=false for ${filePath}: frontmatter field "rotate" must be true or false.`,
268
+ ),
269
+ );
270
+ return false;
271
+ }
272
+
246
273
  function normalizeLoop(
247
274
  value: unknown,
248
275
  filePath: string,
249
276
  source: PromptSource,
250
277
  diagnostics: PromptLoaderDiagnostic[],
251
- ): number | undefined {
278
+ ): number | null | undefined {
252
279
  if (value === undefined) return undefined;
253
280
 
281
+ if (value === true || (typeof value === "string" && value.trim().toLowerCase() === "unlimited")) {
282
+ return null;
283
+ }
284
+
254
285
  let normalizedValue: number | undefined;
255
286
  if (typeof value === "number") {
256
287
  normalizedValue = value;
@@ -267,7 +298,7 @@ function normalizeLoop(
267
298
  "invalid-loop",
268
299
  filePath,
269
300
  source,
270
- `Ignoring invalid loop value in ${filePath}: frontmatter field "loop" must be an integer between 1 and 999.`,
301
+ `Ignoring invalid loop value in ${filePath}: frontmatter field "loop" must be an integer between 1 and 999, true, or "unlimited".`,
271
302
  ),
272
303
  );
273
304
  return undefined;
@@ -481,6 +512,48 @@ function normalizeThinking(
481
512
  return undefined;
482
513
  }
483
514
 
515
+ function normalizeThinkingLevels(
516
+ value: unknown,
517
+ modelCount: number,
518
+ filePath: string,
519
+ source: PromptSource,
520
+ diagnostics: PromptLoaderDiagnostic[],
521
+ ): ThinkingLevel[] | undefined {
522
+ if (typeof value !== "string") return undefined;
523
+
524
+ const levels = value
525
+ .split(",")
526
+ .map((item) => item.trim())
527
+ .filter(Boolean);
528
+
529
+ const invalidLevel = levels.find((level) => !(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase()));
530
+ if (invalidLevel) {
531
+ diagnostics.push(
532
+ createDiagnostic(
533
+ "invalid-thinking-levels",
534
+ filePath,
535
+ source,
536
+ `Ignoring invalid thinking level in ${filePath}: ${JSON.stringify(invalidLevel)}.`,
537
+ ),
538
+ );
539
+ return undefined;
540
+ }
541
+
542
+ if (levels.length !== modelCount) {
543
+ diagnostics.push(
544
+ createDiagnostic(
545
+ "invalid-thinking-level-count",
546
+ filePath,
547
+ source,
548
+ `Ignoring comma-separated thinking levels in ${filePath}: expected ${modelCount} entries to match frontmatter field "model".`,
549
+ ),
550
+ );
551
+ return undefined;
552
+ }
553
+
554
+ return levels.map((level) => level.toLowerCase() as ThinkingLevel);
555
+ }
556
+
484
557
  function loadPromptsWithModelFromDir(
485
558
  dir: string,
486
559
  source: PromptSource,
@@ -619,6 +692,7 @@ function loadPromptsWithModelFromDir(
619
692
  const parsedModels = chain ? [] : normalizeModelSpecs(frontmatter.model, fullPath, source, diagnostics);
620
693
  if (!chain && hasModelField && !parsedModels) continue;
621
694
  const models = chain ? [] : (parsedModels ?? []);
695
+ const rotate = chain ? false : normalizeRotate(frontmatter.rotate, fullPath, source, diagnostics);
622
696
 
623
697
  const name = entry.name.slice(0, -3);
624
698
  if (RESERVED_COMMAND_NAMES.has(name)) {
@@ -637,7 +711,15 @@ function loadPromptsWithModelFromDir(
637
711
  const safeCwd = (chain || subagent !== undefined) ? cwd : undefined;
638
712
  const description = normalizeStringField("description", frontmatter.description, fullPath, source, diagnostics) ?? "";
639
713
  const skill = chain ? undefined : normalizeStringField("skill", frontmatter.skill, fullPath, source, diagnostics);
640
- const thinking = chain ? undefined : normalizeThinking(frontmatter.thinking, fullPath, source, diagnostics);
714
+ let thinking: ThinkingLevel | undefined;
715
+ let thinkingLevels: ThinkingLevel[] | undefined;
716
+ if (!chain) {
717
+ if (rotate && typeof frontmatter.thinking === "string" && frontmatter.thinking.includes(",")) {
718
+ thinkingLevels = normalizeThinkingLevels(frontmatter.thinking, models.length, fullPath, source, diagnostics);
719
+ } else {
720
+ thinking = normalizeThinking(frontmatter.thinking, fullPath, source, diagnostics);
721
+ }
722
+ }
641
723
  const restore = normalizeRestore(frontmatter.restore, fullPath, source, diagnostics);
642
724
  const fresh = normalizeFresh(frontmatter.fresh, fullPath, source, diagnostics);
643
725
  const loop = normalizeLoop(frontmatter.loop, fullPath, source, diagnostics);
@@ -666,8 +748,10 @@ function loadPromptsWithModelFromDir(
666
748
  restore,
667
749
  skill,
668
750
  thinking,
751
+ thinkingLevels,
752
+ rotate: rotate || undefined,
669
753
  fresh: fresh || undefined,
670
- loop: loop || undefined,
754
+ loop: loop !== undefined ? loop : undefined,
671
755
  converge: converge === false ? false : undefined,
672
756
  subagent,
673
757
  inheritContext: safeInheritContext || undefined,
@@ -753,13 +837,15 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
753
837
  return prompt.description ? `${prompt.description} ${details}` : details;
754
838
  }
755
839
  const modelLabel = prompt.models.length > 0 ? prompt.models.map((model) => model.split("/").pop() || model).join("|") : "current";
840
+ const rotateLabel = prompt.rotate ? " rotate" : "";
756
841
  const skillLabel = prompt.skill ? ` +${prompt.skill}` : "";
757
- const thinkingLabel = prompt.thinking ? ` ${prompt.thinking}` : "";
758
- const loopLabel = prompt.loop ? ` loop:${prompt.loop}` : "";
842
+ const thinkingValue = prompt.thinkingLevels ? prompt.thinkingLevels.join(",") : prompt.thinking;
843
+ const thinkingLabel = thinkingValue ? ` ${thinkingValue}` : "";
844
+ const loopLabel = prompt.loop !== undefined ? ` loop:${prompt.loop === null ? "unlimited" : prompt.loop}` : "";
759
845
  const subagentLabel = prompt.subagent ? ` subagent:${prompt.subagent === true ? "delegate" : prompt.subagent}` : "";
760
846
  const cwdLabel = prompt.cwd ? ` cwd:${prompt.cwd}` : "";
761
847
  const inheritContextLabel = prompt.inheritContext ? " fork" : "";
762
- const details = `[${modelLabel}${thinkingLabel}${skillLabel}${loopLabel}${subagentLabel}${cwdLabel}${inheritContextLabel}] ${sourceLabel}`;
848
+ const details = `[${modelLabel}${rotateLabel}${thinkingLabel}${skillLabel}${loopLabel}${subagentLabel}${cwdLabel}${inheritContextLabel}] ${sourceLabel}`;
763
849
  return prompt.description ? `${prompt.description} ${details}` : details;
764
850
  }
765
851