@leviyuan/lodestar 0.1.8 → 0.1.10
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 +46 -32
- package/package.json +1 -1
- package/src/cards.ts +27 -34
- package/src/session.ts +40 -3
- package/src/usage.ts +220 -158
package/README.md
CHANGED
|
@@ -4,55 +4,67 @@
|
|
|
4
4
|
|
|
5
5
|
# 夜航星 (Lodestar)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**把 Claude Code 装进你的飞书群。一个群 = 一个项目 = 一段不熄灯的对话。**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
离开终端,但不离开 Claude Code。手机上、地铁里、半夜的床上,你只要拇指能点字,Claude 就在另一头跑着。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## 它为什么存在
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
AI 不是帮手,是倍率。它放大的不是体力,是你——你的直觉、判断和品味,每一样都被乘以一个你以前不敢想的系数。最终走多远,取决于被放大的你有多强。
|
|
14
|
+
|
|
15
|
+
夜航星让这件事真正发生:在你思考的地方接住想法,在你转身之后继续把它推向终点。**你醒着它在听,你睡了它还在跑。**
|
|
16
|
+
|
|
17
|
+
## 你会得到什么
|
|
18
|
+
|
|
19
|
+
- 🌊 **真·流式卡片** — 飞书 Card Kit v1 streaming,Claude 一个 token 一个 token 地打在同一张卡片里,不是发一堆零碎消息刷屏。
|
|
20
|
+
- 🧠 **思考过程透明** — `thinking` 流式渲染,turn 结束后自动收起为可展开面板。每次工具调用也是一格折叠面板:折起是概述,展开看完整 input/output。
|
|
21
|
+
- 🔐 **权限审批就地完成** — 需要授权的工具调用,**原地**升级为 🔐 等审批状态,三颗按钮 `允许 / 始终允许 / 拒绝` 直接嵌在面板里。不弹独立卡片,不破坏时序。点完按钮,后续 output 接在同一条线上继续往下走。
|
|
22
|
+
- ❓ **结构化追问** — Claude 的 `AskUserQuestion` 在群里呈现为可点击选项行;不满意?直接在群里**打字回答**,daemon 会把自由文本当作 custom answer 发回去。多题串行,有进度计数和"已答 N 题"折叠历史。
|
|
23
|
+
- 📦 **状态面板一键唤出** — 发 `hi` 弹一张控制台:model、上下文占用 %、累计 tokens/cost、上一轮 delta、session id、订阅额度(5h / 7d 真实 utilization,直读 Anthropic 官方 OAuth Usage API,凭据走 `~/.claude/.credentials.json`,token 过期自动 refresh)、本机所有活跃项目并列展示。
|
|
24
|
+
- 📎 **图片 / 文件双向互传** — 用户在群里发图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 在回复里写 `[[send: /abs/path]]`,标记被剥离,文件以独立消息发回群里。出站路径走 realpath + 白名单校验,只允许工作目录、`/tmp/lodestar-*`、inbox 三块,`/etc`、`~/.ssh`、`~/.config` 即使被符号链接绕也拒绝。
|
|
25
|
+
- 📲 **加急锁屏推送** — 需要你回答问题、需要你批准操作、一轮跑完了——三种关键时刻自动触发飞书"应用内加急",直接打穿勿扰、亮屏推送。卡片摘要会同步改写成具体待办("🔐 等审批: Bash · rm -rf …"、"❓ 待回答 3 题: …"),锁屏一瞥就知道发生了什么。
|
|
26
|
+
- 🗂 **多项目并发** — 一个 daemon 同时持有 N 个飞书群 ↔ N 个 Claude session。状态面板能跨群看到所有活跃项目和它们的 uptime,在群 A 里就能查群 B 在干嘛。
|
|
27
|
+
- 🔄 **不丢上下文** — 每次 `system/init` 落盘 SDK session_id;daemon 被 systemd 重启、机器断电、手抖 kill 进程,下次 `restart` 或自动复活都 `--resume` 到同一段对话,Claude 不知道你离开过。
|
|
28
|
+
- 🛡 **后台守护级稳定性** — 单 PID 锁、WS pong watchdog(180s 无心跳自杀,交给 systemd 拉起)、5s 重投 stale 消息丢弃、200 条 message_id 去重、SIGTERM 优雅写盘、`alive marker` 区分"我自己挂的"和"被用户主动 kill 的"——后者不会被复活。
|
|
14
29
|
|
|
15
30
|
## 怎么用
|
|
16
31
|
|
|
17
|
-
每个飞书群对应一个 Claude 会话。**群名 = `~/`
|
|
32
|
+
每个飞书群对应一个 Claude 会话。**群名 = `~/` 下的项目目录名**。这套绑定是骨架,新群第一次发消息时,daemon 会自动 `mkdir -p ~/{群名}` + `git init` 把项目骨架打起来,**开新群 = 开新项目**。
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **图片、文件双向互传**:用户发到群里的图/文件,Claude 通过消息里的 `[file: /abs/path]` 提示就能读;Claude 想把文件发回来,在回复任意位置写 `[[send: /abs/path]]`,标记会被剥离,文件以独立消息出现在群里。出站路径限制在该会话的工作目录、`/tmp/lodestar-*` 与 inbox 之内,`/etc`、`~/.ssh`、`~/.config` 等敏感目录被白名单拒绝。
|
|
23
|
-
- 一轮跑完,卡片合上、可转发;下一句话开新一轮。
|
|
34
|
+
在群里发任意文字,Claude 接管这一轮。回复以流式打字机渲染在一张卡片里,工具调用、思考过程、权限审批、追问选项,全都收纳在这张卡片的不同面板里——一目了然,可转发,可回看。
|
|
35
|
+
|
|
36
|
+
下一句话开新一轮卡片。
|
|
24
37
|
|
|
25
38
|
### 文本控制指令
|
|
26
39
|
|
|
27
|
-
|
|
40
|
+
直接发这四个**裸词**(不需要斜杠,不区分大小写),daemon 拦截、不转发给 Claude:
|
|
28
41
|
|
|
29
42
|
| 指令 | 行为 |
|
|
30
43
|
| --- | --- |
|
|
31
|
-
| `hi` |
|
|
32
|
-
| `kill` | 优雅关闭 Claude
|
|
33
|
-
| `restart` | 用上一次的 `sessionId`
|
|
34
|
-
| `clear` | 杀掉进程并启动一个全新 session
|
|
35
|
-
|
|
36
|
-
> 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
|
|
44
|
+
| `hi` | 未运行时启动;运行中弹一张**状态卡片** |
|
|
45
|
+
| `kill` | 优雅关闭 Claude 进程;记住 `sessionId`,下次 `restart` 还能 resume |
|
|
46
|
+
| `restart` | 用上一次的 `sessionId` 重启会话(保留上下文) |
|
|
47
|
+
| `clear` | 杀掉进程并启动一个全新 session(等价于 Claude Code 的 `/clear`) |
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
> 这四个词被全局保留:在群里发 "hi" 当问候也会触发控制台卡片,不会到 Claude 那边。换来的是手机上单手打字的便利。
|
|
39
50
|
|
|
40
51
|
## 安装
|
|
41
52
|
|
|
42
53
|
### 1. 准备
|
|
43
54
|
|
|
44
|
-
-
|
|
45
|
-
- [Bun](https://bun.sh) 运行时
|
|
46
|
-
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
47
|
-
- 一个飞书自建应用 (`cli_xxx`)
|
|
55
|
+
- 一台能常跑后台进程的机器(自家服务器、闲置 NAS、树莓派均可)
|
|
56
|
+
- [Bun](https://bun.sh) 运行时(≥ 1.0)
|
|
57
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 装好且能跑(怎么认证、走官方账号还是第三方网关,你自己看着办)
|
|
58
|
+
- 一个飞书自建应用 (`cli_xxx`),开通:
|
|
48
59
|
- `im:message:send_as_bot` / `im:message` / `im:chat:readonly` / `im:resource`
|
|
60
|
+
- `im:message.urgent`(加急推送)
|
|
49
61
|
- `cardkit:card:read` `cardkit:card:write`
|
|
50
62
|
`cardkit:card.element:read` `cardkit:card.element:write`
|
|
51
63
|
`cardkit:card.settings:read` `cardkit:card.settings:write`
|
|
52
64
|
|
|
53
65
|
### 2. 配置
|
|
54
66
|
|
|
55
|
-
把凭据写到 `~/.config/lodestar/config.toml
|
|
67
|
+
把凭据写到 `~/.config/lodestar/config.toml`:
|
|
56
68
|
|
|
57
69
|
```toml
|
|
58
70
|
[feishu]
|
|
@@ -60,10 +72,10 @@ app_id = "cli_xxxxxxxxxxxxxxxx"
|
|
|
60
72
|
app_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
61
73
|
|
|
62
74
|
[runtime]
|
|
63
|
-
projects_root = "~/" #
|
|
75
|
+
projects_root = "~/" # 可选,新建群对应的项目目录会落到这里
|
|
64
76
|
```
|
|
65
77
|
|
|
66
|
-
也支持 `LODESTAR_CONFIG=/abs/path.toml` 或 `XDG_CONFIG_HOME`
|
|
78
|
+
也支持 `LODESTAR_CONFIG=/abs/path.toml` 或 `XDG_CONFIG_HOME` 覆盖。运行时状态走 `~/.local/share/lodestar/`(可用 `LODESTAR_DATA_DIR` 或 `XDG_DATA_HOME` 改写)——daemon.pid、daemon.log、session-chat-map、session-resume-map、alive-marker、inbox/ 都在那里。
|
|
67
79
|
|
|
68
80
|
### 3. 启动
|
|
69
81
|
|
|
@@ -72,19 +84,17 @@ bun install -g @leviyuan/lodestar
|
|
|
72
84
|
lodestar-daemon
|
|
73
85
|
```
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
或者一次性跑(无需全局安装):
|
|
76
88
|
|
|
77
89
|
```bash
|
|
78
90
|
bunx @leviyuan/lodestar
|
|
79
91
|
```
|
|
80
92
|
|
|
81
|
-
|
|
93
|
+
把机器人拉进任意飞书群,发一条消息——Claude 就上线了。
|
|
82
94
|
|
|
83
|
-
|
|
95
|
+
### 4. 守护进程(推荐)
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
要让 daemon 7×24 跑,最简单的方法是配一个 `systemd --user` 单元:
|
|
97
|
+
让 daemon 7×24 跑,最简单的方法是配一个 `systemd --user` 单元:
|
|
88
98
|
|
|
89
99
|
```ini
|
|
90
100
|
[Unit]
|
|
@@ -101,7 +111,11 @@ RestartSec=3
|
|
|
101
111
|
WantedBy=default.target
|
|
102
112
|
```
|
|
103
113
|
|
|
104
|
-
|
|
114
|
+
```bash
|
|
115
|
+
systemctl --user enable --now lodestar
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
WS watchdog + alive-marker 的联手设计,意味着每次 systemd 拉起,daemon 会把**上次还在运行的 session 全部 `--resume` 自动复活**;你主动 `kill` 过的不会被吵醒。
|
|
105
119
|
|
|
106
120
|
## 许可
|
|
107
121
|
|
package/package.json
CHANGED
package/src/cards.ts
CHANGED
|
@@ -593,48 +593,41 @@ const PEER_STATUS_EMOJI: Record<string, string> = {
|
|
|
593
593
|
|
|
594
594
|
/** Render the subscription-usage section of the console card. Pulled out
|
|
595
595
|
* of `consoleCard` so the caller can patch it in after the initial card
|
|
596
|
-
* is on screen (
|
|
597
|
-
* the whole panel on it). Layout intentionally splits 5h and 7d onto
|
|
598
|
-
* their own indented lines for readability on phone.
|
|
596
|
+
* is on screen (网络往返可能慢于第一次 paint;先占位、回包后替换)。
|
|
599
597
|
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
* `usage
|
|
598
|
+
* 数据源是 Anthropic 官方 OAuth Usage API (见 src/usage.ts)。
|
|
599
|
+
* 百分比是真实 utilization,失败态按 state 区分显示具体原因。
|
|
600
|
+
*
|
|
601
|
+
* `usage === undefined` → 初始 loading 占位。
|
|
604
602
|
*/
|
|
605
603
|
export function consoleUsageContent(
|
|
606
|
-
usage: import('./usage').UsageSnapshot |
|
|
604
|
+
usage: import('./usage').UsageSnapshot | undefined,
|
|
607
605
|
): string {
|
|
608
606
|
if (usage === undefined) return '**📊 订阅额度** _加载中…_'
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
607
|
+
switch (usage.state) {
|
|
608
|
+
case 'no_credentials':
|
|
609
|
+
return '**📊 订阅额度** 未找到 OAuth 凭据 (`~/.claude/.credentials.json`)'
|
|
610
|
+
case 'auth_failed':
|
|
611
|
+
return '**📊 订阅额度** Token 已过期且刷新失败 — 重新 `claude auth login`'
|
|
612
|
+
case 'rate_limited':
|
|
613
|
+
return '**📊 订阅额度** API 429 限流,稍后重试'
|
|
614
|
+
case 'network':
|
|
615
|
+
return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
|
|
616
|
+
}
|
|
617
|
+
// state === 'ok'
|
|
618
|
+
const head = usage.subscriptionType
|
|
619
|
+
? `**📊 订阅额度** · ${usage.subscriptionType}`
|
|
620
|
+
: '**📊 订阅额度**'
|
|
621
|
+
const lines: string[] = [head]
|
|
617
622
|
if (usage.fiveHour) {
|
|
618
|
-
const parts
|
|
619
|
-
if (usage.fiveHour.
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
parts.push(`$${Math.round(usage.fiveHour.costUsd)}`)
|
|
623
|
-
if (usage.fiveHour.remainingMinutes != null) {
|
|
624
|
-
parts.push(`剩${(usage.fiveHour.remainingMinutes / 60).toFixed(1)}h`)
|
|
625
|
-
}
|
|
626
|
-
lines.push(` · 5h ${parts.join(' ')}`)
|
|
623
|
+
const parts = [`${Math.round(usage.fiveHour.percent)}%`]
|
|
624
|
+
if (usage.fiveHour.resetsAt) parts.push(`重置 ${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
625
|
+
lines.push(` · 5h ${parts.join(' · ')}`)
|
|
627
626
|
}
|
|
628
627
|
if (usage.weekly) {
|
|
629
|
-
const parts
|
|
630
|
-
if (usage.weekly.
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
parts.push(`$${Math.round(usage.weekly.costUsd)}`)
|
|
634
|
-
if (usage.weekly.remainingDays != null) {
|
|
635
|
-
parts.push(`剩${usage.weekly.remainingDays.toFixed(1)}d`)
|
|
636
|
-
}
|
|
637
|
-
lines.push(` · 7d ${parts.join(' ')}`)
|
|
628
|
+
const parts = [`${Math.round(usage.weekly.percent)}%`]
|
|
629
|
+
if (usage.weekly.resetsAt) parts.push(`重置 ${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
630
|
+
lines.push(` · 7d ${parts.join(' · ')}`)
|
|
638
631
|
}
|
|
639
632
|
return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
|
|
640
633
|
}
|
package/src/session.ts
CHANGED
|
@@ -152,6 +152,19 @@ export class Session {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Patch the card-level summary (the text Feishu uses for chat-list
|
|
156
|
+
* preview AND lock-screen push), then return when the API call has
|
|
157
|
+
* landed. Used right before urgent_app so the push notification's
|
|
158
|
+
* derived preview describes the *action that needs attention* (an
|
|
159
|
+
* unanswered question, a pending permission ask) rather than the
|
|
160
|
+
* stale assistant-text tail that patchSummaryThrottled was streaming.
|
|
161
|
+
* cancelSummary kills any in-flight throttled write so our explicit
|
|
162
|
+
* patch isn't immediately clobbered. */
|
|
163
|
+
private async setUrgentSummary(cardId: string, content: string): Promise<void> {
|
|
164
|
+
cardkit.cancelSummary(cardId)
|
|
165
|
+
await cardkit.patchSettings(cardId, { config: { summary: { content } } })
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
/** Minimal cross-chat snapshot for the `hi` peer-list section.
|
|
156
169
|
* `startedAt` stays private so this is the documented read path. */
|
|
157
170
|
peerSnapshot(): { name: string; status: Status; uptimeMs?: number } {
|
|
@@ -745,9 +758,21 @@ export class Session {
|
|
|
745
758
|
targetElementId: cards.ELEMENTS.footer,
|
|
746
759
|
})
|
|
747
760
|
// Phone push — user has to come back and answer before Claude can
|
|
748
|
-
// continue.
|
|
761
|
+
// continue. Set summary to the question text so the lock-screen
|
|
762
|
+
// notification preview shows what the user needs to answer.
|
|
749
763
|
if (this.currentTurn.userOpenId && this.currentTurn.messageId) {
|
|
750
|
-
|
|
764
|
+
const turn = this.currentTurn
|
|
765
|
+
const q0 = questions[0]?.question?.trim() ?? ''
|
|
766
|
+
const truncated = q0.length > 40 ? q0.slice(0, 40) + '…' : q0
|
|
767
|
+
const summary = questions.length > 1
|
|
768
|
+
? `❓ 待回答 ${questions.length} 题${truncated ? `: ${truncated}` : ''}`
|
|
769
|
+
: truncated
|
|
770
|
+
? `❓ ${truncated}`
|
|
771
|
+
: '❓ 等你回答问题'
|
|
772
|
+
void (async () => {
|
|
773
|
+
await this.setUrgentSummary(turn.cardId, summary)
|
|
774
|
+
await feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
775
|
+
})()
|
|
751
776
|
}
|
|
752
777
|
return
|
|
753
778
|
}
|
|
@@ -925,8 +950,20 @@ export class Session {
|
|
|
925
950
|
const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
|
|
926
951
|
void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
|
|
927
952
|
// Phone push — Claude is blocked until the user approves/denies.
|
|
953
|
+
// Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
|
|
954
|
+
// screen notification shows which tool needs approval.
|
|
928
955
|
if (turn.userOpenId && turn.messageId) {
|
|
929
|
-
|
|
956
|
+
const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
|
|
957
|
+
const tail = inputSummary && inputSummary.length > 30
|
|
958
|
+
? inputSummary.slice(0, 30) + '…'
|
|
959
|
+
: inputSummary
|
|
960
|
+
const summary = tail
|
|
961
|
+
? `🔐 等审批: ${meta.name} · ${tail}`
|
|
962
|
+
: `🔐 等审批: ${meta.name}`
|
|
963
|
+
void (async () => {
|
|
964
|
+
await this.setUrgentSummary(turn.cardId, summary)
|
|
965
|
+
await feishu.urgentApp(turn.messageId, [turn.userOpenId])
|
|
966
|
+
})()
|
|
930
967
|
}
|
|
931
968
|
}
|
|
932
969
|
|
package/src/usage.ts
CHANGED
|
@@ -1,201 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subscription usage snapshot for the `hi` console panel.
|
|
3
3
|
*
|
|
4
|
-
* Source:
|
|
5
|
-
*
|
|
6
|
-
* twice in parallel and cache the merged result for CACHE_TTL_MS.
|
|
4
|
+
* Source: Anthropic 官方 OAuth Usage API —— `GET /api/oauth/usage`.
|
|
5
|
+
* 不再依赖外部 ccusage CLI。
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* NOT the Anthropic tier quota (which we have no way to read
|
|
13
|
-
* without OAuth roundtrips). It's an internally-consistent burn
|
|
14
|
-
* indicator, not an official quota gauge.
|
|
7
|
+
* 凭据来源: `~/.claude/.credentials.json`(Linux 服务器,无 macOS
|
|
8
|
+
* Keychain 分支)。结构由 Claude Code 写入,我们读 `claudeAiOauth`
|
|
9
|
+
* 字段拿 access_token / refresh_token / expires_at / subscriptionType /
|
|
10
|
+
* rateLimitTier。
|
|
15
11
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* access_token 过期时,用 refresh_token 调 platform.claude.com
|
|
13
|
+
* `/v1/oauth/token` 刷新,刷新成功后原子写回凭据文件
|
|
14
|
+
* (tmp + rename),保证多进程并发安全。
|
|
19
15
|
*
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
* -
|
|
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
|
+
* 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
|
|
30
|
+
* Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
|
|
31
|
+
* (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
|
|
32
|
+
* 文件锁。
|
|
23
33
|
*/
|
|
24
34
|
|
|
25
|
-
import {
|
|
35
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { homedir } from 'node:os'
|
|
37
|
+
import { join } from 'node:path'
|
|
26
38
|
import { log } from './log'
|
|
27
39
|
|
|
28
|
-
const
|
|
40
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
41
|
+
const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
42
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
43
|
+
const API_TIMEOUT_MS = 10_000
|
|
29
44
|
const CACHE_TTL_MS = 60_000
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
percentUsed: number | null
|
|
42
|
-
/** Minutes left in this 5h window per ccusage's projection. */
|
|
43
|
-
remainingMinutes: number | null
|
|
45
|
+
|
|
46
|
+
function credentialsPath(): string {
|
|
47
|
+
return join(homedir(), '.claude', '.credentials.json')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface OAuthCredentials {
|
|
51
|
+
accessToken: string
|
|
52
|
+
refreshToken?: string
|
|
53
|
+
expiresAt?: number
|
|
54
|
+
subscriptionType?: string
|
|
55
|
+
rateLimitTier?: string
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
export interface
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/** Consumption vs. user's historical peak week (0–100). Null when
|
|
52
|
-
* there's no prior week to compare against. */
|
|
53
|
-
percentUsed: number | null
|
|
54
|
-
/** Fractional days remaining until end of week (start + 7d). */
|
|
55
|
-
remainingDays: number | null
|
|
58
|
+
export interface UsageWindow {
|
|
59
|
+
/** 0-100, Anthropic 直接返回的 utilization 真实值 */
|
|
60
|
+
percent: number
|
|
61
|
+
/** 这个窗口何时重置;ISO 解析失败则 null */
|
|
62
|
+
resetsAt: Date | null
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export type UsageSnapshot =
|
|
59
|
-
| {
|
|
66
|
+
| { state: 'no_credentials' }
|
|
67
|
+
| { state: 'auth_failed' }
|
|
68
|
+
| { state: 'rate_limited' }
|
|
69
|
+
| { state: 'network'; reason?: string }
|
|
60
70
|
| {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
state: 'ok'
|
|
72
|
+
subscriptionType?: string
|
|
73
|
+
fiveHour: UsageWindow | null
|
|
74
|
+
weekly: UsageWindow | null
|
|
65
75
|
fetchedAt: number
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
function clampPct(v: number): number {
|
|
69
|
-
if (!isFinite(v)) return 0
|
|
70
|
-
return Math.max(0, Math.min(100, v))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
78
|
let cache: { data: UsageSnapshot; at: number } | null = null
|
|
74
79
|
let inFlight: Promise<UsageSnapshot> | null = null
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
function readCredentials(): OAuthCredentials | null {
|
|
82
|
+
const path = credentialsPath()
|
|
83
|
+
if (!existsSync(path)) return null
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(path, 'utf8')
|
|
86
|
+
const parsed = JSON.parse(raw)
|
|
87
|
+
const creds = parsed.claudeAiOauth ?? parsed
|
|
88
|
+
if (!creds?.accessToken) return null
|
|
89
|
+
return {
|
|
90
|
+
accessToken: creds.accessToken,
|
|
91
|
+
refreshToken: creds.refreshToken,
|
|
92
|
+
expiresAt: creds.expiresAt,
|
|
93
|
+
subscriptionType: creds.subscriptionType,
|
|
94
|
+
rateLimitTier: creds.rateLimitTier,
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
log(`usage: read credentials failed: ${e}`)
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
/** 把刷新后的 access_token / refresh_token / expires_at 原子写回原文件,
|
|
103
|
+
* 保留其它字段(scopes、subscriptionType、organizationUuid 等)。
|
|
104
|
+
* 走 tmp + rename 防止半写状态被读到。 */
|
|
105
|
+
function writeBackCredentials(updated: OAuthCredentials): void {
|
|
106
|
+
const path = credentialsPath()
|
|
107
|
+
if (!existsSync(path)) return
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
110
|
+
const target = parsed.claudeAiOauth ?? parsed
|
|
111
|
+
target.accessToken = updated.accessToken
|
|
112
|
+
if (updated.refreshToken) target.refreshToken = updated.refreshToken
|
|
113
|
+
if (updated.expiresAt != null) target.expiresAt = updated.expiresAt
|
|
114
|
+
const tmp = `${path}.tmp.${process.pid}`
|
|
86
115
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
writeFileSync(tmp, JSON.stringify(parsed, null, 2), { mode: 0o600 })
|
|
117
|
+
renameSync(tmp, path)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
try { if (existsSync(tmp)) unlinkSync(tmp) } catch {}
|
|
120
|
+
throw e
|
|
92
121
|
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log(`usage: writeBackCredentials failed: ${e}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
93
126
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}, SPAWN_TIMEOUT_MS)
|
|
127
|
+
function isExpired(creds: OAuthCredentials): boolean {
|
|
128
|
+
return creds.expiresAt != null && creds.expiresAt <= Date.now()
|
|
129
|
+
}
|
|
98
130
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
131
|
+
async function refreshAccessToken(refreshToken: string): Promise<OAuthCredentials | null> {
|
|
132
|
+
const body = new URLSearchParams({
|
|
133
|
+
grant_type: 'refresh_token',
|
|
134
|
+
refresh_token: refreshToken,
|
|
135
|
+
client_id: OAUTH_CLIENT_ID,
|
|
136
|
+
}).toString()
|
|
137
|
+
const ctrl = new AbortController()
|
|
138
|
+
const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(TOKEN_REFRESH_URL, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
143
|
+
body,
|
|
144
|
+
signal: ctrl.signal,
|
|
103
145
|
})
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
if (res.status !== 200) {
|
|
147
|
+
log(`usage: token refresh HTTP ${res.status}`)
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
const json = await res.json() as any
|
|
151
|
+
if (!json?.access_token) return null
|
|
152
|
+
return {
|
|
153
|
+
accessToken: json.access_token,
|
|
154
|
+
refreshToken: json.refresh_token ?? refreshToken,
|
|
155
|
+
expiresAt: json.expires_in
|
|
156
|
+
? Date.now() + json.expires_in * 1000
|
|
157
|
+
: json.expires_at,
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
log(`usage: token refresh threw: ${e}`)
|
|
161
|
+
return null
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
}
|
|
116
165
|
}
|
|
117
166
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
167
|
+
interface UsageApiResponse {
|
|
168
|
+
five_hour?: { utilization?: number; resets_at?: string }
|
|
169
|
+
seven_day?: { utilization?: number; resets_at?: string }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseDate(s: string | undefined): Date | null {
|
|
173
|
+
if (!s) return null
|
|
174
|
+
const d = new Date(s)
|
|
175
|
+
return isNaN(d.getTime()) ? null : d
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clampPct(v: number | undefined): number {
|
|
179
|
+
if (v == null || !isFinite(v)) return 0
|
|
180
|
+
return Math.max(0, Math.min(100, v))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface FetchResult {
|
|
184
|
+
data: UsageApiResponse | null
|
|
185
|
+
/** 失败原因:undefined = 成功;其它字符串是分类错误。 */
|
|
186
|
+
reason?: 'rate_limited' | 'network'
|
|
187
|
+
detail?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function fetchUsageFromApi(accessToken: string): Promise<FetchResult> {
|
|
191
|
+
const ctrl = new AbortController()
|
|
192
|
+
const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(USAGE_URL, {
|
|
195
|
+
method: 'GET',
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${accessToken}`,
|
|
198
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
},
|
|
201
|
+
signal: ctrl.signal,
|
|
202
|
+
})
|
|
203
|
+
if (res.status === 200) {
|
|
204
|
+
const data = await res.json() as UsageApiResponse
|
|
205
|
+
return { data }
|
|
146
206
|
}
|
|
207
|
+
if (res.status === 429) return { data: null, reason: 'rate_limited' }
|
|
208
|
+
return { data: null, reason: 'network', detail: `HTTP ${res.status}` }
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
return { data: null, reason: 'network', detail: e?.message ?? String(e) }
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timer)
|
|
147
213
|
}
|
|
214
|
+
}
|
|
148
215
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// Week end = weekStart + 7 days. ccusage emits weekStart as YYYY-MM-DD;
|
|
160
|
-
// parse as UTC so DST/timezone shifts don't drift the countdown.
|
|
161
|
-
const weekStartIso = String(current.week ?? '')
|
|
162
|
-
let remainingDays: number | null = null
|
|
163
|
-
if (weekStartIso) {
|
|
164
|
-
const start = new Date(weekStartIso + 'T00:00:00Z')
|
|
165
|
-
if (!isNaN(start.getTime())) {
|
|
166
|
-
const endMs = start.getTime() + 7 * 24 * 60 * 60 * 1000
|
|
167
|
-
remainingDays = Math.max(0, (endMs - Date.now()) / (24 * 60 * 60 * 1000))
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
wk = {
|
|
171
|
-
weekStart: weekStartIso,
|
|
172
|
-
costUsd: Number(current.totalCost) || 0,
|
|
173
|
-
totalTokens,
|
|
174
|
-
percentUsed,
|
|
175
|
-
remainingDays,
|
|
176
|
-
}
|
|
216
|
+
async function fetchUsage(): Promise<UsageSnapshot> {
|
|
217
|
+
let creds = readCredentials()
|
|
218
|
+
if (!creds) return { state: 'no_credentials' }
|
|
219
|
+
|
|
220
|
+
if (isExpired(creds)) {
|
|
221
|
+
if (!creds.refreshToken) return { state: 'auth_failed' }
|
|
222
|
+
const refreshed = await refreshAccessToken(creds.refreshToken)
|
|
223
|
+
if (!refreshed) return { state: 'auth_failed' }
|
|
224
|
+
creds = { ...creds, ...refreshed }
|
|
225
|
+
writeBackCredentials(creds)
|
|
177
226
|
}
|
|
178
227
|
|
|
179
|
-
|
|
228
|
+
const result = await fetchUsageFromApi(creds.accessToken)
|
|
229
|
+
if (result.reason === 'rate_limited') return { state: 'rate_limited' }
|
|
230
|
+
if (result.reason === 'network' || !result.data) return { state: 'network', reason: result.detail }
|
|
231
|
+
|
|
232
|
+
const data = result.data
|
|
233
|
+
const fiveHour = data.five_hour?.utilization != null
|
|
234
|
+
? { percent: clampPct(data.five_hour.utilization), resetsAt: parseDate(data.five_hour.resets_at) }
|
|
235
|
+
: null
|
|
236
|
+
const weekly = data.seven_day?.utilization != null
|
|
237
|
+
? { percent: clampPct(data.seven_day.utilization), resetsAt: parseDate(data.seven_day.resets_at) }
|
|
238
|
+
: null
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
state: 'ok',
|
|
242
|
+
subscriptionType: creds.subscriptionType,
|
|
243
|
+
fiveHour,
|
|
244
|
+
weekly,
|
|
245
|
+
fetchedAt: Date.now(),
|
|
246
|
+
}
|
|
180
247
|
}
|
|
181
248
|
|
|
182
|
-
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* instant. Never throws — returns `{ installed: false }` if ccusage is
|
|
186
|
-
* missing, or an empty `{ installed: true, fiveHour: null, ... }` if it
|
|
187
|
-
* runs but yields no data. */
|
|
249
|
+
/** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
|
|
250
|
+
* 单次后台 fetch。永不抛出 —— 失败状态由 `state` 字段表达,卡片层
|
|
251
|
+
* 按 state 分支渲染。 */
|
|
188
252
|
export async function readUsage(): Promise<UsageSnapshot> {
|
|
189
253
|
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data
|
|
190
254
|
if (inFlight) return inFlight
|
|
191
|
-
inFlight = fetchUsage()
|
|
192
|
-
cache = { data: d, at: Date.now() }
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return cache?.data ?? { installed: false }
|
|
199
|
-
})
|
|
255
|
+
inFlight = fetchUsage()
|
|
256
|
+
.then(d => { cache = { data: d, at: Date.now() }; inFlight = null; return d })
|
|
257
|
+
.catch(e => {
|
|
258
|
+
log(`usage: fetchUsage threw: ${e}`)
|
|
259
|
+
inFlight = null
|
|
260
|
+
return cache?.data ?? { state: 'network', reason: String(e) }
|
|
261
|
+
})
|
|
200
262
|
return inFlight
|
|
201
263
|
}
|