vps-health-api/agent/claude-usage/push-claude-usage.js
le king fu e274ca7ed5 feat(agent): add the claude-usage agent: plan limits, local cache, push
limits.js reads the Claude Code OAuth credentials read-only and calls the
pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no
retry, never a refresh); detail codes come from a fixed enum only.
state.js freezes every Toronto day before yesterday, empty ones included,
prunes past 35 days, keeps the last good limits, and writes atomically in
mode 600. push-claude-usage.js writes the cache before pushing, refuses a
non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and
prints no token. run-claude-usage.sh takes a non-blocking flock so a beat
that overlaps the previous one exits 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:15 -04:00

238 lines
8.1 KiB
JavaScript
Executable file

#!/usr/bin/env node
// claude-usage agent entry point (issue #50). Runs from the ThinkPad's
// crontab every 15 minutes through run-claude-usage.sh:
//
// 1. aggregate the local transcripts (aggregate.js), frozen days excluded;
// 2. read the plan limits once (limits.js);
// 3. update and write the local cache (state.js) BEFORE pushing, so a failed
// push never costs the next beat a full rescan;
// 4. assemble the schemaVersion 1 snapshot, measure it, push it once.
//
// Zero runtime dependencies (Node 22 global fetch). Same two rules as
// agent/push-metrics.js, for the same reason — the output goes to
// `logger -t claude-usage`, so into syslog and journald for good:
//
// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero.
// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the
// OAuth token is printed. Failures are rebuilt from fixed templates plus an
// HTTP status or a sanitized error code — never an error object, a message
// from the HTTP stack (undici quotes an invalid header value in it), the
// request options or a response body.
const os = require("node:os");
const path = require("node:path");
const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js");
const { fetchLimits, defaultCredentialsPath } = require("./limits.js");
const {
loadState,
saveState,
nextState,
limitsForSnapshot,
defaultStateDir,
stateFilePath,
} = require("./state.js");
const SCHEMA_VERSION = 1;
// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body
// would be refused with a 413, so it is not even sent.
const MAX_BODY_BYTES = 65536;
const PUSH_TIMEOUT_MS = 10000;
const EXIT_OK = 0;
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
const PREFIX = "claude-usage:";
// A token is printable ASCII without spaces. Checked up front: fetch rejects
// an invalid header value with a message that QUOTES it.
const TOKEN_RE = /^[\x21-\x7e]+$/;
// What may be printed of an error: a system or undici code (ECONNREFUSED,
// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name
// (TimeoutError, TypeError). Anything else, whatever field it came from, is
// replaced by UNKNOWN rather than trusted.
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/;
const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/;
function safeCode(err) {
const codes = [err && err.cause && err.cause.code, err && err.code];
for (const c of codes) {
if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c;
}
const name = err && err.name;
return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN";
}
function readConfig(env) {
const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim();
const token = env.CLAUDE_USAGE_INGEST_TOKEN || "";
if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" };
if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" };
if (!TOKEN_RE.test(token)) {
return {
error:
"CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)",
};
}
let target;
try {
target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`);
} catch {
return { error: "CLAUDE_USAGE_API_URL is not a valid URL" };
}
// The ingest token travels in this request: https only, no exception.
if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" };
return { config: { target: target.href, token } };
}
function buildSnapshot({ now, aggregated, limits }) {
const snapshot = {
schemaVersion: SCHEMA_VERSION,
generatedAt: new Date(now).toISOString(),
};
// Left out rather than null: the contract types it as a string, and the
// server drops a null there anyway.
if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion;
snapshot.timeZone = TIME_ZONE;
snapshot.limits = limits;
snapshot.days = aggregated.days;
snapshot.projects = aggregated.projects;
return snapshot;
}
async function pushSnapshot({ target, token, body, fetchImpl }) {
let res;
try {
res = await fetchImpl(target, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body,
redirect: "error",
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
});
} catch (err) {
return { error: `push failed: request error (code=${safeCode(err)})` };
}
// The response body is never read, never logged.
try {
if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
} catch {
// nothing to release
}
const status = Number.isInteger(res.status) ? res.status : 0;
if (status >= 200 && status <= 299) return { status };
return { error: `push failed: HTTP ${status}` };
}
/**
* @param {string[]} argv
* @param {object} env
* @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError
* @returns {Promise<number>} exit code
*/
async function main(argv = process.argv.slice(2), env = process.env, deps = {}) {
const home = deps.home || os.homedir();
const fetchImpl = deps.fetch || globalThis.fetch;
const now = deps.now !== undefined ? deps.now : Date.now();
const log = deps.log || ((line) => console.log(line));
const logError = deps.logError || ((line) => console.error(line));
const dryRun = argv.includes("--dry-run");
// Configuration first: a full scan costs seconds, no point spending them to
// then discover a typo in the env file. A dry run needs no configuration.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
logError(`${PREFIX} configuration error: ${result.error}`);
return EXIT_CONFIG;
}
config = result.config;
}
const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home));
const state = loadState(stateFile);
const today = torontoDay(now);
const aggregated = await aggregate({
root: deps.transcriptsRoot || path.join(home, ".claude", "projects"),
frozenDays: state.frozenDays,
today,
homedir: home,
...(deps.openFile ? { openFile: deps.openFile } : {}),
});
const limits = await fetchLimits({
env,
credentialsPath: deps.credentialsPath || defaultCredentialsPath(home),
...(deps.readFile ? { readFile: deps.readFile } : {}),
fetch: fetchImpl,
claudeCodeVersion: aggregated.claudeCodeVersion,
now,
});
const updated = nextState(state, { openDays: aggregated.openDays, today, limits });
// Cache first. A dry run is read-only: it shows what would be sent and
// leaves the cache exactly as the cron left it.
if (!dryRun) {
try {
saveState(stateFile, updated);
} catch (err) {
// Not fatal: the next beat rescans, which is slow but correct.
logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`);
}
}
const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) });
const body = JSON.stringify(snapshot);
const bytes = Buffer.byteLength(body, "utf-8");
if (dryRun) log(JSON.stringify(snapshot, null, 2));
if (bytes > MAX_BODY_BYTES) {
logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`);
return EXIT_PUSH;
}
if (dryRun) return EXIT_OK;
const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl });
const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`;
if (pushed.error) {
logError(`${PREFIX} ${pushed.error} ${summary}`);
return EXIT_PUSH;
}
log(`${PREFIX} ok status=${pushed.status} ${summary}`);
return EXIT_OK;
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should land here. The code only, never the object or its
// message: either could carry a request option.
console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
main,
readConfig,
buildSnapshot,
pushSnapshot,
MAX_BODY_BYTES,
PUSH_TIMEOUT_MS,
EXIT_OK,
EXIT_CONFIG,
EXIT_PUSH,
};