trantor 0.17.39 → 0.17.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/bin/baton.mjs +1 -1
- package/hooks/handoff-now.mjs +3 -1
- package/hooks/heartbeat.mjs +2 -1
- package/hooks/hooks.json +2 -1
- package/hooks/inbox-deliver.mjs +147 -0
- package/hooks/lib/handoff.mjs +14 -1
- package/hooks/precompact.mjs +1 -1
- package/hub.mjs +0 -0
- package/package.json +2 -2
- package/ui.html +2 -1
|
@@ -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.41"
|
|
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.41",
|
|
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.41",
|
|
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/baton.mjs
CHANGED
|
@@ -30,7 +30,7 @@ function findTranscript() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const transcript = findTranscript();
|
|
33
|
-
const { file } = writeHandoff({ projectDir: cwd, sessionId: "", transcript, trigger: "manual-cli" });
|
|
33
|
+
const { file } = writeHandoff({ projectDir: cwd, sessionId: "", transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
|
|
34
34
|
console.log(`📋 handoff saved for ${project}: ${file}`);
|
|
35
35
|
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
36
36
|
console.log(spawned
|
package/hooks/handoff-now.mjs
CHANGED
|
@@ -12,7 +12,9 @@ import { basename } from "node:path";
|
|
|
12
12
|
const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn", windowId = "", tty = ""] = process.argv;
|
|
13
13
|
try {
|
|
14
14
|
const conf = readConfig();
|
|
15
|
-
const
|
|
15
|
+
const result = writeHandoff({ projectDir, sessionId, transcript, trigger }); // auto path — honors the hub storm guard
|
|
16
|
+
if (result.skipped) { process.stderr.write(`[trantor] handoff SKIPPED by storm-guard (${result.reason}; ${result.sinceSec ?? "?"}s since last) — no fresh window spawned\n`); process.exit(0); }
|
|
17
|
+
const { file, record } = result;
|
|
16
18
|
process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
|
|
17
19
|
await pingBus(basename(projectDir), record.id, conf);
|
|
18
20
|
if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { readConfig, contextUsage, warnFrac, alreadyHandedOff, markHandedOff, controllingTty, terminalWindowForTty, subagentsActive } from "./lib/handoff.mjs";
|
|
22
22
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
|
+
import { installedVersion } from "./lib/update-check.mjs"; // report our hook version so the hub can flag stale sessions
|
|
23
24
|
|
|
24
25
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
25
26
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
@@ -121,7 +122,7 @@ async function main(stdinRaw) {
|
|
|
121
122
|
await fetch(`${relayUrl()}/register`, {
|
|
122
123
|
method: "POST",
|
|
123
124
|
headers: { "content-type": "application/json" },
|
|
124
|
-
body: JSON.stringify({ session, project }),
|
|
125
|
+
body: JSON.stringify({ session, project, hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() }),
|
|
125
126
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
126
127
|
});
|
|
127
128
|
} catch {}
|
package/hooks/hooks.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
|
|
2
|
+
"description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
|
|
6
6
|
],
|
|
7
7
|
"PostToolUse": [
|
|
8
8
|
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
|
|
9
|
+
{ "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/inbox-deliver.mjs" } ] },
|
|
9
10
|
{ "matcher": "TodoWrite", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs" } ] }
|
|
10
11
|
],
|
|
11
12
|
"PreCompact": [
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor PostToolUse inbox delivery — surface bus messages to a BUSY session.
|
|
3
|
+
//
|
|
4
|
+
// The bug this fixes: Trantor message delivery is pure pull-on-demand. relay_send only
|
|
5
|
+
// enqueues on the hub; the recipient learns of a message ONLY when the model itself
|
|
6
|
+
// chooses to call relay_inbox/relay_wait. A session grinding through a long tool-use loop
|
|
7
|
+
// never makes that choice, so it sits "online" (the heartbeat keeps lastSeen fresh) but
|
|
8
|
+
// DEAF — a peer can ping it twice over 10 minutes and get no reply. (Observed 2026-06-23:
|
|
9
|
+
// a new session pinged a mid-build sibling; the sibling never answered because it was busy
|
|
10
|
+
// and never polled.)
|
|
11
|
+
//
|
|
12
|
+
// The fix, hook-side (don't trust the model to poll): a busy session IS firing tool calls,
|
|
13
|
+
// so this PostToolUse hook runs constantly. Each run polls /inbox and injects any NEW peer
|
|
14
|
+
// messages via hookSpecificOutput.additionalContext — which Claude Code delivers as a
|
|
15
|
+
// system reminder the model acts on IN THE SAME TURN, between its own tool calls. So a ping
|
|
16
|
+
// lands within a few seconds even mid-build, and the model can reply via relay_send without
|
|
17
|
+
// waiting for the human to prompt it.
|
|
18
|
+
//
|
|
19
|
+
// Cheap + fail-silent by contract: a per-session poll stamp gates the network call, a short
|
|
20
|
+
// fetch timeout means we never add real latency, and we ALWAYS exit clean with valid stdout.
|
|
21
|
+
// First run initialises the cursor to "now" (current max id) and injects NOTHING, so a
|
|
22
|
+
// session is never flooded with the whole backlog of old broadcasts on its first tool call.
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
27
|
+
|
|
28
|
+
const POLL_MS = Number(process.env.RELAY_INBOX_POLL_MS || 4000);
|
|
29
|
+
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
30
|
+
|
|
31
|
+
function relayUrl() {
|
|
32
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
33
|
+
try {
|
|
34
|
+
const cfg = join(homedir(), ".agent-bus", "config.json");
|
|
35
|
+
if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
|
|
36
|
+
} catch {}
|
|
37
|
+
return "http://127.0.0.1:4477";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Keep injected text safe to embed in JSON: drop control chars that could corrupt the
|
|
41
|
+
// additionalContext payload (the model still gets the readable message).
|
|
42
|
+
function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
|
|
43
|
+
|
|
44
|
+
async function getInbox(url, session, since) {
|
|
45
|
+
const r = await fetch(`${url}/inbox?session=${encodeURIComponent(session)}&since=${since}`,
|
|
46
|
+
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
47
|
+
if (!r.ok) throw new Error(`hub ${r.status}`);
|
|
48
|
+
return r.json(); // { messages: [...], cursor }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
|
|
52
|
+
// a large tool input (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's
|
|
53
|
+
// write if nobody reads. Consume + discard, with a short timeout so we never hang.
|
|
54
|
+
function drainStdin() {
|
|
55
|
+
return new Promise(res => {
|
|
56
|
+
try { process.stdin.resume(); process.stdin.on("data", () => {}); process.stdin.on("end", res); }
|
|
57
|
+
catch { res(); }
|
|
58
|
+
setTimeout(res, 80);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Self-validating stdout: model-facing additionalContext only when we actually deliver.
|
|
63
|
+
function emit(ctx) {
|
|
64
|
+
if (!ctx) return "{}";
|
|
65
|
+
const obj = { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx } };
|
|
66
|
+
const out = JSON.stringify(obj);
|
|
67
|
+
try { JSON.parse(out); return out; } catch { return "{}"; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
72
|
+
// Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
|
|
73
|
+
// isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
|
|
74
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return "{}";
|
|
75
|
+
|
|
76
|
+
// Resolve THIS session's identity EXACTLY as mcp.mjs / heartbeat.mjs do, so we poll the
|
|
77
|
+
// same peer the relay registered (RELAY_SESSION wins; else RELAY_AGENT brand; else host:project).
|
|
78
|
+
const project = resolveProject(projectDir);
|
|
79
|
+
const session = process.env.RELAY_SESSION
|
|
80
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
81
|
+
|
|
82
|
+
const safe = session.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
83
|
+
const dir = join(homedir(), ".agent-bus");
|
|
84
|
+
const pollStamp = join(dir, `inbox-poll-${safe}.stamp`);
|
|
85
|
+
const cursorFile = join(dir, `inbox-cursor-${safe}.id`);
|
|
86
|
+
|
|
87
|
+
// Throttle: poll the hub at most once per POLL_MS. Write the stamp BEFORE the network call
|
|
88
|
+
// so a burst of parallel tool calls doesn't all fire (and double-deliver).
|
|
89
|
+
try {
|
|
90
|
+
if (existsSync(pollStamp)) {
|
|
91
|
+
const last = Number(readFileSync(pollStamp, "utf8")) || 0;
|
|
92
|
+
if (Date.now() - last < POLL_MS) return "{}"; // within window — skip
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
try { writeFileSync(pollStamp, String(Date.now())); } catch {}
|
|
96
|
+
|
|
97
|
+
const url = relayUrl();
|
|
98
|
+
|
|
99
|
+
// First run: no cursor yet. Initialise to the current max deliverable id and inject NOTHING,
|
|
100
|
+
// so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
|
|
101
|
+
if (!existsSync(cursorFile)) {
|
|
102
|
+
try {
|
|
103
|
+
const { cursor } = await getInbox(url, session, 0);
|
|
104
|
+
writeFileSync(cursorFile, String(cursor || 0));
|
|
105
|
+
} catch {}
|
|
106
|
+
return "{}";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let cursor = 0;
|
|
110
|
+
try { cursor = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {}
|
|
111
|
+
|
|
112
|
+
let messages = [], next = cursor;
|
|
113
|
+
try {
|
|
114
|
+
const res = await getInbox(url, session, cursor);
|
|
115
|
+
messages = Array.isArray(res.messages) ? res.messages : [];
|
|
116
|
+
next = res.cursor || cursor;
|
|
117
|
+
} catch { return "{}"; } // hub down / timeout — never block the tool flow
|
|
118
|
+
|
|
119
|
+
if (!messages.length) return "{}";
|
|
120
|
+
|
|
121
|
+
// Advance the cursor immediately so we don't re-inject these on the next tool call.
|
|
122
|
+
try { writeFileSync(cursorFile, String(next)); } catch {}
|
|
123
|
+
|
|
124
|
+
const lines = messages.map(m => {
|
|
125
|
+
const direct = m.to === session;
|
|
126
|
+
const tag = direct ? "📨 DIRECT" : "📣 broadcast";
|
|
127
|
+
const when = (() => { try { return new Date(m.ts).toLocaleTimeString(); } catch { return ""; } })();
|
|
128
|
+
return `- ${tag} from ${sanitize(m.from)}${when ? ` (${when})` : ""}: ${sanitize(m.text)}`;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const ctx =
|
|
132
|
+
`<trantor-inbox count="${messages.length}">\n` +
|
|
133
|
+
`📬 ${messages.length} new bus message(s) arrived while you were working (you did not poll for these — Trantor surfaced them automatically):\n` +
|
|
134
|
+
lines.join("\n") + `\n` +
|
|
135
|
+
`If a peer is asking you something or waiting on you, reply now with the relay_send tool (to their session id). ` +
|
|
136
|
+
`If a message just needs an ack, send a short one. You can keep working after responding.\n` +
|
|
137
|
+
`</trantor-inbox>\n`;
|
|
138
|
+
|
|
139
|
+
return emit(ctx);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Never block or break the tool flow: drain stdin, swallow everything, always emit valid stdout.
|
|
143
|
+
drainStdin()
|
|
144
|
+
.then(main)
|
|
145
|
+
.then(out => { try { process.stdout.write(out || "{}"); } catch {} })
|
|
146
|
+
.catch(() => { try { process.stdout.write("{}"); } catch {} })
|
|
147
|
+
.finally(() => process.exit(0));
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -213,8 +213,21 @@ export function verbatimRecentTail(transcript, chars = 7000) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
// ---- write + announce + spawn ----------------------------------------------
|
|
216
|
-
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary }) {
|
|
216
|
+
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false }) {
|
|
217
217
|
const projectName = basename(projectDir);
|
|
218
|
+
// Server-side storm guard: a session running OLD hooks (before the local markHandedOff guard) re-fires
|
|
219
|
+
// context-warn handoffs every few minutes — the crebral-cortex storm (9 in 49 min, each spawning a
|
|
220
|
+
// window). Ask the hub for clearance (rate-limit per project+session); a non-forced handoff inside the
|
|
221
|
+
// cooldown is SKIPPED — no file, no spawn. Manual (/trantor:handoff) + at-wall (precompact) handoffs
|
|
222
|
+
// force through. Fail-OPEN if the hub is unreachable, so a legit handoff is never blocked.
|
|
223
|
+
if (!force) {
|
|
224
|
+
try {
|
|
225
|
+
const body = JSON.stringify({ project: projectName, session: sessionId || "", trigger: trigger || "auto" });
|
|
226
|
+
const out = execSync(`curl -s --max-time 2 -X POST -H 'content-type: application/json' -d ${JSON.stringify(body)} ${JSON.stringify(relayUrl() + "/handoff")}`, { encoding: "utf8", timeout: 2500 });
|
|
227
|
+
const r = JSON.parse(out);
|
|
228
|
+
if (r && r.allow === false) return { skipped: true, reason: r.reason || "storm-guard", sinceSec: r.sinceSec };
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
218
231
|
if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
|
|
219
232
|
const stamp = nowSec() || Date.now();
|
|
220
233
|
let gitStatus = "";
|
package/hooks/precompact.mjs
CHANGED
|
@@ -25,7 +25,7 @@ try {
|
|
|
25
25
|
const sessionId = input.session_id || "";
|
|
26
26
|
const conf = readConfig();
|
|
27
27
|
|
|
28
|
-
const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
|
|
28
|
+
const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger, force: true }); // at-wall backstop — must never be storm-guard-suppressed
|
|
29
29
|
process.stderr.write(`[trantor] handoff written: ${file} (trigger=${trigger})\n`);
|
|
30
30
|
|
|
31
31
|
await pingBus(projectName, record.id, conf);
|
package/hub.mjs
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.41",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-balances.mjs && node test-subagent-cost.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs"
|
|
14
14
|
},
|
|
15
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).",
|
|
16
16
|
"files": [
|
package/ui.html
CHANGED
|
@@ -53,6 +53,7 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
53
53
|
.agents{display:flex;gap:6px;flex-wrap:wrap}
|
|
54
54
|
.agent{display:flex;align-items:center;gap:5px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2px 9px 2px 6px;font-size:11.5px;color:var(--mut)}
|
|
55
55
|
.agent .nm{color:var(--tx)}
|
|
56
|
+
.agent .stale{font-size:9.5px;font-weight:700;color:#ffb454;background:#2a1f10;border:1px solid #5a3c1a;border-radius:7px;padding:1px 5px;cursor:help}
|
|
56
57
|
.agent svg{flex:none}
|
|
57
58
|
.agent.offl{opacity:.42}
|
|
58
59
|
.agent.err{border-color:#ef4444;color:#ef4444}
|
|
@@ -768,7 +769,7 @@ async function render(){
|
|
|
768
769
|
const pmsgs = msgs.filter(m=>projOf(m)===p.project);
|
|
769
770
|
const done=pt.filter(t=>t.status==='done').length;
|
|
770
771
|
const pct=pt.length?Math.round(done/pt.length*100):0;
|
|
771
|
-
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('');
|
|
772
|
+
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:''}${a.staleHooks?' · OLD Trantor hooks ('+esc(a.hookVersion)+') — restart this session to get the baton safety fixes':''}">${iconFor(a.session,15)}<span class="nm">${esc(a.session)}</span>${a.staleHooks?` <span class="stale" title="running old Trantor hooks (${esc(a.hookVersion)}) — restart to load the baton storm/kill safety fixes">⚠ old hooks</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('');
|
|
772
773
|
const cols=COLS.map(([k,label])=>{
|
|
773
774
|
let cards=pt.filter(t=>k==='testing'?(t.status==='testing'||t.status==='failed'):t.status===k);
|
|
774
775
|
if(k==='done')cards=[...cards].sort((a,b)=>(b.updated||0)-(a.updated||0)); // newest finished on top
|