model-compare-opencode-plugin 0.1.1 → 0.3.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.
package/src/index.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  // warn-and-default, never throws), seeds state from storage (§8's startup
5
5
  // chain: pin → fallback → disabled) without ever blocking on fresh data,
6
6
  // registers the alias transform — after seeding, so the registration-time
7
- // application sees the seeded picks — then the guard hook and the two
8
- // commands, kicks off the first refresh in the background, schedules the
9
- // 30-minute refresh tick, and returns a cleanup that clears the timer.
7
+ // application sees the seeded picks — then the guard hook, the steering
8
+ // injection hook and the two commands, kicks off the first refresh in the
9
+ // background, schedules the 30-minute refresh tick, and returns a cleanup
10
+ // that clears the timer.
10
11
  //
11
12
  // `wire` is the testable core — the same setup with injected seams (acquire,
12
13
  // timer, clock). The default export hands OpenCode the real context; it is
@@ -17,11 +18,12 @@ import { Plugin } from "@opencode/plugin"
17
18
  import { parseConfig } from "./config"
18
19
  import { pinKey, type Pin } from "./core/pins"
19
20
  import { effectiveWeights, rankModels } from "./core/rank"
20
- import type { Catalog, ModelEntry, Priority, Sourced } from "./core/types"
21
- import { formatReport, humanizeAge, reportEntries } from "./opencode/commands"
21
+ import type { Catalog, EndpointCapture, ModelEntry, Priority, Sourced } from "./core/types"
22
+ import { captureEndpoint, injectSteering, steeringFor } from "./core/steering"
23
+ import { formatReport, humanizeAge, reportEntries, steeringNote } from "./opencode/commands"
22
24
  import { ModelCompare } from "./rpc"
23
25
  import { planAlias, type AliasTarget } from "./opencode/aliases"
24
- import { guardDecision, type ModelRef } from "./opencode/guard"
26
+ import { guardDecision, parseAliasRef, type ModelRef } from "./opencode/guard"
25
27
  import { readNewestCachedDoc, readRecord, type Storage } from "./opencode/storage"
26
28
  import { acquire, cacheIdentities } from "./sources"
27
29
  import {
@@ -51,6 +53,89 @@ function bareModelId(configValue: string): string {
51
53
  : configValue
52
54
  }
53
55
 
56
+ // The request body's `model` field when it is a string, else undefined — the
57
+ // gate steeringFor checks against the pick's id (the live alias-bound body
58
+ // carries the pick id verbatim, spike fact 1). Any non-object body reads as
59
+ // undefined; injectSteering rejects those on its own.
60
+ function bodyModelOf(body: unknown): string | undefined {
61
+ if (typeof body !== "object" || body === null) return undefined
62
+ const model = (body as Record<string, unknown>).model
63
+ return typeof model === "string" ? model : undefined
64
+ }
65
+
66
+ // ---- response capture helpers ----------------------------------------------
67
+
68
+ // The bounded peek (global constraint: never buffer or consume a response
69
+ // stream to inspect it — only a bounded first-chunk peek through a
70
+ // pass-through transform, the rest forwarding byte-for-byte). At most this
71
+ // many BYTES of a response stream are decoded for inspection; everything past
72
+ // the bound is untouched. Sized so a first SSE data chunk (a comment line
73
+ // plus one data event in the spike's observations) always fits.
74
+ const PEEK_MAX_BYTES = 4096
75
+
76
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value)
78
+ }
79
+
80
+ // A response field that must be a string, else null (a numeric/absent
81
+ // `provider` or `model` is nothing to capture).
82
+ function stringField(obj: Record<string, unknown>, key: string): string | null {
83
+ const value = obj[key]
84
+ return typeof value === "string" ? value : null
85
+ }
86
+
87
+ // The served `provider`/`model` pair carried by a response's first bytes, or
88
+ // null while the prefix is not yet conclusive (the peek keeps going). Two
89
+ // shapes the spike observed:
90
+ // - SSE (streaming): data lines — the first one carrying a NON-null
91
+ // `provider` decides, and `data: [DONE]` concludes without a capture. A
92
+ // parseable line that carries no provider must NOT conclude anything:
93
+ // the provider may arrive in a later data line within the byte bound.
94
+ // Leading comment or blank lines (`: OPENROUTER PROCESSING`, spike fact
95
+ // 5/warmup-B) carry neither, so the peek simply continues to the next
96
+ // chunk rather than concluding anything from them.
97
+ // - plain JSON (non-streaming): the whole prefix parses to an object. A
98
+ // prefix whose JSON is still truncated (e.g. the `provider` key beyond
99
+ // the peek window) stays inconclusive — the body passes through
100
+ // uncaptured rather than being buffered.
101
+ function extractServedFields(
102
+ prefix: string,
103
+ ): { provider: string | null; model: string | null } | null {
104
+ let sawData = false
105
+ for (const line of prefix.split("\n")) {
106
+ const trimmed = line.trim()
107
+ if (!trimmed.startsWith("data:")) continue
108
+ sawData = true
109
+ const payload = trimmed.slice("data:".length).trim()
110
+ if (payload === "[DONE]") return { provider: null, model: null }
111
+ try {
112
+ const parsed = JSON.parse(payload) as unknown
113
+ if (isPlainObject(parsed)) {
114
+ // Only a NON-null provider concludes the peek: a parseable line
115
+ // without one must not end the search — the provider may arrive in a
116
+ // later data line within the byte bound. `data: [DONE]` (above) is
117
+ // the one line that concludes without a capture.
118
+ const provider = stringField(parsed, "provider")
119
+ if (provider !== null) {
120
+ return { provider, model: stringField(parsed, "model") }
121
+ }
122
+ }
123
+ } catch {
124
+ // A line split across a chunk boundary — keep peeking.
125
+ }
126
+ }
127
+ if (sawData) return null
128
+ try {
129
+ const parsed = JSON.parse(prefix) as unknown
130
+ if (isPlainObject(parsed)) {
131
+ return { provider: stringField(parsed, "provider"), model: stringField(parsed, "model") }
132
+ }
133
+ } catch {
134
+ // Not (yet) JSON — keep peeking.
135
+ }
136
+ return null
137
+ }
138
+
54
139
  // ---- the slice of the OpenCode v2 plugin context the plugin touches --------
