dsh-cline-pass 0.2.4 → 0.2.6

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/lib/adapter.js CHANGED
@@ -54,11 +54,16 @@ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16
54
54
  */
55
55
  export const DEFAULT_CONTEXT_WINDOW = 128000
56
56
  /**
57
- * Conservative per-request output cap for a model the catalog does not
58
- * describe. It must stay well below the smallest published window, because
59
- * the default is materialized into requests whose callers omit `maxTokens`.
57
+ * Per-request output budget for a model whose caller names no `maxTokens`.
58
+ *
59
+ * A budget, not a ceiling: the harness materializes it into requests that omit
60
+ * `maxTokens` and reserves the same number as completion room before it compacts,
61
+ * so this is the number that decides how much of the window stays available to the
62
+ * conversation. 64k is far past any single response a tool-calling agent emits
63
+ * while keeping the reserve well under the point where it would start displacing
64
+ * the compaction ratio. The published ceiling clamps it per model.
60
65
  */
61
- export const DEFAULT_MAX_TOKENS = 32000
66
+ export const DEFAULT_MAX_TOKENS = 64000
62
67
  /**
63
68
  * Request-image budget applied when the caller names none.
64
69
  *
@@ -249,31 +254,88 @@ function collectImageParts(blocks, images, target = []) {
249
254
  /**
250
255
  * Convert the harness conversation to wire messages.
251
256
  *
252
- * Tool results become standalone `{ role: 'tool' }` messages. A user message
253
- * carrying an image switches to the OpenAI content-part array; a text-only one
254
- * keeps the plain-string form.
257
+ * Tool results become standalone `{ role: 'tool' }` messages, in both harness
258
+ * shapes: the current one, where the result IS a `tool`-role message carrying
259
+ * `toolCallId`, and the one dsh < 0.2.0 produced, a user message wrapping a
260
+ * `tool-result` block. A user message carrying an image switches to the OpenAI
261
+ * content-part array; a text-only one keeps the plain-string form.
255
262
  *
256
263
  * An image nested in a tool result (read_image, a screenshot tool) has no
257
264
  * OpenAI `tool` wire spelling, so it rides a following user message instead —
258
- * the same shape the shipped adapters use.
265
+ * the same shape the shipped adapters use. Deferring that message until the
266
+ * next non-tool message keeps a parallel group's answers contiguous.
267
+ *
268
+ * `developer` becomes `system`. The harness uses that role for its own
269
+ * system-level notices — the tool-registry change it appends when the tool set
270
+ * moves — and the newer OpenAI spelling is not accepted by every
271
+ * OpenAI-compatible gateway, while `system` is. Sending it as a user turn (what
272
+ * an unhandled role fell through to) also misrepresents it as something the user
273
+ * said.
274
+ *
275
+ * The group guard below is the same rule stated once: an assistant message
276
+ * carrying `tool_calls` must be followed immediately by a `tool` message per id.
277
+ * Any other message that would land inside that run — a notice, a user turn, the
278
+ * image message a tool-result image needs — is held back until the group closes,
279
+ * because a gateway that validates the rule rejects the whole request with
280
+ * "insufficient tool messages following tool_calls message".
259
281
  */
260
282
  function serializeMessages(messages, images) {
261
283
  const wire = []
262
284
  let pendingToolImages = []
285
+ /** Call ids of the most recent assistant tool_calls still awaiting an answer. */
286
+ let awaiting = []
287
+ /** Messages held back so they cannot split the group; emitted in order. */
288
+ let deferred = []
289
+
290
+ const flushDeferred = () => {
291
+ if (deferred.length === 0) return
292
+ for (const held of deferred) wire.push(held)
293
+ deferred = []
294
+ }
295
+ /**
296
+ * Emit a non-tool message.
297
+ *
298
+ * A message that is not a tool answer cannot go out while a group is open, so
299
+ * it waits. With no group open this is a plain push, which is every ordinary
300
+ * conversation and keeps that output unchanged.
301
+ */
302
+ const emit = (message) => {
303
+ if (awaiting.length > 0) deferred.push(message)
304
+ else wire.push(message)
305
+ }
263
306
  const flushToolImages = () => {
264
307
  if (pendingToolImages.length === 0) return
265
- wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
308
+ const message = { role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] }
266
309
  pendingToolImages = []
310
+ emit(message)
311
+ }
312
+ /** A tool answer: it closes its own id, and the group when it was the last. */
313
+ const answer = (callId, content) => {
314
+ wire.push({ role: 'tool', tool_call_id: callId, content: flattenText(content) || '(no output)' })
315
+ collectImageParts(content, images, pendingToolImages)
316
+ awaiting = awaiting.filter((id) => id !== callId)
317
+ if (awaiting.length === 0) flushDeferred()
267
318
  }
