ultimate-pi 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.pi/agents/harness/planning/hypothesis.md +1 -1
  2. package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
  3. package/.pi/extensions/harness-debate-tools.ts +12 -3
  4. package/.pi/extensions/harness-run-context.ts +12 -0
  5. package/.pi/extensions/harness-subagent-submit.ts +2 -25
  6. package/.pi/extensions/harness-telemetry.ts +29 -4
  7. package/.pi/extensions/lib/debate-bus-core.ts +15 -9
  8. package/.pi/extensions/lib/harness-subagent-auth.ts +104 -19
  9. package/.pi/extensions/lib/harness-subagent-policy.ts +14 -0
  10. package/.pi/extensions/lib/harness-subagents-bridge.ts +85 -0
  11. package/.pi/extensions/lib/plan-debate-eligibility.ts +61 -8
  12. package/.pi/extensions/lib/plan-debate-focus.ts +21 -9
  13. package/.pi/extensions/lib/plan-debate-gate.ts +80 -17
  14. package/.pi/extensions/lib/plan-debate-lanes.ts +27 -3
  15. package/.pi/extensions/lib/plan-debate-round-status.ts +18 -7
  16. package/.pi/extensions/lib/plan-messenger.ts +4 -0
  17. package/.pi/extensions/lib/plan-review-gate.ts +51 -0
  18. package/.pi/extensions/trace-recorder.ts +1 -0
  19. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/implementation-research.yaml +28 -0
  20. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/review-round-consolidated.yaml +25 -0
  21. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-packet.yaml +196 -0
  22. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-review.md +14 -0
  23. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/research-brief.yaml +62 -0
  24. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +40 -17
  25. package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
  26. package/.pi/model-router.example.json +13 -4
  27. package/.pi/prompts/harness-plan.md +25 -7
  28. package/.pi/prompts/harness-setup.md +4 -4
  29. package/.pi/scripts/harness-generate-model-router.mjs +118 -36
  30. package/.pi/scripts/harness-model-router-routing.test.mjs +97 -0
  31. package/.pi/scripts/harness-sync-model-router.mjs +15 -2
  32. package/.pi/scripts/harness-verify.mjs +29 -0
  33. package/CHANGELOG.md +11 -0
  34. package/package.json +1 -1
  35. package/vendor/pi-model-router/UPSTREAM_PIN.md +3 -1
  36. package/vendor/pi-model-router/extensions/commands.ts +4 -4
  37. package/vendor/pi-model-router/extensions/index.ts +21 -0
  38. package/vendor/pi-model-router/extensions/provider.ts +130 -79
  39. package/vendor/pi-model-router/extensions/routing.ts +148 -0
  40. package/vendor/pi-model-router/extensions/state.ts +3 -0
  41. package/vendor/pi-model-router/extensions/types.ts +9 -0
  42. package/vendor/pi-model-router/extensions/ui.ts +16 -2
@@ -22,9 +22,9 @@ const UP_PKG = join(SCRIPT_DIR, "..", "..");
22
22
  const OUT_PATH = join(process.cwd(), ".pi", "model-router.json");
23
23
 
