@goodandready/dsh-subscriptions 0.6.60 → 0.6.62

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/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculateAnalytics } from './analytics.js'
1
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
4
  import { homedir } from 'node:os'
@@ -261,6 +262,22 @@ export class HistoryStore {
261
262
  }
262
263
 
263
264
  const first = this.rows[0] // newest first
265
+ const recentDecisions = []
266
+ for (const r of this.rows) {
267
+ if (r && r.decisionTrace) {
268
+ recentDecisions.push({
269
+ ts: r.ts,
270
+ provider: r.provider,
271
+ ref: r.ref,
272
+ model: r.model || null,
273
+ decisionReason: r.decisionTrace.finalOutcome || (r.decisionTrace.attempts && r.decisionTrace.attempts[0] && r.decisionTrace.attempts[0].decisionReason) || 'selected',
274
+ attemptsCount: Array.isArray(r.decisionTrace.attempts) ? r.decisionTrace.attempts.length : 1,
275
+ cascaded: r.decisionTrace.finalOutcome === 'cascade_fallback' || r.kind === 'cascade',
276
+ })
277
+ if (recentDecisions.length >= 10) break
278
+ }
279
+ }
280
+
264
281
  return {
265
282
  totalRequests: total,
266
283
  requests24h: count24h,
@@ -279,6 +296,11 @@ export class HistoryStore {
279
296
  ? Math.round((totalCacheReadTokens / totalPromptTokens) * 1000) / 10
280
297
  : 0,
281
298
  accounts: this.accountsTelemetry(),
299
+ recentDecisions,
282
300
  }
283
301
  }
302
+
303
+ getAnalytics(accounts = [], nowMs = Date.now()) {
304
+ return calculateAnalytics(this.rows, accounts, nowMs)
305
+ }
284
306
  }
