trantor 0.17.41 → 0.17.42

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.41"
9
+ "version": "0.17.42"
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.41",
16
+ "version": "0.17.42",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.41",
3
+ "version": "0.17.42",
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,9 +1,12 @@
1
1
  {
2
- "description": "trantor — auto-register each session + inject live roster (SessionStart); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
2
+ "description": "trantor — auto-register each session + inject live roster (SessionStart); post an in-flight 'doing' card when a sub-agent is dispatched (PreToolUse); heartbeat presence on every tool call + deliver unread bus messages to a busy session mid-turn + mirror the session's TodoWrite list onto the board as cards (PostToolUse); write a handoff before compaction (PreCompact); card each sub-agent's notional API cost when it finishes (SubagentStop)",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs" } ] }
6
6
  ],
7
+ "PreToolUse": [
8
+ { "matcher": "Task|Agent", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs" } ] }
9
+ ],
7
10
  "PostToolUse": [
8
11
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.mjs" } ] },
9
12
  { "matcher": "", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/inbox-deliver.mjs" } ] },
@@ -94,11 +94,35 @@ function sanitize(s) {
94
94
  return out;
95
95
  }
96
96
 
97
+ // Session title for the picker / `claude --resume` / Claude mobile. Claude Code otherwise names a session
98
+ // after its FIRST PROMPT (the `ai-title` transcript entry) — so several sessions started with the same
99
+ // prompt (or sibling sessions in different projects) all look alike. We name it "<project> · <current work>"
100
+ // where the work is the single most relevant in-flight item from the board, so concurrent sessions are
101
+ // instantly distinguishable. (SessionStart can set the title via hookSpecificOutput.sessionTitle; it has no
102
+ // mid-session rename, so this reflects the project's state at startup.)
103
+ function sessionTitleFrom(project, cu) {
104
+ let work = "";
105
+ if (cu) {
106
+ // prefer real work cards (doing → testing → todo), skipping transient cc-subagent infra cards which
107
+ // would otherwise dominate "most recent" and make every session title noise; then the project brief.
108
+ const real = a => (Array.isArray(a) ? a.filter(t => t.source !== "cc-subagent") : []);
109
+ const first = a => (a.length ? a[0].title : "");
110
+ work = first(real(cu.doing)) || first(real(cu.testing)) || first(real(cu.todo)) || cu.brief || "";
111
+ }
112
+ work = String(work || "").replace(/\s+/g, " ").trim()
113
+ .replace(/^v?\d+\.\d+\.\d+\s*[:—–•·-]\s*/i, ""); // drop a leading release-version prefix (just noise here)
114
+ if (work.length > 44) work = work.slice(0, 43).trimEnd() + "…";
115
+ return sanitize(work ? `${project} · ${work}` : project).slice(0, 90);
116
+ }
117
+
97
118
  let additionalContext = "";
98
119
  let userBanner = ""; // shown to the USER in-terminal via the hook's `systemMessage` (not model-only context)
120
+ let sessionTitle = ""; // picker / --resume / mobile title — set to "<project> · <current work>" below
121
+ let userTitle = ""; // a title the USER set explicitly (--name / rename); never override it
99
122
  try {
100
123
  let source = "", stdinObj = {};
101
124
  try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
125
+ userTitle = (stdinObj && stdinObj.session_title) ? String(stdinObj.session_title) : ""; // user already named it
102
126
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
103
127
  // Sessions started in the home directory itself aren't project work — registering
104
128
  // them spawns a phantom "<username>" project board on the dashboard. Set
@@ -109,6 +133,7 @@ try {
109
133
  process.exit(0);
110
134
  }
111
135
  const project = resolveProject(projectDir);
136
+ sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
112
137
  const session = process.env.RELAY_SESSION
113
138
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
114
139
  const url = relayUrl();
@@ -137,6 +162,7 @@ try {
137
162
  // SAME project where it stands instead of starting blind. Cheap + LLM-free.
138
163
  try {
139
164
  const cu = await jget(`${url}/catchup?project=${encodeURIComponent(project)}`).catch(() => null);
165
+ sessionTitle = sessionTitleFrom(project, cu); // "<project> · <current work>" for the picker/--resume/mobile
140
166
  let gitlog = "";
141
167
  try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
142
168
  if ((cu && cu.total > 0) || gitlog) {
@@ -248,18 +274,21 @@ try {
248
274
  // Hook protocol: emit additionalContext (model-facing) via stdout JSON, plus an optional
249
275
  // `systemMessage` (USER-facing — rendered as a line in the terminal, our update indicator).
250
276
  // Self-validate so we never emit something Claude Code can't parse — fall back to sanitized, then {}.
251
- function emit(ctx, sysMsg) {
277
+ function emit(ctx, sysMsg, title) {
252
278
  const obj = {};
253
279
  if (ctx) obj.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: ctx };
280
+ if (title) (obj.hookSpecificOutput ||= { hookEventName: "SessionStart" }).sessionTitle = title;
254
281
  if (sysMsg) obj.systemMessage = sysMsg;
255
282
  const out = JSON.stringify(obj);
256
283
  try { JSON.parse(out); return out; } catch { /* fall through */ }
257
284
  try {
258
285
  const safe = {};
259
286
  if (ctx) safe.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: sanitize(ctx) };
287
+ if (title) (safe.hookSpecificOutput ||= { hookEventName: "SessionStart" }).sessionTitle = sanitize(title);
260
288
  if (sysMsg) safe.systemMessage = sanitize(sysMsg);
261
289
  return JSON.stringify(safe);
262
290
  } catch { return "{}"; }
263
291
  }
264
- process.stdout.write(emit(additionalContext, userBanner));
292
+ // Set the session title unless the user already named it explicitly (--name / rename).
293
+ process.stdout.write(emit(additionalContext, userBanner, userTitle ? "" : sessionTitle));
265
294
  process.exit(0);
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // trantor PreToolUse hook (Task|Agent) — when a sub-agent is DISPATCHED, post an in-flight "doing" card so
3
+ // the board shows work IN PROGRESS while it runs. The existing SubagentStop hook (subagent-cost.mjs) flips
4
+ // the matching card to "done" via the hub's cc-subagent title-fingerprint dedup. Without this, every auto-
5
+ // card was born "done" (SubagentStop/git-backfill) so nothing ever showed as in progress. Fail-silent:
6
+ // never block or delay the dispatch.
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { resolveProject, hostId } from "../lib/project.mjs";
11
+
12
+ function readStdin() {
13
+ return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
14
+ process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
15
+ setTimeout(() => res(d), 100); });
16
+ }
17
+ function relayUrl() {
18
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
19
+ try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
20
+ return "http://127.0.0.1:4477";
21
+ }
22
+
23
+ try {
24
+ const input = JSON.parse((await readStdin()) || "{}");
25
+ const ti = input.tool_input || {};
26
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
27
+ const project = resolveProject(cwd);
28
+ const agentType = String(ti.subagent_type || "subagent").slice(0, 40);
29
+ // MUST mirror subagent-cost.mjs's title derivation (lines 100-101) so the hub's title fingerprint pairs
30
+ // this start card with the SubagentStop "done" card into ONE rolling cc-subagent card.
31
+ const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
32
+ const title = `${agentType}: ${task}`.slice(0, 180);
33
+ await fetch(`${relayUrl()}/task`, {
34
+ method: "POST", headers: { "content-type": "application/json" },
35
+ body: JSON.stringify({
36
+ project, title, status: "doing",
37
+ assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
38
+ source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
39
+ }),
40
+ signal: AbortSignal.timeout(1500),
41
+ }).catch(() => {});
42
+ } catch (e) {
43
+ process.stderr.write(`[trantor] subagent-start error: ${e?.message || e}\n`);
44
+ }
45
+ process.stdout.write("{}");
46
+ process.exit(0);
package/hub.mjs CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.41",
3
+ "version": "0.17.42",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -10,7 +10,7 @@
10
10
  "zod": "^4.4.3"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs"
14
14
  },
15
15
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
package/ui.html CHANGED
@@ -15,6 +15,24 @@ header{display:flex;align-items:center;gap:13px;padding:11px 18px;border-bottom:
15
15
  main{flex:1;display:grid;grid-template-columns:var(--lw,44px) 1fr 330px;min-height:0}
16
16
  main.learn-open{--lw:372px}
17
17
  .boards{overflow-y:auto;padding:16px 18px}
18
+ /* Project-tab strip (between header + main): jump to a project (multi) or switch focus (solo). */
19
+ .ptabs{display:flex;gap:6px;align-items:center;padding:7px 18px;border-bottom:1px solid var(--line);background:var(--panel);overflow-x:auto;white-space:nowrap}
20
+ .ptab{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:11px;font-weight:600;color:var(--mut);background:#0c121e;border:1px solid #34465f;border-radius:11px;padding:4px 10px;cursor:pointer;white-space:nowrap;text-decoration:none;flex:none}
21
+ .ptab:hover{color:var(--tx);border-color:var(--grn)}
22
+ .ptab.on{color:#06120c;background:var(--grn);border-color:var(--grn)}
23
+ .ptab.live::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn);flex:none}
24
+ .ptab.home{color:var(--dim);font-weight:700}
25
+ /* per-project "open in its own window" pop-out icon */
26
+ .popout{color:var(--mut);text-decoration:none;font-size:16px;line-height:1;cursor:pointer;flex:none}
27
+ .popout:hover{color:var(--grn)}
28
+ .proj.flash{animation:flashp 1.2s ease}
29
+ @keyframes flashp{0%,100%{box-shadow:none}30%{box-shadow:0 0 0 2px var(--grn)}}
30
+ /* SOLO = single-project full-window (deep link ?project=<name>): hide side rails, board fills the window. */
31
+ body.solo main{grid-template-columns:1fr}
32
+ body.solo .learn,body.solo aside{display:none}
33
+ body.solo .boards{padding:0}
34
+ body.solo .popout{display:none}
35
+ body.solo .proj{margin:0;border:none;border-radius:0}
18
36
  /* Learning sidebar (collapsible left rail) — surfaces the self-learning loop */
19
37
  .learn{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
20
38
  .learn-head{display:flex;align-items:center;gap:8px;padding:11px 12px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;user-select:none}
@@ -275,6 +293,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
275
293
  <span class="pill" id="credits" title="Provider credit left — $ balance (prepaid) or % quota (coding plans). Refill/pace before a build stalls. Pushed by sessions/CLI (the hub has no keys)." style="display:none"></span>
276
294
  <span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
277
295
  </header>
296
+ <div class="ptabs" id="ptabs" style="display:none"></div>
278
297
  <main>
279
298
  <section class="learn" id="learn">
280
299
  <div class="learn-head" id="learnToggle" title="Learning — lessons, per-LLM reliability, baked-in guardrails">
@@ -303,6 +322,9 @@ const usd=(n,dp=2)=>(Number(n)||0).toLocaleString('en-US',{minimumFractionDigits
303
322
  const $usd=(n,sym='$',dp=2)=>sym+usd(n,dp);
304
323
  const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked']];
305
324
  let nmsg=0;
325
+ // SOLO = single-project full-window mode via deep link ?project=<name>. Parsed once (search is static
326
+ // per load). When set, render() shows ONLY that project's board/FLOW/TIMELINE and hides the side rails.
327
+ const SOLO=(()=>{try{return decodeURIComponent(new URLSearchParams(location.search).get('project')||'');}catch(e){return new URLSearchParams(location.search).get('project')||'';}})();
306
328
 
307
329
  /* ---- LLM provider icons (lobehub SVGs — same set used across crebral.ai) ----
308
330
  Keyed by the AI coding-CLI brand parsed from a session id (e.g. "claude:crebral"). */
@@ -715,6 +737,26 @@ function chatLane(msgs){
715
737
  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('');
716
738
  return `<div class="proj-chat"><h5><span class="lc"></span>conversation · ${msgs.length}</h5><div class="chatlog">${rows}</div></div>`;
717
739
  }
740
+ // Top project-tab strip: chips for every real project. In SOLO it switches the focused project (navigates);
741
+ // in the multi-project view it smooth-scrolls to that project's section. The current SOLO project is hilit.
742
+ function renderTabStrip(projects){
743
+ const strip=$('#ptabs');if(!strip)return;
744
+ const real=projects.filter(p=>p.project&&p.project!=='(unassigned)');
745
+ if(!real.length){strip.style.display='none';return;}
746
+ strip.style.display='';
747
+ const chips=real.map(p=>{
748
+ const on=SOLO&&(p.project===SOLO||p.project.toLowerCase()===SOLO.toLowerCase());
749
+ const live=(p.agents||[]).some(a=>a.online);
750
+ return `<button class="ptab${on?' on':''}${live?' live':''}" data-proj="${esc(p.project)}" title="${esc(p.project)}"><span>${esc(p.project)}</span></button>`;
751
+ }).join('');
752
+ strip.innerHTML=(SOLO?`<a class="ptab home" href="${esc(location.pathname)}" title="back to all projects">⌂ all</a>`:'')+chips;
753
+ strip.querySelectorAll('.ptab[data-proj]').forEach(b=>b.onclick=()=>{
754
+ const name=b.dataset.proj;
755
+ if(SOLO){location.search='?project='+encodeURIComponent(name);return;}
756
+ const sec=document.querySelector(`.proj[data-projname="${CSS.escape(name)}"], .idle-row[data-projname="${CSS.escape(name)}"]`);
757
+ if(sec){sec.scrollIntoView({behavior:'smooth',block:'start'});sec.classList.add('flash');setTimeout(()=>sec.classList.remove('flash'),1200);}
758
+ });
759
+ }
718
760
  async function render(){
719
761
  let projects=[],tasks=[],msgs=[];
720
762
  try{projects=(await (await fetch('/projects')).json()).projects||[];}catch(e){}
@@ -790,7 +832,7 @@ async function render(){
790
832
  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>`:''}`;
791
833
 
792
834
  return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
793
- `<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>`+
835
+ `<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span><a class="popout" href="?project=${encodeURIComponent(p.project)}" target="_blank" rel="noopener" title="open this project in its own window" onclick="event.stopPropagation()">↗</a>${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>`+
794
836
  `<div class="proj-brief">${brief}${ph?`<span class="phase ${phaseClass(ph)}">${esc(ph)}</span>`:''}</div>`+
795
837
  `<div class="pbar"><i style="width:${pct}%"></i></div>`+
796
838
  (view === "flow" ? flowHTML(pt, p.project) : view === "timeline" ? timelineHTML(p.project) : `<div class="kanban">${cols}</div>`)+
@@ -800,12 +842,21 @@ async function render(){
800
842
  // idle projects (zero online agents) collapse to thin rows below the live boards
801
843
  const idleRow=p=>{
802
844
  const pt=tasks.filter(t=>t.project===p.project);const done=pt.filter(t=>t.status==='done').length;
803
- return `<div class="idle-row" data-projname="${esc(p.project)}" data-idleproj="${esc(p.project)}" title="click to expand">💤 <span class="nm">${esc(p.project)}</span><span class="dim">· ${esc(p.phase||'')} · ${done}/${pt.length} cards</span><span class="spacer"></span><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><span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span></div>`;
845
+ return `<div class="idle-row" data-projname="${esc(p.project)}" data-idleproj="${esc(p.project)}" title="click to expand">💤 <span class="nm">${esc(p.project)}</span><span class="dim">· ${esc(p.phase||'')} · ${done}/${pt.length} cards</span><span class="spacer"></span><a class="popout" href="?project=${encodeURIComponent(p.project)}" target="_blank" rel="noopener" title="open this project in its own window" onclick="event.stopPropagation()">↗</a><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><span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span></div>`;
804
846
  };
805
- const live=projects.filter(p=>p.idle!==true),idlers=projects.filter(p=>p.idle===true);
806
- el.innerHTML=(ORDER.length?`<div class="sortmode" id="sortreset" title="you've ordered projects manually — click to go back to automatic (recency) ordering">sort: manual · ↺ back to auto</div>`:'')
807
- +live.map(projBlock).join('')
808
- +(idlers.length?`<div class="idle-head">idle projects · ${idlers.length}</div>`+idlers.map(p=>idleOpen.has(p.project)?projBlock(p):idleRow(p)).join(''):'');
847
+ renderTabStrip(projects);
848
+ if(SOLO){
849
+ // single-project full-window: render ONLY the deep-linked project, side rails hidden via body.solo
850
+ document.body.classList.add('solo');
851
+ const only=projects.filter(p=>p.project===SOLO||p.project.toLowerCase()===SOLO.toLowerCase());
852
+ if(!only.length){el.innerHTML=`<div class="empty big">project “${esc(SOLO)}” not found — <a href="${esc(location.pathname)}" style="color:var(--grn)">view all projects</a></div>`;return;}
853
+ el.innerHTML=only.map(projBlock).join('');
854
+ } else {
855
+ const live=projects.filter(p=>p.idle!==true),idlers=projects.filter(p=>p.idle===true);
856
+ el.innerHTML=(ORDER.length?`<div class="sortmode" id="sortreset" title="you've ordered projects manually — click to go back to automatic (recency) ordering">sort: manual · ↺ back to auto</div>`:'')
857
+ +live.map(projBlock).join('')
858
+ +(idlers.length?`<div class="idle-head">idle projects · ${idlers.length}</div>`+idlers.map(p=>idleOpen.has(p.project)?projBlock(p):idleRow(p)).join(''):'');
859
+ }
809
860
  // keep each project's chat scrolled to the latest line
810
861
  el.querySelectorAll('.chatlog').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';};});
811
862
  // click a card -> advance status todo->doing->done