@fyeeme/pi-statusline 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-08-07
11
+
12
+ ### Added
13
+
14
+ - DeepSeek 结算币种感知定价(新模块 `cost.ts`):CNY 计费账户按官方 ¥ 单价重算(flash ¥1/¥2/¥0.02、pro ¥3/¥6/¥0.025 每百万 tokens),修正 pi 内置 USD 价低估约 7 倍;USD 计费账户保持 pi 内置价。
15
+ - balance API 自动探测账户结算币种;`/currency` 手动覆盖(auto/¥/$),切换后即时刷新注册表定价。
16
+ - 币种判定后即时对齐运行时注册表定价(覆盖与还原均可逆、跟随结算币种)。
17
+
18
+ ### Fixed
19
+
20
+ - USD 计费账户启动后不再整场会话停在 CNY 价:balance 探测到币种后立即重新应用定价补丁。
21
+ - 会话内新增 deepseek 模型(编辑 `models.json` + reload)后再次应用定价补丁时,不再因陈旧快照 + `registerProvider` 整表替换丢弃新增模型。
22
+
23
+ ## [1.0.2] - 2025-07-15
24
+
25
+ ### Fixed
26
+
27
+ - `getCachedUsage` crash when TTL expires and model provider has no registered UsageProvider (TypeError on null)
28
+ - `scanWeeklyTokens` UTC/local timezone mismatch: file date parsing now uses local timezone to match `startOfCurrentWeekLocal`
29
+ - Unhandled rejection in `refreshUsage` fire-and-forget callers: added catch-all error handler
30
+
31
+ ### Changed
32
+
33
+ - Background usage refresh now deduplicates concurrent TTL-expired callers via memoized `refreshInFlight` promise
34
+ - Weekly token scan results cached for 1 minute to reduce redundant filesystem I/O
35
+
36
+ ### Removed
37
+
38
+ - MCP server status segment from footer and its event listeners (`mcp:status`/`mcp:disconnect`): no pi version emits these events
39
+
40
+ ## [1.0.1] - 2026-06-19
41
+
42
+ ### Fixed
43
+
44
+ - ZAI weekly token count for plans without a `unit:6` weekly quota now comes from the backend `model-usage` API for the current natural week (Mon 00:00 UTC → now) instead of scanning local session files, so it matches the backend's per-week tally. Footer label changed from `7d:` to `W:` to reflect the natural-week window.
45
+
46
+ ### Changed
47
+
48
+ - Natural-week window (DeepSeek local scan and ZAI fallback) now uses the host's local timezone instead of UTC, so "this week" matches the user's expectation. Both providers share the same boundary via `startOfCurrentWeekLocal`.
49
+
50
+ ### Removed
51
+
52
+ - `quota/` directory (`QuotaCalculator` and its DeepSeek/ZAI implementations) and the `quotaCalculator` field on `UsageProvider`. These were never invoked at runtime; the live logic lives in the providers directly.
53
+
10
54
  ## [1.0.1] - 2026-06-19
11
55
 
12
56
  ### Fixed
package/README.md CHANGED
@@ -1,17 +1,55 @@
1
1
  # pi-statusline
2
2
 
