clauderipple 0.9.2 → 0.9.4
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 +45 -0
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/i18n.ts +42 -0
- package/dist/mod/hooks/ladder.js +5 -5
- package/dist/mod/hooks/ladder.ts +5 -5
- package/dist/mod/hooks/register.tsx +28 -11
- package/dist/mod/hooks/session.ts +28 -2
- package/dist/mod/hooks/settings.js +13 -11
- package/dist/mod/hooks/settings.ts +13 -11
- package/dist/mod/hooks/ui/alerts.tsx +10 -0
- package/dist/mod/hooks/ui/art.ts +66 -3
- package/dist/mod/hooks/ui/band.tsx +70 -42
- package/dist/mod/hooks/ui/kit.tsx +49 -4
- package/dist/mod/hooks/ui/view.ts +5 -0
- package/dist/mod/hooks/workers.ts +8 -8
- package/dist/mod/tests/register.test.tsx +41 -3
- package/dist/mod/tests/ui.test.tsx +108 -18
- package/dist/mod/types/index.d.ts +7 -1
- package/dist/router/src/admin.js +69 -9
- package/dist/router/src/agents.js +5 -0
- package/dist/router/src/catalog.js +18 -8
- package/dist/router/src/index.js +13 -4
- package/dist/router/src/providers/chatgpt/index.js +8 -0
- package/dist/router/src/providers/retry.js +15 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +10 -6
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4 — 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Reasoning models are no longer cut off after five minutes of thinking.** Node's `fetch` dropped a
|
|
8
|
+
response that sent nothing for 300 seconds. Muse Spark 1.3 at xhigh thinks for minutes in silence
|
|
9
|
+
on a large context, so long steps failed with 529 at 303–305 seconds and started over. The router
|
|
10
|
+
now waits up to 15 minutes. OpenCode's own CLI had no such limit, which is why the same model
|
|
11
|
+
worked there.
|
|
12
|
+
- **OpenCode Go and Zen models get their real context window.** They fell back to the global
|
|
13
|
+
258,400, so Claude Code compacted DeepSeek V4.1 Flash, GLM-5.3 and Muse Spark 1.3 at a quarter of
|
|
14
|
+
their 1M windows. The router now reads the window from the same models.dev catalogue OpenCode's
|
|
15
|
+
CLI uses, and fills models already saved without one.
|
|
16
|
+
- **A ChatGPT account whose limit was reset or topped up is usable again right away.** It stayed
|
|
17
|
+
"cooling" until the old reset time, so the app could not switch to GPT models.
|
|
18
|
+
- **Adding an OpenAI-compatible provider such as B.ai works.** The connection check asked for one
|
|
19
|
+
token, which some vendors refuse ("max_tokens must be greater than 2", #57).
|
|
20
|
+
- **The plan limit shows on the band**, and every control's tip shows on hover even when the band
|
|
21
|
+
is full.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **The band:** Auto is a word that lights when on, instead of a switch. Compact and Clear (with a
|
|
26
|
+
confirm) sit beside settings. The limit draws as a small gauge and its percent.
|
|
27
|
+
- **Workers read and edit with the dedicated tools.** Generated worker agents are told to read files
|
|
28
|
+
with Read and change them with Edit, not with `sed`/`cat` through Bash.
|
|
29
|
+
- `/ripple debug` also shows the model, the limit and the host's rate limits.
|
|
30
|
+
|
|
31
|
+
## 0.9.3 — 2026-10-14
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Claude workers offer X-High.** Claude's effort ladder stopped at high and max, so X-High could
|
|
36
|
+
not be picked for Claude in the mod or the dashboard though GPT had it. Opus, Sonnet and Haiku 5.5
|
|
37
|
+
all take it (#56).
|
|
38
|
+
- **A model ticked after the mod's worker list was saved is usable.** It used to stay off and show
|
|
39
|
+
as unavailable in the mod (GPT-6 Astra in #56). A worker the list has not seen now starts on.
|
|
40
|
+
Settings saved before this keep the old reading until the list is changed once.
|
|
41
|
+
- **The plan limit shows in the band on Claude chats.** The desktop Code tab left the mod's rate
|
|
42
|
+
limits empty, so the limit never appeared even with it switched on. The mod now falls back to the
|
|
43
|
+
router's figure for the same Claude login, and hides it when that figure is over 15 minutes old.
|
|
44
|
+
- `/ripple-bar` no longer says the band is shown above the prompt when the layout is Minimal, where
|
|
45
|
+
only the short line under the prompt is drawn (#56).
|
|
46
|
+
- The close button lights as one disc under the pointer instead of drawing a second box around it.
|
|
47
|
+
|
|
3
48
|
## 0.9.2 — 2026-10-14
|
|
4
49
|
|
|
5
50
|
### Added
|
package/dist/app/package.json
CHANGED
package/dist/mod/hooks/i18n.ts
CHANGED
|
@@ -63,6 +63,9 @@ const EN = {
|
|
|
63
63
|
'band.warm': 'Warm',
|
|
64
64
|
'band.handoff': 'Handoff',
|
|
65
65
|
'band.compact': 'Compact',
|
|
66
|
+
'band.clear': 'Clear',
|
|
67
|
+
'band.compactAlt': 'Compact — summarise the conversation to free context',
|
|
68
|
+
'band.clearAlt': 'New start (/clear) — clear the conversation and start fresh',
|
|
66
69
|
'band.log': 'Log',
|
|
67
70
|
'band.agents': 'Agents',
|
|
68
71
|
'band.settings': 'Settings',
|
|
@@ -152,6 +155,9 @@ const EN = {
|
|
|
152
155
|
'alerts.demoDown': 'Jev out of credits (demo)',
|
|
153
156
|
'alerts.copied': 'The resume note was copied; this chat was kept.',
|
|
154
157
|
'alerts.compactFailed': 'Compaction did not finish.',
|
|
158
|
+
'alerts.clearTitle': 'Start this conversation over?',
|
|
159
|
+
'alerts.clearBody': 'The conversation\'s context is emptied; a fresh chat starts with nothing.',
|
|
160
|
+
'alerts.clearGo': 'Start over',
|
|
155
161
|
// Settings.
|
|
156
162
|
'settings.effortBlurb': 'How Auto picks the effort for each prompt you send.',
|
|
157
163
|
'settings.workers': 'Workers',
|
|
@@ -361,6 +367,7 @@ const EN = {
|
|
|
361
367
|
'commands.logOpened': 'ClaudeRipple log opened.',
|
|
362
368
|
'commands.logClosed': 'ClaudeRipple log closed.',
|
|
363
369
|
'commands.barShown': 'ClaudeRipple line shown above the prompt.',
|
|
370
|
+
'commands.barShownMinimal': 'The layout is Minimal, so ClaudeRipple shows as a short line under the prompt. For the full band above the prompt, set Layout to Dashboard in /ripple settings.',
|
|
364
371
|
'commands.barHidden': 'ClaudeRipple line hidden. /ripple-bar shows it again.',
|
|
365
372
|
'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requests; {total} weighted tokens',
|
|
366
373
|
'commands.statsTokens': 'Tokens: input {input}, write {write}, read {read}, output {output}',
|
|
@@ -512,6 +519,9 @@ const KO: Record<Key, string> = {
|
|
|
512
519
|
'band.warm': '캐시 유지',
|
|
513
520
|
'band.handoff': '인수인계',
|
|
514
521
|
'band.compact': '압축',
|
|
522
|
+
'band.clear': '새로 시작',
|
|
523
|
+
'band.compactAlt': '대화 압축 — 지금까지 대화를 요약해 컨텍스트를 줄입니다',
|
|
524
|
+
'band.clearAlt': '새로 시작 (/clear) — 대화 맥락을 비우고 새로 시작합니다',
|
|
515
525
|
'band.log': '로그',
|
|
516
526
|
'band.agents': '에이전트',
|
|
517
527
|
'band.settings': '설정',
|
|
@@ -601,6 +611,9 @@ const KO: Record<Key, string> = {
|
|
|
601
611
|
'alerts.demoDown': 'Jev 크레딧 소진 (데모)',
|
|
602
612
|
'alerts.copied': '재개용 요약 메모를 복사했습니다. 현재 대화는 보존됩니다.',
|
|
603
613
|
'alerts.compactFailed': '대화 압축을 마치지 못했습니다.',
|
|
614
|
+
'alerts.clearTitle': '대화를 새로 시작할까요?',
|
|
615
|
+
'alerts.clearBody': '지금 대화 맥락이 비워집니다. 새 대화가 아무것도 없는 상태에서 시작됩니다.',
|
|
616
|
+
'alerts.clearGo': '새로 시작',
|
|
604
617
|
// Settings.
|
|
605
618
|
'settings.effortBlurb': '전송하는 프롬프트마다 추론 강도를 자동으로 선택합니다.',
|
|
606
619
|
'settings.workers': '워커',
|
|
@@ -810,6 +823,7 @@ const KO: Record<Key, string> = {
|
|
|
810
823
|
'commands.logOpened': 'ClaudeRipple 로그를 열었습니다.',
|
|
811
824
|
'commands.logClosed': 'ClaudeRipple 로그를 닫았습니다.',
|
|
812
825
|
'commands.barShown': '프롬프트 상단에 ClaudeRipple 상태 바를 표시합니다.',
|
|
826
|
+
'commands.barShownMinimal': '레이아웃이 최소화로 되어 있어 ClaudeRipple은 프롬프트 아래 한 줄로만 표시됩니다. 프롬프트 위에 상태 바를 띄우려면 /ripple settings에서 레이아웃을 대시보드로 바꾸십시오.',
|
|
813
827
|
'commands.barHidden': 'ClaudeRipple 상태 바를 숨겼습니다. /ripple-bar로 다시 표시할 수 있습니다.',
|
|
814
828
|
'commands.statsAuto': '자동: 프롬프트 {prompts}개, 요청 {requests}개, 가중 토큰 {total}',
|
|
815
829
|
'commands.statsTokens': '토큰: 입력 {input}, 쓰기 {write}, 읽기 {read}, 출력 {output}',
|
|
@@ -959,6 +973,9 @@ const ZH: Record<Key, string> = {
|
|
|
959
973
|
'band.warm': '保温',
|
|
960
974
|
'band.handoff': '交接',
|
|
961
975
|
'band.compact': '压缩',
|
|
976
|
+
'band.clear': '新开',
|
|
977
|
+
'band.compactAlt': '压缩对话 — 总结对话以释放上下文',
|
|
978
|
+
'band.clearAlt': '重新开始 (/clear) — 清空对话上下文,重新开始',
|
|
962
979
|
'band.log': '日志',
|
|
963
980
|
'band.agents': '代理',
|
|
964
981
|
'band.settings': '设置',
|
|
@@ -1048,6 +1065,9 @@ const ZH: Record<Key, string> = {
|
|
|
1048
1065
|
'alerts.demoDown': 'Jev 额度用尽(演示)',
|
|
1049
1066
|
'alerts.copied': '已复制续接说明,此对话已保留。',
|
|
1050
1067
|
'alerts.compactFailed': '压缩未完成。',
|
|
1068
|
+
'alerts.clearTitle': '重新开始此对话?',
|
|
1069
|
+
'alerts.clearBody': '当前对话的上下文将被清空;新对话从空白开始。',
|
|
1070
|
+
'alerts.clearGo': '重新开始',
|
|
1051
1071
|
// Settings.
|
|
1052
1072
|
'settings.effortBlurb': '自动模式如何为你发送的每条提示词选择推理强度。',
|
|
1053
1073
|
'settings.workers': 'Worker',
|
|
@@ -1257,6 +1277,7 @@ const ZH: Record<Key, string> = {
|
|
|
1257
1277
|
'commands.logOpened': '已打开 ClaudeRipple 日志。',
|
|
1258
1278
|
'commands.logClosed': '已关闭 ClaudeRipple 日志。',
|
|
1259
1279
|
'commands.barShown': 'ClaudeRipple 状态栏已显示在提示词上方。',
|
|
1280
|
+
'commands.barShownMinimal': '布局是“极简”,所以 ClaudeRipple 只在提示词下方显示一行。要在提示词上方显示完整状态栏,请在 /ripple settings 里把布局改为“仪表盘”。',
|
|
1260
1281
|
'commands.barHidden': 'ClaudeRipple 状态栏已隐藏,/ripple-bar 可重新显示。',
|
|
1261
1282
|
'commands.statsAuto': '自动:{prompts} 条提示词,{requests} 次请求;加权 token {total}',
|
|
1262
1283
|
'commands.statsTokens': 'Token:输入 {input},写入 {write},读取 {read},输出 {output}',
|
|
@@ -1406,6 +1427,9 @@ const JA: Record<Key, string> = {
|
|
|
1406
1427
|
'band.warm': '保持',
|
|
1407
1428
|
'band.handoff': '引き継ぎ',
|
|
1408
1429
|
'band.compact': '圧縮',
|
|
1430
|
+
'band.clear': '新しく開始',
|
|
1431
|
+
'band.compactAlt': '圧縮 — これまでの会話を要約してコンテキストを減らします',
|
|
1432
|
+
'band.clearAlt': '新しく開始 (/clear) — 会話の文脈を空にして新しく始めます',
|
|
1409
1433
|
'band.log': 'ログ',
|
|
1410
1434
|
'band.agents': 'エージェント',
|
|
1411
1435
|
'band.settings': '設定',
|
|
@@ -1495,6 +1519,9 @@ const JA: Record<Key, string> = {
|
|
|
1495
1519
|
'alerts.demoDown': 'Jev のクレジット切れ(デモ)',
|
|
1496
1520
|
'alerts.copied': '再開メモをコピーしました。この会話はそのままです。',
|
|
1497
1521
|
'alerts.compactFailed': '圧縮が完了しませんでした。',
|
|
1522
|
+
'alerts.clearTitle': 'この会話を新しく始めますか?',
|
|
1523
|
+
'alerts.clearBody': '今の会話のコンテキストは空になります。新しい会話は何もない状態から始まります。',
|
|
1524
|
+
'alerts.clearGo': '新しく開始',
|
|
1498
1525
|
// Settings.
|
|
1499
1526
|
'settings.effortBlurb': '送信するプロンプトごとに、自動が推論強度を選ぶ方法。',
|
|
1500
1527
|
'settings.workers': 'ワーカー',
|
|
@@ -1704,6 +1731,7 @@ const JA: Record<Key, string> = {
|
|
|
1704
1731
|
'commands.logOpened': 'ClaudeRipple のログを開きました。',
|
|
1705
1732
|
'commands.logClosed': 'ClaudeRipple のログを閉じました。',
|
|
1706
1733
|
'commands.barShown': 'ClaudeRipple のバーをプロンプトの上に表示しました。',
|
|
1734
|
+
'commands.barShownMinimal': 'レイアウトがミニマルなので、ClaudeRipple はプロンプトの下に 1 行で表示されます。プロンプトの上にバーを出すには、/ripple settings でレイアウトをダッシュボードにしてください。',
|
|
1707
1735
|
'commands.barHidden': 'ClaudeRipple のバーを非表示にしました。/ripple-bar で再表示できます。',
|
|
1708
1736
|
'commands.statsAuto': '自動: プロンプト {prompts} 件、リクエスト {requests} 件、加重トークン {total}',
|
|
1709
1737
|
'commands.statsTokens': 'トークン: 入力 {input}、書き込み {write}、読み取り {read}、出力 {output}',
|
|
@@ -1853,6 +1881,9 @@ const ES: Record<Key, string> = {
|
|
|
1853
1881
|
'band.warm': 'Mantener',
|
|
1854
1882
|
'band.handoff': 'Traspaso',
|
|
1855
1883
|
'band.compact': 'Compactar',
|
|
1884
|
+
'band.clear': 'Reiniciar',
|
|
1885
|
+
'band.compactAlt': 'Compactar — resume la conversación para liberar contexto',
|
|
1886
|
+
'band.clearAlt': 'Empezar de nuevo (/clear) — vacía la conversación y empieza desde cero',
|
|
1856
1887
|
'band.log': 'Registro',
|
|
1857
1888
|
'band.agents': 'Agentes',
|
|
1858
1889
|
'band.settings': 'Ajustes',
|
|
@@ -1942,6 +1973,9 @@ const ES: Record<Key, string> = {
|
|
|
1942
1973
|
'alerts.demoDown': 'Jev sin créditos (demo)',
|
|
1943
1974
|
'alerts.copied': 'Se copió la nota para continuar; esta conversación se conservó.',
|
|
1944
1975
|
'alerts.compactFailed': 'La compactación no terminó.',
|
|
1976
|
+
'alerts.clearTitle': '¿Empezar esta conversación de nuevo?',
|
|
1977
|
+
'alerts.clearBody': 'El contexto de esta conversación se vacía; una conversación nueva empieza de cero.',
|
|
1978
|
+
'alerts.clearGo': 'Empezar de nuevo',
|
|
1945
1979
|
// Settings.
|
|
1946
1980
|
'settings.effortBlurb': 'Cómo Auto elige el esfuerzo de cada prompt que envías.',
|
|
1947
1981
|
'settings.workers': 'Workers',
|
|
@@ -2151,6 +2185,7 @@ const ES: Record<Key, string> = {
|
|
|
2151
2185
|
'commands.logOpened': 'Registro de ClaudeRipple abierto.',
|
|
2152
2186
|
'commands.logClosed': 'Registro de ClaudeRipple cerrado.',
|
|
2153
2187
|
'commands.barShown': 'Barra de ClaudeRipple visible sobre el prompt.',
|
|
2188
|
+
'commands.barShownMinimal': 'La disposición es Mínimo, así que ClaudeRipple aparece como una línea corta bajo el prompt. Para la barra completa sobre el prompt, elige Panel en Disposición en /ripple settings.',
|
|
2154
2189
|
'commands.barHidden': 'Barra de ClaudeRipple oculta. /ripple-bar la vuelve a mostrar.',
|
|
2155
2190
|
'commands.statsAuto': 'Auto: {prompts} prompts, {requests} solicitudes; {total} tokens ponderados',
|
|
2156
2191
|
'commands.statsTokens': 'Tokens: entrada {input}, escritura {write}, lectura {read}, salida {output}',
|
|
@@ -2300,6 +2335,9 @@ const PT: Record<Key, string> = {
|
|
|
2300
2335
|
'band.warm': 'Manter',
|
|
2301
2336
|
'band.handoff': 'Repasse',
|
|
2302
2337
|
'band.compact': 'Compactar',
|
|
2338
|
+
'band.clear': 'Recomeçar',
|
|
2339
|
+
'band.compactAlt': 'Compactar — resume a conversa para liberar contexto',
|
|
2340
|
+
'band.clearAlt': 'Começar de novo (/clear) — limpa a conversa e começa do zero',
|
|
2303
2341
|
'band.log': 'Log',
|
|
2304
2342
|
'band.agents': 'Agentes',
|
|
2305
2343
|
'band.settings': 'Ajustes',
|
|
@@ -2389,6 +2427,9 @@ const PT: Record<Key, string> = {
|
|
|
2389
2427
|
'alerts.demoDown': 'Jev sem créditos (demo)',
|
|
2390
2428
|
'alerts.copied': 'A nota para continuar foi copiada; esta conversa foi mantida.',
|
|
2391
2429
|
'alerts.compactFailed': 'A compactação não terminou.',
|
|
2430
|
+
'alerts.clearTitle': 'Começar esta conversa de novo?',
|
|
2431
|
+
'alerts.clearBody': 'O contexto desta conversa é esvaziado; uma conversa nova começa do zero.',
|
|
2432
|
+
'alerts.clearGo': 'Começar de novo',
|
|
2392
2433
|
// Settings.
|
|
2393
2434
|
'settings.effortBlurb': 'Como o Auto escolhe o esforço de cada prompt que você envia.',
|
|
2394
2435
|
'settings.workers': 'Workers',
|
|
@@ -2598,6 +2639,7 @@ const PT: Record<Key, string> = {
|
|
|
2598
2639
|
'commands.logOpened': 'Log do ClaudeRipple aberto.',
|
|
2599
2640
|
'commands.logClosed': 'Log do ClaudeRipple fechado.',
|
|
2600
2641
|
'commands.barShown': 'Barra do ClaudeRipple visível acima do prompt.',
|
|
2642
|
+
'commands.barShownMinimal': 'O layout está em Mínimo, então o ClaudeRipple aparece como uma linha curta abaixo do prompt. Para a barra completa acima do prompt, escolha Painel em Layout no /ripple settings.',
|
|
2601
2643
|
'commands.barHidden': 'Barra do ClaudeRipple oculta. /ripple-bar mostra de novo.',
|
|
2602
2644
|
'commands.statsAuto': 'Auto: {prompts} prompts, {requests} requisições; {total} tokens ponderados',
|
|
2603
2645
|
'commands.statsTokens': 'Tokens: entrada {input}, gravação {write}, leitura {read}, saída {output}',
|
package/dist/mod/hooks/ladder.js
CHANGED
|
@@ -150,9 +150,9 @@ export function preset(id, choices) {
|
|
|
150
150
|
}
|
|
151
151
|
return { rungs, substituted };
|
|
152
152
|
}
|
|
153
|
-
/** The choices a person ticked; an empty list ticks every one. */
|
|
154
|
-
export function tickedChoices(choices, allow) {
|
|
155
|
-
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow));
|
|
153
|
+
/** The choices a person ticked; an empty list ticks every one, and a key absent from `known` ticks on too. */
|
|
154
|
+
export function tickedChoices(choices, allow, known = []) {
|
|
155
|
+
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow, known));
|
|
156
156
|
}
|
|
157
157
|
/** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
|
|
158
158
|
export function ladderNeedsFill(rungs, _allow = []) {
|
|
@@ -162,8 +162,8 @@ export function ladderNeedsFill(rungs, _allow = []) {
|
|
|
162
162
|
* Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
|
|
163
163
|
* for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
|
|
164
164
|
*/
|
|
165
|
-
export function fillLadder(rungs, allow, choices) {
|
|
166
|
-
const pick = preset('claudeGpt', tickedChoices(choices, allow));
|
|
165
|
+
export function fillLadder(rungs, allow, choices, known = []) {
|
|
166
|
+
const pick = preset('claudeGpt', tickedChoices(choices, allow, known));
|
|
167
167
|
const out = {};
|
|
168
168
|
const filled = [];
|
|
169
169
|
for (const rung of RUNGS) {
|
package/dist/mod/hooks/ladder.ts
CHANGED
|
@@ -146,9 +146,9 @@ export function preset(id: PresetId, choices: readonly LadderChoice[]): Preset {
|
|
|
146
146
|
return { rungs, substituted }
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/** The choices a person ticked; an empty list ticks every one. */
|
|
150
|
-
export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[]): LadderChoice[] {
|
|
151
|
-
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow))
|
|
149
|
+
/** The choices a person ticked; an empty list ticks every one, and a key absent from `known` ticks on too. */
|
|
150
|
+
export function tickedChoices(choices: readonly LadderChoice[], allow: readonly string[], known: readonly string[] = []): LadderChoice[] {
|
|
151
|
+
return choices.filter(choice => isAllowed(candidateKey(choice.target), choice.provider, allow, known))
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/** Only empty lists need filling; unticked entries stay in order and are skipped at dispatch. */
|
|
@@ -160,8 +160,8 @@ export function ladderNeedsFill(rungs: Rungs, _allow: readonly string[] = []): b
|
|
|
160
160
|
* Fill empty lists from the ticked, connected choices. Existing ordered lists are never rewritten
|
|
161
161
|
* for temporary availability or shared allow-list changes; dispatch explains each skipped entry.
|
|
162
162
|
*/
|
|
163
|
-
export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[]): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
|
|
164
|
-
const pick = preset('claudeGpt', tickedChoices(choices, allow))
|
|
163
|
+
export function fillLadder(rungs: Rungs, allow: readonly string[], choices: readonly LadderChoice[], known: readonly string[] = []): { rungs: Record<Rung, Step[]>; filled: Rung[] } {
|
|
164
|
+
const pick = preset('claudeGpt', tickedChoices(choices, allow, known))
|
|
165
165
|
const out = {} as Record<Rung, Step[]>
|
|
166
166
|
const filled: Rung[] = []
|
|
167
167
|
for (const rung of RUNGS) {
|
|
@@ -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, keptAlert, 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'
|
|
@@ -442,7 +442,7 @@ async function refreshLadder($: EngineInterface): Promise<void> {
|
|
|
442
442
|
try {
|
|
443
443
|
const response = await $.http.fetch(`${await adminUrl($)}/api/mod-options`)
|
|
444
444
|
if (!response.ok) throw new Error(t(await read($, lang), 'commands.routerHttp', { status: response.status }))
|
|
445
|
-
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)
|
|
446
446
|
if (!same(await read($, ladderOptions), fresh)) await update($, ladderOptions, () => fresh)
|
|
447
447
|
} catch (error) { await report($, 'ladder options', error) }
|
|
448
448
|
}
|
|
@@ -692,7 +692,7 @@ async function requestPick($: EngineInterface, e: AgentSpawnInput, s: Settings):
|
|
|
692
692
|
const routing = routingSettings(s)
|
|
693
693
|
const resolved = explicitCandidate && candidateUsable(explicitCandidate, s, usable, providers)
|
|
694
694
|
? { target: target!, effort: verdict.effort }
|
|
695
|
-
: 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)
|
|
696
696
|
const selected = resolved.target
|
|
697
697
|
if (!selected) return { deny: t(language, 'workers.none') }
|
|
698
698
|
const fallbackReason = resolved.fallbackReason
|
|
@@ -931,6 +931,16 @@ async function openCompact($: EngineInterface, surface: Frame['surface']): Promi
|
|
|
931
931
|
await update($, compactNote, () => '')
|
|
932
932
|
await update($, panel, () => 'compact')
|
|
933
933
|
}
|
|
934
|
+
async function openClear($: EngineInterface): Promise<void> {
|
|
935
|
+
await update($, panel, () => 'clear')
|
|
936
|
+
}
|
|
937
|
+
/** The router's figure for this login's plan windows; nothing when the router cannot say. */
|
|
938
|
+
async function routerClaudeLimits($: EngineInterface): Promise<SessionRateLimit[]> {
|
|
939
|
+
try {
|
|
940
|
+
const status = await $.http.fetch(`${await adminUrl($)}/api/status`)
|
|
941
|
+
return status.ok ? claudeLimitsFrom(JSON.parse(status.text), await $.clock.now()) : []
|
|
942
|
+
} catch (error) { await report($, 'claude limits', error); return [] }
|
|
943
|
+
}
|
|
934
944
|
async function refreshUsage($: EngineInterface): Promise<void> {
|
|
935
945
|
const usage = await $.session.usage()
|
|
936
946
|
const c = usage.context
|
|
@@ -940,11 +950,15 @@ async function refreshUsage($: EngineInterface): Promise<void> {
|
|
|
940
950
|
// Below the line again (a compact, a clear), the next crossing shows the heavy-context band once.
|
|
941
951
|
const putOff = await read($, swampHidden)
|
|
942
952
|
if (putOff !== null && (putOff > 100 || !ctx || ctx.percent < (await currentSettings($)).swampAt)) await update($, swampHidden, () => null)
|
|
943
|
-
|
|
953
|
+
// The host can leave the plan windows out though the replies carried them; on a Claude chat the
|
|
954
|
+
// router's figure for the same login stands in.
|
|
955
|
+
const own = usage.rateLimits.some(l => ['five_hour', 'seven_day'].includes(l.kind))
|
|
956
|
+
const rateLimits = own || !/^claude-/.test(await $.session.model()) ? usage.rateLimits : [...usage.rateLimits, ...await routerClaudeLimits($)]
|
|
957
|
+
const h = hottest(rateLimits)
|
|
944
958
|
const previous = await read($, hot)
|
|
945
959
|
if (previous && (!h || previous.kind !== h.kind || previous.resetsAt !== h.resetsAt)) await update($, hotHidden, () => null)
|
|
946
960
|
if (JSON.stringify(h) !== JSON.stringify(previous)) await update($, hot, () => h)
|
|
947
|
-
const top =
|
|
961
|
+
const top = rateLimits.filter(l => ['five_hour', 'seven_day'].includes(l.kind)).sort((a, b) => b.percentUsed - a.percentUsed)[0]
|
|
948
962
|
const l = top ? { kind: top.kind, percent: top.percentUsed, ...(top.resetsAt ? { resetsAt: top.resetsAt } : {}) } : null
|
|
949
963
|
// Check the previous binding deadline before a rolled-forward snapshot replaces it.
|
|
950
964
|
await endModeIfOver($)
|
|
@@ -1079,6 +1093,7 @@ async function alertView($: EngineInterface, now: number): Promise<AlertView | n
|
|
|
1079
1093
|
kept: d === 'kept' || keptAlert(await read($, kept), m, now),
|
|
1080
1094
|
swamp: d === 'swamp' || (s.swampAlert && !await read($, busy) && swampAlert(swamped(c, s.swampAt), await read($, swampHidden))) })
|
|
1081
1095
|
if (which === 'compact') return { kind: 'compactNote', draft: await read($, compactNote) }
|
|
1096
|
+
if (which === 'clear') return { kind: 'clear' }
|
|
1082
1097
|
if (which === 'handoff') return { kind: 'handoffChoice', handoffKind: await read($, handoffChoiceKind), after: await read($, handoffChoiceAfter) }
|
|
1083
1098
|
if (which === 'judgeDown') return { kind: 'judgeDown', judge: s.judge, reason: d === 'down' ? t(language, 'alerts.demoDown') : downReason!, network: d !== 'down' && await read($, judgeDownNetwork) }
|
|
1084
1099
|
if (which === 'hot') return { kind: 'hot', limitKind: h?.kind ?? 'five_hour', percent: d === 'hot' ? 86 : h!.percent, resetsAt: h?.resetsAt, save: (await read($, saveUntil) ?? 0) > now }
|
|
@@ -1116,7 +1131,7 @@ async function dismiss($: EngineInterface, view: AlertView): Promise<void> {
|
|
|
1116
1131
|
else await update($, panel, () => null)
|
|
1117
1132
|
}
|
|
1118
1133
|
function bandActions($: EngineInterface, surface: Frame['surface']): BandActions {
|
|
1119
|
-
return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
|
|
1134
|
+
return { toggleAuto: () => toggleAuto($), toggleWarm: () => toggleWarm($), compact: () => openCompact($, surface), clear: () => openClear($), handoff: () => openHandoff($), toggleLog: () => toggleLog($), toggleAgents: () => toggleAgents($), openSettings: () => startPanel($, 'settings'),
|
|
1120
1135
|
toggleEffortMenu: () => update($, effortMenuOpen, value => !value), pickEffort: effort => manualPick($, effort) }
|
|
1121
1136
|
}
|
|
1122
1137
|
function settingsActions($: EngineInterface): SettingsActions {
|
|
@@ -1136,7 +1151,7 @@ function setupActions($: EngineInterface): SetupActions {
|
|
|
1136
1151
|
back: () => update($, setupStep, step => Math.max(1, step - 1) as 1 | 2 | 3 | 4), next: () => update($, setupStep, step => Math.min(4, step + 1) as 1 | 2 | 3 | 4), finish: () => finishSetup($), skip: () => finishSetup($, true) }
|
|
1137
1152
|
}
|
|
1138
1153
|
function alertActions($: EngineInterface, view: AlertView): AlertActions {
|
|
1139
|
-
return { compact: note => runCompact($, note), handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
|
|
1154
|
+
return { compact: note => runCompact($, note), clear: async () => { await update($, panel, () => null); await $.command.run({ command: 'clear', args: '' }) }, handoff: () => openHandoff($), dismiss: () => dismiss($, view), toggleSave: () => toggleSave($), openSettings: () => startPanel($, 'settings'),
|
|
1140
1155
|
noteInput: text => update($, compactNote, () => text), setHandoffKind: kind => update($, handoffChoiceKind, () => kind), setHandoffAfter: after => update($, handoffChoiceAfter, () => after), go: () => queueHandoff($), copy: () => copyHandoff($), openNew: () => moveToNewSession($) }
|
|
1141
1156
|
}
|
|
1142
1157
|
function toolLine(e: ToolCallInput): string {
|
|
@@ -1446,7 +1461,7 @@ export const register: Register = on => {
|
|
|
1446
1461
|
const language = await read($, lang)
|
|
1447
1462
|
const [sub = 'settings', arg] = e.args.trim().toLowerCase().split(/\s+/)
|
|
1448
1463
|
if (sub === 'stats') return { text: formatStats(await read($, stats), language) }
|
|
1449
|
-
if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest) }) }
|
|
1464
|
+
if (sub === 'debug') return { text: debugText(language, { judge: (await currentSettings($)).judge, judgeModel: (await currentSettings($)).judgeModel, pick: await read($, pick), paused: await read($, paused), cache: await read($, cache), warm: await read($, warm), lastSpawn: await read($, lastSpawn), lastRequest: await read($, lastRequest), model: await $.session.model(), limit: await read($, limit), hostRateLimits: (await $.session.usage()).rateLimits }) }
|
|
1450
1465
|
if (sub === 'settings' || sub === 'setup') await startPanel($, sub)
|
|
1451
1466
|
else if (sub === 'auto') await toggleAuto($)
|
|
1452
1467
|
else if (sub === 'warm') await toggleWarm($)
|
|
@@ -1469,7 +1484,9 @@ export const register: Register = on => {
|
|
|
1469
1484
|
on('command.run', { command: 'ripple-bar' }, async ($, e, next) => {
|
|
1470
1485
|
try {
|
|
1471
1486
|
const language = await read($, lang)
|
|
1472
|
-
|
|
1487
|
+
if (!await toggleBar($)) return { text: t(language, 'commands.barHidden') }
|
|
1488
|
+
// The minimal layout draws no band above the prompt, only the short line under it (#56).
|
|
1489
|
+
return { text: t(language, (await currentSettings($)).layout === 'minimal' ? 'commands.barShownMinimal' : 'commands.barShown') } }
|
|
1473
1490
|
catch (error) { await report($, 'ripple-bar', error); return next(e) }
|
|
1474
1491
|
})
|
|
1475
1492
|
on('ui.close', { id: LOG_PANE }, async ($, e, next) => {
|
|
@@ -1489,7 +1506,7 @@ export const register: Register = on => {
|
|
|
1489
1506
|
const frame = await frameOf($, e)
|
|
1490
1507
|
const el = $.ui.resolve(e)
|
|
1491
1508
|
if (which === 'settings') return renderSettings(el, frame, await settingsView($), settingsActions($)) as RenderElement
|
|
1492
|
-
if (which === 'compact' || which === 'handoff') {
|
|
1509
|
+
if (which === 'compact' || which === 'clear' || which === 'handoff') {
|
|
1493
1510
|
const choice = await alertView($, frame.now)
|
|
1494
1511
|
if (choice) return renderAlert(el, frame, choice, alertActions($, choice)) as RenderElement
|
|
1495
1512
|
}
|
|
@@ -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.
|
|
@@ -61,9 +87,9 @@ export function swampAlert(percent: number | null, hidden: number | null): boole
|
|
|
61
87
|
|
|
62
88
|
/** Rendering consumes eligibility, keeping dismissal and idle decisions outside drawing code. */
|
|
63
89
|
export function bandPriority(state: {
|
|
64
|
-
panel?: 'settings' | 'compact' | 'handoff' | 'setup' | null
|
|
90
|
+
panel?: 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | null
|
|
65
91
|
judgeDown?: boolean; hot?: boolean; result?: boolean; cold?: boolean; swamp?: boolean; kept?: boolean
|
|
66
|
-
}): 'settings' | 'compact' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
|
|
92
|
+
}): 'settings' | 'compact' | 'clear' | 'handoff' | 'setup' | 'judgeDown' | 'hot' | 'result' | 'cold' | 'swamp' | 'kept' | 'dashboard' {
|
|
67
93
|
if (state.panel) return state.panel
|
|
68
94
|
for (const band of ['judgeDown', 'hot', 'result', 'cold', 'swamp', 'kept'] as const) if (state[band]) return band
|
|
69
95
|
return 'dashboard'
|
|
@@ -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)))]
|
|
@@ -114,6 +114,15 @@ function cardOf(frame: Frame, alert: AlertView, a: AlertActions): Card {
|
|
|
114
114
|
main: { ...compact, onPress: () => a.compact(alert.draft) },
|
|
115
115
|
close: cancel,
|
|
116
116
|
}
|
|
117
|
+
case 'clear':
|
|
118
|
+
return {
|
|
119
|
+
key: 'clear',
|
|
120
|
+
face: 'asking',
|
|
121
|
+
title: t(L, 'alerts.clearTitle'),
|
|
122
|
+
body: t(L, 'alerts.clearBody'),
|
|
123
|
+
main: { key: 'clear-go', label: t(L, 'alerts.clearGo'), hotkey: 'g', onPress: a.clear },
|
|
124
|
+
close: cancel,
|
|
125
|
+
}
|
|
117
126
|
case 'handoffChoice':
|
|
118
127
|
return {
|
|
119
128
|
key: 'handoff',
|
|
@@ -222,6 +231,7 @@ function seaOf(card: Card, alert: AlertView): Omit<Sea, 'at'> {
|
|
|
222
231
|
case 'judge':
|
|
223
232
|
return { tone: 'red', weather: 'static' }
|
|
224
233
|
case 'note':
|
|
234
|
+
case 'clear':
|
|
225
235
|
case 'handoff':
|
|
226
236
|
return { tone: 'violet', weather: 'sparkles', ripples: 'calm' }
|
|
227
237
|
default: {
|