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.
- package/CHANGELOG.md +42 -0
- package/README.es.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.pt-BR.md +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +2 -1
- package/dist/mod/hooks/i18n.ts +42 -12
- package/dist/mod/hooks/ladder.js +5 -5
- package/dist/mod/hooks/ladder.ts +5 -5
- package/dist/mod/hooks/register.tsx +58 -15
- package/dist/mod/hooks/session.ts +37 -3
- package/dist/mod/hooks/settings.js +13 -11
- package/dist/mod/hooks/settings.ts +13 -11
- package/dist/mod/hooks/ui/alerts.tsx +17 -4
- package/dist/mod/hooks/ui/art.ts +2 -2
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/kit.tsx +15 -2
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/hooks/workers.ts +8 -8
- package/dist/mod/tests/register.test.tsx +63 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +10 -1
- package/dist/router/src/admin.js +76 -5
- package/dist/router/src/agents.js +4 -0
- package/dist/router/src/config.js +24 -0
- package/dist/router/src/ingress/server.js +2 -2
- package/dist/router/src/providers/grok/auth.js +289 -0
- package/dist/router/src/providers/grok/catalog.js +52 -0
- package/dist/router/src/providers/grok/index.js +94 -0
- package/dist/router/src/providers/openai/index.js +69 -17
- package/dist/router/src/providers/openai/translate.js +6 -1
- package/dist/router/src/proxy.js +43 -0
- package/dist/router/src/redact.js +7 -2
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +115 -6
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +78 -0
- 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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1076
|
-
* only on its own next occasion (the next expiry, 15 more points of context,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
345
|
-
|
|
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
|
}
|
package/dist/mod/hooks/ui/art.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
}
|