55
140
 
56
141
  type Registration = { dispose: () => Promise<void> }
@@ -89,14 +174,19 @@ export type CommandDefinition = {
89
174
  execute: (input: { sessionID: string; prompt: { text: string } }) => Promise<void>
90
175
  }
91
176
 
92
- // The `http.request` event (§9): the guard keys on the requesting agent, not
93
- // on event.model, which already shows the effective (fallback) reference.
177
+ // The `http.request` / `http.response` event (§9): the guard keys on the
178
+ // requesting agent, not on event.model, which already shows the effective
179
+ // (fallback) reference. `response` is present only on http.response events
180
+ // (the real SDK carries both hook events' shapes; only the response's hook
181
+ // receives one) — and it must stay MUTABLE: the capture hook re-creates the
182
+ // Response around a pass-through stream and writes it back.
94
183
  export type HttpEvent = {
95
184
  sessionID: string
96
185
  agent: string
97
186
  model: ModelRef
98
187
  kind: "primary" | "compaction" | "title" | "generate"
99
188
  request: unknown
189
+ response?: Response
100
190
  }
101
191
 
102
192
  export type ModelListEntry = { id: string; providerID: string; enabled: boolean }
@@ -120,7 +210,7 @@ export type PluginCtx = {
120
210
  }
121
211
  session: {
122
212
  hook(
123
- name: "http.request",
213
+ name: "http.request" | "http.response",
124
214
  callback: (event: HttpEvent) => Promise<void> | void,
125
215
  ): Promise<Registration>
126
216
  switchModel(input: { sessionID: string; model: ModelRef }): Promise<void>
@@ -146,6 +236,11 @@ export type WireDeps = {
146
236
  now?: () => number
147
237
  // Per-tick hard deadline (F-01); tests shrink it.
148
238
  deadlineMs?: number
239
+ // State-observation seam (tests): hands the LIVE WireState object to the
240
+ // caller once setup finishes wiring it — every later mutation (steering
241
+ // records, endpoint captures) lands on the same object, so the holder sees
242
+ // the updates in real time. Nothing in production passes it.
243
+ onState?: (state: WireState) => void
149
244
  }
150
245
 
151
246
  // ---- captured state and small helpers --------------------------------------
@@ -166,6 +261,34 @@ export type AliasPlan = {
166
261
  // `registryDisabled[p]` records that the transform wanted alias p enabled but
167
262
  // its concrete model was missing from the editor; the applied view then says
168
263
  // disabled, whatever the plan wanted.
264
+ //
265
+ // `appliedSteering[sessionID]` records the steering order the request hook
266
+ // ACTUALLY injected into that session's most recent request: the order array
267
+ // when it injected (catalog-projected blocks always carry a non-empty order),
268
+ // null when it did not (non-alias ref, no steering data, a pre-existing body
269
+ // `provider`, or any failure — fail open). The response hook consumes it as
270
+ // captureEndpoint's `order`, so the `!` downgrade flag fires only when a
271
+ // non-empty order was really applied. Last request per session wins.
272
+ //
273
+ // `lastEndpoints[sessionID]` records the served-endpoint capture for that
274
+ // session's most recent primary response: the served `provider` string
275
+ // verbatim, the response's concrete `model` id, the downgrade verdict, and
276
+ // the wall-clock moment of the capture. Last-writer-wins WITHIN a session
277
+ // (hook invocations interleave concurrently — every write is per-event, from
278
+ // the event's own sessionID); stale entries for ended sessions are
279
+ // acceptable (no session-removal hook exists to prune them).
280
+ //
281
+ // `cannotSteerToasted[sessionID]` records that the Q2 fallback's cannot-steer
282
+ // event was already successfully emitted for that session: when a steerable
283
+ // alias request (alias ref + a current pick) finds NO steering data — no
284
+ // catalog source, `sourced === null` — the injection hook emits the event
285
+ // exactly ONCE PER SESSION (never once per request), leaving the request body
286
+ // untouched. A body that already carries a `provider` key binds the
287
+ // exclusion: the emit is skipped (user-configured routing makes the "default
288
+ // routing applies" copy false) without consuming the flag, so a later
289
+ // provider-free request still toasts. The flag is set only AFTER a successful
290
+ // emit (a failed one leaves it unset so a later request retries) and is never
291
+ // re-armed: a session that has seen the toast keeps it for its lifetime.
169
292
  export type WireState = {
170
293
  sourced: Sourced | null
171
294
  pins: Partial<Record<Priority, Pin>>
@@ -173,6 +296,9 @@ export type WireState = {
173
296
  enabled: Partial<Record<Priority, boolean>>
174
297
  plan: AliasPlan
175
298
  registryDisabled: Partial<Record<Priority, boolean>>
299
+ appliedSteering: Record<string, string[] | null>
300
+ lastEndpoints: Record<string, EndpointCapture>
301
+ cannotSteerToasted: Record<string, boolean>
176
302
  }
177
303
 
178
304
  // readRecord only guarantees a v=1 object; the pin fields are checked so a
@@ -251,7 +377,18 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
251
377
  enabled: {},
252
378
  plan: { picks: {}, enabled: {} },
253
379
  registryDisabled: {},
380
+ // The three per-session records are keyed by ids that reach them from
381
+ // OUTSIDE (hook events, RPC input): null-prototype objects so a key like
382
+ // "constructor"/"toString"/"__proto__" can never resolve through — or
383
+ // mutate — the prototype chain (a plain-object lookup would return
384
+ // Object.prototype junk and poison the report footer).
385
+ appliedSteering: Object.create(null) as Record<string, string[] | null>,
386
+ lastEndpoints: Object.create(null) as Record<string, EndpointCapture>,
387
+ cannotSteerToasted: Object.create(null) as Record<string, boolean>,
254
388
  }
389
+ // Test seam: hand the live state object out BEFORE any registration can
390
+ // mutate it — the reference never changes, only its contents do.
391
+ deps.onState?.(state)
255
392
 
256
393
  // Seed state from storage (§8 startup) BEFORE registering the transform:
257
394
  // OpenCode applies transforms at registration time (design-spike-verified),
@@ -318,6 +455,13 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
318
455
  // transform from the captured state, so any replay applies the current pick.
319
456
  await ctx.model.transform((editor) => applyAliases(state, editor))
320
457
 
458
+ // The RPC registration (below, before the commands) is the event bridge
459
+ // the steering hook emits the cannot-steer fallback through; the binding is
460
+ // hoisted here so the hook's closure sees it declared. No request can
461
+ // arrive before setup completes, and the emit site guards on the
462
+ // registration being ready anyway (fail open).
463
+ let rpcRegistration: RpcRegistration | undefined
464
+
321
465
  // The guard (§9): a primary request whose agent is configured with a
322
466
  // DISABLED alias aborts with the exact error; everything else passes. The
323
467
  // agent lookup is wrapped — a missing agent must not break the request path.
@@ -333,20 +477,311 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
333
477
  if (decision.error !== undefined) throw new Error(decision.error)
334
478
  })
