@sangyaahub/pi-marg 1.0.5 → 1.0.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.
@@ -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.5"
10
+ "version": "1.0.7"
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.5",
17
+ "version": "1.0.7",
18
18
  "author": {
19
19
  "name": "Sangyaa",
20
20
  "email": "contact@sangyaa.co"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.7 — 2026-10-03
4
+
5
+ - Stages 1–3 of Auto Mode intake now use the host selection tool (`ask` on OMP) instead of asking the user to type numbers in chat.
6
+ - The `auto-intake` agent can call `ask`, so the selection window is available when intake runs as a subagent.
7
+
8
+ ## 1.0.6 — 2026-10-03
9
+
10
+ - Reopening `/pi-marg` highlights the saved boundary, work type, skill, and A/B/C/D selectors. Enter keeps that row, and intake activates the first required stage model before the work message is sent.
11
+ - A saved route now survives a new OMP or Pi process. A selector that disappeared from the live catalog is reported missing and is not remapped or activated.
12
+ - An empty OMP catalog can sign in, add a custom OpenAI-compatible provider, or cancel. Provider files store the env var name, and the secret is written only to the runtime `.env`.
13
+
3
14
  ## 1.0.5 — 2026-09-26
4
15
 
5
16
  - Documented how to register CheaperInference in Oh My Pi so Kimi and GLM models can be chosen at every A/B/C/D stage.
package/MODEL-SETUP.md CHANGED
@@ -16,7 +16,7 @@ LIVE AUTHENTICATED CATALOG
16
16
  high backup ──► low backup
17
17
  ```
18
18
 
19
- If the live catalog is empty, PiMarg stops and promotes provider/subscription setup instead of guessing models.
19
+ If the live catalog is empty, `/pi-marg` offers three actions: sign in with `/login`, add a custom OpenAI-compatible provider, or cancel. The custom path writes `apiKey: ENV_VAR_NAME` into `~/.omp/agent/models.yml` and the secret only into `~/.omp/agent/.env`. Cancelling writes neither file.
20
20
 
21
21
  ## Roles
22
22
 
@@ -37,7 +37,7 @@ Use Pi's normal provider configuration or environment variables, then verify ava
37
37
  pi --list-models
38
38
  ```
39
39
 
40
- The Pi adapter reads `ctx.scopedModels` when the session has a model allowlist; otherwise it reads `ctx.modelRegistry.getAvailable()`. This respects both authentication and an intentional session scope.
40
+ The Pi adapter reads `ctx.scopedModels` when the session has a model allowlist; otherwise it reads `ctx.modelRegistry.getAvailable()`. A model can be authenticated and still hidden because that session allowlist is active. This is a scope limit, not a missing login.
41
41
 
42
42
  ## Oh My Pi
43
43
 
@@ -72,8 +72,8 @@ Any of those four models can be chosen for any stage. Calls spend CheaperInferen
72
72
 
73
73
  ## Selection workflow
74
74
 
75
- 1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models.
76
- 2. Confirm the work type so PiMarg knows which stages are required.
75
+ 1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models. Reopening either one highlights the saved boundary, work type, skill, and each saved A/B/C/D and backup selector. Pressing Enter keeps that highlighted row.
76
+ 2. Confirm the work type so PiMarg knows which stages are required. A saved choice that is no longer in the live catalog is reported as missing and is not activated or remapped.
77
77
  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).
78
78
  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.
79
79
  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.
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.5" src="https://img.shields.io/badge/version-1.0.5-0451F7"></a>
6
+ <a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.7" src="https://img.shields.io/badge/version-1.0.7-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.5 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
14
+ **Version 1.0.7 · 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
 
@@ -122,7 +122,13 @@ Authenticate providers using the active runtime's normal login flow, then run:
122
122
  /auto-models choose
