dsh-ds-balance 2.1.3 → 2.3.0

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.
Files changed (50) hide show
  1. package/README.md +71 -18
  2. package/README_en.md +76 -18
  3. package/lib/adapters/domain-core-store.d.ts +46 -2
  4. package/lib/adapters/domain-core-store.js +144 -4
  5. package/lib/adapters/host-account.d.ts +66 -0
  6. package/lib/adapters/host-account.js +68 -0
  7. package/lib/client.js +437 -95
  8. package/lib/domain/account.d.ts +40 -0
  9. package/lib/domain/account.js +74 -0
  10. package/lib/domain/balance.d.ts +40 -1
  11. package/lib/domain/balance.js +20 -1
  12. package/lib/domain/normalize.d.ts +3 -2
  13. package/lib/domain/normalize.js +3 -2
  14. package/lib/domain/severity.d.ts +8 -2
  15. package/lib/domain/severity.js +11 -4
  16. package/lib/domain/situation.d.ts +104 -0
  17. package/lib/domain/situation.js +93 -0
  18. package/lib/http/handlers.d.ts +1 -1
  19. package/lib/http/handlers.js +29 -5
  20. package/lib/http/wire.d.ts +11 -1
  21. package/lib/http/wire.js +2 -0
  22. package/lib/index.js +122 -6
  23. package/lib/ports/account.d.ts +27 -0
  24. package/lib/ports/account.js +9 -0
  25. package/lib/ports/core-store.d.ts +15 -0
  26. package/lib/services/balance-service.d.ts +66 -94
  27. package/lib/services/balance-service.js +132 -196
  28. package/lib/services/balance-source.d.ts +187 -0
  29. package/lib/services/balance-source.js +320 -0
  30. package/lib/services/scheduler.d.ts +8 -2
  31. package/lib/services/scheduler.js +1 -1
  32. package/lib/services/source-readers.d.ts +28 -0
  33. package/lib/services/source-readers.js +83 -0
  34. package/lib/services/source-selector.d.ts +50 -0
  35. package/lib/services/source-selector.js +64 -0
  36. package/lib/types/client/api-types.d.ts +16 -0
  37. package/lib/types/client/data.d.ts +12 -3
  38. package/lib/types/client/index.d.ts +6 -0
  39. package/lib/types/client/locales.d.ts +10 -18
  40. package/lib/types/client/mock/index.d.ts +12 -11
  41. package/lib/types/client/mock/scenarios.d.ts +13 -5
  42. package/lib/types/client/model.d.ts +24 -25
  43. package/lib/types/client/route-hint.d.ts +61 -0
  44. package/lib/types/client/sidebar/BalancePopover.d.ts +7 -0
  45. package/lib/types/client/sidebar/PercentRing.d.ts +13 -5
  46. package/lib/types/client/sidebar/SidebarBalance.d.ts +9 -1
  47. package/lib/types/client/situation.d.ts +164 -0
  48. package/lib/version.d.ts +1 -1
  49. package/lib/version.js +1 -1
  50. package/package.json +25 -23
package/README.md CHANGED
@@ -29,7 +29,7 @@
29
29
  ---
30
30
 
31
31
  > [!NOTE]
32
- > **余额读自 DeepSeek 官方 `GET /user/balance`**,不是估算。凭据只经 DSH 的凭据通道解析:API Key 不会出现在设置文件里,也不会返回给界面。
32
+ > **余额读自 DeepSeek 官方接口**,不是估算。凭据只经 DSH 的凭据通道解析:API Key 不会出现在设置文件里,也不会返回给界面。
33
33
 
34
34
  余额要有地方看,但不该占地方。左边栏底部一个常驻的状态环,点开是三段金额与数据新鲜度;要改配置,进侧边栏**插件(Plugins)**页里本插件的详情页。
35
35
 
@@ -39,12 +39,40 @@
39
39
  <em>常驻<strong>左边栏底部</strong>,与「使用统计」「设置」并排;点开是余额、赠送 / 充值拆分与数据新鲜度。</em>
40
40
  </p>
41
41
 
42
+ ## 一眼看懂那个环
43
+
44
+ 那个环是「现在是什么情况」,一眼能分出五种,各自对应你该做的事:
45
+
46
+ | 你看到 | 意思 | 你该做什么 |
47
+ |---|---|---|
48
+ | 彩环 + 一段弧 | 有余额,颜色就是多还是少 | **看数字** |
49
+ | 灰环 + 一段缺口在转 | 正在取,还没结果 | **等** |
50
+ | 灰环 + 中间一个 **+** | 没接入官方 | **去配置**(填 API Key 或登录账号) |
51
+ | 灰色空环 | 连上了,账户就是没余额 | **不用管** |
52
+ | 红环 + 中间一个 **叉** | 这里没有可用的数字 | **等、检查,或去查账户** |
53
+
54
+ **鼠标停上去,它会告诉你这一格现在什么意思**(收起态那 56px 轨道、展开态带名字的条目,
55
+ 停上去显示的是同一句话):
56
+
57
+ - **数字可信时**(正常 / 偏低 / 告急)—— 直接显示**金额**;
58
+ - **其余情况** —— 显示这个处境自己的话:「正在获取」「读不到余额」「服务异常」
59
+ 「尚未配置凭据」「账户不可用」「数据已过期」「暂无余额」。
60
+
61
+ **账户停用**与**数据过期**虽然手里有数字,也**不**显示那个数字:
62
+ 停用的账户、过期的快照,报出来的数只会误导(「我有 ¥0」/「我有 ¥110」)。
63
+ 环已经把问题画出来了(红叉 / 旧色),悬停该做的是解释问题。
64
+
65
+ 两种「看不到」分得开:**没接入**提示你去配凭据;**接入了但读不到**说「读不到余额」
66
+ (不写「暂时」—— 有些原因等一下就好,有些得你去改凭据,说「暂时」会把人劝去干等)。
67
+ 那个**叉**既表示「读不到」,也表示「读到了但这个账户不能用」——
68
+ 两者都是「这里没有可用的数字」,都要你去查。
69
+
42
70
  ## 界面一览
43
71
 
44
72
  | 界面 | 一句话 | 你会用它来 |
45
73
  |---|---|---|
