@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,456 @@
1
+ import type { CandidateEvaluation, ModelKey, ProviderQuotaSnapshot, RoutingDecision } from "../core/types.js"
2
+
3
+ /**
4
+ * The GUI's view-model.
5
+ *
6
+ * Pure functions over the data the server already produces. No Solid, no DOM,
7
+ * no RPC — so every rule below is testable in plain Node, and the TUI renderer
8
+ * can reuse it unchanged.
9
+ *
10
+ * The rule this file exists to enforce: the panel shows the router's own words.
11
+ * If the engine says a model was ineligible because the weekly window is spent,
12
+ * that sentence is displayed. The view-model formats and orders; it never
13
+ * invents or softens a reason.
14
+ */
15
+
16
+ export type Verdict = "chosen" | "eligible" | "blocked"
17
+
18
+ export interface CandidateRow {
19
+ readonly key: string
20
+ readonly name: string
21
+ readonly tier: number
22
+ readonly verdict: Verdict
23
+ readonly reason: string
24
+ readonly estimatedUsd: number
25
+ readonly effectiveCost: number
26
+ readonly successProbability: number
27
+ /** Why the ranking moved this model, when a multiplier applied. Empty otherwise. */
28
+ readonly adjustments: ReadonlyArray<string>
29
+ }
30
+
31
+ export interface DecisionView {
32
+ readonly id: string
33
+ readonly chosen: string | undefined
34
+ readonly switched: boolean
35
+ readonly switchReason: string
36
+ readonly taskKind: string
37
+ readonly taskTier: number
38
+ readonly contextTokens: number
39
+ readonly estimatedUsd: number
40
+ readonly reasons: ReadonlyArray<string>
41
+ readonly warnings: ReadonlyArray<string>
42
+ /** One line about a self-healed deterministic rejection, when the decision is one. */
43
+ readonly selfheal?: string
44
+ readonly eligibleCount: number
45
+ readonly blockedCount: number
46
+ readonly rows: ReadonlyArray<CandidateRow>
47
+ }
48
+
49
+ export type WindowID = "fiveHour" | "weekly" | "monthly"
50
+
51
+ export interface QuotaWindowView {
52
+ readonly id: WindowID
53
+ /** Short label used in the panel: 5h, 7d, Monthly. */
54
+ readonly label: string
55
+ readonly usedPct: number
56
+ readonly remainingPct: number
57
+ readonly usedUsd?: number
58
+ readonly capUsd?: number
59
+ readonly resetsAt?: number
60
+ readonly resetLabel?: string
61
+ readonly source: "provider" | "estimate" | "unknown"
62
+ readonly sourceLabel: string
63
+ readonly exceeded: boolean
64
+ /**
65
+ * Whether the provider reports this window at all. A window that does not
66
+ * exist for a plan is different from a window at 0%, and showing "0%" for a
67
+ * five-hour cap that does not apply would be a lie.
68
+ */
69
+ readonly reported: boolean
70
+ /**
71
+ * True when the figure is a lower bound the router derived itself, not a
72
+ * measurement from the provider.
73
+ *
74
+ * This matters more than it looks. OpenDesign publishes no quota endpoint for
75
+ * an inference key, so the router estimates from the traffic it routed itself.
76
+ * A user whose account was already 42% used before installing the router saw
77
+ * "5% monthly" — a true statement about the router's own traffic, and a
78
+ * dangerously wrong impression of their account. A lower bound must never be
79
+ * rendered as a measurement.
80
+ */
81
+ readonly lowerBound: boolean
82
+ }
83
+
84
+ export interface PoolRow {
85
+ readonly id: string
86
+ readonly label: string
87
+ readonly source: "provider" | "estimate" | "unknown"
88
+ readonly sourceLabel: string
89
+ readonly monthlyUsd: number
90
+ readonly usedUsd: number
91
+ readonly remainingUsd: number
92
+ readonly usedPct: number
93
+ readonly weeklyPct?: number
94
+ readonly fiveHourPct?: number
95
+ readonly resetLabel?: string
96
+ readonly note: string
97
+ }
98
+
99
+ export interface ProviderRow {
100
+ readonly providerID: string
101
+ readonly label: string
102
+ readonly plan: string
103
+ /** `provider` and `estimate` are facts of different strength and are never merged. */
104
+ readonly source: "provider" | "estimate" | "unknown"
105
+ readonly sourceLabel: string
106
+ readonly quotaState?: string
107
+ readonly stale: boolean
108
+ readonly fetchedAt: number
109
+ /** The tightest window that is exhausted, if any. */
110
+ readonly exhausted: boolean
111
+ readonly exhaustedWindow?: string
112
+ /** 5h, 7d, monthly — always all three, with `reported` telling the truth. */
113
+ readonly windows: ReadonlyArray<QuotaWindowView>
114
+ readonly pools: ReadonlyArray<PoolRow>
115
+ readonly monthlyRemainingUsd?: number
116
+ readonly extraCreditsUsd?: number
117
+ readonly error?: string
118
+ readonly notes: ReadonlyArray<string>
119
+ /** The sentence that explains why a figure is a lower bound, if it is one. */
120
+ readonly caveat?: string
121
+ }
122
+
123
+ const sourceLabelOf = (source: "provider" | "estimate" | "unknown"): string => {
124
+ switch (source) {
125
+ case "provider":
126
+ return "verified by provider"
127
+ case "estimate":
128
+ return "estimated — lower bound"
129
+ default:
130
+ return "not reported"
131
+ }
132
+ }
133
+
134
+ const windowLabels: Record<WindowID, string> = {
135
+ fiveHour: "5h",
136
+ weekly: "7d",
137
+ monthly: "Monthly",
138
+ }
139
+
140
+ /** Human reset time. Absolute once it is far away; relative while it matters. */
141
+ export const formatReset = (at: number | undefined, now = Date.now()): string | undefined => {
142
+ if (at === undefined) return undefined
143
+ const delta = at - now
144
+ if (delta <= 0) return "resetting"
145
+ const minutes = Math.round(delta / 60_000)
146
+ if (minutes < 60) return `in ${minutes}m`
147
+ const hours = Math.round(minutes / 60)
148
+ if (hours < 36) return `in ${hours}h`
149
+ const days = Math.round(hours / 24)
150
+ return `in ${days}d`
151
+ }
152
+
153
+ const toWindow = (
154
+ id: WindowID,
155
+ window: { usedPct?: number; remainingPct?: number; usedUsd?: number; capUsd?: number; resetsAt?: number; exceeded?: boolean; source?: "provider" | "estimate" | "unknown" } | undefined,
156
+ now: number,
157
+ ): QuotaWindowView => {
158
+ const source = window?.source ?? "unknown"
159
+ const usedPct = window?.usedPct ?? 0
160
+ // A window is reported when the provider said anything about it. A cap with no
161
+ // source is an absent window, not an empty one.
162
+ const reported = source !== "unknown" && (window?.capUsd !== undefined || usedPct > 0)
163
+ return {
164
+ id,
165
+ label: windowLabels[id],
166
+ usedPct,
167
+ remainingPct: window?.remainingPct ?? Math.max(0, 100 - usedPct),
168
+ ...(window?.usedUsd !== undefined ? { usedUsd: window.usedUsd } : {}),
169
+ ...(window?.capUsd !== undefined ? { capUsd: window.capUsd } : {}),
170
+ ...(window?.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}),
171
+ ...(formatReset(window?.resetsAt, now) ? { resetLabel: formatReset(window?.resetsAt, now)! } : {}),
172
+ source,
173
+ sourceLabel: sourceLabelOf(source),
174
+ exceeded: Boolean(window?.exceeded),
175
+ reported,
176
+ lowerBound: source === "estimate",
177
+ }
178
+ }
179
+
180
+ export const toProviderRow = (snapshot: ProviderQuotaSnapshot, now = Date.now()): ProviderRow => {
181
+ const source: ProviderRow["source"] =
182
+ snapshot.monthly?.source === "provider"
183
+ ? "provider"
184
+ : snapshot.monthly?.source === "estimate"
185
+ ? "estimate"
186
+ : "unknown"
187
+ const windows = [
188
+ toWindow("fiveHour", snapshot.rolling, now),
189
+ toWindow("weekly", snapshot.weekly, now),
190
+ toWindow("monthly", snapshot.monthly, now),
191
+ ]
192
+ const exhaustedWindow = windows.find((window) => window.exceeded)
193
+ const pools: PoolRow[] = (snapshot.pools ?? []).map((pool) => ({
194
+ id: pool.id,
195
+ label: pool.label,
196
+ source: pool.source,
197
+ sourceLabel: sourceLabelOf(pool.source),
198
+ monthlyUsd: pool.monthlyUsd,
199
+ usedUsd: pool.usedUsd,
200
+ remainingUsd: pool.remainingUsd,
201
+ usedPct: pool.usedPct,
202
+ ...(pool.weeklyUsedPct !== undefined ? { weeklyPct: pool.weeklyUsedPct } : {}),
203
+ ...(pool.fiveHourUsedPct !== undefined ? { fiveHourPct: pool.fiveHourUsedPct } : {}),
204
+ ...(formatReset(pool.resetsAt, now) ? { resetLabel: formatReset(pool.resetsAt, now)! } : {}),
205
+ note: pool.note,
206
+ }))
207
+
208
+ return {
209
+ providerID: snapshot.providerID,
210
+ label: snapshot.label ?? snapshot.providerID,
211
+ plan: snapshot.plan ?? "",
212
+ source,
213
+ sourceLabel: sourceLabelOf(source),
214
+ ...(snapshot.quotaState ? { quotaState: snapshot.quotaState } : {}),
215
+ stale: Boolean(snapshot.stale),
216
+ fetchedAt: snapshot.fetchedAt ?? now,
217
+ exhausted: Boolean(exhaustedWindow),
218
+ ...(exhaustedWindow ? { exhaustedWindow: exhaustedWindow.label } : {}),
219
+ windows,
220
+ pools,
221
+ ...(snapshot.monthlyRemainingUsd !== undefined ? { monthlyRemainingUsd: snapshot.monthlyRemainingUsd } : {}),
222
+ ...(snapshot.extraCredits ? { extraCreditsUsd: snapshot.extraCredits.totalUsd } : {}),
223
+ ...(snapshot.error ? { error: snapshot.error } : {}),
224
+ notes: snapshot.notes ?? [],
225
+ ...(source === "estimate" ? { caveat: (snapshot.notes ?? [])[0] } : {}),
226
+ }
227
+ }
228
+
229
+ /** Money at the precision the router actually estimates in. */
230
+ export const formatUsd = (value: number): string => {
231
+ if (!Number.isFinite(value)) return "—"
232
+ if (value === 0) return "$0"
233
+ if (value < 0.01) return `$${value.toFixed(4)}`
234
+ if (value < 1) return `$${value.toFixed(3)}`
235
+ return `$${value.toFixed(2)}`
236
+ }
237
+
238
+ export const formatPercent = (value: number): string => (Number.isFinite(value) ? `${Math.round(value)}%` : "—")
239
+
240
+ /**
241
+ * A percentage as the panel should show it.
242
+ *
243
+ * `≥` is not decoration: it tells the reader the real figure is at least this,
244
+ * and may be far higher. Without it, an estimate reads as a measurement.
245
+ */
246
+ export const formatWindowUsed = (window: { usedPct: number; lowerBound: boolean; reported: boolean }): string => {
247
+ if (!window.reported) return "—"
248
+ const value = formatPercent(window.usedPct)
249
+ return window.lowerBound ? `≥${value}` : value
250
+ }
251
+
252
+ /**
253
+ * Explain the multipliers that changed a candidate's rank.
254
+ *
255
+ * These exist because a user who sees two prices and one winner will assume the
256
+ * cheaper one won. When it did not, the panel has to say which multiplier moved
257
+ * it, or the ranking looks arbitrary.
258
+ */
259
+ export const rankingAdjustments = (candidate: CandidateEvaluation): ReadonlyArray<string> => {
260
+ const notes: string[] = []
261
+ const credit = candidate.creditMultiplier
262
+ if (credit !== undefined && credit !== 1) notes.push(`${credit}x plan credits`)
263
+ const speed = candidate.speedFactor
264
+ if (speed !== undefined && speed !== 1) notes.push(`${speed}x measured speed`)
265
+ const marginal = candidate.marginalPriceFactor
266
+ if (marginal !== undefined && marginal !== 1) notes.push(`${marginal}x expiring allowance`)
267
+ if (candidate.switchPenaltyUsd > 0) notes.push(`${formatUsd(candidate.switchPenaltyUsd)} switch cost`)
268
+ return notes
269
+ }
270
+
271
+ /** `provider/model`, the same shape the engine uses for model identity. */
272
+ export const keyOf = (model: ModelKey): string => `${model.providerID}/${model.modelID}`
273
+
274
+ export const toCandidateRow = (candidate: CandidateEvaluation, chosen: string | undefined): CandidateRow => {
275
+ const key = keyOf(candidate.model)
276
+ return {
277
+ key,
278
+ name: candidate.name,
279
+ tier: candidate.tier,
280
+ verdict: key === chosen && candidate.eligible ? "chosen" : candidate.eligible ? "eligible" : "blocked",
281
+ reason: candidate.reason,
282
+ estimatedUsd: candidate.estimatedUsd,
283
+ effectiveCost: candidate.effectiveCost,
284
+ successProbability: candidate.successProbability,
285
+ adjustments: rankingAdjustments(candidate),
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Order for display: what won, then what could have, cheapest first, then what
291
+ * could not and why. A blocked model is never hidden — "why can't I use this"
292
+ * is the question a user asks most often.
293
+ */
294
+ const verdictRank: Record<Verdict, number> = { chosen: 0, eligible: 1, blocked: 2 }
295
+
296
+ export const orderCandidateRows = (rows: ReadonlyArray<CandidateRow>): ReadonlyArray<CandidateRow> =>
297
+ [...rows].sort((left, right) => {
298
+ const byVerdict = verdictRank[left.verdict] - verdictRank[right.verdict]
299
+ if (byVerdict !== 0) return byVerdict
300
+ if (left.verdict === "blocked") return left.key.localeCompare(right.key)
301
+ return left.effectiveCost - right.effectiveCost || left.key.localeCompare(right.key)
302
+ })
303
+
304
+ export const summariseDecision = (decision: RoutingDecision | undefined | null): DecisionView | undefined => {
305
+ if (!decision) return undefined
306
+ const chosenKey = decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : undefined
307
+ const rows = orderCandidateRows(decision.candidates.map((candidate) => toCandidateRow(candidate, chosenKey)))
308
+ return {
309
+ id: decision.id,
310
+ chosen: chosenKey,
311
+ switched: decision.switched,
312
+ switchReason: decision.switchReason,
313
+ taskKind: decision.task?.kind ?? "unknown",
314
+ taskTier: decision.task?.tier ?? 0,
315
+ contextTokens: decision.contextTokens,
316
+ estimatedUsd: decision.estimatedUsd,
317
+ reasons: decision.reasons ?? [],
318
+ warnings: decision.warnings ?? [],
319
+ selfheal: decision.selfheal?.rejected
320
+ ? `rejected ${decision.selfheal.rejected} · ${
321
+ decision.selfheal.action === "noted" ? "noted for the next prompt" : decision.selfheal.action
322
+ }`
323
+ : undefined,
324
+ eligibleCount: rows.filter((row) => row.verdict !== "blocked").length,
325
+ blockedCount: rows.filter((row) => row.verdict === "blocked").length,
326
+ rows,
327
+ }
328
+ }
329
+
330
+ /** The one-line state shown above the decision, and in the footer slot. */
331
+ export const statusLine = (input: {
332
+ runtime?: string
333
+ mode?: string
334
+ current?: { providerID: string; id: string } | undefined
335
+ }): string => {
336
+ const parts = [input.runtime ?? "unknown", input.mode ?? "unknown"]
337
+ parts.push(input.current ? `${input.current.providerID}/${input.current.id}` : "no model yet")
338
+ return parts.join(" · ")
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Providers and models (P3)
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export interface ModelRowView {
346
+ readonly key: string
347
+ readonly providerID: string
348
+ readonly name: string
349
+ readonly tier: number
350
+ readonly intelligence: number
351
+ readonly design: number
352
+ readonly contextLabel: string
353
+ readonly costLabel: string
354
+ readonly vision: boolean
355
+ readonly state: "approved" | "excluded" | "not approved"
356
+ }
357
+
358
+ export interface ProviderGroupView {
359
+ readonly providerID: string
360
+ readonly label: string
361
+ readonly state: "active" | "not configured"
362
+ readonly models: ReadonlyArray<ModelRowView>
363
+ readonly approvedCount: number
364
+ readonly totalCount: number
365
+ /** Why this provider is not being used, when it is not. */
366
+ readonly note?: string
367
+ }
368
+
369
+ const contextLabel = (tokens: number): string => {
370
+ if (!Number.isFinite(tokens) || tokens <= 0) return "unknown"
371
+ // "1M" reads better than "1.0M"; a fraction still needs its decimal.
372
+ return tokens >= 1_000_000
373
+ ? `${Number((tokens / 1_000_000).toFixed(1))}M`
374
+ : `${Math.round(tokens / 1000)}k`
375
+ }
376
+
377
+ /**
378
+ * Providers and their models, as a user needs them to answer "why is that model
379
+ * not being used?".
380
+ *
381
+ * The distinction that matters is between a provider that is off and a model
382
+ * that is unapproved: one is a decision about an account, the other about a
383
+ * model. Collapsing them into "unavailable" is what makes people think a model
384
+ * is missing when it is simply not enabled.
385
+ */
386
+ export const providerGroups = (input: {
387
+ models: ReadonlyArray<{
388
+ key: string
389
+ providerID: string
390
+ name: string
391
+ tier: number
392
+ intelligence: number
393
+ design: number
394
+ vision: boolean
395
+ context: number
396
+ costInput: number
397
+ costOutput: number
398
+ approved: boolean
399
+ excluded: boolean
400
+ }>
401
+ quota: ReadonlyArray<{ providerID: string; label?: string }>
402
+ /** Provider ids the router will route to right now. */
403
+ activeProviders: ReadonlyArray<string>
404
+ }): ReadonlyArray<ProviderGroupView> => {
405
+ const byProvider = new Map<string, ModelRowView[]>()
406
+ for (const model of input.models) {
407
+ const state: ModelRowView["state"] = model.excluded
408
+ ? "excluded"
409
+ : model.approved
410
+ ? "approved"
411
+ : "not approved"
412
+ const row: ModelRowView = {
413
+ key: model.key,
414
+ providerID: model.providerID,
415
+ name: model.name,
416
+ tier: model.tier,
417
+ intelligence: model.intelligence,
418
+ design: model.design,
419
+ contextLabel: contextLabel(model.context),
420
+ costLabel: `${formatUsd(model.costInput)} / ${formatUsd(model.costOutput)} per 1M`,
421
+ vision: model.vision,
422
+ state,
423
+ }
424
+ const list = byProvider.get(model.providerID) ?? []
425
+ list.push(row)
426
+ byProvider.set(model.providerID, list)
427
+ }
428
+
429
+ const quotaLabel = new Map(input.quota.map((entry) => [entry.providerID, entry.label ?? entry.providerID]))
430
+
431
+ return [...byProvider.entries()]
432
+ .map(([providerID, models]) => {
433
+ const active = input.activeProviders.includes(providerID)
434
+ const approvedCount = models.filter((model) => model.state === "approved").length
435
+ return {
436
+ providerID,
437
+ label: quotaLabel.get(providerID) ?? providerID,
438
+ state: active ? ("active" as const) : ("not configured" as const),
439
+ // Approved first, then by tier and cost: the order a routing decision
440
+ // would consider them.
441
+ models: [...models].sort((left, right) => {
442
+ const rank = (row: ModelRowView) => (row.state === "approved" ? 0 : row.state === "excluded" ? 2 : 1)
443
+ return rank(left) - rank(right) || left.tier - right.tier || left.key.localeCompare(right.key)
444
+ }),
445
+ approvedCount,
446
+ totalCount: models.length,
447
+ ...(active
448
+ ? {}
449
+ : { note: "Not routing here: no credential found for this provider on this machine." }),
450
+ }
451
+ })
452
+ .sort((left, right) => {
453
+ if (left.state !== right.state) return left.state === "active" ? -1 : 1
454
+ return left.label.localeCompare(right.label)
455
+ })
456
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { RouterBlockedError, RouterController } from "./server/controller.js"
3
+ import { stripTitlePrefix } from "./core/ui.js"
4
+ import { sharedCore } from "./server/shared-core.js"
5
+
6
+ const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
7
+
8
+ export default Plugin.define({
9
+ id: "opencode-smart-router",
10
+ async setup(ctx) {
11
+ const controller = new RouterController({ ctx, pluginOptions: ctx.options })
12
+ try {
13
+ return await controller.initialize()
14
+ } catch (error) {
15
+ // A broken location must not take down the host or the other bridges.
16
+ sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
17
+ await controller.dispose().catch(() => undefined)
18
+ // A broken location must not silently execute prompts with no routing.
19
+ // Keep a minimal fail-closed hook until the next reload.
20
+ try {
21
+ await ctx.session.hook("prompt", async (event: any) => {
22
+ const sessionID = event?.sessionID
23
+ if (!sessionID) throw new RouterBlockedError("Smart Router setup failed")
24
+ let info: any
25
+ try {
26
+ info = await ctx.session.get({ sessionID })
27
+ } catch {
28
+ info = undefined
29
+ }
30
+ const localID = ctx.location?.project?.id
31
+ if (info?.projectID && localID && info.projectID !== localID) return
32
+ if (info) {
33
+ try {
34
+ const base = stripTitlePrefix(typeof info.title === "string" ? info.title : "") || "Session"
35
+ await ctx.session.update({ sessionID, title: `BLOCKED · router setup failed — ${base}` })
36
+ } catch {
37
+ // The rejection below still stops admission.
38
+ }
39
+ }
40
+ throw new RouterBlockedError("Smart Router setup failed; routing is unavailable")
41
+ })
42
+ } catch (hookError) {
43
+ sharedCore.recordError(`setup fallback hook ${ctx.location?.directory ?? ""}: ${describe(hookError)}`)
44
+ }
45
+ return () => undefined
46
+ }
47
+ },
48
+ })