335
479
 
480
+ // The steering injection (design §Steering injection; spike fact 2):
481
+ // alias-scoped requests whose pick's catalog entry carries a steering block
482
+ // get the block's provider sub-object injected into the JSON body and the
483
+ // Request re-created. No kind filter — alias-bound refs arrive for primary
484
+ // AND compaction (spike scoping), and a directly-picked ref is never an
485
+ // alias ref, so it falls through untouched (the anti-goal). Every path
486
+ // that does not inject leaves the original request untouched (fail open,
487
+ // spec anti-goal) and nulls the session's appliedSteering record.
488
+ await ctx.session.hook("http.request", async (event) => {
489
+ try {
490
+ // Cheap gate before any body read (the guard's parseAliasRef rule;
491
+ // steeringFor re-checks variant-blind): concrete refs — title kinds,
492
+ // direct picks, fallback-bound sessions — skip the body entirely.
493
+ const p = parseAliasRef(event.model)
494
+ if (p === null) {
495
+ state.appliedSteering[event.sessionID] = null
496
+ return
497
+ }
498
+ // The Q2 fallback (Task 6): a steerable alias request (alias ref + a
499
+ // current pick) with NO steering data — no catalog source,
500
+ // `sourced === null` — proceeds untouched, but the TUI is told so it
501
+ // can toast the fallback: exactly ONCE PER SESSION (the
502
+ // cannotSteerToasted flag), never once per request. A disabled alias
503
+ // (no pick) stays silent — that is the guard's domain, not a toast.
504
+ // The body is never reassigned — the request proceeds untouched.
505
+ if (state.sourced === null) {
506
+ state.appliedSteering[event.sessionID] = null
507
+ const pick = state.picks[p]
508
+ // The whole toast block needs the RPC registration (the emit's event
509
+ // bridge): while it is still undefined the block is skipped ENTIRELY —
510
+ // no body read, no flag write, no emit — so nothing is consumed while
511
+ // there is nowhere to emit to (a later request retries).
512
+ const registration = rpcRegistration
513
+ if (
514
+ registration !== undefined &&
515
+ pick !== undefined &&
516
+ pick.id !== null &&
517
+ state.cannotSteerToasted[event.sessionID] !== true
518
+ ) {
519
+ // The one-off body read (fix round 1): gated behind the once-flag
520
+ // so steady-state requests are never parsed. A body that already
521
+ // carries a `provider` key binds the exclusion — user-configured
522
+ // routing makes the "default routing applies" toast false copy —
523
+ // so the emit is skipped WITHOUT consuming the flag: a later
524
+ // provider-free request for the session still toasts. The read is
525
+ // a cheap clone-parse; the original Request is never reassigned.
526
+ let bodyHasProvider = false
527
+ try {
528
+ const body: unknown = JSON.parse(await (event.request as Request).clone().text())
529
+ bodyHasProvider =
530
+ typeof body === "object" && body !== null && Object.hasOwn(body, "provider")
531
+ } catch {
532
+ // Unreadable or missing body: no provider key is knowable —
533
+ // fall back to the plain emit (fail open).
534
+ }
535
+ if (!bodyHasProvider) {
536
+ // Fire-and-forget: the emit must never block the request path.
537
+ // The once-flag is set only AFTER a successful emit; a failed
538
+ // emit leaves it unset so a later request for the session
539
+ // retries — never a permanently lost toast.
540
+ void registration.events
541
+ .emit("cannotSteer", { sessionID: event.sessionID })
542
+ .then(() => {
543
+ state.cannotSteerToasted[event.sessionID] = true
544
+ })
545
+ .catch(() => {
546
+ // Leave the once-flag unset: a later request retries.
547
+ })
548
+ }
549
+ }
550
+ return
551
+ }
552
+ const request = event.request as Request
553
+ let body: unknown
554
+ try {
555
+ body = JSON.parse(await request.clone().text())
556
+ } catch {
557
+ // No JSON body (or unreadable): untouched, fail open.
558
+ state.appliedSteering[event.sessionID] = null
559
+ return
560
+ }
561
+ const steering = steeringFor(
562
+ event.model,
563
+ bodyModelOf(body),
564
+ state.picks,
565
+ new Map(state.sourced.doc.models.map((entry): [string, ModelEntry] => [entry.id, entry])),
566
+ )
567
+ if (steering === null) {
568
+ // No steering data for the pick: untouched, fail open.
569
+ state.appliedSteering[event.sessionID] = null
570
+ return
571
+ }
572
+ const modified = injectSteering(body, steering)
573
+ if (modified === body) {
574
+ // The body already carries a `provider` key (or is not an object):
575
+ // never clobber routing the user or a provider configured.
576
+ state.appliedSteering[event.sessionID] = null
577
+ return
578
+ }
579
+ // Spike fact 2's verified recipe: re-create the Request around the
580
+ // modified body, dropping the stale length/encoding headers (the
581
+ // runtime re-derives content-length for the new body). The original's
582
+ // `signal` is forwarded so abort/interrupt handling stays wired to the
583
+ // request the chain handed us.
584
+ const headers = new Headers(request.headers)
585
+ headers.delete("content-length")
586
+ headers.delete("content-encoding")
587
+ event.request = new Request(request.url, {
588
+ method: request.method,
589
+ headers,
590
+ body: JSON.stringify(modified),
591
+ signal: request.signal,
592
+ })
593
+ // Record the order actually applied — the response hook reads it as
594
+ // captureEndpoint's order (the `!` flag fires only on a non-empty one).
595
+ state.appliedSteering[event.sessionID] = steering.order
596
+ } catch {
597
+ // Fail open: any error leaves the original request proceeding
598
+ // untouched, and records that nothing was injected.
599
+ state.appliedSteering[event.sessionID] = null
600
+ }
601
+ })
602
+
603
+ // The response capture (design §Steering visibility): a primary response's
604
+ // first few bytes are peeked THROUGH a pass-through transform (never
605
+ // buffered or consumed — the peeked chunks are forwarded untouched, so the
606
+ // rest of the body flows byte-for-byte) to read the served `provider` and
607
+ // `model`, and the session's lastEndpoints record is written per-event from
608
+ // the event's OWN sessionID (hook invocations interleave concurrently —
609
+ // never write from a shared "current session"). Only primary responses of
610
+ // alias-bound sessions update the capture — a non-alias model ref returns
611
+ // before any wrap (zero modification, the anti-goal); any body the peek
612
+ // cannot parse passes through unmodified, uncaptured; every error is
613
+ // swallowed — the capture is best-effort and must never break the response
614
+ // itself.
615
+ await ctx.session.hook("http.response", async (event) => {
616
+ try {
617
+ if (event.kind !== "primary") return
618
+ // Alias-scoped (the spec's binding constraint): capture fires only when
619
+ // the session's model is one of the three alias refs. A non-alias
620
+ // session — a direct pick, another agent, any provider — gets ZERO
621
+ // modification: no wrap, no peek, no capture. The request-side half of
622
+ // the binding (the request's model equals that alias's current pick) is
623
+ // inherited via appliedSteering, which only the injection hook's
624
+ // injecting paths set to a non-empty order; a disabled alias's session
625
+ // carries its concrete fallback ref here and captures nothing.
626
+ if (parseAliasRef(event.model) === null) return
627
+ const response = event.response
628
+ if (response === undefined) return
629
+ const body = response.body
630
+ if (body === null) return
631
+
632
+ // Read ONCE, per-event, from the event's own sessionID. The record
633
+ // holds the session's most recent REQUEST's order — best-effort ONLY:
634
+ // with concurrent same-session requests (compaction/title alongside
635
+ // primary) the verdict can be wrong in EITHER direction. A newer
636
+ // non-injecting request nulls the record and this response's downgrade
637
+ // then reads `false` even if its own request did inject (false
638
+ // negative); a newer injecting request leaves ITS order here, and a
639
+ // response produced under the older order may be flagged `!` against
640
+ // the wrong order (false positive). Display-only impact — the alt+e
641
+ // toast and the report footer; nothing about the sent request depends
642
+ // on this read.
643
+ const order = state.appliedSteering[event.sessionID] ?? null
644
+
645
+ const decoder = new TextDecoder()
646
+ let captured = ""
647
+ let peekedBytes = 0
648
+ let done = false
649
+ const record = (provider: string | null, model: string | null) => {
650
+ done = true
651
+ // Nothing to show (no provider at all, or a name with no provider
652
+ // part): treat as no capture — captureEndpoint's endpoint === null
653
+ // means exactly that.
654
+ if (provider === null) return
655
+ const verdict = captureEndpoint(provider, order)
656
+ if (verdict.endpoint === null) return
657
+ state.lastEndpoints[event.sessionID] = {
658
+ sessionID: event.sessionID,
659
+ endpoint: verdict.endpoint,
660
+ // The spike established OpenRouter responses always carry `model`;
661
+ // an outlier without one still records the endpoint (the capture's
662
+ // point), with an empty id standing in for the missing field.
663
+ model: model ?? "",
664
+ downgrade: verdict.downgrade,
665
+ at: now(),
666
+ }
667
+ }
668
+ const inspect = () => {
669
+ const peek = extractServedFields(captured)
670
+ if (peek !== null) record(peek.provider, peek.model)
671
+ }
672
+ const transform = new TransformStream<Uint8Array, Uint8Array>({
673
+ transform: (chunk, controller) => {
674
+ try {
675
+ if (!done) {
676
+ // The peek is byte-bounded: only the first PEEK_MAX_BYTES
677
+ // bytes of the stream are ever decoded for inspection — a
678
+ // chunk that crosses the bound contributes only its head, and
679
+ // the rest is never decoded (it still passes through
680
+ // untouched, below).
681
+ const take = Math.min(chunk.byteLength, PEEK_MAX_BYTES - peekedBytes)
682
+ if (take > 0) captured += decoder.decode(chunk.subarray(0, take), { stream: true })
683
+ peekedBytes += take
684
+ // Extraction runs on every peeked chunk: a first chunk that is
685
+ // only a comment line must not conclude anything — the data
686
+ // line may arrive in a later chunk (spike fact 5).
687
+ inspect()
688
+ if (peekedBytes >= PEEK_MAX_BYTES) done = true
689
+ }
690
+ } catch {
691
+ // Inspection never breaks the stream: give up on the peek, keep
692
+ // forwarding.
693
+ done = true
694
+ }
695
+ controller.enqueue(chunk)
696
+ },
697
+ flush: () => {
698
+ try {
699
+ if (!done) {
700
+ // The stream ended before the bound: flush any held-back
701
+ // multi-byte tail and make one final extraction attempt.
702
+ captured += decoder.decode()
703
+ inspect()
704
+ }
705
+ } catch {
706
+ done = true
707
+ }
708
+ },
709
+ })
710
+ // Re-create the Response around the pass-through stream (mirror of the
711
+ // request hook's re-creation): the original body is owned by the chain
712
+ // and never consumed by the plugin. Deliberate deviation from the spike
713
+ // recipe: the spike DELETED content-length/content-encoding on BOTH the
714
+ // request and the response re-creation, while here the body passes
715
+ // through unchanged, so the response's exact headers are kept verbatim
716
+ // for byte-identical passthrough. Known cost of the re-creation: the
717
+ // new Response cannot carry `url`, `type` or `redirected` (they reset
718
+ // to ""/"default"/false) — no consumer of those fields is known in this
719
+ // plugin; flagged for any downstream hook that reads them.
720
+ event.response = new Response(body.pipeThrough(transform), {
721
+ status: response.status,
722
+ statusText: response.statusText,
723
+ headers: response.headers,
724
+ })
725
+ } catch {
726
+ // Fail open: any error leaves the response untouched and uncaptured.
727
+ }
728
+ })
729
+
336
730
  // The RPC (V2 plugins RPC): the report method serves the SAME text the
