clauderipple 0.9.1 → 0.9.3

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 (41) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.es.md +1 -0
  3. package/README.ja.md +1 -0
  4. package/README.ko.md +1 -0
  5. package/README.md +1 -0
  6. package/README.pt-BR.md +1 -0
  7. package/README.zh-CN.md +1 -0
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +42 -12
  11. package/dist/mod/hooks/ladder.js +5 -5
  12. package/dist/mod/hooks/ladder.ts +5 -5
  13. package/dist/mod/hooks/register.tsx +58 -15
  14. package/dist/mod/hooks/session.ts +37 -3
  15. package/dist/mod/hooks/settings.js +13 -11
  16. package/dist/mod/hooks/settings.ts +13 -11
  17. package/dist/mod/hooks/ui/alerts.tsx +17 -4
  18. package/dist/mod/hooks/ui/art.ts +2 -2
  19. package/dist/mod/hooks/ui/band.tsx +1 -1
  20. package/dist/mod/hooks/ui/kit.tsx +15 -2
  21. package/dist/mod/hooks/ui/view.ts +4 -1
  22. package/dist/mod/hooks/workers.ts +8 -8
  23. package/dist/mod/tests/register.test.tsx +63 -0
  24. package/dist/mod/tests/ui.test.tsx +10 -6
  25. package/dist/mod/types/index.d.ts +10 -1
  26. package/dist/router/src/admin.js +76 -5
  27. package/dist/router/src/agents.js +4 -0
  28. package/dist/router/src/config.js +24 -0
  29. package/dist/router/src/ingress/server.js +2 -2
  30. package/dist/router/src/providers/grok/auth.js +289 -0
  31. package/dist/router/src/providers/grok/catalog.js +52 -0
  32. package/dist/router/src/providers/grok/index.js +94 -0
  33. package/dist/router/src/providers/openai/index.js +69 -17
  34. package/dist/router/src/providers/openai/translate.js +6 -1
  35. package/dist/router/src/proxy.js +43 -0
  36. package/dist/router/src/redact.js +7 -2
  37. package/dist/router/src/version.js +1 -1
  38. package/dist/ui/app.js +115 -6
  39. package/dist/ui/i18n.js +6 -6
  40. package/docs/ARCHITECTURE.md +78 -0
  41. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  // Engine wiring lives here: imported logic and drawings never receive the engine interface.
2
2
  // Timers own background work; hooks only admit/rewrite a request and record its result.
3
3
  import { atom, read, update } from 'claude-code'
4
- import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
4
+ import type { AgentSpawnInput, EngineInterface, ModelCompleteResult, ModelForkResult, Register, RenderElement, RenderInput, SessionRateLimit, ToolCallInput, TurnStepInput, TurnUsage } from 'claude-code'
5
5
  import type { AgentRec, Effort, JudgeInput, JudgeKind, JudgeRun, ModSettingsSnapshot, Pick, Settings, StatusRequest, TierTarget, Verdict, WorkerCandidate, WorkerPick, WorkerVerdict } from '../types'
6
6
  import { MOD_SETTINGS_SCHEMA_VERSION, applySettingsChanges, normalizeSettings, settingsChanges } from './settings'
7
7
  import { debugText, langFrom, t } from './i18n'
