pi-prompt-template-model 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2026-03-19
6
+
7
+ ### Changed
8
+
9
+ - Clarified the README chain examples so the optional ` -- ` shared-args separator is clearly distinct from loop flags like `--loop`, `--fresh`, and `--no-converge`.
10
+ - Clarified in the README that chain frontmatter declarations support per-step `--loop N` inside the `chain:` value.
11
+ - Argument substitution now accepts `@$` as an alias for `$@` for compatibility with commonly-typed placeholder variants.
12
+ - Skill injection now uses a next-turn context message from `before_agent_start` instead of mutating the turn system prompt.
13
+ - Non-chain templates can now omit `model` and inherit the current session model, so inline `<if-model ...>` rendering and skill injection still work without explicit model frontmatter.
14
+ - Chain steps without `model` now inherit a fixed chain-start model snapshot, so model-less chain steps behave as if that model were declared in frontmatter while remaining deterministic across step switches.
15
+
16
+ ### Fixed
17
+
18
+ - Chain step execution now avoids implicit previous-step model bleed for model-less templates by resolving them against the chain-start model snapshot instead of whichever model was active after the prior step.
19
+ - Model-less prompt loading now skips plain templates that do not use extension features, preventing command collisions with other extension commands like `/review` and `/handover`.
20
+ - Model-less prompt loading now also ignores no-op/invalid-only extension metadata (for example `restore`-only or invalid loop flags), so ineffective frontmatter does not unnecessarily claim command names.
21
+ - Model-less prompt loading now recognizes invalid conditional closers like `</else>` as extension-relevant markup, so those templates stay in this extension path and surface proper conditional-parse warnings instead of silently bypassing extension handling.
22
+ - Model-less prompt execution now tracks runtime model changes (`model_select` + internal switches/restores) and uses that tracked model instead of potentially stale command-context snapshots.
23
+ - Prompt commands now fail fast when a configured `skill` file is missing or unreadable, instead of silently sending the prompt without skill context.
24
+ - Skill resolution now returns a typed success/error outcome that callers handle explicitly, rather than emitting notifications from inside the resolver and returning sentinel `null` values.
25
+ - Session start/switch now clear any queued skill context message so stale pending skill payloads cannot leak across session boundaries.
26
+ - Session start/switch now also clear pending single-command restore state (`previousModel`/`previousThinking`) so restore writes cannot leak into a different session.
27
+ - Skill frontmatter resolution now checks registered skill commands first (`pi.getCommands()` skill entries), accepts both `<name>` and `skill:<name>` values, searches additional standard pi skill locations (`.agents/skills` in project ancestors and `~/.agents/skills`), supports direct `<skill>.md` files alongside `SKILL.md` directories, and rejects traversal-like skill names for path fallback.
28
+ - `extractLoopCount()` now strips repeated unquoted `--loop` tokens once looping is active, preventing stray loop flags from leaking into prompt arguments.
29
+ - Chain frontmatter step parsing now strips repeated per-step `--loop` tokens once a valid per-step loop is resolved, and keeps the first valid value (including mixed invalid/valid numeric sequences like `--loop 1000 --loop 2`).
30
+ - Loop-mode restore now tracks runtime model/thinking state per iteration instead of relying on command-context model snapshots, so model restoration remains correct even when command context values are stale.
31
+ - Chain execution now restores model/thinking state in a `finally` path, so restore still runs after unexpected runtime errors during a chain step and chain cleanup state is still reset even when restore itself fails.
32
+ - Loop and chain executions no longer report `Loop finished`/`Loop converged` when runtime errors abort execution mid-loop.
33
+ - Loop and chain error propagation now preserves thrown falsy values (for example `throw 0`) instead of treating them as success, preventing swallowed errors and false completion notifications.
34
+
5
35
  ## [0.5.0] - 2026-03-17
6
36
 
7
37
  ### Added
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  │ /debug-python ──► Extension detects model + skill │
16
16
  │ │ │
17
17
  │ ▼ │
18
- │ Switches to Sonnet ──► Injects tmux skill into system prompt
18
+ │ Switches to Sonnet ──► Queues tmux skill context for next turn
19
19
  │ │ │
20
20
  │ ▼ │
21
21
  │ Agent responds with Sonnet + tmux expertise │
@@ -49,7 +49,7 @@ Restart pi to load the extension.
49
49
 
50
50
  ## Quick Start
51
51
 
52
- Add `model` and optionally `skill` to any prompt template:
52
+ Add `model` (or omit it to inherit the current session model) and optionally `skill` to any prompt template:
53
53
 
54
54
  ```markdown
55
55
  ---
@@ -80,15 +80,15 @@ skill: surf
80
80
  $@
81
81
  ```
82
82
 
83
- Here `skill: surf` loads `~/.pi/agent/skills/surf/SKILL.md` and injects its content directly into the system prompt before the agent even sees your task. No decision-making, no read tool, just immediate expertise. It's a forcing function for when you know exactly what workflow the agent needs.
83
+ Here `skill: surf` loads `~/.pi/agent/skills/surf/SKILL.md` and injects its content as a context message on the next turn before the agent handles your task. No decision-making, no read tool, just immediate expertise. It's a forcing function for when you know exactly what workflow the agent needs.
84
84
 
85
85
  ## Frontmatter Fields
86
86
 
87
87
  | Field | Required | Default | Description |
88
88
  |-------|----------|---------|-------------|
89
- | `model` | Conditional | - | Required for non-chain templates; ignored when `chain` is set |
89
+ | `model` | No | `current` | Target model(s). If omitted on a non-chain template, execution inherits the current session model. Ignored when `chain` is set. |
90
90
  | `chain` | Conditional | - | Chain declaration (`step -> step --loop 2`) for orchestration templates; body is ignored |
91
- | `skill` | No | - | Skill name to inject into system prompt |
91
+ | `skill` | No | - | Skill name to inject as next-turn context message |
92
92
  | `thinking` | No | - | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
93
93
  | `description` | No | - | Shown in autocomplete |
94
94
  | `restore` | No | `true` | Restore previous model and thinking level after response |
@@ -153,7 +153,7 @@ Do a deeper pass and call out subtle risks.
153
153
  </if-model>
154
154
  ```
155
155
 
156
- Conditionals are evaluated against the model that actually runs the command after fallback resolution. That means the same template can render differently depending on which candidate was selected.
156
+ Conditionals are evaluated against the model that actually runs the command. For fallback prompts, that means after candidate resolution; for prompts without `model`, that means the current session model. The same template can render differently depending on which model is active.
157
157
 
158
158
  Supported matches inside `is="..."`:
159
159
 
@@ -183,6 +183,7 @@ Prompt bodies support argument placeholders that expand to command arguments:
183
183
  |-------------|-------------|
184
184
  | `$1`, `$2`, ... | Positional argument (1-indexed) |
185
185
  | `$@` | All arguments joined with spaces |
186
+ | `@$` | Alias for `$@` |
186
187
  | `$ARGUMENTS` | Same as `$@` |
187
188
  | `${@:N}` | All arguments from position N onward |
188
189
  | `${@:N:L}` | L arguments starting from position N |
@@ -203,17 +204,22 @@ Running `/analyze src/main.ts performance edge cases error handling` expands to:
203
204
 
204
205
  ## Skill Resolution
205
206
 
206
- The `skill` field matches the skill's directory name:
207
+ The `skill` field accepts either a bare skill name or a slash-command style name:
207
208
 
208
209
  ```yaml
