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
|
@@ -143,7 +143,7 @@ test('view helpers', () => {
|
|
|
143
143
|
test('the band draws on both surfaces and its controls reach their callbacks', async ($, on) => {
|
|
144
144
|
const { calls, fn } = recorder()
|
|
145
145
|
const actions = {
|
|
146
|
-
toggleAuto: fn('auto'), toggleWarm: fn(
|
|
146
|
+
toggleAuto: fn('auto'), toggleWarm: fn("warm"), compact: fn("compact"), clear: fn("clear"), handoff: fn("handoff"), toggleLog: fn('log'),
|
|
147
147
|
toggleAgents: fn('agents'), openSettings: fn('settings'), toggleEffortMenu: fn('menu'), pickEffort: fn('pick'),
|
|
148
148
|
}
|
|
149
149
|
drawPane(on, 'band', (el, surface) => renderBand(el, frameOf(surface), DASH, actions))
|
|
@@ -152,10 +152,11 @@ test('the band draws on both surfaces and its controls reach their callbacks', a
|
|
|
152
152
|
)
|
|
153
153
|
for (const surface of SURFACES) {
|
|
154
154
|
const ui = await mount($, 'band', surface)
|
|
155
|
-
// Compact
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
155
|
+
// Compact and clear are the desktop's icons; the terminal has no Log (the route is its own press)…
|
|
156
|
+
const keys = surface === 'terminal' ? ['auto', 'warm', 'log', 'agents', 'settings'] : ['auto', 'warm', 'agents', 'compact', 'clear', 'settings']
|
|
157
|
+
// …but no Compact clear card here: the desktop's Compact opens the note bar from the band.
|
|
158
|
+
if (surface === 'terminal') expect(await ui.find({ key: 'compact' })).toBeUndefined()
|
|
159
|
+
for (const key of keys) {
|
|
159
160
|
expect(await ui.find({ type: 'Button', key }), "ui { type: 'Button', key } " + surface).toBeDefined()
|
|
160
161
|
await ui.press({ key })
|
|
161
162
|
}
|
|
@@ -185,7 +186,6 @@ test('the band draws on both surfaces and its controls reach their callbacks', a
|
|
|
185
186
|
|
|
186
187
|
const busy = await mount($, 'band-busy', surface)
|
|
187
188
|
expect(await busy.find({ key: 'handoff' }), "busy { key: 'handoff' } " + surface).toBeDefined()
|
|
188
|
-
expect(await busy.find({ key: 'compact' })).toBeUndefined()
|
|
189
189
|
await busy.press({ key: 'handoff' })
|
|
190
190
|
await busy.press({ key: 'pick-low' })
|
|
191
191
|
if (surface === 'desktop') {
|
|
@@ -200,6 +200,8 @@ test('the band draws on both surfaces and its controls reach their callbacks', a
|
|
|
200
200
|
}
|
|
201
201
|
expect(calls.filter(c => c === 'auto').length).toBe(2)
|
|
202
202
|
expect(calls.filter(c => c === 'log').length).toBe(2)
|
|
203
|
+
expect(calls.filter(c => c === 'compact').length).toBe(1)
|
|
204
|
+
expect(calls.filter(c => c === 'clear').length).toBe(1)
|
|
203
205
|
expect(calls.filter(c => c === 'menu').length).toBe(1)
|
|
204
206
|
expect(calls.filter(c => c === 'pick:["low"]').length).toBe(2)
|
|
205
207
|
expect(calls.filter(c => c === 'handoff').length).toBe(2)
|
|
@@ -207,7 +209,7 @@ test('the band draws on both surfaces and its controls reach their callbacks', a
|
|
|
207
209
|
|
|
208
210
|
test('the band draws the same markup for the same view', async ($, on) => {
|
|
209
211
|
const noop = () => undefined
|
|
210
|
-
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
212
|
+
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, clear: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
211
213
|
drawPane(on, 'band', (el, surface) => renderBand(el, frameOf(surface), { ...DASH, effort: { level: 'medium', mode: 'deciding', since: NOW - 300 } }, actions))
|
|
212
214
|
const once = await mount($, 'band', 'desktop')
|
|
213
215
|
const first = JSON.stringify(await once.drawn())
|
|
@@ -218,7 +220,7 @@ test('the band draws the same markup for the same view', async ($, on) => {
|
|
|
218
220
|
|
|
219
221
|
test('the terminal band fits its rows at narrow widths', async ($, on) => {
|
|
220
222
|
const noop = () => undefined
|
|
221
|
-
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
223
|
+
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, clear: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
222
224
|
const busy: DashboardView = { ...DASH, warm: { on: true, pings: 3, cost: 0 }, limit: { kind: 'seven_day', percent: 91, save: false } }
|
|
223
225
|
drawPane(on, 'wide', el => renderBand(el, frameOf('terminal', 200, 3), busy, actions))
|
|
224
226
|
drawPane(on, 'narrow', el => renderBand(el, frameOf('terminal', 60, 4), busy, actions))
|
|
@@ -242,7 +244,7 @@ test('the minimal footer draws on both surfaces', async ($, on) => {
|
|
|
242
244
|
test('a ko frame resolves temporary Korean text in labels and hover cards', async ($, on) => {
|
|
243
245
|
const original = { ...STRINGS.ko }
|
|
244
246
|
const noop = () => undefined
|
|
245
|
-
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
247
|
+
const actions = { toggleAuto: noop, toggleWarm: noop, compact: noop, clear: noop, handoff: noop, toggleLog: noop, toggleAgents: noop, openSettings: noop, toggleEffortMenu: noop, pickEffort: noop }
|
|
246
248
|
try {
|
|
247
249
|
STRINGS.ko['band.auto'] = '자동'
|
|
248
250
|
STRINGS.ko['band.high'] = '높음'
|
|
@@ -280,7 +282,7 @@ const ALERTS: AlertView[] = [
|
|
|
280
282
|
|
|
281
283
|
test('every alert draws on both surfaces', async ($, on) => {
|
|
282
284
|
const noop = () => undefined
|
|
283
|
-
const actions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
285
|
+
const actions = { compact: noop, clear: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
284
286
|
ALERTS.forEach((alert, i) => drawPane(on, 'alert-' + i, (el, surface) => renderAlert(el, frameOf(surface), alert, actions)))
|
|
285
287
|
for (const surface of SURFACES) {
|
|
286
288
|
for (let i = 0; i < ALERTS.length; i++) {
|
|
@@ -294,7 +296,7 @@ test('every alert draws on both surfaces', async ($, on) => {
|
|
|
294
296
|
test('alert choices reach their callbacks', async ($, on) => {
|
|
295
297
|
const { calls, fn } = recorder()
|
|
296
298
|
const actions = {
|
|
297
|
-
compact: fn(
|
|
299
|
+
compact: fn("compact"), clear: fn("clear"), handoff: fn("handoff"), dismiss: fn("dismiss"), toggleSave: fn('save'), openSettings: fn('settings'), noteInput: fn('note'),
|
|
298
300
|
setHandoffKind: fn('kind'), setHandoffAfter: fn('after'), go: fn('go'), copy: fn('copy'), openNew: fn('open-new'),
|
|
299
301
|
}
|
|
300
302
|
drawPane(on, 'cold', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[0]!, actions))
|
|
@@ -333,7 +335,7 @@ function mascots(tree: unknown): Drawn[] {
|
|
|
333
335
|
|
|
334
336
|
test('each alert shows 리플이 with the face of its state, its gauge in 5% steps; the terminal keeps a text mark', async ($, on) => {
|
|
335
337
|
const noop = () => undefined
|
|
336
|
-
const actions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
338
|
+
const actions = { compact: noop, clear: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
337
339
|
const variants: [string, AlertView][] = [
|
|
338
340
|
['swamp-59', { kind: 'swamp', percent: 59, tokens: 118_000 }],
|
|
339
341
|
['swamp-61', { kind: 'swamp', percent: 61, tokens: 122_000 }],
|
|
@@ -624,7 +626,7 @@ test('every picture is a plain image, 리플이 the one face, and every blank pr
|
|
|
624
626
|
// over it (kit.tsx), never from a blank button over nothing.
|
|
625
627
|
const noop = () => undefined
|
|
626
628
|
const setupActions = { patch: noop, jevKey: noop, testJev: noop, openAllSettings: noop, back: noop, next: noop, finish: noop, skip: noop }
|
|
627
|
-
const alertActions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
629
|
+
const alertActions = { compact: noop, clear: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
628
630
|
const cases: [string, Draw][] = [
|
|
629
631
|
['one-quick', (el, surface) => renderSettings(el, frameOf(surface, 64, 30), { settings: { ...SETTINGS, jevSource: 'typesafe' }, jev: { source: null, test: { state: 'idle' } } }, QUICK_ACTIONS)],
|
|
630
632
|
...([1, 2, 3, 4] as const).map((step): [string, Draw] => ['one-setup-' + step, (el, surface) => renderSetup(el, frameOf(surface, 64, 30), { step, draft: SETTINGS, jev: { source: null, test: { state: 'idle' } } }, setupActions)]),
|
|
@@ -644,7 +646,7 @@ test('every picture is a plain image, 리플이 the one face, and every blank pr
|
|
|
644
646
|
|
|
645
647
|
test('a card says one thing: at most one main action, the others quiet, one way to let it go', async ($, on) => {
|
|
646
648
|
const noop = () => undefined
|
|
647
|
-
const alertActions = { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
649
|
+
const alertActions = { compact: noop, clear: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop }
|
|
648
650
|
const all: AlertView[] = [...ALERTS, { kind: 'hot', limitKind: 'five_hour', percent: 97, save: true }]
|
|
649
651
|
all.forEach((alert, i) => drawPane(on, 'say-' + i, (el, surface) => renderAlert(el, frameOf(surface), alert, alertActions)))
|
|
650
652
|
for (let i = 0; i < all.length; i++) {
|
|
@@ -681,7 +683,7 @@ test('no drawing cuts its text: no truncating Text on any surface', async ($, on
|
|
|
681
683
|
['cut-agents', (el, surface) => renderAgents(el, frameOf(surface, 40), { agents: AGENTS.map(a => ({ ...a, description: a.description + ' and a much longer tail of words that runs past any pane width', state: 'running' as const })), now: NOW, showDone: true }, { toggleDone: noop })],
|
|
682
684
|
['cut-agents-done', (el, surface) => renderAgents(el, frameOf(surface, 40), { agents: AGENTS, now: NOW, showDone: true }, { toggleDone: noop })],
|
|
683
685
|
['cut-log', (el, surface) => renderLog(el, frameOf(surface, 40), { rows: [{ at: '2026-10-10T13:19:10', source: 'a-very-long-requested-model-name@high', target: 'another-very-long-routed-model-name', provider: 'opencode-go', status: 200, ok: true, ms: 2140 }], mineOnly: false }, { toggleScope: noop })],
|
|
684
|
-
...ALERTS.map((alert, i): [string, Draw] => ['cut-alert-' + i, (el, surface) => renderAlert(el, frameOf(surface, 40), alert, { compact: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop })]),
|
|
686
|
+
...ALERTS.map((alert, i): [string, Draw] => ['cut-alert-' + i, (el, surface) => renderAlert(el, frameOf(surface, 40), alert, { compact: noop, clear: noop, handoff: noop, dismiss: noop, toggleSave: noop, openSettings: noop, noteInput: noop, setHandoffKind: noop, setHandoffAfter: noop, go: noop, copy: noop, openNew: noop })]),
|
|
685
687
|
]
|
|
686
688
|
for (const [id, draw] of cases) drawPane(on, id, draw)
|
|
687
689
|
for (const surface of SURFACES) {
|
|
@@ -697,7 +699,7 @@ test('no drawing cuts its text: no truncating Text on any surface', async ($, on
|
|
|
697
699
|
|
|
698
700
|
// ---- The band holds still (2026-10-10) ----------------------------------------------------------
|
|
699
701
|
|
|
700
|
-
const BAND_ACTIONS = { toggleAuto: () => undefined, toggleWarm: () => undefined, compact: () => undefined, handoff: () => undefined, toggleLog: () => undefined, toggleAgents: () => undefined, openSettings: () => undefined, toggleEffortMenu: () => undefined, pickEffort: () => undefined }
|
|
702
|
+
const BAND_ACTIONS = { toggleAuto: () => undefined, toggleWarm: () => undefined, compact: () => undefined, clear: () => undefined, handoff: () => undefined, toggleLog: () => undefined, toggleAgents: () => undefined, openSettings: () => undefined, toggleEffortMenu: () => undefined, pickEffort: () => undefined }
|
|
701
703
|
const unhandled = (tree: unknown) => JSON.stringify(tree).replace(/"handle":\d+/g, '')
|
|
702
704
|
|
|
703
705
|
test('within a minute the band draws the same tree; a minute on, only the countdown changes', async ($, on) => {
|
|
@@ -838,14 +840,16 @@ test('the band names the mode that is on, cut to a slot of its own that keeps it
|
|
|
838
840
|
}
|
|
839
841
|
})
|
|
840
842
|
|
|
841
|
-
test('Handoff advised, the desktop band keeps the route and every control in one row at
|
|
843
|
+
test('Handoff advised, the desktop band keeps the route and every control in one row at 770 px (Chinese), 830 px (Korean, English, Japanese), 870 px (Spanish, Portuguese)', async ($, on) => {
|
|
842
844
|
// The band counts every slot at a fixed width (never by value), drops the controls' words, then
|
|
843
845
|
// the quietest facts, until the route and the controls fit; the reason takes what is left.
|
|
846
|
+
// The Clear and Compact icons joined the row (2026-10-14), so the narrowest window grew by
|
|
847
|
+
// about a control: Hangul labels are the widest, so Korean moved to 830.
|
|
844
848
|
const busy: DashboardView = { ...DASH, effort: { level: 'xhigh', mode: 'manual' }, handoff: 'advised', agents: { live: 2, total: 3 }, warm: { on: true, pings: 3, cost: 0 } }
|
|
845
849
|
const langs = Object.keys(STRINGS) as (keyof typeof STRINGS)[]
|
|
846
850
|
// Cells counted at 8.8 px (the 14 px measure the band used before, 2026-10-10): a margin over
|
|
847
851
|
// the host's 7.917 px for the site's padding and the band's own border.
|
|
848
|
-
const columns = (lang: string) => Math.floor((lang === 'es' || lang === 'pt' ? 870 : lang === 'en' || lang === 'ja' ?
|
|
852
|
+
const columns = (lang: string) => Math.floor((lang === 'es' || lang === 'pt' ? 870 : lang === 'zh' ? 770 : lang === 'ko' || lang === 'en' || lang === 'ja' ? 830 : 770) / 8.8)
|
|
849
853
|
for (const lang of langs) drawPane(on, 'row-' + lang, el => renderBand(el, { ...frameOf('desktop', columns(lang)), lang }, busy, { ...BAND_ACTIONS, hide: () => undefined }))
|
|
850
854
|
for (const lang of langs) {
|
|
851
855
|
const ui = await mount($, 'row-' + lang, 'desktop', columns(lang))
|
|
@@ -855,3 +859,89 @@ test('Handoff advised, the desktop band keeps the route and every control in one
|
|
|
855
859
|
await ui.unmount()
|
|
856
860
|
}
|
|
857
861
|
})
|
|
862
|
+
|
|
863
|
+
// 2026-10-14: Auto's word is the control itself (the switch picture is gone): pressing the word
|
|
864
|
+
// toggles, and the state is in its own drawing — lit violet while on, a quiet ghost while off.
|
|
865
|
+
test('the Auto word toggles on press and draws its state', async ($, on) => {
|
|
866
|
+
const { calls, fn } = recorder()
|
|
867
|
+
const actions = { ...BAND_ACTIONS, toggleAuto: fn('auto') }
|
|
868
|
+
drawPane(on, 'word-auto', el => renderBand(el, frameOf('desktop'), DASH, actions))
|
|
869
|
+
drawPane(on, 'word-auto-off', el => renderBand(el, frameOf('desktop'), { ...DASH, auto: false }, actions))
|
|
870
|
+
const on_ = await mount($, 'word-auto', 'desktop')
|
|
871
|
+
// Lit while on: the picture's root class reads on; the old switch is gone.
|
|
872
|
+
expect(await on_.find({ type: 'Box', key: 'word-on-auto' })).toBeDefined()
|
|
873
|
+
expect(await on_.findAll({ type: 'Svg' }).then(list => list.some(svg => svg.props.alt === 'Auto' && String(svg.props.source).includes('class="on"')))).toBe(true)
|
|
874
|
+
// The press is on the word itself: it reaches the toggle.
|
|
875
|
+
await on_.press({ key: 'auto' })
|
|
876
|
+
expect(calls).toEqual(['auto'])
|
|
877
|
+
await on_.unmount()
|
|
878
|
+
// Off, the same slot draws the quiet ghost instead of the lit pill.
|
|
879
|
+
const off = await mount($, 'word-auto-off', 'desktop')
|
|
880
|
+
expect(await off.find({ type: 'Box', key: 'ghost-auto' })).toBeDefined()
|
|
881
|
+
expect(await off.findAll({ type: 'Svg' }).then(list => list.some(svg => svg.props.alt === 'Auto' && String(svg.props.source).includes('class="off"')))).toBe(true)
|
|
882
|
+
await off.unmount()
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
// 2026-10-14: at the band's narrowest real width (94 columns) everything asked of the band holds:
|
|
886
|
+
// the limit and the new icons stay; Handoff, Auto and the way out draw with them.
|
|
887
|
+
test('at 94 columns with Handoff advised the limit is drawn beside the new controls', async ($, on) => {
|
|
888
|
+
const busy: DashboardView = { ...DASH, limit: { kind: 'seven_day', percent: 42, resetsAt: '2026-10-10T15:40:00', save: false }, handoff: 'advised', extras: ['context', 'limits'] }
|
|
889
|
+
drawPane(on, 'n94', el => renderBand(el, frameOf('desktop', 94), busy, { ...BAND_ACTIONS, hide: () => undefined }))
|
|
890
|
+
const ui = await mount($, 'n94', 'desktop', 94)
|
|
891
|
+
expect(await ui.find({ type: 'Box', key: 'fact-limit' }), 'limit at 94').toBeDefined()
|
|
892
|
+
expect(await ui.find({ type: 'Box', key: 'fact-cache' }), 'cache at 94').toBeDefined()
|
|
893
|
+
expect(await ui.find({ type: 'Box', key: 'fact-effort' })).toBeDefined()
|
|
894
|
+
expect(await ui.find({ type: 'Button', key: 'route' })).toBeDefined()
|
|
895
|
+
for (const key of ['handoff', 'auto', 'warm', 'agents', 'compact', 'clear', 'settings', 'hide']) expect(await ui.find({ type: 'Button', key }), key + ' at 94').toBeDefined()
|
|
896
|
+
await ui.unmount()
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
/** Every tip as drawn: the words it shows, and the hover that reveals it. */
|
|
900
|
+
function tipsOf(tree: unknown): { text: string; display: unknown; scope: string | undefined }[] {
|
|
901
|
+
const out: { text: string; display: unknown; scope: string | undefined }[] = []
|
|
902
|
+
walk(tree, n => {
|
|
903
|
+
const hover = (n as Drawn & { hover?: { scope?: string; display?: string } }).hover
|
|
904
|
+
if (n.type !== 'Box' || n.props?.display !== 'none' || hover?.display !== 'flex' || !hover.scope) return
|
|
905
|
+
const shown = [...walkText(n)]
|
|
906
|
+
out.push({ text: shown.join(''), display: n.props?.display, scope: hover?.scope })
|
|
907
|
+
})
|
|
908
|
+
return out
|
|
909
|
+
}
|
|
910
|
+
function* walkText(node: unknown): Generator<string> {
|
|
911
|
+
if (typeof node === 'string') { yield node; return }
|
|
912
|
+
if (!node || typeof node !== 'object') return
|
|
913
|
+
if (Array.isArray(node)) { for (const c of node) yield* walkText(c); return }
|
|
914
|
+
const n = node as Drawn
|
|
915
|
+
if (n.type === 'Text') yield (n.children ?? []).join('')
|
|
916
|
+
for (const c of n.children ?? []) yield* walkText(c)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 2026-10-14: at the band's real width (100 columns) every slot is drawn and the row leaves nothing
|
|
920
|
+
// free, so a tip must not depend on that free room; each rests hidden (and carries no `key`: a keyed
|
|
921
|
+
// Box drawn display "none" is refused as a hover scope) and reveals through the scope its fact or
|
|
922
|
+
// control joins, so a control's tip shows in a full band.
|
|
923
|
+
test('a control\'s tip is drawn in a full band, hidden and revealed by its scope', async ($, on) => {
|
|
924
|
+
const busy: DashboardView = { ...DASH, limit: { kind: 'seven_day', percent: 42, resetsAt: '2026-10-10T15:40:00', save: false }, handoff: 'advised', extras: ['context', 'limits'] }
|
|
925
|
+
drawPane(on, 'n100', el => renderBand(el, frameOf('desktop', 100), busy, { ...BAND_ACTIONS, hide: () => undefined }))
|
|
926
|
+
const ui = await mount($, 'n100', 'desktop', 100)
|
|
927
|
+
const tree = await ui.drawn()
|
|
928
|
+
const tips = tipsOf(tree)
|
|
929
|
+
// One tip per fact and control (the effort, the context, the cache, the limit, the route, and
|
|
930
|
+
// every control but Handoff, which names itself in its pill) plus the way out — the row is full
|
|
931
|
+
// and all are drawn.
|
|
932
|
+
expect(tips.length, 'tips: ' + tips.map(t => t.text.slice(0, 14)).join(' / ')).toBe(12)
|
|
933
|
+
const words = (start: string) => tips.find(tip => tip.text.startsWith(start))
|
|
934
|
+
for (const start of ['Opus 5.5 · Auto', 'Context 38%', 'Prompt cache', 'Weekly limit 42%', 'Last route', 'Effort Auto', 'Keep cache warm', 'Agents:', 'Compact — ', 'New start (/clear) — ', 'Ripple settings', 'Hide the band']) {
|
|
935
|
+
expect(words(start), start).toBeDefined()
|
|
936
|
+
}
|
|
937
|
+
for (const tip of tips) {
|
|
938
|
+
expect(tip.display, tip.text).toBe('none')
|
|
939
|
+
expect(typeof tip.scope, tip.text).toBe('string')
|
|
940
|
+
}
|
|
941
|
+
// Hovering the compact control (its own hover carries the same scope) reveals the compact tip.
|
|
942
|
+
const compact = words('Compact — ')!
|
|
943
|
+
const joined = JSON.stringify(tree).includes('"scope":"' + compact.scope + '"')
|
|
944
|
+
expect(joined, 'the compact control joins its tip\'s scope').toBe(true)
|
|
945
|
+
expect(await ui.find({ type: 'Button', key: 'compact' })).toBeDefined()
|
|
946
|
+
await ui.unmount()
|
|
947
|
+
})
|
|
@@ -149,6 +149,12 @@ export type Settings = {
|
|
|
149
149
|
* picks, only among these.
|
|
150
150
|
*/
|
|
151
151
|
allow: string[]
|
|
152
|
+
/**
|
|
153
|
+
* The allow-list keys of every worker that existed when `allow` was last written (what
|
|
154
|
+
* `candidateKey` returns). A key absent from this non-empty list counts as ticked on; empty means
|
|
155
|
+
* either every worker is ticked (`allow` empty) or all writers predate the field (#56).
|
|
156
|
+
*/
|
|
157
|
+
allowKnown: string[]
|
|
152
158
|
/** The user's operating modes; none is built in. */
|
|
153
159
|
modes: Mode[]
|
|
154
160
|
/** The mode that is on, by id; absent when none is. */
|
|
@@ -313,7 +319,7 @@ declare module 'claude-code' {
|
|
|
313
319
|
judgeDown: string | null
|
|
314
320
|
advice: string | null
|
|
315
321
|
handoff: { stage: HandoffStage; kind: HandoffKind; text?: string } | null
|
|
316
|
-
panel: 'settings' | 'setup' | 'compact' | 'handoff' | null
|
|
322
|
+
panel: 'settings' | 'setup' | 'compact' | 'clear' | 'handoff' | null
|
|
317
323
|
busy: boolean
|
|
318
324
|
decidingSince: number | null
|
|
319
325
|
heldPick: Pick | null
|
package/dist/router/src/admin.js
CHANGED
|
@@ -100,7 +100,8 @@ async function claudeAccountUsage(deps) {
|
|
|
100
100
|
}
|
|
101
101
|
return rows;
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
// The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
|
|
104
|
+
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
104
105
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
105
106
|
let chatgptLogin = { running: false };
|
|
106
107
|
/** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
|
|
@@ -411,8 +412,8 @@ async function withFilledLadder(deps, scores, settings) {
|
|
|
411
412
|
return settings;
|
|
412
413
|
try {
|
|
413
414
|
const choices = ladderChoices(await modOptions(deps, scores));
|
|
414
|
-
const { rungs } = fillLadder(general.rungs, settings.allow, choices);
|
|
415
|
-
const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices).rungs }));
|
|
415
|
+
const { rungs } = fillLadder(general.rungs, settings.allow, choices, settings.allowKnown);
|
|
416
|
+
const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices, settings.allowKnown).rungs }));
|
|
416
417
|
return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
|
|
417
418
|
}
|
|
418
419
|
catch (error) {
|
|
@@ -1027,11 +1028,13 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
|
|
|
1027
1028
|
const responses = openai && body.wire === "responses" && !body.preset;
|
|
1028
1029
|
const checkUrl = responses ? responsesUrl(body.url) : openai ? chatCompletionsUrl(body.url) : messagesUrl(body.url);
|
|
1029
1030
|
const checkModel = presetProbeModel(body.preset) ?? models[0]?.id ?? body.probeModel ?? "test";
|
|
1031
|
+
// 16 tokens, as on Responses: some vendors refuse a smaller cap outright (B.ai: "max_tokens must be
|
|
1032
|
+
// greater than 2", #57), which read as a failed connection though the key and URL were right.
|
|
1030
1033
|
const checkBody = responses
|
|
1031
1034
|
? { model: checkModel, max_output_tokens: 16, stream: false, store: false, input: "hi" }
|
|
1032
1035
|
: openai
|
|
1033
|
-
? { model: checkModel, max_tokens:
|
|
1034
|
-
: { model: checkModel, max_tokens:
|
|
1036
|
+
? { model: checkModel, max_tokens: 16, stream: false, messages: [{ role: "user", content: "hi" }] }
|
|
1037
|
+
: { model: checkModel, max_tokens: 16, messages: [{ role: "user", content: "hi" }] };
|
|
1035
1038
|
const label = responses ? "responses endpoint" : openai ? "chat completions endpoint" : "messages endpoint";
|
|
1036
1039
|
try {
|
|
1037
1040
|
const response = await probeFetch(checkUrl, {
|
|
@@ -1178,8 +1181,9 @@ function effortUndeclared(p) {
|
|
|
1178
1181
|
*
|
|
1179
1182
|
* Read again here rather than reusing what the job started with: the user may have saved from the
|
|
1180
1183
|
* GUI while the measurements ran, and writing back a stale copy would silently revert that save.
|
|
1181
|
-
* Only a model that is still present is touched, and only into a blank field — a `wire
|
|
1182
|
-
* `effortLevels` already on the model is the user's, and an
|
|
1184
|
+
* Only a model that is still present is touched, and only into a blank field — a `wire`,
|
|
1185
|
+
* `effortLevels` or `contextWindow` already on the model is the user's or the vendor's, and an
|
|
1186
|
+
* explicitly empty ladder is a decision
|
|
1183
1187
|
* (it disables the provider fallback), not an empty slot. A model whose measurement errored is
|
|
1184
1188
|
* left entirely alone. The result is validated with the same `validate` the GUI's save uses, so a
|
|
1185
1189
|
* bad edit is refused rather than written.
|
|
@@ -1202,12 +1206,14 @@ function applyMeasurements(configFile, results) {
|
|
|
1202
1206
|
const model = provider.models.find((entry) => entry.id === result.id);
|
|
1203
1207
|
if (!model)
|
|
1204
1208
|
continue;
|
|
1205
|
-
const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}`;
|
|
1209
|
+
const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}|${model.contextWindow ?? "∅"}`;
|
|
1206
1210
|
if (model.wire === undefined)
|
|
1207
1211
|
model.wire = result.wire;
|
|
1208
1212
|
if (model.effortLevels === undefined && result.effortLevels !== undefined)
|
|
1209
1213
|
model.effortLevels = result.effortLevels;
|
|
1210
|
-
if (
|
|
1214
|
+
if (model.contextWindow === undefined && result.contextWindow !== undefined)
|
|
1215
|
+
model.contextWindow = result.contextWindow;
|
|
1216
|
+
if (`${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}|${model.contextWindow ?? "∅"}` !== before)
|
|
1211
1217
|
applied++;
|
|
1212
1218
|
}
|
|
1213
1219
|
}
|
|
@@ -1248,6 +1254,56 @@ export function measureUnsettledModels(deps) {
|
|
|
1248
1254
|
}
|
|
1249
1255
|
return started;
|
|
1250
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Give models already saved without a context window the one the catalogue states.
|
|
1259
|
+
*
|
|
1260
|
+
* OpenCode's `/models` reports ids alone, so a provider set up before the catalogue's `limit.context`
|
|
1261
|
+
* was read keeps no window and falls back to `cli.autoCompactWindow` — a quarter of a 1M model's real
|
|
1262
|
+
* window. The measurement folds the catalogue's window in, but only over the models it runs on and
|
|
1263
|
+
* only through its own write; this pass reaches the models already on disk, once, at start-up. Only
|
|
1264
|
+
* into a blank field, through the same validated write, so a window the user or the vendor set is
|
|
1265
|
+
* never overwritten. Returns the `provider/model` ids it filled.
|
|
1266
|
+
*/
|
|
1267
|
+
export async function backfillContextWindows(deps) {
|
|
1268
|
+
const catalog = deps.modelCatalog ? await deps.modelCatalog().catch(() => undefined) : undefined;
|
|
1269
|
+
if (!catalog) {
|
|
1270
|
+
deps.log.warn("admin: context windows not backfilled: the catalogue could not be had");
|
|
1271
|
+
return [];
|
|
1272
|
+
}
|
|
1273
|
+
let current;
|
|
1274
|
+
try {
|
|
1275
|
+
current = JSON.parse(fs.readFileSync(deps.configFile, "utf8"));
|
|
1276
|
+
}
|
|
1277
|
+
catch (e) {
|
|
1278
|
+
deps.log.warn(`admin: context windows not backfilled: could not read config: ${errorText(e)}`);
|
|
1279
|
+
return [];
|
|
1280
|
+
}
|
|
1281
|
+
const filled = [];
|
|
1282
|
+
for (const [name, provider] of Object.entries(current.providers)) {
|
|
1283
|
+
if (!("models" in provider) || !provider.models)
|
|
1284
|
+
continue;
|
|
1285
|
+
const preset = "preset" in provider ? provider.preset : undefined;
|
|
1286
|
+
for (const model of provider.models) {
|
|
1287
|
+
if (model.contextWindow !== undefined)
|
|
1288
|
+
continue;
|
|
1289
|
+
const entry = catalogEntry(catalog, preset, model.id);
|
|
1290
|
+
if (entry?.contextWindow === undefined)
|
|
1291
|
+
continue;
|
|
1292
|
+
model.contextWindow = entry.contextWindow;
|
|
1293
|
+
filled.push(`${name}/${model.id}`);
|
|
1294
|
+
deps.log.info(`admin: ${name}/${model.id} context window ${entry.contextWindow} (catalogue)`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (filled.length === 0)
|
|
1298
|
+
return [];
|
|
1299
|
+
const errors = validate(current);
|
|
1300
|
+
if (errors.length > 0) {
|
|
1301
|
+
deps.log.warn(`admin: context windows not backfilled: config did not validate: ${errors[0]}`);
|
|
1302
|
+
return [];
|
|
1303
|
+
}
|
|
1304
|
+
writeConfigFile(deps.configFile, current);
|
|
1305
|
+
return filled;
|
|
1306
|
+
}
|
|
1251
1307
|
/**
|
|
1252
1308
|
* Declare each Claude release the account has gained on every native Anthropic provider that
|
|
1253
1309
|
* declares an older model of the same family (`newClaudeReleases`). The declared list was whatever
|
|
@@ -1327,6 +1383,10 @@ async function runMeasureJob(jobId, providerName, models, deps) {
|
|
|
1327
1383
|
const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) }, { discover: discover && !known });
|
|
1328
1384
|
if (stated && measured.wire)
|
|
1329
1385
|
measured.effortLevels = stated;
|
|
1386
|
+
// The wire was answered, so the model exists: the catalogue's own window travels with the result
|
|
1387
|
+
// and is written only into a model that has none (see applyMeasurements).
|
|
1388
|
+
if (measured.wire && listed?.contextWindow !== undefined)
|
|
1389
|
+
measured.contextWindow = listed.contextWindow;
|
|
1330
1390
|
job.results.push(measured);
|
|
1331
1391
|
job.done++;
|
|
1332
1392
|
deps.log.info(`admin: measured ${providerName}/${id} -> ${measured.error ? `error ${measured.error}` : `wire ${measured.wire}${measured.effortLevels ? ` effort ${measured.effortLevels.join(",")}${known ? " (catalogue)" : ""}` : ""}`}`);
|
|
@@ -36,6 +36,11 @@ const BODY = [
|
|
|
36
36
|
"다른 에이전트에게 넘기지 않는다. 프롬프트 첫 줄의 `[[ripple: …]]` 표식은 라우팅용이니 무시한다.",
|
|
37
37
|
"**시키지 않은 파일은 만들지 않는다.** 작업 지시에 결과물 경로가 없으면 조사·감사라도 작업 디렉터리(cwd)에 아무 파일도 남기지 않는다.",
|
|
38
38
|
"작업 지시가 결과물 경로(파일·폴더)를 정했으면 조사·감사·대조 작업이라도 그 경로에는 반드시 만든다. 그 밖의 메모·사본·임시 파일은 `/tmp` 아래에만 만들고, 끝나면 지운다.",
|
|
39
|
+
// Claude Code tells a bypass/auto session to read and edit through Bash (since CLI 2.1.234). After it
|
|
40
|
+
// arrived Bash edits went from 32% to 78% and Read calls from 2,897 to 151 in the operator's
|
|
41
|
+
// sessions, and details went missing (brain, 2026-08-25); workers here read with `sed -n`/`cat` more
|
|
42
|
+
// than with Read (2026-10-14). The operator's own rule, given to the workers too:
|
|
43
|
+
"**소스 파일 읽기와 수정은 전용 도구로 한다.** 처음 만지는 파일은 Read로 통독하고(필요하면 다시 읽는다), 수정은 Edit, 새 파일은 Write로 한다. `cat`·`sed -n`·`head`로 조각만 읽어 넘기지 않고, `sed -i`·heredoc 덮어쓰기·스크립트 재작성으로 소스를 고치지 않는다 — 간격·정렬·주석이 조용히 깎인다. Bash는 검색(grep/rg)·실행·테스트·빌드, 그리고 수십 곳 이상의 기계적 일괄 치환(끝나면 `git diff`로 확인)에만 쓴다.",
|
|
39
44
|
].join("\n");
|
|
40
45
|
/**
|
|
41
46
|
* Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
// model, one after another. With upstream latency swinging from 0.5s to 25s on identical one-token
|
|
6
6
|
// requests (glm-5.3-flash, measured 2026-10-02), three models took 2m33s.
|
|
7
7
|
//
|
|
8
|
-
// models.dev (https://models.dev/api.json, maintained by the team behind OpenCode) states
|
|
9
|
-
// every model OpenCode serves: `reasoning_options` lists the effort values,
|
|
10
|
-
// the SDK the OpenCode client speaks to it with.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// catalogue
|
|
8
|
+
// models.dev (https://models.dev/api.json, maintained by the team behind OpenCode) states three
|
|
9
|
+
// things for every model OpenCode serves: `reasoning_options` lists the effort values, `provider.npm`
|
|
10
|
+
// names the SDK the OpenCode client speaks to it with, and `limit.context` is the model's context
|
|
11
|
+
// window — which OpenCode's own `/models` does not report at all. It is also more accurate than
|
|
12
|
+
// asking on effort: a 200 only says a level was not refused, and glm-5.3-flash answered 200 to seven
|
|
13
|
+
// levels where the catalogue lists `low`/`high`/`max` — the rest were accepted and, as far as
|
|
14
|
+
// anything can tell, ignored. And muse-spark-1.3-contributor's catalogue entry omits `max` even
|
|
15
|
+
// though the live model takes it: measured 2026-10-14, `reasoning.effort: "max"` was answered 200
|
|
16
|
+
// with effort echoed "max" and 253 reasoning tokens against 134 at xhigh.
|
|
15
17
|
//
|
|
16
18
|
// The catalogue is community-maintained and a new model can arrive a day late; a model it does not
|
|
17
19
|
// list is measured exactly as before.
|
|
@@ -56,7 +58,15 @@ export function catalogEntry(catalog, preset, modelId) {
|
|
|
56
58
|
else if (model.reasoning === false) {
|
|
57
59
|
effortLevels = [];
|
|
58
60
|
}
|
|
59
|
-
|
|
61
|
+
// The catalogue's own window, which OpenCode's `/models` does not carry. A fractional or
|
|
62
|
+
// non-positive `context` is not a window we would hand Claude Code, so it is left unstated.
|
|
63
|
+
const context = model.limit?.context;
|
|
64
|
+
const contextWindow = typeof context === "number" && Number.isInteger(context) && context > 0 ? context : undefined;
|
|
65
|
+
return {
|
|
66
|
+
...(wire ? { wire } : {}),
|
|
67
|
+
...(effortLevels ? { effortLevels } : {}),
|
|
68
|
+
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
69
|
+
};
|
|
60
70
|
}
|
|
61
71
|
let cached;
|
|
62
72
|
/**
|
package/dist/router/src/index.js
CHANGED
|
@@ -14,12 +14,13 @@ import { CertStore } from "./certs.js";
|
|
|
14
14
|
import { Logger } from "./log.js";
|
|
15
15
|
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
16
16
|
import { Proxy } from "./proxy.js";
|
|
17
|
-
import { adoptNewClaudeModels, measureUnsettledModels, startAdmin } from "./admin.js";
|
|
17
|
+
import { adoptNewClaudeModels, backfillContextWindows, measureUnsettledModels, startAdmin } from "./admin.js";
|
|
18
18
|
import { loadModelCatalog } from "./catalog.js";
|
|
19
19
|
import { RequestLog } from "./requestlog.js";
|
|
20
20
|
import { OpenAiIngress } from "./ingress/server.js";
|
|
21
21
|
import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
|
|
22
22
|
import { VERSION } from "./version.js";
|
|
23
|
+
import { allowLongSilences } from "./providers/retry.js";
|
|
23
24
|
// Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
|
|
24
25
|
// second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
|
|
25
26
|
// CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
|
|
@@ -85,6 +86,8 @@ const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstrea
|
|
|
85
86
|
setTimeout(() => process.exit(EXIT_UPSTREAM_UNREACHABLE), 100);
|
|
86
87
|
});
|
|
87
88
|
const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
|
|
89
|
+
// Before any provider fetches: a reasoning model may stay silent past fetch's 300s default.
|
|
90
|
+
allowLongSilences();
|
|
88
91
|
const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
|
|
89
92
|
const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name) });
|
|
90
93
|
process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
|
|
@@ -173,9 +176,15 @@ proxy
|
|
|
173
176
|
const admin = await startAdmin(adminDeps);
|
|
174
177
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
175
178
|
setImmediate(premintLeaves);
|
|
176
|
-
// A minute in, once start-up traffic has settled:
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
+
// A minute in, once start-up traffic has settled: first the catalogue's context windows are
|
|
180
|
+
// written into models already saved without one (a one-shot pass), then a few one-token requests
|
|
181
|
+
// per model whose effort nobody has measured yet, so effort works without the owner re-saving the
|
|
182
|
+
// provider. The backfill runs first so its write lands before the measured jobs re-read the config.
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
void backfillContextWindows(adminDeps)
|
|
185
|
+
.catch((e) => log.warn(`context windows: ${e.message}`))
|
|
186
|
+
.finally(() => measureUnsettledModels(adminDeps));
|
|
187
|
+
}, 60_000).unref();
|
|
179
188
|
// Claude releases reach the declared models without a ClaudeRipple release: checked a minute in,
|
|
180
189
|
// then every six hours (the catalogue itself is cached for an hour).
|
|
181
190
|
const adopt = () => void adoptNewClaudeModels(adminDeps).catch((e) => log.warn(`claude models: ${e.message}`));
|
|
@@ -607,6 +607,14 @@ export class ChatGptAdapter {
|
|
|
607
607
|
return null;
|
|
608
608
|
}
|
|
609
609
|
this.noteRateLimits(tokens, parsed);
|
|
610
|
+
// The backend's own answer that every window has room ends a usage-limit cooldown early: the
|
|
611
|
+
// limit was reset or the plan topped up, and the account stayed refused until the old reset time
|
|
612
|
+
// otherwise (2026-10-14: credits added at 15:50, still "cooling" until 21:42). Only this asked-for
|
|
613
|
+
// figure does it, never the headers of the 429 that started the cooldown.
|
|
614
|
+
if (exhaustedForMs(parsed) === undefined && this.accounts.peekCredentials().some((c) => c.id === tokens.id && !this.pool.hasUsable(this.name, [c]))) {
|
|
615
|
+
this.pool.clear(this.name, tokens.id);
|
|
616
|
+
this.log.info(`chatgpt ${this.name}: account ${tokens.ownerId.slice(0, 8)} has room again; back in rotation`);
|
|
617
|
+
}
|
|
610
618
|
return parsed;
|
|
611
619
|
}
|
|
612
620
|
/** One in-flight catalogue lookup shared by every caller, and the last good list for an hour. */
|
|
@@ -22,7 +22,22 @@
|
|
|
22
22
|
// any status or byte is sent, so a retry cannot splice two answers together. Once the first byte is
|
|
23
23
|
// out the turn is committed and this must not be used.
|
|
24
24
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
25
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
25
26
|
import { classify } from "../pool.js";
|
|
27
|
+
/**
|
|
28
|
+
* How long a translated provider's response may stay silent. Node's `fetch` gives up after 300s
|
|
29
|
+
* without a byte (`UND_ERR_BODY_TIMEOUT`, reproduced 2026-10-14: an SSE response that went quiet was
|
|
30
|
+
* cut at 300.98s). A reasoning model says nothing while it thinks: Muse Spark 1.3 at xhigh on a
|
|
31
|
+
* 100k+ context thought for minutes per step, and every one of its 529s that day ended at 303–305s,
|
|
32
|
+
* while every step that finished did so under 280s — the whole step thrown away and asked again.
|
|
33
|
+
* OpenCode's own CLI has no such limit. Fifteen minutes is past Claude Code's own request timeout,
|
|
34
|
+
* so the client, not this, decides when a turn has taken too long.
|
|
35
|
+
*/
|
|
36
|
+
export const UPSTREAM_SILENCE_MS = 15 * 60_000;
|
|
37
|
+
/** Every `fetch` in this process waits `ms` for headers and between body bytes. Called at startup. */
|
|
38
|
+
export function allowLongSilences(ms = UPSTREAM_SILENCE_MS) {
|
|
39
|
+
setGlobalDispatcher(new Agent({ bodyTimeout: ms, headersTimeout: ms }));
|
|
40
|
+
}
|
|
26
41
|
/** Attempts in total — one ask plus nine retries, matching the CLI's own ceiling (2026-09-21). */
|
|
27
42
|
export const DEFAULT_ATTEMPTS = 10;
|
|
28
43
|
const RETRY_BASE_MS = 250;
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
|
|
6
6
|
// still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
|
|
7
7
|
// running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
|
|
8
|
-
export const VERSION = "0.9.
|
|
8
|
+
export const VERSION = "0.9.4";
|
package/dist/ui/app.js
CHANGED
|
@@ -2486,7 +2486,7 @@ const MOD_DEFAULTS = {
|
|
|
2486
2486
|
hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
|
|
2487
2487
|
} },
|
|
2488
2488
|
},
|
|
2489
|
-
allow: [], modes: [], providers: {},
|
|
2489
|
+
allow: [], allowKnown: [], modes: [], providers: {},
|
|
2490
2490
|
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
2491
2491
|
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
2492
2492
|
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
@@ -2510,6 +2510,7 @@ function modNormalize(raw) {
|
|
|
2510
2510
|
...value,
|
|
2511
2511
|
rules: { ...clone(MOD_DEFAULTS.rules), ...(value.rules || {}) },
|
|
2512
2512
|
allow: Array.isArray(value.allow) ? value.allow : [],
|
|
2513
|
+
allowKnown: Array.isArray(value.allowKnown) ? value.allowKnown : [],
|
|
2513
2514
|
modes: Array.isArray(value.modes) ? value.modes : [],
|
|
2514
2515
|
providers: { ...(value.providers || {}) },
|
|
2515
2516
|
hide: Array.isArray(value.hide) ? value.hide : clone(MOD_DEFAULTS.hide),
|
|
@@ -2695,7 +2696,7 @@ function modEffortCard() {
|
|
|
2695
2696
|
*/
|
|
2696
2697
|
function modChoices() {
|
|
2697
2698
|
const options = modState.options || {};
|
|
2698
|
-
const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "max"];
|
|
2699
|
+
const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "xhigh", "max"];
|
|
2699
2700
|
const workers = modAgents().map((agent) => {
|
|
2700
2701
|
const info = modProviderInfo(agent.provider);
|
|
2701
2702
|
return {
|
|
@@ -2749,17 +2750,20 @@ function modGroupLabel(group) { return group.billing ? `${group.name} · ${t(`mo
|
|
|
2749
2750
|
function modTickKey(choice) { return choice.claude ? choice.value.replace("+", ":") : choice.value; }
|
|
2750
2751
|
/** The models that can be ticked: every worker and Claude's own; the session's model is always there. */
|
|
2751
2752
|
function modTickable() { return modChoices().filter((choice) => choice.value !== "general-purpose"); }
|
|
2752
|
-
/** Whether a model is ticked: an empty list means every model,
|
|
2753
|
+
/** Whether a model is ticked: an empty list means every model, an old provider name ticks that provider's, and a model added after the list was last written (absent from `allowKnown`) ticks on. */
|
|
2753
2754
|
function modTicked(choice) {
|
|
2754
2755
|
const allow = modState.settings.allow || [];
|
|
2755
|
-
|
|
2756
|
+
const known = modState.settings.allowKnown || [];
|
|
2757
|
+
return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider) || known.length > 0 && !known.includes(modTickKey(choice));
|
|
2756
2758
|
}
|
|
2757
2759
|
/** Ticks one model; every model on again is stored as [], and the last one on cannot go off. */
|
|
2758
2760
|
function modTick(choice, on) {
|
|
2759
2761
|
const all = modTickable();
|
|
2760
|
-
const
|
|
2762
|
+
const known = all.map(modTickKey);
|
|
2763
|
+
const kept = new Set(all.filter((model) => modTicked(model)).map(modTickKey));
|
|
2761
2764
|
if (on) kept.add(modTickKey(choice)); else kept.delete(modTickKey(choice));
|
|
2762
|
-
|
|
2765
|
+
const allow = kept.size === known.length ? [] : known.filter((key) => kept.has(key));
|
|
2766
|
+
modPatch({ allow, allowKnown: allow.length ? known : [] });
|
|
2763
2767
|
}
|
|
2764
2768
|
function modTickRows() {
|
|
2765
2769
|
const choices = modTickable();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauderipple",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"workspaces": [
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"url": "https://github.com/PBJ-2/clauderipple/issues"
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
|
-
"proper-lockfile": "^4.1.2"
|
|
80
|
+
"proper-lockfile": "^4.1.2",
|
|
81
|
+
"undici": "7.24.4"
|
|
81
82
|
}
|
|
82
83
|
}
|