@leviyuan/lodestar 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -41
- package/dist/lodestar-setup.js +12 -0
- package/dist/lodestar.js +147 -0
- package/package.json +15 -6
- package/scripts/postinstall.cjs +97 -0
- package/daemon.ts +0 -355
- package/src/cardkit.ts +0 -349
- package/src/cards/console.ts +0 -352
- package/src/cards/elements.ts +0 -22
- package/src/cards/turn.ts +0 -530
- package/src/cards.ts +0 -32
- package/src/claude-process.ts +0 -417
- package/src/config.ts +0 -98
- package/src/feishu.ts +0 -498
- package/src/instructions.ts +0 -13
- package/src/log.ts +0 -11
- package/src/notify.ts +0 -132
- package/src/paths.ts +0 -57
- package/src/session-ask.ts +0 -165
- package/src/session-permission.ts +0 -136
- package/src/session-tools.ts +0 -233
- package/src/session-types.ts +0 -91
- package/src/session.ts +0 -1137
- package/src/sysinfo.ts +0 -273
- package/src/usage.ts +0 -327
package/src/sysinfo.ts
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight host snapshot for the `hi` console panel —— CPU 负载、
|
|
3
|
-
* 内存、根/家目录磁盘、以及当前用户下的 cc-* 系列 systemd 服务。
|
|
4
|
-
*
|
|
5
|
-
* 服务前缀约定: claude code 自己起的常驻进程统一走
|
|
6
|
-
* systemd-run --user --unit=cc-<project>-<purpose> -- <cmd>
|
|
7
|
-
* (见全局 CLAUDE.md 的 background_process_safety 段)。`hi` 面板只列
|
|
8
|
-
* `cc-*` 是要让 daemon 这台机器上的"AI 拉起来的活儿"一眼可见,跟
|
|
9
|
-
* 系统自带 / 第三方服务区分开。
|
|
10
|
-
*
|
|
11
|
-
* 所有数据源都是本机文件 / 系统调用,没有网络往返:
|
|
12
|
-
* /proc/loadavg —— 1m / 5m / 15m
|
|
13
|
-
* /proc/meminfo —— Total / Available
|
|
14
|
-
* statfsSync(path) —— 各挂载点容量
|
|
15
|
-
* /proc/uptime —— monotonic seconds since boot (uptime 推算)
|
|
16
|
-
* systemctl --user show —— cc-* 服务的状态与启动时间
|
|
17
|
-
*
|
|
18
|
-
* 失败可见: 任何一段读不到就把对应字段标 null,卡片层按 null 渲染
|
|
19
|
-
* `_n/a_`,绝不假数据 (no_fallbacks)。
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { readFileSync, statfsSync, statSync } from 'node:fs'
|
|
23
|
-
import { cpus, homedir } from 'node:os'
|
|
24
|
-
import { log } from './log'
|
|
25
|
-
|
|
26
|
-
export interface CpuInfo {
|
|
27
|
-
cores: number
|
|
28
|
-
load1: number
|
|
29
|
-
load5: number
|
|
30
|
-
load15: number
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface MemInfo {
|
|
34
|
-
/** Bytes — MemTotal 来自 /proc/meminfo */
|
|
35
|
-
totalBytes: number
|
|
36
|
-
/** Bytes — MemAvailable;比 free + buffers + cached 更准 (考虑 reclaimable) */
|
|
37
|
-
availBytes: number
|
|
38
|
-
usedBytes: number
|
|
39
|
-
/** 0–100 */
|
|
40
|
-
percent: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface DiskInfo {
|
|
44
|
-
/** 显示用的标签 ('/' 或 '$HOME') */
|
|
45
|
-
label: string
|
|
46
|
-
/** 实际查询的路径 */
|
|
47
|
-
path: string
|
|
48
|
-
totalBytes: number
|
|
49
|
-
availBytes: number
|
|
50
|
-
usedBytes: number
|
|
51
|
-
/** 0–100;按 used / total */
|
|
52
|
-
percent: number
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface ServiceInfo {
|
|
56
|
-
/** 不带 .service 后缀,贴卡片用 */
|
|
57
|
-
name: string
|
|
58
|
-
/** systemd ActiveState: active | inactive | failed | activating | deactivating */
|
|
59
|
-
active: string
|
|
60
|
-
/** SubState: running | exited | dead | start | stop-sigterm | ... */
|
|
61
|
-
sub: string
|
|
62
|
-
/** 自最近一次进入 active 状态起的秒数。从未活跃过则为 null。
|
|
63
|
-
* 对 active 服务等于 "已运行 X 秒";对 inactive/failed 等于
|
|
64
|
-
* "上次跑起来到现在过了 X 秒"。 */
|
|
65
|
-
lastActiveAgoSec: number | null
|
|
66
|
-
/** 当前 ActiveState 的持续秒数 (StateChangeTimestamp → 现在)。
|
|
67
|
-
* 对 active 服务等于 lastActiveAgoSec;对 inactive 服务等于
|
|
68
|
-
* "已停了多久";对 activating/deactivating 等于"切换中多久"。 */
|
|
69
|
-
stateAgoSec: number | null
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface SysInfo {
|
|
73
|
-
cpu: CpuInfo | null
|
|
74
|
-
mem: MemInfo | null
|
|
75
|
-
disks: DiskInfo[]
|
|
76
|
-
services: ServiceInfo[]
|
|
77
|
-
/** 真的查不到时(systemctl 不存在 / 拒绝)就 null;空数组表示"没有 cc-* 服务"。 */
|
|
78
|
-
servicesError: string | null
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** 用户态 systemd-run 服务的统一前缀。改这里要同步改 CLAUDE.md。 */
|
|
82
|
-
export const SERVICE_PREFIX = 'cc-'
|
|
83
|
-
|
|
84
|
-
function readCpu(): CpuInfo | null {
|
|
85
|
-
try {
|
|
86
|
-
const raw = readFileSync('/proc/loadavg', 'utf8').trim().split(/\s+/)
|
|
87
|
-
return {
|
|
88
|
-
cores: cpus().length,
|
|
89
|
-
load1: parseFloat(raw[0] ?? '0'),
|
|
90
|
-
load5: parseFloat(raw[1] ?? '0'),
|
|
91
|
-
load15: parseFloat(raw[2] ?? '0'),
|
|
92
|
-
}
|
|
93
|
-
} catch (e) {
|
|
94
|
-
log(`sysinfo: read /proc/loadavg failed: ${e}`)
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function readMem(): MemInfo | null {
|
|
100
|
-
try {
|
|
101
|
-
const raw = readFileSync('/proc/meminfo', 'utf8')
|
|
102
|
-
const find = (k: string): number => {
|
|
103
|
-
const m = raw.match(new RegExp(`^${k}:\\s+(\\d+)\\s*kB`, 'm'))
|
|
104
|
-
return m ? parseInt(m[1]!, 10) * 1024 : 0
|
|
105
|
-
}
|
|
106
|
-
const totalBytes = find('MemTotal')
|
|
107
|
-
const availBytes = find('MemAvailable')
|
|
108
|
-
if (!totalBytes) return null
|
|
109
|
-
const usedBytes = Math.max(0, totalBytes - availBytes)
|
|
110
|
-
return {
|
|
111
|
-
totalBytes, availBytes, usedBytes,
|
|
112
|
-
percent: Math.round((usedBytes / totalBytes) * 100),
|
|
113
|
-
}
|
|
114
|
-
} catch (e) {
|
|
115
|
-
log(`sysinfo: read /proc/meminfo failed: ${e}`)
|
|
116
|
-
return null
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** statfsSync 拿到的 blocks/bavail 都按 f_frsize 计算 bytes —— 注意
|
|
121
|
-
* `usedBytes` 用 total - avail (不是 total - free),跟 `df` 的 Use% 列
|
|
122
|
-
* 一致 (排除 root 保留块)。 */
|
|
123
|
-
function readDiskFor(label: string, path: string): DiskInfo | null {
|
|
124
|
-
try {
|
|
125
|
-
const s = statfsSync(path, { bigint: false }) as {
|
|
126
|
-
bsize: number; blocks: number; bavail: number
|
|
127
|
-
}
|
|
128
|
-
const totalBytes = s.blocks * s.bsize
|
|
129
|
-
const availBytes = s.bavail * s.bsize
|
|
130
|
-
const usedBytes = Math.max(0, totalBytes - availBytes)
|
|
131
|
-
if (!totalBytes) return null
|
|
132
|
-
return {
|
|
133
|
-
label, path, totalBytes, availBytes, usedBytes,
|
|
134
|
-
percent: Math.round((usedBytes / totalBytes) * 100),
|
|
135
|
-
}
|
|
136
|
-
} catch (e) {
|
|
137
|
-
log(`sysinfo: statfs ${path} failed: ${e}`)
|
|
138
|
-
return null
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** 取 `/` 和 `$HOME`;如果两者属于同一文件系统(同一 device id),
|
|
143
|
-
* 只返回 `/`,避免面板上挂两条一样的数据 (用户在 AskUserQuestion
|
|
144
|
-
* 时知情的选择)。 */
|
|
145
|
-
function readDisks(): DiskInfo[] {
|
|
146
|
-
const out: DiskInfo[] = []
|
|
147
|
-
const root = readDiskFor('/', '/')
|
|
148
|
-
if (root) out.push(root)
|
|
149
|
-
const home = homedir()
|
|
150
|
-
if (home && home !== '/') {
|
|
151
|
-
let homeOnSameFs = false
|
|
152
|
-
try {
|
|
153
|
-
const rs = statSync('/')
|
|
154
|
-
const hs = statSync(home)
|
|
155
|
-
homeOnSameFs = rs.dev === hs.dev
|
|
156
|
-
} catch {}
|
|
157
|
-
if (!homeOnSameFs) {
|
|
158
|
-
const homeDisk = readDiskFor('$HOME', home)
|
|
159
|
-
if (homeDisk) out.push(homeDisk)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return out
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** /proc/uptime 第一个数是 monotonic 自启动以来的秒数。 */
|
|
166
|
-
function readMonotonicSec(): number | null {
|
|
167
|
-
try {
|
|
168
|
-
const raw = readFileSync('/proc/uptime', 'utf8').trim().split(/\s+/)
|
|
169
|
-
return parseFloat(raw[0] ?? '0')
|
|
170
|
-
} catch {
|
|
171
|
-
return null
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** 用 Bun.spawn 跑 `systemctl --user`,合并 stdout 并返回 trim 后的字符串。
|
|
176
|
-
* 超时(默认 2s)或非零退出码都返回 null,让调用方走错误分支。 */
|
|
177
|
-
async function runSystemctl(args: string[], timeoutMs = 2000): Promise<string | null> {
|
|
178
|
-
try {
|
|
179
|
-
const proc = Bun.spawn(['systemctl', '--user', ...args], {
|
|
180
|
-
stdout: 'pipe', stderr: 'pipe',
|
|
181
|
-
})
|
|
182
|
-
const timer = setTimeout(() => {
|
|
183
|
-
try { proc.kill('SIGTERM') } catch {}
|
|
184
|
-
}, timeoutMs)
|
|
185
|
-
const text = await new Response(proc.stdout).text()
|
|
186
|
-
const code = await proc.exited
|
|
187
|
-
clearTimeout(timer)
|
|
188
|
-
if (code !== 0) return null
|
|
189
|
-
return text
|
|
190
|
-
} catch (e) {
|
|
191
|
-
log(`sysinfo: systemctl ${args.join(' ')} failed: ${e}`)
|
|
192
|
-
return null
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** 列 `${SERVICE_PREFIX}*` 服务并解析:第 1 步用 list-units 拿名字,
|
|
197
|
-
* 第 2 步用单次 show -p 拿状态 + ActiveEnterTimestampMonotonic。两步
|
|
198
|
-
* 都是本地调用,加起来 < 100ms。 */
|
|
199
|
-
async function readServices(): Promise<{ services: ServiceInfo[]; error: string | null }> {
|
|
200
|
-
// list-units 输出每行: "<unit> <load> <active> <sub> <description>"。
|
|
201
|
-
// --all 把 inactive 也列出来 (用户停过的服务也值得在面板看到)。
|
|
202
|
-
// 加 --plain --no-legend 关掉表格修饰和 footer,方便机器解析。
|
|
203
|
-
const listOut = await runSystemctl([
|
|
204
|
-
'list-units', '--type=service', '--all', '--no-legend', '--plain', `${SERVICE_PREFIX}*`,
|
|
205
|
-
])
|
|
206
|
-
if (listOut === null) {
|
|
207
|
-
return { services: [], error: 'systemctl 不可用' }
|
|
208
|
-
}
|
|
209
|
-
const lines = listOut.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
|
210
|
-
if (lines.length === 0) return { services: [], error: null }
|
|
211
|
-
|
|
212
|
-
const names: string[] = []
|
|
213
|
-
for (const line of lines) {
|
|
214
|
-
// 第一列是 unit 全名,可能带前缀●;.service 后缀去掉。
|
|
215
|
-
const cols = line.replace(/^●\s*/, '').split(/\s+/)
|
|
216
|
-
const unit = cols[0]
|
|
217
|
-
if (!unit) continue
|
|
218
|
-
if (!unit.startsWith(SERVICE_PREFIX)) continue
|
|
219
|
-
if (!unit.endsWith('.service')) continue
|
|
220
|
-
names.push(unit)
|
|
221
|
-
}
|
|
222
|
-
if (names.length === 0) return { services: [], error: null }
|
|
223
|
-
|
|
224
|
-
// 一次性 show 多个 unit:每个 unit 输出一段属性,段之间空行分隔。
|
|
225
|
-
// ActiveEnter = 最近一次进入 active 的时刻 (即使现在已 inactive 也保留);
|
|
226
|
-
// StateChange = 当前 ActiveState 进入时刻。两者对 active 服务相同,
|
|
227
|
-
// 对 inactive 服务分别是"上次活跃"与"停了多久"。
|
|
228
|
-
const showOut = await runSystemctl([
|
|
229
|
-
'show', ...names,
|
|
230
|
-
'-p', 'Id',
|
|
231
|
-
'-p', 'ActiveState',
|
|
232
|
-
'-p', 'SubState',
|
|
233
|
-
'-p', 'ActiveEnterTimestampMonotonic',
|
|
234
|
-
'-p', 'StateChangeTimestampMonotonic',
|
|
235
|
-
])
|
|
236
|
-
if (showOut === null) return { services: [], error: 'systemctl show 失败' }
|
|
237
|
-
|
|
238
|
-
const monotonicNowSec = readMonotonicSec()
|
|
239
|
-
const blocks = showOut.split(/\n\s*\n/)
|
|
240
|
-
const services: ServiceInfo[] = []
|
|
241
|
-
const ageFrom = (microStr: string | undefined): number | null => {
|
|
242
|
-
const micro = parseInt(microStr ?? '0', 10)
|
|
243
|
-
if (micro <= 0 || monotonicNowSec === null) return null
|
|
244
|
-
return Math.max(0, monotonicNowSec - micro / 1_000_000)
|
|
245
|
-
}
|
|
246
|
-
for (const block of blocks) {
|
|
247
|
-
const props: Record<string, string> = {}
|
|
248
|
-
for (const line of block.split('\n')) {
|
|
249
|
-
const eq = line.indexOf('=')
|
|
250
|
-
if (eq <= 0) continue
|
|
251
|
-
props[line.slice(0, eq)] = line.slice(eq + 1)
|
|
252
|
-
}
|
|
253
|
-
const id = props.Id ?? ''
|
|
254
|
-
if (!id.startsWith(SERVICE_PREFIX) || !id.endsWith('.service')) continue
|
|
255
|
-
services.push({
|
|
256
|
-
name: id.replace(/\.service$/, ''),
|
|
257
|
-
active: props.ActiveState ?? 'unknown',
|
|
258
|
-
sub: props.SubState ?? '',
|
|
259
|
-
lastActiveAgoSec: ageFrom(props.ActiveEnterTimestampMonotonic),
|
|
260
|
-
stateAgoSec: ageFrom(props.StateChangeTimestampMonotonic),
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
services.sort((a, b) => a.name.localeCompare(b.name))
|
|
264
|
-
return { services, error: null }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export async function readSysInfo(): Promise<SysInfo> {
|
|
268
|
-
const cpu = readCpu()
|
|
269
|
-
const mem = readMem()
|
|
270
|
-
const disks = readDisks()
|
|
271
|
-
const { services, error } = await readServices()
|
|
272
|
-
return { cpu, mem, disks, services, servicesError: error }
|
|
273
|
-
}
|
package/src/usage.ts
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscription usage snapshot for the `hi` console panel.
|
|
3
|
-
*
|
|
4
|
-
* Source: Anthropic 官方 OAuth Usage API —— `GET /api/oauth/usage`.
|
|
5
|
-
* 不再依赖外部 ccusage CLI。
|
|
6
|
-
*
|
|
7
|
-
* 凭据来源: `~/.claude/.credentials.json`(Linux 服务器,无 macOS
|
|
8
|
-
* Keychain 分支)。结构由 Claude Code 写入,我们读 `claudeAiOauth`
|
|
9
|
-
* 字段拿 access_token / refresh_token / expires_at / subscriptionType /
|
|
10
|
-
* rateLimitTier。
|
|
11
|
-
*
|
|
12
|
-
* access_token 过期时,用 refresh_token 调 platform.claude.com
|
|
13
|
-
* `/v1/oauth/token` 刷新,刷新成功后原子写回凭据文件
|
|
14
|
-
* (tmp + rename),保证多进程并发安全。
|
|
15
|
-
*
|
|
16
|
-
* 失败可见 (no_fallbacks):
|
|
17
|
-
* - 凭据缺失 → state='no_credentials'
|
|
18
|
-
* - 刷新也失败 → state='auth_failed'
|
|
19
|
-
* - API 返回 429 → state='rate_limited' (+ resetsAt 可选)
|
|
20
|
-
* - 其它网络异常 → state='network'
|
|
21
|
-
*
|
|
22
|
-
* 卡片渲染层 (`cards.consoleUsageContent`) 按 state 分别显示具体原因,
|
|
23
|
-
* 不静默回退到旧值,不伪造百分比。
|
|
24
|
-
*
|
|
25
|
-
* Lodestar 启动后,每次 `hi` 弹板都会拉一次;CACHE_TTL_MS 内的重复
|
|
26
|
-
* 调用共享同一份快照,不打 API。in-flight 去重保证并发的多个
|
|
27
|
-
* 群同时唤出控制台时只触发一次后台请求。
|
|
28
|
-
*
|
|
29
|
-
* Stale fallback (照 omchud HUD 规则): 单独记最后一次成功拉到的
|
|
30
|
-
* `state:'ok'` 快照,本次拉取失败 (network/rate_limited/auth_failed)
|
|
31
|
-
* 且距上次成功 <= MAX_STALE_MS (15 分钟) 时,返回上次的 ok 快照并打
|
|
32
|
-
* `stale:true` 标签,卡片层加 "缓存 Xm 前" 提示。这是 no_fallbacks
|
|
33
|
-
* 规则的显式例外 —— 用户明确要求订阅额度面板用缓存兜底,因为短暂
|
|
34
|
-
* 网络抖动里把面板上的数字抹成红色"拉取失败"信息密度反而更低。
|
|
35
|
-
*
|
|
36
|
-
* 429 指数退避: 收到 rate_limited 时增加 rateLimitedCount,下次允许
|
|
37
|
-
* 实拉的时间设为 now + CACHE_TTL_MS * 2^(count-1),封顶 5 分钟。
|
|
38
|
-
* 退避窗口内的 readUsage 直接走 stale fallback,不打 API。任何非 429
|
|
39
|
-
* 的响应 (ok / network / auth_failed) 都会重置计数器。
|
|
40
|
-
*
|
|
41
|
-
* 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
|
|
42
|
-
* Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
|
|
43
|
-
* (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
|
|
44
|
-
* 文件锁。
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
48
|
-
import { homedir } from 'node:os'
|
|
49
|
-
import { join } from 'node:path'
|
|
50
|
-
import { log } from './log'
|
|
51
|
-
|
|
52
|
-
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
53
|
-
const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
54
|
-
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
55
|
-
const API_TIMEOUT_MS = 10_000
|
|
56
|
-
const CACHE_TTL_MS = 60_000
|
|
57
|
-
/** 失败时回退到上次成功快照的最大年龄。超过此值就不再用缓存兜底,
|
|
58
|
-
* 显示真实失败状态 —— 跟 omchud HUD 的 MAX_STALE_DATA_MS 对齐。 */
|
|
59
|
-
const MAX_STALE_MS = 15 * 60 * 1000
|
|
60
|
-
/** 429 退避封顶,跟 omchud HUD 的 MAX_RATE_LIMITED_BACKOFF_MS 对齐。 */
|
|
61
|
-
const RATE_LIMITED_MAX_BACKOFF_MS = 5 * 60 * 1000
|
|
62
|
-
|
|
63
|
-
function credentialsPath(): string {
|
|
64
|
-
return join(homedir(), '.claude', '.credentials.json')
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface OAuthCredentials {
|
|
68
|
-
accessToken: string
|
|
69
|
-
refreshToken?: string
|
|
70
|
-
expiresAt?: number
|
|
71
|
-
subscriptionType?: string
|
|
72
|
-
rateLimitTier?: string
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface UsageWindow {
|
|
76
|
-
/** 0-100, Anthropic 直接返回的 utilization 真实值 */
|
|
77
|
-
percent: number
|
|
78
|
-
/** 这个窗口何时重置;ISO 解析失败则 null */
|
|
79
|
-
resetsAt: Date | null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export type UsageSnapshot =
|
|
83
|
-
| { state: 'no_credentials' }
|
|
84
|
-
| { state: 'auth_failed' }
|
|
85
|
-
| { state: 'rate_limited' }
|
|
86
|
-
| { state: 'network'; reason?: string }
|
|
87
|
-
| {
|
|
88
|
-
state: 'ok'
|
|
89
|
-
subscriptionType?: string
|
|
90
|
-
fiveHour: UsageWindow | null
|
|
91
|
-
weekly: UsageWindow | null
|
|
92
|
-
fetchedAt: number
|
|
93
|
-
/** true 时本快照不是这次实拉的,而是 lastOk 兜底回来的旧数据。
|
|
94
|
-
* 卡片层据此显示 "缓存" 标记 + 重置时间加 `~` 前缀。 */
|
|
95
|
-
stale?: boolean
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
type UsageSnapshotOk = Extract<UsageSnapshot, { state: 'ok' }>
|
|
99
|
-
|
|
100
|
-
let cache: { data: UsageSnapshot; at: number } | null = null
|
|
101
|
-
/** 最近一次 state:'ok' 的快照,用于失败时兜底。和 cache 分开存:
|
|
102
|
-
* cache 是短时去重 (60s),lastOk 是长尾兜底 (15min)。 */
|
|
103
|
-
let lastOk: { snapshot: UsageSnapshotOk; at: number } | null = null
|
|
104
|
-
let inFlight: Promise<UsageSnapshot> | null = null
|
|
105
|
-
/** 连续 429 计数,用于指数退避;遇到任何非 429 响应就重置为 0。 */
|
|
106
|
-
let rateLimitedCount = 0
|
|
107
|
-
/** 在这个时间戳之前不打 API,直接走 stale fallback。 */
|
|
108
|
-
let rateLimitedUntil = 0
|
|
109
|
-
|
|
110
|
-
function rateLimitedBackoffMs(count: number): number {
|
|
111
|
-
return Math.min(
|
|
112
|
-
CACHE_TTL_MS * Math.pow(2, Math.max(0, count - 1)),
|
|
113
|
-
RATE_LIMITED_MAX_BACKOFF_MS,
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function readCredentials(): OAuthCredentials | null {
|
|
118
|
-
const path = credentialsPath()
|
|
119
|
-
if (!existsSync(path)) return null
|
|
120
|
-
try {
|
|
121
|
-
const raw = readFileSync(path, 'utf8')
|
|
122
|
-
const parsed = JSON.parse(raw)
|
|
123
|
-
const creds = parsed.claudeAiOauth ?? parsed
|
|
124
|
-
if (!creds?.accessToken) return null
|
|
125
|
-
return {
|
|
126
|
-
accessToken: creds.accessToken,
|
|
127
|
-
refreshToken: creds.refreshToken,
|
|
128
|
-
expiresAt: creds.expiresAt,
|
|
129
|
-
subscriptionType: creds.subscriptionType,
|
|
130
|
-
rateLimitTier: creds.rateLimitTier,
|
|
131
|
-
}
|
|
132
|
-
} catch (e) {
|
|
133
|
-
log(`usage: read credentials failed: ${e}`)
|
|
134
|
-
return null
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** 把刷新后的 access_token / refresh_token / expires_at 原子写回原文件,
|
|
139
|
-
* 保留其它字段(scopes、subscriptionType、organizationUuid 等)。
|
|
140
|
-
* 走 tmp + rename 防止半写状态被读到。 */
|
|
141
|
-
function writeBackCredentials(updated: OAuthCredentials): void {
|
|
142
|
-
const path = credentialsPath()
|
|
143
|
-
if (!existsSync(path)) return
|
|
144
|
-
try {
|
|
145
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
146
|
-
const target = parsed.claudeAiOauth ?? parsed
|
|
147
|
-
target.accessToken = updated.accessToken
|
|
148
|
-
if (updated.refreshToken) target.refreshToken = updated.refreshToken
|
|
149
|
-
if (updated.expiresAt != null) target.expiresAt = updated.expiresAt
|
|
150
|
-
const tmp = `${path}.tmp.${process.pid}`
|
|
151
|
-
try {
|
|
152
|
-
writeFileSync(tmp, JSON.stringify(parsed, null, 2), { mode: 0o600 })
|
|
153
|
-
renameSync(tmp, path)
|
|
154
|
-
} catch (e) {
|
|
155
|
-
try { if (existsSync(tmp)) unlinkSync(tmp) } catch {}
|
|
156
|
-
throw e
|
|
157
|
-
}
|
|
158
|
-
} catch (e) {
|
|
159
|
-
log(`usage: writeBackCredentials failed: ${e}`)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function isExpired(creds: OAuthCredentials): boolean {
|
|
164
|
-
return creds.expiresAt != null && creds.expiresAt <= Date.now()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function refreshAccessToken(refreshToken: string): Promise<OAuthCredentials | null> {
|
|
168
|
-
const body = new URLSearchParams({
|
|
169
|
-
grant_type: 'refresh_token',
|
|
170
|
-
refresh_token: refreshToken,
|
|
171
|
-
client_id: OAUTH_CLIENT_ID,
|
|
172
|
-
}).toString()
|
|
173
|
-
const ctrl = new AbortController()
|
|
174
|
-
const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
|
|
175
|
-
try {
|
|
176
|
-
const res = await fetch(TOKEN_REFRESH_URL, {
|
|
177
|
-
method: 'POST',
|
|
178
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
179
|
-
body,
|
|
180
|
-
signal: ctrl.signal,
|
|
181
|
-
})
|
|
182
|
-
if (res.status !== 200) {
|
|
183
|
-
log(`usage: token refresh HTTP ${res.status}`)
|
|
184
|
-
return null
|
|
185
|
-
}
|
|
186
|
-
const json = await res.json() as any
|
|
187
|
-
if (!json?.access_token) return null
|
|
188
|
-
return {
|
|
189
|
-
accessToken: json.access_token,
|
|
190
|
-
refreshToken: json.refresh_token ?? refreshToken,
|
|
191
|
-
expiresAt: json.expires_in
|
|
192
|
-
? Date.now() + json.expires_in * 1000
|
|
193
|
-
: json.expires_at,
|
|
194
|
-
}
|
|
195
|
-
} catch (e) {
|
|
196
|
-
log(`usage: token refresh threw: ${e}`)
|
|
197
|
-
return null
|
|
198
|
-
} finally {
|
|
199
|
-
clearTimeout(timer)
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
interface UsageApiResponse {
|
|
204
|
-
five_hour?: { utilization?: number; resets_at?: string }
|
|
205
|
-
seven_day?: { utilization?: number; resets_at?: string }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function parseDate(s: string | undefined): Date | null {
|
|
209
|
-
if (!s) return null
|
|
210
|
-
const d = new Date(s)
|
|
211
|
-
return isNaN(d.getTime()) ? null : d
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function clampPct(v: number | undefined): number {
|
|
215
|
-
if (v == null || !isFinite(v)) return 0
|
|
216
|
-
return Math.max(0, Math.min(100, v))
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
interface FetchResult {
|
|
220
|
-
data: UsageApiResponse | null
|
|
221
|
-
/** 失败原因:undefined = 成功;其它字符串是分类错误。 */
|
|
222
|
-
reason?: 'rate_limited' | 'network'
|
|
223
|
-
detail?: string
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function fetchUsageFromApi(accessToken: string): Promise<FetchResult> {
|
|
227
|
-
const ctrl = new AbortController()
|
|
228
|
-
const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
|
|
229
|
-
try {
|
|
230
|
-
const res = await fetch(USAGE_URL, {
|
|
231
|
-
method: 'GET',
|
|
232
|
-
headers: {
|
|
233
|
-
Authorization: `Bearer ${accessToken}`,
|
|
234
|
-
'anthropic-beta': 'oauth-2025-04-20',
|
|
235
|
-
'Content-Type': 'application/json',
|
|
236
|
-
},
|
|
237
|
-
signal: ctrl.signal,
|
|
238
|
-
})
|
|
239
|
-
if (res.status === 200) {
|
|
240
|
-
const data = await res.json() as UsageApiResponse
|
|
241
|
-
return { data }
|
|
242
|
-
}
|
|
243
|
-
if (res.status === 429) return { data: null, reason: 'rate_limited' }
|
|
244
|
-
return { data: null, reason: 'network', detail: `HTTP ${res.status}` }
|
|
245
|
-
} catch (e: any) {
|
|
246
|
-
return { data: null, reason: 'network', detail: e?.message ?? String(e) }
|
|
247
|
-
} finally {
|
|
248
|
-
clearTimeout(timer)
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function fetchUsage(): Promise<UsageSnapshot> {
|
|
253
|
-
let creds = readCredentials()
|
|
254
|
-
if (!creds) return { state: 'no_credentials' }
|
|
255
|
-
|
|
256
|
-
if (isExpired(creds)) {
|
|
257
|
-
if (!creds.refreshToken) return { state: 'auth_failed' }
|
|
258
|
-
const refreshed = await refreshAccessToken(creds.refreshToken)
|
|
259
|
-
if (!refreshed) return { state: 'auth_failed' }
|
|
260
|
-
creds = { ...creds, ...refreshed }
|
|
261
|
-
writeBackCredentials(creds)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const result = await fetchUsageFromApi(creds.accessToken)
|
|
265
|
-
if (result.reason === 'rate_limited') return { state: 'rate_limited' }
|
|
266
|
-
if (result.reason === 'network' || !result.data) return { state: 'network', reason: result.detail }
|
|
267
|
-
|
|
268
|
-
const data = result.data
|
|
269
|
-
const fiveHour = data.five_hour?.utilization != null
|
|
270
|
-
? { percent: clampPct(data.five_hour.utilization), resetsAt: parseDate(data.five_hour.resets_at) }
|
|
271
|
-
: null
|
|
272
|
-
const weekly = data.seven_day?.utilization != null
|
|
273
|
-
? { percent: clampPct(data.seven_day.utilization), resetsAt: parseDate(data.seven_day.resets_at) }
|
|
274
|
-
: null
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
state: 'ok',
|
|
278
|
-
subscriptionType: creds.subscriptionType,
|
|
279
|
-
fiveHour,
|
|
280
|
-
weekly,
|
|
281
|
-
fetchedAt: Date.now(),
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** 失败快照 → 如果 MAX_STALE_MS 内还有 lastOk,就返回 lastOk 的副本
|
|
286
|
-
* (打 stale 标);否则透传失败快照。state:'ok' 走 fast path 原样返回。 */
|
|
287
|
-
function withStaleFallback(snapshot: UsageSnapshot): UsageSnapshot {
|
|
288
|
-
if (snapshot.state === 'ok') return snapshot
|
|
289
|
-
if (lastOk && Date.now() - lastOk.at < MAX_STALE_MS) {
|
|
290
|
-
return { ...lastOk.snapshot, stale: true }
|
|
291
|
-
}
|
|
292
|
-
return snapshot
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
|
|
296
|
-
* 单次后台 fetch。拉取失败但 lastOk 仍在 MAX_STALE_MS 内时,回退到
|
|
297
|
-
* lastOk 并打 stale 标。连续 429 走指数退避,退避窗口内不打 API。
|
|
298
|
-
* 永不抛出 —— 失败状态由 `state` 字段表达,卡片层按 state 分支渲染。 */
|
|
299
|
-
export async function readUsage(): Promise<UsageSnapshot> {
|
|
300
|
-
// 429 退避窗口内不打 API。cache 里可能存的就是 rate_limited 失败态,
|
|
301
|
-
// withStaleFallback 会自动用 lastOk 顶上(15min 内)。
|
|
302
|
-
if (Date.now() < rateLimitedUntil) {
|
|
303
|
-
return withStaleFallback(cache?.data ?? { state: 'rate_limited' })
|
|
304
|
-
}
|
|
305
|
-
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return withStaleFallback(cache.data)
|
|
306
|
-
if (inFlight) return inFlight
|
|
307
|
-
inFlight = fetchUsage()
|
|
308
|
-
.then(d => {
|
|
309
|
-
cache = { data: d, at: Date.now() }
|
|
310
|
-
if (d.state === 'ok') lastOk = { snapshot: d, at: Date.now() }
|
|
311
|
-
if (d.state === 'rate_limited') {
|
|
312
|
-
rateLimitedCount += 1
|
|
313
|
-
rateLimitedUntil = Date.now() + rateLimitedBackoffMs(rateLimitedCount)
|
|
314
|
-
} else {
|
|
315
|
-
rateLimitedCount = 0
|
|
316
|
-
rateLimitedUntil = 0
|
|
317
|
-
}
|
|
318
|
-
inFlight = null
|
|
319
|
-
return withStaleFallback(d)
|
|
320
|
-
})
|
|
321
|
-
.catch(e => {
|
|
322
|
-
log(`usage: fetchUsage threw: ${e}`)
|
|
323
|
-
inFlight = null
|
|
324
|
-
return withStaleFallback({ state: 'network', reason: String(e) })
|
|
325
|
-
})
|
|
326
|
-
return inFlight
|
|
327
|
-
}
|