209
210
  skill: tmux
211
+ # also valid
212
+ skill: skill:tmux
210
213
  ```
211
214
 
212
- Resolves to (checked in order):
213
- 1. `<cwd>/.pi/skills/tmux/SKILL.md` (project)
214
- 2. `~/.pi/agent/skills/tmux/SKILL.md` (user)
215
+ Resolution order:
216
+ 1. Registered skill commands from `pi.getCommands()` (`source: "skill"`), matched by `skill:name` or `name`
217
+ 2. `<cwd>/.pi/skills/<name>/SKILL.md` or `<cwd>/.pi/skills/<name>.md`
218
+ 3. `.agents/skills` in `cwd` and ancestor directories (up to git repo root)
219
+ 4. `~/.pi/agent/skills/<name>/SKILL.md` or `~/.pi/agent/skills/<name>.md`
220
+ 5. `~/.agents/skills/<name>/SKILL.md` or `~/.agents/skills/<name>.md`
215
221
 
216
- This matches pi's precedence - project skills override user skills.
222
+ If the configured skill file is missing or unreadable, the command fails fast and does not send the prompt body to the model.
217
223
 
218
224
  ## Subdirectories
219
225
 
@@ -310,13 +316,13 @@ Switched to Haiku. How can I help?
310
316
 
311
317
  ## Chaining Templates
312
318
 
313
- The `/chain-prompts` command runs multiple templates sequentially. Each step switches to its own model, renders any inline model conditionals against that step’s resolved model, injects its own skill, and the conversation context carries forward between steps.
319
+ The `/chain-prompts` command runs multiple templates sequentially. Each step switches to its own model (or, if the step has no `model`, to the chain-start model snapshot), renders inline model conditionals against that resolved step model, injects its own skill context message, and conversation context carries forward between steps.
314
320
 
315
321
  ```
316
322
  /chain-prompts analyze-code -> fix-plan -> summarize -- src/main.ts
317
323
  ```
318
324
 
319
- This runs `analyze-code` first, then `fix-plan` (which sees the analysis in conversation context), then `summarize`. The `-- src/main.ts` provides shared args substituted into every template's `$@`.
325
+ This runs `analyze-code` first, then `fix-plan` (which sees the analysis in conversation context), then `summarize`. The ` -- src/main.ts` part is optional. The literal ` -- ` separator means "shared args start here": everything after it is passed to each step as `$@`, unless that step already has its own inline args.
320
326
 
321
327
  Each step can also receive its own args, overriding the shared args for that step:
322
328
 
@@ -348,7 +354,7 @@ chain: double-check --loop 2 -> deslop --loop 2
348
354
  ignored — chain templates don't use the body
349
355
  ```
350
356
 
351
- This registers `/review-then-clean` as a command that runs `double-check` twice, then `deslop` twice. Each step references a separate prompt template with its own `model`. The chain template itself doesn't need a `model` field each step uses whatever model its template specifies.
357
+ This registers `/review-then-clean` as a command that runs `double-check` twice, then `deslop` twice. Each step references a separate prompt template. Steps with `model` use their configured model; steps without `model` inherit the chain-start model snapshot (the model active when the chain command began), so behavior stays deterministic even if earlier steps switch models.
352
358
 
