trantor 0.17.20 → 0.17.22
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/bin/baton-close.mjs +54 -0
- package/bin/handoff-prompt.sh +6 -4
- package/hooks/handoff-now.mjs +14 -10
- package/hooks/heartbeat.mjs +7 -3
- package/hooks/lib/handoff.mjs +54 -3
- package/hooks/precompact.mjs +7 -3
- package/hub.mjs +34 -1
- 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.22"
|
|
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.22",
|
|
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.22",
|
|
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": {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor baton-close — the second half of the baton pass. Runs DETACHED, armed by the handoff hook.
|
|
3
|
+
// Waits until the FRESH session has consumed the handoff (consumed:true on the handoff file = it
|
|
4
|
+
// started and loaded the context), THEN closes the ORIGINAL session's Terminal window — so you're never
|
|
5
|
+
// left with two live sessions on one project, and never a gap where neither is alive. Defensive: aborts
|
|
6
|
+
// if the fresh session never shows (timeout), and re-validates the window's tty before closing so it can
|
|
7
|
+
// NEVER close the wrong window. Args: <handoffFile> <originalWindowId> <originalTty>
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const [, , handoffFile, windowId, originalTty] = process.argv;
|
|
12
|
+
const POLL_MS = 1500, TIMEOUT_MS = 120_000;
|
|
13
|
+
|
|
14
|
+
const consumed = () => { try { return JSON.parse(readFileSync(handoffFile, "utf8")).consumed === true; } catch { return false; } };
|
|
15
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
function ttyOfWindow(id) {
|
|
18
|
+
try {
|
|
19
|
+
return execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to get tty of selected tab of (first window whose id is ${id})`)}`,
|
|
20
|
+
{ encoding: "utf8", timeout: 3000 }).trim();
|
|
21
|
+
} catch { return ""; }
|
|
22
|
+
}
|
|
23
|
+
function closeWindow(id, tty) {
|
|
24
|
+
// re-validate: only close if the window is STILL on the original tty (never close a re-used/wrong window)
|
|
25
|
+
const cur = ttyOfWindow(id);
|
|
26
|
+
if (!cur || (originalTty && cur !== originalTty)) {
|
|
27
|
+
process.stderr.write(`[trantor] baton-close: window ${id} tty changed (${cur} != ${originalTty}) — NOT closing\n`);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
// SIGKILL the processes on that tty first (claude traps SIGTERM; a live login makes close() pop a dialog)
|
|
32
|
+
const dev = (tty || cur).replace(/^\/dev\//, "");
|
|
33
|
+
for (const pid of execSync(`ps -t ${dev} -o pid= 2>/dev/null || true`, { encoding: "utf8" }).trim().split("\n").filter(Boolean)) {
|
|
34
|
+
try { execSync(`kill -9 ${pid.trim()} 2>/dev/null || true`); } catch {}
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
try { execSync(`osascript -e ${JSON.stringify(`tell application "Terminal" to close (first window whose id is ${id})`)}`, { timeout: 3000 }); return true; } catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
(async () => {
|
|
41
|
+
if (!handoffFile || !windowId) process.exit(0);
|
|
42
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
if (consumed()) {
|
|
45
|
+
await sleep(2500); // let the fresh session settle (register, inject) before we pull the original
|
|
46
|
+
const ok = closeWindow(windowId, originalTty);
|
|
47
|
+
process.stderr.write(`[trantor] baton-close: fresh session took over → original window ${windowId} ${ok ? "closed" : "left (validation/close failed)"}\n`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
await sleep(POLL_MS);
|
|
51
|
+
}
|
|
52
|
+
process.stderr.write(`[trantor] baton-close: fresh session never confirmed within ${TIMEOUT_MS / 1000}s — leaving the original alive (safe)\n`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
})();
|
package/bin/handoff-prompt.sh
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
# Agent to spawn = $AGENT_CMD (default "claude") — same agent, fresh window.
|
|
8
8
|
DIR="${1:-$HOME}"
|
|
9
9
|
TIMEOUT="${2:-25}"
|
|
10
|
-
|
|
10
|
+
# The fresh session SELF-ANNOUNCES: it opens already recapping the handoff it took over, so it's never
|
|
11
|
+
# a confusing empty prompt. Single-quoted so it survives osascript->shell with no escaping (no apostrophes).
|
|
12
|
+
AGENT_CMD="${AGENT_CMD:-claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'}"
|
|
11
13
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
14
|
NAME="$(basename "$DIR")"
|
|
13
15
|
|
|
14
|
-
MSG="trantor —
|
|
16
|
+
MSG="trantor — context is at ~90% on $NAME. Hand off to a FRESH full-window session now? It loads this session's handoff and continues; this window then closes (baton pass). Cancel to keep working here."
|
|
15
17
|
|
|
16
18
|
# Best-effort timed dialog. On timeout, error, or no UI session -> empty -> we spawn (the default).
|
|
17
|
-
CHOICE="$(osascript -e "button returned of (display dialog \"${MSG//\"/\\\"}\" buttons {\"Keep
|
|
19
|
+
CHOICE="$(osascript -e "button returned of (display dialog \"${MSG//\"/\\\"}\" buttons {\"Keep working here\", \"Hand off\"} default button \"Hand off\" giving up after $TIMEOUT with title \"trantor\")" 2>/dev/null)"
|
|
18
20
|
|
|
19
|
-
if [ "$CHOICE" != "Keep
|
|
21
|
+
if [ "$CHOICE" != "Keep working here" ]; then
|
|
20
22
|
"$HERE/open-session.sh" "$DIR" "$AGENT_CMD"
|
|
21
23
|
fi
|
package/hooks/handoff-now.mjs
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor — detached
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
2
|
+
// trantor — detached BATON-PASS worker. The PostToolUse heartbeat spawns this when a session crosses
|
|
3
|
+
// its context warn threshold (~90% of a known window). It writes a whole-session handoff (narrative +
|
|
4
|
+
// verbatim in-flight tail), spawns a FRESH session to take over, and arms the baton-close watcher which
|
|
5
|
+
// — once the fresh session has consumed the handoff — closes THIS (original) session's Terminal window.
|
|
6
|
+
// The heavy scrooge summary runs here, detached, so it never blocks a tool call. The original's window
|
|
7
|
+
// id + tty are detected by the heartbeat (which has the controlling tty) and passed in as args.
|
|
8
|
+
// Args: <projectDir> <sessionId> <transcriptPath> [trigger] [originalWindowId] [originalTty]
|
|
9
|
+
import { readConfig, writeHandoff, pingBus, maybeSpawn, armBatonClose } from "./lib/handoff.mjs";
|
|
9
10
|
import { basename } from "node:path";
|
|
10
11
|
|
|
11
|
-
const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
|
|
12
|
+
const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn", windowId = "", tty = ""] = process.argv;
|
|
12
13
|
try {
|
|
13
14
|
const conf = readConfig();
|
|
14
15
|
const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
|
|
15
|
-
process.stderr.write(`[trantor]
|
|
16
|
-
// notify the bus so a watcher knows a handoff is ready — but do NOT spawn a window here.
|
|
16
|
+
process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
|
|
17
17
|
await pingBus(basename(projectDir), record.id, conf);
|
|
18
|
+
if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
|
|
19
|
+
const armed = windowId ? armBatonClose(file, windowId, tty, conf) : false; // close the original once fresh confirms
|
|
20
|
+
process.stderr.write(`[trantor] fresh session spawned${armed ? ` · baton-close armed for window ${windowId}` : " · no original-window close (none detected / disabled)"}\n`);
|
|
21
|
+
}
|
|
18
22
|
} catch (e) {
|
|
19
23
|
process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
|
|
20
24
|
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { join, basename, dirname } from "node:path";
|
|
|
18
18
|
import { homedir, hostname } from "node:os";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { readConfig, contextUsage, warnFrac, alreadyHandedOff } from "./lib/handoff.mjs";
|
|
21
|
+
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, controllingTty, terminalWindowForTty } from "./lib/handoff.mjs";
|
|
22
22
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
23
|
|
|
24
24
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
@@ -56,8 +56,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
56
56
|
try { writeFileSync(inflight, String(Date.now())); } catch {}
|
|
57
57
|
|
|
58
58
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
|
|
60
|
+
// won't) so the baton-close can replace this exact window once the fresh session takes over.
|
|
61
|
+
const tty = controllingTty();
|
|
62
|
+
const windowId = tty ? terminalWindowForTty(tty) : "";
|
|
63
|
+
process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% of ${usage.window} — baton pass (window ${windowId || "?"})\n`);
|
|
64
|
+
const child = spawn(process.execPath, [join(HERE, "handoff-now.mjs"), projectDir, sessionId, transcript, "context-warn", windowId, tty],
|
|
61
65
|
{ detached: true, stdio: "ignore" });
|
|
62
66
|
child.unref();
|
|
63
67
|
} catch {}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -75,8 +75,8 @@ export function resolveWindow(model = "", conf = readConfig()) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function warnFrac(conf = readConfig()) {
|
|
78
|
-
const f = Number(process.env.RELAY_CONTEXT_WARN_FRAC || conf.contextWarnFrac || 0.
|
|
79
|
-
return f > 0 && f < 1 ? f : 0.
|
|
78
|
+
const f = Number(process.env.RELAY_CONTEXT_WARN_FRAC || conf.contextWarnFrac || 0.90);
|
|
79
|
+
return f > 0 && f < 1 ? f : 0.90; // baton pass fires at 90% — runway to summarize + hand off before the wall
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// ---- per-session guard (shared by both paths) -------------------------------
|
|
@@ -176,6 +176,13 @@ export function buildSummary(transcriptPath) {
|
|
|
176
176
|
return `*(no summarizer available — representative transcript digest)*\n\n${convo.slice(-12000)}`;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// The exact recent exchange, VERBATIM (not summarized/sampled) — so a baton-pass handoff carries the
|
|
180
|
+
// precise in-flight state (e.g. the live cs_live_… URL, the exact decision point) even if the scrooge
|
|
181
|
+
// narrative times out on a huge transcript. This is what lets the fresh session truly continue, not guess.
|
|
182
|
+
export function verbatimRecentTail(transcript, chars = 7000) {
|
|
183
|
+
try { return collectTurns(transcript).join("\n\n").slice(-chars); } catch { return ""; }
|
|
184
|
+
}
|
|
185
|
+
|
|
179
186
|
// ---- write + announce + spawn ----------------------------------------------
|
|
180
187
|
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary }) {
|
|
181
188
|
const projectName = basename(projectDir);
|
|
@@ -183,12 +190,15 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
183
190
|
const stamp = nowSec() || Date.now();
|
|
184
191
|
let gitStatus = "";
|
|
185
192
|
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
193
|
+
const narrative = summary ?? buildSummary(transcript);
|
|
194
|
+
const tail = verbatimRecentTail(transcript);
|
|
186
195
|
const record = {
|
|
187
196
|
id: `${projectName}-${stamp}`,
|
|
188
197
|
project: projectDir, projectName, machine: hostname(),
|
|
189
198
|
session_id: sessionId || "", trigger: trigger || "auto",
|
|
190
199
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
191
|
-
|
|
200
|
+
// narrative + a verbatim recent-exchange block so exact in-flight state always survives
|
|
201
|
+
summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
|
|
192
202
|
gitStatus, consumed: false,
|
|
193
203
|
};
|
|
194
204
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
@@ -196,6 +206,47 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
196
206
|
return { file, record };
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
// --- baton pass: the original session's Terminal window (macOS), so the fresh session can replace it ---
|
|
210
|
+
// Walk the process tree to the controlling tty (the hook itself may show "??" but its parent claude
|
|
211
|
+
// owns the Terminal's tty). Returns "/dev/ttysNNN" or "".
|
|
212
|
+
export function controllingTty() {
|
|
213
|
+
for (const pid of [process.pid, process.ppid, getPpid(process.ppid)]) {
|
|
214
|
+
if (!pid) continue;
|
|
215
|
+
try { const t = execSync(`ps -o tty= -p ${pid}`, { encoding: "utf8" }).trim(); if (t && t !== "??" && t !== "?") return "/dev/" + t; } catch {}
|
|
216
|
+
}
|
|
217
|
+
return "";
|
|
218
|
+
}
|
|
219
|
+
function getPpid(pid) { if (!pid) return 0; try { return Number(execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
|
|
220
|
+
|
|
221
|
+
// Find the Terminal.app window id whose selected tab is on `tty` — the window to close on takeover.
|
|
222
|
+
export function terminalWindowForTty(tty) {
|
|
223
|
+
if (process.platform !== "darwin" || !tty) return "";
|
|
224
|
+
const osa = `tell application "Terminal"
|
|
225
|
+
repeat with w in windows
|
|
226
|
+
try
|
|
227
|
+
if (tty of selected tab of w) is "${tty}" then return (id of w) as string
|
|
228
|
+
end try
|
|
229
|
+
end repeat
|
|
230
|
+
return ""
|
|
231
|
+
end tell`;
|
|
232
|
+
try { return execSync(`osascript -e ${JSON.stringify(osa)}`, { encoding: "utf8", timeout: 3000 }).trim(); } catch { return ""; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Arm the baton-close watcher: a DETACHED process that waits until the fresh session consumes the
|
|
236
|
+
// handoff (consumed:true), then closes the original Terminal window. Never closes blind: aborts on
|
|
237
|
+
// timeout (fresh never showed) and re-validates the window's tty before closing.
|
|
238
|
+
export function armBatonClose(handoffFile, originalWindowId, originalTty, conf = readConfig()) {
|
|
239
|
+
try {
|
|
240
|
+
if (process.platform !== "darwin" || !originalWindowId) return false;
|
|
241
|
+
if (process.env.TRANTOR_NO_BATON_CLOSE === "1" || conf.batonClose === false) return false;
|
|
242
|
+
const closer = join(HERE, "..", "..", "bin", "baton-close.mjs");
|
|
243
|
+
if (!existsSync(closer)) return false;
|
|
244
|
+
const child = spawn(process.execPath, [closer, handoffFile, String(originalWindowId), originalTty || ""], { detached: true, stdio: "ignore" });
|
|
245
|
+
child.unref();
|
|
246
|
+
return true;
|
|
247
|
+
} catch { return false; }
|
|
248
|
+
}
|
|
249
|
+
|
|
199
250
|
export async function pingBus(projectName, id, conf = readConfig()) {
|
|
200
251
|
try {
|
|
201
252
|
await fetch(`${relayUrl(conf)}/send`, {
|
package/hooks/precompact.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// full window. The new session's SessionStart hook loads the handoff. This is the
|
|
7
7
|
// at-the-wall backstop; the heartbeat hook can also fire this earlier when the
|
|
8
8
|
// context window size is known (see hooks/lib/handoff.mjs).
|
|
9
|
-
import { readConfig, writeHandoff, pingBus, maybeSpawn,
|
|
10
|
-
contextUsage, alreadyHandedOff, markHandedOff } from "./lib/handoff.mjs";
|
|
9
|
+
import { readConfig, writeHandoff, pingBus, maybeSpawn, armBatonClose,
|
|
10
|
+
contextUsage, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty } from "./lib/handoff.mjs";
|
|
11
11
|
import { basename } from "node:path";
|
|
12
12
|
|
|
13
13
|
function readStdin() {
|
|
@@ -37,7 +37,11 @@ try {
|
|
|
37
37
|
process.stderr.write(`[trantor] fresh session already spawned for this window — handoff refreshed only\n`);
|
|
38
38
|
} else if (maybeSpawn(projectDir, conf)) {
|
|
39
39
|
markHandedOff(sessionId, cur);
|
|
40
|
-
|
|
40
|
+
// baton pass: arm the close of THIS window once the fresh session takes over (we have the tty here)
|
|
41
|
+
const tty = controllingTty();
|
|
42
|
+
const windowId = tty ? terminalWindowForTty(tty) : "";
|
|
43
|
+
const armed = windowId ? armBatonClose(file, windowId, tty, conf) : false;
|
|
44
|
+
process.stderr.write(`[trantor] fresh-session spawned (PreCompact)${armed ? ` · baton-close armed for window ${windowId}` : ""}\n`);
|
|
41
45
|
}
|
|
42
46
|
} catch (err) {
|
|
43
47
|
process.stderr.write(`[trantor] precompact error: ${err?.message || err}\n`);
|
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 {
|
|
@@ -526,6 +526,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
526
526
|
// back-compat: `scrooge` is the window older dashboards read (honor ?hours= if passed)
|
|
527
527
|
out.scrooge = q.hours ? rollup(rows.filter(c => c.ts >= nowS - Number(q.hours) * 3600)) : out.windows["24h"];
|
|
528
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 {}
|
|
529
562
|
return json(res, 200, out);
|
|
530
563
|
}
|
|
531
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, '');
|