24
24
  const PROVIDER_PRIORITY = [
25
+ "openai",
25
26
  "opencode-go",
26
27
  "anthropic",
27
- "openai",
28
28
  "google",
29
29
  "openrouter",
30
30
  "groq",
@@ -35,6 +35,7 @@ const PROVIDER_PRIORITY = [
35
35
  /** Substring hints per tier (first match in available ids wins). */
36
36
  const TIER_HINTS = {
37
37
  high: [
38
+ "gpt-5.5-pro",
38
39
  "deepseek-v4-pro",
39
40
  "gpt-5.4-pro",
40
41
  "claude-opus",
@@ -43,6 +44,7 @@ const TIER_HINTS = {
43
44
  "pro",
44
45
  ],
45
46
  medium: [
47
+ "gpt-5.5",
46
48
  "qwen3.6-plus",
47
49
  "kimi-k2.6",
48
50
  "gpt-5.4",
@@ -98,7 +100,10 @@ function canonicalRef(provider, modelId) {
98
100
 
99
101
  function pickTierModel(models, tier) {
100
102
  const hints = TIER_HINTS[tier];
101
- const ids = models.map((m) => m.id);
103
+ for (const hint of hints) {
104
+ const exact = models.find((m) => m.id === hint);
105
+ if (exact) return canonicalRef(exact.provider, exact.id);
106
+ }
102
107
  for (const hint of hints) {
103
108
  const match = models.find((m) => m.id.includes(hint));
104
109
  if (match) return canonicalRef(match.provider, match.id);
@@ -114,6 +119,10 @@ function pickTierModel(models, tier) {
114
119
  return canonicalRef(models[0].provider, models[0].id);
115
120
  }
116
121
 
122
+ function modelsForProvider(available, provider) {
123
+ return available.filter((m) => m.provider === provider);
124
+ }
125
+
117
126
  function choosePrimaryProvider(available) {
118
127
  const byProvider = new Map();
119
128
  for (const m of available) {
@@ -129,7 +138,7 @@ function choosePrimaryProvider(available) {
129
138
 
130
139
  function buildFallbacks(available, primaryProvider, highModel) {
131
140
  const fallbacks = [];
132
- for (const p of ["anthropic", "google", "openai"]) {
141
+ for (const p of ["anthropic", "google", "openai", "opencode-go"]) {
133
142
  if (p === primaryProvider) continue;
134
143
  const alt = available.filter((m) => m.provider === p);
135
144
  if (alt.length === 0) continue;
@@ -139,6 +148,76 @@ function buildFallbacks(available, primaryProvider, highModel) {
139
148
  return fallbacks.slice(0, 3);
140
149
  }
141
150
 
151
+ /** Session-locked router: one model SKU per profile; tiers vary thinking only. */
152
+ function buildRoutedProfile(available, provider) {
153
+ const models = modelsForProvider(available, provider);
154
+ if (models.length === 0) return null;
155
+ const sku =
156
+ pickTierModel(models, "medium") ??
157
+ pickTierModel(models, "high") ??
158
+ pickTierModel(models, "low");
159
+ if (!sku) return null;
160
+ const fallbacks = buildFallbacks(available, provider, sku);
161
+ const high = { model: sku, thinking: "high" };
162
+ if (fallbacks.length) high.fallbacks = fallbacks;
163
+ return {
164
+ high,
165
+ medium: { model: sku, thinking: "medium" },
166
+ low: { model: sku, thinking: "low" },
167
+ };
168
+ }
169
+
170
+ function addCheapDeepProfiles(profiles, available, provider) {
171
+ const models = modelsForProvider(available, provider);
172
+ if (models.length === 0) return;
173
+ const sku =
174
+ pickTierModel(models, "medium") ??
175
+ pickTierModel(models, "high") ??
176
+ pickTierModel(models, "low");
177
+ if (!sku) return;
178
+ const fallbacks = buildFallbacks(available, provider, sku);
179
+ const deepHigh = { model: sku, thinking: "xhigh" };
180
+ if (fallbacks.length) deepHigh.fallbacks = fallbacks;
181
+ profiles.cheap = {
182
+ high: { model: sku, thinking: "low" },
183
+ medium: { model: sku, thinking: "off" },
184
+ low: { model: sku, thinking: "off" },
185
+ };
186
+ profiles.deep = {
187
+ high: deepHigh,
188
+ medium: { model: sku, thinking: "medium" },
189
+ low: { model: sku, thinking: "low" },
190
+ };
191
+ }
192
+
193
+ function resolveClassifierModel(available) {
194
+ const openaiModels = modelsForProvider(available, "openai");
195
+ if (openaiModels.length > 0) {
196
+ return (
197
+ pickTierModel(openaiModels, "low") ??
198
+ canonicalRef(openaiModels[openaiModels.length - 1].provider, openaiModels[openaiModels.length - 1].id)
199
+ );
200
+ }
201
+ const { models } = choosePrimaryProvider(available);
202
+ return pickTierModel(models, "medium");
203
+ }
204
+
205
+ /** OpenAI-backed default profile name exposed as `router/auto`. */
206
+ const OPENAI_PROFILE_NAME = "auto";
207
+
208
+ function routerProfileName(provider) {
209
+ return provider === "openai" ? OPENAI_PROFILE_NAME : provider;
210
+ }
211
+
212
+ function resolveDefaultProfile(profiles) {
213
+ if (profiles[OPENAI_PROFILE_NAME]) return OPENAI_PROFILE_NAME;
214
+ if (profiles["opencode-go"]) return "opencode-go";
215
+ return (
216
+ Object.keys(profiles).find((name) => name !== "cheap" && name !== "deep") ??
217
+ OPENAI_PROFILE_NAME
218
+ );
219
+ }
220
+
142
221
  async function main() {
143
222
  const force = process.argv.includes("--force");
144
223
  const dryRun = process.argv.includes("--dry-run");
@@ -171,23 +250,37 @@ async function main() {
171
250
  process.exit(0);
172
251
  }
173
252
 
174
- const { provider: primaryProvider, models: primaryModels } =
175
- choosePrimaryProvider(available);
176
-
177
- const highModel = pickTierModel(primaryModels, "high");
178
- const mediumModel = pickTierModel(primaryModels, "medium");
179
- const lowModel = pickTierModel(primaryModels, "low");
253
+ const profiles = {};
254
+ for (const provider of ["openai", "opencode-go"]) {
255
+ const profile = buildRoutedProfile(available, provider);
256
+ if (profile) profiles[routerProfileName(provider)] = profile;
257
+ }
180
258
 
181
- if (!highModel || !mediumModel || !lowModel) {
182
- fail("could not assign tier models from available registry");
259
+ if (Object.keys(profiles).length === 0) {
260
+ const { provider: primaryProvider, models: primaryModels } =
261
+ choosePrimaryProvider(available);
262
+ const profile = buildRoutedProfile(available, primaryProvider);
263
+ if (!profile) {
264
+ fail("could not assign tier models from available registry");
265
+ }
266
+ profiles[primaryProvider] = profile;
183
267
  }
184
268
 
185
- const fallbacks = buildFallbacks(available, primaryProvider, highModel);
269
+ const cheapDeepSource = profiles["opencode-go"]
270
+ ? "opencode-go"
271
+ : resolveDefaultProfile(profiles);
272
+ addCheapDeepProfiles(profiles, available, cheapDeepSource);
273
+
274
+ const defaultProfile = resolveDefaultProfile(profiles);
275
+ const classifierModel = resolveClassifierModel(available);
276
+ if (!classifierModel) {
277
+ fail("could not assign classifier model from available registry");
278
+ }
186
279
 
187
280
  const config = {
188
- defaultProfile: "auto",
281
+ defaultProfile,
189
282
  debug: false,
190
- classifierModel: mediumModel,
283
+ classifierModel,
191
284
  phaseBias: 0.5,
192
285
  maxSessionBudget: 1.0,
193
286
  largeContextThreshold: 100000,
@@ -199,27 +292,13 @@ async function main() {
199
292
  },
200
293
  { matches: "changelog", tier: "low" },
201
294
  ],
202
- profiles: {
203
- auto: {
204
- high: { model: highModel, thinking: "high", fallbacks },
205
- medium: { model: mediumModel, thinking: "medium" },
206
- low: { model: lowModel, thinking: "low" },
207
- },
208
- cheap: {
209
- high: { model: mediumModel, thinking: "low" },
210
- medium: { model: lowModel, thinking: "off" },
211
- low: { model: lowModel, thinking: "off" },
212
- },
213
- deep: {
214
- high: { model: highModel, thinking: "xhigh", fallbacks },
215
- medium: { model: mediumModel, thinking: "medium" },
216
- low: { model: lowModel, thinking: "low" },
217
- },
218
- },
295
+ profiles,
219
296
  };
220
297
 
221
298
  const json = `${JSON.stringify(config, null, 2)}\n`;
222
299
  const providerSet = [...new Set(available.map((m) => m.provider))].sort();
300
+ const autoProfile = profiles[OPENAI_PROFILE_NAME];
301
+ const opencodeProfile = profiles["opencode-go"];
223
302
 
224
303
  if (dryRun) {
225
304
  process.stdout.write(json);
@@ -230,13 +309,16 @@ async function main() {
230
309
  writeFileSync(OUT_PATH, json, "utf8");
231
310
 
232
311
  console.log("✓ Generated .pi/model-router.json from Pi authenticated providers:");
233
- console.log(` Primary provider: ${primaryProvider}`);
312
+ console.log(` Default profile: ${defaultProfile}`);
313
+ console.log(` Classifier: ${classifierModel}`);
234
314
  console.log(` Authenticated providers: ${providerSet.join(", ")}`);
235
315
  console.log(` Available models: ${available.length}`);
236
- console.log(` High tier: ${highModel}`);
237
- console.log(` Medium tier: ${mediumModel}`);
238
- console.log(` Low tier: ${lowModel}`);
239
- if (fallbacks.length) console.log(` Fallbacks: ${fallbacks.join(", ")}`);
316
+ if (autoProfile) {
317
+ console.log(` auto (openai) high: ${autoProfile.high.model}`);
318
+ }
319
+ if (opencodeProfile) {
320
+ console.log(` opencode-go high: ${opencodeProfile.high.model}`);
321
+ }
240
322
  }
241
323
 
242
324
  main().catch((err) => {
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for session-locked pi-model-router routing (no LLM).
4
+ * Run: npx tsx .pi/scripts/harness-model-router-routing.test.mjs
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import {
12
+ decideSessionLock,
13
+ applyThinkingToDecision,
14
+ buildRoutingDecision,
15
+ decideRouting,
16
+ } from "../../vendor/pi-model-router/extensions/routing.js";
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+
20
+ const sampleProfile = {
21
+ high: { model: "openai/gpt-5.5", thinking: "high" },
22
+ medium: { model: "openai/gpt-5.5", thinking: "medium" },
23
+ low: { model: "openai/gpt-5.5", thinking: "low" },
24
+ };
25
+
26
+ const planningContext = {
27
+ systemPrompt: "You are a harness architect. Design tradeoffs and migration strategy.",
28
+ messages: [
29
+ {
30
+ role: "user",
31
+ content:
32
+ "Plan a multi-phase refactor across modules with architecture review.",
33
+ timestamp: 1,
34
+ },
35
+ ],
36
+ };
37
+
38
+ const shortContext = {
39
+ systemPrompt: "Summarize briefly.",
40
+ messages: [{ role: "user", content: "changelog", timestamp: 1 }],
41
+ };
42
+
43
+ const lockHigh = decideSessionLock(
44
+ planningContext,
45
+ "auto",
46
+ sampleProfile,
47
+ undefined,
48
+ undefined,
49
+ 0.5,
50
+ [{ matches: "changelog", tier: "low" }],
51
+ );
52
+ assert.equal(lockHigh.tier, "high", "planning prompt locks high tier");
53
+
54
+ const lockLow = decideSessionLock(shortContext, "auto", sampleProfile);
55
+ assert.equal(lockLow.tier, "low", "short summary locks low tier");
56
+
57
+ const locked = buildRoutingDecision(
58
+ "auto",
59
+ sampleProfile,
60
+ lockHigh.tier,
61
+ "planning",
62
+ lockHigh.reasoning,
63
+ );
64
+ const thinkingTurn = decideRouting(
65
+ {
66
+ ...planningContext,
67
+ messages: [
68
+ ...planningContext.messages,
69
+ { role: "user", content: "changelog only", timestamp: 2 },
70
+ ],
71
+ },
72
+ "auto",
73
+ sampleProfile,
74
+ locked,
75
+ );
76
+ const merged = applyThinkingToDecision(locked, thinkingTurn, sampleProfile);
77
+ assert.equal(merged.targetLabel, locked.targetLabel, "model stays locked");
78
+ assert.equal(merged.tier, thinkingTurn.tier, "thinking tier follows turn");
79
+ assert.equal(merged.thinking, "low", "low thinking from turn tier config");
80
+
81
+ const examplePath = join(ROOT, ".pi", "model-router.example.json");
82
+ const example = JSON.parse(readFileSync(examplePath, "utf8"));
83
+ for (const [name, profile] of Object.entries(example.profiles ?? {})) {
84
+ const { high, medium, low } = profile;
85
+ assert.equal(
86
+ high.model,
87
+ medium.model,
88
+ `example profile ${name}: medium/high same model`,
89
+ );
90
+ assert.equal(
91
+ medium.model,
92
+ low.model,
93
+ `example profile ${name}: low/medium same model`,
94
+ );
95
+ }
96
+
97
+ console.log("harness-model-router-routing.test: PASS");
@@ -29,11 +29,24 @@ function saveSettings(settingsPath, data) {
29
29
  );
30
30
  }
31
31
 
32
+ function readDefaultRouterProfile(configPath) {
33
+ if (!existsSync(configPath)) return "auto";
34
+ try {
35
+ const data = JSON.parse(readFileSync(configPath, "utf8"));
36
+ const profile =
37
+ typeof data.defaultProfile === "string" ? data.defaultProfile.trim() : "";
38
+ return profile || "auto";
39
+ } catch {
40
+ return "auto";
41
+ }
42
+ }
43
+
32
44
  function main() {
33
45
  const root = process.cwd();
34
46
  const configPath = join(root, ".pi", "model-router.json");
35
47
  const settingsPath = join(root, ".pi", "settings.json");
36
48
  const hasConfig = existsSync(configPath);
49
+ const defaultRouterProfile = readDefaultRouterProfile(configPath);
37
50
 
38
51
  const settings = loadSettings(settingsPath);
39
52
  if (!settings) {
@@ -67,14 +80,14 @@ function main() {
67
80
 
68
81
  if (noProjectDefault) {
69
82
  settings.defaultProvider = "router";
70
- settings.defaultModel = "auto";
83
+ settings.defaultModel = defaultRouterProfile;
71
84
  changed = true;
72
85
  }
73
86
 
74
87
  if (changed) {
75
88
  saveSettings(settingsPath, settings);
76
89
  console.log(
77
- "✓ Router defaults set (`router` / `auto`) — run /reload in pi when ready",
90
+ `✓ Router defaults set (\`router\` / \`${defaultRouterProfile}\`) — run /reload in pi when ready`,
78
91
  );
79
92
  } else {
80
93
  console.log("[harness-model-router] Defaults unchanged (user set defaultProvider)");
@@ -145,6 +145,34 @@ async function checkSentruxRules() {
145
145
  ok(".sentrux/rules.toml present");
146
146
  }
147
147
 
148
+ async function checkModelRouterThinkingOnly() {
149
+ const path = join(ROOT, ".pi", "model-router.json");
150
+ if (!(await fileExists(path))) {
151
+ ok("model-router.json absent (skip thinking-only tier check)");
152
+ return;
153
+ }
154
+ let raw;
155
+ try {
156
+ raw = JSON.parse(await readFile(path, "utf-8"));
157
+ } catch {
158
+ fail("invalid .pi/model-router.json");
159
+ }
160
+ const profiles = raw.profiles ?? {};
161
+ for (const [name, profile] of Object.entries(profiles)) {
162
+ const high = profile?.high?.model;
163
+ const medium = profile?.medium?.model;
164
+ const low = profile?.low?.model;
165
+ if (
166
+ !(high && medium && low && high === medium && medium === low)
167
+ ) {
168
+ fail(
169
+ `model-router profile "${name}" must use the same model on high/medium/low (thinking-only tiers)`,
170
+ );
171
+ }
172
+ }
173
+ ok("model-router.json thinking-only (same model per profile)");
174
+ }
175
+
148
176
  async function checkSentruxGate() {
149
177
  await checkSentruxRules();
150
178
 
@@ -288,6 +316,7 @@ async function main() {
288
316
  ok("test-diff-golden.json");
289
317
 
290
318
  await checkSentruxGate();
319
+ await checkModelRouterThinkingOnly();
291
320
 
292
321
  if (!(await fileExists(AGENTS_MANIFEST))) {
293
322
  fail(
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v0.17.0] — 2026-05-22
8
+
9
+ ### ✨ Features
10
+
11
+ - **Model router:** Session-locked model SKU at start (initial prompt + system prompt); per-turn routing adjusts thinking tier only; subagents lock from agent `systemPrompt` complexity.
12
+ - **Harness:** Thinking-only profile shape in generator/verify; plan review gate, debate eligibility, and smoke fixture updates.
13
+
14
+ ### ✅ Tests
15
+
16
+ - Add `harness-model-router-routing` and plan-debate eligibility coverage.
17
+
7
18
  ## [v0.16.0] — 2026-05-19
8
19
 
9
20
  ### ✨ Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-pi",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Ultimate AI coding harness for pi.dev — extensible skills, Obsidian wiki knowledge layer, compressed context, deterministic output",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -3,6 +3,8 @@
3
3
  - **Repository:** https://github.com/yeliu84/pi-model-router
4
4
  - **License:** MIT (`LICENSE` in this tree)
5
5
  - **Pinned upstream commit:** `8c60095da0e753c242c4be9bb617b85f4dd3255c`
6
- - **Local changes:** TypeScript imports in `extensions/*.ts` use `@earendil-works/*`; relative imports end in `.js` for NodeNext; `package.json` peerDependencies list `@earendil-works/*`.
6
+ - **Local changes:**
7
+ - TypeScript imports in `extensions/*.ts` use `@earendil-works/*`; relative imports end in `.js` for NodeNext; `package.json` peerDependencies list `@earendil-works/*`.
8
+ - **Session-locked routing (ultimate-pi harness):** one concrete model per session/profile, chosen from initial prompt + system prompt complexity; per-turn routing adjusts **thinking tier only** (`sessionLock` persisted in `router-state`). Harness generator emits the same `model` on high/medium/low tiers.
7
9
 
8
10
  **Refresh upstream:** run `npm run vendor:sync-router` from ultimate-pi root (updates this file with the latest commit SHA).
@@ -58,12 +58,12 @@ export const registerCommands = (
58
58
  const SUBCOMMAND_DETAILS = [
59
59
  { name: 'status', desc: 'Show current router status' },
60
60
  { name: 'profile', desc: 'Switch to a different router profile' },
61
- { name: 'pin', desc: 'Pin routing for a profile to a specific tier' },
61
+ { name: 'pin', desc: 'Pin thinking tier for a profile (model stays session-locked)' },
62
62
  { name: 'thinking', desc: 'Override thinking level for a tier or profile' },
63
63
  { name: 'disable', desc: 'Disable the router and restore last model' },
64
64
  {
65
65
  name: 'fix',
66
- desc: 'Correct the last routing decision and pin that tier',
66
+ desc: 'Correct last thinking tier and pin it (model stays session-locked)',
67
67
  },
68
68
  { name: 'widget', desc: 'Toggle the router status widget' },
69
69
  { name: 'debug', desc: 'Toggle or clear router debug history' },
@@ -676,10 +676,10 @@ export const registerCommands = (
676
676
  'Router Subcommands:',
677
677
  ' status Show current status, profile, pin, cost, and last decision.',
678
678
  ' profile [name] Switch to a profile (enables router if off). Lists available if no name.',
679
- ' pin [profile] <tier|auto> Force a tier (high|medium|low) for a profile or set to auto.',
679
+ ' pin [profile] <tier|auto> Pin thinking tier (high|medium|low); model stays locked for the session.',
680
680
  ' thinking [prof] [tier] <lv> Override thinking level for a profile/tier (off|minimal|...|xhigh|auto).',
681
681
  ' disable Disable the router and restore the last used non-router model.',
682
- ' fix <tier> Correct the last routing decision and pin that tier for the current profile.',
682
+ ' fix <tier> Correct last thinking tier and pin it for the current profile.',
683
683
  ' widget <on|off|toggle> Control the persistent status widget visibility.',
684
684
  ' debug <on|off|show|clear> Control routing debug logging to notifications and history.',
685
685
  ' reload Hot-reload the configuration JSON from .pi/model-router.json.',
@@ -10,6 +10,7 @@ import {
10
10
  type RouterThinkingByProfile,
11
11
  type RouterTier,
12
12
  type CustomSessionEntry,
13
+ type SessionLock,
13
14
  } from './types.js';
14
15
  import {
15
16
  FALLBACK_CONFIG,
@@ -47,6 +48,7 @@ const routerExtension = (pi: ExtensionAPI) => {
47
48
  let lastPersistedSnapshot: string | undefined;
48
49
  let isInitialized = false;
49
50
  let isInternalModelSwitch = false;
51
+ let sessionLock: SessionLock | undefined;
50
52
 
51
53
  const setModelInternally = async (
52
54
  model: NonNullable<ExtensionContext['model']>,
@@ -94,6 +96,7 @@ const routerExtension = (pi: ExtensionAPI) => {
94
96
  lastDecision,
95
97
  lastNonRouterModel,
96
98
  accumulatedCost,
99
+ sessionLock,
97
100
  );
98
101
  const snapshot = JSON.stringify({
99
102
  ...state,
@@ -127,6 +130,7 @@ const routerExtension = (pi: ExtensionAPI) => {
127
130
  accumulatedCost,
128
131
  widgetEnabled,
129
132
  currentConfig,
133
+ sessionLock,
130
134
  ),
131
135
  reloadConfig: (
132
136
  ctx?: ExtensionContext,
@@ -204,6 +208,7 @@ const routerExtension = (pi: ExtensionAPI) => {
204
208
  }
205
209
  selectedProfile = resolvedProfile;
206
210
  routerEnabled = true;
211
+ sessionLock = undefined;
207
212
  persistState();
208
213
  actions.updateStatus(ctx);
209
214
  return true;
@@ -253,6 +258,12 @@ const routerExtension = (pi: ExtensionAPI) => {
253
258
  set accumulatedCost(v) {
254
259
  accumulatedCost = v;
255
260
  },
261
+ get sessionLock() {
262
+ return sessionLock;
263
+ },
264
+ set sessionLock(v) {
265
+ sessionLock = v;
266
+ },
256
267
  },
257
268
  {
258
269
  persistState,
@@ -290,6 +301,7 @@ const routerExtension = (pi: ExtensionAPI) => {
290
301
  ? `${ctx.model.provider}/${ctx.model.id}`
291
302
  : lastNonRouterModel;
292
303
  lastDecision = undefined;
304
+ sessionLock = undefined;
293
305
 
294
306
  const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
295
307
  const savedState = entries
@@ -322,6 +334,12 @@ const routerExtension = (pi: ExtensionAPI) => {
322
334
  : [];
323
335
  lastNonRouterModel = savedState.lastNonRouterModel ?? lastNonRouterModel;
324
336
  accumulatedCost = savedState.accumulatedCost ?? 0;
337
+ if (
338
+ savedState.sessionLock &&
339
+ savedState.sessionLock.profile === selectedProfile
340
+ ) {
341
+ sessionLock = { ...savedState.sessionLock };
342
+ }
325
343
  }
326
344
 
327
345
  await actions.ensureValidActiveRouterProfile(ctx);
@@ -432,6 +450,9 @@ const routerExtension = (pi: ExtensionAPI) => {
432
450
  }
433
451
 
434
452
  routerEnabled = true;
453
+ if (selectedProfile !== profileName) {
454
+ sessionLock = undefined;
455
+ }
435
456
  selectedProfile = profileName;
436
457
  } else {
437
458
  routerEnabled = false;