cc-api-statusline 1.0.2 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,15 +2,14 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- A high-performance TUI statusline tool that polls API usage data from Claude API proxy services (sub2api, claude-relay-service, or custom providers) and renders a configurable one-line status display.
5
+ <img src="docs/images/banner-screenshot.png" width="800" alt="cc-api-statusline banner">
6
+
7
+ A high-performance TUI statusline tool that polls API usage data from Claude API services (sub2api, claude-relay-service, or custom providers) and renders a configurable one-line status display.
6
8
 
7
9
  ## Features
8
10
 
9
- - ⚡ **Fast piped mode** — <25ms warm cache, <100ms p95
10
11
  - 🎨 **Highly configurable** — Layouts, colors, bar styles, display modes
11
12
  - 🔌 **Provider autodetection** — Works with sub2api, claude-relay-service, custom providers
12
- - 💾 **Smart caching** — Disk cache with atomic writes, TTL validation, automatic garbage collection
13
- - 🎯 **Claude Code integration** — Auto-setup with `--install` command
14
13
  - 📊 **Multiple components** — Daily/weekly/monthly quotas, balance, tokens, rate limits
15
14
  - 🔁 **Hot switching** — Auto-detects API endpoint and credential changes at runtime
16
15
  - 🔒 **Reliability** — No stale data display, race-condition-free writes, auto cache cleanup
@@ -45,7 +44,7 @@ export ANTHROPIC_AUTH_TOKEN="your-api-token"
45
44
  bunx cc-api-statusline@latest --once
46
45
  ```
47
46
 
48
- ### 3. Install as Claude Code widget (optional)
47
+ ### 3.a Install as Claude Code widget
49
48
 
50
49
  ```bash
51
50
  bunx cc-api-statusline@latest --install
@@ -56,6 +55,7 @@ This adds to `~/.claude/settings.json`:
56
55
  ```json
57
56
  {
58
57
  "statusLine": {
58
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
59
59
  "type": "command",
60
60
  "command": "bunx -y cc-api-statusline@latest",
61
61
  "padding": 0
@@ -63,6 +63,28 @@ This adds to `~/.claude/settings.json`:
63
63
  }
64
64
  ```
65
65
 
66
+ ### 3.b Install as [ccstatusline](https://github.com/anthropics/claude-code) Custom Command
67
+ <img src="docs/images/ccstatusline-command.png" width="800" alt="ccstatusline-command mode">
68
+ Add to `~/.claude/ccstatusline/config.json`:
69
+
70
+ ```json
71
+ {
72
+ "lines": [
73
+ [
74
+ {
75
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
76
+ "type": "custom-command",
77
+ "commandPath": "bunx -y cc-api-statusline@latest --embedded",
78
+ "preserveColors": true,
79
+ "timeout": 10000
80
+ }
81
+ ]
82
+ ]
83
+ }
84
+ ```
85
+
86
+ > **`--embedded` is required here.** Without it, cc-api-statusline prepends an ANSI reset (`\x1b[0m`) that breaks cc-statusline's powerline background colors. The flag tells cc-api-statusline it's running inside a host renderer that handles its own formatting.
87
+
66
88
  Using `bunx` ensures you always run the latest version without a global install. To uninstall:
67
89
 
68
90
  ```bash
@@ -144,31 +166,6 @@ cc-api-statusline --apply-config
144
166
 
145
167
  See [docs/api-config-reference.md](docs/api-config-reference.md) for the full schema.
146
168
 
147
- ## [ccstatusline](https://github.com/anthropics/claude-code) Custom Command
148
-
149
- Add to `~/.claude/ccstatusline/config.json`:
150
-
151
- ```json
152
- {
153
- "customCommands": {
154
- "usage": {
155
- "command": "cc-api-statusline",
156
- "description": "API usage statusline",
157
- "type": "piped"
158
- }
159
- },
160
- "widgets": [
161
- {
162
- "type": "customCommand",
163
- "command": "usage",
164
- "refreshIntervalMs": 30000,
165
- "maxWidth": 100,
166
- "preserveColors": true
167
- }
168
- ]
169
- }
170
- ```
171
-
172
169
  ## Environment Variables
173
170
 
174
171
  All variables are optional at the shell level — `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` can be set via `settings.json` env overlay instead of shell exports (see [Quick Start](#quick-start)).
@@ -180,6 +177,7 @@ All variables are optional at the shell level — `ANTHROPIC_BASE_URL` and `ANTH
180
177
  | `CC_STATUSLINE_PROVIDER` | Yes | Override provider detection (`sub2api`, `claude-relay-service`, or custom) |
181
178
  | `CC_STATUSLINE_POLL` | Yes | Override poll interval (seconds, min 5) |
182
179
  | `CC_STATUSLINE_TIMEOUT` | Yes | Piped mode timeout (milliseconds, default 5000) |
180
+ | `CC_API_STATUSLINE_EMBEDDED` | Yes | Skip host formatting when set to `"1"` or `"true"`. Alternative to `--embedded` flag; prefer the flag in `commandPath` configs |
183
181
  | `DEBUG` or `CC_STATUSLINE_DEBUG` | Yes | Enable debug logging to `~/.claude/cc-api-statusline/debug.log` |
184
182
 
185
183
  ## Troubleshooting
package/README.zh-CN.md CHANGED
@@ -2,13 +2,12 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- 在ClaudeCode状态栏显示API用量,通过轮询 Claude API 代理服务(sub2api、claude-relay-service 或自定义提供商)获取用量数据,并以可配置显示样式。
5
+ 在ClaudeCode状态栏显示API用量,通过轮询 Claude API 服务(sub2api、claude-relay-service 或自定义提供商)获取用量数据,并以可配置显示样式。
6
6
 
7
7
  ## 特性
8
8
 
9
9
  - 🎨 **高度可配置** — 布局、颜色、进度条样式、显示模式任意调整
10
10
  - 🔌 **提供商自动识别** — 开箱支持 sub2api、claude-relay-service 及自定义提供商
11
- - 💾 **智能缓存** — 原子写入磁盘缓存、TTL 验证、自动垃圾回收
12
11
  - 🎯 **Claude Code 集成** — 一键 `--install` 完成安装
13
12
  - 📊 **多维度用量展示** — 每日/每周/每月配额、余额、Token数、速率限制
14
13
  - 🔁 **热切换** — 自动感知 API 端点和凭证变更,无需重启
@@ -44,7 +43,7 @@ export ANTHROPIC_AUTH_TOKEN="your-api-token"
44
43
  bunx cc-api-statusline@latest --once
45
44
  ```
46
45
 
47
- ### 3. 安装为 Claude Code 状态栏组件(可选)
46
+ ### 3.a 安装为 Claude Code 状态栏组件
48
47
 
49
48
  ```bash
50
49
  bunx cc-api-statusline@latest --install
@@ -61,6 +60,27 @@ bunx cc-api-statusline@latest --install
61
60
  }
62
61
  }
