trantor 0.17.31 → 0.17.39
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 +62 -0
- package/bin/cli.mjs +8 -0
- package/bin/gates.mjs +41 -0
- package/bin/git-backfill.mjs +1 -0
- package/bin/init-hooks.mjs +66 -0
- package/bin/profile.mjs +1 -1
- package/bin/recost.mjs +52 -0
- package/hooks/lib/balance-check.mjs +52 -0
- package/hooks/lib/handoff.mjs +10 -1
- package/hooks/lib/subagent-cost-lib.mjs +19 -0
- package/hooks/sessionstart.mjs +28 -0
- package/hooks/subagent-cost.mjs +14 -3
- package/hub.mjs +0 -0
- package/lib/balances.mjs +147 -0
- package/lib/provider-keys.mjs +37 -0
- package/lib/subagent-scan.mjs +0 -0
- package/mcp.mjs +26 -0
- package/package.json +2 -2
- package/ui.html +78 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.39",
|
|
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
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor balances — show how much credit is left on each prepaid provider (DeepSeek, Kimi, OpenRouter…)
|
|
3
|
+
// so you can refill BEFORE a build stalls. Reads keys from the environment, queries each provider's
|
|
4
|
+
// balance API, prints them, and pushes the snapshot to the hub so the dashboard + other sessions see it.
|
|
5
|
+
//
|
|
6
|
+
// Usage: trantor balances [--json] [--no-push]
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
|
|
11
|
+
import { loadProfile } from "./profile.mjs";
|
|
12
|
+
import { resolveKeys } from "../lib/provider-keys.mjs";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const asJson = args.includes("--json");
|
|
16
|
+
const noPush = args.includes("--no-push");
|
|
17
|
+
|
|
18
|
+
// Only check providers the user configured in `trantor profile` — never stray keys in the ambient env.
|
|
19
|
+
const configured = Object.keys(loadProfile().providers || {});
|
|
20
|
+
|
|
21
|
+
function relayUrl() {
|
|
22
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
23
|
+
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
24
|
+
return "http://127.0.0.1:4477";
|
|
25
|
+
}
|
|
26
|
+
let _qpct = DEFAULT_LOW_QUOTA_PCT;
|
|
27
|
+
function thresholds() {
|
|
28
|
+
try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (typeof c.lowQuotaPct === "number") _qpct = c.lowQuotaPct; if (c.lowBalance && typeof c.lowBalance === "object") return { ...DEFAULT_LOW, ...c.lowBalance }; } catch {}
|
|
29
|
+
return DEFAULT_LOW;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const balances = await fetchBalances(resolveKeys(process.env), { only: configured });
|
|
33
|
+
const low = thresholds();
|
|
34
|
+
|
|
35
|
+
// push the snapshot to the hub (best-effort) so the dashboard + warning line can use it
|
|
36
|
+
if (!noPush) {
|
|
37
|
+
try {
|
|
38
|
+
await fetch(`${relayUrl()}/balances`, { method: "POST", headers: { "content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ balances, ts: Date.now() }), signal: AbortSignal.timeout(2500) });
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (asJson) { console.log(JSON.stringify({ balances, low: balances.filter((b) => isLow(b, low, _qpct)).map((b) => b.provider) }, null, 2)); process.exit(0); }
|
|
44
|
+
|
|
45
|
+
if (!balances.length) {
|
|
46
|
+
if (!configured.length) {
|
|
47
|
+
console.log("no quota profile set — Trantor doesn't know which providers you use.");
|
|
48
|
+
console.log("declare them: trantor profile set claude=max kimi=coding-plan deepseek=api zai=coding-plan");
|
|
49
|
+
} else {
|
|
50
|
+
console.log(`configured providers: ${configured.join(", ")} — but none expose a balance/quota API with a key present.`);
|
|
51
|
+
console.log("(subscriptions like claude/codex/gemini have nothing to refill; prepaid/coding-plan providers need their key in the env.)");
|
|
52
|
+
}
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
console.log("PROVIDER CREDITS\n");
|
|
56
|
+
for (const b of balances) {
|
|
57
|
+
const hasNum = b.ok && (b.remaining != null || b.remainingPct != null);
|
|
58
|
+
const flag = isLow(b, low, _qpct) ? " 🔴 REFILL SOON" : (hasNum ? " 🟢" : "");
|
|
59
|
+
console.log(" " + fmtBalance(b) + flag);
|
|
60
|
+
}
|
|
61
|
+
const lows = balances.filter((b) => isLow(b, low, _qpct));
|
|
62
|
+
if (lows.length) console.log(`\n⚠ ${lows.length} provider(s) low — top up / pace: ${lows.map((b) => b.label).join(", ")}`);
|
package/bin/cli.mjs
CHANGED
|
@@ -27,7 +27,11 @@ switch (cmd) {
|
|
|
27
27
|
case "watch": run("bin/relay-watch.mjs"); break;
|
|
28
28
|
case "catchup": run("bin/catchup.mjs"); break;
|
|
29
29
|
case "agents": run("bin/agents.mjs"); break;
|
|
30
|
+
case "gates": run("bin/gates.mjs"); break;
|
|
30
31
|
case "backfill": run("bin/git-backfill.mjs"); break;
|
|
32
|
+
case "init-hooks": run("bin/init-hooks.mjs"); break;
|
|
33
|
+
case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
|
|
34
|
+
case "recost": run("bin/recost.mjs"); break;
|
|
31
35
|
case "handoff": run("bin/baton.mjs"); break;
|
|
32
36
|
case "ui": {
|
|
33
37
|
let url = "http://127.0.0.1:4477";
|
|
@@ -50,7 +54,11 @@ switch (cmd) {
|
|
|
50
54
|
trantor ui open the live dashboard (board + flow views)
|
|
51
55
|
trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
|
|
52
56
|
trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
|
|
57
|
+
trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
|
|
53
58
|
trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
|
|
59
|
+
trantor init-hooks install a git post-commit hook so EVERY commit auto-cards on the board (reliable solo-work backstop) — [--uninstall]
|
|
60
|
+
trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
|
|
61
|
+
trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
|
|
54
62
|
trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
|
|
55
63
|
trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
|
|
56
64
|
trantor hub run the hub in the foreground (setup installs it as a service instead)
|
package/bin/gates.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor gates [--all] [--json] — verification gates for THIS project: structured "must verify
|
|
3
|
+
// before shipping" claims that survive handoffs and surface to whoever takes over. Open by default;
|
|
4
|
+
// --all includes resolved ones. Set/resolve gates from inside a session via the relay_verify_gate tool.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { resolveProject } from "../lib/project.mjs";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const all = args.includes("--all");
|
|
12
|
+
const asJson = args.includes("--json");
|
|
13
|
+
|
|
14
|
+
function relayUrl() {
|
|
15
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
16
|
+
try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
|
|
17
|
+
return "http://127.0.0.1:4477";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const project = resolveProject(process.cwd());
|
|
21
|
+
const url = `${relayUrl()}/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`;
|
|
22
|
+
let gates = [];
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
|
25
|
+
gates = (await r.json()).gates || [];
|
|
26
|
+
} catch {
|
|
27
|
+
console.error(`could not reach the hub at ${relayUrl()} — is it running? (trantor setup / trantor hub)`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (asJson) { process.stdout.write(JSON.stringify(gates, null, 2) + "\n"); process.exit(0); }
|
|
32
|
+
if (!gates.length) { console.log(`${project}: no ${all ? "" : "open "}verification gates`); process.exit(0); }
|
|
33
|
+
|
|
34
|
+
console.log(`${project} — ${gates.length} ${all ? "" : "open "}verification gate(s):`);
|
|
35
|
+
for (const g of gates) {
|
|
36
|
+
const badge = g.status === "open" ? "⚠️ OPEN" : `✓ ${g.status}`;
|
|
37
|
+
console.log(`\n#${g.id} ${badge} ${g.claim}`);
|
|
38
|
+
if (g.why) console.log(` why: ${g.why}`);
|
|
39
|
+
if (g.howToVerify) console.log(` how: ${g.howToVerify}`);
|
|
40
|
+
if (g.status !== "open" && g.resolvedNote) console.log(` resolved: ${g.resolvedNote}`);
|
|
41
|
+
}
|
package/bin/git-backfill.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const me = `${hostId()}:${project}`;
|
|
|
27
27
|
|
|
28
28
|
const themeOf = (s) => {
|
|
29
29
|
let m;
|
|
30
|
+
if ((m = s.match(/\bv?(\d+\.\d+\.\d+)\b/i))) return "v" + m[1]; // any "v0.17.33" anywhere (feat: v0.17.33 — …) → one card per release
|
|
30
31
|
if ((m = s.match(/^release:\s*v?(\d+\.\d+\.\d+)/i))) return "v" + m[1]; // release: v0.17.15 → v0.17.15 (one card per version)
|
|
31
32
|
if ((m = s.match(/^[a-z]+\(([^)]+)\)\s*:/i))) return m[1].trim(); // feat(engine): → engine
|
|
32
33
|
if ((m = s.match(/^([A-Za-z][\w &+/.]*?)\s*:/))) return m[1].trim(); // "Landing: …" → Landing
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor init-hooks — install a git post-commit hook in the current repo so EVERY commit is
|
|
3
|
+
// auto-carded onto the board. This is the reliable backstop for solo work: a session that doesn't
|
|
4
|
+
// fire a crew and doesn't keep a live TodoWrite leaves no work-card otherwise (only presence). The
|
|
5
|
+
// hook calls `trantor backfill` (idempotent, theme-grouped) in the background — never slows a commit.
|
|
6
|
+
//
|
|
7
|
+
// Usage: trantor init-hooks [--since "5 minutes ago"] [--uninstall]
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const arg = (n, d) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i + 1] : d; };
|
|
14
|
+
const since = arg("since", "5 minutes ago");
|
|
15
|
+
const uninstall = args.includes("--uninstall");
|
|
16
|
+
|
|
17
|
+
const MARK_START = "# >>> trantor auto-card (post-commit) >>>";
|
|
18
|
+
const MARK_END = "# <<< trantor auto-card (post-commit) <<<";
|
|
19
|
+
|
|
20
|
+
let gitDir;
|
|
21
|
+
try { gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: process.cwd() }).trim(); }
|
|
22
|
+
catch { console.error("not a git repository (run this inside a repo)"); process.exit(1); }
|
|
23
|
+
|
|
24
|
+
const hooksDir = join(gitDir, "hooks");
|
|
25
|
+
const hookPath = join(hooksDir, "post-commit");
|
|
26
|
+
if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
// the trantor block — fail-silent, non-blocking, PATH-robust
|
|
29
|
+
const block = [
|
|
30
|
+
MARK_START,
|
|
31
|
+
"# Card this commit onto the trantor board. Non-blocking + fail-silent: never slows/breaks a commit.",
|
|
32
|
+
'TRANTOR_BIN="$(command -v trantor 2>/dev/null)"',
|
|
33
|
+
'[ -z "$TRANTOR_BIN" ] && [ -x /opt/homebrew/bin/trantor ] && TRANTOR_BIN=/opt/homebrew/bin/trantor',
|
|
34
|
+
'[ -z "$TRANTOR_BIN" ] && [ -x /usr/local/bin/trantor ] && TRANTOR_BIN=/usr/local/bin/trantor',
|
|
35
|
+
`[ -n "$TRANTOR_BIN" ] && ( "$TRANTOR_BIN" backfill --since ${JSON.stringify(since)} >/dev/null 2>&1 & )`,
|
|
36
|
+
MARK_END,
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
let existing = existsSync(hookPath) ? readFileSync(hookPath, "utf8") : "";
|
|
40
|
+
|
|
41
|
+
// strip any prior trantor block (idempotent install / clean uninstall)
|
|
42
|
+
const stripped = existing.replace(new RegExp(`\\n?${escapeRe(MARK_START)}[\\s\\S]*?${escapeRe(MARK_END)}\\n?`, "g"), "\n").replace(/\n{3,}/g, "\n\n");
|
|
43
|
+
|
|
44
|
+
if (uninstall) {
|
|
45
|
+
if (!existing.includes(MARK_START)) { console.log("no trantor post-commit hook installed — nothing to remove"); process.exit(0); }
|
|
46
|
+
const out = stripped.trim();
|
|
47
|
+
if (out && out !== "#!/bin/sh") { writeFileSync(hookPath, out.endsWith("\n") ? out : out + "\n"); }
|
|
48
|
+
else { writeFileSync(hookPath, "#!/bin/sh\n"); }
|
|
49
|
+
console.log(`✓ removed trantor auto-card from ${hookPath}`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let out;
|
|
54
|
+
if (!stripped.trim()) {
|
|
55
|
+
out = `#!/bin/sh\n${block}\n`;
|
|
56
|
+
} else {
|
|
57
|
+
const base = stripped.trim().startsWith("#!") ? stripped.trimEnd() : `#!/bin/sh\n${stripped.trimEnd()}`;
|
|
58
|
+
out = `${base}\n\n${block}\n`;
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(hookPath, out);
|
|
61
|
+
chmodSync(hookPath, 0o755);
|
|
62
|
+
console.log(`✓ installed trantor auto-card → ${hookPath}`);
|
|
63
|
+
console.log(` every commit now cards itself on the board (backfill --since ${JSON.stringify(since)}, idempotent).`);
|
|
64
|
+
console.log(` remove with: trantor init-hooks --uninstall`);
|
|
65
|
+
|
|
66
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
package/bin/profile.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const [, , cmd, ...args] = process.argv;
|
|
|
37
37
|
// text, but import.meta.url is percent-encoded — so any URL-reserved char in the install path (most
|
|
38
38
|
// commonly a SPACE, e.g. ".../Application Support/...") made this false and silently skipped main (exit 0,
|
|
39
39
|
// no write). pathToFileURL is the canonical Node idiom. See regression in test-handoff.mjs / test.mjs.
|
|
40
|
-
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
40
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
41
41
|
const prof = loadProfile();
|
|
42
42
|
prof.providers ||= {};
|
|
43
43
|
if (cmd === "set") {
|
package/bin/recost.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor recost — recompute Claude Code sub-agent NOTIONAL cost from on-disk transcripts and reseed the
|
|
3
|
+
// board, replacing stale/contaminated cc-subagent cards (e.g. after the v0.17.37 transcript-resolution
|
|
4
|
+
// fix). Honest by construction: only transcripts still on disk are counted, and mis-resolved/implausible
|
|
5
|
+
// ones are guarded out. Run it once after upgrading; new sub-agents accrue correctly via the fixed hook.
|
|
6
|
+
//
|
|
7
|
+
// Usage: trantor recost [--dry-run] [--json]
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { scanSubagentCosts } from "../lib/subagent-scan.mjs";
|
|
12
|
+
|
|
13
|
+
function relayUrl() {
|
|
14
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
15
|
+
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
16
|
+
return "http://127.0.0.1:4477";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const dry = process.argv.includes("--dry-run");
|
|
20
|
+
const asJson = process.argv.includes("--json");
|
|
21
|
+
const byProject = scanSubagentCosts();
|
|
22
|
+
|
|
23
|
+
// flatten to one entry list (each tagged with its raw project) — the hub canon-merges alias lanes
|
|
24
|
+
const allEntries = [];
|
|
25
|
+
for (const [project, entries] of byProject) for (const e of entries) allEntries.push({ ...e, project });
|
|
26
|
+
const scanTotal = allEntries.reduce((s, e) => s + (e.costUsd || 0), 0);
|
|
27
|
+
|
|
28
|
+
if (dry) {
|
|
29
|
+
const rows = [...byProject.entries()].map(([project, entries]) => ({ project, cards: entries.length, invocations: entries.reduce((s, e) => s + e.count, 0), usd: +entries.reduce((s, e) => s + (e.costUsd || 0), 0).toFixed(2) })).sort((a, b) => b.usd - a.usd);
|
|
30
|
+
if (asJson) { console.log(JSON.stringify({ dry: true, scanTotal: +scanTotal.toFixed(2), byScannedProject: rows }, null, 2)); process.exit(0); }
|
|
31
|
+
console.log("[dry-run] Sub-agent notional recomputed from on-disk transcripts (pre-canonicalization):\n");
|
|
32
|
+
for (const r of rows) console.log(` ${r.project.padEnd(24)} ${String(r.cards).padStart(3)} cards · ${String(r.invocations).padStart(4)} inv · $${r.usd.toFixed(2)}`);
|
|
33
|
+
console.log(`\n[dry-run] scanned total $${scanTotal.toFixed(2)} (drop --dry-run to reseed; only on-board project lanes are kept)`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = await fetch(`${relayUrl()}/subagent-recost`, { method: "POST", headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ entries: allEntries }), signal: AbortSignal.timeout(15000) }).then(x => x.json());
|
|
41
|
+
} catch (e) { console.error(`recost failed: ${e.message}`); process.exit(1); }
|
|
42
|
+
|
|
43
|
+
const projs = (result?.projects || []);
|
|
44
|
+
const seeded = projs.filter(p => !p.skipped);
|
|
45
|
+
const skipped = projs.filter(p => p.skipped);
|
|
46
|
+
const seededUsd = seeded.reduce((s, p) => s + (p.usd || 0), 0);
|
|
47
|
+
|
|
48
|
+
if (asJson) { console.log(JSON.stringify(result, null, 2)); process.exit(0); }
|
|
49
|
+
console.log("Sub-agent notional reseeded from on-disk transcripts (canonical project lanes):\n");
|
|
50
|
+
for (const p of seeded.sort((a, b) => (b.usd || 0) - (a.usd || 0))) console.log(` ${p.project.padEnd(24)} ${String(p.added).padStart(3)} cards · $${(p.usd || 0).toFixed(2)} ✓`);
|
|
51
|
+
console.log(`\nSEEDED: $${seededUsd.toFixed(2)} notional · ${result.added} cards across ${seeded.length} project lanes → on the board`);
|
|
52
|
+
if (skipped.length) console.log(`skipped ${skipped.length} non-board context(s) (worktrees / non-project dirs) — not real Trantor lanes.`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// trantor SessionStart balance check — the session env HAS the provider keys (the hub, under launchd,
|
|
2
|
+
// does not), so this is where we fetch prepaid credit, push a snapshot to the hub (for the dashboard),
|
|
3
|
+
// and surface a low-balance warning line. Throttled behind a 3h TTL stamp: most starts do ZERO network.
|
|
4
|
+
// Hard-capped at 4s so it can never noticeably slow a session start. Fail-silent by contract.
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../../lib/balances.mjs";
|
|
9
|
+
import { loadProfile } from "../../bin/profile.mjs";
|
|
10
|
+
import { resolveKeys } from "../../lib/provider-keys.mjs";
|
|
11
|
+
|
|
12
|
+
const STAMP = join(homedir(), ".agent-bus", "balances-check.json");
|
|
13
|
+
const TTL_MS = 3 * 3600 * 1000;
|
|
14
|
+
const CAP_MS = 4000;
|
|
15
|
+
|
|
16
|
+
function relayUrl() {
|
|
17
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
18
|
+
try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
|
|
19
|
+
return "http://127.0.0.1:4477";
|
|
20
|
+
}
|
|
21
|
+
function thresholds() {
|
|
22
|
+
let t = DEFAULT_LOW, q = DEFAULT_LOW_QUOTA_PCT;
|
|
23
|
+
try { const c = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); if (c.lowBalance) t = { ...DEFAULT_LOW, ...c.lowBalance }; if (typeof c.lowQuotaPct === "number") q = c.lowQuotaPct; } catch {}
|
|
24
|
+
return { t, q };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns { low: [{label, line}], cached } — `low` is the list of providers below their refill threshold.
|
|
28
|
+
export async function maybeCheckBalances() {
|
|
29
|
+
if (process.env.TRANTOR_NO_BALANCE_CHECK === "1") return { low: [] };
|
|
30
|
+
let stamp = {}; try { stamp = JSON.parse(readFileSync(STAMP, "utf8")); } catch {}
|
|
31
|
+
if (stamp.ts && Date.now() - stamp.ts < TTL_MS) return { low: stamp.low || [], cached: true };
|
|
32
|
+
|
|
33
|
+
// only the providers configured in `trantor profile` (never stray ambient-env keys)
|
|
34
|
+
const only = Object.keys(loadProfile().providers || {});
|
|
35
|
+
if (!only.length) return { low: [] }; // no profile → nothing to report
|
|
36
|
+
|
|
37
|
+
let balances;
|
|
38
|
+
try { balances = await Promise.race([fetchBalances(resolveKeys(process.env), { only }), new Promise((_, rej) => setTimeout(() => rej(new Error("cap")), CAP_MS))]); }
|
|
39
|
+
catch { return { low: stamp.low || [] }; } // timed out / errored — keep the last known low list, don't rewrite the stamp
|
|
40
|
+
if (!Array.isArray(balances) || !balances.length) { try { writeFileSync(STAMP, JSON.stringify({ ts: Date.now(), low: [] })); } catch {} return { low: [] }; }
|
|
41
|
+
|
|
42
|
+
// push the fresh snapshot to the hub for the dashboard + other sessions (best-effort)
|
|
43
|
+
try {
|
|
44
|
+
await fetch(`${relayUrl()}/balances`, { method: "POST", headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ balances, ts: Date.now(), by: process.env.TRANTOR_SESSION || "" }), signal: AbortSignal.timeout(2000) });
|
|
46
|
+
} catch {}
|
|
47
|
+
|
|
48
|
+
const { t, q } = thresholds();
|
|
49
|
+
const low = balances.filter(b => isLow(b, t, q)).map(b => ({ label: b.label, line: fmtBalance(b) }));
|
|
50
|
+
try { writeFileSync(STAMP, JSON.stringify({ ts: Date.now(), low })); } catch {}
|
|
51
|
+
return { low };
|
|
52
|
+
}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -227,6 +227,15 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
227
227
|
// baked copy is just orientation if the live command isn't available. Best-effort; never throws.
|
|
228
228
|
let subagents = null;
|
|
229
229
|
try { subagents = deriveSubagentManifest(transcript, { projectRoot: projectDir }); } catch {}
|
|
230
|
+
// Open verification gates for this project — structured "must verify before shipping" claims that
|
|
231
|
+
// MUST survive the handoff (a narrative line gets skimmed past; this is what the v0.17.31 incident
|
|
232
|
+
// taught — the "verify Gail coefficients" intent vanished into prose). Fetched synchronously from
|
|
233
|
+
// the local hub; best-effort, never blocks the handoff.
|
|
234
|
+
let verifyGates = [];
|
|
235
|
+
try {
|
|
236
|
+
const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/verify-gates?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
|
|
237
|
+
verifyGates = JSON.parse(out).gates || [];
|
|
238
|
+
} catch {}
|
|
230
239
|
const record = {
|
|
231
240
|
id: `${projectName}-${stamp}`,
|
|
232
241
|
project: projectDir, projectName, machine: hostname(),
|
|
@@ -234,7 +243,7 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
234
243
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
235
244
|
// narrative + a verbatim recent-exchange block so exact in-flight state always survives
|
|
236
245
|
summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
|
|
237
|
-
gitStatus, subagents, consumed: false,
|
|
246
|
+
gitStatus, subagents, verifyGates, consumed: false,
|
|
238
247
|
};
|
|
239
248
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
240
249
|
writeFileSync(file, JSON.stringify(record, null, 2));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Pure, testable guards for the SubagentStop cost hook (extracted so they have regression coverage).
|
|
2
|
+
// The v0.17.37 bug: findTranscript() summed the PARENT session transcript (1000+ turns, 100s of M
|
|
3
|
+
// cache-read) onto tiny recall/handoff sub-agent cards → tens of thousands of $ bogus notional.
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
|
|
6
|
+
// A real sub-agent transcript lives under a /subagents/ tree and is named agent-<id>.jsonl. The MAIN
|
|
7
|
+
// session transcript is <session-uuid>.jsonl at the project root — never accept it as a sub-agent's.
|
|
8
|
+
export function isSubagentTranscript(p) {
|
|
9
|
+
if (!p) return false;
|
|
10
|
+
return /(^|[\/\\])subagents[\/\\]/.test(p) && /[\/\\]agent-[^\/\\]*\.jsonl$/.test(p);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A single sub-agent with >50M cache-read (or >$50 notional) is almost certainly a mis-resolved
|
|
14
|
+
// transcript. Real agents top out ~40M cache-read / ~$30. Treat as suspect → don't record a cost.
|
|
15
|
+
export const SUSPECT_CACHE_READ = 50e6;
|
|
16
|
+
export const SUSPECT_USD = 50;
|
|
17
|
+
export function isImplausibleCost({ usd = null, cacheRead = 0 } = {}) {
|
|
18
|
+
return (cacheRead || 0) > SUSPECT_CACHE_READ || (typeof usd === "number" && usd > SUSPECT_USD);
|
|
19
|
+
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { execSync } from "node:child_process";
|
|
|
13
13
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
14
14
|
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
15
15
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
16
|
+
import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
16
17
|
|
|
17
18
|
// Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
|
|
18
19
|
// / the heartbeat early-warning). `claim` marks it consumed so exactly one session
|
|
@@ -184,6 +185,20 @@ try {
|
|
|
184
185
|
}
|
|
185
186
|
} catch {}
|
|
186
187
|
|
|
188
|
+
// Provider credit low? The session env has the keys (the hub doesn't), so check + push the snapshot
|
|
189
|
+
// here and warn in-terminal so you refill BEFORE a build stalls. Throttled (3h TTL) + 4s-capped +
|
|
190
|
+
// fail-silent — most starts do zero network. Disable: TRANTOR_NO_BALANCE_CHECK=1.
|
|
191
|
+
try {
|
|
192
|
+
const bal = await maybeCheckBalances();
|
|
193
|
+
if (bal.low && bal.low.length) {
|
|
194
|
+
const O = "\x1b[1;38;5;208m", R = "\x1b[0m";
|
|
195
|
+
const line = `🟠 ${O}Provider credit low — refill soon:${R} ${bal.low.map(l => l.line).join(" · ")} · check: ${O}trantor balances${R}`;
|
|
196
|
+
userBanner = userBanner ? `${userBanner}\n${line}` : line;
|
|
197
|
+
additionalContext += `<trantor-balance-low>\n⚠️ ${bal.low.length} provider(s) low on prepaid credit: ${sanitize(bal.low.map(l => l.line).join("; "))}. Tell the user to refill before relying on those providers for a build.\n</trantor-balance-low>\n`;
|
|
198
|
+
process.stderr.write(`[trantor] low balance: ${bal.low.map(l => l.label).join(", ")}\n`);
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
187
202
|
// Pending handoff? A prior session hit the context limit and left a handoff for this
|
|
188
203
|
// project — take over with this fresh full window instead of starting cold. On a
|
|
189
204
|
// compaction-triggered start, DON'T claim it (that's the same session that wrote it;
|
|
@@ -197,6 +212,19 @@ try {
|
|
|
197
212
|
process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
|
|
198
213
|
additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
|
|
199
214
|
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
|
|
215
|
+
// Verification gates FIRST — these are structured "must verify before shipping" claims the prior
|
|
216
|
+
// session couldn't independently prove. They go above the summary on purpose: a safety-critical
|
|
217
|
+
// check must not be skimmed past (the lesson of the lost "verify Gail coefficients" intent).
|
|
218
|
+
if (Array.isArray(handoff.verifyGates) && handoff.verifyGates.length) {
|
|
219
|
+
additionalContext += `## ⚠️ UNVERIFIED — verify before shipping (${handoff.verifyGates.length})\n`;
|
|
220
|
+
additionalContext += `The prior session flagged these as NOT independently verified. Do NOT commit or ship the related work until each is verified (or explicitly waived WITH the user) — passing the author's own tests is not verification. Resolve via the \`relay_verify_gate\` tool (action "resolve") once checked.\n`;
|
|
221
|
+
for (const g of handoff.verifyGates) {
|
|
222
|
+
additionalContext += `- **#${sanitize(String(g.id))}: ${sanitize(g.claim)}**${g.why ? ` — ${sanitize(g.why)}` : ""}`;
|
|
223
|
+
if (g.howToVerify) additionalContext += `\n how to verify: ${sanitize(g.howToVerify)}`;
|
|
224
|
+
additionalContext += `\n`;
|
|
225
|
+
}
|
|
226
|
+
additionalContext += `\n`;
|
|
227
|
+
}
|
|
200
228
|
additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
|
|
201
229
|
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
202
230
|
// Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
|
package/hooks/subagent-cost.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { join, basename } from "node:path";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
12
12
|
import { notionalCost } from "./pricing.mjs";
|
|
13
|
+
import { isSubagentTranscript, isImplausibleCost } from "./lib/subagent-cost-lib.mjs";
|
|
13
14
|
|
|
14
15
|
function readStdin() {
|
|
15
16
|
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
@@ -22,10 +23,11 @@ function relayUrl() {
|
|
|
22
23
|
return "http://127.0.0.1:4477";
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
// Resolve the sub-agent's transcript: the payload path
|
|
26
|
+
// Resolve the sub-agent's OWN transcript: the payload path ONLY if it's truly a sub-agent transcript,
|
|
27
|
+
// else reconstruct from the subagents tree (which can only ever find real agent-*.jsonl), else "".
|
|
26
28
|
function findTranscript(input) {
|
|
27
29
|
const direct = input.transcript_path;
|
|
28
|
-
if (direct && existsSync(direct)) return direct;
|
|
30
|
+
if (direct && existsSync(direct) && isSubagentTranscript(direct)) return direct;
|
|
29
31
|
const sid = input.session_id, aid = input.agent_id;
|
|
30
32
|
// search the session's subagents tree (plain Task → subagents/agent-<id>.jsonl; Workflow → subagents/workflows/<wf>/agent-<id>.jsonl)
|
|
31
33
|
const roots = [];
|
|
@@ -86,6 +88,15 @@ try {
|
|
|
86
88
|
const ttl = process.env.TRANTOR_CACHE_TTL === "1h" ? "1h" : "5m";
|
|
87
89
|
const { usd, tokens, unpriced, model } = notionalCost(rows, ttl);
|
|
88
90
|
|
|
91
|
+
// Sanity guard: a single sub-agent with >50M cache-read (or >$50 notional) is almost certainly a
|
|
92
|
+
// mis-resolved transcript (e.g. a parent session summed in). Don't card a bogus cost — better a card
|
|
93
|
+
// with no $ than one claiming thousands. (Real agents top out ~40M cache-read / ~$30; see the v0.17.37 fix.)
|
|
94
|
+
const totalCacheRead = (tokens?.cacheRead || 0);
|
|
95
|
+
const suspect = isImplausibleCost({ usd, cacheRead: totalCacheRead });
|
|
96
|
+
const safeUsd = suspect ? null : usd;
|
|
97
|
+
const safeNote = suspect ? "skipped-implausible-cost (likely mis-resolved transcript)" : (usd == null ? "usage-unavailable-or-unpriced" : (unpriced ? `${unpriced} turn(s) unpriced` : ""));
|
|
98
|
+
if (suspect) process.stderr.write(`[trantor] subagent-cost: SKIPPED implausible cost ($${usd?.toFixed?.(0)}, ${(totalCacheRead/1e6).toFixed(0)}M cache-read) — ${basename(file)}\n`);
|
|
99
|
+
|
|
89
100
|
const task = (firstUserText || agentType).replace(/\s+/g, " ").slice(0, 90);
|
|
90
101
|
const title = `${agentType}: ${task}`.slice(0, 180);
|
|
91
102
|
const costNote = usd == null ? "usage-unavailable-or-unpriced" : (unpriced ? `${unpriced} turn(s) unpriced` : "");
|
|
@@ -96,7 +107,7 @@ try {
|
|
|
96
107
|
project, title, status: "done",
|
|
97
108
|
assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
|
|
98
109
|
source: "cc-subagent", costKind: "subagent-notional",
|
|
99
|
-
costUsd:
|
|
110
|
+
costUsd: safeUsd, costNote: safeNote, model, effort, tokens: suspect ? null : tokens,
|
|
100
111
|
phase: "sub-agents",
|
|
101
112
|
}),
|
|
102
113
|
signal: AbortSignal.timeout(2500),
|
package/hub.mjs
CHANGED
|
Binary file
|
package/lib/balances.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// trantor provider credit — read-only "how much is left before I stall" across the providers the crew
|
|
2
|
+
// uses. Keys come from the ENVIRONMENT (no new secret storage); each adapter names the env var(s) it
|
|
3
|
+
// reads. Two kinds of "left":
|
|
4
|
+
// • prepaid — a $ balance you top up (OpenRouter, DeepSeek, Moonshot platform). `remaining` = money.
|
|
5
|
+
// • quota — a recurring token/usage window that RESETS (Kimi Code, Z.ai GLM coding plans).
|
|
6
|
+
// `remainingPct` (0-100) + `resetTime`; nothing to "refill", but you can run dry mid-build.
|
|
7
|
+
// Chinese providers split CN vs international endpoints — we use the INTERNATIONAL ones (api.moonshot.ai,
|
|
8
|
+
// api.kimi.com, api.z.ai, api.deepseek.com). Every call is short-timeout + fail-soft: a provider that
|
|
9
|
+
// errors is reported {ok:false,error}, never throws out of fetchBalances.
|
|
10
|
+
//
|
|
11
|
+
// ARCHITECTURE NOTE: the hub runs under launchd with a minimal env (no keys), so it can't fetch these
|
|
12
|
+
// itself. The env-having clients fetch + POST /balances; the hub caches + serves the dashboard.
|
|
13
|
+
|
|
14
|
+
const TIMEOUT = 8000;
|
|
15
|
+
|
|
16
|
+
async function getJSON(url, key, extraHeaders = {}) {
|
|
17
|
+
const r = await fetch(url, {
|
|
18
|
+
headers: { Authorization: `Bearer ${key}`, "content-type": "application/json", Accept: "application/json", ...extraHeaders },
|
|
19
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
20
|
+
});
|
|
21
|
+
const text = await r.text();
|
|
22
|
+
let body; try { body = JSON.parse(text); } catch { body = null; }
|
|
23
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}${body?.error?.message ? " — " + body.error.message : ""}`);
|
|
24
|
+
return body ?? {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const num = (v) => (v == null || v === "" || isNaN(Number(v))) ? null : Number(v);
|
|
28
|
+
|
|
29
|
+
// Each adapter: { provider, label, kind, match:[profile names], envKeys:[...], async fetch(key) }.
|
|
30
|
+
// `match` = the `trantor profile` provider name(s) this adapter serves — balances are ONLY queried for
|
|
31
|
+
// providers the user actually configured in their profile, NOT every key that happens to be in the
|
|
32
|
+
// ambient environment (a dev's shell/.env may hold keys for many unrelated projects). prepaid →
|
|
33
|
+
// { remaining, currency, unlimited? } quota → { remainingPct, plan?, resetTime?, detail? }
|
|
34
|
+
export const ADAPTERS = [
|
|
35
|
+
{
|
|
36
|
+
provider: "openrouter", label: "OpenRouter", kind: "prepaid", match: ["openrouter"], envKeys: ["OPENROUTER_API_KEY"],
|
|
37
|
+
async fetch(key) {
|
|
38
|
+
const d = (await getJSON("https://openrouter.ai/api/v1/key", key)).data || {};
|
|
39
|
+
// limit_remaining is OpenRouter's authoritative credits-left (accounts for top-ups); limit−usage
|
|
40
|
+
// is unreliable (usage is lifetime). null limit_remaining + null limit ⇒ unlimited key.
|
|
41
|
+
const unlimited = d.limit_remaining == null && d.limit == null;
|
|
42
|
+
const remaining = num(d.limit_remaining) != null ? num(d.limit_remaining)
|
|
43
|
+
: (num(d.limit) != null ? Math.max(0, num(d.limit) - (num(d.usage) || 0)) : null);
|
|
44
|
+
return { remaining, currency: "USD", usage: num(d.usage), limit: num(d.limit), unlimited };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
provider: "deepseek", label: "DeepSeek", kind: "prepaid", match: ["deepseek"], envKeys: ["DEEPSEEK_API_KEY"],
|
|
49
|
+
async fetch(key) {
|
|
50
|
+
const j = await getJSON("https://api.deepseek.com/user/balance", key); // global endpoint (no CN/intl split)
|
|
51
|
+
const info = (j.balance_infos || [])[0] || {};
|
|
52
|
+
return { remaining: num(info.total_balance), currency: info.currency || "USD", available: !!j.is_available };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
provider: "moonshot", label: "Moonshot", kind: "prepaid", match: ["moonshot"], envKeys: ["MOONSHOT_API_KEY"],
|
|
57
|
+
async fetch(key) {
|
|
58
|
+
// international platform; china is api.moonshot.cn. (Distinct from Kimi Code sk-kim keys below.)
|
|
59
|
+
const j = await getJSON("https://api.moonshot.ai/v1/users/me/balance", key);
|
|
60
|
+
const d = j.data || j;
|
|
61
|
+
return { remaining: num(d.available_balance), currency: "CNY", cash: num(d.cash_balance), voucher: num(d.voucher_balance) };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
provider: "kimi", label: "Kimi Code", kind: "quota", match: ["kimi"], envKeys: ["KIMI_API_KEY"],
|
|
66
|
+
async fetch(key) {
|
|
67
|
+
// international Kimi Code; the sk-kim API key works as a bearer here (region REGION_OVERSEA).
|
|
68
|
+
const j = await getJSON("https://api.kimi.com/coding/v1/usages", key);
|
|
69
|
+
const tq = j.totalQuota || {};
|
|
70
|
+
const remainingPct = (num(tq.limit) && num(tq.remaining) != null) ? Math.round(num(tq.remaining) / num(tq.limit) * 100)
|
|
71
|
+
: (j.usage && num(j.usage.remaining) != null ? num(j.usage.remaining) : null);
|
|
72
|
+
const w = (j.limits || [])[0];
|
|
73
|
+
const detail = w?.detail ? `${Math.round(num(w.detail.remaining) / num(w.detail.limit) * 100)}% in ${Math.round((w.window?.duration || 0) / 60)}h window` : "";
|
|
74
|
+
const plan = (j.user?.membership?.level || "").replace("LEVEL_", "").toLowerCase() || "coding";
|
|
75
|
+
return { remainingPct, plan, resetTime: j.usage?.resetTime || w?.detail?.resetTime || null, detail };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
provider: "zai", label: "Z.ai (GLM)", kind: "quota", match: ["zai", "glm", "zhipu"], envKeys: ["ZAI_API_KEY", "GLM_API_KEY"],
|
|
80
|
+
async fetch(key) {
|
|
81
|
+
// international Z.ai; coding-plan quota lives at undocumented monitor endpoints (used by their own UI).
|
|
82
|
+
const j = await getJSON("https://api.z.ai/api/monitor/usage/quota/limit", key);
|
|
83
|
+
const limits = j.data?.limits || [];
|
|
84
|
+
const tokens = limits.filter(l => l.type === "TOKENS_LIMIT");
|
|
85
|
+
// headline = the most-consumed token window (lowest remaining %)
|
|
86
|
+
const head = [...tokens].sort((a, b) => (b.percentage || 0) - (a.percentage || 0))[0] || tokens[0];
|
|
87
|
+
const remainingPct = head ? Math.max(0, 100 - (num(head.percentage) || 0)) : null;
|
|
88
|
+
let plan = j.data?.level ? `GLM ${String(j.data.level).toUpperCase()}` : "GLM coding";
|
|
89
|
+
try { const sub = await getJSON("https://api.z.ai/api/biz/subscription/list", key); const p = (sub.data || []).find(x => x.status === "VALID") || (sub.data || [])[0]; if (p?.productName) plan = p.productName; } catch {}
|
|
90
|
+
return { remainingPct, plan, resetTime: head?.nextResetTime || null, detail: "" };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// thresholds: prepaid by currency, quota by percent-remaining. Override via config.json `lowBalance`
|
|
96
|
+
// (currency keys) and `lowQuotaPct`.
|
|
97
|
+
export const DEFAULT_LOW = { USD: 5, CNY: 35, EUR: 5 };
|
|
98
|
+
export const DEFAULT_LOW_QUOTA_PCT = 15;
|
|
99
|
+
|
|
100
|
+
export function isLow(entry, thresholds = DEFAULT_LOW, quotaPct = DEFAULT_LOW_QUOTA_PCT) {
|
|
101
|
+
if (!entry || !entry.ok) return false;
|
|
102
|
+
if (entry.kind === "quota") return entry.remainingPct != null && entry.remainingPct < quotaPct;
|
|
103
|
+
if (entry.remaining == null) return false; // prepaid unlimited/unknown
|
|
104
|
+
const t = thresholds[entry.currency] ?? thresholds.USD ?? 5;
|
|
105
|
+
return entry.remaining < t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fetch credit ONLY for the providers the user configured in `trantor profile` (opts.only = the set of
|
|
109
|
+
// profile provider names). An adapter runs only if it serves a configured provider AND its key is in the
|
|
110
|
+
// env — so a stray OPENROUTER_API_KEY in a dev's .env is NOT reported unless they actually run OpenRouter
|
|
111
|
+
// through Trantor. If `only` is omitted (no profile yet), nothing is fetched — better empty than wrong.
|
|
112
|
+
export async function fetchBalances(env = process.env, opts = {}) {
|
|
113
|
+
const only = Array.isArray(opts.only) ? new Set(opts.only.map((s) => String(s).toLowerCase())) : null;
|
|
114
|
+
const jobs = ADAPTERS.map(async (a) => {
|
|
115
|
+
const names = (a.match || [a.provider]).map((s) => s.toLowerCase());
|
|
116
|
+
if (!only || !names.some((n) => only.has(n))) return null; // not a Trantor-configured provider → skip
|
|
117
|
+
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])) }; }
|
|
121
|
+
catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
|
|
122
|
+
});
|
|
123
|
+
return (await Promise.all(jobs)).filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// human one-liner for a credit entry (CLI + warning line)
|
|
127
|
+
export function fmtBalance(e) {
|
|
128
|
+
if (!e.ok) return `${e.label}: ⚠ ${e.error}`;
|
|
129
|
+
if (e.kind === "quota") {
|
|
130
|
+
if (e.remainingPct == null) return `${e.label}${e.plan ? " (" + e.plan + ")" : ""}: quota unknown`;
|
|
131
|
+
const reset = e.resetTime ? ` · resets ${fmtReset(e.resetTime)}` : "";
|
|
132
|
+
return `${e.label}${e.plan ? " (" + e.plan + ")" : ""}: ${e.remainingPct}% left${reset}`;
|
|
133
|
+
}
|
|
134
|
+
const sym = e.currency === "CNY" ? "¥" : e.currency === "EUR" ? "€" : "$";
|
|
135
|
+
if (e.unlimited || e.remaining == null) return `${e.label}: ${e.kind === "prepaid" ? "no limit / unknown" : e.kind}`;
|
|
136
|
+
const amt = e.remaining.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
137
|
+
return `${e.label}: ${sym}${amt} ${e.currency} left`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fmtReset(t) {
|
|
141
|
+
const ms = typeof t === "number" ? t : Date.parse(t);
|
|
142
|
+
if (!ms || isNaN(ms)) return "";
|
|
143
|
+
const hrs = (ms - Date.now()) / 3600e3;
|
|
144
|
+
if (hrs < 0) return "soon";
|
|
145
|
+
if (hrs < 48) return `${Math.round(hrs)}h`;
|
|
146
|
+
return `${Math.round(hrs / 24)}d`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Resolve provider API keys the SAME way the crew does (bin/crew-runner.mjs:130) so the balance checker
|
|
2
|
+
// can't miss a key that the agents themselves use. The crew sources, in order, ~/.token-scrooge/.env
|
|
3
|
+
// (Scrooge's integrated keys — deepseek/opencode-routed providers) then ~/.agent-bus/.env (Trantor's own
|
|
4
|
+
// key file — wins), on top of the inherited process.env. Reading bare process.env was the bug: keys like
|
|
5
|
+
// DEEPSEEK_API_KEY live in those .env files, not necessarily in the session/launchd environment.
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
// Minimal .env parser: KEY=value / export KEY=value, strips matching quotes, ignores blanks/comments.
|
|
11
|
+
export function parseEnvFile(file) {
|
|
12
|
+
const out = {};
|
|
13
|
+
try {
|
|
14
|
+
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line || line.startsWith("#")) continue;
|
|
17
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
18
|
+
if (!m) continue;
|
|
19
|
+
let v = m[2].trim();
|
|
20
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
21
|
+
out[m[1]] = v;
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The key files Trantor/the crew read, low → high precedence (later wins), matching crew-runner.
|
|
28
|
+
export function keyFiles() {
|
|
29
|
+
return [join(homedir(), ".token-scrooge", ".env"), join(homedir(), ".agent-bus", ".env")];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// process.env overlaid by the key files (~/.agent-bus/.env wins). Use this everywhere a provider key is read.
|
|
33
|
+
export function resolveKeys(env = process.env, files = keyFiles()) {
|
|
34
|
+
let merged = { ...env };
|
|
35
|
+
for (const f of files) if (existsSync(f)) merged = { ...merged, ...parseEnvFile(f) };
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
Binary file
|
package/mcp.mjs
CHANGED
|
@@ -105,6 +105,32 @@ server.tool("relay_lesson", "Record a LESSON learned from a failure so future cr
|
|
|
105
105
|
return { content: [{ type: "text", text: r.dedup ? "lesson already recorded" : `lesson recorded (${r.count} total)` }] };
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
server.tool("relay_verify_gate", "Record a VERIFICATION GATE — a claim that MUST be independently verified before the related work ships (e.g. 'Gail breast coefficients match the published BCRAT model'). Unlike a note buried in a handoff narrative, a gate is STRUCTURED: it travels with handoffs and is shown PROMINENTLY to whoever takes over, so a safety-critical 'verify before commit' can't be skimmed past. action 'add' when you produce code whose correctness you have NOT independently proven (especially formulas/coefficients/security/data-shape); 'resolve' once you've verified it (or waived with the user); 'list' to see open gates. Defaults to THIS project.",
|
|
109
|
+
{ action: z.enum(["add", "resolve", "list"]).describe("add a gate · resolve one · list open gates"),
|
|
110
|
+
claim: z.string().optional().describe("what must be verified (required for add) — a specific, checkable claim"),
|
|
111
|
+
why: z.string().optional().describe("why it matters / the risk if it ships unverified"),
|
|
112
|
+
howToVerify: z.string().optional().describe("the concrete check that would verify it (source to cross-check, command to run)"),
|
|
113
|
+
id: z.number().optional().describe("gate id to resolve"),
|
|
114
|
+
status: z.string().optional().describe("resolve status: 'verified' (default) | 'failed' | 'waived'"),
|
|
115
|
+
note: z.string().optional().describe("resolution note (what you checked / why waived)"),
|
|
116
|
+
project: z.string().optional().describe("target project (default: this session's project)") },
|
|
117
|
+
async ({ action, claim, why, howToVerify, id, status, note, project }) => {
|
|
118
|
+
const proj = project || PROJECT;
|
|
119
|
+
if (action === "list") {
|
|
120
|
+
const { gates } = await api("GET", `/verify-gates?project=${encodeURIComponent(proj)}`);
|
|
121
|
+
if (!gates || !gates.length) return { content: [{ type: "text", text: `${proj}: no open verification gates` }] };
|
|
122
|
+
return { content: [{ type: "text", text: gates.map(g => `#${g.id} ⚠️ ${g.claim}${g.why ? ` — ${g.why}` : ""}`).join("\n") }] };
|
|
123
|
+
}
|
|
124
|
+
if (action === "resolve") {
|
|
125
|
+
if (!id) return { content: [{ type: "text", text: "id required to resolve a gate" }] };
|
|
126
|
+
const r = await api("POST", "/verify-gate", { resolve: true, id, status: status || "verified", note, project: proj, by: SESSION });
|
|
127
|
+
return { content: [{ type: "text", text: r.error ? `error: ${r.error}` : `gate #${id} resolved (${r.gate?.status || status || "verified"})` }] };
|
|
128
|
+
}
|
|
129
|
+
if (!claim) return { content: [{ type: "text", text: "claim required to add a gate" }] };
|
|
130
|
+
const r = await api("POST", "/verify-gate", { claim, why, howToVerify, project: proj, by: SESSION });
|
|
131
|
+
return { content: [{ type: "text", text: r.dedup ? `gate already open (#${r.gate.id})` : `🔒 verification gate #${r.gate.id} recorded — surfaces on every handoff until you resolve it` }] };
|
|
132
|
+
});
|
|
133
|
+
|
|
108
134
|
server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere.",
|
|
109
135
|
{ project: z.string().optional().describe("board to show (default: this session's project)") },
|
|
110
136
|
async ({ project }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.39",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-balances.mjs && node test-subagent-cost.mjs"
|
|
14
14
|
},
|
|
15
15
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
16
16
|
"files": [
|
package/ui.html
CHANGED
|
@@ -11,6 +11,7 @@ header{display:flex;align-items:center;gap:13px;padding:11px 18px;border-bottom:
|
|
|
11
11
|
.dot.off{background:var(--dim);box-shadow:none}
|
|
12
12
|
.muted{color:var(--mut)}.dim{color:var(--dim)}.spacer{flex:1}
|
|
13
13
|
.pill{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 11px;font-size:12px;color:var(--mut)}
|
|
14
|
+
.pill.lowbal{color:#ffb4b4;border-color:#5a2c2c;background:#2a1517;animation:failpulse 2.4s ease-in-out infinite}
|
|
14
15
|
main{flex:1;display:grid;grid-template-columns:var(--lw,44px) 1fr 330px;min-height:0}
|
|
15
16
|
main.learn-open{--lw:372px}
|
|
16
17
|
.boards{overflow-y:auto;padding:16px 18px}
|
|
@@ -89,6 +90,7 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
89
90
|
.diff.easy{background:#13302b;color:#2dd4bf}.diff.medium{background:#2c2a17;color:#f5d90b}.diff.hard{background:#33181f;color:#ff7a90}
|
|
90
91
|
.bounce{display:inline-block;font-size:9.5px;font-weight:700;color:#ef6a6a;background:#2a1517;border:1px solid #5a2c2c;border-radius:8px;padding:1px 6px;margin-left:5px}
|
|
91
92
|
.mdl{font-size:9px;font-family:ui-monospace,monospace;color:#8fa3be;background:#16202f;border:1px solid #243049;border-radius:7px;padding:1px 5px;margin-left:4px;vertical-align:1px}
|
|
93
|
+
.xcount{font-size:9px;font-weight:800;color:#cbd5e1;background:#1c2740;border:1px solid #2c3a52;border-radius:8px;padding:1px 5px;margin-left:5px;vertical-align:1px}
|
|
92
94
|
.tcard.done{opacity:.65}.tcard.done span{text-decoration:line-through}
|
|
93
95
|
.empty{color:var(--dim);text-align:center;padding:30px}
|
|
94
96
|
.empty.big{padding:60px 20px;font-size:15px}
|
|
@@ -269,6 +271,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
269
271
|
<span class="pill" id="hub">—</span>
|
|
270
272
|
<span class="spacer"></span>
|
|
271
273
|
<span class="pill" id="econ" title="Trantor savings vs frontier models — lifetime running total + last 24h (from the Scrooge ledger)" style="display:none"></span>
|
|
274
|
+
<span class="pill" id="credits" title="Provider credit left — $ balance (prepaid) or % quota (coding plans). Refill/pace before a build stalls. Pushed by sessions/CLI (the hub has no keys)." style="display:none"></span>
|
|
272
275
|
<span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
|
|
273
276
|
</header>
|
|
274
277
|
<main>
|
|
@@ -293,6 +296,10 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
293
296
|
<script>
|
|
294
297
|
const $=s=>document.querySelector(s);
|
|
295
298
|
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
299
|
+
// money: thousands separators + 2dp, readable as dollar amounts ($131,965.50). `usd()` returns just the
|
|
300
|
+
// number; `$usd()` prefixes the currency symbol.
|
|
301
|
+
const usd=(n,dp=2)=>(Number(n)||0).toLocaleString('en-US',{minimumFractionDigits:dp,maximumFractionDigits:dp});
|
|
302
|
+
const $usd=(n,sym='$',dp=2)=>sym+usd(n,dp);
|
|
296
303
|
const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked']];
|
|
297
304
|
let nmsg=0;
|
|
298
305
|
|
|
@@ -325,10 +332,17 @@ function econNotional(win){
|
|
|
325
332
|
const ck=ECON&&ECON.costKinds&&ECON.costKinds[win]; if(!ck)return 0;
|
|
326
333
|
return ((ck['subagent-notional']&&ck['subagent-notional'].usd)||0)+((ck['orchestrator-notional']&&ck['orchestrator-notional'].usd)||0);
|
|
327
334
|
}
|
|
335
|
+
// invocation count behind the notional figure — lets the pill stay visible (rebuilding) at $0 after a
|
|
336
|
+
// reset, instead of vanishing, since there's still recorded sub-agent activity.
|
|
337
|
+
function econNotionalCount(win){
|
|
338
|
+
const ck=ECON&&ECON.costKinds&&ECON.costKinds[win]; if(!ck)return 0;
|
|
339
|
+
return ((ck['subagent-notional']&&ck['subagent-notional'].count)||0)+((ck['orchestrator-notional']&&ck['orchestrator-notional'].count)||0);
|
|
340
|
+
}
|
|
328
341
|
function renderEcon(){
|
|
329
342
|
const lifeCalls=(ECON&&ECON.lifetime&&ECON.lifetime.calls)||0;
|
|
330
343
|
const notionalLife=ECON?econNotional('lifetime'):0;
|
|
331
|
-
|
|
344
|
+
const notionalCnt=ECON?econNotionalCount('lifetime'):0;
|
|
345
|
+
if(!ECON||(!lifeCalls&&!notionalCnt&&!(notionalLife>0)))return; // show once we have real savings OR any CC sub-agent activity
|
|
332
346
|
const el=$('#econ'); el.style.display='';
|
|
333
347
|
// lifetime running total is the fixed headline; the dropdown picks the comparison window.
|
|
334
348
|
if(!el.dataset.built){
|
|
@@ -343,12 +357,12 @@ function renderEcon(){
|
|
|
343
357
|
el.dataset.built='1';
|
|
344
358
|
}
|
|
345
359
|
const w=(ECON.windows&&ECON.windows[econWin])||ECON.scrooge, wc=w?w.calls:0;
|
|
346
|
-
$('#econlife').textContent
|
|
347
|
-
$('#econwinval').textContent='
|
|
360
|
+
$('#econlife').textContent=$usd(econSaved(ECON.lifetime));
|
|
361
|
+
$('#econwinval').textContent=' '+$usd(econSaved(w))+' ('+wc+' call'+(wc===1?'':'s')+')';
|
|
348
362
|
// separate notional line — distinct icon + "notional" wording, never added to the savings number
|
|
349
363
|
const nEl=$('#econnotional');
|
|
350
|
-
if(nEl) nEl.innerHTML = notionalLife>0
|
|
351
|
-
? `<span style="opacity:.5"> · </span>🤖 CC sub-agents <b style="color:var(--blu)"
|
|
364
|
+
if(nEl) nEl.innerHTML = (notionalLife>0||notionalCnt>0)
|
|
365
|
+
? `<span style="opacity:.5"> · </span>🤖 CC sub-agents <b style="color:var(--blu)">${$usd(notionalLife)}</b> <span style="opacity:.7">notional${notionalLife<=0&¬ionalCnt>0?' · rebuilding':''}</span>`
|
|
352
366
|
: '';
|
|
353
367
|
const sel=$('#econsel'); if(sel)sel.value=econWin;
|
|
354
368
|
}
|
|
@@ -360,6 +374,42 @@ async function econ(){
|
|
|
360
374
|
}catch(_){}
|
|
361
375
|
}
|
|
362
376
|
econ();setInterval(econ,15000);
|
|
377
|
+
async function credits(){
|
|
378
|
+
try{
|
|
379
|
+
const b=await (await fetch('/balances')).json();
|
|
380
|
+
const el=$('#credits'); if(!el)return;
|
|
381
|
+
const all=(b.entries||[]);
|
|
382
|
+
if(!all.length){el.style.display='none';return;}
|
|
383
|
+
el.style.display='';
|
|
384
|
+
const sym=c=>c==='CNY'?'¥':c==='EUR'?'€':'$';
|
|
385
|
+
const lbl=e=>e.label.replace(/ \(.*/,'');
|
|
386
|
+
const cap=s=>String(s||'').replace(/^./,c=>c.toUpperCase());
|
|
387
|
+
const txt=e=>e.kind==='quota'
|
|
388
|
+
? `${lbl(e)} ${e.remainingPct==null?'?':e.remainingPct+'%'}`
|
|
389
|
+
: `${lbl(e)} ${e.remaining==null?'∞':$usd(e.remaining,sym(e.currency))}`;
|
|
390
|
+
const metered=all.filter(e=>e.kind==='prepaid'||e.kind==='quota');
|
|
391
|
+
const subs=all.filter(e=>e.kind==='subscription');
|
|
392
|
+
const ok=metered.filter(e=>e.ok && (e.remaining!=null||e.remainingPct!=null));
|
|
393
|
+
const lows=ok.filter(e=>e.low);
|
|
394
|
+
const errs=metered.filter(e=>!e.ok).map(lbl);
|
|
395
|
+
const staleTag=b.stale?' · <span style="opacity:.6">stale</span>':'';
|
|
396
|
+
// subscriptions (claude/codex/gemini) have no balance to refill — show them dimmed so the configured
|
|
397
|
+
// crew is fully visible, just not as $ figures.
|
|
398
|
+
const subTxt=subs.length?` <span style="opacity:.5">· ${subs.map(e=>cap(lbl(e))+(e.plan?' ('+e.plan+')':'')).join(' · ')} (sub)</span>`:'';
|
|
399
|
+
if(lows.length){
|
|
400
|
+
el.classList.add('lowbal');
|
|
401
|
+
const rest=ok.filter(e=>!e.low);
|
|
402
|
+
el.innerHTML=`🔴 <b>low:</b> ${lows.map(txt).join(' · ')}`+(rest.length?` <span style="opacity:.7">| ${rest.map(txt).join(' · ')}</span>`:'')+subTxt+staleTag;
|
|
403
|
+
} else if(ok.length){
|
|
404
|
+
el.classList.remove('lowbal');
|
|
405
|
+
el.innerHTML=`💳 ${ok.map(txt).join(' · ')}`+(errs.length?` <span style="opacity:.55">· ${errs.join(',')} ?</span>`:'')+subTxt+staleTag;
|
|
406
|
+
} else {
|
|
407
|
+
el.classList.remove('lowbal');
|
|
408
|
+
el.innerHTML=`💳 <span style="opacity:.6">${subs.length?'metered: none':'no credit reporting'}${errs.length?' ('+errs.join(',')+' ?)':''}</span>`+subTxt;
|
|
409
|
+
}
|
|
410
|
+
}catch(_){}
|
|
411
|
+
}
|
|
412
|
+
credits();setInterval(credits,30000);
|
|
363
413
|
function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
|
|
364
414
|
const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
|
|
365
415
|
let HISTORY = {};
|
|
@@ -386,6 +436,8 @@ function flowHTML(pt, proj){
|
|
|
386
436
|
if (!data.phases || !data.phases.length) return '<div class="pflowwrap"><div class="empty">no cards yet</div></div>';
|
|
387
437
|
const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
|
|
388
438
|
const NW = 168, NH = 34, RG = 11, CG = 64, PGAP = 76, MT = 64, MB = 22;
|
|
439
|
+
const MAXPC = 10; // max nodes stacked per column before collapsing into a "+N more" node
|
|
440
|
+
let pi0 = 0; // running id base so each phase's overflow nodes get unique negative ids
|
|
389
441
|
const stackH = n => n*NH + Math.max(0,n-1)*RG;
|
|
390
442
|
// Per-phase layout: cards spread by their INTRA-PHASE dependency depth. Independent cards share
|
|
391
443
|
// column 0 (parallel, hanging off the plan node); a card that depends on another lands one column
|
|
@@ -403,16 +455,30 @@ function flowHTML(pt, proj){
|
|
|
403
455
|
};
|
|
404
456
|
cards.forEach(c => c._d = depth(c));
|
|
405
457
|
const maxDepth = Math.max(0, ...cards.map(c => c._d));
|
|
406
|
-
|
|
458
|
+
let cols = []; for (let d = 0; d <= maxDepth; d++) cols[d] = cards.filter(c => c._d === d);
|
|
407
459
|
const hasChild = c => cards.some(o => parentsOf(o).includes(c.id));
|
|
408
|
-
|
|
460
|
+
// DEFENSIVE CAP: never stack more than MAXPC nodes in one column. A phase that legitimately (or
|
|
461
|
+
// through a bug — see the cc-subagent dedup) has hundreds of independent cards would otherwise make
|
|
462
|
+
// the whole canvas thousands of px tall. Collapse the overflow into one "+N more" summary node.
|
|
463
|
+
cols = cols.map((col, d) => {
|
|
464
|
+
if (col.length <= MAXPC) return col;
|
|
465
|
+
const shown = col.slice(0, MAXPC - 1);
|
|
466
|
+
const rest = col.slice(MAXPC - 1);
|
|
467
|
+
const runs = rest.reduce((n, c) => n + (c.count || 1), 0);
|
|
468
|
+
shown.push({ id: -(pi0 + d + 1), title: `+${rest.length} more · ${runs} run${runs > 1 ? 's' : ''}`,
|
|
469
|
+
status: rest[0].status, agent: '', synthetic: true, deps: [], _d: d, _overflow: true });
|
|
470
|
+
return shown;
|
|
471
|
+
});
|
|
472
|
+
pi0 += maxDepth + 1;
|
|
473
|
+
const displayCards = cols.flat();
|
|
474
|
+
return { ph, cards: displayCards, byId, parentsOf, hasChild, maxDepth, cols };
|
|
409
475
|
});
|
|
410
476
|
const maxColCount = Math.max(1, ...layouts.flatMap(L => L.cols.map(c => c.length)));
|
|
411
477
|
const Y0 = MT + stackH(maxColCount)/2; // shared horizontal spine midline
|
|
412
478
|
const totalH = MT + stackH(maxColCount) + MB;
|
|
413
479
|
const gnode = (x, y, title, status, id, orch, agent, cost) => {
|
|
414
480
|
const stripe = SCOL[status] || '#3a4458';
|
|
415
|
-
const c = (typeof cost === 'number') ? (cost < 0.005 ? '$' + cost.toFixed(4) :
|
|
481
|
+
const c = (typeof cost === 'number') ? (cost < 0.005 ? '$' + cost.toFixed(4) : $usd(cost)) : '';
|
|
416
482
|
return `<g class="gnode ${status}${orch?' orch':''}"${id?` data-id="${id}"`:''}>`
|
|
417
483
|
+ `<rect class="gnbox" x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>`
|
|
418
484
|
+ `<rect x="${x}" y="${y}" width="4" height="${NH}" rx="2" fill="${stripe}"/>`
|
|
@@ -452,7 +518,9 @@ function flowHTML(pt, proj){
|
|
|
452
518
|
const p = pos[c.id], parents = parentsOf(c);
|
|
453
519
|
if (parents.length) for (const pid of parents){ const pp = pos[pid]; if (pp) svg += gedge(pp.x+NW, pp.y+NH/2, p.x, p.y+NH/2, byId[pid].status==='done'); }
|
|
454
520
|
else svg += gedge(planX+NW, planY+NH/2, p.x, p.y+NH/2, ph.status==='done'); // root → plan
|
|
455
|
-
|
|
521
|
+
const idPrefix = (c.id > 0 && !c.synthetic) ? '#'+c.id+' ' : '';
|
|
522
|
+
const cnt = (c.count > 1) ? ' ×'+c.count : '';
|
|
523
|
+
svg += gnode(p.x, p.y, idPrefix+c.title+cnt, c.status, c.synthetic?null:c.id, !!c._orch, c.agent||'', c.costUsd);
|
|
456
524
|
if (!hasChild(c)) svg += gedge(p.x+NW, p.y+NH/2, intX, intY+NH/2, c.status==='done'); // leaf → integrate
|
|
457
525
|
}
|
|
458
526
|
svg += gnode(intX, intY, '◆ integrate', ph.status, null, true, '');
|
|
@@ -711,7 +779,7 @@ async function render(){
|
|
|
711
779
|
const rank={todo:0,doing:1,testing:2,done:3};
|
|
712
780
|
const bounced=last&&last.from!==undefined&&rank[last.to]<rank[last.from]&&(Date.now()-last.ts)<10*60*1000;
|
|
713
781
|
const trail=hist.map(h=>`${h.from?h.from+'→':''}${h.to}${h.by?' by '+String(h.by).split(':')[0]:''} @${new Date(h.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`).join('\n');
|
|
714
|
-
return `<div class="tcard ${t.status}" data-id="${t.id}" title="${esc(trail)||'click to advance'}"><span>${esc(t.title)}</span>${t.difficulty?`<span class="diff ${t.difficulty}">${t.difficulty[0].toUpperCase()}</span>`:''}${t.model?`<span class="mdl">${esc(t.model)}</span>`:''}${bounced?`<span class="bounce">↩ bounced${last.by?' by '+esc(String(last.by).split(':')[0]):''}</span>`:''}${t.assignee?`<div class="who">${iconFor(t.assignee,12)}@${esc(t.assignee)}</div>`:''}</div>`;
|
|
782
|
+
return `<div class="tcard ${t.status}" data-id="${t.id}" title="${esc(trail)||'click to advance'}"><span>${esc(t.title)}</span>${t.count>1?`<span class="xcount">×${t.count}</span>`:''}${t.difficulty?`<span class="diff ${t.difficulty}">${t.difficulty[0].toUpperCase()}</span>`:''}${t.model?`<span class="mdl">${esc(t.model)}</span>`:''}${bounced?`<span class="bounce">↩ bounced${last.by?' by '+esc(String(last.by).split(':')[0]):''}</span>`:''}${t.assignee?`<div class="who">${iconFor(t.assignee,12)}@${esc(t.assignee)}</div>`:''}</div>`;
|
|
715
783
|
})()).join('')}</div>`;
|
|
716
784
|
}).join('');
|
|
717
785
|
const ph=p.phase||'';
|