@@ -13,7 +13,7 @@ import { activeModeOf, claudeWindow, eagerNote, modeKey, modeOver, quotaWindows,
13
13
  import type { QuotaWindow } from './modes'
14
14
  import type { Spawn, WorkerFix } from './worker'
15
15
  import { afterPing, nextMemo, restoreFromResume, shouldPing } from './cache'
16
- import { bandPriority, coldAlert, hotAlert, hottest, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
16
+ import { bandPriority, claudeLimitsFrom, coldAlert, hotAlert, hottest, keptAlert, saveUntil as limitSaveUntil, swampAlert, swamped } from './session'
17
17
  import { COMPACT_SYSTEM, compactResult, serializeTranscript } from './compact'
18
18
  import { ADVICE_SYSTEM, FULL_PROMPT, QUICK_PROMPT, adviceDue, adviceInput, parseAdvice, wrapForNewChat } from './handoff'
19
19
  import { addJudge, addPrompt, addRequest, addWarm, addWorker, emptyStats, formatStats, weighted } from './stats'
@@ -48,6 +48,7 @@ const lastSpawn = atom({ plugin: 'ripple', key: 'lastSpawn' } as const, null)
48
48
  const agents = atom({ plugin: 'ripple', key: 'agents' } as const, [])
49
49
  const cache = atom({ plugin: 'ripple', key: 'cache' } as const, null)
50
50
  const warm = atom({ plugin: 'ripple', key: 'warm' } as const, { pings: 0, cost: 0 })
51
+ const warmHere = atom({ plugin: 'ripple', key: 'warmHere' } as const, null)
51
52
  const lastRealReplyAt = atom({ plugin: 'ripple', key: 'lastRealReplyAt' } as const, null)
52
53
  const context = atom({ plugin: 'ripple', key: 'context' } as const, null)
53
54
  const hot = atom({ plugin: 'ripple', key: 'hot' } as const, null)
@@ -56,6 +57,7 @@ const hotHidden = atom({ plugin: 'ripple', key: 'hotHidden' } as const, null)
56
57
  const saveUntil = atom({ plugin: 'ripple', key: 'saveUntil' } as const, null)
57
58
  const swampHidden = atom({ plugin: 'ripple', key: 'swampHidden' } as const, null)
58
59
  const coldHidden = atom({ plugin: 'ripple', key: 'coldHidden' } as const, false)
60
+ const kept = atom({ plugin: 'ripple', key: 'kept' } as const, null)
59
61
  const judgeDown = atom({ plugin: 'ripple', key: 'judgeDown' } as const, null)
60
62
  const judgeDownHidden = atom({ plugin: 'ripple', key: 'judgeDownHidden' } as const, null)
61
63
  const judgeSeen = atom({ plugin: 'ripple', key: 'judgeSeen' } as const, [])
@@ -233,6 +235,8 @@ async function installSettings($: EngineInterface, s: Settings): Promise<void> {
233
235
  try { await registerAuto($, s) } catch (error) { await report($, 'agent register', error) }
234
236
  if (!s.effortAuto) await update($, heldPick, () => null)
235
237
  if (s.keepWarm && !previous.keepWarm) await update($, warm, value => ({ ...value, stopped: undefined }))
238
+ // Settings caught up with this chat's own choice: it follows Settings again.
239
+ if (await read($, warmHere) === s.keepWarm) await update($, warmHere, () => null)
236
240
  }
237
241
  function settingsSnapshot(text: string): ModSettingsSnapshot {
238
242
  const value = object(JSON.parse(text))
@@ -438,7 +442,7 @@ async function refreshLadder($: EngineInterface): Promise<void> {
438
442
  try {
439
443
  const response = await $.http.fetch(`${await adminUrl($)}/api/mod-options`)
440
444
  if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
441
- const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow)
445
+ const fresh = ladderOptionsFrom(JSON.parse(response.text), await $.clock.now() + KEEP_MS, (await currentSettings($)).allow, (await currentSettings($)).allowKnown)
442
446
  if (!same(await read($, ladderOptions), fresh)) await update($, ladderOptions, () => fresh)
443
447
  } catch (error) { await report($, 'ladder options', error) }
444
448
  }
@@ -688,7 +692,7 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
688
692
  const routing = routingSettings(s)
689
693
  const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
690
694
  ? { target: target!, effort: verdict.effort }
691
- : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers)
695
+ : ladderPick(verdict, routing.rules.general, routing, pinnedCandidates, usable, language, routing.allow, providers, routing.allowKnown)
692
696
  const selected = resolved.target
693
697
  if (!selected) return { deny: t(language, 'workers.none') }
694
698
  const fallbackReason = resolved.fallbackReason
@@ -712,9 +716,15 @@ async function toggleAuto($: EngineInterface): Promise<void> {
712
716
  const s = await currentSettings($)
713
717
  await saveSettings($, { ...s, effortAuto: !s.effortAuto })
714
718
  }
719
+ /** The band's flame turns keep-warm on or off for this chat only; Settings is what a new chat starts with. */
720
+ async function keepWarmOn($: EngineInterface, s: Settings): Promise<boolean> {
721
+ return await read($, warmHere) ?? s.keepWarm
722
+ }
715
723
  async function toggleWarm($: EngineInterface): Promise<void> {
716
724
  const s = await currentSettings($)
717
- await saveSettings($, { ...s, keepWarm: !s.keepWarm })
725
+ const on = !await keepWarmOn($, s)
726
+ await update($, warmHere, () => on === s.keepWarm ? null : on)
727
+ if (on) await update($, warm, value => ({ ...value, stopped: undefined }))
718
728
  }
719
729
  async function toggleSave($: EngineInterface): Promise<void> {
720
730
  const now = await $.clock.now()
@@ -921,6 +931,13 @@ async function openCompact($: EngineInterface, surface: Frame['surface']): Promi
921
931
  await update($, compactNote, () => '')
922
932
  await update($, panel, () => 'compact')
923
933
  }
934
+ /** The router's figure for this login's plan windows; nothing when the router cannot say. */
935
+ async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
936
+ try {
937
+ const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
938
+ return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
939
+ } catch (error) { await report($, 'claude limits', error); return [] }
940
+ }
924
941
  async function refreshUsage($: EngineInterface): Promise<void> {
925
942
  const usage = await $.session.usage()
926
943
  const c = usage.context
@@ -930,11 +947,15 @@ async function refreshUsage($: EngineInterface): Promise<void> {
930
947
  // Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
931
948
  const putOff = await read($, swampHidden)
932
949
  if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
933
- const h = hottest(usage.rateLimits)
950
+ // The host can leave the plan windows out though the replies carried them; on a Claude chat the
951
+ // router's figure for the same login stands in.
952
+ const own = usage.rateLimits.some(l => ['five_hour', 'seven_day'].includes(l.kind))
953
+ const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
954
+ const h = hottest(rateLimits)
934
955
  const previous = await read($, hot)
935
956
  if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
936
957
  if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
937
- const top = usage.rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
958
+ const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
938
959
  const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
939
960
  // Check the previous binding deadline before a rolled-forward snapshot replaces it.
940
961
  await endModeIfOver($)
@@ -986,7 +1007,8 @@ async function tick($: EngineInterface): Promise<void> {
986
1007
  const answer = await $.model.complete({ model: HAIKU, system: ADVICE_SYSTEM, prompt: queued, effort: 'low', maxTokens: 60, timeoutMs: 8000 })
987
1008
  if (answer.isAnswered) await update($, advice, () => parseAdvice(answer.text))
988
1009
  }
989
- const s = await currentSettings($)
1010
+ const s = { ...await currentSettings($) }
1011
+ s.keepWarm = await keepWarmOn($, s)
990
1012
  const decision = shouldPing({ settings: s, memo: await read($, cache), warm: await read($, warm), now: await $.clock.now(), idle: !await read($, busy) && !await read($, deciding) && !forking,
991
1013
  contextTokens: (await read($, context))?.tokens ?? 0, lastRealReplyAt: await read($, lastRealReplyAt) })
992
1014
  if (decision.ping) {
@@ -997,6 +1019,15 @@ async function tick($: EngineInterface): Promise<void> {
997
1019
  const after = afterPing(before, answer, await $.clock.now(), language)
998
1020
  await update($, warm, () => after)
999
1021
  if (after.stopped && !before.stopped) await $.ui.toast(t(language, 'toasts.warmStopped', { reason: after.stopped }))
1022
+ // A ping read cache tokens: the cache held while the person was away. One card counts them all
1023
+ // (a real reply or the expiry clears it; register.tsx kept), until then each ping adds up.
1024
+ const usage = answer && 'usage' in answer ? answer.usage : null
1025
+ if ((usage?.cache_read_input_tokens ?? 0) > 0) {
1026
+ await update($, kept, prev => ({
1027
+ pings: (prev?.pings ?? 0) + 1,
1028
+ tokens: usage!.cache_read_input_tokens,
1029
+ }))
1030
+ }
1000
1031
  } else if (s.keepWarm && !await read($, busy) && decision.reason === 'keep warm time bound reached' && !(await read($, warm)).stopped) {
1001
1032
  await update($, warm, value => ({ ...value, stopped: t(language, 'toasts.warmBoundReason') }))
1002
1033
  await $.ui.toast(t(language, 'toasts.warmBound'))
@@ -1025,7 +1056,7 @@ async function dashboard($: EngineInterface, working: boolean): Promise<Dashboar
1025
1056
  mode: await read($, deciding) ? 'deciding' : await read($, paused) ? 'paused' : p?.by === 'manual' ? 'manual' : s.effortAuto ? 'auto' : 'off',
1026
1057
  ...(p?.why ? { why: p.why, by: p.by } : {}), ...(await read($, decidingSince) !== null ? { since: (await read($, decidingSince))! } : {}) },
1027
1058
  model: await read($, modelIs) ? modelName((await read($, modelIs))!) : null,
1028
- context: await read($, context), cache: await read($, cache), warm: { ...w, on: s.keepWarm },
1059
+ context: await read($, context), cache: await read($, cache), warm: { ...w, on: await keepWarmOn($, s), ...(await read($, warmHere) !== null ? { here: true } : {}) },
1029
1060
  limit: l ? { ...l, save: saving } : null, route: record ? routeOf(record) : null, notice: await read($, line),
1030
1061
  auto: s.effortAuto, handoff: await read($, advice) ? 'advised' : s.handoffButton === 'always' ? 'always' : 'off',
1031
1062
  agents: { live: list.filter(a => ['running', 'waiting'].includes(a.state)).length, total: list.length },
@@ -1056,6 +1087,7 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1056
1087
  const which = bandPriority({ panel: await read($, panel), judgeDown: d === 'down' || (!!downReason && downReason !== await read($, judgeDownHidden)),
1057
1088
  hot: d === 'hot' || hotAlert(h, await read($, hotHidden)), result: !!writing || !!result,
1058
1089
  cold: d === 'cold' || coldAlert(m, c?.tokens ?? 0, now, await read($, coldHidden)),
1090
+ kept: d === 'kept' || keptAlert(await read($, kept), m, now),
1059
1091
  swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
1060
1092
  if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
1061
1093
  if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
@@ -1068,22 +1100,27 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
1068
1100
  return { kind: 'result', stage, handoffKind: writing.kind, ...(detail ? { detail } : {}) }
1069
1101
  }
1070
1102
  if (which === 'cold') return { kind: 'cold', tokens: d === 'cold' ? 180000 : c!.tokens, idleMinutes: m ? Math.round((now - m.lastReplyAt) / 60000) : 61 }
1103
+ if (which === 'kept') {
1104
+ const k = d === 'kept' ? { pings: 2, tokens: 312000 } : (await read($, kept)) ?? { pings: 0, tokens: 0 }
1105
+ return { kind: 'kept', pings: k.pings, tokens: k.tokens, minutesLeft: Math.ceil(((d === 'kept' ? now + 47 * 60000 : m!.expiresAt) - now) / 60000) }
1106
+ }
1071
1107
  if (which === 'swamp') return { kind: 'swamp', percent: d === 'swamp' ? 65 : c!.percent, tokens: d === 'swamp' ? 160000 : c!.tokens }
1072
1108
  return null
1073
1109
  }
1074
1110
  /**
1075
- * A prompt sent while the cold, heavy or hot band shows counts as having seen it: it comes back
1076
- * only on its own next occasion (the next expiry, 15 more points of context, 10 more of the plan
1077
- * window), never after every message.
1111
+ * A prompt sent while the cold, kept, heavy or hot band shows counts as having seen it: it comes
1112
+ * back only on its own next occasion (the next expiry, one more ping, 15 more points of context,
1113
+ * 10 more of the plan window), never after every message.
1078
1114
  */
1079
1115
  async function markSeen($: EngineInterface): Promise<void> {
1080
1116
  const view = await alertView($, await $.clock.now())
1081
- if (view && (view.kind === 'cold' || view.kind === 'swamp' || view.kind === 'hot')) await dismiss($, view)
1117
+ if (view && (view.kind === 'cold' || view.kind === 'kept' || view.kind === 'swamp' || view.kind === 'hot')) await dismiss($, view)
1082
1118
  }
1083
1119
  async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
1084
1120
  await update($, demo, () => null)
1085
1121
  if (view.kind === 'hot') await update($, hotHidden, () => view.percent)
1086
1122
  else if (view.kind === 'cold') await update($, coldHidden, () => true)
1123
+ else if (view.kind === 'kept') await update($, kept, () => null)
1087
1124
  else if (view.kind === 'swamp') await update($, swampHidden, () => view.percent)
1088
1125
  else if (view.kind === 'judgeDown') await update($, judgeDownHidden, () => view.reason)
1089
1126
  else if (view.kind === 'result') { await update($, resultCard, () => null); await update($, handoff, () => null) }
@@ -1133,6 +1170,7 @@ async function prepareStep($: EngineInterface, e: TurnStepInput): Promise<TurnSt
1133
1170
  if (previousModel !== null) {
1134
1171
  await update($, cache, () => null)
1135
1172
  await update($, lastRealReplyAt, () => null)
1173
+ if (await read($, kept)) await update($, kept, () => null)
1136
1174
  }
1137
1175
  }
1138
1176
  const safe = isCacheSafeModel(e.model)
@@ -1200,6 +1238,7 @@ export const register: Register = on => {
1200
1238
  await update($, cache, () => null)
1201
1239
  await update($, context, () => null)
1202
1240
  await update($, lastRealReplyAt, () => null)
1241
+ if (await read($, kept)) await update($, kept, () => null)
1203
1242
  }
1204
1243
  if (e.model) await update($, modelIs, () => e.model!)
1205
1244
  await update($, paused, () => e.model ? !isCacheSafeModel(e.model) : false)
@@ -1291,6 +1330,7 @@ export const register: Register = on => {
1291
1330
  const memo = nextMemo(await read($, cache), answer.usage, now)
1292
1331
  await update($, cache, () => memo)
1293
1332
  if (await read($, coldHidden)) await update($, coldHidden, () => false)
1333
+ if (await read($, kept)) await update($, kept, () => null)
1294
1334
  await update($, lastRealReplyAt, () => now)
1295
1335
  if ((await read($, warm)).stopped !== undefined) await update($, warm, value => ({ ...value, stopped: undefined }))
1296
1336
  }
@@ -1403,6 +1443,7 @@ export const register: Register = on => {
1403
1443
  if (!answer.isAnswered || answer.text.trim().length <= 200) throw new Error(t(language, 'toasts.summaryEmpty'))
1404
1444
  await update($, cache, () => null)
1405
1445
  await update($, context, () => null)
1446
+ if (await read($, kept)) await update($, kept, () => null)
1406
1447
  await update($, resultCard, () => ({ stage: 'compacted' }))
1407
1448
  return compactResult(answer.text)
1408
1449
  } catch (error) {
@@ -1425,7 +1466,7 @@ export const register: Register = on => {
1425
1466
  else if (sub === 'log') await toggleLog($)
1426
1467
  else if (sub === 'agents') await toggleAgents($)
1427
1468
  else if (sub === 'bar') await toggleBar($)
1428
- else if (['cold', 'swamp', 'hot', 'down'].includes(sub)) { await update($, panel, () => null); await update($, demo, () => sub as 'cold' | 'swamp' | 'hot' | 'down') }
1469
+ else if (['cold', 'swamp', 'hot', 'down', 'kept'].includes(sub)) { await update($, panel, () => null); await update($, demo, () => sub as 'cold' | 'swamp' | 'hot' | 'down' | 'kept') }
1429
1470
  else return { text: t(language, 'commands.help') }
1430
1471
  return { text: t(language, 'commands.done', { command: sub }) }
1431
1472
  } catch (error) { await report($, 'ripple command', error); return next(e) }
@@ -1439,7 +1480,9 @@ export const register: Register = on => {
1439
1480
  on('command.run', { command: 'ripple-bar' }, async ($, e, next) => {
1440
1481
  try {
1441
1482
  const language = await read($, lang)
1442
- return { text: await toggleBar($) ? t(language, 'commands.barShown') : t(language, 'commands.barHidden') } }
1483
+ if (!await toggleBar($)) return { text: t(language, 'commands.barHidden') }
1484
+ // The minimal layout draws no band above the prompt, only the short line under it (#56).
1485
+ return { text: t(language, (await currentSettings($)).layout === 'minimal' ? 'commands.barShownMinimal' : 'commands.barShown') } }
1443
1486
  catch (error) { await report($, 'ripple-bar', error); return next(e) }
1444
1487
  })
1445
1488
  on('ui.close', { id: LOG_PANE }, async ($, e, next) => {
@@ -7,6 +7,32 @@ export function hottest(rateLimits: readonly SessionRateLimit[]): Hot | null {
7
7
  return limit ? { kind: limit.kind, percent: limit.percentUsed, ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}) } : null
8
8
  }
9
9
 
10
+ const WINDOW_KIND: Record<number, string> = { 300: 'five_hour', 10080: 'seven_day' }
11
+ /** A router snapshot older than this is not shown as the plan's figure. */
12
+ export const CLAUDE_USAGE_STALE_MS = 15 * 60_000
13
+
14
+ /**
15
+ * The plan windows of the Claude login this session's requests pass through, from the router's
16
+ * GET /api/status (`claude.accounts[id=current].quota`, packages/router/src/admin.ts
17
+ * claudeAccountUsage): for when the host leaves `rateLimits` empty though the requests carry them
18
+ * (measured 2026-10-14: the router passes the `anthropic-ratelimit-unified-*` headers through).
19
+ * Percent as the router reports it; a stale or unknown snapshot gives nothing.
20
+ */
21
+ export function claudeLimitsFrom(status: unknown, now: number): SessionRateLimit[] {
22
+ const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
23
+ const accounts = object(object(status).claude).accounts
24
+ const current = Array.isArray(accounts) ? accounts.map(object).find(a => a.id === 'current') : undefined
25
+ const quota = object(current?.quota)
26
+ if (typeof quota.at !== 'number' || now - quota.at > CLAUDE_USAGE_STALE_MS) return []
27
+ const limits = object(quota.rate_limits)
28
+ return [limits.primary, limits.secondary].map(object).flatMap(w => {
29
+ const kind = typeof w.window_minutes === 'number' ? WINDOW_KIND[w.window_minutes] : undefined
30
+ if (!kind || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return []
31
+ const reset = typeof w.reset_at === 'number' ? w.reset_at * (w.reset_at < 1e12 ? 1000 : 1) : NaN
32
+ return [{ kind, percentUsed: Math.round(w.used_percent * 10) / 10, ...(Number.isFinite(reset) ? { resetsAt: new Date(reset).toISOString() } : {}) }]
33
+ })
34
+ }
35
+
10
36
  /**
11
37
  * The reset is read in the person's own clock: `offsetMinutes` is `Date#getTimezoneOffset` (minutes
12
38
  * behind UTC), passed in so tests do not depend on the machine's zone.
@@ -37,6 +63,14 @@ export function coldAlert(memo: CacheMemo | null, contextTokens: number, now: nu
37
63
  return !hidden && memo !== null && memo.expiresAt <= now && contextTokens >= 150000
38
64
  }
39
65
 
66
+ /**
67
+ * Keep-warm held the cache while the person was away: at least one ping read cache tokens and the
68
+ * memo says the cache is still warm. Once it has expired the cold card is the truth, never this.
69
+ */
70
+ export function keptAlert(kept: { pings: number; tokens: number } | null, memo: CacheMemo | null, now: number): boolean {
71
+ return kept !== null && kept.pings > 0 && memo !== null && memo.expiresAt > now
72
+ }
73
+
40
74
  export function hotAlert(hot: Hot | null, hidden: number | null): boolean {
41
75
  return hot !== null && hot.percent >= 80 && (hidden === null || hot.percent >= hidden + 10)
42
76
  }
@@ -54,9 +88,9 @@ export function swampAlert(percent: number | null, hidden: number | null): boole
54
88
  /** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
55
89
  export function bandPriority(state: {
56
90
  panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
57
- judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean
58
- }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'dashboard' {
91
+ judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
92
+ }): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
59
93
  if (state.panel) return state.panel
60
- for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp'] as const) if (state[band]) return band
94
+ for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
61
95
  return 'dashboard'
62
96
  }
@@ -21,7 +21,7 @@ export const DEFAULTS = {
21
21
  hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
22
22
  } },
23
23
  },
24
- allow: [], modes: [],
24
+ allow: [], allowKnown: [], modes: [],
25
25
  providers: {},
26
26
  fallback: { agent: 'general-purpose', model: 'sonnet' },
27
27
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -158,16 +158,16 @@ export function migrateAllow(allow, workers) {
158
158
  const agents = new Set(workers.map(w => w.name));
159
159
  return [...new Set(allow.flatMap(entry => agents.has(entry) || !workers.some(w => w.provider === entry) ? [entry] : workers.filter(w => w.provider === entry).map(w => w.name)))];
160
160
  }
161
- /** The workers switched on by an allow-list; empty means every worker. */
162
- export function allowedWorkers(allow, workers) {
161
+ /** The workers switched on by an allow-list; empty means every worker. A worker that did not exist when the list was last written (absent from `known`) counts as on. */
162
+ export function allowedWorkers(allow, workers, known = []) {
163
163
  if (!allow.length)
164
164
  return workers.map(w => w.name);
165
165
  const on = new Set(migrateAllow(allow, workers));
166
- return workers.filter(w => on.has(w.name)).map(w => w.name);
166
+ return workers.filter(w => on.has(w.name) || known.length > 0 && !known.includes(w.name)).map(w => w.name);
167
167
  }
168
- /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
169
- export function toggleAllow(allow, name, workers) {
170
- const on = allowedWorkers(allow, workers);
168
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. `known` is the allow-list keys the writer knew. */
169
+ export function toggleAllow(allow, name, workers, known = []) {
170
+ const on = allowedWorkers(allow, workers, known);
171
171
  const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name);
172
172
  if (!next.length)
173
173
  return [...allow];
@@ -175,10 +175,11 @@ export function toggleAllow(allow, name, workers) {
175
175
  }
176
176
  /**
177
177
  * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
178
- * settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
178
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model. A key
179
+ * absent from a non-empty `known` list (a worker added after the list was last written) ticks on.
179
180
  */
180
- export function isAllowed(key, provider, allow) {
181
- return !allow.length || allow.includes(key) || allow.includes(provider);
181
+ export function isAllowed(key, provider, allow, known = []) {
182
+ return !allow.length || allow.includes(key) || allow.includes(provider) || known.length > 0 && !known.includes(key);
182
183
  }
183
184
  /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
184
185
  const RETIRED_DESIGN = JSON.stringify([claude('sonnet', 'medium'), claude('sonnet', 'high'), claude('opus', 'high'), claude('opus', 'high')].map(s => [s.target.agent, s.target.model, s.effort]));
@@ -266,7 +267,8 @@ export function normalizeSettings(raw, workers) {
266
267
  rules.general = ladderFromPin(targets, {});
267
268
  }
268
269
  const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : []);
269
- const result = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } };
270
+ const savedKnown = names(value.allowKnown);
271
+ const result = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, allowKnown: savedKnown, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } };
270
272
  for (const [name, enabled] of Object.entries(object(value.providers)))
271
273
  if (typeof enabled === 'boolean')
272
274
  result.providers[name] = enabled;
@@ -26,7 +26,7 @@ export const DEFAULTS: Settings = {
26
26
  hard: [{ target: { agent: 'gpt-6-astra' }, effort: 'high' }], plan: [claude('opus', 'high')],
27
27
  } },
28
28
  },
