The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py. Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live. Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004. 🎯 Fix: 80 % 💥 Break: 4 % 💸 Cost: 129 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 60 🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit ``` /home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub ``` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
const SpaceScene = (() => {
|
|
"use strict";
|
|
const {LEVELS, clamp, turn, depthOf} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
|
const TONES = {direct: "direct", broadcast: "broadcast", mayday: "alarm", "pan-pan": "alarm", security: "security", grant: "grant", spawn: "spawn"};
|
|
const RANKS = {alarm: 0, spawn: 1, security: 2, grant: 3, direct: 4, broadcast: 5};
|
|
const MOON = 38;
|
|
const MOON_SPREAD = 0.5;
|
|
const TWIG = 30;
|
|
const TWIG_SPREAD = 0.45;
|
|
const ARC_CAP = 1500;
|
|
|
|
function bend(from, to) {
|
|
const dx = to.x - from.x;
|
|
const dy = to.y - from.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
return {x: ((from.x + to.x) / 2) * 0.82 - (dy / length) * 22, y: ((from.y + to.y) / 2) * 0.82 + (dx / length) * 22};
|
|
}
|
|
|
|
function satellite(base, index, count, reach, spread) {
|
|
const angle = base.angle + (index - (count - 1) / 2) * spread;
|
|
return {x: base.x + reach * Math.cos(angle), y: base.y + reach * Math.sin(angle), angle};
|
|
}
|
|
|
|
function moon(base, index, count) {
|
|
return satellite(base, index, count, MOON, MOON_SPREAD);
|
|
}
|
|
|
|
function nameAt(timeline, ts) {
|
|
const valid = timeline.filter((entry) => entry.since <= ts);
|
|
return (valid.length ? valid[valid.length - 1] : timeline[0]).name;
|
|
}
|
|
|
|
function spotAt(track, id, ts) {
|
|
let after = 0;
|
|
while (after < track.length && track[after].ts < ts) {
|
|
after += 1;
|
|
}
|
|
const late = track[Math.min(after, track.length - 1)].orbit[id];
|
|
const early = track[Math.max(after - 1, 0)].orbit[id];
|
|
if (!late || !early) {
|
|
return late || early || null;
|
|
}
|
|
const span = after > 0 && after < track.length ? track[after].ts - track[after - 1].ts : 0;
|
|
const share = span > 0 ? (ts - track[after - 1].ts) / span : 1;
|
|
return {angle: turn(early.angle, late.angle, share), radius: early.radius + (late.radius - early.radius) * share};
|
|
}
|
|
|
|
function born(row) {
|
|
return row.since === null ? row.started : row.since;
|
|
}
|
|
|
|
function tree(rows) {
|
|
const order = [...rows].sort((a, b) => born(a) - born(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
const parents = {};
|
|
order.forEach((row, index) => {
|
|
const elders = order.slice(0, index).filter((other) => other.depth === row.depth - 1);
|
|
const living = elders.filter((other) => other.until === null || other.until >= born(row));
|
|
const chosen = (living.length ? living : elders).at(-1);
|
|
parents[row.id] = row.depth > 1 && chosen ? chosen.id : "";
|
|
});
|
|
return {order, parents};
|
|
}
|
|
|
|
function capArcs(arcs, cap) {
|
|
if (arcs.length <= cap) {
|
|
return {kept: arcs, dropped: 0};
|
|
}
|
|
const ranks = arcs.map((arc) => (Object.hasOwn(RANKS, arc.tone) ? RANKS[arc.tone] : RANKS.direct));
|
|
const order = arcs.map((arc, index) => index).sort((a, b) => ranks[a] - ranks[b] || arcs[b].ts - arcs[a].ts || a - b);
|
|
const chosen = order.slice(0, cap).sort((a, b) => a - b);
|
|
return {kept: chosen.map((index) => arcs[index]), dropped: arcs.length - cap};
|
|
}
|
|
|
|
function scene(input) {
|
|
const {axis, range, span, cursor, nodes, track, now, lives, subagents, radio, grants, chair, cap} = input;
|
|
const floor = span[0];
|
|
const top = Math.max(Math.min(cursor, span[1]), floor);
|
|
const last = track.length ? track[track.length - 1].ts : -Infinity;
|
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
const moons = new Map();
|
|
for (const node of nodes.filter((entry) => entry.kind === "subagent")) {
|
|
moons.set(node.parent, [...(moons.get(node.parent) || []), node]);
|
|
}
|
|
const lines = [];
|
|
const labels = [];
|
|
const arcs = [];
|
|
const point = (spot, ts, level) => ({x: spot.x, y: spot.y, z: depthOf(axis, {ts, level}, range), ts});
|
|
const headed = (spot) => ({x: spot.x, y: spot.y, angle: spot.x === 0 && spot.y === 0 ? -Math.PI / 2 : Math.atan2(spot.y, spot.x)});
|
|
const stamps = (start, end) => (axis === "time" && start < end ? [start, ...track.map((sample) => sample.ts).filter((ts) => ts > start && ts < end), end] : [end]);
|
|
|
|
function anchor(id, ts) {
|
|
const node = byId.get(id);
|
|
if (!node || node.kind === "operator") {
|
|
return {x: 0, y: 0};
|
|
}
|
|
if (node.kind === "subagent") {
|
|
const family = moons.get(node.parent);
|
|
return moon(headed(anchor(byId.has(node.parent) ? node.parent : chair, ts)), family.indexOf(node), family.length);
|
|
}
|
|
const spot = axis === "time" && ts < last ? spotAt(track, id, ts) : null;
|
|
return spot ? {x: spot.radius * Math.cos(spot.angle), y: spot.radius * Math.sin(spot.angle)} : now[id] || {x: 0, y: 0};
|
|
}
|
|
|
|
function within(life) {
|
|
const start = Math.max(floor, life && life[0] !== null ? life[0] : floor);
|
|
const end = Math.min(top, life && life[1] !== null ? life[1] : top);
|
|
return start <= end ? [start, end] : [floor, top];
|
|
}
|
|
|
|
function addLine(entry, place, start, end) {
|
|
const points = stamps(start, end).map((ts) => point(place(ts), ts, entry.level));
|
|
const index = lines.length;
|
|
lines.push({...entry, points});
|
|
if (entry.kind === "subagent" || (entry.kind === "twig" && !entry.alive)) {
|
|
return;
|
|
}
|
|
const tail = points[points.length - 1];
|
|
labels.push({line: index, at: tail, text: nameAt(entry.timeline, tail.ts)});
|
|
if (axis !== "time") {
|
|
return;
|
|
}
|
|
const head = points[0];
|
|
const changes = entry.timeline.filter((change) => change.since > head.ts && change.since < tail.ts);
|
|
for (const change of changes) {
|
|
labels.push({line: index, at: point(place(change.since), change.since, entry.level), text: change.name});
|
|
}
|
|
if (changes.length) {
|
|
labels.push({line: index, at: head, text: nameAt(entry.timeline, head.ts)});
|
|
}
|
|
}
|
|
|
|
function connect(source, targets, tone, ts, row, count, kind) {
|
|
const moment = axis === "time" ? ts : top;
|
|
const from = point(anchor(source, moment), moment, 0);
|
|
if (!targets.length) {
|
|
arcs.push({kind: "broadcast", tone, ts, source, target: "*", sub: "", row, count, from, via: from, to: from});
|
|
return;
|
|
}
|
|
for (const target of targets) {
|
|
if (target !== source && byId.has(target)) {
|
|
const to = point(anchor(target, moment), moment, 0);
|
|
arcs.push({kind, tone, ts, source, target, sub: "", row, count, from, via: {...bend(from, to), z: from.z}, to});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const node of nodes) {
|
|
const owner = node.kind === "subagent" ? node.parent : node.id;
|
|
const [start, end] = within(lives[owner]);
|
|
const timeline = node.timeline && node.timeline.length ? node.timeline : [{name: node.label, since: floor}];
|
|
addLine({node: node.id, kind: node.kind, session: owner, sub: "", level: node.kind === "subagent" ? 1 : 0, timeline, title: node.label, alive: end >= top}, (ts) => anchor(node.id, ts), start, end);
|
|
}
|
|
for (const session of Object.keys(subagents).sort()) {
|
|
if (!byId.has(session)) {
|
|
continue;
|
|
}
|
|
const {order, parents} = tree(subagents[session]);
|
|
const families = new Map();
|
|
for (const row of order) {
|
|
families.set(parents[row.id], [...(families.get(parents[row.id]) || []), row.id]);
|
|
}
|
|
const chains = new Map();
|
|
for (const row of order) {
|
|
const family = families.get(parents[row.id]);
|
|
chains.set(row.id, [...(chains.get(parents[row.id]) || []), {index: family.indexOf(row.id), count: family.length}]);
|
|
}
|
|
const reach = (chain, ts) => chain.reduce((spot, link) => satellite(spot, link.index, link.count, TWIG, TWIG_SPREAD), headed(anchor(session, ts)));
|
|
for (const row of order) {
|
|
const since = born(row);
|
|
if (since > top || (row.until !== null && row.until < floor)) {
|
|
continue;
|
|
}
|
|
const chain = chains.get(row.id);
|
|
const end = Math.min(top, row.until === null ? top : row.until);
|
|
const alive = row.until === null || row.until >= top;
|
|
const name = row.type || "Subagent";
|
|
addLine({node: null, kind: "twig", session, sub: row.id, level: row.depth, timeline: [{name, since}], title: `${name}: ${row.description}`, alive}, (ts) => reach(chain, ts), Math.max(floor, since), end);
|
|
if (axis !== "time" || since >= floor) {
|
|
const moment = axis === "time" ? since : end;
|
|
const from = point(reach(chain.slice(0, -1), moment), moment, row.depth - 1);
|
|
const to = point(reach(chain, moment), moment, row.depth);
|
|
arcs.push({kind: "spawn", tone: "spawn", ts: since, source: session, target: session, sub: row.id, row: -1, count: 1, from, via: {x: (from.x + to.x) / 2, y: (from.y + to.y) / 2, z: (from.z + to.z) / 2}, to});
|
|
}
|
|
}
|
|
}
|
|
const heard = radio.map((row, index) => ({row, index})).filter(({row}) => row.ts >= floor && row.ts <= top && byId.has(row.source));
|
|
const granted = grants.map((grant, index) => ({grant, index})).filter(({grant}) => grant.ts >= floor && grant.ts <= top && byId.has(chair));
|
|
if (axis === "time") {
|
|
for (const {row, index} of heard) {
|
|
connect(row.source, row.targets, TONES[row.class] || "direct", row.ts, index, 1, "message");
|
|
}
|
|
for (const {grant, index} of granted) {
|
|
connect(chair, [grant.target], "grant", grant.ts, index, 1, "grant");
|
|
}
|
|
} else {
|
|
const groups = new Map();
|
|
const gather = (source, targets, tone, ts, kind) => {
|
|
for (const target of targets.length ? targets : ["*"]) {
|
|
const key = `${kind}\u0000${source}\u0000${target}\u0000${tone}`;
|
|
const group = groups.get(key) || {source, target, tone, ts, kind, count: 0};
|
|
group.count += 1;
|
|
group.ts = Math.max(group.ts, ts);
|
|
groups.set(key, group);
|
|
}
|
|
};
|
|
for (const {row} of heard) {
|
|
gather(row.source, row.targets, TONES[row.class] || "direct", row.ts, "message");
|
|
}
|
|
for (const {grant} of granted) {
|
|
gather(chair, [grant.target], "grant", grant.ts, "grant");
|
|
}
|
|
for (const group of groups.values()) {
|
|
connect(group.source, group.target === "*" ? [] : [group.target], group.tone, group.ts, -1, group.count, group.kind);
|
|
}
|
|
}
|
|
const {kept, dropped} = capArcs(arcs, cap);
|
|
const levels = [...new Set(lines.map((line) => clamp(line.level, 0, LEVELS)))].sort((a, b) => a - b);
|
|
const guides = axis === "time" ? range.map((ts) => ({z: depthOf(axis, {ts, level: 0}, range), ts, level: 0})) : levels.map((level) => ({z: depthOf(axis, {ts: top, level}, range), ts: top, level}));
|
|
const plane = axis === "time" ? {z: depthOf(axis, {ts: top, level: 0}, range), ts: top} : null;
|
|
return {axis, range, span: [floor, top], lines, labels, arcs: kept, guides, plane, total: arcs.length, dropped};
|
|
}
|
|
|
|
return {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene};
|
|
})();
|
|
if (typeof module === "object" && module.exports) {
|
|
module.exports = SpaceScene;
|
|
}
|