63
62
  ```
63
+ ### 3.b 安装为 [ccstatusline](https://github.com/anthropics/claude-code) 自定义命令
64
+ <img src="docs/images/ccstatusline-command.png" width="800" alt="ccstatusline-command mode">
65
+ 在 `~/.claude/ccstatusline/config.json` 中添加如下配置:
66
+
67
+ ```json
68
+ {
69
+ "lines": [
70
+ [
71
+ {
72
+ "id": "e62435aa-bdf5-4ded-a2e3-ae13582439db",
73
+ "type": "custom-command",
74
+ "commandPath": "bunx -y cc-api-statusline@latest --embedded",
75
+ "preserveColors": true,
76
+ "timeout": 10000
77
+ }
78
+ ]
79
+ ]
80
+ }
81
+ ```
82
+
83
+ > **此处必须加 `--embedded`。** 不加的话,cc-api-statusline 会在输出前插入 ANSI 重置码(`\x1b[0m`),破坏 cc-statusline 的 powerline 背景色。该标志告知 cc-api-statusline 当前运行在宿主渲染器内部,由宿主负责格式化。
64
84
 
65
85
  使用 `bunx` 可每次自动拉取最新版本,无需全局安装。如需卸载:
66
86
 
@@ -143,31 +163,6 @@ cc-api-statusline --apply-config
143
163
 
144
164
  完整 Schema 请参阅 [docs/api-config-reference.md](docs/api-config-reference.md)。
145
165
 
146
- ## [ccstatusline](https://github.com/anthropics/claude-code) 自定义命令
147
-
148
- 在 `~/.claude/ccstatusline/config.json` 中添加如下配置:
149
-
150
- ```json
151
- {
152
- "customCommands": {
153
- "usage": {
154
- "command": "cc-api-statusline",
155
- "description": "API usage statusline",
156
- "type": "piped"
157
- }
158
- },
159
- "widgets": [
160
- {
161
- "type": "customCommand",
162
- "command": "usage",
163
- "refreshIntervalMs": 30000,
164
- "maxWidth": 100,
165
- "preserveColors": true
166
- }
167
- ]
168
- }
169
- ```
170
-
171
166
  ## 环境变量
172
167
 
173
168
  以下所有变量均为可选——`ANTHROPIC_BASE_URL` 和 `ANTHROPIC_AUTH_TOKEN` 可通过 `settings.json` 的 env 字段配置,无需在 Shell 中手动导出(详见[快速上手](#快速上手))。
@@ -4,7 +4,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "cc-api-statusline",
7
- version: "1.0.2",
7
+ version: "1.1.2",
8
8
  description: "Claude Code statusline tool that polls API usage from third-party proxy backends",
9
9
  type: "module",
10
10
  bin: {
@@ -70,6 +70,7 @@ function parseArgs() {
70
70
  let uninstall = false;
71
71
  let applyConfig = false;
72
72
  let force = false;
73
+ let embedded = false;
73
74
  let configPath;
74
75
  let runner;
75
76
  for (let i = 0;i < args.length; i++) {
@@ -88,6 +89,8 @@ function parseArgs() {
88
89
  applyConfig = true;
89
90
  } else if (arg === "--force") {
90
91
  force = true;
92
+ } else if (arg === "--embedded") {
93
+ embedded = true;
91
94
  } else if (arg === "--config" && i + 1 < args.length) {
92
95
  configPath = args[i + 1];
93
96
  i++;
@@ -99,7 +102,9 @@ function parseArgs() {
99
102
  i++;
100
103
  }
101
104
  }
102
- return { help, version, once, install, uninstall, applyConfig, force, configPath, runner };
105
+ const envVal = process.env["CC_API_STATUSLINE_EMBEDDED"];
106
+ embedded = embedded || envVal === "1" || envVal === "true";
107
+ return { help, version, once, install, uninstall, applyConfig, force, embedded, configPath, runner };
103
108
  }
104
109
  function showHelp() {
105
110
  console.log(`
@@ -118,14 +123,16 @@ Options:
118
123
  --apply-config Apply endpoint config changes (updates lock file, clears caches)
119
124
  --runner <runner> Package runner: npx or bunx (default: auto-detect)
120
125
  --force Force overwrite existing statusline configuration
126
+ --embedded Skip host formatting (for use inside cc-statusline)
121
127
 
122
128
  Environment Variables:
123
- ANTHROPIC_BASE_URL API endpoint (required)
124
- ANTHROPIC_AUTH_TOKEN API key (required)
125
- CC_STATUSLINE_PROVIDER Override provider detection
126
- CC_STATUSLINE_POLL Override poll interval (seconds)
127
- CC_STATUSLINE_TIMEOUT Piped mode timeout (milliseconds, default 5000)
128
- DEBUG or CC_STATUSLINE_DEBUG Enable debug logging to ~/.claude/cc-api-statusline/debug.log
129
+ ANTHROPIC_BASE_URL API endpoint (required)
130
+ ANTHROPIC_AUTH_TOKEN API key (required)
131
+ CC_STATUSLINE_PROVIDER Override provider detection
132
+ CC_STATUSLINE_POLL Override poll interval (seconds)
133
+ CC_STATUSLINE_TIMEOUT Piped mode timeout (milliseconds, default 5000)
134
+ DEBUG or CC_STATUSLINE_DEBUG Enable debug logging to ~/.claude/cc-api-statusline/debug.log
135
+ CC_API_STATUSLINE_EMBEDDED Skip host formatting when set to "1" or "true" (for use inside cc-statusline)
129
136
 
130
137
  Config File:
131
138
  ~/.claude/cc-api-statusline/config.json
@@ -177,9 +184,10 @@ import { spawn } from "child_process";
177
184
  import { dirname, join } from "path";
178
185
 
179
186
  // src/core/constants.ts
180
- var DEFAULT_FETCH_TIMEOUT_MS = 5000;
181
- var DEFAULT_PIPED_REQUEST_TIMEOUT_MS = 3000;
187
+ var DEFAULT_TIMEOUT_BUDGET_MS = 5000;
188
+ var TTY_TIMEOUT_BUDGET_MS = DEFAULT_TIMEOUT_BUDGET_MS * 2;
182
189
  var EXIT_BUFFER_MS = 50;
190
+ var TIMEOUT_HEADROOM_MS = 100;
183
191
  var STALENESS_THRESHOLD_MINUTES = 5;
184
192
  var VERY_STALE_THRESHOLD_MINUTES = 30;
185
193
  var GC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -190,6 +198,12 @@ var LOG_ROTATION_PROBABILITY = 0.05;
190
198
  var LOG_MAX_SIZE_BYTES = 512 * 1024;
191
199
  var LOG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
192
200
  var LOG_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
201
+ var HEALTH_MATCH_WILDCARD = "*";
202
+ var DETECTION_TTL_BASE_S = 86400;
203
+ var DETECTION_TTL_MAX_S = 604800;
204
+ var DETECTION_TTL_CHANGED_S = 3600;
205
+ var DETECTION_TTL_FAILED_S = 300;
206
+ var MAINTENANCE_GC_PROBABILITY = 0.1;
193
207
 
194
208
  // src/services/log-rotator.ts
195
209
  var ARCHIVE_LOG_RE = /^debug\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}\.log$/;
