@sangyaahub/pi-marg 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.6"
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.6",
18
18
  "author": {
19
19
  "name": "Sangyaa",
20
20
  "email": "contact@sangyaa.co"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.6 — 2026-10-03
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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`.
8
+
3
9
  ## 1.0.5 — 2026-09-26
4
10
 
5
11
  - 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.6" src="https://img.shields.io/badge/version-1.0.6-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.6 · 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;
@@ -0,0 +1,177 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ probeModelIds,
6
+ validateCustomProvider,
7
+ writeCustomProvider,
8
+ type CustomProviderInput,
9
+ } from "../../../core/provider-config";
10
+ import {
11
+ numberedOptionLabel,
12
+ resolveSelectAnswer,
13
+ type ModelLike,
14
+ } from "../../../core/model-routing";
15
+ import { agentDirectory } from "../../../core/user-route";
16
+
17
+ const SETUP_ITEMS = [
18
+ "Sign in to a provider",
19
+ "Add a custom OpenAI-compatible provider",
20
+ "Cancel",
21
+ ] as const;
22
+
23
+ const DISTINCT_ITEMS = [
24
+ "Add another model",
25
+ "Continue and stop before the paired stage",
26
+ ] as const;
27
+
28
+ export type CatalogSetup =
29
+ | { status: "ready"; models: ModelLike[] }
30
+ | { status: "cancelled" }
31
+ | { status: "restart" };
32
+
33
+ async function askText(ctx: any, title: string, placeholder: string, masked = false): Promise<string | undefined> {
34
+ const value = await ctx.ui.input(title, placeholder, masked ? { masked: true } : undefined);
35
+ const trimmed = typeof value === "string" ? value.trim() : "";
36
+ if (!trimmed) {
37
+ ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
38
+ return undefined;
39
+ }
40
+ return trimmed;
41
+ }
42
+
43
+ async function askItems<T extends string>(
44
+ ctx: any,
45
+ title: string,
46
+ items: readonly T[],
47
+ ): Promise<T | undefined> {
48
+ const answer = await ctx.ui.select(
49
+ title,
50
+ items.map((item, index) => numberedOptionLabel(index, item)),
51
+ { selectionMarker: "radio" },
52
+ );
53
+ const resolved = resolveSelectAnswer(answer, items, (item) => item);
54
+ switch (resolved.status) {
55
+ case "matched":
56
+ return resolved.item;
57
+ case "cancelled":
58
+ ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
59
+ return undefined;
60
+ case "conflict":
61
+ ctx.ui.notify("That answer matches more than one option; no model was saved.", "error");
62
+ return undefined;
63
+ case "unrecognized":
64
+ ctx.ui.notify(`Unrecognized selection ${JSON.stringify(resolved.received)}; no model was saved.`, "error");
65
+ return undefined;
66
+ default: {
67
+ const unreachable: never = resolved;
68
+ return unreachable;
69
+ }
70
+ }
71
+ }
72
+
73
+ function envAlreadySet(home: string | undefined, name: string): boolean {
74
+ if (process.env[name]) return true;
75
+ const envPath = join(agentDirectory("OMP", home), ".env");
76
+ if (!existsSync(envPath)) return false;
77
+ return new RegExp(`^${name}=`, "m").test(readFileSync(envPath, "utf8"));
78
+ }
79
+
80
+ async function chooseProbedIds(ctx: any, typed: string[], probed: string[]): Promise<string[] | undefined> {
81
+ const items = ["Keep the model ids I typed", "Use every returned id", ...probed];
82
+ const choice = await askItems(ctx, "Models to register", items);
83
+ if (!choice) return undefined;
84
+ if (choice === "Keep the model ids I typed") return typed;
85
+ if (choice === "Use every returned id") return probed;
86
+ return [choice];
87
+ }
88
+
89
+ export async function addCustomProvider(ctx: any, home: string | undefined): Promise<CatalogSetup> {
90
+ const providerId = await askText(ctx, "Custom provider", "Provider id (lowercase)");
91
+ if (!providerId) return { status: "cancelled" };
92
+ const displayName = await askText(ctx, "Custom provider", "Display name");
93
+ if (!displayName) return { status: "cancelled" };
94
+ const baseUrl = await askText(ctx, "Custom provider", "Base URL");
95
+ if (!baseUrl) return { status: "cancelled" };
96
+ let allowLocalHttp = false;
97
+ try {
98
+ const url = new URL(baseUrl);
99
+ if (url.protocol === "http:" && url.hostname === "127.0.0.1") {
100
+ const confirm = await askItems(ctx, "Local endpoint", ["Use this local http endpoint", "Cancel"]);
101
+ if (confirm !== "Use this local http endpoint") return { status: "cancelled" };
102
+ allowLocalHttp = true;
103
+ }
104
+ } catch {
105
+ ctx.ui.notify("Base URL must be an https URL.", "error");
106
+ return { status: "cancelled" };
107
+ }
108
+ const envVar = await askText(ctx, "Custom provider", "Env var name");
109
+ if (!envVar) return { status: "cancelled" };
110
+ const modelText = await askText(ctx, "Custom provider", "Model ids, comma-separated");
111
+ if (!modelText) return { status: "cancelled" };
112
+ let modelIds = modelText.split(",").map((id) => id.trim()).filter(Boolean);
113
+ const secret = envAlreadySet(home, envVar) ? undefined : await askText(ctx, "Custom provider", `${envVar} value`, true);
114
+ if (!envAlreadySet(home, envVar) && !secret) return { status: "cancelled" };
115
+
116
+ const draft: CustomProviderInput = {
117
+ providerId,
118
+ displayName,
119
+ baseUrl,
120
+ envVar,
121
+ modelIds,
122
+ allowLocalHttp,
123
+ };
124
+ const problem = validateCustomProvider(draft);
125
+ if (problem) {
126
+ ctx.ui.notify(problem, "error");
127
+ return { status: "cancelled" };
128
+ }
129
+
130
+ const probeKey = secret ?? process.env[envVar];
131
+ if (probeKey) {
132
+ const probed = await probeModelIds(baseUrl, probeKey);
133
+ if (probed) {
134
+ const chosen = await chooseProbedIds(ctx, modelIds, probed);
135
+ if (!chosen) return { status: "cancelled" };
136
+ modelIds = chosen;
137
+ draft.modelIds = chosen;
138
+ }
139
+ }
140
+
141
+ try {
142
+ const directory = agentDirectory("OMP", home);
143
+ writeCustomProvider({
144
+ modelsPath: join(directory, "models.yml"),
145
+ envPath: join(directory, ".env"),
146
+ }, draft, secret);
147
+ } catch (error) {
148
+ ctx.ui.notify(error instanceof Error ? error.message : "Could not write the provider.", "error");
149
+ return { status: "cancelled" };
150
+ }
151
+
152
+ if (typeof ctx.models?.reload === "function") {
153
+ await ctx.models.reload();
154
+ }
155
+ const models = (ctx.models.list() ?? []) as ModelLike[];
156
+ if (models.length === 0) return { status: "restart" };
157
+ return { status: "ready", models };
158
+ }
159
+
160
+ export async function configureEmptyCatalog(ctx: any, home: string | undefined): Promise<CatalogSetup> {
161
+ const choice = await askItems(ctx, "Model setup", SETUP_ITEMS);
162
+ if (!choice || choice === "Cancel") return { status: "cancelled" };
163
+ if (choice === "Sign in to a provider") {
164
+ if (typeof ctx.openLogin === "function") await ctx.openLogin();
165
+ else ctx.ui.notify("Sign in with /login, verify with `omp models`, then run /pi-marg again.", "warning");
166
+ const models = (ctx.models.list() ?? []) as ModelLike[];
167
+ return models.length > 0 ? { status: "ready", models } : { status: "cancelled" };
168
+ }
169
+ return addCustomProvider(ctx, home);
170
+ }
171
+
172
+ export async function resolveSingleModel(ctx: any, home: string | undefined): Promise<CatalogSetup | "defer"> {
173
+ const choice = await askItems(ctx, "Distinct models", DISTINCT_ITEMS);
174
+ if (!choice) return { status: "cancelled" };
175
+ if (choice === "Continue and stop before the paired stage") return "defer";
176
+ return addCustomProvider(ctx, home);
177
+ }
@@ -12,10 +12,10 @@ import {
12
12
  hasConfiguredFallback,
13
13
  modelFailureKey,
14
14
  modelToolUsageLimitFailure,
15
- restoreModelRouteState,
16
15
  type ModelLike,
17
16
  type ModelRouteParams,
18
17
  } from "../../../core/model-routing";
18
+ import { loadRuntimeRoute, rememberRuntimeRoute } from "../../../core/user-route";
19
19
 
20
20
  function toolNames(pi: any): string[] {
21
21
  return (pi.getAllTools?.() ?? [])
@@ -38,12 +38,21 @@ export default function modelRouter(pi: any) {
38
38
  let sessionGeneration = 0;
39
39
  let pendingModelFailure: { selector: string; key: string } | undefined;
40
40
  let pendingToolFailure: { selector: string; key: string } | undefined;
41
+ const routeHome = (ctx: any) => typeof ctx?.agentHome === "string" ? ctx.agentHome : undefined;
42
+ const persistRoute = (nextState: Parameters<typeof rememberRuntimeRoute>[1], ctx?: any) => {
43
+ pi.appendEntry(MODEL_STATE_TYPE, nextState);
44
+ try {
45
+ rememberRuntimeRoute("Pi", nextState, routeHome(ctx));
46
+ } catch {
47
+ ctx?.ui?.notify?.("PiMarg saved the route in this session, but could not update the user route file.", "warning");
48
+ }
49
+ };
41
50
  const restore = (_event: unknown, ctx: any) => {
42
51
  sessionGeneration += 1;
43
52
  recoveryToken = undefined;
44
53
  pendingModelFailure = undefined;
45
54
  pendingToolFailure = undefined;
46
- state = restoreModelRouteState(ctx.sessionManager.getBranch());
55
+ state = loadRuntimeRoute("Pi", ctx.sessionManager.getBranch(), routeHome(ctx));
47
56
  };
48
57
 
49
58
  pi.on("session_start", restore);
@@ -63,7 +72,7 @@ export default function modelRouter(pi: any) {
63
72
  activate: (model) => pi.setModel(model),
64
73
  persist: (nextState) => {
65
74
  if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
66
- pi.appendEntry(MODEL_STATE_TYPE, nextState);
75
+ persistRoute(nextState, ctx);
67
76
  },
68
77
  }, failure.key);
69
78
  if (sessionGeneration !== generation) {
@@ -162,7 +171,7 @@ export default function modelRouter(pi: any) {
162
171
  models,
163
172
  hasExternalDevin: hasExternalDevin(pi),
164
173
  activate: (model) => pi.setModel(model),
165
- persist: (nextState) => pi.appendEntry(MODEL_STATE_TYPE, nextState),
174
+ persist: (nextState) => persistRoute(nextState, ctx),
166
175
  });
167
176
  state = execution.state;
168
177
  return execution.response;
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
5
5
 
6
6
  Load `auto-mode-router` and read its model-routing reference.
7
7
 
8
- - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
9
- - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
8
+ - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
9
+ - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
10
10
  - Do not type or persist a model ID that the live catalog did not return.
11
11
  - Treat any model returned by Pi's registry as a normal in-session route. Treat Devin or another external coding-agent session as a capability-gated external route; creating one requires `post-web` approval.
12
12
  - C/D are the authoritative independent review selectors. Any detected advisor or watcher is supplemental and must not weaken the A≠C or B≠D checks.
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
5
5
 
6
6
  Load `auto-mode-router` and read its model-routing reference.
7
7
 
8
- - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
9
- - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
8
+ - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
9
+ - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
10
10
  - Do not type or persist a model ID that the live catalog did not return.
11
11
  - Distinguish native `devin/swe-*` OMP models from the optional external `devin/mcp-session` route. Only creating the external session requires `post-web` approval.
12
12
  - C/D are the authoritative independent review selectors. A configured OMP advisor is supplemental and must not weaken the A≠C or B≠D checks.
@@ -36,9 +36,13 @@ export interface ModelSelection extends ModelCandidate {
36
36
  selectedAt: string;
37
37
  }
38
38
 
39
+ export type WorkBoundary = "Job/client" | "Personal";
40
+
39
41
  export interface ModelRouteState {
40
42
  version: 2;
41
43
  workType?: number;
44
+ boundary?: WorkBoundary;
45
+ skillMode?: string;
42
46
  selections: Partial<Record<AutoStage, ModelSelection>>;
43
47
  fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
44
48
  activeStage?: AutoStage;
@@ -370,6 +374,38 @@ export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]
370
374
  } satisfies Record<FallbackLevel, ModelCandidate[]>;
371
375
  }
372
376
 
377
+ export interface SavedChoiceAvailability {
378
+ status: "present" | "missing";
379
+ selection: ModelSelection;
380
+ }
381
+
382
+ export function revalidateSavedRoute(
383
+ state: ModelRouteState,
384
+ models: readonly ModelLike[],
385
+ hasExternalDevin: boolean,
386
+ ): {
387
+ selections: Partial<Record<AutoStage, SavedChoiceAvailability>>;
388
+ fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>>;
389
+ } {
390
+ const catalog = buildModelCatalog([...models], hasExternalDevin);
391
+ const fallbackCatalog = buildFallbackCatalog(catalog);
392
+ const selections: Partial<Record<AutoStage, SavedChoiceAvailability>> = {};
393
+ for (const stage of STAGES) {
394
+ const selection = state.selections[stage];
395
+ if (!selection) continue;
396
+ const live = catalog[stage].some((item) => item.selector === selection.selector);
397
+ selections[stage] = { status: live ? "present" : "missing", selection };
398
+ }
399
+ const fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>> = {};
400
+ for (const level of FALLBACK_LEVELS) {
401
+ const selection = state.fallbacks[level];
402
+ if (!selection) continue;
403
+ const live = fallbackCatalog[level].some((item) => item.selector === selection.selector);
404
+ fallbacks[level] = { status: live ? "present" : "missing", selection };
405
+ }
406
+ return { selections, fallbacks };
407
+ }
408
+
373
409
  export function numberedOptionLabel(index: number, text: string): string {
374
410
  return `${index + 1}. ${text}`;
375
411
  }
@@ -383,6 +419,42 @@ export function matchNumberedOption<T>(
383
419
  return items.find((item, index) => numberedOptionLabel(index, labelOf(item)) === answer);
384
420
  }
385
421
 
422
+ export type SelectAnswerResolution<T> =
423
+ | { status: "cancelled" }
424
+ | { status: "matched"; item: T }
425
+ | { status: "conflict" }
426
+ | { status: "unrecognized"; received: string };
427
+
428
+ const DISAMBIGUATION_SUFFIX = /^(?: \(\d+\)| · .+)$/;
429
+
430
+ export function resolveSelectAnswer<T>(
431
+ answer: string | undefined,
432
+ items: readonly T[],
433
+ labelOf: (item: T) => string,
434
+ ): SelectAnswerResolution<T> {
435
+ if (answer === undefined) return { status: "cancelled" };
436
+ const received = answer.trim();
437
+ if (!received) return { status: "cancelled" };
438
+
439
+ const numbered = items.filter((item, index) => numberedOptionLabel(index, labelOf(item)) === received);
440
+ if (numbered.length > 1) return { status: "conflict" };
441
+ if (numbered.length === 1) return { status: "matched", item: numbered[0]! };
442
+
443
+ const exact = items.filter((item) => labelOf(item) === received);
444
+ if (exact.length > 1) return { status: "conflict" };
445
+ if (exact.length === 1) return { status: "matched", item: exact[0]! };
446
+
447
+ const suffixed = items.filter((item) => {
448
+ const label = labelOf(item);
449
+ if (!received.startsWith(label) || received.length === label.length) return false;
450
+ return DISAMBIGUATION_SUFFIX.test(received.slice(label.length));
451
+ });
452
+ if (suffixed.length > 1) return { status: "conflict" };
453
+ if (suffixed.length === 1) return { status: "matched", item: suffixed[0]! };
454
+
455
+ return { status: "unrecognized", received };
456
+ }
457
+
386
458
  export function uniqueProviders(candidates: readonly ModelCandidate[]): string[] {
387
459
  return [...new Set(candidates.map((candidate) => candidate.provider))].sort((left, right) => left.localeCompare(right));
388
460
  }
@@ -621,6 +693,59 @@ export async function executeAutomaticFailover(
621
693
  };
622
694
  }
623
695
 
696
+ export interface IntakeRouteDraft {
697
+ workType: number;
698
+ boundary?: WorkBoundary;
699
+ skillMode?: string;
700
+ selections: Partial<Record<AutoStage, ModelCandidate>>;
701
+ fallbacks: Record<FallbackLevel, ModelCandidate>;
702
+ activateStage: AutoStage;
703
+ }
704
+
705
+ export async function commitIntakeRoute(
706
+ currentState: ModelRouteState,
707
+ intake: IntakeRouteDraft,
708
+ port: ModelRoutePort,
709
+ ): Promise<{ state: ModelRouteState; error?: string }> {
710
+ const selected = intake.selections[intake.activateStage];
711
+ if (!selected) {
712
+ return { state: currentState, error: `Stage ${intake.activateStage} has no model to activate.` };
713
+ }
714
+ if (selected.access === "runtime-model") {
715
+ const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
716
+ const failure = `Could not activate ${selected.selector}; verify ${selected.provider} authentication.`;
717
+ if (!model) return { state: currentState, error: failure };
718
+ try {
719
+ const activated = await port.activate(model);
720
+ if (activated === false) return { state: currentState, error: failure };
721
+ } catch {
722
+ return { state: currentState, error: failure };
723
+ }
724
+ }
725
+
726
+ const selectedAt = port.now?.() ?? new Date().toISOString();
727
+ const selections = Object.fromEntries(
728
+ Object.entries(intake.selections).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
729
+ ) as ModelRouteState["selections"];
730
+ const fallbacks = Object.fromEntries(
731
+ Object.entries(intake.fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
732
+ ) as ModelRouteState["fallbacks"];
733
+ const nextState: ModelRouteState = {
734
+ version: 2,
735
+ workType: intake.workType,
736
+ boundary: intake.boundary,
737
+ skillMode: intake.skillMode,
738
+ selections,
739
+ fallbacks,
740
+ activeStage: intake.activateStage,
741
+ activeFallback: undefined,
742
+ exhaustedSelectors: [],
743
+ handledFailureKeys: [],
744
+ };
745
+ port.persist(nextState);
746
+ return { state: nextState };
747
+ }
748
+
624
749
  export async function executeModelRoute(
625
750
  currentState: ModelRouteState,
626
751
  params: ModelRouteParams,
@@ -670,7 +795,14 @@ export async function executeModelRoute(
670
795
  }
671
796
 
672
797
  if (params.action === "status") {
673
- return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
798
+ const availability = revalidateSavedRoute(currentState, port.models, port.hasExternalDevin);
799
+ return {
800
+ state: currentState,
801
+ response: toolResult(
802
+ JSON.stringify({ ...currentState, availability }, null, 2),
803
+ { state: currentState, availability },
804
+ ),
805
+ };
674
806
  }
675
807
 
676
808
  if (params.action === "select_fallback") {
@@ -0,0 +1,142 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface CustomProviderInput {
5
+ providerId: string;
6
+ displayName: string;
7
+ baseUrl: string;
8
+ envVar: string;
9
+ modelIds: string[];
10
+ allowLocalHttp: boolean;
11
+ }
12
+
13
+ export interface ProviderFiles {
14
+ modelsPath: string;
15
+ envPath: string;
16
+ }
17
+
18
+ const PROVIDER_ID = /^[a-z0-9_-]+$/;
19
+ const ENV_VAR = /^[A-Z][A-Z0-9_]*$/;
20
+ const MODEL_ID = /^[A-Za-z0-9._:-]+$/;
21
+
22
+ export function validateCustomProvider(input: CustomProviderInput): string | undefined {
23
+ if (!PROVIDER_ID.test(input.providerId)) {
24
+ return "Provider id must be lowercase letters, numbers, underscores, or hyphens.";
25
+ }
26
+ if (!input.displayName.trim()) return "Display name is required.";
27
+ let url: URL;
28
+ try {
29
+ url = new URL(input.baseUrl);
30
+ } catch {
31
+ return "Base URL must be an https URL.";
32
+ }
33
+ const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
34
+ if (url.protocol !== "https:" && !(local && input.allowLocalHttp)) {
35
+ return "Base URL must use https unless you confirm a local http://127.0.0.1 endpoint.";
36
+ }
37
+ if (!ENV_VAR.test(input.envVar)) {
38
+ return "Env var name must match the runtime convention, such as EXAMPLE_GATEWAY_API_KEY.";
39
+ }
40
+ if (input.modelIds.length === 0) return "At least one model id is required.";
41
+ const unique = new Set(input.modelIds);
42
+ if (unique.size !== input.modelIds.length) return "Model ids must be unique.";
43
+ if (input.modelIds.some((id) => !MODEL_ID.test(id))) return "Model ids must be non-empty and contain no spaces.";
44
+ return undefined;
45
+ }
46
+
47
+ function yamlString(value: string): string {
48
+ if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
49
+ return JSON.stringify(value);
50
+ }
51
+
52
+ export function renderProviderBlock(input: CustomProviderInput): string {
53
+ const models = input.modelIds.map((id, index) => {
54
+ const name = index === 0 && input.displayName.trim() ? input.displayName.trim() : id;
55
+ return ` - id: ${yamlString(id)}\n name: ${yamlString(name)}`;
56
+ }).join("\n");
57
+ return [
58
+ ` ${input.providerId}:`,
59
+ ` baseUrl: ${yamlString(input.baseUrl)}`,
60
+ " api: openai-completions",
61
+ ` apiKey: ${input.envVar}`,
62
+ " authHeader: true",
63
+ " models:",
64
+ models,
65
+ ].join("\n");
66
+ }
67
+
68
+ export function insertProvider(existing: string, providerId: string, block: string): string {
69
+ if (new RegExp(`^ ${providerId}:\\s*$`, "m").test(existing)) {
70
+ throw new Error(`Provider ${providerId} already exists in models.yml.`);
71
+ }
72
+ const trimmedBlock = block.replace(/\s+$/, "");
73
+ if (!existing.trim()) return `providers:\n${trimmedBlock}\n`;
74
+ const lines = existing.split("\n");
75
+ const providersIndex = lines.findIndex((line) => /^providers:\s*$/.test(line));
76
+ if (providersIndex === -1) {
77
+ const suffix = existing.endsWith("\n") ? "" : "\n";
78
+ return `${existing}${suffix}providers:\n${trimmedBlock}\n`;
79
+ }
80
+ lines.splice(providersIndex + 1, 0, ...trimmedBlock.split("\n"));
81
+ return `${lines.join("\n").replace(/\n*$/, "")}\n`;
82
+ }
83
+
84
+ export function upsertEnvAssignment(existing: string, name: string, value: string): string {
85
+ if (new RegExp(`^${name}=`, "m").test(existing)) return existing;
86
+ const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
87
+ return `${existing}${suffix}${name}=${value}\n`;
88
+ }
89
+
90
+ function atomicWrite(path: string, contents: string): void {
91
+ mkdirSync(dirname(path), { recursive: true });
92
+ const backup = `${path}.bak`;
93
+ const temporary = `${path}.${process.pid}.tmp`;
94
+ const hadOriginal = existsSync(path);
95
+ if (hadOriginal) copyFileSync(path, backup);
96
+ try {
97
+ writeFileSync(temporary, contents, { mode: 0o600 });
98
+ renameSync(temporary, path);
99
+ } catch (error) {
100
+ if (hadOriginal && existsSync(backup)) copyFileSync(backup, path);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ export function writeCustomProvider(files: ProviderFiles, input: CustomProviderInput, secret?: string): void {
106
+ const problem = validateCustomProvider(input);
107
+ if (problem) throw new Error(problem);
108
+ if (secret && (secret.includes("\n") || secret.includes("\r"))) {
109
+ throw new Error("The API key must be a single line.");
110
+ }
111
+ const existing = existsSync(files.modelsPath) ? readFileSync(files.modelsPath, "utf8") : "";
112
+ const next = insertProvider(existing, input.providerId, renderProviderBlock(input));
113
+ if (secret && next.includes(secret)) {
114
+ throw new Error("Refusing to write the API key into models.yml.");
115
+ }
116
+ atomicWrite(files.modelsPath, next);
117
+ if (!secret) return;
118
+ const currentEnv = existsSync(files.envPath) ? readFileSync(files.envPath, "utf8") : "";
119
+ atomicWrite(files.envPath, upsertEnvAssignment(currentEnv, input.envVar, secret));
120
+ }
121
+
122
+ export async function probeModelIds(
123
+ baseUrl: string,
124
+ apiKey: string,
125
+ fetchImpl: typeof fetch = fetch,
126
+ ): Promise<string[] | undefined> {
127
+ const url = `${baseUrl.replace(/\/$/, "")}/models`;
128
+ try {
129
+ const response = await fetchImpl(url, {
130
+ headers: { Authorization: `Bearer ${apiKey}` },
131
+ signal: AbortSignal.timeout(5000),
132
+ });
133
+ if (!response.ok) return undefined;
134
+ const body = await response.json() as { data?: Array<{ id?: unknown }> };
135
+ const ids = (body.data ?? [])
136
+ .map((item) => item.id)
137
+ .filter((id): id is string => typeof id === "string" && id.length > 0);
138
+ return ids.length > 0 ? ids : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
@@ -0,0 +1,100 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import {
6
+ emptyModelRouteState,
7
+ isFallbackLevel,
8
+ isStage,
9
+ restoreModelRouteState,
10
+ type ModelRouteState,
11
+ type RuntimeName,
12
+ type WorkBoundary,
13
+ } from "./model-routing";
14
+
15
+ const ROUTE_FILE = "pi-marg-route.json";
16
+
17
+ export function agentDirectory(runtime: RuntimeName, home = process.env.PI_MARG_AGENT_HOME || homedir()): string {
18
+ return join(home, runtime === "OMP" ? ".omp" : ".pi", "agent");
19
+ }
20
+
21
+ export function userRoutePath(runtime: RuntimeName, home?: string): string {
22
+ return join(home ? agentDirectory(runtime, home) : agentDirectory(runtime), ROUTE_FILE);
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function isBoundary(value: unknown): value is WorkBoundary {
30
+ return value === "Job/client" || value === "Personal";
31
+ }
32
+
33
+ function stringList(value: unknown): string[] | undefined {
34
+ if (!Array.isArray(value)) return undefined;
35
+ return value.filter((item): item is string => typeof item === "string");
36
+ }
37
+
38
+ export function parseUserRoute(raw: string): ModelRouteState | undefined {
39
+ try {
40
+ const data = JSON.parse(raw) as unknown;
41
+ if (!isRecord(data) || data.version !== 2) return undefined;
42
+ const activeStage = isStage(data.activeStage) ? data.activeStage : undefined;
43
+ const activeFallback = isFallbackLevel(data.activeFallback) ? data.activeFallback : undefined;
44
+ return {
45
+ version: 2,
46
+ workType: typeof data.workType === "number" ? data.workType : undefined,
47
+ boundary: isBoundary(data.boundary) ? data.boundary : undefined,
48
+ skillMode: typeof data.skillMode === "string" ? data.skillMode : undefined,
49
+ selections: isRecord(data.selections) ? data.selections as ModelRouteState["selections"] : {},
50
+ fallbacks: isRecord(data.fallbacks) ? data.fallbacks as ModelRouteState["fallbacks"] : {},
51
+ ...(activeStage ? { activeStage } : {}),
52
+ ...(activeFallback ? { activeFallback } : {}),
53
+ ...(stringList(data.exhaustedSelectors) ? { exhaustedSelectors: stringList(data.exhaustedSelectors) } : {}),
54
+ ...(stringList(data.handledFailureKeys) ? { handledFailureKeys: stringList(data.handledFailureKeys) } : {}),
55
+ ...(isRecord(data.lastFailover) ? { lastFailover: data.lastFailover as ModelRouteState["lastFailover"] } : {}),
56
+ };
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ export function readUserRoute(path: string): ModelRouteState | undefined {
63
+ try {
64
+ return parseUserRoute(readFileSync(path, "utf8"));
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function writeUserRoute(path: string, state: ModelRouteState): void {
71
+ const payload = parseUserRoute(JSON.stringify({ ...state, version: 2 })) ?? emptyModelRouteState();
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ const temporary = `${path}.${process.pid}.tmp`;
74
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
75
+ renameSync(temporary, path);
76
+ }
77
+
78
+ export function routeHasChoices(state: ModelRouteState): boolean {
79
+ return Boolean(
80
+ state.workType
81
+ || state.boundary
82
+ || state.skillMode
83
+ || Object.keys(state.selections).length > 0
84
+ || Object.keys(state.fallbacks).length > 0,
85
+ );
86
+ }
87
+
88
+ export function loadSavedRoute(entries: readonly unknown[], file: ModelRouteState | undefined): ModelRouteState {
89
+ const branch = restoreModelRouteState(entries);
90
+ if (routeHasChoices(branch)) return branch;
91
+ return file ?? branch;
92
+ }
93
+
94
+ export function loadRuntimeRoute(runtime: RuntimeName, entries: readonly unknown[], home?: string): ModelRouteState {
95
+ return loadSavedRoute(entries, readUserRoute(userRoutePath(runtime, home)));
96
+ }
97
+
98
+ export function rememberRuntimeRoute(runtime: RuntimeName, state: ModelRouteState, home?: string): void {
99
+ writeUserRoute(userRoutePath(runtime, home), state);
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangyaahub/pi-marg",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "A governed, model-routed development path for Pi Agent and Oh My Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,4 +71,4 @@ if [[ "${runtime}" == "omp" || "${runtime}" == "both" ]]; then
71
71
  omp plugin install --scope user pi-marg@pi-marg-marketplace
72
72
  fi
73
73
 
74
- echo "PiMarg v1.0.5 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
74
+ echo "PiMarg v1.0.6 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
@@ -45,7 +45,7 @@ bun -e '
45
45
  const packageJson = await Bun.file("package.json").json();
46
46
  const marketplace = await Bun.file(".omp-plugin/marketplace.json").json();
47
47
  if (packageJson.name !== "@sangyaahub/pi-marg") throw new Error("Unexpected package name");
48
- if (packageJson.version !== "1.0.5") throw new Error("package.json must be v1.0.5");
48
+ if (packageJson.version !== "1.0.6") throw new Error("package.json must be v1.0.6");
49
49
  if (packageJson.license !== "MIT") throw new Error("package.json must declare MIT");
50
50
  if (packageJson.author !== "Sangyaa") throw new Error("Unexpected package author");
51
51
  if (!packageJson.keywords?.includes("pi-package")) throw new Error("Pi catalog keyword is missing");
@@ -125,6 +125,8 @@ ledger: <path>
125
125
 
126
126
  Reuse this state for normal follow-ups. Start over only when the user explicitly resets it or introduces a materially different outcome.
127
127
 
128
+ The v2 model route persists `boundary` (`Job/client` or `Personal`) and `skillMode` with the A/B/C/D selectors and backups. Reopening `/pi-marg` highlights those saved values. The conversational `context:` and `mode:` lines above are the same facts in the ledger's shorthand; they are not a second configuration store.
129
+
128
130
  ## Availability check
129
131
 
130
132
  Before declaring the task ready: