clauderipple 0.9.3 → 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.
@@ -22,7 +22,7 @@ import { rippleSource } from './parts'
22
22
  import type { Els } from './parts'
23
23
  import type { Face } from './ripple'
24
24
  import type { Frame } from './view'
25
- import { CELL_PX, CLOSE_PX, INK, ROW_PX, SUB, SWITCH_H, SWITCH_W, WELL_PX, closeArt, labelPx, pillArt, seaArt, segmentedArt, sliderArt, switchArt, wellArt } from './art'
25
+ import { CELL_PX, CLOSE_PX, INK, ROW_PX, SUB, SWITCH_H, SWITCH_W, WELL_PX, closeArt, extraHoverArt, labelPx, pillArt, seaArt, segmentedArt, sliderArt, switchArt, wellArt, wordArt } from './art'
26
26
  import type { Glyph, PillLook, Sea, Tone } from './art'
27
27
  import { chOf } from './theme'
28
28
 
@@ -35,7 +35,7 @@ const EDGE = 'rgba(128,124,116,0.55)'
35
35
  /** A control under the pointer: a soft light on the water. */
36
36
  const HOVER = 'rgba(255,255,255,0.09)'
37
37
  const HOVER_EDGE = 'rgba(255,255,255,0.16)'
38
- /** A drawn control's height, px: pills, segments. */
38
+ /** A drawn control's height, px: pills, segments (art.ts holds the same figure). */
39
39
  const CONTROL_PX = 26
40
40
 
41
41
  /** Cells a run of px takes, rounded up. */
@@ -116,6 +116,14 @@ export function lit(el: Els, key: string, cells: number, children: RenderNode[],
116
116
  )
117
117
  }
118
118
 
119
+ /** The drawn light over `cells`, in absolute Boxes of whole cells each. */
120
+ function extraHover(el: Els, cells: number): RenderNode[] {
121
+ const { Box } = el
122
+ const parts: RenderNode[] = []
123
+ for (let i = 0; i < cells; i++) parts.push(<Box key={'extra-' + i} position="absolute" top={0} bottom={0} left={i} width={1}>{pic(el, 'extra-' + i, extraHoverArt(CELL_PX), '', CELL_PX, CONTROL_PX)}</Box>)
124
+ return parts
125
+ }
126
+
119
127
  /**
120
128
  * A capsule: a rounded Box edged in violet light, the deep (art.ts `seaArt`) painted behind it from
121
129
  * its top left, and everything it holds in one positioned layer above the sea. `rows` is about how
@@ -184,6 +192,30 @@ export function toggleCells(label: string): number {
184
192
  return Math.ceil((SWITCH_W + CELL_PX + chOf(label) * CELL_PX * 1.1) / CELL_PX) + 1
185
193
  }
186
194
 
195
+ /** Cells the Auto word takes as its own control: its picture's width (the pill's own air drawn in). */
196
+ export function wordCells(label: string): number {
197
+ return wordArt(label, true).cells
198
+ }
199
+
200
+ /**
201
+ * The Auto word as the control itself (art.ts `wordArt`): lit violet while Auto is on, a quiet
202
+ * ghost while off, pulsing while the judge decides; the whole pressable.
203
+ */
204
+ export function word(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
205
+ const { Box } = el
206
+ const art = wordArt(label, on, busy)
207
+ return (
208
+ <Box key={(on ? 'word-on-' : 'ghost-') + key} position="relative" width={art.cells} height={rowsOf(CONTROL_PX)} flexShrink={0} alignItems="center" justifyContent="center">
209
+ {pic(el, key, art.source, label, art.cells * CELL_PX, CONTROL_PX)}
210
+ <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ scope }}>
211
+ {lift(el)}
212
+ {extraHover(el, art.cells)}
213
+ </Box>
214
+ {press(el, key, art.cells * CELL_PX, onPress)}
215
+ </Box>
216
+ )
217
+ }
218
+
187
219
  /** A switch and its word (On / Off), the whole pressable; lit while on. */
188
220
  export function toggle(el: Els, key: string, on: boolean, label: string, onPress: () => void, busy = false, scope?: string): RenderNode {
189
221
  const { Text } = el
@@ -95,6 +95,7 @@ export type BandActions = {
95
95
  toggleAuto: () => void
96
96
  toggleWarm: () => void
97
97
  compact: () => void
98
+ clear: () => void
98
99
  handoff: () => void
99
100
  toggleLog: () => void
100
101
  toggleAgents: () => void
@@ -125,11 +126,15 @@ export type AlertView =
125
126
  | { kind: 'result'; stage: ResultStage; handoffKind?: HandoffKind; detail?: string }
126
127
  /** Compact with an optional note of what to keep. */
127
128
  | { kind: 'compactNote'; draft: string }
129
+ /** The band's Clear asks before the conversation is started over. */
130
+ | { kind: 'clear' }
128
131
  /** Quick or full handoff, and what happens after. */
129
132
  | { kind: 'handoffChoice'; handoffKind: HandoffKind; after: Settings['handoffAfter'] }
130
133
 
131
134
  export type AlertActions = {
132
135
  compact: (note?: string) => void
136
+ /** Runs the conversation's clear, from the Clear confirm card. */
137
+ clear: () => void
133
138
  /** Opens the handoff choice bar. */
134
139
  handoff: () => void
135
140
  dismiss: () => void
@@ -1753,14 +1753,16 @@ test('every band control changes the next drawing, and a toggle says its state o
1753
1753
  http(on)
1754
1754
  await start($)
1755
1755
  await b.clock.advance(3000)
1756
- // The terminal says it in the label (● / ○); the desktop in the control's drawing: a switch's lit
1757
- // track, a lit icon, each picture marked `on` or `off` at its root.
1756
+ // The terminal says it in the label (● / ○); the desktop in the control's drawing: a lit icon,
1757
+ // a lit word, each picture marked `on` or `off` at its root.
1758
1758
  const desktopState = (n: unknown) => {
1759
1759
  const drawing = JSON.stringify(n ?? '')
1760
1760
  return /<svg[^>]* class=\\"on\\"/.test(drawing) ? 'on' : /<svg[^>]* class=\\"off\\"/.test(drawing) ? 'off' : 'other'
1761
1761
  }
1762
1762
  const state = async (ui: Awaited<ReturnType<typeof band>>, key: string, surface: string) =>
1763
- surface === 'desktop' ? desktopState(await ui.find({ type: 'Box', key: 'hv-' + key })) : (n => /●/.test(String(n?.props.label)) ? 'on' : /○/.test(String(n?.props.label)) ? 'off' : 'other')(await ui.find({ type: 'Button', key }))
1763
+ surface === 'desktop'
1764
+ ? desktopState(await ui.find({ type: 'Box', key: key === 'auto' ? 'word-on-auto' : 'hv-' + key }) ?? await ui.find({ type: 'Box', key: 'ghost-' + key }))
1765
+ : (n => /●/.test(String(n?.props.label)) ? 'on' : /○/.test(String(n?.props.label)) ? 'off' : 'other')(await ui.find({ type: 'Button', key }))
1764
1766
  for (const surface of ['terminal', 'desktop'] as const) {
1765
1767
  const ui = await band($, surface)
1766
1768
  const drawn = async () => JSON.stringify(await ui.drawn()).replace(/"handle":\d+/g, '')
@@ -1791,3 +1793,29 @@ test('every band control changes the next drawing, and a toggle says its state o
1791
1793
  await ui.unmount()
1792
1794
  }
1793
1795
  })
1796
+
1797
+ // 2026-10-14: the band's Clear icon asks before it runs /clear; Cancel leaves the chat alone.
1798
+ test('the band Clear asks first, and confirming runs the clear once', async ($, on) => {
1799
+ const b = boundaries(on)
1800
+ http(on)
1801
+ const commands: string[] = []
1802
+ on('command.run', { command: 'clear' }, () => { commands.push('clear'); return {} })
1803
+ await start($)
1804
+ const ui = await band($, 'desktop')
1805
+ expect(await ui.find({ key: 'clear' })).toBeDefined()
1806
+ await ui.press({ key: 'clear' })
1807
+ expect(commands).toEqual([])
1808
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeDefined()
1809
+ expect(await ui.find({ type: 'Text', text: /context is emptied/ })).toBeDefined()
1810
+ // Cancel keeps the conversation and closes the card.
1811
+ await ui.press({ key: 'dismiss' })
1812
+ expect(commands).toEqual([])
1813
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeUndefined()
1814
+ // The main action runs the clear once.
1815
+ await ui.press({ key: 'clear' })
1816
+ await ui.press({ key: 'clear-go' })
1817
+ await b.clock.advance(3000)
1818
+ expect(commands).toEqual(['clear'])
1819
+ expect(await ui.find({ type: 'Text', text: 'Start this conversation over?' })).toBeUndefined()
1820
+ await ui.unmount()
1821
+ })
@@ -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('warm'), compact: fn('compact'), handoff: fn('handoff'), toggleLog: fn('log'),
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 left the band (it is /compact and the alerts' choice).
156
- expect(await ui.find({ key: 'compact' })).toBeUndefined()
157
- // The desktop's Log is the route itself.
158
- for (const key of surface === 'terminal' ? ['auto', 'warm', 'log', 'agents', 'settings'] : ['auto', 'warm', 'agents', 'settings']) {
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('compact'), handoff: fn('handoff'), dismiss: fn('dismiss'), toggleSave: fn('save'), openSettings: fn('settings'), noteInput: fn('note'),
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 700 px (Korean, Chinese), 770 px (English, Japanese), 870 px (Spanish, Portuguese)', async ($, on) => {
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' ? 770 : 700) / 8.8)
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
+ })
@@ -319,7 +319,7 @@ declare module 'claude-code' {
319
319
  judgeDown: string | null
320
320
  advice: string | null
321
321
  handoff: { stage: HandoffStage; kind: HandoffKind; text?: string } | null
322
- panel: 'settings' | 'setup' | 'compact' | 'handoff' | null
322
+ panel: 'settings' | 'setup' | 'compact' | 'clear' | 'handoff' | null
323
323
  busy: boolean
324
324
  decidingSince: number | null
325
325
  heldPick: Pick | null
@@ -1028,11 +1028,13 @@ async function probeProvider(body, probeFetch = fetchWithTimeout) {
1028
1028
  const responses = openai && body.wire === "responses" && !body.preset;
1029
1029
  const checkUrl = responses ? responsesUrl(body.url) : openai ? chatCompletionsUrl(body.url) : messagesUrl(body.url);
1030
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.
1031
1033
  const checkBody = responses
1032
1034
  ? { model: checkModel, max_output_tokens: 16, stream: false, store: false, input: "hi" }
1033
1035
  : openai
1034
- ? { model: checkModel, max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }] }
1035
- : { model: checkModel, max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
1036
+ ? { model: checkModel, max_tokens: 16, stream: false, messages: [{ role: "user", content: "hi" }] }
1037
+ : { model: checkModel, max_tokens: 16, messages: [{ role: "user", content: "hi" }] };
1036
1038
  const label = responses ? "responses endpoint" : openai ? "chat completions endpoint" : "messages endpoint";
1037
1039
  try {
1038
1040
  const response = await probeFetch(checkUrl, {
@@ -1179,8 +1181,9 @@ function effortUndeclared(p) {
1179
1181
  *
1180
1182
  * Read again here rather than reusing what the job started with: the user may have saved from the
1181
1183
  * GUI while the measurements ran, and writing back a stale copy would silently revert that save.
1182
- * Only a model that is still present is touched, and only into a blank field — a `wire` or
1183
- * `effortLevels` already on the model is the user's, and an explicitly empty array is a decision
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
1184
1187
  * (it disables the provider fallback), not an empty slot. A model whose measurement errored is
1185
1188
  * left entirely alone. The result is validated with the same `validate` the GUI's save uses, so a
1186
1189
  * bad edit is refused rather than written.
@@ -1203,12 +1206,14 @@ function applyMeasurements(configFile, results) {
1203
1206
  const model = provider.models.find((entry) => entry.id === result.id);
1204
1207
  if (!model)
1205
1208
  continue;
1206
- const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}`;
1209
+ const before = `${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}|${model.contextWindow ?? "∅"}`;
1207
1210
  if (model.wire === undefined)
1208
1211
  model.wire = result.wire;
1209
1212
  if (model.effortLevels === undefined && result.effortLevels !== undefined)
1210
1213
  model.effortLevels = result.effortLevels;
1211
- if (`${model.wire ?? ""}|${model.effortLevels ? model.effortLevels.join(",") : "∅"}` !== before)
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)
1212
1217
  applied++;
1213
1218
  }
1214
1219
  }
@@ -1249,6 +1254,56 @@ export function measureUnsettledModels(deps) {
1249
1254
  }
1250
1255
  return started;
1251
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
+ }
1252
1307
  /**
1253
1308
  * Declare each Claude release the account has gained on every native Anthropic provider that
1254
1309
  * declares an older model of the same family (`newClaudeReleases`). The declared list was whatever
@@ -1328,6 +1383,10 @@ async function runMeasureJob(jobId, providerName, models, deps) {
1328
1383
  const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) }, { discover: discover && !known });
1329
1384
  if (stated && measured.wire)
1330
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;
1331
1390
  job.results.push(measured);
1332
1391
  job.done++;
1333
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 both for
9
- // every model OpenCode serves: `reasoning_options` lists the effort values, and `provider.npm` names
10
- // the SDK the OpenCode client speaks to it with. It is also more accurate than asking: a 200 only
11
- // says a level was not refused, and glm-5.3-flash answered 200 to seven levels where the catalogue
12
- // lists `low`/`high`/`max` — the rest were accepted and, as far as anything can tell, ignored. And
13
- // muse-spark-1.3-contributor names `max` in its own error message, refuses it when sent, and the
14
- // catalogue never lists it.
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
- return { ...(wire ? { wire } : {}), ...(effortLevels ? { effortLevels } : {}) };
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
  /**
@@ -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: a few one-token requests per model whose
177
- // effort nobody has measured yet, so effort works without the owner re-saving the provider.
178
- setTimeout(() => measureUnsettledModels(adminDeps), 60_000).unref();
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. */