29
- allow: [], modes: [],
29
+ allow: [], allowKnown: [], modes: [],
30
30
  providers: {},
31
31
  fallback: { agent: 'general-purpose', model: 'sonnet' },
32
32
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
@@ -159,16 +159,16 @@ export function migrateAllow(allow: readonly string[], workers: readonly Worker[
159
159
  return [...new Set(allow.flatMap(entry => agents.has(entry) || !workers.some(w => w.provider === entry) ? [entry] : workers.filter(w => w.provider === entry).map(w => w.name)))]
160
160
  }
161
161
 
162
- /** The workers switched on by an allow-list; empty means every worker. */
163
- export function allowedWorkers(allow: readonly string[], workers: readonly Worker[]): string[] {
162
+ /** The workers switched on by an allow-list; empty means every worker. A worker that did not exist when the list was last written (absent from `known`) counts as on. */
163
+ export function allowedWorkers(allow: readonly string[], workers: readonly Worker[], known: readonly string[] = []): string[] {
164
164
  if (!allow.length) return workers.map(w => w.name)
165
165
  const on = new Set(migrateAllow(allow, workers))
166
- return workers.filter(w => on.has(w.name)).map(w => w.name)
166
+ return workers.filter(w => on.has(w.name) || known.length > 0 && !known.includes(w.name)).map(w => w.name)
167
167
  }
168
168
 
169
- /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. */
170
- export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[]): string[] {
171
- const on = allowedWorkers(allow, workers)
169
+ /** Switches one worker; every worker on again is stored as [], and the last one cannot go off. `known` is the allow-list keys the writer knew. */
170
+ export function toggleAllow(allow: readonly string[], name: string, workers: readonly Worker[], known: readonly string[] = []): string[] {
171
+ const on = allowedWorkers(allow, workers, known)
172
172
  const next = on.includes(name) ? on.filter(n => n !== name) : workers.filter(w => w.name === name || on.includes(w.name)).map(w => w.name)
173
173
  if (!next.length) return [...allow]
174
174
  return next.length === workers.length ? [] : next
@@ -176,10 +176,11 @@ export function toggleAllow(allow: readonly string[], name: string, workers: rea
176
176
 
177
177
  /**
178
178
  * Whether a stored model is ticked: its key (an agent name, or `general-purpose:<model>`) or, for
179
- * settings from before 2026-10-10, its provider is listed; an empty list ticks every model.
179
+ * settings from before 2026-10-10, its provider is listed; an empty list ticks every model. A key
180
+ * absent from a non-empty `known` list (a worker added after the list was last written) ticks on.
180
181
  */
181
- export function isAllowed(key: string, provider: string, allow: readonly string[]): boolean {
182
- return !allow.length || allow.includes(key) || allow.includes(provider)
182
+ export function isAllowed(key: string, provider: string, allow: readonly string[], known: readonly string[] = []): boolean {
183
+ return !allow.length || allow.includes(key) || allow.includes(provider) || known.length > 0 && !known.includes(key)
183
184
  }
184
185
 
185
186
  /** The design ladder every save stored until 2026-10-12: Claude, Sonnet then Opus. */
@@ -262,7 +263,8 @@ export function normalizeSettings(raw: unknown, workers?: readonly Worker[]): Se
262
263
  rules.general = ladderFromPin(targets, {})
263
264
  }
264
265
  const ticked = names(Array.isArray(value.allow) ? value.allow : object(stored.general).mode === 'auto' ? object(stored.general).allow : [])
265
- const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
266
+ const savedKnown = names(value.allowKnown)
267
+ const result: Settings = { ...DEFAULTS, rules, allow: workers?.length ? migrateAllow(ticked, workers) : ticked, allowKnown: savedKnown, providers: {}, hide: [...DEFAULTS.hide], extras: [...DEFAULTS.extras], fallback: { ...DEFAULTS.fallback } }
266
268
  for (const [name, enabled] of Object.entries(object(value.providers))) if (typeof enabled === 'boolean') result.providers[name] = enabled
267
269
  for (const key of ['effortAuto', 'keepWarm', 'setupDone', 'swampAlert'] as const) if (typeof value[key] === 'boolean') result[key] = value[key]
268
270
  if (Array.isArray(value.extras)) result.extras = [...new Set(value.extras.filter((part): part is string => typeof part === 'string' && EXTRAS.includes(part)))]
@@ -12,7 +12,7 @@ import { t } from '../i18n'
12
12
  import type { RenderNode } from 'claude-code'
13
13
 
14
14
  import type { HandoffKind, Settings } from '../../types'
15
- import { CELL_PX, MUTE, SUB, TONE, pillArt } from './art'
15
+ import { CELL_PX, MUTE, ROW_PX, SUB, TONE, pillArt } from './art'
16
16
  import type { Sea, Tone } from './art'
17
17
  import { capsule, closeButton, pill, ripple, segmented as drawnSegmented, well } from './kit'
18
18
  import type { SegmentOption } from './kit'
@@ -61,6 +61,14 @@ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
61
61
  quiet: [handoff],
62
62
  close: notNow,
63
63
  }
64
+ case 'kept':
65
+ return {
66
+ key: 'kept',
67
+ face: 'pleased',
68
+ title: t(L, 'alerts.keptTitle'),
69
+ body: t(L, 'alerts.keptBody', { pings: alert.pings, tokens: compactNumber(alert.tokens), minutes: alert.minutesLeft }),
70
+ close: { key: 'dismiss', label: t(L, 'alerts.gotIt'), hotkey: 'x', onPress: a.dismiss },
71
+ }
64
72
  case 'swamp':
65
73
  return {
66
74
  key: 'swamp',
@@ -205,6 +213,8 @@ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
205
213
  switch (card.key) {
206
214
  case 'cold':
207
215
  return { tone: 'ice', weather: 'snow', motes: true }
216
+ case 'kept':
217
+ return { tone: 'amber', weather: 'sparkles', ripples: 'calm' }
208
218
  case 'swamp':
209
219
  return { tone: 'amber', weather: 'bubbles', ripples: 'calm' }
210
220
  case 'hot':
@@ -329,7 +339,8 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
329
339
  </Box>
330
340
  ) : (
331
341
  <Box key="top" flexDirection="row" alignItems="flex-start" columnGap={2} width="100%" minWidth={0}>
332
- {face}
342
+ {/* Beside two lines 리플이 sits at their middle, not on the title's line (2026-10-14: it rode high). */}
343
+ <Box key="face" alignSelf="center" flexShrink={0}>{face}</Box>
333
344
  <Box key="say" flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
334
345
  {title}
335
346
  {body}
@@ -339,10 +350,12 @@ function drawnAlert(el: Els, frame: Frame, alert: AlertView, a: AlertActions): R
339
350
  {actionsBelow ? null : buttons}
340
351
  </Box>
341
352
  )
353
+ const rows = oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0)
342
354
  return capsule(el, frame, {
343
355
  key: 'alert-' + card.key,
344
- sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: 0.5 * 19 * 0.5 + CARD_FACE / 2 } },
345
- rows: oneRow ? 2 : 4 + (more.length ? 3.2 : 0) + (actionsBelow ? 2 : 0) + (frame.columns < 60 ? 3 : 0),
356
+ // The light comes from 리플이: on the title's line in one row, at the card's middle beside more.
357
+ sea: { ...sea, reach: 130, at: { x: 1.5 * CELL_PX + CARD_FACE / 2, y: oneRow ? 0.5 * ROW_PX * 0.5 + CARD_FACE / 2 : rows * ROW_PX / 2 } },
358
+ rows,
346
359
  children: [top],
347
360
  })
348
361
  }
