trantor 0.17.6 → 0.17.8
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 +66 -3
- package/mcp.mjs +26 -13
- package/package.json +1 -1
- package/ui.html +43 -4
|
@@ -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.8"
|
|
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.8",
|
|
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.8",
|
|
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: [], cardEventsBackfilled: false };
|
|
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 : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled };
|
|
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,37 @@ 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
|
+
// One-time backfill: reconstruct the cardEvents history log from each card's authoritative per-card
|
|
66
|
+
// `history` trail, so projects that existed BEFORE the cardEvents log show their FULL past in the
|
|
67
|
+
// TIMELINE view (not just events from now on). Guarded by a flag so it runs once where cardEvents
|
|
68
|
+
// persists; in team mode cardEvents is in-memory, so this re-derives from the persisted task.history
|
|
69
|
+
// on every boot — which is exactly right.
|
|
70
|
+
function backfillCardEvents() {
|
|
71
|
+
if (state.cardEventsBackfilled && state.cardEvents.length) return;
|
|
72
|
+
const events = [];
|
|
73
|
+
for (const t of (state.tasks || [])) for (const h of (t.history || [])) {
|
|
74
|
+
events.push({ ts: h.ts || 0, type: h.from ? "moved" : "created", taskId: t.id, project: t.project,
|
|
75
|
+
title: t.title, from: h.from || null, to: h.to || null, by: h.by || "",
|
|
76
|
+
difficulty: t.difficulty || null, assignee: t.assignee || null });
|
|
77
|
+
}
|
|
78
|
+
if (events.length) {
|
|
79
|
+
events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
80
|
+
if (events.length > 5000) events.splice(0, events.length - 5000);
|
|
81
|
+
events.forEach((e, i) => { e.id = i + 1; });
|
|
82
|
+
state.cardEvents = events; dirty = true;
|
|
83
|
+
}
|
|
84
|
+
state.cardEventsBackfilled = true; dirty = true;
|
|
85
|
+
}
|
|
86
|
+
backfillCardEvents();
|
|
87
|
+
function prunePeers() {
|
|
88
|
+
const cutoff = now() - PEER_TTL_MS;
|
|
89
|
+
let removed = false;
|
|
90
|
+
for (const [session, peer] of Object.entries(state.peers)) {
|
|
91
|
+
if ((peer.lastSeen || 0) < cutoff) { delete state.peers[session]; removed = true; }
|
|
92
|
+
}
|
|
93
|
+
if (removed) dirty = true;
|
|
94
|
+
}
|
|
95
|
+
setInterval(prunePeers, 60000).unref?.();
|
|
62
96
|
|
|
63
97
|
// dashboard HTML (read once at startup)
|
|
64
98
|
let UI = "";
|
|
@@ -93,6 +127,23 @@ function deliverable(m, session) { return (m.to === session || m.to === "all") &
|
|
|
93
127
|
function pushToStreams(msg) {
|
|
94
128
|
for (const s of streams) if (deliverable(msg, s.session)) { try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {} }
|
|
95
129
|
}
|
|
130
|
+
function appendCardEvent(type, task, by, from = null, to = null) {
|
|
131
|
+
const last = state.cardEvents[state.cardEvents.length - 1];
|
|
132
|
+
state.cardEvents.push({
|
|
133
|
+
id: (last?.id || 0) + 1,
|
|
134
|
+
ts: now(),
|
|
135
|
+
type,
|
|
136
|
+
taskId: task.id,
|
|
137
|
+
project: task.project,
|
|
138
|
+
title: task.title,
|
|
139
|
+
from,
|
|
140
|
+
to,
|
|
141
|
+
by: by || "",
|
|
142
|
+
difficulty: task.difficulty || null,
|
|
143
|
+
assignee: task.assignee || null,
|
|
144
|
+
});
|
|
145
|
+
if (state.cardEvents.length > 5000) state.cardEvents.splice(0, state.cardEvents.length - 5000);
|
|
146
|
+
}
|
|
96
147
|
|
|
97
148
|
const server = http.createServer(async (req, res) => {
|
|
98
149
|
const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
|
|
@@ -100,6 +151,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
100
151
|
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
152
|
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
153
|
if (req.method === "GET" && P === "/peers") {
|
|
154
|
+
prunePeers();
|
|
103
155
|
const cutoff = now() - ONLINE_MS;
|
|
104
156
|
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
157
|
}
|
|
@@ -115,12 +167,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
115
167
|
by: b.by || "", ts: now(), updated: now(),
|
|
116
168
|
history: [{ to: st0, by: b.by || "", ts: now() }] };
|
|
117
169
|
state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
|
|
170
|
+
appendCardEvent("created", t, b.by, null, st0);
|
|
118
171
|
dirty = true; return json(res, 200, { ok: true, task: t });
|
|
119
172
|
}
|
|
120
173
|
if (req.method === "POST" && P === "/task/update") { // move/edit a card
|
|
121
174
|
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
122
175
|
if (!t) return json(res, 404, { error: "no such task" });
|
|
176
|
+
let eventType = "updated", eventFrom = null, eventTo = null;
|
|
123
177
|
if (b.status && ["todo","doing","testing","failed","done","blocked"].includes(b.status) && b.status !== t.status) {
|
|
178
|
+
eventType = "moved"; eventFrom = t.status; eventTo = b.status;
|
|
124
179
|
(t.history ||= []).push({ from: t.status, to: b.status, by: b.by || "", ts: now() });
|
|
125
180
|
if (t.history.length > 40) t.history.splice(0, 10);
|
|
126
181
|
t.status = b.status;
|
|
@@ -130,13 +185,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
130
185
|
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
186
|
if (b.assignee !== undefined) t.assignee = b.assignee;
|
|
132
187
|
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);
|
|
188
|
+
if (b.delete) { eventType = "deleted"; eventFrom = null; eventTo = null; state.tasks = state.tasks.filter(x => x.id !== t.id); }
|
|
189
|
+
appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
|
|
134
190
|
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
135
191
|
}
|
|
136
192
|
if (req.method === "GET" && P === "/tasks") {
|
|
137
193
|
const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
|
|
138
194
|
return json(res, 200, { tasks: ts });
|
|
139
195
|
}
|
|
196
|
+
if (req.method === "GET" && P === "/history") {
|
|
197
|
+
const requestedLimit = Number(q.limit || 200);
|
|
198
|
+
const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 200, 0), 1000);
|
|
199
|
+
const events = (q.project ? state.cardEvents.filter(e => e.project === q.project) : state.cardEvents).slice(-limit);
|
|
200
|
+
return json(res, 200, { events });
|
|
201
|
+
}
|
|
140
202
|
if (req.method === "POST" && P === "/project") { // set a project's brief (what & why)
|
|
141
203
|
const b = await body(req); const k = String(b.project || "").slice(0, 80);
|
|
142
204
|
if (!k) return json(res, 400, { error: "project required" });
|
|
@@ -158,6 +220,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
158
220
|
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
221
|
}
|
|
160
222
|
if (req.method === "GET" && P === "/projects") { // project-grouped view
|
|
223
|
+
prunePeers();
|
|
161
224
|
const cutoff = now() - ONLINE_MS; const byProj = {};
|
|
162
225
|
const proj = p => p || "(unassigned)";
|
|
163
226
|
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 });
|
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
|