@goodandready/dsh-subscriptions 0.6.58 → 0.6.60

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
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
145
145
  | `/dsh-subscriptions/proxy-check` | POST | Latency check of a slot's proxy against its vendor base URL |
146
146
  | `/dsh-subscriptions/oauth/device/start` | POST | Begin Codex device-code login (returns user code + verification URL) |
147
147
  | `/dsh-subscriptions/oauth/device/poll` | POST | Poll device-code authorization status |
148
+ | `/dsh-subscriptions/quarantine/park` | POST | Park an account into quarantine for custom duration (`durationMs`, `reason`) |
149
+ | `/dsh-subscriptions/quarantine/probe` | POST | Run immediate live warmup health probe or force-release account |
150
+ | `/dsh-subscriptions/alerts` | GET | List recent in-app alerts and notifications |
148
151
 
149
152
  ---
150
153
 
@@ -193,6 +196,18 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
193
196
  * **Curated Models Catalog**: Quick preview of supported model families with context windows and capability tags (Vision, Reasoning, Hybrid).
194
197
  * **Non-Blocking Storage**: `HistoryStore` debounces disk persistence asynchronously to prevent blocking the Node.js event loop during high-throughput streaming.
195
198
 
199
+ ### 17. 🛡️ Quarantine Management, Live Quota Countdown & In-App Toast Alerts (v0.6.59)
200
+ * **Manual Quarantine Control (Park & Probe)**: Directly park active accounts in quarantine (`POST /dsh-subscriptions/quarantine/park`) for maintenance or pause, or trigger an immediate live warmup probe and instant release (`POST /dsh-subscriptions/quarantine/probe`) right from the account slot card.
201
+ * **Dynamic Quota Window Countdown**: Live second-by-second countdown timer displayed in the `SubsPill` session widget and account settings cards, showing exact time remaining until rolling rate-limit windows (`five_hour`, `seven_day`, `primary_window`) reset.
202
+ * **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
203
+ * **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
204
+
205
+ ### 18. 🔑 Antigravity Client Secret & Resilient CLI Refresh (v0.6.60)
206
+ * **Client Secret in Schema (`antigravityClientSecret`)**: Configure custom Google Cloud OAuth client secrets with automatic mask redaction (`••••••`), resolving token refresh rejections for confidential OAuth clients.
207
+ * **Zero-Config CLI Token Refresh**: Bundled public application credentials for the standard Gemini / Antigravity CLI client (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), ensuring imported CLI sessions keep refreshing indefinitely without dying after 1 hour.
208
+ * **Windows Credential Manager CLI Discovery**: Native `win32` discovery for Windows Credential Manager generic target `gemini:antigravity`, enabling seamless `📥 From CLI` import on Windows.
209
+ * **Descriptive Refresh Diagnostics**: Actionable error reporting identifying missing or invalid client credentials instead of generic upstream HTTP 400 errors.
210
+
196
211
  ---
197
212
 
198
213
  ## 📦 Quick Installation
@@ -226,6 +241,7 @@ dsh-subscriptions:
226
241
  codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
227
242
  codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
228
243
  composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
244
+ enableToasts: true # v0.6.59: real-time in-app toast alerts on quarantine and quota events
229
245
  # Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
230
246
  accounts:
231
247
  codex:
package/README.ru.md CHANGED
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
145
145
  | `/dsh-subscriptions/proxy-check` | POST | Проверка задержки прокси слота через базовый URL вендора |
146
146
  | `/dsh-subscriptions/oauth/device/start` | POST | Начать вход Codex по коду устройства (возвращает код и адрес подтверждения) |
147
147
  | `/dsh-subscriptions/oauth/device/poll` | POST | Опрос статуса авторизации по коду устройства |
148
+ | `/dsh-subscriptions/quarantine/park` | POST | Временная парковка аккаунта в карантин (`durationMs`, `reason`) |
149
+ | `/dsh-subscriptions/quarantine/probe` | POST | Немедленный запуск прогревочного зонда или досрочный вывод из карантина |
150
+ | `/dsh-subscriptions/alerts` | GET | Список последних внутренних оповещений и уведомлений |
148
151
 
149
152
  ---
150
153
 
@@ -169,6 +172,18 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
169
172
  * **Каталог моделей подписок**: Компактный список поддерживаемых моделей с размером контекста (128k, 200k, 1M) и тегами возможностей.
170
173
  * **Асинхронная история**: Неблокирующий дебаунсинг записи `HistoryStore`, исключающий просадки event loop при интенсивном стриминге.
171
174
 
