@goodandready/dsh-opencode-provider 0.5.24 → 0.5.26

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.26 — release 3: quota display and switch notices
4
+
5
+ - **#75 — quota display modes**: new `usageDisplay` setting with `auto`/`always`/`off` (legacy `showSidebarQuota` maps to `always`); `auto` shows the sidebar card when a Go usage report exists. Exposed in Settings and TUI.
6
+ - **#79 — account-switch notice**: routing trace entries now carry the opaque DSH session id; a `conversation.composer.dock` notice shows a non-blocking switch message for the current conversation after rotation or quota fallback. No prompt content or keys are exposed.
7
+ - 130 tests.
8
+
9
+ ## 0.5.25 — release 2: stale data
10
+
11
+ - **#87 — stale usage**: a failed usage refresh now returns the last successful report with `stale: true` and `lastSuccessAt` instead of discarding it. The quota sidebar/panel and Settings label the stale snapshot; an explicit refresh no longer loses the previous reading.
12
+ - **#88 — stale analytics**: a failed analytics rescan keeps the previous report and heatmap visible with a stale notice and a retry button instead of blanking the panel; successful rescans clear the stale flag.
13
+ - 127 tests.
14
+
3
15
  ## 0.5.24 — release 1: account safety
4
16
 
5
17
  - **#89 — managed credential cleanup**: keys saved through Settings or imported from `auth.json` are recorded in `~/.dsh/storages/dsh-opencode/managed-credentials.json` (refs only, never secrets). Removing an account asks for confirmation and deletes only plugin-managed credentials after the settings save succeeds; external environment/`auth.json` references are left intact. Cleanup failures are reported separately from the account removal.
package/README.md CHANGED
@@ -132,12 +132,12 @@ npm pack
132
132
  Install the **packed tarball** into a profile:
133
133
 
134
134
  ```bash
135
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
135
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
136
136
  # DSH Desktop:
137
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
137
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
138
138
 
139
139
  # Or install the published package:
140
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.24
140
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.26
141
141
  ```
142
142
 
143
143
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -131,12 +131,12 @@ npm pack
131
131
  Установка из **упакованного tarball**:
132
132
 
133
133
  ```bash
134
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
134
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
135
135
  # DSH Desktop:
136
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
136
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
137
137
 
138
138
  # Либо установка опубликованного пакета из npm:
139
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.24
139
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.26
140
140
  ```
141
141
 
142
142
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -126,12 +126,12 @@ npm pack
126
126
  通过**打包文件 (.tgz)**安装:
127
127
 
128
128
  ```bash
129
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
129
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
130
130
  # DSH 桌面端:
131
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.24.tgz
131
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.26.tgz
132
132
 
133
133
  # 或从 npm 安装已发布版本:
134
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.24
134
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.26
135
135
  ```
136
136
 
