glm-quota-line 0.7.0 → 0.8.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
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - Added progressive refresh backoff: new sessions start at 3-minute intervals, advance to 5 minutes after 5 refreshes, and cap at 10 minutes after 5 more — giving new users fast feedback while reducing API pressure in long sessions
6
+ - Added low-quota override: when remaining quota drops below 30%, the refresh interval is forced back to 3 minutes regardless of current tier, so users see accurate data when it matters most
7
+ - Low-quota refreshes do not advance the tier counter, preserving the previous tier when quota recovers
8
+ - SessionStart (startup / resume / clear) resets the tier to level 0, ensuring fresh sessions always start responsive
9
+ - Rate-limited and failed responses no longer advance the tier counter
10
+ - Old cache files without tier fields automatically migrate to tier 0
11
+ - Simplified README quick start to two commands with style config clearly marked as optional
12
+
3
13
  ## 0.7.0
4
14
 
5
15
  - Added `--version` and `version` so the installed CLI version is visible directly from the command line
package/README.en.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <h1 align="center">glm-quota-line</h1>
2
2
 
3
3
  <p align="center">
4
- One-command install, zero dependencies. Shows accurate GLM quota balance and reset time in the Claude Code status line — data from the official API, not estimated.
4
+ One-command install, zero dependencies. Shows accurate Zhipu GLM Coding Plan quota balance and reset time in the Claude Code status line — data from the official API, not estimated.
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -15,7 +15,7 @@
15
15
  <a href="./README.md">简体中文</a>
16
16
  </p>
17
17
 
18
- `glm-quota-line` reads the GLM quota API, caches results, and prints a short status line for Claude Code's `statusLine.command`. A `SessionStart` hook pre-refreshes the cache so new sessions never show stale data.
18
+ `glm-quota-line` reads the Zhipu GLM Coding Plan quota API, caches results, and prints a short status line for Claude Code's `statusLine.command`. A `SessionStart` hook pre-refreshes the cache so new sessions never show stale data.
19
19
 
20
20
  ## Features
21
21
 
@@ -25,13 +25,20 @@
25
25
  - **Zero dependencies** — no runtime deps, single-purpose CLI
26
26
  - **Smart caching** — refreshes by session, TTL, and token usage; supports manual auth overrides for proxy/gateway setups
27
27
 
28
- ## Installation
28
+ ## Quick Start
29
29
 
30
30
  ```bash
31
31
  npm install -g glm-quota-line
32
+ glm-quota-line install
33
+ ```
34
+
35
+ That's it — works out of the box. Default output (`style=text`, `theme=ansi`, `palette=dark`):
36
+
37
+ ```text
38
+ GLM Lite | 5h 91% | week 47% | reset 14:47
32
39
  ```
33
40
 
34
- Global install is recommended because `install` writes stable executable paths into `statusLine.command` and `SessionStart` hooks. `npx` works for one-off previews.
41
+ Both the 5-hour quota (primary) and weekly quota (secondary) are shown with automatic reset time.
35
42
 
36
43
  Upgrade:
37
44
 
@@ -40,28 +47,30 @@ npm install -g glm-quota-line
40
47
  glm-quota-line check-update
41
48
  ```
42
49
 
43
- ## Quick Start
50
+ ## Custom Styles
51
+
52
+ All style options are optional — adjust only if needed:
44
53
 
45
54
  ```bash
46
- glm-quota-line install
47
- glm-quota-line config set style bar
48
- glm-quota-line config set theme ansi
49
- glm-quota-line config set palette dark
55
+ glm-quota-line config set style bar # layout: text / compact / bar
56
+ glm-quota-line config set theme ansi # color: plain / ansi
57
+ glm-quota-line config set palette dark # palette: dark / mono (only when theme=ansi)
50
58
  ```
51
59
 
52
- ## Output
60
+ Different `style` outputs:
53
61
 
54
62
  ```text
63
+ # style=text (default)
55
64
  GLM Lite | 5h 91% | week 47% | reset 14:47
56
65
 
66
+ # style=compact
57
67
  GLM 5h 91% W 47% | 14:47
58
68
 
69
+ # style=bar
59
70
  GLM Lite █░░░░░░░░░ 91% | W 47% | 14:47
