@goodandready/dsh-subscriptions 0.4.17 → 0.4.18

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.md CHANGED
@@ -152,6 +152,30 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
152
152
 
153
153
  ---
154
154
 
155
+ ### 12. 🧯 Safe Reset Credits (`v0.4.18`)
156
+ * **Reset Card Visibility**: The Codex account card shows how many ChatGPT quota reset cards are available and when the earliest one expires.
157
+ * **Deliberate Confirmation Flow**: Consuming a card requires an explicit checkbox ("I understand one attempt will be consumed") plus a mandatory 5-second cooldown before the Reset button activates.
158
+ * **Double-Click Proof**: A host-side single-flight gate (synchronous pending lock before the first network await) makes it impossible for a double click or a concurrent call to consume two cards. Uncertain network results return the challenge to "prepared" for a safe retry of the same request.
159
+ * **Honest Results**: Server verdicts are shown verbatim: `reset` / `nothing to reset` (nothing consumed) / `no usable credit` / `already redeemed`.
160
+
161
+ ### 13. 📊 Composer Quota Indicator & Runway Forecast (`v0.4.18`)
162
+ * **Placement**: Renders in the input area next to the model switcher (`conversation.input.right` slot).
163
+ * **Four display modes (`composerQuota` setting)**: `off` / `percent` (`85%`) / `bar` (40px mini bar, green>30 / amber 10-30 / red <10) / `forecast`.
164
+ * **Runway Forecast**: A sliding 24h window of remaining-percent samples (up to 192 points) feeds a recency-weighted least-squares burn rate; below 30 min of observation or <1% consumed it stays `calibrating…`, with no consumption it says `no usage`. Ready state shows `~4.5h` / `~12m`.
165
+ * **Auto-Hide**: No indicator for local Ollama or when nothing is active.
166
+
167
+ ### 14. 🧑‍💻 SUBS Pill & Session Console (`v0.4.18`)
168
+ * **Pill in the Session Header**: `SUBS (N)` in the session header actions area with a pool-health LED: green <50% max usage, amber 50-90%, red ≥90%, gray when nothing is connected.
169
+ * **Modal Console**: Clicking the pill opens an in-session modal listing every account (provider #index, connected/not, cooldown, quota %) and a shortcut to the settings card. Closes on outside click, ✕ or Escape.
170
+
171
+ ### 15. 🫧 Draggable HUD Widget (`v0.4.18`)
172
+ * **Floating Bubble** on `shell.overlay`: a 64px circle with an SVG ring gauge of the active subscription balance, mounted via `createPortal`.
173
+ * **Drag & Dock**: Drag anywhere; it snaps to any screen edge within 24px (peek-style half-hidden until hovered) and remembers its position in `localStorage`.
174
+ * **Frosted Panel**: Hovering reveals a `backdrop-filter: blur(28px)` panel listing every account with usage bars. Clicking the bubble refreshes quota data; data also refreshes every 60 s.
175
+ * **Settings Card Polish (`v0.4.18`)**: Chevron switched to the core `IconChevronDownOutline14` primitive; explicit settings snapshot states (loading / unavailable + Retry) guard against phantom input.
176
+
177
+ ---
178
+
155
179
  ## 📦 Quick Installation
156
180
 
157
181
  ```bash
@@ -177,6 +201,7 @@ dsh-subscriptions:
177
201
  hideDeprecatedModels: false # v0.4.17: filter test/preview/beta/legacy model ids
178
202
  codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
179
203
  codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
204
+ composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
180
205
  # Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
181
206
  accounts:
182
207
  codex:
package/README.ru.md CHANGED
@@ -174,6 +174,7 @@ dsh-subscriptions:
174
174
  hideDeprecatedModels: false # v0.4.17: фильтровать test/preview/beta/legacy id моделей
175
175
  codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
176
176
  codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
177
+ composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
177
178
  # Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
178
179
  accounts:
179
180
  codex:
package/lib/client.js CHANGED
@@ -4,6 +4,9 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} }
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
+ const createPortal = (function () {
8
+ try { return require('react-dom').createPortal } catch { return null }
9
+ })()
7
10
 
8
11
  // Модульный переводчик: доступен всем подпискам компонентов и хелперам.
9
12
  let t = (key) => key
@@ -60,7 +63,48 @@ window.__ModuleLoader__.load({
60
63
  '.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
61
64
  '.dsub-manual{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}' +
62
65
  '.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warning-primary)}' +
63
- '.dsub-verify a{color:var(--dsw-alias-brand-primary)}'
66
+ '.dsub-verify a{color:var(--dsw-alias-brand-primary)}' +
67
+ '.dsub-hud{pointer-events:auto;position:fixed;z-index:10010;width:64px;height:64px;transition:left .2s,top .2s}' +
68
+ '.dsub-hud[data-dragging=true]{transition:none}' +
69
+ '.dsub-hudBtn{position:relative;width:64px;height:64px;padding:0;border:0;border-radius:50%;cursor:grab;display:grid;place-items:center;user-select:none;touch-action:none;' +
70
+ 'background:var(--dsw-alias-bg-layer-3);box-shadow:0 0 0 1px var(--dsw-alias-border-l2),0 8px 24px rgba(0,0,0,.25);backdrop-filter:blur(18px)}' +
71
+ '.dsub-hudBtn:hover{transform:scale(1.05)}' +
72
+ '.dsub-hudBtn:active{cursor:grabbing}' +
73
+ '.dsub-hudRing{position:absolute;inset:4px;width:56px;height:56px;transform:rotate(-90deg)}' +
74
+ '.dsub-hudTrack{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:4}' +
75
+ '.dsub-hudVal{fill:none;stroke:var(--dsub-tone,var(--dsw-alias-state-success-primary));stroke-width:4;stroke-linecap:round;transition:stroke-dashoffset .4s}' +
76
+ '.dsub-hudCore{position:relative;z-index:2;font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}' +
77
+ '.dsub-hud[data-collapsed=true] .dsub-hudBtn{opacity:.45}' +
78
+ '.dsub-hud[data-collapsed=true]:hover .dsub-hudBtn{opacity:1}' +
79
+ '.dsub-hudPanel{pointer-events:none;position:absolute;width:280px;padding:12px;border-radius:14px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 18px 48px rgba(0,0,0,.35);backdrop-filter:blur(28px);opacity:0;transform:translateY(6px);transition:opacity .16s,transform .16s}' +
80
+ '.dsub-hud:hover .dsub-hudPanel{opacity:1;transform:none}' +
81
+ '.dsub-hud[data-dragging=true] .dsub-hudPanel{opacity:0}' +
82
+ '.dsub-hudRow{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:11.5px}' +
83
+ '.dsub-hudRow .dsub-bar{min-width:70px}' +
84
+ '.dsub-hudName{font-weight:600;min-width:52px}' +
85
+ '.dsub-hudPct{margin-left:auto;font-variant-numeric:tabular-nums}' +
86
+ '.dsub-hudHead{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
87
+ '.dsub-hudHint{margin-top:10px;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}' +
88
+ '.dsub-pill{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:11.5px;font-weight:600;cursor:pointer;letter-spacing:.06em}' +
89
+ '.dsub-pill:hover{border-color:var(--dsw-alias-label-dimmed)}' +
90
+ '.dsub-led{width:7px;height:7px;border-radius:50%;flex:none}' +
91
+ '.dsub-ledOk{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 6px var(--dsw-alias-state-success-primary)}' +
92
+ '.dsub-ledWarn{background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 6px var(--dsw-alias-state-warning-primary)}' +
93
+ '.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px var(--dsw-alias-state-error-primary)}' +
94
+ '.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}' +
95
+ '.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb,#000 45%,transparent)}' +
96
+ '.dsub-modal{width:min(560px,92vw);max-height:min(70vh,640px);overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px rgba(0,0,0,.4);padding:16px}' +
97
+ '.dsub-modalHead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}' +
98
+ '.dsub-modalRow{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}' +
99
+ '.dsub-modalName{font-weight:600;font-size:13px}' +
100
+ '.dsub-modalSub{font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
101
+ '.dsub-cq{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);padding:0 6px}' +
102
+ '.dsub-cqBar{position:relative;width:40px;height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
103
+ '.dsub-cqBarFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary)}' +
104
+ '.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-warning-primary)}' +
105
+ '.dsub-cqBarFull .dsub-cqBarFill{background:var(--dsw-alias-state-error-primary)}' +
106
+ '.dsub-cqB{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}' +
107
+ '.dsub-diag{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}'
64
108
 
65
109
  const cssId = 'dsh-subscriptions/settings.module.css'
66
110
  if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
@@ -90,6 +134,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
90
134
  'useOriginHint': 'Leave off unless you registered your own OAuth client for this origin. Vendor CLI clients usually require their published redirect URI plus the paste step.',
91
135
  'privacyMask': 'Mask emails and account identifiers',
92
136
  'privacyMaskHint': 'For demos and screen sharing: emails show as j***n@example.com.',
137
+ 'resetCredits': 'Reset credits',
138
+ 'resetCreditsHint': 'ChatGPT reset cards: shows how many are available. Consuming one is a deliberate action with a 5s confirmation.',
139
+ 'resetAvailable': 'Reset attempts available',
140
+ 'resetExpires': 'earliest expires',
141
+ 'resetAck': 'I understand one attempt will be consumed',
142
+ 'resetWait': 'confirm enabled in',
143
+ 'resetReady': 'ready',
144
+ 'resetGo': 'Reset now',
145
+ 'resetBusy': 'resetting…',
146
+ 'resetDone': 'quota reset',
147
+ 'resetNothing': 'server says nothing needs a reset (no attempt consumed)',
148
+ 'resetNoCredit': 'no usable credit (not consumed)',
149
+ 'resetRedeemed': 'already redeemed earlier',
150
+ 'diagGenerate': 'Generate diagnostics report',
151
+ 'diagCopy': 'Copy report',
152
+ 'diagIssues': 'Open issue tracker',
153
+ 'diagHint': 'The report has no tokens, emails, proxy addresses or personal data.',
154
+ 'subsPill': 'SUBS',
155
+ 'subsModalTitle': 'Subscription console',
156
+ 'subsModalClose': 'Close',
157
+ 'subsLogged': 'connected',
158
+ 'subsNotLogged': 'not connected',
159
+ 'subsCooldown': 'cooldown',
160
+ 'subsOpenSettings': 'Open settings',
161
+ 'hudTitle': 'Subscription balance',
162
+ 'settingsLoading': 'Loading settings…',
163
+ 'settingsUnavailable': 'Settings are unavailable',
164
+ 'settingsRetry': 'Retry',
165
+ 'hudHint': 'drag · edge docks · click refresh',
166
+ 'fcCalibrating': 'calibrating…',
167
+ 'fcIdle': 'no usage',
168
+ 'fcHours': 'h',
169
+ 'fcMinutes': 'm',
93
170
  'reconnect': 'Reconnect',
94
171
  'connect': 'Connect',
95
172
  'disconnect': 'Disconnect',
@@ -157,6 +234,39 @@ const cssId = 'dsh-subscriptions/settings.module.css'
157
234
  'useOriginHint': 'Включайте, только если зарегистрировали собственного клиента OAuth на этот адрес. Штатным консольным клиентам провайдеров нужен их опубликованный адрес возврата и вставка кода вручную.',
158
235
  'privacyMask': 'Маскировать email и учётные записи',
159
236
  'privacyMaskHint': 'Для демонстрации экрана: email отображается как j***n@example.com.',
237
+ 'resetCredits': 'Карты сброса',
238
+ 'resetCreditsHint': 'Карты сброса квоты ChatGPT: показывает, сколько доступно. Списание одной карты — осознанное действие с 5-секундным подтверждением.',
239
+ 'resetAvailable': 'Доступно попыток сброса',
240
+ 'resetExpires': 'ближайшая истекает',
241
+ 'resetAck': 'Я понимаю, что будет списана 1 попытка',
242
+ 'resetWait': 'кнопка активна через',
243
+ 'resetReady': 'готово',
244
+ 'resetGo': 'Сбросить',
245
+ 'resetBusy': 'сбрасываю…',
246
+ 'resetDone': 'квота сброшена',
247
+ 'resetNothing': 'сервер сообщает: сброс не нужен (попытка не списана)',
248
+ 'resetNoCredit': 'нет пригодной карты (не списано)',
249
+ 'resetRedeemed': 'эта карта уже использована ранее',
250
+ 'diagGenerate': 'Сгенерировать диагностический отчёт',
251
+ 'diagCopy': 'Скопировать отчёт',
252
+ 'diagIssues': 'Открыть трекер задач',
253
+ 'diagHint': 'В отчёте нет токенов, email, адресов прокси и персональных данных.',
254
+ 'subsPill': 'SUBS',
255
+ 'subsModalTitle': 'Консоль подписок',
256
+ 'subsModalClose': 'Закрыть',
257
+ 'subsLogged': 'подключен',
258
+ 'subsNotLogged': 'не подключен',
259
+ 'subsCooldown': 'кулдаун',
260
+ 'subsOpenSettings': 'Открыть настройки',
261
+ 'hudTitle': 'Остаток подписки',
262
+ 'settingsLoading': 'Загрузка настроек…',
263
+ 'settingsUnavailable': 'Настройки недоступны',
264
+ 'settingsRetry': 'Повторить',
265
+ 'hudHint': 'перетащи · прилипает к краям · клик — обновить',
266
+ 'fcCalibrating': 'калибровка…',
267
+ 'fcIdle': 'нет расхода',
268
+ 'fcHours': 'ч',
269
+ 'fcMinutes': 'мин',
160
270
  'reconnect': 'Переподключить',
161
271
  'connect': 'Подключить',
162
272
  'disconnect': 'Отключить',
@@ -263,6 +373,69 @@ function PluginCard(props) {
263
373
  const [paste, setPaste] = React.useState({})
264
374
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
265
375
  const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
376
+ const [diag, setDiag] = React.useState('')
377
+ const [reset, setReset] = React.useState({}) // #85 key -> challenge state
378
+ const [, setResetTick] = React.useState(0)
379
+ React.useEffect(() => {
380
+ const anyPending = Object.values(reset).some((r) => r && r.phase === 'confirm' && !r.result)
381
+ if (!anyPending) return undefined
382
+ const id = setInterval(() => setResetTick((n) => n + 1), 1000)
383
+ return () => clearInterval(id)
384
+ }, [reset])
385
+
386
+ // #85: reset credits challenge flow (prepare -> 5s cooldown + ack -> consume).
387
+ const resetPrepare = async (provider, index, key) => {
388
+ setReset((m) => Object.assign({}, m, { [key]: { phase: 'loading' } }))
389
+ try {
390
+ const res = await fetch('/dsh-subscriptions/reset-credits/prepare', {
391
+ method: 'POST',
392
+ headers: { 'Content-Type': 'application/json' },
393
+ body: JSON.stringify({ provider, index }),
394
+ })
395
+ const d = await res.json()
396
+ if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'prepare failed')
397
+ setReset((m) => Object.assign({}, m, { [key]: {
398
+ phase: 'confirm',
399
+ challengeId: d.challengeId,
400
+ availableCount: d.availableCount,
401
+ readyAt: d.readyAt,
402
+ expiresAt: d.expiresAt,
403
+ creditExpiresAt: d.creditExpiresAt || null,
404
+ } }))
405
+ } catch (e) {
406
+ setReset((m) => Object.assign({}, m, { [key]: { phase: 'idle', error: String(e && e.message || e) } }))
407
+ }
408
+ }
409
+ const resetConsume = async (key) => {
410
+ const st = reset[key]
411
+ if (!st || st.phase !== 'confirm' || !st.ack || Date.now() < st.readyAt || st.busy) return
412
+ setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: true }) }))
413
+ try {
414
+ const res = await fetch('/dsh-subscriptions/reset-credits/consume', {
415
+ method: 'POST',
416
+ headers: { 'Content-Type': 'application/json' },
417
+ body: JSON.stringify({ challengeId: st.challengeId, acknowledged: true }),
418
+ })
419
+ const d = await res.json()
420
+ if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'consume failed')
421
+ const code = d.result && d.result.code
422
+ setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: false, result: code, windowsReset: (d.result && d.result.windowsReset) || [] }) }))
423
+ reload().catch(() => {})
424
+ } catch (e) {
425
+ setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { busy: false, error: String(e && e.message || e) }) }))
426
+ }
427
+ }
428
+ // #99: one click - fetch the anonymized report and copy it to the clipboard.
429
+ const genDiag = () => {
430
+ fetch('/dsh-subscriptions/diagnostics', { cache: 'no-store' })
431
+ .then((r) => r.json())
432
+ .then((d) => {
433
+ const txt = d && d.ok ? JSON.stringify(d.report, null, 2) : ('error: ' + ((d && d.error && d.error.message) || 'unknown'))
434
+ setDiag(txt)
435
+ try { navigator.clipboard.writeText(txt) } catch {}
436
+ })
437
+ .catch((e) => setDiag('error: ' + String(e && e.message || e)))
438
+ }
266
439
  const [checkRes, setCheckRes] = React.useState({})
267
440
  const [checking, setChecking] = React.useState({})
268
441
  const [saved, setSaved] = React.useState(false)
@@ -295,7 +468,20 @@ function PluginCard(props) {
295
468
  return () => timers.forEach(clearInterval)
296
469
  })
297
470
 
298
- if (!draft) return React.createElement('div', { className: 'dsub-wrap' }, t('loading'))
471
+ // #81: settings snapshot status - never render phantom inputs before the
472
+ // config snapshot arrives, and offer a retry when the store is unavailable.
473
+ if (!draft) {
474
+ return React.createElement('div', { className: 'dsub-wrap' },
475
+ React.createElement('div', { className: 'dsub-row' },
476
+ React.createElement('span', { className: 'dsub-sub' }, err ? t('settingsUnavailable') : t('settingsLoading')),
477
+ err ? React.createElement('button', {
478
+ type: 'button', className: 'dsub-mini',
479
+ onClick: () => { setErr(''); reload().catch((e) => setErr(String(e && e.message || e))) },
480
+ }, t('settingsRetry')) : null,
481
+ ),
482
+ err ? React.createElement('div', { className: 'dsub-bad' }, err) : null,
483
+ )
484
+ }
299
485
 
300
486
  const slots = Array.isArray(draft.slots) ? draft.slots : []
301
487
  const setSlots = (next) => setDraft((d) => Object.assign({}, d, { slots: next }))
@@ -480,6 +666,20 @@ function PluginCard(props) {
480
666
  React.createElement('div', { className: 'dsub-sub' },
481
667
  t('privacyMaskHint')),
482
668
  ),
669
+ React.createElement('div', { className: 'dsub-block' },
670
+ React.createElement('div', { className: 'dsub-row' },
671
+ React.createElement('button', { type: 'button', className: 'dsub-mini', onClick: genDiag }, t('diagGenerate')),
672
+ React.createElement('a', { href: 'https://github.com/GooDAnDReaDY/dsh-subscriptions/issues', target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('diagIssues')),
673
+ ),
674
+ React.createElement('div', { className: 'dsub-sub' }, t('diagHint')),
675
+ diag ? React.createElement('div', { className: 'dsub-row' },
676
+ React.createElement('button', {
677
+ type: 'button', className: 'dsub-mini',
678
+ onClick: () => { try { navigator.clipboard.writeText(diag) } catch {} },
679
+ }, t('diagCopy')),
680
+ React.createElement('pre', { className: 'dsub-diag' }, diag),
681
+ ) : null,
682
+ ),
483
683
  names.map((prov) => {
484
684
  const rows = slots
485
685
  .map((slot, i) => ({ slot: slot, i: i }))
@@ -601,6 +801,53 @@ function PluginCard(props) {
601
801
  ) : null,
602
802
  )
603
803
  })(),
804
+ (row.slot.provider === 'codex' && on ? (function resetPanel(){
805
+ var st = reset[key] || { phase: 'idle' }
806
+ var now = Date.now()
807
+ function resultText(code) {
808
+ if (code === 'reset') return t('resetDone')
809
+ if (code === 'nothing_to_reset') return t('resetNothing')
810
+ if (code === 'no_credit') return t('resetNoCredit')
811
+ if (code === 'already_redeemed') return t('resetRedeemed')
812
+ return code
813
+ }
814
+ if (st.phase !== 'confirm') {
815
+ return React.createElement('div', { className: 'dsub-row' },
816
+ React.createElement('button', {
817
+ type: 'button', className: 'dsub-mini',
818
+ onClick: () => resetPrepare(row.slot.provider, row.slot.index, key).catch((e) => setErr(String(e && e.message || e))),
819
+ }, st.phase === 'loading' ? '\u2026' : t('resetCredits')),
820
+ React.createElement('div', { className: 'dsub-sub' }, t('resetCreditsHint')),
821
+ )
822
+ }
823
+ var ready = now >= st.readyAt
824
+ var secs = Math.max(0, Math.ceil((st.readyAt - now) / 1000))
825
+ return React.createElement('div', { className: 'dsub-verify' },
826
+ React.createElement('div', null,
827
+ t('resetAvailable') + ': ' + st.availableCount +
828
+ (st.creditExpiresAt ? (' · ' + t('resetExpires') + ' ' + new Date(st.creditExpiresAt).toLocaleString()) : '')
829
+ ),
830
+ React.createElement('label', { className: 'dsub-row' },
831
+ React.createElement('input', {
832
+ type: 'checkbox',
833
+ checked: !!st.ack,
834
+ onChange: (e) => setReset((m) => Object.assign({}, m, { [key]: Object.assign({}, st, { ack: e.target.checked }) })),
835
+ }),
836
+ React.createElement('span', null, t('resetAck')),
837
+ ),
838
+ React.createElement('div', { className: 'dsub-row' },
839
+ React.createElement('button', {
840
+ type: 'button', className: 'dsub-mini',
841
+ disabled: !st.ack || !ready || !!st.busy,
842
+ onClick: () => resetConsume(key).catch((e) => setErr(String(e && e.message || e))),
843
+ }, st.busy ? t('resetBusy') : (ready ? t('resetGo') : (t('resetWait') + ' ' + secs + 's'))),
844
+ !ready && !st.busy ? React.createElement('span', { className: 'dsub-sub' }, t('resetWait') + ' ' + secs + 's') : null,
845
+ st.ack && ready ? React.createElement('span', { className: 'dsub-ok' }, t('resetReady')) : null,
846
+ ),
847
+ st.result ? React.createElement('div', { className: 'dsub-ok' }, resultText(st.result)) : null,
848
+ st.error ? React.createElement('div', { className: 'dsub-bad' }, st.error) : null,
849
+ )
850
+ })() : null),
604
851
  account.accountNotice ? React.createElement('div', { className: 'dsub-verify' }, account.accountNotice) : null,
605
852
  account.refreshError ? React.createElement('div', { className: 'dsub-bad' }, t('reconnectRequired') + ': ' + account.refreshError) : null,
606
853
  (function(){ var r=checkRes[key]; if(!r) return null; var txt=r.ok ? ('ok ' + (r.email||'')) : ('fail ' + (r.error && r.error.message || '')); var cls=r.ok ? 'dsub-ok' : 'dsub-bad'; var q=r.quota; if(q && q.remaining!=null) txt += ' quota:'+q.remaining+(q.limit!=null?'/'+q.limit:''); return React.createElement('div', {className: cls}, txt) })(),
@@ -696,15 +943,7 @@ function PluginCard(props) {
696
943
  React.createElement('div', { className: 'dsub-name' }, t('title')),
697
944
  React.createElement('div', { className: 'dsub-description' }, t('cardIntro')),
698
945
  ),
699
- React.createElement('svg', {
700
- className: 'dsub-chev' + (open ? ' dsub-chevOpen' : ''),
701
- width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
702
- },
703
- React.createElement('path', {
704
- d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': 1.5,
705
- 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
706
- }),
707
- ),
946
+ React.createElement(Chevron, { className: 'dsub-chev' + (open ? ' dsub-chevOpen' : '') }),
708
947
  ),
709
948
  open ? React.createElement('div', { className: 'dsub-body' },
710
949
  React.createElement(SubsSection, props),
@@ -786,9 +1025,370 @@ function PluginCard(props) {
786
1025
  expiresAt: (data && data.expiresAt) || {},
787
1026
  labels: (data && data.labels) || {},
788
1027
  expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
1028
+ composerQuota: (data && data.composerQuota) || 'off',
789
1029
  active: (data && data.active) || null,
790
1030
  }
791
- } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, active: null } }
1031
+ } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, composerQuota: 'off', active: null } }
1032
+ }
1033
+
1034
+ // #84: компактный индикатор квоты активной подписки в области ввода.
1035
+ // Прогноз (runway) считается по скользящему окну сэмплов /status.
1036
+ const fcSamples = { windows: {} }
1037
+
1038
+ function fcObserve(key, remaining, now) {
1039
+ const windows = fcSamples.windows
1040
+ const prev = windows[key]
1041
+ const samples = (prev && prev.samples) || []
1042
+ const last = samples[samples.length - 1]
1043
+ if (!last || (now > last.at && (Math.abs(remaining - last.pct) >= 0.1 || now - last.at >= 15 * 60 * 1000))) {
1044
+ samples.push({ at: now, pct: remaining })
1045
+ }
1046
+ windows[key] = { samples: samples.filter((x) => x.at >= now - 24 * 60 * 60 * 1000).slice(-192) }
1047
+ }
1048
+
1049
+ function fcEstimate(key, remaining, now) {
1050
+ const rec = fcSamples.windows[key]
1051
+ if (!rec) return { status: 'calibrating' }
1052
+ const samples = rec.samples
1053
+ if (samples.length < 3) return { status: 'calibrating' }
1054
+ const first = samples[0]
1055
+ const last = samples[samples.length - 1]
1056
+ const span = last.at - first.at
1057
+ const consumed = first.pct - last.pct
1058
+ if (span < 30 * 60 * 1000 || consumed < 1) return { status: 'calibrating' }
1059
+ const t0 = first.at
1060
+ let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
1061
+ for (const x of samples) {
1062
+ const wx = (x.at - t0) / 3600000
1063
+ const wy = first.pct - x.pct
1064
+ const w = Math.exp((x.at - last.at) / (6 * 3600000))
1065
+ sw += w; sx += w * wx; sy += w * wy; sxx += w * wx * wx; sxy += w * wx * wy
1066
+ }
1067
+ const den = sw * sxx - sx * sx
1068
+ const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
1069
+ if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle' }
1070
+ return { status: 'ready', runwaySeconds: Math.round((remaining / pace) * 3600) }
1071
+ }
1072
+
1073
+ function ComposerQuota(props) {
1074
+ const t = (props && props.t) || ((k) => k)
1075
+ const [state, setState] = React.useState({ mode: 'off', active: null })
1076
+ React.useEffect(() => {
1077
+ let alive = true
1078
+ const pull = () => {
1079
+ refreshLoggedIn().then((r) => {
1080
+ if (!alive) return
1081
+ const mode = r.composerQuota || 'off'
1082
+ const a = r.active
1083
+ if (mode !== 'off' && a && a.provider && a.provider !== 'ollama' && a.usagePercent != null) {
1084
+ const remaining = Math.max(0, 100 - a.usagePercent)
1085
+ const key = 'subs:' + a.provider
1086
+ fcObserve(key, remaining, Date.now())
1087
+ const est = fcEstimate(key, remaining, Date.now())
1088
+ setState({ mode, active: a, remaining, est })
1089
+ } else {
1090
+ setState({ mode, active: a, remaining: null, est: null })
1091
+ }
1092
+ }).catch(() => {})
1093
+ }
1094
+ pull()
1095
+ const id = setInterval(pull, 60 * 1000)
1096
+ return () => { alive = false; clearInterval(id) }
1097
+ }, [])
1098
+ if (state.mode === 'off' || !state.active || state.remaining == null) return null
1099
+ const cls = state.remaining <= 10 ? ' dsub-cqBarFull' : (state.remaining <= 30 ? ' dsub-cqBarWarn' : '')
1100
+ var value = null
1101
+ if (state.mode === 'percent') {
1102
+ value = React.createElement('span', { className: 'dsub-cqB' }, Math.round(state.remaining) + '%')
1103
+ } else if (state.mode === 'bar') {
1104
+ value = React.createElement('span', { className: 'dsub-cqBar' + cls },
1105
+ React.createElement('span', { className: 'dsub-cqBarFill', style: { width: Math.min(100, Math.max(0, state.remaining)) + '%' } }))
1106
+ } else if (state.mode === 'forecast') {
1107
+ var est = state.est
1108
+ if (!est || est.status === 'calibrating') value = React.createElement('span', { className: 'dsub-cqB' }, t('fcCalibrating'))
1109
+ else if (est.status === 'idle') value = React.createElement('span', { className: 'dsub-cqB' }, t('fcIdle'))
1110
+ else {
1111
+ var secs = est.runwaySeconds || 0
1112
+ var txt = secs >= 3600 ? ('~' + (Math.round((secs / 3600) * 10) / 10) + t('fcHours')) : ('~' + Math.max(1, Math.round(secs / 60)) + t('fcMinutes'))
1113
+ value = React.createElement('span', { className: 'dsub-cqB' }, txt)
1114
+ }
1115
+ } else return null
1116
+ return React.createElement('span', { className: 'dsub-cq', title: t('forecast') + ' · ' + state.active.provider }, value)
1117
+ }
1118
+
1119
+ // #83: пилл SUBS(N) в шапке сессии со светодиодом здоровья пула и
1120
+ // модальной консолью аккаунтов (закрытие: крестик, Escape, клик мимо).
1121
+ function SubsPill(props) {
1122
+ const t = (props && props.t) || ((k) => k)
1123
+ const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false })
1124
+ React.useEffect(() => {
1125
+ let alive = true
1126
+ const pull = () => {
1127
+ refreshLoggedIn().then((r) => {
1128
+ if (!alive) return
1129
+ const logged = r.logged || []
1130
+ fetch('/dsh-subscriptions/config', { cache: 'no-store' })
1131
+ .then((res) => res.json())
1132
+ .then((cfg) => {
1133
+ if (!alive) return
1134
+ setState({ logged, usage: r.usage, accounts: (cfg && cfg.accounts) || [], open: false })
1135
+ })
1136
+ .catch(() => { if (alive) setState({ logged, usage: r.usage, accounts: [], open: false }) })
1137
+ }).catch(() => {})
1138
+ }
1139
+ pull()
1140
+ const id = setInterval(pull, 60 * 1000)
1141
+ return () => { alive = false; clearInterval(id) }
1142
+ }, [])
1143
+ React.useEffect(() => {
1144
+ if (!state.open) return undefined
1145
+ const onKey = (e) => { if (e.key === 'Escape') setState((s) => Object.assign({}, s, { open: false })) }
1146
+ document.addEventListener('keydown', onKey)
1147
+ return () => document.removeEventListener('keydown', onKey)
1148
+ }, [state.open])
1149
+ const n = state.logged.length
1150
+ const led = n === 0 ? 'dsub-ledOff' : (state.usage >= 90 ? 'dsub-ledBad' : (state.usage >= 50 ? 'dsub-ledWarn' : 'dsub-ledOk'))
1151
+ const now = Date.now()
1152
+ const rows = state.accounts.map((a, i) => {
1153
+ const isLogged = state.logged.includes(a.provider)
1154
+ const cooled = a.cooldownUntil && a.cooldownUntil > now
1155
+ const pct = a.usagePercent != null ? Math.round(a.usagePercent) : null
1156
+ return React.createElement('div', { className: 'dsub-modalRow', key: i },
1157
+ React.createElement('span', { className: 'dsub-led ' + (pct == null ? 'dsub-ledOff' : (pct >= 90 ? 'dsub-ledBad' : (pct >= 50 ? 'dsub-ledWarn' : 'dsub-ledOk'))) }),
1158
+ React.createElement('span', { className: 'dsub-modalName' }, a.provider + (a.index ? ' #' + a.index : '')),
1159
+ React.createElement('span', { style: { flex: 1 } }),
1160
+ React.createElement('span', { className: 'dsub-modalSub' },
1161
+ (isLogged ? t('subsLogged') : t('subsNotLogged')) +
1162
+ (cooled ? ' · ' + t('subsCooldown') : '') +
1163
+ (pct != null ? ' · ' + pct + '%' : '')
1164
+ ),
1165
+ )
1166
+ })
1167
+ return React.createElement(React.Fragment, null,
1168
+ React.createElement('button', {
1169
+ type: 'button', className: 'dsub-pill', 'aria-expanded': state.open ? 'true' : 'false',
1170
+ onClick: () => setState((s) => Object.assign({}, s, { open: !s.open })),
1171
+ },
1172
+ React.createElement('span', { className: 'dsub-led ' + led }),
1173
+ t('subsPill') + ' (' + n + ')',
1174
+ ),
1175
+ state.open ? React.createElement('div', {
1176
+ className: 'dsub-modalWrap',
1177
+ onClick: (e) => { if (e.target === e.currentTarget) setState((s) => Object.assign({}, s, { open: false })) },
1178
+ },
1179
+ React.createElement('div', { className: 'dsub-modal', role: 'dialog', 'aria-label': t('subsModalTitle') },
1180
+ React.createElement('div', { className: 'dsub-modalHead' },
1181
+ React.createElement('span', { className: 'dsub-h' }, t('subsModalTitle')),
1182
+ React.createElement('button', {
1183
+ type: 'button', className: 'dsub-mini',
1184
+ onClick: () => setState((s) => Object.assign({}, s, { open: false })),
1185
+ }, t('subsModalClose') + ' \u00d7'),
1186
+ ),
1187
+ rows.length ? rows : React.createElement('div', { className: 'dsub-sub' }, t('subsNotLogged')),
1188
+ React.createElement('div', { className: 'dsub-row', style: { marginTop: 12 } },
1189
+ React.createElement('a', {
1190
+ className: 'dsub-mini', href: '#', onClick: (e) => {
1191
+ e.preventDefault()
1192
+ setState((s) => Object.assign({}, s, { open: false }))
1193
+ try { document.querySelector('[data-slot-key=\"settings.plugin.item\"],[data-dsub-open]') } catch {}
1194
+ try {
1195
+ const btn = Array.from(document.querySelectorAll('button')).find((b) => (b.textContent || '').includes(t('title')))
1196
+ if (btn) btn.click()
1197
+ } catch {}
1198
+ },
1199
+ }, t('subsOpenSettings')),
1200
+ ),
1201
+ ),
1202
+ ) : null,
1203
+ )
1204
+ }
1205
+
1206
+ // #82: перетаскиваемый HUD-бабл (shell.overlay) с кольцевой шкалой
1207
+ // остатка активной подписки, прилипанием к краям и frosted-панелью.
1208
+ const HUD_KEY = 'dsh-subscriptions.hud'
1209
+ const HUD_SIZE = 64
1210
+
1211
+ function hudClamp(pos) {
1212
+ const w = window.innerWidth, h = window.innerHeight
1213
+ return {
1214
+ left: Math.min(Math.max(8, pos.left), Math.max(8, w - HUD_SIZE - 8)),
1215
+ top: Math.min(Math.max(8, pos.top), Math.max(8, h - HUD_SIZE - 8)),
1216
+ }
1217
+ }
1218
+
1219
+ function hudLoad() {
1220
+ try {
1221
+ const raw = JSON.parse(localStorage.getItem(HUD_KEY) || 'null')
1222
+ if (raw && typeof raw.left === 'number' && typeof raw.top === 'number') return hudClamp(raw)
1223
+ } catch {}
1224
+ return hudClamp({ left: window.innerWidth - HUD_SIZE - 24, top: window.innerHeight - HUD_SIZE - 120 })
1225
+ }
1226
+
1227
+ function hudDock(pos) {
1228
+ const w = window.innerWidth, h = window.innerHeight
1229
+ const dist = { left: pos.left, right: w - (pos.left + HUD_SIZE), top: pos.top, bottom: h - (pos.top + HUD_SIZE) }
1230
+ const TH = 24
1231
+ let side = null, best = Infinity
1232
+ for (const k of ['left', 'right', 'top', 'bottom']) {
1233
+ if (dist[k] <= TH && dist[k] < best) { best = dist[k]; side = k }
1234
+ }
1235
+ if (!side) return pos
1236
+ const next = { left: pos.left, top: pos.top }
1237
+ if (side === 'left') next.left = 0
1238
+ if (side === 'right') next.left = Math.max(0, w - HUD_SIZE)
1239
+ if (side === 'top') next.top = 0
1240
+ if (side === 'bottom') next.top = Math.max(0, h - HUD_SIZE)
1241
+ return next
1242
+ }
1243
+
1244
+ function UsageBubble(props) {
1245
+ const t = (props && props.t) || ((k) => k)
1246
+ const [pos, setPos] = React.useState(() => (typeof window === 'undefined' ? { left: 0, top: 0 } : hudLoad()))
1247
+ const [accounts, setAccounts] = React.useState([])
1248
+ const [active, setActive] = React.useState(null)
1249
+ const [dragging, setDragging] = React.useState(false)
1250
+ const [busy, setBusy] = React.useState(false)
1251
+ const drag = React.useRef(null)
1252
+ React.useEffect(() => {
1253
+ let alive = true
1254
+ const pull = () => {
1255
+ refreshLoggedIn().then((r) => {
1256
+ if (!alive) return
1257
+ setActive(r.active)
1258
+ fetch('/dsh-subscriptions/config', { cache: 'no-store' })
1259
+ .then((res) => res.json())
1260
+ .then((cfg) => { if (alive) setAccounts((cfg && cfg.accounts) || []) })
1261
+ .catch(() => {})
1262
+ }).catch(() => {})
1263
+ }
1264
+ pull()
1265
+ const id = setInterval(pull, 60 * 1000)
1266
+ return () => { alive = false; clearInterval(id) }
1267
+ }, [])
1268
+ const onPointerDown = (e) => {
1269
+ if (e.button !== 0) return
1270
+ e.preventDefault()
1271
+ e.currentTarget.setPointerCapture(e.pointerId)
1272
+ drag.current = { id: e.pointerId, sx: e.clientX, sy: e.clientY, left: pos.left, top: pos.top, moved: false }
1273
+ }
1274
+ const onPointerMove = (e) => {
1275
+ const d = drag.current
1276
+ if (!d || e.pointerId !== d.id) return
1277
+ if (Math.abs(e.clientX - d.sx) > 3 || Math.abs(e.clientY - d.sy) > 3) { d.moved = true; setDragging(true) }
1278
+ if (!d.moved) return
1279
+ setPos(hudClamp({ left: d.left + (e.clientX - d.sx), top: d.top + (e.clientY - d.sy) }))
1280
+ }
1281
+ const onPointerUp = (e) => {
1282
+ const d = drag.current
1283
+ if (!d || e.pointerId !== d.id) return
1284
+ drag.current = null
1285
+ setDragging(false)
1286
+ setPos((cur) => {
1287
+ const next = d.moved ? hudDock(hudClamp(cur)) : cur
1288
+ try { localStorage.setItem(HUD_KEY, JSON.stringify(next)) } catch {}
1289
+ return next
1290
+ })
1291
+ if (!d.moved) {
1292
+ setBusy(true)
1293
+ refreshLoggedIn().then((r) => {
1294
+ setActive(r.active)
1295
+ return fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
1296
+ }).then((cfg) => setAccounts((cfg && cfg.accounts) || [])).catch(() => {}).finally(() => setBusy(false))
1297
+ }
1298
+ }
1299
+ // Остаток активной подписки: 100 - usagePercent.
1300
+ const remaining = active && active.usagePercent != null ? Math.max(0, 100 - active.usagePercent) : null
1301
+ const tone = remaining == null ? 'var(--dsw-alias-label-tertiary)'
1302
+ : (remaining < 10 ? 'var(--dsw-alias-state-error-primary)'
1303
+ : (remaining < 30 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)'))
1304
+ const R = 26
1305
+ const CIRC = 2 * Math.PI * R
1306
+ const offset = CIRC * (1 - (remaining == null ? 0 : Math.min(100, remaining)) / 100)
1307
+ const rows = accounts.map((a, i) => {
1308
+ const pct = a.usagePercent != null ? Math.round(a.usagePercent) : null
1309
+ return React.createElement('div', { className: 'dsub-hudRow', key: i },
1310
+ React.createElement('span', { className: 'dsub-hudName' }, a.provider + (a.index ? ' #' + a.index : '')),
1311
+ React.createElement('span', { className: 'dsub-bar' },
1312
+ React.createElement('span', { className: 'dsub-barFill', style: { width: (pct == null ? 0 : Math.min(100, pct)) + '%' } })),
1313
+ React.createElement('span', { className: 'dsub-hudPct' }, pct == null ? '—' : pct + '%'),
1314
+ )
1315
+ })
1316
+ if (!createPortal) return null
1317
+ return createPortal(React.createElement('div', {
1318
+ className: 'dsub-hud',
1319
+ 'data-dragging': dragging ? 'true' : undefined,
1320
+ style: { left: pos.left + 'px', top: pos.top + 'px' },
1321
+ },
1322
+ React.createElement('button', {
1323
+ type: 'button', className: 'dsub-hudBtn', 'aria-label': t('hudTitle'),
1324
+ style: { '--dsub-tone': tone },
1325
+ onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp,
1326
+ },
1327
+ React.createElement('svg', { className: 'dsub-hudRing', viewBox: '0 0 56 56', 'aria-hidden': 'true' },
1328
+ React.createElement('circle', { className: 'dsub-hudTrack', cx: 28, cy: 28, r: R }),
1329
+ React.createElement('circle', {
1330
+ className: 'dsub-hudVal', cx: 28, cy: 28, r: R,
1331
+ strokeDasharray: String(CIRC), strokeDashoffset: String(offset),
1332
+ }),
1333
+ ),
1334
+ React.createElement('span', { className: 'dsub-hudCore' },
1335
+ remaining == null ? '—' : (busy ? '\u2026' : Math.round(remaining) + '%'),
1336
+ ),
1337
+ ),
1338
+ React.createElement('div', { className: 'dsub-hudPanel' },
1339
+ React.createElement('div', { className: 'dsub-hudHead' }, t('hudTitle')),
1340
+ rows.length ? rows : React.createElement('div', { className: 'dsub-sub' }, '\u2014'),
1341
+ React.createElement('div', { className: 'dsub-hudHint' }, t('hudHint')),
1342
+ ),
1343
+ ), document.body)
1344
+ }
1345
+
1346
+ function registerUsageBubble(ctx) {
1347
+ ctx.effect(() => {
1348
+ ctx.slots.inject('shell.overlay', () =>
1349
+ ctx.slots.register(
1350
+ {
1351
+ name: 'shell.overlay',
1352
+ id: 'dsh-subscriptions-usage-bubble',
1353
+ order: 40,
1354
+ locale: NS,
1355
+ },
1356
+ (props) => React.createElement(UsageBubble, { t }),
1357
+ ),
1358
+ )
1359
+ }, 'dsh-subscriptions: usage bubble')
1360
+ }
1361
+
1362
+ function registerSubsPill(ctx) {
1363
+ ctx.effect(() => {
1364
+ ctx.slots.inject('conversation.session.header.actions', () =>
1365
+ ctx.slots.register(
1366
+ {
1367
+ name: 'conversation.session.header.actions',
1368
+ id: 'dsh-subscriptions-subs-pill',
1369
+ order: 15,
1370
+ locale: NS,
1371
+ },
1372
+ (props) => React.createElement(SubsPill, { t }),
1373
+ ),
1374
+ )
1375
+ }, 'dsh-subscriptions: subs pill')
1376
+ }
1377
+
1378
+ function registerComposerQuota(ctx) {
1379
+ ctx.effect(() => {
1380
+ ctx.slots.inject('conversation.input.right', () =>
1381
+ ctx.slots.register(
1382
+ {
1383
+ name: 'conversation.input.right',
1384
+ id: 'dsh-subscriptions-composer-quota',
1385
+ order: 5,
1386
+ locale: NS,
1387
+ },
1388
+ (props) => React.createElement(ComposerQuota, { t }),
1389
+ ),
1390
+ )
1391
+ }, 'dsh-subscriptions: composer quota')
792
1392
  }
793
1393
 
794
1394
  // #52: компактный чип в шапке сессии со статусом подключённых подписок.
@@ -995,6 +1595,9 @@ function PluginCard(props) {
995
1595
  exports.apply = function apply(ctx) {
996
1596
  setT(ctx.locale.bind(NS))
997
1597
  registerSettings(ctx)
1598
+ registerUsageBubble(ctx)
1599
+ registerSubsPill(ctx)
1600
+ registerComposerQuota(ctx)
998
1601
  registerHeaderChip(ctx)
999
1602
  registerComposerSwitch(ctx)
1000
1603
  registerSlashCommands(ctx)
@@ -0,0 +1,71 @@
1
+ // #84: predictive runway forecast from a sliding window of usage samples.
2
+ // Port of the reference approach (WSL043 quota-forecast): track remaining
3
+ // percent samples per window key, estimate pace with a recency-weighted
4
+ // least-squares slope, convert to a runway estimate.
5
+
6
+ const HOUR_MS = 60 * 60 * 1000
7
+ const HISTORY_MS = 24 * HOUR_MS
8
+ const MIN_SPAN_MS = 30 * 60 * 1000
9
+ const MIN_CONSUMED = 1
10
+ const MAX_SAMPLES = 192
11
+
12
+ const finite = (v) => Number.isFinite(Number(v))
13
+
14
+ export function observeForecast(state, key, remainingPercent, resetsAt, now) {
15
+ const windows = Object.assign({}, (state && state.windows) || {})
16
+ const pct = Math.max(0, Math.min(100, Number(remainingPercent)))
17
+ const reset = finite(resetsAt) ? Number(resetsAt) : null
18
+ const prev = windows[key]
19
+ const resetChanged = prev !== undefined && (
20
+ (prev.resetsAt === null) !== (reset === null) ||
21
+ (prev.resetsAt !== null && reset !== null && Math.abs(prev.resetsAt - reset) > 300)
22
+ )
23
+ const last = prev && prev.samples && prev.samples[prev.samples.length - 1]
24
+ const increased = last !== undefined && pct > last.pct + 0.5
25
+ const samples = resetChanged || increased ? [] : ((prev && prev.samples) || []).slice()
26
+ if (!samples.length || now > samples[samples.length - 1].at && (
27
+ Math.abs(pct - samples[samples.length - 1].pct) >= 0.1 || now - samples[samples.length - 1].at >= 15 * 60 * 1000
28
+ )) {
29
+ samples.push({ at: now, pct })
30
+ }
31
+ const kept = samples.filter((s) => s.at >= now - HISTORY_MS).slice(-MAX_SAMPLES)
32
+ windows[key] = { resetsAt: reset, samples: kept }
33
+ return { windows }
34
+ }
35
+
36
+ export function estimateForecast(state, key, remainingPercent, resetsAt, now) {
37
+ if (!finite(remainingPercent)) return { status: 'idle' }
38
+ const rec = state && state.windows && state.windows[key]
39
+ if (!rec) return { status: 'calibrating' }
40
+ const reset = finite(resetsAt) ? Number(resetsAt) : null
41
+ if ((rec.resetsAt === null) !== (reset === null) ||
42
+ (rec.resetsAt !== null && reset !== null && Math.abs(rec.resetsAt - reset) > 300)) return { status: 'calibrating' }
43
+ const samples = rec.samples.filter((s) => s.at >= now - HISTORY_MS)
44
+ if (samples.length < 3) return { status: 'calibrating', sampleCount: samples.length }
45
+ const first = samples[0]
46
+ const last = samples[samples.length - 1]
47
+ const spanMs = last.at - first.at
48
+ const consumed = Math.max(0, first.pct - last.pct)
49
+ if (spanMs < MIN_SPAN_MS || consumed < MIN_CONSUMED) return { status: 'calibrating', sampleCount: samples.length }
50
+ const t0 = first.at
51
+ let sw = 0, sx = 0, sy = 0, sxx = 0, sxy = 0
52
+ for (const s of samples) {
53
+ const x = (s.at - t0) / HOUR_MS
54
+ const y = first.pct - s.pct
55
+ const w = Math.exp((s.at - last.at) / (6 * HOUR_MS))
56
+ sw += w; sx += w * x; sy += w * y; sxx += w * x * x; sxy += w * x * y
57
+ }
58
+ const den = sw * sxx - sx * sx
59
+ const pace = den > 0 ? (sw * sxy - sx * sy) / den : 0
60
+ if (!Number.isFinite(pace) || pace < 0.02) return { status: 'idle', pacePerHour: 0 }
61
+ const runwayHours = Math.max(0, Math.min(100, Number(remainingPercent))) / pace
62
+ const resetSec = reset === null ? null : Math.max(0, Math.round((reset - now) / 1000))
63
+ return {
64
+ status: 'ready',
65
+ pacePerHour: pace,
66
+ runwayHours,
67
+ runwaySeconds: Math.round(runwayHours * 3600),
68
+ survivesReset: resetSec !== null && runwayHours * 3600 >= resetSec,
69
+ sampleCount: samples.length,
70
+ }
71
+ }
package/lib/index.js CHANGED
@@ -16,6 +16,7 @@ import { quotaSnapshot } from './ratelimit.js'
16
16
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
17
17
  import { startLoopback } from './loopback.js'
18
18
  import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
19
+ import { createResetCreditService } from './reset-credits.js'
19
20
  import { maskEmail, maskLabel, maskText } from './mask.js'
20
21
  import { proxyFetch, pickFetch } from './proxy.js'
21
22
  import { HistoryStore } from './history.js'
@@ -85,6 +86,8 @@ export const Config = z.object({
85
86
  .description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
86
87
  codexFastMode: z.boolean().default(false)
87
88
  .description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
89
+ composerQuota: z.string().default('off')
90
+ .description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
88
91
  codexRedirectUri: z.string().default(''),
89
92
  codexBaseUrl: z.string().default(''),
90
93
  claudeClientId: z.string().default(''),
@@ -178,6 +181,13 @@ export function apply(ctx, config) {
178
181
  const history = new HistoryStore()
179
182
  const recordHistory = (entry) => history.add(entry)
180
183
 
184
+ // #85: host-only reset credit service for Codex accounts.
185
+ const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
186
+ function refForSlot(provider, index) {
187
+ const slot = normalizeSlots(live().slots).find((s) => s.provider === provider && s.index === index)
188
+ return slot ? slot.ref : null
189
+ }
190
+
181
191
  // #91: local Ollama - native provider + seamless fallback when the whole
182
192
  // pool is exhausted and nothing has been streamed yet.
183
193
  const ollamaAdapter = new OllamaAdapter({
@@ -731,11 +741,85 @@ export function apply(ctx, config) {
731
741
  labels,
732
742
  expiryNotifyDays: live().expiryNotifyDays,
733
743
  fastMode: !!live().codexFastMode,
744
+ composerQuota: String(live().composerQuota || 'off'),
734
745
  active,
735
746
  })
736
747
  },
737
748
  }), 'dsh-subscriptions: /status')
738
749
 
750
+ ctx.effect(() => ctx.webServer.register({
751
+ kind: 'exact',
752
+ path: '/dsh-subscriptions/reset-credits',
753
+ handler: async (req, res) => {
754
+ if (req.method !== 'GET') {
755
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
756
+ return
757
+ }
758
+ const q = queryOf(req)
759
+ const ref = refForSlot(String(q.provider || ''), Number(q.index) || 1)
760
+ if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
761
+ try {
762
+ writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
763
+ } catch (e) {
764
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
765
+ }
766
+ },
767
+ }), 'dsh-subscriptions: /reset-credits')
768
+
769
+ ctx.effect(() => ctx.webServer.register({
770
+ kind: 'exact',
771
+ path: '/dsh-subscriptions/reset-credits/prepare',
772
+ handler: async (req, res) => {
773
+ if (req.method !== 'POST') {
774
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
775
+ return
776
+ }
777
+ if (!isTrustedSettingsRequest(req)) {
778
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
779
+ return
780
+ }
781
+ let payload
782
+ try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
783
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
784
+ return
785
+ }
786
+ const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
787
+ if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
788
+ try {
789
+ writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
790
+ } catch (e) {
791
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
792
+ }
793
+ },
794
+ }), 'dsh-subscriptions: /reset-credits/prepare')
795
+
796
+ ctx.effect(() => ctx.webServer.register({
797
+ kind: 'exact',
798
+ path: '/dsh-subscriptions/reset-credits/consume',
799
+ handler: async (req, res) => {
800
+ if (req.method !== 'POST') {
801
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
802
+ return
803
+ }
804
+ if (!isTrustedSettingsRequest(req)) {
805
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
806
+ return
807
+ }
808
+ let payload
809
+ try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
810
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
811
+ return
812
+ }
813
+ try {
814
+ const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
815
+ writeJson(res, 200, { ok: true, result })
816
+ refreshModels().catch(() => {})
817
+ } catch (e) {
818
+ writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
819
+ }
820
+ },
821
+ }), 'dsh-subscriptions: /reset-credits/consume')
822
+
739
823
  ctx.effect(() => ctx.webServer.register({
740
824
  kind: 'exact',
741
825
  path: '/dsh-subscriptions/diagnostics',
@@ -0,0 +1,193 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ // #85: safe Codex quota reset credits. Host-only: the browser never sees
4
+ // account ids, bearer tokens, credit ids or idempotency keys. A single-flight
5
+ // challenge carries a 5s cooldown plus an explicit acknowledgement, so a
6
+ // double click or concurrent call can never consume two credits.
7
+
8
+ const RESET_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'
9
+ const CONSUME_URL = RESET_URL + '/consume'
10
+ const CONFIRM_DELAY_MS = 5000
11
+ const CHALLENGE_TTL_MS = 60000
12
+ const TIMEOUT_MS = 15000
13
+ const UNCERTAIN = 'reset result is uncertain; re-run the confirmation to check the same request'
14
+
15
+ function record(v) {
16
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
17
+ }
18
+
19
+ function expirationOf(v) {
20
+ if (v == null) return undefined
21
+ if (Number.isSafeInteger(v) && v > 0) return v * 1000
22
+ if (typeof v === 'string' && v.length > 0 && v.length <= 64) {
23
+ const p = Date.parse(v)
24
+ if (Number.isFinite(p) && p > 0) return p
25
+ }
26
+ throw new Error('malformed reset expiry')
27
+ }
28
+
29
+ export function parseDetails(raw, now) {
30
+ if (!record(raw) || !Number.isSafeInteger(raw.available_count) || raw.available_count < 0 || !Array.isArray(raw.credits)) {
31
+ throw new Error('malformed reset details')
32
+ }
33
+ if (raw.available_count === 0) throw new Error('no reset available')
34
+ const nowMs = Number(now) || 0
35
+ const available = raw.credits
36
+ .filter((c) => record(c) && typeof c.id === 'string' && c.id.length > 0 && c.id.length <= 256 && String(c.status || '').toLowerCase() === 'available')
37
+ .map((c) => ({ c, expiresAt: expirationOf(c.expires_at) }))
38
+ .filter((row) => row.expiresAt === undefined || row.expiresAt > nowMs)
39
+ .sort((a, b) => (a.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : a.expiresAt) - (b.expiresAt === undefined ? Number.MAX_SAFE_INTEGER : b.expiresAt))
40
+ if (!available.length) throw new Error('no usable reset credit')
41
+ const first = available[0]
42
+ return {
43
+ availableCount: raw.available_count,
44
+ creditId: first.c.id,
45
+ title: typeof first.c.title === 'string' ? first.c.title.slice(0, 240) : undefined,
46
+ description: typeof first.c.description === 'string' ? first.c.description.slice(0, 240) : undefined,
47
+ creditExpiresAt: first.expiresAt,
48
+ }
49
+ }
50
+
51
+ export function parseConsumeResult(raw) {
52
+ if (!record(raw) || !['reset', 'nothing_to_reset', 'no_credit', 'already_redeemed'].includes(raw.code)) {
53
+ throw new Error('unreadable reset response')
54
+ }
55
+ const windowsReset = Array.isArray(raw.windows_reset)
56
+ ? raw.windows_reset.filter((x) => typeof x === 'string').slice(0, 16)
57
+ : []
58
+ const count = Number.isSafeInteger(raw.windows_reset) && raw.windows_reset >= 0 && raw.windows_reset <= 16
59
+ ? raw.windows_reset
60
+ : undefined
61
+ return { code: raw.code, windowsReset, ...(count === undefined ? {} : { windowsResetCount: count }) }
62
+ }
63
+
64
+ export function createResetCreditService({ loadBlob, fetchImpl, now = Date.now, randomId = randomUUID }) {
65
+ const challenges = new Map()
66
+
67
+ async function readDetails(ref) {
68
+ const blob = await loadBlob(ref)
69
+ const access = blob && blob.accessToken
70
+ const accountId = blob && blob.accountId
71
+ if (!access || !accountId) throw new Error('codex account is not signed in')
72
+ const res = await (fetchImpl || fetch)(RESET_URL, {
73
+ method: 'GET',
74
+ headers: {
75
+ Authorization: 'Bearer ' + access,
76
+ 'chatgpt-account-id': accountId,
77
+ Accept: 'application/json',
78
+ 'cache-control': 'no-store',
79
+ },
80
+ signal: AbortSignal.timeout(TIMEOUT_MS),
81
+ })
82
+ if (!res.ok) {
83
+ throw new Error(res.status === 401 || res.status === 403
84
+ ? 'codex sign-in needs renewal'
85
+ : 'reset request failed (HTTP ' + res.status + ')')
86
+ }
87
+ let raw
88
+ try { raw = await res.json() } catch { throw new Error('unreadable reset details') }
89
+ return { accountId, details: parseDetails(raw, now()) }
90
+ }
91
+
92
+ return {
93
+ async inspect(ref) {
94
+ const { details } = await readDetails(ref)
95
+ return {
96
+ availableCount: details.availableCount,
97
+ ...(details.creditExpiresAt === undefined ? {} : { nextExpiresAt: details.creditExpiresAt }),
98
+ }
99
+ },
100
+
101
+ async prepare(ref) {
102
+ const { accountId, details } = await readDetails(ref)
103
+ const preparedAt = now()
104
+ const readyAt = preparedAt + CONFIRM_DELAY_MS
105
+ const expiresAt = Math.min(preparedAt + CHALLENGE_TTL_MS, details.creditExpiresAt === undefined ? Number.MAX_SAFE_INTEGER : details.creditExpiresAt)
106
+ if (expiresAt <= readyAt) throw new Error('the available reset expires too soon')
107
+ const challengeId = randomId()
108
+ challenges.set(challengeId, {
109
+ state: 'prepared',
110
+ ref,
111
+ accountId,
112
+ creditId: details.creditId,
113
+ redeemRequestId: randomId(),
114
+ readyAt,
115
+ expiresAt,
116
+ availableCount: details.availableCount,
117
+ creditExpiresAt: details.creditExpiresAt,
118
+ title: details.title,
119
+ description: details.description,
120
+ uncertain: false,
121
+ })
122
+ return {
123
+ challengeId,
124
+ availableCount: details.availableCount,
125
+ readyAt,
126
+ expiresAt,
127
+ ...(details.creditExpiresAt === undefined ? {} : { creditExpiresAt: details.creditExpiresAt }),
128
+ ...(details.title ? { title: details.title } : {}),
129
+ ...(details.description ? { description: details.description } : {}),
130
+ }
131
+ },
132
+
133
+ async consume({ challengeId, acknowledged } = {}) {
134
+ const challenge = typeof challengeId === 'string' ? challenges.get(challengeId) : undefined
135
+ if (!challenge) throw new Error('this reset confirmation is no longer valid')
136
+ if (challenge.state === 'pending') throw new Error('this reset is already in progress')
137
+ if (now() < challenge.readyAt) throw new Error('wait before confirming this reset')
138
+ if (now() > challenge.expiresAt) {
139
+ challenges.delete(challengeId)
140
+ throw new Error('this reset confirmation is no longer valid')
141
+ }
142
+ if (acknowledged !== true) throw new Error('acknowledge that one reset attempt will be consumed')
143
+ // Synchronous gate before the first await: rapid clicks and concurrent
144
+ // calls can never create more than one provider POST.
145
+ challenge.state = 'pending'
146
+ let retryable = challenge.uncertain === true
147
+ try {
148
+ const blob = await loadBlob(challenge.ref)
149
+ const access = blob && blob.accessToken
150
+ const accountId = blob && blob.accountId
151
+ if (!access || !accountId) { retryable = false; throw new Error('codex account is not signed in') }
152
+ if (accountId !== challenge.accountId) { retryable = false; throw new Error('the signed-in account changed') }
153
+ let res
154
+ try {
155
+ res = await (fetchImpl || fetch)(CONSUME_URL, {
156
+ method: 'POST',
157
+ headers: {
158
+ Authorization: 'Bearer ' + access,
159
+ 'chatgpt-account-id': accountId,
160
+ Accept: 'application/json',
161
+ 'content-type': 'application/json',
162
+ 'cache-control': 'no-store',
163
+ },
164
+ body: JSON.stringify({
165
+ redeem_request_id: challenge.redeemRequestId,
166
+ credit_id: challenge.creditId,
167
+ }),
168
+ signal: AbortSignal.timeout(TIMEOUT_MS),
169
+ })
170
+ } catch { retryable = true; throw new Error(UNCERTAIN) }
171
+ if (!res.ok) {
172
+ if (res.status >= 500) { retryable = true; throw new Error(UNCERTAIN) }
173
+ throw new Error(res.status === 401 || res.status === 403
174
+ ? 'codex sign-in needs renewal'
175
+ : 'reset request failed (HTTP ' + res.status + ')')
176
+ }
177
+ let raw
178
+ try { raw = await res.json() } catch { retryable = true; throw new Error(UNCERTAIN) }
179
+ let result
180
+ try { result = parseConsumeResult(raw) } catch { retryable = true; throw new Error(UNCERTAIN) }
181
+ retryable = false
182
+ return result
183
+ } finally {
184
+ if (retryable && now() <= challenge.expiresAt) {
185
+ challenge.state = 'prepared'
186
+ challenge.uncertain = true
187
+ } else {
188
+ challenges.delete(challengeId)
189
+ }
190
+ }
191
+ },
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",