dsh-subusage 0.7.0 → 0.8.2

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
@@ -1,80 +1,35 @@
1
1
  # dsh-subusage —— DeepSeek Harness 订阅用量显示
2
2
 
3
- 在模型选择器旁显示当前模型商的订阅余量;点开药丸看用量、重置时间和套餐详情。支持 **Z.ai Coding(中国与国际)/ Kimi Coding / Xiaomi MiMo / OpenCode Go / Command Code / MiniMax(国际与中国)/ Synthetic / NanoGPT**。
3
+ 在模型选择器旁显示当前提供商的订阅余量,点击查看各周期用量、重置时间和套餐信息。设置页集中管理十个提供商/区域的检测开关与凭据。
4
4
 
5
- ## 0.7.0:审查修复与新增订阅商
5
+ 支持 **Z.ai Coding(中国与国际)/ Kimi Coding / Xiaomi MiMo / OpenCode Go / Command Code / MiniMax(国际与中国)/ Synthetic / NanoGPT**。
6
6
 
7
- - 新增 `zai-coding`(国际版)、`synthetic`、`nanogpt`:新安装与旧配置升级均默认关闭,即使有环境凭据也不请求用量。可在「提供商管理」手动开启;原有七项的默认值和用户已保存开关保持不变。
8
- - Z.ai 国际版依据[官方查询脚本](https://github.com/zai-org/zai-coding-plugins/blob/main/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs)直连监控接口,区域凭据独立;补齐 `TOKENS_LIMIT` 格式。Synthetic 使用[官方 quotas API](https://dev.synthetic.new/docs/synthetic/quotas),只读取模型订阅请求池。
9
- - NanoGPT 使用[官方订阅用量 API](https://docs.nano-gpt.com/api-reference/endpoint/subscription-usage),显示每日/每周输入 Token 及试用 Token 池;支持手动填写 `sk-nano-mgmt-…` [Usage only 管理令牌](https://docs.nano-gpt.com/api-reference/management-api),自动选择只读管理接口。忽略图像池,未开通、缺数据、降级响应不会显示虚假剩余额度。
10
- - 修复额度重置时绕过 `Retry-After`、保存一家凭据导致其他用量消失,以及首次读取失败后无法重新初始化的问题。
11
- - 空状态自动展开提供商管理;刷新错误始终在工具栏下可见;药丸弹层适应窄屏和深浅主题。
12
- - Host/Client 必须一起更新。已做离线回归与浏览器布局检查,未使用真实账号在线验证。详见[审查记录](docs/code-review.md)。
7
+ ## 安装与快速开始
13
8
 
14
- ## 0.6.0:提供商管理与默认隐藏
9
+ 当前仓库版本 **0.8.2**,可从 GitHub Release 获取。npm 自动发布采用 Trusted Publishing(GitHub OIDC);可用版本见 [npm](https://www.npmjs.com/package/dsh-subusage)。安装时使用目标 DSH 部署提供的插件管理器或 CLI,并启用本 bundle:
15
10
 
16
- - 设置 → 订阅用量底部新增默认折叠的「提供商管理」:七个提供商/区域独立开启或关闭,立即持久化;关闭后隐藏用量标签与药丸,停止订阅用量请求,不删除凭据、不修改模型路由。
17
- - 新增「没有检测到API的默认隐藏」开关,默认开启。按实际 Key / MiMo Cookie 是否存在判断,不以模型路由是否注册或接口是否成功判断;认证失败、网络错误仍展示异常。
18
- - 管理区始终列出全部提供商:每家开关与「连接与凭据」在同一卡片,就地展开编辑;MiMo 为「登录与凭据」,Command Code 为只读凭据来源。移除跨区域「配置」跳转,隐藏/关闭条目仍可就地编辑。
19
- - 上方只显示用量,查看用量与正在编辑的提供商独立,切换用量不丢失草稿;保存结果和错误在管理区就地反馈。关闭自动隐藏后,无凭据条目恢复指引,手工关闭的提供商不会被自动重新开启。
20
- - 开关保存与凭据保存互相隔离,脏草稿期间锁定开关,多窗口使用 revision 防止覆盖;仅改变默认隐藏不清除 Host 用量缓存。
21
- - 所有导航与套餐选择改为按钮,窄屏自然换行,整个设置页不使用下拉列表。
22
- - **Host 与 Client 必须一起加载新版**。本地 link 安装直接指向工作区;磁盘更新不代表正在运行的 Host/Client 已重载,必要时重启 DSH。在线账号与真实应用界面仍需验收。
11
+ | 来源 | 安装标识或下载地址 |
12
+ |---|---|
13
+ | npm | `dsh-subusage`;支持 npm 源的插件管理器可使用该包名 |
14
+ | GitHub | `https://github.com/KouzakiUmi/dsh-subusage` |
15
+ | 安装包 | [最新 GitHub Release 安装包](https://github.com/KouzakiUmi/dsh-subusage/releases/latest/download/dsh-subusage.tgz) |
23
16
 
24
- ## 0.5.0:MiniMax 订阅检测
17
+ 只需取得 npm 最新压缩包时可运行 `npm pack dsh-subusage`;指定版本前可用 `npm view dsh-subusage version` 核对发布结果。普通 `npm install` 或下载压缩包并不等于已经在 DSH 中启用插件;具体安装参数以目标部署的帮助信息为准。
25
18
 
26
- - 新增 `minimax` / `minimax-cn`,分别继承 `MINIMAX_API_KEY` / `MINIMAX_CN_API_KEY`,也可在设置中保存独立的订阅 Key。区域之间不自动尝试或转发 Key。
27
- - 直连对应区域的 `/v1/token_plan/remains`,只显示 `general` 或旧版 `MiniMax-M*` 编程池,忽略视频/图像池,不累加共享额度。
28
- - 显示短周期和周已用百分比及重置时间;优先采用 `remaining_percent`,计数明细按[官方 CLI 消歧规则](https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts)校准,无法匹配时只显示百分比。旧版无百分比响应按剩余计数解释。
29
- - 无上限、未报告或零总计且无百分比的窗口不绘制,标记部分数据;没有可绘制窗口时额度未知。不推断套餐名称,周加量不折算额外百分比。
30
- - 设置导航扩展为七个区域条目,内部宽度 ≤699px 时切换选择框。
31
- - **Host 与 Client 必须一起更新**;未执行在线账号验收或安装。接口参考:[官方 Token Plan FAQ](https://platform.minimax.io/docs/token-plan/faq)、[官方 CLI 端点](https://github.com/MiniMax-AI/cli/blob/main/src/client/endpoints.ts)。
19
+ 开发目标是 **DeepSeek Harness 0.2.0-rc.2**。核心 peer 范围为 `>=0.2.0-rc.1 <0.3.0-0`,允许该范围内的预发布版本;声明范围不代表所有版本均已实机验证。
32
20
 
33
- ## 0.4.1:补齐 Command Code 月额度
21
+ 1. 安装并启用 bundle,按部署方式重载或重启,使 Host、Client 与依赖一起生效。
22
+ 2. 打开 **设置 → 订阅用量 → 提供商管理**,为需要的订阅配置凭据。
23
+ 3. **Z.ai 国际、Synthetic、NanoGPT 默认关闭**,必须手动开启。原有七项默认开启;升级保留已保存的开关。
24
+ 4. 点击「刷新当前」检查结果;选中对应提供商的模型后,输入区显示余量药丸。
34
25
 
35
- - 显示月额度剩余,并按已知套餐总额计算月已用百分比和已用/上限;例如 GOAT 总额 70、剩余 55.86,显示已用 14.14 / 70(20.2%)。
36
- - 套餐及账期来自 `/alpha/billing/subscriptions`,失败时可回退 credits 内的 planId;没有账期时不编造重置日期。
37
- - 月总额是[提供方插件套餐表](https://github.com/Mars-Sea/dsh-commandcode-provider/blob/main/src/capabilities.ts)的快照,不是计费接口直接报告的硬上限;未知套餐只显示剩余金额并标记部分数据。套餐变更后需核对更新。
38
- - 月余额不包含已购/赠送余额;月池耗尽不据此连坐周/5小时窗口。
39
- - 修复非法重置日期导致整包失败,以及旧配置中的 Command Code 手动 Key 绕过托管凭据来源的问题。
40
- - 修复 MiMo 普通刷新/缓存中的接口 Cookie 回显;脱敏正确处理分号后空格和带引号的值。
41
- - 修复 Netscape Cookie 导入:兼容注释、`#HttpOnly_` 域前缀及 TAB 末列空值,仍严格过滤外域。
42
- - MiMo 登录只锁定本厂商的手动导入/清除,不再阻止其他厂商更换 Key,也不产生实际未执行的待清除提示。
43
-
44
- ## 0.4.0:Command Code 用量显示
45
-
46
- - **支持 Command Code**:该路由由另一个插件 [`@mars-sea/dsh-commandcode-provider`](https://github.com/Mars-Sea/dsh-commandcode-provider) 注册;本插件显示其 5 小时 / 每周 / 月额度池、重置时间、套餐(planId)、月剩余与已购 + 赠送余额,周窗用尽会连坐 5 小时窗口。
47
- - **快速直连刷新**:不消费提供方插件的用量服务(它按账户逐个串行多端点、超时预算长,作为药丸来源太慢),改为自读同一凭据后并行直连 `/alpha/billing/credits` 和 `/alpha/billing/subscriptions`(各 10 秒超时);不读取请求统计、不串行遍历账户。
48
- - **同一凭据来源**:凭据服务 `COMMANDCODE_API_KEY` → 启动环境 → `~/.commandcode/auth.json`(`cmd login` 写入,解析方式与提供方插件一致)。本插件**不保存** Command Code 凭据;Key、登录与多账户在 设置 → Command Code 管理。
49
- - **已知限制**:多账户轮换或固定 `activeAccount` 时显示默认(顶层 Key)账户的额度,可能与实际服务账户不同;提供方插件里自定义 `apiBase` 不会被跟随。
50
- - **五等宽导航**:设置页厂商导航改为五等宽单行,窄内容区(≤499px)切换为选择框。
51
-
52
- **两端必须同时更新**:旧 Host 会拒绝含 `commandcode` 的刷新请求。
53
-
54
- ## 0.3.0:按钮式凭据与 MiMo 自动登录
55
-
56
- - **凭据按钮**:默认只展示当前来源;通过「更换 / 清除」进入编辑,再保存或取消,不再使用「保持 / 替换 / 清除」下拉菜单。
57
- - **自动登录导入**:点击「登录并自动导入」,使用隔离的临时 Chrome 窗口完成官方登录,由 Host 验证并保存必需 Cookie。
58
- - **原生菜单对比度**:剩余套餐/provider 选项显式配对系统前景和背景,避免深色界面出现白底白字。
59
-
60
- ### 延续的设置与可靠性改进
61
-
62
- - **五等宽导航**:`Z.ai / Kimi / MiMo / OpenCode Go / Command` 单行显示,窄内容区改成选择框,不出现换行。
63
- - **用量为主**:凭据与帮助折叠;顶部提供刷新当前/全部,保存按钮仅出现在编辑区。
64
- - **共享刷新**:按厂商缓存与合并在途请求,药丸和设置页共享结果,活跃厂商每分钟检查更新。
65
- - **两条状态轴**:数据获取成功不等于额度可用;额度不足、认证失效、缓存和部分数据均有独立说明。
66
- - **准确限额**:原始比例决定是否限流,不把 99.5% 提前当作 100%;较长周期限额才向内层窗口连坐。
67
- - **凭据来源**:可选择继承或自定义;读取结果不再回传完整 Key/Cookie。
68
- - **编辑保护**:按厂商增量保存,未保存编辑不被刷新覆盖;配置版本检查防止多窗口旧表单覆盖新配置。
69
- - **MiMo Cookie 验证**:支持标准串、多行 KV、成对 name/value、TAB 清单和 JSON 导出;解析失败保留原文,不再失焦自动破坏输入。
70
-
71
- 详细界面约定见 [设计说明](docs/design-ux.md),维护注意事项见 [开发说明](docs/development.md)。
26
+ 「没有检测到API的默认隐藏」默认开启。首次没有可见条目时,管理区自动展开;关闭的提供商仍可配置凭据,但不会请求用量。插件不注册模型路由,药丸按提供商 ID 匹配,配置表见下文。
72
27
 
73
28
  ## 使用
74
29
 
75
30
  ### 药丸
76
31
 
77
- 选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d 已达限额`。多窗口取最差一窗,点击看完整详情。关闭默认隐藏后,缺凭据时显示配置指引;可保留的临时网络错误会明确标注缓存,而非冒充当前成功。
32
+ 选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d 已达限额`。多窗口取最差一窗,点击看完整详情。Command Code 药丸弹层底部可切换提供方插件的服务账户(自动轮换 / 默认账户 / 各额外账户,与其设置页同一开关,即时生效);切换后药丸与弹层上方的用量同步改为所选账户的额度(自动轮换时仍显示默认账户/顶层 Key 的用量,弹层有标注说明)。关闭默认隐藏后,缺凭据时显示配置指引;可保留的临时网络错误会明确标注缓存,而非冒充当前成功。
78
33
 
79
34
  ### 设置
80
35
 
@@ -103,11 +58,11 @@
103
58
  | MiniMax 中国 | `MINIMAX_CN_API_KEY` | `minimax-cn` 路由;中国站订阅 Key |
104
59
  | Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;也兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
105
60
 
106
- Z.ai / Kimi / OpenCode Go / MiniMax 的继承模式按凭据服务 → 启动环境 → 旧手动配置兜底解析;自定义模式仅使用保存的手动 Key。Command Code 仅沿用提供方凭据来源链,不接受本插件的手动 Key;MiMo 仅使用 Cookie。界面区分凭据服务、启动环境和自定义来源。修改启动环境后是否需要重启取决于目标部署,不能把用户环境即时变化当作已经被运行进程读到。
61
+ Z.ai(中国与国际)/ Kimi / OpenCode Go / MiniMax / Synthetic / NanoGPT 的继承模式按凭据服务 → 启动环境 → 旧手动配置兜底解析;自定义模式仅使用保存的手动 Key。Command Code 仅沿用提供方凭据来源链,不接受本插件的手动 Key;MiMo 仅使用 Cookie。界面区分凭据服务、启动环境和自定义来源。修改启动环境后是否需要重启取决于目标部署,不能把用户环境即时变化当作已经被运行进程读到。
107
62
 
108
63
  ### MiMo 登录与 Cookie
109
64
 
110
- 推荐使用 MiMo「连接与凭据」中的 **登录并自动导入**:
65
+ 推荐使用 MiMo「登录与凭据」中的 **登录并自动导入**:
111
66
 
112
67
  1. 先保存或取消当前编辑,再开始登录。
113
68
  2. 插件调用 `playwright-core`,打开**独立临时 Chrome 会话**的官方平台;密码、验证码由你在官方页面自行输入,插件不读取这些字段,也不读取日常 Chrome 配置。
@@ -121,13 +76,27 @@ Z.ai / Kimi / OpenCode Go / MiniMax 的继承模式按凭据服务 → 启动环
121
76
 
122
77
  JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过滤,无关站点的 Cookie 不导入。TAB 清单只读取 Name/Value,不把 Domain/Path 混入值;Netscape 导出支持注释和 `#HttpOnly_` 域前缀,并保留可选 Cookie 的空值(两个必需字段仍须非空)。
123
78
 
79
+ ### Cookie 有效期与到期提醒
80
+
81
+ 官方会话 Cookie **自签发起 24 小时有效**。无论自动登录还是手动导入,保存凭据的同一刻开始计时,并记录在 `~/.dsh/dsh-subusage.json` 的 `xiaomi.loginAt` 与 `xiaomi.expiresAt`(毫秒时间戳,**只是计时元数据,不是秘密**,不参与凭据有效性判断)。
82
+
83
+ | 剩余时间 | 用量药丸 | 设置页「登录与凭据」 |
84
+ |---|---|---|
85
+ | 超过 2 小时 | 保持原余量文案 | 显示登录时间与剩余时间 |
86
+ | 2 小时内 | ⚠ 黄色药丸,文案「Cookie N 小时后到期」 | 黄色提示剩余时间 |
87
+ | 30 分钟内 | ⚠ 橙色药丸,同文案 | 橙色提示 |
88
+ | 已到期 | ✕ 红色药丸,文案「Cookie 已过期,请重新登录」 | 红色提示 |
89
+ | 额度已用尽 | 仍优先显示额度告警 | — |
90
+
91
+ 药丸弹层常驻显示剩余时间与到期时刻。到期只是**提醒**:插件不会因为倒计时归零就停用 Cookie,是否真的失效仍以官方接口返回为准(失效时按认证错误处理,重新登录即可)。倒计时也永远不会盖过接口的判定——官方已判定凭据失效时,药丸显示的是失效原因而非剩余时间(唯一例外是「已过期」本身,因为它就是重新登录的提示)。若浏览器报告的 Cookie 过期时间早于 24 小时,以更早者为准;旧版本保存的 Cookie 没有计时记录,界面会明确说明「未记录登录时间」而不是编造倒计时。
92
+
124
93
  **安全说明**:Key/Cookie 不再通过用量读取接口回传,界面不回填已保存的秘密;但本版仍兼容原本机 JSON 配置存储,**不是加密凭据库**。该配置位于 `~/.dsh/dsh-subusage.json`,新建目录/临时文件在 POSIX 上按 `0700`/`0600` 创建,替换前再次收紧文件权限;Windows 的实际访问权限仍取决于目录 ACL,`chmod` 不能替代 ACL 或加密库。请勿上传、分享或写入日志。隔离浏览器仅用于你主动发起的官方登录;它不是后台扫描或导入日常浏览器全部凭据的工具。加密凭据存储通道仍未实施。
125
94
 
126
95
  ## 支持的数据
127
96
 
128
97
  | 模型商 | 窗口 | 明细 |
129
98
  |---|---|---|
130
- | Z.ai | 5 小时、每周 | 已用/总计 Credits、套餐档 |
99
+ | Z.ai 中国 | 5 小时、每周(按响应) | Token / Credits 配额、套餐档 |
131
100
  | Z.ai 国际 | 5 小时、每周(按响应) | Token / Credits 配额,区域凭据独立 |
132
101
  | Synthetic | 滚动订阅池 | 已用/总请求次数、重置时间 |
133
102
  | NanoGPT | 每日、每周、试用周期(按响应) | 输入 Token 数、重置时间;不包含图像额度 |
@@ -139,49 +108,76 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
139
108
 
140
109
  非公开控制台接口可能变更;缺失或非法百分比不会当作零用量。部分数据、未知额度和暂时失败有明确状态,不能据此保证推理接口一定可用。
141
110
 
142
- ## 安装与生效
143
111
 
144
- 本插件的 npm 包名为 `dsh-subusage`;支持 npm 源的 DSH 插件管理器可使用该包名安装。获取安装包也可使用 `npm pack dsh-subusage`。这与运行中的 Host/Client 重载是独立步骤。
112
+ ## 常见问题
145
113
 
146
- 目标核心版本:**DeepSeek Harness `0.2.0-rc.2`**。插件声明精确 peer 版本;其它版本需重新核验 API 与兼容性。
114
+ | 现象 | 排查方法 |
115
+ |---|---|
116
+ | 设置页没有提供商标签 | 展开提供商管理,确认开关和凭据;无凭据的条目默认隐藏,可临时关闭自动隐藏查看指引 |
117
+ | 已配置凭据却没有药丸 | 确认当前模型的提供商 ID 与支持表一致、检测已开启;自定义 ID 可在设置页查看,但不会自动映射 |
118
+ | 显示认证失效 | 检查是否用了对应产品/区域的订阅 Key;MiMo 重新登录或导入 Cookie;旧额度不会当作有效数据保留 |
119
+ | 显示缓存、部分数据或额度未知 | 查看更新时间和错误说明;缓存来自之前的成功读取,部分数据不保证有可用额度,余额也不等于订阅余量 |
120
+ | 刷新后数字暂未变化 | 成功结果按提供商缓存 60 秒;手动刷新可跳过普通 TTL,但仍遵守限流退避和 Retry-After |
121
+ | MiMo 自动登录无法启动 | Host 所在机器需安装 Google Chrome 并有桌面环境;远程或无桌面部署使用手动导入 |
122
+ | 药丸提示「Cookie N 小时后到期」 | MiMo 会话 Cookie 自签发起 24 小时有效,按提示重新登录即可续期;手动导入同样重新计时 |
123
+ | 药丸显示「未记录登录时间」 | 凭据由旧版本保存,没有计时元数据;重新登录或重新导入一次后开始计时 |
124
+ | 保存成功但验证失败 | 凭据已保存,检查网络、区域或账号后重试;保存与在线验证分别反馈 |
125
+ | 保存提示配置已改变 | 另一窗口或实例更新了设置;重新读取配置后再编辑,避免旧表单覆盖新值 |
126
+ | 升级后仍是旧界面或 RPC 不匹配 | 确认运行中的 Host 和 Client 都已重载;仅刷新页面不能保证 Host 升级 |
127
+ | Command Code 额度与实际账户不同 | 可在药丸弹层切换提供方插件的服务账户(activeAccount),切换后药丸与弹层用量同步显示所选账户的额度;「自动轮换」时仍显示默认账户(顶层 Key)的用量,不跟随轮换中的实际服务账户,提供方自定义 apiBase 也不跟随 |
147
128
 
148
- ```console
149
- git clone https://github.com/KouzakiUmi/dsh-subusage.git
150
- cd dsh-subusage
151
- ```
129
+ ## 提供商 ID 与默认开关
130
+
131
+ | 提供商 | ID | 新安装默认值 |
132
+ |---|---|---|
133
+ | Z.ai 中国 | `zai-coding-cn` | 开启 |
134
+ | Kimi Coding | `kimi-coding` | 开启 |
135
+ | MiMo | `xiaomi-token-plan-cn` | 开启 |
136
+ | OpenCode Go | `opencode-go` | 开启 |
137
+ | Command Code | `commandcode` | 开启 |
138
+ | MiniMax 国际 | `minimax` | 开启 |
139
+ | MiniMax 中国 | `minimax-cn` | 开启 |
140
+ | Z.ai 国际 | `zai-coding` | **关闭** |
141
+ | Synthetic | `synthetic` | **关闭** |
142
+ | NanoGPT | `nanogpt` | **关闭** |
152
143
 
153
- 通过目标部署支持的官方插件管理/CLI 流程将工作区 bundle 安装并启用。不要直接修改核心或 ASAR;本地链接的解析方式、profile 层覆盖与重载行为应按目标环境确认。
144
+ 开关只控制本插件的订阅检测与显示,关闭后保留凭据。默认隐藏只影响显示,不自动开启被关闭的提供商。新增三项在旧配置升级时也保持关闭。
154
145
 
155
- **0.3.0 新增登录 RPC 和 `playwright-core` 依赖,必须让 Host/Client 及依赖一起更新。0.4.0 新增 Command Code 条目,同样要求两端一起更新(旧 Host 不识别 `commandcode`)。** 仅刷新页面不能升级正在运行的旧 Host。安装、重载或重启需要用户另行授权;仓库测试通过不代表运行中的插件已生效。
146
+ ## 界面预览
156
147
 
157
- ## 自动发布
148
+ 以下为 0.7.0 的离线组件测试截图,使用虚构用量数据,不包含真实账号信息,也不是运行中的 DSH 截图。市场截图由根目录的 `screenshots.json` 声明。
158
149
 
159
- 推送或合并到 `main` 后,GitHub Actions 自动运行回归、清单/语法及打包检查,通过后发布当前提交的 GitHub Release。PR 只测试,不发布;不用手动打 tag,也不发布到 npm。
150
+ 深色设置页:查看订阅用量、管理凭据与提供商;新增三家默认关闭。
160
151
 
161
- 每个提交使用独立的 `build-<SHA>` 标签,安装包内仍保留当前项目版本。固定下载:[最新安装包](<https://github.com/KouzakiUmi/dsh-subusage/releases/latest/download/dsh-subusage.tgz>)。发布不会自动安装或重启插件,详见 [发布说明](<docs/publish.md>)。
152
+ ![深色订阅设置页(离线测试预览)](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/settings-dark.png)
162
153
 
163
- ## 开发与测试
154
+ 浅色用量弹层:查看每日与每周额度、用量明细。
155
+
156
+ ![浅色 NanoGPT 用量弹层(离线测试预览)](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/usage-popover-light.png)
157
+
158
+
159
+ ## 开发、发布与文档
160
+
161
+ 基础检查无需安装 DSH 或连接账号:
164
162
 
165
163
  ```console
166
164
  node tests/run-all.mjs
167
165
  node scripts/check-manifest.mjs
168
166
  node --check lib/index.js
169
167
  node --check lib/client.js
168
+ node --check lib/mimo-login.js
170
169
  ```
171
170
 
172
- Host 私有 Remote:
173
-
174
- - `read()`:初始化获取十个厂商/区域条目,使用每条目缓存;新增三项默认关闭。
175
- - `refresh({ providerIds, force })`:按厂商刷新,结果条目由客户端合并。
176
- - `save(settings)`:按厂商 patch 保存;读取/保存结果只含公开配置和凭据存在性。
177
- - `startMimoLogin({ expectedRevision })`:立即返回任务状态,后台等待官方登录,不阻塞 RPC。
178
- - `getMimoLoginStatus()`:轮询状态,成功结果只含公开配置和用量;账号变更后旧成功结果失效。
179
- - `cancelMimoLogin({ jobId })`:只取消匹配的任务,阻止迟到验证保存。
171
+ 推送到 `main` 后,GitHub Actions 检查并发布提交对应的 GitHub Release。推送与包版本一致的 `vX.Y.Z` 标签后,受 npm 信任的 `release.yml` 通过 Trusted Publishing 自动发布新 npm 版本;已有版本跳过。发布产物不会自动安装或重启 DSH。
180
172
 
181
- 可选的本机 Chrome 验收:使用 [离线预览脚本](<scripts/render-ui-preview.mjs>) 生成深浅主题的 530/500/499/360px MiMo 凭据预览(宽度指内部内容区),然后运行 [浏览器验收脚本](<scripts/check-browser-runtime.mjs>)。仅使用本地页面与虚构 Cookie,不连接 DSH 或真正 MiMo,不等于真实账号登录已验证。
173
+ - [开发与验证](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md):RPC、缓存、凭据、离线预览与实机验收。
174
+ - [UX 设计](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/design-ux.md):导航、状态、编辑保护与弹层行为。
175
+ - [发布与市场收录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/publish.md):GitHub Release、npm、条目与截图维护。
176
+ - [更新记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/changelog.md):各版本功能变化。
177
+ - [0.7.0 审查记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/code-review.md):修复、接口来源和验证边界。
182
178
 
183
- 回归测试覆盖归一化、限额边界、Cookie、RPC 契约、缓存/异常隔离、设置状态与 slot 装配。核心依赖与网络使用桩;真实 Loader composition、在线 API 和浏览器视觉仍须部署后验收,不把桩测试当作已上线证明。
179
+ 测试使用虚构凭据、桩网络和离线组件。真实 DSH Loader、实际账号在线接口与多账户映射尚未验收;截图不代表已通过这些检查。
184
180
 
185
181
  ## 许可
186
182
 
187
- MIT。与 DeepSeek Harness 及各服务商无官方关联;请遵守各服务商的使用条款。
183
+ MIT。与 DeepSeek Harness 及各服务商无官方关联。
package/lib/client.js CHANGED
@@ -95,12 +95,28 @@ window.__ModuleLoader__.load({
95
95
  needConfigShort: "需配置",
96
96
  pillNeedKey: "未配置 API Key —— 请打开 设置 → 订阅用量 填写,或导出同名环境变量后重启。",
97
97
  pillNeedCookie: "需登录 —— 请在 设置 → 订阅用量 访问官方平台并替换 Cookie。",
98
+ mimoCookieTtl: "官方会话 Cookie 有效期 24 小时,登录或导入时开始计时;临近到期时用量药丸会提示。",
99
+ mimoLoginAt: "登录于 {time}",
100
+ mimoCookieUntracked: "未记录登录时间(旧版本保存的 Cookie),重新登录后开始计时",
101
+ mimoCookieExpired: "Cookie 已过期,请重新登录",
102
+ pillCookieSoon: "Cookie {rest}后到期",
103
+ mimoCookieExpiresAt: "{time} 到期",
98
104
  noUsageData: "暂无额度数据",
99
105
  providerUnavailable: "用量暂不可用",
100
106
  pillNeedCommandcode: "需配置 —— 可在 设置 → Command Code 登录、运行 cmd login,或导出 COMMANDCODE_API_KEY 后重试。",
101
107
  accountLabel: "账户",
102
108
  commandcodeManaged: "凭据由 Command Code 提供方插件(设置 → Command Code)管理;本页只读、不保存凭据。用量由本插件直接拉取其计费接口,取用同一凭据来源(凭据服务 COMMANDCODE_API_KEY → 启动环境 → ~/.commandcode/auth.json)。",
103
- commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。多账户轮换或固定 activeAccount 时这里显示默认账户(顶层 Key)的额度,可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。"
109
+ commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。显示药丸弹层中选择的账户的额度(未选择时为默认账户/顶层 Key),可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。",
110
+ ccAccountsTitle: "Command Code 账户",
111
+ ccAccountAuto: "自动轮换",
112
+ ccAccountDefault: "默认账户",
113
+ ccAccountActive: "使用中",
114
+ ccAccountsHint: "切换对 Command Code 提供方插件全局生效(与其设置页同一开关);上方用量同步切换为所选账户的额度。",
115
+ ccAccountsUnavailable: "账户列表不可用(需要 Command Code 提供方插件在运行)",
116
+ ccUsageAccount: "上方用量:{account}",
117
+ ccUsageAutoNote: "自动轮换时上方用量仍是默认账户(顶层 Key),不跟随实际服务账户",
118
+ ccSwitching: "切换中…",
119
+ ccSwitchFailed: "切换失败"
104
120
  };
105
121
  const en = {
106
122
  nav: "Subscription usage",
@@ -179,12 +195,28 @@ window.__ModuleLoader__.load({
179
195
  needConfigShort: "Setup",
180
196
  pillNeedKey: "No API key — open Settings → Subscription usage to fill one in, or export the same-named env variable and restart.",
181
197
  pillNeedCookie: "Login required — paste a cookie in Settings → Subscription usage.",
198
+ mimoCookieTtl: "The official session Cookie lasts 24 hours; the countdown starts at sign-in or import. The usage pill warns before it runs out.",
199
+ mimoLoginAt: "Signed in {time}",
200
+ mimoCookieUntracked: "Login time not recorded (Cookie saved by an older version); sign in again to start the countdown",
201
+ mimoCookieExpired: "Cookie expired — sign in again",
202
+ pillCookieSoon: "Cookie expires in {rest}",
203
+ mimoCookieExpiresAt: "Expires {time}",
182
204
  noUsageData: "No quota data",
183
205
  providerUnavailable: "Usage unavailable",
184
206
  pillNeedCommandcode: "Setup required — sign in under Settings → Command Code, run cmd login, or export COMMANDCODE_API_KEY and retry.",
185
207
  accountLabel: "Account",
186
208
  commandcodeManaged: "Credentials are managed by the Command Code provider plugin (Settings → Command Code); this page is read-only and stores nothing. Usage is fetched directly by this plugin from its billing endpoint, using the same credential sources (credential service COMMANDCODE_API_KEY → launch environment → ~/.commandcode/auth.json).",
187
- commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. With account rotation or a pinned activeAccount this shows the default (top-level key) account's quota, which may differ from the serving account; a custom apiBase in the provider plugin is not followed."
209
+ commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. Shows the quota of the account selected in the pill popover (the default/top-level key account when none is selected), which may differ from the serving account; a custom apiBase in the provider plugin is not followed.",
210
+ ccAccountsTitle: "Command Code accounts",
211
+ ccAccountAuto: "Automatic (rotation order)",
212
+ ccAccountDefault: "Default account",
213
+ ccAccountActive: "in use",
214
+ ccAccountsHint: "Switching applies globally to the Command Code provider plugin (same switch as its settings page); the usage above switches to the selected account's quota.",
215
+ ccAccountsUnavailable: "Account list unavailable (requires the Command Code provider plugin to be running)",
216
+ ccUsageAccount: "Usage above: {account}",
217
+ ccUsageAutoNote: "with automatic rotation the usage above stays on the default (top-level key) account, not the serving one",
218
+ ccSwitching: "Switching…",
219
+ ccSwitchFailed: "Switch failed"
188
220
  };
189
221
  const NS = "settings.subusage";
190
222
  const KIND_LABEL = { "5h": "w5h", "7d": "w7d", day: "wday", week: "wweek", month: "wmonth", period: "wperiod", sub: "wsub", rolling: "wrolling" };
@@ -272,16 +304,44 @@ window.__ModuleLoader__.load({
272
304
  function extraLabel(x, t) {
273
305
  return x.kind === "plan" ? t("planLabel") : x.kind === "account" ? t("accountLabel") : x.kind === "monthly-balance" ? t("monthlyBalanceLabel") : t("balanceLabel");
274
306
  }
307
+ /** 时长紧凑文案:窗口重置倒计时与 Cookie 剩余有效期共用同一档位。 */
308
+ function durationText(ms, t) {
309
+ const mins = Math.max(0, Math.round(ms / 60000));
310
+ return mins >= 1440 ? t("restDays").replace("{n}", String(Math.round(mins / 1440)))
311
+ : mins >= 60 ? t("restHours").replace("{n}", String(Math.round(mins / 60)))
312
+ : t("restMins").replace("{n}", String(Math.max(1, mins)));
313
+ }
314
+ /** 官方 MiMo 会话 Cookie 自签发起 24 小时有效:到期前 2 小时起提醒,30 分钟内升级为紧急。 */
315
+ const MIMO_COOKIE_SOON_MS = 2 * 60 * 60 * 1000;
316
+ const MIMO_COOKIE_URGENT_MS = 30 * 60 * 1000;
317
+ /** Cookie 有效期判定:药丸、弹层与设置页共用;无记录时返回 null,绝不编造倒计时。 */
318
+ function cookieExpiry(entry, t) {
319
+ const expiresAt = Date.parse((entry && entry.cookieExpiresAt) || "");
320
+ if (!Number.isFinite(expiresAt)) return null;
321
+ const left = expiresAt - Date.now();
322
+ const rest = durationText(left, t);
323
+ if (left <= 0) return { rank: 4, icon: "✕", color: "#ef4444", expired: true, rest, expiresAt, label: t("mimoCookieExpired") };
324
+ const label = t("pillCookieSoon").replace("{rest}", rest);
325
+ if (left <= MIMO_COOKIE_URGENT_MS) return { rank: 3, icon: "⚠", color: "#f97316", rest, expiresAt, label };
326
+ if (left <= MIMO_COOKIE_SOON_MS) return { rank: 2, icon: "⚠", color: "#eab308", rest, expiresAt, label };
327
+ return { rank: 1, icon: "✓", color: "#22c55e", rest, expiresAt, label };
328
+ }
329
+ /** 设置页有效期说明:登录时间 + 剩余时间;旧版本保存的 Cookie 明确说明未记录。 */
330
+ function cookieLifetimeLine(xiaomi, t, getLocale) {
331
+ if (!xiaomi || !xiaomi.hasCookie) return null;
332
+ const expiry = cookieExpiry({ cookieExpiresAt: xiaomi.expiresAt }, t);
333
+ const parts = [];
334
+ if (xiaomi.loginAt) parts.push(t("mimoLoginAt").replace("{time}", new Date(xiaomi.loginAt).toLocaleString(getLocale())));
335
+ parts.push(expiry ? expiry.label : t("mimoCookieUntracked"));
336
+ return h("p", { role: "status", style: { margin: "6px 0 0", color: expiry && expiry.rank >= 2 ? expiry.color : "inherit" } }, parts.join(" · "));
337
+ }
275
338
  /** 重置行:带倒计时;到期未刷新显式说明。 */
276
339
  function resetLine(w, t, getLocale) {
277
340
  if (!w.resetsAt) return null;
278
341
  const at = new Date(w.resetsAt);
279
342
  const ms = at.getTime() - Date.now();
280
343
  if (!(ms > 0)) return t("detailResetDue");
281
- const mins = Math.round(ms / 60000);
282
- const rest = mins >= 1440 ? t("restDays").replace("{n}", String(Math.round(mins / 1440)))
283
- : mins >= 60 ? t("restHours").replace("{n}", String(Math.round(mins / 60)))
284
- : t("restMins").replace("{n}", String(Math.max(1, mins)));
344
+ const rest = durationText(ms, t);
285
345
  return t("detailResetIn").replace("{time}", at.toLocaleString(getLocale())).replace("{rest}", rest);
286
346
  }
287
347
 
@@ -346,7 +406,7 @@ window.__ModuleLoader__.load({
346
406
  namespace: "subUsage",
347
407
  method: "read",
348
408
  invocation: { kind: "direct" },
349
- parameters: [],
409
+ parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: queryCodec }],
350
410
  result: resultCodec
351
411
  },
352
412
  {
@@ -471,13 +531,18 @@ window.__ModuleLoader__.load({
471
531
  let snapshot = { updatedAt: null, settings: null, configured: {}, entries: [] };
472
532
  let disposed = false, initial = null, timer = null, epoch = 0, sequence = 0, metadataSequence = 0;
473
533
  let loginGeneration = 0, loginJobId = null, loginPending = false;
534
+ // Command Code 显示账户:弹层账户区选中的账户("" = 默认账户/顶层 Key)。
535
+ // 所有 commandcode 读取(readAll/refresh/定时刷新)都携带它,切换后用量同步跟随。
536
+ let ccAccount = "";
474
537
  const mergedLoginJobs = new Set();
475
538
  const providerSequence = new Map();
476
539
  const markRequest = (ids) => { const version = ++sequence; for (const id of ids) providerSequence.set(id, version); return { version, ids }; };
477
540
  const listeners = new Set(), active = new Map(), inflight = new Map(), readers = new Map(), resetSeen = new Set();
478
541
  const emit = () => { if (!disposed) for (const fn of listeners) fn(); };
479
- const invalidate = (ids) => {
480
- snapshot = { ...snapshot, entries: snapshot.entries.map((e) => ids.includes(e.providerId) ? { providerId: e.providerId, apiDetected: e.apiDetected, keySource: e.keySource, state: "loading", coverage: "partial", freshness: "unknown", windows: [], extras: [] } : e) };
542
+ // 凭据被替换或清除时不能沿用旧 cookieExpiresAt:否则已删除的 Cookie 仍显示过期倒计时。
543
+ // keep 表示“未改动”,保留计时以免保存后倒计时闪退。
544
+ const invalidate = (ids, keepCookieExpiry = true) => {
545
+ snapshot = { ...snapshot, entries: snapshot.entries.map((e) => ids.includes(e.providerId) ? { providerId: e.providerId, apiDetected: e.apiDetected, keySource: e.keySource, ...(keepCookieExpiry && e.cookieExpiresAt ? { cookieExpiresAt: e.cookieExpiresAt } : {}), state: "loading", coverage: "partial", freshness: "unknown", windows: [], extras: [] } : e) };
481
546
  for (const id of ids) inflight.delete(id);
482
547
  };
483
548
  const merge = (incoming, request = null, localChange = false) => {
@@ -497,7 +562,7 @@ window.__ModuleLoader__.load({
497
562
  const missing = unique.filter((id) => !inflight.has(id));
498
563
  if (missing.length) {
499
564
  const startedEpoch = epoch, request = markRequest(missing);
500
- const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
565
+ const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
501
566
  // 传输失败不能擅自 retainPrevious:只有 Host 明确标 stale 才保留。
502
567
  if (startedEpoch === epoch) merge({ entries: missing.map((providerId) => ({ providerId, state: "error", error: error instanceof Error ? error.message : String(error), freshness: "unknown", coverage: "partial", windows: [], extras: [] })) }, request);
503
568
  throw error;
@@ -536,16 +601,29 @@ window.__ModuleLoader__.load({
536
601
  }
537
602
  return value;
538
603
  };
604
+ const setCommandCodeAccount = (id) => {
605
+ const next = typeof id === "string" ? id.trim() : "";
606
+ if (disposed || ccAccount === next) return Promise.resolve(snapshot);
607
+ ccAccount = next;
608
+ // 账户变了:旧账户的在途/缓存数据立即失效,强制按新账户重拉。
609
+ invalidate(["commandcode"]);
610
+ // invalidate 只重写快照不通知:补一次 emit 让药丸立即进入加载态,
611
+ // 避免强制刷新返回前「标注已是新账户、数字还是旧账户」的瞬态错标。
612
+ emit();
613
+ return refresh(["commandcode"], true);
614
+ };
539
615
  return {
540
616
  subscribe, getSnapshot: () => snapshot, refresh, activate,
617
+ getCommandCodeAccount: () => ccAccount, setCommandCodeAccount,
541
618
  startMimoLogin: async (request) => { const generation = ++loginGeneration, startedEpoch = epoch; loginJobId = null; return acceptLogin(await call("startMimoLogin", request), generation, startedEpoch); },
542
619
  getMimoLoginStatus: async () => { const generation = loginGeneration, startedEpoch = epoch, jobId = loginPending ? loginJobId : null; return acceptLogin(await call("getMimoLoginStatus"), generation, startedEpoch, jobId); },
543
620
  cancelMimoLogin: async (request) => { if (loginJobId && request.jobId !== loginJobId) return null; const generation = ++loginGeneration, startedEpoch = epoch; return acceptLogin(await call("cancelMimoLogin", request), generation, startedEpoch, request.jobId); },
544
- readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read")).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
621
+ readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read", { providerIds: PROVIDER_ORDER, force: false, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
545
622
  save: async (patch) => {
546
623
  const value = await call("save", patch); epoch++;
547
624
  if (patch.visibility) { const entries = snapshot.entries; markRequest(PROVIDER_ORDER); return merge({ ...value, entries }, null, true); }
548
- invalidate([patch.providerId]); return merge(value, null, true);
625
+ const cookie = patch.cookieUpdate;
626
+ invalidate([patch.providerId], !cookie || cookie.action === "keep"); return merge(value, null, true);
549
627
  },
550
628
  reader: (id) => {
551
629
  if (!readers.has(id)) {
@@ -567,6 +645,122 @@ window.__ModuleLoader__.load({
567
645
  return percent >= 80 ? "high" : void 0;
568
646
  }
569
647
 
648
+ // ── Command Code 账户切换(对齐提供方插件的账户显示)────────────────────
649
+ // 账户列表与 id 规则对齐 @mars-sea/dsh-commandcode-provider 的 slots():
650
+ // 默认账户 id 固定 "default";额外账户 id 取其 apiKeyEnv(凭据引用名)。
651
+ // 与 slots() 的差异:仅配置 literal apiKey(无凭据引用)的槽位不进列表——
652
+ // 其 Key 存在提供方配置内,本插件无法按引用解析,列出只会选中即报 no-key。
653
+ // enrollment 进行中的账户不进列表(与其一致)。
654
+ const COMMANDCODE_SETTINGS_NS = "llm-commandcode";
655
+ function commandCodeAccountList(value) {
656
+ const list = [{ id: "default", label: "default" }];
657
+ const accounts = Array.isArray(value?.accounts) ? value.accounts : [];
658
+ const tasks = Array.isArray(value?.accountEnrollmentTasks) ? value.accountEnrollmentTasks : [];
659
+ accounts.forEach((account, index) => {
660
+ const refName = typeof account?.apiKeyEnv === "string" && account.apiKeyEnv.trim() !== "" ? account.apiKeyEnv.trim() : void 0;
661
+ // 与 slots() 一致:无凭据引用的槽位不显示;登记任务未完成的账户暂不可选。
662
+ if (refName === void 0) return;
663
+ if (tasks.some((task) => task?.ref === refName && task.phase !== "naming")) return;
664
+ list.push({ id: refName, label: typeof account.label === "string" && account.label.trim() !== "" ? account.label.trim() : `Account ${index + 2}` });
665
+ });
666
+ return list;
667
+ }
668
+ /** 当前固定账户:非空且命中账户列表才算固定;空串/"auto"/悬空引用都按自动轮换显示。 */
669
+ function commandCodeActiveId(value, accounts) {
670
+ const raw = typeof value?.activeAccount === "string" ? value.activeAccount.trim() : "";
671
+ return raw !== "" && accounts.some((account) => account.id === raw) ? raw : "";
672
+ }
673
+ /**
674
+ * 账户切换控制器:读走 settings remote 的 describe(llm-commandcode ns 的
675
+ * accounts/activeAccount/revision),写走同一 ns 的 mutate——与 Command Code
676
+ * 设置页完全同一 wire 通道与 op 形式。describe/mutate 由调用方注入,namespace
677
+ * 未挂载(提供方插件未运行)时返回 not-ok,界面降级为不可用。
678
+ */
679
+ function createCommandCodeAccounts({ describe, mutate }) {
680
+ let disposed = false;
681
+ const listeners = new Set();
682
+ let state = { status: "idle", accounts: [], activeId: "", revision: null, writable: false, baseActive: "", switching: false, error: null };
683
+ const emit = () => { for (const fn of [...listeners]) fn(state); };
684
+ const parseRow = (row) => {
685
+ const accounts = commandCodeAccountList(row?.value);
686
+ return { accounts, activeId: commandCodeActiveId(row?.value, accounts), revision: typeof row?.revision === "number" ? row.revision : null, baseActive: typeof row?.base?.activeAccount === "string" ? row.base.activeAccount : "" };
687
+ };
688
+ const load = async (keepError = false) => {
689
+ if (disposed) return state;
690
+ state = { ...state, status: "loading", error: keepError ? state.error : null }; emit();
691
+ try {
692
+ const response = await describe();
693
+ if (!response?.ok) throw new Error(response?.error?.message || "settings describe failed");
694
+ const row = response.value?.namespaces?.find((candidate) => candidate?.ns === COMMANDCODE_SETTINGS_NS);
695
+ if (!row) throw new Error("Command Code settings namespace not found");
696
+ state = { ...state, status: "ready", writable: response.value.writable === true, ...parseRow(row) };
697
+ } catch (error) {
698
+ state = { ...state, status: "unavailable", error: String(error?.message || error) };
699
+ }
700
+ emit();
701
+ return state;
702
+ };
703
+ const switchTo = async (id) => {
704
+ if (disposed || state.status !== "ready" || state.switching) return state;
705
+ // 回自动轮换:组合层已固定(base 非空)时写空串覆盖,否则直接清除该字段——
706
+ // 与提供方插件设置页的 active intent 完全一致。
707
+ const ops = id === ""
708
+ ? (state.baseActive !== "" ? [{ op: "set", path: ["activeAccount"], value: "" }] : [{ op: "unset", path: ["activeAccount"] }])
709
+ : [{ op: "set", path: ["activeAccount"], value: id }];
710
+ state = { ...state, switching: true, error: null }; emit();
711
+ try {
712
+ const response = await mutate(ops, state.revision);
713
+ if (!response?.ok) throw new Error(response?.error?.message || "settings mutate failed");
714
+ state = { ...state, switching: false, ...parseRow(response.value) };
715
+ } catch (error) {
716
+ const message = String(error?.message || error);
717
+ state = { ...state, switching: false, error: message };
718
+ void load(true); // 写失败(含 revision 冲突)后以 Host 实际状态为准;失败原因保留给界面。
719
+ }
720
+ emit();
721
+ return state;
722
+ };
723
+ return {
724
+ load, switchTo,
725
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
726
+ getSnapshot: () => state,
727
+ dispose: () => { disposed = true; listeners.clear(); }
728
+ };
729
+ }
730
+
731
+ /** 弹层内的 Command Code 账户切换区:自动轮换 + 默认账户 + 各额外账户。 */
732
+ function CommandCodeAccountSwitch({ cc, ccState, t }) {
733
+ if (!cc || !ccState) return null;
734
+ const optionButton = (id, labelText) => h("button", {
735
+ type: "button",
736
+ onClick: () => void cc.switchTo(id),
737
+ disabled: ccState.switching || !ccState.writable,
738
+ "aria-pressed": ccState.activeId === id,
739
+ style: {
740
+ display: "flex", alignItems: "center", gap: 6, width: "100%", textAlign: "left",
741
+ padding: "4px 8px", marginTop: 4, borderRadius: 6,
742
+ border: `1px solid ${ccState.activeId === id ? "rgba(59,130,246,.55)" : "rgba(128,128,128,.3)"}`,
743
+ background: ccState.activeId === id ? "rgba(59,130,246,.10)" : "transparent",
744
+ color: "inherit", font: "inherit",
745
+ cursor: ccState.switching || !ccState.writable ? "default" : "pointer"
746
+ }
747
+ },
748
+ h("span", { "aria-hidden": "true" }, ccState.activeId === id ? "●" : "○"),
749
+ h("span", { style: { flex: 1 } }, labelText),
750
+ ccState.activeId === id ? h("span", { style: { fontSize: 11, opacity: 0.75 } }, t("ccAccountActive")) : null
751
+ );
752
+ return h("div", { style: { marginTop: 10, paddingTop: 8, borderTop: "1px solid rgba(128,128,128,.25)" } },
753
+ h("div", { style: { fontSize: 11, opacity: 0.7 } }, t("ccAccountsTitle")),
754
+ ccState.status === "ready"
755
+ ? h("div", null,
756
+ optionButton("", t("ccAccountAuto")),
757
+ ccState.accounts.map((account) => optionButton(account.id, account.id === "default" ? t("ccAccountDefault") : account.label)),
758
+ ccState.error ? h("p", { style: { color: "#f87171", margin: "6px 0 0", whiteSpace: "pre-wrap" } }, `${t("ccSwitchFailed")}: ${ccState.error}`) : null,
759
+ h("p", { style: { margin: "6px 0 0", fontSize: 11, opacity: 0.65 } }, t("ccAccountsHint")))
760
+ : h("p", { style: { margin: "4px 0 0", opacity: 0.7 } }, ccState.status === "loading" || ccState.status === "idle" ? t("loading") : t("ccAccountsUnavailable"))
761
+ );
762
+ }
763
+
570
764
  /** 单窗口行(药丸弹层与设置页共用):徽标 + 已用% 主数字 + 进度条 + 明细行。 */
571
765
  function UsageWindowRow({ w, t, getLocale, uncertain = false }) {
572
766
  const tier = uncertain && w.status !== "rate-limited" ? { icon: "?", badge: "badgeUnknown", color: "#b7791f", rank: 2 } : usageTier(w);
@@ -606,7 +800,7 @@ window.__ModuleLoader__.load({
606
800
  );
607
801
  }
608
802
 
609
- function UsagePill({ providerId, label, readEntry, t, getLocale }) {
803
+ function UsagePill({ providerId, label, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
610
804
  const [snapshot, setSnapshot] = react.useState(null);
611
805
  const [failed, setFailed] = react.useState(null);
612
806
  const [failState, setFailState] = react.useState(void 0);
@@ -614,6 +808,17 @@ window.__ModuleLoader__.load({
614
808
  const [open, setOpen] = react.useState(false);
615
809
  const root = react.useRef(null);
616
810
  const retry = react.useRef(() => {});
811
+ // Command Code 账户区:cc 由装配层注入(提供方插件未运行时为 null,区块整体隐藏)。
812
+ const ccState = react.useSyncExternalStore(cc ? cc.subscribe : () => () => {}, cc ? cc.getSnapshot : () => null);
813
+ react.useEffect(() => { if (open && cc) void cc.load(); }, [open, cc]);
814
+ // 挂载即读一次账户配置:提供方插件固定了账户时,药丸本体不必等弹层打开就跟随。
815
+ react.useEffect(() => { if (providerId === "commandcode" && cc) void cc.load(); }, [providerId, cc]);
816
+ // 显示账户跟随账户区的当前固定项(含切换成功后的回读):ready 才同步,
817
+ // unavailable/loading 保持现状;store 内部对相同账户去重,不产生多余请求。
818
+ react.useEffect(() => {
819
+ if (providerId !== "commandcode" || !ccState || !setCommandCodeAccount) return;
820
+ if (ccState.status === "ready") void setCommandCodeAccount(ccState.activeId);
821
+ }, [providerId, setCommandCodeAccount, ccState && ccState.status, ccState && ccState.activeId]);
617
822
  react.useEffect(() => {
618
823
  let alive = true;
619
824
  let busy = false;
@@ -690,25 +895,36 @@ window.__ModuleLoader__.load({
690
895
  : null;
691
896
  const isConfig = stateText === "no-key" || stateText === "no-cookie";
692
897
  // 余额请求成功不代表有额度窗口(例如小米接口暂时只返回余额)。
693
- const triggerIcon = worstTier && worstTier.rank >= 4 ? worstTier.icon : degraded ? "⚠" : worstTier ? worstTier.icon : ok ? "ℹ" : isConfig ? "⚙" : light;
694
- const triggerText = worst ? `${degraded ? statusText(rawEntry, t) + " · " : ""}${pillText(worst, windows.length > 1, t)}` : entry ? `${degraded ? stateLabel + " · " : ""}${t("noUsageData")}` : isConfig ? t("needConfigShort") : stateLabel;
898
+ // Cookie 临近到期是需要重新登录的行动项:除额度已用尽外,优先于普通余量与降级提示。
899
+ const expiry = cookieExpiry(rawEntry, t);
900
+ const quotaBlocked = !!(worstTier && worstTier.rank >= 4);
901
+ const expiring = !!(expiry && expiry.rank >= 2);
902
+ // 倒计时只用于展示,永远不能盖过官方接口的判定:凭据已被拒绝(state=error)时
903
+ // 主文案必须仍是失败原因,否则「还有 2 小时到期」会把重新登录推迟掉。
904
+ // 例外是已过期:它本身就是「请重新登录」的行动项,比通用错误文案更具体。
905
+ const expiryActionable = expiring && (!(rawEntry && rawEntry.state === "error") || (expiry && expiry.expired));
906
+ // 底色与文字沿用额度告警语汇:已过期与额度用尽同为红,30 分钟内为橙,2 小时内为黄。
907
+ const alertStyle = quotaBlocked || (expiry && expiry.expired) ? { background: "rgba(239,68,68,.10)", borderColor: "rgba(239,68,68,.45)" } : expiryActionable && expiry.rank >= 3 ? { background: "rgba(249,115,22,.10)", borderColor: "rgba(249,115,22,.45)" } : expiryActionable ? { background: "rgba(234,179,8,.10)", borderColor: "rgba(234,179,8,.45)" } : {};
908
+ const triggerColor = quotaBlocked ? worstTier.color : expiryActionable ? expiry.color : worstTier && worstTier.rank > 1 ? worstTier.color : null;
909
+ const triggerIcon = quotaBlocked ? worstTier.icon : expiryActionable ? expiry.icon : degraded ? "⚠" : worstTier ? worstTier.icon : ok ? "ℹ" : isConfig ? "⚙" : light;
910
+ const triggerText = quotaBlocked ? (worst ? pillText(worst, windows.length > 1, t) : entry ? t("noUsageData") : stateLabel) : expiryActionable ? expiry.label : worst ? `${degraded ? statusText(rawEntry, t) + " · " : ""}${pillText(worst, windows.length > 1, t)}` : entry ? `${degraded ? stateLabel + " · " : ""}${t("noUsageData")}` : isConfig ? t("needConfigShort") : stateLabel;
695
911
  return h("span", { ref: root, style: { position: "relative", display: "inline-flex", alignItems: "center" } },
696
912
  h("button", {
697
913
  type: "button",
698
914
  "aria-expanded": open,
699
915
  "aria-haspopup": "dialog",
700
- "aria-label": `${label}: ${stateLabel} · ${summary}`,
701
- title: `${label}: ${stateLabel} · ${summary}`,
916
+ "aria-label": `${label}: ${stateLabel} · ${summary}${expiring && !quotaBlocked ? " · " + expiry.label : ""}`,
917
+ title: `${label}: ${stateLabel} · ${summary}${expiring && !quotaBlocked ? " · " + expiry.label : ""}`,
702
918
  onClick: () => setOpen(!open),
703
919
  style: {
704
920
  display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px",
705
921
  borderRadius: 999, border: "1px solid rgba(128,128,128,.35)", background: "transparent",
706
922
  color: "inherit", font: "inherit", cursor: "pointer", whiteSpace: "nowrap",
707
- ...(worstTier && worstTier.rank >= 4 ? { background: "rgba(239,68,68,.10)", borderColor: "rgba(239,68,68,.45)" } : {})
923
+ ...alertStyle
708
924
  }
709
925
  },
710
926
  h("span", { "aria-hidden": "true", style: { fontSize: 11, lineHeight: 1 } }, triggerIcon),
711
- h("span", { style: worstTier && worstTier.rank > 1 ? { color: worstTier.color } : null }, triggerText),
927
+ h("span", { style: triggerColor ? { color: triggerColor } : null }, triggerText),
712
928
  failed && ok ? h("span", { style: { opacity: 0.7, fontSize: 11 } }, `(${t("unavailable")})`) : null
713
929
  ),
714
930
  open && h("div", {
@@ -727,11 +943,20 @@ window.__ModuleLoader__.load({
727
943
  failed && !needConfig ? h("p", { style: { color: "#f87171", margin: "6px 0 0", whiteSpace: "pre-wrap" } }, failed) : null,
728
944
  !ok && !snapshot && !failed ? h("p", { style: { margin: "6px 0 0", opacity: 0.8 } }, t("loading")) : null,
729
945
  cacheAge(rawEntry, t) ? h("p", { style: { color: "#b7791f" } }, cacheAge(rawEntry, t)) : null,
946
+ // 到期提醒常驻弹层:即使用量正常,也让用户看见 Cookie 还剩多久。
947
+ expiry ? h("p", { role: "status", style: { margin: "6px 0 0", color: expiring ? expiry.color : "inherit" } }, `${expiry.label} · ${t("mimoCookieExpiresAt").replace("{time}", new Date(expiry.expiresAt).toLocaleString(getLocale()))}`) : null,
948
+ // 用量账户标注:账户区 ready 时说明上方数字属于哪个账户;自动轮换时明确不跟随实际服务账户。
949
+ providerId === "commandcode" && ccState && ccState.status === "ready"
950
+ ? h("p", { style: { margin: "6px 0 0", fontSize: 11, opacity: 0.75 } },
951
+ t("ccUsageAccount").replace("{account}", ccState.activeId === "" || ccState.activeId === "default" ? t("ccAccountDefault") : (ccState.accounts.find((a) => a.id === ccState.activeId)?.label ?? ccState.activeId)),
952
+ ccState.activeId === "" ? h("span", { style: { opacity: 0.85 } }, ` · ${t("ccUsageAutoNote")}`) : null)
953
+ : null,
730
954
  windows.map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: degraded })),
731
955
  entry && entry.extras && entry.extras.length > 0
732
956
  ? h("div", { style: { marginTop: 10, opacity: 0.85 } },
733
957
  entry.extras.map((x) => h("div", { key: x.kind }, `${extraLabel(x, t)}: ${x.value}`)))
734
958
  : null,
959
+ providerId === "commandcode" ? h(CommandCodeAccountSwitch, { cc, ccState, t }) : null,
735
960
  snapshot ? h("div", { style: { marginTop: 10, opacity: 0.7 } }, `${t("lastUpdated")} ${new Date(snapshot.updatedAt).toLocaleString(getLocale())}`) : null,
736
961
  h("button", {
737
962
  type: "button",
@@ -945,6 +1170,8 @@ window.__ModuleLoader__.load({
945
1170
  login.state === "error" || login.state === "cancelled" ? h("p", { role: "status" }, login.state === "cancelled" ? text("登录已取消,可重新登录或手动导入。", "Login cancelled. Retry or import manually.") : text("自动登录失败,可重试或手动导入。", "Automatic login failed. Retry or import manually.") + (login.message ? " " + login.message : "")) : null,
946
1171
  h("p", null, h("a", { href: "https://platform.xiaomimimo.com", target: "_blank", rel: "noopener noreferrer" }, text("仅打开 MiMo 官网(不会自动导入)", "Open MiMo website only (no automatic import)"))),
947
1172
  h("p", null, result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie ? text("已保存 Cookie(不回填)", "Cookie saved (never filled back)") : t("statusNoCookie")),
1173
+ cookieLifetimeLine(result.settings && result.settings.xiaomi, t, getLocale),
1174
+ h("p", { style: { fontSize: 12, opacity: .75 } }, t("mimoCookieTtl")),
948
1175
  credentialButtons("cookie", result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie),
949
1176
  draft.cookieAction === "replace" ? h(react.Fragment, null,
950
1177
  h("label", null, t("mimoCookie"), h("textarea", { rows: 3, autoComplete: "off", spellCheck: false, value: draft.cookie, disabled: busy || loginActive, onChange: (e) => editDraft({ cookie: e.target.value }), style: { ...inputStyle, resize: "vertical" } })),
@@ -1056,19 +1283,28 @@ window.__ModuleLoader__.load({
1056
1283
  const usageStore = createUsageStore(call);
1057
1284
  ctx.effect(() => () => { disposed = true; usageStore.dispose(); for (const [timer, reject] of pendingTimers) { clearTimeout(timer); reject(new Error("Client 已卸载")); } pendingTimers.clear(); }, "dsh-subusage: dispose shared store and RPC timers");
1058
1285
  const readEntry = (id) => usageStore.reader(id);
1286
+ // Command Code 账户切换:settings namespace 由核心 remote 提供;提供方插件
1287
+ // 未运行时 describe 拿不到 llm-commandcode 行,账户区降级为不可用,不影响其他功能。
1288
+ let ccSettingsNamespace;
1289
+ const cc = createCommandCodeAccounts({
1290
+ describe: async () => ccSettingsNamespace ? await ccSettingsNamespace.describe() : { ok: false, error: { message: "settings remote namespace is not mounted" } },
1291
+ mutate: async (ops, revision) => ccSettingsNamespace ? await ccSettingsNamespace.mutate(COMMANDCODE_SETTINGS_NS, ops, revision) : { ok: false, error: { message: "settings remote namespace is not mounted" } }
1292
+ });
1293
+ ctx.inject(["remote.settings"], (settingsCtx) => { ccSettingsNamespace = settingsCtx.remote.settings; });
1294
+ ctx.effect(() => () => cc.dispose(), "dsh-subusage: commandcode account switch");
1059
1295
  try {
1060
1296
  ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "subusage", order: 22, label: () => t("nav"), inject: () => ({ usageStore, t, getLocale }) }, SubusageSection));
1061
1297
  } catch (error) { console.error("[dsh-subusage] 设置页注册失败:", error); }
1062
1298
  ctx.inject(["modelDirectories"], (scope) => {
1063
1299
  try {
1064
1300
  scope.slots.inject("conversation.input.right", () => scope.slots.register({ name: "conversation.input.right", id: "subusage-usage", order: 1001,
1065
- inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale }) }, SubusagePillEntry));
1301
+ inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale, cc, setCommandCodeAccount: usageStore.setCommandCodeAccount }) }, SubusagePillEntry));
1066
1302
  } catch (error) { console.error("[dsh-subusage] 药丸注册失败:", error); }
1067
1303
  });
1068
1304
  }
1069
1305
 
1070
1306
  /** 槽包装:先看当前模型的 provider,再分发到对应药丸。 */
1071
- function SubusagePillEntry({ store, readEntry, t, getLocale }) {
1307
+ function SubusagePillEntry({ store, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
1072
1308
  const state = react.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
1073
1309
  const provider = state.current && state.current.provider;
1074
1310
  const info = PROVIDER_ORDER.includes(provider) ? PROVIDER_META[provider] : null;
@@ -1084,7 +1320,9 @@ window.__ModuleLoader__.load({
1084
1320
  label: meta.label,
1085
1321
  readEntry: reader,
1086
1322
  t,
1087
- getLocale
1323
+ getLocale,
1324
+ cc,
1325
+ setCommandCodeAccount
1088
1326
  });
1089
1327
  }
1090
1328
 
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // dsh-subusage Host:秘密仅保存在 Host,远端只暴露 provider 补丁和公开设置。
2
2
  import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
3
+ import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
4
4
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
5
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { createHash } from "node:crypto";
@@ -54,9 +54,13 @@ function commandCodeAuthFileKey(io) {
54
54
  } catch { return undefined; }
55
55
  }
56
56
  const CONFIG_PATH = join(homedir(), ".dsh", "dsh-subusage.json");
57
+ // 官方 MiMo 会话 Cookie 自签发起 24 小时有效;从凭据写入时刻起计时,手动导入与自动登录同源。
58
+ const MIMO_COOKIE_TTL_MS = 24 * 60 * 60 * 1000;
57
59
  const MAX_BYTES = 1024 * 1024;
58
60
  const TTL = 60000;
59
61
  const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
62
+ /** 计时元数据落库用毫秒、过 RPC 用 ISO:非正值一律视为未记录。 */
63
+ const isoStamp = (ms) => Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null;
60
64
  function redactMimo(value, cookie) {
61
65
  const sensitive = [cookie, ...cookie.split(";").map(part => { const trimmed = part.trim(); return trimmed.slice(trimmed.indexOf("=") + 1).replace(/^\"|\"$/g, ""); }).filter(Boolean)].filter(Boolean);
62
66
  // 套餐名是未经结构约束的展示文本;余额/明细已严格限制为数值和货币代码。
@@ -65,13 +69,13 @@ function redactMimo(value, cookie) {
65
69
  return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
66
70
  }
67
71
  function defaultSettings() {
68
- return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "" }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
72
+ return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
69
73
  }
70
74
  function parseStored(value) {
71
75
  const s = defaultSettings();
72
76
  if (!value || typeof value !== "object" || Array.isArray(value)) throw failure("subusage/config", "设置文件结构无效");
73
77
  if (value.zai && typeof value.zai === "object") s.zai = { type: value.zai.type === 2 ? 2 : 1, organization: typeof value.zai.organization === "string" ? value.zai.organization : "", project: typeof value.zai.project === "string" ? value.zai.project : "" };
74
- if (typeof value.xiaomi?.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie;
78
+ if (value.xiaomi && typeof value.xiaomi === "object") { if (typeof value.xiaomi.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie; for (const field of ["loginAt", "expiresAt"]) if (Number.isSafeInteger(value.xiaomi[field]) && value.xiaomi[field] > 0) s.xiaomi[field] = value.xiaomi[field]; }
75
79
  if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
76
80
  for (const id of IDS) {
77
81
  if (typeof value.visibility?.providers?.[id] === "boolean") s.visibility.providers[id] = value.visibility.providers[id];
@@ -82,7 +86,7 @@ function parseStored(value) {
82
86
  return s;
83
87
  }
84
88
  function publicSettings(s) {
85
- return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim() }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
89
+ return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
86
90
  }
87
91
  function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
88
92
  function parseUpdate(update) {
@@ -129,15 +133,21 @@ function parseSettings(value) {
129
133
  return out;
130
134
  }
131
135
  function parseQuery(value) {
136
+ // request 在 wire 层可选(read 描述符声明 acceptsUndefined):缺参/undefined/null
137
+ // = 全量默认账户查询,兼容旧 client bundle 的无参 read。
138
+ if (value === undefined || value === null) return { providerIds: [...IDS], force: false, commandCodeAccount: "" };
132
139
  if (!value || !Array.isArray(value.providerIds) || value.providerIds.some(id => !IDS.includes(id)) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
133
- return { providerIds: [...new Set(value.providerIds)], force: value.force };
140
+ // commandCodeAccount 是可选的显示账户选择:空串/缺省 = 默认账户(顶层 Key);
141
+ // 额外账户 id 即其凭据引用名(apiKeyEnv),由 identity 按引用解析。
142
+ if (value.commandCodeAccount !== undefined && typeof value.commandCodeAccount !== "string") throw invalid("Invalid account selection");
143
+ return { providerIds: [...new Set(value.providerIds)], force: value.force, commandCodeAccount: typeof value.commandCodeAccount === "string" ? value.commandCodeAccount.trim() : "" };
134
144
  }
135
145
  function failure(code, message, retryable = false, retryAfterMs) {
136
146
  return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
137
147
  }
138
148
  async function fetchJson(fetcher, url, headers, lifetimeSignal) {
139
149
  let response;
140
- try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.7.0", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
150
+ try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.8.2", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
141
151
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
142
152
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
143
153
  if (!response.ok) {
@@ -337,7 +347,7 @@ const loginCodec = codec("SubUsageLoginState", value => {
337
347
  export const subUsageRemote = {
338
348
  package: name,
339
349
  descriptors: [
340
- { id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [], result: resultCodec },
350
+ { id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: codec("SubUsageQuery", parseQuery) }], result: resultCodec },
341
351
  ...[["refresh", "request", "SubUsageQuery", parseQuery], ["save", "settings", "SubUsageSettings", parseSettings]].map(([method, param, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: param, wire: param, source: "json", codec: codec(type, parse) }], result: resultCodec })),
342
352
  { id: `${name}#subUsage/getMimoLoginStatus`, service: "subUsage", namespace: "subUsage", method: "getMimoLoginStatus", invocation: { kind: "direct" }, parameters: [], result: loginCodec },
343
353
  ...[["startMimoLogin", "MimoLoginStart", parseLoginStart], ["cancelMimoLogin", "MimoLoginCancel", parseLoginCancel]].map(([method, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", codec: codec(type, parse) }], result: loginCodec }))
@@ -353,13 +363,17 @@ export class SubUsageService extends TypertRemoteService {
353
363
  this.now = options.now ?? Date.now;
354
364
  this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
355
365
  this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
366
+ // 额外账户按凭据引用名(apiKeyEnv)解析,与提供方插件 slots()/resolveRef 同一通道;
367
+ // 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
368
+ this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
369
+ this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
356
370
  this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
357
371
  this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
358
372
  this.login = new MimoLogin({
359
373
  error: failure,
360
374
  capture: revision => { const s = this.load(); if (revision !== hash(s)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取"); return this.mimoMaterial(s); },
361
375
  verify: (cookie, signal) => { parseSettings({ providerId: "xiaomi-token-plan-cn", cookieUpdate: { action: "replace", value: cookie } }); return this.fetchProvider("xiaomi-token-plan-cn", { key: cookie, source: "cookie" }, this.load(), signal); },
362
- commit: (cookie, captured, verified) => this.commitMimoLogin(cookie, captured, verified),
376
+ commit: (cookie, captured, verified, observedExpiresAt) => this.commitMimoLogin(cookie, captured, verified, observedExpiresAt),
363
377
  reconcileResult: result => {
364
378
  const latest = this.load();
365
379
  if (this.disposed || this.mimoMaterial(latest) !== this.loginResultMaterial) return null;
@@ -375,9 +389,19 @@ export class SubUsageService extends TypertRemoteService {
375
389
  catch (e) { if (e?.code === "ENOENT") return defaultSettings(); throw failure("subusage/config", "无法读取订阅设置"); }
376
390
  }
377
391
  result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
378
- async identity(id, s) {
392
+ async identity(id, s, account) {
379
393
  if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
380
394
  if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
395
+ if (id === "commandcode" && account && account !== "default") {
396
+ // 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
397
+ // 插件的 slots,但 client 输入不可信)按未配置处理,不让 credentialRef 抛错。
398
+ if (!isCredentialRefName(account)) return { key: undefined, source: "none" };
399
+ const hit = await this.accountCredentials(account);
400
+ if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
401
+ const env = await this.accountEnvironment(account);
402
+ if (typeof env?.value === "string" && env.value.trim()) return { key: env.value.trim(), source: "env" };
403
+ return { key: undefined, source: "none" };
404
+ }
381
405
  const hit = await this.credentials(id);
382
406
  if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
383
407
  const env = await this.environment(id);
@@ -428,19 +452,27 @@ export class SubUsageService extends TypertRemoteService {
428
452
  if (usageError) data.usageError = usageError;
429
453
  return data;
430
454
  }
431
- base(id, source) { return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false }; }
432
- async provider(id, settings, force) {
455
+ base(id, source, settings, account = "") {
456
+ // MiMo 会话 Cookie 自签发起 24 小时有效;到期时间随每条 entry 下发,供药丸与设置页倒计时。
457
+ const expiresAt = id === "xiaomi-token-plan-cn" ? isoStamp(settings?.xiaomi?.expiresAt) : null;
458
+ // commandcode 记录本条用量所属账户(default 或额外账户引用名),供弹层标注与测试核对。
459
+ const shown = id === "commandcode" ? { account: account !== "" ? account : "default" } : {};
460
+ return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false, ...shown, ...(expiresAt ? { cookieExpiresAt: expiresAt } : {}) };
461
+ }
462
+ async provider(id, settings, force, account = "") {
433
463
  const startedGeneration = this.generations.get(id) ?? 0;
434
464
  let identity;
435
- try { identity = await this.identity(id, settings); }
436
- catch { this.cache.delete(id); return { ...this.base(id, "none"), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
465
+ try { identity = await this.identity(id, settings, account); }
466
+ catch { this.cache.delete(id); return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
437
467
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
438
- if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force);
468
+ if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force, account);
439
469
  if (!settings.visibility.providers[id]) {
440
470
  this.cache.delete(id); this.inflight.delete(id);
441
- return { ...this.base(id, identity.source), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
471
+ return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
442
472
  }
443
- const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null]);
473
+ // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
474
+ // 空串与 "default" 同为默认账户,归一后共享缓存。
475
+ const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
444
476
  if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
445
477
  this.fingerprints.set(id, fingerprint);
446
478
  const generation = this.generations.get(id) ?? 0;
@@ -453,8 +485,8 @@ export class SubUsageService extends TypertRemoteService {
453
485
  const resetDue = previous?.entry.windows?.some(w => Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > previous.at && Date.parse(w.resetsAt) <= now);
454
486
  if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
455
487
  const promise = (async () => {
456
- const base = this.base(id, identity.source); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
457
- if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)" : "未配置 API Key", errorCode: "subusage/no-key" };
488
+ const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
489
+ if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? (account && account !== "default" ? `未检测到账户 ${account} 的 Key:确认提供方插件已保存该账户的凭据(凭据服务或启动环境中无引用 ${account})` : "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)") : "未配置 API Key", errorCode: "subusage/no-key" };
458
490
  else try {
459
491
  const data = await this.fetchProvider(id, identity, settings);
460
492
  const usageError = data.usageError; delete data.usageError;
@@ -470,41 +502,41 @@ export class SubUsageService extends TypertRemoteService {
470
502
  if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
471
503
  }
472
504
  if (!this.disposed && generation === (this.generations.get(id) ?? 0)) this.cache.set(id, { fingerprint, entry, at: this.now(), retryAt, failures });
473
- if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
505
+ if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
474
506
  return entry;
475
507
  })();
476
508
  this.inflight.set(id, { fingerprint, generation, promise });
477
509
  try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
478
510
  }
479
- async read() { return this.refresh({ providerIds: IDS, force: false }); }
511
+ async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
480
512
  async refresh(request) {
481
513
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
482
514
  const query = parseQuery(request); const settings = this.load();
483
- const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force).catch(() => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
515
+ const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force, query.commandCodeAccount).catch(() => ({ ...this.base(id, "none", settings, query.commandCodeAccount), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
484
516
  const latest = this.load();
485
- if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
517
+ if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none", latest, query.commandCodeAccount), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
486
518
  return this.result(latest, entries);
487
519
  }
488
520
  startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
489
521
  getMimoLoginStatus() { return this.login.status(); }
490
522
  cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
491
523
  mimoMaterial(s) { const id = "xiaomi-token-plan-cn"; return hash([s.xiaomi.cookie, s.keys[id] ?? "", s.keyModes[id]]); }
492
- commitMimoLogin(cookie, captured, verified) {
524
+ commitMimoLogin(cookie, captured, verified, observedExpiresAt) {
493
525
  const id = "xiaomi-token-plan-cn", s = this.load();
494
526
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
495
527
  if (this.mimoMaterial(s) !== captured) throw failure("subusage/revision-conflict", "MiMo 设置已被修改,拒绝覆盖");
496
- this.persist({ providerId: id, expectedRevision: hash(s), cookieUpdate: { action: "replace", value: cookie } });
528
+ this.persist({ providerId: id, expectedRevision: hash(s), cookieUpdate: { action: "replace", value: cookie } }, observedExpiresAt);
497
529
  const stored = this.load(); const data = { ...verified }; const usageError = data.usageError; delete data.usageError;
498
- const entry = { ...this.base(id, "cookie"), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
530
+ const entry = { ...this.base(id, "cookie", stored), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
499
531
  // 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
500
532
  const safeEntry = redactMimo(entry, cookie);
501
- const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null]);
533
+ const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null, null]);
502
534
  this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
503
535
  this.loginResultMaterial = this.mimoMaterial(stored);
504
536
  return this.result(stored, [safeEntry]);
505
537
  }
506
538
  async save(settings) { return this.persist(settings); }
507
- persist(settings) {
539
+ persist(settings, cookieCap) {
508
540
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
509
541
  const patch = parseSettings(settings); const stored = this.load();
510
542
  if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
@@ -517,7 +549,14 @@ export class SubUsageService extends TypertRemoteService {
517
549
  if (patch.keyMode !== undefined) stored.keyModes[id] = patch.keyMode;
518
550
  const update = (obj, key, op) => { if (op?.action === "replace") obj[key] = op.value; else if (op?.action === "clear") delete obj[key]; };
519
551
  update(stored.keys, id, patch.keyUpdate);
520
- if (patch.cookieUpdate?.action === "clear") stored.xiaomi.cookie = ""; else update(stored.xiaomi, "cookie", patch.cookieUpdate);
552
+ if (patch.cookieUpdate?.action === "clear") { stored.xiaomi.cookie = ""; stored.xiaomi.loginAt = 0; stored.xiaomi.expiresAt = 0; } else if (patch.cookieUpdate?.action === "replace") {
553
+ // 官方会话 Cookie 自签发起 24 小时有效;浏览器报告了更早的过期时间时以更早者为准。
554
+ // 计时只在真正写入新 Cookie 时起算:keep 是“未改动”,不能借保存动作续满倒计时。
555
+ update(stored.xiaomi, "cookie", patch.cookieUpdate);
556
+ const at = this.now();
557
+ stored.xiaomi.loginAt = at;
558
+ stored.xiaomi.expiresAt = Math.min(at + MIMO_COOKIE_TTL_MS, Number.isSafeInteger(cookieCap) && cookieCap > at ? cookieCap : Number.POSITIVE_INFINITY);
559
+ }
521
560
  if (patch.zai) stored.zai = patch.zai;
522
561
  try { this.io.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); this.io.writeFileSync(`${this.path}.tmp`, JSON.stringify(stored, null, 2), { encoding: "utf8", mode: 0o600 }); this.io.chmodSync(`${this.path}.tmp`, 0o600); this.io.renameSync(`${this.path}.tmp`, this.path); }
523
562
  catch { throw failure("subusage/config", "无法保存订阅设置"); }
package/lib/mimo-login.js CHANGED
@@ -5,7 +5,8 @@ export const MIMO_API_URLS = ["balance", "tokenPlan/detail", "tokenPlan/usage"].
5
5
  const ACTIVE = new Set(["launching", "waiting", "verifying"]);
6
6
  const REQUIRED = ["api-platform_serviceToken", "userId"];
7
7
  // Playwright 已按 URL 过滤;再防御性校验每一个必需 Cookie 对全部验证 URL 有效。
8
- export function extractMimoCookie(cookies, now = Date.now()) {
8
+ // 除 Cookie 串外一并给出平台声明的过期时刻(毫秒);会话 Cookie 无 expires,返回 0 由 24 小时上限兜底。
9
+ export function extractMimoSession(cookies, now = Date.now()) {
9
10
  const selected = REQUIRED.map(name => cookies.filter(c => c.name === name && typeof c.value === "string" && c.value && (c.expires === undefined || c.expires === -1 || c.expires * 1000 > now) && MIMO_API_URLS.every(raw => {
10
11
  const u = new URL(raw); const domain = (c.domain ?? "").replace(/^\./, "").toLowerCase();
11
12
  const allowed = ["platform.xiaomimimo.com", "xiaomimimo.com"].includes(domain);
@@ -14,8 +15,10 @@ export function extractMimoCookie(cookies, now = Date.now()) {
14
15
  return domainOK && (u.pathname === path || u.pathname.startsWith(path.endsWith("/") ? path : `${path}/`));
15
16
  })));
16
17
  if (selected.some(rows => rows.length !== 1)) return null;
17
- return selected.map(rows => `${rows[0].name}=${rows[0].value}`).join("; ");
18
+ const declared = selected.map(rows => rows[0].expires).filter(expires => Number.isFinite(expires) && expires > 0);
19
+ return { cookie: selected.map(rows => `${rows[0].name}=${rows[0].value}`).join("; "), expiresAt: declared.length ? Math.min(...declared) * 1000 : 0 };
18
20
  }
21
+ export const extractMimoCookie = (cookies, now = Date.now()) => extractMimoSession(cookies, now)?.cookie ?? null;
19
22
  const sleep = (ms, signal) => new Promise(resolve => {
20
23
  if (signal.aborted) return resolve();
21
24
  const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
@@ -87,19 +90,19 @@ export class MimoLogin {
87
90
  phase = "verify";
88
91
  let nextAttempt = 0, failures = 0;
89
92
  while (this.alive(job)) {
90
- const cookie = extractMimoCookie(await context.cookies(MIMO_API_URLS), this.now());
93
+ const session = extractMimoSession(await context.cookies(MIMO_API_URLS), this.now());
91
94
  if (!this.alive(job)) return;
92
- if (cookie && this.now() >= nextAttempt) {
95
+ if (session && this.now() >= nextAttempt) {
93
96
  job.public.state = "verifying"; job.public.message = "正在验证登录和订阅额度(尚未保存)";
94
97
  try {
95
- const verified = await this.callbacks.verify(cookie, job.controller.signal);
98
+ const verified = await this.callbacks.verify(session.cookie, job.controller.signal);
96
99
  if (!this.alive(job)) return;
97
100
  if (verified.usageError || !verified.balanceOk) {
98
101
  job.public.state = "waiting"; job.public.message = "已读取账户,但订阅额度尚未完整验证;尚未保存,请确认套餐或继续登录";
99
102
  nextAttempt = this.now() + 15000;
100
103
  } else {
101
104
  // commit 必须同步检查冲突并写入,不能在取消后写入。
102
- const result = this.callbacks.commit(cookie, job.captured, verified);
105
+ const result = this.callbacks.commit(session.cookie, job.captured, verified, session.expiresAt);
103
106
  this.finish(job, "success", undefined, verified.coverage === "complete" && verified.windows?.length ? "登录及订阅额度已验证,Cookie 已保存" : "登录已保存;仅余额可读,订阅额度未知", result); return;
104
107
  }
105
108
  } catch (e) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.7.0",
4
+ "version": "0.8.2",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"