3
- A rich custom status bar for [pi](https://pi.dev) that replaces the default footer.
3
+ [![npm version](https://img.shields.io/npm/v/@fyeeme/pi-statusline)](https://www.npmjs.com/package/@fyeeme/pi-statusline)
4
+ [![License](https://img.shields.io/npm/l/@fyeeme/pi-statusline)](LICENSE)
5
+
6
+ A rich custom status bar for [pi](https://pi.dev) that replaces the default footer. Provider-aware: shows live account balance for **DeepSeek** and rolling quota for **GLM/ZAI**, plus session tokens, cost, context window, and timing.
4
7
 
5
8
  ## Features
6
9
 
7
- - **Token usage**: input, output, cache read/write, total per session
8
- - **Cost**: cumulative cost with currency auto-detection (¥ for DeepSeek, $ otherwise)
9
- - **DeepSeek balance**: live account balance fetched on startup and cached for 5 minutes
10
+ - **Provider-aware usage** — special support for DeepSeek and GLM/ZAI (see [Provider Support](#provider-support))
11
+ - **Token usage**: input, output, cache read/write, total per session, with cache hit rate
12
+ - **Cost**: cumulative session cost with currency auto-detection (¥ for DeepSeek/CNY, $ otherwise)
10
13
  - **Context window**: usage percentage and size
11
14
  - **Timing**: elapsed time + tokens/sec for last response
12
- - **MCP status**: connected server count and total tool count
13
15
  - **Git branch**: current branch shown in cwd display
14
16
 
17
+ ## Provider Support
18
+
19
+ This extension uses a provider-aware strategy: when the active model belongs to a supported provider, the status bar shows live account-level usage data fetched directly from that provider's API. Other providers fall back to session-scoped cost only.
20
+
21
+ ### DeepSeek
22
+
23
+ Shows live **account balance** and **weekly token usage**.
24
+
25
+ | Segment | Example | Source |
26
+ |---------|---------|--------|
27
+ | Balance | `¥0.12/50.00` | `GET /user/balance` (cached 5 min) |
28
+ | Weekly tokens | `7d:1.2M` | Local session file scan (rolling 7 days) |
29
+
30
+ - Currency auto-detected as `¥` (CNY) from the balance API response
31
+ - Balance is fetched on startup and refreshed in the background; cached for 5 minutes
32
+ - Triggered when `model.provider === "deepseek"`
33
+
34
+ ### GLM / ZAI
35
+
36
+ Shows **5-hour rolling quota** and **weekly quota** (or natural-week usage as fallback).
37
+
38
+ | Segment | Example | Source |
39
+ |---------|---------|--------|
40
+ | 5h quota | `Usage 42%(1h23m)` | `GET /api/monitor/usage/quota/limit` (`unit:3`) |
41
+ | Weekly quota | `W:35%(1.2M,3d4h)` | `quota/limit` (`unit:6`) + `model-usage` API |
42
+ | Natural week | `W:1.2M` | `model-usage` API (Mon 00:00 local → now) |
43
+
44
+ - **5-hour rolling window**: percentage used + countdown to reset
45
+ - **Weekly quota**: if the plan exposes a `unit:6` weekly limit, shows percentage, tokens, and reset countdown; otherwise falls back to real usage for the current natural week (host local timezone)
46
+ - **Account level**: also fetched (visible in `/status-debug`)
47
+ - Supports both `zai` (`api.z.ai`) and `zai-coding-cn` (`open.bigmodel.cn`) endpoints
48
+
49
+ ### Other providers
50
+
51
+ Session-scoped cost only (`$0.12` or `¥0.12`), with no live account data.
52
+
15
53
  ## Install
16
54
 
17
55
  Requires the [pi](https://pi.dev) CLI.
@@ -20,16 +58,16 @@ Requires the [pi](https://pi.dev) CLI.
20
58
 
21
59
  ```bash
22
60
  # Global (user) install — available in every project
23
- pi install npm:@fyeeme/pi-statusline
61
+ pi install @fyeeme/pi-statusline
24
62
 
25
63
  # Project-local — written to .pi/settings.json, shareable with your team
26
- pi install -l npm:@fyeeme/pi-statusline
64
+ pi install -l @fyeeme/pi-statusline
27
65
 
28
66
  # Pinned version — skipped by `pi update`
29
- pi install npm:@fyeeme/pi-statusline@1.0.0
67
+ pi install @fyeeme/pi-statusline@1.0.2
30
68
 
31
69
  # Try it once without saving (current run only)
32
- pi -e npm:@fyeeme/pi-statusline
70
+ pi -e @fyeeme/pi-statusline
33
71
  ```
34
72
 
35
73
  ### From GitHub
@@ -40,7 +78,7 @@ Source: [`fyeeme/pi-packages`](https://github.com/fyeeme/pi-packages).
40
78
  # HTTPS shorthand
41
79
  pi install git:github.com/fyeeme/pi-packages
42
80
  # Pin to a tag or commit (skipped by `pi update`)
43
- pi install git:github.com/fyeeme/pi-packages@v1.0.0
81
+ pi install git:github.com/fyeeme/pi-packages@v1.0.2
44
82
  # Raw URL form
45
83
  pi install https://github.com/fyeeme/pi-packages
46
84
  ```
@@ -51,20 +89,41 @@ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of sourc
51
89
 
52
90
  | Command | Description |
53
91
  |---|---|
54
- | `/balance` | Force refresh DeepSeek account balance |
55
- | `/currency [auto\|¥\|$]` | Toggle cost currency display |
56
- | `/status-debug` | Dump session stats to `/tmp/pi-status-debug.log` |
92
+ | `/currency [auto\|¥\|$]` | Toggle cost currency display (`auto` follows DeepSeek → ¥) |
93
+ | `/status-debug` | Dump session stats + provider usage to `/tmp/pi-status-debug.log` |
57
94
 
58
95
  ## Status Bar Layout
59
96
 
60
97
  ```
61
98
  ~/projects/my-repo (main) deepseek-v3 · xhigh
62
- in 12k, out 8k, cache 45k, total 65k · ¥0.12/50.00 · 12.3%/64k · 45s 38.2tok/s · MCP:2(15)
99
+ tokens 65k(in 12k, out 8k, cache 45k) · ¥0.12/50.00 · 7d:1.2M · 45.2%/64k · 2m30s 38.2tok/s
63
100
  ```
64
101
 
65
- Line 1: cwd + git branch (left) | model + thinking level (right)
66
- Line 2: token stats · cost/balance · context · timing · MCP
102
+ **Line 1**: cwd + git branch (left) | model + thinking level (right)
103
+
104
+ **Line 2**: token stats · provider usage · context window · timing
105
+
106
+ ### GLM/ZAI example
107
+
108
+ ```
109
+ ~/projects/my-repo (main) glm-4.6 · high
110
+ tokens 65k(in 12k, out 8k, cache 45k) · Usage 42%(1h23m) · W:35%(1.2M,3d4h) · 45.2%/64k · 2m30s 38.2tok/s
111
+ ```
112
+
113
+ ## Environment Variables
114
+
115
+ None required. API keys are resolved automatically by pi's model registry for the active provider.
116
+
117
+ ## How It Works
118
+
119
+ The extension registers a `UsageProvider` per supported provider and refreshes usage data in the background (on startup, on model switch, and after each agent run). Results are cached with a 5-minute TTL (DeepSeek) or per-refresh (ZAI) to avoid hammering the provider APIs.
120
+
121
+ All network calls are best-effort with a 5-second timeout — failures stay silent and never block the status bar.
122
+
123
+ ## Changelog
124
+
125
+ See [CHANGELOG.md](CHANGELOG.md).
67
126
 
68
- ## MCP Integration
127
+ ## License
69
128
 
70
- When used with [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter), the status bar shows live MCP connection status via `mcp:status` and `mcp:disconnect` events.
129
+ MIT
package/cache.ts CHANGED
@@ -12,9 +12,13 @@ interface CacheEntry {
12
12
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
13
 
14
14
  let usageCache: CacheEntry | null = null;
15
+ let refreshInFlight: Promise<ProviderUsageResult> | null = null;
16
+ let refreshGeneration = 0;
15
17
 
16
18
  export function resetUsageCache(): void {
17
19
  usageCache = null;
20
+ refreshInFlight = null;
21
+ refreshGeneration += 1;
18
22
  }
19
23
 
20
24
  export function getUsageCache(): CacheEntry | null {
@@ -33,13 +37,22 @@ export async function refreshUsage(
33
37
  return null;
34
38
  }
35
39
 
36
- const result = await provider.fetchUsage(modelRegistry, model);
37
- if (result) {
38
- usageCache = { result, fetchedAt: Date.now() };
39
- } else {
40
- usageCache = null;
40
+ const gen = ++refreshGeneration;
41
+ try {
42
+ const result = await provider.fetchUsage(modelRegistry, model);
43
+ // Drop stale responses from older generations
44
+ if (gen !== refreshGeneration) return usageCache?.result ?? null;
45
+ if (result) {
46
+ usageCache = { result, fetchedAt: Date.now() };
47
+ } else {
48
+ usageCache = null;
49
+ }
50
+ return result;
51
+ } catch {
52
+ // Total catch: fire-and-forget callers must never see an unhandled rejection
53
+ if (gen === refreshGeneration) usageCache = null;
54
+ return null;
41
55
  }
42
- return result;
43
56
  }
44
57
 
45
58
  export function getUsageCacheAge(): number | null {
@@ -53,11 +66,22 @@ export function getCachedUsage(
53
66
  lastModel: ExtensionContext["model"] | undefined,
54
67
  ): ProviderUsageResult {
55
68
  if (!usageCache) return null;
56
- if (Date.now() - usageCache.fetchedAt > CACHE_TTL_MS) {
57
- // TTL expired: return stale data but trigger background refresh
69
+ // Capture local snapshot before triggering background refresh: refreshUsage
70
+ // may synchronously set usageCache = null when the provider is not found,
71
+ // which would make the eventual `return cache.result` throw on null.
72
+ const cache = usageCache;
73
+ if (Date.now() - cache.fetchedAt > CACHE_TTL_MS) {
74
+ // TTL expired: return stale data but trigger background refresh.
75
+ // Memoize inflight promise so concurrent callers share one fetch.
58
76
  if (lastCtx && lastModel) {
59
- void refreshUsage(providers, lastCtx.modelRegistry, lastModel);
77
+ if (!refreshInFlight) {
78
+ const promise = refreshUsage(providers, lastCtx.modelRegistry, lastModel);
79
+ refreshInFlight = promise;
80
+ promise.finally(() => {
81
+ if (refreshInFlight === promise) refreshInFlight = null;
82
+ });
83
+ }
60
84
  }
61
85
  }
62
- return usageCache.result;
86
+ return cache.result;
63
87
  }
package/cost.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * DeepSeek 定价修正(跟随结算币种)。
3
+ *
4
+ * 背景:pi 内置的 deepseek 定价数据(pi-ai 的 providers/data/deepseek.json)用的是官方 USD 价
5
+ * (flash $0.14/0.28/0.0028、pro $0.435/0.87/0.003625 每百万 tokens)。DeepSeek 账户分 CNY 与 USD
6
+ * 两种结算:CNY 账户按 ¥1/¥2/¥0.02、¥3/¥6/¥0.025 计费,此时 pi 记录的成本低估约 7 倍;USD 账户
7
+ * 按 USD 计费,pi 内置价本来就是对的。
8
+ *
9
+ * 因此所有"修正"都必须跟随结算币种:
10
+ * 1. `deepSeekBilledInCny()`:判定 DeepSeek 是否按 CNY 计价 —— 用户显式 `/currency` 优先;
11
+ * 否则按 balance API 探测到的账户币种(探测失败/未知时按 CNY,保持原行为)。
12
+ * 2. `deepseekMessageCostCny`:按官方 CNY 单价重算单条消息成本(仅 CNY 计价时用于兜底存量消息,
13
+ * 会话文件里已记录的 cost 无法追溯修正)。
14
+ * 3. `applyDeepSeekPricingPatch`:运行时把注册表中 deepseek 模型定价覆盖为官方 CNY 价(仅 CNY
15
+ * 计价时;USD 计价时还原为 pi 内置 USD 价)。内存补丁,不修改数据文件、pi 升级不丢。
16
+ */
17
+ import type { Model, Api } from "@earendil-works/pi-ai";
18
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
19
+
20
+ export interface DeepSeekPricedUsage {
21
+ input: number;
22
+ output: number;
23
+ cacheRead: number;
24
+ cacheWrite: number;
25
+ }
26
+
27
+ /** DeepSeek 官方 CNY 单价(元 / 百万 tokens):缓存命中输入 / 未命中输入 / 输出(见官方定价页)。 */
28
+ export const DEEPSEEK_CNY_PRICES: Record<string, DeepSeekPricedUsage> = {
29
+ "deepseek-v4-flash": { input: 1, output: 2, cacheRead: 0.02, cacheWrite: 0 },
30
+ "deepseek-v4-pro": { input: 3, output: 6, cacheRead: 0.025, cacheWrite: 0 },
31
+ };
32
+
33
+ const PER_MILLION = 1_000_000;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // 结算币种状态(DeepSeek)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** balance API 探测到的账户结算币种(CNY / USD / 其他)。 */
40
+ let detectedDeepSeekCurrency: string | null = null;
41
+ /** 用户显式 `/currency` 设置,覆盖自动判定。 */
42
+ let costCurrencyOverride: "¥" | "$" | undefined = undefined;
43
+ /** 首次 applyDeepSeekPricingPatch 时缓存的原始模型定义(用于 USD 计价时还原)。 */
44
+ let originalDeepSeekModels: Model<Api>[] | null = null;
45
+
46
+ /** 记录 balance API 探测到的 DeepSeek 账户结算币种(fetchUsage 成功后写入;null 表示未知)。 */
47
+ export function setDetectedDeepSeekCurrency(currency: string | null): void {
48
+ detectedDeepSeekCurrency = currency;
49
+ }
50
+
51
+ /** 设置用户显式币种偏好(`/currency` 命令);undefined 恢复自动判定。 */
52
+ export function setCostCurrencyOverride(override: "¥" | "$" | undefined): void {
53
+ costCurrencyOverride = override;
54
+ }
55
+
56
+ /** 当前用户显式币种偏好(undefined = 自动)。 */
57
+ export function getCostCurrencyOverride(): "¥" | "$" | undefined {
58
+ return costCurrencyOverride;
59
+ }
60
+
61
+ /**
62
+ * DeepSeek 是否按 CNY 计价:用户显式偏好优先;否则仅当探测明确为 USD 时按 USD 计价,
63
+ * 其余情况(CNY 或未知)按 CNY —— 保持原有"deepseek → ¥"的默认行为。
64
+ */
65
+ export function deepSeekBilledInCny(): boolean {
66
+ if (costCurrencyOverride === "$") return false;
67
+ if (costCurrencyOverride === "¥") return true;
68
+ return detectedDeepSeekCurrency !== "USD";
69
+ }
70
+
71
+ /** 重置币种状态与补丁缓存(测试用)。 */
72
+ export function resetDeepSeekPricingState(): void {
73
+ detectedDeepSeekCurrency = null;
74
+ costCurrencyOverride = undefined;
75
+ originalDeepSeekModels = null;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // 单条消息成本重算
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * 按官方 CNY 单价重算单条消息成本;模型不在定价表内返回 null(调用方回退 `m.usage.cost`)。
84
+ *
85
+ * @param model 消息模型 id(如 `deepseek-v4-flash`)
86
+ * @param usage 消息的 token 用量分解
87
+ */
88
+ export function deepseekMessageCostCny(
89
+ model: string | undefined,
90
+ usage: DeepSeekPricedUsage,
91
+ ): number | null {
92
+ if (!model) return null;
93
+ const p = DEEPSEEK_CNY_PRICES[model];
94
+ if (!p) return null;
95
+ return (
96
+ (p.input * usage.input +
97
+ p.output * usage.output +
98
+ p.cacheRead * usage.cacheRead +
99
+ p.cacheWrite * usage.cacheWrite) /
100
+ PER_MILLION
101
+ );
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // 运行时注册表补丁(根治 pi 内置定价,可逆、跟随结算币种)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * 把注册表中 deepseek 模型的定价与结算币种对齐:
110
+ * - CNY 计价:cost 覆盖为官方 CNY 价;
111
+ * - USD 计价:还原为 pi 内置 USD 价(首次调用时缓存原始定义,之后不再被 CNY 覆盖)。
112
+ * 未知/未来新增模型原样保留;重复调用幂等;注册表未加载模型时静默跳过(由调用方后续重试)。
113
+ */
114
+ export function applyDeepSeekPricingPatch(registry: ModelRegistry): void {
115
+ const dsModels = registry.getAll().filter((m) => m.provider === "deepseek");
116
+ if (dsModels.length === 0) return;
117
+ if (!originalDeepSeekModels) {
118
+ originalDeepSeekModels = dsModels;
119
+ } else {
120
+ // Merge in deepseek models added after the first snapshot, so a later
121
+ // re-patch(币种切换 / model_select / 探测后纠正)不会丢掉它们:registerProvider
122
+ // 是整表替换,用陈旧快照 map 会把新增模型从 registry 里静默删掉。
123
+ const known = new Set(originalDeepSeekModels.map((m) => m.id));
124
+ for (const m of dsModels) if (!known.has(m.id)) originalDeepSeekModels.push(m);
125
+ }
126
+
127
+ const billedInCny = deepSeekBilledInCny();
128
+ const models = originalDeepSeekModels.map((m) => {
129
+ if (!billedInCny) return m;
130
+ const cny = DEEPSEEK_CNY_PRICES[m.id];
131
+ if (!cny) return m;
132
+ return { ...m, cost: { ...m.cost, ...cny } };
133
+ });
134
+ registry.registerProvider("deepseek", { models });
135
+ }
package/footer.ts CHANGED
@@ -75,7 +75,6 @@ export function buildStatLine(
75
75
  providers: Record<string, UsageProvider>,
76
76
  getElapsedSec: () => number,
77
77
  lastTps: number,
78
- mcpStatuses: Map<string, { connected: boolean; toolCount: number }>,
79
78
  ): string {
80
79
  const mods: string[] = [];
81
80
 
@@ -131,15 +130,8 @@ export function buildStatLine(
131
130
  if (t.length) mods.push(t.join(" "));
132
131
  }
133
132
 
134
- // MCP: MCP:2(15) or MCP:0
135
- {
136
- const entries = Array.from(mcpStatuses.entries());
137
- if (entries.length) {
138
- const connected = entries.filter(([, s]) => s.connected).length;
139
- const tools = entries.reduce((sum, [, s]) => sum + s.toolCount, 0);
140
- mods.push(connected > 0 ? `MCP:${connected}(${tools})` : "MCP:0");
141
- }
142
- }
133
+ // MCP status: no pi version emits mcp:status/mcp:disconnect events.
134
+ // The segment is disabled until a documented event source exists.
143
135
 
144
136
  return mods.join(" · ");
145
137
  }
package/index.ts CHANGED
@@ -22,6 +22,11 @@ import { DeepSeekUsageProvider } from "./providers/deepseek.ts";
22
22
  import { ZaiUsageProvider } from "./providers/zai.ts";
23
23
  import type { UsageProvider } from "./providers/types.ts";
24
24
  import { SessionTokenUsageCalculator } from "./token-usage.ts";
25
+ import {
26
+ applyDeepSeekPricingPatch,
27
+ setCostCurrencyOverride,
28
+ getCostCurrencyOverride,
29
+ } from "./cost.ts";
25
30
  import { refreshUsage, getCachedUsage, getUsageCacheAge } from "./cache.ts";
26
31
  import { formatCwd, buildStatLine, buildInfoLine } from "./footer.ts";
27
32
 
@@ -45,11 +50,8 @@ let lastModel: ExtensionContext["model"] = undefined;
45
50
  let agentRunning = false;
46
51
  let lastElapsedSec = 0;
47
52
  let lastTps = 0;
48
- let currencyOverride: "¥" | "$" | undefined = undefined;
49
53
 
50
- const mcpStatuses = new Map<string, { connected: boolean; toolCount: number }>();
51
-
52
- const tokenCalculator = new SessionTokenUsageCalculator(() => currencyOverride);
54
+ const tokenCalculator = new SessionTokenUsageCalculator(() => getCostCurrencyOverride());
53
55
 
54
56
  // ---------------------------------------------------------------------------
55
57
  // Helpers
@@ -99,14 +101,6 @@ export default function (pi: ExtensionAPI) {
99
101
  }
100
102
  });
101
103
 
102
- pi.events.on("mcp:status", (raw) => {
103
- const data = raw as { id: string; connected: boolean; toolCount: number };
104
- mcpStatuses.set(data.id, { connected: data.connected, toolCount: data.toolCount });
105
- });
106
- pi.events.on("mcp:disconnect", (raw) => {
107
- mcpStatuses.delete((raw as { id: string }).id);
108
- });
109
-
110
104
  // ---- /status-debug ----
111
105
  pi.registerCommand("status-debug", {
112
106
  description: "Dump session stats to /tmp/pi-status-debug.log + console.error",
@@ -168,17 +162,20 @@ export default function (pi: ExtensionAPI) {
168
162
  handler: async (args, ctx) => {
169
163
  const a = args.trim().toLowerCase();
170
164
  if (a === "auto" || a === "") {
171
- currencyOverride = undefined;
172
- ctx.ui.notify("Currency: auto (deepseek→¥)", "info");
165
+ setCostCurrencyOverride(undefined);
166
+ ctx.ui.notify("Currency: auto (deepseek→¥ or balance currency)", "info");
173
167
  } else if (a === "¥" || a === "rmb" || a === "cny") {
174
- currencyOverride = "¥";
168
+ setCostCurrencyOverride("¥");
175
169
  ctx.ui.notify("Currency: ¥", "info");
176
170
  } else if (a === "$" || a === "usd") {
177
- currencyOverride = "$";
171
+ setCostCurrencyOverride("$");
178
172
  ctx.ui.notify("Currency: $", "info");
179
173
  } else {
180
174
  ctx.ui.notify("Usage: /currency [auto|¥|$]", "warning");
175
+ return;
181
176
  }
177
+ // 币种变更后立即对齐注册表定价(CNY→覆盖为官方价;USD→还原 pi 内置价)
178
+ applyDeepSeekPricingPatch(ctx.modelRegistry);
182
179
  },
183
180
  });
184
181
 
@@ -186,6 +183,7 @@ export default function (pi: ExtensionAPI) {
186
183
  pi.on("model_select", (event) => {
187
184
  if (lastCtx) {
188
185
  lastModel = event.model;
186
+ applyDeepSeekPricingPatch(lastCtx.modelRegistry);
189
187
  void refreshUsage(providers, lastCtx.modelRegistry, event.model);
190
188
  }
191
189
  });
@@ -198,6 +196,9 @@ export default function (pi: ExtensionAPI) {
198
196
  lastElapsedSec = 0;
199
197
  lastTps = 0;
200
198
 
199
+ // 根治:运行时修正 deepseek 定价(pi 内置定价低估约 7 倍),使后续 usage.cost 记录全局正确
200
+ applyDeepSeekPricingPatch(ctx.modelRegistry);
201
+
201
202
  // Prime usage cache on startup (non-blocking)
202
203
  lastCtx = ctx;
203
204
  lastModel = ctx.model;
@@ -235,7 +236,7 @@ export default function (pi: ExtensionAPI) {
235
236
 
236
237
  // Line 1: tokens + cost + provider usage + context + elapsed + tps + mcp
237
238
  lines.push(truncateToWidth(
238
- dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps, mcpStatuses)),
239
+ dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps)),
239
240
  width,
240
241
  dim("..."),
241
242
  ));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-statusline",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Rich custom status bar for pi with token usage, cost, DeepSeek balance, context window, timing, and MCP connection status.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,6 @@
34
34
  "files": [
35
35
  "*.ts",
36
36
  "providers/*.ts",
37
- "quota/*.ts",
38
37
  "README.md",
39
38
  "LICENSE",
40
39
  "CHANGELOG.md"
@@ -1,13 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { DeepSeekResult, ProviderUsageResult } from "../types.ts";
3
3
  import type { UsageProvider } from "./types.ts";
4
- import type { QuotaCalculator } from "../quota/types.ts";
5
- import { DeepSeekQuotaCalculator } from "../quota/deepseek.ts";
4
+ import { applyDeepSeekPricingPatch, setDetectedDeepSeekCurrency } from "../cost.ts";
6
5
  import { scanWeeklyTokens } from "../session-scanner.ts";
7
6
  import { fmt } from "../footer.ts";
8
7
 
9
8
  export class DeepSeekUsageProvider implements UsageProvider {
10
- quotaCalculator: QuotaCalculator = new DeepSeekQuotaCalculator();
11
9
 
12
10
  async fetchUsage(
13
11
  modelRegistry: ExtensionContext["modelRegistry"],
@@ -42,6 +40,13 @@ export class DeepSeekUsageProvider implements UsageProvider {
42
40
  const info = data.balance_infos?.[0];
43
41
  if (!info) return null;
44
42
 
43
+ // 记录账户结算币种:CNY 账户按官方 ¥ 价计费;USD 账户保持 pi 内置 USD 价(不覆盖)
44
+ setDetectedDeepSeekCurrency(info.currency ?? null);
45
+ // 探测到币种后立即重新对齐注册表定价:session_start 时(detectedDeepSeekCurrency 尚为
46
+ // null)已按默认 CNY 覆盖过一次;此处按真实币种纠正,避免 USD 账户停在被高估约 7 倍的
47
+ // CNY 价上,直到下一次 model_select/​/currency 才修正。
48
+ applyDeepSeekPricingPatch(modelRegistry);
49
+
45
50
  const weeklyTokens = scanWeeklyTokens("deepseek");
46
51
 
47
52
  return {
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ProviderUsageResult } from "../types.ts";
3
- import type { QuotaCalculator } from "../quota/types.ts";
4
3
 
5
4
  export interface UsageProvider {
6
5
  fetchUsage(
@@ -9,5 +8,4 @@ export interface UsageProvider {
9
8
  ): Promise<ProviderUsageResult>;
10
9
  formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string;
11
10
  debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void;
12
- quotaCalculator: QuotaCalculator;
13
11
  }
package/providers/zai.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ZaiResult, ProviderUsageResult } from "../types.ts";
3
3
  import type { UsageProvider } from "./types.ts";
4
- import type { QuotaCalculator } from "../quota/types.ts";
5
- import { ZaiQuotaCalculator } from "../quota/zai.ts";
6
4
  import { fmt, formatCountdown, formatWeeklyCountdown } from "../footer.ts";
7
- import { scanWeeklyTokens } from "../session-scanner.ts";
5
+ import { startOfCurrentWeekLocal, startOfNextWeekLocal } from "../week.ts";
8
6
 
9
7
  /** Check if the provider name is a ZAI/GLM variant. */
10
8
  function isZaiProvider(provider: string): boolean {
@@ -12,7 +10,6 @@ function isZaiProvider(provider: string): boolean {
12
10
  }
13
11
 
14
12
  export class ZaiUsageProvider implements UsageProvider {
15
- quotaCalculator: QuotaCalculator = new ZaiQuotaCalculator();
16
13
 
17
14
  async fetchUsage(
18
15
  modelRegistry: ExtensionContext["modelRegistry"],
@@ -75,16 +72,15 @@ export class ZaiUsageProvider implements UsageProvider {
75
72
  const cycleStart = weeklyResetAt - 7 * 24 * 60 * 60 * 1000;
76
73
  weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
77
74
  } else {
78
- // No unit:6 — natural week fallback, same as DeepSeek
75
+ // No unit:6 — natural week fallback. Fetch real usage from the
76
+ // model-usage API for the current natural week (Mon 00:00 local → now)
77
+ // so the figure matches the backend's per-week tally instead of
78
+ // only counting tokens from local session files.
79
79
  isNaturalWeek = true;
80
- weeklyTokens = scanWeeklyTokens(model.provider);
81
-
82
80
  const d = new Date(now);
83
- const dayOfWeek = d.getUTCDay();
84
- const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
85
- weeklyResetAt = Date.UTC(
86
- d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + daysUntilMonday, 0, 0, 0, 0,
87
- );
81
+ const cycleStart = startOfCurrentWeekLocal(d);
82
+ weeklyResetAt = startOfNextWeekLocal(d);
83
+ weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
88
84
  }
89
85
 
90
86
  return {
@@ -114,7 +110,7 @@ export class ZaiUsageProvider implements UsageProvider {
114
110
  // Weekly quota
115
111
  if (zai.isNaturalWeek) {
116
112
  if (zai.weeklyTokens > 0) {
117
- parts.push(`7d:${fmt(zai.weeklyTokens)}`);
113
+ parts.push(`W:${fmt(zai.weeklyTokens)}`);
118
114
  }
119
115
  } else if (zai.weeklyTokens > 0 || zai.weeklyPct > 0) {
120
116
  const weeklyCountdown = formatWeeklyCountdown(zai.weeklyResetAt);
@@ -1,16 +1,25 @@
1
1
  import { readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { startOfCurrentWeekLocal } from "./week.ts";
5
+
6
+ const SCAN_CACHE_TTL_MS = 60_000; // 1 minute
7
+ let scanCache: { providerName: string; sessionsDir: string; weekStartMs: number; total: number; fetchedAt: number } | null = null;
4
8
 
5
9
  export function scanWeeklyTokens(providerName: string): number {
6
10
  const sessionsDir = join(getAgentDir(), "sessions");
7
- const now = new Date();
8
- // Natural week: Monday 00:00 UTC
9
- const dayOfWeek = now.getUTCDay();
10
- const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
11
- const weekStart = new Date(
12
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset, 0, 0, 0, 0),
13
- );
11
+ const weekStartMs = startOfCurrentWeekLocal();
12
+
13
+ // Return cached result if still valid for the same week window and sessions dir
14
+ if (
15
+ scanCache &&
16
+ scanCache.providerName === providerName &&
17
+ scanCache.sessionsDir === sessionsDir &&
18
+ scanCache.weekStartMs === weekStartMs &&
19
+ Date.now() - scanCache.fetchedAt < SCAN_CACHE_TTL_MS
20
+ ) {
21
+ return scanCache.total;
22
+ }
14
23
 
15
24
  let total = 0;
16
25
 
@@ -28,8 +37,8 @@ export function scanWeeklyTokens(providerName: string): number {
28
37
  for (const fname of files) {
29
38
  if (!fname.endsWith(".jsonl")) continue;
30
39
  try {
31
- const fileDate = new Date(fname.slice(0, 10) + "T00:00:00Z");
32
- if (fileDate < weekStart) continue;
40
+ const fileDate = new Date(fname.slice(0, 10) + "T00:00:00");
41
+ if (fileDate.getTime() < weekStartMs) continue;
33
42
  } catch {
34
43
  continue;
35
44
  }
@@ -60,5 +69,6 @@ export function scanWeeklyTokens(providerName: string): number {
60
69
  // sessions dir doesn't exist or is unreadable
61
70
  }
62
71
 
72
+ scanCache = { providerName, sessionsDir, weekStartMs, total, fetchedAt: Date.now() };
63
73
  return total;
64
74
  }
package/token-usage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { TokenUsage, TokenUsageCalculator } from "./types.ts";
4
+ import { deepSeekBilledInCny, deepseekMessageCostCny } from "./cost.ts";
4
5
 
5
6
  export class SessionTokenUsageCalculator implements TokenUsageCalculator {
6
7
  private getCurrencyOverride: () => "\u00a5" | "$" | undefined;
@@ -17,6 +18,8 @@ export class SessionTokenUsageCalculator implements TokenUsageCalculator {
17
18
  let total = 0;
18
19
  let cost = 0;
19
20
 
21
+ const billedInCny = deepSeekBilledInCny();
22
+
20
23
  for (const entry of ctx.sessionManager.getEntries()) {
21
24
  if (entry.type === "message" && entry.message.role === "assistant") {
22
25
  const m = entry.message as AssistantMessage;
@@ -25,7 +28,9 @@ export class SessionTokenUsageCalculator implements TokenUsageCalculator {
25
28
  cacheRead += m.usage.cacheRead;
26
29
  cacheWrite += m.usage.cacheWrite;
27
30
  total += m.usage.totalTokens;
28
- cost += m.usage.cost.total;
31
+ // DeepSeek 仅按 CNY 计价时重算(pi 内置 USD 价低估约 7 倍);USD 结算用户沿用 pi 记录的 cost
32
+ const dsCost = billedInCny ? deepseekMessageCostCny(m.model, m.usage) : null;
33
+ cost += dsCost !== null ? dsCost : (m.usage.cost?.total ?? 0);
29
34
  }
30
35
  }
31
36
 
@@ -37,7 +42,7 @@ export class SessionTokenUsageCalculator implements TokenUsageCalculator {
37
42
  currency = override;
38
43
  } else {
39
44
  const p = ctx.model?.provider ?? "";
40
- if (p.toLowerCase().includes("deepseek")) currency = "\u00a5";
45
+ if (p.toLowerCase().includes("deepseek")) currency = deepSeekBilledInCny() ? "\u00a5" : "$";
41
46
  }
42
47
 
43
48
  return { input, output, cacheRead, cacheWrite, total, hitRate, cost, currency };
package/week.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Natural-week window helpers.
3
+ *
4
+ * Both DeepSeek (local session scan) and ZAI (backend usage API) derive their
5
+ * weekly window from here, so the two providers always agree on the same
6
+ * natural-week boundary — Monday 00:00 in the host's local timezone.
7
+ *
8
+ * Note: UTC was used previously; local timezone is now intentional so the
9
+ * figures line up with how users perceive "this week".
10
+ */
11
+
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Epoch ms of the start of the current natural week (this Monday 00:00 local).
16
+ * `now` is overridable for testing.
17
+ */
18
+ export function startOfCurrentWeekLocal(now: Date = new Date()): number {
19
+ const dayOfWeek = now.getDay(); // 0=Sun .. 6=Sat
20
+ const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
21
+ return new Date(
22
+ now.getFullYear(), now.getMonth(), now.getDate() - mondayOffset,
23
+ 0, 0, 0, 0,
24
+ ).getTime();
25
+ }
26
+
27
+ /** Epoch ms of the start of the next natural week (next Monday 00:00 local). */
28
+ export function startOfNextWeekLocal(now: Date = new Date()): number {
29
+ return startOfCurrentWeekLocal(now) + 7 * DAY_MS;
30
+ }
package/quota/deepseek.ts DELETED
@@ -1,31 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
3
- import { scanWeeklyTokens } from "../session-scanner.ts";
4
-
5
- export class DeepSeekQuotaCalculator implements QuotaCalculator {
6
- async calculate(
7
- _modelRegistry: ExtensionContext["modelRegistry"],
8
- _model: ExtensionContext["model"],
9
- ): Promise<QuotaInfo> {
10
- const weeklyTokens = scanWeeklyTokens("deepseek");
11
-
12
- // Next Monday 00:00 UTC
13
- const now = new Date();
14
- const dayOfWeek = now.getUTCDay();
15
- const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
16
- const nextMonday = new Date(
17
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
18
- );
19
-
20
- const weekly: WeeklyQuota = {
21
- usedTokens: weeklyTokens,
22
- resetAt: nextMonday.getTime(),
23
- crossesNaturalWeek: false,
24
- };
25
-
26
- return {
27
- rolling: null,
28
- weekly,
29
- };
30
- }
31
- }
package/quota/types.ts DELETED
@@ -1,34 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- /** Rolling quota window (e.g. ZAI 5-hour token quota) */
4
- export interface RollingQuota {
5
- /** Usage percentage 0–100 */
6
- usedPct: number;
7
- /** Epoch ms when the window resets */
8
- resetAt: number;
9
- }
10
-
11
- /** Weekly subscription quota */
12
- export interface WeeklyQuota {
13
- /** Total tokens used in the subscription week */
14
- usedTokens: number;
15
- /** Epoch ms when the subscription week resets */
16
- resetAt: number;
17
- /** Whether resetAt crosses a natural week boundary */
18
- crossesNaturalWeek: boolean;
19
- /** Usage percentage from unit:6 TOKENS_LIMIT (0 if natural week) */
20
- weeklyPct?: number;
21
- }
22
-
23
- /** Provider-specific quota. Fields are null when the provider doesn't support them. */
24
- export interface QuotaInfo {
25
- rolling: RollingQuota | null;
26
- weekly: WeeklyQuota | null;
27
- }
28
-
29
- export interface QuotaCalculator {
30
- calculate(
31
- modelRegistry: ExtensionContext["modelRegistry"],
32
- model: ExtensionContext["model"],
33
- ): Promise<QuotaInfo>;
34
- }
package/quota/zai.ts DELETED
@@ -1,90 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
3
- import { scanWeeklyTokens } from "../session-scanner.ts";
4
-
5
- export class ZaiQuotaCalculator implements QuotaCalculator {
6
- async calculate(
7
- modelRegistry: ExtensionContext["modelRegistry"],
8
- model: ExtensionContext["model"],
9
- ): Promise<QuotaInfo> {
10
- if (!model || (model.provider !== "zai" && model.provider !== "zai-coding-cn")) {
11
- return { rolling: null, weekly: null };
12
- }
13
-
14
- const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
15
- if (!apiKey) return { rolling: null, weekly: null };
16
-
17
- const defaultOrigin = model.provider === "zai-coding-cn"
18
- ? "https://open.bigmodel.cn"
19
- : "https://api.z.ai";
20
- let origin = defaultOrigin;
21
- try { origin = new URL(model.baseUrl ?? defaultOrigin).origin; } catch { /* use default */ }
22
-
23
- const headers: Record<string, string> = {
24
- Authorization: apiKey,
25
- "Accept-Language": "en-US,en",
26
- "Content-Type": "application/json",
27
- };
28
-
29
- let rolling: QuotaInfo["rolling"] = null;
30
- let weekly: QuotaInfo["weekly"] = null;
31
-
32
- try {
33
- const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
34
- headers,
35
- signal: AbortSignal.timeout(5000),
36
- });
37
- if (quotaRes.ok) {
38
- const quotaData = (await quotaRes.json()) as {
39
- data?: {
40
- limits?: Array<{
41
- type: string;
42
- unit?: number;
43
- percentage?: number;
44
- nextResetTime?: number;
45
- }>;
46
- };
47
- };
48
-
49
- const limits = quotaData.data?.limits ?? [];
50
- const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
51
- const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
52
-
53
- if (fiveHourLimit) {
54
- rolling = {
55
- usedPct: fiveHourLimit.percentage ?? 0,
56
- resetAt: fiveHourLimit.nextResetTime ?? 0,
57
- };
58
- }
59
-
60
- if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
61
- // unit:6 — weekly quota from API
62
- weekly = {
63
- usedTokens: 0, // actual token count fetched separately by provider
64
- resetAt: weeklyLimit.nextResetTime ?? 0,
65
- crossesNaturalWeek: true,
66
- weeklyPct: weeklyLimit.percentage ?? 0,
67
- };
68
- } else {
69
- // No unit:6 — natural week fallback
70
- const weeklyTokens = scanWeeklyTokens(model.provider);
71
- const now = new Date();
72
- const dayOfWeek = now.getUTCDay();
73
- const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
74
- const nextMonday = new Date(
75
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
76
- );
77
- weekly = {
78
- usedTokens: weeklyTokens,
79
- resetAt: nextMonday.getTime(),
80
- crossesNaturalWeek: false,
81
- };
82
- }
83
- }
84
- } catch {
85
- // quota unavailable
86
- }
87
-
88
- return { rolling, weekly };
89
- }
90
- }