@tsa-group/claude-usage 0.4.4 → 0.4.6

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
@@ -340,8 +340,13 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
340
340
  `oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
341
341
  這是**未公開的內部格式**,Anthropic 改版就可能失效 —— 與 `/api/oauth/usage` 同一個
342
342
  風險類別。失效時會在 `claude-usage doctor` 顯示卡在哪一步,不會靜默。
343
- - **Windows 背景任務未完整實機驗證**:schtasks + VBS 隱藏視窗啟動器。裝完請用
344
- `claude-usage status` 確認有心跳。
343
+ - **Windows 筆電請確認電源設定**:`schtasks` 建立的工作預設「只在接上電源時執行」、
344
+ 「拔電就中斷」、且「錯過的排程不補跑」。v0.4.6 `install` 會自動放寬這三項並讀回
345
+ 驗證;**v0.4.5 以前註冊的工作不會自動修正**,請重跑一次 `claude-usage install`。
346
+ `claude-usage status` 會在設定不對時明講。
347
+ (實測:2026-08-29 四台 Win11 因此全數斷線 10.7–18.1 小時,其中一台累計只跑過 7 次。)
348
+ - **重疊的 tick 尚未互斥**:VBS 以非阻塞方式啟動 node,工作排程器認為任務瞬間完成,
349
+ 所以 `MultipleInstances` 擋不住重疊的 node 行程。極少數情況下會競寫 `health.json`。
345
350
  - **PowerShell 執行原則**:預設 `Restricted` 會同時擋掉 `npm.ps1`(安裝時)與
346
351
  `claude-usage.ps1`(執行時)。處理方式見上面 **Windows → 第 0 步**。
347
352
  - **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
package/dist/install.js CHANGED
@@ -90,9 +90,57 @@ function macStatus() {
90
90
  return 0;
91
91
  }
92
92
  // ────────────────────────────── Windows ──────────────────────────────
93
- // ⚠️ 以下未在真機實測(見 README「已知限制」)。
93
+ // 實機狀態(2026-08-29,四台 Win11 10.0.26200):
94
+ // ✅ 已驗證:工作註冊成功、VBS 隱藏視窗可用、tick 會執行並成功上傳
95
+ // ✅ 已修正:電源預設值害它在電池下不執行(見 winHardenTask)
96
+ // ⚠️ 未驗證:winHardenTask 本身 —— 寫完當下沒有 Windows 機器可測,所以它**讀回設定
97
+ // 並印出結果**,讓第一個安裝的人一眼看得出有沒有生效。
98
+ //
99
+ // ⚠️ 已知且**本次未解決**:VBS 用 s.Run(..., 0, False) 非阻塞啟動 node,wscript 立刻
100
+ // 返回,所以工作排程器認為任務瞬間完成。這代表 -MultipleInstances IgnoreNew
101
+ // 擋不住重疊的 **node** 行程(它擋的是重疊的「工作」,而工作早就結束了)。
102
+ // 重疊的 tick 會競寫 health.json —— 那正是先前 consecutive_failures 卡在 152 的成因。
103
+ // 要真正解決得在 daemon 內加檔案鎖,不屬於這次的範圍。
94
104
  /** 隱藏視窗的啟動器:node 沒有 pythonw 那種無 console 的變體,用 VBS 包一層是標準做法。 */
95
105
  const VBS_PATH = () => join(stateDir(), "run-hidden.vbs");
106
+ /**
107
+ * schtasks.exe 建立的工作有三個**預設值會讓它在筆電上幾乎不執行**,而 schtasks 沒有
108
+ * 任何旗標可以覆蓋它們 —— 只能事後用 PowerShell 的 ScheduledTasks cmdlet 改。
109
+ *
110
+ * DisallowStartIfOnBatteries = True → 靠電池時**根本不啟動**
111
+ * StopIfGoingOnBatteries = True → 執行中拔掉電源就中斷
112
+ * StartWhenAvailable = False → 錯過的排程不補跑,開機後不會自己追上
113
+ *
114
+ * ★ 這不是臆測,是實機資料逼出來的:2026-08-29 四台 Windows 全數斷線 10.7–18.1
115
+ * 小時,其中一台安裝後累計只跑過 7 次 tick(約 35 分鐘)。同一時間唯一存活的是
116
+ * macOS 那台(launchd 沒有這些預設)。症狀完全吻合「大部分時間用電池」。
117
+ *
118
+ * ★ 為什麼要讀回來驗證:我們沒有 Windows 機器可以測這段。`Set-ScheduledTask` 失敗
119
+ * 時未必回非零,所以不能只看 rc —— 讓它自己把結果印出來,成不成一眼可見。
120
+ *
121
+ * 失敗不是致命的:工作本身還在,只是接上電源時才會跑。所以回 boolean 由呼叫端決定
122
+ * 要不要當成錯誤,而不是在這裡 return 1 把整個 install 判定成失敗。
123
+ */
124
+ function winHardenTask() {
125
+ const cmd = `$ErrorActionPreference='Stop';` +
126
+ `Set-ScheduledTask -TaskName '${WIN_TASK}' -Settings (New-ScheduledTaskSettingsSet ` +
127
+ `-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable ` +
128
+ `-MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10)) | Out-Null;` +
129
+ `$s=(Get-ScheduledTask -TaskName '${WIN_TASK}').Settings;` +
130
+ `"$($s.DisallowStartIfOnBatteries)|$($s.StopIfGoingOnBatteries)|$($s.StartWhenAvailable)"`;
131
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd], { encoding: "utf8", timeout: 20_000, windowsHide: true });
132
+ const got = (r.stdout ?? "").trim();
133
+ // 期望 False|False|True。任何其他值都代表沒真的套用上去。
134
+ if (r.status === 0 && got === "False|False|True") {
135
+ console.log("schtasks: 已放寬電源限制(電池下照跑、錯過會補跑)");
136
+ return true;
137
+ }
138
+ console.error(`! 電源設定沒有套用成功(rc=${r.status}, 讀回 "${got || "無輸出"}")。\n` +
139
+ " 工作仍然存在,但**只有接上電源時才會執行**。手動補上(單行,別換行):\n" +
140
+ ` Set-ScheduledTask -TaskName ${WIN_TASK} -Settings (New-ScheduledTaskSettingsSet ` +
141
+ "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable)");
142
+ return false;
143
+ }
96
144
  function winInstall() {
97
145
  const vbs = `Set s = CreateObject("Wscript.Shell")\r\n` +
98
146
  `s.Run """${NODE}"" ""${CLI}"" daemon-tick", 0, False\r\n`;
@@ -110,6 +158,7 @@ function winInstall() {
110
158
  console.log(`wrote ${VBS_PATH()}`);
111
159
  if (r.status === 0) {
112
160
  console.log(`schtasks: 已建立工作 "${WIN_TASK}",每 ${Math.max(1, Math.floor(BASE_INTERVAL_SEC / 60))} 分鐘執行一次`);
161
+ winHardenTask(); // 建立完才能改設定;失敗只警告,工作本身仍可用
113
162
  }
114
163
  else {
115
164
  // 失敗才印原始輸出。用 latin1 至少保證位元組不失真,並告訴使用者怎麼看到真訊息。
@@ -119,9 +168,8 @@ function winInstall() {
119
168
  console.error(` 手動執行這行可看到正確訊息: schtasks ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`);
120
169
  }
121
170
  console.log(`uninstall: schtasks /Delete /TN ${WIN_TASK} /F`);
122
- console.log("! 憑證與採樣已於 2026-08-28 Windows 實機驗證通過(含桌面版加密憑證庫)。\n" +
123
- " 尚未完整驗證的是背景任務本身(schtasks + VBS 隱藏視窗)——\n" +
124
- " 裝完請跑 claude-usage status 確認有心跳。");
171
+ console.log("! 裝完請跑 claude-usage status 確認有心跳,並確認電源設定那一行沒有警告。\n" +
172
+ " 若你是筆電使用者,這一步特別重要 —— 舊版的排程在電池下不會執行。");
125
173
  return r.status ?? 1;
126
174
  }
127
175
  function winUninstall() {
@@ -130,19 +178,64 @@ function winUninstall() {
130
178
  rmSync(VBS_PATH());
131
179
  return 0;
132
180
  }
181
+ /** schtasks 的 LastTaskResult。0 以外的值多半代表背景任務其實沒跑成功。 */
182
+ const TASK_RESULT = {
183
+ 0: "上次執行成功",
184
+ 267011: "尚未執行過",
185
+ 267009: "正在執行中",
186
+ 267014: "上次被中止",
187
+ };
133
188
  function winStatus() {
134
- // winInstall:不設 encoding。Windows 主控台是本地碼頁(繁中 CP950),用 utf8
135
- // 解碼會把整段表格變成亂碼,而使用者要看的只是「這個工作在不在、下次何時跑」。
136
- const r = spawnSync("schtasks", ["/Query", "/TN", WIN_TASK]);
137
- if (r.status !== 0) {
138
- console.log("bg-task: NOT installed (schtasks)");
139
- return r.status ?? 1;
189
+ // 不要印 schtasks 的原始輸出:它走**主控台代碼頁**(繁中是 CP950),而 Node 只會
190
+ // 用 utf8/latin1 解,中文必定變亂碼(實測「下午 03:45 就緒」印成 `¤U¤È 03:45 ´N°ü`)。
191
+ // 先前的版本用正則挑日期迴避,但仍會連帶吃到後面的本地化文字。
192
+ // 改走 PowerShell ScheduledTasks cmdlet,**自己指定 ASCII 安全的輸出格式** ——
193
+ // 順便拿到 LastTaskResult,那正是「背景任務上次到底有沒有跑成功」的直接答案,
194
+ // 而那是 schtasks 表格裡最該看、卻最容易被亂碼蓋掉的一欄。
195
+ const cmd = `$ErrorActionPreference='SilentlyContinue';` +
196
+ `$t=Get-ScheduledTask -TaskName '${WIN_TASK}';` +
197
+ `if(-not $t){'MISSING'}else{` +
198
+ `$i=Get-ScheduledTaskInfo -TaskName '${WIN_TASK}';` +
199
+ `$last=if($i.LastRunTime){$i.LastRunTime.ToString('yyyy-MM-dd HH:mm')}else{'-'};` +
200
+ `$next=if($i.NextRunTime){$i.NextRunTime.ToString('yyyy-MM-dd HH:mm')}else{'-'};` +
201
+ // 電源設定一併帶回來:已經裝好的機器不會再跑 install,若不在 status 顯示,
202
+ // 那些工作會永遠停在「只有插電才跑」而沒有任何地方看得出來。
203
+ `$s=$t.Settings;` +
204
+ `"$($t.State)|$($i.LastTaskResult)|$last|$next` +
205
+ `|$($s.DisallowStartIfOnBatteries)|$($s.StopIfGoingOnBatteries)|$($s.StartWhenAvailable)"}`;
206
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd], { encoding: "utf8", timeout: 15_000, windowsHide: true });
207
+ const line = (r.stdout ?? "").trim();
208
+ if (r.status !== 0 || !line || line === "MISSING") {
209
+ console.log(`bg-task: NOT installed —— 跑 claude-usage install 重新註冊`);
210
+ return 1;
211
+ }
212
+ const [state, resultRaw, last, next, noBatt, stopBatt, catchUp] = line.split("|");
213
+ const result = Number(resultRaw);
214
+ const meaning = TASK_RESULT[result] ?? `結束碼 ${resultRaw}`;
215
+ console.log(`bg-task: 已註冊 "${WIN_TASK}" 狀態=${state} 上次=${last} 下次=${next}`);
216
+ console.log(` ${meaning}`);
217
+ // 舊版 client 註冊的工作沒有放寬電源限制 —— 那會表現成「白天在辦公室有心跳、
218
+ // 拔線後整段消失」。這裡把它講成可行動的一句話,而不是留給人去猜為什麼會斷。
219
+ // 欄位可能不存在(極舊的 PowerShell 或未來格式變動)→ 缺欄位就安靜跳過。
220
+ if (noBatt !== undefined) {
221
+ const bad = [];
222
+ if (noBatt === "True")
223
+ bad.push("靠電池時不啟動");
224
+ if (stopBatt === "True")
225
+ bad.push("拔電源就中斷");
226
+ if (catchUp === "False")
227
+ bad.push("錯過的排程不補跑");
228
+ if (bad.length) {
229
+ console.log(` ! 電源設定會讓它在筆電上幾乎不執行:${bad.join("、")}`);
230
+ console.log(` 修正: claude-usage install (會就地重新註冊並放寬限制)`);
231
+ return 1;
232
+ }
233
+ }
234
+ // 只有「成功」與「還沒跑過」算正常;其餘代表背景任務其實沒在做事。
235
+ if (result !== 0 && result !== 267011 && result !== 267009) {
236
+ console.log(` ! 背景任務上次沒有正常結束 —— 細節: schtasks /Query /TN ${WIN_TASK} /V /FO LIST`);
237
+ return 1;
140
238
  }
141
- // 只挑出下次執行時間那一段數字/日期,避開會亂碼的本地化欄位標題
142
- const raw = (r.stdout ?? Buffer.alloc(0)).toString("latin1");
143
- const when = raw.match(/\d{4}[/-]\d{1,2}[/-]\d{1,2}[^\r\n]*/)?.[0]?.trim();
144
- console.log(`bg-task: 已註冊 "${WIN_TASK}"${when ? `,下次執行 ${when}` : ""}`);
145
- console.log(` 完整資訊: schtasks /Query /TN ${WIN_TASK} /V /FO LIST`);
146
239
  return 0;
147
240
  }
148
241
  const sessionStartGroups = (s) => s.hooks?.SessionStart ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsa-group/claude-usage",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Per-user Claude usage collector — measures Claude Code token detail and account-level rate-limit utilization locally, reports to your own ingest server.",
5
5
  "type": "module",
6
6
  "bin": {