319
+
268
320
  for (const message of messages) {
269
- if (message.role === 'system') {
321
+ if (message.role === 'system' || message.role === 'developer') {
270
322
  flushToolImages()
271
- wire.push({ role: 'system', content: flattenText(message.content) })
323
+ emit({ role: 'system', content: flattenText(message.content) })
272
324
  continue
273
325
  }
274
326
  if (message.role === 'assistant') {
275
327
  flushToolImages()
328
+ // A turn boundary. Anything still held belongs before the new turn, and an
329
+ // unanswered group from the previous turn can no longer be answered.
330
+ flushDeferred()
276
331
  wire.push(serializeAssistant(message))
332
+ awaiting = (message.content ?? []).filter((block) => block.type === 'tool-call').map((block) => block.id)
333
+ continue
334
+ }
335
+ // A harness tool result: the call identity is on the message. No flush here,
336
+ // so the answers to one assistant message's parallel calls stay adjacent.
337
+ if (message.role === 'tool') {
338
+ answer(message.toolCallId, message.content)
277
339
  continue
278
340
  }
279
341
  const toolResults = (message.content ?? []).filter((block) => block.type === 'tool-result')
@@ -286,18 +348,16 @@ function serializeMessages(messages, images) {
286
348
  const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
287
349
  if (hasImage || text.length > 0 || toolResults.length === 0) {
288
350
  flushToolImages()
289
- wire.push({ role: 'user', content: hasImage ? parts : text })
351
+ emit({ role: 'user', content: hasImage ? parts : text })
290
352
  }
291
353
  for (const result of toolResults) {
292
- wire.push({
293
- role: 'tool',
294
- tool_call_id: result.toolCallId,
295
- content: flattenText(result.content) || '(no output)',
296
- })
297
- collectImageParts(result.content, images, pendingToolImages)
354
+ answer(result.toolCallId, result.content)
298
355
  }
299
356
  }
300
357
  flushToolImages()
358
+ // A group the history never closed leaves nothing to wait for; whatever was
359
+ // held still belongs in the request, just after the answers that do exist.
360
+ flushDeferred()
301
361
  return wire
302
362
  }
303
363
 
package/lib/catalog.js CHANGED
@@ -165,11 +165,44 @@ function pick(configured, published, fallback) {
165
165
  */
166
166
  const SEAM_MODALITIES = new Set(['text', 'image'])
167
167
 
168
+ /**
169
+ * The output budget to advertise for one model.
170
+ *
171
+ * `defaultMaxTokens` is what a request asks for, not what the model can emit, and
172
+ * the harness spends it twice: it fills a request's `max_tokens` from it, and it
173
+ * reserves the same number as completion room before deciding to compact. So the
174
+ * published output ceiling clamps the configured budget rather than becoming it.
175
+ * Reporting the ceiling instead reserves that much of every window — at the
176
+ * shipped 384k ceiling compaction moves from 80% of a 1M window down to 55% — and
177
+ * a ceiling equal to the window leaves the compaction policy no message budget at
178
+ * all, which it answers with an error rather than a reading.
179
+ *
180
+ * A budget is also never taken past half the window: reserving more than half of it
181
+ * leaves the conversation less room than a single response, which is the state the
182
+ * policy refuses. Every shipped model still clears the harness's headroom at that
183
+ * bound, so the guard only ever catches a hand-set value.
184
+ *
185
+ * @param budget - the configured per-request budget, when one is set.
186
+ * @param ceiling - the model's published output ceiling, when one is known.
187
+ * @param contextWindow - the resolved window, when one is known.
188
+ * @returns the tokens to advertise, or undefined when nothing constrains it.
189
+ */
190
+ function resolveOutputBudget(budget, ceiling, contextWindow) {
191
+ const wanted = Number.isFinite(budget) && budget > 0 ? budget : undefined
192
+ const most = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : undefined
193
+ const chosen = wanted === undefined ? most : most === undefined ? wanted : Math.min(wanted, most)
194
+ if (chosen === undefined) return undefined
195
+ const half = Number.isFinite(contextWindow) && contextWindow > 0 ? Math.floor(contextWindow / 2) : undefined
196
+ return half === undefined ? chosen : Math.min(chosen, half)
197
+ }
198
+
168
199
  /**
169
200
  * Resolve the metadata seam fields for one model.
170
201
  *
171
202
  * Precedence per field: the configured override, then the shipped catalog, then
172
- * what the last official scan published, then the route-wide fallback.
203
+ * what the last official scan published, then the route-wide fallback. The output
204
+ * budget is the exception — there the published figure is a ceiling that bounds the
205
+ * configured budget, which is why it goes through {@link resolveOutputBudget}.
173
206
  *
174
207
  * The live scan sits below the shipped table rather than above it because the
175
208
  * shipped entry is curated: models.dev reports `audio`/`video`/`pdf`, and the
@@ -185,7 +218,11 @@ export function resolveModelMetadata(provider, model, override, fallback, discov
185
218
  const published = { ...(discovered ?? {}), ...(catalogEntry(model) ?? {}) }
186
219
  const name = pick(override?.name, published.name, model)
187
220
  const contextWindow = pick(override?.contextWindow, published.contextWindow, fallback.contextWindow)
188
- const maxTokens = pick(override?.maxTokens, published.maxTokens, fallback.maxTokens)
221
+ const maxTokens = resolveOutputBudget(
222
+ fallback.maxTokens,
223
+ pick(override?.maxTokens, published.maxTokens, undefined),
224
+ contextWindow,
225
+ )
189
226
  const modality = pick(override?.input, published.input ?? published.inputModalities, undefined)
190
227
  const reasoning = override?.reasoning ?? published.reasoning ?? fallback.reasoning
191
228
  const offered = (modality ?? ['text']).filter((entry) => SEAM_MODALITIES.has(entry))
package/lib/client.js CHANGED
@@ -250,6 +250,14 @@ window.__ModuleLoader__.load({
250
250
  '.cp-pill-pop{position:absolute;bottom:calc(100% + 8px);right:0;z-index:40;min-width:236px}',
251
251
  '.cp-pill-card{background:var(--dsw-alias-bg-layer-1);border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:12px 14px;display:flex;flex-direction:column;gap:9px}',
252
252
  '.cp-pill-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}',
253
+ // The heading row carries the account switcher in a pool, so the title and
254
+ // the arrows sit on one line and the reading keeps the width below them.
255
+ '.cp-pill-head{display:flex;align-items:center;justify-content:space-between;gap:10px}',
256
+ '.cp-pill-nav{display:flex;align-items:center;gap:5px;min-width:0}',
257
+ '.cp-pill-arrow{border:0.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-radius:6px;width:18px;height:18px;line-height:1;font:inherit;font-size:12px;cursor:pointer;padding:0;flex:none}',
258
+ '.cp-pill-arrow:hover{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}',
259
+ '.cp-pill-who{font-size:11.5px;color:var(--dsw-alias-label-secondary);max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
260
+ '.cp-pill-pos{font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none}',
253
261
  '.cp-pill-row{display:flex;flex-direction:column;gap:4px}',
254
262
  '.cp-pill-row-head{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--dsw-alias-label-primary)}',
255
263
  '.cp-pill-percent{font-variant-numeric:tabular-nums;font-weight:600}',
@@ -1546,16 +1554,24 @@ window.__ModuleLoader__.load({
1546
1554
  * One reading per window: the official percentage, plus when it resets.
1547
1555
  */
1548
1556
  function UsageCard(props) {
1549
- const { t, usage, disabled, actions, unavailable, activeAccount, accountMode } = props
1557
+ const { t, usage, disabled, actions, unavailable, activeAccount, accountMode, effectiveAccount } = props
1550
1558
  const fetched = Number(usage?.fetchedAt ?? 0) > 0
1551
1559
  const accounts = usage?.accounts ?? []
1552
1560
  // A pool is shown one account at a time. Stacking every account turned the
1553
1561
  // card into a wall of bars and pushed the one reading a user came for below
1554
1562
  // the fold, so the arrows — not a scroll — are what moves between accounts.
1555
- const [picked, setPicked] = React.useState(0)
1563
+ //
1564
+ // `null` means "still following the account a request would use". Opening on
1565
+ // the first entry puts the reading a user came for behind an arrow, and the
1566
+ // reading is per account, so the first entry answers a question about a key
1567
+ // that may not be about to be spent. Once they page, their choice wins: the
1568
+ // cursor moving underneath must not yank the card away mid-review.
1569
+ const [picked, setPicked] = React.useState(null)
1570
+ const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount ?? ''))
1571
+ const at = picked === null ? Math.max(0, followed) : picked
1556
1572
  // The selection is an index, so a shorter list — an account removed, or its
1557
1573
  // key deleted — can leave it past the end. Clamp instead of rendering nothing.
1558
- const index = accounts.length === 0 ? 0 : Math.min(Math.max(picked, 0), accounts.length - 1)
1574
+ const index = accounts.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
1559
1575
  const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
1560
1576
  const windowRow = (type, key, limit) => {
1561
1577
  const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
@@ -1750,12 +1766,16 @@ window.__ModuleLoader__.load({
1750
1766
  key: 'usage',
1751
1767
  t,
1752
1768
  usage: data.usage,
1753
- // Which account the pool would use, resolved the same way the accounts
1754
- // card resolves it: an empty `activeAccount` means "first enabled", not
1755
- // "none", so the badge has to name that account rather than nobody.
1756
- activeAccount: data.activeAccount !== ''
1757
- ? data.activeAccount
1758
- : (data.accounts.find((account) => account.enabled)?.key ?? ''),
1769
+ // Which account the pool would use. The host resolves it, so round-robin
1770
+ // is covered too: `activeAccount` only decides single mode, and the badge
1771
+ // claims to mark the account a request would actually spend. The fallback
1772
+ // covers a state read from a host that predates the field.
1773
+ activeAccount: data.effectiveAccount !== undefined && data.effectiveAccount !== ''
1774
+ ? data.effectiveAccount
1775
+ : (data.activeAccount !== ''
1776
+ ? data.activeAccount
1777
+ : (data.accounts.find((account) => account.enabled)?.key ?? '')),
1778
+ effectiveAccount: data.effectiveAccount,
1759
1779
  accountMode: data.accountMode,
1760
1780
  unavailable: readFailed,
1761
1781
  disabled,
@@ -1891,13 +1911,28 @@ window.__ModuleLoader__.load({
1891
1911
  /** The pill itself: a compact reading, expanding into the window breakdown. */
1892
1912
  function UsagePillActive(props) {
1893
1913
  const t = props.t
1914
+ // The panel's live snapshot, which an account switch updates at once. The
1915
+ // reading below arrives on a timer, so the account is taken from here: a
1916
+ // pill keyed to its own reading would keep naming the previous account for
1917
+ // up to a minute after the user changed it.
1918
+ const panel = React.useSyncExternalStore(props.store.subscribe, props.store.getSnapshot, props.store.getSnapshot)
1894
1919
  const [snapshot, setSnapshot] = React.useState(null)
1895
1920
  const [failed, setFailed] = React.useState(false)
1896
1921
  const [open, setOpen] = React.useState(false)
1922
+ // Which account the popover reads. `null` keeps following the account a
1923
+ // request would use; paging sets it and wins while the popover is open.
1924
+ const [picked, setPicked] = React.useState(null)
1897
1925
  const root = React.useRef(null)
1898
1926
  // Kept across renders so the timer's callback always sees the latest one.
1899
1927
  const read = React.useRef(() => {})
1900
1928
 
1929
+ // Closing forgets the page, so the next look starts from the account a
1930
+ // request would use — which is what someone reopening after switching
1931
+ // accounts expects to see, rather than the page they left behind.
1932
+ React.useEffect(() => {
1933
+ if (!open) setPicked(null)
1934
+ }, [open])
1935
+
1901
1936
  React.useEffect(() => {
1902
1937
  let alive = true
1903
1938
  let busy = false
@@ -1948,7 +1983,30 @@ window.__ModuleLoader__.load({
1948
1983
  }, [open])
1949
1984
 
1950
1985
  const accounts = snapshot?.value?.usage?.accounts ?? []
1951
- const account = accounts.find((entry) => entry.ok === true) ?? accounts[0]
1986
+ // The account a request would use, not merely the first one that answered.
1987
+ // Quota is per account, so showing a healthy reading from an account the
1988
+ // next request will not touch is worse than showing nothing: it answers a
1989
+ // question about a key that is not about to be spent. The host names the
1990
+ // effective account because only it knows the cursor; the fallbacks cover
1991
+ // a reading taken before that account existed.
1992
+ //
1993
+ // The account is read from the panel's snapshot, not from this reading: a
1994
+ // switch lands there at once, while a reading only arrives on a timer. The
1995
+ // reading's own copy is the fallback for a state taken before the store had
1996
+ // one.
1997
+ //
1998
+ // The selection is by index so the arrows can move it, and a cursor that
1999
+ // advances underneath only moves the reading while the popover is closed:
2000
+ // once the user pages, their choice holds for as long as it is open.
2001
+ const effectiveAccount = panel?.data?.effectiveAccount !== undefined
2002
+ ? panel.data.effectiveAccount
2003
+ : (snapshot?.value?.effectiveAccount ?? '')
2004
+ const followed = accounts.findIndex((entry) => String(entry.account) === String(effectiveAccount))
2005
+ const usable = accounts.findIndex((entry) => entry.ok === true)
2006
+ const at = picked === null ? (followed >= 0 ? followed : Math.max(0, usable)) : picked
2007
+ const index = accounts.length === 0 ? 0 : Math.min(Math.max(at, 0), accounts.length - 1)
2008
+ const account = accounts[index]
2009
+ const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
1952
2010
  const limits = account?.limits ?? []
1953
2011
  const byType = new Map(limits.map((limit) => [String(limit.type), limit]))
1954
2012
  const percentOf = (type) => {
@@ -1978,7 +2036,26 @@ window.__ModuleLoader__.load({
1978
2036
  h('button', { key: 'r', type: 'button', className: 'cp-pill-retry', onClick: () => read.current() }, t('usagePillRetry')),
1979
2037
  ])
1980
2038
  : h('div', { key: 'card', className: 'cp-pill-card' }, [
1981
- h('div', { key: 'title', className: 'cp-pill-title' }, t('usagePillTitle')),
2039
+ h('div', { key: 'head', className: 'cp-pill-head' }, [
2040
+ h('span', { key: 't', className: 'cp-pill-title' }, t('usagePillTitle')),
2041
+ // A pool is read one account at a time, the way the settings card reads
2042
+ // it: the quota is per account, so the arrows move between them rather
2043
+ // than stacking every window at once. Absent with a single account,
2044
+ // where there is nowhere to go.
2045
+ accounts.length <= 1 ? null : h('span', { key: 'nav', className: 'cp-pill-nav' }, [
2046
+ h('button', {
2047
+ key: 'prev', type: 'button', className: 'cp-pill-arrow',
2048
+ title: t('usagePrev'), 'aria-label': t('usagePrev'), onClick: () => step(-1),
2049
+ }, '‹'),
2050
+ h('span', { key: 'who', className: 'cp-pill-who', title: account?.displayName ?? account?.account ?? '' },
2051
+ account?.displayName ?? account?.account ?? ''),
2052
+ h('span', { key: 'pos', className: 'cp-pill-pos' }, `${index + 1} / ${accounts.length}`),
2053
+ h('button', {
2054
+ key: 'next', type: 'button', className: 'cp-pill-arrow',
2055
+ title: t('usageNext'), 'aria-label': t('usageNext'), onClick: () => step(1),
2056
+ }, '›'),
2057
+ ]),
2058
+ ]),
1982
2059
  rows === null
1983
2060
  ? h('div', { key: 'empty', className: 'cp-pill-fail' }, t('usagePillUnavailable'))
1984
2061
  : rows.map(([type, key, limit]) => {
@@ -2101,7 +2178,18 @@ window.__ModuleLoader__.load({
2101
2178
  // read has landed. Read it at render time rather than capturing it:
2102
2179
  // the pill can mount before the first read completes.
2103
2180
  route: () => controller.store.getSnapshot().data?.provider ?? NS,
2104
- readUsage: async () => await controller.call('usage', {}),
2181
+ // The panel's own snapshot. Switching accounts is a panel action that
2182
+ // updates this store at once, whereas a usage reading is only taken on
2183
+ // mount and on a timer — so the account is read from here, and a pill
2184
+ // keyed to its own reading names the previous account until the poll.
2185
+ store: controller.store,
2186
+ readUsage: async () => {
2187
+ const value = await controller.call('usage', {})
2188
+ // Keep the panel's snapshot as fresh as this reading, so whatever the
2189
+ // host has just named is visible everywhere without waiting a minute.
2190
+ controller.applyState(value)
2191
+ return value
2192
+ },
2105
2193
  t: translate,
2106
2194
  }),
2107
2195
  }, UsagePill))
package/lib/index.js CHANGED
@@ -89,6 +89,12 @@ export const AccountProfile = z.object({
89
89
  export const ModelProfile = z.object({
90
90
  name: z.string().default(''),
91
91
  contextWindow: z.number().default(0),
92
+ /**
93
+ * This model's output ceiling, overriding the published one. It bounds what a
94
+ * request may ask for; it is not itself the ask, because the harness reserves the
95
+ * asked-for budget as completion room before it compacts. Raise `defaultMaxTokens`
96
+ * to spend more of the window on output.
97
+ */
92
98
  maxTokens: z.number().default(0),
93
99
  reasoning: z.union([z.boolean(), z.const(undefined)]).default(undefined),
94
100
  input: z.array(z.string()).default([]),
@@ -135,6 +141,15 @@ export const Config = z.object({
135
141
  perModel: markVolatile(z.dict(PinProfile).default({})),
136
142
  reasoningModels: z.boolean().default(true),
137
143
  defaultContextWindow: z.number().default(DEFAULT_CONTEXT_WINDOW),
144
+ /**
145
+ * What one request asks for as output, and therefore how much of the window the
146
+ * harness holds back as completion room before it compacts.
147
+ *
148
+ * A policy rather than a ceiling: the model's published output capacity is much
149
+ * larger, and reporting that here reserves it on every request — at the shipped
150
+ * 384k figure a 1M window compacts at 55% instead of the 80% the threshold ratio
151
+ * asks for. Each model's published ceiling clamps whatever is set here.
152
+ */
138
153
  defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
139
154
  streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
140
155
  /**
@@ -270,27 +285,53 @@ export function apply(ctx, config) {
270
285
  return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined
271
286
  }
272
287
 
273
- /** Pick the account for the next request and resolve its key. */
274
- const resolveAccount = async ({ exclude = [] } = {}) => {
275
- const cfg = live()
288
+ /**
289
+ * The enabled accounts in the order the router would consider them.
290
+ *
291
+ * Shared by the pick and by the read-only peek the panel uses, because the two
292
+ * have to agree: the peek is what the usage pill shows, and a pill that named
293
+ * an account the next request would not use is exactly the bug it exists to
294
+ * avoid. Keeping one ordering is also what stops the cooldown rule from being
295
+ * applied in one place and forgotten in the other.
296
+ */
297
+ const candidateAccounts = (exclude = []) => {
276
298
  const skip = new Set(exclude.map(String))
277
299
  const enabled = accountProfiles().filter(([, profile]) => profile.enabled)
278
- if (enabled.length === 0) {
279
- throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
280
- }
281
300
  // Accounts that just refused a request are stood down for a while, so a pool
282
301
  // does not spend one failed round trip per call rediscovering the same
283
302
  // exhausted key. When every candidate is cooling the oldest one is used
284
303
  // anyway: a window can reset, and refusing to try at all would be worse.
285
304
  const now = Date.now()
286
305
  const eligible = enabled.filter(([key]) => !skip.has(key) && (cooling.get(key) ?? 0) <= now)
287
- const pool = eligible.length > 0
288
- ? eligible
289
- : enabled.filter(([key]) => !skip.has(key))
290
- if (pool.length === 0) return undefined
291
- const ordered = eligible.length > 0
292
- ? pool
293
- : [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0))
306
+ if (eligible.length > 0) return eligible
307
+ const pool = enabled.filter(([key]) => !skip.has(key))
308
+ return [...pool].sort((left, right) => (cooling.get(left[0]) ?? 0) - (cooling.get(right[0]) ?? 0))
309
+ }
310
+
311
+ /**
312
+ * The account the next request would use, without consuming a round-robin turn.
313
+ *
314
+ * Reading quota is not a request that advances the cursor, so asking which
315
+ * account is in line must not move it either — otherwise showing the user their
316
+ * quota would change which account answers them.
317
+ */
318
+ const effectiveAccount = () => {
319
+ const cfg = live()
320
+ const ordered = candidateAccounts()
321
+ if (ordered.length === 0) return ''
322
+ if (cfg.accountMode === 'roundrobin' && ordered.length > 1) return ordered[roundRobin % ordered.length][0]
323
+ return (ordered.find(([key]) => key === cfg.activeAccount) ?? ordered[0])[0]
324
+ }
325
+
326
+ /** Pick the account for the next request and resolve its key. */
327
+ const resolveAccount = async ({ exclude = [] } = {}) => {
328
+ const cfg = live()
329
+ const enabled = accountProfiles().filter(([, profile]) => profile.enabled)
330
+ if (enabled.length === 0) {
331
+ throw new LlmError('cline-pass: every configured account is disabled; enable one or add another', 'MISSING_CREDENTIAL')
332
+ }
333
+ const ordered = candidateAccounts(exclude)
334
+ if (ordered.length === 0) return undefined
294
335
  let picked
295
336
  if (cfg.accountMode === 'roundrobin' && ordered.length > 1) {
296
337
  picked = ordered[roundRobin % ordered.length]
@@ -556,6 +597,11 @@ export function apply(ctx, config) {
556
597
  }
557
598
  })),
558
599
  readCredential: async (ref) => (await resolveKey(credentialRef(ref))) ?? '',
600
+ // The account a request would use right now. The panel reads this so its
601
+ // usage surfaces can name the account a request would actually spend, which
602
+ // is what a pool makes non-obvious: `activeAccount` is only the single-mode
603
+ // choice, and in round-robin it is not the account in line at all.
604
+ effectiveAccount,
559
605
  usageAccounts,
560
606
  setCredential: async (ref, value) => {
561
607
  const credentials = ctx.get('credentials')
package/lib/panel.js CHANGED
@@ -130,6 +130,11 @@ export function createPanel({ control, engine, store }) {
130
130
  settingsAvailable: control.settingsAvailable(),
131
131
  accountMode: config.accountMode === 'roundrobin' ? 'roundrobin' : 'single',
132
132
  activeAccount: String(config.activeAccount ?? ''),
133
+ // The account a request would use, which in a pool is not the configured
134
+ // one: `activeAccount` only decides single mode, and round-robin picks from
135
+ // the cursor. Usage is per account, so a reading has to name the account it
136
+ // belongs to or the surface showing it describes someone else's quota.
137
+ effectiveAccount: control.effectiveAccount(),
133
138
  accounts,
134
139
  ready: usable,
135
140
  models,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Self-contained Cline Pass provider for dsh: register Cline Pass subscription models on the harness LLM seam, enumerate and validate the upstream channels behind each model, pin or exclude them with failover, and drive an account pool. No proxy process and no external cline-pass-switcher install required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",