Merge feature/orchestrator/s53-agent-presence into master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/requirements/001-agent-orchestrator.md
#	orchestrator/templates/page-board.js
#	tests/e2e/test_orchestrator_e2e.py
This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-11 07:48:48 +00:00
commit afebf24703
24 files changed
+798 -13

No files matched your search

+1
View File
@@ -15,6 +15,7 @@
const tasks = node ? node.tasks : [];
const claims = node ? node.claims : [];
agentDom.info.replaceChildren(
...[presenceMark(ui.agent, true)].filter(Boolean),
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
h("h3", {}, "Tasks", counter(tasks.length)),
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
+23 -1
View File
@@ -91,10 +91,32 @@
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
return {bar, mode};
}
function lookBox(id, label, on, run) {
const state = Object.hasOwn(PRESENCE, id) ? {class: "check presence", "data-state": id, "data-tone": PRESENCE[id][2]} : {class: "check"};
return h("label", state, h("input", {type: "checkbox", checked: on, "data-look": id, on: {change: (event) => run(event.target.checked)}}), state["data-state"] ? icon(PRESENCE[id][1], "glyph") : null, label);
}
function setLook(change) {
change();
keep(STORE.status, {hidden: [...look.hidden], faces: look.faces});
render(false);
drawAgent();
}
function statusMenu() {
return h("details", {class: "menu"}, h("summary", named("Status", "Status: which agent states show their symbol, and faces in 3D"), icon("bi-activity"), caret()),
h("div", {class: "menu-box"}, h("h3", {}, "Agent states"),
Object.entries(PRESENCE).map(([id, [label]]) => lookBox(id, label, !look.hidden.has(id), (on) => setLook(() => (on ? look.hidden.delete(id) : look.hidden.add(id))))),
h("h3", {}, "3D"),
lookBox("faces", "Faces in 3D", look.faces, (on) => setLook(() => {
look.faces = on;
}))));
}
function drawToolbar() {
drawSpot();
const toolbar = graphDom && graphDom.toolbar;
+9 -1
View File
@@ -37,7 +37,15 @@
const MOVE = 2000;
const RELAYOUT = 60000;
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
const STORE = {views: "kvb-orchestrator.views"};
const STORE = {views: "kvb-orchestrator.views", status: "kvb-orchestrator.status"};
const PRESENCE = {
working: ["Working", "bi-gear-fill", "info"],
alive: ["Alive", "bi-heart-pulse-fill", "ok"],
sleeping: ["Sleeping", "bi-moon-stars-fill", "violet"],
suspending: ["Suspending", "bi-hourglass-split", "warn"],
unreachable: ["Unreachable", "bi-wifi-off", "bad"],
dead: ["Dead", "bi-x-octagon-fill", "bad"],
};
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
const SPEECH_QUEUE = 20;
+14
View File
@@ -75,6 +75,20 @@
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
};
const presenceOf = (key) => {
const value = (state.presence || {})[key];
return Object.hasOwn(PRESENCE, value) && !look.hidden.has(value) ? value : null;
};
const presenceMark = (key, labelled) => {
const value = presenceOf(key);
return value ? h("span", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], role: "img", ...named(PRESENCE[value][0])}, icon(PRESENCE[value][1], "glyph"), labelled ? PRESENCE[value][0] : null) : null;
};
const presenceGlyph = (key, x, y, size) => {
const value = presenceOf(key);
return value ? s("g", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], transform: `translate(${fixed(x)} ${fixed(y)})`},
s("title", {}, PRESENCE[value][0]), s("rect", {class: "presence-dot", x: fixed(-size * 0.65), y: fixed(-size * 0.65), width: fixed(size * 1.3), height: fixed(size * 1.3), rx: fixed(size * 0.65)}),
s("g", {class: "glyph"}, s("use", {href: `#${PRESENCE[value][1]}`, x: fixed(-size / 2), y: fixed(-size / 2), width: fixed(size), height: fixed(size)}))) : null;
};
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
const people = () => state.people || {};
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
@@ -114,6 +114,7 @@
lanes.forEach((node, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
svg.append(...[presenceGlyph(node.id, left - 14, top + index * lane + lane / 2, 12)].filter(Boolean));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
@@ -138,6 +138,7 @@
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
s("circle", {r}),
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
node.kind === "subagent" ? null : presenceGlyph(node.id, r * 0.8, -r * 0.8, Math.max(10, r * 0.7)),
caption(node, at, r, gap));
}
+146 -2
View File
@@ -10,7 +10,8 @@
const SPAWNED = new Set(["twig", "subagent"]);
const DEPTH_GAP = 90;
const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
const TOP = {operator: 6, chair: 4, session: 2.5};
const g3 = {lib: null, three: null, THREE: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false, badges: new Map(), faces: new Set(), textures: new Map(), pulse: 0};
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
@@ -84,6 +85,7 @@
const nodes = state.graph.nodes.map((node) => ({
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
face: look.faces && Object.hasOwn(TOP, node.kind), badge: node.kind === "chair" || node.kind === "session",
}));
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
const walk = (twig, parent, depth) => {
@@ -196,7 +198,135 @@
}
function signature3d(tab, data) {
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone, node.face]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
function canvasTexture(size, paint) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
paint(canvas.getContext("2d"));
const texture = new g3.THREE.CanvasTexture(canvas);
texture.colorSpace = g3.THREE.SRGBColorSpace;
return texture;
}
function cached(key, make) {
if (!g3.textures.has(key)) {
g3.textures.set(key, make());
}
return g3.textures.get(key);
}
function paintFace(context, key) {
context.scale(128 / Faces.SIZE, 128 / Faces.SIZE);
for (const [tag, part] of Faces.of(key).parts) {
const path = tag === "path" ? new Path2D(part.d) : new Path2D();
if (tag === "ellipse") {
path.ellipse(part.cx, part.cy, part.rx, part.ry, 0, 0, 2 * Math.PI);
} else if (tag === "rect") {
path.roundRect(part.x, part.y, part.width, part.height, part.rx || 0);
}
context.globalAlpha = part.opacity ?? 1;
if (part.fill && part.fill !== "none") {
context.fillStyle = part.fill;
context.fill(path);
}
if (part.stroke) {
context.globalAlpha *= part["stroke-opacity"] ?? 1;
context.strokeStyle = part.stroke;
context.lineWidth = part["stroke-width"] || 1;
context.lineCap = part["stroke-linecap"] || "butt";
context.lineJoin = part["stroke-linejoin"] || "miter";
context.stroke(path);
}
}
}
function badgeTexture(value) {
const [, name, tone] = PRESENCE[value];
const color = g3.colors[tone] || g3.colors.text;
return cached(`${value}:${color}`, () => canvasTexture(64, (context) => {
context.fillStyle = "rgba(255, 255, 255, 0.92)";
context.arc(32, 32, 31, 0, 2 * Math.PI);
context.fill();
context.fillStyle = color;
context.translate(12, 12);
context.scale(2.5, 2.5);
for (const path of document.querySelectorAll(`#${name} path`)) {
context.fill(new Path2D(path.getAttribute("d")), path.getAttribute("fill-rule") || "nonzero");
}
}));
}
function count3d() {
const box = g3.host && g3.host.querySelector(".graph3d");
if (box) {
box.dataset.faces = String(g3.faces.size);
box.dataset.badges = String([...g3.badges.values()].filter((badge) => badge.visible).length);
}
if (!g3.pulse && g3.badges.size && !calm.matches) {
g3.pulse = window.requestAnimationFrame(pulse3d);
}
}
function paintBadge(key, badge) {
const value = presenceOf(key);
badge.visible = value !== null;
badge.userData.state = value;
const map = value ? badgeTexture(value) : null;
if (map && badge.material.map !== map) {
badge.material.map = map;
badge.material.needsUpdate = true;
}
}
function badges3d() {
for (const [key, badge] of g3.badges) {
paintBadge(key, badge);
}
count3d();
}
function pulse3d(now) {
g3.pulse = 0;
if (!g3.graph || !g3.badges.size || calm.matches) {
return;
}
for (const badge of g3.badges.values()) {
const {state: value, size} = badge.userData;
const beat = value === "alive" ? 1 + 0.2 * Math.max(0, Math.sin(now / 150)) : 1;
badge.scale.set(size * beat, size * beat, 1);
badge.material.rotation = value === "working" ? now / 500 : value === "suspending" ? Math.PI * (Math.floor(now / 1200) % 2) : 0;
badge.material.opacity = ["sleeping", "dead", "unreachable"].includes(value) ? 0.6 + 0.4 * Math.cos(now / (value === "unreachable" ? 200 : 900)) ** 2 : 1;
}
g3.pulse = window.requestAnimationFrame(pulse3d);
}
function object3d(node) {
const THREE = g3.THREE;
if (!THREE || !(node.face || node.badge)) {
return null;
}
const radius = Math.cbrt(TOP[node.kind]) * 5;
const group = new THREE.Group();
if (node.face) {
const face = new THREE.Sprite(new THREE.SpriteMaterial({map: cached(`face:${node.id}`, () => canvasTexture(128, (context) => paintFace(context, node.id)))}));
face.scale.set(radius * 2.4, radius * 2.4, 1);
group.add(face);
g3.faces.add(node.id);
}
if (node.badge) {
const badge = new THREE.Sprite(new THREE.SpriteMaterial());
badge.userData.size = radius * 1.1;
badge.scale.set(badge.userData.size, badge.userData.size, 1);
badge.position.set(0, radius * 1.7, 0);
paintBadge(node.id, badge);
g3.badges.set(node.id, badge);
group.add(badge);
}
count3d();
return group;
}
async function stage3d(tab, host) {
@@ -209,6 +339,7 @@
const data = model3d(tab);
const sig = signature3d(tab, data);
if (g3.host === host && g3.sig === sig) {
badges3d();
return;
}
if (g3.host !== host) {
@@ -232,6 +363,12 @@
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
return;
}
if (tab === "agents" && !g3.THREE) {
g3.THREE = await threeModule();
if (g3.host !== host) {
return;
}
}
if (!g3.graph) {
try {
g3.graph = Graph({controlType: "orbit"})(box);
@@ -246,6 +383,7 @@
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
.nodeThreeObject(object3d).nodeThreeObjectExtend((node) => !node.face)
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
g3.fit = true;
g3.graph.warmupTicks(calm.matches ? 160 : 80);
@@ -257,6 +395,8 @@
}
const layout = layoutOf(tab);
place(data.nodes, layout);
g3.badges = new Map();
g3.faces = new Set();
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
@@ -271,10 +411,14 @@
g3.graph._destructor();
}
}
window.cancelAnimationFrame(g3.pulse);
g3.pulse = 0;
g3.graph = null;
g3.host = null;
g3.sig = "";
g3.planes = [];
g3.badges = new Map();
g3.faces = new Set();
}
function refresh3d() {
+2 -1
View File
@@ -11,7 +11,7 @@
function hierarchySig() {
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
return JSON.stringify([held, state.agent_order, state.people]);
return JSON.stringify([held, state.agent_order, state.people, state.presence, [...look.hidden]]);
}
function drawHierarchy(force) {
@@ -56,6 +56,7 @@
return h("div", {class: "org-row", "data-office": claim.office},
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
claim.session === "-" ? null : presenceMark(claim.session, false),
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
h("span", {class: "org-actions"},
+1 -1
View File
@@ -93,7 +93,7 @@
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, entry.kind === "session" || entry.kind === "chair" ? presenceMark(entry.id, false) : null, running, meta),
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
}
+6
View File
@@ -25,6 +25,11 @@
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
}
function validLook(value) {
const hidden = value && Array.isArray(value.hidden) ? value.hidden.filter((name) => Object.hasOwn(PRESENCE, name)) : [];
return {hidden: new Set(hidden), faces: !(value && value.faces === false)};
}
let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
@@ -46,6 +51,7 @@
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
};
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
const look = validLook(stored(STORE.status, null));
let settings = state.settings;
let settingsSig = JSON.stringify(settings);
let agentDom = null;
+24
View File
@@ -18,6 +18,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
@media (prefers-reduced-motion: reduce) {
.radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
}
.presence { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
.presence[data-tone="ok"] { color: var(--ok); }
.presence[data-tone="bad"] { color: var(--bad); }
.presence[data-tone="warn"] { color: var(--warn); }
.presence[data-tone="info"] { color: var(--info); }
.presence[data-tone="violet"] { color: var(--violet); }
svg .presence { fill: currentColor; }
svg .presence-dot { fill: var(--panel); stroke: currentColor; stroke-width: 1; }
.presence .glyph { transform-box: fill-box; transform-origin: center; }
.presence[data-state="working"] .glyph { animation: presence-spin 3s linear infinite; }
.presence[data-state="alive"] .glyph { animation: presence-beat 1.6s ease-in-out infinite; }
.presence[data-state="sleeping"] .glyph { animation: presence-drift 4s ease-in-out infinite; }
.presence[data-state="suspending"] .glyph { animation: presence-flip 2.4s ease-in-out infinite; }
.presence[data-state="unreachable"] .glyph { animation: presence-blink 1.2s ease-in-out infinite; }
.presence[data-state="dead"] .glyph { animation: presence-fade 5s ease-in-out infinite; }
@keyframes presence-spin { to { transform: rotate(360deg); } }
@keyframes presence-beat { 15%, 45% { transform: scale(1.25); } 30% { transform: scale(1); } }
@keyframes presence-drift { 50% { transform: translateY(-15%); opacity: .6; } }
@keyframes presence-flip { 50%, 100% { transform: rotate(180deg); } }
@keyframes presence-blink { 50% { opacity: .25; } }
@keyframes presence-fade { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) {
.presence .glyph { animation: none; }
}
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }