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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/hooks.json +4 -1
- package/hooks/sessionstart.mjs +31 -2
- package/hooks/subagent-start.mjs +46 -0
- package/hub.mjs +0 -0
- package/package.json +2 -2
- package/ui.html +57 -6
|
@@ -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.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.
|
|
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.
|
|
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" } ] },
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|