cc-api-statusline 1.0.2 → 1.1.1

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.1",
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}`);
@@ -2935,6 +2989,35 @@ function isComponentId(key) {
2935
2989
  return DEFAULT_COMPONENT_ORDER.includes(key);
2936
2990
  }
2937
2991
 
2992
+ // src/core/error-classifier.ts
2993
+ function classifyFetchError(error) {
2994
+ if (error && typeof error === "object") {
2995
+ if ("statusCode" in error) {
2996
+ const statusCode = error.statusCode;
2997
+ if (statusCode === 404 || statusCode === 410) {
2998
+ return "site-closed";
2999
+ }
3000
+ return "transient";
3001
+ }
3002
+ if (error instanceof Error) {
3003
+ if (error.name === "TimeoutError") {
3004
+ return "transient";
3005
+ }
3006
+ if (error.name === "ResponseTooLargeError") {
3007
+ return "provider-mismatch";
3008
+ }
3009
+ if (error instanceof SyntaxError) {
3010
+ return "provider-mismatch";
3011
+ }
3012
+ const msg = error.message.toLowerCase();
3013
+ if (msg.includes("invalid response") || msg.includes("expected object") || msg.includes("missing data") || msg.includes("missing limits")) {
3014
+ return "provider-mismatch";
3015
+ }
3016
+ }
3017
+ }
3018
+ return "transient";
3019
+ }
3020
+
2938
3021
  // src/core/execute-cycle.ts
2939
3022
  async function executeCycle(ctx) {
2940
3023
  const { env, config, configHash, endpointConfigHash, endpointLock, cachedEntry, providerId, provider, timeoutBudgetMs, startTime, fetchTimeoutMs } = ctx;
@@ -2947,7 +3030,9 @@ async function executeCycle(ctx) {
2947
3030
  return {
2948
3031
  output: cachedEntry.renderedLine,
2949
3032
  exitCode: 0,
2950
- cacheUpdate: null
3033
+ cacheUpdate: null,
3034
+ invalidateProvider: false,
3035
+ path: "A"
2951
3036
  };
2952
3037
  }
2953
3038
  }
@@ -2962,14 +3047,18 @@ async function executeCycle(ctx) {
2962
3047
  return {
2963
3048
  output: statusline,
2964
3049
  exitCode: 0,
2965
- cacheUpdate: null
3050
+ cacheUpdate: null,
3051
+ invalidateProvider: false,
3052
+ path: "B2"
2966
3053
  };
2967
3054
  }
2968
3055
  const errorOutput = renderError("endpoint-config-changed", "without-cache");
2969
3056
  return {
2970
3057
  output: errorOutput,
2971
3058
  exitCode: 0,
2972
- cacheUpdate: null
3059
+ cacheUpdate: null,
3060
+ invalidateProvider: false,
3061
+ path: "B2"
2973
3062
  };
2974
3063
  }
2975
3064
  if (cachedEntry && isCacheValid(cachedEntry, env) && isCacheProviderValid(cachedEntry, providerId)) {
@@ -2984,7 +3073,9 @@ async function executeCycle(ctx) {
2984
3073
  return {
2985
3074
  output: statusline,
2986
3075
  exitCode: 0,
2987
- cacheUpdate: updatedEntry
3076
+ cacheUpdate: updatedEntry,
3077
+ invalidateProvider: false,
3078
+ path: "B"
2988
3079
  };
2989
3080
  }
2990
3081
  const deadline = startTime + timeoutBudgetMs - EXIT_BUFFER_MS;
@@ -2995,7 +3086,9 @@ async function executeCycle(ctx) {
2995
3086
  return {
2996
3087
  output: errorOutput,
2997
3088
  exitCode: 0,
2998
- cacheUpdate: null
3089
+ cacheUpdate: null,
3090
+ invalidateProvider: false,
3091
+ path: "D"
2999
3092
  };
3000
3093
  }
3001
3094
  try {
@@ -3005,7 +3098,9 @@ async function executeCycle(ctx) {
3005
3098
  return {
3006
3099
  output: renderError("missing-env", "without-cache"),
3007
3100
  exitCode: 0,
3008
- cacheUpdate: null
3101
+ cacheUpdate: null,
3102
+ invalidateProvider: false,
3103
+ path: "D"
3009
3104
  };
3010
3105
  }
3011
3106
  logger.debug("Path C: Fetching from provider", { providerId, fetchTimeoutMs });
@@ -3031,23 +3126,30 @@ async function executeCycle(ctx) {
3031
3126
  return {
3032
3127
  output: statusline,
3033
3128
  exitCode: 0,
3034
- cacheUpdate: newEntry
3129
+ cacheUpdate: newEntry,
3130
+ invalidateProvider: false,
3131
+ path: "C"
3035
3132
  };
3036
3133
  } catch (error) {
3037
3134
  logger.error("Path D: Fetch error", { error: String(error), hasCachedEntry: !!cachedEntry });
3135
+ const errorCategory = classifyFetchError(error);
3038
3136
  let errorState = "network-error";
3039
3137
  if (error && typeof error === "object" && "statusCode" in error) {
3040
3138
  const statusCode = error.statusCode;
3041
3139
  if (statusCode === 429) {
3042
3140
  errorState = "rate-limited";
3141
+ } else if (errorCategory === "site-closed") {
3142
+ errorState = "network-error";
3043
3143
  } else if (statusCode && statusCode >= 500) {
3044
3144
  errorState = "server-error";
3045
3145
  } else if (statusCode === 401 || statusCode === 403) {
3046
3146
  errorState = "auth-error";
3047
3147
  }
3148
+ } else if (errorCategory === "provider-mismatch") {
3149
+ errorState = "parse-error";
3048
3150
  }
3049
3151
  if (cachedEntry) {
3050
- logger.debug("Discarding stale cache, showing error", { errorState });
3152
+ logger.debug("Discarding stale cache, showing error", { errorState, errorCategory });
3051
3153
  } else {
3052
3154
  logger.warn("No cache available for error fallback");
3053
3155
  }
@@ -3055,10 +3157,32 @@ async function executeCycle(ctx) {
3055
3157
  return {
3056
3158
  output: errorOutput,
3057
3159
  exitCode: 0,
3058
- cacheUpdate: null
3160
+ cacheUpdate: null,
3161
+ invalidateProvider: errorCategory === "provider-mismatch",
3162
+ path: "D"
3059
3163
  };
3060
3164
  }
3061
3165
  }
3166
+ // src/core/maintenance-scheduler.ts
3167
+ function selectMaintenanceTask(ctx) {
3168
+ if (ctx.path !== "A" && ctx.path !== "B")
3169
+ return "none";
3170
+ if (ctx.detectionCacheAgeMs === null)
3171
+ return "health-probe";
3172
+ if (ctx.detectionCacheAgeMs >= ctx.detectionCacheTtlMs * 0.5)
3173
+ return "health-probe";
3174
+ if (Math.random() < MAINTENANCE_GC_PROBABILITY)
3175
+ return "cache-gc";
3176
+ return "none";
3177
+ }
3178
+ function computeDynamicDetectionTtl(outcome, currentProvider, currentTtlSeconds) {
3179
+ if (!outcome.success)
3180
+ return DETECTION_TTL_FAILED_S;
3181
+ if (outcome.matchedProvider !== currentProvider)
3182
+ return DETECTION_TTL_CHANGED_S;
3183
+ return Math.min(currentTtlSeconds * 2, DETECTION_TTL_MAX_S);
3184
+ }
3185
+
3062
3186
  // src/services/cache-gc.ts
3063
3187
  import { readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync6, existsSync as existsSync6 } from "fs";
3064
3188
  import { join as join12 } from "path";
@@ -3168,6 +3292,34 @@ function runCacheGC(cacheDir) {
3168
3292
  }
3169
3293
 
3170
3294
  // src/cli/piped-mode.ts
3295
+ var DEFAULT_PIPED_MODE_DEPS = {
3296
+ readCurrentEnv,
3297
+ validateRequiredEnv,
3298
+ readCache,
3299
+ writeCache,
3300
+ getCacheDir,
3301
+ isCacheValid,
3302
+ loadConfigWithHash,
3303
+ loadEndpointConfigs,
3304
+ computeEndpointConfigHash,
3305
+ readEndpointLock,
3306
+ writeEndpointLock,
3307
+ needsConfigInit,
3308
+ writeDefaultConfigs,
3309
+ resolveProvider,
3310
+ getProvider,
3311
+ invalidateDetectionCache,
3312
+ deleteProviderDetectionCache,
3313
+ renderError,
3314
+ dimText,
3315
+ executeCycle,
3316
+ logger,
3317
+ runCacheGC,
3318
+ probeHealthWithMetrics,
3319
+ readDetectionCacheMeta,
3320
+ cacheProviderDetectionWithTtl
3321
+ };
3322
+
3171
3323
  class StatuslineError extends Error {
3172
3324
  errorType;
3173
3325
  constructor(errorType) {
@@ -3180,15 +3332,15 @@ function safeStdoutWrite(data) {
3180
3332
  process.stdout["write"](data);
3181
3333
  } catch {}
3182
3334
  }
3183
- function readAndValidateEnv() {
3184
- const env = readCurrentEnv();
3185
- logger.debug("Environment loaded", {
3335
+ function readAndValidateEnv(deps) {
3336
+ const env = deps.readCurrentEnv();
3337
+ deps.logger.debug("Environment loaded", {
3186
3338
  baseUrl: env.baseUrl ? `${env.baseUrl.substring(0, 30)}...` : undefined,
3187
3339
  hasToken: !!env.authToken,
3188
3340
  providerOverride: env.providerOverride,
3189
3341
  pollIntervalOverride: env.pollIntervalOverride
3190
3342
  });
3191
- const envError = validateRequiredEnv(env);
3343
+ const envError = deps.validateRequiredEnv(env);
3192
3344
  if (envError) {
3193
3345
  throw new StatuslineError("missing-env");
3194
3346
  }
@@ -3198,75 +3350,75 @@ function readAndValidateEnv() {
3198
3350
  }
3199
3351
  return { env, baseUrl };
3200
3352
  }
3201
- function ensureDefaultConfigs() {
3202
- if (needsConfigInit()) {
3203
- logger.debug("First run detected - initializing default configs");
3204
- writeDefaultConfigs();
3353
+ function ensureDefaultConfigs(deps) {
3354
+ if (deps.needsConfigInit()) {
3355
+ deps.logger.debug("First run detected - initializing default configs");
3356
+ deps.writeDefaultConfigs();
3205
3357
  }
3206
3358
  }
3207
- function loadEndpointConfigsWithHash() {
3208
- const endpointConfigs = loadEndpointConfigs();
3209
- const endpointConfigHash = computeEndpointConfigHash();
3210
- logger.debug("Endpoint configs loaded", {
3359
+ function loadEndpointConfigsWithHash(deps) {
3360
+ const endpointConfigs = deps.loadEndpointConfigs();
3361
+ const endpointConfigHash = deps.computeEndpointConfigHash();
3362
+ deps.logger.debug("Endpoint configs loaded", {
3211
3363
  configCount: Object.keys(endpointConfigs).length,
3212
3364
  endpointConfigHash
3213
3365
  });
3214
3366
  return { endpointConfigs, endpointConfigHash };
3215
3367
  }
3216
- function resolveEndpointLock(hash) {
3217
- const existing = readEndpointLock();
3368
+ function resolveEndpointLock(hash, deps) {
3369
+ const existing = deps.readEndpointLock();
3218
3370
  if (existing) {
3219
- logger.debug("Endpoint lock file loaded", {
3371
+ deps.logger.debug("Endpoint lock file loaded", {
3220
3372
  lockedHash: existing.hash,
3221
3373
  currentHash: hash,
3222
3374
  locked: existing.hash === hash
3223
3375
  });
3224
3376
  return existing;
3225
3377
  }
3226
- logger.debug("Endpoint lock file missing - creating with current hash");
3227
- writeEndpointLock(hash);
3378
+ deps.logger.debug("Endpoint lock file missing - creating with current hash");
3379
+ deps.writeEndpointLock(hash);
3228
3380
  return { hash, lockedAt: new Date().toISOString() };
3229
3381
  }
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 });
3382
+ async function resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, timeoutMs, deps) {
3383
+ const probeTimeout = isPiped ? Math.floor(timeoutMs / 2) : timeoutMs;
3384
+ const providerId = await deps.resolveProvider(baseUrl, env.providerOverride, endpointConfigs, probeTimeout);
3385
+ const provider = deps.getProvider(providerId, endpointConfigs);
3386
+ deps.logger.debug("Provider resolved", { providerId, probeTimeout });
3235
3387
  if (!provider) {
3236
- logger.error("Provider not found", { providerId });
3388
+ deps.logger.error("Provider not found", { providerId });
3237
3389
  throw new StatuslineError("provider-unknown");
3238
3390
  }
3239
3391
  return { providerId, provider };
3240
3392
  }
3241
3393
  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;
3394
+ const timeoutBudgetMs = isPiped ? timeoutMs : TTY_TIMEOUT_BUDGET_MS;
3395
+ const fetchTimeoutMs = isPiped ? Math.min(config.pipedRequestTimeoutMs ?? DEFAULT_TIMEOUT_BUDGET_MS, timeoutBudgetMs - TIMEOUT_HEADROOM_MS) : TTY_TIMEOUT_BUDGET_MS;
3244
3396
  return { timeoutBudgetMs, fetchTimeoutMs };
3245
3397
  }
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", {
3398
+ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps) {
3399
+ const { env, baseUrl } = readAndValidateEnv(deps);
3400
+ ensureDefaultConfigs(deps);
3401
+ const { config, configHash } = deps.loadConfigWithHash(args.configPath);
3402
+ const { endpointConfigs, endpointConfigHash } = loadEndpointConfigsWithHash(deps);
3403
+ const endpointLock = resolveEndpointLock(endpointConfigHash, deps);
3404
+ const cachedEntry = deps.readCache(baseUrl);
3405
+ deps.logger.debug("Cache read", {
3254
3406
  cacheHit: !!cachedEntry,
3255
3407
  cacheAge: cachedEntry ? `${Math.floor((Date.now() - new Date(cachedEntry.fetchedAt).getTime()) / 1000)}s` : "N/A"
3256
3408
  });
3257
3409
  let providerId;
3258
3410
  let provider;
3259
- if (cachedEntry && isCacheValid(cachedEntry, env)) {
3260
- const cachedProvider = getProvider(cachedEntry.provider, endpointConfigs);
3411
+ if (cachedEntry && deps.isCacheValid(cachedEntry, env)) {
3412
+ const cachedProvider = deps.getProvider(cachedEntry.provider, endpointConfigs);
3261
3413
  if (cachedProvider) {
3262
3414
  providerId = cachedEntry.provider;
3263
3415
  provider = cachedProvider;
3264
- logger.debug("Cache-first: skipping provider probe", { providerId });
3416
+ deps.logger.debug("Cache-first: skipping provider probe", { providerId });
3265
3417
  } else {
3266
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3418
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3267
3419
  }
3268
3420
  } else {
3269
- ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs));
3421
+ ({ providerId, provider } = await resolveProviderWithTimeout(baseUrl, env, endpointConfigs, isPiped, rawTimeoutMs, deps));
3270
3422
  }
3271
3423
  const { timeoutBudgetMs, fetchTimeoutMs } = computeTimeoutBudgets(isPiped, config, rawTimeoutMs);
3272
3424
  const ctx = {
@@ -3282,82 +3434,124 @@ async function buildExecutionContext(args, isPiped, startTime, rawTimeoutMs) {
3282
3434
  startTime,
3283
3435
  fetchTimeoutMs
3284
3436
  };
3285
- return { ctx, baseUrl };
3437
+ return { ctx, baseUrl, endpointConfigs };
3286
3438
  }
3287
- function formatOutput(output, isPiped) {
3439
+ function formatOutput(output, mode, log) {
3288
3440
  let normalizedOutput = output;
3289
3441
  if (!normalizedOutput || normalizedOutput.trim().length === 0) {
3290
- logger.debug("Empty output detected, using fallback");
3442
+ log.debug("Empty output detected, using fallback");
3291
3443
  normalizedOutput = "[loading...]";
3292
3444
  }
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;
3445
+ switch (mode) {
3446
+ case "piped-embedded":
3447
+ log.debug("Output written (embedded piped mode - no host formatting)");
3448
+ return normalizedOutput;
3449
+ case "piped":
3450
+ log.debug("Output formatted for piped mode (ANSI reset + NBSP)");
3451
+ return "\x1B[0m" + normalizedOutput.replace(/ /g, " ");
3452
+ case "tty":
3453
+ log.debug("Output written (TTY mode)");
3454
+ return normalizedOutput + `
3455
+ `;
3456
+ }
3457
+ }
3458
+ async function runMaintenance(result, baseUrl, startTime, budgetMs, endpointConfigs, currentProviderId, deps) {
3459
+ const { ageMs, ttlMs } = deps.readDetectionCacheMeta(baseUrl);
3460
+ const task = selectMaintenanceTask({
3461
+ path: result.path,
3462
+ detectionCacheAgeMs: ageMs,
3463
+ detectionCacheTtlMs: ttlMs
3464
+ });
3465
+ if (task === "none")
3466
+ return;
3467
+ deps.logger.debug("Maintenance task selected", { task, path: result.path });
3468
+ if (task === "health-probe") {
3469
+ const elapsed = Date.now() - startTime;
3470
+ const remainingMs = Math.max(50, budgetMs - elapsed - TIMEOUT_HEADROOM_MS);
3471
+ const currentTtlS = Math.floor(ttlMs / 1000) || DETECTION_TTL_BASE_S;
3472
+ const outcome = await deps.probeHealthWithMetrics(baseUrl, remainingMs, endpointConfigs);
3473
+ deps.logger.debug("Maintenance probe completed", {
3474
+ success: outcome.success,
3475
+ matchedProvider: outcome.matchedProvider,
3476
+ responseTimeMs: outcome.responseTimeMs
3477
+ });
3478
+ if (outcome.success && outcome.matchedProvider) {
3479
+ const newTtlS = computeDynamicDetectionTtl(outcome, currentProviderId, currentTtlS);
3480
+ deps.cacheProviderDetectionWithTtl(baseUrl, outcome.matchedProvider, newTtlS);
3481
+ deps.logger.debug("Detection cache refreshed", { ttlSeconds: newTtlS, provider: outcome.matchedProvider });
3482
+ }
3483
+ } else if (task === "cache-gc") {
3484
+ deps.runCacheGC(deps.getCacheDir());
3485
+ deps.logger.debug("Cache GC completed");
3299
3486
  }
3300
3487
  }
3301
- async function executePipedMode(args) {
3488
+ async function executePipedMode(args, deps = DEFAULT_PIPED_MODE_DEPS) {
3302
3489
  const startTime = Date.now();
3303
- logger.debug("=== cc-api-statusline execution started ===");
3304
- logger.debug("Start time", { startTime });
3490
+ deps.logger.debug("=== cc-api-statusline execution started ===");
3491
+ deps.logger.debug("Start time", { startTime });
3305
3492
  const isPiped = !process.stdin.isTTY;
3306
- logger.debug("Mode detection", { isPiped, once: args.once });
3307
- const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? 5000);
3493
+ const outputMode = !isPiped ? "tty" : args.embedded ? "piped-embedded" : "piped";
3494
+ deps.logger.debug("Mode detection", { isPiped, once: args.once, outputMode });
3495
+ const rawTimeoutMs = Number(process.env["CC_STATUSLINE_TIMEOUT"] ?? DEFAULT_TIMEOUT_BUDGET_MS);
3308
3496
  if (isPiped) {
3309
- const watchdogMs = rawTimeoutMs - 100;
3497
+ const watchdogMs = rawTimeoutMs - TIMEOUT_HEADROOM_MS;
3310
3498
  setTimeout(() => {
3311
- logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3312
- const fallback = dimText("⟳ Refreshing...");
3313
- const formatted = formatOutput(fallback, isPiped);
3499
+ deps.logger.error("Watchdog timeout - forcing clean exit", { watchdogMs });
3500
+ const fallback = deps.dimText("⟳ Refreshing...");
3501
+ const formatted = formatOutput(fallback, outputMode, deps.logger);
3314
3502
  safeStdoutWrite(formatted);
3315
3503
  process.exit(0);
3316
3504
  }, watchdogMs).unref();
3317
3505
  }
3318
3506
  let ctx;
3319
3507
  let baseUrl;
3508
+ let endpointConfigs;
3320
3509
  try {
3321
- ({ ctx, baseUrl } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs));
3510
+ ({ ctx, baseUrl, endpointConfigs } = await buildExecutionContext(args, isPiped, startTime, rawTimeoutMs, deps));
3322
3511
  } catch (error) {
3323
- logger.error("Failed to build execution context", { error: String(error) });
3512
+ deps.logger.error("Failed to build execution context", { error: String(error) });
3324
3513
  const errorType = error instanceof StatuslineError ? error.errorType : "network-error";
3325
- const errorOutput = renderError(errorType, "without-cache");
3326
- const formattedOutput2 = formatOutput(errorOutput, isPiped);
3514
+ const errorOutput = deps.renderError(errorType, "without-cache");
3515
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3327
3516
  safeStdoutWrite(formattedOutput2);
3328
- logger.debug("=== cc-api-statusline execution completed ===");
3517
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3329
3518
  process.exit(0);
3330
3519
  }
3331
- logger.debug("Execution context prepared", {
3520
+ deps.logger.debug("Execution context prepared", {
3332
3521
  timeoutBudgetMs: ctx.timeoutBudgetMs,
3333
3522
  fetchTimeoutMs: ctx.fetchTimeoutMs
3334
3523
  });
3335
3524
  let result;
3336
3525
  try {
3337
- result = await executeCycle(ctx);
3526
+ result = await deps.executeCycle(ctx);
3338
3527
  } 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);
3528
+ deps.logger.error("Execution cycle failed", { error: String(error) });
3529
+ const errorOutput = deps.renderError("network-error", "without-cache");
3530
+ const formattedOutput2 = formatOutput(errorOutput, outputMode, deps.logger);
3342
3531
  safeStdoutWrite(formattedOutput2);
3343
- logger.debug("=== cc-api-statusline execution completed ===");
3532
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3344
3533
  process.exit(0);
3345
3534
  }
3346
3535
  const executionTime = Date.now() - startTime;
3347
- logger.debug("Execution completed", {
3536
+ deps.logger.debug("Execution completed", {
3348
3537
  exitCode: result.exitCode,
3349
3538
  executionTime: `${executionTime}ms`,
3350
3539
  outputLength: result.output.length,
3351
3540
  cacheUpdate: !!result.cacheUpdate
3352
3541
  });
3353
- const formattedOutput = formatOutput(result.output, isPiped);
3542
+ const formattedOutput = formatOutput(result.output, outputMode, deps.logger);
3354
3543
  safeStdoutWrite(formattedOutput);
3544
+ if (result.invalidateProvider) {
3545
+ deps.invalidateDetectionCache(baseUrl);
3546
+ deps.deleteProviderDetectionCache(baseUrl);
3547
+ deps.logger.debug("Provider detection cache invalidated", { baseUrl });
3548
+ }
3355
3549
  if (result.cacheUpdate) {
3356
- writeCache(baseUrl, result.cacheUpdate);
3357
- logger.debug("Cache written", { baseUrl });
3358
- runCacheGC(getCacheDir());
3550
+ deps.writeCache(baseUrl, result.cacheUpdate);
3551
+ deps.logger.debug("Cache written", { baseUrl });
3359
3552
  }
3360
- logger.debug("=== cc-api-statusline execution completed ===");
3553
+ await runMaintenance(result, baseUrl, startTime, rawTimeoutMs, endpointConfigs, ctx.providerId, deps);
3554
+ deps.logger.debug("=== cc-api-statusline execution completed ===");
3361
3555
  process.exit(result.exitCode);
3362
3556
  }
3363
3557
  // 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.1",
4
4
  "description": "Claude Code statusline tool that polls API usage from third-party proxy backends",
5
5
  "type": "module",
6
6
  "bin": {