ultimate-pi 0.15.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 (90) hide show
  1. package/.agents/skills/harness-governor/SKILL.md +11 -0
  2. package/.agents/skills/harness-orchestration/SKILL.md +3 -1
  3. package/.agents/skills/harness-plan/SKILL.md +5 -5
  4. package/.pi/agents/harness/adversary.md +1 -1
  5. package/.pi/agents/harness/evaluator.md +1 -1
  6. package/.pi/agents/harness/executor.md +1 -1
  7. package/.pi/agents/harness/incident-recorder.md +1 -1
  8. package/.pi/agents/harness/meta-optimizer.md +1 -1
  9. package/.pi/agents/harness/planning/decompose.md +4 -33
  10. package/.pi/agents/harness/planning/execution-plan-author.md +3 -2
  11. package/.pi/agents/harness/planning/hypothesis-validator.md +3 -2
  12. package/.pi/agents/harness/planning/hypothesis.md +4 -27
  13. package/.pi/agents/harness/planning/implementation-researcher.md +3 -2
  14. package/.pi/agents/harness/planning/plan-adversary.md +2 -3
  15. package/.pi/agents/harness/planning/plan-evaluator.md +3 -2
  16. package/.pi/agents/harness/planning/review-integrator.md +2 -3
  17. package/.pi/agents/harness/planning/scout-graphify.md +3 -22
  18. package/.pi/agents/harness/planning/scout-semantic.md +3 -18
  19. package/.pi/agents/harness/planning/scout-structure.md +3 -18
  20. package/.pi/agents/harness/planning/sprint-contract-auditor.md +3 -2
  21. package/.pi/agents/harness/planning/stack-researcher.md +3 -2
  22. package/.pi/agents/harness/tie-breaker.md +1 -1
  23. package/.pi/agents/harness/trace-librarian.md +1 -1
  24. package/.pi/extensions/budget-guard.ts +33 -19
  25. package/.pi/extensions/harness-debate-tools.ts +54 -6
  26. package/.pi/extensions/harness-run-context.ts +108 -2
  27. package/.pi/extensions/harness-subagent-submit.ts +172 -0
  28. package/.pi/extensions/harness-telemetry.ts +29 -4
  29. package/.pi/extensions/lib/debate-bus-core.ts +49 -6
  30. package/.pi/extensions/lib/harness-subagent-auth.ts +104 -19
  31. package/.pi/extensions/lib/harness-subagent-policy.ts +59 -0
  32. package/.pi/extensions/lib/harness-subagent-submit-pipeline.ts +82 -0
  33. package/.pi/extensions/lib/harness-subagent-submit-registry.ts +172 -0
  34. package/.pi/extensions/lib/harness-subagents-bridge.ts +127 -0
  35. package/.pi/extensions/lib/plan-debate-eligibility.ts +61 -8
  36. package/.pi/extensions/lib/plan-debate-focus.ts +21 -9
  37. package/.pi/extensions/lib/plan-debate-gate.ts +92 -18
  38. package/.pi/extensions/lib/plan-debate-lane.ts +15 -0
  39. package/.pi/extensions/lib/plan-debate-lanes.ts +27 -3
  40. package/.pi/extensions/lib/plan-debate-round-status.ts +18 -7
  41. package/.pi/extensions/lib/plan-messenger.ts +4 -0
  42. package/.pi/extensions/lib/plan-review-gate.ts +51 -0
  43. package/.pi/extensions/trace-recorder.ts +1 -0
  44. package/.pi/harness/agents.manifest.json +22 -22
  45. package/.pi/harness/docs/adrs/0037-subagent-submit-tools.md +31 -0
  46. package/.pi/harness/docs/adrs/0038-budget-telemetry-only.md +23 -0
  47. package/.pi/harness/docs/adrs/README.md +2 -0
  48. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/implementation-research.yaml +28 -0
  49. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/review-round-consolidated.yaml +25 -0
  50. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-packet.yaml +196 -0
  51. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-review.md +14 -0
  52. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/research-brief.yaml +62 -0
  53. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +40 -17
  54. package/.pi/harness/specs/harness-executor-handoff.schema.json +19 -0
  55. package/.pi/harness/specs/harness-human-required.schema.json +16 -0
  56. package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
  57. package/.pi/harness/specs/plan-scout-findings.schema.json +19 -0
  58. package/.pi/lib/harness-agent-output.ts +45 -0
  59. package/.pi/lib/harness-budget-enforce.ts +18 -0
  60. package/.pi/lib/harness-schema-validate.ts +89 -0
  61. package/.pi/lib/harness-spawn-parse.ts +86 -0
  62. package/.pi/lib/harness-subagent-submit-path.ts +41 -0
  63. package/.pi/lib/harness-ui-state.ts +15 -2
  64. package/.pi/model-router.example.json +13 -4
  65. package/.pi/prompts/harness-auto.md +2 -2
  66. package/.pi/prompts/harness-plan.md +34 -14
  67. package/.pi/prompts/harness-run.md +2 -2
  68. package/.pi/prompts/harness-setup.md +4 -4
  69. package/.pi/scripts/harness-generate-model-router.mjs +118 -36
  70. package/.pi/scripts/harness-model-router-routing.test.mjs +97 -0
  71. package/.pi/scripts/harness-sync-model-router.mjs +15 -2
  72. package/.pi/scripts/harness-verify.mjs +31 -0
  73. package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
  75. package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
  76. package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
  77. package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
  78. package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
  79. package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
  80. package/CHANGELOG.md +21 -0
  81. package/package.json +4 -2
  82. package/vendor/pi-model-router/UPSTREAM_PIN.md +3 -1
  83. package/vendor/pi-model-router/extensions/commands.ts +4 -4
  84. package/vendor/pi-model-router/extensions/index.ts +21 -0
  85. package/vendor/pi-model-router/extensions/provider.ts +130 -79
  86. package/vendor/pi-model-router/extensions/routing.ts +148 -0
  87. package/vendor/pi-model-router/extensions/state.ts +3 -0
  88. package/vendor/pi-model-router/extensions/types.ts +9 -0
  89. package/vendor/pi-model-router/extensions/ui.ts +16 -2
  90. package/vendor/pi-subagents/src/subagents.ts +29 -3
