trantor 0.17.9 → 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.9"
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.9",
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.9",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.9",
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()=>{