trantor 0.18.14 → 0.18.15
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/.claude-plugin/plugin.json +1 -1
- package/bin/balances.mjs +9 -4
- package/hooks/lib/balance-check.mjs +5 -1
- package/lib/balances.mjs +54 -3
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.15",
|
|
4
4
|
"description": "Trantor \u2014 the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/balances.mjs
CHANGED
|
@@ -29,11 +29,16 @@ function thresholds() {
|
|
|
29
29
|
const balances = await fetchBalances(resolveKeys(process.env), { only: configured });
|
|
30
30
|
const low = thresholds();
|
|
31
31
|
|
|
32
|
-
// push the snapshot to the hub (best-effort) so the dashboard + warning line can use it
|
|
32
|
+
// push the snapshot to the hub (best-effort) so the dashboard + warning line can use it.
|
|
33
|
+
// TWO pushes on purpose: the project hub (fleet visibility) AND the LOCAL hub explicitly —
|
|
34
|
+
// the desktop app reads balances from 127.0.0.1 because the data is machine-local, and the
|
|
35
|
+
// project-resolved push goes to the REMOTE hub for pinned projects. That mismatch left the
|
|
36
|
+
// local snapshot 11 days stale on 2026-08-28: the header chips faithfully rendered Aug-16
|
|
37
|
+
// numbers, dimmed, while fresh pushes landed on a hub the app never asks.
|
|
33
38
|
if (!noPush) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} catch {}
|
|
39
|
+
const snap = { balances, ts: Date.now() };
|
|
40
|
+
try { await signedPost("/balances", snap, { timeoutMs: 2500 }); } catch {}
|
|
41
|
+
try { await signedPost("http://127.0.0.1:4477/balances", snap, { timeoutMs: 2500 }); } catch {}
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
if (asJson) { console.log(JSON.stringify({ balances, low: balances.filter((b) => isLow(b, low, _qpct)).map((b) => b.provider) }, null, 2)); process.exit(0); }
|
|
@@ -36,7 +36,11 @@ export async function maybeCheckBalances() {
|
|
|
36
36
|
if (!Array.isArray(balances) || !balances.length) { try { writeFileSync(STAMP, JSON.stringify({ ts: Date.now(), low: [] })); } catch {} return { low: [] }; }
|
|
37
37
|
|
|
38
38
|
// push the fresh snapshot to the hub for the dashboard + other sessions (best-effort, signed)
|
|
39
|
-
|
|
39
|
+
const snap = { balances, ts: Date.now(), by: process.env.TRANTOR_SESSION || "" };
|
|
40
|
+
await signedPost("/balances", snap, { timeoutMs: 2000 });
|
|
41
|
+
// The desktop reads balances from the LOCAL hub (machine-local data); a pinned project's
|
|
42
|
+
// default push lands on the REMOTE one — the 11-day-stale-header bug. Push both.
|
|
43
|
+
await signedPost("http://127.0.0.1:4477/balances", snap, { timeoutMs: 2000 }).catch(() => {});
|
|
40
44
|
|
|
41
45
|
const { t, q } = thresholds();
|
|
42
46
|
const low = balances.filter(b => isLow(b, t, q)).map(b => ({ label: b.label, line: fmtBalance(b) }));
|
package/lib/balances.mjs
CHANGED
|
@@ -32,6 +32,29 @@ const num = (v) => (v == null || v === "" || isNaN(Number(v))) ? null : Number(v
|
|
|
32
32
|
// ambient environment (a dev's shell/.env may hold keys for many unrelated projects). prepaid →
|
|
33
33
|
// { remaining, currency, unlimited? } quota → { remainingPct, plan?, resetTime?, detail? }
|
|
34
34
|
export const ADAPTERS = [
|
|
35
|
+
{
|
|
36
|
+
// The Claude subscription's REAL windows — the same numbers the claude.ai usage page shows.
|
|
37
|
+
// Auth is the operator's own Claude Code OAuth token (credentials file, then the macOS
|
|
38
|
+
// keychain entry Claude Code itself writes). Read-only, machine-local; only the resulting
|
|
39
|
+
// percentages ever leave this process (the hub snapshot) — the token never does.
|
|
40
|
+
provider: "claude", label: "Claude", kind: "windows", match: ["claude", "anthropic"], envKeys: [],
|
|
41
|
+
keyless: true,
|
|
42
|
+
async fetch() {
|
|
43
|
+
const tok = await claudeOAuthToken();
|
|
44
|
+
if (!tok) throw new Error("no Claude Code OAuth token found");
|
|
45
|
+
const r = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
46
|
+
headers: { authorization: `Bearer ${tok}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
47
|
+
signal: AbortSignal.timeout(8000),
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok) throw new Error(`usage endpoint ${r.status}`);
|
|
50
|
+
const d = await r.json();
|
|
51
|
+
const win = (w, name) => (w && w.utilization != null)
|
|
52
|
+
? { name, usedPct: Math.round(w.utilization), resetsAt: w.resets_at || null, locked: w.locked_reason || null }
|
|
53
|
+
: null;
|
|
54
|
+
return { windows: [win(d.five_hour, "5h"), win(d.seven_day, "7d")].filter(Boolean) };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
35
58
|
{
|
|
36
59
|
provider: "openrouter", label: "OpenRouter", kind: "prepaid", match: ["openrouter"], envKeys: ["OPENROUTER_API_KEY"],
|
|
37
60
|
async fetch(key) {
|
|
@@ -100,6 +123,9 @@ export const DEFAULT_LOW_QUOTA_PCT = 15;
|
|
|
100
123
|
export function isLow(entry, thresholds = DEFAULT_LOW, quotaPct = DEFAULT_LOW_QUOTA_PCT) {
|
|
101
124
|
if (!entry || !entry.ok) return false;
|
|
102
125
|
if (entry.kind === "quota") return entry.remainingPct != null && entry.remainingPct < quotaPct;
|
|
126
|
+
// A usage window is LOW when what remains of it dips under the same quota threshold — 90% used
|
|
127
|
+
// on the 5h window is exactly the moment to know before firing a crew.
|
|
128
|
+
if (entry.kind === "windows") return (entry.windows || []).some((w) => w.usedPct != null && (100 - w.usedPct) < quotaPct || w.locked);
|
|
103
129
|
if (entry.remaining == null) return false; // prepaid unlimited/unknown
|
|
104
130
|
const t = thresholds[entry.currency] ?? thresholds.USD ?? 5;
|
|
105
131
|
return entry.remaining < t;
|
|
@@ -109,15 +135,36 @@ export function isLow(entry, thresholds = DEFAULT_LOW, quotaPct = DEFAULT_LOW_QU
|
|
|
109
135
|
// profile provider names). An adapter runs only if it serves a configured provider AND its key is in the
|
|
110
136
|
// env — so a stray OPENROUTER_API_KEY in a dev's .env is NOT reported unless they actually run OpenRouter
|
|
111
137
|
// through Trantor. If `only` is omitted (no profile yet), nothing is fetched — better empty than wrong.
|
|
138
|
+
|
|
139
|
+
// The operator's own Claude Code OAuth token: the credentials file first, then the keychain item
|
|
140
|
+
// Claude Code writes on macOS. Used ONLY to read the subscription's usage windows.
|
|
141
|
+
async function claudeOAuthToken() {
|
|
142
|
+
try {
|
|
143
|
+
const { readFileSync: rf, existsSync: ex } = await import("node:fs");
|
|
144
|
+
const { join: j } = await import("node:path");
|
|
145
|
+
const { homedir: h } = await import("node:os");
|
|
146
|
+
const p = j(h(), ".claude", ".credentials.json");
|
|
147
|
+
if (ex(p)) {
|
|
148
|
+
const t = JSON.parse(rf(p, "utf8"))?.claudeAiOauth?.accessToken;
|
|
149
|
+
if (t) return t;
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
try {
|
|
153
|
+
const { execFileSync } = await import("node:child_process");
|
|
154
|
+
const out = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", timeout: 4000 }).trim();
|
|
155
|
+
return JSON.parse(out)?.claudeAiOauth?.accessToken || null;
|
|
156
|
+
} catch { return null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
112
159
|
export async function fetchBalances(env = process.env, opts = {}) {
|
|
113
160
|
const only = Array.isArray(opts.only) ? new Set(opts.only.map((s) => String(s).toLowerCase())) : null;
|
|
114
161
|
const jobs = ADAPTERS.map(async (a) => {
|
|
115
162
|
const names = (a.match || [a.provider]).map((s) => s.toLowerCase());
|
|
116
163
|
if (!only || !names.some((n) => only.has(n))) return null; // not a Trantor-configured provider → skip
|
|
117
164
|
const envKey = a.envKeys.find((k) => env[k]);
|
|
118
|
-
if (!envKey) return null;
|
|
119
|
-
const base = { provider: a.provider, label: a.label, kind: a.kind, via: envKey };
|
|
120
|
-
try { return { ...base, ok: true, ...(await a.fetch(env[envKey])) }; }
|
|
165
|
+
if (!envKey && !a.keyless) return null; // configured but no key in env → can't query
|
|
166
|
+
const base = { provider: a.provider, label: a.label, kind: a.kind, via: envKey || "oauth" };
|
|
167
|
+
try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined)) }; }
|
|
121
168
|
catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
|
|
122
169
|
});
|
|
123
170
|
const rows = (await Promise.all(jobs)).filter(Boolean);
|
|
@@ -146,6 +193,10 @@ export function fmtBalance(e) {
|
|
|
146
193
|
const reset = e.resetTime ? ` · resets ${fmtReset(e.resetTime)}` : "";
|
|
147
194
|
return `${e.label}${e.plan ? " (" + e.plan + ")" : ""}: ${e.remainingPct}% left${reset}`;
|
|
148
195
|
}
|
|
196
|
+
if (e.kind === "windows") {
|
|
197
|
+
const parts = (e.windows || []).map((w) => `${w.name} ${w.usedPct}% used${w.resetsAt ? " · resets " + fmtReset(w.resetsAt) : ""}${w.locked ? " · LOCKED" : ""}`);
|
|
198
|
+
return `${e.label}: ${parts.join(" · ") || "windows unknown"}`;
|
|
199
|
+
}
|
|
149
200
|
if (e.kind === "subscription") return `${e.label}: ${e.plan || "subscription"} (${e.note || "no balance API"})`;
|
|
150
201
|
const sym = e.currency === "CNY" ? "¥" : e.currency === "EUR" ? "€" : "$";
|
|
151
202
|
if (e.unlimited || e.remaining == null) return `${e.label}: ${e.kind === "prepaid" ? "no limit / unknown" : e.kind}`;
|