@sangyaahub/pi-marg 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "PiMarg — a governed development path for Pi Agent and Oh My Pi.",
10
- "version": "1.0.1"
10
+ "version": "1.0.4"
11
11
  },
12
12
  "plugins": [
13
13
  {
14
14
  "name": "pi-marg",
15
15
  "description": "Intent-led development workflow with live A/B/C/D routing, capability detection, verification, and approval gates.",
16
16
  "source": "./",
17
- "version": "1.0.1",
17
+ "version": "1.0.4",
18
18
  "author": {
19
19
  "name": "Sangyaa",
20
20
  "email": "contact@sangyaa.co"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.4 — 2026-09-17
4
+
5
+ - Added two runtime-only recovery choices: a strong **high** backup followed by an economical **low** backup.
6
+ - Automatically switches and resumes after OMP or Pi settles a usage-limit/quota failure, with a visible notification and a no-repeat-side-effects continuation instruction.
7
+ - Handles direct runtime-model failures and detected model-execution tool failures (including external Devin and subagents); old v1 session routes migrate without losing their A/B/C/D selections.
8
+ - Deduplicates repeated failure events, ignores transient throttles, advances to low only when the active high model fails, restores Pi tree-navigation state, and warns without stranding the run if ledger persistence fails after activation.
9
+
10
+ ## 1.0.3 — 2026-09-14
11
+
12
+ - Made model routing explicitly model-agnostic: live OMP/Pi authenticated catalogs are the only source of truth, with soft preferred-class ranking that never requires a maintainer's subscriptions.
13
+ - Promotes provider/subscription setup when the live catalog is empty (`/login` + `omp models` on OMP; `pi --list-models` on Pi).
14
+ - Shows required A/B/C/D stage options and catalog sections in ascending letter order.
15
+
16
+ ## 1.0.2 — 2026-09-12
17
+
18
+ - Changed live catalog presentation to a provider-first numbered two-step: list every authenticated provider, then every model for the chosen provider.
19
+ - Added optional `provider` filter on `auto_model_route` catalog so agents can request one provider's full model list without truncating giant catalogs.
20
+ - Documented an OMP catalog advisory when both `cursor` and `openai-codex` are live: mid-session switches can hit a harness `call_id` max-length 64 rejection (tracked upstream in `@oh-my-pi/pi-coding-agent`); recover with a fresh session that does not replay the affected tool history.
21
+
3
22
  ## 1.0.1 — 2026-09-10
4
23
 
5
24
  - Added the global `pi-marg start` and `pi-marg "<work prompt>"` OMP launch commands.
package/MODEL-SETUP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Model setup
2
2
 
3
- PiMarg reads the active Pi or OMP model registry whenever `auto_model_route` runs. Exact selectors are deliberately absent from this document because providers rename and replace models.
3
+ PiMarg reads the active Pi or OMP model registry whenever `auto_model_route` runs. It is model-agnostic: whatever subscriptions or providers someone has configured are what appear in the catalog. Exact selectors are deliberately absent from this document because providers rename and replace models.
4
4
 
5
5
  ```text
6
6
  LIVE AUTHENTICATED CATALOG
@@ -11,15 +11,20 @@ LIVE AUTHENTICATED CATALOG
11
11
  A / C B / D
12
12
  │ │
13
13
  └─ different ─┘ within each pair
14
+ │
15
+ ▼ quota failure
16
+ high backup ──► low backup
14
17
  ```
15
18
 
19
+ If the live catalog is empty, PiMarg stops and promotes provider/subscription setup instead of guessing models.
20
+
16
21
  ## Roles
17
22
 
18
- | Stage | Purpose | Preferred class |
23
+ | Stage | Purpose | Preferred class (optional ranking) |
19
24
  |---|---|---|
20
- | A | Discovery and difficult reasoning | Latest eligible Claude frontier, OpenAI frontier, or Grok frontier |
25
+ | A | Discovery and difficult reasoning | Latest eligible Claude frontier, OpenAI frontier, or Grok frontier when present |
26
+ | B | Execute the accepted plan | Claude/OpenAI mid-layer, Cursor Composer, eligible zero-metered route, or Devin SWE when present |
21
27
  | C | Independent plan review | A different frontier model/family from A |
22
- | B | Execute the accepted plan | Claude/OpenAI mid-layer, Cursor Composer, eligible zero-metered route, or Devin SWE |
23
28
  | D | Independent implementation review | A different execution model/family from B |
24
29
 
25
30
  “Zero-metered” means the runtime reports zero token price for that route. Subscription quotas and pool limits can still apply.
@@ -48,15 +53,54 @@ The OMP adapter reads `ctx.models.list()`, which supplies authenticated and enab
48
53
 
49
54
  1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models.
50
55
  2. Confirm the work type so PiMarg knows which stages are required.
51
- 3. Select A and C when discovery/review is required; recommended frontier choices appear first.
52
- 4. Select B and D when implementation is required; recommended execution choices appear first.
53
- 5. Resolve any A=C or B=D identity conflict before continuing.
54
- 6. Activate the saved selector immediately before its stage.
56
+ 3. Required stage prompts and catalog sections are always shown in ascending order: **A, then B, then C, then D** (skipping stages that do not apply).
57
+ 4. For each required stage, choose a **provider** first (numbered `1. …`), then a **model** from that provider (numbered `1. …`). Exact `provider/id` selectors are the selection keys—not display numbers alone.
58
+ 5. Select A and C when discovery/review is required; recommended frontier choices appear first within their lists when recognizable, but every live model remains selectable.
59
+ 6. Select B and D when implementation is required; recommended execution choices appear first within their lists when recognizable, but every live model remains selectable.
60
+ 7. Resolve any A=C or B=D identity conflict before continuing.
61
+ 8. Activate the saved selector immediately before its stage.
62
+ 9. Choose a runtime-only **high backup** (strongest recovery fit) and a distinct runtime-only **low backup** (economical recovery fit). External-agent entries cannot be backups because PiMarg must be able to activate them in the current session.
63
+ 10. Automatic recovery becomes active only after both backups are saved. On a positively identified terminal usage-limit failure, PiMarg preserves the stage, waits for the runtime to settle, switches high then low, notifies the user, and resumes from the last safe point. Duplicate events and temporary throttles do not consume another backup.
64
+
65
+ ### Tool form (`auto_model_route`)
66
+
67
+ ```text
68
+ # 1) Overview: every provider, no model dump
69
+ auto_model_route action=catalog workType=<n>
70
+
71
+ # 2) Full model list for one authenticated provider
72
+ auto_model_route action=catalog workType=<n> provider=<provider-name>
55
73
 
56
- Every authenticated and enabled runtime model remains selectable at every required stage. The preferred classes above control ranking, not visibility.
74
+ # 3) Persist and activate an exact selector
75
+ auto_model_route action=select stage=<A|B|C|D> target=<provider>/<model-id> workType=<n>
76
+
77
+ # 4) Persist the automatic recovery pair (does not activate either model yet)
78
+ auto_model_route action=select_fallback fallback=high target=<provider>/<model-id>
79
+ auto_model_route action=select_fallback fallback=low target=<provider>/<model-id>
80
+ ```
81
+
82
+ Never present a hand-curated subset that omits a logged-in provider. Every authenticated and enabled runtime model remains selectable at every required stage. Soft preferred-class ranking controls order, not visibility.
57
83
 
58
84
  The normalized identity check treats the same underlying model reached through different providers as the same candidate when their identifiers normalize equally.
59
85
 
86
+ ### OMP cursor → openai-codex note
87
+
88
+ On OMP with `@oh-my-pi/pi-coding-agent` 18.1.17, switching from a `cursor` stage to an `openai-codex` stage in the **same** session can fail if prior `cursor-agent` tool ids are replayed (`call_id` longer than 64). PiMarg surfaces this as a catalog advisory when both providers are live. Workaround: start a **fresh session** that does not replay the affected tool history. The durable fix belongs in the harness, not in PiMarg selectors.
89
+
90
+ ## Provider recommendation (optional ranking only)
91
+
92
+ These names are soft ranking hints for common catalogs. They are never required. If your subscriptions expose different models, PiMarg still lists every authenticated entry and lets you choose.
93
+
94
+ - Cursor Composer or a zero-metered Cursor route: economical B/D work when present in the live catalog.
95
+ - Claude Fable/Opus: A/C product thinking, architecture, specification, and cross-family review.
96
+ - Claude Sonnet: B/D mid-layer implementation or review.
97
+ - OpenAI Astra/Sol: A/C complex discovery and plan review.
98
+ - OpenAI Terra/Luna/Codex-class: B/D implementation and review, chosen by task risk and cost.
99
+ - Latest matching Grok 4.6-or-newer model: A/C independent frontier analysis or current web/social context.
100
+ - Native Devin SWE: B/D when exposed by the runtime. External Devin: bounded delegation with acceptance criteria and a quota limit.
101
+
102
+ Use Pi or OMP's own authentication and model UI as the authority for what the account can actually access. When nothing is authenticated, set up any subscription first (`/login` on OMP, or Pi provider credentials), then re-run PiMarg.
103
+
60
104
  ## Devin
61
105
 
62
106
  Native Devin model entries are ordinary B/D candidates when the runtime catalog exposes them. A separate external Devin session appears only when a Devin tool is detected. Creating that session is a web mutation and requires one-use `post-web` approval.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.1" src="https://img.shields.io/badge/version-1.0.1-0451F7"></a>
6
+ <a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.4" src="https://img.shields.io/badge/version-1.0.4-0451F7"></a>
7
7
  <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-C27B1A"></a>
8
8
  <a href="https://pi.dev/packages?name=pi-marg"><img alt="Pi package" src="https://img.shields.io/badge/Pi-package-0338A8"></a>
9
9
  <a href="https://omp.sh"><img alt="Oh My Pi plugin" src="https://img.shields.io/badge/OMP-plugin-021E5C"></a>
@@ -11,7 +11,7 @@
11
11
 
12
12
  # PiMarg
13
13
 
14
- **Version 1.0.1 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
14
+ **Version 1.0.4 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
15
15
 
16
16
  PiMarg turns an ordinary prompt into a governed development path. It identifies the work boundary, selects one engineering method, discovers the models and tools the active runtime really provides, separates thinking from execution, records evidence, and stops before protected actions.
17
17
 
@@ -55,14 +55,14 @@ The same model-selection and approval logic lives in `core/`. Runtime-specific c
55
55
  |---|---|---|
56
56
  | Ordinary prompts enter the workflow | Pi bootstrap hook | OMP global rule |
57
57
  | Shared skills and decision flow | Yes | Yes |
58
- | Dynamic A/B/C/D model routing | Pi model registry | OMP model registry |
58
+ | Dynamic A/B/C/D model routing + high/low failover | Pi model registry | OMP model registry |
59
59
  | Approval guard | Pi event adapter | OMP event adapter |
60
60
  | Slash workflow entry points | Pi prompt templates | OMP commands |
61
61
  | CodeGraph | CLI/integration when detected | MCP integration when configured |
62
62
  | Advisor, checkpoint, security scan, Hindsight | Extension/fallback when detected | Native capability when enabled |
63
63
  | Parallel agents | Only when a compatible task extension exists | Native `task`/workflow routes when available |
64
64
 
65
- Tested locally against Pi Agent `0.85.1` and OMP `18.1.15`. Optional features are always capability-detected, so their absence produces a recorded fallback rather than a false success claim.
65
+ Tested locally against Pi Agent `0.85.1` and OMP `18.2.1`. Optional features are always capability-detected, so their absence produces a recorded fallback rather than a false success claim.
66
66
 
67
67
  ## Try the demo
68
68
 
@@ -141,11 +141,25 @@ pi-marg "add CSV export to this repository"
141
141
  /auto add CSV export to this repository
142
142
  ```
143
143
 
144
- The native picker asks for work boundary, work type, workflow skill, and every model required by that work type. It shows only models reported as authenticated and enabled by OMP, with recommended stage fits first.
144
+ The native picker asks for work boundary, work type, workflow skill, every model required by that work type, then two distinct runtime backups: **high** first and **low** second. Automatic recovery stays disabled until both are saved. It shows only models reported as authenticated and enabled by OMP, with recommended fits first.
145
+
146
+ ```text
147
+ stage model hits quota
148
+ │
149
+ ▼
150
+ OMP/Pi native retry
151
+ │ still failed
152
+ ▼
153
+ high backup ──fails──► low backup
154
+ │ │
155
+ └──── notify + resume┘
156
+ ```
157
+
158
+ PiMarg switches only after a positively identified terminal usage-limit/quota failure (or a settled model-execution tool failure such as external Devin or a subagent); a temporary 429/rate-limit response does not consume a backup. It records and deduplicates each failure, advances from high to low only when the active high model itself fails, notifies the user, and resumes with an instruction to inspect current state before repeating any side effect. If both backups are exhausted, it stops with an actionable request to run the model chooser again.
145
159
 
146
160
  Useful entry points:
147
161
 
148
- - `/auto-models choose` — refresh A/B/C/D choices.
162
+ - `/auto-models choose` — refresh A/B/C/D choices and the high/low recovery pair.
149
163
  - `/auto-runtime status` — show detected capabilities and fallbacks.
150
164
  - `/auto-review plan|code` — run the independent review contract.
151
165
  - `/auto-status` — show the current ledger and evidence.
@@ -1,12 +1,27 @@
1
1
  import {
2
+ FALLBACK_LEVELS,
2
3
  MODEL_STATE_TYPE,
3
4
  STAGES,
5
+ buildFallbackCatalog,
4
6
  buildModelCatalog,
7
+ candidatesForProvider,
8
+ emptyCatalogSetupMessage,
5
9
  emptyModelRouteState,
10
+ executeAutomaticFailover,
6
11
  executeModelRoute,
12
+ failedModelSelector,
13
+ findTerminalUsageLimitFailure,
14
+ formatFailoverRecoveryMessage,
15
+ hasConfiguredFallback,
16
+ matchNumberedOption,
17
+ modelFailureKey,
18
+ modelToolUsageLimitFailure,
19
+ numberedOptionLabel,
7
20
  requiredStagesForWorkType,
8
21
  restoreModelRouteState,
22
+ uniqueProviders,
9
23
  type AutoStage,
24
+ type FallbackLevel,
10
25
  type ModelCandidate,
11
26
  type ModelLike,
12
27
  type ModelRouteParams,
@@ -14,7 +29,7 @@ import {
14
29
  } from "../../../core/model-routing";
15
30
  import { normalizeCapabilityNames } from "../../../core/runtime-capabilities";
16
31
 
17
- const WORK_BOUNDARIES = ["Job/client", "Personal"] as const;
32
+ const WORK_BOUNDARIES = ["1. Job/client", "2. Personal"] as const;
18
33
 
19
34
  const WORK_TYPES = [
20
35
  "1. New idea or development from scratch",
@@ -40,6 +55,11 @@ const STAGE_PURPOSE: Record<AutoStage, string> = {
40
55
  D: "implementation second-eye review",
41
56
  };
42
57
 
58
+ const FALLBACK_PURPOSE: Record<FallbackLevel, string> = {
59
+ high: "strong recovery model used first",
60
+ low: "economical recovery model used if high is unavailable or exhausted",
61
+ };
62
+
43
63
  function hasExternalDevin(pi: any): boolean {
44
64
  return normalizeCapabilityNames(pi.getAllTools?.() ?? [])
45
65
  .some((name) => /(?:^|[_-])devin(?:[_-]|$)/i.test(name));
@@ -77,28 +97,99 @@ async function chooseModel(
77
97
  );
78
98
  return undefined;
79
99
  }
80
- const options = eligible.map((candidate) => ({
81
- label: optionLabel(candidate),
82
- description: optionDescription(stage, candidate),
100
+
101
+ return chooseCandidateByProvider(ctx, eligible, {
102
+ providerTitle: `Stage ${stage} provider`,
103
+ modelTitle: `Stage ${stage} model`,
104
+ purpose: STAGE_PURPOSE[stage],
105
+ providerDescription: (name) => `All authenticated ${name} models for ${STAGE_PURPOSE[stage]}`,
106
+ modelDescription: (candidate) => optionDescription(stage, candidate),
107
+ });
108
+ }
109
+
110
+ async function chooseFallbackModel(
111
+ ctx: any,
112
+ level: FallbackLevel,
113
+ candidates: ModelCandidate[],
114
+ other?: ModelCandidate,
115
+ ): Promise<ModelCandidate | undefined> {
116
+ const eligible = candidates.filter((candidate) => (
117
+ candidate.access === "runtime-model" && (!other || candidate.identity !== other.identity)
118
+ ));
119
+ const title = level === "high" ? "High" : "Low";
120
+ if (eligible.length === 0) {
121
+ ctx.ui.notify(
122
+ "Automatic recovery requires two distinct authenticated runtime models. Configure another model, then run /pi-marg again.",
123
+ "error",
124
+ );
125
+ return undefined;
126
+ }
127
+
128
+ return chooseCandidateByProvider(ctx, eligible, {
129
+ providerTitle: `${title} backup provider`,
130
+ modelTitle: `${title} backup model`,
131
+ purpose: FALLBACK_PURPOSE[level],
132
+ providerDescription: (name) => `Authenticated ${name} models · ${FALLBACK_PURPOSE[level]}`,
133
+ modelDescription: (candidate) => (
134
+ `${FALLBACK_PURPOSE[level]}${candidate.billingNote ? ` · ${candidate.billingNote}` : ""}`
135
+ ),
136
+ });
137
+ }
138
+
139
+ async function chooseCandidateByProvider(
140
+ ctx: any,
141
+ candidates: ModelCandidate[],
142
+ copy: {
143
+ providerTitle: string;
144
+ modelTitle: string;
145
+ purpose: string;
146
+ providerDescription(provider: string): string;
147
+ modelDescription(candidate: ModelCandidate): string;
148
+ },
149
+ ): Promise<ModelCandidate | undefined> {
150
+ const providers = uniqueProviders(candidates);
151
+ const providerOptions = providers.map((name, index) => ({
152
+ label: numberedOptionLabel(index, `${name} (${candidatesForProvider(candidates, name).length})`),
153
+ description: copy.providerDescription(name),
83
154
  }));
84
- const answer = await ctx.ui.select(`Stage ${stage} model`, options, {
155
+ const providerAnswer = await ctx.ui.select(copy.providerTitle, providerOptions, {
85
156
  selectionMarker: "radio",
86
- helpText: `${eligible.length} authenticated and enabled choices · ${STAGE_PURPOSE[stage]}`,
157
+ helpText: `${providers.length} providers · ${candidates.length} models · ${copy.purpose}`,
87
158
  });
88
- return eligible.find((candidate) => optionLabel(candidate) === answer);
159
+ const provider = matchNumberedOption(
160
+ providerAnswer,
161
+ providers,
162
+ (name) => `${name} (${candidatesForProvider(candidates, name).length})`,
163
+ );
164
+ if (!provider) return undefined;
165
+
166
+ const providerModels = candidatesForProvider(candidates, provider);
167
+ const modelOptions = providerModels.map((candidate, index) => ({
168
+ label: numberedOptionLabel(index, optionLabel(candidate)),
169
+ description: copy.modelDescription(candidate),
170
+ }));
171
+ const answer = await ctx.ui.select(copy.modelTitle, modelOptions, {
172
+ selectionMarker: "radio",
173
+ helpText: `${providerModels.length} ${provider} choices · ${copy.purpose}`,
174
+ });
175
+ return matchNumberedOption(answer, providerModels, optionLabel);
89
176
  }
90
177
 
91
178
  function selectedState(
92
179
  workType: number,
93
180
  selected: Partial<Record<AutoStage, ModelCandidate>>,
181
+ fallbacks: Record<FallbackLevel, ModelCandidate>,
94
182
  ): ModelRouteState {
95
183
  const selectedAt = new Date().toISOString();
96
184
  return {
97
- version: 1,
185
+ version: 2,
98
186
  workType,
99
187
  selections: Object.fromEntries(
100
188
  Object.entries(selected).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
101
189
  ) as ModelRouteState["selections"],
190
+ fallbacks: Object.fromEntries(
191
+ Object.entries(fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
192
+ ) as ModelRouteState["fallbacks"],
102
193
  };
103
194
  }
104
195
 
@@ -108,6 +199,7 @@ function intakeMessage(
108
199
  workType: string,
109
200
  skillMode: string,
110
201
  selected: Partial<Record<AutoStage, ModelCandidate>>,
202
+ fallbacks: Record<FallbackLevel, ModelCandidate>,
111
203
  ): string {
112
204
  const models = STAGES
113
205
  .filter((stage) => selected[stage])
@@ -122,15 +214,23 @@ function intakeMessage(
122
214
  `- Work type: ${workType}`,
123
215
  `- Workflow skill: ${skillMode}`,
124
216
  models,
217
+ `- High backup: ${fallbacks.high.selector}`,
218
+ `- Low backup: ${fallbacks.low.selector}`,
125
219
  "",
126
- "Load auto-mode-router, record these choices in the session ledger, activate each saved model before its stage, and continue the workflow. Ask interactively only for information that is still missing.",
220
+ "Load auto-mode-router, record these choices in the session ledger, activate each saved model before its stage, and continue the workflow. If a terminal usage-limit failure occurs, PiMarg will switch high then low and resume from the last safe point. Ask interactively only for information that is still missing.",
127
221
  ].join("\n");
128
222
  }
129
223
 
130
224
  export default function modelRouter(pi: any) {
131
225
  const z = pi.zod;
132
226
  let state = emptyModelRouteState();
227
+ let recoveryToken: symbol | undefined;
228
+ let sessionGeneration = 0;
229
+ let pendingToolFailure: { selector: string; key: string } | undefined;
133
230
  const restore = (_event: unknown, ctx: any) => {
231
+ sessionGeneration += 1;
232
+ recoveryToken = undefined;
233
+ pendingToolFailure = undefined;
134
234
  state = restoreModelRouteState(ctx.sessionManager.getBranch());
135
235
  };
136
236
 
@@ -139,6 +239,93 @@ export default function modelRouter(pi: any) {
139
239
  pi.on("session_branch", restore);
140
240
  pi.on("session_tree", restore);
141
241
 
242
+ const recoverFromUsageLimit = async (failure: { selector: string; key: string }, ctx: any) => {
243
+ if (recoveryToken) return;
244
+ if (!hasConfiguredFallback(state)) return;
245
+ const generation = sessionGeneration;
246
+ const token = Symbol("pi-marg-recovery");
247
+ recoveryToken = token;
248
+ try {
249
+ const execution = await executeAutomaticFailover(state, failure.selector, {
250
+ runtimeName: "OMP",
251
+ models: ctx.models.list() as ModelLike[],
252
+ hasExternalDevin: hasExternalDevin(pi),
253
+ activate: (model) => pi.setModel(model),
254
+ persist: (nextState) => {
255
+ if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
256
+ pi.appendEntry(MODEL_STATE_TYPE, nextState);
257
+ },
258
+ }, failure.key);
259
+ if (sessionGeneration !== generation) {
260
+ ctx.ui?.notify?.(
261
+ "PiMarg stopped an in-flight recovery because the session changed. Verify the active model before continuing.",
262
+ "warning",
263
+ );
264
+ return;
265
+ }
266
+ if (execution.ignoredDuplicate) return;
267
+ state = execution.state;
268
+ if (!execution.switched) {
269
+ if (!execution.stateChanged) return;
270
+ ctx.ui?.notify?.(
271
+ "PiMarg could not resume automatically because no configured backup model remains. Run /auto-models choose after restoring provider access.",
272
+ "error",
273
+ );
274
+ if (execution.persistenceError) {
275
+ ctx.ui?.notify?.(
276
+ `PiMarg updated recovery state only in memory because the session ledger could not be saved: ${execution.persistenceError}`,
277
+ "error",
278
+ );
279
+ }
280
+ return;
281
+ }
282
+
283
+ const title = execution.switched.level === "high" ? "High" : "Low";
284
+ ctx.ui?.notify?.(
285
+ execution.reusedCurrent
286
+ ? `PiMarg kept the ${title} backup ${execution.switched.selection.selector} active after ${failure.selector} reached its limit and is resuming safely.`
287
+ : `PiMarg switched ${failure.selector} to the ${title} backup ${execution.switched.selection.selector} and is resuming automatically.`,
288
+ "warning",
289
+ );
290
+ if (execution.persistenceError) {
291
+ ctx.ui?.notify?.(
292
+ `The model switched, but PiMarg could not save the recovery state to the session ledger: ${execution.persistenceError}`,
293
+ "error",
294
+ );
295
+ }
296
+ pi.sendUserMessage(
297
+ formatFailoverRecoveryMessage(
298
+ state,
299
+ failure.selector,
300
+ execution.switched.level,
301
+ execution.switched.selection.selector,
302
+ execution.reusedCurrent,
303
+ ),
304
+ { deliverAs: "aside" },
305
+ );
306
+ } finally {
307
+ if (recoveryToken === token) recoveryToken = undefined;
308
+ }
309
+ };
310
+
311
+ pi.on("agent_end", async (event: any, ctx: any) => {
312
+ if (event?.willContinue || event?.isTerminal === false) return;
313
+ const failed = findTerminalUsageLimitFailure(event?.messages ?? []);
314
+ const failure = failed
315
+ ? {
316
+ selector: failedModelSelector(failed, ctx.models?.current?.() ?? ctx.model),
317
+ key: modelFailureKey(failed),
318
+ }
319
+ : pendingToolFailure;
320
+ pendingToolFailure = undefined;
321
+ if (!failure) return;
322
+ await recoverFromUsageLimit(failure, ctx);
323
+ });
324
+
325
+ pi.on("tool_execution_end", (event: any) => {
326
+ pendingToolFailure = modelToolUsageLimitFailure(event) ?? pendingToolFailure;
327
+ });
328
+
142
329
  pi.registerCommand("pi-marg", {
143
330
  description: "Start PiMarg with interactive intake, or pass a work prompt.",
144
331
  async handler(args: string, ctx: any) {
@@ -164,7 +351,8 @@ export default function modelRouter(pi: any) {
164
351
  const boundaryLabel = await ctx.ui.select("Work boundary", [...WORK_BOUNDARIES], {
165
352
  selectionMarker: "radio",
166
353
  });
167
- if (!WORK_BOUNDARIES.includes(boundaryLabel)) return;
354
+ const boundary = matchNumberedOption(boundaryLabel, ["Job/client", "Personal"], (item) => item);
355
+ if (!boundary) return;
168
356
 
169
357
  const workTypeLabel = await ctx.ui.select("Work type", [...WORK_TYPES], { selectionMarker: "radio" });
170
358
  const workType = Number.parseInt(workTypeLabel?.match(/^([1-7])\./)?.[1] ?? "", 10);
@@ -175,10 +363,11 @@ export default function modelRouter(pi: any) {
175
363
 
176
364
  const liveModels = ctx.models.list() as ModelLike[];
177
365
  if (liveModels.length === 0) {
178
- ctx.ui.notify("No authenticated and enabled OMP models were found. Configure a provider, then run /pi-marg again.", "error");
366
+ ctx.ui.notify(emptyCatalogSetupMessage("OMP"), "error");
179
367
  return;
180
368
  }
181
369
  const catalog = buildModelCatalog(liveModels, hasExternalDevin(pi));
370
+ const fallbackCatalog = buildFallbackCatalog(catalog);
182
371
  const selected: Partial<Record<AutoStage, ModelCandidate>> = {};
183
372
  for (const stage of requiredStagesForWorkType(workType)) {
184
373
  const choice = await chooseModel(ctx, stage, catalog[stage], selected);
@@ -186,22 +375,30 @@ export default function modelRouter(pi: any) {
186
375
  selected[stage] = choice;
187
376
  }
188
377
 
189
- const nextState = selectedState(workType, selected);
378
+ const high = await chooseFallbackModel(ctx, "high", fallbackCatalog.high);
379
+ if (!high) return;
380
+ const low = await chooseFallbackModel(ctx, "low", fallbackCatalog.low, high);
381
+ if (!low) return;
382
+ const fallbacks = { high, low };
383
+
384
+ const nextState = selectedState(workType, selected, fallbacks);
190
385
  state = nextState;
191
386
  pi.appendEntry(MODEL_STATE_TYPE, nextState);
192
- pi.sendUserMessage(intakeMessage(request, boundaryLabel, workTypeLabel!, skillMode, selected));
387
+ pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel!, skillMode, selected, fallbacks));
193
388
  },
194
389
  });
195
390
 
196
391
  pi.registerTool({
197
392
  name: "auto_model_route",
198
393
  label: "PiMarg Model Router",
199
- description: "Discover, select, and activate live A/B/C/D model choices while enforcing A different from C and B different from D.",
394
+ description: "Discover, select, and activate live A/B/C/D models plus distinct high/low automatic usage-limit backups.",
200
395
  parameters: z.object({
201
- action: z.enum(["catalog", "select", "activate", "status"]),
396
+ action: z.enum(["catalog", "select", "select_fallback", "activate", "status"]),
202
397
  stage: z.enum(STAGES).optional(),
398
+ fallback: z.enum(FALLBACK_LEVELS).optional(),
203
399
  target: z.string().optional(),
204
400
  workType: z.number().optional(),
401
+ provider: z.string().optional(),
205
402
  }),
206
403
  async execute(_toolCallId: string, params: ModelRouteParams, _signal: unknown, _onUpdate: unknown, ctx: any) {
207
404
  const models = ctx.models.list() as ModelLike[];