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.
@@ -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.6"
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.6",
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.6",
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");
@@ -69,11 +69,13 @@ try {
69
69
  process.stdout.write("{}");
70
70
  process.exit(0);
71
71
  }
72
- const session = process.env.RELAY_SESSION || `${hostname()}:${basename(projectDir)}`;
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: basename(projectDir), status: `active in ${basename(projectDir)}` }).catch(() => {});
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
- setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
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