60
71
  ```
61
72
 
62
- Both the 5-hour quota (primary) and weekly quota (secondary) are shown with automatic reset time.
63
-
64
- ## Styles & Colors
73
+ Recommended combinations:
65
74
 
66
75
  | Use case | Config |
67
76
  | -------------- | --------------------------------------------- |
@@ -69,10 +78,6 @@ Both the 5-hour quota (primary) and weekly quota (secondary) are shown with auto
69
78
  | Light terminal | `style=compact`, `theme=ansi`, `palette=mono` |
70
79
  | Plain fallback | any `style`, `theme=plain` |
71
80
 
72
- `style` controls layout (`text` / `compact` / `bar`). `theme` enables ANSI color. `palette` only applies when `theme=ansi`.
73
-
74
- Set `NO_COLOR=1` to force plain output.
75
-
76
81
  ## Custom Auth
77
82
 
78
83
  If Claude Code runs behind a gateway or proxy and the injected `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` are not the real values:
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <h1 align="center">glm-quota-line</h1>
2
2
 
3
3
  <p align="center">
4
- 一行命令安装、零依赖,在 Claude Code 状态栏实时显示 GLM 配额余额与重置时间。数据来自官方接口,精准非估算。
4
+ 一行命令安装、零依赖,在 Claude Code 状态栏实时显示智谱(Zhipu)GLM Coding Plan 配额余额与重置时间。数据来自官方接口,精准非估算。
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -15,7 +15,7 @@
15
15
  <a href="./README.en.md">English</a>
16
16
  </p>
17
17
 
18
- `glm-quota-line` 读取 GLM 配额接口、缓存结果,并输出一行适合 Claude Code `statusLine.command` 的状态文本。通过 `SessionStart` hook 预刷新缓存,新会话不会显示旧配额。
18
+ `glm-quota-line` 读取智谱(Zhipu)GLM Coding Plan 配额接口、缓存结果,并输出一行适合 Claude Code `statusLine.command` 的状态文本。通过 `SessionStart` hook 预刷新缓存,新会话不会显示旧配额。
19
19
 
20
20
  ## 功能
21
21
 
@@ -25,43 +25,52 @@
25
25
  - **零依赖** — 无运行时依赖,单一用途
26
26
  - **智能缓存** — 按会话、TTL 和 token 用量刷新;支持代理/网关手动覆盖鉴权
27
27
 
28
- ## 安装
28
+ ## 快速开始
29
29
 
30
30
  ```bash
31
31
  npm install -g glm-quota-line
32
+ glm-quota-line install
33
+ ```
34
+
35
+ 两步完成,开箱即用。默认输出(`style=text`,`theme=ansi`,`palette=dark`):
36
+
37
+ ```text
38
+ GLM Lite | 5h 91% | week 47% | reset 14:47
32
39
  ```
33
40
 
34
- 推荐全局安装,`install` 会将稳定的可执行路径写入 `statusLine.command` 和 `SessionStart` hooks。`npx` 适合一次性试跑。
41
+ 同时显示 5 小时配额(主要)和周配额(次要),并自动展示重置时间。
35
42
 
36
- 升级:
43
+ 升级版本:
37
44
 
38
45
  ```bash
39
46
  npm install -g glm-quota-line
40
47
  glm-quota-line check-update
41
48
  ```
42
49
 
43
- ## 快速开始
50
+ ## 自定义样式
51
+
52
+ 所有样式配置均为可选,按需调整:
44
53
 
45
54
  ```bash
46
- glm-quota-line install
47
- glm-quota-line config set style bar
48
- glm-quota-line config set theme ansi
49
- glm-quota-line config set palette dark
55
+ glm-quota-line config set style bar # 布局: text / compact / bar
56
+ glm-quota-line config set theme ansi # 配色: plain / ansi
57
+ glm-quota-line config set palette dark # 色板: dark / mono(仅 theme=ansi 时生效)
50
58
  ```
51
59
 
52
- ## 输出示例
60
+ 不同 style 效果:
53
61
 
54
62
  ```text
63
+ # style=text(默认)
55
64
  GLM Lite | 5h 91% | week 47% | reset 14:47
56
65
 
66
+ # style=compact
57
67
  GLM 5h 91% W 47% | 14:47
58
68
 
69
+ # style=bar
59
70
  GLM Lite █░░░░░░░░░ 91% | W 47% | 14:47
60
71
  ```
61
72
 
62
- 同时显示 5 小时配额(主要)和周配额(次要),并自动展示重置时间。
63
-
64
- ## 样式与配色
73
+ 推荐搭配:
65
74
 
66
75
  | 使用场景 | 配置 |
67
76
  | ---------- | --------------------------------------------- |
@@ -69,10 +78,6 @@ GLM Lite █░░░░░░░░░ 91% | W 47% | 14:47
69
78
  | 浅色终端 | `style=compact`, `theme=ansi`, `palette=mono` |
70
79
  | 纯文本回退 | 任意 `style`,`theme=plain` |
71
80
 
72
- `style` 控制布局(`text` / `compact` / `bar`),`theme` 控制是否启用 ANSI 颜色,`palette` 仅在 `theme=ansi` 时生效。
73
-
74
- 设置 `NO_COLOR=1` 可强制关闭颜色。
75
-
76
81
  ## 自定义鉴权
77
82
 
78
83
  如果 Claude Code 运行在代理或网关后面,注入的 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` 不是实际值:
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "glm-quota-line",
3
- "version": "0.7.0",
4
- "description": "Claude Code status line helper for GLM Coding Plan quota.",
3
+ "version": "0.8.0",
4
+ "description": "Zhipu GLM Coding Plan quota monitor for Claude Code status line.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "caojian",
8
8
  "preferGlobal": true,
9
9
  "keywords": [
10
10
  "claude-code",
11
- "statusline",
12
11
  "glm",
12
+ "zhipu",
13
+ "z.ai",
14
+ "coding-plan",
13
15
  "quota",
14
- "cli"
16
+ "statusline"
15
17
  ],