@@ -0,0 +1,240 @@
1
+ import { PROVIDERS, displayName } from './refs.js'
2
+
3
+ export function isDeprecatedId(id) {
4
+ return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(String(id || ''))
5
+ }
6
+ import { getVendor } from './vendors/index.js'
7
+ import { getAccountRemainingPercent } from './rotate.js'
8
+
9
+ export const CAPABILITY_TAGS = {
10
+ VISION: 'vision',
11
+ TOOLS: 'tools',
12
+ REASONING: 'reasoning',
13
+ CODING: 'coding',
14
+ FAST: 'fast',
15
+ MATH: 'math',
16
+ }
17
+
18
+ /**
19
+ * Infer context window and capability tags for a given provider and model.
20
+ * @param {string} provider
21
+ * @param {string} modelId
22
+ * @returns {{ contextWindow: number, contextLabel: string, capabilities: string[] }}
23
+ */
24
+ export function inferModelCapabilities(provider, modelId) {
25
+ const id = String(modelId || '').toLowerCase()
26
+ const p = String(provider || '').toLowerCase()
27
+ const caps = new Set([CAPABILITY_TAGS.TOOLS])
28
+ let contextWindow = 128000
29
+ let contextLabel = '128k'
30
+
31
+ // Vision detection
32
+ if (
33
+ id.includes('vision') ||
34
+ id.includes('gpt-4o') ||
35
+ id.includes('gpt-5') ||
36
+ id.includes('gpt-6') ||
37
+ id.includes('claude') ||
38
+ id.includes('gemini') ||
39
+ id.includes('grok-2') ||
40
+ id.includes('grok-3')
41
+ ) {
42
+ caps.add(CAPABILITY_TAGS.VISION)
43
+ }
44
+
45
+ // Reasoning detection
46
+ if (
47
+ id.includes('thinking') ||
48
+ id.includes('reasoning') ||
49
+ id.includes('o1') ||
50
+ id.includes('o3') ||
51
+ id.includes('gpt-5') ||
52
+ id.includes('gpt-6') ||
53
+ id.includes('claude-opus') ||
54
+ id.includes('claude-sonnet-5') ||
55
+ id.includes('claude-fable') ||
56
+ id.includes('claude-3-7') ||
57
+ id.includes('k1.5') ||
58
+ id.includes('r1')
59
+ ) {
60
+ caps.add(CAPABILITY_TAGS.REASONING)
61
+ }
62
+
63
+ // Coding detection
64
+ if (
65
+ id.includes('codex') ||
66
+ id.includes('coder') ||
67
+ id.includes('coding') ||
68
+ id.includes('sonnet') ||
69
+ id.includes('gpt-5') ||
70
+ id.includes('gpt-6') ||
71
+ id.includes('sol') ||
72
+ id.includes('terra') ||
73
+ id.includes('luna') ||
74
+ p === 'codex' ||
75
+ p === 'copilot' ||
76
+ p === 'cursor'
77
+ ) {
78
+ caps.add(CAPABILITY_TAGS.CODING)
79
+ }
80
+
81
+ // Fast / Speed detection
82
+ if (id.includes('flash') || id.includes('mini') || id.includes('haiku') || id.includes('small') || id.includes('fast')) {
83
+ caps.add(CAPABILITY_TAGS.FAST)
84
+ }
85
+
86
+ // Context window mapping
87
+ if (p === 'antigravity' && (id.includes('gemini-2.5') || id.includes('gemini-3'))) {
88
+ contextWindow = id.includes('pro') ? 2000000 : 1000000
89
+ contextLabel = id.includes('pro') ? '2M' : '1M'
90
+ } else if (id.includes('grok-3') || id.includes('1m')) {
91
+ contextWindow = 1000000
92
+ contextLabel = '1M'
93
+ } else if (
94
+ id.includes('claude-') ||
95
+ id.includes('o1') ||
96
+ id.includes('o3') ||
97
+ id.includes('gpt-5') ||
98
+ id.includes('gpt-6') ||
99
+ id.includes('kimi') ||
100
+ id.includes('200k')
101
+ ) {
102
+ contextWindow = 200000
103
+ contextLabel = '200k'
104
+ }
105
+
106
+ return {
107
+ contextWindow,
108
+ contextLabel,
109
+ capabilities: Array.from(caps),
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Calculates pool-wide status and quota for a provider from the account list.
115
+ * @param {string} provider
116
+ * @param {Array<object>} accounts
117
+ * @param {number} [nowMs]
118
+ * @returns {object}
119
+ */
120
+ export function calculatePoolStatus(provider, accounts = [], nowMs = Date.now()) {
121
+ const p = String(provider || '').toLowerCase()
122
+ const matching = (accounts || []).filter((a) => a && String(a.provider).toLowerCase() === p)
123
+
124
+ if (!matching.length) {
125
+ return {
126
+ status: 'unconfigured',
127
+ totalAccounts: 0,
128
+ healthyAccounts: 0,
129
+ quarantinedAccounts: 0,
130
+ cooldownAccounts: 0,
131
+ remainingPercent: null,
132
+ resetAt: null,
133
+ }
134
+ }
135
+
136
+ let healthyCount = 0
137
+ let quarantinedCount = 0
138
+ let cooldownCount = 0
139
+ let maxRemainingPercent = null
140
+ let nearestResetAt = null
141
+
142
+ for (const acc of matching) {
143
+ const isQuarantined = Boolean(acc.quarantineUntil && Number(acc.quarantineUntil) > nowMs)
144
+ const isCooldown = Boolean(acc.cooldownUntil && Number(acc.cooldownUntil) > nowMs)
145
+ const rem = getAccountRemainingPercent(acc)
146
+ const isExhausted = (rem !== null && rem <= 0) || (acc.usagePercent != null && Number(acc.usagePercent) >= 100)
147
+ const hasToken = Boolean(acc.configured && acc.writable !== false)
148
+
149
+ if (isQuarantined) quarantinedCount++
150
+ if (isCooldown) cooldownCount++
151
+
152
+ if (hasToken && !isQuarantined && !isCooldown && !isExhausted) {
153
+ healthyCount++
154
+ if (rem !== null) {
155
+ maxRemainingPercent = maxRemainingPercent === null ? rem : Math.max(maxRemainingPercent, rem)
156
+ }
157
+ }
158
+
159
+ const resetAt = acc.quota?.resetAt ? Number(acc.quota.resetAt) : null
160
+ if (resetAt && resetAt > nowMs) {
161
+ if (!nearestResetAt || resetAt < nearestResetAt) {
162
+ nearestResetAt = resetAt
163
+ }
164
+ }
165
+ }
166
+
167
+ let status = 'available'
168
+ if (healthyCount === 0) {
169
+ if (quarantinedCount === matching.length) {
170
+ status = 'quarantined'
171
+ } else if (cooldownCount === matching.length) {
172
+ status = 'cooldown'
173
+ } else {
174
+ status = 'exhausted'
175
+ }
176
+ } else if (healthyCount < matching.length) {
177
+ status = 'degraded'
178
+ }
179
+
180
+ return {
181
+ status,
182
+ totalAccounts: matching.length,
183
+ healthyAccounts: healthyCount,
184
+ quarantinedAccounts: quarantinedCount,
185
+ cooldownAccounts: cooldownCount,
186
+ remainingPercent: maxRemainingPercent !== null ? Math.round(maxRemainingPercent) : null,
187
+ resetAt: nearestResetAt,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Builds the enriched model catalog across all supported subscription vendors.
193
+ * @param {object} options
194
+ * @param {Array<object>} options.accounts
195
+ * @param {object} [options.liveConfig]
196
+ * @param {boolean} [options.hideDeprecated]
197
+ * @param {number} [options.nowMs]
198
+ * @returns {Array<object>}
199
+ */
200
+ export function buildEnrichedCatalog({
201
+ accounts = [],
202
+ liveConfig = {},
203
+ hideDeprecated = false,
204
+ nowMs = Date.now(),
205
+ } = {}) {
206
+ const result = []
207
+
208
+ for (const provId of PROVIDERS) {
209
+ const vendor = getVendor(provId)
210
+ const pool = calculatePoolStatus(provId, accounts, nowMs)
211
+ const modelsKey = `${provId}Models`
212
+ let rawList = liveConfig && Array.isArray(liveConfig[modelsKey]) && liveConfig[modelsKey].length
213
+ ? liveConfig[modelsKey]
214
+ : (vendor.defaults ? vendor.defaults().models : [])
215
+
216
+ if (!Array.isArray(rawList)) rawList = []
217
+
218
+ for (const item of rawList) {
219
+ const modelId = typeof item === 'string' ? item : (item.id || item.slug || item.name)
220
+ if (!modelId) continue
221
+ if (hideDeprecated && isDeprecatedId(modelId)) continue
222
+
223
+ const modelName = (typeof item === 'object' && (item.name || item.display_name)) ? (item.name || item.display_name) : modelId
224
+ const caps = inferModelCapabilities(provId, modelId)
225
+
226
+ result.push({
227
+ id: modelId,
228
+ name: modelName,
229
+ provider: provId,
230
+ providerName: displayName(provId),
231
+ contextWindow: caps.contextWindow,
232
+ contextLabel: caps.contextLabel,
233
+ capabilities: caps.capabilities,
234
+ pool,
235
+ })
236
+ }
237
+ }
238
+
239
+ return result
240
+ }
package/lib/rotate.js CHANGED
@@ -63,7 +63,7 @@ export function getAccountRemainingPercent(acc) {
63
63
  return null
64
64
  }
65
65
 
66
- export function pickAccount(accounts, nowMs, opts) {
66
+ export function pickAccountWithTrace(accounts, nowMs, opts) {
67
67
  const list = Array.isArray(accounts) ? accounts : []
68
68
  const now = Number(nowMs) || 0
69
69
  const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
@@ -72,6 +72,8 @@ export function pickAccount(accounts, nowMs, opts) {
72
72
  const targetTag = opts && opts.tag
73
73
  const targetSession = opts && opts.sessionId
74
74
 
75
+ const evaluations = []
76
+
75
77
  function isQuotaExhausted(acc) {
76
78
  if (!thr || thr <= 0) return false
77
79
  const q = acc.quota
@@ -96,7 +98,7 @@ export function pickAccount(accounts, nowMs, opts) {
96
98
  return true
97
99
  }
98
100
 
99
- // Sticky Session Pinning (#170)
101
+ // Sticky Session Pinning (#170, #517)
100
102
  if (targetSession) {
101
103
  const pinnedRef = getPinnedAccountRef(targetSession)
102
104
  if (pinnedRef) {
@@ -108,8 +110,42 @@ export function pickAccount(accounts, nowMs, opts) {
108
110
  (pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
109
111
  ) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
110
112
  if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
111
- return pinned
113
+ evaluations.push({
114
+ ref: pinnedRef,
115
+ eligible: true,
116
+ tier: 0,
117
+ reason: 'session_pinned',
118
+ pinned: true,
119
+ })
120
+ return {
121
+ account: pinned,
122
+ trace: {
123
+ chosenRef: pinnedRef,
124
+ decisionReason: 'session_pinned',
125
+ tier: 0,
126
+ evaluations,
127
+ selectionMetrics: {
128
+ pinned: true,
129
+ remainingPercent: getAccountRemainingPercent(pinned),
130
+ healthScore: pinned.healthScore ?? 100,
131
+ },
132
+ },
133
+ }
134
+ } else {
135
+ evaluations.push({
136
+ ref: pinnedRef,
137
+ eligible: false,
138
+ reason: isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted',
139
+ pinned: true,
140
+ })
112
141
  }
142
+ } else if (pinned) {
143
+ evaluations.push({
144
+ ref: pinnedRef,
145
+ eligible: false,
146
+ reason: !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine',
147
+ pinned: true,
148
+ })
113
149
  }
114
150
  }
115
151
  }
@@ -121,7 +157,12 @@ export function pickAccount(accounts, nowMs, opts) {
121
157
  const tiers = [[], [], []]
122
158
 
123
159
  for (const acc of list) {
124
- if (!acc || !acc.hasToken) continue
160
+ if (!acc) continue
161
+ const ref = acc.ref || acc.id || 'unknown'
162
+ if (!acc.hasToken) {
163
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'no_token' })
164
+ continue
165
+ }
125
166
 
126
167
  if (!opts || opts.autoPacing !== false) {
127
168
  if (acc.pacingRisk === undefined) {
@@ -143,34 +184,62 @@ export function pickAccount(accounts, nowMs, opts) {
143
184
  }
144
185
 
145
186
  // VIP Slot Reservation (#175)
146
- if (acc.vipOnly && !isVipRequest) continue
187
+ if (acc.vipOnly && !isVipRequest) {
188
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'vip_reserved' })
189
+ continue
190
+ }
147
191
 
148
192
  // Tag-based filtering (#173)
149
- if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) continue
193
+ if (targetTag && Array.isArray(acc.tags) && !acc.tags.includes(targetTag)) {
194
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'tag_mismatch' })
195
+ continue
196
+ }
150
197
 
151
198
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
199
+ if (isCooldown) {
200
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'cooldown', until: acc.cooldownUntil })
201
+ continue
202
+ }
203
+
152
204
  const inQuarantine = isQuarantined(acc, now)
205
+ if (inQuarantine) {
206
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'quarantine', until: acc.quarantineUntil, quarantineReason: acc.quarantineReason })
207
+ continue
208
+ }
209
+
153
210
  const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
