@fyeeme/pi-statusline 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2025-07-15
11
+
12
+ ### Fixed
13
+
14
+ - `getCachedUsage` crash when TTL expires and model provider has no registered UsageProvider (TypeError on null)
15
+ - `scanWeeklyTokens` UTC/local timezone mismatch: file date parsing now uses local timezone to match `startOfCurrentWeekLocal`
16
+ - Unhandled rejection in `refreshUsage` fire-and-forget callers: added catch-all error handler
17
+
18
+ ### Changed
19
+
20
+ - Background usage refresh now deduplicates concurrent TTL-expired callers via memoized `refreshInFlight` promise
21
+ - Weekly token scan results cached for 1 minute to reduce redundant filesystem I/O
22
+
23
+ ### Removed
24
+
25
+ - MCP server status segment from footer and its event listeners (`mcp:status`/`mcp:disconnect`): no pi version emits these events
26
+
27
+ ## [1.0.1] - 2026-06-19
28
+
29
+ ### Fixed
30
+
31
+ - 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.
32
+
33
+ ### Changed
34
+
35
+ - 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`.
36
+
37
+ ### Removed
38
+
39
+ - `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.
40
+
10
41
  ## [1.0.1] - 2026-06-19
11
42
 
12
43
  ### Fixed
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/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
@@ -47,8 +47,6 @@ let lastElapsedSec = 0;
47
47
  let lastTps = 0;
48
48
  let currencyOverride: "¥" | "$" | undefined = undefined;
49
49
 
50
- const mcpStatuses = new Map<string, { connected: boolean; toolCount: number }>();
51
-
52
50
  const tokenCalculator = new SessionTokenUsageCalculator(() => currencyOverride);
53
51
 
54
52
  // ---------------------------------------------------------------------------
@@ -99,14 +97,6 @@ export default function (pi: ExtensionAPI) {
99
97
  }
100
98
  });
101
99
 
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
100
  // ---- /status-debug ----
111
101
  pi.registerCommand("status-debug", {
112
102
  description: "Dump session stats to /tmp/pi-status-debug.log + console.error",
@@ -235,7 +225,7 @@ export default function (pi: ExtensionAPI) {
235
225
 
236
226
  // Line 1: tokens + cost + provider usage + context + elapsed + tps + mcp
237
227
  lines.push(truncateToWidth(
238
- dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps, mcpStatuses)),
228
+ dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps)),
239
229
  width,
240
230
  dim("..."),
241
231
  ));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-statusline",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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,10 @@
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";
6
4
  import { scanWeeklyTokens } from "../session-scanner.ts";
7
5
  import { fmt } from "../footer.ts";
8
6
 
9
7
  export class DeepSeekUsageProvider implements UsageProvider {
10
- quotaCalculator: QuotaCalculator = new DeepSeekQuotaCalculator();
11
8
 
12
9
  async fetchUsage(
13
10
  modelRegistry: ExtensionContext["modelRegistry"],
@@ -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/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
- }