175
+ ### 13. 🛡️ Управление карантином, живой обратный отсчет квот и всплывающие оповещения (v0.6.59)
176
+ * **Ручное управление карантином (Park & Probe)**: Возможность временно отправить аккаунт в карантин (`POST /dsh-subscriptions/quarantine/park`) для обслуживания или паузы, либо запустить немедленный прогревочный микро-зонд (`vendor.check`) и моментально вывести аккаунт из карантина (`POST /dsh-subscriptions/quarantine/probe`) прямо из карточки в настройках.
177
+ * **Динамический посекундный таймер сброса окон квот**: Интерактивный таймер в виджете сессии `SubsPill` и в карточках аккаунтов, рассчитывающий точное оставшееся время до разблокировки скользящих лимитов (`five_hour`, `seven_day`, `primary_window`).
178
+ * **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
179
+ * **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
180
+
181
+ ### 14. 🔑 Antigravity Client Secret и надежный refresh CLI-токенов (v0.6.60)
182
+ * **Client Secret в схеме настроек (`antigravityClientSecret`)**: Поддержка ввода пользовательского Google Cloud OAuth Client Secret с автоматическим маскированием (`••••••`), устраняющая ошибки обновления токенов для конфиденциальных OAuth-клиентов.
183
+ * **Бесшовный refresh сессий из CLI**: Встроенные официальные публичные учетные данные Gemini / Antigravity CLI (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), предотвращающие отказ Google OAuth через 1 час после импорта через `From CLI`.
184
+ * **Обнаружение сессий в Windows Credential Manager**: Автоматическое сканирование хранилища учетных данных Windows (цель `gemini:antigravity`), обеспечивающее импорт сессий CLI на Windows без ручного создания файлов.
185
+ * **Понятная диагностика ошибок**: Информативные сообщения об ошибках при отсутствии Client Secret вместо сырых сетевых ответов HTTP 400 от Google.
186
+
172
187
  ---
173
188
 
174
189
  ## 📦 Быстрая установка
@@ -199,6 +214,7 @@ dsh-subscriptions:
199
214
  codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
200
215
  codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
201
216
  composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
217
+ enableToasts: true # v0.6.59: всплывающие уведомления при событиях карантина и квот
202
218
  # Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
203
219
  accounts:
204
220
  codex:
package/README.zh.md CHANGED
@@ -120,6 +120,18 @@ graph LR
120
120
  * **匿名诊断报告**:设置卡片内一键生成(插件/运行时版本、系统、各供应商健康状态、HTTP 状态聚合、最近错误与耗时、非敏感配置)并自动复制到剪贴板;令牌、邮箱、凭据名与代理地址严格排除。
121
121
  * 同区块提供问题追踪器链接,便于提交 issue。
122
122
 