211
+ if (isHardExhausted) {
212
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'hard_exhausted', usagePercent: acc.usagePercent })
213
+ continue
214
+ }
215
+
154
216
  const isZeroQuota = Boolean(acc.quota && (
155
217
  (acc.quota.remaining != null && Number(acc.quota.remaining) <= 0) ||
156
218
  (acc.quota.remainingPercent != null && Number(acc.quota.remainingPercent) <= 0)
157
219
  ) && (!acc.quota.resetAt || Number(acc.quota.resetAt) > now))
158
-
159
- if (isCooldown || inQuarantine || isHardExhausted || isZeroQuota) {
220
+ if (isZeroQuota) {
221
+ evaluations.push({ ref, eligible: false, tier: null, reason: 'zero_quota' })
160
222
  continue
161
223
  }
162
224
 
163
225
  const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
164
226
  if (softExhausted) {
165
227
  tiers[2].push(acc)
228
+ evaluations.push({ ref, eligible: true, tier: 2, reason: 'soft_exhausted', remainingPercent: getAccountRemainingPercent(acc) })
166
229
  continue
167
230
  }
168
231
 
169
- if (!acc.quota) tiers[1].push(acc)
170
- else tiers[0].push(acc)
232
+ if (!acc.quota) {
233
+ tiers[1].push(acc)
234
+ evaluations.push({ ref, eligible: true, tier: 1, reason: 'healthy_unknown_quota' })
235
+ } else {
236
+ tiers[0].push(acc)
237
+ evaluations.push({ ref, eligible: true, tier: 0, reason: 'healthy_known_quota', remainingPercent: getAccountRemainingPercent(acc) })
238
+ }
171
239
  }