353
359
  Per-step `--loop N` repeats that step N times before moving to the next. Per-step convergence applies: if a step makes no file changes on an iteration, its inner loop stops early (unless the step's template has `converge: false`).
354
360
 
@@ -440,14 +446,15 @@ Review the codebase and improve code quality. $@
440
446
  Chains support the same looping forms:
441
447
 
442
448
  ```
449
+ /chain-prompts analyze -> fix --loop 3
450
+ /chain-prompts analyze -> fix --loop=3
451
+ /chain-prompts analyze -> fix --loop
452
+ /chain-prompts analyze -> fix --loop 3 --fresh
453
+ /chain-prompts analyze -> fix --loop 3 --no-converge
443
454
  /chain-prompts analyze -> fix --loop 3 -- src/main.ts
444
- /chain-prompts analyze -> fix --loop=3 -- src/main.ts
445
- /chain-prompts analyze -> fix --loop -- src/main.ts
446
- /chain-prompts analyze -> fix --loop 3 --fresh -- src/main.ts
447
- /chain-prompts analyze -> fix --loop 3 --no-converge -- src/main.ts
448
455
  ```
449
456
 
450
- This runs the full chain (analyze → fix) three times. Convergence detection applies across all steps in each iteration — if no step made file changes, the loop stops. Each iteration re-reads prompts from disk, so template edits take effect between iterations. The status bar shows `loop 2/3` during execution.
457
+ This runs the full chain (analyze → fix) three times. The final example adds optional shared args: ` -- src/main.ts` means "pass `src/main.ts` to any step that doesn't already have its own args." If you don't need shared args, leave that part out entirely. Convergence detection applies across all steps in each iteration — if no step made file changes, the loop stops. Each iteration re-reads prompts from disk, so template edits take effect between iterations. The status bar shows `loop 2/3` during execution. Chain frontmatter declarations also support per-step `--loop N` inside the `chain:` value (for example `chain: double-check --loop 3 -> simplify -> deslop`).
451
458
 
452
459
  ## Agent Tool
453
460
 
@@ -499,11 +506,12 @@ These commands work in print mode too:
499
506
  pi -p "/debug-python my code crashes on line 42"
500
507
  ```
501
508
 
502
- The model switches, skill injects, agent responds, and output prints to stdout. Useful for scripting or piping to other tools.
509
+ The model switches, a skill context message is injected, the agent responds, and output prints to stdout. Useful for scripting or piping to other tools.
503
510
 
504
511
  ## Limitations
505
512
 
506
513
  - Prompt files are reloaded on session start and whenever an extension-owned prompt command runs. If you add a brand-new prompt file while already inside a session, run another extension-owned command such as `/chain-prompts`, start a new session, or reload pi so the new slash command is registered.
507
514
  - Model restore state is in-memory. Closing pi mid-response loses restore state.
508
- - Chain steps must reference templates with a `model` field. Chain templates themselves use `chain` and do not execute their own body.
515
+ - Model-less templates are only managed by this extension when they use extension features (for example `skill`, `thinking`, loop flags, or inline `<if-model ...>`). Plain prompt templates without extension features stay with pi's default prompt loader to avoid command conflicts.
516
+ - In chains, model-less steps inherit the chain-start model snapshot, not the immediately previous step model. This is intentional for deterministic behavior.
509
517
  - The `run-prompt` tool must be explicitly enabled with `/prompt-tool on` before the agent can use it.
package/args.ts CHANGED
@@ -17,6 +17,7 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
17
17
  let fresh = false;
18
18
  let noConverge = false;
19
19
  const tokensToRemove: Array<{ start: number; end: number }> = [];
20
+ const loopTokenRanges: Array<{ start: number; end: number }> = [];
20
21
 
21
22
  let i = 0;
22
23
  while (i < argsString.length) {
@@ -39,45 +40,46 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
39
40
  while (i < argsString.length && !/\s/.test(argsString[i])) i++;
40
41
  const token = argsString.slice(tokenStart, i);
41
42
 
42
- if (!loopFound && (token === "--loop" || token.startsWith("--loop="))) {
43
- if (token.startsWith("--loop=")) {
44
- const value = token.slice("--loop=".length);
45
- if (/^\d+$/.test(value)) {
46
- const parsed = parseInt(value, 10);
47
- if (parsed >= 1 && parsed <= 999) {
48
- loopFound = true;
49
- loopCount = parsed;
50
- tokensToRemove.push({ start: tokenStart, end: i });
51
- }
43
+ if (token.startsWith("--loop=")) {
44
+ loopTokenRanges.push({ start: tokenStart, end: i });
45
+ const value = token.slice("--loop=".length);
46
+ if (/^\d+$/.test(value)) {
47
+ const parsed = parseInt(value, 10);
48
+ if (parsed >= 1 && parsed <= 999 && !loopFound) {
49
+ loopFound = true;
50
+ loopCount = parsed;
52
51
  }
53
- } else {
54
- let lookahead = i;
55
- while (lookahead < argsString.length && /\s/.test(argsString[lookahead])) lookahead++;
56
-
57
- if (lookahead < argsString.length && argsString[lookahead] !== '"' && argsString[lookahead] !== "'") {
58
- const nextTokenStart = lookahead;
59
- while (lookahead < argsString.length && !/\s/.test(argsString[lookahead])) lookahead++;
60
- const nextToken = argsString.slice(nextTokenStart, lookahead);
61
-
62
- if (/^\d+$/.test(nextToken)) {
63
- const parsed = parseInt(nextToken, 10);
64
- if (parsed >= 1 && parsed <= 999) {
65
- loopFound = true;
66
- loopCount = parsed;
67
- tokensToRemove.push({ start: tokenStart, end: i }, { start: nextTokenStart, end: lookahead });
68
- i = lookahead;
69
- }
70
- } else {
52
+ }
53
+ continue;
54
+ }
55
+
56
+ if (token === "--loop") {
57
+ let lookahead = i;
58
+ while (lookahead < argsString.length && /\s/.test(argsString[lookahead])) lookahead++;
59
+
60
+ if (lookahead < argsString.length && argsString[lookahead] !== '"' && argsString[lookahead] !== "'") {
61
+ const nextTokenStart = lookahead;
62
+ while (lookahead < argsString.length && !/\s/.test(argsString[lookahead])) lookahead++;
63
+ const nextToken = argsString.slice(nextTokenStart, lookahead);
64
+
65
+ if (/^\d+$/.test(nextToken)) {
66
+ loopTokenRanges.push({ start: tokenStart, end: i }, { start: nextTokenStart, end: lookahead });
67
+ const parsed = parseInt(nextToken, 10);
68
+ if (parsed >= 1 && parsed <= 999 && !loopFound) {
71
69
  loopFound = true;
72
- loopCount = null;
73
- tokensToRemove.push({ start: tokenStart, end: i });
70
+ loopCount = parsed;
74
71
  }
75
- } else {
76
- loopFound = true;
77
- loopCount = null;
78
- tokensToRemove.push({ start: tokenStart, end: i });
72
+ i = lookahead;
73
+ continue;
79
74
  }
80
75
  }
76
+
77
+ loopTokenRanges.push({ start: tokenStart, end: i });
78
+ if (!loopFound) {
79
+ loopFound = true;
80
+ loopCount = null;
81
+ }
82
+ continue;
81
83
  }
82
84
 
83
85
  if (token === "--fresh") {
@@ -91,15 +93,16 @@ export function extractLoopCount(argsString: string): LoopExtraction | null {
91
93
  }
92
94
  }
93
95
 
94
- if (loopCount === null && !loopFound) return null;
96
+ if (!loopFound) return null;
95
97
 
96
- tokensToRemove.sort((a, b) => b.start - a.start);
98
+ const allRanges = [...tokensToRemove, ...loopTokenRanges];
99
+ allRanges.sort((a, b) => b.start - a.start);
97
100
  let cleaned = argsString;
98
- for (const { start, end } of tokensToRemove) {
101
+ for (const { start, end } of allRanges) {
99
102
  cleaned = cleaned.slice(0, start) + cleaned.slice(end);
100
103
  }
101
104
 
102
- const converge = loopFound && loopCount === null ? true : !noConverge;
105
+ const converge = loopCount === null ? true : !noConverge;
103
106
  return { args: cleaned.trim(), loopCount, fresh, converge };
104
107
  }
105
108
 
@@ -227,6 +230,7 @@ export function substituteArgs(content: string, args: string[]): string {
227
230
  const allArgs = args.join(" ");
228
231
  result = result.replace(/\$ARGUMENTS/g, allArgs);
229
232
  result = result.replace(/\$@/g, allArgs);
233
+ result = result.replace(/@\$/g, allArgs);
230
234
 
231
235
  return result;
232
236
  }
package/chain-parser.ts CHANGED
@@ -76,19 +76,20 @@ function scanSegmentTokens(segment: string): SegmentToken[] {
76
76
 
77
77
  function extractStepLoopCount(segment: string): { cleanedSegment: string; loopCount?: number } {
78
78
  const tokens = scanSegmentTokens(segment);
79
+ const loopTokenRanges: Array<{ start: number; end: number }> = [];
80
+ let loopCount: number | undefined;
79
81
 
80
82
  for (let i = 1; i < tokens.length; i++) {
81
83
  const token = tokens[i];
82
84
  if (token.quoted) continue;
83
85
 
84
86
  if (token.value.startsWith("--loop=")) {
87
+ loopTokenRanges.push({ start: token.start, end: token.end });
85
88
  const value = token.value.slice("--loop=".length);
86
- if (/^\d+$/.test(value)) {
87
- const parsed = parseInt(value, 10);
88
- if (parsed >= 1 && parsed <= 999) {
89
- const cleanedSegment = `${segment.slice(0, token.start)}${segment.slice(token.end)}`.trim();
90
- return { cleanedSegment, loopCount: parsed };
91
- }
89
+ if (!/^\d+$/.test(value)) continue;
90
+ const parsed = parseInt(value, 10);
91
+ if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
92
+ loopCount = parsed;
92
93
  }
93
94
  continue;
94
95
  }
@@ -96,16 +97,28 @@ function extractStepLoopCount(segment: string): { cleanedSegment: string; loopCo
96
97
  if (token.value === "--loop" && i + 1 < tokens.length) {
97
98
  const next = tokens[i + 1];
98
99
  if (!next.quoted && /^\d+$/.test(next.value)) {
100
+ loopTokenRanges.push({ start: token.start, end: token.end }, { start: next.start, end: next.end });
99
101
  const parsed = parseInt(next.value, 10);
100
- if (parsed >= 1 && parsed <= 999) {
101
- const cleanedSegment = `${segment.slice(0, token.start)}${segment.slice(next.end)}`.trim();
102
- return { cleanedSegment, loopCount: parsed };
102
+ if (parsed >= 1 && parsed <= 999 && loopCount === undefined) {
103
+ loopCount = parsed;
103
104
  }
105
+ i++;
106
+ continue;
104
107
  }
105
108
  }
106
109
  }
107
110
 
108
- return { cleanedSegment: segment };
111
+ if (loopCount === undefined || loopTokenRanges.length === 0) {
112
+ return { cleanedSegment: segment };
113
+ }
114
+
115
+ loopTokenRanges.sort((a, b) => b.start - a.start);
116
+ let cleanedSegment = segment;
117
+ for (const { start, end } of loopTokenRanges) {
118
+ cleanedSegment = `${cleanedSegment.slice(0, start)}${cleanedSegment.slice(end)}`;
119
+ }
120
+
121
+ return { cleanedSegment: cleanedSegment.trim(), loopCount };
109
122
  }
110
123
 
111
124
  export function parseChainSteps(args: string): ParsedChainSteps {
package/index.ts CHANGED
@@ -22,11 +22,29 @@ interface FreshCollapse {
22
22
  totalIterations: number | null;
23
23
  }
24
24
 
25
+ interface PendingSkillMessage {
26
+ customType: "skill-loaded";
27
+ content: string;
28
+ display: true;
29
+ details: SkillLoadedDetails;
30
+ }
31
+
32
+ type SkillMessageResolution =
33
+ | { kind: "none" }
34
+ | { kind: "ready"; message: PendingSkillMessage }
35
+ | { kind: "error"; error: string };
36
+
37
+ interface ExecutionErrorState {
38
+ hasError: boolean;
39
+ error: unknown;
40
+ }
41
+
25
42
  export default function promptModelExtension(pi: ExtensionAPI) {
26
43
  let prompts = new Map<string, PromptWithModel>();
27
44
  let previousModel: Model<any> | undefined;
28
45
  let previousThinking: ThinkingLevel | undefined;
29
- let pendingSkill: { name: string; cwd: string } | undefined;
46
+ let pendingSkillMessage: PendingSkillMessage | undefined;
47
+ let runtimeModel: Model<any> | undefined;
30
48
  let chainActive = false;
31
49
  let loopState: LoopState | null = null;
32
50
  let freshCollapse: FreshCollapse | null = null;
@@ -49,6 +67,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
49
67
  return a.provider === b.provider && a.id === b.id;
50
68
  }
51
69
 
70
+ function getCurrentModel(ctx: Pick<ExtensionContext, "model">): Model<any> | undefined {
71
+ return runtimeModel ?? ctx.model;
72
+ }
73
+
52
74
  pi.registerMessageRenderer<SkillLoadedDetails>("skill-loaded", renderSkillLoaded);
53
75
 
54
76
  function registerPromptCommand(name: string) {
@@ -76,6 +98,74 @@ export default function promptModelExtension(pi: ExtensionAPI) {
76
98
  lastDiagnostics = fingerprint;
77
99
  }
78
100
 
101
+ function consumePendingSkillMessage() {
102
+ if (!pendingSkillMessage) return undefined;
103
+ const message = pendingSkillMessage;
104
+ pendingSkillMessage = undefined;
105
+ return message;
106
+ }
107
+
108
+ function normalizeSkillName(skillName: string): string {
109
+ return skillName.startsWith("skill:") ? skillName.slice("skill:".length) : skillName;
110
+ }
111
+
112
+ function isPathResolvableSkillName(skillName: string): boolean {
113
+ if (skillName === "." || skillName === "..") return false;
114
+ if (skillName.includes("/")) return false;
115
+ if (skillName.includes("\\")) return false;
116
+ return true;
117
+ }
118
+
119
+ function resolveRegisteredSkillPath(skillName: string): string | undefined {
120
+ const normalizedSkillName = normalizeSkillName(skillName);
121
+ if (!normalizedSkillName) return undefined;
122
+ const candidates = new Set([normalizedSkillName, `skill:${normalizedSkillName}`]);
123
+
124
+ for (const command of pi.getCommands()) {
125
+ if (command.source !== "skill") continue;
126
+ if (!command.path) continue;
127
+ if (!candidates.has(command.name)) continue;
128
+ return command.path;
129
+ }
130
+
131
+ return undefined;
132
+ }
133
+
134
+ function resolveSkillMessage(skillName: string | undefined, cwd: string): SkillMessageResolution {
135
+ if (!skillName) {
136
+ return { kind: "none" };
137
+ }
138
+
139
+ const normalizedSkillName = normalizeSkillName(skillName);
140
+ if (!normalizedSkillName) {
141
+ return { kind: "error", error: `Skill "${skillName}" not found` };
142
+ }
143
+
144
+ const skillPath =
145
+ resolveRegisteredSkillPath(skillName) ?? (isPathResolvableSkillName(normalizedSkillName) ? resolveSkillPath(normalizedSkillName, cwd) : undefined);
146
+ if (!skillPath) {
147
+ return { kind: "error", error: `Skill "${skillName}" not found` };
148
+ }
149
+
150
+ try {
151
+ const skillContent = readSkillContent(skillPath);
152
+ return {
153
+ kind: "ready",
154
+ message: {
155
+ customType: "skill-loaded",
156
+ content: `<skill name="${normalizedSkillName}">\n${skillContent}\n</skill>`,
157
+ display: true,
158
+ details: { skillName: normalizedSkillName, skillContent, skillPath },
159
+ },
160
+ };
161
+ } catch (error) {
162
+ return {
163
+ kind: "error",
164
+ error: `Failed to read skill "${skillName}": ${error instanceof Error ? error.message : String(error)}`,
165
+ };
166
+ }
167
+ }
168
+
79
169
  async function waitForTurnStart(ctx: ExtensionContext) {
80
170
  while (ctx.isIdle()) {
81
171
  await new Promise((resolve) => setTimeout(resolve, 10));
@@ -90,19 +180,19 @@ export default function promptModelExtension(pi: ExtensionAPI) {
90
180
  currentThinking?: ThinkingLevel,
91
181
  ) {
92
182
  const restoredParts: string[] = [];
93
- const shouldRestoreModel = originalModel !== undefined && !sameModel(originalModel, currentModel);
94
183
  const shouldRestoreThinking =
95
184
  originalThinking !== undefined && (currentThinking === undefined || currentThinking !== originalThinking);
96
185
 
97
- if (shouldRestoreModel && originalModel) {
186
+ if (originalModel && !sameModel(originalModel, currentModel)) {
98
187
  const restoredModel = await pi.setModel(originalModel);
99
188
  if (restoredModel) {
189
+ runtimeModel = originalModel;
100
190
  restoredParts.push(originalModel.id);
101
191
  } else {
102
192
  notify(ctx, `Failed to restore model ${originalModel.provider}/${originalModel.id}`, "error");
103
193
  }
104
194
  }
105
- if (shouldRestoreThinking && originalThinking !== undefined) {
195
+ if (shouldRestoreThinking) {
106
196
  restoredParts.push(`thinking:${originalThinking}`);
107
197
  pi.setThinkingLevel(originalThinking);
108
198
  }
@@ -111,6 +201,63 @@ export default function promptModelExtension(pi: ExtensionAPI) {
111
201
  }
112
202
  }
113
203
 
204
+ async function restoreAfterExecution(
205
+ ctx: ExtensionContext,
206
+ shouldRestore: boolean,
207
+ originalModel: Model<any> | undefined,
208
+ originalThinking: ThinkingLevel | undefined,
209
+ currentModel: Model<any> | undefined,
210
+ currentThinking: ThinkingLevel | undefined,
211
+ errorState: ExecutionErrorState,
212
+ phase: "loop" | "chain",
213
+ ): Promise<ExecutionErrorState> {
214
+ if (!shouldRestore) return errorState;
215
+
216
+ try {
217
+ await restoreSessionState(ctx, originalModel, originalThinking, currentModel, currentThinking);
218
+ } catch (error) {
219
+ if (errorState.hasError) {
220
+ notify(
221
+ ctx,
222
+ `Failed to restore session state after ${phase} error: ${error instanceof Error ? error.message : String(error)}`,
223
+ "error",
224
+ );
225
+ return errorState;
226
+ }
227
+ return { hasError: true, error };
228
+ }
229
+
230
+ return errorState;
231
+ }
232
+
233
+ function notifyLoopCompletion(
234
+ ctx: ExtensionContext,
235
+ completedIterations: number,
236
+ totalIterations: number | null,
237
+ effectiveMax: number,
238
+ converged: boolean,
239
+ requireMultipleIterations: boolean,
240
+ ) {
241
+ if (converged) {
242
+ const convergedLabel = totalIterations !== null ? `${completedIterations}/${totalIterations}` : `${completedIterations}`;
243
+ notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
244
+ return;
245
+ }
246
+
247
+ if (completedIterations === 0) return;
248
+ if (requireMultipleIterations && effectiveMax <= 1) return;
249
+
250
+ if (totalIterations !== null) {
251
+ notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
252
+ return;
253
+ }
254
+ if (completedIterations === effectiveMax) {
255
+ notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
256
+ return;
257
+ }
258
+ notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
259
+ }
260
+
114
261
  function updateLoopStatus(ctx: ExtensionContext) {
115
262
  if (!ctx.hasUI) return;
116
263
  if (loopState) {
@@ -152,8 +299,10 @@ export default function promptModelExtension(pi: ExtensionAPI) {
152
299
  return;
153
300
  }
154
301
 
155
- const savedModel = ctx.model;
302
+ const savedModel = getCurrentModel(ctx);
156
303
  const savedThinking = pi.getThinkingLevel();
304
+ let currentModel = savedModel;
305
+ let currentThinking = savedThinking;
157
306
  const shouldRestore = initialPrompt.restore;
158
307
  const useFresh = freshFlag || initialPrompt.fresh === true;
159
308
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
@@ -166,6 +315,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
166
315
  updateLoopStatus(ctx);
167
316
  let completedIterations = 0;
168
317
  let converged = false;
318
+ let loopErrorState: ExecutionErrorState = { hasError: false, error: undefined };
169
319
 
170
320
  try {
171
321
  for (let i = 0; i < effectiveMax; i++) {
@@ -181,7 +331,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
181
331
  break;
182
332
  }
183
333
 
184
- const prepared = await preparePromptExecution(prompt, parseCommandArgs(cleanedArgs), ctx.model, ctx.modelRegistry);
334
+ const prepared = await preparePromptExecution(prompt, parseCommandArgs(cleanedArgs), currentModel, ctx.modelRegistry);
185
335
  if (!prepared) {
186
336
  notify(ctx, `No available model from: ${prompt.models.join(", ")}`, "error");
187
337
  break;
@@ -195,19 +345,29 @@ export default function promptModelExtension(pi: ExtensionAPI) {
195
345
  notify(ctx, prepared.warning, "warning");
196
346
  }
197
347
 
348
+ const skillResolution = resolveSkillMessage(prompt.skill, ctx.cwd);
349
+ if (skillResolution.kind === "error") {
350
+ notify(ctx, skillResolution.error, "error");
351
+ break;
352
+ }
353
+
198
354
  if (!prepared.selectedModel.alreadyActive) {
199
355
  const switched = await pi.setModel(prepared.selectedModel.model);
200
356
  if (!switched) {
201
357
  notify(ctx, `Failed to switch to model ${prepared.selectedModel.model.provider}/${prepared.selectedModel.model.id}`, "error");
202
358
  break;
203
359
  }
360
+ runtimeModel = prepared.selectedModel.model;
204
361
  }
362
+ currentModel = prepared.selectedModel.model;
363
+ currentThinking = pi.getThinkingLevel();
205
364
 
206
365
  if (prompt.thinking) {
207
366
  pi.setThinkingLevel(prompt.thinking);
367
+ currentThinking = pi.getThinkingLevel();
208
368
  }
209
369
 
210
- pendingSkill = prompt.skill ? { name: prompt.skill, cwd: ctx.cwd } : undefined;
370
+ pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
211
371
  const iterationStartId = ctx.sessionManager.getLeafId();
212
372
 
213
373
  pi.sendUserMessage(prepared.content);
@@ -230,29 +390,33 @@ export default function promptModelExtension(pi: ExtensionAPI) {
230
390
  }
231
391
  }
232
392
  }
393
+ } catch (error) {
394
+ loopErrorState = { hasError: true, error };
233
395
  } finally {
396
+ loopErrorState = await restoreAfterExecution(
397
+ ctx,
398
+ shouldRestore,
399
+ savedModel,
400
+ savedThinking,
401
+ currentModel,
402
+ currentThinking,
403
+ loopErrorState,
404
+ "loop",
405
+ );
406
+
234
407
  loopState = null;
235
- pendingSkill = undefined;
408
+ pendingSkillMessage = undefined;
236
409
  freshCollapse = null;
237
410
  accumulatedSummaries = [];
238
411
  updateLoopStatus(ctx);
239
412
 
240
- if (converged) {
241
- const convergedLabel = totalIterations !== null ? `${completedIterations}/${totalIterations}` : `${completedIterations}`;
242
- notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
243
- } else if (completedIterations > 0) {
244
- if (totalIterations !== null) {
245
- notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
246
- } else if (completedIterations === effectiveMax) {
247
- notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
248
- } else {
249
- notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
250
- }
413
+ if (!loopErrorState.hasError) {
414
+ notifyLoopCompletion(ctx, completedIterations, totalIterations, effectiveMax, converged, false);
251
415
  }
416
+ }
252
417
 
253
- if (shouldRestore) {
254
- await restoreSessionState(ctx, savedModel, savedThinking, ctx.model, pi.getThinkingLevel());
255
- }
418
+ if (loopErrorState.hasError) {
419
+ throw loopErrorState.error;
256
420
  }
257
421
  }
258
422
 
@@ -273,7 +437,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
273
437
  }
274
438
 
275
439
  for (const step of steps) {
276
- if (prompts.get(step.name)?.chain) {
440
+ const stepPrompt = prompts.get(step.name);
441
+ if (!stepPrompt) continue;
442
+ if (stepPrompt.chain) {
277
443
  notify(ctx, `Step "${step.name}" is a chain template. Chain nesting is not supported.`, "error");
278
444
  return false;
279
445
  }
@@ -284,12 +450,13 @@ export default function promptModelExtension(pi: ExtensionAPI) {
284
450
 
285
451
  if (!validateChainSteps()) return;
286
452
 
287
- const originalModel = ctx.model;
453
+ const originalModel = getCurrentModel(ctx);
454
+ const chainInheritedModel = getCurrentModel(ctx);
288
455
  const originalThinking = pi.getThinkingLevel();
289
456
  let currentModel = originalModel;
290
457
  let currentThinking = originalThinking;
291
458
  chainActive = true;
292
- pendingSkill = undefined;
459
+ pendingSkillMessage = undefined;
293
460
  const effectiveMax = totalIterations ?? UNLIMITED_LOOP_CAP;
294
461
  const isUnlimited = totalIterations === null;
295
462
  const useConverge = isUnlimited ? true : converge;
@@ -298,6 +465,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
298
465
  const chainStepNames = steps.map((step) => step.name).join(" -> ");
299
466
  let completedIterations = 0;
300
467
  let converged = false;
468
+ let chainErrorState: ExecutionErrorState = { hasError: false, error: undefined };
301
469
  if (effectiveMax > 1) {
302
470
  loopState = { currentIteration: 1, totalIterations };
303
471
  accumulatedSummaries = [];
@@ -347,7 +515,9 @@ export default function promptModelExtension(pi: ExtensionAPI) {
347
515
  const iterSuffix = stepLoop > 1 ? ` (iter ${stepIteration + 1}/${stepLoop})` : "";
348
516
  notify(ctx, `${loopPrefix}Step ${stepNumber}/${templates.length}: ${template.name}${iterSuffix} ${buildPromptCommandDescription(template)}`, "info");
349
517
 
350
- const prepared = await preparePromptExecution(template, effectiveArgs, currentModel, ctx.modelRegistry);
518
+ const prepared = await preparePromptExecution(template, effectiveArgs, currentModel, ctx.modelRegistry, {
519
+ inheritedModel: chainInheritedModel,
520
+ });
351
521
  if (!prepared) {
352
522
  notify(
353
523
  ctx,
@@ -367,6 +537,13 @@ export default function promptModelExtension(pi: ExtensionAPI) {
367
537
  notify(ctx, prepared.warning, "warning");
368
538
  }
369
539
 
540
+ const skillResolution = resolveSkillMessage(template.skill, ctx.cwd);
541
+ if (skillResolution.kind === "error") {
542
+ notify(ctx, `Step ${stepNumber}/${templates.length} failed: ${skillResolution.error}`, "error");
543
+ aborted = true;
544
+ break;
545
+ }
546
+
370
547
  if (!prepared.selectedModel.alreadyActive) {
371
548
  const switched = await pi.setModel(prepared.selectedModel.model);
372
549
  if (!switched) {
@@ -378,6 +555,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
378
555
  aborted = true;
379
556
  break;
380
557
  }
558
+ runtimeModel = prepared.selectedModel.model;
381
559
  }
382
560
 
383
561
  currentModel = prepared.selectedModel.model;
@@ -386,7 +564,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
386
564
  pi.setThinkingLevel(template.thinking);
387
565
  currentThinking = pi.getThinkingLevel();
388
566
  }
389
- pendingSkill = template.skill ? { name: template.skill, cwd: ctx.cwd } : undefined;
567
+ pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
390
568
 
391
569
  const stepIterationStartId = ctx.sessionManager.getLeafId();
392
570
  pi.sendUserMessage(prepared.content);
@@ -426,30 +604,35 @@ export default function promptModelExtension(pi: ExtensionAPI) {
426
604
  }
427
605
  }
428
606
 
429
- if (shouldRestore) {
430
- await restoreSessionState(ctx, originalModel, originalThinking, currentModel, currentThinking);
431
- }
607
+ } catch (error) {
608
+ chainErrorState = { hasError: true, error };
432
609
  } finally {
433
- pendingSkill = undefined;
610
+ chainErrorState = await restoreAfterExecution(
611
+ ctx,
612
+ shouldRestore,
613
+ originalModel,
614
+ originalThinking,
615
+ currentModel,
616
+ currentThinking,
617
+ chainErrorState,
618
+ "chain",
619
+ );
620
+
621
+ pendingSkillMessage = undefined;
434
622
  chainActive = false;
435
623
  loopState = null;
436
624
  freshCollapse = null;
437
625
  accumulatedSummaries = [];
438
626
  updateLoopStatus(ctx);
439
627
 
440
- if (converged) {
441
- const convergedLabel = totalIterations !== null ? `${completedIterations}/${totalIterations}` : `${completedIterations}`;
442
- notify(ctx, `Loop converged at ${convergedLabel} (no changes)`, "info");
443
- } else if (effectiveMax > 1 && completedIterations > 0) {
444
- if (totalIterations !== null) {
445
- notify(ctx, `Loop finished: ${completedIterations}/${totalIterations} iterations`, "info");
446
- } else if (completedIterations === effectiveMax) {
447
- notify(ctx, `Loop finished: ${completedIterations} iterations (cap reached)`, "info");
448
- } else {
449
- notify(ctx, `Loop finished: ${completedIterations} iterations`, "info");
450
- }
628
+ if (!chainErrorState.hasError) {
629
+ notifyLoopCompletion(ctx, completedIterations, totalIterations, effectiveMax, converged, true);
451
630
  }
452
631
  }
632
+
633
+ if (chainErrorState.hasError) {
634
+ throw chainErrorState.error;
635
+ }
453
636
  }
454
637
 
455
638
  async function runPromptCommand(name: string, args: string, ctx: ExtensionCommandContext) {
@@ -514,7 +697,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
514
697
  return;
515
698
  }
516
699
 
517
- const savedModel = ctx.model;
700
+ const savedModel = getCurrentModel(ctx);
518
701
  const savedThinking = pi.getThinkingLevel();
519
702
  const prepared = await preparePromptExecution(prompt, parseCommandArgs(args), savedModel, ctx.modelRegistry);
520
703
  if (!prepared) {
@@ -530,12 +713,19 @@ export default function promptModelExtension(pi: ExtensionAPI) {
530
713
  notify(ctx, prepared.warning, "warning");
531
714
  }
532
715
 
716
+ const skillResolution = resolveSkillMessage(prompt.skill, ctx.cwd);
717
+ if (skillResolution.kind === "error") {
718
+ notify(ctx, skillResolution.error, "error");
719
+ return;
720
+ }
721
+
533
722
  if (!prepared.selectedModel.alreadyActive) {
534
723
  const switched = await pi.setModel(prepared.selectedModel.model);
535
724
  if (!switched) {
536
725
  notify(ctx, `Failed to switch to model ${prepared.selectedModel.model.provider}/${prepared.selectedModel.model.id}`, "error");
537
726
  return;
538
727
  }
728
+ runtimeModel = prepared.selectedModel.model;
539
729
  }
540
730
 
541
731
  if (prompt.restore && !prepared.selectedModel.alreadyActive) {
@@ -548,26 +738,36 @@ export default function promptModelExtension(pi: ExtensionAPI) {
548
738
  }
549
739
  pi.setThinkingLevel(prompt.thinking);
550
740
  }
551
- pendingSkill = prompt.skill ? { name: prompt.skill, cwd: ctx.cwd } : undefined;
741
+ pendingSkillMessage = skillResolution.kind === "ready" ? skillResolution.message : undefined;
552
742
 
553
743
  pi.sendUserMessage(prepared.content);
554
744
  await waitForTurnStart(ctx);
555
745
  await ctx.waitForIdle();
556
746
  }
557
747
 
558
- pi.on("session_start", async (_event, ctx) => {
748
+ function resetSessionScopedState(ctx: ExtensionContext) {
559
749
  storedCommandCtx = null;
750
+ pendingSkillMessage = undefined;
751
+ previousModel = undefined;
752
+ previousThinking = undefined;
753
+ runtimeModel = ctx.model;
560
754
  toolManager.clearQueue();
561
755
  refreshPrompts(ctx.cwd, ctx);
756
+ }
757
+
758
+ pi.on("session_start", async (_event, ctx) => {
759
+ resetSessionScopedState(ctx);
562
760
  });
563
761
 
564
762
  pi.on("session_switch", async (_event, ctx) => {
565
- storedCommandCtx = null;
566
- toolManager.clearQueue();
567
- refreshPrompts(ctx.cwd, ctx);
763
+ resetSessionScopedState(ctx);
764
+ });
765
+
766
+ pi.on("model_select", async (event) => {
767
+ runtimeModel = event.model;
568
768
  });
569
769
 
570
- pi.on("before_agent_start", async (event, ctx) => {
770
+ pi.on("before_agent_start", async (event) => {
571
771
  let systemPrompt = event.systemPrompt;
572
772
 
573
773
  if (toolManager.isEnabled() && !loopState && !chainActive) {
@@ -586,38 +786,22 @@ export default function promptModelExtension(pi: ExtensionAPI) {
586
786
  systemPrompt += `\n\nYou are on ${iterText} of the same prompt. Previous iterations and their results are visible in the conversation above. Build on that work — focus on what remains to improve.`;
587
787
  }
588
788
 
589
- if (pendingSkill) {
590
- const { name: skillName, cwd } = pendingSkill;
591
- pendingSkill = undefined;
592
-
593
- const skillPath = resolveSkillPath(skillName, cwd);
594
- if (skillPath) {
595
- try {
596
- const skillContent = readSkillContent(skillPath);
597
- pi.sendMessage<SkillLoadedDetails>({
598
- customType: "skill-loaded",
599
- content: `Loaded skill: ${skillName}`,
600
- display: true,
601
- details: { skillName, skillContent, skillPath },
602
- });
603
- systemPrompt += `\n\n<skill name="${skillName}">\n${skillContent}\n</skill>`;
604
- } catch (error) {
605
- notify(ctx, `Failed to read skill "${skillName}": ${error instanceof Error ? error.message : String(error)}`, "error");
606
- }
607
- } else {
608
- notify(ctx, `Skill "${skillName}" not found`, "error");
609
- }
610
- }
789
+ const skillMessage = consumePendingSkillMessage();
790
+ const hasSystemPromptOverride = systemPrompt !== event.systemPrompt;
791
+ if (!hasSystemPromptOverride && !skillMessage) return;
611
792
 
612
- if (systemPrompt !== event.systemPrompt) {
613
- return { systemPrompt };
614
- }
793
+ return {
794
+ ...(hasSystemPromptOverride ? { systemPrompt } : {}),
795
+ ...(skillMessage ? { message: skillMessage } : {}),
796
+ };
615
797
  });
616
798
 
617
799
  pi.on("agent_end", async (_event, ctx) => {
618
800
  if (chainActive) return;
619
801
  if (loopState) return;
620
802
 
803
+ runtimeModel = ctx.model;
804
+
621
805
  const restoreModel = previousModel;
622
806
  const restoreThinking = previousThinking;
623
807
  previousModel = undefined;
@@ -625,7 +809,7 @@ export default function promptModelExtension(pi: ExtensionAPI) {
625
809
 
626
810
  const restoreFn = async () => {
627
811
  if (restoreModel || restoreThinking !== undefined) {
628
- await restoreSessionState(ctx, restoreModel, restoreThinking, ctx.model, pi.getThinkingLevel());
812
+ await restoreSessionState(ctx, restoreModel, restoreThinking, getCurrentModel(ctx), pi.getThinkingLevel());
629
813
  }
630
814
  };
631
815
  const processed = await toolManager.processQueue(ctx, restoreFn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-prompt-template-model",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Prompt template model selector extension for pi coding agent",
6
6
  "author": "Nico Bailon",
@@ -16,14 +16,40 @@ export interface EmptyPromptAbort {
16
16
  warning?: string;
17
17
  }
18
18
 
19
+ interface PromptExecutionOptions {
20
+ inheritedModel?: Model<any>;
21
+ }
22
+
23
+ function sameModel(a: Model<any> | undefined, b: Model<any> | undefined): boolean {
24
+ if (!a || !b) return a === b;
25
+ return a.provider === b.provider && a.id === b.id;
26
+ }
27
+
19
28
  export async function preparePromptExecution(
20
29
  prompt: Pick<PromptWithModel, "name" | "content" | "models">,
21
30
  args: string[],
22
31
  currentModel: Model<any> | undefined,
23
32
  modelRegistry: Pick<ModelRegistry, "find" | "getAll" | "getAvailable" | "getApiKey" | "isUsingOAuth">,
33
+ options?: PromptExecutionOptions,
24
34
  ): Promise<PreparedPromptExecution | EmptyPromptAbort | undefined> {
25
- const selectedModel = await selectModelCandidate(prompt.models, currentModel, modelRegistry);
35
+ const selectedModel =
36
+ prompt.models.length === 0
37
+ ? (() => {
38
+ const hasInheritedModel = options !== undefined && Object.hasOwn(options, "inheritedModel");
39
+ const inheritedModel = hasInheritedModel ? options.inheritedModel : currentModel;
40
+ if (!inheritedModel) {
41
+ return {
42
+ message: `Prompt \`${prompt.name}\` has no \`model\` configured and there is no active session model to inherit.`,
43
+ };
44
+ }
45
+ return {
46
+ model: inheritedModel,
47
+ alreadyActive: sameModel(currentModel, inheritedModel),
48
+ };
49
+ })()
50
+ : await selectModelCandidate(prompt.models, currentModel, modelRegistry);
26
51
  if (!selectedModel) return undefined;
