trantor 0.17.5 → 0.17.7
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/doctor.mjs +10 -1
- package/hooks/sessionstart.mjs +4 -2
- package/hub.mjs +95 -32
- package/mcp.mjs +26 -13
- package/package.json +1 -1
- package/ui.html +53 -12
|
@@ -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.7"
|
|
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.7",
|
|
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.7",
|
|
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/doctor.mjs
CHANGED
|
@@ -19,7 +19,7 @@ let issues = 0;
|
|
|
19
19
|
|
|
20
20
|
console.log("TRANTOR DOCTOR\n");
|
|
21
21
|
|
|
22
|
-
// runtime + hub
|
|
22
|
+
// runtime + hub + client version
|
|
23
23
|
console.log("core");
|
|
24
24
|
Number(process.versions.node.split(".")[0]) >= 18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} too old`, "install node >= 18");
|
|
25
25
|
const cfg = read(join(H, ".agent-bus", "config.json")) || {};
|
|
@@ -31,6 +31,15 @@ try {
|
|
|
31
31
|
warn(`hub not reachable at ${HUB}`, `bash ${join(ROOT, "deploy", "setup.sh")} # installs the always-on service`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// version — heartbeat/presence support
|
|
35
|
+
const pkg = read(join(ROOT, "package.json"));
|
|
36
|
+
if (pkg?.version) {
|
|
37
|
+
const min = [0, 17, 0];
|
|
38
|
+
const cur = pkg.version.split(".").map(Number);
|
|
39
|
+
const tooOld = cur[0] < min[0] || (cur[0] === min[0] && (cur[1] < min[1] || (cur[1] === min[1] && cur[2] < min[2])));
|
|
40
|
+
tooOld ? warn(`trantor v${pkg.version} too old — heartbeat/presence requires v0.17.0+`, "npm update -g trantor") : ok(`trantor v${pkg.version}`);
|
|
41
|
+
} else warn("could not read trantor version", "reinstall: npm install -g trantor");
|
|
42
|
+
|
|
34
43
|
// claude plugin
|
|
35
44
|
console.log("\nclaude (the orchestrator)");
|
|
36
45
|
if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -69,11 +69,13 @@ try {
|
|
|
69
69
|
process.stdout.write("{}");
|
|
70
70
|
process.exit(0);
|
|
71
71
|
}
|
|
72
|
-
const
|
|
72
|
+
const project = process.env.RELAY_PROJECT || basename(projectDir);
|
|
73
|
+
const session = process.env.RELAY_SESSION
|
|
74
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
|
|
73
75
|
const url = relayUrl();
|
|
74
76
|
|
|
75
77
|
// register self + post an initial presence status (no LLM turn — instant for others to read)
|
|
76
|
-
await jpost(`${url}/register`, { session, project
|
|
78
|
+
await jpost(`${url}/register`, { session, project, status: `active in ${project}` }).catch(() => {});
|
|
77
79
|
|
|
78
80
|
// fetch roster of OTHER online sessions
|
|
79
81
|
let peers = [];
|
package/hub.mjs
CHANGED
|
@@ -14,6 +14,9 @@ const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
|
14
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
|
+
const PEER_TTL_DEFAULT_MS = 21600000; // 6h
|
|
18
|
+
const _peerTtlRaw = Number(process.env.RELAY_PEER_TTL_MS || PEER_TTL_DEFAULT_MS);
|
|
19
|
+
const PEER_TTL_MS = Math.max(Number.isFinite(_peerTtlRaw) ? _peerTtlRaw : PEER_TTL_DEFAULT_MS, ONLINE_MS);
|
|
17
20
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
18
21
|
|
|
19
22
|
// Scrooge ledger cache: /economics is polled every ~15s by the dashboard, but the ledger
|
|
@@ -47,11 +50,11 @@ function scanTelemetry() {
|
|
|
47
50
|
|
|
48
51
|
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
49
52
|
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
50
|
-
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [] };
|
|
53
|
+
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [] };
|
|
51
54
|
try {
|
|
52
55
|
if (existsSync(DATA)) {
|
|
53
56
|
const loaded = JSON.parse(readFileSync(DATA, "utf8"));
|
|
54
|
-
state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [] };
|
|
57
|
+
state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [] };
|
|
55
58
|
for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
|
|
56
59
|
state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
|
|
57
60
|
}
|
|
@@ -59,6 +62,15 @@ try {
|
|
|
59
62
|
let dirty = false;
|
|
60
63
|
const persist = () => { if (dirty) { try { writeFileSync(DATA, JSON.stringify(state)); dirty = false; } catch {} } };
|
|
61
64
|
setInterval(persist, 1000).unref?.();
|
|
65
|
+
function prunePeers() {
|
|
66
|
+
const cutoff = now() - PEER_TTL_MS;
|
|
67
|
+
let removed = false;
|
|
68
|
+
for (const [session, peer] of Object.entries(state.peers)) {
|
|
69
|
+
if ((peer.lastSeen || 0) < cutoff) { delete state.peers[session]; removed = true; }
|
|
70
|
+
}
|
|
71
|
+
if (removed) dirty = true;
|
|
72
|
+
}
|
|
73
|
+
setInterval(prunePeers, 60000).unref?.();
|
|
62
74
|
|
|
63
75
|
// dashboard HTML (read once at startup)
|
|
64
76
|
let UI = "";
|
|
@@ -93,6 +105,23 @@ function deliverable(m, session) { return (m.to === session || m.to === "all") &
|
|
|
93
105
|
function pushToStreams(msg) {
|
|
94
106
|
for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
|
|
95
107
|
}
|
|
108
|
+
function appendCardEvent(type, task, by, from = null, to = null) {
|
|
109
|
+
const last = state.cardEvents[state.cardEvents.length - 1];
|
|
110
|
+
state.cardEvents.push({
|
|
111
|
+
id: (last?.id || 0) + 1,
|
|
112
|
+
ts: now(),
|
|
113
|
+
type,
|
|
114
|
+
taskId: task.id,
|
|
115
|
+
project: task.project,
|
|
116
|
+
title: task.title,
|
|
117
|
+
from,
|
|
118
|
+
to,
|
|
119
|
+
by: by || "",
|
|
120
|
+
difficulty: task.difficulty || null,
|
|
121
|
+
assignee: task.assignee || null,
|
|
122
|
+
});
|
|
123
|
+
if (state.cardEvents.length > 5000) state.cardEvents.splice(0, state.cardEvents.length - 5000);
|
|
124
|
+
}
|
|
96
125
|
|
|
97
126
|
const server = http.createServer(async (req, res) => {
|
|
98
127
|
const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
|
|
@@ -100,6 +129,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
100
129
|
if (req.method === "POST" && P === "/register") { const b = await body(req); touch(b.session, b.status, b.project); return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) }); }
|
|
101
130
|
if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project); return json(res, 200, { ok: true }); }
|
|
102
131
|
if (req.method === "GET" && P === "/peers") {
|
|
132
|
+
prunePeers();
|
|
103
133
|
const cutoff = now() - ONLINE_MS;
|
|
104
134
|
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 || "" })) });
|
|
105
135
|
}
|
|
@@ -115,12 +145,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
115
145
|
by: b.by || "", ts: now(), updated: now(),
|
|
116
146
|
history: [{ to: st0, by: b.by || "", ts: now() }] };
|
|
117
147
|
state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
|
|
148
|
+
appendCardEvent("created", t, b.by, null, st0);
|
|
118
149
|
dirty = true; return json(res, 200, { ok: true, task: t });
|
|
119
150
|
}
|
|
120
151
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
121
152
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
122
153
|
if (!t) return json(res, 404, { error: "no such task" });
|
|
154
|
+
let eventType = "updated", eventFrom = null, eventTo = null;
|
|
123
155
|
if (b.status && ["todo","doing","testing","failed","done","blocked"].includes(b.status) && b.status !== t.status) {
|
|
156
|
+
eventType = "moved"; eventFrom = t.status; eventTo = b.status;
|
|
124
157
|
(t.history ||= []).push({ from: t.status, to: b.status, by: b.by || "", ts: now() });
|
|
125
158
|
if (t.history.length > 40) t.history.splice(0, 10);
|
|
126
159
|
t.status = b.status;
|
|
@@ -130,13 +163,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
130
163
|
if (Array.isArray(b.deps)) t.deps = [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0 && n !== t.id))].slice(0, 20);
|
|
131
164
|
if (b.assignee !== undefined) t.assignee = b.assignee;
|
|
132
165
|
if (b.title !== undefined) t.title = String(b.title).slice(0,200);
|
|
133
|
-
if (b.delete) state.tasks = state.tasks.filter(x => x.id !== t.id);
|
|
166
|
+
if (b.delete) { eventType = "deleted"; eventFrom = null; eventTo = null; state.tasks = state.tasks.filter(x => x.id !== t.id); }
|
|
167
|
+
appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
|
|
134
168
|
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
135
169
|
}
|
|
136
170
|
if (req.method === "GET" && P === "/tasks") {
|
|
137
171
|
const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
|
|
138
172
|
return json(res, 200, { tasks: ts });
|
|
139
173
|
}
|
|
174
|
+
if (req.method === "GET" && P === "/history") {
|
|
175
|
+
const requestedLimit = Number(q.limit || 200);
|
|
176
|
+
const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 200, 0), 1000);
|
|
177
|
+
const events = (q.project ? state.cardEvents.filter(e => e.project === q.project) : state.cardEvents).slice(-limit);
|
|
178
|
+
return json(res, 200, { events });
|
|
179
|
+
}
|
|
140
180
|
if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
|
|
141
181
|
const b = await body(req); const k = String(b.project || "").slice(0, 80);
|
|
142
182
|
if (!k) return json(res, 400, { error: "project required" });
|
|
@@ -158,6 +198,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
158
198
|
return json(res, 200, { ok: true, project: k, removed: { tasks: nt - state.tasks.length, peers: np - Object.keys(state.peers).length, messages: nm - state.messages.length } });
|
|
159
199
|
}
|
|
160
200
|
if (req.method === "GET" && P === "/projects") { // project-grouped view
|
|
201
|
+
prunePeers();
|
|
161
202
|
const cutoff = now() - ONLINE_MS; const byProj = {};
|
|
162
203
|
const proj = p => p || "(unassigned)";
|
|
163
204
|
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 });
|
|
@@ -250,7 +291,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
250
291
|
// ts must not throw (new Date(NaN).toISOString() does) and 500 the whole endpoint — return null
|
|
251
292
|
// and let callers skip that day-bucket.
|
|
252
293
|
const dayOf = ts => { const n = Number(ts); if (!n) return null; const d = new Date(n > 2e10 ? n : n * 1000); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
|
|
253
|
-
const
|
|
294
|
+
const ALL = "*"; // the cross-project ("All projects") bucket
|
|
295
|
+
const out = { totals: {}, lessons: { global: [], byAgent: {}, byProject: {}, projects: [] }, agents: [], agentsByProject: {}, models: [], modelsByProject: {} };
|
|
254
296
|
|
|
255
297
|
// relay lessons → global / by-agent / by-project (project derived from the recorder's session id)
|
|
256
298
|
const projSet = new Set();
|
|
@@ -259,55 +301,76 @@ const server = http.createServer(async (req, res) => {
|
|
|
259
301
|
if (l.scope === "global") out.lessons.global.push(rec); else (out.lessons.byAgent[l.scope] ||= []).push(rec);
|
|
260
302
|
if (rec.project) { (out.lessons.byProject[rec.project] ||= []).push(rec); projSet.add(rec.project); }
|
|
261
303
|
}
|
|
262
|
-
out.lessons.projects = [...projSet].sort();
|
|
263
304
|
|
|
264
|
-
// per-LLM reliability from turn telemetry
|
|
305
|
+
// per-LLM reliability from turn telemetry, bucketed BY PROJECT (+ a global ALL bucket) so the
|
|
306
|
+
// sidebar's project filter scopes the charts. Each turn carries its own project.
|
|
265
307
|
const turns = scanTelemetry();
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
308
|
+
const relAgg = {}; // scope -> agent -> {turns,failures,models:Set,lastFailure,days}
|
|
309
|
+
const scopeModels = {}; // scope -> Set(model used)
|
|
310
|
+
let totalTurns = 0, totalFails = 0;
|
|
311
|
+
const bumpRel = (scope, t) => {
|
|
312
|
+
const a = ((relAgg[scope] ||= {})[t.agent] ||= { agent: t.agent, turns: 0, failures: 0, models: new Set(), lastFailure: null, days: {} });
|
|
313
|
+
a.turns++;
|
|
314
|
+
if (t.model) { a.models.add(t.model); if (t.model !== "default") (scopeModels[scope] ||= new Set()).add(t.model); }
|
|
271
315
|
const dk = dayOf(t.ts); const d = dk ? (a.days[dk] ||= { turns: 0, failures: 0 }) : null; if (d) d.turns++;
|
|
272
|
-
if (t.exit && t.exit !== 0) { a.failures++;
|
|
316
|
+
if (t.exit && t.exit !== 0) { a.failures++; if (d) d.failures++; if (!a.lastFailure || t.ts > a.lastFailure.ts) a.lastFailure = { ts: t.ts, exit: t.exit, project: t.project || "" }; }
|
|
317
|
+
};
|
|
318
|
+
for (const t of turns) {
|
|
319
|
+
if (!t.agent) continue;
|
|
320
|
+
totalTurns++; if (t.exit && t.exit !== 0) totalFails++;
|
|
321
|
+
bumpRel(ALL, t);
|
|
322
|
+
if (t.project) { bumpRel(t.project, t); projSet.add(t.project); }
|
|
273
323
|
}
|
|
274
|
-
// lessons-accumulated-over-time per
|
|
275
|
-
const
|
|
276
|
-
for (const l of state.lessons) {
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
324
|
+
// lessons-accumulated-over-time per scope -> agent brand -> day (agent-scoped lessons only)
|
|
325
|
+
const lessonAgg = {};
|
|
326
|
+
for (const l of state.lessons) {
|
|
327
|
+
if (l.scope === "global") continue; const d = dayOf(l.ts); if (!d) continue;
|
|
328
|
+
const bump = scope => { (((lessonAgg[scope] ||= {})[l.scope] ||= {})[d]) = (lessonAgg[scope][l.scope][d] || 0) + 1; };
|
|
329
|
+
bump(ALL); const p = projOf(l.by); if (p) bump(p);
|
|
330
|
+
}
|
|
331
|
+
const buildAgents = scope => Object.values(relAgg[scope] || {}).sort((a, b) => b.turns - a.turns).map(a => {
|
|
332
|
+
const days = Object.keys(a.days).sort(); let cum = 0; const ld = (lessonAgg[scope] || {})[a.agent] || {};
|
|
333
|
+
return { agent: a.agent, turns: a.turns, failures: a.failures, failRate: a.turns ? +(a.failures / a.turns).toFixed(3) : 0,
|
|
282
334
|
lastFailure: a.lastFailure, models: [...a.models],
|
|
283
335
|
series: {
|
|
284
336
|
failRate: days.map(d => ({ day: d, turns: a.days[d].turns, failures: a.days[d].failures, rate: a.days[d].turns ? +(a.days[d].failures / a.days[d].turns).toFixed(3) : 0 })),
|
|
285
337
|
lessons: Object.keys(ld).sort().map(d => ({ day: d, count: (cum += ld[d]) })),
|
|
286
|
-
}
|
|
287
|
-
};
|
|
338
|
+
} };
|
|
288
339
|
});
|
|
289
340
|
|
|
290
|
-
// Scrooge guardrails (per model
|
|
341
|
+
// Scrooge guardrails (global per model) + per-model economics from the ledger, bucketed by project
|
|
291
342
|
let guard = {}; try { guard = JSON.parse(readFileSync(join(homedir(), ".token-scrooge", "lessons.json"), "utf8")) || {}; } catch {}
|
|
292
343
|
try { const lp = join(homedir(), ".token-scrooge", "calls.jsonl"); const st = statSync(lp); if (st.mtimeMs !== _ledgerCache.mtimeMs) { const rows = readFileSync(lp, "utf8").trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(c => c && c.ok); _ledgerCache = { mtimeMs: st.mtimeMs, rows }; } } catch {}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
const m = (
|
|
344
|
+
const ledAgg = {}; // scope -> model -> {calls,ti,to,cost,days}
|
|
345
|
+
const bumpLed = (scope, c) => {
|
|
346
|
+
const m = ((ledAgg[scope] ||= {})[c.model] ||= { calls: 0, ti: 0, to: 0, cost: 0, days: {} });
|
|
296
347
|
m.calls++; m.ti += c.tokens_in || 0; m.to += c.tokens_out || 0; m.cost += c.cost_usd || 0;
|
|
297
348
|
const dk = dayOf(c.ts); if (dk) { const d = (m.days[dk] ||= { cost: 0, ti: 0, to: 0 }); d.cost += c.cost_usd || 0; d.ti += c.tokens_in || 0; d.to += c.tokens_out || 0; }
|
|
298
|
-
|
|
349
|
+
(scopeModels[scope] ||= new Set()).add(c.model);
|
|
350
|
+
};
|
|
351
|
+
for (const c of _ledgerCache.rows) { if (!c.model) continue; bumpLed(ALL, c); if (c.project) { bumpLed(c.project, c); projSet.add(c.project); } }
|
|
352
|
+
|
|
299
353
|
const savedOf = (ti, to, cost) => +Math.max(0, ti * 15 / 1e6 + to * 75 / 1e6 - cost).toFixed(2);
|
|
300
354
|
let totalGuardrails = 0;
|
|
301
|
-
const mkModel = (model, g) => {
|
|
355
|
+
const mkModel = (scope, model, g) => {
|
|
302
356
|
const gcount = Object.values(g || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0);
|
|
303
|
-
totalGuardrails += gcount;
|
|
357
|
+
if (scope === ALL) totalGuardrails += gcount; // guardrails are global — count once
|
|
358
|
+
const lm = (ledAgg[scope] || {})[model];
|
|
304
359
|
return { model, guardrails: g || {}, guardrailCount: gcount, calls: lm ? lm.calls : 0, cost_usd: lm ? +lm.cost.toFixed(4) : 0,
|
|
305
360
|
saved_usd: lm ? savedOf(lm.ti, lm.to, lm.cost) : 0,
|
|
306
361
|
series: { saved: lm ? Object.keys(lm.days).sort().map(d => ({ day: d, saved: savedOf(lm.days[d].ti, lm.days[d].to, lm.days[d].cost) })) : [] } };
|
|
307
362
|
};
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
363
|
+
const buildModels = scope => {
|
|
364
|
+
const keys = new Set(scopeModels[scope] || []); // models used in this scope
|
|
365
|
+
if (scope === ALL) for (const k of Object.keys(guard)) if (k !== "*") keys.add(k); // global view also lists every guardrailed model
|
|
366
|
+
const arr = [...keys].sort().map(m => mkModel(scope, m, guard[m]));
|
|
367
|
+
if (guard["*"]) arr.unshift(mkModel(scope, "∗ all models", guard["*"])); // guardrails that apply to every model
|
|
368
|
+
return arr;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
out.lessons.projects = [...projSet].sort();
|
|
372
|
+
out.agents = buildAgents(ALL); out.models = buildModels(ALL);
|
|
373
|
+
for (const p of out.lessons.projects) { out.agentsByProject[p] = buildAgents(p); out.modelsByProject[p] = buildModels(p); }
|
|
311
374
|
|
|
312
375
|
out.totals = { lessons: state.lessons.length, guardrails: totalGuardrails, turns: totalTurns, failures: totalFails, failRate: totalTurns ? +(totalFails / totalTurns).toFixed(3) : 0, models: out.models.length };
|
|
313
376
|
return json(res, 200, out);
|
package/mcp.mjs
CHANGED
|
@@ -161,19 +161,32 @@ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next me
|
|
|
161
161
|
return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(timed out, no message)" }] };
|
|
162
162
|
});
|
|
163
163
|
|
|
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
164
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
176
|
-
|
|
165
|
+
|
|
166
|
+
// Mirror the SessionStart/PostToolUse hooks: a session opened in the home directory itself
|
|
167
|
+
// isn't project work — auto-registering it would spawn a phantom "<username>" project board.
|
|
168
|
+
// Opt in explicitly with RELAY_SESSION or RELAY_PROJECT. The MCP server still starts so the
|
|
169
|
+
// user can call relay tools (e.g. relay_whoami) deliberately; we just skip auto-presence.
|
|
170
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
171
|
+
const isHomeDirSession = !process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir();
|
|
172
|
+
|
|
173
|
+
if (!isHomeDirSession) {
|
|
174
|
+
await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}` })
|
|
175
|
+
.catch((err) => { process.stderr.write(`[trantor-mcp] initial register failed: ${err?.message || err}\n`); });
|
|
176
|
+
|
|
177
|
+
// Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
|
|
178
|
+
// Registration alone decays after the hub's online window (5 min); without this, idle agents
|
|
179
|
+
// — and EVERY agent after the laptop sleeps (dead connection, no resume event) — fall off the
|
|
180
|
+
// board while their process is still alive. This is the UNIVERSAL counterpart to the Claude-only
|
|
181
|
+
// PostToolUse heartbeat hook: it runs inside the relay every agent loads (Claude, codex, gemini,
|
|
182
|
+
// kimi, deepseek), so the whole crew stays tracked. We POST /register with NO status, so the
|
|
183
|
+
// hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
|
|
184
|
+
// sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
|
|
185
|
+
// still exit cleanly when the agent closes the stdio transport (no phantom peers).
|
|
186
|
+
setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
|
|
187
|
+
} else {
|
|
188
|
+
process.stderr.write("[trantor-mcp] home directory — not auto-registering on the bus (set RELAY_SESSION or RELAY_PROJECT to opt in)\n");
|
|
189
|
+
}
|
|
177
190
|
|
|
178
191
|
await server.connect(new StdioServerTransport());
|
|
179
|
-
process.stderr.write(`[trantor-mcp] connected as ${SESSION} -> ${URL_BASE} (heartbeat ${HEARTBEAT_MS}ms)\n`);
|
|
192
|
+
process.stderr.write(`[trantor-mcp] connected as ${SESSION} -> ${URL_BASE}${isHomeDirSession ? " (no auto-presence: home dir)" : ` (heartbeat ${HEARTBEAT_MS}ms)`}\n`);
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -140,6 +140,19 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
140
140
|
.felabel{fill:#7e8ca3;font-size:9px;font-family:ui-monospace,monospace;opacity:0;pointer-events:none;transition:opacity .15s}
|
|
141
141
|
.fe:hover .felabel{opacity:1}
|
|
142
142
|
.fe:hover .fedge{stroke:#5fa8ff}
|
|
143
|
+
/* timeline view */
|
|
144
|
+
.timeline{padding:14px 18px;overflow-y:auto;max-height:66vh;display:flex;flex-direction:column;gap:5px;background:#0c111c;border-top:1px solid var(--line)}
|
|
145
|
+
.tevent{font-size:12.5px;line-height:1.4;color:var(--mut);display:flex;align-items:center;gap:7px;padding:4px 0}
|
|
146
|
+
.tevent .tts{font-family:ui-monospace,monospace;font-size:10px;color:var(--dim);min-width:64px}
|
|
147
|
+
.tevent .tphrase b{color:var(--tx)}
|
|
148
|
+
.tevent .tstatus{font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
|
|
149
|
+
.tevent .tstatus.todo{color:var(--mut)}
|
|
150
|
+
.tevent .tstatus.doing{color:var(--blu)}
|
|
151
|
+
.tevent .tstatus.testing{color:var(--amb)}
|
|
152
|
+
.tevent .tstatus.failed{color:var(--red)}
|
|
153
|
+
.tevent .tstatus.done{color:var(--grn)}
|
|
154
|
+
.tevent .tstatus.blocked{color:var(--red)}
|
|
155
|
+
.tevent .arr{color:var(--dim);margin:0 2px}
|
|
143
156
|
/* per-project inter-agent conversation lane */
|
|
144
157
|
.proj-chat{margin:4px 16px 14px;border:1px solid var(--line);border-radius:10px;background:#0c111c}
|
|
145
158
|
.proj-chat h5{margin:0;padding:7px 11px;font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:6px}
|
|
@@ -250,6 +263,7 @@ async function econ(){
|
|
|
250
263
|
econ();setInterval(econ,15000);
|
|
251
264
|
function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
|
|
252
265
|
const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
|
|
266
|
+
let HISTORY = {};
|
|
253
267
|
function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
|
|
254
268
|
function toggleIdle(name){ if(!name)return; const s=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]")); s.has(name)?s.delete(name):s.add(name); localStorage.setItem("abIdleOpen",JSON.stringify([...s])); render(); }
|
|
255
269
|
let armedDel=null,armedTs=0; // pending ✕ confirmation (project name + when it was armed)
|
|
@@ -405,6 +419,19 @@ function wireFlow(el){
|
|
|
405
419
|
}
|
|
406
420
|
let suppressClickId = null;
|
|
407
421
|
function projOf(m){return m.project||(String(m.from).includes(':')?String(m.from).split(':').pop():'');}
|
|
422
|
+
function timelineHTML(proj) {
|
|
423
|
+
const events = HISTORY[proj] || [];
|
|
424
|
+
if (!events.length) return '<div class="timeline empty">no history yet</div>';
|
|
425
|
+
const rows = events.map(e => {
|
|
426
|
+
const time = new Date(e.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
427
|
+
let phrase = "";
|
|
428
|
+
if (e.type === "created") phrase = `created "<b>${esc(e.title)}</b>" as <span class="tstatus ${esc(e.to)}">${esc(e.to)}</span>`;
|
|
429
|
+
else if (e.type === "moved") phrase = `moved "<b>${esc(e.title)}</b>" <span class="tstatus ${esc(e.from)}">${esc(e.from)}</span> <span class="arr">→</span> <span class="tstatus ${esc(e.to)}">${esc(e.to)}</span>`;
|
|
430
|
+
else phrase = `${esc(e.type)} "<b>${esc(e.title)}</b>"`;
|
|
431
|
+
return `<div class="tevent"><span class="tts">${time}</span>${iconFor(e.by, 13)} <span class="tphrase">${phrase}</span> <span class="dim">by @${esc(String(e.by).split(':')[0])}</span></div>`;
|
|
432
|
+
}).join('');
|
|
433
|
+
return `<div class="timeline" data-proj="${esc(proj)}">${rows}</div>`;
|
|
434
|
+
}
|
|
408
435
|
function chatLane(msgs){
|
|
409
436
|
if(!msgs.length)return `<div class="proj-chat"><h5><span class="lc"></span>conversation</h5><div class="chatempty">no messages yet — agents talk here as they coordinate</div></div>`;
|
|
410
437
|
const rows=msgs.slice(-8).map(m=>`<div class="cmsg ${m.to==='all'?'bc':''}"><span class="ct">${new Date(m.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>${iconFor(m.from,13)}<span class="cf">${esc(String(m.from).split(':')[0])}</span><span class="arr">→</span><span class="cto">${esc(m.to==='all'?'all':String(m.to).split(':')[0])}</span>: ${esc(m.text)}</div>`).join('');
|
|
@@ -415,6 +442,15 @@ async function render(){
|
|
|
415
442
|
try{projects=(await (await fetch('/projects')).json()).projects||[];}catch(e){}
|
|
416
443
|
try{tasks=(await (await fetch('/tasks')).json()).tasks||[];}catch(e){}
|
|
417
444
|
try{msgs=(await (await fetch('/recent?limit=200')).json()).messages||[];}catch(e){}
|
|
445
|
+
|
|
446
|
+
const histP = projects.filter(p => VIEWS[p.project] === "timeline").map(async p => {
|
|
447
|
+
try {
|
|
448
|
+
const h = await (await fetch(`/history?project=${encodeURIComponent(p.project)}`)).json();
|
|
449
|
+
HISTORY[p.project] = h.events || [];
|
|
450
|
+
} catch(e) {}
|
|
451
|
+
});
|
|
452
|
+
await Promise.all(histP);
|
|
453
|
+
|
|
418
454
|
// hub was reset (server empty but feed shows history) -> clear the stale client-side feed
|
|
419
455
|
if(!msgs.length&&$('#feed').childElementCount>0){$('#feed').innerHTML='';nmsg=0;}
|
|
420
456
|
const liveSess=new Set();projects.forEach(p=>p.agents.forEach(a=>{if(a.online)liveSess.add(a.session)}));
|
|
@@ -441,7 +477,8 @@ async function render(){
|
|
|
441
477
|
if(!projects.length){el.innerHTML='<div class="empty big">no projects yet — agents register a project on connect</div>';return;}
|
|
442
478
|
const idleOpen=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]"));
|
|
443
479
|
const projBlock=p=>{
|
|
444
|
-
const pt=tasks.filter(t=>t.project===p.project);
|
|
480
|
+
const pt = tasks.filter(t=>t.project===p.project);
|
|
481
|
+
const pmsgs = msgs.filter(m=>projOf(m)===p.project);
|
|
445
482
|
const done=pt.filter(t=>t.status==='done').length;
|
|
446
483
|
const pct=pt.length?Math.round(done/pt.length*100):0;
|
|
447
484
|
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('');
|
|
@@ -460,15 +497,15 @@ async function render(){
|
|
|
460
497
|
}).join('');
|
|
461
498
|
const ph=p.phase||'';
|
|
462
499
|
const brief=p.brief?`<span class="brief">${esc(p.brief)}</span>`:`<span class="brief dim">— no brief yet · an agent sets it with relay_project_brief</span>`;
|
|
463
|
-
const pmsgs=msgs.filter(m=>projOf(m)===p.project);
|
|
464
500
|
const view = VIEWS[p.project] || "board";
|
|
465
|
-
const vtog = `<div class="vtog"><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button></div>`;
|
|
501
|
+
const vtog = `<div class="vtog"><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button><button class="vbtn ${view==="timeline"?"on":""}" data-proj="${esc(p.project)}" data-view="timeline">TIMELINE</button></div>`;
|
|
466
502
|
const ctl=`<span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
|
|
503
|
+
|
|
467
504
|
return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
|
|
468
505
|
`<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span>${vtog}<div class="agents">${agents||'<span class="dim">no agents</span>'}</div><span class="spacer"></span><span class="prog">${done}/${pt.length} done · ${pct}%</span>${ctl}</div>`+
|
|
469
506
|
`<div class="proj-brief">${brief}${ph?`<span class="phase ${phaseClass(ph)}">${esc(ph)}</span>`:''}</div>`+
|
|
470
507
|
`<div class="pbar"><i style="width:${pct}%"></i></div>`+
|
|
471
|
-
(view === "flow" ? flowHTML(pt, p.project) : `<div class="kanban">${cols}</div>`)+
|
|
508
|
+
(view === "flow" ? flowHTML(pt, p.project) : view === "timeline" ? timelineHTML(p.project) : `<div class="kanban">${cols}</div>`)+
|
|
472
509
|
chatLane(pmsgs)+
|
|
473
510
|
`</div>`;
|
|
474
511
|
};
|
|
@@ -504,6 +541,8 @@ async function render(){
|
|
|
504
541
|
if(armedDel&&Date.now()-armedTs<4000){const ab=el.querySelector(`.pdel[data-proj="${CSS.escape(armedDel)}"]`);if(ab){ab.classList.add('arm');ab.textContent='✕ sure?';}}
|
|
505
542
|
const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
|
|
506
543
|
wireFlow(el);
|
|
544
|
+
// keep each timeline scrolled to the latest event
|
|
545
|
+
el.querySelectorAll('.timeline').forEach(c=>{if(c.scrollHeight-c.clientHeight<60||c.dataset.stuck!=='0')c.scrollTop=c.scrollHeight;c.onscroll=()=>{c.dataset.stuck=(c.scrollHeight-c.scrollTop-c.clientHeight<40)?'1':'0';};});
|
|
507
546
|
el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{
|
|
508
547
|
const id=+c.dataset.id,t=tasks.find(x=>x.id===id);if(!t)return;
|
|
509
548
|
if(suppressClickId&&+suppressClickId===id)return; // drag-end, not a click
|
|
@@ -557,20 +596,22 @@ function renderLearnBody(){const el=$('#learnBody');if(!el)return;if(!LEARN){el.
|
|
|
557
596
|
else h+=lessons.slice(0,40).map(l=>`<div class="lles"><span class="sc">${esc(l.scope)}</span>${esc(l.text)}</div>`).join('');
|
|
558
597
|
if(!learnProj)for(const[ag,arr]of Object.entries(L.lessons.byAgent||{}))h+=`<div class="lles"><span class="sc">${esc(ag)}</span>${esc(arr[0].text)}${arr.length>1?` <span class="dim">+${arr.length-1} more</span>`:''}</div>`;
|
|
559
598
|
h+=`</div>`;
|
|
560
|
-
// Per-LLM reliability + progression charts
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
599
|
+
// Per-LLM reliability + progression charts — scoped to the selected project (or all)
|
|
600
|
+
const scopeLbl=learnProj?` <span class="dim" style="text-transform:none;letter-spacing:0">· ${esc(learnProj)}</span>`:'';
|
|
601
|
+
const ags=learnProj?(L.agentsByProject[learnProj]||[]):(L.agents||[]);
|
|
602
|
+
h+=`<div class="lsec"><h3>Per-LLM reliability${scopeLbl}</h3>`;
|
|
603
|
+
if(!ags.length)h+=`<div class="lempty">no turn telemetry${learnProj?' for this project':''} yet</div>`;
|
|
604
|
+
for(const a of ags){const fr=(a.failRate*100).toFixed(1);
|
|
564
605
|
h+=`<div class="lcard"><div class="lc-h">${iconFor(a.agent,14)}<span class="nm">${esc(a.agent)}</span><span class="mut">${a.models.filter(m=>m!=='default').join(', ')||'default'}</span></div>`;
|
|
565
606
|
h+=`<span class="lstat"><b>${a.turns}</b> turns</span><span class="lstat ${a.failures?'bad':'good'}"><b>${fr}%</b> fail</span>${a.lastFailure?`<span class="lstat">last exit ${a.lastFailure.exit}</span>`:''}`;
|
|
566
607
|
if((a.series.failRate||[]).length>1)h+=`<div class="lchart"><div class="ltask">fail-rate / day</div>${lcLine(a.series.failRate,'rate',{color:LRED,fill:true})}</div>`;
|
|
567
608
|
if((a.series.lessons||[]).length>1)h+=`<div class="lchart"><div class="ltask">lessons learned (cumulative)</div>${lcLine(a.series.lessons,'count',{color:LGRN,fill:true})}</div>`;
|
|
568
609
|
h+=`</div>`;}
|
|
569
610
|
h+=`</div>`;
|
|
570
|
-
// Baked-in guardrails per model + savings chart
|
|
571
|
-
h+=`<div class="lsec"><h3>Baked-in improvements <span class="lbadge">auto-injected into prompts</span></h3>`;
|
|
572
|
-
const gm=(L.models||[]).filter(m=>m.guardrailCount>0);
|
|
573
|
-
if(!gm.length)h+=`<div class="lempty">no
|
|
611
|
+
// Baked-in guardrails per model + savings chart — guardrails are global; savings scoped to the project
|
|
612
|
+
h+=`<div class="lsec"><h3>Baked-in improvements${scopeLbl} <span class="lbadge">auto-injected into prompts</span></h3>`;
|
|
613
|
+
const gm=(learnProj?(L.modelsByProject[learnProj]||[]):(L.models||[])).filter(m=>m.guardrailCount>0);
|
|
614
|
+
if(!gm.length)h+=`<div class="lempty">no guardrailed models${learnProj?' used in this project':''} yet</div>`;
|
|
574
615
|
for(const m of gm){h+=`<div class="lcard"><div class="lc-h"><span class="nm">${esc(m.model)}</span>${m.saved_usd?`<span class="mut">$${m.saved_usd} saved · ${m.calls} calls</span>`:''}</div>`;
|
|
575
616
|
for(const[task,arr]of Object.entries(m.guardrails||{})){h+=`<div class="ltask">${esc(task)}</div>`;for(const g of arr)h+=`<div class="lguard">${esc(g)}</div>`;}
|
|
576
617
|
if((m.series.saved||[]).length>1)h+=`<div class="lchart"><div class="ltask">savings / day</div>${lcBars(m.series.saved,'saved',{color:LGRN})}</div>`;
|