@tsa-group/claude-usage 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 +216 -0
- package/dist/cli.js +104 -0
- package/dist/creds.js +103 -0
- package/dist/daemon.js +217 -0
- package/dist/events.js +448 -0
- package/dist/health.js +95 -0
- package/dist/install.js +263 -0
- package/dist/paths.js +57 -0
- package/dist/upload.js +188 -0
- package/dist/usage.js +112 -0
- package/dist/util.js +73 -0
- package/dist/version.js +24 -0
- package/dist/view.js +182 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# @tsa-group/claude-usage
|
|
2
|
+
|
|
3
|
+
每人 Claude 用量的本地採集工具。在你的機器上就地量測 **Claude Code 的 token 明細** 與
|
|
4
|
+
**帳號層級的額度水位(5 小時 / 每週 %,含 Claude 網頁 chat)**,在背景非同步回報到你們自己的
|
|
5
|
+
ingest server,彙整成團隊用量儀表。
|
|
6
|
+
|
|
7
|
+
> 需要一台**相容的 ingest server**(見下方「回報協定」)。這個套件只是 client。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🔒 隱私(請先看這段)
|
|
12
|
+
|
|
13
|
+
**送出去的**
|
|
14
|
+
- 你的 Claude 帳號 email / uuid / 組織 uuid(來自 `/api/oauth/profile`,用於識別是誰)
|
|
15
|
+
- 用量數字:per-message 的 token 計數、session 維度、5 小時 / 每週額度百分比
|
|
16
|
+
- client 自身的健康狀態(上次成功採樣時間、連續失敗次數等)
|
|
17
|
+
|
|
18
|
+
**永遠不送、也永遠不離開這台機器**
|
|
19
|
+
- ❌ 你的 Claude OAuth token
|
|
20
|
+
- ❌ 任何 prompt / 回應 / 對話內容
|
|
21
|
+
- ❌ 原始檔案路徑 —— 專案路徑只送 `SHA256(cwd)` 的前 16 碼
|
|
22
|
+
|
|
23
|
+
原始碼很短,`src/events.ts` 就是「哪些欄位會被送出去」的唯一權威來源,可以自己讀過再裝。
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 需求
|
|
28
|
+
|
|
29
|
+
- **Node.js >= 20**
|
|
30
|
+
- **Claude Code** 已登入(工具讀它的憑證來認證,見上方隱私說明)
|
|
31
|
+
- macOS:完整支援並實測
|
|
32
|
+
- Windows:程式路徑已備妥但**尚未在真機驗證**(見「已知限制」)
|
|
33
|
+
- Linux:背景任務(systemd timer)未實作,其餘指令可用
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 安裝(3 步)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm i -g @tsa-group/claude-usage
|
|
41
|
+
|
|
42
|
+
# 1) 指向你們的 ingest server
|
|
43
|
+
claude-usage configure --server https://<你們的 ingest host>
|
|
44
|
+
|
|
45
|
+
# 2) 一鍵安裝:enroll(取得身份)+ 註冊 SessionStart hook + 背景任務
|
|
46
|
+
# 先加 --dry-run 可預覽會做什麼、不動任何系統設定
|
|
47
|
+
claude-usage install --dry-run
|
|
48
|
+
claude-usage install --enroll-secret <管理者給你的密語>
|
|
49
|
+
|
|
50
|
+
# 3) 確認狀態
|
|
51
|
+
claude-usage status
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
安裝後會發生:
|
|
55
|
+
|
|
56
|
+
- **hook**(開 session 時):抓一筆即時額度快照。註冊在 `~/.claude/settings.json` 的
|
|
57
|
+
`SessionStart`,**fire-and-forget、失敗永遠安靜、10 分鐘去抖動** —— 採集用量絕不該擋住或
|
|
58
|
+
拖慢你開 session。
|
|
59
|
+
- **背景任務**(macOS launchd / Windows 工作排程器):每 5 分鐘喚醒一次,**自我判斷**是否要
|
|
60
|
+
採樣,並非同步上傳。採樣間隔自適應:有在用 15 分鐘、閒置退避到 60 分鐘。
|
|
61
|
+
- 升級(`npm update -g @tsa-group/claude-usage`)後**請重跑一次 `claude-usage install`**:
|
|
62
|
+
背景任務與 hook 記的是絕對路徑,換了 Node 版本或安裝位置就要重新註冊。
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 平常怎麼看自己的用量
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude-usage show # 目前額度水位(含 Anthropic 自己判定的 severity 燈號)
|
|
70
|
+
claude-usage show --history # 快照時間序
|
|
71
|
+
claude-usage sessions # session / token 明細
|
|
72
|
+
claude-usage sessions --json # 同上,機器可讀
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
採集 08-24 16:58 (13m ago) 訂閱=team tier=default_claude_max_5x creds=keychain
|
|
77
|
+
|
|
78
|
+
5 小時窗 3% 🟢 normal reset in 3h 8m
|
|
79
|
+
每週(全) 27% 🟢 normal reset in 17h 48m
|
|
80
|
+
每週(單模型) 8% 🟢 normal reset in 17h 48m [Fable]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
三個數字**不是同一件事**:
|
|
84
|
+
|
|
85
|
+
- **5 小時窗 / 每週(全)** 是**帳號全域**的額度水位,天然包含 claude.ai 網頁 chat。
|
|
86
|
+
- **每週(單模型)** 是 per-model 的週上限,回答「我的週額度是不是被單一模型吃掉的」。
|
|
87
|
+
- `sessions` 的 token 數**只含 Claude Code**,與上面兩個是獨立指標,不可相加。
|
|
88
|
+
|
|
89
|
+
`severity`(🟢 normal / 🟡 warning / 🔴 critical)是 **Anthropic 自己的判定**,門檻未公開,
|
|
90
|
+
所以照抄不自己算。
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 移除
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
claude-usage uninstall # 移除 hook 與背景任務
|
|
98
|
+
npm rm -g @tsa-group/claude-usage
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
本機資料(`~/.claude-usage/`)不會被自動刪除,要清就自己刪那個目錄。
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 指令一覽
|
|
106
|
+
|
|
107
|
+
| 指令 | 用途 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `configure --server <url>` | 設定 ingest server 位址 |
|
|
110
|
+
| `install [--enroll-secret <s>] [--dry-run]` | enroll + hook + 背景任務 |
|
|
111
|
+
| `status` | 裝好了嗎?**採樣真的有在動嗎?** |
|
|
112
|
+
| `uninstall` | 移除 hook 與背景任務 |
|
|
113
|
+
| `show [--history]` | 看自己的額度水位 / 時間序 |
|
|
114
|
+
| `sessions [--json]` | session 與 token 明細 |
|
|
115
|
+
| `sample [--hook]` | 手動抓一筆額度快照 |
|
|
116
|
+
| `daemon-tick [--explain]` | 背景排程器呼叫的單元;`--explain` 只印決策不採樣 |
|
|
117
|
+
| `enroll [--enroll-secret <s>]` | 手動與 server 溝通取得身份 |
|
|
118
|
+
| `report [--full] [--dry-run]` | 手動上報。`--full` 忽略游標整包重送(重送是安全的) |
|
|
119
|
+
| `health` | 背景任務心跳;不健康時 **exit 1**(可接監控) |
|
|
120
|
+
|
|
121
|
+
`--enroll-secret` **刻意不寫進設定檔**:enroll 是一次性動作,把密語落地在每台機器上只是
|
|
122
|
+
多開一個洩漏面。也可用環境變數 `CLAUDE_USAGE_ENROLL_SECRET`。
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 資料存哪
|
|
127
|
+
|
|
128
|
+
| 路徑 | 內容 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `~/.claude-usage/config.json` | server URL 等設定 |
|
|
131
|
+
| `~/.claude-usage/device.json` | device_id + device_key(**不含** Claude token) |
|
|
132
|
+
| `~/.claude-usage/usage_snapshots.jsonl` | 本地額度快照 |
|
|
133
|
+
| `~/.claude-usage/cursor.json` | 上報游標(每個 JSONL 檔讀到第幾個 byte) |
|
|
134
|
+
| `~/.claude-usage/health.json` | 背景任務心跳 |
|
|
135
|
+
| `~/.claude-usage/daemon.log` | 背景任務輸出 |
|
|
136
|
+
| `~/.claude/projects/**/*.jsonl` | Claude Code 原生資料(本工具**唯讀**) |
|
|
137
|
+
|
|
138
|
+
`CLAUDE_USAGE_HOME` 可覆寫狀態目錄位置。
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 「裝好了」不等於「有資料進來」
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
claude-usage status # 不健康時 exit 1
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
這個工具吃過一次虧,所以特別強調:曾經發生過 token 過期後背景任務**每 5 分鐘照跑、
|
|
149
|
+
log 照寫、作業系統顯示 `runs=556 / last exit code=0`**,但 **17.6 小時一筆快照都沒進來**,
|
|
150
|
+
而使用者完全無感(Claude Code 好得很 —— 它把憑證留在記憶體,不會把刷新後的 token 寫回
|
|
151
|
+
磁碟上的副本)。
|
|
152
|
+
|
|
153
|
+
**「背景任務已註冊且 exit 0」不能當成「有資料進來」的代理指標,必須量測資料本身。**
|
|
154
|
+
所以 `status` / `health` 看的是「上次**成功採樣**是什麼時候」,而且 client 就算沒有新資料
|
|
155
|
+
也會定期送心跳 —— 否則「機器閒置」與「採樣壞掉」在 server 眼中長得一模一樣。
|
|
156
|
+
|
|
157
|
+
採樣壞掉時會告訴你原因,常見的是:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
status : UNHEALTHY - 已 8.3 小時沒有成功採樣(最後成功 2026-08-22T...)
|
|
161
|
+
-> access token EXPIRED 5.2h ago — run /login in Claude Code
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
處置就是在 Claude Code 裡跑 `/login`。工具**刻意不自動 refresh token**:refresh token 若是
|
|
165
|
+
一次性輪替,我們換掉會讓 Claude Code 拿舊的失敗、可能把你登出。這個風險不值得為了背景
|
|
166
|
+
採樣承擔。
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 回報協定(給要自建 server 的人)
|
|
171
|
+
|
|
172
|
+
Client 對 server 只用兩個端點,皆為 `application/json`:
|
|
173
|
+
|
|
174
|
+
- `POST /v1/enroll` — 送 `{ profile, device_id, os, agent_version, enroll_secret, seat }`,
|
|
175
|
+
取回 `{ device_id, device_key }`。**token 不上傳**,server 應驗證 `profile.organization.uuid`。
|
|
176
|
+
- `POST /v1/report` — Header `Authorization: Bearer <device_key>`,送
|
|
177
|
+
`{ agent_version, limit_snapshots[], sessions[], token_events[], health{} }`。
|
|
178
|
+
|
|
179
|
+
三個 client 端的性質,server 端設計時可以依賴:
|
|
180
|
+
|
|
181
|
+
1. **上報是增量的**,以 per-file byte offset 游標推進,且**游標只在 HTTP 200 之後才前進** ——
|
|
182
|
+
非 200 會重送同一批。
|
|
183
|
+
2. **重送必須是安全的。** client 端只在單一批次內去重,跨批次去重是 server 的責任。
|
|
184
|
+
建議 append-only + 去重 view(去重鍵是 `(message_id, request_id)` 複合鍵,單用
|
|
185
|
+
`message_id` 會漏掉重試產生的重複)。
|
|
186
|
+
3. **沒有新資料時仍會送 health-only 心跳**,`limit_snapshots` / `sessions` / `token_events`
|
|
187
|
+
皆為空陣列。
|
|
188
|
+
|
|
189
|
+
欄位語意見 `src/events.ts` —— 那是本機資料到 wire schema 的唯一權威映射層。
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 已知限制
|
|
194
|
+
|
|
195
|
+
- **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
|
|
196
|
+
- **Windows 未實機驗證**:憑證改讀檔案版(mac 走 Keychain)、背景任務用 schtasks +
|
|
197
|
+
VBS 隱藏視窗啟動器。程式路徑都在,但沒有在真的 Windows 上跑過。裝完請用
|
|
198
|
+
`claude-usage status` 確認有心跳。
|
|
199
|
+
- **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
|
|
200
|
+
- **`session_id` 跨 compaction / resume 不穩定**:session **數**會高估。token 與成本不受影響
|
|
201
|
+
(那是 per-event 去重的,與 session 身份無關)。
|
|
202
|
+
- **enroll 是 client 自報身份**:可偽造 email / org,共享密語擋得住路過的人,擋不住內部人。
|
|
203
|
+
要真正的身份保證需要 server 端接 SSO 或人工核准流程。
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 開發
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npm install
|
|
211
|
+
npm test # node:test,零執行期依賴
|
|
212
|
+
npm run build # tsc -> dist/
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
client 端**沒有任何執行期依賴**是刻意的:這東西裝在別人的機器上,每一個 dependency 都是
|
|
216
|
+
一個他們沒同意過的信任關係。
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-usage — per-user Claude usage collector.
|
|
4
|
+
*
|
|
5
|
+
* 隱私:只送用量數字 + 你的 Claude 帳號 email/uuid。你的 OAuth token 與所有
|
|
6
|
+
* prompt / 回應內容**永遠不離開這台機器**。
|
|
7
|
+
*/
|
|
8
|
+
import { printHealth, tick } from "./daemon.js";
|
|
9
|
+
import { install, status, uninstall } from "./install.js";
|
|
10
|
+
import { DEFAULT_SERVER, configPath, setConfig } from "./paths.js";
|
|
11
|
+
import { enroll, report } from "./upload.js";
|
|
12
|
+
import { sample } from "./usage.js";
|
|
13
|
+
import { showHistory, showLatest, showSessions } from "./view.js";
|
|
14
|
+
import { VERSION } from "./version.js";
|
|
15
|
+
const HELP = `claude-usage v${VERSION} — per-user Claude usage collector
|
|
16
|
+
|
|
17
|
+
安裝(3 步)
|
|
18
|
+
claude-usage configure --server https://<ingest host>
|
|
19
|
+
claude-usage install [--enroll-secret <密語>] [--dry-run]
|
|
20
|
+
claude-usage status
|
|
21
|
+
|
|
22
|
+
平常(多半是自動的)
|
|
23
|
+
show [--history] 看自己的額度水位 / 時間序
|
|
24
|
+
sessions [--json] session 與 token 明細
|
|
25
|
+
sample [--hook] 手動抓一筆額度快照
|
|
26
|
+
daemon-tick [--explain] 背景排程器呼叫的單元(採樣 + 上傳)
|
|
27
|
+
enroll [--enroll-secret <密語>]
|
|
28
|
+
report [--full] [--dry-run]
|
|
29
|
+
health 背景任務心跳;不健康時 exit 1
|
|
30
|
+
uninstall 移除 hook 與背景任務
|
|
31
|
+
|
|
32
|
+
隱私:Claude OAuth token 與 prompt/回應內容永不離開本機。只送數字與帳號 email/uuid。`;
|
|
33
|
+
function configure(argv) {
|
|
34
|
+
const i = argv.indexOf("--server");
|
|
35
|
+
const server = i >= 0 ? argv[i + 1] : undefined;
|
|
36
|
+
if (i >= 0 && !server) {
|
|
37
|
+
console.error("--server 後面要接位址,例如 --server https://ingest.example.com");
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
const c = setConfig(server ? { server_url: server } : {});
|
|
41
|
+
console.log(`config -> ${configPath()}`);
|
|
42
|
+
console.log(` server_url = ${c.server_url ?? DEFAULT_SERVER}`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
async function main() {
|
|
46
|
+
const [cmd = "", ...argv] = process.argv.slice(2);
|
|
47
|
+
switch (cmd) {
|
|
48
|
+
case "":
|
|
49
|
+
case "-h":
|
|
50
|
+
case "--help":
|
|
51
|
+
case "help":
|
|
52
|
+
console.log(HELP);
|
|
53
|
+
return 0;
|
|
54
|
+
case "-v":
|
|
55
|
+
case "--version":
|
|
56
|
+
case "version":
|
|
57
|
+
console.log(VERSION);
|
|
58
|
+
return 0;
|
|
59
|
+
case "configure":
|
|
60
|
+
return configure(argv);
|
|
61
|
+
case "install":
|
|
62
|
+
return await install(argv);
|
|
63
|
+
case "uninstall":
|
|
64
|
+
return uninstall();
|
|
65
|
+
case "status":
|
|
66
|
+
return status();
|
|
67
|
+
case "sample": {
|
|
68
|
+
const hook = argv.includes("--hook");
|
|
69
|
+
const r = await sample({ hook });
|
|
70
|
+
// hook 模式一律安靜且 exit 0 —— 採集用量絕不該擋住或拖慢開 session。
|
|
71
|
+
if (hook)
|
|
72
|
+
return 0;
|
|
73
|
+
console[r.code === 0 ? "log" : "error"](r.code === 0 ? `sampled: ${r.message}` : `sample failed (${r.code}): ${r.message}`);
|
|
74
|
+
return r.code;
|
|
75
|
+
}
|
|
76
|
+
case "daemon-tick":
|
|
77
|
+
await tick({ explain: argv.includes("--explain") });
|
|
78
|
+
return 0;
|
|
79
|
+
case "health":
|
|
80
|
+
return printHealth();
|
|
81
|
+
case "enroll":
|
|
82
|
+
return await enroll(argv);
|
|
83
|
+
case "report":
|
|
84
|
+
return await report(argv);
|
|
85
|
+
case "show":
|
|
86
|
+
return argv.includes("--history") ? showHistory() : showLatest();
|
|
87
|
+
case "sessions":
|
|
88
|
+
return showSessions(argv);
|
|
89
|
+
default:
|
|
90
|
+
console.error(`unknown command: ${cmd}\n`);
|
|
91
|
+
console.error(HELP);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
main()
|
|
96
|
+
.then((code) => {
|
|
97
|
+
process.exitCode = code;
|
|
98
|
+
})
|
|
99
|
+
.catch((e) => {
|
|
100
|
+
// 未預期的錯誤要**大聲**,但 server 位址錯這類常見狀況已經在各指令裡處理過了。
|
|
101
|
+
const err = e;
|
|
102
|
+
console.error(`claude-usage: ${err.name ?? "Error"}: ${err.message}`);
|
|
103
|
+
process.exitCode = typeof err.code === "number" ? err.code : 1;
|
|
104
|
+
});
|
package/dist/creds.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code OAuth 憑證讀取。
|
|
3
|
+
*
|
|
4
|
+
* ★ mac 上**主儲存是 Keychain**,`~/.claude/.credentials.json` 只是過時副本。
|
|
5
|
+
* 實測 2026-08-23:檔案版過期 17.6 小時而使用者完全無感(Claude Code 好得很 ——
|
|
6
|
+
* 它把憑證留在記憶體、不會把刷新後的 token 寫回那個檔),Keychain 版有效 +5.2h,
|
|
7
|
+
* 且**額外帶 subscriptionType / rateLimitTier**(檔案版沒有)。同一個資料源錯誤
|
|
8
|
+
* 同時造成了「daemon 靜默死亡」與「拿不到 seat_tier」兩個問題。
|
|
9
|
+
*
|
|
10
|
+
* SECURITY:token 只用於認證,**永不列印、永不寫入快照、永不上傳**。
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { userInfo } from "node:os";
|
|
14
|
+
import { credsPath } from "./paths.js";
|
|
15
|
+
import { readJson } from "./util.js";
|
|
16
|
+
/** 可區分的結束碼 —— daemon 靠這個把「token 過期」和「網路壞掉」分開記錄。
|
|
17
|
+
* 過去兩者都是 exit 1,daemon 只能寫 sample-failed,於是 2026-08-22 那次
|
|
18
|
+
* token 過期靜默死了 17.6 小時沒人發現。 */
|
|
19
|
+
export const EXIT = {
|
|
20
|
+
OK: 0,
|
|
21
|
+
TOKEN_EXPIRED: 2,
|
|
22
|
+
NO_TOKEN: 3,
|
|
23
|
+
HTTP: 4,
|
|
24
|
+
NETWORK: 5,
|
|
25
|
+
};
|
|
26
|
+
export class CuError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(message, code) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = "CuError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
35
|
+
function fromKeychain() {
|
|
36
|
+
if (process.platform !== "darwin")
|
|
37
|
+
return [null, "not-macos"];
|
|
38
|
+
let r;
|
|
39
|
+
try {
|
|
40
|
+
r = spawnSync("security", ["find-generic-password", "-w", "-s", KEYCHAIN_SERVICE, "-a", userInfo().username], { encoding: "utf8", timeout: 10_000 });
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return [null, `keychain-error:${e.name}`];
|
|
44
|
+
}
|
|
45
|
+
if (r.status !== 0 || !r.stdout?.trim()) {
|
|
46
|
+
// rc 語意取自 Claude Code 自身實作(bin/claude 字串表):
|
|
47
|
+
// 44 = item 不存在(沒登入過,或帳號名不符)→ 要 /login
|
|
48
|
+
// 36 = keychain 被鎖住 → 要解鎖,**不是**重新登入
|
|
49
|
+
// 兩者處置完全不同,必須分開記錄。
|
|
50
|
+
const by = { 44: "keychain-no-item", 36: "keychain-locked" };
|
|
51
|
+
return [null, by[r.status ?? -1] ?? `keychain-rc${r.status}`];
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return [(JSON.parse(r.stdout).claudeAiOauth ?? {}), "keychain"];
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [null, "keychain-not-json"];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function fromFile() {
|
|
61
|
+
const o = readJson(credsPath());
|
|
62
|
+
if (!o)
|
|
63
|
+
return [null, "file-error:unreadable"];
|
|
64
|
+
return [(o.claudeAiOauth ?? {}), "file"];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 讀 access token。**刻意不做 refresh**:refresh token 若是一次性輪替,我們換完之後
|
|
68
|
+
* Claude Code 拿舊的去用會失敗,可能把使用者登出。這個風險不值得為了背景採樣承擔
|
|
69
|
+
* —— 過期就大聲回報,讓使用者自己 /login。
|
|
70
|
+
*/
|
|
71
|
+
export function loadToken() {
|
|
72
|
+
let [o, source] = fromKeychain();
|
|
73
|
+
if (!o?.accessToken) {
|
|
74
|
+
const kcWhy = source;
|
|
75
|
+
[o, source] = fromFile();
|
|
76
|
+
if (!o?.accessToken) {
|
|
77
|
+
const hint = kcWhy === "keychain-locked"
|
|
78
|
+
? " -> 解鎖 login keychain(不是重新登入)"
|
|
79
|
+
: kcWhy === "keychain-no-item"
|
|
80
|
+
? " -> 在 Claude Code 跑 /login"
|
|
81
|
+
: "";
|
|
82
|
+
throw new CuError(`cannot read credentials (keychain: ${kcWhy}; file: ${source})${hint}`, EXIT.NO_TOKEN);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (o.expiresAt && Date.now() >= o.expiresAt) {
|
|
86
|
+
const hrs = (Date.now() - o.expiresAt) / 3_600_000;
|
|
87
|
+
throw new CuError(`access token EXPIRED ${hrs.toFixed(1)}h ago — run /login in Claude Code ` +
|
|
88
|
+
`(Claude Code 不會把刷新後的 token 寫回這個檔案)`, EXIT.TOKEN_EXPIRED);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
token: o.accessToken,
|
|
92
|
+
subscription: o.subscriptionType ?? null,
|
|
93
|
+
tier: o.rateLimitTier ?? null,
|
|
94
|
+
source,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** 打 Anthropic OAuth API 用的共同 headers。token 只出現在這裡。 */
|
|
98
|
+
export const authHeaders = (token) => ({
|
|
99
|
+
Authorization: `Bearer ${token}`,
|
|
100
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
101
|
+
"anthropic-version": "2023-06-01",
|
|
102
|
+
Accept: "application/json",
|
|
103
|
+
});
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon — 自適應背景採樣 + 上傳 + 自我健康判斷。
|
|
3
|
+
*
|
|
4
|
+
* 設計:作業系統排程器(launchd / schtasks / systemd)以**固定基礎頻率**呼叫
|
|
5
|
+
* tick(),由 tick() 自己決定這一輪要不要真的採樣。自適應間隔因此活在**一個
|
|
6
|
+
* 跨平台函式**裡,不需要常駐行程,也不需要在三個排程器上各實作一次間隔邏輯。
|
|
7
|
+
*/
|
|
8
|
+
import { statSync, readdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { EXIT } from "./creds.js";
|
|
12
|
+
import { healthWarning, lastSnapshot, loadHealth, saveHealth, } from "./health.js";
|
|
13
|
+
import { devicePath, projectsDir } from "./paths.js";
|
|
14
|
+
import { report } from "./upload.js";
|
|
15
|
+
import { sample } from "./usage.js";
|
|
16
|
+
import { nowIso, parseIso } from "./util.js";
|
|
17
|
+
// ── 自適應策略 ──
|
|
18
|
+
/** 多少分鐘內有 JSONL 被動過就算「使用中」 */
|
|
19
|
+
export const ACTIVE_WINDOW_MIN = 30;
|
|
20
|
+
/** 使用中的採樣節奏 */
|
|
21
|
+
export const INTERVAL_ACTIVE = 15;
|
|
22
|
+
/** 閒置時的採樣節奏 */
|
|
23
|
+
export const INTERVAL_IDLE = 60;
|
|
24
|
+
/** 窗剛 reset 後補一筆的寬限期 */
|
|
25
|
+
export const RESET_GRACE_MIN = 3;
|
|
26
|
+
/** 排程器的基礎頻率(秒) */
|
|
27
|
+
export const BASE_TICK_SEC = 300;
|
|
28
|
+
/**
|
|
29
|
+
* Pre-reset 對時採樣。窗內 utilization 單調遞增、到 reset 才歸零,所以
|
|
30
|
+
* **reset 前最後一筆約等於該窗真實峰值** —— 峰值偵測靠「對時」而不是高頻採樣。
|
|
31
|
+
*
|
|
32
|
+
* 必須 > BASE_TICK_SEC/60(=5),否則排程抖動時可能沒有任何一個 tick 落在窗內。
|
|
33
|
+
* 實測動機:舊版只在 reset **之後**補一筆(拿到新窗的低值,方向剛好錯),9 個 5h
|
|
34
|
+
* 窗有 3 個最後一筆距 reset 超過 60 分鐘,峰值沒抓到。
|
|
35
|
+
*/
|
|
36
|
+
export const PRERESET_MIN = 8;
|
|
37
|
+
/** 距最近一次 JSONL 修改幾分鐘(Claude 活動訊號)。沒有任何檔案時回 null。 */
|
|
38
|
+
export function lastActivityMin() {
|
|
39
|
+
let newest = 0;
|
|
40
|
+
const walk = (dir, depth) => {
|
|
41
|
+
if (depth > 3)
|
|
42
|
+
return;
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const p = join(dir, e.name);
|
|
52
|
+
if (e.isDirectory())
|
|
53
|
+
walk(p, depth + 1);
|
|
54
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
55
|
+
try {
|
|
56
|
+
newest = Math.max(newest, statSync(p).mtimeMs);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// 檔案在走訪途中被換掉是正常的
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
walk(projectsDir(), 0);
|
|
65
|
+
if (!newest)
|
|
66
|
+
return null;
|
|
67
|
+
return (Date.now() - newest) / 60_000;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 已知窗即將 reset 且我們還沒在 pre-reset 窗內採過樣 -> 強制採一筆。
|
|
71
|
+
* 5h 與 weekly 都要檢查:weekly 是升降級判斷的主要訊號。
|
|
72
|
+
*/
|
|
73
|
+
function preresetDue(snap, snapT) {
|
|
74
|
+
const usage = (snap["usage"] ?? {});
|
|
75
|
+
for (const [label, key] of [
|
|
76
|
+
["5h", "five_hour"],
|
|
77
|
+
["weekly", "seven_day"],
|
|
78
|
+
]) {
|
|
79
|
+
const w = (usage[key] ?? {});
|
|
80
|
+
const reset = parseIso(w["resets_at"]);
|
|
81
|
+
if (!reset)
|
|
82
|
+
continue;
|
|
83
|
+
const minsUntil = (reset.getTime() - Date.now()) / 60_000;
|
|
84
|
+
if (minsUntil > 0 &&
|
|
85
|
+
minsUntil <= PRERESET_MIN &&
|
|
86
|
+
snapT.getTime() < reset.getTime() - PRERESET_MIN * 60_000) {
|
|
87
|
+
return `${label} 窗 ${minsUntil.toFixed(1)}m 後 reset — 搶在歸零前抓峰值`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export function decide() {
|
|
93
|
+
const act = lastActivityMin();
|
|
94
|
+
const active = act !== null && act <= ACTIVE_WINDOW_MIN;
|
|
95
|
+
const interval = active ? INTERVAL_ACTIVE : INTERVAL_IDLE;
|
|
96
|
+
const snap = lastSnapshot();
|
|
97
|
+
if (snap === null)
|
|
98
|
+
return { should: true, reason: "no prior snapshot", interval };
|
|
99
|
+
const snapT = parseIso(snap["collected_at"]);
|
|
100
|
+
if (snapT === null)
|
|
101
|
+
return { should: true, reason: "last snapshot unparseable", interval };
|
|
102
|
+
const ageMin = (Date.now() - snapT.getTime()) / 60_000;
|
|
103
|
+
// 1. 最高優先:搶在 reset 前抓峰值(否則該窗峰值永久遺失)
|
|
104
|
+
const pre = preresetDue(snap, snapT);
|
|
105
|
+
if (pre)
|
|
106
|
+
return { should: true, reason: pre, interval };
|
|
107
|
+
// 2. reset 剛過 -> 補一筆確認窗邊界(對峰值無用,但便宜且能對齊窗)
|
|
108
|
+
const usage = (snap["usage"] ?? {});
|
|
109
|
+
const fh = (usage["five_hour"] ?? {});
|
|
110
|
+
const reset = parseIso(fh["resets_at"]);
|
|
111
|
+
if (reset) {
|
|
112
|
+
const passedMin = (Date.now() - reset.getTime()) / 60_000;
|
|
113
|
+
if (passedMin >= 0 && passedMin <= RESET_GRACE_MIN && snapT.getTime() < reset.getTime()) {
|
|
114
|
+
return {
|
|
115
|
+
should: true,
|
|
116
|
+
reason: `5h window reset ${passedMin.toFixed(1)}m ago — capture the drop`,
|
|
117
|
+
interval,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 3. 一般節奏
|
|
122
|
+
if (ageMin >= interval) {
|
|
123
|
+
return {
|
|
124
|
+
should: true,
|
|
125
|
+
reason: `${active ? "active" : "idle"}: last snap ${ageMin.toFixed(0)}m ago >= ${interval}m cadence`,
|
|
126
|
+
interval,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
should: false,
|
|
131
|
+
reason: `debounced: last snap ${ageMin.toFixed(0)}m ago < ${interval}m cadence`,
|
|
132
|
+
interval,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort 把本機資料推給 ingest server。任何失敗(離線、未 enroll、server 掛掉)
|
|
137
|
+
* 都只記錄不拋出 —— daemon 絕不能因為上傳失敗就整個死掉。
|
|
138
|
+
*/
|
|
139
|
+
async function flushUploads() {
|
|
140
|
+
if (!existsSync(devicePath()))
|
|
141
|
+
return "not-enrolled";
|
|
142
|
+
try {
|
|
143
|
+
return (await report([])) === 0 ? "uploaded" : "upload-failed";
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return "upload-error";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function tick(opts = {}) {
|
|
150
|
+
const explain = opts.explain === true;
|
|
151
|
+
const { should, reason, interval } = decide();
|
|
152
|
+
const act = lastActivityMin();
|
|
153
|
+
const actS = act === null ? "-" : `${act.toFixed(0)}m`;
|
|
154
|
+
const ts = new Date().toLocaleTimeString();
|
|
155
|
+
const h = loadHealth();
|
|
156
|
+
h.last_tick_at = nowIso();
|
|
157
|
+
h.ticks = (h.ticks ?? 0) + 1;
|
|
158
|
+
let action;
|
|
159
|
+
if (should && !explain) {
|
|
160
|
+
const res = await sample();
|
|
161
|
+
const status = res.code === EXIT.OK ? "ok" : ({ 2: "token_expired", 3: "no_token", 4: "http_error", 5: "network_error" }[res.code] ??
|
|
162
|
+
`unknown_exit_${res.code}`);
|
|
163
|
+
h.last_sample_at = nowIso();
|
|
164
|
+
h.last_sample_status = status;
|
|
165
|
+
// 只在失敗時記 last_error —— 成功時也把訊息存進去,會讓 health 顯示
|
|
166
|
+
// 「錯誤: 5h=8%」這種自相矛盾的內容。
|
|
167
|
+
h.last_error = status === "ok" ? null : res.message.slice(0, 200);
|
|
168
|
+
if (status === "ok") {
|
|
169
|
+
h.last_ok_at = nowIso();
|
|
170
|
+
h.consecutive_failures = 0;
|
|
171
|
+
delete h.first_failure_at;
|
|
172
|
+
delete h.last_ok_at_backfilled; // 有真的成功紀錄了,回填標記該退場
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
h.consecutive_failures = (h.consecutive_failures ?? 0) + 1;
|
|
176
|
+
h.first_failure_at ??= nowIso();
|
|
177
|
+
}
|
|
178
|
+
action = status === "ok" ? "SAMPLED" : `FAILED:${status}`;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
action = should && explain ? "would-sample" : "skip";
|
|
182
|
+
}
|
|
183
|
+
const upload = explain ? "(skipped)" : await flushUploads();
|
|
184
|
+
h.last_upload_state = upload;
|
|
185
|
+
const warn = healthWarning(h);
|
|
186
|
+
h.unhealthy = Boolean(warn);
|
|
187
|
+
h.warning = warn;
|
|
188
|
+
if (!explain)
|
|
189
|
+
saveHealth(h);
|
|
190
|
+
console.log(`[${ts}] activity=${actS} cadence=${interval}m -> ${action.padEnd(22)} ` +
|
|
191
|
+
`upload=${upload} . ${reason}`);
|
|
192
|
+
if (warn) {
|
|
193
|
+
// 前綴固定,方便 grep daemon.log 或讓 status 指令抓
|
|
194
|
+
console.error(`[${ts}] UNHEALTHY: ${warn}`);
|
|
195
|
+
}
|
|
196
|
+
return should;
|
|
197
|
+
}
|
|
198
|
+
/** `claude-usage status` 與 `daemon --health` 共用。不健康時回 1。 */
|
|
199
|
+
export function printHealth() {
|
|
200
|
+
const h = loadHealth();
|
|
201
|
+
if (Object.keys(h).length === 0) {
|
|
202
|
+
console.log("no health data yet (daemon has never ticked)");
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
const warn = healthWarning(h);
|
|
206
|
+
const row = (k, v) => console.log(` ${k.padEnd(20)}: ${v ?? "-"}`);
|
|
207
|
+
row("last_tick_at", h.last_tick_at);
|
|
208
|
+
row("last_sample_at", h.last_sample_at);
|
|
209
|
+
row("last_sample_status", h.last_sample_status);
|
|
210
|
+
row("last_ok_at", h.last_ok_at + (h.last_ok_at_backfilled ? " (從既有快照回填,非真的成功紀錄)" : ""));
|
|
211
|
+
row("consecutive_failures", h.consecutive_failures ?? 0);
|
|
212
|
+
row("last_upload_state", h.last_upload_state);
|
|
213
|
+
if (h.last_error)
|
|
214
|
+
row("last_error", h.last_error);
|
|
215
|
+
row("status", warn ? `UNHEALTHY - ${warn}` : "ok");
|
|
216
|
+
return warn ? 1 : 0;
|
|
217
|
+
}
|