@qadanm/opencode-smart-router 0.4.0 → 0.6.1

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.
@@ -20,7 +20,12 @@ import { repairMessages } from "../core/reprompt.js"
20
20
  import { providerProtocol } from "../core/models/protocol.js"
21
21
  import { PromptRouteTracker } from "./route-tracker.js"
22
22
  import { buildProviderUsageReport } from "../core/quota/report.js"
23
- import { resolvePlan, resolvePlanWithReason } from "../core/plans/catalog.js"
23
+ import {
24
+ OPENDESIGN_DESIGN_PLAN_MODELS,
25
+ OPENDESIGN_PLAN_MODEL_POOL,
26
+ resolvePlan,
27
+ resolvePlanWithReason,
28
+ } from "../core/plans/catalog.js"
24
29
  import { loadSettings, validateSettings, type SettingsLoadResult } from "../core/config.js"
25
30
  import { writeSettingsPatch } from "../core/config-writer.js"
26
31
  import { describeConsoleReading, parseConsolePage, type UsageReading } from "../core/plan-page.js"
@@ -51,12 +56,10 @@ import { decidePremium, evaluateEligibility } from "../core/policy/eligibility.j
51
56
  import { sharedCore, type RuntimeOverrides } from "./shared-core.js"
52
57
  import {
53
58
  ChangeNotifier,
54
- buildIndicatorTitle,
55
59
  formatQuotaLines,
56
60
  indicatorState,
57
61
  providerShort,
58
62
  quotaStateLabel,
59
- resolveBaseTitle,
60
63
  stripTitlePrefix,
61
64
  type Indicator,
62
65
  } from "../core/ui.js"
@@ -80,6 +83,7 @@ import {
80
83
  unique,
81
84
  usd,
82
85
  } from "../core/util.js"
86
+ import { containHook, withDeadline } from "./containment.js"
83
87
 