337
731
  // /model-compare command synthesizes (one builder — the two can never
338
732
  // drift), and the registration carries the report EVENT the command emits
339
733
  // so the TUI half can open the report as an instant selectable dialog.
340
734
  // Registered BEFORE the commands: the command's execute emits through it.
341
- const rpcRegistration = await ctx.rpc.register(ModelCompare, {
342
- report: async () => {
735
+ // One registration per rpcID: the host resolves duplicates last-one-wins,
736
+ // so a second register() would silently rebind the events bridge.
737
+ //
738
+ // The report footer (design §Steering visibility): the payload carries the
739
+ // session's last-endpoint capture and the steering note. One derivation
740
+ // (reportFooter, below) serves the RPC method, the event emit and
741
+ // formatReport, so the dialog's footer and the transcript text agree.
742
+ const reportFooter = (sessionID: string | null) => {
743
+ // The capture is keyed by sessionID (Task 5's per-session map): a null
744
+ // session (the opening screen) has nothing to look up. The record's
745
+ // display fields are projected — the toast needs `downgrade` too, so all
746
+ // three travel.
747
+ const record = sessionID === null ? undefined : state.lastEndpoints[sessionID]
748
+ const capture =
749
+ record === undefined
750
+ ? null
751
+ : { endpoint: record.endpoint, model: record.model, downgrade: record.downgrade }
752
+ // The steering note needs the catalog: without a source there is no data
753
+ // to derive it from, and the note does not render.
754
+ const steeringNoteFor =
755
+ state.sourced === null ? null : steeringNote(state.sourced.doc, state.picks, state.enabled)
756
+ return { capture, steeringNote: steeringNoteFor }
757
+ }
758
+
759
+ rpcRegistration = await ctx.rpc.register(ModelCompare, {
760
+ report: async (rawInput: unknown) => {
761
+ const input = rawInput as { sessionID?: string | null } | undefined
762
+ const sessionID = input?.sessionID ?? null
343
763
  const ranks = reportRanks()
764
+ // ONE derivation per call: the same footer fields feed formatReport's
765
+ // context and the returned payload, so the transcript text and the
766
+ // dialog footer can never disagree.
767
+ const footer = reportFooter(sessionID)
344
768
  if (state.sourced === null || ranks === null) {
345
- return { text: "model-compare: no ranking data available yet", age: null, entries: [] }
769
+ return {
770
+ text: "model-compare: no ranking data available yet",
771
+ age: null,
772
+ ...footer,
773
+ entries: [],
774
+ }
346
775
  }
347
776
  return {
348
- text: formatReport(state.sourced, ranks, { picks: state.picks, overrides: config.weights }),
777
+ text: formatReport(state.sourced, ranks, {
778
+ picks: state.picks,
779
+ overrides: config.weights,
780
+ enabled: state.enabled,
781
+ capture: footer.capture,
782
+ }),
349
783
  age: humanizeAge(Date.now() - state.sourced.acquiredAt),
784
+ ...footer,
350
785
  entries: reportEntries(state.sourced, ranks, { picks: state.picks, overrides: config.weights }),
351
786
  }
352
787
  },
@@ -415,7 +850,12 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
415
850
  // transcript notice is the durable copy, not the fast path).
416
851
  if (sourced === null || ranks === null) {
417
852
  try {
418
- await rpcRegistration.events.emit("report", { sessionID, age: null, entries: [] })
853
+ await rpcRegistration.events.emit("report", {
854
+ sessionID,
855
+ age: null,
856
+ ...reportFooter(sessionID),
857
+ entries: [],
858
+ })
419
859
  } catch {
420
860
  // Emission failures must not fail the command.
421
861
  }
@@ -426,11 +866,19 @@ export async function wire(ctx: PluginCtx, deps: WireDeps = {}): Promise<() => v
426
866
  })
427
867
  return
428
868
  }
429
- const context = { picks: state.picks, overrides: config.weights }
869
+ const footer = reportFooter(sessionID)
870
+ const context = {
871
+ picks: state.picks,
872
+ overrides: config.weights,
873
+ enabled: state.enabled,
874
+ capture: footer.capture,
875
+ }
430
876
  try {
431
877
  await rpcRegistration.events.emit("report", {
432
878
  sessionID,
433
879
  age: humanizeAge(Date.now() - sourced.acquiredAt),
880
+ capture: footer.capture,
881
+ steeringNote: footer.steeringNote,
434
882
  entries: reportEntries(sourced, ranks, context),
435
883
  })
436
884
  } catch {