trantor 0.17.8 → 0.17.10

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.8"
9
+ "version": "0.17.10"
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.8",
16
+ "version": "0.17.10",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.8",
3
+ "version": "0.17.10",
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/hooks/hooks.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call so live sessions stay green and recover after sleep (PostToolUse); write a handoff before compaction (PreCompact)",
2
+ "description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
6
6
  ],
7
7
  "PostToolUse": [
8
- { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] }
8
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
9
+ { "matcher": "TodoWrite", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/todo-sync.mjs" } ] }
9
10
  ],
10
11
  "PreCompact": [
11
12
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs" } ] }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // trantor PostToolUse(TodoWrite) — mirror the session's todo list onto its project board as cards,
3
+ // so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
4
+ // todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
5
+ // a home-dir session, or any error must never block or break the tool flow.
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import { join, basename } from "node:path";
8
+ import { homedir, hostname } from "node:os";
9
+
10
+ function relayUrl() {
11
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
12
+ try {
13
+ const cfg = join(homedir(), ".agent-bus", "config.json");
14
+ if (existsSync(cfg)) { const u = JSON.parse(readFileSync(cfg, "utf8")).url; if (u) return u; }
15
+ } catch {}
16
+ return "http://127.0.0.1:4477";
17
+ }
18
+ function readStdin() {
19
+ return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
20
+ process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
21
+ setTimeout(() => res(d), 200); });
22
+ }
23
+
24
+ async function main() {
25
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
26
+ // Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
27
+ // (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
28
+ if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
29
+
30
+ let input = {};
31
+ try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
32
+ if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
33
+ const todos = input.tool_input?.todos;
34
+ if (!Array.isArray(todos) || !todos.length) return;
35
+
36
+ // Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
37
+ const project = process.env.RELAY_PROJECT || basename(projectDir);
38
+ const session = process.env.RELAY_SESSION
39
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
40
+
41
+ try {
42
+ await fetch(`${relayUrl()}/todos`, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json" },
45
+ body: JSON.stringify({ session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) }),
46
+ signal: AbortSignal.timeout(1500),
47
+ });
48
+ } catch {}
49
+ }
50
+
51
+ // Never block or break the tool flow: swallow everything, always exit clean.
52
+ main().catch(() => {}).finally(() => process.exit(0));
package/hub.mjs CHANGED
@@ -189,6 +189,44 @@ const server = http.createServer(async (req, res) => {
189
189
  appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
190
190
  t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
191
191
  }
192
+ // Mirror a session's TodoWrite list onto its board as cards, so SOLO work (no crew) shows up live
193
+ // and accrues timeline history. pending/in_progress/completed -> todo/doing/done. Reconciled by
194
+ // todo text per session: present todos create/update; a vanished todo's card is deleted UNLESS it
195
+ // was already done (accomplished work stays in the DONE column). Posted by hooks/todo-sync.mjs.
196
+ if (req.method === "POST" && P === "/todos") {
197
+ const b = await body(req);
198
+ const session = String(b.session || b.by || "").slice(0, 120);
199
+ const project = String(b.project || "").slice(0, 80);
200
+ if (!session || !project) return json(res, 400, { error: "session and project required" });
201
+ touch(session, undefined, project);
202
+ const ST = { pending: "todo", in_progress: "doing", completed: "done" };
203
+ const todos = Array.isArray(b.todos) ? b.todos : [];
204
+ const mine = state.tasks.filter(t => t.source === "todo" && t.assignee === session && t.project === project);
205
+ const seen = new Set();
206
+ for (const todo of todos) {
207
+ const key = String(todo?.content || "").trim().slice(0, 200);
208
+ if (!key) continue;
209
+ seen.add(key);
210
+ const want = ST[todo.status] || "todo";
211
+ let t = mine.find(c => c.todoKey === key);
212
+ if (!t) {
213
+ t = { id: ++state.taskSeq, project, title: key, assignee: session, status: want, difficulty: "", model: "",
214
+ deps: [], by: session, ts: now(), updated: now(), source: "todo", todoKey: key,
215
+ history: [{ to: want, by: session, ts: now() }] };
216
+ state.tasks.push(t); appendCardEvent("created", t, session, null, want); dirty = true;
217
+ } else if (t.status !== want) {
218
+ (t.history ||= []).push({ from: t.status, to: want, by: session, ts: now() });
219
+ if (t.history.length > 40) t.history.splice(0, 10);
220
+ appendCardEvent("moved", t, session, t.status, want); t.status = want; t.updated = now(); dirty = true;
221
+ }
222
+ }
223
+ for (const t of mine) {
224
+ if (seen.has(t.todoKey) || t.status === "done") continue; // keep accomplished work on the board
225
+ state.tasks = state.tasks.filter(x => x.id !== t.id); appendCardEvent("deleted", t, session, null, null); dirty = true;
226
+ }
227
+ if (state.tasks.length > 2000) state.tasks.splice(0, state.tasks.length - 2000);
228
+ return json(res, 200, { ok: true, count: todos.length });
229
+ }
192
230
  if (req.method === "GET" && P === "/tasks") {
193
231
  const proj = q.project; const ts = proj ? state.tasks.filter(t => t.project === proj) : state.tasks;
194
232
  return json(res, 200, { tasks: ts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.8",
3
+ "version": "0.17.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
package/ui.html CHANGED
@@ -140,6 +140,28 @@ 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
+ /* FLOW = development timeline: build-order columns, agent lanes, readable fixed cards, native scroll */
144
+ .tlwrap{position:relative;background:#0b1019;border-top:1px solid var(--line);overflow:hidden}
145
+ .tlscroll{position:absolute;inset:0;overflow-x:auto;overflow-y:auto}
146
+ .tlscroll svg{display:block}
147
+ .tlband{fill:#101827}
148
+ .tlsep{stroke:#161e2c;stroke-width:1}
149
+ .tlgrid{stroke:#161e2c;stroke-width:1}
150
+ .tlt{fill:var(--dim);font-size:9px;font-family:ui-sans-serif,system-ui;text-anchor:middle}
151
+ .tledge{fill:none;stroke:#33405c;stroke-width:1.6;opacity:.8}
152
+ .tledge.done{stroke:var(--grn2)}
153
+ .tlbar{fill:none;stroke:rgba(255,255,255,.14);stroke-width:1}
154
+ .tlnode{cursor:default}
155
+ .tlnode:hover .tlbar{stroke:rgba(255,255,255,.55)}
156
+ .tlnode.doing .tlbar,.tlnode.testing .tlbar{stroke:rgba(255,255,255,.3)}
157
+ .tlnode.failed .tlbar,.tlnode.blocked .tlbar{stroke:var(--red)}
158
+ .tltitle{fill:#eaf1fa;font-size:10.5px;font-family:ui-sans-serif,system-ui;pointer-events:none}
159
+ /* pinned lane-label gutter (stays put while the chart scrolls) */
160
+ .tlgut{position:absolute;left:0;top:0;bottom:0;background:#0b1019;border-right:1px solid var(--line);z-index:3;pointer-events:none}
161
+ .tlguth{display:flex;align-items:center;padding-left:8px;font-size:9px;color:var(--dim);border-bottom:1px solid #161e2c}
162
+ .tlgl{display:flex;align-items:center;gap:6px;padding-left:8px;font-size:11px;font-weight:600;color:var(--mut)}
163
+ .tlgl svg{flex:none}
164
+ .tlwrap .fhint{position:absolute;right:10px;bottom:6px;z-index:3}
143
165
  /* timeline view */
144
166
  .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
167
  .tevent{font-size:12.5px;line-height:1.4;color:var(--mut);display:flex;align-items:center;gap:7px;padding:4px 0}
@@ -275,66 +297,70 @@ function moveProj(name,dir){
275
297
  [names[i],names[j]]=[names[j],names[i]];
276
298
  localStorage.setItem("abOrder",JSON.stringify(names)); render();
277
299
  }
278
- function flowLayout(cards, proj){
279
- const byId = Object.fromEntries(cards.map(t => [t.id, t]));
280
- let anyDeps = cards.some(t => (t.deps || []).length);
281
- const deps = t => {
282
- if (anyDeps) return (t.deps || []).filter(d => byId[d]);
283
- if (/integrat|assemb|ship it|final/i.test(t.title)) return cards.filter(o => o.id !== t.id).map(o => o.id);
284
- return [];
285
- };
286
- const L = {};
287
- const layer = (t, seen = new Set()) => {
288
- if (L[t.id] !== undefined) return L[t.id];
289
- if (seen.has(t.id)) return (L[t.id] = 0);
290
- seen.add(t.id);
291
- const ds = deps(t);
292
- return (L[t.id] = ds.length ? 1 + Math.max(...ds.map(d => layer(byId[d], seen))) : 0);
293
- };
294
- cards.forEach(t => layer(t));
295
- const cols = {};
296
- cards.forEach(t => (cols[L[t.id]] ||= []).push(t));
297
- const NW = 190, NH = 64, GX = 90, GY = 26;
298
- const nodes = {}, maxRows = Math.max(...Object.values(cols).map(c => c.length));
299
- const totalH = maxRows * (NH + GY) - GY;
300
- const manual = (JSON.parse(localStorage.getItem("abFlowPos") || "{}"))[proj] || {};
301
- for (const [li, col] of Object.entries(cols)) {
302
- const colH = col.length * (NH + GY) - GY, y0 = (totalH - colH) / 2;
303
- col.forEach((t, ri) => {
304
- const m = manual[t.id];
305
- nodes[t.id] = { t, x: m ? m.x : li * (NW + GX), y: m ? m.y : y0 + ri * (NH + GY) };
306
- });
307
- }
308
- const edges = [];
309
- cards.forEach(t => deps(t).forEach(d => edges.push([d, t.id])));
310
- return { nodes, edges, NW, NH };
311
- }
300
+ // FLOW = development timeline. Cards laid left→right in BUILD ORDER (even slots — bursts don't cram,
301
+ // idle gaps don't waste space), one lane per agent, each card a readable FIXED-width block segmented
302
+ // by the time it spent in each status. Natively scrollable left/right; lane labels pinned in a left
303
+ // gutter; dependency edges converge = merges. (No proportional-time slivers, no zoom cut-off.)
304
+ const TLSCROLL = {};
312
305
  function flowHTML(pt, proj){
313
306
  if (!pt.length) return '<div class="empty">no cards yet</div>';
314
- const { nodes, edges, NW, NH } = flowLayout(pt, proj);
315
- const cam = (JSON.parse(localStorage.getItem("abFlowCam") || "{}"))[proj] || { s: 0.9, tx: 30, ty: 20 };
316
- let inner = "";
317
- for (const [a, b] of edges) {
318
- const A = nodes[a], B = nodes[b]; if (!A || !B) continue;
319
- const x1 = A.x + NW, y1 = A.y + NH / 2, x2 = B.x, y2 = B.y + NH / 2, mx = (x1 + x2) / 2;
320
- const cls = A.t.status === "done" ? "done" : (B.t.status === "doing" || B.t.status === "testing") ? "active" : "";
321
- const mk = cls === "done" ? "arrg" : cls === "active" ? "arrb" : "arr";
322
- const d = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
323
- const label = `${String(A.t.title).slice(0, 18)} → ${String(B.t.title).slice(0, 18)}`;
324
- inner += `<g class="fe"><path class="fedge-hit" d="${d}"><title>${esc(A.t.title)} ${esc(B.t.title)}</title></path><path class="fedge ${cls}" marker-end="url(#${mk})" d="${d}"/><text class="felabel" x="${mx}" y="${(y1 + y2) / 2 - 6}" text-anchor="middle">${esc(label)}</text></g>`;
307
+ const NOW = Date.now();
308
+ const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
309
+ const GUT = 96, TH = 24, ROW = 42, BAR = 26, SLOT = 188, CW = 172, PAD = 10;
310
+ const cards = pt.map(t => {
311
+ const h = (t.history && t.history.length) ? t.history.slice() : [{ to: t.status || 'todo', ts: t.ts || NOW }];
312
+ return { t, h, tStart: h[0].ts || t.ts || NOW, tEnd: (t.status === 'done' ? (h[h.length-1].ts || NOW) : NOW) };
313
+ });
314
+ cards.sort((a,b)=> a.tStart - b.tStart || a.t.id - b.t.id);
315
+ cards.forEach((c,i)=> c.col = i);
316
+ const byId = {}; for (const c of cards) byId[c.t.id] = c;
317
+ const laneKey = t => (String(t.assignee || 'unassigned').split(':')[0] || 'unassigned');
318
+ const laneFirst = {}; for (const c of cards){ const k = laneKey(c.t); if (laneFirst[k] == null) laneFirst[k] = c.col; }
319
+ const lanes = [...new Set(cards.map(c => laneKey(c.t)))].sort((a,b)=> laneFirst[a]-laneFirst[b]);
320
+ const laneIdx = Object.fromEntries(lanes.map((k,i)=>[k,i]));
321
+ const X = col => GUT + col*SLOT + PAD;
322
+ const laneY = i => TH + i*ROW + (ROW-BAR)/2;
323
+ const totalW = GUT + cards.length*SLOT + 24, totalH = TH + lanes.length*ROW + 8;
324
+ let svg = '';
325
+ for (let i=0;i<lanes.length;i++){ const yb = TH + i*ROW;
326
+ if (i%2) svg += `<rect class="tlband" x="${GUT}" y="${yb}" width="${totalW-GUT}" height="${ROW}"/>`;
327
+ svg += `<line class="tlsep" x1="${GUT}" y1="${yb}" x2="${totalW}" y2="${yb}"/>`;
328
+ }
329
+ // build-order time markers (every few cards, label that card's real timestamp) — scroll with the chart
330
+ const step = Math.max(1, Math.round(cards.length/12));
331
+ for (let i=0;i<cards.length;i+=step){ const xx = X(cards[i].col) + CW/2;
332
+ svg += `<line class="tlgrid" x1="${xx}" y1="${TH}" x2="${xx}" y2="${totalH-6}"/>`;
333
+ svg += `<text class="tlt" x="${xx}" y="15">${esc(new Date(cards[i].tStart).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}))}</text>`;
334
+ }
335
+ for (const c of cards) for (const d of (c.t.deps||[])){ const A = byId[d]; if (!A) continue;
336
+ const x1 = X(A.col)+CW, y1 = laneY(laneIdx[laneKey(A.t)])+BAR/2, x2 = X(c.col), y2 = laneY(laneIdx[laneKey(c.t)])+BAR/2, mx = (x1+x2)/2;
337
+ svg += `<path class="tledge ${A.t.status==='done'?'done':''}" marker-end="url(#tarr)" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`;
325
338
  }
326
- for (const { t, x, y } of Object.values(nodes)) {
327
- const bounces = (t.history || []).filter(h => ({todo:0,doing:1,testing:2,done:3}[h.to] ?? 0) < ({todo:0,doing:1,testing:2,done:3}[h.from] ?? 0)).length;
328
- inner += `<g class="fnode ${t.status}" data-id="${t.id}" transform="translate(${x},${y})">`;
329
- inner += `<rect width="${NW}" height="${NH}" rx="9"/>`;
330
- inner += `<text class="ftitle" x="10" y="20">${esc(t.title.slice(0, 26))}${t.title.length > 26 ? "…" : ""}</text>`;
331
- inner += `<text class="fmeta" x="10" y="37">${esc(String(t.assignee || "").split(":")[0])}${t.difficulty ? " · " + t.difficulty : ""}${t.model ? " · " + esc(t.model.slice(0, 16)) : ""}</text>`;
332
- inner += `<text class="fmeta" x="10" y="53">${t.status}</text>`;
333
- if (bounces) inner += `<path class="floop" marker-end="url(#arr)" d="M${NW - 34},0 C${NW - 34},-16 ${NW - 6},-16 ${NW - 6},-1"/><text class="floopn" x="${NW - 30}" y="-7">↩${bounces}</text>`;
334
- inner += `</g>`;
339
+ for (const c of cards){ const xx = X(c.col), yy = laneY(laneIdx[laneKey(c.t)]), dur = Math.max(1, c.tEnd - c.tStart);
340
+ svg += `<g class="tlnode ${c.t.status}" data-id="${c.t.id}"><title>${esc(c.t.title)} ${c.t.status} · @${esc(laneKey(c.t))}\n${esc(new Date(c.tStart).toLocaleString())}</title>`;
341
+ let sx = xx;
342
+ for (let i=0;i<c.h.length;i++){ const s0 = c.h[i].ts || c.tStart, s1 = (i+1<c.h.length) ? (c.h[i+1].ts||s0) : c.tEnd;
343
+ const w = Math.max(1.5, (Math.max(0, s1-s0)/dur)*CW);
344
+ svg += `<rect x="${sx.toFixed(1)}" y="${yy}" width="${w.toFixed(1)}" height="${BAR}" fill="${SCOL[c.h[i].to]||'#3a4458'}"/>`; sx += w;
345
+ }
346
+ svg += `<rect class="tlbar" x="${xx}" y="${yy}" width="${CW}" height="${BAR}" rx="5"/>`;
347
+ svg += `<text class="tltitle" x="${xx+7}" y="${yy+BAR/2+4}">${esc(c.t.title.slice(0,25))}${c.t.title.length>25?'…':''}</text>`;
348
+ svg += `</g>`;
335
349
  }
336
- const defs = `<defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2b3650"/></marker><marker id="arrg" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#14b8a6"/></marker><marker id="arrb" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#4a90d9"/></marker></defs>`;
337
- return `<div class="flowwrap" data-proj="${esc(proj)}"><div class="fctl"><button data-fa="out">−</button><button data-fa="in">+</button><button data-fa="fit">FIT</button><button data-fa="auto">AUTO</button></div><div class="fhint">⌘+scroll or pinch to zoom · drag canvas to pan</div><svg>${defs}<g class="fcam" transform="translate(${cam.tx},${cam.ty}) scale(${cam.s})">${inner}</g></svg></div>`;
350
+ const defs = `<defs><marker id="tarr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#14b8a6"/></marker></defs>`;
351
+ let gut = `<div class="tlgut" style="width:${GUT}px"><div class="tlguth" style="height:${TH}px">time →</div>`;
352
+ for (const k of lanes) gut += `<div class="tlgl" style="height:${ROW}px">${iconFor(k,13)}<span>${esc(k)}</span></div>`;
353
+ gut += `</div>`;
354
+ return `<div class="tlwrap" data-proj="${esc(proj)}" style="height:${Math.min(totalH+14, 540)}px">`
355
+ + `<div class="tlscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${svg}</svg></div>`
356
+ + gut + `<div class="fhint">build order → · scroll left/right</div></div>`;
357
+ }
358
+ function wireTimeline(el){
359
+ el.querySelectorAll('.tlscroll').forEach(s => {
360
+ const proj = s.dataset.proj;
361
+ if (TLSCROLL[proj] != null) s.scrollLeft = TLSCROLL[proj];
362
+ s.onscroll = () => { TLSCROLL[proj] = s.scrollLeft; };
363
+ });
338
364
  }
339
365
  let dragging = null; // suppresses re-render mid-gesture
340
366
  function saveCam(proj, cam){ const c = JSON.parse(localStorage.getItem("abFlowCam") || "{}"); c[proj] = cam; localStorage.setItem("abFlowCam", JSON.stringify(c)); }
@@ -462,15 +488,21 @@ async function render(){
462
488
  const sel=$('#to'),cur=sel.value;
463
489
  sel.innerHTML='<option value="all">all (broadcast)</option>'+allS.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
464
490
  sel.value=cur;
465
- // sort: manual order (abOrder, set via the ▲▼ controls) wins; otherwise online
466
- // first, then most-recent activity — working boards float to the top on their own
491
+ // STABLE display order: projects keep their first-seen position so the board NEVER reshuffles on
492
+ // activity — a working board updates in place instead of jumping to the top (which made boards
493
+ // pop in and out and impossible to read). Manual ▲▼ (abOrder) still wins; live boards group above
494
+ // idle. (Recency auto-sort removed — that was the thrash.)
495
+ let SEEN=JSON.parse(localStorage.getItem("abSeen")||"[]");
496
+ const curNames=projects.map(p=>p.project);
497
+ const SEEN2=[...SEEN.filter(n=>curNames.includes(n)),...curNames.filter(n=>!SEEN.includes(n))];
498
+ if(JSON.stringify(SEEN2)!==JSON.stringify(SEEN)){SEEN=SEEN2;localStorage.setItem("abSeen",JSON.stringify(SEEN));}
467
499
  const ORDER=JSON.parse(localStorage.getItem("abOrder")||"[]");
468
500
  projects.sort((a,b)=>{
469
501
  const ia=ORDER.indexOf(a.project),ib=ORDER.indexOf(b.project);
470
502
  if(ia>=0||ib>=0)return (ia<0?1e9:ia)-(ib<0?1e9:ib);
471
503
  const oa=a.agents.some(x=>x.online),ob=b.agents.some(x=>x.online);if(oa!==ob)return ob-oa;
472
504
  if(a.project==='(unassigned)')return 1;if(b.project==='(unassigned)')return -1;
473
- return (b.lastActivity||0)-(a.lastActivity||0)||a.project.localeCompare(b.project);
505
+ return SEEN.indexOf(a.project)-SEEN.indexOf(b.project); // stable first-seen order
474
506
  });
475
507
  const el=$('#boards');
476
508
  if(dragging)return; // never rebuild mid-gesture
@@ -540,7 +572,7 @@ async function render(){
540
572
  // "✕ sure?" confirmation survives until it expires instead of silently resetting
541
573
  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?';}}
542
574
  const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
543
- wireFlow(el);
575
+ wireFlow(el);wireTimeline(el);
544
576
  // keep each timeline scrolled to the latest event
545
577
  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';};});
546
578
  el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{