pi-prompt-template-model 0.6.5 → 0.6.7

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,31 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.6.7] - 2026-03-28
4
+
5
+ ### Changed
6
+ - Delegation progress widget now shows a unified tool stream where completed tools scroll chronologically with the active tool highlighted at the bottom, replacing the old design where the active tool flashed at the top and disappeared on completion.
7
+ - Delegation progress widget now renders the subagent's model name in the header (e.g., `delegate [fork] gpt-5.3-codex | 14 tools, 170k tok, 2m47s`).
8
+ - Delegation progress widget no longer caps tool history or output lines. The box grows to show the full execution trace.
9
+ - Delegation progress widget now refreshes every second during idle periods (model thinking) so the elapsed timer ticks smoothly instead of freezing between progress updates.
10
+ - Enriched the delegation bridge protocol to pass through full `recentOutputLines`, `recentTools` history, and `model` from pi-subagents progress data, replacing the old single-line `recentOutput` and missing tool/model fields.
11
+ - Removed `lastTool`/`lastToolArgs` tracking from live state (dead code after the unified tool stream redesign).
12
+
13
+ ## [0.6.6] - 2026-03-28
14
+
15
+ ### Added
16
+ - Added `--model=provider/model-id` runtime flag to override a template's model for a single invocation. Works with single execution, loops, and delegation.
17
+ - Added `--fork` runtime flag to enable `inheritContext` (forked context) at invocation time. Implies `--subagent` if not already set.
18
+ - 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.
19
+ - Added `loop: unlimited` (and `loop: true`) frontmatter for open-ended loops that run until convergence, user interrupt, or the 999-iteration safety cap.
20
+ - 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.
21
+
22
+ ### Fixed
23
+ - 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.
24
+ - 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).
25
+ - Per-step bare `--loop` in chain declarations (e.g., `double-check --loop -> deslop`) now correctly runs unlimited iterations instead of running once.
26
+
27
+ ### Changed
28
+ - 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
29
 
5
30
  ## [0.6.5] - 2026-03-24
