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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.14",
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
- try {
35
- await signedPost("/balances", { balances, ts: Date.now() }, { timeoutMs: 2500 });
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
- await signedPost("/balances", { balances, ts: Date.now(), by: process.env.TRANTOR_SESSION || "" }, { timeoutMs: 2000 });
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; // configured but no key in env → can't query
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.14",
3
+ "version": "0.18.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"