trantor 0.17.1 → 0.17.3

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.1"
9
+ "version": "0.17.3"
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.1",
16
+ "version": "0.17.3",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
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
- model: r.model || (["scrooge", "orchestrator"].includes(r.executor) ? undefined : `${r.executor}-default`),
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": {
@@ -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 qualify bare ids for the deepseek/opencode agents
52
- if (MODEL && !MODEL.includes("/") && (AGENT === "deepseek" || AGENT === "opencode")) MODEL = `deepseek/${MODEL}`;
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
- const r = spawnSync("/bin/bash", ["-c", cli.sid ? `${cmd} | tee /dev/stderr` : cmd], {
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(); runTurn(prompt + LESSONS, false, direct.length ? "direct message" : "@mention");
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
  })();
@@ -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
- const START = Date.now();
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() - START < TIMEOUT * 1000 && up.size < want.size) {
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 >= START) up.add(p.session);
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
- [ "$CMD" != "up" ] && { echo "usage: crew.sh up <agent...> | crew.sh down"; exit 1; }
48
- [ $# -eq 0 ] && { echo "usage: crew.sh up codex gemini kimi deepseek (any subset; agent:model pins a model, e.g. deepseek:deepseek-v4-pro)"; exit 1; }
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
- MODEL=""; [ "$SPEC" != "$AGENT" ] && MODEL="${SPEC#*:}"
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' >> "$STATE" 2>/dev/null && echo " → $AGENT window spawned" || echo " ✗ $AGENT osascript spawn ERROR"
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
- echo "— retrying failed spawns: $RETRY —"
114
- spawn_grid $RETRY
115
- VER2=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $RETRY --timeout 30)
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
@@ -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
@@ -11,7 +11,7 @@ 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 });
@@ -51,6 +51,15 @@ function touch(session, status, project) {
51
51
  if (!p.project && session.includes(":")) p.project = session.split(":").pop().slice(0, 80);
52
52
  state.peers[session] = p; dirty = true;
53
53
  }
54
+ // Derive a coarse health from the free-text status the runner sets on a failed turn
55
+ // ("errored: <reason>" / "down: <reason>") — lets the board show a failing-but-alive agent
56
+ // distinctly instead of a healthy green. Default "ok".
57
+ function healthOf(status) {
58
+ const s = String(status || "").toLowerCase();
59
+ if (s.startsWith("down")) return "down";
60
+ if (s.startsWith("errored")) return "errored";
61
+ return "ok";
62
+ }
54
63
  function deliverable(m, session) { return (m.to === session || m.to === "all") && m.from !== session; }
55
64
  function pushToStreams(msg) {
56
65
  for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
@@ -63,7 +72,7 @@ const server = http.createServer(async (req, res) => {
63
72
  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
73
  if (req.method === "GET" && P === "/peers") {
65
74
  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 || "" })) });
75
+ 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
76
  }
68
77
  // --- Kanban tasks ---
69
78
  if (req.method === "POST" && P === "/task") { // create a card
@@ -124,7 +133,7 @@ const server = http.createServer(async (req, res) => {
124
133
  const proj = p => p || "(unassigned)";
125
134
  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
135
  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 || "" });
136
+ 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
137
  if ((v.lastSeen || 0) > e.lastActivity) e.lastActivity = v.lastSeen;
129
138
  }
130
139
  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; }
package/mcp.mjs CHANGED
@@ -103,7 +103,12 @@ server.tool("relay_board", "Show THIS project's Kanban board (all cards + their
103
103
 
104
104
  server.tool("relay_peers", "List other Claude sessions connected to the relay (online in last 5 min).", {}, async () => {
105
105
  const { peers } = await api("GET", "/peers");
106
- const lines = peers.map(p => `${p.online ? "🟢" : "⚪"} ${p.session}${p.session === SESSION ? " (you)" : ""}`);
106
+ const lines = peers.map(p => {
107
+ // health surfaces a failing-but-alive agent (runner-reported) — not a green lie
108
+ const icon = !p.online ? "⚪" : p.health === "down" ? "🛑" : p.health === "errored" ? "🔴" : "🟢";
109
+ const note = (p.health === "errored" || p.health === "down") && p.status ? ` — ${p.status}` : "";
110
+ return `${icon} ${p.session}${p.session === SESSION ? " (you)" : ""}${note}`;
111
+ });
107
112
  return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
108
113
  });
109
114
 
@@ -152,5 +157,18 @@ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next me
152
157
  });
