diff --git a/.claude/scripts/statusline.sh b/.claude/scripts/statusline.sh index d6a66b945e8..d89b5520166 100755 --- a/.claude/scripts/statusline.sh +++ b/.claude/scripts/statusline.sh @@ -17,11 +17,133 @@ process.stdin.on("end", () => { const total = j?.context_window?.context_window_size ?? 200000; const used = Math.floor(total * pct / 100); const cwd = j?.cwd ?? "~"; + + // --- Cross-session rate-limit harvest ------------------------------------- + // rate_limits is ACCOUNT-WIDE (server-side rolling 5h / 7d windows), but + // Claude Code only refreshes it in THIS session JSON after an API response, + // so between prompts it is frozen. To show a live figure that also reflects + // other sessions, every session writes what it observes to a shared file and + // all sessions display the freshest value across them. Freshness rule per + // window: the later resets_at is the newer reading (windows advance over + // time); on a tie, the higher used_percentage is newer (usage accrues within + // a fixed window). ts is stamped only when a genuinely new reading wins, so + // idle 60s re-renders do not falsely refresh it. + const os = require("os"); + const fs = require("fs"); + const path = require("path"); + const SHARED = (() => { + // Resolve a file that is the SAME physical file from Windows and WSL2 (so + // sessions in both environments aggregate together), and a normal per-home + // file on macOS / plain Linux / native Windows. + const envp = process.env.CLAUDE_USAGE_SHARED_FILE; + if (envp) return envp; // explicit override wins + const isWSL = process.platform === "linux" && + (/microsoft|wsl/i.test(String(os.release())) || !!process.env.WSL_DISTRO_NAME); + if (isWSL) { + // Write to the Windows-side home so Windows and WSL share one file. + const pick = (home) => { + try { return fs.existsSync(path.join(home, ".claude")) ? path.join(home, ".claude", "rate-limits-shared.json") : undefined; } + catch (e) { return undefined; } + }; + let user; + try { user = os.userInfo().username; } catch (e) { user = process.env.USER; } + const skip = ["Public", "Default", "Default User", "All Users", "desktop.ini"]; + for (const drv of ["c", "C"]) { + if (user) { const r = pick(`/mnt/${drv}/Users/${user}`); if (r) return r; } + } + for (const drv of ["c", "C"]) { + let names = []; + try { names = fs.readdirSync(`/mnt/${drv}/Users`); } catch (e) {} + for (const n of names) { + if (skip.includes(n)) continue; + const r = pick(`/mnt/${drv}/Users/${n}`); if (r) return r; + } + } + // fall through to WSL-local home if no Windows home with .claude found + } + return path.join(os.homedir(), ".claude", "rate-limits-shared.json"); + })(); + const STALE_SECS = 300; // values not refreshed within this many seconds get a "*" + const nowSec = Math.floor(Date.now() / 1000); + + let saved = {}; + try { saved = JSON.parse(fs.readFileSync(SHARED, "utf8")) || {}; } catch { saved = {}; } + + const mergeWin = (obs, sv) => { + let cur; + if (obs && typeof obs.used_percentage === "number") { + cur = { + used_percentage: obs.used_percentage, + resets_at: typeof obs.resets_at === "number" ? obs.resets_at : undefined, + }; + } + // Expired entries are intentionally kept (not dropped) so a passed + // resets_at can display as "0*" until a fresh reading of the new window + // arrives; the later-resets_at rule below still supersedes it then. + if (!cur && !sv) return undefined; + if (cur && !sv) return { ...cur, ts: nowSec }; + if (!cur && sv) return sv; + let curWins; + if (typeof cur.resets_at === "number" && typeof sv.resets_at === "number" && cur.resets_at !== sv.resets_at) { + curWins = cur.resets_at > sv.resets_at; + } else { + curWins = Math.round(cur.used_percentage) > Math.round(sv.used_percentage); + } + return curWins ? { ...cur, ts: nowSec } : sv; + }; + + const rl = j?.rate_limits; + const merged = {}; + const fh = mergeWin(rl?.five_hour, saved.five_hour); + const sd = mergeWin(rl?.seven_day, saved.seven_day); + if (fh) merged.five_hour = fh; + if (sd) merged.seven_day = sd; + + // Persist best-effort (temp + rename; readers tolerate a rare torn read). + try { + fs.mkdirSync(path.dirname(SHARED), { recursive: true }); + const tmp = `${SHARED}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(merged)); + try { fs.renameSync(tmp, SHARED); } + catch { fs.writeFileSync(SHARED, JSON.stringify(merged)); try { fs.unlinkSync(tmp); } catch (e) {} } + } catch (e) {} + + // Each window reads " %": the live countdown to reset is the + // leading label. 5h counts down in hours+minutes ("2h13m") and also shows its + // reset clock in local 24h ("(1900)"); 7d counts down in whole days until + // under a day, then hours ("5d", "15h"), with no clock. A resets_at that has + // already passed shows "0*". + const hhmm = (epoch) => { + const d = new Date(epoch * 1000); + return String(d.getHours()).padStart(2, "0") + String(d.getMinutes()).padStart(2, "0"); + }; + const cd5h = (secs) => { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h${String(m).padStart(2, "0")}m`; + }; + const cd7d = (secs) => (secs <= 86400 ? `${Math.ceil(secs / 3600)}h` : `${Math.ceil(secs / 86400)}d`); + const fmtWin = (e, cd, paren) => { + if (!e || typeof e.used_percentage !== "number") return undefined; + const pct = Math.round(e.used_percentage); + const stale = typeof e.ts === "number" && nowSec - e.ts > STALE_SECS; + if (typeof e.resets_at !== "number") return `${pct}%${stale ? "*" : ""}`; + const rem = e.resets_at - nowSec; + if (rem <= 0) return `0* ${pct}%`; + const tail = paren ? ` (${paren(e.resets_at)})` : ""; + return `${cd(rem)} ${pct}%${stale ? "*" : ""}${tail}`; + }; + const rate = [ + fmtWin(merged.five_hour, cd5h, hhmm), + fmtWin(merged.seven_day, cd7d, undefined), + ].filter(Boolean).join(" | "); + console.log(model); console.log(pct); console.log(total); console.log(used); console.log(cwd); + console.log(rate); }); ' <<< "$input") @@ -31,6 +153,7 @@ process.stdin.on("end", () => { IFS= read -r TOTAL IFS= read -r USED IFS= read -r CWD + IFS= read -r RATE } <<< "$node_output" # Default values for robustness when node output is empty (node absent) @@ -58,4 +181,14 @@ fmt_tokens() { fi } -printf "%s | %s%% ctx | %s/%s tokens | %s%s" "$MODEL" "$PCT" "$(fmt_tokens "$USED")" "$(fmt_tokens "$TOTAL")" "$DIR" "$GIT" +# Append rate-limit usage to the right of the branch, when available. True +# right-alignment isn't possible: Claude Code doesn't pass the terminal width +# to the status line, and stdout is a pipe (no tty) so $COLUMNS / tput cols are +# unreliable. +if [ -n "$RATE" ]; then + RATE_SEG=" | $RATE" +else + RATE_SEG="" +fi + +printf "%s | %s%% ctx | %s/%s tokens | %s%s%s" "$MODEL" "$PCT" "$(fmt_tokens "$USED")" "$(fmt_tokens "$TOTAL")" "$DIR" "$GIT" "$RATE_SEG" diff --git a/.claude/settings.json b/.claude/settings.json index 3e99ad30417..0d32a3ea4f4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "statusLine": { "type": "command", "command": ".claude/scripts/statusline.sh", - "padding": 2 + "padding": 2, + "refreshInterval": 60 }, "permissions": {