16
18
  "repository": {
17
19
  "type": "git",
@@ -32,7 +32,7 @@ function isToolManagedStatusLine(statusLine) {
32
32
  return isToolManagedCommand(statusLine?.command);
33
33
  }
34
34
 
35
- export function getStatusLineEntryPath() {
35
+ function getStatusLineEntryPath() {
36
36
  return path.resolve(__dirname, "..", "cli", "index.js");
37
37
  }
38
38
 
@@ -53,38 +53,41 @@ function isToolManagedHook(hook) {
53
53
  return hook?.type === "command" && isToolManagedCommand(hook.command);
54
54
  }
55
55
 
56
- function removeManagedSessionStartHooks(settings) {
57
- if (!settings.hooks || typeof settings.hooks !== "object") {
58
- return false;
59
- }
60
-
61
- const existingGroups = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : null;
62
- if (!existingGroups) {
63
- return false;
64
- }
56
+ function filterManagedHooksFromGroups(groups) {
57
+ let hadManaged = false;
58
+ const filtered = [];
65
59
 
66
- let removed = false;
67
- const nextGroups = [];
68
-
69
- for (const group of existingGroups) {
60
+ for (const group of groups) {
70
61
  if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
71
- nextGroups.push(group);
62
+ filtered.push(group);
72
63
  continue;
73
64
  }
74
65
 
75
66
  const nextHooks = group.hooks.filter((hook) => !isToolManagedHook(hook));
76
67
  if (nextHooks.length !== group.hooks.length) {
77
- removed = true;
68
+ hadManaged = true;
78
69
  }
79
70
 
80
71
  if (nextHooks.length > 0) {
81
- nextGroups.push({
82
- ...group,
83
- hooks: nextHooks
84
- });
72
+ filtered.push({ ...group, hooks: nextHooks });
85
73
  }
86
74
  }
87
75
 
76
+ return { groups: filtered, hadManaged };
77
+ }
78
+
79
+ function removeManagedSessionStartHooks(settings) {
80
+ if (!settings.hooks || typeof settings.hooks !== "object") {
81
+ return false;
82
+ }
83
+
84
+ const existingGroups = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : null;
85
+ if (!existingGroups) {
86
+ return false;
87
+ }
88
+
89
+ const { groups: nextGroups, hadManaged: removed } = filterManagedHooksFromGroups(existingGroups);
90
+
88
91
  if (nextGroups.length > 0) {
89
92
  settings.hooks.SessionStart = nextGroups;
90
93
  } else {
@@ -101,21 +104,10 @@ function removeManagedSessionStartHooks(settings) {
101
104
  function installManagedSessionStartHooks(settings, command) {
102
105
  const existingGroups =
103
106
  settings.hooks && typeof settings.hooks === "object" && Array.isArray(settings.hooks.SessionStart)
104
- ? settings.hooks.SessionStart.map((group) =>
105
- group && typeof group === "object" ? { ...group, hooks: Array.isArray(group.hooks) ? [...group.hooks] : group.hooks } : group
106
- )
107
+ ? settings.hooks.SessionStart
107
108
  : [];
108
109
 
109
- const nextGroups = existingGroups.map((group) => {
110
- if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
111
- return group;
112
- }
113
-
114
- return {
115
- ...group,
116
- hooks: group.hooks.filter((hook) => !isToolManagedHook(hook))
117
- };
118
- });
110
+ const { groups: nextGroups } = filterManagedHooksFromGroups(existingGroups);
119
111
 
120
112
  for (const matcher of SESSION_START_MATCHERS) {
121
113
  const targetGroup = nextGroups.find(
@@ -123,21 +115,13 @@ function installManagedSessionStartHooks(settings, command) {
123
115
  );
124
116
 
125
117
  if (targetGroup) {
126
- targetGroup.hooks.push({
127
- type: "command",
128
- command
129
- });
118
+ targetGroup.hooks.push({ type: "command", command });
130
119
  continue;
131
120
  }
132
121
 
133
122
  nextGroups.push({
134
123
  matcher,
135
- hooks: [
136
- {
137
- type: "command",
138
- command
139
- }
140
- ]
124
+ hooks: [{ type: "command", command }]
141
125
  });
142
126
  }
143
127
 
@@ -3,7 +3,7 @@ import { resolveQuotaStatus } from "../core/quota/service.js";
3
3
  import { readStatusLineInput } from "./input.js";
4
4
  import { readToolConfig } from "./settings.js";
5
5
 
6
- export const SESSION_START_MATCHERS = ["startup", "resume", "clear", "compact"];
6
+ export const SESSION_START_MATCHERS = ["startup", "resume", "clear"];
7
7
 
8
8
  export async function refreshQuotaOnSessionStart(options = {}) {
9
9
  const hookInput = await readStatusLineInput(options.stdin ?? process.stdin);
@@ -12,10 +12,7 @@ export async function refreshQuotaOnSessionStart(options = {}) {
12
12
  const resolveQuotaStatusFn = options.resolveQuotaStatusFn ?? resolveQuotaStatus;
13
13
  const config = {
14
14
  ...loadConfigFn(options.env ?? process.env, userConfig),
15
- sessionId: hookInput?.session_id || "",
16
- // SessionStart runs before statusLine has token totals, so treat a new session
17
- // as starting from zero to preserve later token-threshold refreshes.
18
- observedTokens: 0
15
+ sessionId: hookInput?.session_id || ""
19
16
  };
20
17
 
21
18
  return resolveQuotaStatusFn(config, {
@@ -10,8 +10,9 @@ import {
10
10
  isValidTheme
11
11
  } from "../shared/constants.js";
12
12
  import { readJsonFile, writeJsonFile } from "../shared/jsonFile.js";
13
+ import { normalizeOptionalString } from "../shared/config.js";
13
14
 
14
- export function getClaudeDir() {
15
+ function getClaudeDir() {
15
16
  return path.join(os.homedir(), ".claude");
16
17
  }
17
18
 
@@ -23,15 +24,6 @@ export function getToolConfigPath() {
23
24
  return path.join(getClaudeDir(), "glm-quota-line.json");
24
25
  }
25
26
 
26
- function normalizeOptionalString(value) {
27
- if (typeof value !== "string") {
28
- return undefined;
29
- }
30
-
31
- const trimmed = value.trim();
32
- return trimmed || undefined;
33
- }
34
-
35
27
  function redactSecret(value) {
36
28
  if (!value) {
37
29
  return value;
package/src/cli/index.js CHANGED
@@ -67,14 +67,6 @@ Examples:
67
67
  Environment:
68
68
  ANTHROPIC_AUTH_TOKEN
69
69
  ANTHROPIC_BASE_URL
70
- GLM_DISPLAY_MODE
71
- GLM_STYLE
72
- GLM_BAR_WIDTH
73
- GLM_TIMEOUT_MS
74
- GLM_CACHE_TTL_MS
75
- GLM_THEME
76
- GLM_PALETTE
77
- NO_COLOR
78
70
  `);
79
71
  }
80
72
 
@@ -87,21 +79,6 @@ function getStoredDisplayOverrides(userConfig) {
87
79
  };
88
80
  }
89
81
 
90
- function asNonNegativeNumber(value) {
91
- return Number.isFinite(value) && value >= 0 ? value : null;
92
- }
93
-
94
- function getObservedTokens(statusLineInput) {
95
- const inputTokens = asNonNegativeNumber(statusLineInput?.context_window?.total_input_tokens);
96
- const outputTokens = asNonNegativeNumber(statusLineInput?.context_window?.total_output_tokens);
97
-
98
- if (inputTokens === null && outputTokens === null) {
99
- return null;
100
- }
101
-
102
- return (inputTokens ?? 0) + (outputTokens ?? 0);
103
- }
104
-
105
82
  export async function main() {
106
83
  try {
107
84
  const args = parseArgs();
@@ -128,8 +105,7 @@ export async function main() {
128
105
  // Display config precedence is env defaults -> persisted config -> CLI flags.
129
106
  ...getStoredDisplayOverrides(userConfig),
130
107
  ...args,
131
- sessionId: statusLineInput?.session_id || "",
132
- observedTokens: getObservedTokens(statusLineInput)
108
+ sessionId: statusLineInput?.session_id || ""
133
109
  };
134
110
  const quotaStatus = await resolveQuotaStatus(config);
135
111
 
package/src/cli/update.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { DEFAULT_TIMEOUT_MS } from "../shared/constants.js";
1
2
  import { readPackageInfo } from "../shared/packageInfo.js";
2
3
 
3
- const DEFAULT_TIMEOUT_MS = 5000;
4
4
  const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
5
5
 
6
6
  function parseVersion(version) {
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { asFiniteNumber } from "./parse.js";
5
+
4
6
  function isValidQuotaShape(value) {
5
7
  if (!value || typeof value.key !== "string") {
6
8
  return false;
@@ -69,10 +71,6 @@ function isSuccessCacheShape(value) {
69
71
  return isMultiQuotaSuccessCacheShape(value) || isLegacyPercentSuccessCacheShape(value);
70
72
  }
71
73
 
72
- function asFiniteNumber(value) {
73
- return Number.isFinite(value) ? value : null;
74
- }
75
-
76
74
  function normalizeCache(parsed) {
77
75
  if (!parsed || typeof parsed !== "object") {
78
76
  return null;
@@ -81,9 +79,7 @@ function normalizeCache(parsed) {
81
79
  const result = isSuccessCacheShape(parsed.result) ? parsed.result : null;
82
80
  const savedAt = asFiniteNumber(parsed.savedAt);
83
81
  const lastAttemptAt = asFiniteNumber(parsed.lastAttemptAt) ?? savedAt;
84
- const lastObservedTokensAtFetch = asFiniteNumber(parsed.lastObservedTokensAtFetch);
85
82
  const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : "";
86
- const skipNextTokenTrigger = parsed.skipNextTokenTrigger === true;
87
83
 
88
84
  if (result && savedAt === null) {
89
85
  return null;
@@ -93,12 +89,21 @@ function normalizeCache(parsed) {
93
89
  return null;
94
90
  }
95
91
 
92
+ const refreshCount =
93
+ typeof parsed.refreshCount === "number" && Number.isFinite(parsed.refreshCount)
94
+ ? Math.max(0, Math.floor(parsed.refreshCount))
95
+ : 0;
96
+ const tierIndex =
97
+ typeof parsed.tierIndex === "number" && Number.isFinite(parsed.tierIndex)
98
+ ? Math.max(0, Math.floor(parsed.tierIndex))
99
+ : 0;
100
+
96
101
  return {
97
102
  savedAt,
98
103
  lastAttemptAt,
99
- lastObservedTokensAtFetch,
100
104
  sessionId,
101
- skipNextTokenTrigger,
105
+ refreshCount,
106
+ tierIndex,
102
107
  result
103
108
  };
104
109
  }
@@ -109,10 +114,8 @@ async function writeCache(cacheFilePath, cache) {
109
114
  ...(Number.isFinite(cache.savedAt) ? { savedAt: cache.savedAt } : {}),
110
115
  ...(Number.isFinite(cache.lastAttemptAt) ? { lastAttemptAt: cache.lastAttemptAt } : {}),
111
116
  ...(cache.sessionId ? { sessionId: cache.sessionId } : {}),
112
- ...(Number.isFinite(cache.lastObservedTokensAtFetch)
113
- ? { lastObservedTokensAtFetch: cache.lastObservedTokensAtFetch }
114
- : {}),
115
- ...(cache.skipNextTokenTrigger ? { skipNextTokenTrigger: true } : {}),
117
+ refreshCount: cache.refreshCount ?? 0,
118
+ tierIndex: cache.tierIndex ?? 0,
116
119
  ...(cache.result ? { result: cache.result } : {})
117
120
  },
118
121
  null,
@@ -139,8 +142,8 @@ export async function writeSuccessCache(cacheFilePath, result, options = {}) {
139
142
  savedAt: now,
140
143
  lastAttemptAt: now,
141
144
  sessionId: options.sessionId || "",
142
- lastObservedTokensAtFetch: asFiniteNumber(options.observedTokens),
143
- skipNextTokenTrigger: false,
145
+ refreshCount: options.refreshCount ?? 0,
146
+ tierIndex: options.tierIndex ?? 0,
144
147
  result
145
148
  });
146
149
  }
@@ -152,21 +155,8 @@ export async function writeRateLimitedCache(cacheFilePath, cached, options = {})
152
155
  savedAt: cached?.savedAt ?? null,
153
156
  lastAttemptAt: now,
154
157
  sessionId: options.sessionId || cached?.sessionId || "",
155
- lastObservedTokensAtFetch:
156
- asFiniteNumber(options.observedTokens) ?? cached?.lastObservedTokensAtFetch ?? null,
157
- skipNextTokenTrigger: true,
158
- result: cached?.result ?? null
159
- });
160
- }
161
-
162
- export async function writeSkippedTokenTriggerCache(cacheFilePath, cached, options = {}) {
163
- await writeCache(cacheFilePath, {
164
- savedAt: cached?.savedAt ?? null,
165
- lastAttemptAt: cached?.lastAttemptAt ?? null,
166
- sessionId: options.sessionId || cached?.sessionId || "",
167
- lastObservedTokensAtFetch:
168
- asFiniteNumber(options.observedTokens) ?? cached?.lastObservedTokensAtFetch ?? null,
169
- skipNextTokenTrigger: false,
158
+ refreshCount: cached?.refreshCount ?? 0,
159
+ tierIndex: cached?.tierIndex ?? 0,
170
160
  result: cached?.result ?? null
171
161
  });
172
162
  }
@@ -1,4 +1,4 @@
1
- function asFiniteNumber(value) {
1
+ export function asFiniteNumber(value) {
2
2
  return Number.isFinite(value) ? value : null;
3
3
  }
4
4
 
@@ -1,74 +1,74 @@
1
1
  import {
2
2
  readCache,
3
3
  writeRateLimitedCache,
4
- writeSkippedTokenTriggerCache,
5
4
  writeSuccessCache
6
5
  } from "./cache.js";
7
6
  import { fetchQuota } from "./fetch.js";
8
7
  import { parseQuotaResponse } from "./parse.js";
9
-
10
- const TOKEN_REFRESH_THRESHOLD = 300_000;
11
-
12
- function normalizeObservedTokens(value) {
13
- return Number.isFinite(value) && value >= 0 ? value : null;
14
- }
8
+ import {
9
+ LOW_QUOTA_THRESHOLD,
10
+ REFRESH_TIERS
11
+ } from "../../shared/constants.js";
15
12
 
16
13
  function getCachedStatus(cached) {
17
14
  return cached?.result ?? { kind: "unavailable" };
18
15
  }
19
16
 
20
- function shouldRefreshQuota(cached, cacheTtlMs, now, sessionId, observedTokens) {
21
- if (!cached) {
22
- return { kind: "fetch" };
17
+ function getEffectiveTtlMs(tierIndex, leftPercent) {
18
+ if (Number.isFinite(leftPercent) && leftPercent < LOW_QUOTA_THRESHOLD) {
19
+ return REFRESH_TIERS[0].ttlMs;
23
20
  }
24
21
 
25
- if (sessionId && cached.sessionId !== sessionId) {
26
- return { kind: "fetch" };
27
- }
22
+ const tier = REFRESH_TIERS[tierIndex];
23
+ return tier ? tier.ttlMs : REFRESH_TIERS[REFRESH_TIERS.length - 1].ttlMs;
24
+ }
28
25
 
29
- if (cached.lastAttemptAt === null || now - cached.lastAttemptAt >= cacheTtlMs) {
30
- return { kind: "fetch" };
26
+ function advanceTier(refreshCount, tierIndex) {
27
+ const nextCount = refreshCount + 1;
28
+ const tier = REFRESH_TIERS[tierIndex] ?? REFRESH_TIERS[REFRESH_TIERS.length - 1];
29
+
30
+ if (nextCount >= tier.maxRefreshes && tierIndex < REFRESH_TIERS.length - 1) {
31
+ return { refreshCount: 1, tierIndex: tierIndex + 1 };
31
32
  }
32
33
 
33
- if (observedTokens === null || cached.lastObservedTokensAtFetch === null) {
34
- return { kind: "cache" };
34
+ return { refreshCount: nextCount, tierIndex };
35
+ }
36
+
37
+ function shouldRefreshQuota(cached, cacheTtlMs, now, sessionId) {
38
+ if (!cached) {
39
+ return true;
35
40
  }
36
41
 
37
- if (observedTokens - cached.lastObservedTokensAtFetch < TOKEN_REFRESH_THRESHOLD) {
38
- return { kind: "cache" };
42
+ if (sessionId && cached.sessionId !== sessionId) {
43
+ return true;
39
44
  }
40
45
 
41
- if (cached.skipNextTokenTrigger) {
42
- return { kind: "skip_token_trigger" };
46
+ if (cached.lastAttemptAt === null || now - cached.lastAttemptAt >= cacheTtlMs) {
47
+ return true;
43
48
  }
44
49
 
45
- return { kind: "fetch" };
50
+ return false;
46
51
  }
47
52
 
48
53
  export async function resolveQuotaStatus(config, options = {}) {
49
54
  const now = options.now ?? Date.now();
50
55
  const fetchImpl = options.fetchImpl;
51
56
  const sessionId = config.sessionId || "";
52
- const observedTokens = normalizeObservedTokens(config.observedTokens);
53
57
 
54
58
  if (!config.authorization) {
55
59
  return { kind: "auth_error" };
56
60
  }
57
61
 
58
62
  const cached = await readCache(config.cacheFilePath);
59
- const refreshDecision = options.forceRefresh
60
- ? { kind: "fetch" }
61
- : shouldRefreshQuota(cached, config.cacheTtlMs, now, sessionId, observedTokens);
63
+ const tierIndex = cached?.tierIndex ?? 0;
64
+ const leftPercent = cached?.result?.leftPercent;
65
+ const effectiveTtlMs = getEffectiveTtlMs(tierIndex, leftPercent);
62
66
 
63
- if (refreshDecision.kind === "cache") {
64
- return getCachedStatus(cached);
65
- }
67
+ const shouldRefresh = options.forceRefresh
68
+ ? true
69
+ : shouldRefreshQuota(cached, effectiveTtlMs, now, sessionId);
66
70
 
67
- if (refreshDecision.kind === "skip_token_trigger") {
68
- await writeSkippedTokenTriggerCache(config.cacheFilePath, cached, {
69
- sessionId,
70
- observedTokens
71
- });
71
+ if (!shouldRefresh) {
72
72
  return getCachedStatus(cached);
73
73
  }
74
74
 
@@ -76,10 +76,23 @@ export async function resolveQuotaStatus(config, options = {}) {
76
76
  const parsed = parseQuotaResponse(response);
77
77
 
78
78
  if (parsed.kind === "success") {
79
+ const isNewSession = Boolean(sessionId) && cached?.sessionId !== sessionId;
80
+ const isLowQuota = Number.isFinite(parsed.leftPercent) && parsed.leftPercent < LOW_QUOTA_THRESHOLD;
81
+
82
+ let nextTier;
83
+ if (isNewSession) {
84
+ nextTier = { refreshCount: 1, tierIndex: 0 };
85
+ } else if (isLowQuota) {
86
+ nextTier = { refreshCount: cached?.refreshCount ?? 0, tierIndex };
87
+ } else {
88
+ nextTier = advanceTier(cached?.refreshCount ?? 0, tierIndex);
89
+ }
90
+
79
91
  await writeSuccessCache(config.cacheFilePath, parsed, {
80
92
  now,
81
93
  sessionId,
82
- observedTokens
94
+ refreshCount: nextTier.refreshCount,
95
+ tierIndex: nextTier.tierIndex
83
96
  });
84
97
  return parsed;
85
98
  }
@@ -91,8 +104,7 @@ export async function resolveQuotaStatus(config, options = {}) {
91
104
  if (parsed.kind === "rate_limited") {
92
105
  await writeRateLimitedCache(config.cacheFilePath, cached, {
93
106
  now,
94
- sessionId,
95
- observedTokens
107
+ sessionId
96
108
  });
97
109
  return getCachedStatus(cached);
98
110
  }
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_BAR_WIDTH, STATUS_BAR_CHARACTERS } from "../../shared/constants.js";
2
2
 
3
- export function normalizeBarWidth(barWidth) {
3
+ function normalizeBarWidth(barWidth) {
4
4
  if (!Number.isFinite(barWidth)) {
5
5
  return DEFAULT_BAR_WIDTH;
6
6
  }
@@ -8,10 +8,6 @@ import { buildBar } from "./bar.js";
8
8
  import { applyTheme } from "./theme.js";
9
9
  import { buildStatusViewModel } from "./viewModel.js";
10
10
 
11
- function getSeverityTone(model) {
12
- return model.severity || "neutral";
13
- }
14
-
15
11
  function createErrorSegments(model) {
16
12
  const tone = model.kind === "auth_error" ? "danger" : "warn";
17
13
  return [
@@ -75,7 +71,7 @@ function appendResetSegments(segments, model) {
75
71
  }
76
72
 
77
73
  function createTextSegments(model, displayMode) {
78
- const severityTone = getSeverityTone(model);
74
+ const severityTone = model.severity;
79
75
 
80
76
  return appendResetSegments(
81
77
  appendSecondarySegments(
@@ -91,7 +87,7 @@ function createTextSegments(model, displayMode) {
91
87
  }
92
88
 
93
89
  function createCompactSegments(model) {
94
- const severityTone = getSeverityTone(model);
90
+ const severityTone = model.severity;
95
91
  let segments;
96
92
 
97
93
  if (model.secondaryQuota) {
@@ -126,7 +122,7 @@ function createBarSegments(model, barWidth) {
126
122
  }
127
123
 
128
124
  const bar = buildBar(model.primaryQuota.usedPercent, barWidth);
129
- const severityTone = getSeverityTone(model);
125
+ const severityTone = model.severity;
130
126
  const segments = [
131
127
  { text: model.levelLabel, tone: "label" },
132
128
  { text: " ", tone: "plain" },
@@ -157,8 +153,7 @@ export function formatStatus(result, options = {}) {
157
153
  if (model.kind !== "success") {
158
154
  return applyTheme(createErrorSegments(model), {
159
155
  theme: normalizeTheme(options.theme),
160
- palette: normalizePalette(options.palette),
161
- env: options.env
156
+ palette: normalizePalette(options.palette)
162
157
  });
163
158
  }
164
159
 
@@ -175,7 +170,6 @@ export function formatStatus(result, options = {}) {
175
170
 
176
171
  return applyTheme(segments, {
177
172
  theme: normalizeTheme(options.theme),
178
- palette: normalizePalette(options.palette),
179
- env: options.env
173
+ palette: normalizePalette(options.palette)
180
174
  });
181
175
  }
@@ -17,15 +17,6 @@ const ANSI = {
17
17
  red: "\u001b[31m"
18
18
  };
19
19
 
20
- function shouldUseAnsi(theme, env = process.env) {
21
- // NO_COLOR is the explicit opt-out and wins over local config.
22
- if (env?.NO_COLOR) {
23
- return false;
24
- }
25
-
26
- return normalizeTheme(theme || DEFAULT_THEME) === "ansi";
27
- }
28
-
29
20
  function applyCodes(text, codes) {
30
21
  if (!text || !codes.length) {
31
22
  return text;
@@ -76,9 +67,8 @@ function getMonoCodes(tone) {
76
67
  export function applyTheme(segments, options = {}) {
77
68
  const theme = normalizeTheme(options.theme || DEFAULT_THEME);
78
69
  const palette = normalizePalette(options.palette || DEFAULT_PALETTE);
79
- const env = options.env || process.env;
80
70
 
81
- if (!shouldUseAnsi(theme, env)) {
71
+ if (theme !== "ansi") {
82
72
  return segments.map((segment) => segment.text).join("");
83
73
  }
84
74
 
@@ -12,22 +12,9 @@ import {
12
12
  DEFAULT_QUOTA_URL,
13
13
  DEFAULT_STYLE,
14
14
  DEFAULT_THEME,
15
- DEFAULT_TIMEOUT_MS,
16
- normalizeDisplayMode,
17
- normalizePalette,
18
- normalizeStatusStyle,
19
- normalizeTheme
15
+ DEFAULT_TIMEOUT_MS
20
16
  } from "./constants.js";
21
17
 
22
- function parsePositiveInt(value, fallback) {
23
- if (value === undefined || value === null || value === "") {
24
- return fallback;
25
- }
26
-
27
- const parsed = Number.parseInt(String(value), 10);
28
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
29
- }
30
-
31
18
  function getCacheRoot() {
32
19
  if (process.platform === "darwin") {
33
20
  return path.join(os.homedir(), "Library", "Caches");
@@ -68,7 +55,7 @@ function deriveQuotaUrl(baseUrl) {
68
55
  return "";
69
56
  }
70
57
 
71
- function normalizeOptionalString(value) {
58
+ export function normalizeOptionalString(value) {
72
59
  if (typeof value !== "string") {
73
60
  return "";
74
61
  }
@@ -92,13 +79,13 @@ export function loadConfig(env = process.env, overrides = {}) {
92
79
  quotaUrl: derivedQuotaUrl || DEFAULT_QUOTA_URL,
93
80
  authorization,
94
81
  anthropicBaseUrl,
95
- timeoutMs: parsePositiveInt(env.GLM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
96
- cacheTtlMs: parsePositiveInt(env.GLM_CACHE_TTL_MS, DEFAULT_CACHE_TTL_MS),
97
- displayMode: normalizeDisplayMode(env.GLM_DISPLAY_MODE || DEFAULT_DISPLAY_MODE),
98
- style: normalizeStatusStyle(env.GLM_STYLE || DEFAULT_STYLE),
99
- theme: normalizeTheme(env.GLM_THEME || DEFAULT_THEME),
100
- palette: normalizePalette(env.GLM_PALETTE || DEFAULT_PALETTE),
101
- barWidth: parsePositiveInt(env.GLM_BAR_WIDTH, DEFAULT_BAR_WIDTH),
82
+ timeoutMs: DEFAULT_TIMEOUT_MS,
83
+ cacheTtlMs: DEFAULT_CACHE_TTL_MS,
84
+ displayMode: DEFAULT_DISPLAY_MODE,
85
+ style: DEFAULT_STYLE,
86
+ theme: DEFAULT_THEME,
87
+ palette: DEFAULT_PALETTE,
88
+ barWidth: DEFAULT_BAR_WIDTH,
102
89
  cacheFilePath: path.join(getCacheRoot(), "glm-quota-line", cacheFileName)
103
90
  };
104
91
  }
@@ -2,18 +2,21 @@ export const DEFAULT_QUOTA_URL = "https://bigmodel.cn/api/monitor/usage/quota/li
2
2
  export const DEFAULT_CN_BASE_URL = "https://open.bigmodel.cn";
3
3
  export const DEFAULT_INTL_BASE_URL = "https://api.z.ai";
4
4
  export const DEFAULT_TIMEOUT_MS = 5000;
5
- export const DEFAULT_CACHE_TTL_MS = 300_000;
5
+ export const DEFAULT_CACHE_TTL_MS = 600_000;
6
+
7
+ export const REFRESH_TIERS = [
8
+ { ttlMs: 180_000, maxRefreshes: 5 }, // 3 min × 5
9
+ { ttlMs: 300_000, maxRefreshes: 5 }, // 5 min × 5
10
+ { ttlMs: 600_000, maxRefreshes: Infinity } // 10 min (cap)
11
+ ];
12
+
13
+ export const LOW_QUOTA_THRESHOLD = 30;
6
14
  export const DEFAULT_DISPLAY_MODE = "left";
7
15
  export const DEFAULT_STYLE = "text";
8
16
  export const DEFAULT_BAR_WIDTH = 10;
9
- export const DEFAULT_THEME = "plain";
17
+ export const DEFAULT_THEME = "ansi";
10
18
  export const DEFAULT_PALETTE = "dark";
11
19
 
12
- export const STATUS_STYLES = ["text", "compact", "bar"];
13
- export const DISPLAY_MODES = ["left", "used", "both"];
14
- export const THEMES = ["plain", "ansi"];
15
- export const PALETTES = ["dark", "mono"];
16
-
17
20
  export const STATUS_BAR_CHARACTERS = {
18
21
  filled: "█",
19
22
  empty: "░"
@@ -22,23 +25,20 @@ export const STATUS_BAR_CHARACTERS = {
22
25
  export const TOOL_CONFIG_SCHEMA_VERSION = 1;
23
26
  export const TOOL_CONFIG_MANAGED_BY = "glm-quota-line";
24
27
 
25
- export const QUOTA_UNAVAILABLE_TEXT = "GLM | quota unavailable";
26
- export const AUTH_EXPIRED_TEXT = "GLM | auth expired";
27
-
28
28
  export function isValidStatusStyle(value) {
29
- return STATUS_STYLES.includes(value);
29
+ return value === "text" || value === "compact" || value === "bar";
30
30
  }
31
31
 
32
32
  export function isValidDisplayMode(value) {
33
- return DISPLAY_MODES.includes(value);
33
+ return value === "left" || value === "used" || value === "both";
34
34
  }
35
35
 
36
36
  export function isValidTheme(value) {
37
- return THEMES.includes(value);
37
+ return value === "plain" || value === "ansi";
38
38
  }
39
39
 
40
40
  export function isValidPalette(value) {
41
- return PALETTES.includes(value);
41
+ return value === "dark" || value === "mono";
42
42
  }
43
43
 
44
44
  export function normalizeStatusStyle(value) {