trantor 0.17.19 → 0.17.21
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/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/hooks.json +4 -1
- package/hooks/pricing.mjs +46 -0
- package/hooks/subagent-cost.mjs +111 -0
- package/hub.mjs +45 -2
- package/package.json +1 -1
- package/ui.html +22 -5
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
|
|
9
|
-
"version": "0.17.
|
|
9
|
+
"version": "0.17.21"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, Gemini, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.21",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.21",
|
|
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/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact)",
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
],
|
|
11
11
|
"PreCompact": [
|
|
12
12
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
|
|
13
|
+
],
|
|
14
|
+
"SubagentStop": [
|
|
15
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-cost.mjs" } ] }
|
|
13
16
|
]
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// trantor — Anthropic API pricing, the single source of truth for the NOTIONAL dollar cost of a
|
|
2
|
+
// Claude Code sub-agent's token usage (it's "notional" because on a Pro/Max plan those tokens are
|
|
3
|
+
// plan-covered, not billed — we still want the number). $/MTok. Re-confirm before trusting old reports.
|
|
4
|
+
export const PRICING_AS_OF = "2026-06";
|
|
5
|
+
|
|
6
|
+
// in/out = input/output; cw5m/cw1h = cache-WRITE at the 5-minute / 1-hour TTL; cr = cache-READ.
|
|
7
|
+
// (cache write 5m = 1.25× input, 1h = 2× input, read = 0.1× input — encoded directly here.)
|
|
8
|
+
export const ANTHROPIC_PRICING = {
|
|
9
|
+
opus: { in: 5, out: 25, cw5m: 6.25, cw1h: 10, cr: 0.5 },
|
|
10
|
+
sonnet: { in: 3, out: 15, cw5m: 3.75, cw1h: 6, cr: 0.3 },
|
|
11
|
+
haiku: { in: 1, out: 5, cw5m: 1.25, cw1h: 2, cr: 0.1 },
|
|
12
|
+
"opus-4-1": { in: 15, out: 75, cw5m: 18.75, cw1h: 30, cr: 1.5 }, // legacy numbering — far pricier
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Map a transcript model id ("claude-opus-4-8", "claude-sonnet-4-6", …) to a price tier, or null.
|
|
16
|
+
export function tierFor(model) {
|
|
17
|
+
const m = String(model || "").toLowerCase();
|
|
18
|
+
if (/opus-4-1\b|opus-4\.1/.test(m)) return "opus-4-1";
|
|
19
|
+
if (m.includes("opus")) return "opus";
|
|
20
|
+
if (m.includes("sonnet")) return "sonnet";
|
|
21
|
+
if (m.includes("haiku")) return "haiku";
|
|
22
|
+
return null; // unknown → caller emits costUsd:null + a costNote rather than guessing
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Notional USD for one turn's usage. Sub-agents always start a COLD cache (5m TTL) even on a
|
|
26
|
+
// subscription, so cache writes default to the 5m rate. Returns null when the model isn't priced.
|
|
27
|
+
export function costOfTurn({ model, input = 0, output = 0, cacheWrite = 0, cacheRead = 0 }, ttl = "5m") {
|
|
28
|
+
const p = ANTHROPIC_PRICING[tierFor(model)];
|
|
29
|
+
if (!p) return null;
|
|
30
|
+
const cw = ttl === "1h" ? p.cw1h : p.cw5m;
|
|
31
|
+
return (input * p.in + output * p.out + cacheWrite * cw + cacheRead * p.cr) / 1e6;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Sum a list of usage rows → { usd, tokens, unpriced, model }. usd is null only if NOTHING was priced.
|
|
35
|
+
export function notionalCost(rows, ttl = "5m") {
|
|
36
|
+
const tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
37
|
+
let usd = 0, priced = 0, unpriced = 0, model = "";
|
|
38
|
+
for (const r of rows) {
|
|
39
|
+
tokens.input += r.input || 0; tokens.output += r.output || 0;
|
|
40
|
+
tokens.cacheWrite += r.cacheWrite || 0; tokens.cacheRead += r.cacheRead || 0;
|
|
41
|
+
if (r.model) model = r.model;
|
|
42
|
+
const c = costOfTurn(r, ttl);
|
|
43
|
+
if (c == null) unpriced++; else { usd += c; priced++; }
|
|
44
|
+
}
|
|
45
|
+
return { usd: priced ? usd : null, tokens, unpriced, priced, model };
|
|
46
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor SubagentStop hook — when a Claude Code sub-agent (Agent/Task tool, Workflow swarm, ultracode,
|
|
3
|
+
// agent-team teammate) finishes, read ITS OWN transcript's token usage and post a board card tagged with
|
|
4
|
+
// the NOTIONAL API cost (what those tokens would cost at API rates — plan-covered, not billed, on a sub).
|
|
5
|
+
// No hook carries cost, so we parse the sub-agent transcript (confirmed to carry per-turn message.usage +
|
|
6
|
+
// message.model). This is the orchestrator's-own-work blind spot that crew (external CLIs) + Scrooge
|
|
7
|
+
// (real $) don't cover. Fail-silent: never break the parent session.
|
|
8
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join, basename } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
12
|
+
import { notionalCost } from "./pricing.mjs";
|
|
13
|
+
|
|
14
|
+
function readStdin() {
|
|
15
|
+
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
16
|
+
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
17
|
+
setTimeout(() => res(d), 100); });
|
|
18
|
+
}
|
|
19
|
+
function relayUrl() {
|
|
20
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
21
|
+
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 {}
|
|
22
|
+
return "http://127.0.0.1:4477";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve the sub-agent's transcript: the payload path, else reconstruct, else newest agent-*.jsonl.
|
|
26
|
+
function findTranscript(input) {
|
|
27
|
+
const direct = input.transcript_path;
|
|
28
|
+
if (direct && existsSync(direct)) return direct;
|
|
29
|
+
const sid = input.session_id, aid = input.agent_id;
|
|
30
|
+
// search the session's subagents tree (plain Task → subagents/agent-<id>.jsonl; Workflow → subagents/workflows/<wf>/agent-<id>.jsonl)
|
|
31
|
+
const roots = [];
|
|
32
|
+
try {
|
|
33
|
+
const base = join(homedir(), ".claude", "projects");
|
|
34
|
+
for (const proj of readdirSync(base)) {
|
|
35
|
+
const sdir = join(base, proj, sid || "", "subagents");
|
|
36
|
+
if (sid && existsSync(sdir)) roots.push(sdir);
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
let best = "", bestM = 0;
|
|
40
|
+
const walk = dir => { let ents = []; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
41
|
+
for (const e of ents) {
|
|
42
|
+
const p = join(dir, e.name);
|
|
43
|
+
if (e.isDirectory()) walk(p);
|
|
44
|
+
else if (e.isFile() && /^agent-.*\.jsonl$/.test(e.name)) {
|
|
45
|
+
if (aid && e.name.includes(aid)) return (best = p, bestM = Infinity); // exact id wins
|
|
46
|
+
try { const m = statSync(p).mtimeMs; if (m > bestM) { best = p; bestM = m; } } catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const r of roots) walk(r);
|
|
51
|
+
return best || "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function usageRows(file) {
|
|
55
|
+
const rows = [];
|
|
56
|
+
let firstUserText = "";
|
|
57
|
+
try {
|
|
58
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
61
|
+
if (!firstUserText && r.type === "user" && r.message) {
|
|
62
|
+
const c = r.message.content;
|
|
63
|
+
firstUserText = (typeof c === "string" ? c : Array.isArray(c) ? c.filter(b => b?.type === "text").map(b => b.text).join(" ") : "").trim();
|
|
64
|
+
}
|
|
65
|
+
const u = r?.message?.usage;
|
|
66
|
+
if (r.type === "assistant" && u) rows.push({
|
|
67
|
+
model: r.message.model || "",
|
|
68
|
+
input: u.input_tokens || 0, output: u.output_tokens || 0,
|
|
69
|
+
cacheWrite: u.cache_creation_input_tokens || 0, cacheRead: u.cache_read_input_tokens || 0,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return { rows, firstUserText };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const input = JSON.parse((await readStdin()) || "{}");
|
|
78
|
+
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
79
|
+
const project = resolveProject(cwd);
|
|
80
|
+
const agentType = String(input.agent_type || "subagent").slice(0, 40);
|
|
81
|
+
const effort = input.effort?.level || "";
|
|
82
|
+
|
|
83
|
+
const file = findTranscript(input);
|
|
84
|
+
if (!file) { process.stderr.write("[trantor] subagent-cost: no transcript found\n"); process.stdout.write("{}"); process.exit(0); }
|
|
85
|
+
const { rows, firstUserText } = usageRows(file);
|
|
86
|
+
const ttl = process.env.TRANTOR_CACHE_TTL === "1h" ? "1h" : "5m";
|
|
87
|
+
const { usd, tokens, unpriced, model } = notionalCost(rows, ttl);
|
|
88
|
+
|
|
89
|
+
const task = (firstUserText || agentType).replace(/\s+/g, " ").slice(0, 90);
|
|
90
|
+
const title = `${agentType}: ${task}`.slice(0, 180);
|
|
91
|
+
const costNote = usd == null ? "usage-unavailable-or-unpriced" : (unpriced ? `${unpriced} turn(s) unpriced` : "");
|
|
92
|
+
|
|
93
|
+
await fetch(`${relayUrl()}/task`, {
|
|
94
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
project, title, status: "done",
|
|
97
|
+
assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
|
|
98
|
+
source: "cc-subagent", costKind: "subagent-notional",
|
|
99
|
+
costUsd: usd, costNote, model, effort, tokens,
|
|
100
|
+
phase: "sub-agents",
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(2500),
|
|
103
|
+
}).catch(() => {});
|
|
104
|
+
process.stderr.write(`[trantor] subagent-cost: ${agentType} ${model} ~$${usd == null ? "?" : usd.toFixed(4)} (${tokens.input + tokens.output + tokens.cacheWrite + tokens.cacheRead} tok) → ${project}\n`);
|
|
105
|
+
// surface it back to the parent's Claude inline
|
|
106
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStop", additionalContext: `[trantor] logged sub-agent ${agentType} — notional $${usd == null ? "?" : usd.toFixed(4)}` } }));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
process.stderr.write(`[trantor] subagent-cost error: ${e?.message || e}\n`);
|
|
109
|
+
process.stdout.write("{}");
|
|
110
|
+
}
|
|
111
|
+
process.exit(0);
|
package/hub.mjs
CHANGED
|
@@ -228,7 +228,7 @@ function derivePhases(tasks) {
|
|
|
228
228
|
const phases = [...byPhase.entries()].map(([key, cards]) => {
|
|
229
229
|
const counts = { todo:0, doing:0, testing:0, failed:0, done:0, blocked:0 };
|
|
230
230
|
for (const c of cards) counts[c.status] = (counts[c.status] || 0) + 1;
|
|
231
|
-
const node = (c) => ({ id: c.id, title: c.title, assignee: c.assignee || "", agent: agentBrand(c.assignee), model: c.model || "", status: c.status, difficulty: c.difficulty || "", ts: c.ts || 0, updated: c.updated || c.ts || 0, deps: Array.isArray(c.deps) ? c.deps : [] });
|
|
231
|
+
const node = (c) => ({ id: c.id, title: c.title, assignee: c.assignee || "", agent: agentBrand(c.assignee), model: c.model || "", status: c.status, difficulty: c.difficulty || "", ts: c.ts || 0, updated: c.updated || c.ts || 0, deps: Array.isArray(c.deps) ? c.deps : [], costKind: c.costKind || "", costUsd: (typeof c.costUsd === "number") ? c.costUsd : null, source: c.source || "" });
|
|
232
232
|
const crew = cards.filter(c => !isOrchAssignee(c.assignee)).map(node);
|
|
233
233
|
const orchestrators = cards.filter(c => isOrchAssignee(c.assignee)).map(node);
|
|
234
234
|
return {
|
|
@@ -262,7 +262,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
262
262
|
const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
|
|
263
263
|
assignee: b.assignee || "", status: st0,
|
|
264
264
|
phase: String(b.phase || "").slice(0, 40), // explicit phase tag (FLOW v2) — wins over title-prefix inference
|
|
265
|
-
source: String(b.source || "").slice(0, 20), // e.g. "git" (backfill), "todo" — provenance
|
|
265
|
+
source: String(b.source || "").slice(0, 20), // e.g. "git" (backfill), "todo", "cc-subagent" — provenance
|
|
266
|
+
// economics: how this card's cost should be counted. costKind discriminates the source so the
|
|
267
|
+
// dashboard can show notional (plan-covered) vs real spend inline-but-differentiated.
|
|
268
|
+
costKind: String(b.costKind || "").slice(0, 24), // subagent-notional|orchestrator-notional|crew-subscription|scrooge-real
|
|
269
|
+
costUsd: (typeof b.costUsd === "number" && isFinite(b.costUsd)) ? b.costUsd : null,
|
|
270
|
+
costNote: String(b.costNote || "").slice(0, 80),
|
|
271
|
+
effort: String(b.effort || "").slice(0, 12),
|
|
272
|
+
tokens: (b.tokens && typeof b.tokens === "object") ? {
|
|
273
|
+
input: Number(b.tokens.input) || 0, output: Number(b.tokens.output) || 0,
|
|
274
|
+
cacheWrite: Number(b.tokens.cacheWrite) || 0, cacheRead: Number(b.tokens.cacheRead) || 0,
|
|
275
|
+
} : null,
|
|
266
276
|
difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
|
|
267
277
|
model: String(b.model || "").slice(0, 60),
|
|
268
278
|
deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
|
|
@@ -516,6 +526,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
516
526
|
// back-compat: `scrooge` is the window older dashboards read (honor ?hours= if passed)
|
|
517
527
|
out.scrooge = q.hours ? rollup(rows.filter(c => c.ts >= nowS - Number(q.hours) * 3600)) : out.windows["24h"];
|
|
518
528
|
} catch {}
|
|
529
|
+
// --- card-based costs (FLOW v2): the orchestrator's OWN work, by costKind ---
|
|
530
|
+
// NOTIONAL (Claude sub-agents/orchestrator — plan-covered) is kept STRICTLY SEPARATE from REAL
|
|
531
|
+
// spend (Scrooge). We never sum them into one headline — that would imply we paid for plan-covered
|
|
532
|
+
// tokens. Crew is subscription (no per-task $). Card ts is in ms (the scrooge ledger is in seconds).
|
|
533
|
+
try {
|
|
534
|
+
const WINDOWS_MS = { "24h": 864e5, week: 7 * 864e5, month: 30 * 864e5, quarter: 90 * 864e5, year: 365 * 864e5 };
|
|
535
|
+
const costCards = state.tasks.filter(t => t.costKind || t.costUsd != null);
|
|
536
|
+
const rollupCards = cards => {
|
|
537
|
+
const byKind = {};
|
|
538
|
+
for (const t of cards) {
|
|
539
|
+
const k = t.costKind || "other";
|
|
540
|
+
const e = byKind[k] ||= { count: 0, usd: 0, tokens_in: 0, tokens_out: 0, cache_read: 0, cache_write: 0, by_model: {}, hasUsd: false };
|
|
541
|
+
e.count++;
|
|
542
|
+
if (typeof t.costUsd === "number") { e.usd += t.costUsd; e.hasUsd = true; }
|
|
543
|
+
if (t.tokens) { e.tokens_in += t.tokens.input || 0; e.tokens_out += t.tokens.output || 0; e.cache_read += t.tokens.cacheRead || 0; e.cache_write += t.tokens.cacheWrite || 0; }
|
|
544
|
+
if (t.model) { const m = e.by_model[t.model] ||= { count: 0, usd: 0 }; m.count++; m.usd += t.costUsd || 0; }
|
|
545
|
+
}
|
|
546
|
+
for (const e of Object.values(byKind)) { e.usd = +e.usd.toFixed(4); e.usd = e.hasUsd ? e.usd : null; }
|
|
547
|
+
return byKind;
|
|
548
|
+
};
|
|
549
|
+
out.costKinds = {};
|
|
550
|
+
const nowMs = now();
|
|
551
|
+
for (const [k, ms] of Object.entries(WINDOWS_MS)) out.costKinds[k] = rollupCards(costCards.filter(t => (t.ts || 0) >= nowMs - ms));
|
|
552
|
+
out.costKinds.lifetime = rollupCards(costCards);
|
|
553
|
+
// per-project notional totals (subagent+orchestrator) so the dashboard can scope it like reliability
|
|
554
|
+
const perProject = {};
|
|
555
|
+
for (const t of costCards) {
|
|
556
|
+
if (typeof t.costUsd !== "number") continue;
|
|
557
|
+
if (t.costKind !== "subagent-notional" && t.costKind !== "orchestrator-notional") continue;
|
|
558
|
+
perProject[canon(t.project)] = +((perProject[canon(t.project)] || 0) + t.costUsd).toFixed(4);
|
|
559
|
+
}
|
|
560
|
+
out.notionalByProject = perProject;
|
|
561
|
+
} catch {}
|
|
519
562
|
return json(res, 200, out);
|
|
520
563
|
}
|
|
521
564
|
if (req.method === "GET" && P === "/lessons") {
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -189,6 +189,7 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
189
189
|
.gnode.failed .gnbox,.gnode.blocked .gnbox{stroke:var(--red)}
|
|
190
190
|
.gntext{fill:#eaf1fa;font-size:11px;font-family:ui-sans-serif,system-ui;pointer-events:none}
|
|
191
191
|
.gnsub{fill:var(--mut);font-size:9.5px;font-family:ui-sans-serif,system-ui;pointer-events:none}
|
|
192
|
+
.gncost{fill:var(--blu);font-size:9px;font-family:ui-sans-serif,system-ui;text-anchor:end;pointer-events:none}
|
|
192
193
|
.pfband{fill:#0c1320;opacity:.45}
|
|
193
194
|
.pfband.alt{fill:#0e1626;opacity:.55}
|
|
194
195
|
.pfbandtop{opacity:.12}
|
|
@@ -318,8 +319,16 @@ let ECON=null;
|
|
|
318
319
|
const ECON_WINS=[['24h','last 24h'],['week','last week'],['month','last month'],['quarter','last quarter'],['year','last year']];
|
|
319
320
|
let econWin=localStorage.getItem('abEconWin')||'24h';
|
|
320
321
|
const econSaved=x=>x?(x.saved_usd!=null?(+x.saved_usd):Math.max(0,(x.opus_equiv_usd||0)-(x.cost_usd||0))):0;
|
|
322
|
+
// notional (plan-covered) cost of the orchestrator's OWN Claude work for a window — subagent +
|
|
323
|
+
// orchestrator costKinds, summed ONLY with each other (never with real Scrooge spend).
|
|
324
|
+
function econNotional(win){
|
|
325
|
+
const ck=ECON&&ECON.costKinds&&ECON.costKinds[win]; if(!ck)return 0;
|
|
326
|
+
return ((ck['subagent-notional']&&ck['subagent-notional'].usd)||0)+((ck['orchestrator-notional']&&ck['orchestrator-notional'].usd)||0);
|
|
327
|
+
}
|
|
321
328
|
function renderEcon(){
|
|
322
|
-
|
|
329
|
+
const lifeCalls=(ECON&&ECON.lifetime&&ECON.lifetime.calls)||0;
|
|
330
|
+
const notionalLife=ECON?econNotional('lifetime'):0;
|
|
331
|
+
if(!ECON||(!lifeCalls&&!(notionalLife>0)))return; // show once we have EITHER real savings OR notional CC cost
|
|
323
332
|
const el=$('#econ'); el.style.display='';
|
|
324
333
|
// lifetime running total is the fixed headline; the dropdown picks the comparison window.
|
|
325
334
|
if(!el.dataset.built){
|
|
@@ -327,7 +336,8 @@ function renderEcon(){
|
|
|
327
336
|
`<span style="opacity:.55"> · lifetime · </span>`+
|
|
328
337
|
`<select id="econsel" title="comparison window" style="background:#1a2030;color:inherit;border:1px solid rgba(255,255,255,.18);border-radius:4px;font:inherit;font-size:11px;padding:1px 3px;cursor:pointer;outline:none">`+
|
|
329
338
|
ECON_WINS.map(([k,l])=>`<option value="${k}">${l}</option>`).join('')+`</select>`+
|
|
330
|
-
`<span style="opacity:.85" id="econwinval"></span
|
|
339
|
+
`<span style="opacity:.85" id="econwinval"></span>`+
|
|
340
|
+
`<span id="econnotional" title="Claude's OWN sub-agent work (Agent/Workflow/ultracode) at API rates — plan-covered on a subscription, NOT real spend. Shown separately so it's never confused with the real Scrooge \$ saved." style="opacity:.9;margin-left:2px"></span>`;
|
|
331
341
|
const sel=$('#econsel'); sel.value=econWin;
|
|
332
342
|
sel.onchange=()=>{econWin=sel.value; localStorage.setItem('abEconWin',econWin); renderEcon();};
|
|
333
343
|
el.dataset.built='1';
|
|
@@ -335,6 +345,11 @@ function renderEcon(){
|
|
|
335
345
|
const w=(ECON.windows&&ECON.windows[econWin])||ECON.scrooge, wc=w?w.calls:0;
|
|
336
346
|
$('#econlife').textContent='$'+econSaved(ECON.lifetime).toFixed(2);
|
|
337
347
|
$('#econwinval').textContent=' $'+econSaved(w).toFixed(2)+' ('+wc+' call'+(wc===1?'':'s')+')';
|
|
348
|
+
// separate notional line — distinct icon + "notional" wording, never added to the savings number
|
|
349
|
+
const nEl=$('#econnotional');
|
|
350
|
+
if(nEl) nEl.innerHTML = notionalLife>0
|
|
351
|
+
? `<span style="opacity:.5"> · </span>🤖 CC sub-agents <b style="color:var(--blu)">$${notionalLife.toFixed(2)}</b> <span style="opacity:.7">notional</span>`
|
|
352
|
+
: '';
|
|
338
353
|
const sel=$('#econsel'); if(sel)sel.value=econWin;
|
|
339
354
|
}
|
|
340
355
|
async function econ(){
|
|
@@ -395,14 +410,16 @@ function flowHTML(pt, proj){
|
|
|
395
410
|
const maxColCount = Math.max(1, ...layouts.flatMap(L => L.cols.map(c => c.length)));
|
|
396
411
|
const Y0 = MT + stackH(maxColCount)/2; // shared horizontal spine midline
|
|
397
412
|
const totalH = MT + stackH(maxColCount) + MB;
|
|
398
|
-
const gnode = (x, y, title, status, id, orch, agent) => {
|
|
413
|
+
const gnode = (x, y, title, status, id, orch, agent, cost) => {
|
|
399
414
|
const stripe = SCOL[status] || '#3a4458';
|
|
415
|
+
const c = (typeof cost === 'number') ? (cost < 0.005 ? '$' + cost.toFixed(4) : '$' + cost.toFixed(2)) : '';
|
|
400
416
|
return `<g class="gnode ${status}${orch?' orch':''}"${id?` data-id="${id}"`:''}>`
|
|
401
417
|
+ `<rect class="gnbox" x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>`
|
|
402
418
|
+ `<rect x="${x}" y="${y}" width="4" height="${NH}" rx="2" fill="${stripe}"/>`
|
|
403
419
|
+ `<text class="gntext" x="${x+11}" y="${y+(agent?15:23)}">${esc(title.slice(0,24))}${title.length>24?'…':''}</text>`
|
|
404
420
|
+ (agent?`<text class="gnsub" x="${x+11}" y="${y+29}">@${esc(agent)}</text>`:'')
|
|
405
|
-
+
|
|
421
|
+
+ (c?`<text class="gncost" x="${x+NW-8}" y="${y+(agent?15:23)}">${c}</text>`:'')
|
|
422
|
+
+ `<title>${esc(title)}${agent?' · @'+esc(agent):''}${c?` · ${c} notional`:''} — ${status}</title></g>`;
|
|
406
423
|
};
|
|
407
424
|
const gedge = (x1,y1,x2,y2,done) => { const mx=(x1+x2)/2;
|
|
408
425
|
return `<path class="gedge${done?' done':''}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`; };
|
|
@@ -435,7 +452,7 @@ function flowHTML(pt, proj){
|
|
|
435
452
|
const p = pos[c.id], parents = parentsOf(c);
|
|
436
453
|
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'); }
|
|
437
454
|
else svg += gedge(planX+NW, planY+NH/2, p.x, p.y+NH/2, ph.status==='done'); // root → plan
|
|
438
|
-
svg += gnode(p.x, p.y, (c.id?'#'+c.id+' ':'')+c.title, c.status, c.synthetic?null:c.id, !!c._orch, c.agent||'');
|
|
455
|
+
svg += gnode(p.x, p.y, (c.id?'#'+c.id+' ':'')+c.title, c.status, c.synthetic?null:c.id, !!c._orch, c.agent||'', c.costUsd);
|
|
439
456
|
if (!hasChild(c)) svg += gedge(p.x+NW, p.y+NH/2, intX, intY+NH/2, c.status==='done'); // leaf → integrate
|
|
440
457
|
}
|
|
441
458
|
svg += gnode(intX, intY, '◆ integrate', ph.status, null, true, '');
|