172
240
 
173
- for (const tier of tiers) {
241
+ for (let tierIdx = 0; tierIdx < tiers.length; tierIdx++) {
242
+ const tier = tiers[tierIdx]
174
243
  if (tier.length) {
175
244
  tier.sort((a, b) => {
176
245
  // #485: Smart Quota Drainer - prioritize higher burn urgency first
@@ -206,14 +275,46 @@ export function pickAccount(accounts, nowMs, opts) {
206
275
  })
207
276
 
208
277
  const chosen = tier[0]
278
+ const chosenRef = chosen.ref || chosen.id
209
279
  if (targetSession && chosen) {
210
- pinSession(targetSession, chosen.ref || chosen.id)
280
+ pinSession(targetSession, chosenRef)
281
+ }
282
+ return {
283
+ account: chosen,
284
+ trace: {
285
+ chosenRef,
286
+ decisionReason: tierIdx === 0 ? 'healthy_tier0' : tierIdx === 1 ? 'healthy_tier1' : 'tier2_fallback',
287
+ tier: tierIdx,
288
+ evaluations,
289
+ selectionMetrics: {
290
+ tier: tierIdx,
291
+ burnUrgency: chosen.burnUrgency != null ? Number(chosen.burnUrgency) : null,
292
+ pacingRisk: chosen.pacingRisk != null ? Number(chosen.pacingRisk) : null,
293
+ remainingPercent: getAccountRemainingPercent(chosen),
294
+ resetAt: chosen.quota?.resetAt || null,
295
+ weight: chosen.weight || (chosen.isPro ? 5 : 1),
296
+ healthScore: chosen.healthScore ?? 100,
297
+ },
298
+ },
211
299
  }
212
- return chosen
213
300
  }
214
301
  }
215
302
 
216
- return null
303
+ return {
304
+ account: null,
305
+ trace: {
306
+ chosenRef: null,
307
+ decisionReason: 'no_usable_account',
308
+ tier: null,
309
+ evaluations,
310
+ selectionMetrics: null,
311
+ },
312
+ }
313
+ }
314
+
315
+ export function pickAccount(accounts, nowMs, opts) {
316
+ const res = pickAccountWithTrace(accounts, nowMs, opts)
317
+ return res ? res.account : null
217
318
  }
218
319
 
219
320
  export function markCooldown(account, nowMs, cooldownMs, family) {
@@ -1,3 +1,6 @@
1
+ import { defaultConcurrencyQueue } from '../concurrency-queue.js'
2
+ import { calculateAnalytics } from '../analytics.js'
3
+ import { buildEnrichedCatalog } from '../models-catalog.js'
1
4
  // #374: Sensitive GET routes protected by same-origin check
2
5
  import { readFileSync } from 'node:fs'
3
6
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
@@ -36,12 +39,19 @@ export function registerStatusRoutes(ctx, state) {
36
39
  }
37
40
 
38
41
  async function configResponse() {
42
+ const accts = await accountsView()
39
43
  return {
40
44
  ok: true,
41
45
  revision: getCurrentRevision(),
42
46
  config: publicConfig(live()),
43
- accounts: await accountsView(),
47
+ accounts: accts,
44
48
  providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
49
+ modelsCatalog: buildEnrichedCatalog({
50
+ accounts: accts,
51
+ liveConfig: live(),
52
+ hideDeprecated: !!live().hideDeprecatedModels,
53
+ }),
54
+ queue: defaultConcurrencyQueue.stats(),
45
55
  }
46
56
  }
47
57
 
@@ -239,6 +249,30 @@ export function registerStatusRoutes(ctx, state) {
239
249
  }), 'dsh-subscriptions: /status')