6
31
 
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),
@@ -131,9 +132,12 @@ export default function promptModelExtension(pi: ExtensionAPI) {
131
132
 
132
133
  for (const command of pi.getCommands()) {
133
134
  if (command.source !== "skill") continue;
134
- if (!command.sourceInfo.path) continue;
135
+ const sourceInfo = "sourceInfo" in command
136
+ ? (command as { sourceInfo?: { path?: string } }).sourceInfo
137
+ : undefined;
138
+ if (!sourceInfo?.path) continue;
135
139
  if (!candidates.has(command.name)) continue;
136
- return command.sourceInfo.path;
140
+ return sourceInfo.path;
137
141
  }
138
142
 
139
143
  return undefined;
@@ -196,6 +200,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
196
200
  override?: SubagentOverride,
197
201
  inheritedModel?: Model<any>,
198
202
  taskPreamble?: string,
203
+ loopContext?: string,
199
204
  ): Promise<PromptStepResult | "aborted"> {
200
205
  if (shouldDelegatePrompt(prompt, override)) {
201
206
  try {
@@ -216,7 +221,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
216
221
  return { changed: delegated.changed };
217
222
  } catch (error) {
218
223
  notify(ctx, error instanceof Error ? error.message : String(error), "error");
219
- return "aborted";
224
+ return { changed: false };
220
225
  }
221
226
  }
222
227
 
@@ -258,10 +263,14 @@ export default function promptModelExtension(pi: ExtensionAPI) {
258
263
  pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
259
264
 
260
265
  const startId = ctx.sessionManager.getLeafId();
261
- pi.sendUserMessage(prepared.content);
266
+ const content = loopContext ? `[${loopContext}]\n\n${prepared.content}` : prepared.content;
267
+ pi.sendUserMessage(content);
262
268
  await waitForTurnStart(ctx);
263
269
  await ctx.waitForIdle();
264
- return { changed: didIterationMakeChanges(getIterationEntries(ctx, startId)) };
270
+
271
+ const entries = getIterationEntries(ctx, startId);
272
+ if (wasIterationAborted(entries)) return "aborted";
273
+ return { changed: didIterationMakeChanges(entries) };
265
274
  }
266
275
 
267
276
  async function restoreSessionState(
@@ -353,10 +362,11 @@ export default function promptModelExtension(pi: ExtensionAPI) {
353
362
  function updateLoopStatus(ctx: ExtensionContext) {
354
363
  if (!ctx.hasUI) return;
355
364
  if (loopState) {
365
+ const suffix = loopState.rotationLabel ? ` · ${loopState.rotationLabel}` : "";
356
366
  const label =
357
367
  loopState.totalIterations !== null
358
- ? `loop ${loopState.currentIteration}/${loopState.totalIterations}`
359
- : `loop ${loopState.currentIteration}`;
368
+ ? `loop ${loopState.currentIteration}/${loopState.totalIterations}${suffix}`
369
+ : `loop ${loopState.currentIteration}${suffix}`;
360
370
  ctx.ui.setStatus("prompt-loop", ctx.ui.theme.fg("warning", label));
361
371
  } else {
362
372
  ctx.ui.setStatus("prompt-loop", undefined);
@@ -385,6 +395,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
385
395
  ctx: ExtensionCommandContext,
386
396
  subagentOverride?: SubagentOverride,
387
397
  cwdOverride?: string,
398
+ promptOverrides?: Partial<Pick<PromptWithModel, "models" | "inheritContext">>,
388
399
  ) {
389
400
  refreshPrompts(ctx.cwd, ctx);
390
401
  const initialPrompt = prompts.get(name);
@@ -401,7 +412,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
401
412
  const useFresh = freshFlag || initialPrompt.fresh === true;
402
413
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
403
414
  const isUnlimited = totalIterations === null;
404
- const useConverge = isUnlimited ? true : converge && initialPrompt.converge !== false;
415
+ const useConverge = converge && initialPrompt.converge !== false;
405
416
  const anchorId = useFresh ? ctx.sessionManager.getLeafId() : null;
406
417
 
407
418
  loopState = { currentIteration: 1, totalIterations };
@@ -414,9 +425,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
414
425
  try {
415
426
  for (let i = 0; i < effectiveMax; i++) {
416
427
  loopState.currentIteration = i + 1;
417
- updateLoopStatus(ctx);
418
428
  const iterationLabel = totalIterations !== null ? `${i + 1}/${totalIterations}` : `${i + 1}`;
419
- notify(ctx, `Loop ${iterationLabel}: ${name}`, "info");
420
429
 
421
430
  refreshPrompts(ctx.cwd, ctx);
422
431
  const prompt = prompts.get(name);
@@ -424,15 +433,40 @@ export default function promptModelExtension(pi: ExtensionAPI) {
424
433
  notify(ctx, `Prompt "${name}" no longer exists`, "error");
425
434
  break;
426
435
  }
427
- const effectivePrompt = cwdOverride ? { ...prompt, cwd: cwdOverride } : prompt;
436
+ const effectivePrompt = { ...prompt, ...(cwdOverride ? { cwd: cwdOverride } : {}), ...promptOverrides };
437
+ let iterationPrompt = effectivePrompt;
438
+ loopState!.rotationLabel = undefined;
439
+ if (effectivePrompt.rotate && effectivePrompt.models.length > 1) {
440
+ const rotationIndex = i % effectivePrompt.models.length;
441
+ const rotatedThinking = effectivePrompt.thinkingLevels
442
+ ? effectivePrompt.thinkingLevels[rotationIndex]
443
+ : effectivePrompt.thinking;
444
+ iterationPrompt = {
445
+ ...effectivePrompt,
446
+ models: [effectivePrompt.models[rotationIndex]],
447
+ thinking: rotatedThinking,
448
+ };
449
+ const shortModel = effectivePrompt.models[rotationIndex].split("/").pop() || effectivePrompt.models[rotationIndex];
450
+ const thinkingLabel = rotatedThinking ? ` ${rotatedThinking}` : "";
451
+ loopState!.rotationLabel = `${shortModel}${thinkingLabel}`;
452
+ }
453
+ updateLoopStatus(ctx);
454
+ const rotationSuffix = loopState!.rotationLabel ? ` [${loopState!.rotationLabel}]` : "";
455
+ notify(ctx, `Loop ${iterationLabel}: ${name}${rotationSuffix}`, "info");
428
456
 
457
+ const loopContext = loopState!.rotationLabel
458
+ ? `Loop ${iterationLabel} · ${loopState!.rotationLabel}`
459
+ : `Loop ${iterationLabel}`;
429
460
  const iterationStartId = ctx.sessionManager.getLeafId();
430
461
  const stepResult = await executePromptStep(
431
- effectivePrompt,
462
+ iterationPrompt,
432
463
  parseCommandArgs(cleanedArgs),
433
464
  ctx,
434
465
  currentModel,
435
466
  subagentOverride,
467
+ undefined,
468
+ undefined,
469
+ loopContext,
436
470
  );
437
471
  if (stepResult === "aborted") break;
438
472
 
@@ -440,7 +474,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
440
474
  currentThinking = pi.getThinkingLevel();
441
475
  completedIterations++;
442
476
 
443
- const iterationChanged = shouldDelegatePrompt(effectivePrompt, subagentOverride)
477
+ const iterationChanged = shouldDelegatePrompt(iterationPrompt, subagentOverride)
444
478
  ? stepResult.changed
445
479
  : didIterationMakeChanges(getIterationEntries(ctx, iterationStartId));
446
480
  if (useConverge && (isUnlimited || effectiveMax > 1) && !iterationChanged) {
@@ -567,7 +601,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
567
601
  pendingSkillMessage = undefined;
568
602
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
569
603
  const isUnlimited = totalIterations === null;
570
- const useConverge = isUnlimited ? true : converge;
604
+ const useConverge = converge;
571
605
 
572
606
  const anchorId = fresh ? ctx.sessionManager.getLeafId() : null;
573
607
  const chainStepNames = steps
@@ -612,7 +646,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
612
646
  ...(cwdOverride ? { cwd: cwdOverride } : {}),
613
647
  },
614
648
  stepArgs: step.args,
615
- stepLoop: step.loopCount ?? 1,
649
+ stepLoop: step.loopCount !== undefined ? step.loopCount : 1,
616
650
  stepWithContext: step.withContext === true,
617
651
  },
618
652
  },
@@ -676,7 +710,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
676
710
  }
677
711
 
678
712
  const singleStep = stepTemplate.singleStep;
679
- const stepLoop = singleStep.stepLoop;
713
+ const stepLoopTotal = singleStep.stepLoop;
714
+ const stepLoopMax = stepLoopTotal ?? UNLIMITED_LOOP_CAP;
715
+ const isStepLooping = stepLoopMax > 1;
680
716
  const effectiveArgs = singleStep.stepArgs.length > 0 ? singleStep.stepArgs : sharedArgs;
681
717
  const shouldInjectSummary =
682
718
  shouldDelegatePrompt(singleStep.prompt, subagentOverride) &&
@@ -684,19 +720,23 @@ export default function promptModelExtension(pi: ExtensionAPI) {
684
720
  (chainContextEnabled || singleStep.stepWithContext === true);
685
721
  const outerLoopState = loopState ? { ...loopState } : null;
686
722
  const stepStartId = ctx.sessionManager.getLeafId();
687
- if (stepLoop > 1) {
688
- loopState = { currentIteration: 1, totalIterations: stepLoop };
723
+ if (isStepLooping) {
724
+ loopState = { currentIteration: 1, totalIterations: stepLoopTotal };
689
725
  updateLoopStatus(ctx);
690
726
  }
691
727
 
692
728
  try {
693
- for (let stepIteration = 0; stepIteration < stepLoop; stepIteration++) {
694
- if (stepLoop > 1) {
695
- loopState = { currentIteration: stepIteration + 1, totalIterations: stepLoop };
729
+ for (let stepIteration = 0; stepIteration < stepLoopMax; stepIteration++) {
730
+ if (isStepLooping) {
731
+ loopState = { currentIteration: stepIteration + 1, totalIterations: stepLoopTotal };
696
732
  updateLoopStatus(ctx);
697
733
  }
698
734
 
699
- const iterSuffix = stepLoop > 1 ? ` (iter ${stepIteration + 1}/${stepLoop})` : "";
735
+ const iterSuffix = isStepLooping
736
+ ? stepLoopTotal !== null
737
+ ? ` (iter ${stepIteration + 1}/${stepLoopTotal})`
738
+ : ` (iter ${stepIteration + 1})`
739
+ : "";
700
740
  notify(
701
741
  ctx,
702
742
  `${loopPrefix}Step ${stepNumber}/${templates.length}: ${singleStep.prompt.name}${iterSuffix} ${buildPromptCommandDescription(singleStep.prompt)}`,
@@ -709,6 +749,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
709
749
  ? `[Previous chain steps]\n\n${chainStepSummaries.join("\n\n")}`
710
750
  : undefined;
711
751
 
752
+ const stepLoopContext = isStepLooping
753
+ ? `Step ${stepNumber}/${templates.length}: ${singleStep.prompt.name}${iterSuffix}`
754
+ : undefined;
712
755
  const stepIterationStartId = ctx.sessionManager.getLeafId();
713
756
  const stepResult = await executePromptStep(
714
757
  singleStep.prompt,
@@ -718,6 +761,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
718
761
  subagentOverride,
719
762
  chainInheritedModel,
720
763
  taskPreamble,
764
+ stepLoopContext,
721
765
  );
722
766
  if (stepResult === "aborted") {
723
767
  aborted = true;
@@ -729,12 +773,12 @@ export default function promptModelExtension(pi: ExtensionAPI) {
729
773
 
730
774
  const stepIterationEntries = getIterationEntries(ctx, stepIterationStartId);
731
775
  const stepIterationChanged = didIterationMakeChanges(stepIterationEntries);
732
- if (stepLoop > 1 && singleStep.prompt.converge !== false && !stepIterationChanged) {
776
+ if (isStepLooping && singleStep.prompt.converge !== false && !stepIterationChanged) {
733
777
  break;
734
778
  }
735
779
  }
736
780
  } finally {
737
- if (stepLoop > 1) {
781
+ if (isStepLooping) {
738
782
  loopState = outerLoopState ? { ...outerLoopState } : null;
739
783
  updateLoopStatus(ctx);
740
784
  }
@@ -817,10 +861,12 @@ export default function promptModelExtension(pi: ExtensionAPI) {
817
861
  const argsWithoutSubagent = subagent.args;
818
862
 
819
863
  if (prompt.chain) {
864
+ if (subagent.model) notify(ctx, `--model is not supported on chain prompts (ignored)`, "warning");
865
+ if (subagent.fork) notify(ctx, `--fork is not supported on chain prompts (ignored)`, "warning");
820
866
  const extracted = extractChainContextFlag(argsWithoutSubagent);
821
867
  const chainContextEnabled = extracted.chainContext || prompt.chainContext === "summary";
822
868
  const loop = extractLoopCount(extracted.args);
823
- let totalIterations: number | null = prompt.loop ?? 1;
869
+ let totalIterations: number | null = prompt.loop !== undefined ? prompt.loop : 1;
824
870
  let fresh = false;
825
871
  let converge = true;
826
872
  let cleanedArgs = extracted.args;
@@ -863,19 +909,24 @@ export default function promptModelExtension(pi: ExtensionAPI) {
863
909
  return;
864
910
  }
865
911
 
912
+ const promptOverrides: Partial<Pick<PromptWithModel, "models" | "inheritContext">> = {
913
+ ...(subagent.model ? { models: [subagent.model] } : {}),
914
+ ...(subagent.fork ? { inheritContext: true } : {}),
915
+ };
916
+
866
917
  const loop = extractLoopCount(argsWithoutSubagent);
867
918
  if (loop) {
868
- await runPromptLoop(name, loop.args, loop.loopCount, loop.fresh, loop.converge, ctx, subagent.override, runtimeCwd);
919
+ await runPromptLoop(name, loop.args, loop.loopCount, loop.fresh, loop.converge, ctx, subagent.override, runtimeCwd, promptOverrides);
869
920
  return;
870
921
  }
871
922
 
872
923
  if (prompt.loop !== undefined) {
873
924
  const flags = extractLoopFlags(argsWithoutSubagent);
874
- await runPromptLoop(name, flags.args, prompt.loop, flags.fresh, flags.converge, ctx, subagent.override, runtimeCwd);
925
+ await runPromptLoop(name, flags.args, prompt.loop, flags.fresh, flags.converge, ctx, subagent.override, runtimeCwd, promptOverrides);
875
926
  return;
876
927
  }
877
928
 
878
- const effectivePrompt = runtimeCwd ? { ...prompt, cwd: runtimeCwd } : prompt;
929
+ const effectivePrompt = { ...prompt, ...(runtimeCwd ? { cwd: runtimeCwd } : {}), ...promptOverrides };
879
930
  const savedModel = getCurrentModel(ctx);
880
931
  const savedThinking = pi.getThinkingLevel();
881
932
  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.7",
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
 
@@ -52,6 +52,9 @@ export interface DelegatedSubagentUpdate {
52
52
  currentTool?: string;
53
53
  currentToolArgs?: string;
54
54
  recentOutput?: string;
55
+ recentOutputLines?: string[];
56
+ recentTools?: Array<{ tool: string; args: string }>;
57
+ model?: string;
55
58
  toolCount?: number;
56
59
  durationMs?: number;
57
60
  tokens?: number;
@@ -65,6 +68,9 @@ export interface DelegatedSubagentTaskProgress {
65
68
  currentTool?: string;
66
69
  currentToolArgs?: string;
67
70
  recentOutput?: string;
71
+ recentOutputLines?: string[];
72
+ recentTools?: Array<{ tool: string; args: string }>;
73
+ model?: string;
68
74
  toolCount?: number;
69
75
  durationMs?: number;
70
76
  tokens?: number;
@@ -74,9 +80,9 @@ export interface DelegatedSubagentLiveState {
74
80
  status?: string;
75
81
  currentTool?: string;
76
82
  currentToolArgs?: string;
77
- lastTool?: string;
78
- lastToolArgs?: string;
79
83
  recentOutput: string[];
84
+ recentTools: Array<{ tool: string; args: string }>;
85
+ model?: string;
80
86
  toolCount: number;
81
87
  durationMs: number;
82
88
  tokens: number;
@@ -151,6 +157,7 @@ export function updateDelegatedLiveState(requestId: string, update: Partial<Dele
151
157
  const now = Date.now();
152
158
  const existing = delegatedLiveState.get(requestId) ?? {
153
159
  recentOutput: [],
160
+ recentTools: [],
154
161
  toolCount: 0,
155
162
  durationMs: 0,
156
163
  tokens: 0,
@@ -158,21 +165,16 @@ export function updateDelegatedLiveState(requestId: string, update: Partial<Dele
158
165
  startedAt: now,
159
166
  updatedAt: now,
160
167
  };
161
- // When a tool finishes (currentTool goes undefined), preserve it as lastTool
162
- const toolJustCleared = update.currentTool === undefined && existing.currentTool !== undefined;
163
- const lastTool = toolJustCleared ? existing.currentTool : (update.currentTool ?? existing.lastTool);
164
- const lastToolArgs = toolJustCleared ? existing.currentToolArgs : (update.currentToolArgs ?? existing.lastToolArgs);
165
-
166
168
  const next: DelegatedSubagentLiveState = {
167
169
  ...existing,
168
170
  ...update,
169
171
  recentOutput: update.recentOutput ?? existing.recentOutput,
172
+ recentTools: update.recentTools ?? existing.recentTools,
173
+ model: update.model ?? existing.model,
170
174
  toolCount: update.toolCount ?? existing.toolCount,
171
175
  durationMs: update.durationMs ?? (now - existing.startedAt),
172
176
  tokens: update.tokens ?? existing.tokens,
173
177
  taskProgress: update.taskProgress ?? existing.taskProgress,
174
- lastTool,
175
- lastToolArgs,
176
178
  startedAt: existing.startedAt,
177
179
  updatedAt: now,
178
180
  };
@@ -184,6 +186,7 @@ export function appendDelegatedLiveOutput(requestId: string, line?: string): voi
184
186
  const fallbackNow = Date.now();
185
187
  const existing = delegatedLiveState.get(requestId) ?? {
186
188
  recentOutput: [],
189
+ recentTools: [],
187
190
  toolCount: 0,
188
191
  durationMs: 0,
189
192
  tokens: 0,
@@ -191,7 +194,7 @@ export function appendDelegatedLiveOutput(requestId: string, line?: string): voi
191
194
  startedAt: fallbackNow,
192
195
  updatedAt: fallbackNow,
193
196
  };
194
- const recentOutput = [...existing.recentOutput, line].slice(-12);
197
+ const recentOutput = [...existing.recentOutput, line];
195
198
  delegatedLiveState.set(requestId, {
196
199
  ...existing,
197
200
  recentOutput,
package/subagent-step.ts CHANGED
@@ -204,6 +204,9 @@ function formatProgressStatus(update: DelegatedSubagentUpdate): string | undefin
204
204
  if (update.currentTool) {
205
205
  return `running ${update.currentTool}${update.currentToolArgs ? ` ${update.currentToolArgs}` : ""}`;
206
206
  }
207
+ if (update.taskProgress?.some((task) => task.status === "running")) {
208
+ return "running";
209
+ }
207
210
  if (update.toolCount && update.toolCount > 0) {
208
211
  return `completed ${update.toolCount} tool${update.toolCount === 1 ? "" : "s"}`;
209
212
  }
@@ -216,6 +219,38 @@ function formatParallelProgressStatus(update: DelegatedSubagentUpdate): string |
216
219
  return `parallel ${completed}/${update.taskProgress.length} running`;
217
220
  }
218
221
 
222
+ function hasOwn<T extends object>(value: T, key: PropertyKey): boolean {
223
+ return Object.prototype.hasOwnProperty.call(value, key);
224
+ }
225
+
226
+ function sanitizeOutputLines(lines: string[] | undefined): string[] {
227
+ if (!lines || lines.length === 0) return [];
228
+ return lines.filter((line): line is string => typeof line === "string" && line.trim() && line.trim() !== "(running...)");
229
+ }
230
+
231
+ function collectNewOutputLines(previous: string[] | undefined, next: string[] | undefined): string[] {
232
+ const previousLines = sanitizeOutputLines(previous);
233
+ const nextLines = sanitizeOutputLines(next);
234
+ if (nextLines.length === 0) return [];
235
+ if (previousLines.length === 0) return nextLines;
236
+
237
+ const overlapLimit = Math.min(previousLines.length, nextLines.length);
238
+ for (let overlap = overlapLimit; overlap > 0; overlap--) {
239
+ let matches = true;
240
+ for (let index = 0; index < overlap; index++) {
241
+ if (previousLines[previousLines.length - overlap + index] !== nextLines[index]) {
242
+ matches = false;
243
+ break;
244
+ }
245
+ }
246
+ if (matches) {
247
+ return nextLines.slice(overlap);
248
+ }
249
+ }
250
+
251
+ return nextLines;
252
+ }
253
+
219
254
  function mergeTaskProgress(
220
255
  requestTasks: DelegatedSubagentTask[] | undefined,
221
256
  existingProgress: DelegatedSubagentTaskProgress[] | undefined,
@@ -235,6 +270,9 @@ function mergeTaskProgress(
235
270
  currentTool: existing?.currentTool,
236
271
  currentToolArgs: existing?.currentToolArgs,
237
272
  recentOutput: existing?.recentOutput,
273
+ recentOutputLines: existing?.recentOutputLines,
274
+ recentTools: existing?.recentTools,
275
+ model: existing?.model ?? task.model,
238
276
  toolCount: existing?.toolCount,
239
277
  durationMs: existing?.durationMs,
240
278
  tokens: existing?.tokens,
@@ -255,11 +293,20 @@ function mergeTaskProgress(
255
293
  }
256
294
  if (targetIndex < 0) continue;
257
295
  consumed.add(targetIndex);
296
+ const current = merged[targetIndex]!;
258
297
  merged[targetIndex] = {
259
- ...merged[targetIndex],
260
- ...entry,
261
298
  index: targetIndex,
262
- agent: merged[targetIndex]!.agent,
299
+ agent: current.agent,
300
+ status: entry.status ?? current.status,
301
+ currentTool: hasOwn(entry, "currentTool") ? entry.currentTool : current.currentTool,
302
+ currentToolArgs: hasOwn(entry, "currentToolArgs") ? entry.currentToolArgs : current.currentToolArgs,
303
+ recentOutput: entry.recentOutput ?? current.recentOutput,
304
+ recentOutputLines: entry.recentOutputLines ?? current.recentOutputLines,
305
+ recentTools: entry.recentTools ?? current.recentTools,
306
+ model: entry.model ?? current.model,
307
+ toolCount: entry.toolCount ?? current.toolCount,
308
+ durationMs: entry.durationMs ?? current.durationMs,
309
+ tokens: entry.tokens ?? current.tokens,
263
310
  };
264
311
  }
265
312
 
@@ -316,18 +363,29 @@ async function requestDelegatedRun(
316
363
 
317
364
  let lastProgressStatus = "";
318
365
  let widgetSet = false;
366
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
319
367
 
320
368
  const showWidget = () => {
321
369
  if (!ctx.hasUI || widgetSet) return;
322
370
  widgetSet = true;
323
371
  ctx.ui.setWidget(
324
372
  DELEGATED_WIDGET_KEY,
325
- (_tui, theme) => createDelegatedProgressWidget(request.requestId, request.agent, request.context, request.task, request.tasks, theme),
373
+ (_tui, theme) => createDelegatedProgressWidget(request.requestId, request.agent, request.context, request.task, request.tasks, theme, request.model),
326
374
  { placement: "aboveEditor" },
327
375
  );
376
+ // Force TUI repaints every second so the elapsed timer ticks during idle periods
377
+ refreshTimer = setInterval(() => {
378
+ if (done) return;
379
+ const statusLine = lastProgressStatus || "running...";
380
+ ctx.ui.setStatus("prompt-subagent", `delegating to ${requestLabel} · ${statusLine}`);
381
+ }, 1000);
328
382
  };
329
383
 
330
384
  const clearWidget = () => {
385
+ if (refreshTimer) {
386
+ clearInterval(refreshTimer);
387
+ refreshTimer = null;
388
+ }
331
389
  if (ctx.hasUI && widgetSet) {
332
390
  ctx.ui.setWidget(DELEGATED_WIDGET_KEY, undefined);
333
391
  widgetSet = false;
@@ -338,9 +396,11 @@ async function requestDelegatedRun(
338
396
  if (done || !data || typeof data !== "object") return;
339
397
  const update = data as DelegatedSubagentUpdate;
340
398
  if (update.requestId !== request.requestId) return;
399
+
400
+ const previousTaskProgress = getDelegatedLiveState(request.requestId)?.taskProgress;
341
401
  const mergedTaskProgress = mergeTaskProgress(
342
402
  request.tasks,
343
- getDelegatedLiveState(request.requestId)?.taskProgress,
403
+ previousTaskProgress,
344
404
  update.taskProgress,
345
405
  );
346
406
  const isParallel = (request.tasks?.length ?? 0) > 0;
@@ -353,18 +413,46 @@ async function requestDelegatedRun(
353
413
  if (progressStatus) {
354
414
  lastProgressStatus = progressStatus;
355
415
  }
416
+
356
417
  updateDelegatedLiveState(request.requestId, {
357
418
  status: progressStatus ?? (lastProgressStatus || "running..."),
358
419
  currentTool: update.currentTool,
359
420
  currentToolArgs: update.currentToolArgs,
421
+ recentTools: update.recentTools,
422
+ model: update.model,
360
423
  toolCount: update.toolCount,
361
424
  durationMs: update.durationMs,
362
425
  tokens: update.tokens,
363
426
  taskProgress: mergedTaskProgress,
364
427
  });
365
- appendDelegatedLiveOutput(request.requestId, update.recentOutput);
366
- if (mergedTaskProgress) {
428
+
429
+ if (!isParallel) {
430
+ if (update.recentOutputLines && update.recentOutputLines.length > 0) {
431
+ updateDelegatedLiveState(request.requestId, {
432
+ recentOutput: sanitizeOutputLines(update.recentOutputLines),
433
+ });
434
+ } else {
435
+ appendDelegatedLiveOutput(request.requestId, update.recentOutput);
436
+ }
437
+ }
438
+
439
+ if (isParallel && mergedTaskProgress) {
367
440
  for (const task of mergedTaskProgress) {
441
+ const previousTask =
442
+ previousTaskProgress?.find((entry) => entry.index === task.index) ??
443
+ previousTaskProgress?.find((entry) => entry.agent === task.agent);
444
+
445
+ const newOutputLines = collectNewOutputLines(previousTask?.recentOutputLines, task.recentOutputLines);
446
+ if (newOutputLines.length > 0) {
447
+ for (const line of newOutputLines) {
448
+ appendDelegatedLiveOutput(request.requestId, line);
449
+ }
450
+ continue;
451
+ }
452
+
453
+ if (!task.recentOutput || task.recentOutput === previousTask?.recentOutput) {
454
+ continue;
455
+ }
368
456
  appendDelegatedLiveOutput(request.requestId, task.recentOutput);
369
457
  }
370
458
  }
@@ -567,11 +655,12 @@ export async function executeSubagentPromptStep(options: DelegatedPromptOptions)
567
655
  agent: preparedTasks[0]!.agent,
568
656
  };
569
657
  } catch (error) {
570
- const responseText = error instanceof Error ? error.message : String(error);
658
+ const cause = error instanceof Error ? error : new Error(String(error));
659
+ const responseText = cause.message;
571
660
  if (isParallelRequest) {
572
- throw new Error(`Parallel delegated prompts (${promptLabel}) failed: ${responseText}`);
661
+ throw new Error(`Parallel delegated prompts (${promptLabel}) failed: ${responseText}`, { cause });
573
662
  }
574
- throw new Error(`Prompt \`${preparedTasks[0]!.promptName}\` delegated subagent \`${preparedTasks[0]!.agent}\` failed: ${responseText}`);
663
+ throw new Error(`Prompt \`${preparedTasks[0]!.promptName}\` delegated subagent \`${preparedTasks[0]!.agent}\` failed: ${responseText}`, { cause });
575
664
  } finally {
576
665
  clearDelegatedLiveState(request.requestId);
577
666
  if (ctx.hasUI) {
@@ -18,6 +18,28 @@ function formatTokens(n: number | undefined): string {
18
18
  return String(n);
19
19
  }
20
20
 
21
+ function normalizeModelLabel(model: string | undefined): string | undefined {
22
+ if (!model) return undefined;
23
+ return model.includes("/") ? model.split("/").pop() : model;
24
+ }
25
+
26
+ function formatToolCall(tool: string, args: string): string {
27
+ const safeArgs = args ?? "";
28
+ switch (tool) {
29
+ case "bash": {
30
+ const cmd = safeArgs.replace(/[\n\t]/g, " ").trim();
31
+ return `$ ${cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd}`;
32
+ }
33
+ case "read": return `[read: ${safeArgs}]`;
34
+ case "write": return `[write: ${safeArgs}]`;
35
+ case "edit": return `[edit: ${safeArgs}]`;
36
+ default: {
37
+ const short = safeArgs.length > 60 ? safeArgs.slice(0, 60) + "..." : safeArgs;
38
+ return `[${tool}: ${short}]`;
39
+ }
40
+ }
41
+ }
42
+
21
43
  export function createDelegatedProgressWidget(
22
44
  requestId: string,
23
45
  agent: string,
@@ -25,11 +47,18 @@ export function createDelegatedProgressWidget(
25
47
  task: string,
26
48
  tasks: DelegatedSubagentTask[] | undefined,
27
49
  theme: Theme,
50
+ model?: string,
28
51
  ): Container & { dispose?(): void } {
29
52
  const contextSuffix = context === "fork" ? theme.fg("warning", " [fork]") : "";
30
- const taskPreview = task.length > 120 ? `${task.slice(0, 120)}...` : task;
53
+ const taskPreview = task.length > 200 ? `${task.slice(0, 200)}...` : task;
31
54
  const parallelTasks = tasks ?? [];
32
55
  const isParallel = parallelTasks.length > 0;
56
+ const parallelModels = [...new Set(parallelTasks
57
+ .map((task) => normalizeModelLabel(task.model))
58
+ .filter((entry): entry is string => !!entry))];
59
+ const requestModel = isParallel
60
+ ? (parallelModels.length === 1 ? parallelModels[0] : undefined)
61
+ : normalizeModelLabel(model);
33
62
 
34
63
  const container = new Container();
35
64
  container.addChild(new Spacer(1));
@@ -44,7 +73,7 @@ export function createDelegatedProgressWidget(
44
73
  const key = stateKey(state, elapsed);
45
74
  if (key !== lastKey) {
46
75
  lastKey = key;
47
- rebuildBox(box, agent, contextSuffix, taskPreview, parallelTasks, isParallel, state, elapsed, theme);
76
+ rebuildBox(box, agent, contextSuffix, taskPreview, parallelTasks, isParallel, state, elapsed, theme, requestModel);
48
77
  }
49
78
  return Container.prototype.render.call(container, width);
50
79
  };
@@ -55,11 +84,16 @@ export function createDelegatedProgressWidget(
55
84
  function stateKey(state: DelegatedSubagentLiveState | undefined, elapsed: number): string {
56
85
  if (!state) return "none";
57
86
  const elapsedBucket = Math.floor(elapsed / 1000);
58
- const tool = state.currentTool ?? state.lastTool ?? "";
87
+ const tool = state.currentTool ?? "";
88
+ const outputLen = state.recentOutput.length;
89
+ const outputTail = state.recentOutput.length > 0
90
+ ? state.recentOutput[state.recentOutput.length - 1]?.slice(0, 80) ?? ""
91
+ : "";
92
+ const toolsLen = state.recentTools.length;
59
93
  const taskProgressKey = state.taskProgress
60
94
  .map((entry) => `${entry.index ?? ""}:${entry.agent}:${entry.status ?? ""}:${entry.currentTool ?? ""}:${entry.toolCount ?? 0}`)
61
95
  .join("|");
62
- return `${state.status}|${tool}|${state.toolCount}|${state.tokens}|${state.recentOutput.length}|${taskProgressKey}|${elapsedBucket}`;
96
+ return `${state.status}|${tool}|${state.toolCount}|${state.tokens}|${outputLen}:${outputTail}|${toolsLen}|${state.model ?? ""}|${taskProgressKey}|${elapsedBucket}`;
63
97
  }
64
98
 
65
99
  function rebuildBox(
@@ -72,34 +106,49 @@ function rebuildBox(
72
106
  state: DelegatedSubagentLiveState | undefined,
73
107
  elapsed: number,
74
108
  theme: Theme,
109
+ requestModel?: string,
75
110
  ): void {
76
111
  box.clear();
77
112
 
78
- const toolCount = state?.toolCount ?? 0;
79
- const tokens = formatTokens(state?.tokens);
113
+ const taskProgress = state?.taskProgress ?? [];
114
+ const baseToolCount = state?.toolCount ?? 0;
115
+ const baseTokens = state?.tokens ?? 0;
116
+ const parallelToolCount = taskProgress.reduce((sum, entry) => sum + (entry.toolCount ?? 0), 0);
117
+ const parallelTokens = taskProgress.reduce((sum, entry) => sum + (entry.tokens ?? 0), 0);
118
+ const toolCount = isParallel && parallelToolCount > 0 ? parallelToolCount : baseToolCount;
119
+ const tokens = isParallel && parallelTokens > 0 ? parallelTokens : baseTokens;
120
+ const tokensLabel = formatTokens(tokens);
80
121
  const duration = formatDuration(elapsed);
81
- const isThinking = toolCount === 0 && (state?.tokens ?? 0) === 0;
122
+ const isThinking = toolCount === 0 && tokens === 0;
82
123
  const icon = theme.fg("warning", "...");
124
+ const modelLabel = isParallel
125
+ ? requestModel
126
+ : normalizeModelLabel(state?.model ?? requestModel);
127
+ const modelSuffix = modelLabel ? ` ${theme.fg("dim", modelLabel)}` : "";
83
128
  const stats = isThinking
84
129
  ? `thinking, ${duration}`
85
- : `${toolCount} tool${toolCount === 1 ? "" : "s"}, ${tokens} tok, ${duration}`;
86
- const taskProgress = state?.taskProgress ?? [];
130
+ : `${toolCount} tool${toolCount === 1 ? "" : "s"}, ${tokensLabel} tok, ${duration}`;
87
131
 
132
+ // Header
88
133
  if (isParallel) {
89
134
  const completedCount = taskProgress.filter((entry) => entry.status === "completed").length;
90
135
  const runningLabel = `parallel ${completedCount}/${parallelTasks.length} running`;
91
- box.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(runningLabel))}${contextSuffix} | ${stats}`, 0, 0));
136
+ box.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(runningLabel))}${contextSuffix}${modelSuffix} | ${stats}`, 0, 0));
92
137
  } else {
93
138
  box.addChild(new Text(
94
- `${icon} ${theme.fg("toolTitle", theme.bold(agent))}${contextSuffix} | ${stats}`,
139
+ `${icon} ${theme.fg("toolTitle", theme.bold(agent))}${contextSuffix}${modelSuffix} | ${stats}`,
95
140
  0, 0,
96
141
  ));
97
142
  }
98
143
  box.addChild(new Spacer(1));
144
+
145
+ // Task preview
99
146
  if (!isParallel) {
100
147
  box.addChild(new Text(theme.fg("dim", `Task: ${taskPreview}`), 0, 0));
148
+ box.addChild(new Spacer(1));
101
149
  }
102
150
 
151
+ // Parallel task list
103
152
  if (isParallel) {
104
153
  for (let index = 0; index < parallelTasks.length; index++) {
105
154
  const task = parallelTasks[index]!;
@@ -111,35 +160,38 @@ function rebuildBox(
111
160
  if (taskStatus === "running") {
112
161
  const runningTool = progress.currentTool ? ` ${progress.currentTool}...` : "";
113
162
  box.addChild(new Text(theme.fg("dim", ` ${task.agent}: running${runningTool}`), 0, 0));
114
- continue;
115
- }
116
- if (taskStatus === "completed") {
163
+ } else if (taskStatus === "completed") {
117
164
  const toolSuffix =
118
165
  progress?.toolCount !== undefined
119
166
  ? ` (${progress.toolCount} tool${progress.toolCount === 1 ? "" : "s"})`
120
167
  : "";
121
168
  box.addChild(new Text(theme.fg("dim", ` ${task.agent}: completed${toolSuffix}`), 0, 0));
122
- continue;
123
- }
124
- if (taskStatus === "failed") {
169
+ } else if (taskStatus === "failed") {
125
170
  box.addChild(new Text(theme.fg("dim", ` ${task.agent}: failed`), 0, 0));
126
- continue;
171
+ } else {
172
+ box.addChild(new Text(theme.fg("dim", ` ${task.agent}: pending`), 0, 0));
127
173
  }
128
- box.addChild(new Text(theme.fg("dim", ` ${task.agent}: pending`), 0, 0));
129
174
  }
130
175
  return;
131
176
  }
132
177
 
133
- const activeTool = state?.currentTool;
134
- const displayTool = activeTool ?? state?.lastTool;
135
- if (displayTool) {
136
- const toolArgs = activeTool ? state?.currentToolArgs : state?.lastToolArgs;
137
- const toolLine = `${displayTool}${toolArgs ? ` ${toolArgs}` : ""}`;
138
- box.addChild(new Text(theme.fg("dim", toolLine), 0, 0));
178
+ // Unified tool stream: completed tools (dim) then active tool (warning) at bottom.
179
+ // When a tool finishes it moves from active → completed in place — no visual jump.
180
+ const recentTools = state?.recentTools ?? [];
181
+ for (const tool of recentTools) {
182
+ box.addChild(new Text(theme.fg("dim", formatToolCall(tool.tool, tool.args)), 0, 0));
183
+ }
184
+ if (state?.currentTool) {
185
+ const active = formatToolCall(state.currentTool, state.currentToolArgs ?? "");
186
+ box.addChild(new Text(theme.fg("warning", `> ${active}`), 0, 0));
139
187
  }
140
188
 
189
+ // Recent output
141
190
  if (state && state.recentOutput.length > 0) {
142
- for (const line of state.recentOutput.slice(-4)) {
191
+ if (recentTools.length > 0 || state.currentTool) {
192
+ box.addChild(new Spacer(1));
193
+ }
194
+ for (const line of state.recentOutput) {
143
195
  box.addChild(new Text(theme.fg("dim", ` ${line}`), 0, 0));
144
196
  }
145
197
  }