@@ -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;
@@ -19,15 +19,21 @@ import type {
19
19
  RouterTier,
20
20
  RouterPinByProfile,
21
21
  RouterThinkingByProfile,
22
+ SessionLock,
22
23
  } from './types.js';
23
24
  import { profileNames, parseCanonicalModelRef, ROUTER_TIERS } from './config.js';
24
25
  import {
25
26
  phaseForTier,
26
27
  buildRoutingDecision,
27
28
  decideRouting,
29
+ decideSessionLock,
28
30
  runClassifier,
29
31
  extractTextFromContent,
30
32
  hasImageAttachment,
33
+ buildSessionLockContext,
34
+ sessionLockToRoutingDecision,
35
+ routingDecisionToSessionLock,
36
+ applyThinkingToDecision,
31
37
  } from './routing.js';
32
38
 
33
39
  export const createErrorMessage = (
@@ -138,6 +144,8 @@ export const registerRouterProvider = (
138
144
  readonly thinkingByProfile: RouterThinkingByProfile;
139
145
  readonly pinnedTierByProfile: RouterPinByProfile;
140
146
  accumulatedCost: number;
147
+ get sessionLock(): SessionLock | undefined;
148
+ set sessionLock(v: SessionLock | undefined);
141
149
  },
142
150
  actions: {
143
151
  persistState: () => void;
@@ -217,26 +225,111 @@ export const registerRouterProvider = (
217
225
  state.routerEnabled = true;
218
226
 
219
227
  const pinnedTier = state.pinnedTierByProfile[model.id];
228
+ const thinkingOverrides = state.thinkingByProfile[model.id];
220
229
  const isBudgetExceeded =
221
230
  state.currentConfig.maxSessionBudget !== undefined &&
222
231
  state.accumulatedCost >= state.currentConfig.maxSessionBudget;
223
232
 
224
- let decision: RoutingDecision = decideRouting(
233
+ const checkModelSupportsImage = (modelRef: string) => {
234
+ try {
235
+ const { provider, modelId } = parseCanonicalModelRef(modelRef);
236
+ const m = state.currentModelRegistry?.find(provider, modelId);
237
+ return m?.input?.includes('image') ?? false;
238
+ } catch {
239
+ return false;
240
+ }
241
+ };
242
+
243
+ const pickVisionCapableLock = (
244
+ lockDecision: RoutingDecision,
245
+ ): RoutingDecision => {
246
+ if (!hasImageAttachment(context)) return lockDecision;
247
+ const candidates = [
248
+ lockDecision.targetLabel,
249
+ ...(profile[lockDecision.tier].fallbacks ?? []),
250
+ ];
251
+ if (candidates.some(checkModelSupportsImage)) return lockDecision;
252
+ for (const tier of ROUTER_TIERS) {
253
+ const refs = [profile[tier].model, ...(profile[tier].fallbacks ?? [])];
254
+ const visionRef = refs.find(checkModelSupportsImage);
255
+ if (visionRef) {
256
+ const { provider, modelId } = parseCanonicalModelRef(visionRef);
257
+ return {
258
+ ...lockDecision,
259
+ tier,
260
+ targetLabel: visionRef,
261
+ targetProvider: provider,
262
+ targetModelId: modelId,
263
+ reasoning: `${lockDecision.reasoning} | Vision-capable model for image input.`,
264
+ };
265
+ }
266
+ }
267
+ return lockDecision;
268
+ };
269
+
270
+ let lock = state.sessionLock;
271
+ if (!lock || lock.profile !== model.id) {
272
+ const lockContext = buildSessionLockContext(context);
273
+ let lockDecision = decideSessionLock(
274
+ lockContext,
275
+ model.id,
276
+ profile,
277
+ pinnedTier,
278
+ thinkingOverrides,
279
+ state.currentConfig.phaseBias,
280
+ state.currentConfig.rules,
281
+ );
282
+
283
+ if (
284
+ state.currentConfig.classifierModel &&
285
+ !pinnedTier &&
286
+ !lockDecision.isRuleMatched
287
+ ) {
288
+ const classifierResult = await runClassifier(
289
+ state.currentConfig.classifierModel,
290
+ state.currentModelRegistry,
291
+ lockContext,
292
+ undefined,
293
+ );
294
+ if (classifierResult) {
295
+ lockDecision = buildRoutingDecision(
296
+ model.id,
297
+ profile,
298
+ classifierResult.tier,
299
+ phaseForTier(classifierResult.tier),
300
+ `Session lock classifier: ${classifierResult.reasoning}`,
301
+ thinkingOverrides,
302
+ true,
303
+ );
304
+ }
305
+ }
306
+
307
+ lockDecision = pickVisionCapableLock(lockDecision);
308
+ lock = routingDecisionToSessionLock(lockDecision);
309
+ state.sessionLock = lock;
310
+ }
311
+
312
+ const lockedBase = sessionLockToRoutingDecision(
313
+ lock,
314
+ profile,
315
+ thinkingOverrides,
316
+ );
317
+
318
+ let thinkingDecision: RoutingDecision = decideRouting(
225
319
  context,
226
320
  model.id,
227
321
  profile,
228
322
  state.lastDecision,
229
323
  pinnedTier,
230
- state.thinkingByProfile[model.id],
324
+ thinkingOverrides,
231
325
  state.currentConfig.phaseBias,
232
326
  state.currentConfig.rules,
233
327
  isBudgetExceeded,
234
328
  );
235
329
 
236
- // Optional Context Trigger Upgrade
237
330
  if (
238
331
  state.currentConfig.largeContextThreshold &&
239
- decision.tier !== 'high' &&
332
+ thinkingDecision.tier !== 'high' &&
240
333
  state.lastExtensionContext
241
334
  ) {
242
335
  try {
@@ -245,28 +338,24 @@ export const registerRouterProvider = (
245
338
  usage?.tokens &&
246
339
  usage.tokens > state.currentConfig.largeContextThreshold
247
340
  ) {
248
- decision = buildRoutingDecision(
249
- model.id,
250
- profile,
251
- 'high',
252
- 'planning',
253
- `Context usage (${usage.tokens}) exceeds threshold (${state.currentConfig.largeContextThreshold}). Forced high tier.`,
254
- state.thinkingByProfile[model.id],
255
- false,
256
- );
257
- decision.isContextTriggered = true;
341
+ thinkingDecision = {
342
+ ...thinkingDecision,
343
+ tier: 'high',
344
+ phase: 'planning',
345
+ reasoning: `Context usage (${usage.tokens}) exceeds threshold (${state.currentConfig.largeContextThreshold}). Forced high thinking.`,
346
+ isContextTriggered: true,
347
+ };
258
348
  }
259
- } catch (e) {
349
+ } catch (_e) {
260
350
  // ignore
261
351
  }
262
352
  }
263
353
 
264
- // Classifier Override
265
354
  if (
266
355
  state.currentConfig.classifierModel &&
267
356
  !pinnedTier &&
268
- !decision.isContextTriggered &&
269
- !decision.isRuleMatched
357
+ !thinkingDecision.isContextTriggered &&
358
+ !thinkingDecision.isRuleMatched
270
359
  ) {
271
360
  const classifierResult = await runClassifier(
272
361
  state.currentConfig.classifierModel,
@@ -275,24 +364,34 @@ export const registerRouterProvider = (
275
364
  state.lastDecision?.phase,
276
365
  );
277
366
  if (classifierResult) {
278
- decision = buildRoutingDecision(
367
+ thinkingDecision = buildRoutingDecision(
279
368
  model.id,
280
369
  profile,
281
370
  classifierResult.tier,
282
371
  phaseForTier(classifierResult.tier),
283
- `Classifier: ${classifierResult.reasoning}`,
284
- state.thinkingByProfile[model.id],
372
+ `Thinking classifier: ${classifierResult.reasoning}`,
373
+ thinkingOverrides,
285
374
  true,
286
375
  );
287
- if (isBudgetExceeded && decision.tier === 'high') {
288
- decision.tier = 'medium';
289
- decision.phase = 'implementation';
290
- decision.reasoning = `Budget exceeded. Downgraded classifier decision to medium. (Original: ${decision.reasoning})`;
291
- decision.isBudgetForced = true;
376
+ if (isBudgetExceeded && thinkingDecision.tier === 'high') {
377
+ thinkingDecision = {
378
+ ...thinkingDecision,
379
+ tier: 'medium',
380
+ phase: 'implementation',
381
+ reasoning: `Budget exceeded. Downgraded thinking to medium. (Original: ${thinkingDecision.reasoning})`,
382
+ isBudgetForced: true,
383
+ };
292
384
  }
293
385
  }
294
386
  }
295
387
 
388
+ let decision = applyThinkingToDecision(
389
+ lockedBase,
390
+ thinkingDecision,
391
+ profile,
392
+ thinkingOverrides,
393
+ );
394
+
296
395
  const lastMessage = context.messages[context.messages.length - 1];
297
396
  const previousDecision = state.lastDecision;
298
397
  const isGoogleThinkingToolContinuation =
@@ -302,7 +401,7 @@ export const registerRouterProvider = (
302
401
  previousDecision.thinking !== 'off' &&
303
402
  decision.targetProvider === 'google' &&
304
403
  decision.thinking !== 'off' &&
305
- previousDecision.targetLabel !== decision.targetLabel;
404
+ previousDecision.targetLabel === decision.targetLabel;
306
405
 
307
406
  if (isGoogleThinkingToolContinuation) {
308
407
  decision = {
@@ -319,56 +418,6 @@ export const registerRouterProvider = (
319
418
  };
320
419
  }
321
420
 
322
- const imageAttached = hasImageAttachment(context);
323
- if (imageAttached) {
324
- const checkModelSupportsImage = (modelRef: string) => {
325
- try {
326
- const { provider, modelId } = parseCanonicalModelRef(modelRef);
327
- const m = state.currentModelRegistry?.find(provider, modelId);
328
- return m?.input?.includes('image') ?? false;
329
- } catch {
330
- return false;
331
- }
332
- };
333
-
334
- const tierModels = [
335
- decision.targetLabel,
336
- ...(profile[decision.tier].fallbacks ?? []),
337
- ];
338
- if (!tierModels.some(checkModelSupportsImage)) {
339
- const tiersToTry: RouterTier[] =
340
- decision.tier === 'low'
341
- ? ['medium', 'high']
342
- : decision.tier === 'medium'
343
- ? ['high']
344
- : [];
345
-
346
- let foundTier: RouterTier | undefined;
347
- for (const t of tiersToTry) {
348
- const tModels = [
349
- profile[t].model,
350
- ...(profile[t].fallbacks ?? []),
351
- ];
352
- if (tModels.some(checkModelSupportsImage)) {
353
- foundTier = t;
354
- break;
355
- }
356
- }
357
-
358
- if (foundTier) {
359
- decision = buildRoutingDecision(
360
- model.id,
361
- profile,
362
- foundTier,
363
- phaseForTier(foundTier),
364
- `Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
365
- state.thinkingByProfile[model.id],
366
- false,
367
- );
368
- }
369
- }
370
- }
371
-
372
421
  state.lastDecision = decision;
373
422
  actions.recordDebugDecision(decision);
374
423
 
@@ -376,10 +425,12 @@ export const registerRouterProvider = (
376
425
  actions.updateStatus(state.lastExtensionContext);
377
426
  }
378
427
 
428
+ const lockTier = lock.tier;
379
429
  let modelsToTry = [
380
- decision.targetLabel,
381
- ...(profile[decision.tier].fallbacks ?? []),
430
+ lock.modelRef,
431
+ ...(profile[lockTier].fallbacks ?? []),
382
432
  ];
433
+ const imageAttached = hasImageAttachment(context);
383
434
  if (imageAttached) {
384
435
  modelsToTry = modelsToTry.filter((modelRef) => {
385
436
  try {
@@ -7,6 +7,7 @@ import type {
7
7
  RoutingDecision,
8
8
  RoutingRule,
9
9
  RouterThinkingByTier,
10
+ SessionLock,
10
11
  } from './types.js';
11
12
  import { parseCanonicalModelRef, isRouterTier } from './config.js';
12
13
 
@@ -38,6 +39,153 @@ export const getLastUserText = (context: Context): string => {
38
39
  return '';
39
40
  };
40
41
 
42
+ export const getFirstUserText = (context: Context): string => {
43
+ for (const message of context.messages) {
44
+ if (message.role === 'user') {
45
+ return extractTextFromContent(message.content).trim();
46
+ }
47
+ }
48
+ return '';
49
+ };
50
+
51
+ /** Context for one-shot session model lock: system prompt + first user message only. */
52
+ export const buildSessionLockContext = (context: Context): Context => {
53
+ const firstUser = context.messages.find((message) => message.role === 'user');
54
+ const messages = firstUser ? [firstUser] : context.messages.slice(0, 1);
55
+ return { ...context, messages };
56
+ };
57
+
58
+ export const sessionLockToRoutingDecision = (
59
+ lock: SessionLock,
60
+ profile: RouterProfile,
61
+ thinkingOverrides?: RouterThinkingByTier,
62
+ ): RoutingDecision => {
63
+ const routed = profile[lock.tier];
64
+ const { provider, modelId } = parseCanonicalModelRef(lock.modelRef);
65
+ const baseThinking =
66
+ routed.thinking ??
67
+ (lock.tier === 'high' ? 'high' : lock.tier === 'low' ? 'low' : 'medium');
68
+ const effectiveThinking = thinkingOverrides?.[lock.tier] ?? baseThinking;
69
+ return {
70
+ profile: lock.profile,
71
+ tier: lock.tier,
72
+ phase: phaseForTier(lock.tier),
73
+ targetProvider: provider,
74
+ targetModelId: modelId,
75
+ targetLabel: lock.modelRef,
76
+ reasoning: lock.reasoning,
77
+ thinking: effectiveThinking,
78
+ timestamp: Date.now(),
79
+ };
80
+ };
81
+
82
+ export const routingDecisionToSessionLock = (
83
+ decision: RoutingDecision,
84
+ ): SessionLock => ({
85
+ profile: decision.profile,
86
+ tier: decision.tier,
87
+ modelRef: decision.targetLabel,
88
+ reasoning: decision.reasoning,
89
+ });
90
+
91
+ /** Text used to score initial session complexity (system prompt + first user message). */
92
+ export const getSessionLockAnalysisText = (context: Context): string => {
93
+ const lockContext = buildSessionLockContext(context);
94
+ const system = (context.systemPrompt ?? '').trim();
95
+ const firstUser = getFirstUserText(lockContext);
96
+ if (system && firstUser) return `${system}\n\n${firstUser}`;
97
+ return system || firstUser || '';
98
+ };
99
+
100
+ /**
101
+ * One-shot tier pick for session model lock (no prior-turn stickiness).
102
+ */
103
+ export const decideSessionLock = (
104
+ context: Context,
105
+ profileName: string,
106
+ profile: RouterProfile,
107
+ pinnedTier?: RouterTier,
108
+ thinkingOverrides?: RouterThinkingByTier,
109
+ phaseBias = 0.5,
110
+ rules?: RoutingRule[],
111
+ ): RoutingDecision => {
112
+ const analysisText = getSessionLockAnalysisText(context);
113
+ const synthetic: Context = {
114
+ systemPrompt: context.systemPrompt,
115
+ messages: analysisText
116
+ ? [{ role: 'user', content: analysisText, timestamp: Date.now() }]
117
+ : [],
118
+ };
119
+ return decideRouting(
120
+ synthetic,
121
+ profileName,
122
+ profile,
123
+ undefined,
124
+ pinnedTier,
125
+ thinkingOverrides,
126
+ phaseBias,
127
+ rules,
128
+ false,
129
+ );
130
+ };
131
+
132
+ /** Per-turn thinking tier merged onto a session-locked model decision. */
133
+ export const applyThinkingToDecision = (
134
+ locked: RoutingDecision,
135
+ thinkingDecision: RoutingDecision,
136
+ profile: RouterProfile,
137
+ thinkingOverrides?: RouterThinkingByTier,
138
+ ): RoutingDecision => {
139
+ const tier = thinkingDecision.tier;
140
+ const routed = profile[tier];
141
+ const baseThinking =
142
+ routed.thinking ??
143
+ (tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
144
+ const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
145
+
146
+ return {
147
+ profile: locked.profile,
148
+ tier,
149
+ phase: thinkingDecision.phase,
150
+ targetProvider: locked.targetProvider,
151
+ targetModelId: locked.targetModelId,
152
+ targetLabel: locked.targetLabel,
153
+ reasoning: `${locked.reasoning} | Thinking: ${thinkingDecision.reasoning}`,
154
+ thinking: effectiveThinking,
155
+ timestamp: Date.now(),
156
+ isClassifier: thinkingDecision.isClassifier,
157
+ isRuleMatched: thinkingDecision.isRuleMatched,
158
+ isBudgetForced: thinkingDecision.isBudgetForced,
159
+ isContextTriggered: thinkingDecision.isContextTriggered,
160
+ };
161
+ };
162
+
163
+ /** Harness subagents: tier from system prompt + optional first user line. */
164
+ export const resolveTierFromPrompt = (
165
+ systemPrompt: string,
166
+ userText: string,
167
+ profileName: string,
168
+ profile: RouterProfile,
169
+ rules?: RoutingRule[],
170
+ phaseBias = 0.5,
171
+ ): RouterTier => {
172
+ const context: Context = {
173
+ systemPrompt,
174
+ messages: userText
175
+ ? [{ role: 'user', content: userText, timestamp: Date.now() }]
176
+ : [],
177
+ };
178
+ return decideSessionLock(
179
+ context,
180
+ profileName,
181
+ profile,
182
+ undefined,
183
+ undefined,
184
+ phaseBias,
185
+ rules,
186
+ ).tier;
187
+ };
188
+
41
189
  export const getRecentConversationText = (
42
190
  context: Context,
43
191
  limit = 6,
@@ -3,6 +3,7 @@ import type {
3
3
  RouterThinkingByProfile,
4
4
  RoutingDecision,
5
5
  RouterPersistedState,
6
+ SessionLock,
6
7
  } from './types.js';
7
8
 
8
9
  export const isRouterPersistedState = (
@@ -30,6 +31,7 @@ export const buildPersistedState = (
30
31
  lastDecision: RoutingDecision | undefined,
31
32
  lastNonRouterModel: string | undefined,
32
33
  accumulatedCost: number,
34
+ sessionLock: SessionLock | undefined,
33
35
  ): RouterPersistedState => {
34
36
  return {
35
37
  enabled: routerEnabled,
@@ -37,6 +39,7 @@ export const buildPersistedState = (
37
39
  pinTier: pinnedTierByProfile[selectedProfile],
38
40
  pinByProfile: { ...pinnedTierByProfile },
39
41
  thinkingByProfile: { ...thinkingByProfile },
42
+ sessionLock,
40
43
  debugEnabled,
41
44
  widgetEnabled,
42
45
  debugHistory,
@@ -53,12 +53,21 @@ export interface RoutingDecision {
53
53
  isRuleMatched?: boolean;
54
54
  }
55
55
 
56
+ /** Fixed model for a router profile for the lifetime of a session (or until profile switch). */
57
+ export interface SessionLock {
58
+ profile: string;
59
+ tier: RouterTier;
60
+ modelRef: string;
61
+ reasoning: string;
62
+ }
63
+
56
64
  export interface RouterPersistedState {
57
65
  enabled: boolean;
58
66
  selectedProfile: string;
59
67
  pinTier?: RouterTier;
60
68
  pinByProfile?: RouterPinByProfile;
61
69
  thinkingByProfile?: RouterThinkingByProfile;
70
+ sessionLock?: SessionLock;
62
71
  debugEnabled?: boolean;
63
72
  widgetEnabled?: boolean;
64
73
  debugHistory?: RoutingDecision[];
@@ -4,6 +4,7 @@ import type {
4
4
  RouterConfig,
5
5
  RouterPinByProfile,
6
6
  RouterThinkingByProfile,
7
+ SessionLock,
7
8
  } from './types.js';
8
9
 
9
10
  const getEffectiveThinking = (
@@ -63,6 +64,7 @@ export const updateStatus = (
63
64
  accumulatedCost: number,
64
65
  widgetEnabled: boolean,
65
66
  currentConfig: RouterConfig,
67
+ sessionLock: SessionLock | undefined,
66
68
  ) => {
67
69
  const activeRouterProfile = routerEnabled ? selectedProfile : undefined;
68
70
  const statusProfile = selectedProfile;
@@ -81,7 +83,13 @@ export const updateStatus = (
81
83
  activeRouterProfile,
82
84
  lastDecision,
83
85
  );
84
- statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${effectiveThinking})`;
86
+ const locked =
87
+ sessionLock?.profile === activeRouterProfile
88
+ ? sessionLock.modelRef
89
+ : lastDecision.targetLabel;
90
+ statusText = `router:${activeRouterProfile}${pinLabel} locked:${locked} thinking:${lastDecision.tier} (${effectiveThinking})`;
91
+ } else if (sessionLock?.profile === activeRouterProfile) {
92
+ statusText = `router:${activeRouterProfile}${pinLabel} locked:${sessionLock.modelRef} -> waiting`;
85
93
  } else {
86
94
  statusText = `router:${activeRouterProfile}${pinLabel} -> waiting`;
87
95
  }
@@ -104,6 +112,11 @@ export const updateStatus = (
104
112
  ? ` / $${currentConfig.maxSessionBudget.toFixed(2)}`
105
113
  : ''),
106
114
  ];
115
+ if (sessionLock && sessionLock.profile === statusProfile) {
116
+ widgetLines.push(
117
+ `Locked model: ${sessionLock.modelRef} (lock tier ${sessionLock.tier})`,
118
+ );
119
+ }
107
120
  if (lastDecision && lastDecision.profile === statusProfile) {
108
121
  const effectiveThinking = getEffectiveThinking(
109
122
  thinkingByProfile,
@@ -114,8 +127,9 @@ export const updateStatus = (
114
127
  const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
115
128
 
116
129
  widgetLines.push(
117
- `Route: ${lastDecision.tier}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${effectiveThinking})`,
130
+ `Thinking: ${lastDecision.tier}${flagsStr} (${effectiveThinking})`,
118
131
  `Phase: ${lastDecision.phase}`,
132
+ `Delegate: ${lastDecision.targetProvider}/${lastDecision.targetModelId}`,
119
133
  );
120
134
  } else if (!routerEnabled && lastNonRouterModel) {
121
135
  widgetLines.push(`Fallback: ${lastNonRouterModel}`);