46
- | 侧栏圆环 | 常驻左边栏底部的状态环 + 名称,**展开态悬浮条目就报出余额金额**,**环里填了多少 = 余额离该币种预警线还有多远** | 扫一眼就知道还剩多少、离预警线多远 |
47
- | 余额浮层 | 点条目展开:总额、赠送 / 充值拆分、数据新鲜度、手动刷新(带冷却);**右上角图标跳到本插件的配置格**(宿主没有那条跨插件深链服务时退回 Plugins 列表,悬浮文字同步改口径) | 核对具体数字,以及数据是几分钟前的 |
74
+ | 侧栏圆环 | 常驻左边栏底部的状态环 + 名称;**悬停(收起态与展开态一样)显示同一句话**:数字可信时显金额,否则显状态文案;**环里填了多少 = 余额离该币种预警线还有多远** | 扫一眼就知道还剩多少、离预警线多远 |
75
+ | 余额浮层 | 点条目展开:总额、赠送 / 充值拆分、数据新鲜度、手动刷新(带冷却);**右上角图标直达本插件的配置**(宿主版本不支持时退回 Plugins 列表,悬浮文字同步改口径) | 核对具体数字,以及数据是几分钟前的 |
48
76
  | 设置卡片 | 连接 / 展示 / 阈值 / 刷新四组,**默认四组全收起**,点组头逐组展开 | 换端点、换币种、调预警线、调节奏 |
49
77
 
50
78
  三种界面的分工是死的:**圆环回答「大概还剩多少」,浮层回答「具体是多少」,卡片回答「怎么算」。**
@@ -73,16 +101,16 @@
73
101
 
74
102
  ### 前置
75
103
 
76
- - **DSH**:版本范围以 [package.json](package.json) 的 `engines.dsh` 与 `peerDependencies` 为准,本插件跟的是宿主当前那条 alpha 线。
77
- - Node `>= 20`(同上,真源是 `engines.node`)。
104
+ - **DSH**:版本范围以 [package.json](package.json) 的声明为准,本插件跟的是宿主当前那条 **RC 线**(dist-tag 是 `next`)。
105
+ - **Node**:`>= 20`(同一处声明)。
78
106
 
79
- 装宿主时**要显式点名版本线**:`@deepseek-ai/dsh` 的 `latest` 标签比本插件要求的那条线还旧 —— 按默认方式装会落在声明范围之外。
107
+ 装宿主时**要显式点名版本线**:`@deepseek-ai/dsh` 的 `alpha` 与 `latest` 都**不是**我们声明的那条线 —— 按默认方式装会落在声明范围之外。
80
108
 
81
109
  ```bash
82
- npm install -g @deepseek-ai/dsh@alpha # 本插件承诺支持的线
110
+ npm install -g @deepseek-ai/dsh@next # 本插件承诺支持的线(RC 线;npm 上没有 rc 这个 tag)
83
111
  ```
84
112
 