137
137
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -96,6 +96,7 @@ window.__ModuleLoader__.load({
96
96
  runwaySafe: "safe",
97
97
  noUsage: "Quota is unavailable.",
98
98
  usageError: "Quota error",
99
+ usageStale: "Showing the last successful quota reading; the latest refresh failed.",
99
100
  refresh: "Refresh",
100
101
  refreshed: "Refreshed",
101
102
  loading: "Loading…",
@@ -144,6 +145,7 @@ window.__ModuleLoader__.load({
144
145
  heatmapAllAccounts: "All accounts",
145
146
  analyticsHint: "Aggregated from local session metadata by last activity. A session that switched models is counted under its last model; failures are not exposed by the projection. Only plugin routes are counted; legacy opencode sessions belong to the built-in catalog.",
146
147
  analyticsPartial: "This report is partial: the projection scan hit its file limit or skipped unreadable files.",
148
+ analyticsStale: "Showing the previous successful scan; the latest refresh failed.",
147
149
  analyticsEmpty: "No plugin sessions in this window. Legacy opencode sessions belong to the built-in catalog.",
148
150
  showAll: "Show all",
149
151
  modelSearch: "Search models…",
@@ -187,6 +189,11 @@ window.__ModuleLoader__.load({
187
189
  sidebar: "Sidebar",
188
190
  showSidebarQuota: "Show the quota card in the sidebar",
189
191
  showSidebarQuotaHint: "The card updates immediately after saving and otherwise every 60 seconds.",
192
+ usageDisplay: "Quota display",
193
+ usageDisplayAuto: "Auto (when Go usage is available)",
194
+ usageDisplayAlways: "Always",
195
+ usageDisplayOff: "Off",
196
+ usageDisplayHint: "Auto shows the sidebar card only when a Go usage report is available; Always keeps it visible; Off stops polling-hidden display.",
190
197
  routing: "Routing",
191
198
  routingHint: "Recent account/model choices. No prompts or keys are recorded.",
192
199
  routingEmpty: "No routing decisions yet.",
@@ -197,6 +204,7 @@ window.__ModuleLoader__.load({
197
204
  routingDefault: "Automatic",
198
205
  routingRotation: "Rotation",
199
206
  routingFallback: "Quota fallback",
207
+ accountSwitchNotice: "Account switched to {account} ({reason}).",
200
208
  routingFailed: "Failed",
201
209
  quotaFallbackModel: "Quota fallback model",
202
210
  quotaFallbackModelHint: "If Go rejects with a quota error, retry once on this free model through Console/Zen.",
@@ -314,6 +322,7 @@ window.__ModuleLoader__.load({
314
322
  runwaySafe: "安全",
315
323
  noUsage: "配额不可用。",
316
324
  usageError: "配额错误",
325
+ usageStale: "显示上次成功读取的配额;最新刷新失败。",
317
326
  refresh: "刷新",
318
327
  refreshed: "已刷新",
319
328
  loading: "加载中…",
@@ -362,6 +371,7 @@ window.__ModuleLoader__.load({
362
371
  heatmapAllAccounts: "全部账户",
363
372
  analyticsHint: "按最后活动时间聚合本地会话元数据。切换过模型的会话计入最后使用的模型;本地投影不暴露失败。仅统计插件路由;旧 opencode 会话属于内置目录。",
364
373
  analyticsPartial: "此报告不完整:投影扫描达到文件上限或跳过了不可读文件。",
374
+ analyticsStale: "显示上次成功扫描的结果;最新刷新失败。",
365
375
  analyticsEmpty: "此窗口内没有插件会话。旧 opencode 会话属于内置目录。",
366
376
  showAll: "显示全部",
367
377
  modelSearch: "搜索模型…",
@@ -405,6 +415,11 @@ window.__ModuleLoader__.load({
405
415
  sidebar: "侧边栏",
406
416
  showSidebarQuota: "在侧边栏显示配额卡片",
407
417
  showSidebarQuotaHint: "保存后立即更新;其余时间每 60 秒更新一次。",
418
+ usageDisplay: "配额显示",
419
+ usageDisplayAuto: "自动(有 Go 用量时)",
420
+ usageDisplayAlways: "常驻",
421
+ usageDisplayOff: "关闭",
422
+ usageDisplayHint: "自动:仅有 Go 用量报告时显示侧边栏卡片;常驻:始终显示;关闭:隐藏。",
408
423
  routing: "路由",
409
424
  routingHint: "最近使用的账户/模型选择。不会记录提示词或密钥。",
410
425
  routingEmpty: "尚无路由决策。",
@@ -415,6 +430,7 @@ window.__ModuleLoader__.load({
415
430
  routingDefault: "自动",
416
431
  routingRotation: "轮换",
417
432
  routingFallback: "配额回退",
433
+ accountSwitchNotice: "账户已切换为 {account}({reason})。",
418
434
  routingFailed: "失败",
419
435
  quotaFallbackModel: "配额回退模型",
420
436
  quotaFallbackModelHint: "Go 返回配额错误时,通过 Console/Zen 用此免费模型重试一次。",
@@ -465,6 +481,9 @@ window.__ModuleLoader__.load({
465
481
  function fetchState() {
466
482
  return call("/dsh-opencode/state");
467
483
  }
484
+ function fetchNotices() {
485
+ return call("/dsh-opencode/notices");
486
+ }
468
487
  function refreshState() {
469
488
  return call("/dsh-opencode/refresh", {
470
489
  method: "POST",
@@ -1016,6 +1035,10 @@ window.__ModuleLoader__.load({
1016
1035
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1017
1036
  className: "oc-bars",
1018
1037
  children: [
1038
+ usage.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1039
+ className: "oc-hint",
1040
+ children: [t("usageStale"), usage.lastSuccessAt === void 0 ? "" : ` (${new Date(usage.lastSuccessAt).toLocaleTimeString()})`]
1041
+ }) : null,
1019
1042
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1020
1043
  label: t("fiveHour"),
1021
1044
  window: windows.fiveHour
@@ -1230,7 +1253,10 @@ window.__ModuleLoader__.load({
1230
1253
  const { t } = props;
1231
1254
  const state = useHostState(6e4);
1232
1255
  const expanded = usePanelOpen();
1233
- if (state === null || state.config.showSidebarQuota !== true) return null;
1256
+ if (state === null) return null;
1257
+ const mode = state.config.usageDisplay ?? (state.config.showSidebarQuota === true ? "always" : "auto");
1258
+ if (mode === "off") return null;
1259
+ if (mode === "auto" && state.usage === null) return null;
1234
1260
  const windows = state.usage?.report?.windows;
1235
1261
  const accountLabel = state.accounts.find((account) => account.active)?.label;
1236
1262
  const rows = [
@@ -1344,6 +1370,7 @@ window.__ModuleLoader__.load({
1344
1370
  const [stateError, setStateError] = (0, react.useState)(null);
1345
1371
  const [analytics, setAnalytics] = (0, react.useState)(null);
1346
1372
  const [analyticsError, setAnalyticsError] = (0, react.useState)(null);
1373
+ const [analyticsStale, setAnalyticsStale] = (0, react.useState)(false);
1347
1374
  const [days, setDays] = (0, react.useState)(7);
1348
1375
  const [busy, setBusy] = (0, react.useState)(false);
1349
1376
  const analyticsSeq = (0, react.useRef)(0);
@@ -1359,14 +1386,16 @@ window.__ModuleLoader__.load({
1359
1386
  const seq = analyticsSeq.current + 1;
1360
1387
  analyticsSeq.current = seq;
1361
1388
  setAnalyticsError(null);
1389
+ setAnalyticsStale(analytics !== null);
1362
1390
  fetchAnalytics(windowDays).then((next) => {
1363
1391
  if (seq !== analyticsSeq.current) return;
1364
1392
  setAnalytics(next);
1365
1393
  setAnalyticsError(null);
1394
+ setAnalyticsStale(false);
1366
1395
  }).catch((caught) => {
1367
1396
  if (seq !== analyticsSeq.current) return;
1368
- setAnalytics(null);
1369
1397
  setAnalyticsError(caught instanceof Error ? caught.message : "analytics failed");
1398
+ setAnalyticsStale(analytics !== null);
1370
1399
  });
1371
1400
  };
1372
1401
  (0, react.useEffect)(() => {
@@ -1583,59 +1612,69 @@ window.__ModuleLoader__.load({
1583
1612
  children: t("intro")
1584
1613
  })]
1585
1614
  }),
1586
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1615
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1587
1616
  className: "oc-card",
1588
- children: analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1617
+ children: [
1589
1618
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1590
1619
  className: "oc-card-title",
1591
1620
  children: t("usageAnalytics")
1592
1621
  }),
1593
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1622
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1594
1623
  className: "oc-hint",
1595
1624
  children: [
1596
1625
  t("failed"),
1597
1626
  ": ",
1598
1627
  analyticsError
1599
1628
  ]
1600
- }),
1601
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1629
+ }) : null,
1630
+ analyticsStale && analytics !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1631
+ className: "oc-hint",
1632
+ children: t("analyticsStale")
1633
+ }) : null,
1634
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1602
1635
  type: "button",
1603
1636
  className: "oc-button",
1604
1637
  onClick: () => loadAnalytics(days),
1605
1638
  children: t("refresh")
1639
+ }) : null,
1640
+ analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1641
+ className: "oc-hint",
1642
+ children: t("loading")
1643
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageAnalyticsView, {
1644
+ analytics,
1645
+ t,
1646
+ days,
1647
+ onDays: setDays
1606
1648
  })
1607
- ] }) : analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1608
- className: "oc-card-title",
1609
- children: t("usageAnalytics")
1610
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1611
- className: "oc-hint",
1612
- children: t("loading")
1613
- })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageAnalyticsView, {
1614
- analytics,
1615
- t,
1616
- days,
1617
- onDays: setDays
1618
- })
1649
+ ]
1619
1650
  }),
1620
1651
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1621
1652
  className: "oc-card",
1622
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1623
- className: "oc-card-title",
1624
- children: t("requestHeatmap")
1625
- }), analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1626
- className: "oc-hint",
1627
- children: [
1628
- t("failed"),
1629
- ": ",
1630
- analyticsError
1631
- ]
1632
- }) : analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1633
- className: "oc-hint",
1634
- children: t("loading")
1635
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestHeatmapView, {
1636
- heatmap: analytics.heatmap,
1637
- t
1638
- })]
1653
+ children: [
1654
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1655
+ className: "oc-card-title",
1656
+ children: t("requestHeatmap")
1657
+ }),
1658
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1659
+ className: "oc-hint",
1660
+ children: [
1661
+ t("failed"),
1662
+ ": ",
1663
+ analyticsError
1664
+ ]
1665
+ }) : null,
1666
+ analyticsStale && analytics !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1667
+ className: "oc-hint",
1668
+ children: t("analyticsStale")
1669
+ }) : null,
1670
+ analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1671
+ className: "oc-hint",
1672
+ children: t("loading")
1673
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestHeatmapView, {
1674
+ heatmap: analytics.heatmap,
1675
+ t
1676
+ })
1677
+ ]
1639
1678
  })
