dsh-cline-pass 0.2.0 → 0.2.2

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/README.en.md CHANGED
@@ -13,7 +13,7 @@ Connect [Cline Pass](https://cline.bot/cline-pass) subscription models to the [D
13
13
 
14
14
  ## Requirements
15
15
 
16
- - dsh `>= 0.1.2-alpha.3 < 0.2.0`
16
+ - dsh `>= 0.1.2-alpha.3 < 0.3.0`
17
17
  - Node.js `>= 20.3`
18
18
  - A Cline Pass API key
19
19
 
@@ -68,7 +68,25 @@ To override defaults, add a `cline-pass` section to your profile's `$DSH_HOME/pr
68
68
  apiKeyEnv: CLINE_PASS_BACKUP_KEY
69
69
  ```
70
70
 
71
- Common settings include `baseURL`, `knownModels`, `models`, `perModel`, `exposeCatalog`, and `historyLimit`. The default gateway is `https://api.cline.bot/api/v1`.
71
+ Common settings include `baseURL`, `knownModels`, `models`, `perModel`, `exposeTools`, `exposeCatalog`, and `historyLimit`. The default gateway is `https://api.cline.bot/api/v1`. `maxConcurrentRequests` caps how many streams each account keeps open to the gateway at once (default 16); further requests wait for a free slot, and `0` lifts the cap.
72
+
73
+ ### Management tools
74
+
75
+ The `cline_pass_*` tools are **not registered by default**. Their eight definitions total roughly 5.5k characters of schema and description that ride in every request's tool list, and managing the route is an occasional need, so the default keeps that cost out of every conversation.
76
+
77
+ Turn them on with **Expose the tools (takes effect after a restart)** in **Settings → Cline Pass**, or in the profile configuration:
78
+
79
+ ```yaml
80
+ - id: cline-pass
81
+ config:
82
+ exposeTools: true
83
+ ```
84
+
85
+ **The value is read when the plugin activates, so a change needs a dsh restart** — a tool already registered cannot be pulled back out of a conversation in progress.
86
+
87
+ The provider route and the setup panel are unaffected — the panel is a browser page rather than a tool, and it stays available with the tools off.
88
+
89
+ > A profile upgraded from 0.2.1 or earlier has no `exposeTools` key, so it adopts the new default of `false` and its `cline_pass_*` tools stop being registered. The panel reports this; tick the checkbox and restart to bring them back.
72
90
 
73
91
  ## Upstream channels
74
92
 
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## 要求
15
15
 
16
- - dsh `>= 0.1.2-alpha.3`
16
+ - dsh `>= 0.1.2-alpha.3 < 0.3.0`
17
17
  - Node.js `>= 20.3`
18
18
  - Cline Pass API Key
19
19
 
@@ -68,7 +68,25 @@ cline_pass_accounts action=mode mode=roundrobin
68
68
  apiKeyEnv: CLINE_PASS_BACKUP_KEY
69
69
  ```
70
70
 
71
- 常用设置包括 `baseURL`、`knownModels`、`models`、`perModel`、`exposeCatalog` 和 `historyLimit`。默认网关地址为 `https://api.cline.bot/api/v1`。
71
+ ### 管理工具
72
+
73
+ `cline_pass_*` 工具**默认不注册**。8 个工具定义合计约 5.5k 字符,会出现在每次请求的工具列表里,而管理路由只是偶尔需要,所以默认让所有对话都不必付这份开销。
74
+
75
+ 需要时在**设置 → Cline Pass** 中勾选「开启工具注入(重启后生效)」,或在 profile 配置里打开:
76
+
77
+ ```yaml
78
+ - id: cline-pass
79
+ config:
80
+ exposeTools: true
81
+ ```
82
+
83
+ **该值在插件激活时读取,改动后需重启 dsh 才生效** —— 已经注册的工具无法从进行中的对话里撤回。
84
+
85
+ 提供商路由和设置面板不受影响 —— 面板是浏览器页面而非工具,工具关闭时仍然可用。
86
+
87
+ > 从 0.2.1 及更早版本升级的 profile 里没有 `exposeTools` 键,因此会采用新的默认值 `false`:升级后 `cline_pass_*` 工具将不再注册。面板会提示,勾选该复选框并在重启后恢复。
88
+
89
+ 常用设置包括 `baseURL`、`knownModels`、`models`、`perModel`、`exposeCatalog` 和 `historyLimit`。默认网关地址为 `https://api.cline.bot/api/v1`。`maxConcurrentRequests` 限制每个账号同时发往网关的流数,默认 16,超出的请求排队等待空位,设为 `0` 表示不限。
72
90
 
73
91
  ## 上游渠道
74
92
 
package/lib/adapter.js CHANGED
@@ -38,6 +38,15 @@ import { parseServerSentEvents } from './engine.js'
38
38
 
39
39
  /** Default maximum idle interval while a stream read is outstanding. */
40
40
  export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300000
41
+ /**
42
+ * Default number of streams one account keeps open to the gateway at once.
43
+ *
44
+ * Twice dsh 0.1.7's default `maxActiveSubagents` (8): a main agent with a full
45
+ * set of subagents, plus a title or compaction call, never queues, while a
46
+ * runaway fan-out still cannot open an unbounded number of upstream streams
47
+ * (CWE-770). `0` lifts the cap.
48
+ */
49
+ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 16
41
50
  /**
42
51
  * Conservative context capacity for a model the catalog does not describe.
43
52
  * Shipped models carry their published window, so this only bounds the
@@ -558,6 +567,81 @@ async function* tapRouting(payloads, observed) {
558
567
  }
559
568
  }
560
569
 
570
+ //#region upstream concurrency
571
+
572
+ /**
573
+ * Per-account slots for streams open to the gateway.
574
+ *
575
+ * A slot is held for a whole attempt — from before `fetch` until the response
576
+ * body is closed — because the stream, not the wait for headers, is what keeps
577
+ * a socket and the account's concurrency busy. Accounts are counted apart, so a
578
+ * stalled account cannot hold up requests routed to a healthy one; absorbing
579
+ * exactly that is what the pool is for.
580
+ *
581
+ * Waiters are served first in, first out, and a freed slot is handed to the
582
+ * next waiter and counted in the same step, so a request arriving in between
583
+ * cannot take it as well. A waiter whose signal aborts leaves the queue at
584
+ * once; a held slot is also freed when its signal aborts, so a consumer that
585
+ * stops reading without closing the stream does not keep it.
586
+ *
587
+ * @returns `{ acquire(key, limit, signal) }`, resolving to a release function
588
+ * that is safe to call more than once. A `limit` below 1 means no limit.
589
+ */
590
+ export function createUpstreamSlots() {
591
+ const pools = new Map()
592
+ const admit = (pool) => {
593
+ while (pool.waiting.length > 0 && pool.active < pool.limit) {
594
+ pool.active += 1
595
+ pool.waiting.shift()()
596
+ }
597
+ }
598
+ const hold = (pool, signal) => {
599
+ let held = true
600
+ const release = () => {
601
+ if (!held) return
602
+ held = false
603
+ signal?.removeEventListener('abort', release)
604
+ pool.active -= 1
605
+ admit(pool)
606
+ }
607
+ signal?.addEventListener('abort', release, { once: true })
608
+ return release
609
+ }
610
+ return {
611
+ async acquire(key, limit, signal) {
612
+ signal?.throwIfAborted()
613
+ let pool = pools.get(key)
614
+ if (pool === undefined) {
615
+ pool = { active: 0, limit: Infinity, waiting: [] }
616
+ pools.set(key, pool)
617
+ }
618
+ // Read on every call, so a changed setting reaches the next request; a
619
+ // raised limit admits the queue before this caller joins it.
620
+ pool.limit = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : Infinity
621
+ admit(pool)
622
+ if (pool.waiting.length === 0 && pool.active < pool.limit) {
623
+ pool.active += 1
624
+ return hold(pool, signal)
625
+ }
626
+ return await new Promise((resolve, reject) => {
627
+ const grant = () => {
628
+ signal?.removeEventListener('abort', abandon)
629
+ resolve(hold(pool, signal))
630
+ }
631
+ const abandon = () => {
632
+ const at = pool.waiting.indexOf(grant)
633
+ if (at !== -1) pool.waiting.splice(at, 1)
634
+ reject(signal.reason)
635
+ }
636
+ pool.waiting.push(grant)
637
+ signal?.addEventListener('abort', abandon, { once: true })
638
+ })
639
+ },
640
+ }
641
+ }
642
+
643
+ //#endregion
644
+
561
645
  /**
562
646
  * The Cline Pass adapter. One instance serves the plugin's provider route(s);
563
647
  * the harness model id IS the gateway's wire model id.
@@ -566,6 +650,7 @@ export class ClinePassAdapter extends LlmAdapter {
566
650
  constructor(config) {
567
651
  super()
568
652
  this.config = config
653
+ this.upstreamSlots = createUpstreamSlots()
569
654
  }
570
655
 
571
656
  providerInfo(provider) {
@@ -668,9 +753,18 @@ export class ClinePassAdapter extends LlmAdapter {
668
753
  const account = await this.config.resolveAccount({ exclude: [...refused] })
669
754
  if (account === undefined) break
670
755
  const body = injectPrefs(buildRequestBody(options, connection, images), meta, attempt)
671
- const attemptStarted = Date.now()
672
756
  const consumer = new AbortController()
673
757
  const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal])
758
+ // The account's slot is taken before the attempt's clock starts: the wait
759
+ // is local, and counting it would teach the channel a latency it never had.
760
+ let release
761
+ try {
762
+ release = await this.upstreamSlots.acquire(account.name, connection.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS, upstream)
763
+ } catch (error) {
764
+ if (options.signal?.aborted) throw new LlmError('Cline Pass request aborted by caller', 'ABORTED', { cause: error })
765
+ throw error
766
+ }
767
+ const attemptStarted = Date.now()
674
768
  const watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
675
769
  try {
676
770
  let response
@@ -847,6 +941,9 @@ export class ClinePassAdapter extends LlmAdapter {
847
941
  if (attempt.upstream !== null && adherence === 'adopted') this.config.learnUpstream(options.model, attempt.upstream, 'ok', '', Date.now() - attemptStarted)
848
942
  return
849
943
  } finally {
944
+ // Held until the attempt ends, response body included, so the slot
945
+ // bounds open streams rather than requests waiting for headers.
946
+ release()
850
947
  watchdog[Symbol.dispose]()
851
948
  }
852
949
  }
package/lib/client.js CHANGED
@@ -119,16 +119,43 @@ window.__ModuleLoader__.load({
119
119
  // track is resolved from that row's own content, so a row whose upstream
120
120
  // is a long pill and a row whose upstream is "(auto)" end up with
121
121
  // different column widths — and nothing lines up with the header.
122
- '.cp-history-table{width:100%;border-collapse:collapse;table-layout:fixed}',
122
+ //
123
+ // `table-layout:auto`, i.e. the browser's own algorithm, and no `min-width`:
124
+ // the table always fits its container, so the log never needs sideways
125
+ // scrolling, and the sizing decision is left to the engine that knows each
126
+ // cell's real content width.
127
+ //
128
+ // That decision matters because the content genuinely does not fit. Measured
129
+ // at this card's real width (about 490px of table inside the settings card,
130
+ // which caps at 760px): the columns need roughly 85 + 208 + 234 + 111 =
131
+ // 638px, so something has to give. Under `table-layout:fixed` plus shares,
132
+ // what gave was the right-hand figures — they were ellipsised to "13…" and
133
+ // "4…", losing the number the reader came for. Left to itself the engine
134
+ // gives way on the token column instead, which simply wraps to a second
135
+ // line: the figures stay whole and nothing is lost.
136
+ '.cp-history-table{width:100%;border-collapse:collapse;table-layout:auto}',
123
137
  // Header and data share one alignment: a table whose labels sit left of
124
138
  // their values is the thing this panel keeps getting asked to fix.
125
139
  '.cp-history-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);text-align:center;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);padding:2px 0 6px;border-bottom:0.5px solid var(--dsw-alias-border-l3)}',
126
140
  '.cp-history-table td{padding:6px 0;border-bottom:0.5px solid var(--dsw-alias-border-l2);font-size:12px;vertical-align:middle;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
127
141
  '.cp-history-table tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
128
- '.cp-history-col-dot{width:18px}',
129
- '.cp-history-col-when{width:62px}',
130
- '.cp-history-col-tokens{width:120px}',
131
- '.cp-history-col-load{width:108px}',
142
+ // Only two columns are pinned, and each to its measured need:
143
+ // 点 16px — the status dot
144
+ // 时间 82px — a clock, or a dated stamp like "09-24 04:48" (needs 85px,
145
+ // but the engine measures a little differently than the
146
+ // intrinsic probe; 82px is where the stamp stops clipping)
147
+ // TOKEN and 延迟 are left to the engine. 延迟 holds single-line figures, so
148
+ // it always gets what it needs — which is the point: previously the model
149
+ // column took every spare pixel while the figures on the right were clipped,
150
+ // and that is the "left too loose, right too tight" shape being fixed.
151
+ '.cp-history-col-dot{width:16px}',
152
+ '.cp-history-col-when{width:82px}',
153
+ // The columns used to sit flush against one another at `padding:0`, which is
154
+ // what made the right-hand figures read as cramped. A little padding on each
155
+ // side of the three content columns separates label from neighbour.
156
+ '.cp-history-table th:nth-child(2),.cp-history-table td:nth-child(2){padding-left:10px;padding-right:10px}',
157
+ '.cp-history-table th:nth-child(4),.cp-history-table td:nth-child(4){padding-left:10px;padding-right:10px}',
158
+ '.cp-history-table th:nth-child(5),.cp-history-table td:nth-child(5){padding-left:10px;padding-right:10px}',
132
159
  '.cp-history-when{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
133
160
  '.cp-history-model-cell{min-width:0}',
134
161
  // The flex stack lives INSIDE the cell, never on the `<td>` itself: a td
@@ -142,7 +169,20 @@ window.__ModuleLoader__.load({
142
169
  '.cp-history-table td.cp-history-tokens{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip;line-height:1.5}',
143
170
  // Three stacked figures read like a request log: label left, value right.
144
171
  '.cp-history-table td.cp-history-load{font-variant-numeric:tabular-nums;white-space:normal;overflow:visible;text-overflow:clip}',
145
- '.cp-history-load-row{display:flex;justify-content:space-between;gap:6px;font-size:11.5px;line-height:1.5}',
172
+ // `space-between` is what keeps the three figures right-aligned with each
173
+ // other across rows, but it also turns every spare pixel of the column into
174
+ // a gap between label and value. Capping the row and centring it makes that
175
+ // surplus invisible instead, so the column can be given generous width at a
176
+ // wide panel without the figures drifting apart.
177
+ '.cp-history-load-row{display:flex;justify-content:space-between;gap:5px;max-width:112px;margin:0 auto;font-size:11.5px;line-height:1.5}',
178
+ // With the table no longer allowed to scroll sideways, a narrow pane can
179
+ // leave this column short of its need. The label must not wrap then — a
180
+ // taller cell would change the row height — so the label stays whole and
181
+ // the figure is the part that gives way, ellipsised with its exact value
182
+ // still available in the row tooltip.
183
+ '.cp-history-load-row>span{white-space:nowrap}',
184
+ '.cp-history-load-row>span:first-child{flex:none}',
185
+ '.cp-history-load-row>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;text-align:right}',
146
186
  '.cp-history-meta{display:flex;align-items:center;justify-content:center;gap:6px;min-width:0;overflow:hidden}',
147
187
  // A failure gets its own row under the entry rather than widening it: the
148
188
  // gateway's messages are long enough to wreck a one-line layout.
@@ -173,8 +213,15 @@ window.__ModuleLoader__.load({
173
213
  '.cp-fold:hover .cp-chevron{color:var(--dsw-alias-label-primary)}',
174
214
  '.cp-set-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px}',
175
215
  '.cp-usage-groups{display:flex;flex-direction:column;gap:12px}',
176
- '.cp-usage-group+.cp-usage-group{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px}',
177
- '.cp-usage-account{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
216
+ // A pool shows one account at a time: the arrows are the only way to reach
217
+ // the others, so they sit on the same row as the name they act on.
218
+ '.cp-usage-pager{display:flex;align-items:center;gap:8px}',
219
+ '.cp-usage-nav{box-sizing:border-box;width:24px;height:24px;flex:none;padding:0;border:0.5px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:14px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}',
220
+ '.cp-usage-nav:hover{border-color:var(--dsw-alias-brand-primary)}',
221
+ '.cp-usage-name{display:flex;align-items:center;gap:8px;min-width:0}',
222
+ '.cp-usage-acct{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
223
+ '.cp-usage-active{border:0.5px solid var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 7px;font-size:11px;font-weight:600;flex:none}',
224
+ '.cp-usage-pos{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
178
225
  '.cp-usage{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
179
226
  '.cp-usage-item{border:0.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
180
227
  '.cp-usage-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
@@ -193,6 +240,7 @@ window.__ModuleLoader__.load({
193
240
  '.cp-usage-model-cost{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}',
194
241
  '.cp-usage-model-tokens{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);min-width:52px;text-align:right}',
195
242
  '.cp-check{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}',
243
+ '.cp-tools-label{cursor:pointer;gap:8px}',
196
244
  '.cp-model-hidden .cp-mono,.cp-model-hidden .cp-check{opacity:.55}',
197
245
  ].join('')
198
246
 
@@ -262,6 +310,9 @@ window.__ModuleLoader__.load({
262
310
  usageUnavailable: '暂时读不到官方额度。',
263
311
  usageEmpty: '官方未返回额度窗口。',
264
312
  usageAccount: '账号 {name}',
313
+ usagePrev: '上一个账号',
314
+ usageNext: '下一个账号',
315
+ usagePosition: '第 {i} / {n} 个',
265
316
  historyWhen: '时间',
266
317
  historyModel: '模型',
267
318
  historyUpstream: '上游',
@@ -286,6 +337,7 @@ window.__ModuleLoader__.load({
286
337
  noHistory: '暂无记录。',
287
338
  busy: '处理中…',
288
339
  settingsUnavailable: '设置服务不可用,改动无法保存。',
340
+ toolsExpose: '开启工具注入(重启后生效)',
289
341
  retry: '重试',
290
342
  }
291
343
  const EN = {
@@ -341,6 +393,9 @@ window.__ModuleLoader__.load({
341
393
  usageUnavailable: 'The official quota is unavailable right now.',
342
394
  usageEmpty: 'The gateway reported no quota windows.',
343
395
  usageAccount: 'account {name}',
396
+ usagePrev: 'Previous account',
397
+ usageNext: 'Next account',
398
+ usagePosition: '{i} of {n}',
344
399
  historyWhen: 'Time',
345
400
  historyModel: 'Model',
346
401
  historyUpstream: 'Upstream',
@@ -365,6 +420,7 @@ window.__ModuleLoader__.load({
365
420
  noHistory: 'Nothing recorded.',
366
421
  busy: 'Working…',
367
422
  settingsUnavailable: 'Settings are read-only; changes will not persist.',
423
+ toolsExpose: 'Expose the tools (takes effect after a restart)',
368
424
  retry: 'Retry',
369
425
  }
370
426
 
@@ -644,6 +700,7 @@ window.__ModuleLoader__.load({
644
700
  setAccountEnabled: (name, enabled) => controller.setAccountEnabled(name, enabled),
645
701
  pinModel: (payload) => controller.pinModel(payload),
646
702
  setModelVisible: (model, visible) => controller.setModelVisible(model, visible),
703
+ setToolsExposed: (value) => controller.setToolsExposed(value),
647
704
  setModelsVisibility: (mode) => controller.setModelsVisibility(mode),
648
705
  probeModel: (model) => controller.probeModel(model),
649
706
  validateModel: (model) => controller.validateModel(model),
@@ -819,6 +876,21 @@ window.__ModuleLoader__.load({
819
876
  return result
820
877
  }
821
878
 
879
+ /**
880
+ * Turn the `cline_pass_*` tools on or off.
881
+ *
882
+ * The host reads the setting when the plugin activates, so this cannot
883
+ * change the running plugin: a tool already registered cannot be pulled
884
+ * back out of a conversation's tool list. The host says as much in its
885
+ * reply, and the panel relays it rather than implying the switch already
886
+ * took effect.
887
+ */
888
+ async setToolsExposed(value) {
889
+ const result = await this.run('tools', async () => await this.call('tools.expose', { value }))
890
+ if (result !== undefined) this.applyState(result)
891
+ return result
892
+ }
893
+
822
894
  /**
823
895
  * Show, hide or swap every subscription model at once.
824
896
  *
@@ -1443,10 +1515,17 @@ window.__ModuleLoader__.load({
1443
1515
  * One reading per window: the official percentage, plus when it resets.
1444
1516
  */
1445
1517
  function UsageCard(props) {
1446
- const { t, usage, disabled, actions, unavailable } = props
1518
+ const { t, usage, disabled, actions, unavailable, activeAccount, accountMode } = props
1447
1519
  const fetched = Number(usage?.fetchedAt ?? 0) > 0
1448
1520
  const accounts = usage?.accounts ?? []
1449
- const anyWindow = accounts.some((entry) => (entry.limits ?? []).length > 0)
1521
+ // A pool is shown one account at a time. Stacking every account turned the
1522
+ // card into a wall of bars and pushed the one reading a user came for below
1523
+ // the fold, so the arrows — not a scroll — are what moves between accounts.
1524
+ const [picked, setPicked] = React.useState(0)
1525
+ // The selection is an index, so a shorter list — an account removed, or its
1526
+ // key deleted — can leave it past the end. Clamp instead of rendering nothing.
1527
+ const index = accounts.length === 0 ? 0 : Math.min(Math.max(picked, 0), accounts.length - 1)
1528
+ const step = (delta) => setPicked((accounts.length + index + delta) % accounts.length)
1450
1529
  const windowRow = (type, key, limit) => {
1451
1530
  const percent = Math.max(0, Math.min(100, Number(limit.percentUsed ?? 0)))
1452
1531
  const left = countdown(t, limit.resetsAt)
@@ -1464,8 +1543,14 @@ window.__ModuleLoader__.load({
1464
1543
  left === '' ? null : h('div', { key: 'note', className: 'cp-usage-note' }, fill(t('usageResetsIn'), { t: left })),
1465
1544
  ])
1466
1545
  }
1467
- /** One account's block: its name when there is more than one, then its windows. */
1468
- const accountGroup = (account) => {
1546
+ /**
1547
+ * One account's block: a name row that doubles as the account switcher,
1548
+ * then its windows.
1549
+ *
1550
+ * The arrows only exist in a pool. With a single account there is nowhere
1551
+ * to go, so they would be two controls that cannot do anything.
1552
+ */
1553
+ const accountGroup = (account, at) => {
1469
1554
  const limits = account.limits ?? []
1470
1555
  const known = new Map(limits.map((limit) => [String(limit.type), limit]))
1471
1556
  const windows = [
@@ -1479,15 +1564,41 @@ window.__ModuleLoader__.load({
1479
1564
  : windows.length === 0
1480
1565
  ? h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1481
1566
  : h('div', { key: 'bars', className: 'cp-usage' }, windows.map(([type, key, limit]) => windowRow(type, key, limit)))
1482
- return h('div', { key: account.account, className: 'cp-usage-group' }, [
1483
- accounts.length > 1 ? h('div', { key: 'name', className: 'cp-usage-account' }, account.account) : null,
1484
- inner,
1485
- ])
1567
+ const pooled = accounts.length > 1
1568
+ const label = account.displayName || account.account
1569
+ // Only a single-account pool has a "current" account: in round-robin every
1570
+ // request rotates, so a badge here would name a fact that keeps changing.
1571
+ const isActive = accountMode !== 'roundrobin' && activeAccount !== '' && account.account === activeAccount
1572
+ const head = pooled
1573
+ ? h('div', { key: 'name', className: 'cp-usage-pager' }, [
1574
+ // Switching accounts re-reads nothing: the whole pool was read in one
1575
+ // request, so paging is local and stays available while the panel is
1576
+ // busy or the settings service is read-only.
1577
+ h('button', {
1578
+ key: 'prev', type: 'button', className: 'cp-usage-nav',
1579
+ title: t('usagePrev'), 'aria-label': t('usagePrev'),
1580
+ onClick: () => step(-1),
1581
+ }, '‹'),
1582
+ h('div', { key: 'who', className: 'cp-usage-name' }, [
1583
+ h('span', { key: 'n', className: 'cp-usage-acct', title: label }, label),
1584
+ isActive ? h('span', { key: 'a', className: 'cp-usage-active' }, t('accountsActive')) : null,
1585
+ h('span', { key: 'p', className: 'cp-usage-pos' }, fill(t('usagePosition'), { i: at + 1, n: accounts.length })),
1586
+ ]),
1587
+ h('button', {
1588
+ key: 'next', type: 'button', className: 'cp-usage-nav',
1589
+ title: t('usageNext'), 'aria-label': t('usageNext'),
1590
+ onClick: () => step(1),
1591
+ }, '›'),
1592
+ ])
1593
+ : null
1594
+ return h('div', { key: String(account.account), className: 'cp-usage-group' }, [head, inner])
1486
1595
  }
1487
1596
  let body
1488
1597
  if (!fetched) body = h('div', { key: 'wait', className: 'cp-muted' }, unavailable === true ? t('usageUnavailable') : t('usageLoading'))
1489
1598
  else if (accounts.length === 0) body = h('div', { key: 'none', className: 'cp-muted' }, t('usageEmpty'))
1490
- else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accounts.map(accountGroup))
1599
+ // `index` is the account on screen; its own key remounts the block so the
1600
+ // windows never animate between two accounts' readings.
1601
+ else body = h('div', { key: 'groups', className: 'cp-usage-groups' }, accountGroup(accounts[index], index))
1491
1602
  const title = accounts.length > 1
1492
1603
  ? `${t('usageTitle')} · ${fill(t('accountsCount'), { n: accounts.length })}`
1493
1604
  : `${t('usageTitle')}${accounts[0] ? ` · ${fill(t('usageAccount'), { name: accounts[0].account })}` : ''}`
@@ -1568,6 +1679,7 @@ window.__ModuleLoader__.load({
1568
1679
  setAccountEnabled: props.setAccountEnabled,
1569
1680
  pinModel: props.pinModel,
1570
1681
  setModelVisible: props.setModelVisible,
1682
+ setToolsExposed: props.setToolsExposed,
1571
1683
  setModelsVisibility: props.setModelsVisibility,
1572
1684
  probeModel: props.probeModel,
1573
1685
  validateModel: props.validateModel,
@@ -1607,6 +1719,13 @@ window.__ModuleLoader__.load({
1607
1719
  key: 'usage',
1608
1720
  t,
1609
1721
  usage: data.usage,
1722
+ // Which account the pool would use, resolved the same way the accounts
1723
+ // card resolves it: an empty `activeAccount` means "first enabled", not
1724
+ // "none", so the badge has to name that account rather than nobody.
1725
+ activeAccount: data.activeAccount !== ''
1726
+ ? data.activeAccount
1727
+ : (data.accounts.find((account) => account.enabled)?.key ?? ''),
1728
+ accountMode: data.accountMode,
1610
1729
  unavailable: readFailed,
1611
1730
  disabled,
1612
1731
  actions,
@@ -1614,6 +1733,21 @@ window.__ModuleLoader__.load({
1614
1733
  // Everything below the accounts card reads through the key that card
1615
1734
  // stores, so none of it exists until there is one.
1616
1735
  data === null || !ready ? null : h(ModelsCard, { key: 'models', t, data, disabled, busy: state.busy, actions }),
1736
+ // Route-wide switches last: they are not about one model or account, and
1737
+ // nothing below them depends on their value.
1738
+ data === null ? null : h('div', { key: 'tools', className: 'cp-card' }, [
1739
+ h('label', { key: 'head', className: 'cp-row cp-tools-label' }, [
1740
+ h('input', {
1741
+ key: 'on',
1742
+ type: 'checkbox',
1743
+ className: 'cp-check',
1744
+ checked: data.exposeTools !== false,
1745
+ disabled,
1746
+ onChange: (event) => actions.setToolsExposed(event.target.checked),
1747
+ }),
1748
+ h('span', { key: 'l' }, t('toolsExpose')),
1749
+ ]),
1750
+ ]),
1617
1751
  // Diagnostic detail, so it stays folded away until asked for.
1618
1752
  data === null || !ready ? null : h('details', { key: 'history', className: 'cp-card' }, [
1619
1753
  foldHeader(`${t('history')} · ${total}`),
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
22
22
  import {
23
23
  ClinePassAdapter,
24
24
  DEFAULT_CONTEXT_WINDOW,
25
+ DEFAULT_MAX_CONCURRENT_REQUESTS,
25
26
  DEFAULT_MAX_TOKENS,
26
27
  DEFAULT_REQUEST_IMAGE_POLICY,
27
28
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
@@ -34,7 +35,7 @@ import { BUILD_TAG } from './protocol.js'
34
35
  import { createStore } from './store.js'
35
36
  import { registerTools } from './tools.js'
36
37
 
37
- export { ClinePassAdapter, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_REQUEST_IMAGE_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS }
38
+ export { ClinePassAdapter, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_CONCURRENT_REQUESTS, DEFAULT_MAX_TOKENS, DEFAULT_REQUEST_IMAGE_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS }
38
39
  export { collectImageRefs, prepareRequestImages, projectImageDimensions, requestImageTarget } from './adapter.js'
39
40
  export { MODEL_CATALOG, REASONING_EFFORTS } from './catalog.js'
40
41
  export { createEngine } from './engine.js'
@@ -136,6 +137,31 @@ export const Config = z.object({
136
137
  defaultContextWindow: z.number().default(DEFAULT_CONTEXT_WINDOW),
137
138
  defaultMaxTokens: z.number().default(DEFAULT_MAX_TOKENS),
138
139
  streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
140
+ /**
141
+ * Streams one account may keep open to the gateway at once.
142
+ *
143
+ * A request past the cap waits for a slot (and can still be cancelled while it
144
+ * waits) instead of opening another stream. `0` lifts the cap.
145
+ */
146
+ maxConcurrentRequests: z.number().default(DEFAULT_MAX_CONCURRENT_REQUESTS),
147
+ /**
148
+ * Whether the `cline_pass_*` management tools are registered at all.
149
+ *
150
+ * The eight definitions are ~5.5k characters of schema and description that
151
+ * ride in every request's tool list, and the route itself does not need them:
152
+ * a conversation that never probes, pins or edits the pool pays for them on
153
+ * every turn. Off leaves the provider and the setup panel exactly as they are
154
+ * — the panel is a browser page, not a tool, so it keeps working.
155
+ *
156
+ * Off by default: the cost is paid by every conversation, while the tools are
157
+ * wanted by the few that manage the route. Turning them on is one checkbox.
158
+ * Existing profiles have no `exposeTools` key, so they adopt this default on
159
+ * upgrade and their tools stop being registered — the panel says so, and the
160
+ * checkbox restores them.
161
+ *
162
+ * Read when the plugin activates, so a change needs a restart.
163
+ */
164
+ exposeTools: z.boolean().default(false),
139
165
  exposeCatalog: z.boolean().default(false),
140
166
  historyLimit: z.number().default(100),
141
167
  /**
@@ -303,6 +329,8 @@ export function apply(ctx, config) {
303
329
  const key = await resolveKey(profile.apiKeyEnv).catch(() => undefined)
304
330
  return {
305
331
  name,
332
+ // The name a person recognizes, for the panel's account switcher.
333
+ displayName: profile.displayName,
306
334
  key: key ?? '',
307
335
  keyConfigured: key !== undefined,
308
336
  baseURL: profile.baseURL || cfg.baseURL,
@@ -342,6 +370,7 @@ export function apply(ctx, config) {
342
370
  maxTokens: cfg.defaultMaxTokens,
343
371
  reasoningModels: cfg.reasoningModels,
344
372
  streamIdleTimeoutMs: cfg.streamIdleTimeoutMs,
373
+ maxConcurrentRequests: cfg.maxConcurrentRequests,
345
374
  }
346
375
  }
347
376
 
@@ -532,9 +561,22 @@ export function apply(ctx, config) {
532
561
  await credentials.set(credentialRef(ref), String(value))
533
562
  },
534
563
  refreshCatalog,
564
+ // Read at activation, so this reports the value the running plugin was
565
+ // built with rather than the current config field.
566
+ exposeTools: () => toolsExposed,
535
567
  }
536
568
 
537
- registerTools(ctx, { store, engine, control })
569
+ // The agent-facing management surface. Optional: the eight definitions are
570
+ // ~5.5k characters of tool list on every request, and a conversation that
571
+ // never probes, pins or edits the pool does not need them. `exposeTools:
572
+ // false` drops them while leaving the provider and the setup panel — a
573
+ // browser page rather than a tool — untouched.
574
+ const toolsExposed = config.exposeTools !== false
575
+ if (toolsExposed) {
576
+ registerTools(ctx, { store, engine, control })
577
+ } else {
578
+ ctx.logger?.info('[cline-pass] cline_pass_* tools are not exposed (exposeTools: false); the provider route and the setup panel are unaffected')
579
+ }
538
580
 
539
581
  // The same surface as an ordinary browser page, for users who would rather
540
582
  // click than compose a tool call. Absent in a headless composition.
package/lib/panel.js CHANGED
@@ -139,6 +139,10 @@ export function createPanel({ control, engine, store }) {
139
139
  usage: store.usage(),
140
140
  catalogCount: Number(store.catalog()?.ids?.length ?? 0),
141
141
  historySize: store.historySize(),
142
+ // Whether the `cline_pass_*` tools are registered. Reported, not switched:
143
+ // the value is read when the plugin activates, so a control here could not
144
+ // take effect without a reload.
145
+ exposeTools: control.exposeTools(),
142
146
  }
143
147
  }
144
148
 
@@ -155,12 +159,18 @@ export function createPanel({ control, engine, store }) {
155
159
  const accounts = await control.usageAccounts()
156
160
  if (accounts.length === 0) throw new Error('every configured Cline Pass account is disabled; enable one first')
157
161
  const readings = await Promise.all(accounts.map(async (account) => {
158
- if (account.keyConfigured !== true) return { account: account.name, ok: false, limits: [], error: `no API key stored for ${account.name}` }
162
+ // The reading is keyed by the configuration name, but the panel's switcher
163
+ // shows the name the accounts card shows, so both travel together.
164
+ const label = {
165
+ account: account.name,
166
+ displayName: String(account.displayName ?? '') || String(account.name),
167
+ }
168
+ if (account.keyConfigured !== true) return { ...label, ok: false, limits: [], error: `no API key stored for ${account.name}` }
159
169
  const result = await fetchUsageLimits({
160
170
  baseURL: account.baseURL || String(config.baseURL ?? ''),
161
171
  apiKey: account.key,
162
172
  })
163
- return { account: account.name, ...result }
173
+ return { ...label, ...result }
164
174
  }))
165
175
  const reading = store.setUsage({ accounts: readings })
166
176
  return { ...(await state()), usage: reading }
@@ -464,6 +474,21 @@ export function createPanel({ control, engine, store }) {
464
474
  }
465
475
  }
466
476
 
477
+ /**
478
+ * Whether the `cline_pass_*` tools are registered.
479
+ *
480
+ * The value is read when the plugin activates, so this writes the setting for
481
+ * the next start rather than changing the running plugin: a tool that is
482
+ * already registered cannot be pulled back out of a conversation's tool list.
483
+ * The response carries `restartRequired` so the panel can say so instead of
484
+ * implying the switch already took effect.
485
+ */
486
+ async function toolsExpose(payload) {
487
+ if (typeof payload?.value !== 'boolean') throw new Error('this action needs a boolean `value`')
488
+ await control.updateConfig({ exposeTools: payload.value })
489
+ return { restartRequired: true, ...(await state()) }
490
+ }
491
+
467
492
  return {
468
493
  state,
469
494
  usage,
@@ -481,6 +506,7 @@ export function createPanel({ control, engine, store }) {
481
506
  'model.test': modelTest,
482
507
  'model.reset': modelReset,
483
508
  'models.refresh': modelsRefresh,
509
+ 'tools.expose': toolsExpose,
484
510
  history,
485
511
  }
486
512
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cline-pass",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -56,18 +56,18 @@
56
56
  ]
57
57
  },
58
58
  "compatibility": {
59
- "dsh": ">=0.1.2-alpha.3 <0.2.0"
59
+ "dsh": ">=0.1.2-alpha.3 <0.3.0"
60
60
  }
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@deepseek-ai/cordis": "^4.0.2",
64
- "@deepseek-ai/dsh-credentials": ">=0.1.2-alpha.3 <0.2.0",
65
- "@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.3 <0.2.0",
66
- "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.3 <0.2.0",
67
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.3 <0.2.0",
68
- "@deepseek-ai/dsh-timeout": ">=0.1.2-alpha.3 <0.2.0",
69
- "@deepseek-ai/dsh-tools": ">=0.1.2-alpha.3 <0.2.0",
70
- "@deepseek-ai/dsh-util-values": ">=0.1.2-alpha.3 <0.2.0",
64
+ "@deepseek-ai/dsh-credentials": ">=0.1.2-alpha.3 <0.3.0",
65
+ "@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.3 <0.3.0",
66
+ "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.3 <0.3.0",
67
+ "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.3 <0.3.0",
68
+ "@deepseek-ai/dsh-timeout": ">=0.1.2-alpha.3 <0.3.0",
69
+ "@deepseek-ai/dsh-tools": ">=0.1.2-alpha.3 <0.3.0",
70
+ "@deepseek-ai/dsh-util-values": ">=0.1.2-alpha.3 <0.3.0",
71
71
  "@deepseek-ai/schemastery": "^3.18.1"
72
72
  }
73
73
  }