153
158
 
154
159
  await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}` }).catch(() => {});
160
+
161
+ // Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
162
+ // Registration alone decays after the hub's online window (5 min); without this, idle agents
163
+ // — and EVERY agent after the laptop sleeps (dead connection, no resume event) — fall off the
164
+ // board while their process is still alive. This is the UNIVERSAL counterpart to the Claude-only
165
+ // PostToolUse heartbeat hook: it runs inside the relay every agent loads (Claude, codex, gemini,
166
+ // kimi, deepseek), so the whole crew stays tracked. We POST /register with NO status, so the
167
+ // hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
168
+ // sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
169
+ // still exit cleanly when the agent closes the stdio transport (no phantom peers).
170
+ const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
171
+ setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
172
+
155
173
  await server.connect(new StdioServerTransport());
156
- process.stderr.write(`[trantor-mcp] connected as ${SESSION} -> ${URL_BASE}\n`);
174
+ 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.1",
3
+ "version": "0.17.3",
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 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).",
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 */
@@ -385,7 +389,7 @@ async function render(){
385
389
  const pt=tasks.filter(t=>t.project===p.project);
386
390
  const done=pt.filter(t=>t.status==='done').length;
387
391
  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('');
392
+ 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
393
  const cols=COLS.map(([k,label])=>{
390
394
  let cards=pt.filter(t=>k==='testing'?(t.status==='testing'||t.status==='failed'):t.status===k);
391
395
  if(k==='done')cards=[...cards].sort((a,b)=>(b.updated||0)-(a.updated||0)); // newest finished on top
@@ -458,14 +462,25 @@ function addMsg(m,count=true){
458
462
  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
463
  const f=$('#feed');const stick=f.scrollHeight-f.scrollTop-f.clientHeight<50;f.appendChild(d);if(stick)f.scrollTop=f.scrollHeight;
460
464
  }
461
- function stream(){const ev=new EventSource('/stream?session=all');
465
+ let _ev=null;
466
+ function stream(){
467
+ try{_ev&&_ev.close();}catch(_){} // drop any prior connection before reopening (no duplicate streams)
468
+ const ev=new EventSource('/stream?session=all');_ev=ev;
462
469
  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);};}
470
+ ev.onerror=()=>{$('#livedot').classList.add('off');setTimeout(()=>{ev.close();if(_ev===ev)stream();$('#livedot').classList.remove('off');},2000);};}
464
471
  $('#send').onclick=async()=>{const t=$('#text').value.trim();if(!t)return;
465
472
  await fetch('/send',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({from:'dashboard',to:$('#to').value,text:t})});$('#text').value='';};
466
473
  $('#text').addEventListener('keydown',e=>{if(e.key==='Enter')$('#send').click();});
467
474
  fetch('/recent?limit=40').then(r=>r.json()).then(d=>(d.messages||[]).forEach(m=>addMsg(m,false))).catch(()=>{});
468
475
  $('#hub').textContent=location.host;
469
476
  render();setInterval(render,2500);stream();
477
+ // Self-heal after the laptop sleeps / the tab is backgrounded: browser timers and the SSE
478
+ // stream get suspended and don't reliably resume, so the board freezes on stale data. The
479
+ // moment the tab becomes visible / focused / regains network, force an immediate refresh +
480
+ // stream reconnect — the equivalent of the heartbeat's "first action after wake" on the UI side.
481
+ function wake(){render();stream();}
482
+ document.addEventListener('visibilitychange',()=>{if(!document.hidden)wake();});
483
+ addEventListener('focus',wake);
484
+ addEventListener('online',wake);
470
485
  </script>
471
486
  </body></html>