1640
1679
  ]
1641
1680
  });
@@ -1660,6 +1699,7 @@ window.__ModuleLoader__.load({
1660
1699
  maxOutputTokensOverrides: Object.fromEntries(Object.entries(state.config.maxOutputTokensOverrides).map(([id, value]) => [id, String(value)])),
1661
1700
  showSidebarQuota: state.config.showSidebarQuota,
1662
1701
  activeAccount: state.config.activeAccount,
1702
+ usageDisplay: state.config.usageDisplay,
1663
1703
  accounts: state.config.accounts.map((account) => ({ ...account })),
1664
1704
  modelAccountRules: state.config.modelAccountRules.map((rule) => ({ ...rule })),
1665
1705
  quotaFallbackModel: state.config.quotaFallbackModel,
@@ -1706,6 +1746,7 @@ window.__ModuleLoader__.load({
1706
1746
  maxOutputTokensOverrides: numberRecordOrNull(draft.maxOutputTokensOverrides),
1707
1747
  showSidebarQuota: draft.showSidebarQuota,
1708
1748
  activeAccount: draft.activeAccount.trim(),
1749
+ usageDisplay: draft.usageDisplay,
1709
1750
  accounts: draft.accounts.map((account) => ({
1710
1751
  label: account.label.trim(),
1711
1752
  apiKeyEnv: account.apiKeyEnv.trim()
@@ -2334,6 +2375,10 @@ window.__ModuleLoader__.load({
2334
2375
  ]
2335
2376
  });
2336
2377
  })(),
2378
+ defaultAccount?.usage?.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2379
+ className: "oc-hint",
2380
+ children: t("usageStale")
2381
+ }) : null,
2337
2382
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2338
2383
  className: "oc-row",
2339
2384
  children: [
@@ -2435,8 +2480,8 @@ window.__ModuleLoader__.load({
2435
2480
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2436
2481
  className: "oc-card-title",
2437
2482
  children: [t("serviceStatus"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2438
- className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2439
- children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2483
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 && state.service.usageStale !== true ? "oc-badge-ok" : "oc-badge-bad"}`,
2484
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 && state.service.usageStale !== true ? t("serviceOk") : t("serviceDegraded")
2440
2485
  })]
2441
2486
  }),
2442
2487
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2447,7 +2492,7 @@ window.__ModuleLoader__.load({
2447
2492
  children: t("usageEndpoint")
2448
2493
  }),
2449
2494
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2450
- className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2495
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 && state.service.usageStale !== true ? "oc-badge-ok" : "oc-badge-bad"}`,
2451
2496
  children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2452
2497
  }),
2453
2498
  state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2460,6 +2505,10 @@ window.__ModuleLoader__.load({
2460
2505
  className: "oc-hint",
2461
2506
  children: state.service.usageError
2462
2507
  }),
2508
+ state.service.usageStale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2509
+ className: "oc-hint",
2510
+ children: t("usageStale")
2511
+ }) : null,
2463
2512
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2464
2513
  className: "oc-row",
2465
2514
  children: [
@@ -2641,6 +2690,10 @@ window.__ModuleLoader__.load({
2641
2690
  known.usage.error
2642
2691
  ]
2643
2692
  }),
2693
+ known?.usage?.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2694
+ className: "oc-hint",
2695
+ children: t("usageStale")
2696
+ }) : null,
2644
2697
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2645
2698
  className: "oc-row",