123
123
  ```
124
124
 
125
- The router shows every model in the live authenticated and enabled registry. Stage-appropriate choices appear first and all other enabled choices remain selectable. It does not promise that a subscription route is free or unlimited. See [MODEL-SETUP.md](MODEL-SETUP.md).
125
+ The router shows every model in the live authenticated and enabled registry. Stage-appropriate choices appear first and all other enabled choices remain selectable. Reopening `/pi-marg` highlights the saved selector; Enter keeps it and activates the first required stage model. It does not promise that a subscription route is free or unlimited. See [MODEL-SETUP.md](MODEL-SETUP.md).
126
+
127
+ ### Custom OpenAI-compatible provider
128
+
129
+ When OMP has no models yet, `/pi-marg` can register a custom provider without leaving the intake. It asks for a provider id, display name, base URL, env var name, and model ids. `models.yml` stores `apiKey: YOUR_ENV_VAR`. The secret goes only in `~/.omp/agent/.env`. After the runtime reloads, every model from that provider is offered at A, B, C, and D. A and C must still be different models, and B and D must still be different models. With only one model, PiMarg says so before stage A and lets you add another model or stop before the paired stage.
130
+
131
+ CheaperInference below is one worked example of that same shape, not the only custom provider PiMarg accepts.
126
132
 
127
133
  ### CheaperInference (Kimi and GLM)
128
134
 
@@ -5,7 +5,7 @@ import {
5
5
  buildFallbackCatalog,
6
6
  buildModelCatalog,
7
7
  candidatesForProvider,
8
- emptyCatalogSetupMessage,
8
+ commitIntakeRoute,
9
9
  emptyModelRouteState,
10
10
  executeAutomaticFailover,
11
11
  executeModelRoute,
@@ -13,12 +13,12 @@ import {
13
13
  findTerminalUsageLimitFailure,
14
14
  formatFailoverRecoveryMessage,
15
15
  hasConfiguredFallback,
16
- matchNumberedOption,
17
16
  modelFailureKey,
18
17
  modelToolUsageLimitFailure,
19
18
  numberedOptionLabel,
20
19
  requiredStagesForWorkType,
21
- restoreModelRouteState,
20
+ resolveSelectAnswer,
21
+ revalidateSavedRoute,
22
22
  uniqueProviders,
23
23
  type AutoStage,
24
24
  type FallbackLevel,
@@ -26,8 +26,12 @@ import {
26
26
  type ModelLike,
27
27
  type ModelRouteParams,
28
28
  type ModelRouteState,
29
+ type SelectAnswerResolution,
30
+ type WorkBoundary,
29
31
  } from "../../../core/model-routing";
30
32
  import { normalizeCapabilityNames } from "../../../core/runtime-capabilities";
33
+ import { loadRuntimeRoute, rememberRuntimeRoute } from "../../../core/user-route";
34
+ import { configureEmptyCatalog, resolveSingleModel } from "./provider-setup";
31
35
 
32
36
  const WORK_BOUNDARIES = ["1. Job/client", "2. Personal"] as const;
33
37
 
@@ -71,10 +75,68 @@ function commandPrompt(args: string): string {
71
75
  return trimmed;
72
76
  }
73
77
 
78
+ const WORK_BOUNDARY_ITEMS = ["Job/client", "Personal"] as const;
79
+ const WORK_TYPE_ITEMS = [
80
+ "New idea or development from scratch",
81
+ "New feature for an existing repository",
82
+ "Improve an existing feature",
83
+ "Pro-active bug fix",
84
+ "Security review or fixes",
85
+ "Thoughts about an existing repository or features",
86
+ "Business, sales, or other non-technical analysis",
87
+ ] as const;
88
+ const SKILL_MODE_ITEMS = [
89
+ "Compound Engineering",
90
+ "Superpowers",
91
+ "GSD Core",
92
+ "PiMarg chooses the best available skill",
93
+ ] as const;
94
+
74
95
  function optionLabel(candidate: ModelCandidate): string {
75
96
  return `${candidate.label} — ${candidate.selector}`;
76
97
  }
77
98
 
99
+ function savedIndex<T>(items: readonly T[], matches: (item: T) => boolean): number | undefined {
100
+ const index = items.findIndex(matches);
101
+ return index >= 0 ? index : undefined;
102
+ }
103
+
104
+ function takeAnswer<T>(ctx: any, resolution: SelectAnswerResolution<T>): T | undefined {
105
+ switch (resolution.status) {
106
+ case "matched":
107
+ return resolution.item;
108
+ case "cancelled":
109
+ ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
110
+ return undefined;
111
+ case "conflict":
112
+ ctx.ui.notify("That answer matches more than one option; no model was saved.", "error");
113
+ return undefined;
114
+ case "unrecognized":
115
+ ctx.ui.notify(`Unrecognized selection ${JSON.stringify(resolution.received)}; no model was saved.`, "error");
116
+ return undefined;
117
+ default: {
118
+ const unreachable: never = resolution;
119
+ return unreachable;
120
+ }
121
+ }
122
+ }
123
+
124
+ async function askChoice<T>(
125
+ ctx: any,
126
+ title: string,
127
+ items: readonly T[],
128
+ labelOf: (item: T) => string,
129
+ options: ReadonlyArray<string | { label: string; description?: string }>,
130
+ settings: { initialIndex?: number; helpText?: string } = {},
131
+ ): Promise<T | undefined> {
132
+ const answer = await ctx.ui.select(title, [...options], {
133
+ selectionMarker: "radio",
134
+ ...(settings.helpText ? { helpText: settings.helpText } : {}),
135
+ ...(typeof settings.initialIndex === "number" ? { initialIndex: settings.initialIndex } : {}),
136
+ });
137
+ return takeAnswer(ctx, resolveSelectAnswer(answer, items, labelOf));
138
+ }
139
+
78
140
  function optionDescription(stage: AutoStage, candidate: ModelCandidate): string {
79
141
  const preferredTier = stage === "A" || stage === "C" ? "frontier" : "execution";
80
142
  const fit = candidate.tier === preferredTier ? "Recommended fit" : "Available";
@@ -86,6 +148,7 @@ async function chooseModel(
86
148
  stage: AutoStage,
87
149
  candidates: ModelCandidate[],
88
150
  selected: Partial<Record<AutoStage, ModelCandidate>>,
151
+ saved?: ModelCandidate,
89
152
  ): Promise<ModelCandidate | undefined> {
90
153
  const opposite: Record<AutoStage, AutoStage> = { A: "C", C: "A", B: "D", D: "B" };
91
154
  const other = selected[opposite[stage]];
@@ -98,13 +161,14 @@ async function chooseModel(
98
161
  return undefined;
99
162
  }
100
163
 
164
+ const highlighted = saved && eligible.some((candidate) => candidate.selector === saved.selector) ? saved : undefined;
101
165
  return chooseCandidateByProvider(ctx, eligible, {
102
166
  providerTitle: `Stage ${stage} provider`,
103
167
  modelTitle: `Stage ${stage} model`,
104
168
  purpose: STAGE_PURPOSE[stage],
105
169
  providerDescription: (name) => `All authenticated ${name} models for ${STAGE_PURPOSE[stage]}`,
106
170
  modelDescription: (candidate) => optionDescription(stage, candidate),
107
- });
171
+ }, highlighted);
108
172
  }
109
173
 
110
174
  async function chooseFallbackModel(
@@ -112,6 +176,7 @@ async function chooseFallbackModel(
112
176
  level: FallbackLevel,
113
177
  candidates: ModelCandidate[],
114
178
  other?: ModelCandidate,
179
+ saved?: ModelCandidate,
115
180
  ): Promise<ModelCandidate | undefined> {
116
181
  const eligible = candidates.filter((candidate) => (
117
182
  candidate.access === "runtime-model" && (!other || candidate.identity !== other.identity)
@@ -125,6 +190,7 @@ async function chooseFallbackModel(
125
190
  return undefined;
126
191
  }
127
192
 
193
+ const highlighted = saved && eligible.some((candidate) => candidate.selector === saved.selector) ? saved : undefined;
128
194
  return chooseCandidateByProvider(ctx, eligible, {
129
195
  providerTitle: `${title} backup provider`,
130
196
  modelTitle: `${title} backup model`,
@@ -133,7 +199,7 @@ async function chooseFallbackModel(
133
199
  modelDescription: (candidate) => (
134
200
  `${FALLBACK_PURPOSE[level]}${candidate.billingNote ? ` · ${candidate.billingNote}` : ""}`
135
201
  ),
136
- });
202
+ }, highlighted);
137
203
  }
138
204
 
139
205
  async function chooseCandidateByProvider(
@@ -146,20 +212,23 @@ async function chooseCandidateByProvider(
146
212
  providerDescription(provider: string): string;
147
213
  modelDescription(candidate: ModelCandidate): string;
148
214
  },
215
+ saved?: ModelCandidate,
149
216
  ): Promise<ModelCandidate | undefined> {
150
217
  const providers = uniqueProviders(candidates);
151
218
  const providerOptions = providers.map((name, index) => ({
152
219
  label: numberedOptionLabel(index, `${name} (${candidatesForProvider(candidates, name).length})`),
153
220
  description: copy.providerDescription(name),
154
221
  }));
155
- const providerAnswer = await ctx.ui.select(copy.providerTitle, providerOptions, {
156
- selectionMarker: "radio",
157
- helpText: `${providers.length} providers · ${candidates.length} models · ${copy.purpose}`,
158
- });
159
- const provider = matchNumberedOption(
160
- providerAnswer,
222
+ const provider = await askChoice(
223
+ ctx,
224
+ copy.providerTitle,
161
225
  providers,
162
226
  (name) => `${name} (${candidatesForProvider(candidates, name).length})`,
227
+ providerOptions,
228
+ {
229
+ initialIndex: saved ? savedIndex(providers, (name) => name === saved.provider) : undefined,
230
+ helpText: `${providers.length} providers · ${candidates.length} models · ${copy.purpose}`,
231
+ },
163
232
  );
164
233
  if (!provider) return undefined;
165
234
 
@@ -168,29 +237,11 @@ async function chooseCandidateByProvider(
168
237
  label: numberedOptionLabel(index, optionLabel(candidate)),
169
238
  description: copy.modelDescription(candidate),
170
239
  }));
171
- const answer = await ctx.ui.select(copy.modelTitle, modelOptions, {
172
- selectionMarker: "radio",
173
- helpText: `${providerModels.length} ${provider} choices · ${copy.purpose}`,
240
+ const savedHere = saved?.provider === provider ? saved.selector : undefined;
241
+ return askChoice(ctx, copy.modelTitle, providerModels, optionLabel, modelOptions, {
242
+ initialIndex: savedHere ? savedIndex(providerModels, (candidate) => candidate.selector === savedHere) : undefined,
243
+ helpText: `${providerModels.length} ${provider} choices · ${copy.purpose}${savedHere ? ` · saved ${savedHere}` : ""}`,
174
244
  });
175
- return matchNumberedOption(answer, providerModels, optionLabel);
176
- }
177
-
178
- function selectedState(
179
- workType: number,
180
- selected: Partial<Record<AutoStage, ModelCandidate>>,
181
- fallbacks: Record<FallbackLevel, ModelCandidate>,
182
- ): ModelRouteState {
183
- const selectedAt = new Date().toISOString();
184
- return {
185
- version: 2,
186
- workType,
187
- selections: Object.fromEntries(
188
- Object.entries(selected).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
189
- ) as ModelRouteState["selections"],
190
- fallbacks: Object.fromEntries(
191
- Object.entries(fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
192
- ) as ModelRouteState["fallbacks"],
193
- };
194
245
  }
195
246
 
196
247
  function intakeMessage(
@@ -227,11 +278,20 @@ export default function modelRouter(pi: any) {
227
278
  let recoveryToken: symbol | undefined;
228
279
  let sessionGeneration = 0;
229
280
  let pendingToolFailure: { selector: string; key: string } | undefined;
281
+ const routeHome = (ctx: any) => typeof ctx?.agentHome === "string" ? ctx.agentHome : undefined;
282
+ const persistRoute = (nextState: ModelRouteState, ctx?: any) => {
283
+ pi.appendEntry(MODEL_STATE_TYPE, nextState);
284
+ try {
285
+ rememberRuntimeRoute("OMP", nextState, routeHome(ctx));
286
+ } catch {
287
+ ctx?.ui?.notify?.("PiMarg saved the route in this session, but could not update the user route file.", "warning");
288
+ }
289
+ };
230
290
  const restore = (_event: unknown, ctx: any) => {
231
291
  sessionGeneration += 1;
232
292
  recoveryToken = undefined;
233
293
  pendingToolFailure = undefined;
234
- state = restoreModelRouteState(ctx.sessionManager.getBranch());
294
+ state = loadRuntimeRoute("OMP", ctx.sessionManager.getBranch(), routeHome(ctx));
235
295
  };
236
296
 
237
297
  pi.on("session_start", restore);
@@ -253,7 +313,7 @@ export default function modelRouter(pi: any) {
253
313
  activate: (model) => pi.setModel(model),
254
314
  persist: (nextState) => {
255
315
  if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
256
- pi.appendEntry(MODEL_STATE_TYPE, nextState);
316
+ persistRoute(nextState, ctx);
257
317
  },
258
318
  }, failure.key);
259
319
  if (sessionGeneration !== generation) {
@@ -348,43 +408,167 @@ export default function modelRouter(pi: any) {
348
408
  }
349
409
  if (!request) return;
350
410
 
351
- const boundaryLabel = await ctx.ui.select("Work boundary", [...WORK_BOUNDARIES], {
352
- selectionMarker: "radio",
353
- });
354
- const boundary = matchNumberedOption(boundaryLabel, ["Job/client", "Personal"], (item) => item);
411
+ const boundary = await askChoice(
412
+ ctx,
413
+ "Work boundary",
414
+ WORK_BOUNDARY_ITEMS,
415
+ (item) => item,
416
+ WORK_BOUNDARIES,
417
+ { initialIndex: savedIndex(WORK_BOUNDARY_ITEMS, (item) => item === state.boundary) },
418
+ );
355
419
  if (!boundary) return;
356
420
 
357
- const workTypeLabel = await ctx.ui.select("Work type", [...WORK_TYPES], { selectionMarker: "radio" });
358
- const workType = Number.parseInt(workTypeLabel?.match(/^([1-7])\./)?.[1] ?? "", 10);
359
- if (!Number.isInteger(workType)) return;
360
-
361
- const skillMode = await ctx.ui.select("Workflow skill", [...SKILL_MODES], { selectionMarker: "radio" });
362
- if (!skillMode) return;
363
-
364
- const liveModels = ctx.models.list() as ModelLike[];
421
+ const workTypeItem = await askChoice(
422
+ ctx,
423
+ "Work type",
424
+ WORK_TYPE_ITEMS,
425
+ (item) => item,
426
+ WORK_TYPES,
427
+ {
428
+ initialIndex: typeof state.workType === "number" && state.workType >= 1 && state.workType <= WORK_TYPE_ITEMS.length
429
+ ? state.workType - 1
430
+ : undefined,
431
+ },
432
+ );
433
+ if (!workTypeItem) return;
434
+ const workType = WORK_TYPE_ITEMS.indexOf(workTypeItem) + 1;
435
+ const workTypeLabel = WORK_TYPES[workType - 1]!;
436
+
437
+ const skillItem = await askChoice(
438
+ ctx,
439
+ "Workflow skill",
440
+ SKILL_MODE_ITEMS,
441
+ (item) => item,
442
+ SKILL_MODES,
443
+ { initialIndex: savedIndex(SKILL_MODES, (item) => item === state.skillMode) },
444
+ );
445
+ if (!skillItem) return;
446
+ const skillMode = SKILL_MODES[SKILL_MODE_ITEMS.indexOf(skillItem)]!;
447
+
448
+ const home = routeHome(ctx);
449
+ const pauseForRestart = () => {
450
+ rememberRuntimeRoute("OMP", {
451
+ version: 2,
452
+ workType,
453
+ boundary,
454
+ skillMode,
455
+ selections: {},
456
+ fallbacks: {},
457
+ }, home);
458
+ ctx.ui.notify(
459
+ "Restart OMP so it reloads models.yml, then run /pi-marg again. Work type and skill answers are saved and model selection will resume.",
460
+ "warning",
461
+ );
462
+ };
463
+ let liveModels = ctx.models.list() as ModelLike[];
365
464
  if (liveModels.length === 0) {
366
- ctx.ui.notify(emptyCatalogSetupMessage("OMP"), "error");
367
- return;
465
+ const setup = await configureEmptyCatalog(ctx, home);
466
+ if (setup.status === "restart") {
467
+ pauseForRestart();
468
+ return;
469
+ }
470
+ if (setup.status !== "ready") return;
471
+ liveModels = setup.models;
368
472
  }
473
+
474
+ let stopBeforePair = false;
475
+ if (liveModels.length === 1) {
476
+ const decision = await resolveSingleModel(ctx, home);
477
+ if (decision === "defer") {
478
+ stopBeforePair = true;
479
+ } else if (decision.status === "restart") {
480
+ pauseForRestart();
481
+ return;
482
+ } else if (decision.status !== "ready") {
483
+ return;
484
+ } else {
485
+ liveModels = decision.models;
486
+ }
487
+ }
488
+
369
489
  const catalog = buildModelCatalog(liveModels, hasExternalDevin(pi));
370
490
  const fallbackCatalog = buildFallbackCatalog(catalog);
491
+ const availability = revalidateSavedRoute(state, liveModels, hasExternalDevin(pi));
371
492
  const selected: Partial<Record<AutoStage, ModelCandidate>> = {};
372
- for (const stage of requiredStagesForWorkType(workType)) {
373
- const choice = await chooseModel(ctx, stage, catalog[stage], selected);
493
+ const stages = requiredStagesForWorkType(workType);
494
+ const oppositeStage: Record<AutoStage, AutoStage> = { A: "C", C: "A", B: "D", D: "B" };
495
+ for (const stage of stages) {
496
+ if (stopBeforePair && selected[oppositeStage[stage]]) {
497
+ ctx.ui.notify(
498
+ `Stage ${stage} needs a different model from Stage ${oppositeStage[stage]}. Stopping before that paired stage; nothing was saved as a complete route.`,
499
+ "warning",
500
+ );
501
+ return;
502
+ }
503
+ const savedChoice = availability.selections[stage];
504
+ if (savedChoice?.status === "missing") {
505
+ ctx.ui.notify(
506
+ `Saved Stage ${stage} selector ${savedChoice.selection.selector} is missing from the live catalog. Choose a replacement.`,
507
+ "warning",
508
+ );
509
+ }
510
+ const choice = await chooseModel(
511
+ ctx,
512
+ stage,
513
+ catalog[stage],
514
+ selected,
515
+ savedChoice?.status === "present" ? savedChoice.selection : undefined,
516
+ );
374
517
  if (!choice) return;
375
518
  selected[stage] = choice;
376
519
  }
377
520
 
378
- const high = await chooseFallbackModel(ctx, "high", fallbackCatalog.high);
521
+ const savedHigh = availability.fallbacks.high;
522
+ if (savedHigh?.status === "missing") {
523
+ ctx.ui.notify(
524
+ `Saved high backup ${savedHigh.selection.selector} is missing from the live catalog. Choose a replacement.`,
525
+ "warning",
526
+ );
527
+ }
528
+ const high = await chooseFallbackModel(
529
+ ctx,
530
+ "high",
531
+ fallbackCatalog.high,
532
+ undefined,
533
+ savedHigh?.status === "present" ? savedHigh.selection : undefined,
534
+ );
379
535
  if (!high) return;
380
- const low = await chooseFallbackModel(ctx, "low", fallbackCatalog.low, high);
536
+ const savedLow = availability.fallbacks.low;
537
+ if (savedLow?.status === "missing") {
538
+ ctx.ui.notify(
539
+ `Saved low backup ${savedLow.selection.selector} is missing from the live catalog. Choose a replacement.`,
540
+ "warning",
541
+ );
542
+ }
543
+ const low = await chooseFallbackModel(
544
+ ctx,
545
+ "low",
546
+ fallbackCatalog.low,
547
+ high,
548
+ savedLow?.status === "present" ? savedLow.selection : undefined,
549
+ );
381
550
  if (!low) return;
382
- const fallbacks = { high, low };
383
551
 
384
- const nextState = selectedState(workType, selected, fallbacks);
385
- state = nextState;
386
- pi.appendEntry(MODEL_STATE_TYPE, nextState);
387
- pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel!, skillMode, selected, fallbacks));
552
+ const committed = await commitIntakeRoute(state, {
553
+ workType,
554
+ boundary: boundary as WorkBoundary,
555
+ skillMode,
556
+ selections: selected,
557
+ fallbacks: { high, low },
558
+ activateStage: stages[0]!,
559
+ }, {
560
+ runtimeName: "OMP",
561
+ models: liveModels,
562
+ hasExternalDevin: hasExternalDevin(pi),
563
+ activate: (model) => pi.setModel(model),
564
+ persist: (nextState) => persistRoute(nextState, ctx),
565
+ });
566
+ if (committed.error) {
567
+ ctx.ui.notify(committed.error, "error");
568
+ return;
569
+ }
570
+ state = committed.state;
571
+ pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel, skillMode, selected, { high, low }));
388
572
  },
389
573
  });
390
574
 
@@ -407,7 +591,7 @@ export default function modelRouter(pi: any) {
407
591
  models,
408
592
  hasExternalDevin: hasExternalDevin(pi),
409
593
  activate: (model) => pi.setModel(model),
410
- persist: (nextState) => pi.appendEntry(MODEL_STATE_TYPE, nextState),
594
+ persist: (nextState) => persistRoute(nextState, ctx),
411
595
  });
412
596
  state = execution.state;
413
597
  return execution.response;