@@ -564,7 +578,7 @@ var DEFAULT_CONFIG = {
564
578
  chill: { tiers: buildTiers(["cyan", "cyan", "blue", "blue", "magenta"]) }
565
579
  },
566
580
  pollIntervalSeconds: 30,
567
- pipedRequestTimeoutMs: 3000
581
+ pipedRequestTimeoutMs: DEFAULT_TIMEOUT_BUDGET_MS
568
582
  };
569
583
  var BAR_SIZE_MAP = {
570
584
  small: 4,
@@ -636,12 +650,11 @@ function isCacheEntry(value) {
636
650
  const c = value;
637
651
  return typeof c["version"] === "number" && typeof c["provider"] === "string" && typeof c["baseUrl"] === "string" && typeof c["tokenHash"] === "string" && typeof c["configHash"] === "string" && typeof c["endpointConfigHash"] === "string" && typeof c["data"] === "object" && c["data"] !== null && typeof c["renderedLine"] === "string" && typeof c["fetchedAt"] === "string" && typeof c["ttlSeconds"] === "number" && (c["errorState"] === null || typeof c["errorState"] === "object");
638
652
  }
639
- var PROVIDER_DETECTION_TTL_SECONDS = 86400;
640
653
  function isProviderDetectionCacheEntry(value) {
641
654
  if (typeof value !== "object" || value === null)
642
655
  return false;
643
656
  const c = value;
644
- return typeof c["baseUrl"] === "string" && typeof c["provider"] === "string" && (c["detectedVia"] === "health-probe" || c["detectedVia"] === "url-pattern" || c["detectedVia"] === "override") && typeof c["detectedAt"] === "string" && typeof c["ttlSeconds"] === "number";
657
+ return typeof c["baseUrl"] === "string" && typeof c["provider"] === "string" && (c["detectedVia"] === "health-probe" || c["detectedVia"] === "override") && typeof c["detectedAt"] === "string" && typeof c["ttlSeconds"] === "number";
645
658
  }
646
659
  // src/services/endpoint-config.ts
647
660
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
@@ -814,7 +827,6 @@ function getBuiltInEndpointConfigs() {
814
827
  resetSemantics: "rolling-window"
815
828
  },
816
829
  detection: {
817
- urlPatterns: ["/apistats", "/api/user-stats"],
818
830
  healthMatch: { service: "*" }
819
831
  },
820
832
  responseMapping: {
@@ -999,7 +1011,15 @@ function writeDefaultConfigs(customDir) {
999
1011
  ensureDir(apiConfigDir);
1000
1012
  if (!existsSync4(configPath)) {
1001
1013
  const styleConfigWithoutColors = serializableConfig(getDefaultStyleConfig());
1002
- atomicWriteFile(configPath, JSON.stringify(styleConfigWithoutColors, null, 2), {
1014
+ const autoColorEntry = DEFAULT_CONFIG.colors?.auto;
1015
+ if (!autoColorEntry || typeof autoColorEntry === "string") {
1016
+ throw new Error("DEFAULT_CONFIG is missing the built-in auto color alias");
1017
+ }
1018
+ const configWithAutoColor = {
1019
+ ...styleConfigWithoutColors,
1020
+ colors: { auto: { tiers: autoColorEntry.tiers } }
1021
+ };
1022
+ atomicWriteFile(configPath, JSON.stringify(configWithAutoColor, null, 2), {
1003
1023
  appendNewline: true
1004
1024
  });
1005
1025
  }
@@ -1091,7 +1111,7 @@ async function readBodyWithLimit(response) {
1091
1111
  throw new HttpError(`Failed to read response body: ${error}`);
1092
1112
  }
1093
1113
  }
1094
- async function secureFetch(url, options = {}, timeoutMs = 5000, userAgent) {
1114
+ async function secureFetch(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS, userAgent) {
1095
1115
  const signal = AbortSignal.timeout(timeoutMs);
1096
1116
  const fetchOptions = {
1097
1117
  ...options,
@@ -1140,7 +1160,33 @@ function extractOrigin(baseUrl) {
1140
1160
  return baseUrl;
1141
1161
  }
1142
1162
  }
1143
- async function probeHealth(baseUrl, timeoutMs = 1500) {
1163
+ function matchHealthResponse(data, endpointConfigs) {
1164
+ const candidates = Object.entries(endpointConfigs).reduce((acc, [providerId, config]) => {
1165
+ const healthMatch = config.detection?.healthMatch;
1166
+ if (healthMatch != null && Object.keys(healthMatch).length > 0) {
1167
+ acc.push({ providerId, healthMatch });
1168
+ }
1169
+ return acc;
1170
+ }, []);
1171
+ candidates.sort((a, b) => {
1172
+ const diff = Object.keys(b.healthMatch).length - Object.keys(a.healthMatch).length;
1173
+ return diff !== 0 ? diff : a.providerId.localeCompare(b.providerId);
1174
+ });
1175
+ for (const { providerId, healthMatch } of candidates) {
1176
+ const matches = Object.entries(healthMatch).every(([field, expected]) => {
1177
+ const actual = data[field];
1178
+ if (expected === HEALTH_MATCH_WILDCARD) {
1179
+ return typeof actual === "string";
1180
+ }
1181
+ return actual === expected;
1182
+ });
1183
+ if (matches) {
1184
+ return providerId;
1185
+ }
1186
+ }
1187
+ return null;
1188
+ }
1189
+ async function probeHealth(baseUrl, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS, endpointConfigs = {}) {
1144
1190
  const origin = extractOrigin(baseUrl);
1145
1191
  const healthUrl = `${origin}/health`;
1146
1192
  logger.debug("Probing health endpoint", { healthUrl, timeoutMs });
@@ -1153,13 +1199,10 @@ async function probeHealth(baseUrl, timeoutMs = 1500) {
1153
1199
  }, timeoutMs);
1154
1200
  const data = JSON.parse(responseText);
1155
1201
  logger.debug("Health probe response", { data });
1156
- if (typeof data["service"] === "string") {
1157
- logger.debug("Detected provider from service field", { provider: data["service"] });
1158
- return data["service"];
1159
- }
1160
- if (data["status"] === "ok") {
1161
- logger.debug("Detected sub2api from status: ok pattern");
1162
- return "sub2api";
1202
+ const matched = matchHealthResponse(data, endpointConfigs);
1203
+ if (matched) {
1204
+ logger.debug("Detected provider from health response", { provider: matched });
1205
+ return matched;
1163
1206
  }
1164
1207
  logger.debug("Health probe returned unrecognized pattern", { data });
1165
1208
  return null;
@@ -1168,6 +1211,15 @@ async function probeHealth(baseUrl, timeoutMs = 1500) {
1168
1211
  return null;
1169
1212
  }
1170
1213
  }
1214
+ async function probeHealthWithMetrics(baseUrl, timeoutMs, endpointConfigs) {
1215
+ const start = Date.now();
1216
+ const matchedProvider = await probeHealth(baseUrl, timeoutMs, endpointConfigs);
1217
+ return {
1218
+ success: matchedProvider !== null,
1219
+ matchedProvider,
1220
+ responseTimeMs: Date.now() - start
1221
+ };
1222
+ }
1171
1223
 
1172
1224
  // src/services/cache.ts
1173
1225
  import { readFileSync as readFileSync6, unlinkSync as unlinkSync4 } from "fs";
@@ -1290,6 +1342,37 @@ function readProviderDetectionCache(baseUrl) {
1290
1342
  return null;
1291
1343
  }
1292
1344
  }
1345
+ function deleteProviderDetectionCache(baseUrl) {
1346
+ const path = getProviderDetectionCachePath(baseUrl);
1347
+ try {
1348
+ unlinkSync4(path);
1349
+ } catch (err) {
1350
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1351
+ return;
1352
+ }
1353
+ logger.warn(`Failed to delete provider detection cache at ${path}: ${err}`);
1354
+ }
1355
+ }
1356
+ function readDetectionCacheMeta(baseUrl) {
1357
+ const defaultTtlMs = DETECTION_TTL_BASE_S * 1000;
1358
+ const path = getProviderDetectionCachePath(baseUrl);
1359
+ let content;
1360
+ try {
1361
+ content = readFileSync6(path, "utf-8");
1362
+ } catch {
1363
+ return { ageMs: null, ttlMs: defaultTtlMs };
1364
+ }
1365
+ try {
1366
+ const data = JSON.parse(content);
1367
+ if (!isProviderDetectionCacheEntry(data))
1368
+ return { ageMs: null, ttlMs: defaultTtlMs };
1369
+ const detectedAt = new Date(data.detectedAt).getTime();
1370
+ const ageMs = isNaN(detectedAt) ? null : Date.now() - detectedAt;
1371
+ return { ageMs, ttlMs: data.ttlSeconds * 1000 };
1372
+ } catch {
1373
+ return { ageMs: null, ttlMs: defaultTtlMs };
1374
+ }
1375
+ }
1293
1376
  function writeProviderDetectionCache(baseUrl, entry) {
1294
1377
  const path = getProviderDetectionCachePath(baseUrl);
1295
1378
  try {
@@ -1303,27 +1386,7 @@ function writeProviderDetectionCache(baseUrl, entry) {
1303
1386
 
1304
1387
  // src/providers/autodetect.ts
1305
1388
  var detectionCache = new Map;
1306
- function detectProviderFromUrlPattern(baseUrl, endpointConfigs = {}, options = {}) {
1307
- const includeBuiltInPatterns = options.includeBuiltInPatterns ?? true;
1308
- const fallbackProvider = Object.prototype.hasOwnProperty.call(options, "fallbackProvider") ? options.fallbackProvider ?? null : "sub2api";
1309
- const normalizedUrl = baseUrl.toLowerCase().replace(/\/$/, "");
1310
- for (const [providerId, config] of Object.entries(endpointConfigs)) {
1311
- const urlPatterns = config.detection?.urlPatterns;
1312
- if (urlPatterns && urlPatterns.length > 0) {
1313
- for (const pattern of urlPatterns) {
1314
- const normalizedPattern = pattern.toLowerCase();
1315
- if (normalizedUrl.includes(normalizedPattern)) {
1316
- return providerId;
1317
- }
1318
- }
1319
- }
1320
- }
1321
- if (includeBuiltInPatterns && (normalizedUrl.includes("/apistats") || normalizedUrl.includes("/api/user-stats"))) {
1322
- return "claude-relay-service";
1323
- }
1324
- return fallbackProvider;
1325
- }
1326
- async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {}, probeTimeoutMs = 1500) {
1389
+ async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {}, probeTimeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1327
1390
  if (providerOverride) {
1328
1391
  logger.debug("Provider override detected", { provider: providerOverride });
1329
1392
  return providerOverride;
@@ -1345,33 +1408,18 @@ async function resolveProvider(baseUrl, providerOverride, endpointConfigs = {},
1345
1408
  });
1346
1409
  return diskCached.provider;
1347
1410
  }
1348
- const endpointPatternProvider = detectProviderFromUrlPattern(baseUrl, endpointConfigs, {
1349
- includeBuiltInPatterns: false,
1350
- fallbackProvider: null
1351
- });
1352
- if (endpointPatternProvider) {
1353
- logger.debug("Provider detected via endpoint URL pattern", { provider: endpointPatternProvider });
1354
- cacheProviderDetection(baseUrl, endpointPatternProvider, "url-pattern");
1355
- return endpointPatternProvider;
1356
- }
1357
1411
  logger.debug("Attempting health probe", { baseUrl, timeoutMs: probeTimeoutMs });
1358
- const probedProvider = await probeHealth(baseUrl, probeTimeoutMs);
1412
+ const probedProvider = await probeHealth(baseUrl, probeTimeoutMs, endpointConfigs);
1359
1413
  if (probedProvider) {
1360
1414
  logger.debug("Provider detected via health probe", { provider: probedProvider });
1361
1415
  cacheProviderDetection(baseUrl, probedProvider, "health-probe");
1362
1416
  return probedProvider;
1363
1417
  }
1364
- const patternProvider = detectProviderFromUrlPattern(baseUrl, {});
1365
- if (!patternProvider) {
1366
- logger.debug("Provider URL pattern detection had no match, defaulting to sub2api");
1367
- cacheProviderDetection(baseUrl, "sub2api", "url-pattern");
1368
- return "sub2api";
1369
- }
1370
- logger.debug("Provider detected via built-in URL pattern", { provider: patternProvider });
1371
- cacheProviderDetection(baseUrl, patternProvider, "url-pattern");
1372
- return patternProvider;
1418
+ logger.debug("Health probe failed, defaulting to sub2api");
1419
+ cacheProviderDetection(baseUrl, "sub2api", "health-probe");
1420
+ return "sub2api";
1373
1421
  }
1374
- function cacheProviderDetection(baseUrl, provider, detectedVia) {
1422
+ function cacheProviderDetection(baseUrl, provider, detectedVia, ttlSeconds = DETECTION_TTL_BASE_S) {
1375
1423
  const now = new Date().toISOString();
1376
1424
  detectionCache.set(baseUrl, {
1377
1425
  provider,
@@ -1382,9 +1430,15 @@ function cacheProviderDetection(baseUrl, provider, detectedVia) {
1382
1430
  provider,
1383
1431
  detectedVia,
1384
1432
  detectedAt: now,
1385
- ttlSeconds: PROVIDER_DETECTION_TTL_SECONDS
1433
+ ttlSeconds
1386
1434
  });
1387
1435
  }
1436
+ function cacheProviderDetectionWithTtl(baseUrl, provider, ttlSeconds) {
1437
+ cacheProviderDetection(baseUrl, provider, "health-probe", ttlSeconds);
1438
+ }
1439
+ function invalidateDetectionCache(baseUrl) {
1440
+ detectionCache.delete(baseUrl);
1441
+ }
1388
1442
  function clearDetectionCache() {
1389
1443
  detectionCache.clear();
1390
1444
  }
@@ -1540,7 +1594,7 @@ function mapPeriodTokens(data) {
1540
1594
  cost: data.cost ?? 0
1541
1595
  };
1542
1596
  }
1543
- async function fetchSub2api(baseUrl, token, config, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1597
+ async function fetchSub2api(baseUrl, token, config, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1544
1598
  const url = `${baseUrl}/v1/usage`;
1545
1599
  const resolvedUA = resolveUserAgent(config.spoofClaudeCodeUA);
1546
1600
  if (resolvedUA) {
@@ -1621,7 +1675,7 @@ function computeWeeklyResetTime(resetDay, resetHour) {
1621
1675
  const resetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilReset, resetHour, 0, 0, 0));
1622
1676
  return resetDate.toISOString();
1623
1677
  }
1624
- async function fetchClaudeRelayService(baseUrl, token, config, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1678
+ async function fetchClaudeRelayService(baseUrl, token, config, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1625
1679
  const origin = extractOrigin(baseUrl);
1626
1680
  const url = `${origin}/apiStats/api/user-stats`;
1627
1681
  const resolvedUA = resolveUserAgent(config.spoofClaudeCodeUA);
@@ -1861,7 +1915,7 @@ function validateEndpointConfigSemantics(config) {
1861
1915
  }
1862
1916
  return null;
1863
1917
  }
1864
- async function fetchEndpoint(baseUrl, token, appConfig, endpointConfig, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
1918
+ async function fetchEndpoint(baseUrl, token, appConfig, endpointConfig, timeoutMs = DEFAULT_TIMEOUT_BUDGET_MS) {
1865
1919
  const validationError = validateEndpointConfigSemantics(endpointConfig);
1866
1920
  if (validationError) {
1867
1921
  throw new Error(`Invalid endpoint config: ${validationError}`);
@@ -1951,7 +2005,7 @@ var ANSI_COLORS = {
1951
2005
  grey: "\x1B[90m"
1952
2006
  };
1953
2007
  var THEME_COLORS = {
1954
- cool: "#56B6C2",
2008
+ cool: "#569AD4",
1955
2009
  comfortable: "#5EBE8A",
1956
2010
  warm: "#C9A84C",
1957
2011
  hot: "#D68B45",
@@ -2428,6 +2482,10 @@ function getProgressIcon(percent, nerdFontAvailable = true) {
2428
2482
 
2429
2483
  // src/renderer/format.ts
2430
2484
  function formatCurrency(n) {
2485
+ if (n > 0 && n < 10) {
2486
+ const rounded = Math.round(n * 10) / 10;
2487
+ return `$${rounded.toFixed(1)}`;
2488
+ }
2431
2489
  return `$${Math.floor(n)}`;
2432
2490
  }
2433
2491
  function formatCurrencyQuota(used, limit) {
@@ -2504,7 +2562,8 @@ function renderQuotaComponent(componentId, quota, options, componentConfig, glob
2504
2562
  const labelColor = resolvePartColor("label", usagePercent, componentConfig, globalConfig);
2505
2563
  const countdownColor = resolvePartColor("countdown", usagePercent, componentConfig, globalConfig);
2506
2564
  const progress = renderProgress(progressStyle, usagePercent, barSize, barStyle, barColor, null, renderContext);
2507
- const value = showPercentage ? ansiColor(`${Math.round(usagePercent)}%`, valueColor, renderContext) : "";
2565
+ const percentText = usagePercent > 0 && usagePercent < 10 ? `${(Math.round(usagePercent * 10) / 10).toFixed(1)}%` : `${Math.round(usagePercent)}%`;
2566
+ const value = showPercentage ? ansiColor(percentText, valueColor, renderContext) : "";
2508
2567
  const countdown = renderSecondaryDisplay(quota.resetsAt, quota, componentConfig.countdown, countdownColor, clockFormat, renderContext);
2509
2568
  return assembleComponent(layout, label, labelColor, progress, value, countdown, renderContext);
2510
2569
  }
@@ -2935,6 +2994,35 @@ function isComponentId(key) {
2935
2994
  return DEFAULT_COMPONENT_ORDER.includes(key);
2936
2995
  }
2937
2996
 
2997
+ // src/core/error-classifier.ts
2998
+ function classifyFetchError(error) {
2999
+ if (error && typeof error === "object") {
3000
+ if ("statusCode" in error) {
3001
+ const statusCode = error.statusCode;
3002
+ if (statusCode === 404 || statusCode === 410) {
3003
+ return "site-closed";
3004
+ }
3005
+ return "transient";
3006
+ }
3007
+ if (error instanceof Error) {
3008
+ if (error.name === "TimeoutError") {
3009
+ return "transient";
3010
+ }
3011
+ if (error.name === "ResponseTooLargeError") {
3012
+ return "provider-mismatch";
3013
+ }
3014
+ if (error instanceof SyntaxError) {
3015
+ return "provider-mismatch";
3016
+ }
3017
+ const msg = error.message.toLowerCase();
3018
+ if (msg.includes("invalid response") || msg.includes("expected object") || msg.includes("missing data") || msg.includes("missing limits")) {
3019
+ return "provider-mismatch";
3020
+ }
3021
+ }
3022
+ }
3023
+ return "transient";
3024
+ }
3025
+
2938
3026
  // src/core/execute-cycle.ts
2939
3027
  async function executeCycle(ctx) {
2940
3028
  const { env, config, configHash, endpointConfigHash, endpointLock, cachedEntry, providerId, provider, timeoutBudgetMs, startTime, fetchTimeoutMs } = ctx;
@@ -2947,7 +3035,9 @@ async function executeCycle(ctx) {
2947
3035
  return {
2948
3036
  output: cachedEntry.renderedLine,
2949
3037
  exitCode: 0,
2950
- cacheUpdate: null
3038
+ cacheUpdate: null,
3039
+ invalidateProvider: false,
3040
+ path: "A"
2951
3041
  };
2952
3042
  }
2953
3043
  }
@@ -2962,14 +3052,18 @@ async function executeCycle(ctx) {
2962
3052
  return {
2963
3053
  output: statusline,
2964
3054
  exitCode: 0,
2965
- cacheUpdate: null
3055
+ cacheUpdate: null,
3056
+ invalidateProvider: false,
3057
+ path: "B2"
2966
3058
  };
2967
3059
  }
2968
3060
  const errorOutput = renderError("endpoint-config-changed", "without-cache");
2969
3061
  return {
2970
3062
  output: errorOutput,
2971
3063
  exitCode: 0,
2972
- cacheUpdate: null
3064
+ cacheUpdate: null,
3065
+ invalidateProvider: false,
3066
+ path: "B2"
2973
3067
  };
2974
3068
  }
2975
3069
  if (cachedEntry && isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
@@ -2984,7 +3078,9 @@ async function executeCycle(ctx) {
2984
3078
  return {
2985
3079
  output: statusline,
2986
3080
  exitCode: 0,
2987
- cacheUpdate: updatedEntry
3081
+ cacheUpdate: updatedEntry,
3082
+ invalidateProvider: false,
3083
+ path: "B"
2988
3084
  };
2989
3085
  }
2990
3086
  const deadline = startTime + timeoutBudgetMs - EXIT_BUFFER_MS;
@@ -2995,7 +3091,9 @@ async function executeCycle(ctx) {
2995
3091
  return {
2996
3092
  output: errorOutput,
2997
3093
  exitCode: 0,
2998
- cacheUpdate: null
3094
+ cacheUpdate: null,
3095
+ invalidateProvider: false,
3096
+ path: "D"
2999
3097
  };
3000
3098
  }
3001
3099
  try {
@@ -3005,7 +3103,9 @@ async function executeCycle(ctx) {
3005
3103
  return {
3006
3104
  output: renderError("missing-env", "without-cache"),
3007
3105
  exitCode: 0,
3008
- cacheUpdate: null
3106
+ cacheUpdate: null,
3107
+ invalidateProvider: false,
3108
+ path: "D"
3009
3109
  };
3010
3110
  }
3011
3111
  logger.debug("Path C: Fetching from provider", { providerId, fetchTimeoutMs });
@@ -3031,23 +3131,30 @@ async function executeCycle(ctx) {
3031
3131
  return {
3032
3132
  output: statusline,
3033
3133
  exitCode: 0,
3034
- cacheUpdate: newEntry
3134
+ cacheUpdate: newEntry,
3135
+ invalidateProvider: false,
3136
+ path: "C"
3035
3137
  };
3036
3138
  } catch (error) {
3037
3139
  logger.error("Path D: Fetch error", { error: String(error), hasCachedEntry: !!cachedEntry });
3140
+ const errorCategory = classifyFetchError(error);
3038
3141
  let errorState = "network-error";
3039
3142
  if (error && typeof error === "object" && "statusCode" in error) {
3040
3143
  const statusCode = error.statusCode;
3041
3144
  if (statusCode === 429) {
3042
3145
  errorState = "rate-limited";
3146
+ } else if (errorCategory === "site-closed") {
3147
+ errorState = "network-error";
3043
3148
  } else if (statusCode && statusCode >= 500) {
3044
3149
  errorState = "server-error";
3045
3150
  } else if (statusCode === 401 || statusCode === 403) {
3046
3151
  errorState = "auth-error";
3047
3152
  }
3153
+ } else if (errorCategory === "provider-mismatch") {
3154
+ errorState = "parse-error";
3048
3155
  }
3049
3156
  if (cachedEntry) {
3050
- logger.debug("Discarding stale cache, showing error", { errorState });
3157
+ logger.debug("Discarding stale cache, showing error", { errorState, errorCategory });
3051
3158
  } else {
3052
3159
  logger.warn("No cache available for error fallback");
3053
3160
  }
@@ -3055,10 +3162,32 @@ async function executeCycle(ctx) {
3055
3162
  return {
3056
3163
  output: errorOutput,
3057
3164
  exitCode: 0,
3058
- cacheUpdate: null
3165
+ cacheUpdate: null,
3166
+ invalidateProvider: errorCategory === "provider-mismatch",
3167
+ path: "D"
3059
3168
  };
3060
3169
  }
3061
3170
  }
3171
+ // src/core/maintenance-scheduler.ts
3172
+ function selectMaintenanceTask(ctx) {
3173
+ if (ctx.path !== "A" && ctx.path !== "B")
3174
+ return "none";
3175
+ if (ctx.detectionCacheAgeMs === null)
3176
+ return "health-probe";
3177
+ if (ctx.detectionCacheAgeMs >= ctx.detectionCacheTtlMs * 0.5)
3178
+ return "health-probe";
3179
+ if (Math.random() < MAINTENANCE_GC_PROBABILITY)
3180
+ return "cache-gc";
3181
+ return "none";
3182
+ }
3183
+ function computeDynamicDetectionTtl(outcome, currentProvider, currentTtlSeconds) {
3184
+ if (!outcome.success)
3185
+ return DETECTION_TTL_FAILED_S;
3186
+ if (outcome.matchedProvider !== currentProvider)
3187
+ return DETECTION_TTL_CHANGED_S;
3188
+ return Math.min(currentTtlSeconds * 2, DETECTION_TTL_MAX_S);
3189
+ }
3190
+
3062
3191
  // src/services/cache-gc.ts
3063
3192
  import { readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync6, existsSync as existsSync6 } from "fs";
3064
3193
  import { join as join12 } from "path";
@@ -3168,6 +3297,34 @@ function runCacheGC(cacheDir) {
3168
3297
  }
3169
3298
 
3170
3299
  // src/cli/piped-mode.ts
3300
+ var DEFAULT_PIPED_MODE_DEPS = {
3301
+ readCurrentEnv,
3302
+ validateRequiredEnv,
3303
+ readCache,
3304
+ writeCache,
3305
+ getCacheDir,
3306
+ isCacheValid,
3307
+ loadConfigWithHash,
3308
+ loadEndpointConfigs,
3309
+ computeEndpointConfigHash,
3310
+ readEndpointLock,
3311
+ writeEndpointLock,
3312
+ needsConfigInit,
3313
+ writeDefaultConfigs,
3314
+ resolveProvider,
3315
+ getProvider,
3316
+ invalidateDetectionCache,
3317
+ deleteProviderDetectionCache,
3318
+ renderError,
3319
+ dimText,
3320
+ executeCycle,
3321
+ logger,
3322
+ runCacheGC,
3323
+ probeHealthWithMetrics,
3324
+ readDetectionCacheMeta,
3325
+ cacheProviderDetectionWithTtl
3326
+ };
3327
+
3171
3328
  class StatuslineError extends Error {
3172
3329
  errorType;
3173
3330
  constructor(errorType) {
@@ -3180,15 +3337,15 @@ function safeStdoutWrite(data) {
3180
3337
  process.stdout["write"](data);
3181
3338
  } catch {}
3182
3339
  }
3183
- function readAndValidateEnv() {
3184
- const env = readCurrentEnv();
3185
- logger.debug("Environment loaded", {
3340
+ function readAndValidateEnv(deps) {
3341
+ const env = deps.readCurrentEnv();
3342
+ deps.logger.debug("Environment loaded", {
3186
3343
  baseUrl: env.baseUrl ? `${env.baseUrl.substring(0, 30)}...` : undefined,
3187
3344
  hasToken: !!env.authToken,
3188
3345
  providerOverride: env.providerOverride,
3189
3346
  pollIntervalOverride: env.pollIntervalOverride
3190
3347
  });
3191
- const envError = validateRequiredEnv(env);
3348
+ const envError = deps.validateRequiredEnv(env);
3192
3349
  if (envError) {
3193
3350
  throw new StatuslineError("missing-env");
3194
3351
  }
@@ -3198,75 +3355,75 @@ function readAndValidateEnv() {
3198
3355
  }
3199
3356
  return { env, baseUrl };
3200
3357
  }
3201
- function ensureDefaultConfigs() {
3202
- if (needsConfigInit()) {
3203
- logger.debug("First run detected - initializing default configs");
3204
- writeDefaultConfigs();
3358
+ function ensureDefaultConfigs(deps) {
3359
+ if (deps.needsConfigInit()) {
3360
+ deps.logger.debug("First run detected - initializing default configs");
3361
+ deps.writeDefaultConfigs();
3205
3362
  }
3206
3363
  }
3207
- function loadEndpointConfigsWithHash() {
3208
- const endpointConfigs = loadEndpointConfigs();
3209
- const endpointConfigHash = computeEndpointConfigHash();
3210
- logger.debug("Endpoint configs loaded", {
3364
+ function loadEndpointConfigsWithHash(deps) {
3365
+ const endpointConfigs = deps.loadEndpointConfigs();
3366
+ const endpointConfigHash = deps.computeEndpointConfigHash();
3367
+ deps.logger.debug("Endpoint configs loaded", {
3211
3368
  configCount: Object.keys(endpointConfigs).length,
3212
3369
  endpointConfigHash
3213
3370
  });
3214
3371
  return { endpointConfigs, endpointConfigHash };
3215
3372
  }
3216
- function resolveEndpointLock(hash) {
3217
- const existing = readEndpointLock();
3373
+ function resolveEndpointLock(hash, deps) {
3374
+ const existing = deps.readEndpointLock();
3218
3375
  if (existing) {
3219
- logger.debug("Endpoint lock file loaded", {
3376
+ deps.logger.debug("Endpoint lock file loaded", {
3220
3377
  lockedHash: existing.hash,
3221
3378
  currentHash: hash,
3222
3379
  locked: existing.hash === hash
3223
3380
  });
3224
3381
  return existing;
3225
3382
  }
3226
- logger.debug("Endpoint lock file missing - creating with current hash");
3227
- writeEndpointLock(hash);
3383
+ deps.logger.debug("Endpoint lock file missing - creating with current hash");
3384
+ deps.writeEndpointLock(hash);
3228
3385
  return { hash, lockedAt: new Date().toISOString() };
3229
3386
  }
3230
- async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs) {
3231
- const probeTimeout = isPiped ? Math.min(1500, Math.max(200, timeoutMs - 200)) : 3000;
3232
- const providerId = await resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3233
- const provider = getProvider(providerId, endpointConfigs);
3234
- logger.debug("Provider resolved", { providerId, probeTimeout });
3387
+ async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs, deps) {
3388
+ const probeTimeout = isPiped ? Math.floor(timeoutMs / 2) : timeoutMs;
3389
+ const providerId = await deps.resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3390
+ const provider = deps.getProvider(providerId, endpointConfigs);
3391
+ deps.logger.debug("Provider resolved", { providerId, probeTimeout });
3235
3392
  if (!provider) {
3236
- logger.error("Provider not found", { providerId });
3393
+ deps.logger.error("Provider not found", { providerId });
3237
3394
  throw new StatuslineError("provider-unknown");
3238
3395
  }
3239
3396
  return { providerId, provider };
3240
3397
  }
3241
3398
  function computeTimeoutBudgets(isPiped, config, timeoutMs) {
3242
- const timeoutBudgetMs = isPiped ? timeoutMs : 1e4;
3243
- const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? DEFAULT_PIPED_REQUEST_TIMEOUT_MS, timeoutBudgetMs - 100) : 1e4;
3399
+ const timeoutBudgetMs = isPiped ? timeoutMs : TTY_TIMEOUT_BUDGET_MS;
3400
+ const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? DEFAULT_TIMEOUT_BUDGET_MS, timeoutBudgetMs - TIMEOUT_HEADROOM_MS) : TTY_TIMEOUT_BUDGET_MS;
3244
3401
  return { timeoutBudgetMs, fetchTimeoutMs };
3245
3402
  }
3246
- async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3247
- const { env, baseUrl } = readAndValidateEnv();
3248
- ensureDefaultConfigs();
3249
- const { config, configHash } = loadConfigWithHash(args.configPath);
3250
- const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash();
3251
- const endpointLock = resolveEndpointLock(endpointConfigHash);
3252
- const cachedEntry = readCache(baseUrl);
3253
- logger.debug("Cache read", {
3403
+ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps) {
3404
+ const { env, baseUrl } = readAndValidateEnv(deps);
3405
+ ensureDefaultConfigs(deps);
3406
+ const { config, configHash } = deps.loadConfigWithHash(args.configPath);
3407
+ const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash(deps);
3408
+ const endpointLock = resolveEndpointLock(endpointConfigHash, deps);
3409
+ const cachedEntry = deps.readCache(baseUrl);
3410
+ deps.logger.debug("Cache read", {
3254
3411
  cacheHit: !!cachedEntry,
3255
3412
  cacheAge: cachedEntry ? `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s` : "N/A"
3256
3413
  });
3257
3414
  let providerId;
3258
3415
  let provider;
3259
- if (cachedEntry && isCacheValid(cachedEntry, env)) {
3260
- const cachedProvider = getProvider(cachedEntry.provider, endpointConfigs);
3416
+ if (cachedEntry && deps.isCacheValid(cachedEntry, env)) {
3417
+ const cachedProvider = deps.getProvider(cachedEntry.provider, endpointConfigs);
3261
3418
  if (cachedProvider) {
3262
3419
  providerId = cachedEntry.provider;
3263
3420
  provider = cachedProvider;
3264
- logger.debug("Cache-first: skipping provider probe", { providerId });
3421
+ deps.logger.debug("Cache-first: skipping provider probe", { providerId });
3265
3422
  } else {
3266
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3423
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3267
3424
  }
3268
3425
  } else {
3269
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3426
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3270
3427
  }
3271
3428
  const { timeoutBudgetMs, fetchTimeoutMs } = computeTimeoutBudgets(isPiped, config, rawTimeoutMs);
3272
3429
  const ctx = {
@@ -3282,82 +3439,124 @@ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3282
3439
  startTime,
3283
3440
  fetchTimeoutMs
3284
3441
  };
3285
- return { ctx, baseUrl };
3442
+ return { ctx, baseUrl, endpointConfigs };
3286
3443
  }
3287
- function formatOutput(output, isPiped) {
3444
+ function formatOutput(output, mode, log) {
3288
3445
  let normalizedOutput = output;
3289
3446
  if (!normalizedOutput || normalizedOutput.trim().length === 0) {
3290
- logger.debug("Empty output detected, using fallback");
3447
+ log.debug("Empty output detected, using fallback");
3291
3448
  normalizedOutput = "[loading...]";
3292
3449
  }
3293
- if (isPiped) {
3294
- logger.debug("Output formatted for piped mode (ANSI reset + NBSP)");
3295
- return "\x1B[0m" + normalizedOutput.replace(/ /g, " ");
3296
- } else {
3297
- logger.debug("Output written (TTY mode)");
3298
- return normalizedOutput;
3450
+ switch (mode) {
3451
+ case "piped-embedded":
3452
+ log.debug("Output written (embedded piped mode - no host formatting)");
3453
+ return normalizedOutput;
3454
+ case "piped":
3455
+ log.debug("Output formatted for piped mode (ANSI reset + NBSP)");
3456
+ return "\x1B[0m" + normalizedOutput.replace(/ /g, " ");
3457
+ case "tty":
3458
+ log.debug("Output written (TTY mode)");
3459
+ return normalizedOutput + `
3460
+ `;
3299
3461
  }
3300
3462
  }
3301
- async function executePipedMode(args) {
3463
+ async function runMaintenance(result, baseUrl, startTime, budgetMs, endpointConfigs, currentProviderId, deps) {
3464
+ const { ageMs, ttlMs } = deps.readDetectionCacheMeta(baseUrl);
3465
+ const task = selectMaintenanceTask({
3466
+ path: result.path,
3467
+ detectionCacheAgeMs: ageMs,
3468
+ detectionCacheTtlMs: ttlMs
3469
+ });
3470
+ if (task === "none")
3471
+ return;
3472
+ deps.logger.debug("Maintenance task selected", { task, path: result.path });
3473
+ if (task === "health-probe") {
3474
+ const elapsed = Date.now() - startTime;
3475
+ const remainingMs = Math.max(50, budgetMs - elapsed - TIMEOUT_HEADROOM_MS);
3476
+ const currentTtlS = Math.floor(ttlMs / 1000) || DETECTION_TTL_BASE_S;
3477
+ const outcome = await deps.probeHealthWithMetrics(baseUrl, remainingMs, endpointConfigs);
3478
+ deps.logger.debug("Maintenance probe completed", {
3479
+ success: outcome.success,
3480
+ matchedProvider: outcome.matchedProvider,
3481
+ responseTimeMs: outcome.responseTimeMs
3482
+ });
3483
+ if (outcome.success && outcome.matchedProvider) {
3484
+ const newTtlS = computeDynamicDetectionTtl(outcome, currentProviderId, currentTtlS);
3485
+ deps.cacheProviderDetectionWithTtl(baseUrl, outcome.matchedProvider, newTtlS);
3486
+ deps.logger.debug("Detection cache refreshed", { ttlSeconds: newTtlS, provider: outcome.matchedProvider });
3487
+ }
3488
+ } else if (task === "cache-gc") {
3489
+ deps.runCacheGC(deps.getCacheDir());
3490
+ deps.logger.debug("Cache GC completed");
3491
+ }
3492
+ }
3493
+ async function executePipedMode(args, deps = DEFAULT_PIPED_MODE_DEPS) {
3302
3494
  const startTime = Date.now();
3303
- logger.debug("=== cc-api-statusline execution started ===");
3304
- logger.debug("Start time", { startTime });
3495
+ deps.logger.debug("=== cc-api-statusline execution started ===");
3496
+ deps.logger.debug("Start time", { startTime });
3305
3497
  const isPiped = !process.stdin.isTTY;
3306
- logger.debug("Mode detection", { isPiped, once: args.once });
3307
- const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 5000);
3498
+ const outputMode = !isPiped ? "tty" : args.embedded ? "piped-embedded" : "piped";
3499
+ deps.logger.debug("Mode detection", { isPiped, once: args.once, outputMode });
3500
+ const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? DEFAULT_TIMEOUT_BUDGET_MS);
3308
3501
  if (isPiped) {
3309
- const watchdogMs = rawTimeoutMs - 100;
3502
+ const watchdogMs = rawTimeoutMs - TIMEOUT_HEADROOM_MS;
3310
3503
  setTimeout(() => {
3311
- logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3312
- const fallback = dimText("⟳ Refreshing...");
3313
- const formatted = formatOutput(fallback, isPiped);
3504
+ deps.logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3505
+ const fallback = deps.dimText("⟳ Refreshing...");
3506
+ const formatted = formatOutput(fallback, outputMode, deps.logger);
3314
3507
  safeStdoutWrite(formatted);
3315
3508
  process.exit(0);
3316
3509
  }, watchdogMs).unref();
3317
3510
  }
3318
3511
  let ctx;
3319
3512
  let baseUrl;
3513
+ let endpointConfigs;
3320
3514
  try {
3321
- ({ ctx, baseUrl } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs));
3515
+ ({ ctx, baseUrl, endpointConfigs } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps));
3322
3516
  } catch (error) {
3323
- logger.error("Failed to build execution context", { error: String(error) });
3517
+ deps.logger.error("Failed to build execution context", { error: String(error) });
3324
3518
  const errorType = error instanceof StatuslineError ? error.errorType : "network-error";
3325
- const errorOutput = renderError(errorType, "without-cache");
3326
- const formattedOutput2 = formatOutput(errorOutput, isPiped);
3519
+ const errorOutput = deps.renderError(errorType, "without-cache");
3520
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3327
3521
  safeStdoutWrite(formattedOutput2);
3328
- logger.debug("=== cc-api-statusline execution completed ===");
3522
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3329
3523
  process.exit(0);
3330
3524
  }
3331
- logger.debug("Execution context prepared", {
3525
+ deps.logger.debug("Execution context prepared", {
3332
3526
  timeoutBudgetMs: ctx.timeoutBudgetMs,
3333
3527
  fetchTimeoutMs: ctx.fetchTimeoutMs
3334
3528
  });
3335
3529
  let result;
3336
3530
  try {
3337
- result = await executeCycle(ctx);
3531
+ result = await deps.executeCycle(ctx);
3338
3532
  } catch (error) {
3339
- logger.error("Execution cycle failed", { error: String(error) });
3340
- const errorOutput = renderError("network-error", "without-cache");
3341
- const formattedOutput2 = formatOutput(errorOutput, isPiped);
3533
+ deps.logger.error("Execution cycle failed", { error: String(error) });
3534
+ const errorOutput = deps.renderError("network-error", "without-cache");
3535
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3342
3536
  safeStdoutWrite(formattedOutput2);
3343
- logger.debug("=== cc-api-statusline execution completed ===");
3537
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3344
3538
  process.exit(0);
3345
3539
  }
3346
3540
  const executionTime = Date.now() - startTime;
3347
- logger.debug("Execution completed", {
3541
+ deps.logger.debug("Execution completed", {
3348
3542
  exitCode: result.exitCode,
3349
3543
  executionTime: `${executionTime}ms`,
3350
3544
  outputLength: result.output.length,
3351
3545
  cacheUpdate: !!result.cacheUpdate
3352
3546
  });
3353
- const formattedOutput = formatOutput(result.output, isPiped);
3547
+ const formattedOutput = formatOutput(result.output, outputMode, deps.logger);
3354
3548
  safeStdoutWrite(formattedOutput);
3549
+ if (result.invalidateProvider) {
3550
+ deps.invalidateDetectionCache(baseUrl);
3551
+ deps.deleteProviderDetectionCache(baseUrl);
3552
+ deps.logger.debug("Provider detection cache invalidated", { baseUrl });
3553
+ }
3355
3554
  if (result.cacheUpdate) {
3356
- writeCache(baseUrl, result.cacheUpdate);
3357
- logger.debug("Cache written", { baseUrl });
3358
- runCacheGC(getCacheDir());
3555
+ deps.writeCache(baseUrl, result.cacheUpdate);
3556
+ deps.logger.debug("Cache written", { baseUrl });
3359
3557
  }
3360
- logger.debug("=== cc-api-statusline execution completed ===");
3558
+ await runMaintenance(result, baseUrl, startTime, rawTimeoutMs, endpointConfigs, ctx.providerId, deps);
3559
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3361
3560
  process.exit(result.exitCode);
3362
3561
  }
3363
3562
  // src/main.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-api-statusline",
3
- "version": "1.0.2",
3
+ "version": "1.1.2",
4
4
  "description": "Claude Code statusline tool that polls API usage from third-party proxy backends",
5
5
  "type": "module",
6
6
  "bin": {