240
250
 
241
251
 
252
+ ctx.effect(() => ctx.webServer.register({
253
+ kind: 'exact',
254
+ path: '/dsh-subscriptions/models',
255
+ handler: safeJsonHandler(async (req, res) => {
256
+ if (req.method !== 'GET') {
257
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
258
+ return
259
+ }
260
+ if (!isTrustedSettingsRequest(req, ctx)) {
261
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
262
+ return
263
+ }
264
+ const accts = await accountsView()
265
+ writeJson(res, 200, {
266
+ ok: true,
267
+ models: buildEnrichedCatalog({
268
+ accounts: accts,
269
+ liveConfig: live(),
270
+ hideDeprecated: !!live().hideDeprecatedModels,
271
+ }),
272
+ })
273
+ }),
274
+ }), 'dsh-subscriptions: /models')
275
+
242
276
  ctx.effect(() => ctx.webServer.register({
243
277
  kind: 'exact',
244
278
  path: '/dsh-subscriptions/diagnostics',
@@ -507,4 +541,43 @@ export function registerStatusRoutes(ctx, state) {
507
541
  }),
508
542
  }), 'dsh-subscriptions: /alerts')
509
543
 
544
+
545
+ // #511: Detailed cache and subscription analytics endpoint
546
+ ctx.effect(() => ctx.webServer.register({
547
+ kind: 'exact',
548
+ path: '/dsh-subscriptions/analytics',
549
+ handler: safeJsonHandler(async (req, res) => {
550
+ if (req.method !== 'GET') {
551
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
552
+ return
553
+ }
554
+ if (!isTrustedSettingsRequest(req, ctx)) {
555
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
556
+ return
557
+ }
558
+ const accounts = await accountsView()
559
+ const analytics = typeof history.getAnalytics === 'function'
560
+ ? history.getAnalytics(accounts)
561
+ : calculateAnalytics(history.all(), accounts)
562
+ writeJson(res, 200, { ok: true, analytics })
563
+ }),
564
+ }), 'dsh-subscriptions: /analytics')
565
+
566
+ // #518: Concurrency and request queue status
567
+ ctx.effect(() => ctx.webServer.register({
568
+ kind: 'exact',
569
+ path: '/dsh-subscriptions/queue',
570
+ handler: safeJsonHandler(async (req, res) => {
571
+ if (req.method !== 'GET') {
572
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
573
+ return
574
+ }
575
+ if (!isTrustedSettingsRequest(req, ctx)) {
576
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
577
+ return
578
+ }
579
+ const queueStats = defaultConcurrencyQueue.stats()
580
+ writeJson(res, 200, { ok: true, queue: queueStats })
581
+ }),
582
+ }), 'dsh-subscriptions: /queue')
510
583
  }
@@ -46,3 +46,7 @@ export function getPinnedAccountRef(sessionId) {
46
46
  }
47
47
  return entry.accountRef
48
48
  }
49
+
50
+ export function clearSessionPins() {
51
+ sessionPins.clear()
52
+ }