trantor 0.17.1 → 0.17.4
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/advise.mjs +4 -1
- package/bin/cli.mjs +1 -0
- package/bin/crew-runner.mjs +56 -6
- package/bin/crew-verify.mjs +18 -4
- package/bin/crew.sh +108 -14
- package/engine/bin/scrooge +89 -1
- package/hooks/heartbeat.mjs +68 -0
- package/hooks/hooks.json +4 -1
- package/hub.mjs +53 -17
- package/mcp.mjs +39 -16
- package/package.json +3 -3
- package/ui.html +43 -9
|
@@ -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.4"
|
|
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.4",
|
|
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.4",
|
|
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/advise.mjs
CHANGED
|
@@ -122,7 +122,10 @@ export function advise(input, world = loadWorld()) {
|
|
|
122
122
|
const cards = routing.map((r, i) => ({
|
|
123
123
|
order: i + 1, title: r.title, difficulty: r.difficulty,
|
|
124
124
|
assignee: r.executor === "scrooge" || r.executor === "orchestrator" ? undefined : `${r.executor}:<project>`,
|
|
125
|
-
|
|
125
|
+
// "auto" = resolve a LIVE model at spawn (the orchestrator runs `trantor up <agent>:<provider>
|
|
126
|
+
// --task --difficulty`, which picks the best live model). Was `<cli>-default` — a stale default.
|
|
127
|
+
model: r.model || (["scrooge", "orchestrator"].includes(r.executor) ? undefined : "auto"),
|
|
128
|
+
task: ["scrooge", "orchestrator"].includes(r.executor) ? undefined : r.kind,
|
|
126
129
|
via: r.executor === "scrooge" ? "relay_scrooge" : "relay_task_add",
|
|
127
130
|
deps_orders: r.executor === "orchestrator" && /integrat/i.test(r.title)
|
|
128
131
|
? routing.map((x, j) => j + 1).filter(j => j !== i + 1)
|
package/bin/cli.mjs
CHANGED
|
@@ -22,6 +22,7 @@ switch (cmd) {
|
|
|
22
22
|
case "verify": run("bin/crew-verify.mjs"); break;
|
|
23
23
|
case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
24
24
|
case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down"], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
25
|
+
case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
25
26
|
case "hub": run("hub.mjs"); break;
|
|
26
27
|
case "watch": run("bin/relay-watch.mjs"); break;
|
|
27
28
|
case "ui": {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -48,8 +48,11 @@ async function api(path, body) {
|
|
|
48
48
|
// ---- per-CLI invocation (first turn vs resume turn). {P} = prompt file path ----
|
|
49
49
|
// CREW_MODEL env pins the model: each CLI gets its own flag via {M} (empty when unset).
|
|
50
50
|
let MODEL = process.env.CREW_MODEL || "";
|
|
51
|
-
// opencode expects provider/model
|
|
52
|
-
|
|
51
|
+
// opencode expects provider/model. A BARE id for the `deepseek` agent qualifies to its
|
|
52
|
+
// own provider; `opencode` ids must already be provider-qualified (e.g.
|
|
53
|
+
// `zai-coding-plan/glm-5.1`) — never assume `deepseek/` for opencode (that mangled
|
|
54
|
+
// ZAI-coding-plan models into deepseek/…). `scrooge route` returns qualified ids.
|
|
55
|
+
if (MODEL && !MODEL.includes("/") && AGENT === "deepseek") MODEL = `deepseek/${MODEL}`;
|
|
53
56
|
const CLI = {
|
|
54
57
|
codex: { first: `codex exec{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
55
58
|
next: `codex exec resume --last{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
@@ -69,6 +72,46 @@ if (!cli) { console.error(`unknown agent '${AGENT}' (known: ${Object.keys(CLI).j
|
|
|
69
72
|
|
|
70
73
|
const RULES = `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
71
74
|
|
|
75
|
+
// ---- failure visibility ----------------------------------------------------
|
|
76
|
+
// A turn's CLI can fail (credits exhausted, auth, crash) and the runner would just
|
|
77
|
+
// re-park — staying green on the bus, telling the orchestrator NOTHING. These surface
|
|
78
|
+
// every non-zero turn to the bus in real time so the orchestrator (and `trantor swap`)
|
|
79
|
+
// can react, and flip presence to errored/down.
|
|
80
|
+
let consecFails = 0;
|
|
81
|
+
let lastErrText = "";
|
|
82
|
+
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
83
|
+
|
|
84
|
+
function classifyFailure(exit, errText) {
|
|
85
|
+
const t = (errText || "").toLowerCase();
|
|
86
|
+
if (exit === 127) return "missing-cli";
|
|
87
|
+
if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|out of (credit|quota)/.test(t)) return "exhausted";
|
|
88
|
+
if (/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/.test(t)) return "auth";
|
|
89
|
+
return "crashed";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function reportFailure(exit, trigger) {
|
|
93
|
+
consecFails++;
|
|
94
|
+
const reason = classifyFailure(exit, lastErrText);
|
|
95
|
+
const down = consecFails >= 2;
|
|
96
|
+
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
97
|
+
await api("/register", { session: SESSION, project: PROJ, status }).catch(() => {});
|
|
98
|
+
const hint = reason === "exhausted" ? " — needs `trantor swap`"
|
|
99
|
+
: reason === "auth" ? " — check credentials"
|
|
100
|
+
: reason === "missing-cli" ? " — CLI not on PATH" : "";
|
|
101
|
+
const text = down
|
|
102
|
+
? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}`
|
|
103
|
+
: `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}`;
|
|
104
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
105
|
+
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function reportHealthy() {
|
|
109
|
+
if (consecFails === 0) return; // already healthy — don't spam
|
|
110
|
+
consecFails = 0;
|
|
111
|
+
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}` }).catch(() => {});
|
|
112
|
+
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
let sid = "";
|
|
73
116
|
function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
74
117
|
TURN++; banner(trigger);
|
|
@@ -82,12 +125,16 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
82
125
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
83
126
|
for (const f of envs.reverse()) cmd = `set -a; source ${f}; set +a; ${cmd}`; // ~/.agent-bus/.env wins
|
|
84
127
|
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
85
|
-
// inherit stdio so the window shows the agent working live; also capture for sid-parsing
|
|
86
|
-
|
|
128
|
+
// inherit stdio so the window shows the agent working live; also capture for sid-parsing.
|
|
129
|
+
// Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
|
|
130
|
+
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
|
|
131
|
+
const inner = cli.sid ? `${cmd} | tee /dev/stderr` : cmd;
|
|
132
|
+
const r = spawnSync("/bin/bash", ["-c", `{ ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
|
|
87
133
|
cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
88
134
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ },
|
|
89
135
|
maxBuffer: 16 * 1024 * 1024,
|
|
90
136
|
});
|
|
137
|
+
try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
|
|
91
138
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
92
139
|
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: r.status });
|
|
93
140
|
log(`turn ended (exit ${r.status}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
@@ -114,7 +161,8 @@ async function loadLessons() {
|
|
|
114
161
|
await api("/register", { session: SESSION, project: PROJ, status: "crew member booting" }).catch(() => {});
|
|
115
162
|
|
|
116
163
|
let pendingBcast = [];
|
|
117
|
-
runTurn(KICKOFF + LESSONS, true, "kickoff");
|
|
164
|
+
const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
|
|
165
|
+
if (ec0) await reportFailure(ec0, "kickoff"); // a failed kickoff = the "fired up, died, nobody knew" case
|
|
118
166
|
log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
|
|
119
167
|
|
|
120
168
|
while (true) {
|
|
@@ -134,7 +182,9 @@ async function loadLessons() {
|
|
|
134
182
|
pendingBcast = [];
|
|
135
183
|
const lines = wake.map(m => `[${m.from}${m.to === "all" ? " -> all (mentions you)" : ""}]: ${m.text}`).join("\n");
|
|
136
184
|
const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
|
|
137
|
-
await loadLessons();
|
|
185
|
+
await loadLessons();
|
|
186
|
+
const ec = runTurn(prompt + LESSONS, false, direct.length ? "direct message" : "@mention");
|
|
187
|
+
if (ec) await reportFailure(ec, "message"); else await reportHealthy();
|
|
138
188
|
log("parked — waiting for the next message");
|
|
139
189
|
}
|
|
140
190
|
})();
|
package/bin/crew-verify.mjs
CHANGED
|
@@ -13,8 +13,16 @@ import { homedir } from "node:os";
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
const ti = args.indexOf("--timeout");
|
|
15
15
|
const TIMEOUT = ti >= 0 ? Number(args.splice(ti, 2)[1]) : 30;
|
|
16
|
+
// --since <ms>: the spawn epoch captured by the launcher BEFORE it spawned the windows.
|
|
17
|
+
// An agent counts as up the moment it registers (even "booting") with lastSeen >= this epoch.
|
|
18
|
+
// Without it we'd default to "now", but the launcher only starts us AFTER the spawn+serialize
|
|
19
|
+
// sleep, so a runner's early "booting" registration can land just before our own start and then
|
|
20
|
+
// go silent through a slow first turn (e.g. opencode+GLM cold start ~40s) — a false failure that
|
|
21
|
+
// triggers a duplicate respawn. Anchoring to the pre-spawn epoch removes that race.
|
|
22
|
+
const si = args.indexOf("--since");
|
|
23
|
+
const SINCE = si >= 0 ? Number(args.splice(si, 2)[1]) : NaN;
|
|
16
24
|
const [PROJ, ...AGENTS] = args;
|
|
17
|
-
if (!PROJ || !AGENTS.length) { console.error("usage: crew-verify.mjs <project> <agent...> [--timeout 30]"); process.exit(2); }
|
|
25
|
+
if (!PROJ || !AGENTS.length) { console.error("usage: crew-verify.mjs <project> <agent...> [--timeout 30] [--since <ms>]"); process.exit(2); }
|
|
18
26
|
|
|
19
27
|
function hubUrl() {
|
|
20
28
|
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
@@ -22,15 +30,21 @@ function hubUrl() {
|
|
|
22
30
|
return "http://127.0.0.1:4477";
|
|
23
31
|
}
|
|
24
32
|
const HUB = hubUrl();
|
|
25
|
-
|
|
33
|
+
// Two distinct clocks, deliberately separate:
|
|
34
|
+
// - FRESH_SINCE: the freshness threshold. A registration counts only if lastSeen >= this.
|
|
35
|
+
// Prefer the launcher's pre-spawn epoch (so an early "booting" beat counts); else our start.
|
|
36
|
+
// - DEADLINE: how long WE poll, always measured from our own start so it can't be skewed
|
|
37
|
+
// (e.g. an epoch far in the past wouldn't shrink the window; one in the future wouldn't hang).
|
|
38
|
+
const DEADLINE = Date.now() + TIMEOUT * 1000;
|
|
39
|
+
const FRESH_SINCE = Number.isFinite(SINCE) ? SINCE : Date.now();
|
|
26
40
|
|
|
27
41
|
(async () => {
|
|
28
42
|
const want = new Set(AGENTS.map(a => `${a}:${PROJ}`));
|
|
29
43
|
const up = new Set();
|
|
30
|
-
while (Date.now()
|
|
44
|
+
while (Date.now() < DEADLINE && up.size < want.size) {
|
|
31
45
|
try {
|
|
32
46
|
const { peers } = await (await fetch(`${HUB}/peers`)).json();
|
|
33
|
-
for (const p of peers) if (want.has(p.session) && p.lastSeen >=
|
|
47
|
+
for (const p of peers) if (want.has(p.session) && p.lastSeen >= FRESH_SINCE) up.add(p.session);
|
|
34
48
|
} catch {}
|
|
35
49
|
if (up.size < want.size) await new Promise(s => setTimeout(s, 1500));
|
|
36
50
|
}
|
package/bin/crew.sh
CHANGED
|
@@ -23,7 +23,8 @@ mkdir -p "$HOME/.agent-bus"
|
|
|
23
23
|
|
|
24
24
|
down() {
|
|
25
25
|
[ -f "$STATE" ] || { echo "no tracked crew windows"; return 0; }
|
|
26
|
-
while read -r wid; do
|
|
26
|
+
while IFS=$'\t' read -r a wid; do
|
|
27
|
+
[ -n "${wid:-}" ] || wid="$a" # back-compat: old STATE stored bare window ids
|
|
27
28
|
TTY=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
|
|
28
29
|
if [ -n "$TTY" ]; then
|
|
29
30
|
# SIGKILL everything on the tty, login included — TUIs trap SIGTERM, and a live login
|
|
@@ -32,7 +33,8 @@ down() {
|
|
|
32
33
|
fi
|
|
33
34
|
done < "$STATE"
|
|
34
35
|
sleep 1
|
|
35
|
-
while read -r wid; do
|
|
36
|
+
while IFS=$'\t' read -r a wid; do
|
|
37
|
+
[ -n "${wid:-}" ] || wid="$a"
|
|
36
38
|
osascript -e "tell application \"Terminal\" to close (first window whose id is $wid)" 2>/dev/null
|
|
37
39
|
done < "$STATE"
|
|
38
40
|
sleep 0.5
|
|
@@ -44,8 +46,50 @@ down() {
|
|
|
44
46
|
echo "crew torn down"
|
|
45
47
|
}
|
|
46
48
|
[ "$CMD" = "down" ] && { down; exit 0; }
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <oldAgent> <newAgent[:provider[/model]]> | crew.sh down"; exit 1 ;; esac
|
|
50
|
+
|
|
51
|
+
# --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
|
|
52
|
+
# An agent spec is one of: `codex` (CLI default) · `opencode:zai-coding-plan` (provider only →
|
|
53
|
+
# pick the best live model now) · `opencode:zai-coding-plan/glm-5.2` (full pin, used as-is).
|
|
54
|
+
TASK="code"; DIFF="medium"; _ARGS=()
|
|
55
|
+
while [ $# -gt 0 ]; do
|
|
56
|
+
case "$1" in
|
|
57
|
+
--task) TASK="${2:-code}"; shift 2 || shift ;;
|
|
58
|
+
--difficulty|--diff) DIFF="${2:-medium}"; shift 2 || shift ;;
|
|
59
|
+
*) _ARGS+=("$1"); shift ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
if [ ${#_ARGS[@]} -gt 0 ]; then set -- "${_ARGS[@]}"; else set --; fi
|
|
63
|
+
[ $# -eq 0 ] && { echo "usage: crew.sh up [--task K --difficulty D] codex gemini kimi deepseek (agent:provider picks a live model; agent:provider/model pins one)"; exit 1; }
|
|
64
|
+
|
|
65
|
+
# scrooge (the model-routing brain) is bundled with this trantor install; fall back to PATH.
|
|
66
|
+
SCROOGE="$BUS_DIR/engine/bin/scrooge"
|
|
67
|
+
[ -f "$SCROOGE" ] || SCROOGE="$(command -v scrooge 2>/dev/null || echo scrooge)"
|
|
68
|
+
|
|
69
|
+
# resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty
|
|
70
|
+
# (→ CLI default). Enumeration is CLI-aware and never guesses an endpoint: opencode-managed
|
|
71
|
+
# agents list via `opencode models <provider>`; others self-enumerate via the provider's /models.
|
|
72
|
+
resolve_model() {
|
|
73
|
+
local agent="$1" provider="$2" task="$3" diff="$4" cands="" out=""
|
|
74
|
+
case "$agent" in
|
|
75
|
+
opencode|deepseek)
|
|
76
|
+
cands="$(opencode models "$provider" 2>/dev/null | tr '\n' ' ')"
|
|
77
|
+
[ -n "$cands" ] || { echo "[crew] no live models via 'opencode models $provider' — CLI default" >&2; return 0; }
|
|
78
|
+
out="$(python3 "$SCROOGE" route --candidates "$cands" -t "$task" -d "$diff" --json 2>/dev/null)" ;;
|
|
79
|
+
*)
|
|
80
|
+
out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)" ;;
|
|
81
|
+
esac
|
|
82
|
+
[ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — CLI default" >&2; return 0; }
|
|
83
|
+
printf '%s' "$out" | python3 -c 'import json,sys
|
|
84
|
+
try: print(json.load(sys.stdin).get("qualified") or "")
|
|
85
|
+
except Exception: pass' 2>/dev/null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# epoch_ms: milliseconds since the epoch, captured BEFORE a spawn so crew-verify can count an
|
|
89
|
+
# agent the moment it registers (even "booting"), instead of racing its own start time. A slow
|
|
90
|
+
# first turn (opencode+GLM cold start ~40s) means no heartbeat for the whole turn; anchoring the
|
|
91
|
+
# verifier to this pre-spawn epoch lets the early "booting" registration satisfy it.
|
|
92
|
+
epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
|
49
93
|
|
|
50
94
|
if [ "$(uname)" != "Darwin" ]; then
|
|
51
95
|
echo "Window spawning is macOS-only. Run one per terminal, in $DIR:"
|
|
@@ -82,37 +126,87 @@ spawn_grid() { # $@ = agents — (re)computes the grid for THIS batch and spawn
|
|
|
82
126
|
[ $N -le 2 ] && COLS=1
|
|
83
127
|
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
84
128
|
local CW=$(( GW / COLS )) CH=$(( GH / ROWS ))
|
|
85
|
-
local i=0 SPEC AGENT MODEL
|
|
129
|
+
local i=0 SPEC AGENT FIELD MODEL
|
|
86
130
|
for SPEC in "$@"; do
|
|
87
|
-
AGENT="${SPEC%%:*}" # agent[:model] — model rides in as CREW_MODEL
|
|
88
|
-
|
|
131
|
+
AGENT="${SPEC%%:*}" # agent[:provider[/model]] — model rides in as CREW_MODEL
|
|
132
|
+
FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
|
|
133
|
+
MODEL=""
|
|
134
|
+
if [ -n "$FIELD" ]; then
|
|
135
|
+
case "$FIELD" in
|
|
136
|
+
*/*) MODEL="$FIELD" ;; # full pin: provider/model
|
|
137
|
+
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" # provider only: pick live now
|
|
138
|
+
if [ -n "$MODEL" ]; then echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"
|
|
139
|
+
else echo " → $AGENT: '$FIELD' live selection unavailable — CLI default"; fi ;;
|
|
140
|
+
esac
|
|
141
|
+
fi
|
|
89
142
|
local C=$(( i % COLS )) R=$(( i / COLS ))
|
|
90
|
-
local X1=$(( GX + C * CW )) Y1=$(( GY + R * CH ))
|
|
91
|
-
osascript \
|
|
143
|
+
local X1=$(( GX + C * CW )) Y1=$(( GY + R * CH )) WID=""
|
|
144
|
+
WID="$(osascript \
|
|
92
145
|
-e 'tell application "Terminal"' \
|
|
93
146
|
-e " set w to do script \"cd $DIR && clear && CREW_MODEL=$MODEL node $BUS_DIR/bin/crew-runner.mjs $AGENT $DIR\"" \
|
|
94
147
|
-e " set custom title of w to \"$(echo "$AGENT" | tr '[:lower:]' '[:upper:]') — trantor crew\"" \
|
|
95
148
|
-e " set theWin to first window whose tabs contains w" \
|
|
96
149
|
-e " set bounds of theWin to {$X1, $Y1, $(( X1 + CW )), $(( Y1 + CH ))}" \
|
|
97
150
|
-e " return id of theWin" \
|
|
98
|
-
-e 'end tell'
|
|
151
|
+
-e 'end tell' 2>/dev/null)"
|
|
152
|
+
if [ -n "$WID" ]; then printf '%s\t%s\n' "$AGENT" "$WID" >> "$STATE"; echo " → $AGENT window spawned"; else echo " ✗ $AGENT osascript spawn ERROR"; fi
|
|
99
153
|
sleep 1.2 # serialize — rapid-fire 'do script' calls race and silently drop windows
|
|
100
154
|
i=$(( i + 1 ))
|
|
101
155
|
done
|
|
102
156
|
}
|
|
103
157
|
|
|
158
|
+
# swap <oldAgent> <newSpec>: replace a live agent (e.g. one reported exhausted) with a fresh
|
|
159
|
+
# one whose model is live-selected. Tears down the old agent's window, spawns the new spec.
|
|
160
|
+
swap() {
|
|
161
|
+
local OLD="${1:-}" NEWSPEC="${2:-}"
|
|
162
|
+
[ -n "$OLD" ] && [ -n "$NEWSPEC" ] || { echo "usage: trantor swap <oldAgent> <newAgent[:provider[/model]]> [--task K --difficulty D]"; exit 1; }
|
|
163
|
+
if [ -f "$STATE" ]; then
|
|
164
|
+
local tmp="$STATE.tmp"; : > "$tmp"
|
|
165
|
+
while IFS=$'\t' read -r a wid; do
|
|
166
|
+
[ -n "${wid:-}" ] || { wid="$a"; a=""; }
|
|
167
|
+
if [ "$a" = "$OLD" ]; then
|
|
168
|
+
echo "— tearing down old agent '$OLD' (window $wid) —"
|
|
169
|
+
local TTY; TTY=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
|
|
170
|
+
[ -n "$TTY" ] && for pid in $(ps -t "${TTY#/dev/}" -o pid= 2>/dev/null); do kill -9 "$pid" 2>/dev/null; done
|
|
171
|
+
sleep 0.5
|
|
172
|
+
osascript -e "tell application \"Terminal\" to close (first window whose id is $wid)" 2>/dev/null
|
|
173
|
+
else
|
|
174
|
+
printf '%s\t%s\n' "$a" "$wid" >> "$tmp"
|
|
175
|
+
fi
|
|
176
|
+
done < "$STATE"
|
|
177
|
+
mv "$tmp" "$STATE"
|
|
178
|
+
fi
|
|
179
|
+
echo "— spawning replacement: $NEWSPEC ($TASK/$DIFF) —"
|
|
180
|
+
local SWAP_EPOCH; SWAP_EPOCH=$(epoch_ms)
|
|
181
|
+
spawn_grid "$NEWSPEC"
|
|
182
|
+
local NEWAGENT="${NEWSPEC%%:*}"
|
|
183
|
+
echo "— verifying replacement on the bus —"
|
|
184
|
+
node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" "$NEWAGENT" --since "$SWAP_EPOCH" --timeout 30
|
|
185
|
+
echo "— swapped. RESEND the contract to '$NEWAGENT' (it joined fresh with no context). —"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if [ "$CMD" = "swap" ]; then swap "$@"; exit 0; fi
|
|
189
|
+
|
|
190
|
+
# spec_for_agent <agent> <spec...>: echo the FULL original spec (agent:provider[/model]) whose
|
|
191
|
+
# agent part matches — so a retry respawns on the SAME live-selected model, not the CLI default.
|
|
192
|
+
spec_for_agent() { local want="$1"; shift; local s; for s in "$@"; do [ "${s%%:*}" = "$want" ] && { printf '%s' "$s"; return; }; done; printf '%s' "$want"; }
|
|
193
|
+
|
|
104
194
|
echo "— spawning crew (serialized) —"
|
|
195
|
+
SPAWN_EPOCH=$(epoch_ms)
|
|
105
196
|
spawn_grid "$@"
|
|
106
197
|
|
|
107
198
|
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
108
199
|
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
109
|
-
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --timeout 30)
|
|
200
|
+
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
|
|
110
201
|
echo "$VER"
|
|
111
202
|
RETRY=$(echo "$VER" | grep "^FAILED:" | cut -d: -f2 | tr ',' ' ')
|
|
112
203
|
if [ -n "${RETRY// }" ]; then
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
204
|
+
# map failed agent names back to their FULL specs (preserve provider/model on respawn)
|
|
205
|
+
RETRY_SPECS=""; for a in $RETRY; do RETRY_SPECS="$RETRY_SPECS $(spec_for_agent "$a" "$@")"; done
|
|
206
|
+
echo "— retrying failed spawns:$RETRY_SPECS —"
|
|
207
|
+
RETRY_EPOCH=$(epoch_ms)
|
|
208
|
+
spawn_grid $RETRY_SPECS
|
|
209
|
+
VER2=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $RETRY --since "$RETRY_EPOCH" --timeout 30)
|
|
116
210
|
echo "$VER2"
|
|
117
211
|
STILL=$(echo "$VER2" | grep "^FAILED:" | cut -d: -f2)
|
|
118
212
|
if [ -n "$STILL" ]; then
|
package/engine/bin/scrooge
CHANGED
|
@@ -590,6 +590,86 @@ def cmd_models(reg, args):
|
|
|
590
590
|
for i in ids:
|
|
591
591
|
print(i)
|
|
592
592
|
|
|
593
|
+
def _model_version_key(mid):
|
|
594
|
+
"""Best-effort 'newest' ordering when no capability data exists: the trailing
|
|
595
|
+
numeric version wins (glm-5.2 > glm-5.1 > glm-4.7). Returns a sortable tuple."""
|
|
596
|
+
m = re.search(r"(\d+(?:\.\d+)*)", mid)
|
|
597
|
+
return tuple(int(x) for x in m.group(1).split(".")) if m else (0,)
|
|
598
|
+
|
|
599
|
+
_SMALL_VARIANT = ("air", "mini", "flash", "lite", "turbo", "nano", "small")
|
|
600
|
+
def _is_small_variant(mid):
|
|
601
|
+
low = mid.lower()
|
|
602
|
+
return any(s in low for s in _SMALL_VARIANT)
|
|
603
|
+
|
|
604
|
+
def _heuristic_pick(cands, difficulty):
|
|
605
|
+
"""No capability data → pick by newest version, difficulty-aware on size variants
|
|
606
|
+
(easy prefers a small/turbo variant; medium/hard prefers the full model)."""
|
|
607
|
+
top_ver = max(_model_version_key(c) for c in cands)
|
|
608
|
+
top = [c for c in cands if _model_version_key(c) == top_ver]
|
|
609
|
+
if difficulty == "easy":
|
|
610
|
+
smalls = sorted(c for c in top if _is_small_variant(c))
|
|
611
|
+
if smalls:
|
|
612
|
+
return smalls[0]
|
|
613
|
+
bigs = sorted(c for c in top if not _is_small_variant(c))
|
|
614
|
+
return (bigs or sorted(top))[0]
|
|
615
|
+
|
|
616
|
+
def cmd_route(reg, args):
|
|
617
|
+
"""Pick ONE deliberate live model for the crew path (task × difficulty).
|
|
618
|
+
Enumeration is the CALLER's job for CLI-managed providers — pass ids via
|
|
619
|
+
--candidates (e.g. `opencode models <provider>`); for raw-API providers in the
|
|
620
|
+
registry, --provider self-enumerates via /models. Scoring uses capabilities when
|
|
621
|
+
available, else a newest-version heuristic. JSON with --json. NEVER guesses an
|
|
622
|
+
endpoint."""
|
|
623
|
+
caps = load_caps()
|
|
624
|
+
provider = args.provider
|
|
625
|
+
raw = []
|
|
626
|
+
if args.candidates:
|
|
627
|
+
raw = [c.strip() for c in re.split(r"[,\s]+", args.candidates) if c.strip()]
|
|
628
|
+
elif provider:
|
|
629
|
+
if provider not in reg["providers"]:
|
|
630
|
+
print(json.dumps({"error": "unknown provider '%s' and no --candidates given" % provider}))
|
|
631
|
+
raise SystemExit(2)
|
|
632
|
+
if not provider_key(reg, provider):
|
|
633
|
+
print(json.dumps({"error": "no API key for provider '%s'" % provider}))
|
|
634
|
+
raise SystemExit(2)
|
|
635
|
+
raw = list_live_models(reg, provider, ttl=0)
|
|
636
|
+
if not raw:
|
|
637
|
+
print(json.dumps({"error": "no candidate models (provider offline / no key / empty --candidates)"}))
|
|
638
|
+
raise SystemExit(2)
|
|
639
|
+
# bare id (strip any provider/ prefix) → first original (qualified) form seen
|
|
640
|
+
by_bare = {}
|
|
641
|
+
for r in raw:
|
|
642
|
+
by_bare.setdefault(r.split("/")[-1], r)
|
|
643
|
+
bare_ids = list(by_bare.keys())
|
|
644
|
+
task = args.task or "code"
|
|
645
|
+
difficulty = args.difficulty or "medium"
|
|
646
|
+
metric = task_metric(task)
|
|
647
|
+
# Only trust the capability-weighted router when the NEWEST-version candidate is itself
|
|
648
|
+
# scored. Otherwise stale caps (which lag new releases) would demote a newer model to 0
|
|
649
|
+
# and pick an old one — the exact failure that motivated this. Then prefer newest.
|
|
650
|
+
top_ver = max(_model_version_key(b) for b in bare_ids)
|
|
651
|
+
top_scored = any(model_quality(caps, b, metric) > 0
|
|
652
|
+
for b in bare_ids if _model_version_key(b) == top_ver)
|
|
653
|
+
if top_scored:
|
|
654
|
+
scored = weigh_candidates(reg, caps, bare_ids, task, difficulty)
|
|
655
|
+
pick_bare = scored[0][0]
|
|
656
|
+
weighed, why = True, "capability×cost ranked (%s · %s floor) over %d live" % (task, difficulty, len(bare_ids))
|
|
657
|
+
cand_out = [{"model": b, "score": round(s, 4)} for b, s in scored[:6]]
|
|
658
|
+
else:
|
|
659
|
+
pick_bare = _heuristic_pick(bare_ids, difficulty)
|
|
660
|
+
weighed, why = False, "capabilities lag the newest model(s) → newest-version heuristic (difficulty-aware); run scrooge-capabilities to enrich"
|
|
661
|
+
cand_out = [{"model": b} for b in sorted(bare_ids, key=_model_version_key, reverse=True)[:6]]
|
|
662
|
+
original = by_bare[pick_bare]
|
|
663
|
+
qualified = original if "/" in original else (("%s/%s" % (provider, pick_bare)) if provider else pick_bare)
|
|
664
|
+
result = {"provider": provider, "model": pick_bare, "qualified": qualified,
|
|
665
|
+
"weighed": weighed, "task": task, "difficulty": difficulty, "why": why, "candidates": cand_out}
|
|
666
|
+
if getattr(args, "json", False):
|
|
667
|
+
print(json.dumps(result))
|
|
668
|
+
else:
|
|
669
|
+
err(DIM("[scrooge route] %s → %s (%s)" % (provider or "?", qualified, "weighed" if weighed else "heuristic")))
|
|
670
|
+
print(qualified)
|
|
671
|
+
return 0
|
|
672
|
+
|
|
593
673
|
def cmd_list(reg, args):
|
|
594
674
|
print("PROVIDERS (live = key present):")
|
|
595
675
|
for p, cfg in reg["providers"].items():
|
|
@@ -1207,11 +1287,19 @@ def main():
|
|
|
1207
1287
|
reg = load_registry()
|
|
1208
1288
|
|
|
1209
1289
|
# Manual subcommand dispatch (avoids argparse subparser vs positional-prompt clash).
|
|
1210
|
-
if argv and argv[0] in ("models", "list", "ledger", "watch", "learn", "lessons", "forget"):
|
|
1290
|
+
if argv and argv[0] in ("models", "route", "list", "ledger", "watch", "learn", "lessons", "forget"):
|
|
1211
1291
|
cmd, rest = argv[0], argv[1:]
|
|
1212
1292
|
if cmd == "models":
|
|
1213
1293
|
ap = argparse.ArgumentParser(prog="scrooge models"); ap.add_argument("provider")
|
|
1214
1294
|
return cmd_models(reg, ap.parse_args(rest))
|
|
1295
|
+
if cmd == "route":
|
|
1296
|
+
ap = argparse.ArgumentParser(prog="scrooge route")
|
|
1297
|
+
ap.add_argument("--provider", "-p", help="registry API provider to self-enumerate via /models")
|
|
1298
|
+
ap.add_argument("--candidates", "-c", help="comma/space-separated model ids (e.g. `opencode models <p>`); takes precedence over --provider enumeration")
|
|
1299
|
+
ap.add_argument("--task", "-t", default="code")
|
|
1300
|
+
ap.add_argument("--difficulty", "-d", choices=["easy", "medium", "hard"], default="medium")
|
|
1301
|
+
ap.add_argument("--json", action="store_true")
|
|
1302
|
+
return cmd_route(reg, ap.parse_args(rest))
|
|
1215
1303
|
if cmd == "list":
|
|
1216
1304
|
return cmd_list(reg, None)
|
|
1217
1305
|
if cmd == "ledger":
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PostToolUse heartbeat — keeps a live session's presence fresh on the bus.
|
|
3
|
+
//
|
|
4
|
+
// Registration (sessionstart.mjs / mcp.mjs) tells the hub a session was BORN; nothing
|
|
5
|
+
// tells it the session is still ALIVE. So presence decays after RELAY_ONLINE_MS (5 min)
|
|
6
|
+
// and the dashboard rots into a graveyard of "idle" boards even while sessions work —
|
|
7
|
+
// worst right after the laptop wakes from sleep, when every lastSeen is stale at once and
|
|
8
|
+
// there is no resume event to re-register. This hook fixes that: every tool call (a true
|
|
9
|
+
// sign of life) refreshes lastSeen, throttled so we hit the hub at most once per window.
|
|
10
|
+
// The first tool call after a wake re-greens the session — that first action IS the resume signal.
|
|
11
|
+
//
|
|
12
|
+
// Cheap + fail-silent by contract: a per-session stamp file gates the network call to once
|
|
13
|
+
// per HEARTBEAT_MS, and a short fetch timeout means we never add real latency to a tool call.
|
|
14
|
+
// We POST /register WITHOUT a status field so the session's meaningful status is preserved
|
|
15
|
+
// (the hub only overwrites status when one is supplied).
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { join, basename } from "node:path";
|
|
18
|
+
import { homedir, hostname } from "node:os";
|
|
19
|
+
|
|
20
|
+
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
21
|
+
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
22
|
+
|
|
23
|
+
function relayUrl() {
|
|
24
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
25
|
+
try {
|
|
26
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
27
|
+
if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
|
|
28
|
+
} catch {}
|
|
29
|
+
return "http://127.0.0.1:4477";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
34
|
+
// Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
|
|
35
|
+
// them (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
|
|
36
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
|
|
37
|
+
|
|
38
|
+
// Mirror mcp.mjs identity resolution EXACTLY so we refresh the same peer the relay
|
|
39
|
+
// registered (not a phantom): RELAY_PROJECT wins for project; RELAY_SESSION wins for
|
|
40
|
+
// identity, else a RELAY_AGENT brand ("codex","kimi",…) per project, else hostname:project.
|
|
41
|
+
const project = process.env.RELAY_PROJECT || basename(projectDir);
|
|
42
|
+
const session = process.env.RELAY_SESSION
|
|
43
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
|
|
44
|
+
|
|
45
|
+
// Throttle: only ping if HEARTBEAT_MS has elapsed since the last ping for THIS session.
|
|
46
|
+
const stamp = join(homedir(), ".agent-bus", `hb-${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(stamp)) {
|
|
49
|
+
const last = Number(readFileSync(stamp, "utf8")) || 0;
|
|
50
|
+
if (Date.now() - last < HEARTBEAT_MS) return; // within window — nothing to do
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
// Write the stamp BEFORE the network call so rapid concurrent tool calls don't all fire.
|
|
54
|
+
try { writeFileSync(stamp, String(Date.now())); } catch {}
|
|
55
|
+
|
|
56
|
+
// POST /register with no status -> hub refreshes lastSeen + project, preserves status.
|
|
57
|
+
try {
|
|
58
|
+
await fetch(`${relayUrl()}/register`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "content-type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ session, project }),
|
|
62
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
63
|
+
});
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Never block or break the tool flow: swallow everything, always exit clean.
|
|
68
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
package/hooks/hooks.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); write a handoff before compaction (PreCompact)",
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call so live sessions stay green and recover after sleep (PostToolUse); write a handoff before compaction (PreCompact)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
6
6
|
],
|
|
7
|
+
"PostToolUse": [
|
|
8
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] }
|
|
9
|
+
],
|
|
7
10
|
"PreCompact": [
|
|
8
11
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
|
|
9
12
|
]
|
package/hub.mjs
CHANGED
|
@@ -5,17 +5,23 @@
|
|
|
5
5
|
// machines reach it (e.g. over a Tailscale tailnet), set RELAY_HOST=0.0.0.0 — but only on a
|
|
6
6
|
// private network, or add auth first. See "Always-on / remote hub" in the README (roadmap).
|
|
7
7
|
import http from "node:http";
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
12
|
const PORT = Number(process.env.RELAY_PORT || 4477);
|
|
13
13
|
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
14
|
-
const DATA_DIR = join(homedir(), ".agent-bus");
|
|
14
|
+
const DATA_DIR = process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
|
|
15
15
|
const DATA = join(DATA_DIR, "bus.json");
|
|
16
16
|
const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
|
|
17
17
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
18
18
|
|
|
19
|
+
// Scrooge ledger cache: /economics is polled every ~15s by the dashboard, but the ledger
|
|
20
|
+
// (~/.token-scrooge/calls.jsonl) only changes when a cheap-model call lands. Re-parse the whole
|
|
21
|
+
// file only when its mtime moves; otherwise reuse the parsed rows. Keeps the lifetime running
|
|
22
|
+
// total cheap to serve no matter how big the ledger grows.
|
|
23
|
+
let _ledgerCache = { mtimeMs: -1, rows: [] };
|
|
24
|
+
|
|
19
25
|
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
20
26
|
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
21
27
|
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [] };
|
|
@@ -51,6 +57,15 @@ function touch(session, status, project) {
|
|
|
51
57
|
if (!p.project && session.includes(":")) p.project = session.split(":").pop().slice(0, 80);
|
|
52
58
|
state.peers[session] = p; dirty = true;
|
|
53
59
|
}
|
|
60
|
+
// Derive a coarse health from the free-text status the runner sets on a failed turn
|
|
61
|
+
// ("errored: <reason>" / "down: <reason>") — lets the board show a failing-but-alive agent
|
|
62
|
+
// distinctly instead of a healthy green. Default "ok".
|
|
63
|
+
function healthOf(status) {
|
|
64
|
+
const s = String(status || "").toLowerCase();
|
|
65
|
+
if (s.startsWith("down")) return "down";
|
|
66
|
+
if (s.startsWith("errored")) return "errored";
|
|
67
|
+
return "ok";
|
|
68
|
+
}
|
|
54
69
|
function deliverable(m, session) { return (m.to === session || m.to === "all") && m.from !== session; }
|
|
55
70
|
function pushToStreams(msg) {
|
|
56
71
|
for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
|
|
@@ -63,7 +78,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
63
78
|
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project); return json(res, 200, { ok: true }); }
|
|
64
79
|
if (req.method === "GET" && P === "/peers") {
|
|
65
80
|
const cutoff = now() - ONLINE_MS;
|
|
66
|
-
return json(res, 200, { peers: Object.entries(state.peers).map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", project: v.project || "" })) });
|
|
81
|
+
return json(res, 200, { peers: Object.entries(state.peers).map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "" })) });
|
|
67
82
|
}
|
|
68
83
|
// --- Kanban tasks ---
|
|
69
84
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
@@ -124,7 +139,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
124
139
|
const proj = p => p || "(unassigned)";
|
|
125
140
|
const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
|
|
126
141
|
for (const [s, v] of Object.entries(state.peers)) {
|
|
127
|
-
const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "" });
|
|
142
|
+
const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status) });
|
|
128
143
|
if ((v.lastSeen || 0) > e.lastActivity) e.lastActivity = v.lastSeen;
|
|
129
144
|
}
|
|
130
145
|
for (const t of state.tasks) { const e = mk(proj(t.project)); e.tasks[t.status] = (e.tasks[t.status]||0)+1; if (t.status === "doing") e.doingTitles.push(t.title); if ((t.updated || 0) > e.lastActivity) e.lastActivity = t.updated; }
|
|
@@ -158,22 +173,43 @@ const server = http.createServer(async (req, res) => {
|
|
|
158
173
|
dirty = true; return json(res, 200, { ok: true, count: state.lessons.length });
|
|
159
174
|
}
|
|
160
175
|
if (req.method === "GET" && P === "/economics") { // the brain's books, surfaced: scrooge ledger + quota profile
|
|
161
|
-
const out = { scrooge: null, profile: null };
|
|
176
|
+
const out = { scrooge: null, lifetime: null, profile: null };
|
|
162
177
|
try { out.profile = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
|
|
163
178
|
try {
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
m.calls++; m.cost_usd += c.cost_usd || 0;
|
|
179
|
+
const ledger = join(homedir(), ".token-scrooge", "calls.jsonl");
|
|
180
|
+
const st = statSync(ledger);
|
|
181
|
+
if (st.mtimeMs !== _ledgerCache.mtimeMs) { // ledger changed → reparse the whole file once
|
|
182
|
+
const rows = readFileSync(ledger, "utf8").trim().split("\n")
|
|
183
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
184
|
+
.filter(c => c && c.ok);
|
|
185
|
+
_ledgerCache = { mtimeMs: st.mtimeMs, rows };
|
|
172
186
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
const rows = _ledgerCache.rows;
|
|
188
|
+
// Roll up a set of calls into spend + the frontier-model yardstick (~$15/M in, $75/M out,
|
|
189
|
+
// same reference scrooge's own ledger uses) and the resulting savings.
|
|
190
|
+
const rollup = calls => {
|
|
191
|
+
const s = { calls: calls.length, tokens_in: 0, tokens_out: 0, cost_usd: 0, by_model: {} };
|
|
192
|
+
for (const c of calls) {
|
|
193
|
+
s.tokens_in += c.tokens_in || 0; s.tokens_out += c.tokens_out || 0; s.cost_usd += c.cost_usd || 0;
|
|
194
|
+
const m = s.by_model[c.model] ||= { calls: 0, cost_usd: 0 };
|
|
195
|
+
m.calls++; m.cost_usd += c.cost_usd || 0;
|
|
196
|
+
}
|
|
197
|
+
s.opus_equiv_usd = +(s.tokens_in * 15 / 1e6 + s.tokens_out * 75 / 1e6).toFixed(2);
|
|
198
|
+
s.cost_usd = +s.cost_usd.toFixed(4);
|
|
199
|
+
s.saved_usd = +Math.max(0, s.opus_equiv_usd - s.cost_usd).toFixed(2);
|
|
200
|
+
return s;
|
|
201
|
+
};
|
|
202
|
+
// Named rolling windows the dashboard dropdown offers, all served in one response so
|
|
203
|
+
// switching the selector is instant (no refetch) — cheap because the rows are cached.
|
|
204
|
+
const nowS = now() / 1000;
|
|
205
|
+
const WINDOWS = { "24h": 24, "week": 168, "month": 720, "quarter": 2160, "year": 8760 };
|
|
206
|
+
out.windows = {};
|
|
207
|
+
for (const [k, hrs] of Object.entries(WINDOWS)) out.windows[k] = rollup(rows.filter(c => c.ts >= nowS - hrs * 3600));
|
|
208
|
+
out.lifetime = rollup(rows); // all-time running total
|
|
209
|
+
out.lifetime.since_ts = rows.length ? rows[0].ts : null; // first ledgered call
|
|
210
|
+
out.windows.lifetime = out.lifetime;
|
|
211
|
+
// back-compat: `scrooge` is the window older dashboards read (honor ?hours= if passed)
|
|
212
|
+
out.scrooge = q.hours ? rollup(rows.filter(c => c.ts >= nowS - Number(q.hours) * 3600)) : out.windows["24h"];
|
|
177
213
|
} catch {}
|
|
178
214
|
return json(res, 200, out);
|
|
179
215
|
}
|
package/mcp.mjs
CHANGED
|
@@ -44,11 +44,12 @@ server.tool("relay_whoami", "Show this session's relay identity, project, and th
|
|
|
44
44
|
return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${URL_BASE}` }] };
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
server.tool("relay_task_add", "Add a Kanban card to
|
|
48
|
-
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)") },
|
|
49
|
-
async ({ title, status, assignee, difficulty, model, deps }) => {
|
|
50
|
-
const
|
|
51
|
-
|
|
47
|
+
server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
|
|
48
|
+
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
|
|
49
|
+
async ({ title, status, assignee, difficulty, model, deps, project }) => {
|
|
50
|
+
const proj = project || PROJECT;
|
|
51
|
+
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
|
|
52
|
+
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]` }] };
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external.",
|
|
@@ -58,11 +59,12 @@ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doin
|
|
|
58
59
|
return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
server.tool("relay_project_brief", "Set a one-paragraph brief for
|
|
62
|
-
{ brief: z.string().describe("1-3 sentences: what this project is + why + the goal") },
|
|
63
|
-
async ({ brief }) => {
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
server.tool("relay_project_brief", "Set a one-paragraph brief for a project shown on the dashboard: what it is, why it matters, and the goal. Defaults to THIS project; pass `project` to brief a crew board you orchestrate from elsewhere. Set it once when you start work so anyone watching the board understands the project at a glance (the board itself shows where it is in the process).",
|
|
63
|
+
{ brief: z.string().describe("1-3 sentences: what this project is + why + the goal"), project: z.string().optional().describe("board to brief (default: this session's project)") },
|
|
64
|
+
async ({ brief, project }) => {
|
|
65
|
+
const proj = project || PROJECT;
|
|
66
|
+
await api("POST", "/project", { project: proj, brief, by: SESSION });
|
|
67
|
+
return { content: [{ type: "text", text: `brief set for ${proj}` }] };
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
server.tool("relay_advise", "THE ADVISOR — ask the brain how to execute a body of work before spending tokens. Give it your work packages (with difficulty); it weighs task shape x the user's plan economics x context horizon and returns: mode (solo|scrooge|crew|hybrid), per-package executor+model routing, and a real-money estimate with quota-pool accounting. Call this at project kickoff and PRESENT the summary to the user before firing anything up.",
|
|
@@ -92,18 +94,26 @@ server.tool("relay_lesson", "Record a LESSON learned from a failure so future cr
|
|
|
92
94
|
return { content: [{ type: "text", text: r.dedup ? "lesson already recorded" : `lesson recorded (${r.count} total)` }] };
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
server.tool("relay_board", "Show
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
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.",
|
|
98
|
+
{ project: z.string().optional().describe("board to show (default: this session's project)") },
|
|
99
|
+
async ({ project }) => {
|
|
100
|
+
const proj = project || PROJECT;
|
|
101
|
+
const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
|
|
102
|
+
if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
|
|
98
103
|
const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
|
|
99
104
|
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}`);
|
|
100
105
|
const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
|
|
101
|
-
return { content: [{ type: "text", text: `${
|
|
106
|
+
return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
|
|
102
107
|
});
|
|
103
108
|
|
|
104
109
|
server.tool("relay_peers", "List other Claude sessions connected to the relay (online in last 5 min).", {}, async () => {
|
|
105
110
|
const { peers } = await api("GET", "/peers");
|
|
106
|
-
const lines = peers.map(p =>
|
|
111
|
+
const lines = peers.map(p => {
|
|
112
|
+
// health surfaces a failing-but-alive agent (runner-reported) — not a green lie
|
|
113
|
+
const icon = !p.online ? "⚪" : p.health === "down" ? "🛑" : p.health === "errored" ? "🔴" : "🟢";
|
|
114
|
+
const note = (p.health === "errored" || p.health === "down") && p.status ? ` — ${p.status}` : "";
|
|
115
|
+
return `${icon} ${p.session}${p.session === SESSION ? " (you)" : ""}${note}`;
|
|
116
|
+
});
|
|
107
117
|
return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
|
|
108
118
|
});
|
|
109
119
|
|
|
@@ -152,5 +162,18 @@ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next me
|
|
|
152
162
|
});
|
|
153
163
|
|
|
154
164
|
await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}` }).catch(() => {});
|
|
165
|
+
|
|
166
|
+
// Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
|
|
167
|
+
// Registration alone decays after the hub's online window (5 min); without this, idle agents
|
|
168
|
+
// — and EVERY agent after the laptop sleeps (dead connection, no resume event) — fall off the
|
|
169
|
+
// board while their process is still alive. This is the UNIVERSAL counterpart to the Claude-only
|
|
170
|
+
// PostToolUse heartbeat hook: it runs inside the relay every agent loads (Claude, codex, gemini,
|
|
171
|
+
// kimi, deepseek), so the whole crew stays tracked. We POST /register with NO status, so the
|
|
172
|
+
// hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
|
|
173
|
+
// sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
|
|
174
|
+
// still exit cleanly when the agent closes the stdio transport (no phantom peers).
|
|
175
|
+
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
176
|
+
setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
|
|
177
|
+
|
|
155
178
|
await server.connect(new StdioServerTransport());
|
|
156
|
-
process.stderr.write(`[trantor-mcp] connected as ${SESSION} -> ${URL_BASE}\n`);
|
|
179
|
+
process.stderr.write(`[trantor-mcp] connected as ${SESSION} -> ${URL_BASE} (heartbeat ${HEARTBEAT_MS}ms)\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs"
|
|
14
14
|
},
|
|
15
|
-
"description": "The hub-world for AI agent crews
|
|
15
|
+
"description": "The hub-world for AI agent crews \u2014 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": [
|
|
17
17
|
"hub.mjs",
|
|
18
18
|
"mcp.mjs",
|
package/ui.html
CHANGED
|
@@ -22,6 +22,10 @@ main{flex:1;display:grid;grid-template-columns:1fr 330px;min-height:0}
|
|
|
22
22
|
.agent .nm{color:var(--tx)}
|
|
23
23
|
.agent svg{flex:none}
|
|
24
24
|
.agent.offl{opacity:.42}
|
|
25
|
+
.agent.err{border-color:#ef4444;color:#ef4444}
|
|
26
|
+
.agent.err .nm{color:#ef4444}
|
|
27
|
+
.agent.down{border-color:#ef4444;background:rgba(239,68,68,.14);color:#ef4444}
|
|
28
|
+
.agent.down .nm{color:#ef4444;font-weight:600}
|
|
25
29
|
.agent .ast{color:var(--mut)}
|
|
26
30
|
.prog{font-size:11.5px;color:var(--dim);white-space:nowrap}
|
|
27
31
|
/* project brief + phase row */
|
|
@@ -137,7 +141,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
137
141
|
<span class="logo">tran<b>t</b>or</span>
|
|
138
142
|
<span class="pill" id="hub">—</span>
|
|
139
143
|
<span class="spacer"></span>
|
|
140
|
-
<span class="pill" id="econ" title="
|
|
144
|
+
<span class="pill" id="econ" title="Trantor savings vs frontier models — lifetime running total + last 24h (from the Scrooge ledger)" style="display:none"></span>
|
|
141
145
|
<span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
|
|
142
146
|
</header>
|
|
143
147
|
<main>
|
|
@@ -177,15 +181,34 @@ function iconFor(s,size){const b=brandOf(s);if(!b||!ICON[b])return `<span style=
|
|
|
177
181
|
const phaseClass=ph=>/^idle ·/.test(ph)?'planned':/FAILED|blocked/.test(ph)?'blocked':/building|verifying|progress/.test(ph)?'building':/shipped|done/.test(ph)?'shipped':'planned';
|
|
178
182
|
|
|
179
183
|
let POOLS={};
|
|
184
|
+
let ECON=null;
|
|
185
|
+
const ECON_WINS=[['24h','last 24h'],['week','last week'],['month','last month'],['quarter','last quarter'],['year','last year']];
|
|
186
|
+
let econWin=localStorage.getItem('abEconWin')||'24h';
|
|
187
|
+
const econSaved=x=>x?(x.saved_usd!=null?(+x.saved_usd):Math.max(0,(x.opus_equiv_usd||0)-(x.cost_usd||0))):0;
|
|
188
|
+
function renderEcon(){
|
|
189
|
+
if(!ECON||!ECON.lifetime||!ECON.lifetime.calls)return;
|
|
190
|
+
const el=$('#econ'); el.style.display='';
|
|
191
|
+
// lifetime running total is the fixed headline; the dropdown picks the comparison window.
|
|
192
|
+
if(!el.dataset.built){
|
|
193
|
+
el.innerHTML=`🪙 Trantor saved <b style="color:var(--grn)" id="econlife"></b> vs frontier`+
|
|
194
|
+
`<span style="opacity:.55"> · lifetime · </span>`+
|
|
195
|
+
`<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">`+
|
|
196
|
+
ECON_WINS.map(([k,l])=>`<option value="${k}">${l}</option>`).join('')+`</select>`+
|
|
197
|
+
`<span style="opacity:.85" id="econwinval"></span>`;
|
|
198
|
+
const sel=$('#econsel'); sel.value=econWin;
|
|
199
|
+
sel.onchange=()=>{econWin=sel.value; localStorage.setItem('abEconWin',econWin); renderEcon();};
|
|
200
|
+
el.dataset.built='1';
|
|
201
|
+
}
|
|
202
|
+
const w=(ECON.windows&&ECON.windows[econWin])||ECON.scrooge, wc=w?w.calls:0;
|
|
203
|
+
$('#econlife').textContent='$'+econSaved(ECON.lifetime).toFixed(2);
|
|
204
|
+
$('#econwinval').textContent=' $'+econSaved(w).toFixed(2)+' ('+wc+' call'+(wc===1?'':'s')+')';
|
|
205
|
+
const sel=$('#econsel'); if(sel)sel.value=econWin;
|
|
206
|
+
}
|
|
180
207
|
async function econ(){
|
|
181
208
|
try{
|
|
182
209
|
const e=await (await fetch('/economics')).json();
|
|
183
210
|
POOLS={}; for(const[k,v]of Object.entries(e.profile||{}))POOLS[k]=v.tier;
|
|
184
|
-
|
|
185
|
-
const s=e.scrooge;
|
|
186
|
-
const el=$('#econ'); el.style.display='';
|
|
187
|
-
el.innerHTML=`🪙 scrooge 24h: <b style="color:var(--grn)">$${s.cost_usd}</b> · saved ~$${Math.max(0,(s.opus_equiv_usd-s.cost_usd)).toFixed(2)} · ${s.calls} calls`;
|
|
188
|
-
}
|
|
211
|
+
ECON=e; renderEcon();
|
|
189
212
|
}catch(_){}
|
|
190
213
|
}
|
|
191
214
|
econ();setInterval(econ,15000);
|
|
@@ -385,7 +408,7 @@ async function render(){
|
|
|
385
408
|
const pt=tasks.filter(t=>t.project===p.project);
|
|
386
409
|
const done=pt.filter(t=>t.status==='done').length;
|
|
387
410
|
const pct=pt.length?Math.round(done/pt.length*100):0;
|
|
388
|
-
const agents=p.agents.sort((a,b)=>b.online-a.online).map(a=>`<span class="agent ${a.online?'':'offl'}" title="${esc(a.session)}${a.online?' · online':' · offline'}">${iconFor(a.session,15)}<span class="nm">${esc(a.session)}</span>${a.status?` <span class="ast">· ${esc(a.status)}</span>`:''}${poolOf(a.session)?` <span class="ast" style="opacity:.7">[${esc(poolOf(a.session))}]</span>`:''}</span>`).join('');
|
|
411
|
+
const agents=p.agents.sort((a,b)=>b.online-a.online).map(a=>`<span class="agent ${a.online?'':'offl'}${a.health==='down'?' down':a.health==='errored'?' err':''}" title="${esc(a.session)}${a.online?' · online':' · offline'}${a.health&&a.health!=='ok'?' · '+a.health:''}">${iconFor(a.session,15)}<span class="nm">${esc(a.session)}</span>${a.status?` <span class="ast">· ${esc(a.status)}</span>`:''}${poolOf(a.session)?` <span class="ast" style="opacity:.7">[${esc(poolOf(a.session))}]</span>`:''}</span>`).join('');
|
|
389
412
|
const cols=COLS.map(([k,label])=>{
|
|
390
413
|
let cards=pt.filter(t=>k==='testing'?(t.status==='testing'||t.status==='failed'):t.status===k);
|
|
391
414
|
if(k==='done')cards=[...cards].sort((a,b)=>(b.updated||0)-(a.updated||0)); // newest finished on top
|
|
@@ -458,14 +481,25 @@ function addMsg(m,count=true){
|
|
|
458
481
|
d.innerHTML=`<span class="t">${new Date(m.ts).toLocaleTimeString()}</span> ${iconFor(m.from,12)} <span class="from">${esc(m.from)}</span> → <span class="to">${esc(m.to)}</span>: ${esc(m.text)}`;
|
|
459
482
|
const f=$('#feed');const stick=f.scrollHeight-f.scrollTop-f.clientHeight<50;f.appendChild(d);if(stick)f.scrollTop=f.scrollHeight;
|
|
460
483
|
}
|
|
461
|
-
|
|
484
|
+
let _ev=null;
|
|
485
|
+
function stream(){
|
|
486
|
+
try{_ev&&_ev.close();}catch(_){} // drop any prior connection before reopening (no duplicate streams)
|
|
487
|
+
const ev=new EventSource('/stream?session=all');_ev=ev;
|
|
462
488
|
ev.onmessage=e=>{try{addMsg(JSON.parse(e.data));render();}catch(_){}};
|
|
463
|
-
ev.onerror=()=>{$('#livedot').classList.add('off');setTimeout(()=>{ev.close();stream();$('#livedot').classList.remove('off');},2000);};}
|
|
489
|
+
ev.onerror=()=>{$('#livedot').classList.add('off');setTimeout(()=>{ev.close();if(_ev===ev)stream();$('#livedot').classList.remove('off');},2000);};}
|
|
464
490
|
$('#send').onclick=async()=>{const t=$('#text').value.trim();if(!t)return;
|
|
465
491
|
await fetch('/send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({from:'dashboard',to:$('#to').value,text:t})});$('#text').value='';};
|
|
466
492
|
$('#text').addEventListener('keydown',e=>{if(e.key==='Enter')$('#send').click();});
|
|
467
493
|
fetch('/recent?limit=40').then(r=>r.json()).then(d=>(d.messages||[]).forEach(m=>addMsg(m,false))).catch(()=>{});
|
|
468
494
|
$('#hub').textContent=location.host;
|
|
469
495
|
render();setInterval(render,2500);stream();
|
|
496
|
+
// Self-heal after the laptop sleeps / the tab is backgrounded: browser timers and the SSE
|
|
497
|
+
// stream get suspended and don't reliably resume, so the board freezes on stale data. The
|
|
498
|
+
// moment the tab becomes visible / focused / regains network, force an immediate refresh +
|
|
499
|
+
// stream reconnect — the equivalent of the heartbeat's "first action after wake" on the UI side.
|
|
500
|
+
function wake(){render();stream();}
|
|
501
|
+
document.addEventListener('visibilitychange',()=>{if(!document.hidden)wake();});
|
|
502
|
+
addEventListener('focus',wake);
|
|
503
|
+
addEventListener('online',wake);
|
|
470
504
|
</script>
|
|
471
505
|
</body></html>
|