2646
2699
  children: [
@@ -3115,16 +3168,33 @@ window.__ModuleLoader__.load({
3115
3168
  children: t("sidebar")
3116
3169
  }),
3117
3170
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3118
- className: "oc-row",
3119
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3120
- type: "checkbox",
3121
- checked: draft.showSidebarQuota,
3122
- onChange: (event) => update({ showSidebarQuota: event.target.checked })
3123
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("showSidebarQuota") })]
3171
+ className: "oc-field",
3172
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3173
+ className: "oc-label",
3174
+ children: t("usageDisplay")
3175
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
3176
+ className: "oc-select",
3177
+ value: draft.usageDisplay,
3178
+ onChange: (event) => update({ usageDisplay: event.target.value }),
3179
+ children: [
3180
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3181
+ value: "auto",
3182
+ children: t("usageDisplayAuto")
3183
+ }),
3184
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3185
+ value: "always",
3186
+ children: t("usageDisplayAlways")
3187
+ }),
3188
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3189
+ value: "off",
3190
+ children: t("usageDisplayOff")
3191
+ })
3192
+ ]
3193
+ })]
3124
3194
  }),
3125
3195
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3126
3196
  className: "oc-hint",
3127
- children: t("showSidebarQuotaHint")
3197
+ children: t("usageDisplayHint")
3128
3198
  })
