feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
This commit is contained in:
@@ -61,6 +61,27 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
start(`agenthub-lane-11-${i}`, {
|
||||
laneId: 11,
|
||||
tool: "shell",
|
||||
startedAt: Date.parse(`2026-10-0${i}T10:00:00Z`),
|
||||
});
|
||||
}
|
||||
start("agenthub-lane-11", { laneId: 11, tool: "claude", startedAt: Date.parse("2026-09-01") });
|
||||
// Pane 5 still runs; every other one has closed.
|
||||
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
|
||||
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
|
||||
assert.deepEqual(shells, [
|
||||
"agenthub-lane-11-5",
|
||||
"agenthub-lane-11-4",
|
||||
"agenthub-lane-11-3",
|
||||
"agenthub-lane-11-2",
|
||||
]);
|
||||
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -195,6 +195,7 @@ describe("collectChanges — branch range", () => {
|
||||
|
||||
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
|
||||
assert.equal(out.baseRef, "origin/development");
|
||||
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["a.txt"]
|
||||
@@ -401,6 +402,8 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
||||
["touch keep", "touch a", "init"]
|
||||
);
|
||||
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
|
||||
assert.deepEqual(all[0].parents, [all[1].sha]);
|
||||
assert.deepEqual(all[2].parents, []);
|
||||
|
||||
const forA = await listCommits(LANE, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file Tests server/lib/quota.js: a rollout's `rate_limits`
|
||||
* window mapped to the status bar's label/percent/reset, and a window that has
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
assert.strictEqual(windowLabel(43200), "mo");
|
||||
assert.strictEqual(windowLabel(2880), "2d");
|
||||
});
|
||||
|
||||
test("codexWindow keeps a live window and empties a reset one", () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||
assert.deepStrictEqual(
|
||||
codexWindow({ used_percent: 27, window_minutes: 300, resets_at: future }, Date.now()),
|
||||
{ label: "5h", usedPercent: 27, resetsAt: new Date(future * 1000).toISOString() }
|
||||
);
|
||||
const past = codexWindow(
|
||||
{ used_percent: 92, window_minutes: 10080, resets_in_seconds: 60 },
|
||||
Date.now() - 120_000
|
||||
);
|
||||
assert.strictEqual(past.usedPercent, 0);
|
||||
assert.strictEqual(codexWindow(null, 0), null);
|
||||
});
|
||||
|
||||
test("codexAccount reads the email and access-token expiry, never a token", () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { codexAccount } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-codex-"));
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = home;
|
||||
try {
|
||||
assert.deepStrictEqual(codexAccount(), {});
|
||||
fs.writeFileSync(
|
||||
path.join(home, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a saved profile is asked with its own token only while valid, never refreshed", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-claude-"));
|
||||
const dir = path.join(home, "agenthub-profiles");
|
||||
fs.mkdirSync(dir);
|
||||
const profile = (name, token, expiresAt) =>
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
label: name,
|
||||
account: { accountUuid: name, emailAddress: `${name}@x.y` },
|
||||
credentials: { accessToken: token, refreshToken: "r", expiresAt },
|
||||
})
|
||||
);
|
||||
profile("fresh", "tok-fresh", Date.now() + 3600_000);
|
||||
profile("stale", "tok-stale", Date.now() - 1000);
|
||||
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||
const asked = [];
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url, opts) => {
|
||||
asked.push(`${url} ${opts.headers.Authorization}`);
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () => ({ five_hour: { utilization: 40, resets_at: null } }),
|
||||
};
|
||||
};
|
||||
try {
|
||||
__resetCache();
|
||||
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
|
||||
assert.deepStrictEqual(asked, ["https://api.anthropic.com/api/oauth/usage Bearer tok-fresh"]);
|
||||
const by = Object.fromEntries(claude.map((t) => [t.label, t]));
|
||||
assert.strictEqual(by.fresh.active, false);
|
||||
assert.strictEqual(by.fresh.windows[0].usedPercent, 40);
|
||||
assert.strictEqual(by.stale.error, "EPROFILEEXPIRED");
|
||||
assert.strictEqual(by.stale.account, "stale@x.y");
|
||||
} finally {
|
||||
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prev.home;
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -491,6 +491,7 @@ module.exports = {
|
||||
readProfile,
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -263,12 +263,28 @@ const abandonStmt = db.prepare(`
|
||||
WHERE id = ? AND status IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/** Closed Terminal tabs kept per lane — what the sidebar lists
|
||||
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
|
||||
const CLOSED_SHELLS_KEPT = 3;
|
||||
// A shell has no session, so its row is only that list's entry: one the list
|
||||
// cannot show is dropped, or removing the shown ones uncovers older ones.
|
||||
const pruneShellsStmt = db.prepare(`
|
||||
DELETE FROM dashboard_runs WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
|
||||
) WHERE n > ${CLOSED_SHELLS_KEPT}
|
||||
)
|
||||
`);
|
||||
|
||||
/**
|
||||
* Flags `abandoned` every row still marked running/spawning whose pane is
|
||||
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
|
||||
* A pane is a tmux session, which outlives a server restart, so liveness is
|
||||
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
|
||||
* and on the server's run-heal beat.
|
||||
* and on the server's run-heal beat, which also drops the closed Terminal
|
||||
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
@@ -278,6 +294,7 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
for (const { id } of runningStmt.all()) {
|
||||
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
|
||||
}
|
||||
pruneShellsStmt.run();
|
||||
return changes;
|
||||
} catch {
|
||||
return 0;
|
||||
|
||||
+14
-5
@@ -508,7 +508,7 @@ async function synthesizeAddedPatch(dir, file) {
|
||||
* `file` is set, which is already a full read of one file.
|
||||
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
|
||||
* raises this to expand a diff towards the whole file.
|
||||
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
|
||||
* @returns {Promise<{range, base, baseRef, mergeBase, sha, context, files, truncated}>}
|
||||
*/
|
||||
async function collectChanges(
|
||||
dir,
|
||||
@@ -520,11 +520,17 @@ async function collectChanges(
|
||||
const ctx = normalizeContext(context);
|
||||
|
||||
let baseRef = null;
|
||||
// The commit a branch diff is taken from (`<baseRef>...HEAD`): the editor
|
||||
// reads a file's original text there.
|
||||
let mergeBase = null;
|
||||
if (range === "branch") {
|
||||
baseRef = await resolveBaseRef(dir, base);
|
||||
if (!baseRef) {
|
||||
throw badRequest("ENOBASE", "no base branch to compare against");
|
||||
}
|
||||
mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"])
|
||||
.then((r) => r.stdout.trim() || null)
|
||||
.catch(() => null);
|
||||
}
|
||||
if (range === "commit") {
|
||||
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
|
||||
@@ -598,7 +604,7 @@ async function collectChanges(
|
||||
}
|
||||
}
|
||||
|
||||
return { range, base, baseRef, sha, context: ctx, files, truncated };
|
||||
return { range, base, baseRef, mergeBase, sha, context: ctx, files, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,8 +621,10 @@ async function collectChanges(
|
||||
*/
|
||||
async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
|
||||
const args = ["log", `-${n}`, `--format=${format}%x1e`];
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
|
||||
// --date-order: no parent before all its children, which the history's
|
||||
// commit graph needs to draw a lane down to each parent.
|
||||
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
|
||||
// Through the same lexical guard as every other path: in a subdirectory
|
||||
// lane `../x` would list commit metadata for a path outside the lane.
|
||||
if (file) args.push("--", lanePath(file));
|
||||
@@ -625,7 +633,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
const line = record.replace(/^\n/, "");
|
||||
if (!line.trim()) continue;
|
||||
const [sha, short, subject, author, date, refs] = line.split("\x1f");
|
||||
const [sha, short, subject, author, date, refs, parents] = line.split("\x1f");
|
||||
commits.push({
|
||||
sha,
|
||||
short,
|
||||
@@ -638,6 +646,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
.split(", ")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean),
|
||||
parents: (parents || "").split(" ").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @file quota.js
|
||||
* @description How much of each native AI tool's plan quota is spent, for the
|
||||
* status bar's quota chips (`GET /api/pricing/quota`).
|
||||
*
|
||||
* - **Claude**: the logged-in account's `GET /api/oauth/usage` (`cc-usage.js`),
|
||||
* with the access token the CLI holds RIGHT NOW. It is never refreshed here —
|
||||
* a refresh rotates the CLI's credentials — so an expired token answers
|
||||
* `ELIVEEXPIRED` until Claude Code next refreshes it itself. Every OTHER
|
||||
* saved auth profile (`cc-profiles.js`) is asked with its own stored token
|
||||
* while that token is valid and never refreshed (a refresh rotates the
|
||||
* saved credentials); past expiry it shows its last figure, `asOf`. Cached
|
||||
* `CLAUDE_TTL_MS` per account, so a poll from every open tab is one
|
||||
* upstream call each. The live entry is `active: true`.
|
||||
* - **Codex**: no network. Every `token_count` event Codex writes into its
|
||||
* rollout journal carries the account's `rate_limits`; the newest one wins.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email) and `expiresAt`,
|
||||
* when its stored access token runs out — Claude's `claudeAiOauth.expiresAt`,
|
||||
* Codex's `auth.json` access-token `exp` (claims read, never verified).
|
||||
*
|
||||
* Only percentages, window lengths, reset/expiry times and the account's
|
||||
* email leave this module — never a token.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const CODEX_FILES = 5;
|
||||
|
||||
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
|
||||
const lastGood = new Map(); // profile name → { windows, at }
|
||||
|
||||
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click). */
|
||||
async function cached(key, force, fn) {
|
||||
const hit = cache.get(key);
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
|
||||
const value = await fn();
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function windowLabel(minutes) {
|
||||
if (minutes === 300) return "5h";
|
||||
if (minutes === 10080) return "wk";
|
||||
if (minutes >= 40000 && minutes <= 45000) return "mo";
|
||||
return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
const iso = (ms) => (Number(ms) ? new Date(Number(ms)).toISOString() : null);
|
||||
const fresh = (ms) => Number(ms) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
|
||||
function pct(w) {
|
||||
return { usedPercent: w.utilization, resetsAt: w.resetsAt };
|
||||
}
|
||||
|
||||
function claudeWindows(u) {
|
||||
const windows = [];
|
||||
if (u.fiveHour) windows.push({ label: "5h", ...pct(u.fiveHour) });
|
||||
if (u.sevenDay) windows.push({ label: "wk", ...pct(u.sevenDay) });
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** The account the CLI is logged into, with the token it holds right now. */
|
||||
async function liveClaude() {
|
||||
let who = {};
|
||||
try {
|
||||
const { oauth, account } = readActiveAuth();
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
return { ...who, windows: claudeWindows(await fetchUsage(oauth.accessToken)) };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved profile that is not the live account, asked with its OWN stored
|
||||
* token only while that token is still valid. Never refreshed here: a refresh
|
||||
* rotates the profile's credentials on disk, so it happens only on the
|
||||
* Profiles modal's Check quota click (`cc-usage.profileUsage`). Past expiry
|
||||
* the last figure this process read is shown with its `asOf`.
|
||||
*/
|
||||
async function profileClaude(item) {
|
||||
const who = { account: item.email || item.displayName || null, expiresAt: iso(item.expiresAt) };
|
||||
let creds = null;
|
||||
try {
|
||||
creds = readProfile(item.name)?.credentials || null;
|
||||
} catch {
|
||||
/* a corrupt profile file reads as signed out */
|
||||
}
|
||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||
try {
|
||||
const windows = claudeWindows(await fetchUsage(creds.accessToken));
|
||||
lastGood.set(item.name, { windows, at: Date.now() });
|
||||
return { ...who, windows };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
const prev = lastGood.get(item.name);
|
||||
if (!prev) return { ...who, windows: [], error: "EPROFILEEXPIRED" };
|
||||
return { ...who, windows: prev.windows.map(expireReset), asOf: iso(prev.at) };
|
||||
}
|
||||
|
||||
/** A window whose reset has passed is empty again. */
|
||||
function expireReset(w) {
|
||||
return w.resetsAt && Date.parse(w.resetsAt) < Date.now() ? { ...w, usedPercent: 0 } : w;
|
||||
}
|
||||
|
||||
/** The live account first, then every other saved profile (by label). */
|
||||
async function claudeQuotas(force) {
|
||||
let items = [];
|
||||
try {
|
||||
items = listProfiles().items;
|
||||
} catch {
|
||||
/* no profiles dir */
|
||||
}
|
||||
const [live, ...others] = await Promise.all([
|
||||
cached("live", force, liveClaude),
|
||||
...items
|
||||
.filter((it) => !it.active)
|
||||
.map(async (it) => ({
|
||||
id: "claude",
|
||||
active: false,
|
||||
label: it.label,
|
||||
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
||||
})),
|
||||
]);
|
||||
const mine = items.find((it) => it.active);
|
||||
return [live && { id: "claude", active: true, label: mine?.label || null, ...live }, ...others];
|
||||
}
|
||||
|
||||
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
||||
function lastRateLimits(file) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file.path, "r");
|
||||
try {
|
||||
const len = Math.min(file.size, TAIL_BYTES);
|
||||
const buf = Buffer.alloc(len);
|
||||
fs.readSync(fd, buf, 0, len, file.size - len);
|
||||
text = buf.toString("utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!lines[i].includes('"rate_limits"')) continue;
|
||||
try {
|
||||
const rl = JSON.parse(lines[i])?.payload?.rate_limits;
|
||||
if (rl && (rl.primary || rl.secondary)) return { rl, mtime: file.mtime };
|
||||
} catch {
|
||||
/* a cut first line or a partial write */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function codexWindow(w, mtime) {
|
||||
if (!w || typeof w.used_percent !== "number") return null;
|
||||
const resetsMs = Number(w.resets_at)
|
||||
? Number(w.resets_at) * 1000
|
||||
: Number(w.resets_in_seconds)
|
||||
? mtime + Number(w.resets_in_seconds) * 1000
|
||||
: null;
|
||||
// A window that has reset since Codex last wrote is empty again.
|
||||
const used = resetsMs && resetsMs < Date.now() ? 0 : w.used_percent;
|
||||
return {
|
||||
label: windowLabel(Number(w.window_minutes)),
|
||||
usedPercent: used,
|
||||
resetsAt: resetsMs ? new Date(resetsMs).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A JWT's claims, unverified — only for showing who is signed in. */
|
||||
function jwtClaims(token) {
|
||||
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
||||
}
|
||||
|
||||
/** Codex's ChatGPT login from `auth.json`: email + access-token expiry. */
|
||||
function codexAccount() {
|
||||
try {
|
||||
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
|
||||
const { exp } = jwtClaims(tokens.access_token);
|
||||
return {
|
||||
account: jwtClaims(tokens.id_token).email || null,
|
||||
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
|
||||
};
|
||||
} catch {
|
||||
return {}; // API-key login, no file, or a token that is not a JWT
|
||||
}
|
||||
}
|
||||
|
||||
function codexQuota() {
|
||||
const files = allRolloutFiles()
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, CODEX_FILES);
|
||||
for (const f of files) {
|
||||
const found = lastRateLimits(f);
|
||||
if (!found) continue;
|
||||
const windows = [found.rl.primary, found.rl.secondary]
|
||||
.map((w) => codexWindow(w, found.mtime))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
id: "codex",
|
||||
active: true,
|
||||
...codexAccount(),
|
||||
windows,
|
||||
asOf: new Date(found.mtime).toISOString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function quotas({ force = false } = {}) {
|
||||
const tools = [...(await claudeQuotas(force)), codexQuota()].filter(Boolean);
|
||||
return { tools, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function __resetCache() {
|
||||
cache.clear();
|
||||
lastGood.clear();
|
||||
}
|
||||
|
||||
module.exports = { quotas, codexWindow, codexAccount, windowLabel, __resetCache };
|
||||
@@ -6,6 +6,7 @@
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const { quotas } = require("../lib/quota");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
@@ -508,6 +509,12 @@ router.get("/cost", (req, res) => {
|
||||
res.json({ ...result, daily_costs });
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
|
||||
router.get("/quota", async (req, res) => {
|
||||
res.json(await quotas({ force: req.query.force === "1" }));
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
|
||||
router.get("/cost/:sessionId", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
|
||||
Reference in New Issue
Block a user