@@ -431,8 +431,8 @@ export function wellArt(width: number, height: number): string {
431
431
  export const CLOSE_PX = 22
432
432
 
433
433
  /** The way out of a capsule, 22 px: a soft disc with a ×. */
434
- export function closeArt(): string {
435
- return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity=".07" stroke="' + HAIR + '" stroke-opacity=".2"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
434
+ export function closeArt(lit = false): string {
435
+ return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity="' + (lit ? '.16' : '.07') + '" stroke="' + HAIR + '" stroke-opacity="' + (lit ? '.45' : '.2') + '"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
436
436
  }
437
437
 
438
438
  // ---- The band's instruments ---------------------------------------------------------------------
@@ -596,7 +596,7 @@ function controlsDrawn(el: Els, frame: Frame, view: DashboardView, actions: Band
596
596
  ? { key: 'auto', cells: toggleCells(autoLabel), node: toggle(el, 'auto', view.auto, autoLabel, actions.toggleAuto, view.effort.mode === 'deciding', tipScope('auto')), tip: autoTip }
597
597
  : { key: 'auto', cells: SWITCH_ONLY, node: lit(el, 'auto', SWITCH_ONLY, [pic(el, 'auto', switchArt(view.auto, view.effort.mode === 'deciding'), autoLabel, 36, 22), press(el, 'auto', SWITCH_ONLY * CELL_PX, actions.toggleAuto)], 0.25, tipScope('auto')), tip: autoTip })
598
598
  const warmLabel = t(L, 'band.warm')
599
- out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
599
+ out.push(iconControl(el, 'warm', iconArt('flame', view.warm.on, 'amber'), ICON_PX, warmLabel, t(L, 'band.warmAlt', { state: state(view.warm.on) }) + ' · ' + t(L, view.warm.here ? 'band.warmHere' : 'settings.warmLine'), actions.toggleWarm, words.includes('warm') ? warmLabel : undefined, view.warm.on ? TONE.amber.text : SUB))
600
600
  const agentsLabel = t(L, 'band.agents')
601
601
  const live = view.agents.live
602
602
  // The live count sits on the icon's shoulder, in one picture of one width: no width change.
@@ -161,9 +161,22 @@ export function pill(el: Els, key: string, label: string, look: PillLook, onPres
161
161
  /** Cells the way out takes. */
162
162
  export const CLOSE_CELLS = cellsOf(CLOSE_PX + 4)
163
163
 
164
- /** The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. */
164
+ /**
165
+ * The way out: a soft disc with a ×, `label` its name for a reader that cannot see it. The disc is
166
+ * its own light under the pointer: `lit`'s rounded box around it read as a second button
167
+ * (2026-10-14).
168
+ */
165
169
  export function closeButton(el: Els, key: string, label: string, onPress: () => void, scope?: string): RenderNode {
166
- return lit(el, key, CLOSE_CELLS, [pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX), press(el, key, CLOSE_CELLS * CELL_PX, onPress)], 0.25, scope)
170
+ const { Box } = el
171
+ return (
172
+ <Box key={'hv-' + key} position="relative" flexDirection="row" alignItems="center" justifyContent="center" width={CLOSE_CELLS} flexShrink={0} {...(scope ? { hover: { scope } } : {})}>
173
+ {pic(el, key, closeArt(), label, CLOSE_PX, CLOSE_PX)}
174
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center" display="none" hover={{ display: 'flex' }}>
175
+ {pic(el, key + '-lit', closeArt(true), label, CLOSE_PX, CLOSE_PX)}
176
+ </Box>
177
+ {press(el, key, CLOSE_CELLS * CELL_PX, onPress)}
178
+ </Box>
179
+ )
167
180
  }
168
181
 
169
182
  /** Cells a switch and its word take, their hover's air included. */
@@ -65,7 +65,8 @@ export type DashboardView = {
65
65
  context: { percent: number; tokens: number; window: number } | null
66
66
  /** The last main reply's cache; null when unknown. */
67
67
  cache: { expiresAt: number; ttlMs: number } | null
68
- warm: { on: boolean; pings: number; cost: number; stopped?: string }
68
+ /** `here`: this chat differs from Settings, set by the band's flame. */
69
+ warm: { on: boolean; pings: number; cost: number; stopped?: string; here?: boolean }
69
70
  /** The hottest plan window, shown when present. */
70
71
  limit: { kind: string; percent: number; resetsAt?: string; save: boolean } | null
71
72
  route: RouteView | null
@@ -113,6 +114,8 @@ export type ResultStage = 'handingOff' | 'handoffReady' | 'handoffDone' | 'compa
113
114
  export type AlertView =
114
115
  /** The cache expired on a big chat: the next prompt re-reads it all. */
115
116
  | { kind: 'cold'; tokens: number; idleMinutes: number }
117
+ /** Keep-warm held the cache while the person was away. */
118
+ | { kind: 'kept'; pings: number; tokens: number; minutesLeft: number }
116
119
  /** Context is heavy. */
117
120
  | { kind: 'swamp'; percent: number; tokens: number }
118
121
  /** A plan window at or above 80%. */
@@ -93,8 +93,8 @@ export function scoreModels(raw: unknown): ModelScore[] {
93
93
  * Allow-lists name worker agents, and Claude's own models as `general-purpose:<model>`; provider
94
94
  * names from settings saved before 2026-10-10 still match.
95
95
  */
96
- export function allowed(candidate: WorkerCandidate, allow: readonly string[]): boolean {
97
- return isAllowed(candidateKey(candidate.target), candidate.provider, allow)
96
+ export function allowed(candidate: WorkerCandidate, allow: readonly string[], known: readonly string[] = []): boolean {
97
+ return isAllowed(candidateKey(candidate.target), candidate.provider, allow, known)
98
98
  }
99
99
  /** Claude's own models as candidates: general-purpose on each. */
100
100
  export function claudeCandidates(): WorkerCandidate[] {
@@ -106,16 +106,16 @@ const BILLINGS: readonly Billing[] = ['free', 'payg', 'subscription']
106
106
  * provider can run now, with ladders, billing and cheapest measured cost, then Claude's own
107
107
  * models; only the ticked ones (`allow`, empty for every model).
108
108
  */
109
- export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = []): LadderOptions {
109
+ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly string[] = [], allowKnown: readonly string[] = []): LadderOptions {
110
110
  const root = record(raw)
111
111
  const efforts = (value: unknown): Effort[] => list(value).filter((e): e is Effort => EFFORTS.includes(e as Effort))
112
112
  const usable = new Set(list(root.providers).flatMap(p => { const v = record(p); return typeof v.name === 'string' && v.usable !== false ? [v.name] : [] }))
113
- const known = list(root.providers).length > 0
113
+ const catalogue = list(root.providers).length > 0
114
114
  const scores = scoreModels(root.scores)
115
115
  const choices: LadderChoice[] = list(root.agents).flatMap(value => {
116
116
  const a = record(value)
117
117
  if (typeof a.name !== 'string' || !a.name || typeof a.model !== 'string' || typeof a.provider !== 'string') return []
118
- if (known && !usable.has(a.provider)) return []
118
+ if (catalogue && !usable.has(a.provider)) return []
119
119
  const costs = (scores.find(s => s.model === a.model && s.provider === a.provider)?.variants ?? []).flatMap(v => v.costPerTask === null ? [] : [v.costPerTask])
120
120
  const billing = BILLINGS.includes(a.billing as Billing) ? a.billing as Billing : 'payg'
121
121
  return [{ target: { agent: a.name }, model: a.model, provider: a.provider, billing, efforts: efforts(a.efforts), ...(costs.length ? { cost: Math.min(...costs) } : {}) }]
@@ -125,7 +125,7 @@ export function ladderOptionsFrom(raw: unknown, until: number, allow: readonly s
125
125
  if (typeof p.name !== 'string' || typeof p.usable !== 'boolean') return []
126
126
  return [[p.name, { usable: p.usable, ...(typeof p.reason === 'string' ? { reason: p.reason } : {}), ...(typeof p.exhaustedUntil === 'number' ? { exhaustedUntil: p.exhaustedUntil } : {}) }]]
127
127
  }))
128
- return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow), providers }
128
+ return { until, choices: tickedChoices([...choices, ...claudeChoices(efforts(root.claudeEfforts))], allow, allowKnown), providers }
129
129
  }
130
130
  export function candidateUsable(candidate: WorkerCandidate, settings: Settings, chatgpt: boolean, providers: NonNullable<LadderOptions['providers']> = {}): boolean {
131
131
  const live = providers[candidate.provider]
@@ -152,7 +152,7 @@ export const SESSION_MODEL: TierTarget = { agent: 'general-purpose' }
152
152
  * The ladder: the verdict's rung, else one rung up, else the session model, never down. Whatever
153
153
  * ran instead says why (the fallback toast).
154
154
  */
155
- export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}): Resolution {
155
+ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Settings, candidates: readonly WorkerCandidate[], chatgpt: boolean, lang: Lang, allow: readonly string[] = [], providers: NonNullable<LadderOptions['providers']> = {}, known: readonly string[] = []): Resolution {
156
156
  const wanted = rungOf(verdict)
157
157
  const skipped: NonNullable<Resolution['skipped']> = []
158
158
  const reasons: string[] = []
@@ -163,7 +163,7 @@ export function ladderPick(verdict: WorkerVerdict, rule: Rule, settings: Setting
163
163
  for (const [index, step] of steps.entries()) {
164
164
  const target = step.target
165
165
  const candidate = candidateOf(target, candidates)
166
- const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow)
166
+ const ticked = target.agent === 'general-purpose' && !target.model || candidate && allowed(candidate, allow, known)
167
167
  if (candidate && candidateUsable(candidate, settings, chatgpt, providers) && ticked) {
168
168
  return { target: { ...target }, effort: step.effort, rung, position: index + 1, skipped, ...(reasons.length ? { fallbackReason: reasons.join('; ') } : {}) }
169
169
  }