@xtruder/opencode-claude-max-plugin 0.3.0 → 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.
- package/README.md +14 -17
- package/build/index.d.ts +13 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +207 -38
- package/build/model.d.ts +25 -0
- package/build/model.d.ts.map +1 -1
- package/build/server.js +206 -37
- package/build/stream.d.ts +31 -1
- package/build/stream.d.ts.map +1 -1
- package/build/tui.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/tui.tsx +196 -17
package/src/tui.tsx
CHANGED
|
@@ -132,6 +132,144 @@ function createUsageStore(pollInterval: number) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// ─── Fallback store ──────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Watches for server-side model fallback (Fable 5 safety refusal → Opus 4.8).
|
|
139
|
+
*
|
|
140
|
+
* The provider attaches a display-only `anthropic.servedBy` object to part
|
|
141
|
+
* metadata when a response was served by a fallback model (see stream.ts).
|
|
142
|
+
* OpenCode persists part metadata and forwards it here via
|
|
143
|
+
* `message.part.updated` — no side-channel store is needed.
|
|
144
|
+
*
|
|
145
|
+
* Sticky routing is keyed on the conversation prefix server-side, so the
|
|
146
|
+
* fallback state is PER SESSION — a refusal in one session says nothing
|
|
147
|
+
* about another.
|
|
148
|
+
*
|
|
149
|
+
* The sidebar state is DERIVED from the session's persisted messages (via
|
|
150
|
+
* api.state, OpenCode's synced replica) rather than accumulated from live
|
|
151
|
+
* events — so it is correct after a TUI restart or when opening an old
|
|
152
|
+
* session. The event subscription only drives the live toast, deduped with
|
|
153
|
+
* an in-memory Set (part events never replay across restarts).
|
|
154
|
+
*/
|
|
155
|
+
type FallbackInfo = {
|
|
156
|
+
from: string
|
|
157
|
+
to: string
|
|
158
|
+
kind: "fallback" | "sticky"
|
|
159
|
+
at: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const shortModel = (model: string) => model.replace(/^claude-/, "")
|
|
163
|
+
|
|
164
|
+
// Absolute time — stable without periodic re-rendering ("ago" phrasing
|
|
165
|
+
// would freeze between reactive updates).
|
|
166
|
+
const atTime = (at: number) =>
|
|
167
|
+
new Date(at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
|
168
|
+
|
|
169
|
+
function createFallbackStore(api: Api) {
|
|
170
|
+
// Dedupe the repeated part-update events that fire as a part streams.
|
|
171
|
+
const seenParts = new Set<string>()
|
|
172
|
+
|
|
173
|
+
/** Extract the servedBy marker from one assistant message's parts. */
|
|
174
|
+
const markerOf = (msg: any): FallbackInfo | null => {
|
|
175
|
+
let latest: FallbackInfo | null = null
|
|
176
|
+
for (const part of api.state.part(msg.id)) {
|
|
177
|
+
const servedBy = (part as any)?.metadata?.anthropic?.servedBy
|
|
178
|
+
if (!servedBy?.to) continue
|
|
179
|
+
const time = (part as any)?.time
|
|
180
|
+
const at = time?.end ?? time?.start ?? msg.time?.created ?? 0
|
|
181
|
+
latest = {
|
|
182
|
+
from: String(servedBy.from ?? "unknown"),
|
|
183
|
+
to: String(servedBy.to),
|
|
184
|
+
kind: servedBy.kind === "sticky" ? "sticky" : "fallback",
|
|
185
|
+
at,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return latest
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Walk a session's messages newest-first, yielding real assistant turns
|
|
193
|
+
* (skipping title-generation / compaction summaries, which never carry
|
|
194
|
+
* routing markers and would mask the real last turn).
|
|
195
|
+
*/
|
|
196
|
+
function* assistantTurns(sessionID: string, beforeMessageID?: string) {
|
|
197
|
+
const messages = api.state.session.messages(sessionID)
|
|
198
|
+
let skipping = beforeMessageID !== undefined
|
|
199
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
200
|
+
const msg = messages[i] as any
|
|
201
|
+
if (skipping) {
|
|
202
|
+
if (msg?.id === beforeMessageID) skipping = false
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (msg?.role !== "assistant") continue
|
|
206
|
+
if (msg.summary) continue
|
|
207
|
+
yield msg
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Live toast — only on the TRANSITION into fallback. A conversation that
|
|
212
|
+
// trips the classifier every turn would otherwise toast on every message;
|
|
213
|
+
// the sidebar line covers the continuing state. Sticky follow-ups never
|
|
214
|
+
// toast for the same reason.
|
|
215
|
+
const off = api.event.on("message.part.updated", (event) => {
|
|
216
|
+
const part = (event as any).properties?.part
|
|
217
|
+
const sessionID = (event as any).properties?.sessionID ?? part?.sessionID
|
|
218
|
+
const servedBy = part?.metadata?.anthropic?.servedBy
|
|
219
|
+
if (!servedBy?.to || !part?.id || !part?.messageID || !sessionID) return
|
|
220
|
+
if (seenParts.has(part.id)) return
|
|
221
|
+
seenParts.add(part.id)
|
|
222
|
+
if (servedBy.kind === "sticky") return
|
|
223
|
+
|
|
224
|
+
// Was the previous completed assistant turn already fallback-served?
|
|
225
|
+
for (const msg of assistantTurns(sessionID, part.messageID)) {
|
|
226
|
+
if (markerOf(msg)) return // continuation, not a transition
|
|
227
|
+
if (msg.time?.completed) break // last known state was the primary model
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
api.ui.toast({
|
|
231
|
+
variant: "warning",
|
|
232
|
+
title: "Model fallback",
|
|
233
|
+
message: `${shortModel(String(servedBy.from ?? "unknown"))} refused — answered by ${shortModel(String(servedBy.to))}`,
|
|
234
|
+
duration: 6000,
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Fallback state for one session, derived from its message parts — a pure
|
|
240
|
+
* reactive derivation that re-evaluates only when session state changes.
|
|
241
|
+
*
|
|
242
|
+
* Only the MOST RECENT *settled* assistant turn is consulted: sticky
|
|
243
|
+
* routing re-emits a servedBy marker on every turn while active, and a
|
|
244
|
+
* turn that routed back to the primary model carries no marker. Looking
|
|
245
|
+
* further back would resurrect a stale fallback after the session has
|
|
246
|
+
* already returned to the requested model.
|
|
247
|
+
*
|
|
248
|
+
* A still-streaming turn without a marker is "unknown", not "no
|
|
249
|
+
* fallback" — the previous completed turn's state keeps showing until
|
|
250
|
+
* the new turn settles (otherwise the line flickers off on every send
|
|
251
|
+
* and back on when the refusal hits mid-stream).
|
|
252
|
+
*
|
|
253
|
+
* No time window: "the latest turn was served by X" is a fact that stays
|
|
254
|
+
* true until the next turn says otherwise. Whether the server's sticky
|
|
255
|
+
* routing is still active is unobservable — we don't guess.
|
|
256
|
+
*/
|
|
257
|
+
const active = (sessionID: string | undefined): FallbackInfo | null => {
|
|
258
|
+
if (!sessionID) return null
|
|
259
|
+
for (const msg of assistantTurns(sessionID)) {
|
|
260
|
+
const marker = markerOf(msg)
|
|
261
|
+
if (marker) return marker
|
|
262
|
+
// No marker on a finished turn → currently served by the requested
|
|
263
|
+
// model. No marker on a streaming turn → not known yet, keep looking
|
|
264
|
+
// at the previous turn.
|
|
265
|
+
if (msg.time?.completed) return null
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { active, dispose: off }
|
|
271
|
+
}
|
|
272
|
+
|
|
135
273
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
136
274
|
|
|
137
275
|
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
|
@@ -242,6 +380,34 @@ function UsageSidebar(props: {
|
|
|
242
380
|
)
|
|
243
381
|
}
|
|
244
382
|
|
|
383
|
+
// ─── Fallback Sidebar Component ──────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function FallbackSidebar(props: {
|
|
386
|
+
theme: TuiThemeCurrent
|
|
387
|
+
sessionID: string | undefined
|
|
388
|
+
active: (sessionID: string | undefined) => FallbackInfo | null
|
|
389
|
+
}) {
|
|
390
|
+
return (
|
|
391
|
+
<Show when={props.active(props.sessionID)}>
|
|
392
|
+
{(info: Accessor<FallbackInfo>) => (
|
|
393
|
+
<box width="100%" flexDirection="column">
|
|
394
|
+
<text fg={props.theme.warning}>
|
|
395
|
+
<b>Model Fallback</b>
|
|
396
|
+
</text>
|
|
397
|
+
<text fg={props.theme.text}>
|
|
398
|
+
{shortModel(info().from)} → {shortModel(info().to)}
|
|
399
|
+
</text>
|
|
400
|
+
<text fg={props.theme.textMuted}>
|
|
401
|
+
{info().kind === "sticky"
|
|
402
|
+
? `sticky-routed at ${atTime(info().at)}`
|
|
403
|
+
: `refused at ${atTime(info().at)}`}
|
|
404
|
+
</text>
|
|
405
|
+
</box>
|
|
406
|
+
)}
|
|
407
|
+
</Show>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
245
411
|
// ─── Dialog Component ────────────────────────────────────────────────
|
|
246
412
|
|
|
247
413
|
function UsageDialog(props: {
|
|
@@ -353,6 +519,10 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
353
519
|
const store = createUsageStore(config.poll_interval)
|
|
354
520
|
api.lifecycle.onDispose(store.dispose)
|
|
355
521
|
|
|
522
|
+
// Model fallback notifications (toast) + sidebar status line
|
|
523
|
+
const fallback = createFallbackStore(api)
|
|
524
|
+
api.lifecycle.onDispose(fallback.dispose)
|
|
525
|
+
|
|
356
526
|
// Refresh when session goes idle (inference completed — server just wrote new headers)
|
|
357
527
|
const offEvent = api.event.on("session.idle", () => {
|
|
358
528
|
setTimeout(store.refresh, 1000)
|
|
@@ -360,31 +530,40 @@ const tui: TuiPlugin = async (api, options) => {
|
|
|
360
530
|
api.lifecycle.onDispose(offEvent)
|
|
361
531
|
|
|
362
532
|
// Register /usage command
|
|
363
|
-
api.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
533
|
+
const offCommand = api.keymap.registerLayer({
|
|
534
|
+
commands: [
|
|
535
|
+
{
|
|
536
|
+
namespace: "palette",
|
|
537
|
+
name: "anthropic-sdk.usage",
|
|
538
|
+
title: "Claude subscription usage",
|
|
539
|
+
category: "Claude",
|
|
540
|
+
slashName: "usage",
|
|
541
|
+
run() {
|
|
542
|
+
store.refresh()
|
|
543
|
+
api.ui.dialog.replace(() => (
|
|
544
|
+
<UsageDialog api={api} usage={store.usage} status={store.status} />
|
|
545
|
+
))
|
|
546
|
+
},
|
|
376
547
|
},
|
|
377
|
-
|
|
378
|
-
|
|
548
|
+
],
|
|
549
|
+
})
|
|
550
|
+
api.lifecycle.onDispose(offCommand)
|
|
379
551
|
|
|
380
552
|
// Register sidebar slot
|
|
381
553
|
if (config.sidebar) {
|
|
382
554
|
api.slots.register({
|
|
383
555
|
order: 110,
|
|
384
556
|
slots: {
|
|
385
|
-
sidebar_content(ctx) {
|
|
557
|
+
sidebar_content(ctx, props) {
|
|
386
558
|
return (
|
|
387
|
-
|
|
559
|
+
<>
|
|
560
|
+
<UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
|
|
561
|
+
<FallbackSidebar
|
|
562
|
+
theme={ctx.theme.current}
|
|
563
|
+
sessionID={(props as { session_id?: string } | undefined)?.session_id}
|
|
564
|
+
active={fallback.active}
|
|
565
|
+
/>
|
|
566
|
+
</>
|
|
388
567
|
)
|
|
389
568
|
},
|
|
390
569
|
},
|