3129
3199
  ]
3130
3200
  }),
@@ -3257,6 +3327,7 @@ window.__ModuleLoader__.load({
3257
3327
  .oc-drag { cursor: grab; color: var(--oc-text-tertiary); user-select: none; font-size: 13px; line-height: 1; }
3258
3328
  .oc-drag:active { cursor: grabbing; }
3259
3329
  .oc-notice { font-size: 12px; padding: 7px 10px; border-radius: 7px; background: var(--oc-accent-soft); border: 1px solid var(--oc-accent-border); }
3330
+ .oc-switch-notice { margin: 4px auto; padding: 4px 10px; border: 1px solid var(--oc-border); border-radius: 999px; background: var(--oc-fill-soft); color: var(--oc-text-secondary); font-size: 11px; line-height: 1.4; text-align: center; }
3260
3331
  .oc-error { font-size: 12px; padding: 7px 10px; border-radius: 7px; background: var(--oc-danger-soft); border: 1px solid var(--oc-danger-border); color: var(--oc-danger); }
3261
3332
  [class*="_footerActions"]:has(.oc-foot), [class*="_footerActions"]:has(.oc-railButton) { flex-direction: column; }
3262
3333
  .oc-foot { box-sizing: border-box; flex: 1 1 auto; width: 100%; min-width: 0; display: flex; flex-direction: column; gap: 6px; margin: 0 0 4px; padding: 8px; border: 0.5px solid transparent; border-radius: var(--dsw-radius-md, 8px); background: transparent; color: var(--oc-text-secondary); font: inherit; text-align: left; cursor: pointer; }
@@ -3288,6 +3359,47 @@ window.__ModuleLoader__.load({
3288
3359
  .oc-table th, .oc-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--oc-border-soft); vertical-align: top; }
3289
3360
  .oc-table th { font-weight: 600; opacity: 0.8; }
3290
3361
  `;
3362
+ // src/client/switch-notice.tsx
3363
+ /** Ambient notice under the composer when this conversation switched accounts. */
3364
+ function SwitchNotice({ sessionId, t }) {
3365
+ const [notice, setNotice] = (0, react.useState)(null);
3366
+ const seenAt = (0, react.useRef)(Date.now() - 5e3);
3367
+ (0, react.useEffect)(() => {
3368
+ if (sessionId === void 0 || String(sessionId) === "") return;
3369
+ let alive = true;
3370
+ const poll = () => {
3371
+ fetchNotices().then(({ events }) => {
3372
+ if (!alive) return;
3373
+ const event = events.filter((entry) => entry.session === String(sessionId) && entry.at > seenAt.current && entry.ok && (entry.reason === "rotation" || entry.reason === "quota-fallback")).sort((left, right) => right.at - left.at)[0];
3374
+ if (event === void 0) return;
3375
+ seenAt.current = event.at;
3376
+ setNotice({
3377
+ account: event.account,
3378
+ reason: event.reason,
3379
+ at: event.at
3380
+ });
3381
+ }).catch(() => {});
3382
+ };
3383
+ poll();
3384
+ const timer = setInterval(poll, 5e3);
3385
+ return () => {
3386
+ alive = false;
3387
+ clearInterval(timer);
3388
+ };
3389
+ }, [sessionId]);
3390
+ (0, react.useEffect)(() => {
3391
+ if (notice === null) return;
3392
+ const timer = setTimeout(() => setNotice(null), 6e4);
3393
+ return () => clearTimeout(timer);
3394
+ }, [notice]);
3395
+ if (notice === null) return null;
3396
+ const reason = notice.reason === "quota-fallback" ? t("routingFallback") : t("routingRotation");
3397
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3398
+ className: "oc-switch-notice",
3399
+ role: "status",
3400
+ children: t("accountSwitchNotice").replace("{account}", notice.account).replace("{reason}", reason)
3401
+ });
3402
+ }
3291
3403
  // src/client/index.tsx
3292
3404
  function injectCss() {
3293
3405
  if (typeof document === "undefined") return () => {};
@@ -3323,6 +3435,12 @@ window.__ModuleLoader__.load({
3323
3435
  locale: NS,
3324
3436
  inject: () => ({ close: () => ctx.get("layout")?.selectPanel(null) })
3325
3437
  }, QuotaPanel));
3438
+ ctx.slots.inject("conversation.composer.dock", () => slots.register({
3439
+ name: "conversation.composer.dock",
3440
+ id: "opencode-switch-notice",
3441
+ order: 80,
3442
+ locale: NS
3443
+ }, SwitchNotice));
3326
3444
  ctx.inject(["layout"], (layoutCtx) => {
3327
3445
  const layoutSlots = layoutCtx.slots;
3328
3446
  layoutCtx.slots.inject("sidebar.footer.action", () => layoutSlots.register({
package/lib/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Context } from "@deepseek-ai/cordis";
4
4
  import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
5
5
  import { WebServer } from "@deepseek-ai/dsh-host-webserver";
6
6
  type TransportId = 'openai-chat' | 'openai-responses' | 'anthropic' | 'google';
7
+ type UsageDisplay = 'auto' | 'always' | 'off';
7
8
  interface AccountConfig {
8
9
  label?: string;
9
10
  apiKeyEnv?: string;
@@ -66,7 +67,9 @@ interface Config {
66
67
  stickySession?: boolean;
67
68
  /** Pinned account id; empty means automatic. */
68
69
  activeAccount?: string;
69
- /** Show the quota card in the sidebar. */
70
+ /** Sidebar quota visibility; legacy booleans map to always/auto. */
71
+ usageDisplay?: UsageDisplay;
72
+ /** Legacy boolean; used only when `usageDisplay` is absent. */
70
73
  showSidebarQuota?: boolean;
71
74
  /** Product user agent sent to OpenCode. */
72
75
  userAgent?: string;
@@ -97,6 +100,7 @@ interface ResolvedConfig {
97
100
  autoSwitch: boolean;
98
101
  stickySession: boolean;
99
102
  activeAccount: string;
103
+ usageDisplay: UsageDisplay;
100
104
  showSidebarQuota: boolean;
101
105
  userAgent: string;
102
106
  }
@@ -163,6 +167,8 @@ interface RoutingTraceEntry {
163
167
  readonly provider: string;
164
168
  readonly model: string;
165
169
  readonly account: string;
170
+ /** DSH session id; never prompt content. */
171
+ readonly session?: string;
166
172
  readonly reason: RoutingReason;
167
173
  readonly fallback: boolean;
168
174
  readonly ok: boolean;
@@ -263,6 +269,9 @@ interface UsageResult {
263
269
  readonly keyStatus?: UsageKeyStatus;
264
270
  readonly latencyMs?: number;
265
271
  readonly checkedAt: number;
272
+ /** True when `report` is the last successful reading after a failed refresh. */
273
+ readonly stale?: boolean;
274
+ readonly lastSuccessAt?: number;
266
275
  }
267
276
  interface UsageWindowPayload {
268
277
  status?: string;
package/lib/index.js CHANGED
@@ -81,7 +81,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
81
81
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
82
82
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
83
83
  const DEFAULT_AUTH_ENTRY = "opencode-go";
84
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.24";
84
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.26";
85
85
  const DEFAULT_CONTEXT_WINDOW = 262144;
86
86
  const DEFAULT_MAX_TOKENS = 32768;
87
87
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -126,6 +126,11 @@ const Config = z.object({
126
126
  autoSwitch: markVolatile(z.boolean().default(true)),
127
127
  stickySession: markVolatile(z.boolean().default(true)),
128
128
  activeAccount: markVolatile(z.string().default("")),
129
+ usageDisplay: markVolatile(z.union([
130
+ "auto",
131
+ "always",
132
+ "off"
133
+ ])),
129
134
  showSidebarQuota: markVolatile(z.boolean().default(false)),
130
135
  userAgent: markVolatile(z.string().default(DEFAULT_USER_AGENT))
131
136
  });
@@ -222,6 +227,7 @@ function resolveConfig(config) {
222
227
  autoSwitch: config.autoSwitch !== false,
223
228
  stickySession: config.stickySession !== false,
224
229
  activeAccount: typeof config.activeAccount === "string" ? config.activeAccount : "",
230
+ usageDisplay: config.usageDisplay === "always" || config.usageDisplay === "off" ? config.usageDisplay : config.usageDisplay === "auto" ? "auto" : config.showSidebarQuota === true ? "always" : "auto",
225
231
  showSidebarQuota: config.showSidebarQuota === true,
226
232
  userAgent: typeof config.userAgent === "string" && config.userAgent !== "" ? config.userAgent : DEFAULT_USER_AGENT
227
233
  };
@@ -3305,6 +3311,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3305
3311
  const runStartedAt = Date.now();
3306
3312
  const model = effectiveModel(options.model, config);
3307
3313
  const sessionId = openCodeSessionId(options);
3314
+ const traceSession = options.sessionId === void 0 ? "" : String(options.sessionId);
3308
3315
  const rawKeys = orderKeysForModel(this.deps.resolveKeys !== void 0 ? await this.deps.resolveKeys(options.provider) : [await this.deps.resolveKey(options.provider)], options.model, config.modelAccountRules);
3309
3316
  const rule = matchModelAccount(options.model, config.modelAccountRules);
3310
3317
  let keys = rawKeys;
@@ -3404,6 +3411,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3404
3411
  provider: options.provider,
3405
3412
  model: options.model,
3406
3413
  account: entry.label ?? "default",
3414
+ ...traceSession === "" ? {} : { session: traceSession },
3407
3415
  reason: attemptReason,
3408
3416
  fallback,
3409
3417
  ok: true
@@ -3418,6 +3426,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3418
3426
  provider: options.provider,
3419
3427
  model: options.model,
3420
3428
  account: entry.label ?? "default",
3429
+ ...traceSession === "" ? {} : { session: traceSession },
3421
3430
  reason: attemptReason,
3422
3431
  fallback,
3423
3432
  ok: false,
@@ -3446,6 +3455,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3446
3455
  provider: options.provider,
3447
3456
  model: options.model,
3448
3457
  account: lastAccount,
3458
+ ...traceSession === "" ? {} : { session: traceSession },
3449
3459
  reason,
3450
3460
  fallback,
3451
3461
  ok: false,
@@ -4734,6 +4744,7 @@ function publicConfig(config) {
4734
4744
  autoSwitch: config.autoSwitch,
4735
4745
  stickySession: config.stickySession,
4736
4746
  activeAccount: config.activeAccount,
4747
+ usageDisplay: config.usageDisplay,
4737
4748
  showSidebarQuota: config.showSidebarQuota,
4738
4749
  userAgent: config.userAgent
4739
4750
  };
@@ -4923,6 +4934,10 @@ function configOps(patch) {
4923
4934
  if ("autoSwitch" in patch) set("autoSwitch", patch.autoSwitch === true);
4924
4935
  if ("stickySession" in patch) set("stickySession", patch.stickySession === true);
4925
4936
  if ("activeAccount" in patch) set("activeAccount", patch.activeAccount);
4937
+ if ("usageDisplay" in patch) {
4938
+ const value = patch.usageDisplay;
4939
+ if (value === "auto" || value === "always" || value === "off") set("usageDisplay", value);
4940
+ }
4926
4941
  if ("showSidebarQuota" in patch) set("showSidebarQuota", patch.showSidebarQuota === true);
4927
4942
  if ("userAgent" in patch) set("userAgent", patch.userAgent);
4928
4943
  return ops;
@@ -5033,7 +5048,8 @@ async function buildState(ctx, deps) {
5033
5048
  service: {
5034
5049
  ...usage?.checkedAt === void 0 ? {} : { usageCheckedAt: usage.checkedAt },
5035
5050
  ...usage?.latencyMs === void 0 ? {} : { usageLatencyMs: usage.latencyMs },
5036
- ...usage?.error === void 0 ? {} : { usageError: usage.error }
5051
+ ...usage?.error === void 0 ? {} : { usageError: usage.error },
5052
+ ...usage?.stale === true ? { usageStale: true } : {}
5037
5053
  },
5038
5054
  routing: deps.adapter.routingTrace(),
5039
5055
  auth: {
@@ -5073,6 +5089,21 @@ function applyRoutes(ctx, web, deps) {
5073
5089
  json(res, 500, { error: error instanceof Error ? error.message : "state failed" });
5074
5090
  }
5075
5091
  });
5092
+ route("/dsh-opencode/notices", async (req, res) => {
5093
+ if (req.method !== "GET") {
5094
+ json(res, 405, { error: "GET required" });
5095
+ return;
5096
+ }
5097
+ if (!trustedHost(req)) {
5098
+ json(res, 403, { error: "non-local Host refused" });
5099
+ return;
5100
+ }
5101
+ if (!allowedOrigin(req)) {
5102
+ json(res, 403, { error: "cross-origin request refused" });
5103
+ return;
5104
+ }
5105
+ json(res, 200, { events: deps.adapter.routingTrace() });
5106
+ });
5076
5107
  route("/dsh-opencode/health", async (req, res) => {
5077
5108
  if (req.method !== "GET") {
5078
5109
  json(res, 405, { error: "GET required" });
@@ -5476,6 +5507,32 @@ function buildOpenCodeTuiSection(deps) {
5476
5507
  value: text.trim() === "true"
5477
5508
  })
5478
5509
  },
5510
+ {
5511
+ path: ["usageDisplay"],
5512
+ group: "sidebar",
5513
+ kind: "select",
5514
+ label: "Quota display",
5515
+ hint: "Auto, Always, or Off for the sidebar quota card.",
5516
+ options: [
5517
+ {
5518
+ value: "auto",
5519
+ label: "Auto"
5520
+ },
5521
+ {
5522
+ value: "always",
5523
+ label: "Always"
5524
+ },
5525
+ {
5526
+ value: "off",
5527
+ label: "Off"
5528
+ }
5529
+ ],
5530
+ format: (value) => typeof value === "string" ? value : deps.usageDisplay(),
5531
+ parse: (text) => text === "auto" || text === "always" || text === "off" ? {
5532
+ kind: "set",
5533
+ value: text
5534
+ } : void 0
5535
+ },
5479
5536
  {
5480
5537
  path: ["showSidebarQuota"],
5481
5538
  group: "sidebar",
@@ -5618,6 +5675,8 @@ function fingerprint(key) {
5618
5675
  /** Cached reader for the (undocumented) Go usage endpoint. Fail-open by design. */
5619
5676
  function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5620
5677
  const cache = /* @__PURE__ */ new Map();
5678
+ /** Last successful reading per key, kept across explicit cache clears. */
5679
+ const lastSuccess = /* @__PURE__ */ new Map();
5621
5680
  const read = async (provider, explicitKey) => {
5622
5681
  if (provider !== "opencode-go-direct" && provider !== "opencode-go") return void 0;
5623
5682
  const current = config();
@@ -5635,7 +5694,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5635
5694
  const cacheKey = `${provider}:${fingerprint(key)}`;
5636
5695
  const cached = cache.get(cacheKey);
5637
5696
  if (cached !== void 0) {
5638
- const ttl = cached.report === void 0 ? FAILURE_TTL_MS : SUCCESS_TTL_MS;
5697
+ const ttl = cached.report === void 0 || cached.stale === true ? FAILURE_TTL_MS : SUCCESS_TTL_MS;
5639
5698
  if (Date.now() - cached.checkedAt < ttl) return cached;
5640
5699
  }
5641
5700
  const result = async () => {
@@ -5693,8 +5752,25 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5693
5752
  }
5694
5753
  };
5695
5754
  const fresh = await result();
5696
- cache.set(cacheKey, fresh);
5697
- return fresh;
5755
+ const previous = cached?.report !== void 0 ? cached : lastSuccess.get(cacheKey);
5756
+ if (fresh.report === void 0 && previous?.report !== void 0) {
5757
+ const stale = {
5758
+ ...fresh,
5759
+ report: previous.report,
5760
+ stale: true,
5761
+ lastSuccessAt: previous.lastSuccessAt ?? previous.checkedAt
5762
+ };
5763
+ cache.set(cacheKey, stale);
5764
+ return stale;
5765
+ }
5766
+ const next = fresh.report === void 0 ? fresh : {
5767
+ ...fresh,
5768
+ stale: false,
5769
+ lastSuccessAt: fresh.checkedAt
5770
+ };
5771
+ if (next.report !== void 0) lastSuccess.set(cacheKey, next);
5772
+ cache.set(cacheKey, next);
5773
+ return next;
5698
5774
  };
5699
5775
  read.clear = () => {
5700
5776
  cache.clear();
@@ -5773,6 +5849,7 @@ function apply(ctx, config) {
5773
5849
  quotaFallbackModel: () => current().quotaFallbackModel,
5774
5850
  autoSwitch: () => current().autoSwitch,
5775
5851
  stickySession: () => current().stickySession,
5852
+ usageDisplay: () => current().usageDisplay,
5776
5853
  showSidebarQuota: () => current().showSidebarQuota
5777
5854
  });
5778
5855
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",