52
+ if ("message" in selectedModel) return selectedModel;
27
53
 
28
54
  const rendered = renderTemplateConditionals(prompt.content, getResolvedModelRef(selectedModel.model), prompt.name);
29
55
  const content = substituteArgs(rendered.content, args);
package/prompt-loader.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
5
5
  import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
6
 
@@ -436,8 +436,10 @@ function loadPromptsWithModelFromDir(
436
436
  if (!frontmatter) continue;
437
437
  const { body } = parsed;
438
438
  const chain = normalizeChain(frontmatter.chain, fullPath, source, diagnostics);
439
- const models = chain ? [] : normalizeModelSpecs(frontmatter.model, fullPath, source, diagnostics);
440
- if (!chain && !models) continue;
439
+ const hasModelField = Object.hasOwn(frontmatter, "model");
440
+ const parsedModels = chain ? [] : normalizeModelSpecs(frontmatter.model, fullPath, source, diagnostics);
441
+ if (!chain && hasModelField && !parsedModels) continue;
442
+ const models = chain ? [] : (parsedModels ?? []);
441
443
 
442
444
  const name = entry.name.slice(0, -3);
443
445
  if (RESERVED_COMMAND_NAMES.has(name)) {
@@ -459,6 +461,17 @@ function loadPromptsWithModelFromDir(
459
461
  const fresh = normalizeFresh(frontmatter.fresh, fullPath, source, diagnostics);
460
462
  const loop = normalizeLoop(frontmatter.loop, fullPath, source, diagnostics);
461
463
  const converge = normalizeConverge(frontmatter.converge, fullPath, source, diagnostics);
464
+ const hasModelConditionalDirectives = /<if-model(?:\s|>)|<else(?:\s|>)|<\/if-model\s*>|<\/else(?:\s|>)/.test(body);
465
+ const hasExtensionSpecificConfig =
466
+ skill !== undefined ||
467
+ thinking !== undefined ||
468
+ fresh === true ||
469
+ loop !== undefined ||
470
+ converge === false ||
471
+ hasModelConditionalDirectives;
472
+ if (!chain && !hasModelField && !hasExtensionSpecificConfig) {
473
+ continue;
474
+ }
462
475
 
463
476
  prompts.push({
464
477
  name,
@@ -550,7 +563,7 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
550
563
  const details = `[chain: ${prompt.chain}] ${sourceLabel}`;
551
564
  return prompt.description ? `${prompt.description} ${details}` : details;
552
565
  }
553
- const modelLabel = prompt.models.map((model) => model.split("/").pop() || model).join("|");
566
+ const modelLabel = prompt.models.length > 0 ? prompt.models.map((model) => model.split("/").pop() || model).join("|") : "current";
554
567
  const skillLabel = prompt.skill ? ` +${prompt.skill}` : "";
555
568
  const thinkingLabel = prompt.thinking ? ` ${prompt.thinking}` : "";
556
569
  const loopLabel = prompt.loop ? ` loop:${prompt.loop}` : "";
@@ -558,16 +571,53 @@ export function buildPromptCommandDescription(prompt: PromptWithModel): string {
558
571
  return prompt.description ? `${prompt.description} ${details}` : details;
559
572
  }
560
573
 
561
- export function resolveSkillPath(skillName: string, cwd: string): string | undefined {
562
- const projectPath = resolve(cwd, ".pi", "skills", skillName, "SKILL.md");
563
- if (existsSync(projectPath)) return projectPath;
574
+ function getSkillCandidates(baseDir: string, skillName: string): string[] {
575
+ return [join(baseDir, skillName, "SKILL.md"), join(baseDir, `${skillName}.md`)];
576
+ }
564
577
 
565
- const userPath = join(homedir(), ".pi", "agent", "skills", skillName, "SKILL.md");
566
- if (existsSync(userPath)) return userPath;
578
+ function* walkAncestors(startDir: string, stopDir?: string): Generator<string> {
579
+ let current = startDir;
580
+ while (true) {
581
+ yield current;
582
+ if (stopDir && current === stopDir) return;
583
+ const parent = dirname(current);
584
+ if (parent === current) return;
585
+ current = parent;
586
+ }
587
+ }
567
588
 
589
+ function findRepoRoot(startDir: string): string | undefined {
590
+ for (const dir of walkAncestors(startDir)) {
591
+ if (existsSync(join(dir, ".git"))) return dir;
592
+ }
568
593
  return undefined;
569
594
  }
570
595
 
596
+ function findFirstExisting(paths: string[]): string | undefined {
597
+ for (const path of paths) {
598
+ if (existsSync(path)) return path;
599
+ }
600
+ return undefined;
601
+ }
602
+
603
+ export function resolveSkillPath(skillName: string, cwd: string): string | undefined {
604
+ const projectDir = resolve(cwd);
605
+
606
+ const projectPiSkill = findFirstExisting(getSkillCandidates(resolve(projectDir, ".pi", "skills"), skillName));
607
+ if (projectPiSkill) return projectPiSkill;
608
+
609
+ const repoRoot = findRepoRoot(projectDir);
610
+ for (const dir of walkAncestors(projectDir, repoRoot)) {
611
+ const projectAgentsSkill = findFirstExisting(getSkillCandidates(join(dir, ".agents", "skills"), skillName));
612
+ if (projectAgentsSkill) return projectAgentsSkill;
613
+ }
614
+
615
+ const globalPiSkill = findFirstExisting(getSkillCandidates(join(homedir(), ".pi", "agent", "skills"), skillName));
616
+ if (globalPiSkill) return globalPiSkill;
617
+
618
+ return findFirstExisting(getSkillCandidates(join(homedir(), ".agents", "skills"), skillName));
619
+ }
620
+
571
621
  export function readSkillContent(skillPath: string): string {
572
622
  const raw = readFileSync(skillPath, "utf-8");
573
623
  return parseFrontmatter(raw).body;