84
88
  interface ControllerOptions {
85
89
  readonly ctx: any
@@ -172,7 +176,6 @@ const applyRuntime = (base: RouterSettings, runtime: RuntimeOverrides): RouterSe
172
176
  if (runtime.mode) next.mode = runtime.mode
173
177
  if (runtime.paused === true) next.enabled = false
174
178
  if (typeof runtime.dryRun === "boolean") next.dryRun = runtime.dryRun
175
- if (typeof runtime.titleIndicator === "boolean") next.ui.titleIndicator = runtime.titleIndicator
176
179
  if (typeof runtime.compactMode === "boolean") {
177
180
  next.ui.compactMode = runtime.compactMode
178
181
  next.ui.compact = runtime.compactMode
@@ -379,6 +382,8 @@ export class RouterController {
379
382
  }
380
383
 
381
384
  private modelList: unknown = []
385
+ /** Shared in-flight discovery; see ensureModelsLoaded(). */
386
+ private modelLoadPromise?: Promise<void>
382
387
 
383
388
  async initialize(): Promise<() => void | Promise<void>> {
384
389
  // Before anything reads settings: a provider the user never configured
@@ -937,7 +942,7 @@ export class RouterController {
937
942
  state,
938
943
  ...(ref ? { provider: providerShort(ref.providerID), model: ref.id } : {}),
939
944
  ...(model ? { modelLabel: model.name } : ref ? { modelLabel: ref.id } : {}),
940
- ...(session?.indicatorTitle ? { title: session.indicatorTitle } : {}),
945
+
941
946
  }
942
947
  }
943
948
 
@@ -954,6 +959,19 @@ export class RouterController {
954
959
  return next
955
960
  }
956
961
 
962
+ /**
963
+ * The router never renames a session.
964
+ *
965
+ * A chat title is the owner's name for the work, and a plugin that rewrites it
966
+ * to advertise which model it picked trades the owner's memory for the
967
+ * plugin's status line. Status lives in the panel, the terminal footer and the
968
+ * decision record instead.
969
+ *
970
+ * One exception remains, and it only runs against titles this plugin wrote in
971
+ * an older version: a single pass that strips the prefix when the session is
972
+ * next touched, so upgrades heal instead of leaving `AUTO · OD · model`
973
+ * forever.
974
+ */
957
975
  private async applyTitleNow(sessionID: string) {
958
976
  const session = this.ledger.getSession(sessionID)
959
977
  if (!session) return
@@ -966,7 +984,7 @@ export class RouterController {
966
984
  if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
967
985
  const currentTitle = typeof info?.title === "string" ? info.title : undefined
968
986
 
969
- if (!this.settings.ui.titleIndicator) {
987
+ {
970
988
  /**
971
989
  * The indicator is off. If a previous version of this plugin left its
972
990
  * prefix on the title, put the name back once, the first time the session
@@ -986,67 +1004,27 @@ export class RouterController {
986
1004
  return
987
1005
  }
988
1006
 
989
- const modelRef = session.pinnedModel ?? session.manualModel ?? session.lastModel
990
- if (!modelRef) return
991
-
992
- // Adopt a name the router did not write. Never fall back to `session.title`:
993
- // that value is the router's own previous output, so reading it back is how
994
- // the prefix used to become the name permanently.
995
- const baseTitle = resolveBaseTitle({
996
- currentTitle,
997
- lastWrittenTitle: session.indicatorTitle,
998
- storedBase: session.baseTitle,
999
- })
1000
- // Nothing worth annotating. Leave the user's title exactly as it is.
1001
- if (!baseTitle) return
1002
-
1003
- const model = this.registry.get(modelRef.providerID, modelRef.id)
1004
- const indicator = this.indicatorFor(sessionID, { ...session, baseTitle })
1005
- const nextTitle = buildIndicatorTitle({
1006
- state: indicator.state,
1007
- provider: modelRef.providerID,
1008
- modelLabel: model?.name ?? modelRef.id,
1009
- baseTitle,
1010
- compact: this.settings.ui.compactMode || this.settings.ui.compact || this.settings.compactMode || this.settings.compact,
1011
- })
1012
- if (currentTitle === nextTitle && session.indicatorTitle === nextTitle) return
1013
- session.baseTitle = baseTitle
1014
- session.indicatorTitle = nextTitle
1015
- session.title = nextTitle
1016
- this.ledger.patchSession(sessionID, {
1017
- baseTitle,
1018
- indicatorTitle: nextTitle,
1019
- title: nextTitle,
1020
- })
1021
- try {
1022
- await this.ctx.session.update({ sessionID, title: nextTitle })
1023
- } catch (error) {
1024
- this.settingsWarnings.push(`Could not update the session title indicator: ${safeError(error)}`)
1025
- }
1026
- }
1027
-
1028
- private async refreshKnownTitles() {
1029
- const ids = Object.values(this.ledger.data.sessions)
1030
- .filter((session) => this.ownsSession(session))
1031
- .map((session) => session.sessionID)
1032
- await Promise.allSettled(ids.map((sessionID) => this.queueTitle(sessionID)))
1033
1007
  }
1034
1008
 
1035
- private async restoreTitles() {
1009
+ /** Strips prefixes this plugin wrote. Returns how many titles changed. */
1010
+ private async restoreTitles(): Promise<number> {
1011
+ let restored = 0
1036
1012
  for (const session of Object.values(this.ledger.data.sessions)) {
1037
1013
  if (!this.ownsSession(session)) continue
1038
- const base = session.baseTitle || stripTitlePrefix(session.title)
1039
- if (!base) continue
1014
+ const visible = session.title ?? session.indicatorTitle
1015
+ const base = session.baseTitle || stripTitlePrefix(visible)
1016
+ // A title that is only a prefix has no name left to restore. Rewriting it
1017
+ // to the prefix again would keep the damage; leave it for the owner.
1018
+ if (!base || base === visible) continue
1040
1019
  try {
1041
1020
  await this.ctx.session.update({ sessionID: session.sessionID, title: base })
1042
- this.ledger.patchSession(session.sessionID, {
1043
- title: base,
1044
- indicatorTitle: undefined,
1045
- })
1021
+ this.ledger.patchSession(session.sessionID, { title: base, indicatorTitle: undefined })
1022
+ restored += 1
1046
1023
  } catch (error) {
1047
1024
  this.settingsWarnings.push(`Could not restore session title: ${safeError(error)}`)
1048
1025
  }
1049
1026
  }
1027
+ return restored
1050
1028
  }
1051
1029
 
1052
1030
  private async notifyChange(key: string, message: string) {
@@ -1084,18 +1062,54 @@ export class RouterController {
1084
1062
  return out
1085
1063
  }
1086
1064
 
1087
- private premiumState(session?: SessionState): PremiumState { const spent = this.ledger.premiumSpent()
1065
+ private premiumState(session?: SessionState): PremiumState {
1066
+ // Reading budget state must never be able to fail a prompt or a status
1067
+ // request. Every value here degrades to a conservative default, and a
1068
+ // ledger that cannot answer is reported as unknown rather than thrown.
1069
+ const read = <T>(fn: () => T, fallback: T): T => {
1070
+ try {
1071
+ const value = fn()
1072
+ return value === undefined || value === null ? fallback : value
1073
+ } catch {
1074
+ return fallback
1075
+ }
1076
+ }
1077
+ const spent = read(() => this.ledger.premiumSpent(), 0)
1088
1078
  const budget = this.settings.premium.monthlyBudgetUsd
1079
+ // The plan-model pool is the escalation budget of record. Its cap comes
1080
+ // from the resolved plan catalog, not from premium.monthlyBudgetUsd, so
1081
+ // the gate tracks the allowance the account actually has.
1082
+ const planPoolMonthlyUsd = this.planPoolMonthlyUsd()
1083
+ const planPoolSpent = read(
1084
+ () => (typeof this.ledger.planPoolSpent === "function" ? this.ledger.planPoolSpent() : undefined),
1085
+ undefined as number | undefined,
1086
+ )
1089
1087
  return {
1090
1088
  mode: this.settings.premium.mode,
1091
1089
  monthlyBudgetUsd: budget,
1092
1090
  spentUsd: spent,
1093
1091
  remainingUsd: Math.max(0, budget - spent),
1092
+ ...(planPoolMonthlyUsd !== undefined && planPoolSpent !== undefined
1093
+ ? { planPoolRemainingUsd: Math.max(0, planPoolMonthlyUsd - planPoolSpent) }
1094
+ : {}),
1094
1095
  oneShotGranted: session?.premiumOneShot === true,
1095
1096
  ...(session?.premiumOneShotAt ? { oneShotGrantedAt: session.premiumOneShotAt } : {}),
1096
1097
  }
1097
1098
  }
1098
1099
 
1100
+ /**
1101
+ * The monthly cap of the OpenDesign plan-model pool for the configured plan,
1102
+ * when the model pool is the one escalation draws from. Undefined for any
1103
+ * other configuration, which leaves the gate on the legacy premium budget.
1104
+ */
1105
+ private planPoolMonthlyUsd(): number | undefined {
1106
+ const provider = this.settings.providers.opendesign
1107
+ if (!provider?.enabled) return undefined
1108
+ const plan = resolvePlan("opendesign", provider.plan)
1109
+ if (!plan) return undefined
1110
+ return plan.pools.find((pool) => pool.id === OPENDESIGN_PLAN_MODEL_POOL)?.monthlyUsd
1111
+ }
1112
+
1099
1113
  private quotaSnapshots() {
1100
1114
  return this.quota
1101
1115
  ? this.quota.read().map((snapshot) => {
@@ -1262,20 +1276,9 @@ export class RouterController {
1262
1276
  const ref = session.lastModel
1263
1277
  const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
1264
1278
  const label = model?.name ?? ref?.id ?? "current model"
1265
- const blockedTitle = buildIndicatorTitle({
1266
- state: "BLOCKED",
1267
- provider: ref?.providerID,
1268
- modelLabel: label,
1269
- baseTitle: session.baseTitle || session.title,
1270
- })
1271
- session.indicatorTitle = blockedTitle
1272
- session.title = blockedTitle
1273
- this.ledger.patchSession(sessionID, { indicatorTitle: blockedTitle, title: blockedTitle })
1274
- try {
1275
- await this.ctx.session.update({ sessionID, title: blockedTitle })
1276
- } catch {
1277
- // The decision still carries the reason.
1278
- }
1279
+ // Blocking a prompt must not rename the owner's chat. The refusal lives in
1280
+ // the decision record, the panel and the notice.
1281
+ this.settingsWarnings.push(`Blocked a prompt: ${reason}`)
1279
1282
  void this.notifyChange(`${sessionID}:blocked:${reason}`, `BLOCKED · ${label} — ${reason}`)
1280
1283
  }
1281
1284
 
@@ -1347,6 +1350,7 @@ export class RouterController {
1347
1350
  void this.ensureContextTokens(sessionID)
1348
1351
 
1349
1352
  if (runtime !== "automatic") {
1353
+ const models = await this.routingModels()
1350
1354
  const task = classifyTask({
1351
1355
  text,
1352
1356
  files: this.classifierFiles(files),
@@ -1358,8 +1362,8 @@ export class RouterController {
1358
1362
  settings,
1359
1363
  mode: settings.mode,
1360
1364
  runtime,
1361
- models: this.routingModels(),
1362
- intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1365
+ models: models,
1366
+ intelligenceFloor: this.routingFloor(models).floor,
1363
1367
  current: session.lastModel,
1364
1368
  session,
1365
1369
  quota: this.quotaSnapshots(),
@@ -1374,6 +1378,7 @@ export class RouterController {
1374
1378
  return result.decision
1375
1379
  }
1376
1380
 
1381
+ const models = await this.routingModels()
1377
1382
  const task = classifyTask({
1378
1383
  text,
1379
1384
  files: this.classifierFiles(files),
@@ -1390,8 +1395,8 @@ export class RouterController {
1390
1395
  settings,
1391
1396
  mode: effectiveMode,
1392
1397
  runtime,
1393
- models: this.routingModels(),
1394
- intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1398
+ models: models,
1399
+ intelligenceFloor: this.routingFloor(models).floor,
1395
1400
  current: session.lastModel,
1396
1401
  session,
1397
1402
  quota,
@@ -1556,45 +1561,81 @@ export class RouterController {
1556
1561
  return finalDecision
1557
1562
  }
1558
1563
 
1564
+ /**
1565
+ * How long routing may hold a prompt before the prompt is served without it.
1566
+ *
1567
+ * The number is a ceiling on waiting, not a performance target. Routing that
1568
+ * has not finished in this long is not going to improve the request in front
1569
+ * of the owner, and every extra second is a second the editor appears frozen.
1570
+ */
1571
+ private routeBudgetMs(): number {
1572
+ const configured = Number((this.settings as any)?.routing?.routeBudgetMs)
1573
+ if (Number.isFinite(configured) && configured > 0) return Math.min(configured, 60_000)
1574
+ return 10_000
1575
+ }
1576
+
1559
1577
  private async registerHooks() {
1560
1578
  this.registrations.push(
1561
- await this.ctx.session.hook("prompt", async (event: any) => {
1562
- const sessionID = event.sessionID
1563
- if (!sessionID) return
1564
- try {
1565
- const probe: any = await this.ctx.session.get({ sessionID })
1566
- if (!this.projectMatches(probe?.projectID, probe?.location?.directory)) return
1567
- } catch {
1568
- // If the location probe fails, the location-scoped hook can still run.
1569
- }
1570
- sharedCore.claimSession(sessionID, this.bridgeID)
1571
- this.remember(this.promptSeen, sessionID)
1572
- this.pendingHeals.delete(sessionID)
1573
- if (this.inflight.has(sessionID)) return
1574
- this.inflight.add(sessionID)
1575
- try {
1576
- const decision = await this.routePrompt(sessionID, event.prompt?.text ?? "", event.prompt?.files)
1577
- if (decision) {
1578
- // Available to the retry hook, which has no prompt linkage of its
1579
- // own: a deterministic rejection re-routes this same task.
1580
- this.promptRoutes.set(sessionID, {
1581
- messageID: typeof event.messageID === "string" ? event.messageID : undefined,
1582
- task: decision.task,
1583
- decision,
1584
- at: Date.now(),
1579
+ await this.ctx.session.hook("prompt", containHook(
1580
+ async (event: any) => {
1581
+ const sessionID = event.sessionID
1582
+ if (!sessionID) return
1583
+ try {
1584
+ const probe: any = await this.ctx.session.get({ sessionID })
1585
+ if (!this.projectMatches(probe?.projectID, probe?.location?.directory)) return
1586
+ } catch {
1587
+ // If the location probe fails, the location-scoped hook can still run.
1588
+ }
1589
+ sharedCore.claimSession(sessionID, this.bridgeID)
1590
+ this.remember(this.promptSeen, sessionID)
1591
+ this.pendingHeals.delete(sessionID)
1592
+ if (this.inflight.has(sessionID)) return
1593
+ this.inflight.add(sessionID)
1594
+ try {
1595
+ // Routing runs against a deadline. If it cannot finish, the prompt
1596
+ // proceeds unrouted: an optimization that cannot finish is worth
1597
+ // nothing unless it gets out of the way.
1598
+ const decision = await withDeadline({
1599
+ work: () => this.routePrompt(sessionID, event.prompt?.text ?? "", event.prompt?.files),
1600
+ ms: this.routeBudgetMs(),
1601
+ onTimeout: () => {
1602
+ this.settingsWarnings.push(
1603
+ `Routing did not finish within ${this.routeBudgetMs()}ms; the prompt was served without a route.`,
1604
+ )
1605
+ },
1585
1606
  })
1607
+ if (decision) {
1608
+ // Available to the retry hook, which has no prompt linkage of its
1609
+ // own: a deterministic rejection re-routes this same task.
1610
+ this.promptRoutes.set(sessionID, {
1611
+ messageID: typeof event.messageID === "string" ? event.messageID : undefined,
1612
+ task: decision.task,
1613
+ decision,
1614
+ at: Date.now(),
1615
+ })
1616
+ }
1617
+ } catch (error) {
1618
+ if (error instanceof RouterBlockedError || (error as any)?.name === "RouterBlockedError") throw error
1619
+ await this.handleRoutingFailure(sessionID, event.prompt?.text ?? "", event.prompt?.files, error)
1620
+ } finally {
1621
+ this.inflight.delete(sessionID)
1586
1622
  }
1587
- } catch (error) {
1588
- if (error instanceof RouterBlockedError || (error as any)?.name === "RouterBlockedError") throw error
1589
- await this.handleRoutingFailure(sessionID, event.prompt?.text ?? "", event.prompt?.files, error)
1590
- } finally {
1591
- this.inflight.delete(sessionID)
1592
- }
1593
- }),
1623
+ },
1624
+ {
1625
+ label: "prompt routing",
1626
+ allowBlocked: true,
1627
+ onContained: (message) => {
1628
+ // A defect inside the router is reported, never escalated to a
1629
+ // refused prompt. The host keeps working; the owner can see why
1630
+ // routing was skipped.
1631
+ this.settingsWarnings.push(`Routing skipped: ${message}`)
1632
+ },
1633
+ },
1634
+ )),
1594
1635
  )
1595
1636
 
1596
1637
  this.registrations.push(
1597
- await this.ctx.session.hook("model.request", (event: any) => {
1638
+ await this.ctx.session.hook("model.request", containHook((event: any) => {
1598
1639
  if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1599
1640
  // Latch the request kind so the context hook (whose event carries no
1600
1641
  // kind) can tell the agent loop apart from title and compaction runs.
@@ -1617,7 +1658,10 @@ export class RouterController {
1617
1658
  if (this.settings.privacy.requireZdr && event.model?.providerID === "commandcode") {
1618
1659
  event.headers["x-cmd-zdr"] = "1"
1619
1660
  }
1620
- }),
1661
+ }, {
1662
+ label: "request bookkeeping",
1663
+ onContained: (message) => this.settingsWarnings.push(`Request bookkeeping skipped: ${message}`),
1664
+ })),
1621
1665
  )
1622
1666
 
1623
1667
  // The host emits `model.request` for every model call with the request's
@@ -1650,7 +1694,7 @@ export class RouterController {
1650
1694
  )
1651
1695
 
1652
1696
  this.registrations.push(
1653
- await this.ctx.session.hook("retry", async (event: any) => {
1697
+ await this.ctx.session.hook("retry", containHook(async (event: any) => {
1654
1698
  if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1655
1699
  const session = this.ledger.getSession(event.sessionID)
1656
1700
  const assessment = classifyFailure(
@@ -1751,7 +1795,12 @@ export class RouterController {
1751
1795
  } else if (assessment.class === "provider_outage" || assessment.class === "network") {
1752
1796
  if (event.attempt >= 3) event.decision = { retry: false }
1753
1797
  }
1754
- }),
1798
+ }, {
1799
+ label: "retry handling",
1800
+ // A contained retry failure leaves the host's own policy in place,
1801
+ // which is a working default rather than a blocked turn.
1802
+ onContained: (message) => this.settingsWarnings.push(`Retry handling skipped: ${message}`),
1803
+ })),
1755
1804
  )
1756
1805
  }
1757
1806
 
@@ -1862,13 +1911,14 @@ export class RouterController {
1862
1911
  }
1863
1912
 
1864
1913
  const session = this.sessionState(sessionID)
1914
+ const models = await this.routingModels()
1865
1915
  const result = routeTask({
1866
1916
  task: route.task,
1867
1917
  settings: this.settings,
1868
1918
  mode: session.parentID && this.settings.mode === "premium" ? "balanced" : this.settings.mode,
1869
1919
  runtime: "automatic",
1870
- models: this.routingModels(),
1871
- intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1920
+ models: models,
1921
+ intelligenceFloor: this.routingFloor(models).floor,
1872
1922
  current: rejected,
1873
1923
  session,
1874
1924
  quota: this.quotaSnapshots(),
@@ -2042,10 +2092,31 @@ export class RouterController {
2042
2092
  }
2043
2093
 
2044
2094
  private async modelResolvable(providerID: string, modelID: string): Promise<boolean> {
2045
- // The host is the authority, but it is not always reachable. A MISSING API is not
2046
- // a "no": it means this host cannot answer the question, so fall back to the
2047
- // previous behaviour of attempting the switch and surfacing whatever happens.
2048
- // Only an API that answers and returns nothing is treated as a real refusal.
2095
+ // The host is the authority on which models it can serve, and there are two
2096
+ // ways to ask it. Use both, cheapest and most authoritative first.
2097
+ //
2098
+ // 1. The model list this plugin already fetched. It is the closest thing to
2099
+ // "what this machine can actually serve", but it is not exhaustive: a
2100
+ // provider read from a gateway (OpenDesign) is merged in separately, and
2101
+ // a provider that is not configured yet is absent rather than empty. So
2102
+ // the refusal is scoped to the case that is genuinely knowable: the host
2103
+ // lists this provider, and does not list this model. That is the state
2104
+ // that produced "Model unavailable: opencode-go/claude-haiku-5-5" on a
2105
+ // fresh install (scripts/isolated-e2e.sh, 2026-11-06).
2106
+ const known = unwrap<any>(this.modelList ?? [])
2107
+ if (Array.isArray(known) && known.length > 0) {
2108
+ const providerIsKnown = known.some((entry: any) => entry?.providerID === providerID)
2109
+ if (providerIsKnown) {
2110
+ const modelIsKnown = known.some(
2111
+ (entry: any) => entry?.providerID === providerID && entry?.id === modelID,
2112
+ )
2113
+ if (!modelIsKnown) return false
2114
+ }
2115
+ }
2116
+ // 2. Ask the host directly. A MISSING API is not a "no": it means this host
2117
+ // cannot answer the question, so fall back to attempting the switch and
2118
+ // surfacing whatever happens. Only an API that answers and returns
2119
+ // nothing is treated as a real refusal.
2049
2120
  const get = this.ctx?.model?.get
2050
2121
  if (typeof get !== "function") return true
2051
2122
  try {
@@ -2063,10 +2134,59 @@ export class RouterController {
2063
2134
  * engine applies its own allow-list on top, so this is a convenience rather
2064
2135
  * than the security boundary; the boundary is in `routeTask` and stays there
2065
2136
  * even if a future caller passes the wrong list.
2137
+ *
2138
+ * MODELS THE HOST CANNOT SERVE ARE NOT CANDIDATES.
2139
+ *
2140
+ * The seed catalog describes providers in general; it does not know which of
2141
+ * them are configured on this machine. Offering the whole catalog meant the
2142
+ * router could choose a model OpenCode had never heard of and switch the
2143
+ * session onto it, after which the next step died with "Model unavailable".
2144
+ * Verified on a fresh install in scripts/isolated-e2e.sh (2026-11-06): the
2145
+ * host exposed only its own default model, and routing reached for
2146
+ * `opencode-go/claude-haiku-5-5`.
2147
+ *
2148
+ * The live list is authoritative once it has been read: it is OpenCode's own
2149
+ * catalog plus the OpenDesign gateway's entitled models. Until it has been
2150
+ * read it is empty, and an empty list must not filter anything out, or the
2151
+ * router would have no candidates during startup.
2066
2152
  */
2067
- private routingModels() {
2153
+ private async routingModels(): Promise<ModelRecord[]> {
2068
2154
  if (this.settings.offline?.enabled) return this.registry.modelsForOffline()
2069
- return this.registry.approved()
2155
+ const approved = this.registry.approved()
2156
+ // WAIT FOR THE HOST'S MODEL LIST ON THE FIRST ROUTING DECISION.
2157
+ //
2158
+ // Discovery starts in the background during initialize(), so the first
2159
+ // prompt after startup used to route against an empty list -- the exact
2160
+ // window in which the router could pick a seed model this machine cannot
2161
+ // serve. Awaiting it is bounded by the routing deadline, and a discovery
2162
+ // that fails leaves the list empty, which falls back to the old behaviour.
2163
+ await this.ensureModelsLoaded()
2164
+ const live = unwrap<any>(this.modelList ?? [])
2165
+ if (!Array.isArray(live) || live.length === 0) return approved
2166
+ const servable = new Set(live.map((entry: any) => modelKey(entry?.providerID ?? "", entry?.id ?? "")))
2167
+ // No fallback to the unfiltered list. Returning the full catalog when the
2168
+ // intersection is empty is how an unservable model gets chosen anyway: on a
2169
+ // machine whose providers do not match the seed catalog, filtering produced
2170
+ // an empty list, the fallback reinstated every seed model, and the router
2171
+ // switched onto `opencode-go/claude-haiku-5-5` -- a model the host had never
2172
+ // heard of. Verified in scripts/isolated-e2e.sh on 2026-11-06.
2173
+ //
2174
+ // An empty list is the correct answer there. It means "this plugin has
2175
+ // nothing it may usefully route to on this machine", and the router then
2176
+ // leaves the session alone, which is what the host would have done had the
2177
+ // plugin not been installed.
2178
+ return approved.filter((model) => servable.has(model.key))
2179
+ }
2180
+
2181
+ /** One in-flight model discovery shared by every caller. */
2182
+ private async ensureModelsLoaded(): Promise<void> {
2183
+ if (unwrap<any>(this.modelList ?? []).length > 0) return
2184
+ if (!this.modelLoadPromise) {
2185
+ this.modelLoadPromise = this.loadModels().finally(() => {
2186
+ this.modelLoadPromise = undefined
2187
+ })
2188
+ }
2189
+ await this.modelLoadPromise
2070
2190
  }
2071
2191
 
2072
2192
  /**
@@ -2468,6 +2588,13 @@ export class RouterController {
2468
2588
  const tokens = tokensFrom(data.tokens)
2469
2589
  const latency = this.stepStarted.get(sessionID) ? Date.now() - (this.stepStarted.get(sessionID) ?? Date.now()) : undefined
2470
2590
  if (model) {
2591
+ // Plan-model-pool identity is resolved from the model id, so every
2592
+ // recorded step attributes its spend to the right allowance: plan
2593
+ // models draw the $120 escalation pool; Design Plan models and every
2594
+ // other provider's models draw their own.
2595
+ const modelRecord = this.registry.get(model.providerID, model.id)
2596
+ const isOpendesignPlanPool =
2597
+ model.providerID === "opendesign" && modelRecord !== undefined && !OPENDESIGN_DESIGN_PLAN_MODELS.has(model.id)
2471
2598
  this.ledger.recordUsage({
2472
2599
  sessionID,
2473
2600
  model,
@@ -2475,7 +2602,8 @@ export class RouterController {
2475
2602
  tokens,
2476
2603
  at: typeof event.created === "number" ? event.created : Date.now(),
2477
2604
  latencyMs: latency,
2478
- premium: this.registry.get(model.providerID, model.id)?.premium ?? false,
2605
+ premium: modelRecord?.premium ?? false,
2606
+ planPool: isOpendesignPlanPool,
2479
2607
  })
2480
2608
  // A completed step is proof the provider's pre-request path worked,
2481
2609
  // so any run of provider-scoped failures starts over.
@@ -2555,7 +2683,6 @@ export class RouterController {
2555
2683
  setPaused: async (input: any) => {
2556
2684
  this.runtime.paused = Boolean(input.paused)
2557
2685
  this.persistRuntime()
2558
- void this.refreshKnownTitles()
2559
2686
  void this.notifyChange(
2560
2687
  `global:paused:${Boolean(input.paused)}`,
2561
2688
  input.paused ? "PAUSED · automatic routing is paused" : "AUTO · automatic routing resumed",
@@ -2590,7 +2717,6 @@ export class RouterController {
2590
2717
  setDryRun: async (input: any) => {
2591
2718
  this.runtime.dryRun = Boolean(input.dryRun)
2592
2719
  this.persistRuntime()
2593
- void this.refreshKnownTitles()
2594
2720
  void this.notifyChange(
2595
2721
  `global:dry-run:${Boolean(input.dryRun)}`,
2596
2722
  input.dryRun ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
@@ -2764,10 +2890,10 @@ export class RouterController {
2764
2890
  return summary
2765
2891
  },
2766
2892
  setTitleIndicator: async (input: any) => {
2767
- this.runtime.titleIndicator = Boolean(input.enabled)
2768
- this.persistRuntime()
2769
- if (input.enabled) await this.refreshKnownTitles()
2770
- else await this.restoreTitles()
2893
+ // The router no longer renames sessions. `enabled: true` is accepted for
2894
+ // old callers and ignored; `enabled: false` runs the one-time cleanup of
2895
+ // titles written by earlier versions.
2896
+ if (input?.enabled !== true) await this.restoreTitles()
2771
2897
  const summary = this.statusFor()
2772
2898
  void this.emit("status", { summary })
2773
2899
  return summary
@@ -2885,7 +3011,6 @@ export class RouterController {
2885
3011
  await command("router-pause", "Pause automatic routing globally", async ({ sessionID }: any) => {
2886
3012
  this.runtime.paused = true
2887
3013
  this.persistRuntime()
2888
- void this.refreshKnownTitles()
2889
3014
  void this.notifyChange("global:paused:true", "PAUSED · automatic routing is paused")
2890
3015
  await notice(sessionID, "PAUSED · automatic routing is paused.")
2891
3016
  }),
@@ -2895,7 +3020,6 @@ export class RouterController {
2895
3020
  await command("router-resume", "Resume automatic routing globally", async ({ sessionID }: any) => {
2896
3021
  this.runtime.paused = false
2897
3022
  this.persistRuntime()
2898
- void this.refreshKnownTitles()
2899
3023
  void this.notifyChange("global:paused:false", "AUTO · automatic routing resumed")
2900
3024
  await notice(sessionID, "AUTO · automatic routing resumed.")
2901
3025
  }),
@@ -2922,7 +3046,6 @@ export class RouterController {
2922
3046
  }
2923
3047
  this.runtime.dryRun = value === "on"
2924
3048
  this.persistRuntime()
2925
- void this.refreshKnownTitles()
2926
3049
  void this.notifyChange(
2927
3050
  `global:dry-run:${value === "on"}`,
2928
3051
  value === "on" ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
@@ -2932,17 +3055,18 @@ export class RouterController {
2932
3055
  )
2933
3056
 
2934
3057
  this.registrations.push(
2935
- await command("router-title", "Turn the persistent session-title indicator on or off: /router-title on|off", async ({ sessionID, prompt }: any) => {
3058
+ await command("router-title", "Strip router-written prefixes from existing session titles: /router-title off", async ({ sessionID, prompt }: any) => {
2936
3059
  const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
2937
- if (value !== "on" && value !== "off") {
2938
- await notice(sessionID, "Usage: /router-title on|off")
3060
+ if (value === "on") {
3061
+ await notice(sessionID, "The router no longer renames sessions. Titles stay yours; status lives in the panel and the header.")
2939
3062
  return
2940
3063
  }
2941
- this.runtime.titleIndicator = value === "on"
2942
- this.persistRuntime()
2943
- if (value === "on") await this.refreshKnownTitles()
2944
- else await this.restoreTitles()
2945
- await notice(sessionID, value === "on" ? "Session-title indicator enabled." : "Session-title indicator disabled; base titles restored.")
3064
+ if (value !== "off") {
3065
+ await notice(sessionID, "Usage: /router-title off (strips prefixes this plugin wrote in older versions)")
3066
+ return
3067
+ }
3068
+ const restored = await this.restoreTitles()
3069
+ await notice(sessionID, `Cleaned ${restored} session title${restored === 1 ? "" : "s"}. Titles written before the separator was lost cannot be recovered.`)
2946
3070
  }),
2947
3071
  )
2948
3072