85
- 兼容性不是推断出来的:每周由 [compat.yml](.github/workflows/compat.yml) 在 `alpha` 与 `next` 两条线上换包实跑一遍现有测试,另有一步单独判声明面罩不罩得住;红了会开或更新一条固定标题的跟踪 issue。当前结论与红了怎么办见 [兼容性](docs/PUBLISHING.md#兼容性),宿主版本的分水岭见[版本兼容](#版本兼容)。
113
+ 兼容性怎么保证、哪条线该跟,见[版本兼容](#版本兼容)。
86
114
 
87
115
  ### 从 npm 安装
88
116
 
@@ -104,6 +132,9 @@ dsh plugin --profile web add "$PWD"
104
132
 
105
133
  与 npm 那条路一样,重启后生效。
106
134
 
135
+ ⚠️ 上面那步 `npm install` 不能省:`link:` 装法下依赖由仓库自己提供(见 [AGENTS.md](AGENTS.md)),
136
+ 漏装则插件显示「未运行」(`ERR_MODULE_NOT_FOUND`)。
137
+
107
138
  ### 发现与安装
108
139
 
109
140
  - **npm**:[`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
@@ -113,10 +144,18 @@ dsh plugin --profile web add "$PWD"
113
144
 
114
145
  ## 版本兼容
115
146
 
116
- - 配置界面注册在宿主的 `plugins.bundle.config` 槽,**key 逐字就是包名**:一个 bundle 一份配置,渲染在它自己的详情页里。**这个落点往返过一次**(bundle 槽 → 行槽 → bundle 槽),两次都不是版本问题,是 UX 选择:行槽要多点一次 Configure,而 bundle 槽的座位 props **永远不带 `form`** —— 表单由卡片自己向 `ctx.configForms.get(<本插件那一行的 Loader 条目 id>)` 取。下限的真源是 [package.json](package.json) 的 `engines.dsh`,现状现查 `node scripts/compat-swap.mjs check`。
117
- - 下限抬上去的原因**是设置接缝换了,不是槽**:客户端侧的作用域服务被删、宿主侧 `settings.register` 被删,两半改用新的 `configForms` 与 volatile 配置引用 —— **没有那两样东西,浏览器半边整个不渲染**(圆环与浮层也一起消失)。插件不查宿主版本号:它盯的是 `ctx.inject(['configForms'])` 在窗口内有没有回调(**不盯槽名** —— 两个候选槽在更早的宿主上也在座,槽在不在推不出拿不拿得到表单);拿不到时**浮层里会多一行英文 `[WARN]`**,说明配置界面为什么不可用、该往哪儿升级。
118
- - 需要配置界面,就把宿主升到 `engines.dsh` 声明的那一版或更高:`npm install -g @deepseek-ai/dsh@alpha`。
119
- - 声明是**窄**的:下限是我们实测过的那一版,用 `>=` 而**不设上限** —— 既不承诺「以后都兼容」,也不会因为宿主多推一个预发布段就失效。为什么这么定见[兼容性](docs/PUBLISHING.md#兼容性)。
147
+ - **需要哪一版宿主**:以 [package.json](package.json) 的声明为准,本插件跟的是宿主当前那条 **RC 线**。
148
+ 需要配置界面,就把宿主升到那一版或更高:`npm install -g @deepseek-ai/dsh@next`。
149
+ - **声明是「窄」的**:下限是我们实测过的那一版,用 `>=` 而**不设上限** —— 不承诺「以后都兼容」。
150
+ (它罩得住**同一 `major.minor.patch` 内**后续的预发布版本;宿主换一个补丁位再推预发布,
151
+ 这条下限就罩不住了,届时要再抬一次。为什么见[兼容性](docs/PUBLISHING.md#兼容性)。)
152
+ - **下限为什么抬上去**:不是换了界面位置,而是**设置接缝换过**(旧宿主上没有本插件依赖的那两样东西,
153
+ 浏览器半边会整个不渲染 —— 圆环与浮层也一起消失)。插件不查宿主版本号,而是**当场探测那个能力**;
154
+ 拿不到时浮层里会多一行英文提示,说明配置界面为什么不可用、该往哪儿升级。
155
+ 技术细节见 [配置表单的能力探测](docs/ARCHITECTURE.md)。
156
+ - **兼容性不是推断出来的**:每周由 [compat.yml](.github/workflows/compat.yml) 在 `next`(RC 线)上
157
+ 换包实跑一遍现有测试,另有一步单独判声明面罩不罩得住;红了会开或更新一条固定标题的跟踪 issue。
158
+ 当前结论与红了怎么办见 [兼容性](docs/PUBLISHING.md#兼容性)。
120
159
 
121
160
  ## 配置
122
161
 
@@ -131,9 +170,9 @@ dsh plugin --profile web add "$PWD"
131
170
  - **连接**:API 地址与凭据,两项都默认留空 —— 地址留空即用 DeepSeek 官方端点,凭据继承官方模型页那一份、只读不可改;
132
171
  二级「自定义设置」里只放凭据引用名。
133
172
  - **展示**:金额用哪种币种,或让它自动跟随账户。
134
- - **阈值**:每个币种两档提醒线(预警 / 告急)。同一币种内**告急必须严格低于预警**,相等也会被拒绝。
135
- `POST /api/v1/config` 也按这条判,违反回 `422`;
136
- 但**手改配置文件写成的非法组合不会再报错** —— 宿主那一侧已经不再强制它,插件会在读到的那一刻把这一对**回落成默认值**并在日志里记一次。
173
+ - **阈值**:每个币种两档提醒线(预警 / 告急)。同一币种内**告急必须严格低于预警**,相等也会被拒绝
174
+ (保存时就会拦下来);但**手改配置文件写成的非法组合不会再报错** ——
175
+ 插件读到的那一刻会把这一对**回落成默认值**并在日志里记一次。
137
176
 
138
177
  > **升级提示**:设置项存在插件那一行的配置里,键名跟着宿主升级变过一次,**旧值不会自动迁移** —— 升级后请照上表重填一次。
139
178
  - **刷新**:服务端刷新周期、界面轮询周期与手动刷新冷却。冷却从**刷完那一刻**起算,
@@ -145,7 +184,9 @@ dsh plugin --profile web add "$PWD"
145
184
 
146
185
  - 环里填多少 = 当前余额占该币种**预警线**的比例,100% 封顶;告急线不参与画环 —— 它已经决定了颜色。
147
186
  - 颜色只表达状态,与金额大小无关:正常、偏低、告急各一色;账户读不到时另画一个带叉号的环。
148
- - 没配阈值的币种退回按状态定性:正常与不可用画满环,偏低 3/4,告急 1/4,未知空环。
187
+ - 没配阈值的币种退回按状态定性:**正常画满环,偏低 3/4,告急 1/4**。
188
+ (只有**有余额可看**的那一族画弧 —— 上面三档,加上「数据已过期」时的那份旧数字;
189
+ 账户读不到、账户停用、没接入、账户没钱这些**都不画弧**,环靠颜色与中心记号说话。)
149
190
  - 为什么颜色不由金额算、阈值为什么只当刻度 → [数据流](docs/ARCHITECTURE.md#数据流)。
150
191
 
151
192
  ### 凭据
@@ -153,6 +194,18 @@ dsh plugin --profile web add "$PWD"
153
194
  密钥只经 DSH 的凭据通道解析,卡片里的 `API Key` 一栏默认继承官方模型页配好的那一份,且只读不可改。
154
195
  由启动环境(环境变量)提供时,字段只读,徽标写明来源。
155
196
 
197
+ **余额有两条官方取数路,插件自己选**:模型页 / 环境变量配的 API Key 一条,**DSH 账号登录**一条
198
+ (只登录、没配 Key 也能看到余额)。选择顺序是:
199
+
200
+ - **当前会话在用的那条** —— 切会话、换模型都跟着换;
201
+ - 读不到会话就按全局默认模型的路由;
202
+ - 都判不出来时 **Key 优先、账号兜底**;
203
+ - 两条都没有,才是「尚未配置凭据」。
204
+
205
+ 浮层标题右边那个括号就是这份数字的来源:`(API Key)` / `(账号登录)`。**显示哪个、刷新就刷哪个、标签也写哪个** —— 三者始终同一条。
206
+
207
+ **这个插件只记官方的那一个数字**,所以首选那条读不到时会自动退到另一条官方路:只要账号或 API Key 有一个能用,就照常显示(哪怕是一份稍旧的快照)。**两条路都拿不到数字,才会提示** —— 那才是真的没接入官方。左栏那个环这时长什么样,见[一眼看懂那个环](#一眼看懂那个环)。
208
+
156
209
  **换了 key 多久生效,跟着 DSH 的层走 —— 本插件不额外追:**
157
210
 
158
211
  - **启动环境(环境变量)给的 key:改了要重启 DSH。** 进程的环境在它启动那一刻就定死了,之后再改外面那个变量,跑着的插件看不到。
@@ -165,8 +218,8 @@ dsh plugin --profile web add "$PWD"
165
218
 
166
219
  - **API Key 永不返回界面**:配置接口只回一个固定长度的掩码串,连末几位也不给。
167
220
  - 密钥不落日志、不落本插件的文件;设置文件里只有引用名,卡片可以安全截图或分享。
168
- - 余额快照存在 DSH 自己的数据目录里,按凭据派生出的账本标识分组 —— 换 key 自动开新账本,旧快照不会被混用。
169
- - 账本标识还要一个 `.salt` 文件(在 DSH 的 home 目录下)参与派生;**它丢了,旧快照就读不回来**。
221
+ - 余额快照存在 DSH 自己的数据目录里,按凭据派生出的标识分组 —— **换 key 自动开新账本**,旧快照不会被混用。
222
+ - 那份标识还要 DSH home 目录下一个随机盐文件参与派生;**它丢了,旧快照就读不回来**(不会串到别人账上)。
170
223
  - 只访问 `api.deepseek.com`,不代理、不转发其他流量。
171
224
 
172
225
  ## 许可
package/README_en.md CHANGED
@@ -29,7 +29,7 @@
29
29
  ---
30
30
 
31
31
  > [!NOTE]
32
- > **The balance is read from the official `GET /user/balance`** — not estimated. The credential is resolved only through DSH's credential channel: the API key never lands in a settings file and is never returned to the UI.
32
+ > **The balance is read from the official DeepSeek API** — not estimated. The credential is resolved only through DSH's credential channel: the API key never lands in a settings file and is never returned to the UI.
33
33
 
34
34
  The balance needs somewhere to live that does not take up room. A permanent status ring at the bottom of the sidebar; click it for the three amounts and the freshness of the data. Anything you want to configure lives on the plugin's details page under **Plugins → Installed**.
35
35
 
@@ -39,12 +39,42 @@ The balance needs somewhere to live that does not take up room. A permanent stat
39
39
  <em>A permanent fixture at the <strong>bottom of the sidebar</strong>, alongside Usage statistics and Settings; click it for the balance, the granted / topped-up split, and how fresh the data is.</em>
40
40
  </p>
41
41
 
42
+ ## The ring at a glance
43
+
44
+ That ring is "what is going on right now" — five shapes, each with a different thing for you to do:
45
+
46
+ | What you see | Meaning | What you do |
47
+ |---|---|---|
48
+ | Coloured ring + an arc | There is a balance; the colour is how much | **Read the number** |
49
+ | Grey ring + a gap rotating | Fetching; no answer yet | **Wait** |
50
+ | Grey ring + a **+** in the middle | Nothing connected | **Configure** (an API key, or sign in) |
51
+ | Grey, empty ring | Connected, the account simply has no balance | **Nothing** |
52
+ | Red ring + a **×** in the middle | No usable number here | **Wait, investigate, or check the account** |
53
+
54
+ **Hover it and it tells you what this spot means right now** (the same sentence whether you hover the
55
+ 56px rail ring or the expanded entry with its label):
56
+
57
+ - **When the number is trustworthy** (normal / low / critical) — it shows the **amount**;
58
+ - **otherwise** — it shows that situation's own line: "Loading", "Balance unavailable",
59
+ "Service error", "No credential configured", "Account unavailable", "Data is stale", "No balance".
60
+
61
+ **A suspended account and stale data hold a number too, and still do not show it**:
62
+ a suspended account or an expired snapshot reporting a figure only misleads ("I have ¥0" /
63
+ "I have ¥110"). The ring already draws the problem (red cross / old colour); hovering explains it.
64
+
65
+ The two kinds of "nothing to show" are told apart as well: **not connected** asks you to configure
66
+ a credential; **connected but unreadable** says "Balance unavailable".
67
+ It deliberately does **not** say "temporarily" — some causes clear up on their own, others need you
68
+ to fix a credential, and "temporarily" would send the second group away to wait for nothing.
69
+ The **×** covers both "could not read it" and "read it, but this account cannot be used" —
70
+ both mean "no usable number here", and both send you to investigate.
71
+
42
72
  ## Surface at a glance
43
73
 
44
74
  | Surface | One line | Use it for |
45
75
  |---|---|---|
46
- | Sidebar ring | A status ring plus label at the bottom of the sidebar; **in the expanded state, hovering the entry reports the balance amount**, and **how full it is = how far the balance is from that currency's warning line** | Seeing at a glance how much is left and how close it is to the warning line |
47
- | Balance popover | Click the entry: total, granted / topped-up split, data freshness, manual refresh (with cooldown); **an icon at its top right opens this plugin's configuration page** (on a host without that cross-plugin deep link it falls back to the Plugins list, and its tooltip says so) | Checking the exact figures, and how many minutes old they are |
76
+ | Sidebar ring | A status ring plus label at the bottom of the sidebar; **hovering it (in either state) says what this spot means** — the amount when the number is trustworthy, otherwise the situation's own line; and **how full it is = how far the balance is from that currency's warning line** | Seeing at a glance how much is left and how close it is to the warning line |
77
+ | Balance popover | Click the entry: total, granted / topped-up split, data freshness, manual refresh (with cooldown); **an icon at its top right opens this plugin's configuration page** (on a host that does not support it, it falls back to the Plugins list and its tooltip says so) | Checking the exact figures, and how many minutes old they are |
48
78
  | Settings card | Connection / Display / Thresholds / Refresh, **all collapsed by default**, expand a group from its header | Changing the endpoint, the currency, the warning lines, the cadence |
49
79
 
50
80
  The division of labour is fixed: **the ring answers "roughly how much is left", the popover answers "exactly how much", and the card answers "how is that computed".**
@@ -73,16 +103,16 @@ The division of labour is fixed: **the ring answers "roughly how much is left",
73
103
 
74
104
  ### Requirements
75
105
 
76
- - **DSH**: the range is whatever [package.json](package.json) declares under `engines.dsh` and `peerDependencies`; this plugin follows the alpha line the host is on.
77
- - Node `>= 20` (same source of truth: `engines.node`).
106
+ - **DSH**: the range is whatever [package.json](package.json) declares; this plugin follows the host's current **RC line** (the dist-tag is `next`).
107
+ - **Node**: `>= 20` (declared in the same place).
78
108
 
79
- Install the host by **naming the version line explicitly**: the `latest` tag of `@deepseek-ai/dsh` is older than the line this plugin requires — a default install lands outside the declared range.
109
+ Install the host by **naming the version line explicitly**: neither `alpha` nor `latest` of `@deepseek-ai/dsh` is the line we declare — a default install lands outside the declared range.
80
110
 
81
111
  ```bash
82
- npm install -g @deepseek-ai/dsh@alpha # the line this plugin promises to support
112
+ npm install -g @deepseek-ai/dsh@next # the line this plugin promises to support (the RC line; there is no `rc` tag on npm)
83
113
  ```
84
114
 
85
- Compatibility is measured, not inferred: every week [compat.yml](.github/workflows/compat.yml) swaps packages onto the `alpha` and `next` lines and reruns the existing tests, and one separate job judges whether the declared ranges still cover the line; a red patrol opens or updates a tracking issue with a fixed title. The current verdict, and what to do when it goes red, are in [Compatibility](docs/PUBLISHING.md#兼容性); the Host-version watershed is in [Version compatibility](#version-compatibility).
115
+ Compatibility is measured, not inferred: every week [compat.yml](.github/workflows/compat.yml) swaps packages onto the `next` (RC) line and reruns the existing tests, and one separate job judges whether the declared ranges still cover the line; a red patrol opens or updates a tracking issue with a fixed title. The current verdict, and what to do when it goes red, are in [Compatibility](docs/PUBLISHING.md#兼容性); the Host-version watershed is in [Version compatibility](#version-compatibility).
86
116
 
87
117
  ### From npm
88
118
 
@@ -104,6 +134,10 @@ dsh plugin --profile web add "$PWD"
104
134
 
105
135
  Same as the npm route: it takes effect after a restart.
106
136
 
137
+ ⚠️ The `npm install` above is not optional: under the `link:` form the repository supplies its
138
+ own dependencies (see [AGENTS.md](AGENTS.md)) — skip it and the plugin shows as "not running"
139
+ (`ERR_MODULE_NOT_FOUND`).
140
+
107
141
  ### Discovery
108
142
 
109
143
  - **npm**: [`dsh-ds-balance`](https://www.npmjs.com/package/dsh-ds-balance)
@@ -113,10 +147,21 @@ The repository carries the GitHub topic [`dsh-plugin`](https://github.com/topics
113
147
 
114
148
  ## Version compatibility
115
149
 
116
- - The configuration UI registers into the Host's `plugins.bundle.config` slot, **keyed literally by the package name**: one bundle, one configuration, rendered on that bundle's own details page. **That landing spot travelled once** (bundle slot → row slot → bundle slot) and neither move was about Host versions — it is a UX choice: the row slot costs one extra Configure click, while the bundle slot's owner props **never carry a `form`** — the card fetches it itself through `ctx.configForms.get(<the Loader entry id of this plugin's row>)`. The floor's single source is `engines.dsh` in [package.json](package.json); check the current lines with `node scripts/compat-swap.mjs check`.
117
- - The floor moved **because the settings seam changed, not because of the slot**: the client-side scope service was removed, the Host-side `settings.register` was removed, and both halves moved to the new `configForms` service and volatile config references — **without those two the whole client half never renders** (the ring and the popover go with it). The plugin never reads a Host version: it watches whether `ctx.inject(['configForms'])` calls back within its window (**never the slot name** — both candidate slots are present on earlier Hosts too, and a slot being there says nothing about getting a form). When it never does, **the popover carries one extra English `[WARN]` line** saying why the configuration page is unavailable and where to upgrade.
118
- - To get the configuration page, upgrade the Host to the version `engines.dsh` declares or higher: `npm install -g @deepseek-ai/dsh@alpha`.
119
- - The declaration is **narrow**: the floor is the version we actually tested, written as `>=` with **no ceiling** — it claims neither "everything in the future counts" nor anything earlier. Why it is written that way is in [Compatibility](docs/PUBLISHING.md#兼容性).
150
+ - **Which Host version is required**: whatever [package.json](package.json) declares; this plugin follows the host's current **RC line**.
151
+ To get the configuration page, upgrade the Host to that version or higher: `npm install -g @deepseek-ai/dsh@next`.
152
+ - **The declaration is narrow**: the floor is the version we actually tested, written as `>=` with **no ceiling** —
153
+ it claims no "everything in the future counts".
154
+ (It does cover later prereleases **within the same `major.minor.patch`**; once the host moves a patch position and
155
+ ships another prerelease, this floor no longer covers it and has to be raised again. Why: [Compatibility](docs/PUBLISHING.md#兼容性).)
156
+ - **Why the floor moved**: not a change of where the UI lives, but a change of the **settings seam**
157
+ (without the two things this plugin relies on, the whole client half never renders — the ring and the popover go with it).
158
+ The plugin never reads a Host version; it **probes that capability live**, and when it is absent the popover carries
159
+ one extra English line saying why the configuration page is unavailable and where to upgrade.
160
+ Technical detail: see [Architecture](docs/ARCHITECTURE.md).
161
+ - **Compatibility is not inferred**: a weekly [compat.yml](.github/workflows/compat.yml) run swaps packages on both the
162
+ `alpha` and `next` lines and re-runs the existing tests, plus a separate check that the declared range still covers the
163
+ tracked line; a failure opens or updates a tracking issue with a fixed title. Current conclusions and what to do when
164
+ it goes red: see [Compatibility](docs/PUBLISHING.md#兼容性).
120
165
 
121
166
  ## Configuration
122
167
 
@@ -131,9 +176,8 @@ Open **Plugins → Installed** and step into the **DeepSeek Balance** (shown as
131
176
  - **Connection**: the API base URL and the credential, both blank by default — a blank URL means the official DeepSeek endpoint, and the credential is inherited from the official model page and is read-only.
132
177
  The nested "Customised settings" holds only the credential reference name.
133
178
  - **Display**: which currency to use for amounts, or let it follow the account.
134
- - **Thresholds**: two alert lines per currency (warning / critical). **Within one currency the critical line must be strictly lower than the warning line** — equality is rejected too.
135
- `POST /api/v1/config` enforces the same rule and answers `422` otherwise;
136
- but a hand-edited config file with an illegal pair **no longer errors** — the Host side stopped enforcing it, so the plugin **falls that pair back to its defaults** the moment it reads it, and logs one line.
179
+ - **Thresholds**: two alert lines per currency (warning / critical). **Within one currency the critical line must be strictly lower than the warning line** — equality is rejected too, and saving is blocked while it is.
180
+ But a hand-edited config file with an illegal pair **no longer errors** — the plugin **falls that pair back to its defaults** the moment it reads it, and logs one line.
137
181
 
138
182
  > **Upgrade note**: the settings live in this plugin's own entry config and the key it is filed under changed once; **old values are not migrated** — please fill the table above in once after upgrading.
139
183
  - **Refresh**: the server refresh interval, the UI poll interval and the manual refresh cooldown. The cooldown counts from **the moment the refresh finishes**, so the seconds shown in the popover are the real ones left; if the backend says the refresh did not fire, the UI takes its remaining time instead of pretending otherwise.
@@ -144,7 +188,9 @@ Saving applies immediately; there is no need to restart DSH.
144
188
 
145
189
  - How full the ring is = the current balance as a fraction of that currency's **warning line**, capped at 100%; the critical line takes no part in drawing it — it already decided the colour.
146
190
  - Colour carries state only, never an amount: normal, low and critical each get one hue; an account that cannot be read gets a ring with a cross instead.
147
- - A currency with no threshold configured falls back to state: full ring for normal and unavailable, 3/4 for low, 1/4 for critical, empty for unknown.
191
+ - A currency with no threshold configured falls back to state: **a full ring for normal, 3/4 for low, 1/4 for critical**.
192
+ (Only the family with a balance to look at draws an arc — those three, plus the old number while data is stale;
193
+ an unreadable account, a suspended account, nothing connected, an account with no balance: none of those draw an arc.)
148
194
  - Why colour is never computed from an amount, and why thresholds are only a scale → [Data flow](docs/ARCHITECTURE.md#数据流).
149
195
 
150
196
  ### Credentials
@@ -152,6 +198,18 @@ Saving applies immediately; there is no need to restart DSH.
152
198
  The key is resolved only through DSH's credential channel; the card's "API key" inherits the one already configured on the official model settings page.
153
199
  When it comes from the launch environment (an environment variable) the field is read-only and the badge says where it came from.
154
200
 
201
+ **The balance has two official routes and the plugin picks one itself**: the API key from the model settings page or the launch environment, and the **DSH account sign-in** (signed in without a key is enough). The order is:
202
+
203
+ - **the route the current session is on** — switching sessions *or the model in this session* re-reads it at once;
204
+ - with no session readable, the globally selected model's route;
205
+ - with neither decidable, **key first, account as the fallback**;
206
+ - only when both are missing does it read "no credential configured".
207
+
208
+ The parenthesised label to the right of the popover title is where that number came from: `(API key)` / `(account sign-in)`.
209
+ **Whichever is shown is also the one that gets refreshed and named** — display, refresh and label never disagree.
210
+
211
+ **This plugin only tracks the official number**, so if the preferred route has nothing to show it falls back to the other official one: as long as either the account or an API key works, you still see a balance (even a slightly older snapshot). **You are only told about it when neither route can produce a number** — that is what "not connected to official" means. What the ring looks like at that moment: see [the ring at a glance](#the-ring-at-a-glance).
212
+
155
213
  **How long a key change takes to land follows DSH's own layers — this plugin deliberately adds nothing on top:**
156
214
 
157
215
  - **A key from the launch environment (an environment variable): restart DSH to change it.** A process's environment is fixed the moment it starts; a later change to that variable outside is invisible to the running plugin.
@@ -164,8 +222,8 @@ Why it is this way → [Key decisions](docs/ARCHITECTURE.md#关键决策).
164
222
 
165
223
  - **The API key is never returned to the UI**: the config endpoint returns only a fixed-length mask, not even the last few characters.
166
224
  - The key is never logged and never written to a file owned by this plugin; the settings file holds only a reference name, so the card is safe to screenshot or share.
167
- - Balance snapshots live in DSH's own data directory, grouped by a ledger identifier derived from the credential — changing the key opens a new ledger and old snapshots are never mixed in.
168
- - That identifier also involves a `.salt` file in DSH's home directory; **lose it and old snapshots become unreadable**.
225
+ - Balance snapshots live in DSH's own data directory, grouped by an identifier derived from the credential — **changing the key opens a new ledger** and old snapshots are never mixed in.
226
+ - That identifier also involves a random salt file in DSH's home directory; **lose it and old snapshots become unreadable** (they can never resolve to someone else's).
169
227
  - Only `api.deepseek.com` is contacted; nothing is proxied or forwarded.
170
228
 
171
229
  ## License
@@ -7,7 +7,7 @@
7
7
  * - 打开失败要**降级**而不是抛出去 —— 未观察的 rejection 曾把宿主整个拖下水。
8
8
  * @module dsh-ds-balance/adapters/domain-core-store
9
9
  */
10
- import type { BalanceSnapshot } from '../domain/balance.js';
10
+ import { type BalanceSnapshot, type BalanceSource } from '../domain/balance.js';
11
11
  import type { CoreStore, StoreHealth } from '../ports/core-store.js';
12
12
  import type { Logger } from '../ports/logger.js';
13
13
  /** 域里的表名。 */
@@ -24,7 +24,7 @@ export interface StoredSnapshot {
24
24
  granted: string;
25
25
  toppedUp: string;
26
26
  }>;
27
- source: 'deepseek-http';
27
+ source: BalanceSource;
28
28
  }
29
29
  /**
30
30
  * 域声明。
@@ -41,8 +41,11 @@ export declare const DS_BALANCE_DOMAIN: {
41
41
  /** 表句柄的最小结构化面;测试用替身实现它。 */
42
42
  export interface KvTableLike {
43
43
  get(key: string): StoredSnapshot | undefined;
44
+ /** 一次迭代同时拿到 `[key, record]`;比 `keys()` +逐键 `get()` 少一轮查找。 */
45
+ entries(): IterableIterator<[string, StoredSnapshot]>;
44
46
  keys(): IterableIterator<string>;
45
47
  put(key: string, value: StoredSnapshot): Promise<void>;
48
+ delete(key: string): Promise<boolean>;
46
49
  }
47
50
  /** 域句柄的最小结构化面。 */
48
51
  export interface DomainLike {
@@ -95,8 +98,49 @@ export declare class DomainCoreStore implements CoreStore {
95
98
  *
96
99
  * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
97
100
  * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
101
+ *
102
+ * **走 `entries()` 而不是 `keys()` + 逐键 `get()`**:后者是每条记录两次查找,
103
+ * 记录数涨上去(保留策略落地前实测过千条)时白费一大半;`entries()` 一次迭代给全。
98
104
  */
99
105
  loadLatestSnapshot(accountTag: string): Promise<BalanceSnapshot | null>;
106
+ /**
107
+ * 只保留该账本最近 `keepN` 条,删掉更旧的(跨账本不动)。
108
+ *
109
+ * **批间让出事件循环的理由是摊平写放大,不是防限流**:宿主 json 后端默认 `single`
110
+ * 布局,每次 `delete` 都要原子重写整份单元文件。删几十条若挤在同一个 tick 上,
111
+ * 会连带把同一进程里的取数与轮询一起拖住。所以内部**分批 + 每批之后让出一次** ——
112
+ * 批次大小与间隔属于实现细节,不上端口(领域层只调一次,不需要知道分了几批)。
113
+ *
114
+ * **单次最多删 {@link PRUNE_MAX_PER_CALL} 条**:超出部分留给后续启动或写入路径,
115
+ * 保证任何一次调用的耗时都有确定上界(理由见那个常量)。
116
+ *
117
+ * **零写入的幂等**:没超期时一条都不删、返回 0(超期判定先做完再动手)。
118
+ *
119
+ * @param accountTag - 要修剪的账本。
120
+ * @param keepN - 保留条数(新到旧)。
121
+ * @returns 本次**实际**删掉的条数(可能小于超期总数,因为有单次上限)。
122
+ */
123
+ pruneByTag(accountTag: string, keepN: number): Promise<number>;
124
+ /**
125
+ * 修剪一个账本的内部实现(不重复 `requireTable` 的开表开销)。
126
+ */
127
+ private pruneOwned;
128
+ /**
129
+ * 启动时的一次性清理:把**所有**账本桶各修剪一次。
130
+ *
131
+ * 为什么扫全表而不是只清「当前那个账本」:启动这一刻还不知道会用哪个 `accountTag`
132
+ * (要等 reader 给),而首次升级的迁移诉求是「把所有攒下来的桶都收一遍」——
133
+ * 只清一个桶的话,凭据轮换出来的旧桶永远清不掉,文件就一直大。
134
+ *
135
+ * **失败绝不影响 open**:清理抛错只记一条 warn。这里抛出去会让
136
+ * `ensureOpen` 判成打开失败,整个存储被打成降级 —— 那比慢更糟。
137
+ * 删到一半也是安全的:清理幂等,下次启动继续。
138
+ */
139
+ private pruneAllLedgersOnOpen;
140
+ /** 取出属于该账本的全部记录。 */
141
+ private collectOwned;
142
+ /** 分批删除,每批之后让出一次事件循环。 */
143
+ private deleteInBatches;
100
144
  health(): Promise<StoreHealth>;
101
145
  /** 幂等关闭。**必须挂在 `ctx.effect` 的 disposer 上。** */
102
146
  close(): Promise<void>;
@@ -9,10 +9,35 @@
9
9
  */
10
10
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
11
11
  import { z } from 'zod';
12
+ import { SNAPSHOT_KEEP_N, } from '../domain/balance.js';
12
13
  import { StorageError, describeError } from '../domain/errors.js';
13
14
  import { formatMoney, parseMoney } from '../domain/money.js';
14
15
  /** 域里的表名。 */
15
16
  export const SNAPSHOT_TABLE = 'snapshots';
17
+ /**
18
+ * 修剪时每批删多少条,之后让出一次事件循环。
19
+ *
20
+ * **实现细节,不上端口**:领域层只调一次 `pruneByTag`,不需要知道分了几批。
21
+ * 取值只是「别让一批太大」的粗界,真实瓶颈是单布局下的整份重写次数,
22
+ * 所以调大调小不影响正确性,只影响同进程别的活被挤住多久。
23
+ */
24
+ const PRUNE_BATCH_SIZE = 40;
25
+ /**
26
+ * 单次修剪最多删多少条,**之后剩下的交给后续启动或写入路径**。
27
+ *
28
+ * **为什么需要上界**:`single` 布局下每次 `delete` 都要原子重写整份单元文件,
29
+ * 而「删掉一批」的开销随文件体积线性涨。首次升级时存量可能很大(本机实测 9000 条 /
30
+ * 3.8 MB),若一次性清空就是数千次整份重写,**会把宿主连同 Agent 一起卡死**
31
+ * —— 实测现象是侧栏无限转圈、Agent 停止响应,清空数据后立刻恢复。
32
+ *
33
+ * 这个数是**实现约束**不是领域策略:领域层只说「留几条」,「一次最多删多少」
34
+ * 属于「别把自己卡死」的工程判断,所以不上端口(与批次大小同理)。
35
+ *
36
+ * 取 500 的依据:配合 {@link PRUNE_BATCH_SIZE} 是 12.5 批,批次边界清晰;
37
+ * 收敛靠**多次启动叠加**而不是一次删完 —— 清到 20 条这个终态是幂等的,
38
+ * 每次启动删一批,未删完的下次继续。
39
+ */
40
+ const PRUNE_MAX_PER_CALL = 500;
16
41
  const storedBalanceSchema = z.object({
17
42
  currency: z.string(),
18
43
  total: z.string(),
@@ -25,7 +50,9 @@ const storedSnapshotSchema = z.object({
25
50
  fetchedAt: z.number(),
26
51
  isAvailable: z.boolean(),
27
52
  balances: z.array(storedBalanceSchema),
28
- source: z.literal('deepseek-http'),
53
+ // **取值只增不改**:域版本一动,已落盘的账本会在 open 时直接 `version-mismatch` 拒开。
54
+ // 旧记录里只有 `deepseek-http`,它必须一直是合法值。
55
+ source: z.enum(['deepseek-http', 'deepseek-account']),
29
56
  });
30
57
  /**
31
58
  * 域声明。
@@ -125,6 +152,10 @@ export class DomainCoreStore {
125
152
  this.handle = handle;
126
153
  this.table = handle.table(SNAPSHOT_TABLE);
127
154
  this.openError = undefined;
155
+ // 首次升级的一次性清理:必须在**任何读取之前**做,否则首次
156
+ // loadLatestSnapshot 仍要面对存量的大文件。等下一次抓取再清太晚 ——
157
+ // 那正是「保留策略只在稳态有效、首次升级要等两小时」那个盲区。
158
+ await this.pruneAllLedgersOnOpen(SNAPSHOT_KEEP_N);
128
159
  }
129
160
  /** 拿到可用表,或在降级状态下抛出可读错误。 */
130
161
  async requireTable() {
@@ -147,19 +178,128 @@ export class DomainCoreStore {
147
178
  *
148
179
  * **按 `accountTag` 过滤**:凭据轮换后 tag 会变,旧快照不得混用。
149
180
  * 用 `snapshotId`(单调可排序)决定新旧,不依赖 `fetchedAt`。
181
+ *
182
+ * **走 `entries()` 而不是 `keys()` + 逐键 `get()`**:后者是每条记录两次查找,
183
+ * 记录数涨上去(保留策略落地前实测过千条)时白费一大半;`entries()` 一次迭代给全。
150
184
  */
151
185
  async loadLatestSnapshot(accountTag) {
152
186
  const table = await this.requireTable();
153
187
  let newest = null;
154
- for (const key of table.keys()) {
155
- const record = table.get(key);
156
- if (record === undefined || record.accountTag !== accountTag)
188
+ for (const [, record] of table.entries()) {
189
+ if (record.accountTag !== accountTag)
157
190
  continue;
158
191
  if (newest === null || record.snapshotId > newest.snapshotId)
159
192
  newest = record;
160
193
  }
161
194
  return newest === null ? null : fromStored(newest);
162
195
  }
196
+ /**
197
+ * 只保留该账本最近 `keepN` 条,删掉更旧的(跨账本不动)。
198
+ *
199
+ * **批间让出事件循环的理由是摊平写放大,不是防限流**:宿主 json 后端默认 `single`
200
+ * 布局,每次 `delete` 都要原子重写整份单元文件。删几十条若挤在同一个 tick 上,
201
+ * 会连带把同一进程里的取数与轮询一起拖住。所以内部**分批 + 每批之后让出一次** ——
202
+ * 批次大小与间隔属于实现细节,不上端口(领域层只调一次,不需要知道分了几批)。
203
+ *
204
+ * **单次最多删 {@link PRUNE_MAX_PER_CALL} 条**:超出部分留给后续启动或写入路径,
205
+ * 保证任何一次调用的耗时都有确定上界(理由见那个常量)。
206
+ *
207
+ * **零写入的幂等**:没超期时一条都不删、返回 0(超期判定先做完再动手)。
208
+ *
209
+ * @param accountTag - 要修剪的账本。
210
+ * @param keepN - 保留条数(新到旧)。
211
+ * @returns 本次**实际**删掉的条数(可能小于超期总数,因为有单次上限)。
212
+ */
213
+ async pruneByTag(accountTag, keepN) {
214
+ const table = await this.requireTable();
215
+ return this.pruneOwned(table, accountTag, keepN);
216
+ }
217
+ /**
218
+ * 修剪一个账本的内部实现(不重复 `requireTable` 的开表开销)。
219
+ */
220
+ async pruneOwned(table, accountTag, keepN) {
221
+ if (keepN < 0)
222
+ return 0;
223
+ const owned = this.collectOwned(table, accountTag);
224
+ if (owned.length <= keepN)
225
+ return 0;
226
+ // 先按 snapshotId 排好序再决定删哪些:留下的必然是「最新的 keepN 条」,
227
+ // 而刚写入的那条 id 单调最大,所以永远在保留集里。
228
+ const ordered = owned
229
+ .slice()
230
+ .sort((a, b) => (a.snapshotId < b.snapshotId ? -1 : a.snapshotId > b.snapshotId ? 1 : 0));
231
+ const doomed = ordered.slice(0, ordered.length - keepN).slice(0, PRUNE_MAX_PER_CALL);
232
+ await this.deleteInBatches(table, doomed);
233
+ return doomed.length;
234
+ }
235
+ /**
236
+ * 启动时的一次性清理:把**所有**账本桶各修剪一次。
237
+ *
238
+ * 为什么扫全表而不是只清「当前那个账本」:启动这一刻还不知道会用哪个 `accountTag`
239
+ * (要等 reader 给),而首次升级的迁移诉求是「把所有攒下来的桶都收一遍」——
240
+ * 只清一个桶的话,凭据轮换出来的旧桶永远清不掉,文件就一直大。
241
+ *
242
+ * **失败绝不影响 open**:清理抛错只记一条 warn。这里抛出去会让
243
+ * `ensureOpen` 判成打开失败,整个存储被打成降级 —— 那比慢更糟。
244
+ * 删到一半也是安全的:清理幂等,下次启动继续。
245
+ */
246
+ async pruneAllLedgersOnOpen(keepN) {
247
+ const table = this.table;
248
+ if (table === null)
249
+ return;
250
+ try {
251
+ const buckets = new Map();
252
+ for (const [, record] of table.entries()) {
253
+ const bucket = buckets.get(record.accountTag);
254
+ if (bucket === undefined)
255
+ buckets.set(record.accountTag, [record]);
256
+ else
257
+ bucket.push(record);
258
+ }
259
+ let removed = 0;
260
+ let remaining = 0;
261
+ for (const accountTag of buckets.keys()) {
262
+ removed += await this.pruneOwned(table, accountTag, keepN);
263
+ }
264
+ for (const bucket of buckets.values())
265
+ remaining += Math.min(bucket.length, keepN);
266
+ if (removed > 0) {
267
+ // 进度日志:删了多少、之后大约还剩多少(每次启动还会再删一批,直到收敛)。
268
+ // 没有它,使用者只会看到「数据怎么还在变多」,无从判断清理是否在推进。
269
+ this.options.logger?.debug('ds-balance: pruned stored snapshots on open', {
270
+ removed,
271
+ remainingApprox: remaining,
272
+ buckets: buckets.size,
273
+ keepN,
274
+ });
275
+ }
276
+ }
277
+ catch (error) {
278
+ // 刻意只记一条 warn:清理是尽力而为,不该把存储打成降级。
279
+ this.options.logger?.warn('ds-balance: startup snapshot prune failed, continuing', {
280
+ error: describeError(error),
281
+ });
282
+ }
283
+ }
284
+ /** 取出属于该账本的全部记录。 */
285
+ collectOwned(table, accountTag) {
286
+ const owned = [];
287
+ for (const [, record] of table.entries()) {
288
+ if (record.accountTag === accountTag)
289
+ owned.push(record);
290
+ }
291
+ return owned;
292
+ }
293
+ /** 分批删除,每批之后让出一次事件循环。 */
294
+ async deleteInBatches(table, doomed) {
295
+ for (let offset = 0; offset < doomed.length; offset += PRUNE_BATCH_SIZE) {
296
+ const batch = doomed.slice(offset, offset + PRUNE_BATCH_SIZE);
297
+ for (const record of batch)
298
+ await table.delete(record.snapshotId);
299
+ // 让出一次事件循环:把同 tick 的连续原子重写摊开,别挤住同进程的别的活。
300
+ await new Promise((resolve) => setImmediate(resolve));
301
+ }
302
+ }
163
303
  async health() {
164
304
  if (this.closed)
165
305
  return { ok: false, detail: 'closed' };