trantor 0.17.14 → 0.17.15
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/handoff-now.mjs +9 -10
- package/hooks/heartbeat.mjs +2 -2
- package/hooks/sessionstart.mjs +2 -2
- package/hub.mjs +114 -2
- package/lib/project.mjs +24 -1
- package/mcp.mjs +14 -6
- package/package.json +1 -1
- package/ui.html +131 -61
|
@@ -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.15"
|
|
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.15",
|
|
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.15",
|
|
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/handoff-now.mjs
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor — detached handoff worker. The PostToolUse heartbeat spawns this when a
|
|
3
|
-
// session crosses its context warn threshold
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
// trantor — detached EARLY-WARNING handoff worker. The PostToolUse heartbeat spawns this when a
|
|
3
|
+
// session crosses its context warn threshold (~85% of a known window). Its job is to PREPARE a
|
|
4
|
+
// safety-net handoff and NOTIFY — NOT to open a window. Spawning a fresh session while the original
|
|
5
|
+
// is still perfectly usable created a surprise pop-up + an orphaned duplicate session; the actual
|
|
6
|
+
// fresh-window spawn now happens only AT THE WALL (PreCompact). The (~60s) scrooge summary runs here,
|
|
7
|
+
// detached, so it never blocks a tool call. Args: <projectDir> <sessionId> <transcriptPath> [trigger]
|
|
8
|
+
import { readConfig, writeHandoff, pingBus, contextUsage } from "./lib/handoff.mjs";
|
|
8
9
|
import { basename } from "node:path";
|
|
9
10
|
|
|
10
11
|
const [, , projectDir = process.cwd(), sessionId = "", transcript = "", trigger = "context-warn"] = process.argv;
|
|
11
12
|
try {
|
|
12
13
|
const conf = readConfig();
|
|
13
|
-
const cur = contextUsage(transcript, conf)?.tokens || 0;
|
|
14
|
-
if (alreadyHandedOff(sessionId, cur)) process.exit(0); // another path beat us to it
|
|
15
14
|
const { file, record } = writeHandoff({ projectDir, sessionId, transcript, trigger });
|
|
16
|
-
process.stderr.write(`[trantor] early handoff written: ${file}
|
|
15
|
+
process.stderr.write(`[trantor] early handoff written (safety net, no spawn): ${file}\n`);
|
|
16
|
+
// notify the bus so a watcher knows a handoff is ready — but do NOT spawn a window here.
|
|
17
17
|
await pingBus(basename(projectDir), record.id, conf);
|
|
18
|
-
if (maybeSpawn(projectDir, conf)) markHandedOff(sessionId, cur);
|
|
19
18
|
} catch (e) {
|
|
20
19
|
process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
|
|
21
20
|
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { homedir, hostname } from "node:os";
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { readConfig, contextUsage, warnFrac, alreadyHandedOff } from "./lib/handoff.mjs";
|
|
22
|
-
import { resolveProject } from "../lib/project.mjs";
|
|
22
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
23
23
|
|
|
24
24
|
const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
|
|
25
25
|
const FETCH_TIMEOUT_MS = Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500);
|
|
@@ -83,7 +83,7 @@ async function main(stdinRaw) {
|
|
|
83
83
|
// identity, else a RELAY_AGENT brand ("codex","kimi",…) per project, else hostname:project.
|
|
84
84
|
const project = resolveProject(projectDir);
|
|
85
85
|
const session = process.env.RELAY_SESSION
|
|
86
|
-
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${
|
|
86
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
87
87
|
|
|
88
88
|
// Throttle: only act if HEARTBEAT_MS has elapsed since the last tick for THIS session.
|
|
89
89
|
const stamp = join(homedir(), ".agent-bus", `hb-${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.stamp`);
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
10
10
|
import { join, basename } from "node:path";
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
|
-
import { resolveProject } from "../lib/project.mjs";
|
|
13
|
+
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
14
14
|
|
|
15
15
|
// Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
|
|
16
16
|
// / the heartbeat early-warning). `claim` marks it consumed so exactly one session
|
|
@@ -78,7 +78,7 @@ try {
|
|
|
78
78
|
}
|
|
79
79
|
const project = resolveProject(projectDir);
|
|
80
80
|
const session = process.env.RELAY_SESSION
|
|
81
|
-
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${
|
|
81
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
82
82
|
const url = relayUrl();
|
|
83
83
|
|
|
84
84
|
// register self + post an initial presence status (no LLM turn — instant for others to read)
|
package/hub.mjs
CHANGED
|
@@ -50,11 +50,11 @@ function scanTelemetry() {
|
|
|
50
50
|
|
|
51
51
|
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
52
52
|
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
53
|
-
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {} };
|
|
53
|
+
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {} };
|
|
54
54
|
try {
|
|
55
55
|
if (existsSync(DATA)) {
|
|
56
56
|
const loaded = JSON.parse(readFileSync(DATA, "utf8"));
|
|
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, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {} };
|
|
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, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {}, phaseMeta: (loaded.phaseMeta && typeof loaded.phaseMeta === "object") ? loaded.phaseMeta : {} };
|
|
58
58
|
for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
|
|
59
59
|
state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
|
|
60
60
|
}
|
|
@@ -154,6 +154,95 @@ function appendCardEvent(type, task, by, from = null, to = null) {
|
|
|
154
154
|
if (state.cardEvents.length > 5000) state.cardEvents.splice(0, state.cardEvents.length - 5000);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// --- FLOW v2: derive a project's PHASES (the orchestrator-rooted flowchart spine) ---
|
|
158
|
+
// Real data is deps-sparse (most cards carry no deps), so we DON'T derive phases from the
|
|
159
|
+
// dependency graph. Instead: a card's phase = its title-prefix family (P5a/P5b → "P5",
|
|
160
|
+
// CBv2-1/CBfix → "CB", FA-comp1 → "FA", …) when present; otherwise it's clustered with its
|
|
161
|
+
// time-neighbours into a "Setup N" round (gap > 8h opens a new round). Phases are ordered by
|
|
162
|
+
// first-seen. The orchestrator (host session, "machine:project") vs crew ("brand:project")
|
|
163
|
+
// split gives each phase its fan-out actors; the plan/integrate spine nodes are synthetic
|
|
164
|
+
// because real per-phase orchestrator cards are rare. `sparse` flags a board that's mostly
|
|
165
|
+
// un-prefixed (the UI shows an "inferred phases" notice — never a silent blob).
|
|
166
|
+
const PHASE_GAP_MS = 8 * 60 * 60 * 1000;
|
|
167
|
+
const agentBrand = (a) => { const s = String(a || ""); const i = s.indexOf(":"); return i > 0 ? s.slice(0, i) : (s || ""); };
|
|
168
|
+
// Crew = a known helper-CLI brand; anything else with a brand (a machine hostname like
|
|
169
|
+
// "MacBook-Pro-M1.local" or "MacBookPro.hsd1.fl.comcast.net", or a generic "host") is the
|
|
170
|
+
// orchestrator. Brand-based (not hostname-pattern) so it's robust to hostname instability.
|
|
171
|
+
const CREW_BRANDS = /^(codex|gemini|kimi|deepseek|claude|qwen|grok|glm|mistral|llama)$/i;
|
|
172
|
+
const isOrchAssignee = (a) => { const b = agentBrand(a); return !!b && !CREW_BRANDS.test(b); };
|
|
173
|
+
function phaseFamily(title) {
|
|
174
|
+
const s = String(title || "").trim();
|
|
175
|
+
// "P5a Structured…", "P4-construction", "P3 Quantity" → P5/P4/P3 (group all P5a/b/c/d together).
|
|
176
|
+
// The trailing letter and the separator must NOT be swallowed by \b (P5a has none between 5 and a).
|
|
177
|
+
let m;
|
|
178
|
+
if ((m = s.match(/^P(\d+)[a-z]?(?:[\s\-:.]|$)/i))) return "P" + m[1];
|
|
179
|
+
if (/^CBv?\d/i.test(s) || /^CBfix/i.test(s) || /^CB[\s\-:.]/i.test(s)) return "CB";
|
|
180
|
+
if (/^FA[\s\-:.\d]/i.test(s)) return "FA";
|
|
181
|
+
if (/^RunCost/i.test(s)) return "RunCost";
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function phaseStatus(counts) {
|
|
185
|
+
if (counts.failed) return "failed";
|
|
186
|
+
if (counts.doing || counts.testing) return "active";
|
|
187
|
+
const total = counts.todo + counts.doing + counts.testing + counts.failed + counts.done + counts.blocked;
|
|
188
|
+
if (total > 0 && counts.done === total) return "done";
|
|
189
|
+
if (counts.blocked) return "blocked";
|
|
190
|
+
if (counts.todo === total) return "planned";
|
|
191
|
+
return "active";
|
|
192
|
+
}
|
|
193
|
+
// A human "what is this phase about" line derived from the cards themselves: strip the phase-prefix
|
|
194
|
+
// token, take the subject before the first em/en-dash, dedupe, join the first few. Retroactive — no
|
|
195
|
+
// captured plan needed. An explicit phase goal (phaseMeta) overrides this in the /phases endpoint.
|
|
196
|
+
function phaseTheme(cards) {
|
|
197
|
+
const subs = [];
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
for (const c of cards) {
|
|
200
|
+
let s = String(c.title || "")
|
|
201
|
+
// drop the phase token INCLUDING any sub-index (P3.5, P5a, CBv2-1) + separators, so no "1"/".5" leaks
|
|
202
|
+
.replace(/^\s*(P\d+[a-z]?(?:[.\-]\d+)?|CBv?\d+(?:[.\-]\d+)?|CBfix|FA[-\s:]?\w*|RunCost)[\s:\-–—#]*/i, "")
|
|
203
|
+
.split(/[—–]| - /)[0].trim(); // subject before a dash
|
|
204
|
+
if (!s) continue;
|
|
205
|
+
const k = s.toLowerCase().slice(0, 22);
|
|
206
|
+
if (seen.has(k)) continue;
|
|
207
|
+
seen.add(k); subs.push(s.slice(0, 48));
|
|
208
|
+
if (subs.length >= 3) break;
|
|
209
|
+
}
|
|
210
|
+
return subs.join(" · ").slice(0, 120);
|
|
211
|
+
}
|
|
212
|
+
function derivePhases(tasks) {
|
|
213
|
+
const sorted = [...tasks].sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
214
|
+
let miscRound = 0, lastMiscTs = 0;
|
|
215
|
+
for (const t of sorted) {
|
|
216
|
+
// an explicit phase tag (set at plan time) wins; else infer from the title prefix; else time-cluster.
|
|
217
|
+
const explicit = t.phase && String(t.phase).trim();
|
|
218
|
+
const fam = explicit || phaseFamily(t.title);
|
|
219
|
+
if (fam) { t._phase = fam; }
|
|
220
|
+
else {
|
|
221
|
+
if (!lastMiscTs || (t.ts || 0) - lastMiscTs > PHASE_GAP_MS) miscRound++;
|
|
222
|
+
lastMiscTs = t.ts || lastMiscTs;
|
|
223
|
+
t._phase = `Setup ${miscRound}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const byPhase = new Map();
|
|
227
|
+
for (const t of sorted) { if (!byPhase.has(t._phase)) byPhase.set(t._phase, []); byPhase.get(t._phase).push(t); }
|
|
228
|
+
const phases = [...byPhase.entries()].map(([key, cards]) => {
|
|
229
|
+
const counts = { todo:0, doing:0, testing:0, failed:0, done:0, blocked:0 };
|
|
230
|
+
for (const c of cards) counts[c.status] = (counts[c.status] || 0) + 1;
|
|
231
|
+
const node = (c) => ({ id: c.id, title: c.title, assignee: c.assignee || "", agent: agentBrand(c.assignee), model: c.model || "", status: c.status, difficulty: c.difficulty || "", ts: c.ts || 0, updated: c.updated || c.ts || 0, deps: Array.isArray(c.deps) ? c.deps : [] });
|
|
232
|
+
const crew = cards.filter(c => !isOrchAssignee(c.assignee)).map(node);
|
|
233
|
+
const orchestrators = cards.filter(c => isOrchAssignee(c.assignee)).map(node);
|
|
234
|
+
return {
|
|
235
|
+
key, label: key, theme: phaseTheme(cards),
|
|
236
|
+
start: Math.min(...cards.map(c => c.ts || 0)), end: Math.max(...cards.map(c => c.updated || c.ts || 0)),
|
|
237
|
+
counts, total: cards.length, status: phaseStatus(counts),
|
|
238
|
+
agents: [...new Set(crew.map(c => c.agent).filter(Boolean))],
|
|
239
|
+
crew, orchestrators,
|
|
240
|
+
};
|
|
241
|
+
}).sort((a, b) => a.start - b.start);
|
|
242
|
+
const miscCount = sorted.filter(t => /^Setup /.test(t._phase)).length;
|
|
243
|
+
return { phases, total: sorted.length, sparse: sorted.length > 0 && miscCount / sorted.length > 0.5, derivedBy: "title-prefix + time-cluster" };
|
|
244
|
+
}
|
|
245
|
+
|
|
157
246
|
const server = http.createServer(async (req, res) => {
|
|
158
247
|
const u = new URL(req.url, "http://x"); const q = Object.fromEntries(u.searchParams); const P = u.pathname;
|
|
159
248
|
try {
|
|
@@ -170,6 +259,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
170
259
|
const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
|
|
171
260
|
const t = { id: ++state.taskSeq, project: canon(String(b.project || "").slice(0,80)), title: String(b.title||"").slice(0,200),
|
|
172
261
|
assignee: b.assignee || "", status: st0,
|
|
262
|
+
phase: String(b.phase || "").slice(0, 40), // explicit phase tag (FLOW v2) — wins over title-prefix inference
|
|
173
263
|
difficulty: ["easy","medium","hard"].includes(b.difficulty) ? b.difficulty : "",
|
|
174
264
|
model: String(b.model || "").slice(0, 60),
|
|
175
265
|
deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
|
|
@@ -323,6 +413,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
323
413
|
lastActivity,
|
|
324
414
|
});
|
|
325
415
|
}
|
|
416
|
+
// FLOW v2: the orchestrator-rooted phase flowchart. Returns the project's cards grouped into
|
|
417
|
+
// ordered phases (title-prefix + time-cluster), each with its crew fan-out + orchestrator nodes.
|
|
418
|
+
if (req.method === "GET" && P === "/phases") {
|
|
419
|
+
const proj = canon(q.project || "");
|
|
420
|
+
if (!proj) return json(res, 400, { error: "project required" });
|
|
421
|
+
const mine = state.tasks.filter(t => canon(t.project) === proj);
|
|
422
|
+
const out = derivePhases(mine);
|
|
423
|
+
for (const p of out.phases) p.goal = state.phaseMeta[`${proj}::${p.key}`]?.goal || ""; // explicit goal overrides the derived theme
|
|
424
|
+
return json(res, 200, { project: proj, brief: state.projectMeta[proj]?.brief || "", ...out });
|
|
425
|
+
}
|
|
426
|
+
// Set a phase's explicit GOAL — what this phase needs to do (the orchestrator captures this at plan
|
|
427
|
+
// time, like a per-phase brief). Surfaces in the FLOW v2 header in place of the derived theme.
|
|
428
|
+
if (req.method === "POST" && P === "/phase") {
|
|
429
|
+
const b = await body(req);
|
|
430
|
+
const proj = canon(String(b.project || "").slice(0, 80)), phase = String(b.phase || "").slice(0, 40);
|
|
431
|
+
if (!proj || !phase) return json(res, 400, { error: "project + phase required" });
|
|
432
|
+
const k = `${proj}::${phase}`; const m = state.phaseMeta[k] || {};
|
|
433
|
+
if (b.goal !== undefined) m.goal = String(b.goal).slice(0, 400);
|
|
434
|
+
m.by = b.by || m.by || ""; m.updated = now();
|
|
435
|
+
state.phaseMeta[k] = m; dirty = true;
|
|
436
|
+
return json(res, 200, { ok: true, project: proj, phase, goal: m.goal || "" });
|
|
437
|
+
}
|
|
326
438
|
if (req.method === "GET" && P === "/projects") { // project-grouped view
|
|
327
439
|
prunePeers();
|
|
328
440
|
const cutoff = now() - ONLINE_MS; const byProj = {};
|
package/lib/project.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
|
|
7
7
|
// applies an alias map on top of this to fold any historical divergence.
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { basename, join, dirname } from "node:path";
|
|
11
|
+
import { homedir, hostname } from "node:os";
|
|
10
12
|
|
|
11
13
|
export function gitRoot(dir) {
|
|
12
14
|
try {
|
|
@@ -21,3 +23,24 @@ export function resolveProject(cwd = process.cwd()) {
|
|
|
21
23
|
const root = gitRoot(cwd);
|
|
22
24
|
return basename(root || cwd).slice(0, 80);
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
// Stable machine identity. os.hostname() is network-dependent — the same Mac reports
|
|
28
|
+
// "MacBook-Pro-M1.local" on one network and "MacBookPro.hsd1.fl.comcast.net" on another, which
|
|
29
|
+
// forks one machine into two session identities on the bus. Resolve a stable id ONCE and persist it
|
|
30
|
+
// to ~/.agent-bus/machine-id so it never drifts: RELAY_HOST_ID > persisted id > macOS LocalHostName
|
|
31
|
+
// (stable, no domain) > hostname() without its domain suffix.
|
|
32
|
+
let _hostId = null;
|
|
33
|
+
export function hostId() {
|
|
34
|
+
if (_hostId) return _hostId;
|
|
35
|
+
if (process.env.RELAY_HOST_ID) return (_hostId = process.env.RELAY_HOST_ID.slice(0, 60));
|
|
36
|
+
const f = join(homedir(), ".agent-bus", "machine-id");
|
|
37
|
+
try { if (existsSync(f)) { const v = readFileSync(f, "utf8").trim(); if (v) return (_hostId = v.slice(0, 60)); } } catch {}
|
|
38
|
+
let id = "";
|
|
39
|
+
if (process.platform === "darwin") {
|
|
40
|
+
try { id = execSync("scutil --get LocalHostName", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1500 }).trim(); } catch {}
|
|
41
|
+
}
|
|
42
|
+
if (!id) id = String(hostname() || "host").split(".")[0];
|
|
43
|
+
id = id.slice(0, 60) || "host";
|
|
44
|
+
try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, id); } catch {}
|
|
45
|
+
return (_hostId = id);
|
|
46
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { join, basename } from "node:path";
|
|
|
10
10
|
import { homedir, hostname } from "node:os";
|
|
11
11
|
import { execSync, spawnSync } from "node:child_process";
|
|
12
12
|
import { advise } from "./bin/advise.mjs";
|
|
13
|
-
import { resolveProject } from "./lib/project.mjs";
|
|
13
|
+
import { resolveProject, hostId } from "./lib/project.mjs";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
|
|
16
16
|
function relayUrl() {
|
|
@@ -28,7 +28,7 @@ const PROJECT = resolveProject(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
|
28
28
|
// Identity: RELAY_SESSION wins; else RELAY_AGENT ("codex", "kimi", …) brands the session per-project
|
|
29
29
|
// (set it once in the CLI's global MCP config — works in every project); else hostname:project.
|
|
30
30
|
const SESSION = process.env.RELAY_SESSION
|
|
31
|
-
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${
|
|
31
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
|
|
32
32
|
let cursor = 0;
|
|
33
33
|
|
|
34
34
|
async function api(method, path, payload) {
|
|
@@ -48,11 +48,19 @@ server.tool("relay_whoami", "Show this session's relay identity, project, and th
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
|
|
51
|
-
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
|
|
52
|
-
async ({ title, status, assignee, difficulty, model, deps, project }) => {
|
|
51
|
+
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
|
|
52
|
+
async ({ title, status, assignee, difficulty, model, deps, phase, project }) => {
|
|
53
53
|
const proj = project || PROJECT;
|
|
54
|
-
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
|
|
55
|
-
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]` }] };
|
|
54
|
+
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, by: SESSION });
|
|
55
|
+
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}` }] };
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown as the phase header in the Flow view (overrides the theme auto-derived from card titles). Capture this when you plan a phase so the board says what each milestone needs to do, not just 'P5'. Phase keys match relay_task_add's `phase` (or the inferred title-prefix family like 'P5').",
|
|
59
|
+
{ phase: z.string().describe("the phase key (e.g. 'P5', 'Auth', 'Launch')"), goal: z.string().describe("1-2 sentences: what this phase delivers + done-criteria"), project: z.string().optional().describe("board (default: this session's project)") },
|
|
60
|
+
async ({ phase, goal, project }) => {
|
|
61
|
+
const proj = project || PROJECT;
|
|
62
|
+
await api("POST", "/phase", { project: proj, phase, goal, by: SESSION });
|
|
63
|
+
return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
|
|
56
64
|
});
|
|
57
65
|
|
|
58
66
|
server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external.",
|
package/package.json
CHANGED
package/ui.html
CHANGED
|
@@ -107,9 +107,14 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
107
107
|
.sortmode:hover{color:var(--mut)}
|
|
108
108
|
.proj.idle .proj-h{opacity:.55;cursor:pointer}
|
|
109
109
|
/* flow (DAG) view */
|
|
110
|
-
|
|
111
|
-
.
|
|
112
|
-
.
|
|
110
|
+
/* view switcher — a clearly-labelled segmented control so it doesn't disappear next to the model chips */
|
|
111
|
+
.vtog{display:inline-flex;align-items:stretch;margin-left:12px;border:1px solid #34465f;border-radius:11px;overflow:hidden;background:#0c121e;flex:none}
|
|
112
|
+
.vtog .vlbl{display:flex;align-items:center;font-size:8.5px;font-weight:800;letter-spacing:.09em;color:var(--mut);padding:0 8px;text-transform:uppercase;background:#131c2c;border-right:1px solid #34465f}
|
|
113
|
+
.vbtn{font-size:10.5px;font-weight:800;letter-spacing:.04em;padding:4px 13px;border:0;border-left:1px solid #1f2839;background:transparent;color:#9fb0c4;cursor:pointer}
|
|
114
|
+
.vbtn:first-of-type{border-left:0}
|
|
115
|
+
.vbtn:hover{background:#172339;color:var(--tx)}
|
|
116
|
+
.vbtn.on{color:#04110e;background:var(--grn)}
|
|
117
|
+
.vbtn.on:hover{background:var(--grn);color:#04110e}
|
|
113
118
|
.flowwrap{padding:14px 16px;overflow:auto}
|
|
114
119
|
.flowwrap svg{display:block}
|
|
115
120
|
.fnode{cursor:pointer}
|
|
@@ -165,6 +170,34 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
165
170
|
.tlgl svg{flex:none}
|
|
166
171
|
.tlwrap .fhint{position:absolute;right:10px;bottom:6px;z-index:3}
|
|
167
172
|
.tlnode{cursor:pointer}
|
|
173
|
+
/* FLOW v2 = horizontal orchestrator-rooted flowchart TREE. A spine runs left→right; each phase is a
|
|
174
|
+
◇ plan node that fans OUT (drawn edges) to its crew/LLM card nodes, then converges INTO a ◆ integrate
|
|
175
|
+
node, which connects to the next phase. Agent is a node property, not the axis. Native horizontal scroll. */
|
|
176
|
+
.pflowwrap{background:#0b1019;border-top:1px solid var(--line)}
|
|
177
|
+
.finfo{background:#1a2030;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:11px;color:var(--mut);margin:10px 14px 0}
|
|
178
|
+
.pflowwrap.gflow{position:relative;padding-bottom:22px}
|
|
179
|
+
.gscroll{overflow-x:auto;overflow-y:hidden;padding:4px 0 2px}
|
|
180
|
+
.gscroll svg{display:block}
|
|
181
|
+
.gedge{fill:none;stroke:#33405c;stroke-width:1.6;opacity:.8}
|
|
182
|
+
.gedge.done{stroke:var(--grn2);opacity:.5}
|
|
183
|
+
.gspine{fill:none;stroke:#5a6b86;stroke-width:2.5}
|
|
184
|
+
.gnode{cursor:default}
|
|
185
|
+
.gnode[data-id]{cursor:pointer}
|
|
186
|
+
.gnbox{fill:#101827;stroke:rgba(255,255,255,.12);stroke-width:1}
|
|
187
|
+
.gnode[data-id]:hover .gnbox{stroke:rgba(255,255,255,.55);fill:#162032}
|
|
188
|
+
.gnode.orch .gnbox{fill:#0e1730;stroke:#2c3a55;stroke-dasharray:4 3}
|
|
189
|
+
.gnode.failed .gnbox,.gnode.blocked .gnbox{stroke:var(--red)}
|
|
190
|
+
.gntext{fill:#eaf1fa;font-size:11px;font-family:ui-sans-serif,system-ui;pointer-events:none}
|
|
191
|
+
.gnsub{fill:var(--mut);font-size:9.5px;font-family:ui-sans-serif,system-ui;pointer-events:none}
|
|
192
|
+
.pfband{fill:#0c1320;opacity:.45}
|
|
193
|
+
.pfband.alt{fill:#0e1626;opacity:.55}
|
|
194
|
+
.pfbandtop{opacity:.12}
|
|
195
|
+
.pfbandtop.done{fill:var(--grn2)}.pfbandtop.active{fill:#f0b24b}.pfbandtop.failed,.pfbandtop.blocked{fill:var(--red)}.pfbandtop.planned{fill:#46566f}
|
|
196
|
+
.pflabel{font-size:16px;font-weight:800;font-family:ui-sans-serif,system-ui}
|
|
197
|
+
.pflabel.done{fill:var(--grn2)}.pflabel.active{fill:#f0b24b}.pflabel.failed{fill:var(--red)}.pflabel.planned,.pflabel.blocked{fill:#aeb9c8}
|
|
198
|
+
.pfcount{fill:var(--mut);font-size:10.5px;font-family:ui-sans-serif,system-ui}
|
|
199
|
+
.pfsub{fill:#9fb0c4;font-size:10.5px;font-family:ui-sans-serif,system-ui}
|
|
200
|
+
.pflowwrap .fhint{position:absolute;right:12px;bottom:5px}
|
|
168
201
|
/* card detail modal — the full story of one card: status journey + the agent's own bus reports */
|
|
169
202
|
.cmodal{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(4,7,12,.62)}
|
|
170
203
|
.cmpanel{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(760px,93vw);max-height:84vh;display:flex;flex-direction:column;box-shadow:0 20px 64px rgba(0,0,0,.55)}
|
|
@@ -309,6 +342,7 @@ econ();setInterval(econ,15000);
|
|
|
309
342
|
function poolOf(session){const b=brandOf(session);const k=b==='anthropic'?'claude':b==='openai'?'codex':b==='moonshot'?'kimi':b;return POOLS[k]||'';}
|
|
310
343
|
const VIEWS = JSON.parse(localStorage.getItem("abViews") || "{}");
|
|
311
344
|
let HISTORY = {};
|
|
345
|
+
let PHASES = {};
|
|
312
346
|
function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
|
|
313
347
|
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(); }
|
|
314
348
|
let armedDel=null,armedTs=0; // pending ✕ confirmation (project name + when it was armed)
|
|
@@ -320,71 +354,104 @@ function moveProj(name,dir){
|
|
|
320
354
|
[names[i],names[j]]=[names[j],names[i]];
|
|
321
355
|
localStorage.setItem("abOrder",JSON.stringify(names)); render();
|
|
322
356
|
}
|
|
323
|
-
// FLOW =
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
357
|
+
// FLOW v2 = the project's TRUE shape: an orchestrator-rooted phase flowchart. The orchestrator is the
|
|
358
|
+
// spine (the left rail); each PHASE blooms — ◇ plan → the crew fans out to its cards → ◆ converge —
|
|
359
|
+
// then the next phase begins. Agent identity is a node badge, not the axis. Phases come from the hub
|
|
360
|
+
// (/phases: title-prefix + time-cluster; deps-sparse boards fall back to time + a visible notice).
|
|
361
|
+
// Each crew node keeps its full /card detail on click.
|
|
328
362
|
function flowHTML(pt, proj){
|
|
329
|
-
|
|
330
|
-
|
|
363
|
+
const data = PHASES[proj];
|
|
364
|
+
if (!data) return '<div class="pflowwrap"><div class="empty">loading flow…</div></div>';
|
|
365
|
+
if (!data.phases || !data.phases.length) return '<div class="pflowwrap"><div class="empty">no cards yet</div></div>';
|
|
331
366
|
const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
367
|
+
const NW = 168, NH = 34, RG = 11, CG = 64, PGAP = 76, MT = 64, MB = 22;
|
|
368
|
+
const stackH = n => n*NH + Math.max(0,n-1)*RG;
|
|
369
|
+
// Per-phase layout: cards spread by their INTRA-PHASE dependency depth. Independent cards share
|
|
370
|
+
// column 0 (parallel, hanging off the plan node); a card that depends on another lands one column
|
|
371
|
+
// to the right of its parent (a branch/chain). Leaves (no dependents) converge into integrate.
|
|
372
|
+
const layouts = data.phases.map(ph => {
|
|
373
|
+
const work = [...ph.crew.map(n => ({ ...n, _orch:false })), ...ph.orchestrators.map(n => ({ ...n, _orch:true }))];
|
|
374
|
+
const cards = work.length ? work : [{ id:0, title:'(no cards)', status:ph.status, agent:'', synthetic:true, deps:[] }];
|
|
375
|
+
const idSet = new Set(cards.map(c => c.id)); const byId = {}; cards.forEach(c => byId[c.id] = c);
|
|
376
|
+
const parentsOf = c => (c.deps || []).filter(d => idSet.has(d) && d !== c.id);
|
|
377
|
+
const dmemo = {}; const depth = (c, seen) => {
|
|
378
|
+
if (dmemo[c.id] != null) return dmemo[c.id];
|
|
379
|
+
seen = seen || new Set(); if (seen.has(c.id)) return 0; seen.add(c.id);
|
|
380
|
+
const ps = parentsOf(c); const d = ps.length ? Math.max(...ps.map(p => depth(byId[p], seen))) + 1 : 0;
|
|
381
|
+
return (dmemo[c.id] = d);
|
|
382
|
+
};
|
|
383
|
+
cards.forEach(c => c._d = depth(c));
|
|
384
|
+
const maxDepth = Math.max(0, ...cards.map(c => c._d));
|
|
385
|
+
const cols = []; for (let d = 0; d <= maxDepth; d++) cols[d] = cards.filter(c => c._d === d);
|
|
386
|
+
const hasChild = c => cards.some(o => parentsOf(o).includes(c.id));
|
|
387
|
+
return { ph, cards, byId, parentsOf, hasChild, maxDepth, cols };
|
|
336
388
|
});
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
389
|
+
const maxColCount = Math.max(1, ...layouts.flatMap(L => L.cols.map(c => c.length)));
|
|
390
|
+
const Y0 = MT + stackH(maxColCount)/2; // shared horizontal spine midline
|
|
391
|
+
const totalH = MT + stackH(maxColCount) + MB;
|
|
392
|
+
const gnode = (x, y, title, status, id, orch, agent) => {
|
|
393
|
+
const stripe = SCOL[status] || '#3a4458';
|
|
394
|
+
return `<g class="gnode ${status}${orch?' orch':''}"${id?` data-id="${id}"`:''}>`
|
|
395
|
+
+ `<rect class="gnbox" x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>`
|
|
396
|
+
+ `<rect x="${x}" y="${y}" width="4" height="${NH}" rx="2" fill="${stripe}"/>`
|
|
397
|
+
+ `<text class="gntext" x="${x+11}" y="${y+(agent?15:23)}">${esc(title.slice(0,24))}${title.length>24?'…':''}</text>`
|
|
398
|
+
+ (agent?`<text class="gnsub" x="${x+11}" y="${y+29}">@${esc(agent)}</text>`:'')
|
|
399
|
+
+ `<title>${esc(title)}${agent?' · @'+esc(agent):''} — ${status}</title></g>`;
|
|
400
|
+
};
|
|
401
|
+
const gedge = (x1,y1,x2,y2,done) => { const mx=(x1+x2)/2;
|
|
402
|
+
return `<path class="gedge${done?' done':''}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}"/>`; };
|
|
403
|
+
let x = 26, svg = '', bands = '', prevIntRight = null, pi = 0;
|
|
404
|
+
for (const L of layouts){
|
|
405
|
+
const { ph, cards, byId, parentsOf, hasChild, maxDepth, cols } = L;
|
|
406
|
+
const planX = x, planY = Y0 - NH/2;
|
|
407
|
+
const colX = d => planX + (d+1)*(NW+CG);
|
|
408
|
+
const intX = colX(maxDepth + 1), intY = Y0 - NH/2;
|
|
409
|
+
// per-phase background band (alternating) so each phase reads as one region; collected separately
|
|
410
|
+
// so all bands paint BEHIND the edges/nodes. The header sits inside the band's top strip.
|
|
411
|
+
const bx = planX - 16, bw = (intX + NW + 16) - bx;
|
|
412
|
+
bands += `<rect class="pfband ${pi%2?'alt':''}" x="${bx}" y="2" width="${bw}" height="${totalH-4}" rx="10"/>`
|
|
413
|
+
+ `<rect class="pfbandtop ${ph.status}" x="${bx}" y="2" width="${bw}" height="${MT-12}" rx="10"/>`;
|
|
414
|
+
// header: phase label + the GOAL (explicit) or derived THEME — "what this phase is about"
|
|
415
|
+
const desc = (ph.goal || ph.theme || '').slice(0, Math.max(28, Math.floor(bw/7)));
|
|
416
|
+
svg += `<text class="pflabel ${ph.status}" x="${planX}" y="${MT-38}">${esc(ph.label)}</text>`
|
|
417
|
+
+ `<text class="pfcount" x="${planX}" y="${MT-38}" dx="${(esc(ph.label).length*9)+12}">${ph.status} · ${ph.total} card${ph.total>1?'s':''}</text>`
|
|
418
|
+
+ `<text class="pfsub" x="${planX}" y="${MT-21}">${esc(desc)}</text>`;
|
|
419
|
+
if (prevIntRight != null) svg += `<path class="gspine" marker-end="url(#garr)" d="M${prevIntRight},${Y0} L${planX},${Y0}"/>`;
|
|
420
|
+
svg += gnode(planX, planY, '◇ '+ph.label+' · plan', ph.status, null, true, '');
|
|
421
|
+
// position every card: x by dep-depth column, y stacked + centered on the spine within its column
|
|
422
|
+
const pos = {};
|
|
423
|
+
cols.forEach((colCards, d) => {
|
|
424
|
+
const startY = Y0 - stackH(colCards.length)/2;
|
|
425
|
+
colCards.forEach((c, k) => { pos[c.id] = { x: colX(d), y: startY + k*(NH+RG) }; });
|
|
426
|
+
});
|
|
427
|
+
for (const c of cards){
|
|
428
|
+
const p = pos[c.id], parents = parentsOf(c);
|
|
429
|
+
if (parents.length) for (const pid of parents){ const pp = pos[pid]; if (pp) svg += gedge(pp.x+NW, pp.y+NH/2, p.x, p.y+NH/2, byId[pid].status==='done'); }
|
|
430
|
+
else svg += gedge(planX+NW, planY+NH/2, p.x, p.y+NH/2, ph.status==='done'); // root → plan
|
|
431
|
+
svg += gnode(p.x, p.y, (c.id?'#'+c.id+' ':'')+c.title, c.status, c.synthetic?null:c.id, !!c._orch, c.agent||'');
|
|
432
|
+
if (!hasChild(c)) svg += gedge(p.x+NW, p.y+NH/2, intX, intY+NH/2, c.status==='done'); // leaf → integrate
|
|
368
433
|
}
|
|
369
|
-
svg +=
|
|
370
|
-
|
|
371
|
-
|
|
434
|
+
svg += gnode(intX, intY, '◆ integrate', ph.status, null, true, '');
|
|
435
|
+
prevIntRight = intX + NW;
|
|
436
|
+
x = intX + NW + PGAP;
|
|
437
|
+
pi++;
|
|
372
438
|
}
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
+ `<div class="
|
|
379
|
-
+ gut + `<div class="fhint">build order → · scroll left/right</div></div>`;
|
|
439
|
+
const totalW = x + 10;
|
|
440
|
+
const defs = `<defs><marker id="garr" 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="#46566f"/></marker></defs>`;
|
|
441
|
+
const notice = data.sparse ? `<div class="finfo">⚠ inferred phases — few cards carry explicit tags (P1/P5…), so they're grouped by time + title. Prefix cards (e.g. "P6 …") for sharper phases.</div>` : '';
|
|
442
|
+
return `<div class="pflowwrap gflow" data-proj="${esc(proj)}">${notice}`
|
|
443
|
+
+ `<div class="gscroll" data-proj="${esc(proj)}"><svg width="${totalW}" height="${totalH}">${defs}${bands}${svg}</svg></div>`
|
|
444
|
+
+ `<div class="fhint">orchestrator → crew → integrate · phase by phase · scroll left/right →</div></div>`;
|
|
380
445
|
}
|
|
446
|
+
const GSCROLL = {};
|
|
381
447
|
function wireTimeline(el){
|
|
382
|
-
|
|
448
|
+
// FLOW v2 graph: click a card node to open its full /card detail; preserve horizontal scroll per project.
|
|
449
|
+
el.querySelectorAll('.gscroll').forEach(s => {
|
|
383
450
|
const proj = s.dataset.proj;
|
|
384
|
-
if (
|
|
385
|
-
s.onscroll = () => {
|
|
451
|
+
if (GSCROLL[proj] != null) s.scrollLeft = GSCROLL[proj];
|
|
452
|
+
s.onscroll = () => { GSCROLL[proj] = s.scrollLeft; };
|
|
386
453
|
});
|
|
387
|
-
el.querySelectorAll('.
|
|
454
|
+
el.querySelectorAll('.gnode[data-id]').forEach(n => n.onclick = () => openCard(+n.dataset.id));
|
|
388
455
|
}
|
|
389
456
|
// Card detail modal: one card's FULL story — its status journey interleaved with the agent's own bus
|
|
390
457
|
// reports (what it did, why, how). Click any card in the FLOW timeline to open it.
|
|
@@ -531,7 +598,10 @@ async function render(){
|
|
|
531
598
|
HISTORY[p.project] = h.events || [];
|
|
532
599
|
} catch(e) {}
|
|
533
600
|
});
|
|
534
|
-
|
|
601
|
+
const phaseP = projects.filter(p => VIEWS[p.project] === "flow").map(async p => {
|
|
602
|
+
try { PHASES[p.project] = await (await fetch(`/phases?project=${encodeURIComponent(p.project)}`)).json(); } catch(e) {}
|
|
603
|
+
});
|
|
604
|
+
await Promise.all([...histP, ...phaseP]);
|
|
535
605
|
|
|
536
606
|
// hub was reset (server empty but feed shows history) -> clear the stale client-side feed
|
|
537
607
|
if(!msgs.length&&$('#feed').childElementCount>0){$('#feed').innerHTML='';nmsg=0;}
|
|
@@ -586,7 +656,7 @@ async function render(){
|
|
|
586
656
|
const ph=p.phase||'';
|
|
587
657
|
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>`;
|
|
588
658
|
const view = VIEWS[p.project] || "board";
|
|
589
|
-
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>`;
|
|
659
|
+
const vtog = `<div class="vtog"><span class="vlbl">view</span><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>`;
|
|
590
660
|
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>`:''}`;
|
|
591
661
|
|
|
592
662
|
return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
|