trantor 0.18.105 → 0.18.106

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.105",
3
+ "version": "0.18.106",
4
4
  "description": "Trantor — 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
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: config.json is user-editable external input; thresholds validates its optional number/object fields at that I/O boundary. */
3
- // trantor balances — show how much credit is left on each prepaid provider (DeepSeek, Kimi, OpenRouter…)
4
- // so you can refill BEFORE a build stalls. Reads keys from the environment, queries each provider's
5
- // balance API, prints them, and pushes the snapshot to the hub so the dashboard + other sessions see it.
6
- //
3
+ // trantor balances: how much credit is left on each provider, so you can refill before a build
4
+ // stalls. Queries each provider with the env keys and pushes the snapshot to the hub.
7
5
  // Usage: trantor balances [--json] [--no-push]
8
6
  import { readFileSync, existsSync } from "node:fs";
9
7
  import { join } from "node:path";
10
8
  import { homedir } from "node:os";
11
- import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
9
+ import { fetchBalances, isLow, fmtBalance, snapshotPayload, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
10
+ import { hostId } from "../lib/project.mjs";
12
11
  import { detectedCliBalanceRows } from "../lib/providers.mjs";
13
12
  import { loadProfile } from "./profile.mjs";
14
13
  import { resolveKeys } from "../lib/provider-keys.mjs";
@@ -19,9 +18,10 @@ const noPush = args.includes("--no-push");
19
18
 
20
19
  // API-key providers stay profile-scoped so ambient keys are never scraped. Claude and Codex are
21
20
  // machine CLI logins: the registry admits them from binary + credential + live-probe detection.
22
- const configured = Object.keys(loadProfile().providers || {});
21
+ const profileProviders = loadProfile().providers || {};
22
+ const configured = Object.keys(profileProviders);
23
23
 
24
- // Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
24
+ // Signed via the shared client: an unsigned POST is rejected under enforce.
25
25
  import { signedPost } from "../hooks/lib/api.mjs";
26
26
  let _qpct = DEFAULT_LOW_QUOTA_PCT;
27
27
  function thresholds() {
@@ -35,14 +35,10 @@ const profileScoped = await fetchBalances(env, { only: configured.filter((provid
35
35
  const balances = [...detected, ...profileScoped];
36
36
  const low = thresholds();
37
37
 
38
- // push the snapshot to the hub (best-effort) so the dashboard + warning line can use it.
39
- // TWO pushes on purpose: the project hub (fleet visibility) AND the LOCAL hub explicitly —
40
- // the desktop app reads balances from 127.0.0.1 because the data is machine-local, and the
41
- // project-resolved push goes to the REMOTE hub for pinned projects. That mismatch left the
42
- // local snapshot 11 days stale on 2026-08-28: the header chips faithfully rendered Aug-16
43
- // numbers, dimmed, while fresh pushes landed on a hub the app never asks.
38
+ // Push the snapshot to the project hub and the LOCAL hub (best-effort); the machine key and
39
+ // profile let a shared hub keep one snapshot per machine.
44
40
  if (!noPush) {
45
- const snap = { balances, ts: Date.now() };
41
+ const snap = snapshotPayload(balances, { machine: hostId(), profile: profileProviders });
46
42
  try { await signedPost("/balances", snap, { timeoutMs: 2500 }); } catch {}
47
43
  try { await signedPost("http://127.0.0.1:4477/balances", snap, { timeoutMs: 2500 }); } catch {}
48
44
  }
@@ -5,7 +5,8 @@
5
5
  import { readFileSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
- import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../../lib/balances.mjs";
8
+ import { fetchBalances, isLow, fmtBalance, snapshotPayload, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../../lib/balances.mjs";
9
+ import { hostId } from "../../lib/project.mjs";
9
10
  import { loadProfile } from "../../bin/profile.mjs";
10
11
  import { resolveKeys } from "../../lib/provider-keys.mjs";
11
12
  import { signedPost } from "./api.mjs";
@@ -38,7 +39,7 @@ export async function maybeCheckBalances() {
38
39
  if (!Array.isArray(balances) || !balances.length) { try { writeFileSync(STAMP, JSON.stringify({ ts: Date.now(), low: [] })); } catch {} return { low: [] }; }
39
40
 
40
41
  // push the fresh snapshot to the hub for the dashboard + other sessions (best-effort, signed)
41
- const snap = { balances, ts: Date.now(), by: process.env.TRANTOR_SESSION || "" };
42
+ const snap = snapshotPayload(balances, { machine: hostId(), profile: loadProfile().providers, by: process.env.TRANTOR_SESSION || "" });
42
43
  await signedPost("/balances", snap, { timeoutMs: 2000 });
43
44
  // The desktop reads balances from the LOCAL hub (machine-local data); a pinned project's
44
45
  // default push lands on the REMOTE one — the 11-day-stale-header bug. Push both.
@@ -40,6 +40,8 @@ async function main() {
40
40
  try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
41
41
  // SECONDARY, best-effort: the hub copy, for dashboard surfaces that read hub state.
42
42
  const { signedPost } = await import("./lib/api.mjs");
43
+ const { hostId } = await import("../lib/project.mjs");
44
+ payload.machine = hostId();
43
45
  await signedPost("/usage/claude", payload, { timeoutMs: 2500 });
44
46
  }
45
47
 
@@ -387,8 +387,21 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
387
387
  const b = await body(req);
388
388
  const entries = Array.isArray(b.balances) ? b.balances.slice(0, 30) : [];
389
389
  const ts = (Number.isFinite(b.ts) && b.ts > 0) ? Math.floor(b.ts) : now();
390
+ const by = String(b.by || "").slice(0, 120);
390
391
  // only accept a newer snapshot (avoid an older session clobbering a fresh push)
391
- if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by: String(b.by || "").slice(0, 120), entries }; markDirty(); }
392
+ if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by, entries }; markDirty(); }
393
+ // Per-machine copy: two Macs on one hub each keep their own providers and profile.
394
+ const machine = String(b.machine || "").trim().slice(0, 60);
395
+ if (machine) {
396
+ const byM = (state.balancesByMachine ||= {});
397
+ if (ts >= (byM[machine]?.ts || 0)) {
398
+ const prof = (b.profile && typeof b.profile === "object" && !Array.isArray(b.profile)) ? Object.fromEntries(Object.entries(b.profile).slice(0, 30)) : {};
399
+ byM[machine] = { ts, by, entries, profile: prof };
400
+ const keys = Object.keys(byM);
401
+ if (keys.length > 20) for (const k of keys.sort((x, y) => byM[x].ts - byM[y].ts).slice(0, keys.length - 20)) delete byM[k];
402
+ markDirty();
403
+ }
404
+ }
392
405
  return json(res, 200, { ok: true });
393
406
  }
394
407
  // USAGE v2: the Claude statusline sidechannel. Claude Code >=2.1.80 pipes rate_limits into the
@@ -402,14 +415,23 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
402
415
  const wins = [["fiveHour", "5h"], ["sevenDay", "7d"], ["fable", "Fable"]]
403
416
  .map(([k, n]) => win(b[k], n)).filter(Boolean);
404
417
  if (!wins.length) return json(res, 400, { error: "no usable windows" });
405
- state.balances ||= { ts: 0, by: "", entries: [] };
406
- let e = state.balances.entries.find(x => x.provider === "claude");
407
- if (!e) { e = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; state.balances.entries.push(e); }
418
+ const patchSnaps = [(state.balances ||= { ts: 0, by: "", entries: [] })];
419
+ const machine = String(b.machine || "").trim().slice(0, 60);
420
+ if (machine) patchSnaps.push(((state.balancesByMachine ||= {})[machine] ||= { ts: now(), by: "", entries: [], profile: {} }));
421
+ const claudeEntry = (snap) => {
422
+ let c = snap.entries.find(x => x.provider === "claude");
423
+ if (!c) { c = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; snap.entries.push(c); }
424
+ return c;
425
+ };
426
+ const e = claudeEntry(patchSnaps[patchSnaps.length - 1]); // dedupe against the most specific copy
408
427
  // Same-value posts inside 30s are dropped (the statusline ticks ~3x/sec while streaming).
409
428
  const sig = JSON.stringify(wins);
410
429
  if (e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
411
- for (const w of wins) { const cur = (e.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else e.windows.push(w); }
412
- e.ok = true; e.liveTs = now(); e._liveSig = sig; e.liveSource = "statusline";
430
+ for (const snap of patchSnaps) {
431
+ const t = claudeEntry(snap);
432
+ for (const w of wins) { const cur = (t.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else t.windows.push({ ...w }); }
433
+ t.ok = true; t.liveTs = now(); t._liveSig = sig; t.liveSource = "statusline";
434
+ }
413
435
  markDirty();
414
436
  return json(res, 200, { ok: true, windows: wins.length });
415
437
  }
@@ -422,7 +444,12 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
422
444
  : (e.remaining != null && e.remaining < (low[e.currency] ?? low.USD ?? 5)));
423
445
  const ALIAS = { kimi: "moonshot", moonshot: "moonshot", glm: "zhipu", zai: "zhipu", zhipu: "zhipu" };
424
446
  const canonP = p => ALIAS[p] || p;
425
- let prof = {}; try { prof = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
447
+ // ?machine=X serves that machine's own snapshot and profile; otherwise the legacy latest.
448
+ const mSnap = q.machine ? state.balancesByMachine?.[String(q.machine).trim().slice(0, 60)] : null;
449
+ const snap = mSnap || state.balances;
450
+ let prof = {};
451
+ if (mSnap?.profile && Object.keys(mSnap.profile).length) prof = mSnap.profile;
452
+ else { try { prof = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {} }
426
453
  const profByCanon = {}; for (const [p, v] of Object.entries(prof)) profByCanon[canonP(p)] = v;
427
454
  const detectedCli = new Set(["claude", "codex"]);
428
455
  // API-key rows remain profile-scoped so a stray ambient key never appears. Claude and Codex
@@ -430,7 +457,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
430
457
  // missing quota declaration must not hide those machine-login rows from the bottom bar. A
431
458
  // prepaid entry that ERRORED but is a subscription per profile is really one (401 expected).
432
459
  const isSub = (t) => !!t && t !== "api"; // capped-sub / high-sub → a subscription (nothing to refill)
433
- const entries = (state.balances?.entries || []).filter(e => detectedCli.has(e.provider) || profByCanon[canonP(e.provider)]).map(e => {
460
+ const entries = (snap?.entries || []).filter(e => detectedCli.has(e.provider) || profByCanon[canonP(e.provider)]).map(e => {
434
461
  const pv = profByCanon[canonP(e.provider)];
435
462
  if (!e.ok && isSub(pv?.tier)) return { provider: e.provider, label: e.label, kind: "subscription", plan: pv.plan, ok: true, remaining: null, low: false };
436
463
  return { ...e, low: lowOf(e) };
@@ -441,8 +468,8 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
441
468
  const subs = Object.entries(prof)
442
469
  .filter(([p, v]) => !detectedCli.has(p) && isSub(v?.tier) && !known.has(canonP(p)))
443
470
  .map(([p, v]) => ({ provider: p, label: p, kind: "subscription", plan: v.plan, ok: true, remaining: null, low: false }));
444
- return json(res, 200, { ts: state.balances?.ts || 0, by: state.balances?.by || "", thresholds: low,
445
- entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (state.balances?.ts || 0)) > 6 * 3600e3 });
471
+ return json(res, 200, { ts: snap?.ts || 0, by: snap?.by || "", thresholds: low,
472
+ entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (snap?.ts || 0)) > 6 * 3600e3 });
446
473
  }
447
474
  // Rebuild cc-subagent notional cards for a project from recomputed on-disk transcript costs
448
475
  // (`trantor recost`). REPLACES the project's existing cc-subagent cards with the supplied set so the
package/hub/store.mjs CHANGED
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
6
6
 
7
7
  export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
8
8
  function emptyState() {
9
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
9
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
10
10
  }
11
11
 
12
12
  const CARD_LOG_MAX = 40;
@@ -103,6 +103,7 @@ function normalizeState(loaded = {}) {
103
103
  s.proposals = Array.isArray(loaded.proposals) ? loaded.proposals : [];
104
104
  s.proposalSeq = Number(loaded.proposalSeq || Math.max(0, ...s.proposals.map(p => Number(p.id) || 0))) || 0;
105
105
  s.balances = loaded.balances && typeof loaded.balances === "object" ? loaded.balances : { ts: 0, by: "", entries: [] };
106
+ s.balancesByMachine = loaded.balancesByMachine && typeof loaded.balancesByMachine === "object" ? loaded.balancesByMachine : {};
106
107
  s.subagentCostReset = !!loaded.subagentCostReset;
107
108
  s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
108
109
  s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
package/lib/balances.mjs CHANGED
@@ -313,3 +313,11 @@ export function fmtReset(t) {
313
313
  if (hrs < 48) return `${Math.round(hrs)}h`;
314
314
  return `${Math.round(hrs / 24)}d`;
315
315
  }
316
+
317
+ // The snapshot a machine pushes to a hub: its balances plus the machine key and the provider
318
+ // profile, so a shared hub can keep one snapshot per machine and filter by that machine's profile.
319
+ export function snapshotPayload(balances, { machine, profile, by, ts = Date.now() }) {
320
+ const snap = { balances, ts, machine: String(machine || ""), profile: profile ?? {} };
321
+ if (by) snap.by = by;
322
+ return snap;
323
+ }
package/lib/store-pg.mjs CHANGED
@@ -162,6 +162,7 @@ function kvFromState(state) {
162
162
  eventSeq: Number(state.eventSeq || 0),
163
163
  inviteTokens: state.inviteTokens || {},
164
164
  instances: state.instances || {},
165
+ balancesByMachine: state.balancesByMachine || {},
165
166
  },
166
167
  subagentCostReset: !!state.subagentCostReset,
167
168
  seq: Number(state.seq || 0),
@@ -731,6 +732,7 @@ export class PgStore {
731
732
  identities,
732
733
  inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
733
734
  instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
735
+ balancesByMachine: meta.balancesByMachine && typeof meta.balancesByMachine === "object" ? meta.balancesByMachine : {},
734
736
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
735
737
  overseerState: meta.overseerState || {},
736
738
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.105",
3
+ "version": "0.18.106",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"