Commit 74e574d8a36f

Vincent Demeester <vincent@sbr.pm>
2026-09-11 10:39:37
feat(pi): route subagents by active provider
Subagents now favor the active provider while masking unavailable Vertex models and using only explicit same-tier model fallbacks. Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent af68ac5
docs/superpowers/plans/2026-09-11-pi-subagent-model-routing.md
@@ -0,0 +1,480 @@
+# Pi Subagent Model Routing Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Prefer the parent Pi session’s provider for subagents while selecting only explicitly allowed same-tier model fallbacks.
+
+**Architecture:** Extract deterministic routing from the subagent extension into a pure `model-routing.ts` module. The extension reads settings and catalog/credential state, calls the resolver with the parent provider, and only spawns Pi after a candidate is selected. Agent files request current model versions; same-tier historical versions live solely in settings fallback chains.
+
+**Tech Stack:** TypeScript extension modules, Bun’s `node:test` runner, Pi model registry, JSON settings, Markdown agent definitions.
+
+---
+
+## File structure
+
+| File | Responsibility |
+| --- | --- |
+| `dots/pi/agent/extensions/subagent/model-routing.ts` | Pure ordered candidate selection, exact model masking, catalog matching, and structured rejections. |
+| `dots/pi/agent/extensions/subagent/model-routing.test.ts` | Unit tests for provider priority, masks, same-tier fallbacks, credentials, and legacy behavior. |
+| `dots/pi/agent/extensions/subagent/index.ts` | Load and validate routing settings, collect model availability, invoke the resolver, and display failure diagnostics. |
+| `dots/pi/agent/settings.json` | Default provider fallback order plus explicit current-model fallback chains and Vertex masks. |
+| `dots/pi/agent/ensure-settings.sh` | Ensure new routing keys are merged into the runtime settings and print accurate configuration guidance. |
+| `dots/pi/agent/agents/*.md` | Pin each specialized agent to its current intended model tier. |
+| `dots/pi/agent/extensions/subagent/README.md` | Document active-provider-first resolution, masks, same-tier fallbacks, diagnostics, and current provider names. |
+
+This repository forbids commits unless the user explicitly asks. Do not add commit steps or create a commit while executing this plan.
+
+### Task 1: Define and test pure routing behavior
+
+**Files:**
+- Create: `dots/pi/agent/extensions/subagent/model-routing.ts`
+- Create: `dots/pi/agent/extensions/subagent/model-routing.test.ts`
+
+- [ ] **Step 1: Write failing tests for provider order and masks**
+
+Create `model-routing.test.ts` using Bun’s Node compatibility APIs. Define a shared catalog and credential set in the test file, then assert the parent provider wins and a provider-specific mask is skipped:
+
+```ts
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { resolveSubagentModel } from "./model-routing.ts";
+
+const catalog = [
+  { provider: "github-copilot", modelId: "claude-opus-5" },
+  { provider: "github-copilot", modelId: "claude-opus-4-8" },
+  { provider: "anthropic-vertex", modelId: "claude-opus-5" },
+  { provider: "anthropic-vertex", modelId: "claude-opus-4-8" },
+  { provider: "google", modelId: "claude-opus-4-8" },
+];
+const ready = new Set(catalog.map(({ provider, modelId }) => `${provider}\u0000${modelId}`));
+
+function resolve(overrides = {}) {
+  return resolveSubagentModel({
+    parentProvider: "github-copilot",
+    providerPreference: ["anthropic-vertex", "google"],
+    requestedModel: "claude-opus-5",
+    modelFallbacks: { "claude-opus-5": ["claude-opus-4-8"] },
+    providerModelMasks: {},
+    catalog,
+    ready,
+    ...overrides,
+  });
+}
+
+describe("resolveSubagentModel", () => {
+  it("prefers the parent provider for the requested model", () => {
+    assert.deepEqual(resolve(), {
+      selected: { provider: "github-copilot", modelId: "claude-opus-5" },
+      rejected: [],
+    });
+  });
+
+  it("skips a masked parent provider/model pair before selection", () => {
+    assert.deepEqual(
+      resolve({
+        providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+      }),
+      {
+        selected: { provider: "github-copilot", modelId: "claude-opus-4-8" },
+        rejected: [{ provider: "github-copilot", modelId: "claude-opus-5", reason: "masked" }],
+      },
+    );
+  });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: FAIL because `./model-routing.ts` does not exist.
+
+- [ ] **Step 3: Implement the minimal pure resolver**
+
+Create `model-routing.ts`. Keep it free of Pi SDK imports and I/O so tests can exercise it directly:
+
+```ts
+export type ModelEntry = { provider: string; modelId: string };
+export type RejectionReason = "masked" | "not-in-catalog" | "missing-credentials";
+export type Rejection = { provider: string; modelId: string; reason: RejectionReason };
+export type Resolution = {
+  selected: { provider: string; modelId: string } | null;
+  rejected: Rejection[];
+};
+
+export type ResolveSubagentModelOptions = {
+  parentProvider?: string;
+  providerPreference: string[];
+  requestedModel: string;
+  modelFallbacks: Record<string, string[]>;
+  providerModelMasks: Record<string, string[]>;
+  catalog: ModelEntry[];
+  ready: ReadonlySet<string>;
+};
+
+const key = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
+
+function orderedUnique(values: Array<string | undefined>): string[] {
+  return [...new Set(values.filter((value): value is string => Boolean(value)))];
+}
+
+function catalogModelId(catalog: ModelEntry[], provider: string, requested: string): string | undefined {
+  return catalog.find((entry) => entry.provider === provider && entry.modelId === requested)?.modelId
+    ?? catalog.find((entry) => entry.provider === provider && entry.modelId.startsWith(`${requested}@`))?.modelId;
+}
+
+export function resolveSubagentModel(options: ResolveSubagentModelOptions): Resolution {
+  const providers = orderedUnique([options.parentProvider, ...options.providerPreference]);
+  const models = orderedUnique([options.requestedModel, ...(options.modelFallbacks[options.requestedModel] ?? [])]);
+  const rejected: Rejection[] = [];
+
+  for (const provider of providers) {
+    const masked = new Set(options.providerModelMasks[provider] ?? []);
+    for (const requestedModel of models) {
+      if (masked.has(requestedModel)) {
+        rejected.push({ provider, modelId: requestedModel, reason: "masked" });
+        continue;
+      }
+      const modelId = catalogModelId(options.catalog, provider, requestedModel);
+      if (!modelId) {
+        rejected.push({ provider, modelId: requestedModel, reason: "not-in-catalog" });
+        continue;
+      }
+      if (!options.ready.has(key(provider, modelId))) {
+        rejected.push({ provider, modelId, reason: "missing-credentials" });
+        continue;
+      }
+      return { selected: { provider, modelId }, rejected };
+    }
+  }
+  return { selected: null, rejected };
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS, with both tests green.
+
+### Task 2: Cover all approved routing constraints with tests
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/model-routing.test.ts`
+
+- [ ] **Step 1: Add failing fallback, provider fallback, credential, and no-cross-tier tests**
+
+Append these cases within the existing `describe` block:
+
+```ts
+it("uses an allowed same-tier fallback on the parent provider", () => {
+  const result = resolve({
+    providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+  });
+  assert.deepEqual(result.selected, { provider: "github-copilot", modelId: "claude-opus-4-8" });
+});
+
+it("moves to configured providers after parent candidates are unavailable", () => {
+  const result = resolve({
+    catalog: [{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" }],
+    ready: new Set(["anthropic-vertex\u0000claude-opus-4-8"]),
+  });
+  assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-8" });
+});
+
+it("does not invent a cross-tier fallback", () => {
+  const result = resolve({
+    catalog: [{ provider: "github-copilot", modelId: "claude-sonnet-5" }],
+    ready: new Set(["github-copilot\u0000claude-sonnet-5"]),
+  });
+  assert.equal(result.selected, null);
+  assert.ok(result.rejected.every((entry) => entry.modelId.startsWith("claude-opus-")));
+});
+
+it("reports catalog and credential rejection reasons", () => {
+  const result = resolve({
+    catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
+    ready: new Set(),
+  });
+  assert.deepEqual(result.selected, null);
+  assert.ok(result.rejected.some((entry) => entry.reason === "missing-credentials"));
+  assert.ok(result.rejected.some((entry) => entry.reason === "not-in-catalog"));
+});
+
+it("retains static preference behavior when no parent provider or new settings exist", () => {
+  const result = resolve({
+    parentProvider: undefined,
+    providerPreference: ["anthropic-vertex", "github-copilot"],
+    modelFallbacks: {},
+    providerModelMasks: {},
+  });
+  assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-5" });
+});
+```
+
+- [ ] **Step 2: Run the expanded suite to verify the expected failure**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: the legacy-behavior test fails because the helper’s default `parentProvider` overrides `undefined`; correct the helper to use explicit complete argument objects rather than spreading defaults over an intentional `undefined` value.
+
+- [ ] **Step 3: Correct the test helper and resolver only as needed**
+
+Replace the helper with a base object that callers can explicitly override without an `undefined` value being lost:
+
+```ts
+const baseOptions = {
+  parentProvider: "github-copilot",
+  providerPreference: ["anthropic-vertex", "google"],
+  requestedModel: "claude-opus-5",
+  modelFallbacks: { "claude-opus-5": ["claude-opus-4-8"] },
+  providerModelMasks: {},
+  catalog,
+  ready,
+};
+
+function resolve(overrides: Partial<typeof baseOptions> = {}) {
+  return resolveSubagentModel({ ...baseOptions, ...overrides });
+}
+```
+
+For the legacy test, call `resolveSubagentModel` directly with `parentProvider: undefined`; do not change production logic to accommodate a test-helper limitation.
+
+- [ ] **Step 4: Run the expanded suite**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS for all seven behavior cases.
+
+### Task 3: Integrate routing and pre-spawn diagnostics into the extension
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/index.ts:13-80,265-425,618-850`
+
+- [ ] **Step 1: Add an integration test seam before changing spawning behavior**
+
+In `model-routing.test.ts`, add a test for Pi’s dated model aliases so the existing catalog behavior remains supported:
+
+```ts
+it("selects a dated catalog model for an undated requested ID", () => {
+  const result = resolveSubagentModel({
+    parentProvider: "anthropic-vertex",
+    providerPreference: [],
+    requestedModel: "claude-haiku-4-5",
+    modelFallbacks: {},
+    providerModelMasks: {},
+    catalog: [{ provider: "anthropic-vertex", modelId: "claude-haiku-4-5@20251001" }],
+    ready: new Set(["anthropic-vertex\u0000claude-haiku-4-5@20251001"]),
+  });
+  assert.deepEqual(result.selected, {
+    provider: "anthropic-vertex",
+    modelId: "claude-haiku-4-5@20251001",
+  });
+});
+```
+
+- [ ] **Step 2: Run the unit suite before integration**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS; the pure resolver already supports the dated alias form.
+
+- [ ] **Step 3: Replace the old resolver with an adapter around `resolveSubagentModel`**
+
+In `index.ts`:
+
+1. Add `import { resolveSubagentModel, type Rejection } from "./model-routing.ts";`.
+2. Delete `findModelAcrossProviders` entirely.
+3. Add these configuration types and helpers near `ModelCacheEntry`:
+
+```ts
+type SubagentRoutingSettings = {
+  providerPreference: string[];
+  modelFallbacks: Record<string, string[]>;
+  providerModelMasks: Record<string, string[]>;
+};
+
+function stringArray(value: unknown): string[] {
+  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
+
+function stringArrayMap(value: unknown): Record<string, string[]> {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+  return Object.fromEntries(Object.entries(value).map(([name, models]) => [name, stringArray(models)]));
+}
+
+function loadRoutingSettings(flagValue: string | undefined): SubagentRoutingSettings {
+  const defaults: SubagentRoutingSettings = {
+    providerPreference: flagValue?.split(",").map((item) => item.trim()).filter(Boolean) ?? DEFAULT_PROVIDER_PREFERENCE,
+    modelFallbacks: {},
+    providerModelMasks: {},
+  };
+  try {
+    const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"), "utf-8"));
+    return {
+      providerPreference: flagValue ? defaults.providerPreference : stringArray(settings.subagentProviderPreference),
+      modelFallbacks: stringArrayMap(settings.subagentModelFallbacks),
+      providerModelMasks: stringArrayMap(settings.subagentProviderModelMasks),
+    };
+  } catch {
+    return defaults;
+  }
+}
+
+function formatRoutingFailure(requestedModel: string, rejected: Rejection[]): string {
+  const lines = [`No usable subagent model for ${requestedModel}.`];
+  for (const entry of rejected) lines.push(`- ${entry.provider}/${entry.modelId}: ${entry.reason}`);
+  return lines.join("\n");
+}
+```
+
+4. Change `runSingleAgent` to accept `parentProvider: string | undefined` and `routing: SubagentRoutingSettings` instead of `providerPreference`.
+5. Before appending `--provider`/`--model`, build a credential-ready set by checking every catalog entry with `await modelRegistry.getApiKeyAndHeaders(model)`. Call `resolveSubagentModel` with `parentProvider`, `routing`, `modelCache ?? []`, and the ready set.
+6. When no pair is selected, return a `SingleResult` with `exitCode: 1`, no messages, and `stderr: formatRoutingFailure(agent.model, result.rejected)`. Do not append bare `--model`; this is the required no-spawn behavior.
+7. When selected, append the resolved `--provider` and `--model` arguments exactly as today.
+8. At every `runSingleAgent` call site, pass `ctx.model?.provider` and one `routing` object loaded once at the beginning of `execute` with `loadRoutingSettings(pi.getFlag("--subagent-providers") as string | undefined)`.
+
+- [ ] **Step 4: Run type/transpile and routing tests**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts && bun --check index.ts
+```
+
+Expected: tests PASS and Bun reports no syntax errors.
+
+### Task 4: Configure current agent tiers and supported Vertex fallback behavior
+
+**Files:**
+- Modify: `dots/pi/agent/settings.json:3-27`
+- Modify: `dots/pi/agent/ensure-settings.sh:10-61`
+- Modify: `dots/pi/agent/agents/github-triage-analyzer.md:1-6`
+- Modify: `dots/pi/agent/agents/github-triage-deep.md:1-6`
+- Modify: `dots/pi/agent/agents/oracle.md:1-6`
+- Modify: `dots/pi/agent/agents/planner.md:1-6`
+- Modify: `dots/pi/agent/agents/researcher.md:1-6`
+- Modify: `dots/pi/agent/agents/reviewer*.md:1-6`
+- Modify: `dots/pi/agent/agents/worker.md:1-5`
+
+- [ ] **Step 1: Update settings and merge defaults**
+
+Add these keys immediately after `subagentProviderPreference` in both the JSON template and the shell script’s `REQUIRED_SETTINGS` JSON:
+
+```json
+"subagentModelFallbacks": {
+  "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+  "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+  "claude-haiku-4-5": []
+},
+"subagentProviderModelMasks": {
+  "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+},
+```
+
+Keep `subagentProviderPreference` as the static fallback list. The runtime parent provider is prepended by the extension, not stored in JSON. Update `ensure-settings.sh` output to print the two new keys and replace the stale `google-vertex-claude` example with `anthropic-vertex`.
+
+- [ ] **Step 2: Refresh model pins in agent definitions**
+
+Make only frontmatter model changes:
+
+```text
+claude-sonnet-5: github-triage-analyzer, oracle, planner, researcher, worker
+claude-opus-5: github-triage-deep and every reviewer*.md file
+claude-haiku-4-5: scout.md (unchanged)
+```
+
+Do not alter agent prompts, tool lists, or descriptions.
+
+- [ ] **Step 3: Validate configuration and pins against the catalog**
+
+Run:
+
+```bash
+jq empty dots/pi/agent/settings.json
+bash -n dots/pi/agent/ensure-settings.sh
+pi --list-models | rg 'anthropic-vertex +(claude-opus-5|claude-opus-4-8|claude-opus-4-7|claude-opus-4-6|claude-sonnet-5|claude-sonnet-4-6|claude-sonnet-4-5|claude-haiku-4-5)'
+rg '^model:' dots/pi/agent/agents
+```
+
+Expected: JSON and shell syntax are valid; all configured requested/fallback models appear for `anthropic-vertex`; agent pins are only Sonnet 5, Opus 5, or Haiku 4.5.
+
+### Task 5: Document runtime behavior and configuration
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/README.md:1-258`
+- Modify: `dots/pi/agent/extensions/subagent/index.ts:1195-1278`
+
+- [ ] **Step 1: Update the README’s model-resolution and configuration sections**
+
+Replace the current static-preference description with these guarantees:
+
+```markdown
+1. The dispatching session's active provider is tried first.
+2. `subagentProviderPreference` supplies ordered fallback providers.
+3. For every provider, the requested model is tried before only its explicitly configured fallback IDs.
+4. `subagentProviderModelMasks` skips known-unavailable exact provider/model pairs before a child process is started.
+5. No compatible candidate produces a diagnostic; the extension does not spawn Pi or retry a failed task.
+```
+
+Use `anthropic-vertex` throughout. Include the exact default `subagentModelFallbacks` and `subagentProviderModelMasks` examples from Task 4 and explain that masks are provider-specific and must be updated after Vertex entitlement changes. Replace stale 4.5/4.6 examples with the current agent policy: scout/Haiku 4.5, standard work/Sonnet 5, deep work/Opus 5.
+
+- [ ] **Step 2: Update `/subagent-config` output**
+
+Extend the command output after “Provider Preference” to show:
+
+```ts
+lines.push("Model Fallbacks:");
+for (const [model, fallbacks] of Object.entries(routing.modelFallbacks)) {
+  lines.push(`  ${model}: ${fallbacks.length ? fallbacks.join(" → ") : "none"}`);
+}
+lines.push("");
+lines.push("Provider/Model Masks:");
+for (const [provider, models] of Object.entries(routing.providerModelMasks)) {
+  lines.push(`  ${provider}: ${models.length ? models.join(", ") : "none"}`);
+}
+```
+
+Load `routing` via the same `loadRoutingSettings` helper used by the tool. Update the example resolution wording to say the current session provider is considered first, rather than claiming the first configured provider will be selected.
+
+- [ ] **Step 3: Verify documentation references and working tree quality**
+
+Run:
+
+```bash
+rg -n 'google-vertex-claude|claude-sonnet-4-5@20250929|claude-opus-4-6' dots/pi/agent/extensions/subagent/README.md dots/pi/agent/ensure-settings.sh
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts && bun --check index.ts
+git diff --check
+git status --short
+```
+
+Expected: stale provider/model references are absent from the README and settings script; tests and syntax checks pass; `git diff --check` is clean; no commit is created.
+
+## Final verification checklist
+
+- [ ] `bun test dots/pi/agent/extensions/subagent/model-routing.test.ts` passes from the repository root.
+- [ ] `bun --check dots/pi/agent/extensions/subagent/index.ts` reports no syntax error.
+- [ ] `jq empty dots/pi/agent/settings.json` and `bash -n dots/pi/agent/ensure-settings.sh` pass.
+- [ ] `pi --list-models` confirms every default requested/fallback model is catalog-visible.
+- [ ] The configured Vertex mask causes `claude-opus-5` to select its first catalog-visible, credential-ready Opus fallback on Vertex, while the same model remains eligible on GitHub Copilot.
+- [ ] `git diff --check` passes and the working tree has no unrequested changes.
docs/superpowers/specs/2026-09-11-pi-subagent-model-routing-design.md
@@ -0,0 +1,116 @@
+# Pi subagent model routing design
+
+## Goal
+
+Make Pi subagent selection prefer the active session's provider while retaining a deterministic, configurable fallback path. Operators must be able to prevent routing a model to a provider where it is known to be unavailable, such as a model absent from a Vertex AI project entitlement.
+
+## Scope
+
+This applies to the subagent extension at `dots/pi/agent/extensions/subagent/` and the user-level agent definitions in `dots/pi/agent/agents/`.
+
+It does not probe provider entitlements at runtime or retry failed task executions. A model/provider pair is selected or rejected before a child Pi process is spawned.
+
+## Configuration
+
+Retain `subagentProviderPreference` as the ordered fallback provider list. Add two settings:
+
+```json
+{
+  "subagentModelFallbacks": {
+    "claude-opus-5": [
+      "claude-opus-4-8",
+      "claude-opus-4-7",
+      "claude-opus-4-6"
+    ],
+    "claude-sonnet-5": [
+      "claude-sonnet-4-6",
+      "claude-sonnet-4-5"
+    ],
+    "claude-haiku-4-5": []
+  },
+  "subagentProviderModelMasks": {
+    "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+  }
+}
+```
+
+- `subagentModelFallbacks` maps a requested model ID to an ordered list of permitted fallback model IDs.
+- Every fallback is explicitly configured and stays within the same model tier. In particular, Opus must never silently fall back to Sonnet or Haiku; Sonnet must never silently fall back to Haiku.
+- `subagentProviderModelMasks` maps a provider to exact model IDs that must not be routed there. A mask applies only to the named provider. It does not prevent selecting that model from GitHub Copilot or another provider.
+- Settings absent from an existing configuration preserve current behavior: configured provider order, requested model only, and no masks.
+
+## Resolution algorithm
+
+For each subagent invocation:
+
+1. Start provider candidates with the active parent session provider.
+2. Append `subagentProviderPreference`, removing duplicate providers while preserving order.
+3. Build model candidates from the requested agent model followed by its explicit configured fallback list. If the agent has no model, preserve inheritance from the parent session.
+4. Evaluate the Cartesian order by provider first, model second:
+   - active provider/requested model;
+   - active provider/fallback models in order;
+   - first fallback provider/requested model;
+   - that provider/fallback models in order; and so on.
+5. Skip a pair if the exact model is masked for that provider.
+6. For remaining pairs, select the first model known in Pi's catalog for the provider and usable with the provider's configured credentials.
+7. Spawn the child Pi process only once a candidate has been selected.
+
+This allows a GitHub Copilot parent session to use its own compatible model first, while a Vertex-backed session can deliberately select a known-enabled earlier same-tier release rather than attempting a masked model.
+
+## Failure diagnostics
+
+If no pair can be selected, do not spawn a child process. Return a diagnostic that lists candidate pairs and why each was rejected:
+
+- masked by configuration;
+- absent from Pi's model catalog for that provider;
+- catalog-present but missing provider credentials.
+
+An invocation failure after a child has started is a task/provider execution error and is not retried by this feature. Entitlement changes are handled by updating the masks and fallback lists deliberately.
+
+## Agent definitions
+
+Refresh stale model pins:
+
+- Planning, general implementation, and standard analysis agents use `claude-sonnet-5`.
+- Deep analysis and review agents use `claude-opus-5`.
+- The scout remains on `claude-haiku-4-5` for inexpensive reconnaissance.
+- Older versions belong in `subagentModelFallbacks`, rather than being pinned in individual definitions.
+
+## Implementation structure
+
+Extract routing into a small pure resolver that accepts:
+
+- parent provider;
+- configured provider preference;
+- requested model;
+- fallback map;
+- provider/model mask map;
+- catalog entries; and
+- credential availability.
+
+It returns either a selected `{ provider, modelId }` pair or structured rejection information for UI diagnostics. The extension remains responsible for loading settings/catalog data and spawning the selected child process.
+
+## Documentation
+
+Update the subagent README, settings template/merge script, and `/subagent-config` output to describe:
+
+- active-provider-first routing;
+- fallback providers;
+- model-family fallback chains;
+- provider-specific masks;
+- current provider naming (`anthropic-vertex`); and
+- how to update masks when Vertex model access changes.
+
+## Validation
+
+Add focused resolver tests for:
+
+1. The active provider winning when it offers the requested unmasked model.
+2. A masked active-provider pair being skipped before spawn.
+3. Selection of a same-tier fallback on the active provider.
+4. Selection from the configured provider fallback list when no allowed candidate exists on the active provider.
+5. No cross-tier fallback from Opus to Sonnet/Haiku or Sonnet to Haiku.
+6. Distinct structured rejection reasons for mask, catalog absence, and unavailable credentials.
+7. Existing settings with no new keys retaining current static-order/requested-model behavior.
+
+Also parse the settings JSON and ensure all model IDs in updated agent definitions and default fallback settings are present in the current Pi catalog.
dots/pi/agent/agents/github-triage-analyzer.md
@@ -2,7 +2,7 @@
 name: github-triage-analyzer
 description: Investigates a single GitHub issue or PR against its codebase. Returns structured classification and findings.
 tools: read, bash, web_search
-model: claude-sonnet-4-6
+model: claude-sonnet-5
 ---
 
 You are a GitHub issue/PR investigator. You receive a single issue or PR and investigate it against the codebase to produce structured findings.
dots/pi/agent/agents/github-triage-deep.md
@@ -2,7 +2,7 @@
 name: github-triage-deep
 description: Deep investigation of a single GitHub issue or PR. Traces code paths, forms root cause hypotheses, suggests fixes.
 tools: read, bash, web_search
-model: claude-opus-4
+model: claude-opus-5
 ---
 
 You are a GitHub issue/PR deep investigator. You receive a single issue or PR and perform thorough code analysis to determine root cause and suggest fixes.
dots/pi/agent/agents/oracle.md
@@ -2,7 +2,7 @@
 name: oracle
 description: Deep analysis, debugging, and architecture decisions
 tools: read, grep, find, ls, bash
-model: claude-sonnet-4-6
+model: claude-sonnet-5
 ---
 
 You are a deep analysis specialist. You investigate complex problems, debug tricky issues, and provide architecture guidance.
dots/pi/agent/agents/planner.md
@@ -2,7 +2,7 @@
 name: planner
 description: Creates implementation plans from context and requirements
 tools: read, grep, find, ls
-model: claude-sonnet-4-6
+model: claude-sonnet-5
 ---
 
 You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
dots/pi/agent/agents/researcher.md
@@ -2,7 +2,7 @@
 name: researcher
 description: Deep research on technical topics, frameworks, and integrations
 tools: read, bash, web_search, github_search, stack_overflow_search
-model: claude-sonnet-4
+model: claude-sonnet-5
 ---
 
 You are a technical researcher. Your job is to conduct thorough research on technical topics, frameworks, integrations, and architectural patterns.
dots/pi/agent/agents/reviewer-ghactions.md
@@ -2,7 +2,7 @@
 name: reviewer-ghactions
 description: GitHub Actions workflow review for correctness, efficiency, and best practices
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a GitHub Actions workflow reviewer. Your job is to find correctness issues, inefficiencies, and anti-patterns in GitHub Actions workflows and composite actions.
dots/pi/agent/agents/reviewer-go.md
@@ -2,7 +2,7 @@
 name: reviewer-go
 description: Go-focused code review for idioms, error handling, concurrency, and performance patterns
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a Go-focused code reviewer. Your job is to find Go anti-patterns, concurrency bugs, error handling gaps, and idiomatic issues.
dots/pi/agent/agents/reviewer-k8s.md
@@ -2,7 +2,7 @@
 name: reviewer-k8s
 description: Kubernetes-focused code review for manifests, RBAC, resource management, and operational correctness
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a Kubernetes-focused code reviewer. Your job is to find operational issues, misconfigurations, and anti-patterns in Kubernetes manifests and Go code that interacts with the Kubernetes API.
dots/pi/agent/agents/reviewer-nix.md
@@ -2,7 +2,7 @@
 name: reviewer-nix
 description: Nix-focused code review for idioms, module patterns, eval cost, and reproducibility
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a Nix-focused code reviewer. Your job is to find Nix anti-patterns, module design issues, evaluation performance problems, and reproducibility gaps.
dots/pi/agent/agents/reviewer-performance.md
@@ -2,7 +2,7 @@
 name: reviewer-performance
 description: Performance-focused code review for complexity, allocations, caching, and concurrency
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a performance-focused code reviewer. Your job is to find performance regressions, inefficiencies, and scalability issues.
dots/pi/agent/agents/reviewer-python.md
@@ -2,7 +2,7 @@
 name: reviewer-python
 description: Python-focused code review for type safety, error handling, packaging, and anti-patterns
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a Python-focused code reviewer. Your job is to find Python anti-patterns, type safety issues, error handling gaps, and packaging problems.
dots/pi/agent/agents/reviewer-security.md
@@ -2,7 +2,7 @@
 name: reviewer-security
 description: Security-focused code review for vulnerabilities, injection, auth, and secrets
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a security-focused code reviewer. Your job is to find vulnerabilities, injection risks, authentication flaws, and secret exposure.
dots/pi/agent/agents/reviewer-shell.md
@@ -2,7 +2,7 @@
 name: reviewer-shell
 description: Shell script review for robustness, portability, quoting, and error handling
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a shell script reviewer. Your job is to find robustness issues, quoting bugs, error handling gaps, and portability problems in Bash and POSIX shell scripts.
dots/pi/agent/agents/reviewer-tekton.md
@@ -2,7 +2,7 @@
 name: reviewer-tekton
 description: Tekton-focused code review for pipeline/task design, parameter handling, workspace patterns, and API correctness
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a Tekton-focused code reviewer. Your job is to find issues in Tekton Pipeline YAML definitions, Go code that implements Tekton controllers/APIs, and Tekton usage patterns.
dots/pi/agent/agents/reviewer.md
@@ -2,7 +2,7 @@
 name: reviewer
 description: General code review for bugs, logic errors, maintainability, and code smells
 tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
 ---
 
 You are a senior code reviewer focused on **general code quality**. Your job is to find bugs, logic errors, maintainability issues, and code smells.
dots/pi/agent/agents/worker.md
@@ -1,7 +1,7 @@
 ---
 name: worker
 description: General-purpose subagent with full capabilities, isolated context
-model: claude-sonnet-4-6
+model: claude-sonnet-5
 ---
 
 You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
dots/pi/agent/extensions/subagent/index.ts
@@ -23,6 +23,7 @@ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-a
 import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
 import { Type } from "@sinclair/typebox";
 import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
+import { resolveSubagentModel, type Rejection } from "./model-routing.ts";
 
 const MAX_PARALLEL_TASKS = 16;
 const MAX_CONCURRENCY = 8;
@@ -38,6 +39,13 @@ interface ModelCacheEntry {
 	provider: string;
 	modelId: string;
 }
+
+type SubagentRoutingSettings = {
+	providerPreference: string[];
+	modelFallbacks: Record<string, string[]>;
+	providerModelMasks: Record<string, string[]>;
+};
+
 let modelCache: ModelCacheEntry[] | null = null;
 
 /**
@@ -258,119 +266,49 @@ async function mapWithConcurrencyLimit<TIn, TOut>(
 	return results;
 }
 
-/**
- * Find a model by ID across all providers, preferring those with API keys
- * Supports fuzzy matching: "claude-haiku-4-5" will match "claude-haiku-4-5@20251001"
- * 
- * @param modelId - The model ID to search for (e.g., "claude-sonnet-4-5", "claude-haiku-4-5@20251001")
- * @param modelRegistry - The model registry from context
- * @param preferredProviders - Ordered list of preferred providers (empty = all providers)
- * @returns { provider, model } or null if not found
- */
-async function findModelAcrossProviders(
-	modelId: string,
-	modelRegistry: any,
-	preferredProviders: string[],
-): Promise<{ provider: string; modelId: string } | null> {
-	// Known providers - expand this list as needed
-	const knownProviders = [
-		"anthropic",
-		"openai",
-		"google",
-		"google-vertex",
-		"google-vertex-claude",
-		"vertex",
-		"llama-cpp",
-		"openrouter",
-		"groq",
-		"xai",
-		"deepseek",
-		"copilot",
-		"codex",
-	];
-	
-	// If no preference list, use all known providers
-	const providersToTry = preferredProviders.length > 0 ? preferredProviders : knownProviders;
+function stringArray(value: unknown): string[] {
+	return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
 
-	/**
-	 * Try to find a model on a provider, with fuzzy matching support
-	 * Returns the actual model ID if found (which may include version suffix)
-	 */
-	const tryFindModel = async (providerName: string, requestedId: string): Promise<string | null> => {
-		// Try exact match first
-		let model = modelRegistry.find(providerName, requestedId);
-		if (model) return requestedId;
-		
-		// Use model cache for fuzzy matching
-		if (modelCache && modelCache.length > 0) {
-			// Find models on this provider that match the requested ID
-			for (const entry of modelCache) {
-				if (entry.provider === providerName) {
-					// Match if exact or if model ID starts with requested ID followed by @
-					if (entry.modelId === requestedId || entry.modelId.startsWith(requestedId + "@")) {
-						// Verify it actually exists (double-check with registry)
-						const verifyModel = modelRegistry.find(providerName, entry.modelId);
-						if (verifyModel) {
-							return entry.modelId;
-						}
-					}
-				}
-			}
-		}
-		
-		return null;
+function stringArrayMap(value: unknown): Record<string, string[]> {
+	if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+	return Object.fromEntries(Object.entries(value).map(([name, models]) => [name, stringArray(models)]));
+}
+
+function loadRoutingSettings(flagValue: string | undefined): SubagentRoutingSettings {
+	const defaults: SubagentRoutingSettings = {
+		providerPreference: flagValue?.split(",").map((item) => item.trim()).filter(Boolean) ?? DEFAULT_PROVIDER_PREFERENCE,
+		modelFallbacks: {},
+		providerModelMasks: {},
 	};
 
-	// First pass: try preferred providers with API keys (exact or fuzzy match)
-	for (const providerName of providersToTry) {
-		const foundModelId = await tryFindModel(providerName, modelId);
-		if (foundModelId) {
-			const model = modelRegistry.find(providerName, foundModelId);
-			if (model) {
-				const auth = await modelRegistry.getApiKeyAndHeaders(model);
-				if (auth.ok && auth.apiKey) {
-					return { provider: providerName, modelId: foundModelId };
-				}
-			}
-		}
+	try {
+		const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"), "utf-8"));
+		return {
+			providerPreference: flagValue ? defaults.providerPreference : stringArray(settings.subagentProviderPreference),
+			modelFallbacks: stringArrayMap(settings.subagentModelFallbacks),
+			providerModelMasks: stringArrayMap(settings.subagentProviderModelMasks),
+		};
+	} catch {
+		return defaults;
 	}
+}
 
-	// Second pass: try other known providers with API keys (not in preference list)
-	if (preferredProviders.length > 0) {
-		for (const providerName of knownProviders) {
-			if (preferredProviders.includes(providerName)) continue;
-			const foundModelId = await tryFindModel(providerName, modelId);
-			if (foundModelId) {
-				const model = modelRegistry.find(providerName, foundModelId);
-				if (model) {
-					const auth = await modelRegistry.getApiKeyAndHeaders(model);
-					if (auth.ok && auth.apiKey) {
-						return { provider: providerName, modelId: foundModelId };
-					}
-				}
-			}
-		}
+function formatRoutingFailure(requestedModel: string, rejected: Rejection[]): string {
+	const lines = [`No usable subagent model for ${requestedModel}.`];
+	for (const entry of rejected) lines.push(`- ${entry.provider}/${entry.modelId}: ${entry.reason}`);
+	return lines.join("\n");
+}
+
+async function credentialReadyModels(modelRegistry: any): Promise<Set<string>> {
+	const ready = new Set<string>();
+	for (const entry of modelCache ?? []) {
+		const model = modelRegistry.find(entry.provider, entry.modelId);
+		if (!model) continue;
+		const auth = await modelRegistry.getApiKeyAndHeaders(model);
+		if (auth.ok && auth.apiKey) ready.add(`${entry.provider}\u0000${entry.modelId}`);
 	}
-
-	// Third pass: try preferred providers without API keys (will fail but with clearer error)
-	for (const providerName of providersToTry) {
-		const foundModelId = await tryFindModel(providerName, modelId);
-		if (foundModelId) {
-			return { provider: providerName, modelId: foundModelId };
-		}
-	}
-
-	// Last resort: try any known provider (if we had a preference list)
-	if (preferredProviders.length > 0) {
-		for (const providerName of knownProviders) {
-			const foundModelId = await tryFindModel(providerName, modelId);
-			if (foundModelId) {
-				return { provider: providerName, modelId: foundModelId };
-			}
-		}
-	}
-
-	return null;
+	return ready;
 }
 
 function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
@@ -394,7 +332,8 @@ async function runSingleAgent(
 	onUpdate: OnUpdateCallback | undefined,
 	makeDetails: (results: SingleResult[]) => SubagentDetails,
 	modelRegistry: any,
-	providerPreference: string[],
+	parentProvider: string | undefined,
+	routing: SubagentRoutingSettings,
 ): Promise<SingleResult> {
 	const agent = agents.find((a) => a.name === agentName);
 
@@ -413,18 +352,32 @@ async function runSingleAgent(
 
 	const args: string[] = ["--mode", "json", "-p", "--no-session"];
 	
-	// Resolve model across providers
 	if (agent.model) {
-		const resolved = await findModelAcrossProviders(agent.model, modelRegistry, providerPreference);
-		if (resolved) {
-			args.push("--provider", resolved.provider);
-			args.push("--model", resolved.modelId);
-		} else {
-			// Fallback to just passing model ID (will likely fail but with pi's error message)
-			args.push("--model", agent.model);
+		const resolution = resolveSubagentModel({
+			parentProvider,
+			providerPreference: routing.providerPreference,
+			requestedModel: agent.model,
+			modelFallbacks: routing.modelFallbacks,
+			providerModelMasks: routing.providerModelMasks,
+			catalog: modelCache ?? [],
+			ready: await credentialReadyModels(modelRegistry),
+		});
+		if (!resolution.selected) {
+			return {
+				agent: agentName,
+				agentSource: agent.source,
+				task,
+				exitCode: 1,
+				messages: [],
+				stderr: formatRoutingFailure(agent.model, resolution.rejected),
+				usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
+				step,
+			};
 		}
+		args.push("--provider", resolution.selected.provider);
+		args.push("--model", resolution.selected.modelId);
 	}
-	
+
 	if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
 
 	let tmpPromptDir: string | null = null;
@@ -617,28 +570,7 @@ export default function (pi: ExtensionAPI) {
 			const agents = discovery.agents;
 			const confirmProjectAgents = params.confirmProjectAgents ?? true;
 			
-			// Get provider preference from (in order):
-			// 1. --subagent-providers flag
-			// 2. settings.json "subagentProviderPreference"
-			// 3. DEFAULT_PROVIDER_PREFERENCE (empty = all providers)
-			let providerPreference = DEFAULT_PROVIDER_PREFERENCE;
-			const flagValue = pi.getFlag("--subagent-providers") as string;
-			if (flagValue) {
-				providerPreference = flagValue.split(",").map(p => p.trim()).filter(Boolean);
-			} else {
-				// Try to load from settings.json
-				try {
-					const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
-					if (fs.existsSync(settingsPath)) {
-						const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
-						if (Array.isArray(settings.subagentProviderPreference)) {
-							providerPreference = settings.subagentProviderPreference;
-						}
-					}
-				} catch {
-					// Fall back to default
-				}
-			}
+			const routing = loadRoutingSettings(pi.getFlag("--subagent-providers") as string | undefined);
 
 			const hasChain = (params.chain?.length ?? 0) > 0;
 			const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -726,7 +658,8 @@ export default function (pi: ExtensionAPI) {
 						chainUpdate,
 						makeDetails("chain"),
 						ctx.modelRegistry,
-						providerPreference,
+						ctx.model?.provider,
+						routing,
 					);
 					results.push(result);
 
@@ -808,7 +741,8 @@ export default function (pi: ExtensionAPI) {
 						},
 						makeDetails("parallel"),
 						ctx.modelRegistry,
-						providerPreference,
+						ctx.model?.provider,
+						routing,
 					);
 					allResults[index] = result;
 					emitParallelUpdate();
@@ -844,7 +778,8 @@ export default function (pi: ExtensionAPI) {
 					onUpdate,
 					makeDetails("single"),
 					ctx.modelRegistry,
-					providerPreference,
+					ctx.model?.provider,
+					routing,
 				);
 				const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
 				if (isError) {
@@ -1187,7 +1122,7 @@ export default function (pi: ExtensionAPI) {
 
 	// Register flag for provider preference
 	pi.registerFlag("subagent-providers", {
-		description: "Comma-separated list of preferred providers for subagent model resolution (e.g., 'google-vertex-claude,google,llama-cpp')",
+		description: "Comma-separated fallback providers for subagent model resolution after the active provider (e.g., 'anthropic-vertex,google,llama-cpp')",
 		type: "string",
 	});
 
@@ -1195,27 +1130,9 @@ export default function (pi: ExtensionAPI) {
 	pi.registerCommand("subagent-config", {
 		description: "Show subagent provider preference configuration",
 		handler: async (_args, ctx) => {
-			let providerPreference = DEFAULT_PROVIDER_PREFERENCE;
-			let source = "default";
-			const flagValue = pi.getFlag("--subagent-providers") as string;
-			
-			if (flagValue) {
-				providerPreference = flagValue.split(",").map(p => p.trim()).filter(Boolean);
-				source = "flag";
-			} else {
-				try {
-					const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
-					if (fs.existsSync(settingsPath)) {
-						const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
-						if (Array.isArray(settings.subagentProviderPreference)) {
-							providerPreference = settings.subagentProviderPreference;
-							source = "settings.json";
-						}
-					}
-				} catch {
-					// Ignore
-				}
-			}
+			const flagValue = pi.getFlag("--subagent-providers") as string | undefined;
+			const routing = loadRoutingSettings(flagValue);
+			const source = flagValue ? "flag" : "settings.json";
 
 			// Build a formatted message
 			const lines: string[] = [];
@@ -1238,36 +1155,32 @@ export default function (pi: ExtensionAPI) {
 			
 			// Current setting
 			lines.push("Provider Preference:");
-			if (providerPreference.length > 0) {
-				lines.push(`  Source: ${source}`);
-				lines.push(`  Order: ${providerPreference.join(" → ")}`);
-			} else {
-				lines.push("  No preference (tries all providers with API keys)");
+			lines.push(`  Source: ${source}`);
+			lines.push(`  Fallback order: ${routing.providerPreference.join(" → ") || "none"}`);
+			lines.push(`  Active provider: ${ctx.model?.provider ?? "none"} (tried first)`);
+			lines.push("");
+
+			lines.push("Model Fallbacks:");
+			for (const [model, fallbacks] of Object.entries(routing.modelFallbacks)) {
+				lines.push(`  ${model}: ${fallbacks.length ? fallbacks.join(" → ") : "none"}`);
 			}
 			lines.push("");
-			
-			// Configuration methods
+			lines.push("Provider/Model Masks:");
+			for (const [provider, models] of Object.entries(routing.providerModelMasks)) {
+				lines.push(`  ${provider}: ${models.length ? models.join(", ") : "none"}`);
+			}
+			lines.push("");
+
 			lines.push("Configuration:");
-			lines.push("  1. Flag:     pi --subagent-providers google-vertex-claude,google");
-			lines.push("  2. Settings: Add to ~/.pi/agent/settings.json:");
-			lines.push('               "subagentProviderPreference": ["google-vertex-claude", ...]');
+			lines.push("  1. Flag:     pi --subagent-providers anthropic-vertex,google");
+			lines.push("  2. Settings: Add subagentProviderPreference, subagentModelFallbacks,");
+			lines.push("               and subagentProviderModelMasks to ~/.pi/agent/settings.json.");
 			lines.push("");
-			
-			// How it works
+
 			lines.push("Resolution Order:");
-			lines.push("  1. Try preferred providers WITH API keys");
-			lines.push("  2. Try other providers WITH API keys");
-			lines.push("  3. Fallback to any provider (may fail)");
-			lines.push("");
-			
-			// Example
-			lines.push("Example:");
-			lines.push("  Agent defines:    model: claude-haiku-4-5");
-			if (providerPreference.length > 0) {
-				lines.push(`  Resolves to:      ${providerPreference[0]}/claude-haiku-4-5`);
-			} else {
-				lines.push("  Resolves to:      (first provider with API key)");
-			}
+			lines.push("  1. Try the active session provider first");
+			lines.push("  2. Try configured fallback providers in order");
+			lines.push("  3. Skip masked pairs and use only explicit same-tier fallbacks");
 			
 			const output = lines.join("\n");
 			
dots/pi/agent/extensions/subagent/model-routing.test.ts
@@ -0,0 +1,124 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { resolveSubagentModel } from "./model-routing.ts";
+
+const catalog = [
+	{ provider: "github-copilot", modelId: "claude-opus-5" },
+	{ provider: "github-copilot", modelId: "claude-opus-4-8" },
+	{ provider: "github-copilot", modelId: "claude-opus-4-7" },
+	{ provider: "anthropic-vertex", modelId: "claude-opus-5" },
+	{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" },
+	{ provider: "anthropic-vertex", modelId: "claude-opus-4-7" },
+	{ provider: "google", modelId: "claude-opus-4-8" },
+];
+const ready = new Set(catalog.map(({ provider, modelId }) => `${provider}\u0000${modelId}`));
+
+const baseOptions = {
+	parentProvider: "github-copilot",
+	providerPreference: ["anthropic-vertex", "google"],
+	requestedModel: "claude-opus-5",
+	modelFallbacks: { "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7"] },
+	providerModelMasks: {},
+	catalog,
+	ready,
+};
+
+function resolve(overrides: Partial<typeof baseOptions> = {}) {
+	return resolveSubagentModel({ ...baseOptions, ...overrides });
+}
+
+describe("resolveSubagentModel", () => {
+	it("prefers the parent provider for the requested model", () => {
+		assert.deepEqual(resolve(), {
+			selected: { provider: "github-copilot", modelId: "claude-opus-5" },
+			rejected: [],
+		});
+	});
+
+	it("skips a masked parent provider/model pair before selection", () => {
+		assert.deepEqual(
+			resolve({
+				providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+			}),
+			{
+				selected: { provider: "github-copilot", modelId: "claude-opus-4-8" },
+				rejected: [{ provider: "github-copilot", modelId: "claude-opus-5", reason: "masked" }],
+			},
+		);
+	});
+
+	it("uses an allowed same-tier fallback on the parent provider", () => {
+		const result = resolve({
+			providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+		});
+		assert.deepEqual(result.selected, { provider: "github-copilot", modelId: "claude-opus-4-8" });
+	});
+
+	it("uses the configured Vertex mask without masking the same Copilot model", () => {
+		const result = resolve({
+			parentProvider: "anthropic-vertex",
+			providerModelMasks: { "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"] },
+		});
+		assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-7" });
+		assert.deepEqual(result.rejected, [
+			{ provider: "anthropic-vertex", modelId: "claude-opus-5", reason: "masked" },
+			{ provider: "anthropic-vertex", modelId: "claude-opus-4-8", reason: "masked" },
+		]);
+	});
+
+	it("moves to configured providers after parent candidates are unavailable", () => {
+		const result = resolve({
+			catalog: [{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" }],
+			ready: new Set(["anthropic-vertex\u0000claude-opus-4-8"]),
+		});
+		assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-8" });
+	});
+
+	it("does not invent a cross-tier fallback", () => {
+		const result = resolve({
+			catalog: [{ provider: "github-copilot", modelId: "claude-sonnet-5" }],
+			ready: new Set(["github-copilot\u0000claude-sonnet-5"]),
+		});
+		assert.equal(result.selected, null);
+		assert.ok(result.rejected.every((entry) => entry.modelId.startsWith("claude-opus-")));
+	});
+
+	it("reports catalog and credential rejection reasons", () => {
+		const result = resolve({
+			catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
+			ready: new Set(),
+		});
+		assert.deepEqual(result.selected, null);
+		assert.ok(result.rejected.some((entry) => entry.reason === "missing-credentials"));
+		assert.ok(result.rejected.some((entry) => entry.reason === "not-in-catalog"));
+	});
+
+	it("retains static preference behavior when no parent provider or new settings exist", () => {
+		const result = resolveSubagentModel({
+			parentProvider: undefined,
+			providerPreference: ["anthropic-vertex", "github-copilot"],
+			requestedModel: "claude-opus-5",
+			modelFallbacks: {},
+			providerModelMasks: {},
+			catalog,
+			ready,
+		});
+		assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-5" });
+	});
+
+	it("selects a dated catalog model for an undated requested ID", () => {
+		const result = resolveSubagentModel({
+			parentProvider: "anthropic-vertex",
+			providerPreference: [],
+			requestedModel: "claude-haiku-4-5",
+			modelFallbacks: {},
+			providerModelMasks: {},
+			catalog: [{ provider: "anthropic-vertex", modelId: "claude-haiku-4-5@20251001" }],
+			ready: new Set(["anthropic-vertex\u0000claude-haiku-4-5@20251001"]),
+		});
+		assert.deepEqual(result.selected, {
+			provider: "anthropic-vertex",
+			modelId: "claude-haiku-4-5@20251001",
+		});
+	});
+});
dots/pi/agent/extensions/subagent/model-routing.ts
@@ -0,0 +1,58 @@
+export type ModelEntry = { provider: string; modelId: string };
+export type RejectionReason = "masked" | "not-in-catalog" | "missing-credentials";
+export type Rejection = { provider: string; modelId: string; reason: RejectionReason };
+export type Resolution = {
+	selected: { provider: string; modelId: string } | null;
+	rejected: Rejection[];
+};
+
+export type ResolveSubagentModelOptions = {
+	parentProvider?: string;
+	providerPreference: string[];
+	requestedModel: string;
+	modelFallbacks: Record<string, string[]>;
+	providerModelMasks: Record<string, string[]>;
+	catalog: ModelEntry[];
+	ready: ReadonlySet<string>;
+};
+
+const key = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
+
+function orderedUnique(values: Array<string | undefined>): string[] {
+	return [...new Set(values.filter((value): value is string => Boolean(value)))];
+}
+
+function catalogModelId(catalog: ModelEntry[], provider: string, requested: string): string | undefined {
+	return (
+		catalog.find((entry) => entry.provider === provider && entry.modelId === requested)?.modelId ??
+		catalog.find((entry) => entry.provider === provider && entry.modelId.startsWith(`${requested}@`))?.modelId
+	);
+}
+
+export function resolveSubagentModel(options: ResolveSubagentModelOptions): Resolution {
+	const providers = orderedUnique([options.parentProvider, ...options.providerPreference]);
+	const models = orderedUnique([options.requestedModel, ...(options.modelFallbacks[options.requestedModel] ?? [])]);
+	const rejected: Rejection[] = [];
+
+	for (const provider of providers) {
+		const masked = new Set(options.providerModelMasks[provider] ?? []);
+		for (const requestedModel of models) {
+			if (masked.has(requestedModel)) {
+				rejected.push({ provider, modelId: requestedModel, reason: "masked" });
+				continue;
+			}
+			const modelId = catalogModelId(options.catalog, provider, requestedModel);
+			if (!modelId) {
+				rejected.push({ provider, modelId: requestedModel, reason: "not-in-catalog" });
+				continue;
+			}
+			if (!options.ready.has(key(provider, modelId))) {
+				rejected.push({ provider, modelId, reason: "missing-credentials" });
+				continue;
+			}
+			return { selected: { provider, modelId }, rejected };
+		}
+	}
+
+	return { selected: null, rejected };
+}
dots/pi/agent/extensions/subagent/README.md
@@ -1,258 +1,129 @@
-# Subagent Extension - Multi-Model Provider Support
+# Subagent Extension
 
-Delegate tasks to specialized subagents with isolated context windows and intelligent multi-provider model resolution.
+Delegate tasks to specialized subagents with isolated context windows and deterministic multi-provider model routing.
 
 ## Features
 
-- **Isolated context**: Each subagent runs in a separate `pi` process
-- **Multi-model support**: Each agent can use a different model
-- **Intelligent provider resolution**: Automatically finds models across providers
-- **Provider preference**: Configure which providers to prefer for model lookup
-- **Streaming output**: See tool calls and progress as they happen
-- **Parallel execution**: Run multiple agents concurrently
-- **Chain workflows**: Sequential execution with output passing
+- **Isolated context:** each subagent runs in a separate `pi` process.
+- **Active-provider-first routing:** a child first tries the provider selected by the parent session.
+- **Provider fallbacks:** configured providers are tried after the active provider, in order.
+- **Same-tier model fallbacks:** a requested model uses only its explicitly configured fallback versions.
+- **Provider/model masks:** known-unavailable pairs are skipped before Pi spawns a child process.
+- **Parallel and chain workflows:** run independent tasks concurrently or pass output between sequential agents.
 
-## Model Resolution
+## Model resolution
 
-When an agent specifies a model ID (e.g., `claude-sonnet-4-5`), the extension:
+For an agent with `model: claude-opus-5`, the extension:
 
-1. First tries **preferred providers with API keys** (in order)
-2. Then tries **other providers with API keys**
-3. Falls back to any provider (will fail if no API key configured)
+1. tries the parent session provider with `claude-opus-5`;
+2. tries that provider with the configured Opus fallback versions;
+3. repeats for `subagentProviderPreference` providers, in order;
+4. skips exact pairs in `subagentProviderModelMasks`;
+5. selects the first catalog-visible pair with credentials.
 
-This allows you to:
-- Use `google-vertex-claude` for Claude models (if configured)
-- Fall back to `anthropic` if vertex isn't available
-- Use local models via `llama-cpp`
-- Configure provider preferences globally or per-session
+The model catalog says that Pi knows a model ID, not that a Vertex AI project is entitled to invoke it. Use a provider/model mask for models that are catalog-visible but not enabled for your Vertex project.
+
+If no pair is usable, the extension does not spawn Pi. It reports whether each candidate was masked, absent from the catalog, or missing credentials. It does not retry a child that has already started and failed.
 
 ## Configuration
 
-### Provider Preference
-
-Configure provider preference in **three ways** (priority order):
-
-#### 1. Command-line flag (highest priority)
-
-```bash
-pi --subagent-providers google-vertex-claude,google,llama-cpp
-```
-
-#### 2. Settings file (`~/.pi/agent/settings.json`)
+Configure fallbacks and masks in `~/.pi/agent/settings.json`:
 
 ```json
 {
   "subagentProviderPreference": [
-    "google-vertex-claude",
-    "google", 
-    "llama-cpp",
-    "anthropic",
-    "openai"
-  ]
+    "anthropic-vertex",
+    "google",
+    "llama-cpp"
+  ],
+  "subagentModelFallbacks": {
+    "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+    "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+    "claude-haiku-4-5": []
+  },
+  "subagentProviderModelMasks": {
+    "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+  }
 }
 ```
 
-#### 3. Default behavior (lowest priority)
+`subagentProviderPreference` is the fallback order, not an override of the active session provider. Duplicates are removed, preserving order.
 
-If not configured, the extension tries **all providers**, preferring those with API keys.
+`subagentModelFallbacks` is explicit and model-family scoped. Do not configure an Opus-to-Sonnet or Sonnet-to-Haiku fallback: model tiers must not be silently downgraded.
 
-### View Current Configuration
+`subagentProviderModelMasks` is provider-specific. The example prevents Opus 5 and Opus 4.8 from being sent to Vertex, but still permits them through `github-copilot`. Update the mask deliberately after Vertex model entitlements change.
+
+### Per-session provider fallback override
+
+The `--subagent-providers` flag replaces `subagentProviderPreference` for a session but does not replace model fallbacks or masks:
 
 ```bash
-pi
-> /subagent-config
+pi --subagent-providers github-copilot,anthropic-vertex,google
 ```
 
-## Agent Definitions
+### View current configuration
 
-Create agents in `~/.pi/agent/agents/*.md`:
-
-```markdown
----
-name: scout
-description: Fast reconnaissance
-tools: read, grep, find, ls
-model: claude-haiku-4-5
----
-
-You are a scout. Quickly investigate a codebase...
+```text
+/subagent-config
 ```
 
-The `model:` field is just the model ID - the provider is resolved automatically.
+The command shows the active provider, fallback order, model fallback chains, masks, and model-cache status.
 
-### Available Models by Provider
+## Agent definitions
 
-**Anthropic Claude via Google Vertex:**
-- `claude-opus-4-6`
-- `claude-sonnet-4-5@20250929`
-- `claude-haiku-4-5@20251001`
-
-**Google Gemini:**
-- `gemini-2.5-flash`
-- `gemini-2.5-pro`
-
-**Local (llama-cpp):**
-- `Qwen/Qwen3-8B-GGUF:Q4_K_M`
-- `bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M`
-
-## Usage Examples
-
-### Simple Usage
-
-```
-Use scout to find all Tekton tasks
-```
-
-### Chain Workflow
-
-```
-Chain: scout finds auth code, planner creates refactor plan, worker implements it
-```
-
-This might use:
-- scout → `claude-haiku-4-5` via `google-vertex-claude`
-- planner → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
-- worker → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
-
-## Example Agents
-
-### Scout (Fast, Cheap)
-
-```markdown
----
-name: scout
-description: Fast reconnaissance
-tools: read, grep, find, ls, bash
-model: claude-haiku-4-5
----
-
-Quickly investigate and return compressed findings.
-```
-
-### Planner (Detailed Analysis)
+Agents live in `~/.pi/agent/agents/*.md` and specify a model ID without its provider:
 
 ```markdown
 ---
 name: planner
 description: Creates implementation plans
 tools: read, grep, find, ls
-model: claude-sonnet-4-5@20250929
+model: claude-sonnet-5
 ---
 
-Receive context and create detailed implementation plans.
+You are a planning specialist.
 ```
 
-### Local Researcher
+Current conventions are:
 
-```markdown
----
-name: local-researcher
-description: Research using local model
-tools: web_search, github_search, stack_overflow_search
-model: Qwen/Qwen3-8B-GGUF:Q4_K_M
----
+| Workload | Model |
+| --- | --- |
+| Fast reconnaissance | `claude-haiku-4-5` |
+| Planning, implementation, standard analysis | `claude-sonnet-5` |
+| Deep analysis and code review | `claude-opus-5` |
 
-Research using a local model to save API costs.
+## Usage
+
+### Single agent
+
+```text
+Use scout to find all Tekton tasks.
 ```
 
-## Cost Optimization
+### Chain
 
-Configure provider preference to optimize costs:
-
-```json
-{
-  "subagentProviderPreference": [
-    "llama-cpp",              // Free (local)
-    "google-vertex-claude",   // Discounted Claude via Vertex
-    "google",                 // Gemini
-    "anthropic"               // Full-price Claude (fallback)
-  ]
-}
+```text
+Use a chain: scout finds auth code, planner creates a plan from {previous}, then worker implements it.
 ```
 
-Now agents requesting `claude-haiku-4-5` will:
-1. Try `llama-cpp` first (no claude there, skip)
-2. Try `google-vertex-claude` (found! use it)
-3. Skip remaining providers
+### Parallel work
+
+```text
+Run separate reviewer-go and reviewer-security subagents in parallel.
+```
+
+The tool supports single (`agent` + `task`), parallel (`tasks`), and sequential (`chain`) modes. Project-local agents require `agentScope: "project"` or `"both"`; only enable them for trusted repositories.
 
 ## Troubleshooting
 
-### "No API key configured for provider"
+### A model is available in Pi but fails on Vertex
 
-**Problem:** Agent fails because the subagent process doesn't have an API key.
+Add it to `subagentProviderModelMasks.anthropic-vertex` and configure a known-enabled version in the same model family under `subagentModelFallbacks`.
 
-**Solution:** Add provider preference to route to a configured provider:
+### An unexpected provider was selected
 
-```json
-{
-  "subagentProviderPreference": ["google-vertex-claude"]
-}
-```
+Check `/subagent-config`. The parent session provider is always considered first; change the parent model/provider or set a fallback order with `--subagent-providers`.
 
-### Model not found
+### No usable subagent model
 
-**Problem:** Agent specifies a model that doesn't exist on any provider.
-
-**Solution:** Check available models:
-
-```bash
-pi --list-models | grep claude-haiku
-```
-
-Update agent definition with correct model ID.
-
-### Wrong provider being used
-
-**Problem:** Agent uses an unexpected provider.
-
-**Solution:** 
-1. Check current config: `/subagent-config`
-2. Add explicit preference in settings
-3. Or use flag: `pi --subagent-providers google-vertex-claude`
-
-## API Reference
-
-### Tool Parameters
-
-```typescript
-{
-  agent: "scout",                    // Single mode
-  task: "find auth code",
-  
-  // OR parallel mode:
-  tasks: [
-    { agent: "scout", task: "..." },
-    { agent: "planner", task: "..." }
-  ],
-  
-  // OR chain mode:
-  chain: [
-    { agent: "scout", task: "find auth" },
-    { agent: "planner", task: "plan using {previous}" }
-  ],
-  
-  agentScope: "user" | "project" | "both",  // Default: "user"
-  confirmProjectAgents: true,               // Default: true
-  cwd: "/path/to/working/dir"              // Optional
-}
-```
-
-### Settings Schema
-
-```json
-{
-  "subagentProviderPreference": ["provider1", "provider2", "..."]
-}
-```
-
-### Command-line Flag
-
-```bash
---subagent-providers <comma-separated-list>
-```
-
-## Security
-
-**Project-local agents** (`.pi/agents/*.md`) can execute arbitrary code. Only use `agentScope: "both"` or `agentScope: "project"` for repositories you trust.
-
-By default, only user-level agents (`~/.pi/agent/agents/`) are loaded.
+Read the tool diagnostic. It identifies masked pairs, catalog-absent models, and models without configured credentials. Run `pi --list-models` to inspect Pi’s current catalog.
dots/pi/agent/ensure-settings.sh
@@ -17,6 +17,14 @@ REQUIRED_SETTINGS='{
     "google",
     "llama-cpp"
   ],
+  "subagentModelFallbacks": {
+    "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+    "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+    "claude-haiku-4-5": []
+  },
+  "subagentProviderModelMasks": {
+    "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+  },
   "packages": [
     "npm:@aliou/pi-processes@0.12.0",
     "npm:@twogiants/pi-anthropic-vertex",
@@ -49,6 +57,8 @@ if command -v jq >/dev/null 2>&1; then
 	echo "   - treeFilterMode: no-tools"
 
 	echo "   - subagentProviderPreference: anthropic-vertex, google, llama-cpp"
+	echo "   - subagentModelFallbacks: same-tier Claude fallbacks"
+	echo "   - subagentProviderModelMasks: anthropic-vertex/claude-opus-5, claude-opus-4-8"
 	echo "   - packages: @aliou/pi-processes@0.12.0, @tmustier/pi-usage-extension@0.9.4"
 else
 	echo "⚠️  jq not found - cannot merge settings automatically"
@@ -57,6 +67,8 @@ else
 	echo "   - quietStartup: true"
 	echo "   - treeFilterMode: no-tools"
 
-	echo "   - subagentProviderPreference: [\"google-vertex-claude\", \"vertex\", ...]"
+	echo "   - subagentProviderPreference: [\"anthropic-vertex\", \"google\", ...]"
+	echo "   - subagentModelFallbacks: {\"claude-opus-5\": [\"claude-opus-4-8\", ...]}"
+	echo "   - subagentProviderModelMasks: {\"anthropic-vertex\": [\"claude-opus-5\", \"claude-opus-4-8\"]}"
 	exit 1
 fi
dots/pi/agent/settings.json
@@ -20,6 +20,14 @@
     "anthropic",
     "openai"
   ],
+  "subagentModelFallbacks": {
+    "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+    "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+    "claude-haiku-4-5": []
+  },
+  "subagentProviderModelMasks": {
+    "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+  },
   "packages": [
     "npm:@aliou/pi-processes@0.12.0",
     "npm:@twogiants/pi-anthropic-vertex",