diff --git a/README.md b/README.md
index bb3c574..75bd8a9 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques
| MiniApp | What it does | Author |
| --- | --- | --- |
+| [蹬王之王 / Expiry Sentinel](plugins/hylouis233/expiry-sentinel/) | Track MiniMax Code credit and reset card expiry, then automatically spend whichever lapses first | [hylouis233](https://github.com/hylouis233) |
| [Token Usage Board](plugins/amszuidas/mcode-token-usage-board/) | Explore local Token usage by date, model, and session, including input, output, and cache usage | [amszuidas](https://github.com/amszuidas) |
| [Token Usage Board](plugins/yanhy2000/mcode-usage-monitor/) | Watch local Token usage, output speed, and cache hit rate in near real time; filter by time range, model, and session | [yanhy2000](https://github.com/yanhy2000) |
| [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 241b298..c0f493a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -60,6 +60,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求
| MiniApp | 功能 | 作者 |
| --- | --- | --- |
+| [蹬王之王](plugins/hylouis233/expiry-sentinel/README.zh-CN.md) | 追踪 MiniMax Code 积分与重置卡的到期时间,自动按「先过期先用」消耗 | [hylouis233](https://github.com/hylouis233) |
| [Token 用量看板](plugins/amszuidas/mcode-token-usage-board/README.zh-CN.md) | 按时间、模型和会话查看本机 Token 用量,包含输入、输出和缓存用量 | [amszuidas](https://github.com/amszuidas) |
| [Token 用量看板](plugins/yanhy2000/mcode-usage-monitor/README.zh-CN.md) | 近实时查看本机 Token 用量、输出速度与缓存命中率,可按时间范围、模型和会话筛选 | [yanhy2000](https://github.com/yanhy2000) |
| [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) |
diff --git a/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json b/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json
new file mode 100644
index 0000000..d11db59
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/.minimax-plugin/plugin.json
@@ -0,0 +1,18 @@
+{
+ "schemaVersion": 1,
+ "name": "expiry-sentinel",
+ "displayName": "蹬王之王",
+ "version": "1.0.0",
+ "description": "实时追踪 MiniMax Code 积分与重置卡的到期时间,自动按「先过期先用」决定用卡还是留积分,榨干最后一点算力。",
+ "author": "hylouis233",
+ "icon": "icon.png",
+ "category": "Productivity",
+ "exampleQueries": [
+ "打开蹬王之王",
+ "看看我的积分和重置卡什么时候过期",
+ "我的额度快用完了,该用重置卡还是留着?"
+ ],
+ "apps": [],
+ "mcpServers": [],
+ "skills": []
+}
diff --git a/plugins/hylouis233/expiry-sentinel/LICENSE b/plugins/hylouis233/expiry-sentinel/LICENSE
new file mode 100644
index 0000000..1c08e39
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hylouis233
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/hylouis233/expiry-sentinel/README.md b/plugins/hylouis233/expiry-sentinel/README.md
new file mode 100644
index 0000000..b1e7d2b
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/README.md
@@ -0,0 +1,147 @@
+
+
+
+
+# 蹬王之王 · Expiry Sentinel
+
+**Track what expires first, then spend exactly that.**
+
+English | [简体中文](README.zh-CN.md)
+
+[](LICENSE)
+
+
+
+---
+
+MiniMax Code credits and reset cards both expire on a timer. Left alone, the
+shortest-dated balance quietly evaporates while you keep using the one that
+lasts longest. **蹬王之王** shows every balance with its expiry date, then spends
+whichever runs out first — so nothing is wasted and nothing expires unused.
+
+## Screenshot
+
+
+
+
+
+The header answers the only question that matters: *how much is about to expire?*
+Below it, the 5-hour quota, total credit balance, the next expiry, and how many
+reset cards are left.
+
+
+
+
+
+Credit detail, reset cards by expiry, and platform credit pools each get their own
+table, sorted by the date they lapse.
+
+> **Note on the screenshots.** They were captured from a real account, so the
+> balances, dates, and totals in them are genuine numbers rather than samples.
+> No card IDs are shown anywhere in the UI.
+
+## How the decision works
+
+When the 5-hour or weekly quota runs out, requests start spending credits. That is
+the moment to act, and the app compares two dates:
+
+| Situation | Decision | Why |
+| --- | --- | --- |
+| Reset card expires **before** the oldest credit | **Spend the card** | Restores the quota, so the longer-lived credit is preserved |
+| Credit expires **before** the card | **Keep the card** | Let the credit lapse first; the card still has value later |
+| Quota not exhausted | **Do nothing** | No credits are being burned yet |
+
+Both the Node service and the background daemon implement this rule, and a
+parity test compares them character by character so the two cannot drift apart.
+
+## Install
+
+Copy this entire directory — including the hidden `.minimax-plugin` directory —
+into your MiniMax Code data directory. By default that is `.minimax` in your home
+folder, so the default path is:
+
+```text
+/plugins/expiry-sentinel/
+```
+
+If you configured a different data directory, use that instead. Restart MiniMax
+Code, confirm the plugin is recognized and enabled, then open **蹬王之王**, or ask
+the Agent to open it.
+
+## Use it
+
+**Watch only (default).** Open the board and read it. Nothing is spent.
+
+**Auto-spend inside the page.** Turn on 自动执行 and pick an interval (seconds or
+minutes). The page decides and spends on that interval while it stays open.
+「立即判定一次」 always triggers one check immediately, with no throttle.
+
+**Auto-spend around the clock.** Turn on 后台服务. This installs a launchd agent
+that keeps running with the page closed, and starts at login on macOS. The page
+shows its PID and lets you stop it.
+
+Both paths share one decision rule, and the server deduplicates by idempotency
+key, so a card is never spent twice even if both are active.
+
+## Background daemon (optional)
+
+The `daemon/` directory contains a Rust rewrite of the same decision logic,
+built for low overhead while it runs unattended:
+
+```bash
+cd daemon
+cargo build --release
+```
+
+It needs a token. It obtains one through the desktop app's OAuth lease socket
+(`~/.minimax/run/mcode-auth-lease-v1.sock`), so it must run while MiniMax Code is
+running. Set `MCODE_TOKEN` to use a fixed token instead.
+
+Typical resident memory is under 1 MB, and it polls one endpoint per cycle and
+caches the rest by how often each changes.
+
+## Tested environment
+
+- MiniMax Code desktop app on macOS (Apple Silicon)
+- Verified against a live account: quota polling, credit detail, card listing,
+ card spending, and the auto-spend path that fired on quota exhaustion
+- Cross-platform behaviour and other client versions are **unconfirmed** — the
+ daemon path uses launchd and has only been exercised on macOS
+- The endpoints are private APIs. They may change without notice, and the app
+ will report a parse or read error rather than invent data when they do
+
+## Data & access
+
+**Files read.** The desktop app's OAuth lease capability file
+(`~/.minimax/run/mcode-auth-lease-v1.cap`) and the lease socket beside it, to
+obtain the same session the desktop app uses. These paths are an internal
+implementation detail of the client, not a documented interface; the background
+daemon will stop working if they change.
+
+**Files written.**
+
+- `~/.config/mcode-reset-card/token` — only when you provide `MCODE_TOKEN`
+- `~/.config/mcode-reset-card/bg-state.json` — the daemon's record of which cards
+ it already attempted, so a restart cannot spend the same card twice
+- `~/Library/LaunchAgents/com.local.mcode-reset-card.plist` — written only when you
+ enable 后台服务
+
+**Network hosts.** Two, both MiniMax:
+
+- `https://www.minimaxi.com` — quota, platform credit pools, reset cards
+- `https://agent.minimaxi.com` — MiniMax Code credit detail, membership summary
+
+**Processes spawned.** `launchctl load` / `unload` for the background service,
+and `launchctl list` to read its status. Nothing else.
+
+**Agent turns.** None. The app never invokes the Agent.
+
+**Secrets.** The session token lives only in the Node process and the daemon.
+It is never placed in HTML, client JavaScript, logs, or API responses.
+
+**Spending is a real write.** Enabling auto-spend spends real reset cards on your
+account. Start with the dashboard in watch-only mode.
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/plugins/hylouis233/expiry-sentinel/README.zh-CN.md b/plugins/hylouis233/expiry-sentinel/README.zh-CN.md
new file mode 100644
index 0000000..fa7500b
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/README.zh-CN.md
@@ -0,0 +1,128 @@
+
+
+
+
+# 蹬王之王 · Expiry Sentinel
+
+**看清谁先过期,就先用哪一个。**
+
+[English](README.md) | 简体中文
+
+[](LICENSE)
+
+
+
+---
+
+MiniMax Code 的积分和重置卡都会到期。放着不管,最快到期的那笔会悄悄作废,
+而你一直在用最耐放的那笔。**蹬王之王**把每笔余额和到期日摊开给你看,然后
+「先过期先用」——不浪费,也不过期。
+
+## 界面
+
+
+
+
+
+顶部只回答一个最要紧的问题:**有多少快要过期了**。下面是 5 小时限额、积分总余额、
+最早到期日和剩余重置卡数量。
+
+
+
+
+
+积分明细、重置卡、平台积分池各一张表,全部按到期日排序。
+
+> **关于截图**:截图取自真实账号,因此其中的余额、日期与合计都是真实数据,
+> 并非示意样例。界面任何位置都不展示卡号。
+
+## 决策规则
+
+5 小时或周限额耗尽时,请求开始扣积分——这就是该动手的时刻。应用会比两个日期:
+
+| 情况 | 决策 | 原因 |
+| --- | --- | --- |
+| 重置卡比最早到期的积分**更早**过期 | **用卡** | 重置额度,保住更耐放的积分 |
+| 积分比重置卡**更早**过期 | **留卡** | 让积分先消耗掉,卡留到以后更有价值 |
+| 额度未耗尽 | **不动** | 还没在烧积分 |
+
+Node 服务和后台守护进程实现同一套规则,并有逐字比对的一致性校验,防止两边漂移。
+
+## 安装
+
+把整个目录(含隐藏的 `.minimax-plugin` 目录)复制到 MiniMax Code 的数据目录。
+默认为用户目录下的 `.minimax`,即:
+
+```text
+/plugins/expiry-sentinel/
+```
+
+如果你配置了其他数据目录,请用那个目录。重启 MiniMax Code,确认插件已被识别并启用,
+然后打开 **蹬王之王**,也可以直接让 Agent 帮你打开。
+
+## 使用
+
+**只看不动(默认)。** 打开看板读数据,不消耗任何东西。
+
+**页面内自动执行。** 打开「自动执行」并设置间隔(秒或分钟)。页面开着时按间隔
+判定并执行。「立即判定一次」可随时手动触发,无节流。
+
+**全天候后台执行。** 打开「后台服务」。它会安装一个 launchd 代理,关掉页面也在跑,
+macOS 上开机自启。页面上能看到它的 PID,也可以随时停掉。
+
+两条路径共用同一套决策规则,服务端按幂等键去重——即使同时开着,卡也不会被扣两次。
+
+## 后台守护进程(可选)
+
+`daemon/` 里是同一套决策逻辑的 Rust 版本,为长时间无人值守而写:
+
+```bash
+cd daemon
+cargo build --release
+```
+
+它需要 token:通过桌面端的 OAuth lease socket
+(`~/.minimax/run/mcode-auth-lease-v1.sock`)获取,也就是说 MiniMax Code 需要在运行。
+也可以设置 `MCODE_TOKEN` 使用固定凭据。
+
+实测常驻内存不到 1 MB,每轮只请求一个接口,其余按各自变化频率缓存。
+
+## 测试环境
+
+- macOS(Apple Silicon)上的 MiniMax Code 桌面端
+- 已在真实账号验证:额度轮询、积分明细、卡列表、扣卡,以及额度耗尽时触发的自动执行
+- **未验证**:跨平台表现与其他客户端版本——守护进程走 launchd,目前只在 macOS 上跑过
+- 所用接口是私有接口,可能随时变化;接口变化时应用会如实报错,不会编造数据
+
+## 数据与访问
+
+**读取的文件。** 桌面端的 OAuth lease capability 文件
+(`~/.minimax/run/mcode-auth-lease-v1.cap`)及其同目录的 lease socket,
+用于获取与桌面端相同的登录态。这些路径是客户端内部实现细节,并非公开接口;
+一旦变化,后台守护进程会失效。
+
+**写入的文件。**
+
+- `~/.config/mcode-reset-card/token` —— 仅当你提供 `MCODE_TOKEN` 时
+- `~/.config/mcode-reset-card/bg-state.json` —— 守护进程记录已尝试过的卡,
+ 避免重启后重复扣同一张
+- `~/Library/LaunchAgents/com.local.mcode-reset-card.plist` —— 仅在启用「后台服务」时写入
+
+**网络地址。** 两个,均为 MiniMax:
+
+- `https://www.minimaxi.com` —— 额度、平台积分池、重置卡
+- `https://agent.minimaxi.com` —— MiniMax Code 积分明细、会员汇总
+
+**启动的进程。** 仅 `launchctl load` / `unload`(后台服务)与 `launchctl list`(读状态)。
+
+**Agent 调用。** 无。本应用从不调用 Agent。
+
+**凭据。** 会话 token 只存在于 Node 进程和守护进程内存中,绝不进入 HTML、
+客户端 JavaScript、日志或 API 响应。
+
+**扣卡是真实写操作。** 开启自动执行会在你的账号上真实消耗重置卡。
+建议先用只读模式熟悉一下。
+
+## 许可证
+
+MIT —— 见 [LICENSE](LICENSE)。
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock
new file mode 100644
index 0000000..5c0dc4d
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.lock
@@ -0,0 +1,833 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cookie"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+ "zlib-rs",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "litrs"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "reset-card-daemon"
+version = "1.0.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "ureq",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+dependencies = [
+ "base64",
+ "cookie_store",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml
new file mode 100644
index 0000000..73b8afd
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "reset-card-daemon"
+version = "1.0.0"
+edition = "2024"
+
+[dependencies]
+# HTTP 客户端:手写 TLS 容易出错且难维护,用成熟实现更可靠
+ureq = { version = "3", default-features = false, features = ["rustls", "json", "gzip"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[profile.release]
+opt-level = "z" # 体积优先:后台常驻服务不需要极致速度
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs
new file mode 100644
index 0000000..b9e7921
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/src/api.rs
@@ -0,0 +1,313 @@
+//! 账号 API 客户端 + OAuth lease token。
+//!
+//! 域名划分是实测结论,不是猜的:
+//! 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径 404 或返回 HTML)
+//! Code 积分 / 会员信息 → agent.minimaxi.com(platform 域返回非 JSON)
+
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+pub const BASE_CN: &str = "https://www.minimaxi.com";
+pub const BASE_GLOBAL: &str = "https://platform.minimax.io";
+pub const CODE_BASE: &str = "https://agent.minimaxi.com";
+
+const LEASE_SOCK: &str = ".minimax/run/mcode-auth-lease-v1.sock";
+const LEASE_CAP: &str = ".minimax/run/mcode-auth-lease-v1.cap";
+
+const TOKEN_TTL_MS: u64 = 30 * 60 * 1000;
+const TOKEN_REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000;
+
+pub fn now_ms() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .unwrap_or(0)
+}
+
+#[derive(Debug)]
+pub enum ApiError {
+ Unauthorized(String),
+ Network(String),
+ Business(String),
+}
+
+impl ApiError {
+ pub fn message(&self) -> &str {
+ match self {
+ ApiError::Unauthorized(m) | ApiError::Network(m) | ApiError::Business(m) => m,
+ }
+ }
+ pub fn is_unauthorized(&self) -> bool {
+ matches!(self, ApiError::Unauthorized(_))
+ }
+}
+
+impl std::fmt::Display for ApiError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.message())
+ }
+}
+
+fn home() -> PathBuf {
+ PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/".into()))
+}
+
+fn random_hex(nbytes: usize) -> String {
+ let mut buf = vec![0u8; nbytes];
+ if std::fs::File::open("/dev/urandom")
+ .and_then(|mut f| f.read_exact(&mut buf))
+ .is_err()
+ {
+ buf[..8].copy_from_slice(&now_ms().to_be_bytes());
+ }
+ buf.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// 向桌面端的 lease socket 取一个短期 access token。
+/// 协议:4 字节大端长度前缀 + JSON。详见 packages/oauth-lease-protocol。
+pub fn acquire_lease_token() -> Option {
+ let capability = std::fs::read_to_string(home().join(LEASE_CAP))
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())?;
+ let sock_path = home().join(LEASE_SOCK);
+ if !sock_path.exists() {
+ return None;
+ }
+
+ let payload = serde_json::json!({
+ "version": 1,
+ "requestId": random_hex(16),
+ "capability": capability,
+ "method": "lease",
+ "minValidityMs": 120_000i64,
+ })
+ .to_string();
+
+ let body = payload.as_bytes();
+ let mut frame = Vec::with_capacity(4 + body.len());
+ frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
+ frame.extend_from_slice(body);
+
+ let mut stream = UnixStream::connect(&sock_path).ok()?;
+ stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?;
+ stream.set_write_timeout(Some(Duration::from_secs(5))).ok()?;
+ stream.write_all(&frame).ok()?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).ok()?;
+ let size = u32::from_be_bytes(len_buf) as usize;
+ if size > 1 << 20 {
+ return None;
+ }
+ let mut buf = vec![0u8; size];
+ stream.read_exact(&mut buf).ok()?;
+
+ let resp: serde_json::Value = serde_json::from_slice(&buf).ok()?;
+ if resp.get("ok").and_then(|v| v.as_bool()) != Some(true) {
+ return None;
+ }
+ let result = resp.get("result")?;
+ if result.get("method").and_then(|v| v.as_str()) != Some("lease") {
+ return None;
+ }
+ result
+ .get("accessToken")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+}
+
+/// token 来源:环境变量 → 本地文件 → lease socket
+pub fn resolve_token() -> Option {
+ for env in ["MCODE_TOKEN", "MINIMAX_TOKEN"] {
+ if let Ok(v) = std::env::var(env) {
+ let t = v.trim().to_string();
+ if !t.is_empty() {
+ return Some(t);
+ }
+ }
+ }
+ if let Ok(t) = std::fs::read_to_string(home().join(".config/mcode-reset-card/token")) {
+ let t = t.trim().to_string();
+ if !t.is_empty() {
+ return Some(t);
+ }
+ }
+ acquire_lease_token()
+}
+
+/// ureq Agent 自带 keep-alive 连接池:后台每轮打多个 API,
+/// 复用连接省掉每轮的 TLS 握手 —— 这是常驻进程最大的一笔固定开销。
+pub struct Client {
+ agent: ureq::Agent,
+ token: String,
+ cached_at: u64,
+}
+
+impl Client {
+ pub fn new(base: &str) -> Result {
+ let token = resolve_token().ok_or_else(|| {
+ ApiError::Unauthorized(
+ "未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录".into(),
+ )
+ })?;
+
+ let config = ureq::Agent::config_builder()
+ .timeout_global(Some(Duration::from_secs(20)))
+ .timeout_connect(Some(Duration::from_secs(5)))
+ .max_idle_connections(8)
+ .build();
+ let agent: ureq::Agent = config.into();
+
+ // 用一个明确的 base 做首连校验,确保 base 可用
+ let c = Client { agent, token, cached_at: now_ms() as u64 };
+ let _ = base;
+ Ok(c)
+ }
+
+ fn token(&mut self) -> String {
+ let age = (now_ms() as u64).saturating_sub(self.cached_at);
+ if age >= TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS {
+ if let Some(t) = acquire_lease_token() {
+ self.token = t;
+ self.cached_at = now_ms() as u64;
+ }
+ }
+ self.token.clone()
+ }
+
+ fn base_for(&self, code_domain: bool) -> &'static str {
+ if code_domain { CODE_BASE } else { BASE_CN }
+ }
+
+ pub fn get(&mut self, path: &str, code_domain: bool) -> Result {
+ self.do_request("GET", path, None, code_domain)
+ }
+
+ pub fn post(
+ &mut self,
+ path: &str,
+ body: &serde_json::Value,
+ code_domain: bool,
+ ) -> Result {
+ self.do_request("POST", path, Some(body), code_domain)
+ }
+
+ fn do_request(
+ &mut self,
+ method: &str,
+ path: &str,
+ body: Option<&serde_json::Value>,
+ code_domain: bool,
+ ) -> Result {
+ let base = self.base_for(code_domain);
+ let url = format!("{base}{path}");
+
+ // 鉴权失败重取 token 再试一次
+ for attempt in 0..2 {
+ let token = self.token();
+ let auth = format!("Bearer {token}");
+ let accept = "application/json";
+ let ua = "MiniMaxCode";
+
+ let result = match (method, body) {
+ ("GET", None) => self
+ .agent
+ .get(&url)
+ .header("Authorization", &auth)
+ .header("Accept", accept)
+ .header("User-Agent", ua)
+ .call(),
+ ("POST", Some(b)) => self
+ .agent
+ .post(&url)
+ .header("Authorization", &auth)
+ .header("Accept", accept)
+ .header("User-Agent", ua)
+ .send_json(b.clone()),
+ _ => return Err(ApiError::Network(format!("不支持的请求 {method}"))),
+ };
+
+ match result {
+ Ok(resp) => {
+ let text = resp
+ .into_body()
+ .read_to_string()
+ .map_err(|e| ApiError::Network(format!("读取响应体失败: {e}")))?;
+ let value: serde_json::Value =
+ serde_json::from_str(&text).map_err(json_err)?;
+ return check_business(value);
+ }
+ Err(ureq::Error::StatusCode(401)) | Err(ureq::Error::StatusCode(403))
+ if attempt == 0 =>
+ {
+ self.cached_at = 0;
+ continue;
+ }
+ Err(ureq::Error::StatusCode(code)) => {
+ return Err(ApiError::Network(format!("HTTP {code}")));
+ }
+ Err(e) => return Err(ApiError::Network(format!("{e}"))),
+ }
+ }
+ Err(ApiError::Unauthorized("登录态失效".into()))
+ }
+}
+
+fn json_err(e: serde_json::Error) -> ApiError {
+ ApiError::Network(format!("JSON 解析失败: {e}"))
+}
+
+/// 检查 `base_resp.status_code`。
+///
+/// 这是踩过的坑:`{"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}}`
+/// 是**错误**,不是空结果。必须在这里拦下,否则会把接口错误读成「没有记录」。
+fn check_business(value: serde_json::Value) -> Result {
+ if let Some(code) = value
+ .get("base_resp")
+ .and_then(|b| b.get("status_code"))
+ .and_then(|v| v.as_i64())
+ {
+ if code != 0 {
+ let msg = value
+ .get("base_resp")
+ .and_then(|b| b.get("status_msg"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown")
+ .to_string();
+ if code == 1004 {
+ return Err(ApiError::Unauthorized(format!("{msg} (code={code})")));
+ }
+ return Err(ApiError::Business(format!("{msg} (code={code})")));
+ }
+ }
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn business_error_detected() {
+ let v = serde_json::json!({"total":0,"base_resp":{"status_code":2013,"status_msg":"invalid params"}});
+ match check_business(v) {
+ Err(ApiError::Business(m)) => assert!(m.contains("invalid params")),
+ other => panic!("应当识别为业务错误,实际 {other:?}"),
+ }
+ }
+
+ #[test]
+ fn success_passes_through() {
+ let v = serde_json::json!({"base_resp":{"status_code":0},"data":[1,2]});
+ assert!(check_business(v).is_ok());
+ }
+
+ #[test]
+ fn unauthorized_code() {
+ let v = serde_json::json!({"base_resp":{"status_code":1004,"status_msg":"not login"}});
+ assert!(matches!(check_business(v), Err(ApiError::Unauthorized(_))));
+ }
+}
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs
new file mode 100644
index 0000000..6c3861b
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/src/decision.rs
@@ -0,0 +1,408 @@
+//! 额度与到期判定。
+//!
+//! 这些分支与 Mini App「蹬王之王」的 `decide()` 一一对应,
+//! 由 `verify-parity.mjs` 逐字比对「动作 + 理由文本」来防止漂移。
+//! **改动任何一条理由文案,两侧都要同步改,并重跑校验器。**
+
+use serde::Deserialize;
+
+pub const QUOTA_FIVE_HOUR: i64 = 1;
+pub const QUOTA_WEEKLY: i64 = 2;
+pub const QUOTA_VIDEO: i64 = 3;
+
+/// 一个限额维度的用量。
+#[derive(Debug, Clone, Copy)]
+pub struct QuotaState {
+ pub quota_type: i64,
+ pub total: f64,
+ pub used: f64,
+ pub is_unlimited: bool,
+}
+
+impl QuotaState {
+ /// 是否已耗尽。不限量或总量为 0 都不算耗尽。
+ pub fn exhausted(&self) -> bool {
+ if self.is_unlimited || self.total <= 0.0 {
+ return false;
+ }
+ self.used >= self.total
+ }
+
+ pub fn remaining_percent(&self) -> f64 {
+ if self.is_unlimited || self.total <= 0.0 {
+ return 100.0;
+ }
+ ((self.total - self.used) / self.total * 100.0).max(0.0)
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self.quota_type {
+ QUOTA_FIVE_HOUR => "5小时限额",
+ QUOTA_WEEKLY => "周限额",
+ QUOTA_VIDEO => "视频限额",
+ _ => "未知限额",
+ }
+ }
+
+ pub fn describe(&self) -> String {
+ if self.is_unlimited {
+ return format!("{}=不限量", self.label());
+ }
+ format!(
+ "{}=已用{:.0}% / 剩余{:.0}%",
+ self.label(),
+ self.used,
+ self.remaining_percent()
+ )
+ }
+}
+
+/// 金额字符串(可能带千分位)转数字。
+pub fn amount(value: &serde_json::Value) -> f64 {
+ match value {
+ serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
+ serde_json::Value::String(s) => {
+ let cleaned: String = s.chars().filter(|c| *c != ',').collect();
+ cleaned.trim().parse::().unwrap_or(0.0)
+ }
+ _ => 0.0,
+ }
+}
+
+/// 百分数字符串(如 "71%")转数字。
+///
+/// 接口返回的就是 `"71%"` 这种带百分号的字符串,直接 `parse::()` 会失败并
+/// 静默退回 0,把「已用 71%」误判成「已用 0%」。这里必须先剥掉 `%`。
+pub fn percent(value: &serde_json::Value) -> f64 {
+ match value {
+ serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
+ serde_json::Value::String(s) => s.trim().trim_end_matches('%').parse::().unwrap_or(0.0),
+ _ => 0.0,
+ }
+}
+
+#[derive(Deserialize)]
+pub struct ModelRemains {
+ pub model_name: String,
+ #[serde(default)]
+ pub current_interval_total_percent: serde_json::Value,
+ #[serde(default)]
+ pub current_interval_used_percent: serde_json::Value,
+ #[serde(default)]
+ pub current_interval_status: i64,
+ #[serde(default)]
+ pub current_interval_total_count: f64,
+ #[serde(default)]
+ pub current_interval_usage_count: f64,
+ #[serde(default)]
+ pub current_weekly_total_percent: serde_json::Value,
+ #[serde(default)]
+ pub current_weekly_used_percent: serde_json::Value,
+ #[serde(default)]
+ pub current_weekly_status: i64,
+ #[serde(default)]
+ pub end_time: Option,
+}
+
+#[derive(Deserialize)]
+pub struct RemainsPayload {
+ #[serde(default)]
+ pub model_remains: Vec,
+}
+
+pub fn parse_quotas(payload: &RemainsPayload) -> Vec {
+ let mut out = Vec::with_capacity(3);
+
+ let general = payload
+ .model_remains
+ .iter()
+ .find(|m| m.model_name == "general")
+ .or_else(|| payload.model_remains.first());
+
+ if let Some(g) = general {
+ let total = {
+ let t = percent(&g.current_interval_total_percent);
+ if t > 0.0 { t } else { 100.0 }
+ };
+ out.push(QuotaState {
+ quota_type: QUOTA_FIVE_HOUR,
+ total,
+ used: percent(&g.current_interval_used_percent),
+ is_unlimited: g.current_interval_status == 3,
+ });
+ out.push(QuotaState {
+ quota_type: QUOTA_WEEKLY,
+ total: {
+ let t = percent(&g.current_weekly_total_percent);
+ if t > 0.0 { t } else { 100.0 }
+ },
+ used: percent(&g.current_weekly_used_percent),
+ is_unlimited: g.current_weekly_status == 3,
+ });
+ }
+
+ // 视频额度:只有非 general 且计数 > 0 的模型
+ if let Some(v) = payload
+ .model_remains
+ .iter()
+ .find(|m| m.model_name != "general" && m.current_interval_total_count > 0.0)
+ {
+ out.push(QuotaState {
+ quota_type: QUOTA_VIDEO,
+ total: v.current_interval_total_count,
+ used: v.current_interval_usage_count,
+ is_unlimited: v.current_interval_status == 3,
+ });
+ }
+
+ out
+}
+
+/// 一张可用于扣卡的重置卡。
+#[derive(Debug, Clone)]
+pub struct UsableCard {
+ pub card_id: String,
+ pub source_type: String,
+ pub out_biz_no: String,
+ pub expire_time: i64,
+}
+
+/// 扣卡决策的动作。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Action {
+ /// 用重置卡重置额度
+ UseCard,
+ /// 不用卡,让积分先消耗
+ KeepCredit,
+ /// 额度充足,不动手
+ Idle,
+ /// 既没积分也没卡,等着
+ Wait,
+}
+
+impl Action {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Action::UseCard => "use-card",
+ Action::KeepCredit => "keep-credit",
+ Action::Idle => "idle",
+ Action::Wait => "wait",
+ }
+ }
+}
+
+pub struct Decision {
+ pub action: Action,
+ pub reason: String,
+}
+
+/// 毫秒时间戳 → "10月25日"。与 Python 的 `_fmt_day` 同一时区口径。
+pub fn fmt_day(ms: Option) -> String {
+ let Some(ms) = ms else {
+ return "未知".to_string();
+ };
+ if ms == 0 {
+ return "未知".to_string();
+ }
+ // 本地时区;固定 UTC 会导致跨日错位,必须借 localtime_r
+ let (m, d) = local_md(ms.div_euclid(1000));
+ if m == 0 {
+ return "未知".to_string();
+ }
+ format!("{m}月{d}日")
+}
+
+/// 把 Unix 秒换算成本地时区的 (月, 日)。
+///
+/// 桌面端与 Python 版都用本地时区展示;这里手算避免引入 chrono 依赖。
+/// libc 的 `struct tm`,只需本地时区换算用到的字段。
+#[repr(C)]
+#[derive(Default)]
+struct Tm {
+ tm_sec: i32, tm_min: i32, tm_hour: i32, tm_mday: i32,
+ tm_mon: i32, tm_year: i32, tm_wday: i32, tm_yday: i32, tm_isdst: i32,
+ tm_gmtoff: i64, tm_zone: *const u8,
+}
+
+#[repr(C)]
+struct TimeT(i64);
+
+unsafe extern "C" {
+ fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm;
+}
+
+/// 把 Unix 秒换算成本地时区结构。
+fn secs_to_local(secs: i64) -> Option {
+ let t = TimeT(secs);
+ let mut tm = Tm::default();
+ unsafe {
+ if localtime_r(&t, &mut tm).is_null() {
+ return None;
+ }
+ }
+ Some(tm)
+}
+
+/// 本地时区的 (月, 日),供日志使用。
+pub fn local_md(secs: i64) -> (u32, u32) {
+ secs_to_local(secs)
+ .map(|t| (t.tm_mon as u32 + 1, t.tm_mday as u32))
+ .unwrap_or((0, 0))
+}
+
+/// 本地时区的 (时, 分)。
+pub fn local_hm(secs: i64) -> (u32, u32) {
+ secs_to_local(secs)
+ .map(|t| (t.tm_hour as u32, t.tm_min as u32))
+ .unwrap_or((0, 0))
+}
+
+/// 后台扣卡决策(纯函数,不触碰网络)。
+///
+/// 动作 ∈ {use-card, keep-credit, idle, wait}。
+///
+/// 分支与 Mini App 的 `decide()` 刻意同构:
+/// 未耗尽 → idle
+/// 耗尽 + 无积分 + 有卡 → use-card
+/// 耗尽 + 无积分 + 无卡 → wait
+/// 耗尽 + 有积分 + 无卡 → keep-credit
+/// 耗尽 + 都有,卡先过期 → use-card
+/// 耗尽 + 都有,积分先过期 → keep-credit
+pub fn decide(
+ quotas: &[QuotaState],
+ usable_cards: &[UsableCard],
+ credit_expiry_ms: Option,
+ credit_desc: &str,
+) -> Decision {
+ let five = quotas.iter().find(|q| q.quota_type == QUOTA_FIVE_HOUR);
+ let weekly = quotas.iter().find(|q| q.quota_type == QUOTA_WEEKLY);
+
+ let five_exhausted = five.is_some_and(|q| q.exhausted());
+ let weekly_exhausted = weekly.is_some_and(|q| q.exhausted());
+
+ // ── 未耗尽 ──
+ if !five_exhausted && !weekly_exhausted {
+ return Decision {
+ action: Action::Idle,
+ reason: "额度充足,暂不需要处理".to_string(),
+ };
+ }
+
+ // ── 无可用积分:只能靠卡 ──
+ let Some(credit_expiry) = credit_expiry_ms else {
+ if !usable_cards.is_empty() {
+ return Decision {
+ action: Action::UseCard,
+ reason: "没有可用积分,使用重置卡重置额度".to_string(),
+ };
+ }
+ return Decision {
+ action: Action::Wait,
+ reason: "额度已耗尽,既没有积分也没有可用的重置卡".to_string(),
+ };
+ };
+
+ // ── 有积分但没卡:让积分先消耗 ──
+ // 注意这一句带「到期」二字,与 Mini App / Python 的文案保持逐字一致。
+ // 改文案前务必跑 `node verify-parity.mjs`。
+ if usable_cards.is_empty() {
+ return Decision {
+ action: Action::KeepCredit,
+ reason: format!(
+ "没有可用的重置卡,让{credit_desc}({}到期)先消耗",
+ fmt_day(Some(credit_expiry))
+ ),
+ };
+ }
+
+ let card_expiry = usable_cards[0].expire_time;
+
+ // ── 两者都有:比「先过期先用」 ──
+ if card_expiry < credit_expiry {
+ return Decision {
+ action: Action::UseCard,
+ reason: format!(
+ "重置卡({})比{credit_desc}({})先过期,先用卡保住积分",
+ fmt_day(Some(card_expiry)),
+ fmt_day(Some(credit_expiry))
+ ),
+ };
+ }
+ Decision {
+ action: Action::KeepCredit,
+ reason: format!(
+ "{credit_desc}({})比重置卡({})先过期,不用卡,让积分先消耗",
+ fmt_day(Some(credit_expiry)),
+ fmt_day(Some(card_expiry))
+ ),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn q(t: i64, total: f64, used: f64, unlimited: bool) -> QuotaState {
+ QuotaState { quota_type: t, total, used, is_unlimited: unlimited }
+ }
+
+ fn c(days: i64) -> UsableCard {
+ UsableCard {
+ card_id: "C1".into(),
+ source_type: "mcode_signin".into(),
+ out_biz_no: "b1".into(),
+ expire_time: 1_700_000_000_000 + days * 86_400_000,
+ }
+ }
+
+ #[test]
+ fn percent_strings() {
+ assert_eq!(percent(&serde_json::json!("71%")), 71.0);
+ assert_eq!(percent(&serde_json::json!("100%")), 100.0);
+ assert_eq!(percent(&serde_json::json!(0)), 0.0);
+ assert_eq!(percent(&serde_json::json!("")), 0.0);
+ }
+
+ #[test]
+ fn amount_strings() {
+ assert_eq!(amount(&serde_json::json!("17400.00")), 17400.0);
+ assert_eq!(amount(&serde_json::json!("1,200.50")), 1200.5);
+ }
+
+ #[test]
+ fn exhausted_rules() {
+ assert!(!q(1, 100.0, 99.0, false).exhausted());
+ assert!(q(1, 100.0, 100.0, false).exhausted());
+ assert!(!q(1, 100.0, 100.0, true).exhausted(), "不限量不算耗尽");
+ assert!(!q(1, 0.0, 0.0, false).exhausted(), "总量为 0 不算耗尽");
+ }
+
+ #[test]
+ fn decide_branches() {
+ let five_ok = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 0.0, true)];
+ let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)];
+ let week_dead = vec![q(1, 100.0, 50.0, false), q(2, 100.0, 100.0, false)];
+ let credit = Some(1_700_000_000_000 + 13 * 86_400_000);
+
+ assert_eq!(decide(&five_ok, &[c(7)], credit, "平台积分").action, Action::Idle);
+ assert_eq!(decide(&five_dead, &[c(7)], credit, "平台积分").action, Action::UseCard);
+ assert_eq!(decide(&five_dead, &[], credit, "平台积分").action, Action::KeepCredit);
+ assert_eq!(decide(&five_dead, &[], None, "").action, Action::Wait);
+ assert_eq!(decide(&five_dead, &[c(7)], None, "").action, Action::UseCard);
+ assert_eq!(decide(&week_dead, &[c(7)], credit, "平台积分").action, Action::UseCard);
+ // 积分更早过期 → 不用卡
+ let credit_soon = Some(1_700_000_000_000 + 2 * 86_400_000);
+ assert_eq!(
+ decide(&five_dead, &[c(20)], credit_soon, "平台积分").action,
+ Action::KeepCredit
+ );
+ }
+
+ #[test]
+ fn reasons_are_filled() {
+ let five_dead = vec![q(1, 100.0, 100.0, false), q(2, 100.0, 0.0, true)];
+ let d = decide(&five_dead, &[], Some(1_700_000_000_000 + 13 * 86_400_000), "平台积分");
+ assert!(d.reason.contains("没有可用的重置卡"), "{}", d.reason);
+ assert!(d.reason.contains("平台积分"), "{}", d.reason);
+ }
+}
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs
new file mode 100644
index 0000000..8a4050f
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/src/main.rs
@@ -0,0 +1,491 @@
+//! 后台守护进程:额度探测 + 先过期先用决策 + 自动扣卡。
+//!
+//! 与 Mini App「蹬王之王」的判定逻辑严格同构,由 `verify-parity.mjs`
+//! 逐字比对「动作 + 理由文本」防止漂移。
+//!
+//! 设计要点:
+//! - 分层缓存:额度每轮取,积分/卡/汇总按各自变化速度缓存
+//! - 先记账再请求:崩溃或重启都不会重复扣同一张卡
+//! - 幂等键:用服务端下发的 out_biz_no,重试安全
+
+mod api;
+mod decision;
+mod state;
+
+use std::collections::{BTreeMap, HashSet};
+use std::time::Duration;
+
+use api::{Client, ApiError, BASE_CN};
+use decision::{
+ amount, decide, fmt_day, parse_quotas, Action, QuotaState, RemainsPayload, UsableCard,
+};
+use serde_json::json;
+
+/// 日志:带本地时间戳,便于 tail 追踪。
+fn log(msg: &str) {
+ use std::io::Write;
+ let now = api::now_ms();
+ let secs = now.div_euclid(1000);
+ let ms = now.rem_euclid(1000);
+ // 复用 decision 里的本地时区换算
+ let (month, day) = decision::local_md(secs);
+ let (hh, mm) = decision::local_hm(secs);
+ let _ = std::io::stdout().write_all(
+ format!("[{month:02}-{day:02} {hh:02}:{mm:02}.{ms:03}] {msg}\n").as_bytes(),
+ );
+}
+
+/// 各数据源的缓存 TTL(毫秒)。按变化速度分配,不是所有数据都值得每轮取。
+struct Ttl {
+ quota: i64,
+ code_credits: i64,
+ cards: i64,
+ platform: i64,
+}
+
+impl Default for Ttl {
+ fn default() -> Self {
+ // quota TTL=0:额度是所有联动的触发源,必须每轮看。
+ // 其余按「多久会变」给:积分随消耗变、卡按天签到变、平台池极少变。
+ Ttl { quota: 0, code_credits: 60_000, cards: 300_000, platform: 300_000 }
+ }
+}
+
+struct Cache {
+ slots: BTreeMap<&'static str, (Option, i64)>,
+}
+
+impl Cache {
+ fn new() -> Self {
+ Cache { slots: BTreeMap::new() }
+ }
+ fn get(&mut self, key: &'static str, ttl: i64) -> Option {
+ let entry = self.slots.get(key).cloned();
+ if let Some((Some(v), at)) = entry {
+ if api::now_ms() - at < ttl {
+ return Some(v);
+ }
+ }
+ None
+ }
+ fn put(&mut self, key: &'static str, value: T) {
+ self.slots.insert(key, (Some(value), api::now_ms()));
+ }
+ fn invalidate(&mut self, key: &str) {
+ self.slots.remove(key);
+ }
+}
+
+struct Caches {
+ quota: Cache,
+ code: Cache,
+ platform: Cache,
+ cards: Cache>,
+}
+
+impl Caches {
+ fn new() -> Self {
+ Caches {
+ quota: Cache::new(),
+ code: Cache::new(),
+ platform: Cache::new(),
+ cards: Cache::new(),
+ }
+ }
+}
+
+struct Daemon {
+ client: Client,
+ ttl: Ttl,
+ cache: Caches,
+}
+
+/// 取缓存:命中则零网络,未命中才请求。
+///
+/// 做成自由函数(而非 `&mut self` 方法):这样 `cache` 与 `client`
+/// 可以分别借用,不会触发「同一个 self 被可变借两次」。
+fn cached(
+ client: &mut Client,
+ cache: &mut Cache,
+ key: &'static str,
+ ttl: i64,
+ load: impl FnOnce(&mut Client) -> Result,
+) -> Result {
+ if let Some(v) = cache.get(key, ttl) {
+ return Ok(v);
+ }
+ let v = load(client)?;
+ cache.put(key, v.clone());
+ Ok(v)
+}
+
+impl Daemon {
+ fn new(client: Client) -> Self {
+ Daemon { client, ttl: Ttl::default(), cache: Caches::new() }
+ }
+
+ fn quotas(&mut self) -> Vec {
+ let ttl = self.ttl.quota;
+ let Daemon { client, cache, .. } = self;
+ match cached(client, &mut cache.quota, "quota", ttl, |c| {
+ c.get("/backend/account/token_plan/remains_percent", false)
+ }) {
+ Ok(v) => {
+ let payload: RemainsPayload = serde_json::from_value(v).unwrap_or(RemainsPayload {
+ model_remains: vec![],
+ });
+ parse_quotas(&payload)
+ }
+ Err(e) => {
+ log(&format!(" 额度读取失败(沿用上次): {e}"));
+ vec![]
+ }
+ }
+ }
+
+ /// 全部分页拉取重置卡
+ fn all_cards(&mut self) -> Vec {
+ let ttl = self.ttl.cards;
+ if let Some(v) = self.cache.cards.get("cards", ttl) {
+ return v;
+ }
+ let mut seen: HashSet = HashSet::new();
+ let mut all = Vec::new();
+ for page in 1..=10 {
+ match self
+ .client
+ .get(&format!("/backend/account/reset_card/list?page={page}&size=20"), false)
+ {
+ Ok(v) => {
+ let cards = v.get("cards").and_then(|c| c.as_array()).cloned().unwrap_or_default();
+ if cards.is_empty() {
+ break;
+ }
+ let n = cards.len();
+ for c in cards {
+ if let Some(id) = c.get("card_id").and_then(|x| x.as_str()) {
+ if seen.insert(id.to_string()) {
+ all.push(c);
+ }
+ }
+ }
+ if n < 20 {
+ break;
+ }
+ }
+ Err(e) => {
+ log(&format!(" 重置卡列表读取失败(忽略): {e}"));
+ break;
+ }
+ }
+ }
+ self.cache.cards.put("cards", all.clone());
+ all
+ }
+
+ /// 可用于扣卡的卡:未过期 + 有 out_biz_no + 有 source_type,按到期排序
+ fn usable_cards(&mut self) -> Vec {
+ let now = api::now_ms();
+ let mut out: Vec = self
+ .all_cards()
+ .iter()
+ .filter_map(|c| {
+ let expire = c.get("expire_time").and_then(|v| v.as_i64())?;
+ if expire <= now {
+ return None;
+ }
+ let out_biz_no = c.get("out_biz_no").and_then(|v| v.as_str()).unwrap_or("");
+ let source_type = c.get("source_type").and_then(|v| v.as_str()).unwrap_or("");
+ if out_biz_no.is_empty() || source_type.is_empty() {
+ return None;
+ }
+ Some(UsableCard {
+ card_id: c.get("card_id")?.as_str()?.to_string(),
+ source_type: source_type.to_string(),
+ out_biz_no: out_biz_no.to_string(),
+ expire_time: expire,
+ })
+ })
+ .collect();
+ out.sort_by_key(|c| c.expire_time);
+ out
+ }
+
+ /// 全账户里最早到期的一笔「仍有效且有余额」的积分(两个来源取更早的)
+ fn earliest_credit(&mut self) -> (Option, String) {
+ let now = api::now_ms();
+ let mut best: Option = None;
+ let mut desc = String::new();
+
+ let mut consider = |exp: Option, balance: f64, label: &str| {
+ let (Some(e), true) = (exp, balance > 0.0) else { return };
+ if e <= now {
+ return;
+ }
+ if best.is_none_or(|b| e < b) {
+ best = Some(e);
+ desc = label.to_string();
+ }
+ };
+
+ // 平台积分池
+ let pttl = self.ttl.platform;
+ let platform_result = {
+ let Daemon { client, cache, .. } = self;
+ cached(client, &mut cache.platform, "platform", pttl, |c| {
+ c.get("/backend/account/token_plan_credit", false)
+ })
+ };
+ match platform_result {
+ Ok(v) => {
+ if let Some(buckets) = v
+ .get("balance_breakdown")
+ .and_then(|b| b.get("buckets"))
+ .and_then(|b| b.as_array())
+ {
+ for b in buckets {
+ let label = match b.get("source").and_then(|s| s.as_i64()) {
+ Some(2) => "平台积分·充值".to_string(),
+ Some(3) => "平台积分·赠送".to_string(),
+ _ => "平台积分·未知".to_string(),
+ };
+ consider(
+ b.get("expire_time_ms").and_then(|v| v.as_i64()),
+ amount(b.get("balance").unwrap_or(&json!(0))),
+ &label,
+ );
+ }
+ }
+ }
+ Err(e) => log(&format!(" 平台积分读取失败(忽略): {e}")),
+ }
+
+ // Code 积分明细
+ let cttl = self.ttl.code_credits;
+ let raw = {
+ let Daemon { client, cache, .. } = self;
+ cached(client, &mut cache.code, "code", cttl, |c| {
+ c.get(
+ "/minimax-cloud/api/v1/credit/details?page_number=1&page_size=100&include_expired=1",
+ true,
+ )
+ })
+ };
+ match raw {
+ Ok(v) => {
+ if let Some(details) = v.get("details").and_then(|d| d.as_array()) {
+ for item in details {
+ let label = if item.get("credit_type").and_then(|c| c.as_i64()) == Some(2) {
+ "Code 积分·赠送"
+ } else {
+ "Code 积分·购买"
+ };
+ consider(
+ item.get("expire_at_ms").and_then(|v| v.as_i64()),
+ amount(item.get("remaining_amount").unwrap_or(&json!(0))),
+ label,
+ );
+ }
+ }
+ }
+ Err(e) => log(&format!(" Code 积分读取失败(忽略): {e}")),
+ }
+
+ (best, desc)
+ }
+
+ /// 一轮:判定 + 必要时扣卡
+ fn tick(&mut self, dry_run: bool) -> Result<(Action, String, bool), ApiError> {
+ let quotas = self.quotas();
+ let usable = self.usable_cards();
+ let (credit_expiry, credit_desc) = self.earliest_credit();
+
+ let d = decide("as, &usable, credit_expiry, &credit_desc);
+ log(&format!(
+ "额度: {}",
+ quotas.iter().map(|q| q.describe()).collect::>().join(" | ")
+ ));
+ log(&format!("决策[{}]: {}", d.action.as_str(), d.reason));
+
+ if d.action != Action::UseCard || dry_run {
+ return Ok((d.action, d.reason, false));
+ }
+ let Some(card) = usable.first() else {
+ return Ok((d.action, d.reason, false));
+ };
+
+ let fingerprint = format!("{}:{}", card.card_id, card.out_biz_no);
+ if state::was_spent(&fingerprint) {
+ log("这张卡本进程已尝试过,跳过");
+ return Ok((d.action, d.reason, false));
+ }
+
+ // 先记账再请求:崩溃/重启都不会重复扣
+ state::mark_spent(&fingerprint, &card.card_id, &card.source_type);
+ self.cache.cards.invalidate("cards");
+
+ match self
+ .client
+ .post(
+ "/backend/account/reset_card/use",
+ &json!({ "source_type": card.source_type, "out_biz_no": card.out_biz_no }),
+ false,
+ ) {
+ Ok(v) => {
+ let outcome = v.get("outcome").and_then(|x| x.as_str()).unwrap_or("UNKNOWN");
+ let ok = outcome == "SUCCESS" || outcome == "REPLAYED";
+ if ok {
+ log(&format!("✓ 已使用重置卡 {},额度已重置", card.card_id));
+ } else {
+ log(&format!("✗ 重置卡不可用({outcome})"));
+ }
+ Ok((d.action, d.reason, ok))
+ }
+ Err(e) => {
+ log(&format!("✗ 扣卡失败:{e}"));
+ Ok((d.action, d.reason, false))
+ }
+ }
+ }
+}
+
+/// 校验模式:读入与 Node/Python 同一份用例 JSON,打印同样的结果结构。
+///
+/// 供 `verify-parity.mjs` 调用,让三方(Mini App / Python / Rust)跑同一组输入。
+/// 不联网、不落盘,纯函数比对。
+fn selftest() {
+ use std::io::Read as _;
+ let mut input = String::new();
+ if std::io::stdin().read_to_string(&mut input).is_err() {
+ eprintln!("selftest: 无法读取 stdin");
+ std::process::exit(2);
+ }
+ let payload: serde_json::Value = match serde_json::from_str(&input) {
+ Ok(v) => v,
+ Err(e) => {
+ eprintln!("selftest: JSON 解析失败 {e}");
+ std::process::exit(2);
+ }
+ };
+ let now = payload["now"].as_i64().unwrap_or(0);
+ let day = payload["day"].as_i64().unwrap_or(86_400_000);
+ let mut out = Vec::new();
+
+ for c in payload["cases"].as_array().cloned().unwrap_or_default() {
+ let mk = |t: i64, used: f64, total: f64, unlimited: bool| QuotaState {
+ quota_type: t,
+ total,
+ used,
+ is_unlimited: unlimited,
+ };
+ let five_used = c["five"].as_f64().unwrap_or(0.0);
+ let week_used = c["week"].as_i64();
+ let quotas = vec![
+ mk(1, five_used, 100.0, false),
+ match week_used {
+ None => mk(2, 0.0, 100.0, true),
+ Some(w) => mk(2, w as f64, 100.0, false),
+ },
+ ];
+ let cards: Vec = c["cards"]
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|v| {
+ let d = v.as_i64()?;
+ Some(UsableCard {
+ card_id: format!("C{d}"),
+ source_type: "mcode_signin".into(),
+ out_biz_no: format!("b{d}"),
+ expire_time: now + d * day,
+ })
+ })
+ .collect();
+ let credit = c["credit"].as_i64().map(|d| now + d * day);
+
+ let d = decide("as, &cards, credit, "平台积分");
+ out.push(serde_json::json!({
+ "n": c["n"].as_str().unwrap_or(""),
+ "action": d.action.as_str(),
+ "reason": d.reason,
+ }));
+ }
+ println!("{}", serde_json::to_string(&out).unwrap_or_default());
+}
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ if args.iter().any(|a| a == "--selftest") {
+ selftest();
+ return;
+ }
+
+ let mut interval: f64 = 60.0;
+ let mut dry_run = false;
+ let mut cmd = "watch".to_string();
+
+ let mut i = 1;
+ while i < args.len() {
+ match args[i].as_str() {
+ "--interval" if i + 1 < args.len() => {
+ interval = args[i + 1].parse().unwrap_or(60.0);
+ i += 2;
+ }
+ "--base" if i + 1 < args.len() => i += 2,
+ "--dry-run" => {
+ dry_run = true;
+ i += 1;
+ }
+ "watch" | "once" | "status" | "forget" => {
+ cmd = args[i].clone();
+ i += 1;
+ }
+ _ => i += 1,
+ }
+ }
+
+ if cmd == "forget" {
+ state::clear_spent();
+ println!("已清空本地去重记录。");
+ return;
+ }
+
+ let client = match Client::new(BASE_CN) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("初始化失败:{e}");
+ std::process::exit(1);
+ }
+ };
+ let mut daemon = Daemon::new(client);
+
+ if cmd == "once" {
+ match daemon.tick(dry_run) {
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!("失败:{e}");
+ std::process::exit(1);
+ }
+ }
+ return;
+ }
+
+ log(&format!("启动后台监控,间隔 {interval}s(Ctrl+C 退出)"));
+ log("扣卡策略:与 Mini App「蹬王之王」一致 —— 额度真正耗尽才动手,并按先过期先用");
+ loop {
+ match daemon.tick(dry_run) {
+ Ok(_) => {}
+ Err(e) if e.is_unauthorized() => {
+ log(&format!(
+ "✗ 鉴权失败:{e}。请确认 MiniMax Code 桌面端已启动并处于登录状态。"
+ ));
+ std::process::exit(1);
+ }
+ Err(e) => log(&format!("⚠️ 本轮失败:{e},下一轮继续")),
+ }
+ // 抖动,避免与整点/整 5 小时重置撞在一起
+ let jitter = (api::now_ms() % 1000) as f64 / 1000.0 * 4.0 - 2.0;
+ std::thread::sleep(Duration::from_millis(((interval + jitter) * 1000.0) as u64));
+ }
+}
diff --git a/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs b/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs
new file mode 100644
index 0000000..7d4dd6b
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/daemon/src/state.rs
@@ -0,0 +1,95 @@
+//! 去重记录的持久化。
+//!
+//! 落盘而非只放内存:后台进程会被 launchd 重启,只靠内存的话
+//! 重启后会把同一张卡再扣一遍。先写盘再请求,崩溃也不重复。
+
+use std::collections::BTreeSet;
+use std::path::PathBuf;
+
+fn state_dir() -> Option {
+ let home = std::env::var("HOME").ok()?;
+ let dir = PathBuf::from(home).join(".config/mcode-reset-card");
+ Some(dir)
+}
+
+fn state_file() -> Option {
+ Some(state_dir()?.join("bg-state.json"))
+}
+
+fn read_set() -> BTreeSet {
+ let Some(path) = state_file() else {
+ return BTreeSet::new();
+ };
+ let Ok(text) = std::fs::read_to_string(&path) else {
+ return BTreeSet::new();
+ };
+ serde_json::from_str::(&text)
+ .ok()
+ .and_then(|v| v.get("spent").cloned())
+ .and_then(|s| serde_json::from_value::>(s).ok())
+ .map(|v| v.into_iter().collect())
+ .unwrap_or_default()
+}
+
+fn write_set(set: &BTreeSet) {
+ let Some(dir) = state_dir() else { return };
+ let Some(path) = state_file() else { return };
+ if std::fs::create_dir_all(&dir).is_err() {
+ return;
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
+ }
+ let json = serde_json::json!({
+ "spent": set.iter().collect::>(),
+ "updatedAtMs": crate::api::now_ms(),
+ });
+ // 原子替换:先写临时文件再 rename,避免半截文件
+ let tmp = path.with_extension("json.tmp");
+ if std::fs::write(&tmp, serde_json::to_string_pretty(&json).unwrap_or_default()).is_err() {
+ return;
+ }
+ if std::fs::rename(&tmp, &path).is_err() {
+ let _ = std::fs::remove_file(&tmp);
+ return;
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
+ }
+}
+
+pub fn was_spent(fingerprint: &str) -> bool {
+ read_set().contains(fingerprint)
+}
+
+pub fn mark_spent(fingerprint: &str, card_id: &str, source_type: &str) {
+ let mut set = read_set();
+ if set.insert(fingerprint.to_string()) {
+ write_set(&set);
+ }
+ crate::log(&format!("已记账:{card_id} ({source_type})"));
+}
+
+pub fn clear_spent() {
+ if let Some(path) = state_file() {
+ let _ = std::fs::remove_file(path);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn set_roundtrip() {
+ let mut s = BTreeSet::new();
+ s.insert("a:1".to_string());
+ s.insert("b:2".to_string());
+ assert!(s.contains("a:1"));
+ assert!(!s.contains("c:3"));
+ }
+}
diff --git a/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png b/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png
new file mode 100644
index 0000000..99299d5
Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/docs/preview-cards.png differ
diff --git a/plugins/hylouis233/expiry-sentinel/docs/preview.png b/plugins/hylouis233/expiry-sentinel/docs/preview.png
new file mode 100644
index 0000000..a19e454
Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/docs/preview.png differ
diff --git a/plugins/hylouis233/expiry-sentinel/icon.png b/plugins/hylouis233/expiry-sentinel/icon.png
new file mode 100644
index 0000000..416bf1d
Binary files /dev/null and b/plugins/hylouis233/expiry-sentinel/icon.png differ
diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html b/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html
new file mode 100644
index 0000000..9c0daee
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/miniapp/client/index.html
@@ -0,0 +1,934 @@
+
+
+
+
+
+ 蹬王之王
+
+
+
+
+
+
蹬王之王
+
实时追踪积分与重置卡的到期时间,按「先过期先用」决定用卡还是留积分,不让额度白白作废
+
+ ●
+ 正在连接实时更新…
+
+
+
+ 刷新
+ 立即重连
+ 使用建议的重置卡
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 判定结论为「保留积分」时不执行任何写操作;「立即判定一次」可随时手动触发,无节流。
+
+
+
+
+
+
+
+
+
+ 后台服务:关闭本页面也继续自动判定
+ 由系统守护进程常驻,开机自启
+
+
+ 启用
+
+
+
+ 与上面的「自动执行」是两套独立机制:上面只在本页面打开时生效,这里交给系统常驻进程,
+ 两者用同一套「先过期先用」决策,不会重复扣卡(服务端按幂等键去重)。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json b/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json
new file mode 100644
index 0000000..498d880
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/miniapp/miniapp.json
@@ -0,0 +1,20 @@
+{
+ "schemaVersion": 1,
+ "artifacts": {
+ "client": [
+ "./miniapp/client"
+ ],
+ "node": [
+ "./miniapp/node"
+ ]
+ },
+ "runtime": {
+ "kind": "process",
+ "entry": "./miniapp/node/server.mjs",
+ "lifecycle": "on-demand"
+ },
+ "surface": {
+ "path": "/dashboard"
+ },
+ "mcpEndpoints": []
+}
diff --git a/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs b/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs
new file mode 100644
index 0000000..307325b
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/miniapp/node/server.mjs
@@ -0,0 +1,1256 @@
+// @ts-check
+/**
+ * 蹬王之王 — 积分与重置卡的到期决策看板。
+ *
+ * 数据源(全部经实测确认,非推测)。两套 API 分属不同域名:
+ *
+ * 开放平台积分 → www.minimaxi.com
+ * GET /backend/account/token_plan_credit → 余额 + 各积分池到期时间
+ * GET /backend/account/token_plan/remains_percent → 5小时/周额度用量
+ * GET /backend/account/reset_card/list → 重置卡与到期时间
+ * POST /backend/account/reset_card/use → 使用一张重置卡
+ *
+ * MiniMax Code 积分 → agent.minimaxi.com
+ * GET /minimax-cloud/api/v1/credit/details → 逐笔授予/消耗/到期明细
+ *
+ * 决策规则:把「最早到期的积分」(平台或 Code 任一)与「最早到期的重置卡」比,
+ * 谁先过期先用谁。积分先过期 → 不用卡,让积分先消耗完;重置卡先过期 → 用卡
+ * 把额度重置回来,把更耐放的积分留着。
+ *
+ * 鉴权:与桌面端共用同一登录态,经 OAuth lease socket 取短期 access token。
+ * 凭据只在本进程内存中使用,绝不下发给页面、绝不写日志。
+ */
+
+import { createServer } from 'node:http';
+import { readFileSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { randomBytes } from 'node:crypto';
+import { join } from 'node:path';
+import { connect } from 'node:net';
+import { homedir } from 'node:os';
+
+/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */
+/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */
+
+
+// ── OAuth lease:与桌面端同一套登录态 ────────────────────────────────────────
+const LEASE_SOCK = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.sock');
+const LEASE_CAP = join(homedir(), '.minimax', 'run', 'mcode-auth-lease-v1.cap');
+
+function randomId() {
+ return Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+/**
+ * 向桌面端的 lease socket 实时取一个短期 access token。
+ * 协议见 packages/oauth-lease-protocol:4 字节大端长度前缀 + JSON。
+ * @returns {Promise}
+ */
+function acquireLeaseToken() {
+ return new Promise((resolve) => {
+ let capability;
+ try {
+ capability = readFileSync(LEASE_CAP, 'utf8').trim();
+ } catch {
+ resolve(null);
+ return;
+ }
+ if (!capability) {
+ resolve(null);
+ return;
+ }
+
+ const payload = Buffer.from(
+ JSON.stringify({
+ version: 1,
+ requestId: randomId(),
+ capability,
+ method: 'lease',
+ minValidityMs: 120_000,
+ }),
+ 'utf8',
+ );
+ const frame = Buffer.alloc(4 + payload.length);
+ frame.writeUInt32BE(payload.length, 0);
+ payload.copy(frame, 4);
+
+ const socket = connect(LEASE_SOCK);
+ const chunks = [];
+ let done = false;
+
+ const finish = (value) => {
+ if (done) return;
+ done = true;
+ socket.destroy();
+ resolve(value);
+ };
+
+ socket.setTimeout(5000);
+ socket.on('error', () => finish(null));
+ socket.on('timeout', () => finish(null));
+ socket.on('connect', () => socket.write(frame));
+ socket.on('data', (chunk) => {
+ chunks.push(chunk);
+ const buf = Buffer.concat(chunks);
+ if (buf.length < 4) return;
+ const size = buf.readUInt32BE(0);
+ if (buf.length < 4 + size) return;
+ try {
+ const response = JSON.parse(buf.subarray(4, 4 + size).toString('utf8'));
+ const result = response?.result;
+ if (response?.ok && result?.method === 'lease' && typeof result.accessToken === 'string') {
+ finish(result.accessToken);
+ return;
+ }
+ finish(null);
+ } catch {
+ finish(null);
+ }
+ });
+ });
+}
+
+/**
+ * token 复用:lease token 有效期约 40 分钟,没必要每轮都开一次 unix socket。
+ * 只在过期前 5 分钟才重新取,且同一个 in-flight 请求会被复用(并发去重)。
+ */
+const TOKEN_TTL_MS = 30 * 60 * 1000;
+const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
+let tokenCache = { value: null, at: 0 };
+let tokenPending = null;
+
+function getToken() {
+ const now = Date.now();
+ if (tokenCache.value && now - tokenCache.at < TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS) {
+ return Promise.resolve(tokenCache.value);
+ }
+ // 并发去重:同一时刻只开一个 lease 连接
+ if (tokenPending) return tokenPending;
+ tokenPending = acquireLeaseToken()
+ .then((token) => {
+ if (token) {
+ tokenCache = { value: token, at: Date.now() };
+ }
+ return token;
+ })
+ .finally(() => {
+ tokenPending = null;
+ });
+ return tokenPending;
+}
+
+// ── 账号 API ──────────────────────────────────────────────────────────────────
+/**
+ * 两套账号 API 分属不同域名,实测确认:
+ * 开放平台 / 额度 / 重置卡 → www.minimaxi.com(agent 域对这些路径返回 404 或 HTML)
+ * MiniMax Code 积分明细 → agent.minimaxi.com(platform 域返回非 JSON)
+ */
+const PLATFORM_BASE = 'https://www.minimaxi.com';
+const CODE_BASE = 'https://agent.minimaxi.com';
+
+class ApiError extends Error {
+ constructor(message, kind = 'business', code) {
+ super(message);
+ this.kind = kind;
+ this.code = code;
+ }
+}
+
+async function apiGet(path, token, base = PLATFORM_BASE) {
+ const response = await fetch(`${base}${path}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/json',
+ 'User-Agent': 'MiniMaxCode',
+ },
+ });
+ if (response.status === 401 || response.status === 403) {
+ tokenCache = { value: null, at: 0 };
+ throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized');
+ }
+ if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status);
+ const payload = await response.json();
+ const baseResp = payload?.base_resp;
+ if (baseResp && baseResp.status_code !== 0) {
+ throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code);
+ }
+ return payload;
+}
+
+/** 部分接口(如 get_membership_info)是 POST,桌面端即如此调用。 */
+async function apiPost(path, token, base = CODE_BASE, body = {}) {
+ const response = await fetch(`${base}${path}`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'MiniMaxCode',
+ },
+ body: JSON.stringify(body),
+ });
+ if (response.status === 401 || response.status === 403) {
+ tokenCache = { value: null, at: 0 };
+ throw new ApiError('登录态失效,请重新打开 MiniMax Code', 'unauthorized');
+ }
+ if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status);
+ const payload = await response.json();
+ const baseResp = payload?.base_resp;
+ if (baseResp && baseResp.status_code !== 0) {
+ throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code);
+ }
+ return payload;
+}
+
+async function apiUseResetCard(sourceType, outBizNo, token) {
+ const response = await fetch(`${PLATFORM_BASE}/backend/account/reset_card/use`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'MiniMaxCode',
+ },
+ body: JSON.stringify({ source_type: sourceType, out_biz_no: outBizNo }),
+ });
+ if (!response.ok) throw new ApiError(`HTTP ${response.status}`, 'network', response.status);
+ const payload = await response.json();
+ const baseResp = payload?.base_resp;
+ if (baseResp && baseResp.status_code !== 0) {
+ throw new ApiError(baseResp.status_msg || `code=${baseResp.status_code}`, 'business', baseResp.status_code);
+ }
+ return String(payload?.outcome || 'UNKNOWN');
+}
+
+// ── 解析 ──────────────────────────────────────────────────────────────────────
+/** 百分数字符串(如 "71%")转数字。接口就是这种格式,直接 Number() 会得到 0。 */
+function percent(value) {
+ if (typeof value === 'string') value = value.trim().replace(/%/g, '');
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** 金额字符串(可能带千分位)转数字。 */
+function amount(value) {
+ if (typeof value === 'string') value = value.replace(/,/g, '').trim();
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+const BUCKET_LABEL = { 2: '充值', 3: '赠送' };
+/** Code 积分 credit_type:1=购买,2=赠送(取自桌面端 Purchased / Free 判定)。 */
+const CODE_CREDIT_LABEL = { 1: '购买', 2: '赠送' };
+
+/** 开放平台积分池(/backend/account/token_plan_credit)。 */
+function parsePlatformPools(credit) {
+ const buckets = credit?.balance_breakdown?.buckets ?? [];
+ return buckets
+ .filter((b) => b && typeof b.balance === 'number')
+ .map((b) => ({
+ kind: 'platform',
+ source: 'platform',
+ label: BUCKET_LABEL[b.source] ?? `来源${b.source}`,
+ balance: b.balance,
+ expireTimeMs: typeof b.expire_time_ms === 'number' ? b.expire_time_ms : null,
+ }))
+ .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity));
+}
+
+/**
+ * MiniMax Code 积分明细(/minimax-cloud/api/v1/credit/details)。
+ *
+ * 分页参数是 `page_number` / `page_size`(下划线命名),且必须带
+ * `include_expired=1` 才会返回历史记录——桌面端就是这么调的
+ * (见 app.asar 中 open-platform-commerce 的 fetch 函数)。
+ *
+ * 不带 include_expired 时接口只返回当前有效的最近 20 条,据此判断「已过期多少」
+ * 会得出完全错误的结论(曾经据此误答「0 过期」)。page_size 上限 100。
+ */
+async function fetchCodeCredits(token) {
+ const pageSize = 100;
+ const collected = [];
+ let totalCount = 0;
+ for (let pageNumber = 1; pageNumber <= 20; pageNumber += 1) {
+ const payload = await apiGet(
+ `/minimax-cloud/api/v1/credit/details?page_number=${pageNumber}&page_size=${pageSize}&include_expired=1`,
+ token,
+ CODE_BASE,
+ );
+ const details = Array.isArray(payload.details) ? payload.details : [];
+ totalCount = Number(payload.total_count ?? collected.length);
+ collected.push(...details);
+ if (collected.length >= totalCount || details.length < pageSize) break;
+ }
+ return { payload: { total_count: totalCount, details: collected } };
+}
+
+function parseCodeCredits(payload) {
+ const details = Array.isArray(payload?.details) ? payload.details : [];
+ const now = Date.now();
+ const items = details
+ .filter((d) => d && (d.credit_type === 1 || d.credit_type === 2))
+ .map((d) => ({
+ kind: 'code-credit',
+ source: 'code',
+ label: CODE_CREDIT_LABEL[d.credit_type] ?? `类型${d.credit_type}`,
+ granted: amount(d.granted_amount),
+ consumed: amount(d.consumed_amount),
+ balance: amount(d.remaining_amount),
+ expireTimeMs: typeof d.expire_at_ms === 'number' ? d.expire_at_ms : null,
+ grantedAtMs: typeof d.granted_at_ms === 'number' ? d.granted_at_ms : null,
+ // 过期时仍有余额 = 真实损失;过期但已用尽不算损失
+ lost: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now && amount(d.remaining_amount) > 0,
+ expired: typeof d.expire_at_ms === 'number' && d.expire_at_ms < now,
+ }))
+ .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity));
+
+ const live = items.filter((d) => !d.expired);
+ return {
+ items,
+ totalCount: Number(payload?.total_count ?? items.length),
+ truncated: Number(payload?.total_count ?? 0) > items.length,
+ liveBalance: live.reduce((sum, d) => sum + d.balance, 0),
+ lostBalance: items.filter((d) => d.lost).reduce((sum, d) => sum + d.balance, 0),
+ lostCount: items.filter((d) => d.lost).length,
+ expiredCount: items.filter((d) => d.expired).length,
+ };
+}
+
+/**
+ * 未来 N 天内将过期的积分子集。
+ * 这是整个看板最有价值的数字:签到分按天逐笔到账、各自 30 天到期,
+ * 所以到期是连续排队的滚动流失,不是一次性清算。
+ */
+function expiringWithin(items, days, now = Date.now()) {
+ const limit = now + days * 86_400_000;
+ return items.filter(
+ (d) => !d.expired && d.balance > 0 && d.expireTimeMs !== null && d.expireTimeMs <= limit,
+ );
+}
+
+/**
+ * 账户积分汇总(POST /matrix/api/v1/commerce/get_membership_info)。
+ *
+ * 这是 MCode 用量页上「积分」那一栏的权威口径:
+ * total_remaining_amount 总余额(截图里的 30947)
+ * purchased_remaining_amount 购入部分
+ * free_remaining_amount 赠送部分
+ *
+ * 与另两个口径的关系(已实测核对,不要混算):
+ * purchased ≈ token_plan_credit 的 remaining_credits(6487.506 vs 6487,小数为精度差)
+ * free ≥ credit/details 明细之和(24460 vs 17400,因该接口只返回最近 20 笔)
+ * 因此这里只作为「总余额」展示,**不参与**到期排序,避免与明细重复计数。
+ */
+function parseCreditSummary(payload) {
+ const s = payload?.op_credit_summary;
+ if (!s) return null;
+ const total = amount(s.total_remaining_amount);
+ const purchased = amount(s.purchased_remaining_amount);
+ const free = amount(s.free_remaining_amount);
+ if (!total && !purchased && !free) return null;
+ return { total, purchased, free };
+}
+
+function parseQuota(payload) {
+ const models = payload?.model_remains ?? [];
+ const general =
+ models.find((m) => m?.model_name === 'general') ?? models.find((m) => m?.model_name) ?? null;
+ if (!general) return null;
+ const total = percent(general.current_interval_total_percent) || 100;
+ const used = percent(general.current_interval_used_percent);
+ return {
+ fiveHour: {
+ usedPercent: used,
+ totalPercent: total,
+ remainingPercent: Math.max(0, (total - used) / total) * 100,
+ exhausted: general.current_interval_status !== 3 && total > 0 && used >= total,
+ unlimited: general.current_interval_status === 3,
+ resetTimeMs: general.end_time ?? null,
+ },
+ weekly: {
+ usedPercent: percent(general.current_weekly_used_percent),
+ totalPercent: percent(general.current_weekly_total_percent) || 100,
+ unlimited: general.current_weekly_status === 3,
+ resetTimeMs: general.weekly_end_time ?? null,
+ },
+ };
+}
+
+/**
+ * 重置卡的使用流水。
+ *
+ * 关键点:`create_time` 是**必填**参数,桌面端就是那样调的。
+ * 传 `create_time=0` 表示不限起始时间;传一个具体时间戳反而会返回
+ * 2013 invalid params —— 那是一条**错误**,不是「没有记录」。
+ * 曾经把这条错误读成 total=0,因而误判「一张卡都没用过」。
+ */
+async function fetchCardFlows(token) {
+ try {
+ const payload = await apiGet('/backend/account/reset_card/flows?page=1&size=20&create_time=0', token);
+ const flows = Array.isArray(payload.flows) ? payload.flows : [];
+ const used = flows.filter((f) => f?.event_type === 'USE');
+ return {
+ available: true,
+ total: Number(payload.total ?? flows.length),
+ usedCount: used.length,
+ grantedCount: flows.filter((f) => f?.event_type === 'GRANT').length,
+ lastUsedAtMs: used.length ? Math.max(...used.map((f) => Number(f.create_time) || 0)) : null,
+ };
+ } catch {
+ // 读不到流水时不编数字,交给页面显示「未知」
+ return { available: false, total: 0, usedCount: null, grantedCount: null, lastUsedAtMs: null };
+ }
+}
+
+async function fetchCards(token) {
+ const now = Date.now();
+ const seen = new Map();
+ for (let page = 1; page <= 10; page += 1) {
+ const payload = await apiGet(`/backend/account/reset_card/list?page=${page}&size=20`, token);
+ const cards = Array.isArray(payload.cards) ? payload.cards : [];
+ for (const card of cards) {
+ if (card && card.card_id) seen.set(String(card.card_id), card);
+ }
+ if (cards.length < 20) break;
+ }
+ return [...seen.values()]
+ .map((c) => ({
+ cardId: String(c.card_id),
+ sourceType: String(c.source_type ?? ''),
+ outBizNo: String(c.out_biz_no ?? ''),
+ expireTimeMs: typeof c.expire_time === 'number' ? c.expire_time : null,
+ createTimeMs: typeof c.create_time === 'number' ? c.create_time : null,
+ expired: typeof c.expire_time === 'number' && c.expire_time <= now,
+ }))
+ .sort((a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity));
+}
+
+// ── 决策:先过期先用 ──────────────────────────────────────────────────────────
+const SOURCE_NAME = { platform: '平台积分', code: 'Code 积分', card: '重置卡' };
+
+/**
+ * 跨三类来源比较到期时间:平台积分池、Code 积分明细、重置卡。
+ *
+ * 只有「最早到期的积分」与「最早到期的重置卡」直接可比——
+ * 决定用不用卡,看的是哪一边更快作废:
+ * 卡先过期 → 用卡把额度重置回来,把更耐放的积分留着;
+ * 积分先过期 → 不用卡,让积分自然消耗完,卡留到以后更划算的时候。
+ */
+function decide({ platformPools, codeCredits, cards, quota }) {
+ const now = Date.now();
+ const liveCards = cards.filter((c) => !c.expired && c.outBizNo && c.sourceType);
+ const liveCredits = [...platformPools, ...codeCredits].filter(
+ (c) => c.balance > 0 && c.expireTimeMs && c.expireTimeMs > now,
+ );
+
+ const earliestCard = liveCards[0] ?? null;
+ const earliestCredit = liveCredits.sort(
+ (a, b) => (a.expireTimeMs ?? Infinity) - (b.expireTimeMs ?? Infinity),
+ )[0] ?? null;
+
+ const cardExpire = earliestCard?.expireTimeMs ?? null;
+ const creditExpire = earliestCredit?.expireTimeMs ?? null;
+ const creditName = earliestCredit ? SOURCE_NAME[earliestCredit.source] : '积分';
+
+ let action = 'wait';
+ let reason = '';
+ let usedCard = false;
+
+ if (!quota) {
+ reason = '未获取到额度状态';
+ } else if (!quota.fiveHour.exhausted && !quota.weekly.exhausted) {
+ action = 'idle';
+ reason = '额度充足,暂不需要处理';
+ } else if (!earliestCredit) {
+ // 没有可消耗的积分,只能靠卡把额度重置回来
+ if (earliestCard) {
+ action = 'use-card';
+ usedCard = true;
+ reason = '没有可用积分,使用重置卡重置额度';
+ } else {
+ action = 'wait';
+ reason = '额度已耗尽,既没有积分也没有可用的重置卡';
+ }
+ } else if (!earliestCard) {
+ action = 'use-credit';
+ reason = `没有可用的重置卡,让${creditName}(${fmtDay(creditExpire)}到期)先消耗`;
+ } else if (cardExpire < creditExpire) {
+ action = 'use-card';
+ usedCard = true;
+ reason = `重置卡(${fmtDay(cardExpire)})比${creditName}(${fmtDay(creditExpire)})先过期,先用卡保住积分`;
+ } else {
+ action = 'use-credit';
+ reason = `${creditName}(${fmtDay(creditExpire)})比重置卡(${fmtDay(cardExpire)})先过期,不用卡,让积分先消耗`;
+ }
+
+ return {
+ action,
+ usedCard,
+ reason,
+ target:
+ usedCard && earliestCard
+ ? {
+ cardId: earliestCard.cardId,
+ sourceType: earliestCard.sourceType,
+ outBizNo: earliestCard.outBizNo,
+ expireTimeMs: cardExpire,
+ }
+ : null,
+ earliestCardExpireMs: cardExpire,
+ earliestCreditExpireMs: creditExpire,
+ earliestCreditSource: earliestCredit?.source ?? null,
+ earliestCreditLabel: earliestCredit?.label ?? null,
+ exhausted: Boolean(quota && (quota.fiveHour.exhausted || quota.weekly.exhausted)),
+ };
+}
+
+function fmtDay(ms) {
+ if (!ms) return '未知';
+ const d = new Date(ms);
+ return `${d.getMonth() + 1}月${d.getDate()}日`;
+}
+
+// ── 分层缓存 ──────────────────────────────────────────────────────────────────
+/**
+ * 按接口的「变化速度」分层缓存,直接砍掉无谓的网络往返。
+ *
+ * 实测依据(单轮 5 个接口共 ~19.8 KB / ~1.05s,其中 5 个串行等待是大头):
+ * 额度用量 每轮必取 —— 消耗在变,是所有联动的触发源
+ * 积分明细 60s —— 81 笔里绝大多数只在被消耗时才动
+ * 重置卡 300s —— 按天签到产生,变化极慢
+ * 平台积分池 300s —— 同上
+ * 账户汇总 600s —— 只在签到/充值后变
+ *
+ * 过期未取数时回落到上一次成功结果,宁可显示略旧的数据,也不让看板空白。
+ */
+const TTL = {
+ quota: 0,
+ codeCredits: 60_000,
+ cards: 300_000,
+ platform: 300_000,
+ membership: 600_000,
+ cardFlows: 120_000,
+};
+
+const cache = {
+ quota: { value: null, at: 0 },
+ codeCredits: { value: null, at: 0 },
+ cards: { value: null, at: 0 },
+ platform: { value: null, at: 0 },
+ membership: { value: null, at: 0 },
+ cardFlows: { value: null, at: 0 },
+};
+
+/** 带 TTL 与陈旧回落的取值:命中则零网络,未命中才请求。 */
+async function cached(slot, loader) {
+ const entry = cache[slot];
+ const now = Date.now();
+ if (entry.value !== null && now - entry.at < TTL[slot]) {
+ return { value: entry.value, fromCache: true };
+ }
+ try {
+ const value = await loader();
+ if (value !== null && value !== undefined) {
+ entry.value = value;
+ entry.at = now;
+ return { value, fromCache: false };
+ }
+ // 本轮失败:用旧值兜底,别把上次的正常数据清掉
+ return { value: entry.value, fromCache: true };
+ } catch {
+ return { value: entry.value, fromCache: true };
+ }
+}
+
+function invalidateCache(slot) {
+ if (slot) cache[slot].at = 0;
+ else for (const key of Object.keys(cache)) cache[key].at = 0;
+}
+
+// ── 快照 ──────────────────────────────────────────────────────────────────────
+/**
+ * 同一时刻只允许一次真实取数。
+ *
+ * SSE 轮询与自动判定会各自调用 buildSnapshot,若不合并就会把
+ * 每轮的接口数翻倍。这里让并发的调用共享同一个 in-flight Promise。
+ */
+let inflightSnapshot = null;
+
+function buildSnapshot() {
+ if (inflightSnapshot) return inflightSnapshot;
+ inflightSnapshot = doBuildSnapshot().finally(() => {
+ inflightSnapshot = null;
+ });
+ return inflightSnapshot;
+}
+
+async function doBuildSnapshot() {
+ const token = await getToken();
+ if (!token) {
+ throw new ApiError('未获取到登录态,请确认 MiniMax Code 桌面端正在运行且已登录', 'unauthorized');
+ }
+
+ // 四路并发放大并行收益;积分明细与账户汇总失败不拖垮看板
+ const [platform, quotaRes, cardsRes, codeRes, membershipRes, cardFlowsRes] = await Promise.all([
+ cached('platform', () => apiGet('/backend/account/token_plan_credit', token)),
+ cached('quota', () => apiGet('/backend/account/token_plan/remains_percent', token)),
+ cached('cards', () => fetchCards(token)),
+ cached('codeCredits', () =>
+ fetchCodeCredits(token)
+ .then((r) => r.payload)
+ .catch(() => null),
+ ),
+ cached('membership', () =>
+ apiPost('/matrix/api/v1/commerce/get_membership_info', token, CODE_BASE).catch(() => null),
+ ),
+ cached('cardFlows', () => fetchCardFlows(token)),
+ ]);
+
+ const platformRaw = platform.value;
+ const quotaRaw = quotaRes.value;
+ const cards = cardsRes.value ?? [];
+ const codePage = codeRes.value;
+ const membership = membershipRes.value;
+ const cardUsage = cardFlowsRes.value ?? { available: false, usedCount: null, grantedCount: null, lastUsedAtMs: null };
+
+ const platformPools = platformRaw ? parsePlatformPools(platformRaw) : [];
+ const codeCredits = parseCodeCredits(codePage);
+ const quota = quotaRaw ? parseQuota(quotaRaw) : null;
+ const decision = decide({ platformPools, codeCredits: codeCredits.items, cards, quota });
+
+ const now = Date.now();
+ const soon7 = expiringWithin(codeCredits.items, 7, now);
+ const soon30 = expiringWithin(codeCredits.items, 30, now);
+ const sumBalance = (list) => list.reduce((s, d) => s + d.balance, 0);
+
+ return {
+ fetchedAtMs: now,
+ // 哪些来自缓存 —— 页面据此判断数据新鲜度,不必假装每轮都是最新的
+ fresh: {
+ quota: !quotaRes.fromCache,
+ codeCredits: !codeRes.fromCache,
+ cards: !cardsRes.fromCache,
+ platform: !platform.fromCache,
+ membership: !membershipRes.fromCache,
+ },
+ creditSummary: parseCreditSummary(membership),
+ platformCredits: {
+ total: Number(platformRaw?.total_credits ?? 0),
+ used: Number(platformRaw?.used_credits ?? 0),
+ remaining: Number(platformRaw?.remaining_credits ?? 0),
+ fallbackEnabled: Boolean(platformRaw?.token_plan_credit_fallback_enabled),
+ pools: platformPools,
+ },
+ codeCredits: {
+ available: codePage !== null,
+ totalCount: codeCredits.totalCount,
+ liveBalance: codeCredits.liveBalance,
+ lostBalance: codeCredits.lostBalance,
+ /**
+ * 明细按「会不会再变 / 有没有意义」分区传输:
+ * active —— 仍有效、余额可能随消耗变化,主表格展示这个
+ * lost —— 已过期但**仍有余额**,即「没来得及蹬完」,单独一块展示
+ * 余额为 0 的笔既不展示也不传输,已折算进 usedUp 汇总。
+ * lost 分区只增不减(过期只会增加),因此适合增量推送:
+ * 它没变就不重发。
+ */
+ active: codeCredits.items.filter((d) => !d.expired && d.balance > 0),
+ lost: codeCredits.items.filter((d) => d.expired && d.balance > 0),
+ /**
+ * 余额为 0 的笔(已用尽)不进表格 —— 45 行零余额对用户没有信息量,
+ * 只把它折算成一句可读的成果汇总。口径是 consumed 之和,
+ * 依据是 granted - consumed == balance 在全部明细上恒成立。
+ */
+ usedUp: (() => {
+ const zero = codeCredits.items.filter((d) => d.balance <= 0);
+ return {
+ count: zero.length,
+ consumed: zero.reduce((sum, d) => sum + d.consumed, 0),
+ expiredCount: zero.filter((d) => d.expired).length,
+ };
+ })(),
+ },
+ expiry: {
+ soon7Count: soon7.length,
+ soon7Balance: sumBalance(soon7),
+ soon30Count: soon30.length,
+ soon30Balance: sumBalance(soon30),
+ nextExpireMs: codeCredits.items.find((d) => !d.expired && d.balance > 0)?.expireTimeMs ?? null,
+ },
+ quota,
+ cards: {
+ total: cards.length,
+ live: cards.filter((c) => !c.expired).length,
+ /** 序号 + 到期日即可识别,不再下发卡号。 */
+ items: cards
+ .filter((c) => !c.expired)
+ .slice(0, 12)
+ .map((c, i) => ({ index: i + 1, expireTimeMs: c.expireTimeMs })),
+ },
+ cardUsage,
+ decision,
+ };
+}
+
+// ── 扣卡与自动执行(唯一写路径,先记账再请求)─────────────────────────────────
+/** 自动判定间隔的允许范围:最快 5 秒(够灵敏),最慢 60 分钟(够省心)。 */
+const AUTO_MIN_MS = 5_000;
+const AUTO_MAX_MS = 60 * 60 * 1000;
+const AUTO_DEFAULT_MS = 15_000;
+
+function clampAutoInterval(ms) {
+ const n = Number(ms);
+ if (!Number.isFinite(n)) return AUTO_DEFAULT_MS;
+ return Math.min(AUTO_MAX_MS, Math.max(AUTO_MIN_MS, Math.round(n)));
+}
+
+const state = {
+ autoEnabled: false,
+ autoIntervalMs: AUTO_DEFAULT_MS,
+ spent: new Map(),
+ lastResult: null,
+ autoRuns: [],
+ lastAutoAtMs: 0,
+};
+
+/**
+ * 不再设时间节流。
+ * 重复扣卡的防护由两道更硬的机制承担:
+ * 1. state.spent —— 同一张卡在本进程生命周期内只尝试一次
+ * 2. out_biz_no —— 服务端幂等键,即使跨进程重试也不会重复扣除
+ * 额度耗尽时可以立即再次判定并执行,不必等固定间隔。
+ */
+
+async function useCard(reasonLabel) {
+ const token = await getToken();
+ if (!token) throw new ApiError('未获取到登录态', 'unauthorized');
+
+ const snapshot = await buildSnapshot();
+ const target = snapshot.decision.target;
+ if (!target) {
+ return { ok: false, message: snapshot.decision.reason, action: snapshot.decision.action };
+ }
+
+ const fingerprint = `${target.cardId}:${target.outBizNo}`;
+ if (state.spent.has(fingerprint)) {
+ // skipped=true 表示「去重保护生效」,不是扣卡失败,调用方不应记入失败日志
+ return { ok: false, skipped: true, message: '这张卡已尝试过,避免重复扣卡', action: snapshot.decision.action };
+ }
+
+ /**
+ * 不依赖时间节流,改用「卡是否还在可用列表」判断。
+ * 用掉之后服务端会把它移出可用列表,下一轮自然不会再选到它——
+ * 这比固定时间窗更准确:额度真正恢复前不会误扣,恢复了也不会漏扣。
+ * 进程重启后 spent 清空也没关系,列表判定依然成立。
+ */
+ const stillListed = (snapshot.cards.items ?? []).some(
+ (c) => c.cardId === target.cardId && !c.expired,
+ );
+ if (!stillListed) {
+ state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() });
+ return { ok: false, skipped: true, message: '这张卡已不在可用列表中,跳过', action: snapshot.decision.action };
+ }
+
+ // 先记账:进程中途崩溃也不会把这张卡再扣一次
+ state.spent.set(fingerprint, { cardId: target.cardId, sourceType: target.sourceType, atMs: Date.now() });
+
+ try {
+ const outcome = await apiUseResetCard(target.sourceType, target.outBizNo, token);
+ const success = outcome === 'SUCCESS' || outcome === 'REPLAYED';
+ // 卡已消耗,立刻让列表缓存失效,下一轮重新取。
+ // 否则 5 分钟内列表里还留着这张已用掉的卡,「是否还在列表」的判断会失真。
+ if (success) {
+ invalidateCache('cards');
+ invalidateCache('cardFlows');
+ }
+ state.lastResult = {
+ atMs: Date.now(),
+ cardId: target.cardId,
+ outcome,
+ ok: success,
+ label: reasonLabel,
+ };
+ return {
+ ok: success,
+ outcome,
+ cardId: target.cardId,
+ message: success
+ ? `已使用重置卡 ${target.cardId},额度已重置`
+ : `重置卡不可用(${outcome})`,
+ action: snapshot.decision.action,
+ };
+ } catch (error) {
+ state.lastResult = {
+ atMs: Date.now(),
+ cardId: target.cardId,
+ ok: false,
+ outcome: 'ERROR',
+ label: reasonLabel,
+ };
+ throw error;
+ }
+}
+
+/**
+ * 自动执行一轮:额度耗尽时按「先过期先用」的结论决定用卡还是留卡。
+ *
+ * 只有结论是 use-card 时才真正扣卡;结论是 use-credit(积分先过期)时
+ * 只记录一条决策,不做任何写操作——我们无法指定服务端扣哪一笔积分。
+ */
+async function autoTick(trigger) {
+ const now = Date.now();
+ // 提前返回的分支也要带 outcome,否则页面会渲染成 undefined
+ if (!state.autoEnabled) {
+ return { atMs: now, trigger, outcome: 'AUTO_OFF', reason: '自动执行未开启', message: '' };
+ }
+ state.lastAutoAtMs = now;
+
+ let snapshot;
+ try {
+ snapshot = await buildSnapshot();
+ } catch (error) {
+ const entry = { atMs: now, trigger, outcome: 'ERROR', message: describe(error) };
+ state.autoRuns.unshift(entry);
+ state.autoRuns.length = Math.min(state.autoRuns.length, 20);
+ return entry;
+ }
+
+ const d = snapshot.decision;
+ let entry;
+ if (d.action === 'use-card') {
+ entry = { atMs: now, trigger, outcome: 'USE_CARD', reason: d.reason, message: '' };
+ try {
+ const result = await useCard('自动执行');
+ // 「这张卡已尝试过」是去重保护在正常工作,不是失败。
+ // 记成 CARD_FAILED 会让同一页日志被这条刷屏,反而淹没真正的扣卡失败。
+ if (result.skipped) {
+ return { skipped: 'already-spent', outcome: 'SKIPPED_DUPLICATE' };
+ }
+ entry.outcome = result.ok ? 'CARD_USED' : 'CARD_FAILED';
+ entry.message = result.message;
+ entry.cardId = result.cardId;
+ } catch (error) {
+ entry.outcome = 'CARD_ERROR';
+ entry.message = describe(error);
+ }
+ } else if (d.action === 'use-credit') {
+ entry = {
+ atMs: now,
+ trigger,
+ outcome: 'KEEP_CREDIT',
+ reason: d.reason,
+ message: '积分先过期,保留重置卡,未执行任何写操作',
+ };
+ } else {
+ entry = { atMs: now, trigger, outcome: 'NO_ACTION', reason: d.reason, message: '' };
+ }
+ state.autoRuns.unshift(entry);
+ state.autoRuns.length = Math.min(state.autoRuns.length, 20);
+ return entry;
+}
+
+// ── 后台守护进程管理(launchd)────────────────────────────────────────────────
+/**
+ * 后台服务由 launchd 常驻,关闭本页面后仍会运行 ——
+ * 这正是它与页面内 SSE 自动判定的区别。
+ */
+const DAEMON_LABEL = 'com.local.mcode-reset-card';
+const DAEMON_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${DAEMON_LABEL}.plist`);
+
+async function run(cmd, args) {
+ const { spawn } = await import('node:child_process');
+ return new Promise((resolve) => {
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '';
+ child.stdout.on('data', (d) => { out += d.toString(); });
+ child.stderr.on('data', (d) => { out += d.toString(); });
+ child.on('close', (code) => resolve({ code, out }));
+ child.on('error', (e) => resolve({ code: -1, out: String(e) }));
+ });
+}
+
+/** 后台服务当前是否在跑(读 launchd 已加载的作业表)。 */
+async function daemonStatus() {
+ const { existsSync } = await import('node:fs');
+ const installed = existsSync(DAEMON_PLIST);
+ if (!installed) return { installed: false, running: false, pid: null, label: DAEMON_LABEL };
+
+ // 注意:`launchctl list ` 返回的是 plist 文本(含 "PID" = 123;),
+ // 只有不带参数才是表格行(PIDSTATUSLABEL)。这里要表格形式。
+ const r = await run('launchctl', ['list']);
+ const line = r.out
+ .split('\n')
+ .map((l) => l.trim())
+ .find((l) => l.endsWith(DAEMON_LABEL));
+ const running = Boolean(line);
+ const pid = running ? Number.parseInt(line.split(/\s+/)[0], 10) || null : null;
+ return { installed: true, running, pid, label: DAEMON_LABEL };
+}
+
+/** 启停后台服务。launchctl 的动词是 load / unload。 */
+async function controlDaemon(action) {
+ const status = await daemonStatus();
+ if (!status.installed) {
+ return { ok: false, message: `未安装后台服务(缺少 ${DAEMON_PLIST})`, ...status };
+ }
+
+ if (action === 'start') {
+ if (status.running) return { ok: true, message: '后台服务已在运行', ...(await daemonStatus()) };
+ const r = await run('launchctl', ['load', DAEMON_PLIST]);
+ if (r.code !== 0) {
+ return { ok: false, message: `启动失败:${r.out.trim() || `launchctl exit ${r.code}`}`, ...(await daemonStatus()) };
+ }
+ } else if (action === 'stop') {
+ if (!status.running) return { ok: true, message: '后台服务本就未运行', ...(await daemonStatus()) };
+ const r = await run('launchctl', ['unload', DAEMON_PLIST]);
+ if (r.code !== 0) {
+ return { ok: false, message: `停止失败:${r.out.trim() || `launchctl exit ${r.code}`}`, ...(await daemonStatus()) };
+ }
+ } else {
+ const verb = status.running ? 'unload' : 'load';
+ const r = await run('launchctl', [verb, DAEMON_PLIST]);
+ if (r.code !== 0) {
+ return { ok: false, message: `重启失败:${r.out.trim()}`, ...(await daemonStatus()) };
+ }
+ }
+
+ // 给 launchd 一点时间拉起/回收
+ await new Promise((r) => setTimeout(r, 600));
+ const after = await daemonStatus();
+ const label = { start: '已启动', stop: '已停止', restart: '已重启' }[action];
+ return {
+ ok: action === 'stop' ? !after.running : after.running,
+ message: action === 'stop'
+ ? (after.running ? '停止未生效' : '后台服务已停止')
+ : (after.running ? `${label}(PID ${after.pid})` : `${label}失败`),
+ ...after,
+ };
+}
+
+// ── HTTP ──────────────────────────────────────────────────────────────────────
+const json = (response, status, body) => {
+ const text = JSON.stringify(body);
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
+ response.end(text);
+};
+
+// ── SSE 实时推送 ─────────────────────────────────────────────────────────────
+/**
+ * 只在有页面连接时轮询,且只在数据**真的变化**时推送。
+ *
+ * 为什么不直接高频轮询:每次快照要打 5 个接口,高频轮询既浪费也容易被限流。
+ * 这里用一个轻量签名(关键数字 + 决策)比对,没变就不打扰页面。
+ */
+const sseClients = new Set();
+let sseTimer = null;
+let lastSignature = null;
+/** 上次推给页面的明细分区签名,用来只发变化的部分 */
+let lastParts = { lostSig: '', activeSig: '', usedSig: '' };
+
+/** 分区签名:只取会影响展示的字段,忽略顺序。 */
+function partSignature(list) {
+ return list
+ .map((d) => `${d.expireTimeMs}:${d.balance}:${d.granted}:${d.consumed}`)
+ .join(',');
+}
+
+/** 构造比对签名:只取会影响展示的关键字段,忽略时间戳。 */
+function signatureOf(s) {
+ const d = s.decision || {};
+ const e = s.expiry || {};
+ const c = s.codeCredits || {};
+ return [
+ Math.round(c.liveBalance ?? 0),
+ Math.round(c.lostBalance ?? 0),
+ c.expiredCount ?? 0,
+ e.soon7Count ?? 0,
+ Math.round(e.soon7Balance ?? 0),
+ Math.round(s.quota?.fiveHour?.remainingPercent ?? 0),
+ s.quota?.fiveHour?.exhausted ?? null,
+ s.cards.live ?? 0,
+ d.action ?? '',
+ d.target?.cardId ?? '',
+ s.autoEnabled ?? state.autoEnabled,
+ ].join('|');
+}
+
+/**
+ * 裁掉本轮没有变化的明细分区。
+ * 已过期记录(通常占整包 60%+)一旦推过就不再重发。
+ */
+function withIncremental(snapshot) {
+ const c = snapshot.codeCredits;
+ if (!c) return snapshot;
+ const lostSig = partSignature(c.lost ?? []);
+ const activeSig = partSignature(c.active ?? []);
+ const usedSig = c.usedUp ? `${c.usedUp.count}:${Math.round(c.usedUp.consumed)}` : '';
+ const out = { ...snapshot, codeCredits: { ...c } };
+ if (lostSig === lastParts.lostSig) delete out.codeCredits.lost;
+ if (activeSig === lastParts.activeSig) delete out.codeCredits.active;
+ if (usedSig === lastParts.usedSig) delete out.codeCredits.usedUp;
+ return out;
+}
+
+function rememberParts(snapshot) {
+ const c = snapshot.codeCredits;
+ if (!c) return;
+ lastParts = {
+ lostSig: partSignature(c.lost ?? []),
+ activeSig: partSignature(c.active ?? []),
+ usedSig: c.usedUp ? `${c.usedUp.count}:${Math.round(c.usedUp.consumed)}` : '',
+ };
+}
+
+function broadcast(snapshot, { incremental = false } = {}) {
+ if (sseClients.size === 0) return;
+ const payload = `data: ${JSON.stringify(incremental ? withIncremental(snapshot) : snapshot)}\n\n`;
+ for (const res of sseClients) {
+ try {
+ res.write(payload);
+ } catch {
+ sseClients.delete(res);
+ }
+ }
+}
+
+async function pollOnce() {
+ try {
+ const snapshot = await buildSnapshot();
+ snapshot.autoEnabled = state.autoEnabled;
+ snapshot.lastResult = state.lastResult;
+ snapshot.autoRuns = state.autoRuns.slice(0, 8);
+ const sig = signatureOf(snapshot);
+ if (sig !== lastSignature) {
+ lastSignature = sig;
+ broadcast(snapshot, { incremental: true });
+ rememberParts(snapshot);
+ }
+ } catch {
+ // 单轮失败不终止轮询;下一轮继续
+ }
+}
+
+function startSsePolling() {
+ if (sseTimer) return;
+ void pollOnce(); // 立刻来一次,让页面马上拿到当前状态
+ // 有了分层缓存,每轮只打「额度用量」一个接口(TTL=0),
+ // 15 秒一轮的开销远小于原来 30 秒打 5 个接口。
+ sseTimer = setInterval(() => void pollOnce(), 15_000);
+ sseTimer.unref?.();
+}
+
+function stopSsePolling() {
+ if (!sseTimer) return;
+ clearInterval(sseTimer);
+ sseTimer = null;
+}
+
+/**
+ * @param {MiniAppContext} context
+ * @returns {Promise}
+ */
+export async function start(context) {
+ const clientHtml = await readFile(join(context.pluginRoot, 'miniapp/client/index.html'));
+
+ const server = createServer((request, response) => {
+ const url = new URL(request.url ?? '/', 'http://miniapp.local');
+
+ if (request.method === 'GET' && url.pathname === '/dashboard') {
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ response.end(clientHtml);
+ return;
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/stream') {
+ response.writeHead(200, {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'cache-control': 'no-cache, no-transform',
+ connection: 'keep-alive',
+ });
+ response.write(': connected\n\n');
+ sseClients.add(response);
+ startSsePolling();
+ const cleanup = () => {
+ sseClients.delete(response);
+ if (sseClients.size === 0) stopSsePolling();
+ };
+ request.on('close', cleanup);
+ request.on('error', cleanup);
+ // 先补一次当前状态,页面无需再单独请求 /api/state
+ buildSnapshot()
+ .then((snapshot) => {
+ snapshot.autoEnabled = state.autoEnabled;
+ snapshot.autoIntervalMs = state.autoIntervalMs;
+ snapshot.lastResult = state.lastResult;
+ snapshot.autoRuns = state.autoRuns.slice(0, 8);
+ lastSignature = signatureOf(snapshot);
+ rememberParts(snapshot);
+ response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
+ })
+ .catch((error) => {
+ response.write(`data: ${JSON.stringify({ error: describe(error) })}\n\n`);
+ });
+ return;
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/state') {
+ buildSnapshot()
+ .then((snapshot) => {
+ snapshot.autoEnabled = state.autoEnabled;
+ snapshot.autoIntervalMs = state.autoIntervalMs;
+ snapshot.lastResult = state.lastResult;
+ snapshot.autoRuns = state.autoRuns.slice(0, 8);
+ json(response, 200, snapshot);
+ })
+ .catch((error) => {
+ json(response, 200, { error: describe(error) });
+ });
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/daemon') {
+ // 启停后台守护进程(launchd 常驻)。关掉本页面后它照常运行。
+ const action = url.searchParams.get('action');
+ if (action !== 'start' && action !== 'stop' && action !== 'restart') {
+ json(response, 400, { error: 'invalid_action' });
+ return;
+ }
+ controlDaemon(action)
+ .then((result) => json(response, 200, result))
+ .catch((error) => json(response, 200, { ok: false, message: describe(error) }));
+ return;
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/daemon') {
+ daemonStatus()
+ .then((result) => json(response, 200, result))
+ .catch((error) => json(response, 200, { installed: false, running: false, message: describe(error) }));
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/auto-interval') {
+ // 手动调整检测间隔:立即生效并重建定时器,无需重启
+ state.autoIntervalMs = clampAutoInterval(url.searchParams.get('ms'));
+ scheduleAuto();
+ json(response, 200, {
+ autoIntervalMs: state.autoIntervalMs,
+ minMs: AUTO_MIN_MS,
+ maxMs: AUTO_MAX_MS,
+ });
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/auto') {
+ // 开关只切换本地状态;开启时立刻跑一轮判定,不直接扣卡
+ state.autoEnabled = url.searchParams.get('enabled') === '1';
+ const rawMs = url.searchParams.get('intervalMs');
+ if (rawMs !== null) state.autoIntervalMs = clampAutoInterval(rawMs);
+ scheduleAuto();
+ if (state.autoEnabled) {
+ autoTick('开关开启')
+ .then((entry) =>
+ json(response, 200, {
+ autoEnabled: state.autoEnabled,
+ autoIntervalMs: state.autoIntervalMs,
+ firstRun: entry,
+ }),
+ )
+ .catch((error) =>
+ json(response, 200, {
+ autoEnabled: state.autoEnabled,
+ autoIntervalMs: state.autoIntervalMs,
+ firstRun: { outcome: 'ERROR', message: describe(error) },
+ }),
+ );
+ return;
+ }
+ json(response, 200, { autoEnabled: false, autoIntervalMs: state.autoIntervalMs });
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/auto-run') {
+ autoTick('手动触发')
+ .then((entry) => json(response, 200, entry))
+ .catch((error) => json(response, 200, { outcome: 'ERROR', message: describe(error) }));
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/use-card') {
+ useCard('手动使用')
+ .then((result) => json(response, 200, result))
+ .catch((error) => json(response, 200, { ok: false, message: describe(error) }));
+ return;
+ }
+
+ json(response, 404, { error: 'not_found' });
+ });
+
+ await listen(server, context.listen.host, context.listen.port);
+ context.logger.info('expiryGuardian.listening');
+
+ let disposed = false;
+ let autoTimer = null;
+
+ /**
+ * 自动判定定时器:间隔可由页面设置,随时重建。
+ * 关闭时不启动定时器,避免无意义唤醒。
+ */
+ function scheduleAuto() {
+ if (autoTimer) {
+ clearInterval(autoTimer);
+ autoTimer = null;
+ }
+ if (!state.autoEnabled || disposed) return;
+ autoTimer = setInterval(() => {
+ autoTick('定时').catch((error) => context.logger.warn('autoTick failed', { error: describe(error) }));
+ }, state.autoIntervalMs);
+ autoTimer.unref?.();
+ }
+
+ const dispose = async () => {
+ if (disposed) return;
+ disposed = true;
+ if (autoTimer) clearInterval(autoTimer);
+ stopSsePolling();
+ // 结束所有 SSE 长连接,否则 server.close() 会一直等它们断开
+ for (const res of sseClients) {
+ try {
+ res.end();
+ } catch {
+ // 已断开的连接忽略
+ }
+ }
+ sseClients.clear();
+ context.signal.removeEventListener('abort', onAbort);
+ await close(server);
+ };
+ const onAbort = () => {
+ void dispose();
+ };
+ context.signal.addEventListener('abort', onAbort, { once: true });
+ if (context.signal.aborted) await dispose();
+
+ return { dispose };
+}
+
+function describe(error) {
+ if (error instanceof ApiError) return error.message;
+ return '请求失败,请稍后重试';
+}
+
+function listen(server, host, port) {
+ return new Promise((resolve, reject) => {
+ const onError = (error) => reject(error);
+ server.once('error', onError);
+ server.listen(port, host, () => {
+ server.off('error', onError);
+ resolve();
+ });
+ });
+}
+
+function close(server) {
+ return new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+}
diff --git a/plugins/hylouis233/expiry-sentinel/package.json b/plugins/hylouis233/expiry-sentinel/package.json
new file mode 100644
index 0000000..aab6b8d
--- /dev/null
+++ b/plugins/hylouis233/expiry-sentinel/package.json
@@ -0,0 +1,6 @@
+{
+ "mcode": {
+ "schemaVersion": 2,
+ "miniApp": "./miniapp/miniapp.json"
+ }
+}