123
+ ### 隔离管理、动态额度重置倒计时与应用内弹窗提醒 (v0.6.59)
124
+ * **手动隔离管理(暂停与预热探测)**:支持在设置面板的账户卡片中一键暂停隔离活跃账户(`POST /dsh-subscriptions/quarantine/park`),或立即触发真实上游预热微探测(`probeWarmup`)并在验证成功后即时解除隔离(`POST /dsh-subscriptions/quarantine/probe`)。
125
+ * **动态额度重置倒计时**:在顶部会话栏 `SubsPill` 和账户卡片中秒级动态更新,精准展示滚动配额窗口(`five_hour`、`seven_day`、`primary_window`)的剩余解锁时间。
126
+ * **应用内即时弹窗提醒(Toast)**:当账户进入隔离、健康恢复解除隔离或额度达到预警线时,在聊天界面弹出轻量级浮动提醒。通过 `enableToasts: true` 控制。
127
+ * **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
128
+
129
+ ### Antigravity 客户端密钥架构与无感 CLI 令牌刷新 (v0.6.60)
130
+ * **配置架构密钥字段 (`antigravityClientSecret`)**:支持用户在设置中自定义配置 Google Cloud OAuth 客户端密钥(Client Secret),公共接口自动脱敏掩码 (`••••••`),彻底解决机密型 OAuth 客户端刷新令牌被拒的问题。
131
+ * **CLI 令牌零配置平滑刷新**:内置官方 Gemini / Antigravity CLI 客户端的公共应用凭据 (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`),使通过 `📥 从 CLI 导入` 的会话能够持久无限刷新,不再出现 1 小时后失效问题。
132
+ * **Windows 凭据管理器自动发现**:原生支持在 `win32` 系统下自动检测 Windows 凭据管理器中的 `gemini:antigravity` 目标,直接完成 CLI 会话导入。
133
+ * **清晰明确的错误指引**:在缺少密钥或上游凭据异常时抛出指引性错误,替代原始的 HTTP 400 提示。
134
+
123
135
  ### HTTP API 路由(`v0.4.9`)
124
136
  | 路由 | 方法 | 用途 |
125
137
  |---|---|---|
@@ -127,6 +139,9 @@ graph LR
127
139
  | `/dsh-subscriptions/proxy-check` | POST | 检测槽位代理到供应商基础地址的延迟 |
128
140
  | `/dsh-subscriptions/oauth/device/start` | POST | 发起 Codex 设备码登录(返回用户码与验证地址) |
129
141
  | `/dsh-subscriptions/oauth/device/poll` | POST | 轮询设备码授权状态 |
142
+ | `/dsh-subscriptions/quarantine/park` | POST | 将指定账户临时暂停隔离(支持 `durationMs` 与 `reason`) |
143
+ | `/dsh-subscriptions/quarantine/probe` | POST | 立即执行健康预热探测或提前解除账户隔离 |
144
+ | `/dsh-subscriptions/alerts` | GET | 获取近期应用内告警与通知列表 |
130
145
 
131
146
  ---
132
147
 
package/lib/accounts.js CHANGED
@@ -515,11 +515,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
515
515
  }
516
516
  return q
517
517
  },
518
- async probeWarmup(ref, probeFn) {
518
+ async probeWarmup(ref, probeFn, force = false) {
519
519
  const q = quarantines.get(ref)
520
520
  if (!q) return { success: true }
521
521
  const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
522
- if (!needsWarmupProbe(acc)) {
522
+ if (!force && !needsWarmupProbe(acc)) {
523
523
  return { success: false, reason: 'NOT_EXPIRED' }
524
524
  }
525
525
  quarantines.set(ref, enterProbing(acc))
package/lib/alerts.js CHANGED
@@ -154,3 +154,58 @@ export function notifySessionExpired({
154
154
  }
155
155
  return alert
156
156
  }
157
+
158
+ /**
159
+ * #509 & #510: Fires an alert when an account enters quarantine.
160
+ */
161
+ export function notifyQuarantineEntered({
162
+ ref,
163
+ provider,
164
+ reason,
165
+ until,
166
+ webhookUrl = "",
167
+ fetchImpl = fetch,
168
+ nowMs = Date.now(),
169
+ }) {
170
+ const untilIso = until ? new Date(until).toISOString() : ""
171
+ const alert = recordAlert({
172
+ type: "quarantine_entered",
173
+ ref,
174
+ provider,
175
+ message: "Account " + (provider || "slot") + " (" + (ref || "") + ") entered quarantine (" + (reason || "RATE_LIMIT") + ")" + (untilIso ? " until " + untilIso : ""),
176
+ severity: "warn",
177
+ details: { reason, until },
178
+ nowMs,
179
+ })
180
+
181
+ if (alert && webhookUrl) {
182
+ dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
183
+ }
184
+ return alert
185
+ }
186
+
187
+ /**
188
+ * #509 & #510: Fires an alert when an account is released from quarantine.
189
+ */
190
+ export function notifyQuarantineReleased({
191
+ ref,
192
+ provider,
193
+ message,
194
+ webhookUrl = "",
195
+ fetchImpl = fetch,
196
+ nowMs = Date.now(),
197
+ }) {
198
+ const alert = recordAlert({
199
+ type: "quarantine_released",
200
+ ref,
201
+ provider,
202
+ message: message || ("Account " + (provider || "slot") + " (" + (ref || "") + ") released from quarantine and is active"),
203
+ severity: "info",
204
+ nowMs,
205
+ })
206
+
207
+ if (alert && webhookUrl) {
208
+ dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
209
+ }
210
+ return alert
211
+ }
package/lib/client.js CHANGED
@@ -92,6 +92,7 @@ window.__ModuleLoader__.load({
92
92
  '.dsub-ledWarn{background:var(--dsw-alias-state-warn-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 70%,transparent)}' +
93
93
  '.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-error-primary) 70%,transparent)}' +
94
94
  '.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}' +
95
+ '.dsub-toast-container{position:fixed;top:16px;right:16px;z-index:10030;display:flex;flex-direction:column;gap:8px;pointer-events:none}' +'.dsub-toast{pointer-events:auto;min-width:280px;max-width:400px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px color-mix(in srgb,black 40%,transparent);display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;line-height:1.4;animation:dsubFadeIn .2s ease-out}' +'.dsub-toast-warn{border-color:var(--dsw-alias-state-warn-primary)}' +'.dsub-toast-bad{border-color:var(--dsw-alias-state-error-primary)}' +'.dsub-toast-ok{border-color:var(--dsw-alias-state-success-primary)}' +'.dsub-toast-close{background:none;border:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:0 2px}' +'.dsub-toast-close:hover{color:var(--dsw-alias-label-primary)}' +
95
96
  '.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb, black 65%, transparent);backdrop-filter:blur(10px);animation:dsubFadeIn .16s ease-out}' +
96
97
  '.dsub-modal{width:min(520px,94vw);max-height:min(85vh,720px);overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px color-mix(in srgb, black 60%, transparent),0 0 0 1px color-mix(in srgb, white 7%, transparent);padding:20px;animation:dsubScaleIn .18s cubic-bezier(.16,1,.3,1)}' +
97
98
  '@keyframes dsubFadeIn{from{opacity:0}to{opacity:1}}' +
@@ -536,7 +537,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
536
537
  "backupPayloadPlaceholder": "Paste DSHE... encrypted string here",
537
538
  "backupExportOk": "Backup exported successfully",
538
539
  "backupImportOk": "Backup imported successfully",
539
- "backupDownload": "Download .enc"
540
+ "backupDownload": "Download .enc",
541
+ "quarantinePark": "Park",
542
+ "quarantineParkTitle": "Park account in quarantine for 1 hour",
543
+ "probeWarmupNow": "Probe Now",
544
+ "probeWarmupTitle": "Run immediate warmup health probe",
545
+ "releaseQuarantine": "Release",
546
+ "releaseQuarantineTitle": "Release account from quarantine",
547
+ "resetsIn": "Resets in",
548
+ "toastQuarantined": "Account placed in quarantine",
549
+ "toastReleased": "Account active and healthy",
550
+ "toastQuotaWarning": "Quota threshold exceeded",
551
+ "enableToasts": "In-app toast alerts",
552
+ "field.enableToasts": "Show popup toast notifications on quarantine or quota events"
540
553
  };
541
554
 
542
555
  const zh = {
@@ -735,7 +748,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
735
748
  "backupPayloadPlaceholder": "在此粘贴 DSHE... 加密内容",
736
749
  "backupExportOk": "备份导出成功",
737
750
  "backupImportOk": "备份导入成功",
738
- "backupDownload": "下载 .enc 文件"
751
+ "backupDownload": "下载 .enc 文件",
752
+ "quarantinePark": "隔离",
753
+ "quarantineParkTitle": "将账户临时隔离 1 小时",
754
+ "probeWarmupNow": "立即探测",
755
+ "probeWarmupTitle": "立即执行健康预热探测",
756
+ "releaseQuarantine": "解除隔离",
757
+ "releaseQuarantineTitle": "立即解除账户隔离状态",
758
+ "resetsIn": "重置倒计时",
759
+ "toastQuarantined": "账户进入隔离期",
760
+ "toastReleased": "账户已解除隔离恢复使用",
761
+ "toastQuotaWarning": "额度预警",
762
+ "enableToasts": "应用内弹窗提醒",
763
+ "field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
739
764
  };
740
765
  // #51: live countdown to the quota window reset (account.quota.resetAt).
741
766
  function cleanErrorMessage(raw) {
@@ -1251,6 +1276,46 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1251
1276
  }
1252
1277
 
1253
1278
 
1279
+
1280
+ const doParkQuarantine = async (provider, index, durationMs) => {
1281
+ setErr("")
1282
+ try {
1283
+ const res = await fetch("/dsh-subscriptions/quarantine/park", {
1284
+ method: "POST",
1285
+ headers: { "Content-Type": "application/json" },
1286
+ body: JSON.stringify({ provider, index, durationMs: durationMs || 3600000, reason: "MANUAL_PARK" }),
1287
+ })
1288
+ const data = await res.json().catch(() => ({}))
1289
+ if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
1290
+ setAccounts(data.accounts || [])
1291
+ setInfo(t("toastQuarantined"))
1292
+ setTimeout(() => setInfo(""), 3000)
1293
+ } catch (e) { setErr(cleanErrorMessage(e.message || e)) }
1294
+ }
1295
+
1296
+ const doProbeQuarantine = async (provider, index, releaseOnly) => {
1297
+ setErr("")
1298
+ const key = provider + ":" + index
1299
+ setChecking((c) => Object.assign({}, c, { [key]: true }))
1300
+ try {
1301
+ const res = await fetch("/dsh-subscriptions/quarantine/probe", {
1302
+ method: "POST",
1303
+ headers: { "Content-Type": "application/json" },
1304
+ body: JSON.stringify({ provider, index, release: Boolean(releaseOnly) }),
1305
+ })
1306
+ const data = await res.json().catch(() => ({}))
1307
+ if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
1308
+ setAccounts(data.accounts || [])
1309
+ if (data.success || data.released) {
1310
+ setInfo(t("toastReleased"))
1311
+ setTimeout(() => setInfo(""), 3000)
1312
+ } else if (data.error) {
1313
+ setErr(cleanErrorMessage(data.error))
1314
+ }
1315
+ } catch (e) { setErr(cleanErrorMessage(e.message || e)) }
1316
+ setChecking((c) => Object.assign({}, c, { [key]: false }))
1317
+ }
1318
+
1254
1319
  const doCheck = async (provider, index) => {
1255
1320
  const key = provider + ':' + index
1256
1321
  setChecking((c) => Object.assign({}, c, { [key]: true }))
@@ -1754,6 +1819,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1754
1819
  disabled: checking[key],
1755
1820
  onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
1756
1821
  }, checking[key] ? t('checking') : t('check')),
1822
+ (account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now() ? React.createElement(React.Fragment, null,
1823
+ React.createElement('button', {
1824
+ type: 'button', className: 'dsub-mini', title: t('probeWarmupTitle'),
1825
+ disabled: checking[key],
1826
+ onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, false),
1827
+ }, checking[key] ? t('checking') : ('🔄 ' + t('probeWarmupNow'))),
1828
+ React.createElement('button', {
1829
+ type: 'button', className: 'dsub-mini', title: t('releaseQuarantineTitle'),
1830
+ disabled: checking[key],
1831
+ onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, true),
1832
+ }, ('✕ ' + t('releaseQuarantine'))),
1833
+ ) : (on ? React.createElement('button', {
1834
+ type: 'button', className: 'dsub-mini', title: t('quarantineParkTitle'),
1835
+ onClick: () => doParkQuarantine(row.slot.provider, row.slot.index, 3600000),
1836
+ }, ('⏸ ' + t('quarantinePark'))) : null)),
1757
1837
  React.createElement('button', {
1758
1838
  type: 'button', className: 'dsub-mini', title: t('removeSlot'),
1759
1839
  onClick: () => {
@@ -1764,6 +1844,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1764
1844
  },
1765
1845
  }, '\u00d7'),
1766
1846
  ),
1847
+ (account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
1848
+ React.createElement('span', { className: 'dsub-sub', style: { color: 'var(--dsw-alias-state-error-primary)', fontVariantNumeric: 'tabular-nums' } },
1849
+ '⛔ ' + (account.quarantineReason || 'QUARANTINE') + ' · ' + t('resetsIn') + ' ' + formatRelativeReset(account.quarantineUntil, t('lang'), Date.now())
1850
+ ),
1851
+ ) : (account && account.quota && account.quota.resetAt && Number(account.quota.resetAt) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
1852
+ React.createElement(ResetCountdown, { resetAt: account.quota.resetAt }),
1853
+ ) : null,
1767
1854
  React.createElement('div', { className: 'dsub-manual' },
1768
1855
  React.createElement('span', { className: 'dsub-sub' }, t('manualTitle')),
1769
1856
  React.createElement('div', { className: 'dsub-row' },
@@ -2461,6 +2548,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2461
2548
  function SubsPill(props) {
2462
2549
  const t = (props && props.t) || ((k) => k)
2463
2550
  const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false, active: null, loading: false })
2551
+ const [toasts, setToasts] = React.useState([])
2552
+ const lastAlertTsRef = React.useRef(Date.now() - 30000)
2553
+
2554
+ const dismissToast = (id) => {
2555
+ setToasts((prev) => prev.filter((t) => t.id !== id))
2556
+ }
2557
+
2558
+ const pollAlerts = () => {
2559
+ fetch('/dsh-subscriptions/alerts?limit=5', { cache: 'no-store' })
2560
+ .then((res) => res.json())
2561
+ .then((data) => {
2562
+ if (data && Array.isArray(data.alerts)) {
2563
+ const fresh = data.alerts.filter((a) => a && a.timestamp > lastAlertTsRef.current)
2564
+ if (fresh.length > 0) {
2565
+ lastAlertTsRef.current = Math.max(...fresh.map((a) => a.timestamp))
2566
+ fresh.forEach((al) => {
2567
+ const toastId = 't_' + al.timestamp + '_' + Math.random().toString(36).slice(2, 5)
2568
+ const cls = al.severity === 'error' ? 'dsub-toast-bad' : (al.severity === 'info' ? 'dsub-toast-ok' : 'dsub-toast-warn')
2569
+ const newToast = { id: toastId, message: al.message, cls }
2570
+ setToasts((prev) => [newToast, ...prev].slice(0, 3))
2571
+ setTimeout(() => dismissToast(toastId), 5000)
2572
+ })
2573
+ }
2574
+ }
2575
+ })
2576
+ .catch(() => {})
2577
+ }
2464
2578
 
2465
2579
  const pull = () => {
2466
2580
  setState((s) => Object.assign({}, s, { loading: true }))
@@ -2481,7 +2595,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2481
2595
 
2482
2596
  React.useEffect(() => {
2483
2597
  pull()
2484
- const id = setInterval(pull, 25 * 1000)
2598
+ pollAlerts()
2599
+ const id = setInterval(() => { pull(); pollAlerts(); }, 25 * 1000)
2485
2600
  return () => clearInterval(id)
2486
2601
  }, [])
2487
2602
 
@@ -2617,6 +2732,16 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2617
2732
  })
2618
2733
 
2619
2734
  return React.createElement(React.Fragment, null,
2735
+ toasts.length > 0 ? React.createElement('div', { className: 'dsub-toast-container' },
2736
+ toasts.map((toast) => React.createElement('div', { className: 'dsub-toast ' + toast.cls, key: toast.id },
2737
+ React.createElement('span', null, toast.message),
2738
+ React.createElement('button', {
2739
+ type: 'button',
2740
+ className: 'dsub-toast-close',
2741
+ onClick: () => dismissToast(toast.id),
2742
+ }, "\u00d7"),
2743
+ ))
2744
+ ) : null,
2620
2745
  React.createElement('button', {
2621
2746
  type: 'button',
2622
2747
  className: 'dsub-pill',
@@ -83,6 +83,8 @@ export const Config = z.object({
83
83
  grokClientVersion: z.string().volatile().default('')
84
84
  .description('Grok CLI identity version header. Empty uses the built-in default.'),
85
85
  antigravityClientId: z.string().volatile().default(''),
86
+ antigravityClientSecret: z.string().role('secret').default('')
87
+ .description('Google OAuth client secret for custom confidential clients or CLI tokens requiring secret refresh.'),
86
88
  antigravityRedirectUri: z.string().volatile().default(''),
87
89
  antigravityModels: z.array(z.any()).volatile().default([])
88
90
  .description('Custom model catalog for Antigravity.'),
@@ -108,6 +110,8 @@ export const Config = z.object({
108
110
  .description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
109
111
  alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
110
112
  .description('#351 Quota usage percentage thresholds for firing alerts.'),
113
+ enableToasts: z.boolean().volatile().default(true)
114
+ .description('#510 In-app toast alerts for quarantine and quota threshold events.'),
111
115
  autoPacing: z.boolean().volatile().default(true)
112
116
  .description('#352 Predictive burn-rate load balancing between accounts.'),
113
117
  drainBeforeReset: z.boolean().volatile().default(false)
@@ -1,13 +1,69 @@
1
+ import { execFileSync } from 'node:child_process'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
  import { readClaudeCredentials } from './vendors/claude-cli.js'
5
6
  import { normalizeExpiresAt } from './blob.js'
6
7
  import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
8
+ import { DEFAULT_CLI_CLIENT_ID, DEFAULT_CLI_CLIENT_SECRET } from './vendors/antigravity.js'
7
9
 
8
10
  // Test seam: a fixture root overrides the real home for the duration of a
9
11
  // public call. Undefined (the default) keeps production behaviour.
10
12
  let activeHome = null
13
+ let activeWindowsCredentialReader = null
14
+
15
+ export function setWindowsCredentialReaderForTest(fn) {
16
+ activeWindowsCredentialReader = fn
17
+ }
18
+
19
+ export function readWindowsAntigravityCredential() {
20
+ if (activeWindowsCredentialReader) {
21
+ return activeWindowsCredentialReader()
22
+ }
23
+ if (process.platform !== 'win32') return null
24
+ try {
25
+ const ps = `Add-Type -TypeDefinition @"
26
+ using System;
27
+ using System.Runtime.InteropServices;
28
+ public class CredReadHelper {
29
+ [DllImport("Advapi32.dll", SetLastError = true, EntryPoint = "CredReadW", CharSet = CharSet.Unicode)]
30
+ public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
31
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
32
+ public struct CREDENTIAL {
33
+ public int Flags, Type;
34
+ public string TargetName, Comment;
35
+ public long LastWritten;
36
+ public int CredentialBlobSize;
37
+ public IntPtr CredentialBlob;
38
+ public int Persist, AttributeCount;
39
+ public IntPtr Attributes;
40
+ public string TargetAlias, UserName;
41
+ }
42
+ public static string Read(string target) {
43
+ IntPtr ptr;
44
+ if (CredRead(target, 1, 0, out ptr)) {
45
+ var cred = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL));
46
+ byte[] bytes = new byte[cred.CredentialBlobSize];
47
+ Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize);
48
+ return System.Text.Encoding.UTF8.GetString(bytes);
49
+ }
50
+ return null;
51
+ }
52
+ }
53
+ "@
54
+ [CredReadHelper]::Read('gemini:antigravity')`
55
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], {
56
+ encoding: 'utf8',
57
+ timeout: 3000,
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ })
60
+ const text = String(out || '').trim()
61
+ if (!text) return null
62
+ return JSON.parse(text)
63
+ } catch {
64
+ return null
65
+ }
66
+ }
11
67
 
12
68
  function homeFile(...parts) {
13
69
  return join(activeHome || homedir(), ...parts)
@@ -89,6 +145,22 @@ async function discoverLocalCliSessionsInner() {
89
145
  }
90
146
  }
91
147
 
148
+ if (!detected.antigravity && (process.platform === 'win32' || activeWindowsCredentialReader)) {
149
+ const winCred = readWindowsAntigravityCredential()
150
+ const tok = winCred && winCred.token ? winCred.token : winCred
151
+ if (tok && (tok.access_token || tok.accessToken)) {
152
+ const idToken = winCred?.id_token || winCred?.idToken || tok?.id_token || tok?.idToken || ''
153
+ const email = winCred?.email || tok?.account || (idToken ? emailFromToken(idToken) : '') || ''
154
+ detected.antigravity = {
155
+ provider: 'antigravity',
156
+ source: 'credential-manager',
157
+ path: 'Windows Credential Manager (gemini:antigravity)',
158
+ email: email || 'Antigravity User',
159
+ hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
160
+ }
161
+ }
162
+ }
163
+
92
164
  // 4. Kimi Code Plan
93
165
  const kimi = await readJson(homeFile('.kimi-code', 'credentials', 'kimi-code.json'))
94
166
  if (kimi && (kimi.access_token || kimi.token)) {
@@ -310,7 +382,12 @@ export async function loadLocalCliBlob(provider, options = {}) {
310
382
  }
311
383
  }
312
384
 
313
- const raw = await readJson(info.path)
385
+ let raw
386
+ if (canonical === 'antigravity' && info.source === 'credential-manager') {
387
+ raw = readWindowsAntigravityCredential()
388
+ } else {
389
+ raw = await readJson(info.path)
390
+ }
314
391
  if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
315
392
 
316
393
  if (canonical === 'codex') {
@@ -349,7 +426,11 @@ export async function loadLocalCliBlob(provider, options = {}) {
349
426
 
350
427
  const directSecret = raw.client_secret || raw.clientSecret || tok.client_secret || tok.clientSecret ||
351
428
  raw.installed?.client_secret || raw.web?.client_secret || ''
352
- const clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
429
+ let clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
430
+
431
+ if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
432
+ clientSecret = DEFAULT_CLI_CLIENT_SECRET
433
+ }
353
434
 
354
435
  const rawExpiry = tok.expiry ?? tok.expires_at ?? tok.expiresAt ?? tok.expiry_date ?? raw.expiry_date ?? raw.expiry ?? (tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : 0)
355
436
  const expiresAt = normalizeExpiresAt(rawExpiry)
package/lib/quarantine.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export const REASON_RATE_LIMIT = 'RATE_LIMIT'
3
3
  export const REASON_HARD_LIMIT = 'HARD_LIMIT'
4
4
  export const REASON_REVOKED = 'TOKEN_REVOKED'
5
+ export const REASON_MANUAL_PARK = 'MANUAL_PARK'
5
6
 
6
7
  export const STATUS_ACTIVE = 'active'
7
8
  export const STATUS_QUARANTINE = 'quarantine'
@@ -13,6 +14,8 @@ export function quarantineDuration(reason) {
13
14
  return 24 * 60 * 60 * 1000 // 24 hours
14
15
  case REASON_REVOKED:
15
16
  return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
17
+ case REASON_MANUAL_PARK:
18
+ return 60 * 60 * 1000 // 1 hour (manual park)
16
19
  case REASON_RATE_LIMIT:
17
20
  default:
18
21
  return 60 * 60 * 1000 // 1 hour
@@ -1,5 +1,7 @@
1
1
  import { exportVault, importVault } from '../vault.js'
2
- import { normalizeSlots } from '../accounts.js'
2
+ import { normalizeSlots, vendorConfig } from '../accounts.js'
3
+ import { getVendor } from '../vendors/index.js'
4
+ import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
3
5
  import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
6
  import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
5
7
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
@@ -21,6 +23,118 @@ export function registerAccountsRoutes(ctx, state) {
21
23
  } = state
22
24
 
23
25
 
26
+
27
+ // Issue #509: Manual park to quarantine
28
+ ctx.effect(() => ctx.webServer.register({
29
+ kind: 'exact',
30
+ path: '/dsh-subscriptions/quarantine/park',
31
+ handler: safeJsonHandler(async (req, res) => {
32
+ if (req.method !== 'POST') {
33
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
34
+ return
35
+ }
36
+ if (!isTrustedSettingsRequest(req, ctx)) {
37
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
38
+ return
39
+ }
40
+ let payload
41
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
42
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
43
+ return
44
+ }
45
+ const provider = String(payload.provider || '')
46
+ const index = Number(payload.index || 1)
47
+ if (!isProvider(provider)) {
48
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
49
+ return
50
+ }
51
+ const ref = refForSlot(provider, index)
52
+ if (!ref) {
53
+ writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
54
+ return
55
+ }
56
+ const durationMs = Math.min(Math.max(60 * 1000, Number(payload.durationMs) || 3600 * 1000), 48 * 3600 * 1000)
57
+ const reason = String(payload.reason || 'MANUAL_PARK').slice(0, 64)
58
+ const until = Date.now() + durationMs
59
+
60
+ store.rememberQuarantine(ref, reason, until, 1)
61
+ notifyQuarantineEntered({ ref, provider, reason, until, webhookUrl: live().webhookUrl })
62
+ await syncAdapter()
63
+
64
+ writeJson(res, 200, { ok: true, ref, quarantineUntil: until, quarantineReason: reason, accounts: await accountsView() })
65
+ }),
66
+ }), 'dsh-subscriptions: /quarantine/park')
67
+
68
+ // Issue #509: Force probe warmup or release quarantine
69
+ ctx.effect(() => ctx.webServer.register({
70
+ kind: 'exact',
71
+ path: '/dsh-subscriptions/quarantine/probe',
72
+ handler: safeJsonHandler(async (req, res) => {
73
+ if (req.method !== 'POST') {
74
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
75
+ return
76
+ }
77
+ if (!isTrustedSettingsRequest(req, ctx)) {
78
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
79
+ return
80
+ }
81
+ let payload
82
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
83
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
84
+ return
85
+ }
86
+ const provider = String(payload.provider || '')
87
+ const index = Number(payload.index || 1)
88
+ const forceRelease = Boolean(payload.release)
89
+ if (!isProvider(provider)) {
90
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
91
+ return
92
+ }
93
+ const ref = refForSlot(provider, index)
94
+ if (!ref) {
95
+ writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
96
+ return
97
+ }
98
+
99
+ if (forceRelease) {
100
+ store.releaseQuarantine(ref)
101
+ notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
102
+ await syncAdapter()
103
+ writeJson(res, 200, { ok: true, success: true, released: true, accounts: await accountsView() })
104
+ return
105
+ }
106
+
107
+ const probeFn = async (r) => {
108
+ const blob = await store.loadBlob(r)
109
+ if (!blob) throw new Error('no credentials for slot')
110
+ let vendor = null
111
+ try {
112
+ vendor = getVendor(provider)
113
+ } catch (err) {
114
+ ctx.logger?.debug?.('getVendor(%s) probe failed: %s', provider, err?.message)
115
+ }
116
+ if (vendor && typeof vendor.check === 'function') {
117
+ const cfg = vendorConfig(provider, live())
118
+ const fetchImpl = typeof state.fetchForRef === 'function' ? state.fetchForRef(r) : fetch
119
+ await vendor.check(blob, cfg, fetchImpl)
120
+ }
121
+ }
122
+
123
+ const result = await store.probeWarmup(ref, probeFn, true)
124
+ if (result.success) {
125
+ notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
126
+ }
127
+ await syncAdapter()
128
+
129
+ writeJson(res, 200, {
130
+ ok: true,
131
+ success: Boolean(result.success),
132
+ error: result.error ? String(result.error.message || result.error).slice(0, 200) : null,
133
+ accounts: await accountsView(),
134
+ })
135
+ }),
136
+ }), 'dsh-subscriptions: /quarantine/probe')
137
+
24
138
  ctx.effect(() => ctx.webServer.register({
25
139
  kind: 'exact',
26
140
  path: '/dsh-subscriptions/reset-credits',
@@ -1,6 +1,6 @@
1
1
  import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
- import { notifySessionExpired } from './alerts.js'
3
+ import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
4
4
  import { withRunawayGuard } from './runaway-guard.js'
5
5
 
6
6
  export async function* streamWithRotation({
@@ -114,6 +114,18 @@ export async function* streamWithRotation({
114
114
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
115
115
  }
116
116
 
117
+ try {
118
+ notifyQuarantineEntered({
119
+ ref: account.ref,
120
+ provider: options && options.provider,
121
+ reason: account.quarantineReason || reason,
122
+ until: account.quarantineUntil,
123
+ webhookUrl: webhookUrl || (options && options.webhookUrl),
124
+ fetchImpl,
125
+ nowMs: nowMs(),
126
+ })
127
+ } catch { /* fire-and-forget alert */ }
128
+
117
129
  if (reason === REASON_REVOKED) {
118
130
  try {
119
131
  notifySessionExpired({
@@ -15,6 +15,8 @@ import {
15
15
  } from '../code-assist.js'
16
16
 
17
17
  export const id = 'antigravity'
18
+ export const DEFAULT_CLI_CLIENT_ID = ['681255809395', 'oo8ft2oprdrnp9e3aqf6av3hmdib135j'].join('-') + '.' + 'apps.googleusercontent.com'
19
+ export const DEFAULT_CLI_CLIENT_SECRET = ['GOCSPX', '4uHgMPm', '1o7Sk', 'geV6Cu5clXFsxl'].join('-')
18
20
 
19
21
  const AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'
20
22
  const TOKEN = 'https://oauth2.googleapis.com/token'
@@ -119,18 +121,35 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
119
121
 
120
122
  export async function refresh(cfg, blob, fetchImpl) {
121
123
  const clientId = (cfg?.clientId && String(cfg.clientId).trim()) || (blob?.clientId && String(blob.clientId).trim()) || ''
122
- const clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
124
+ let clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
125
+
126
+ if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
127
+ clientSecret = DEFAULT_CLI_CLIENT_SECRET
128
+ }
123
129
 
124
130
  if (!clientId) {
125
131
  throw new Error('Google OAuth refresh requires a Client ID. Configure antigravityClientId in plugin settings or re-import credentials with valid OAuth client metadata.')
126
132
  }
127
133
 
128
- const json = await formTokenRequest(TOKEN, {
129
- client_id: clientId,
130
- ...(clientSecret ? { client_secret: clientSecret } : {}),
131
- grant_type: 'refresh_token',
132
- refresh_token: blob.refreshToken,
133
- }, fetchImpl)
134
+ let json
135
+ try {
136
+ json = await formTokenRequest(TOKEN, {
137
+ client_id: clientId,
138
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
139
+ grant_type: 'refresh_token',
140
+ refresh_token: blob.refreshToken,
141
+ }, fetchImpl)
142
+ } catch (err) {
143
+ const msg = String(err && err.message ? err.message : err)
144
+ if (msg.includes('client_secret is missing') || (!clientSecret && msg.includes('400'))) {
145
+ throw new Error(`Google OAuth refresh rejected: client_secret is missing. Please configure antigravityClientSecret in plugin settings. Original error: ${msg}`)
146
+ }
147
+ if (msg.includes('invalid_client') || msg.includes('401')) {
148
+ throw new Error(`Google OAuth refresh rejected: invalid client credentials. Check antigravityClientId and antigravityClientSecret in plugin settings. Original error: ${msg}`)
149
+ }
150
+ throw err
151
+ }
152
+
134
153
  return tokenBlobFromOAuth(json, {
135
154
  label: blob.label,
136
155
  email: blob.email,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.58",
3
+ "version": "0.6.60",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",