trantor 0.17.18 → 0.17.20

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.
@@ -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.18"
9
+ "version": "0.17.20"
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.18",
16
+ "version": "0.17.20",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.18",
3
+ "version": "0.17.20",
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": {
@@ -27,6 +27,7 @@ const me = `${hostId()}:${project}`;
27
27
 
28
28
  const themeOf = (s) => {
29
29
  let m;
30
+ 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)
30
31
  if ((m = s.match(/^[a-z]+\(([^)]+)\)\s*:/i))) return m[1].trim(); // feat(engine): → engine
31
32
  if ((m = s.match(/^([A-Za-z][\w &+/.]*?)\s*:/))) return m[1].trim(); // "Landing: …" → Landing
32
33
  if ((m = s.match(/^([A-Za-z][\w.+-]*)/))) return m[1]; // first word
@@ -57,7 +58,11 @@ const ents = [...groups.entries()].sort((a, b) => a[1].latest - b[1].latest);
57
58
  let posted = 0, skipped = 0;
58
59
  for (const [theme, g] of ents) {
59
60
  const latest = g.commits.sort((a, b) => b.ts - a.ts)[0];
60
- const subj = latest.subject.replace(/^[a-z]+\([^)]*\)\s*:\s*/i, "").replace(/^[A-Za-z][\w &+/.]*?:\s*/, "").slice(0, 70);
61
+ const subj = latest.subject
62
+ .replace(/^[a-z]+\([^)]*\)\s*:\s*/i, "") // strip feat(scope):
63
+ .replace(/^[A-Za-z][\w &+/.]*?:\s*/, "") // strip "release:" / "Landing:"
64
+ .replace(/^v?\d+\.\d+\.\d+\s*[—–-]\s*/, "") // strip a leading "v0.17.15 — " (release version dup)
65
+ .slice(0, 70);
61
66
  const title = `${theme}: ${subj}${g.commits.length > 1 ? ` (+${g.commits.length - 1} more)` : ""}`.slice(0, 190);
62
67
  if (existing.has(title)) { skipped++; continue; }
63
68
  if (dry) { console.log(`+ [${new Date(g.latest).toISOString().slice(0, 10)}] ${theme.padEnd(20)} ${g.commits.length}c ${title.slice(0, 64)}`); posted++; continue; }
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
@@ -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) : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.18",
3
+ "version": "0.17.20",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"