Merge feature/orchestrator/s52-night-per-agent into master

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

# Conflicts:
#	docs/requirements/001-agent-orchestrator.md
#	orchestrator/kvb_orchestrator/view.py
#	orchestrator/templates/page-board.js
#	tests/test_hub_state.py
This commit is contained in:
Kevin Veen-Birkenbach committed 2026-11-11 07:53:40 +00:00
commit c3ab6b7e18
18 files changed
+408 -64

No files matched your search

+8 -2
View File
@@ -14,6 +14,8 @@
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
const tasks = node ? node.tasks : [];
const claims = node ? node.claims : [];
const part = state.night_parts[ui.agent] || state.night;
agentDom.night.replaceChildren(pill(...(NIGHT[part] || [part, "neutral"])));
agentDom.info.replaceChildren(
...[presenceMark(ui.agent, true)].filter(Boolean),
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
@@ -109,10 +111,14 @@
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
const log = h("div", {"aria-live": "polite"});
const life = h("span", {class: "muted"});
const night = h("span", {});
const claimed = nightAgents().some(([sid]) => sid === key);
const nightRow = h("div", {class: "actions agent-night", role: "group", "aria-label": "Day/Night"}, night, h("span", {class: "spacer"}),
claimed ? ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, () => [key])) : null);
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
openPopup(name, [
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
h("div", {class: "popup-body"}, nightRow, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
form,
], anchor, () => {
agentDom = null;
@@ -122,7 +128,7 @@
}
});
ui.agent = key;
agentDom = {info, history: log};
agentDom = {info, history: log, night};
drawAgent();
loadSubagents(key, subs, life);
text.focus();
+54 -11
View File
@@ -13,14 +13,19 @@
const actionOf = (action) => ACTIONS.find(([id]) => id === action);
async function confirmAct(action) {
const nightAgents = () => [...new Map(state.claims.filter((claim) => claim.agent && claim.agent !== HUMAN && claim.session !== "-").map((claim) => [claim.session, claim.agent]))]
.sort(([one, a], [two, b]) => a.localeCompare(b) || one.localeCompare(two));
async function confirmAct(action, agents) {
const [, label, question] = actionOf(action);
if (!window.confirm(question)) {
const names = new Map(nightAgents());
if (!window.confirm(agents.length ? question.replace("all agents", agents.map((sid) => names.get(sid) || sid).join(", ")) : question)) {
return;
}
const signed = await signedProof("/api/board/sign", {action}, label);
const chosen = agents.length ? {agents} : {};
const signed = await signedProof("/api/board/sign", {action, ...chosen}, label);
if (signed) {
await act(action, label, {proof: signed.proof});
await act(action, label, {proof: signed.proof, ...chosen});
}
}
@@ -31,9 +36,39 @@
const picked = (group) => group.querySelector("input:checked").value;
function actionButton(action, cls, name) {
function actionButton(action, cls, name, agents) {
const [, label, , hint] = actionOf(action);
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label);
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action, agents())}}, name ? icon(name) : null, label);
}
function nightChosen() {
const agents = nightAgents().map(([sid]) => sid);
const on = agents.filter((sid) => !ui.nightOff.has(sid));
return on.length === agents.length ? [] : on;
}
function syncPicks(toolbar) {
const boxes = [...toolbar.picks.querySelectorAll("input[data-session]")];
boxes.forEach((box) => {
box.checked = !ui.nightOff.has(box.dataset.session);
});
toolbar.picks.querySelector("input[data-all]").checked = boxes.every((box) => box.checked);
const none = boxes.length > 0 && boxes.every((box) => !box.checked);
toolbar.nightButtons.forEach((button) => {
button.disabled = none;
});
}
function pickAgents(toolbar, sids, on) {
sids.forEach((sid) => (on ? ui.nightOff.delete(sid) : ui.nightOff.add(sid)));
syncPicks(toolbar);
}
function drawPicks(toolbar) {
const agents = nightAgents();
const box = (label, attrs, sids) => h("label", {class: "check"}, h("input", {type: "checkbox", ...attrs, on: {change: (event) => pickAgents(toolbar, sids, event.target.checked)}}), label);
toolbar.picks.replaceChildren(box("All", {"data-all": true}, agents.map(([sid]) => sid)), ...agents.map(([sid, name]) => box(name, {"data-session": sid}, [sid])));
syncPicks(toolbar);
}
let spotDom = null;
@@ -63,7 +98,7 @@
gear.setAttribute("aria-expanded", "false");
gear.setAttribute("aria-controls", "radio-gear");
const [, label, , hint] = actionOf("clear-mayday");
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday"), hint);
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday", []), hint);
const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot},
h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, radioSide(), gearBox));
const toggle = document.getElementById("radio-open");
@@ -89,10 +124,12 @@
function agentsToolbar() {
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 picks = h("div", {class: "night-picks", role: "group", "aria-label": "Agents"});
const nightButtons = ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, nightChosen));
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, picks,
h("div", {class: "actions"}, nightButtons)));
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};
return {bar, mode, picks, nightButtons, agents: ""};
}
function lookBox(id, label, on, run) {
@@ -124,5 +161,11 @@
return;
}
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
toolbar.mode.replaceChildren(pill(mode, tone));
const alone = Object.values(state.night_parts).filter((part) => part !== "day").length;
toolbar.mode.replaceChildren(pill(mode, tone), alone ? pill(`${alone} alone`, "warn") : null);
const agents = JSON.stringify(nightAgents());
if (agents !== toolbar.agents) {
toolbar.agents = agents;
drawPicks(toolbar);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
{label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id, [])})),
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
{label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet},
+1 -1
View File
@@ -99,7 +99,7 @@
const banner = document.getElementById("mayday");
const silence = state.silence;
banner.hidden = !silence;
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : []));
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday", []), "cancel: end the mayday (radio.py clear)")] : []));
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
window.setTimeout(measure, 0);
}
+1 -1
View File
@@ -32,7 +32,7 @@
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};
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, nightOff: new Set(), cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
+3
View File
@@ -61,6 +61,9 @@ body.fit .spot-host { display: flex; flex: 0 1 auto; flex-direction: column; min
body.fit .radio-spot { flex: 0 1 auto; min-height: 0; }
.radio-side { display: grid; gap: var(--sp-3); align-content: start; min-width: 0; }
.spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.night-picks { display: flex; flex-wrap: wrap; gap: 4px var(--sp-3); max-width: 420px; max-height: 30vh; overflow: auto; }
.night-picks:empty { display: none; }
.agent-night { align-items: center; margin: 0 0 var(--sp-2); }
.spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; }
.radio-gear { min-width: 0; }
.composer { display: grid; gap: var(--sp-2); }