Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.3 KiB
JavaScript
198 lines
8.3 KiB
JavaScript
function shownCursor() {
|
||
const glide = space.cursor;
|
||
if (glide.to === null) {
|
||
return state.until;
|
||
}
|
||
const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1);
|
||
return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress);
|
||
}
|
||
|
||
function sceneInput() {
|
||
const span = [Math.min(Math.max(state.since, state.first), state.until), state.until];
|
||
const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span;
|
||
const lives = {};
|
||
const subagents = {};
|
||
for (const [key, entry] of space.subs) {
|
||
if (entry.data) {
|
||
lives[key] = [entry.data.since, entry.data.until];
|
||
subagents[key] = entry.data.subagents;
|
||
}
|
||
}
|
||
return {
|
||
axis: depth(), range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
|
||
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
|
||
};
|
||
}
|
||
|
||
function buildSpace() {
|
||
const input = sceneInput();
|
||
space.sceneCursor = input.cursor;
|
||
space.scene = Space.scene(input);
|
||
}
|
||
|
||
function spaceLabel() {
|
||
const hint = AXES.find(([id]) => id === depth())[2];
|
||
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
|
||
}
|
||
|
||
function stageSpace() {
|
||
if (!graphDom.space) {
|
||
const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"});
|
||
steer(canvas, "graph", graphHooks);
|
||
graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false};
|
||
graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list);
|
||
}
|
||
if (space.cursor.to !== state.until) {
|
||
space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()};
|
||
}
|
||
if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) {
|
||
space.trackAsked = Date.now();
|
||
load();
|
||
}
|
||
buildSpace();
|
||
const held = graphDom.space;
|
||
if (held.data !== state || held.version !== space.version || held.node !== ui.node) {
|
||
held.data = state;
|
||
held.version = space.version;
|
||
held.node = ui.node;
|
||
drawSpaceList();
|
||
refreshSubs();
|
||
}
|
||
drawSpaceNote();
|
||
requestSpace();
|
||
}
|
||
|
||
function drawSpaceNote() {
|
||
const built = space.scene;
|
||
const format = (ts) => absolute.format(stampOf(ts));
|
||
const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`;
|
||
const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`;
|
||
if (graphDom.space.words === words) {
|
||
return;
|
||
}
|
||
graphDom.space.words = words;
|
||
const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : [];
|
||
graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped);
|
||
}
|
||
|
||
function drawSpaceList() {
|
||
const built = space.scene;
|
||
const held = graphDom.space;
|
||
const chip = (line) => {
|
||
const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]);
|
||
const former = [...new Set(line.timeline.map((entry) => entry.name))];
|
||
const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`;
|
||
return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]);
|
||
};
|
||
const twigs = built.lines.filter((line) => line.kind === "twig");
|
||
const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null;
|
||
if (more) {
|
||
more.addEventListener("toggle", () => {
|
||
held.open = more.open;
|
||
});
|
||
}
|
||
held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || "");
|
||
}
|
||
|
||
async function refreshSubs() {
|
||
if (!graph3d() || space.loading) {
|
||
return;
|
||
}
|
||
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
|
||
.filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL);
|
||
if (!due.length) {
|
||
return;
|
||
}
|
||
space.loading = true;
|
||
await Promise.all(due.map(async (node) => {
|
||
let data = null;
|
||
try {
|
||
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
|
||
data = response.ok ? await response.json() : null;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
space.subs.set(node.id, {at: Date.now(), data});
|
||
}));
|
||
space.loading = false;
|
||
space.version += 1;
|
||
stage();
|
||
}
|
||
|
||
function spaceStyle() {
|
||
if (!space.style || space.styleKey !== sky.mode) {
|
||
const css = window.getComputedStyle(document.documentElement);
|
||
const value = (name) => css.getPropertyValue(`--${name}`).trim();
|
||
const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"};
|
||
space.styleKey = sky.mode;
|
||
space.style = {
|
||
colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])),
|
||
font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe",
|
||
};
|
||
}
|
||
const built = space.scene;
|
||
return {
|
||
...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand",
|
||
format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)),
|
||
};
|
||
}
|
||
|
||
function requestSpace() {
|
||
if (!space.frame) {
|
||
space.frame = window.requestAnimationFrame(paintSpace);
|
||
}
|
||
}
|
||
|
||
function paintSpace() {
|
||
space.frame = 0;
|
||
if (!graph3d() || !graphDom || !graphDom.space || !space.scene) {
|
||
return;
|
||
}
|
||
const canvas = graphDom.space.canvas;
|
||
const paint = canvas.getContext("2d");
|
||
if (!paint) {
|
||
return;
|
||
}
|
||
const cursor = shownCursor();
|
||
if (cursor !== space.sceneCursor) {
|
||
buildSpace();
|
||
}
|
||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||
const width = canvas.clientWidth;
|
||
const height = canvas.clientHeight;
|
||
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
||
canvas.width = Math.round(width * ratio);
|
||
canvas.height = Math.round(height * ratio);
|
||
}
|
||
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
space.cam = Space.camera(seenView("graph"), width, height);
|
||
Space.paint(paint, space.scene, space.cam, spaceStyle());
|
||
if (space.glides.graph || cursor !== space.cursor.to) {
|
||
requestSpace();
|
||
}
|
||
}
|
||
|
||
function pickLine(line) {
|
||
if (line.kind === "twig") {
|
||
focusSub(line.session, line.sub);
|
||
} else {
|
||
select(line.node);
|
||
}
|
||
}
|
||
|
||
function pickSpace(x, y) {
|
||
const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null;
|
||
if (!hit) {
|
||
return;
|
||
}
|
||
if (hit.kind === "line") {
|
||
pickLine(hit.line);
|
||
} else if (hit.arc.kind === "spawn") {
|
||
focusSub(hit.arc.source, hit.arc.sub);
|
||
} else if (hit.arc.kind === "grant") {
|
||
select(hit.arc.target);
|
||
} else {
|
||
useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)});
|
||
}
|
||
}
|