feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface

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>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 22:20:06 +00:00
1 parent 54f3725aa1
commit 6f940b4415
125 files changed
+1725 -782

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install --group lint
pip install --requirement hub/requirements.txt
pip install --requirement orchestrator/requirements.txt
sudo apt-get update && sudo apt-get install -y shellcheck
npm install -g markdownlint-cli2
+16 -16
View File
@@ -1,4 +1,4 @@
.PHONY: install project test update budget hub-install hub-check hub-pin hub-runner hub-hook watch-check
.PHONY: install project test update budget orchestrator-install orchestrator-check orchestrator-pin orchestrator-runner orchestrator-hook watch-check
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
install:
@@ -24,25 +24,25 @@ update:
budget:
@python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index)
# Install, build and start only the kvb-hub, without touching the installed skills.
hub-install:
@bash scripts/hub.sh install
# Migrate, install, build and start only the Agent Orchestrator, without touching the installed skills.
orchestrator-install:
@bash scripts/orchestrator.sh install
# Check the installed kvb-hub: image, container, logs, HTTP, pinned keys, hook, runner, disk.
hub-check:
@bash scripts/hub.sh check
# Check the installed Agent Orchestrator: image, container, logs, HTTP, pinned keys, hook, runner, disk.
orchestrator-check:
@bash scripts/orchestrator.sh check
# Param: CREDENTIAL the blob the hub's "Passkey registrieren" shows: make hub-pin CREDENTIAL=<blob>
hub-pin:
@bash scripts/hub.sh pin "$(CREDENTIAL)"
# Param: CREDENTIAL the blob "Passkey registrieren" shows: make orchestrator-pin CREDENTIAL=<blob>
orchestrator-pin:
@bash scripts/orchestrator.sh pin "$(CREDENTIAL)"
# Install, enable and restart the kvb-hub runner user service.
hub-runner:
@bash scripts/hub.sh runner
# Install, enable and restart the Agent Orchestrator runner user service.
orchestrator-runner:
@bash scripts/orchestrator.sh runner
# Register the kvb-hub PermissionRequest hook in ~/.claude/settings.json.
hub-hook:
@bash scripts/hub.sh hook
# Register the Agent Orchestrator PermissionRequest hook in ~/.claude/settings.json.
orchestrator-hook:
@bash scripts/orchestrator.sh hook
# Show whether the agents-council watch hook is stuck.
watch-check:
+5 -5
View File
@@ -380,8 +380,8 @@ flowchart LR
- Node.js (provides `npx skills`).
- `jq` (only required for `make update`).
- Python `jinja2` for `make test` (pinned in `hub/requirements.txt`).
- Docker with the compose plugin (optional, runs the kvb-hub review hub).
- Python `jinja2` for `make test` (pinned in `orchestrator/requirements.txt`).
- Docker with the compose plugin (optional, runs the Agent Orchestrator).
## Installation 🚀
@@ -399,11 +399,11 @@ make project TARGET=/path/to/repo
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
The global install also runs the kvb-hub, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `hub/` to `~/.local/share/kvb-hub`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-hub`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_HUB_PORT` in `hub/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_HUB_OPERATOR_VOICE` in `hub/.env`.
The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`.
Without reinstalling the skills, `make hub-install` installs, builds and starts only the hub, `make hub-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make hub-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make hub-runner` enables the runner service, and `make hub-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier kvb-hub entry and keeping the file mode, after a copy to `settings.json.before-kvb-hub-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-hub/client/approval.py pin --port <port> --approvers ~/.config/kvb-hub/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-hub/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-hub-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-hub-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-hub/approvers.json --replay ~/.local/state/kvb-hub-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
Restart your agent afterwards so it loads the new skills.
-5
View File
@@ -1,5 +0,0 @@
KVB_HUB_PORT=8737
KVB_HUB_LLM_URL=
KVB_HUB_LLM_MODEL=
KVB_HUB_LLM_KEY_FILE=
KVB_HUB_OPERATOR_VOICE=en_US-libritts-high:0
-1
View File
@@ -1 +0,0 @@
NAME = "kvb-hub"
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=kvb-hub runner: runs the operator commands a pinned passkey signed in the kvb-hub
[Service]
Type=simple
EnvironmentFile=%h/.local/share/kvb-hub/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-hub/client/runner.py --port ${KVB_HUB_PORT} --interval 2 --approvers %h/.config/kvb-hub/approvers.json --replay %h/.local/state/kvb-hub-approvals/runner.json
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
-96
View File
@@ -1,96 +0,0 @@
async function act(action, label, payload) {
ui.busy = true;
const result = await sendJson(`/api/board/${action}`, payload);
ui.busy = false;
if (result.data && result.data.runs) {
toast(`${label}: ${result.data.ok ? "erledigt" : "fehlgeschlagen"}`, runLines(result.data), result.data.ok);
} else {
toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
}
await load();
return result.ok && result.data && result.data.ok;
}
function confirmAct(action) {
const [, label, question] = ACTIONS.find(([id]) => id === action);
if (window.confirm(question)) {
act(action, label, {});
}
}
function boardContent() {
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
ui.order = [...state.priority];
}
const [mode, modeTone] = NIGHT[state.night] || [state.night, "neutral"];
const broadcast = h("textarea", {"aria-label": "Rundruf", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
ui.draft = event.target.value;
}}});
broadcast.value = ui.draft;
const list = h("ol", {class: "order-list"});
let dragged = null;
ui.order.forEach((name, index) => {
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
item.addEventListener("dragstart", (event) => {
dragged = name;
item.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
});
item.addEventListener("dragend", () => item.classList.remove("dragging"));
item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => {
event.preventDefault();
if (dragged === null || dragged === name) {
return;
}
const next = ui.order.filter((entry) => entry !== dragged);
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
ui.order = next;
boardSheet();
});
list.append(item);
});
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
return h("div", {class: "board"},
h("article", {class: `card${state.silence ? " alarm" : ""}`},
h("div", {class: "card-head"}, h("h3", {}, "Mayday"), state.silence ? pill("Funkstille", "bad") : pill("ruhig", "ok")),
state.silence ? h("p", {class: "text"}, `${state.silence.by}: ${state.silence.text}`) : h("p", {class: "muted"}, "Keine Funkstille aktiv."),
h("div", {class: "actions"}, h("button", {type: "button", class: "btn danger", disabled: !state.silence, on: {click: () => confirmAct("clear-mayday")}}, "Mayday aufheben"))),
h("article", {class: "card"},
h("div", {class: "card-head"}, h("h3", {}, "Rundruf an alle")),
broadcast,
h("div", {class: "actions"}, h("span", {class: "spacer"}), h("button", {type: "button", class: "btn soft", on: {click: async () => {
if (!ui.draft.trim()) {
return;
}
if (await act("broadcast", "Rundruf", {text: ui.draft})) {
ui.draft = "";
broadcast.value = "";
}
}}}, "Senden"))),
h("article", {class: "card"},
h("div", {class: "card-head"}, h("h3", {}, "Tag und Nacht"), pill(mode, modeTone)),
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn", () => confirmAct(id))))),
h("article", {class: "card"},
h("div", {class: "card-head"}, h("h3", {}, "Priorität der Agenten"), counter(ui.order.length)),
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
h("div", {class: "actions"},
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
ui.order = [...state.priority];
boardSheet();
}}}, "Zurücksetzen"),
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
if (await act("order", "Priorität", {agents: ui.order})) {
ui.order = null;
boardSheet();
}
}}}, "Reihenfolge speichern"))));
}
function boardSheet() {
openSheet("Steuerung", [boardContent()], true);
}
-93
View File
@@ -1,93 +0,0 @@
const nav = document.getElementById("tabs");
const screenButtons = new Map(SCREENS.map(([id, label], index) => {
const button = h("button", {type: "button", role: "tab", id: `tab-${id}`, "aria-controls": "main", title: `${label} (Taste ${index + 1})`, on: {click: () => go(id, null)}}, label);
nav.append(button);
return [id, button];
}));
function measure() {
document.documentElement.style.setProperty("--head", `${document.getElementById("head").offsetHeight}px`);
}
function go(screen, sub) {
const hash = screen === "graph" ? `#graph/${sub || ui.graph}` : `#${screen}`;
if (window.location.hash === hash) {
apply();
} else {
window.location.hash = hash;
}
}
function apply() {
const [screen, sub] = decodeURIComponent(window.location.hash.slice(1)).split("/");
ui.screen = SCREENS.some(([id]) => id === screen) ? screen : "stream";
if (GRAPHS.some(([id]) => id === sub)) {
ui.graph = sub;
}
if (document.startViewTransition && drawn.screen && !calm.matches) {
document.startViewTransition(() => render(true));
} else {
render(true);
}
}
function signature() {
return JSON.stringify(ui.screen === "graph" ? [ui.graph, space.views.graph.axis] : []);
}
function render(force) {
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
ui.node = null;
}
for (const [id, button] of screenButtons) {
button.setAttribute("aria-selected", String(ui.screen === id));
}
const sig = signature();
if (force || drawn.screen !== ui.screen || drawn.sig !== sig) {
graphDom = null;
stream.dom = null;
tree.dom = null;
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren({stream: streamScreen, graph: graphScreen, tree: treeScreen}[ui.screen]());
drawn.screen = ui.screen;
drawn.sig = sig;
}
stage();
drawSide();
drawInbox();
drawStatus();
tick();
}
function drawStatus() {
const alarms = alarmRows().length;
const fresh = state.now - ACTIVE;
const agents = new Set(state.claims.filter((claim) => claim.beat !== null && claim.beat >= fresh).map((claim) => claim.agent)).size;
const jobs = state.jobs.filter((job) => job.status === "queued" || job.status === "running").length;
const [mode] = NIGHT[state.night] || [state.night];
const waiting = waitingCount();
const box = (value, label, tone) => h("div", {class: `stat-box${tone ? ` ${tone}` : ""}`}, h("span", {class: "num"}, String(value)), h("span", {class: "lbl"}, label));
document.getElementById("status").replaceChildren(
box(waiting, "wartet auf dich", waiting ? "urgent" : ""),
box(alarms, "Mayday", alarms ? "alarm" : ""),
box(agents, "Agenten aktiv", ""),
box(jobs, "Runner", ""),
box(mode, "Modus", ""));
const badge = document.getElementById("inbox-count");
badge.textContent = String(waiting);
badge.className = `badge${alarms ? " alarm" : waiting ? " urgent" : ""}`;
document.title = `${waiting ? `(${waiting}) ` : ""}kvb-hub`;
}
function toggleLeitstand() {
const on = !document.body.classList.contains("leitstand");
document.body.classList.toggle("leitstand", on);
if (on && document.documentElement.requestFullscreen && !document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => null);
}
if (!on && document.fullscreenElement) {
document.exitFullscreen().catch(() => null);
}
window.setTimeout(measure, 50);
}
+5
View File
@@ -0,0 +1,5 @@
KVB_ORCHESTRATOR_PORT=8737
KVB_ORCHESTRATOR_LLM_URL=
KVB_ORCHESTRATOR_LLM_MODEL=
KVB_ORCHESTRATOR_LLM_KEY_FILE=
KVB_ORCHESTRATOR_OPERATOR_VOICE=en_US-libritts-high:0
+1 -1
View File
@@ -35,7 +35,7 @@ COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli
RUN chmod 0755 /opt/voices /opt/whisper
COPY server.py /app/server.py
COPY kvb_hub /app/kvb_hub
COPY kvb_orchestrator /app/kvb_orchestrator
COPY client/signing.py /app/client/signing.py
COPY templates /app/templates
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Args:
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
that the kvb-hub settings panel registered (the blob it shows) in
that the Agent Orchestrator settings panel registered (the blob it shows) in
<file>, written with mode 0600. Run it on the host, outside every
sandbox, e.g. --approvers ~/.config/kvb-hub/approvers.json.
sandbox, e.g. --approvers ~/.config/kvb-orchestrator/approvers.json.
verify --port <port> --approvers <file> --replay <file> --review <id>
--gate <pass|fail>: run in the worktree right before the commit;
fetches the review's signature from the kvb-hub on 127.0.0.1:<port>
fetches the review's signature from the Agent Orchestrator on 127.0.0.1:<port>
and checks it against the repository, worktree, branch and the tree
git write-tree prints, as this process sees them.
@@ -57,9 +57,9 @@ def fetch(port: int, path: str) -> dict:
try:
data = load(request, SLACK)
except (OSError, ValueError) as error:
raise Refused(f"the kvb-hub did not answer: {error}") from error
raise Refused(f"the Agent Orchestrator did not answer: {error}") from error
if not isinstance(data, dict):
raise Refused("the kvb-hub answered no object")
raise Refused("the Agent Orchestrator answered no object")
return data
@@ -4,7 +4,7 @@ import json
import urllib.request
SLACK = 15.0
GUARD = {"X-Kvb-Hub": "1"}
GUARD = {"X-Kvb-Orchestrator": "1"}
class NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -202,7 +202,7 @@ def key_type(alg: int, spki: bytes) -> bool:
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
if not key_type(alg, spki):
return False
with tempfile.TemporaryDirectory(prefix="kvb-hub-verify-") as folder:
with tempfile.TemporaryDirectory(prefix="kvb-orchestrator-verify-") as folder:
key, message, sealed = (Path(folder) / name for name in ("key.pem", "data.bin", "signature.bin"))
key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii")
message.write_bytes(data)
@@ -1,18 +1,18 @@
#!/usr/bin/env python3
"""Args:
--port: port of the kvb-hub on 127.0.0.1; the signature's origin is
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
http://localhost:<port>.
--timeout: seconds to wait for the operator's decision; keep it below the
hook's timeout in settings.json.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: this hook's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-hub-approvals/hook.json.
~/.local/state/kvb-orchestrator-approvals/hook.json.
stdin: the Claude Code PermissionRequest hook input.
Returns:
stdout: the PermissionRequest decision JSON: allow only when a pinned
passkey signed exactly this tool, command, cwd, session and ask id;
deny when the operator denied in the kvb-hub. Nothing on timeout,
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
error, a missing or invalid signature, a value longer than the hub
takes or a hub that is down, so Claude Code shows its own dialog.
The exit code is 0.
@@ -36,7 +36,7 @@ NAME_LIMIT = 200
COMMAND_LIMIT = 4000
PATH_LIMIT = 4096
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
DENIED = "The operator denied this in the kvb-hub."
DENIED = "The operator denied this in the Agent Orchestrator."
def summary(tool_input: object) -> str:
@@ -82,7 +82,7 @@ def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
try:
verifier.check(answer.get("proof"), item, "", trust, time.time())
except (signing.Refused, OSError) as error:
print(f"kvb-hub: not signed, Claude Code asks instead: {error}", file=sys.stderr)
print(f"kvb-orchestrator: not signed, Claude Code asks instead: {error}", file=sys.stderr)
return None
return {"behavior": "allow"}
@@ -102,7 +102,7 @@ def main() -> int:
ask = facts(hook)
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
if too_long:
print(f"kvb-hub: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
print(f"kvb-orchestrator: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
return 0
ask_id = create(base, ask, args.timeout)
answer = {"status": "pending"}
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Args:
--port: port of the kvb-hub on 127.0.0.1; the signature's origin is
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
http://localhost:<port>.
--interval: seconds between two polls of the hub.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: the runner's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-hub-approvals/runner.json.
~/.local/state/kvb-orchestrator-approvals/runner.json.
--once: handle the queued jobs once and exit.
Returns:
Runs as the operator, outside any sandbox, every operator command a
pinned passkey signed in the kvb-hub: exactly the signed text, with bash
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
in the signed directory, each signature once and before its expiry.
Refuses every other job. Posts the last 200 output lines back while it
runs and the exit code at its end. The exit code is 0.
File renamed without changes.
@@ -95,7 +95,7 @@ def consume(store: Path, key: str, expires: int, credential: str, count: int, no
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
"""Args:
proof: {"payload", "assertion"} the kvb-hub relayed for the item.
proof: {"payload", "assertion"} the Agent Orchestrator relayed for the item.
item: the executor's own item, built from what it is about to do.
gate: pass or fail as the executor itself saw it; a rule approves a
review on pass only.
+24 -24
View File
@@ -1,12 +1,12 @@
name: kvb-hub
name: kvb-orchestrator
services:
hub:
build: .
image: kvb-hub:local
image: kvb-orchestrator:local
restart: unless-stopped
init: true
user: "${KVB_HUB_USER:?written into .env by scripts/install.sh}"
user: "${KVB_ORCHESTRATOR_USER:?written into .env by scripts/install.sh}"
read_only: true
cap_drop:
- ALL
@@ -20,7 +20,7 @@ services:
timeout: 5s
retries: 3
ports:
- "127.0.0.1:${KVB_HUB_PORT:?set in .env}:8080"
- "127.0.0.1:${KVB_ORCHESTRATOR_PORT:?set in .env}:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
command:
@@ -29,7 +29,7 @@ services:
- --listen-port
- "8080"
- --public-port
- "${KVB_HUB_PORT:?set in .env}"
- "${KVB_ORCHESTRATOR_PORT:?set in .env}"
- --council
- /tmp/agents-council
- --pages
@@ -37,7 +37,7 @@ services:
- --state
- /state
- --host-state
- "${KVB_HUB_STATE:?written into .env by scripts/install.sh}"
- "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
- --skills
- /skills
- --transcripts
@@ -45,23 +45,23 @@ services:
- --templates
- /app/templates
- --repos
- "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
- "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
- --worktrees
- "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
- "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
- --timers
- /timers
- --llm-url
- "${KVB_HUB_LLM_URL}"
- "${KVB_ORCHESTRATOR_LLM_URL}"
- --llm-model
- "${KVB_HUB_LLM_MODEL}"
- "${KVB_ORCHESTRATOR_LLM_MODEL}"
- --llm-key-file
- /run/kvb-hub/llm.key
- /run/kvb-orchestrator/llm.key
- --piper
- /opt/piper/bin/piper
- --voices
- /opt/voices
- --operator-voice
- "${KVB_HUB_OPERATOR_VOICE:?set in .env}"
- "${KVB_ORCHESTRATOR_OPERATOR_VOICE:?set in .env}"
- --whisper
- /opt/whisper/whisper-cli
- --whisper-model
@@ -78,55 +78,55 @@ services:
target: /tmp
read_only: true
- type: bind
source: "${KVB_HUB_COUNCIL:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_COUNCIL:?written into .env by scripts/install.sh}"
target: /tmp/agents-council
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_SKILLS:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_SKILLS:?written into .env by scripts/install.sh}"
target: /skills
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_PROJECTS:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_PROJECTS:?written into .env by scripts/install.sh}"
target: /projects
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_STATE:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
target: /state
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
target: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
target: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
target: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
target: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_TIMERS:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_TIMERS:?written into .env by scripts/install.sh}"
target: /timers
read_only: true
bind:
create_host_path: false
- type: bind
source: "${KVB_HUB_APPROVERS:?written into .env by scripts/install.sh}"
source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}"
target: /approvers
read_only: true
bind:
create_host_path: false
- type: bind
# /dev/null is an empty key file, which server.py --llm-key-file reads as no key
source: "${KVB_HUB_LLM_KEY_FILE:-/dev/null}"
target: /run/kvb-hub/llm.key
source: "${KVB_ORCHESTRATOR_LLM_KEY_FILE:-/dev/null}"
target: /run/kvb-orchestrator/llm.key
read_only: true
bind:
create_host_path: false
@@ -0,0 +1 @@
NAME = "kvb-orchestrator"
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from pathlib import Path
from .records import epoch, read_json, shown
@@ -7,8 +8,12 @@ from .records import epoch, read_json, shown
NEWS = "news.jsonl"
HUMAN = "Operator"
SESSION = " session"
ALARMS = (("MAYDAY", "mayday"), ("PAN-PAN", "pan-pan"))
CLASSES = ("broadcast", "direct", "security", "pan-pan", "mayday")
NOTE = re.compile(r"(\S.*?)\s+\([^()]*\)")
PREFIXES = (("MAYDAY", "mayday"), ("PAN-PAN", "pan-pan"))
CLASSES = ("post", "call", "newsletter", "broadcast", "mayday", "pan-pan", "security")
RECEIPT = "receipt"
ALARMS = ("mayday", "pan-pan", "security")
VERSION = 2
MODES = ("evening", "sunset")
RADIO_LIMIT = 5000
GRANT_LIMIT = 50
@@ -19,6 +24,9 @@ def agent(name: object) -> str:
if not isinstance(name, str):
return ""
text = name.strip()
noted = NOTE.fullmatch(text)
if noted and not subagent(text):
text = noted.group(1)
if text.lower().endswith(SESSION):
return text[: -len(SESSION)].strip()
return text
@@ -33,13 +41,26 @@ def ordered(names: set[str]) -> list[str]:
def kind(row: dict, names: list[str]) -> str:
if row.get("class") in CLASSES:
return row["class"]
"""Args:
row: a news.jsonl row; rows without the version mark predate the
split of radio and post and keep their old meaning.
names: the addressees of the row.
Returns:
One of CLASSES or RECEIPT, the same reading as radio.py's kind().
"""
cls = row.get("class")
if row.get("v") == VERSION and cls in (*CLASSES, RECEIPT):
return cls
if cls in ALARMS:
return cls
text = row.get("text")
for prefix, alarm in ALARMS:
if isinstance(text, str) and text.lstrip().startswith(prefix):
for prefix, alarm in PREFIXES:
if cls is None and isinstance(text, str) and text.lstrip().startswith(prefix):
return alarm
return "direct" if names else "broadcast"
if cls == "direct" or (cls != "broadcast" and names):
return "call" if row.get("wake") else "post"
return "newsletter"
def radio_row(row: dict, ts: float) -> dict:
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,8 @@
from __future__ import annotations
import datetime
import json
import re
import secrets
import threading
import time
@@ -14,23 +16,66 @@ from .records import read_jsonl, shown
from .register import status_of
from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS
EVENT_KINDS = ("radio", "review", "ask", "decision", "operator", "register", "rename", "cron", "rule", "approval", "run", "transcript")
KINDS = (
("radio", "Funk"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Entscheidungen"), ("operator", "Operator"), ("register", "Register"),
("rename", "Namen"), ("cron", "Crons"), ("rule", "Regeln"), ("approval", "Freigaben"), ("run", "Runner"), ("transcript", "Transkripte"),
)
EVENT_KINDS = tuple(name for name, _ in KINDS)
WORK = [name for name in EVENT_KINDS if name not in ("radio", "transcript")]
VIEWS = {
"fuer-dich": {"label": "Für dich", "kinds": list(EVENT_KINDS[:-1]), "waiting": True},
"reviews": {"label": "Reviews", "kinds": ["review"], "waiting": False},
"asks": {"label": "Asks", "kinds": ["ask"], "waiting": False},
"entscheidungen": {"label": "Entscheidungen", "kinds": ["decision"], "waiting": False},
"operator": {"label": "Operator", "kinds": ["operator", "run"], "waiting": False},
"register": {"label": "Register", "kinds": ["register", "rename"], "waiting": False},
"radio": {"label": "Radio", "kinds": ["radio"], "waiting": False},
"crons": {"label": "Crons", "kinds": ["cron"], "waiting": False},
"fuer-dich": {"label": "Für dich", "kinds": ["radio", *WORK], "waiting": True},
"funk": {"label": "Funk", "kinds": ["radio"], "waiting": False},
"arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False},
"transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False},
}
ART = {word: name for name, label in KINDS for word in (name, label.lower())}
SPAN = re.compile(r"(\d{1,4})([mhd])")
UNITS = {"m": 60, "h": 3600, "d": 86400}
def event(fields: dict) -> dict:
return {"part": "", "agent": "", "now": "", "session": "", "sub": "", "text": "", "waiting": False, "ref": {}, **fields}
def since_of(word: str, now: float) -> float:
span = SPAN.fullmatch(word)
if span:
return now - int(span.group(1)) * UNITS[span.group(2)]
try:
return datetime.datetime.fromisoformat(word).timestamp()
except ValueError:
raise ToolError("seit: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None
def search(text: str, now: float) -> dict:
"""Args:
text: the search line: words, `agent:<name>`, `art:<kind or label>`
and `seit:<span or date>`.
now: the clock `seit:` spans count back from.
Returns:
The plain words as `q`, plus `agent`, `kinds` and `since` for each
token the line holds.
"""
found: dict = {"q": []}
for word in text.split():
key, colon, value = word.partition(":")
if not colon or key not in ("agent", "art", "seit"):
found["q"].append(word)
elif not value:
raise ToolError(f"{key}: needs a value")
elif key == "agent":
found["agent"] = value
elif key == "art":
if value.lower() not in ART:
raise ToolError(f"art: takes one of {', '.join(EVENT_KINDS)}")
found.setdefault("kinds", set()).add(ART[value.lower()])
else:
found["since"] = since_of(value, now)
found["q"] = " ".join(found["q"]).lower()
return found
def event_filters(query: dict[str, list[str]]) -> dict:
view = single(query, "view") or ""
if view:
@@ -48,13 +93,17 @@ def event_filters(query: dict[str, list[str]]) -> dict:
if not chosen <= set(STREAM_KINDS):
raise ToolError(f"parts must be a comma list of {', '.join(STREAM_KINDS)}")
texts = {name: single(query, name) or "" for name in ("agent", "session", "sub")}
return {"kinds": kinds, "waiting": waiting, "parts": chosen, "q": (single(query, "q") or "").lower(), "aliases": set(), **texts}
line = search(single(query, "q") or "", time.time())
texts["agent"] = line.get("agent", texts["agent"])
kinds &= line.get("kinds", kinds)
return {"kinds": kinds, "waiting": waiting, "parts": chosen, "q": line["q"], "since": line.get("since"), "aliases": set(), **texts}
def wanted(entry: dict, filters: dict) -> bool:
return all(
(
entry["kind"] in filters["kinds"],
filters["since"] is None or entry["ts"] >= filters["since"],
not filters["waiting"] or entry["waiting"],
not filters["agent"] or entry["agent"] == filters["agent"] or entry["session"] in filters["aliases"],
not filters["session"] or entry["session"] == filters["session"],
@@ -11,7 +11,7 @@ from . import NAME
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
from .hub import Hub
GUARD = "X-Kvb-Hub"
GUARD = "X-Kvb-Orchestrator"
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
LENGTH = re.compile(r"[0-9]{1,9}")
QUERY_FIELDS = 32
@@ -26,7 +26,7 @@ FIELDS = {
"to": (re.compile(LINE.format(200)), "an agent name, one line of 1 to 200 characters"),
"label": (re.compile(LINE.format(NAME_LIMIT)), f"one line of 1 to {NAME_LIMIT} characters"),
"tool": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"),
"id": (re.compile(r"[0-9a-f]{16}"), "an id the kvb-hub returned"),
"id": (re.compile(r"[0-9a-f]{16}"), "an id the Agent Orchestrator returned"),
}
File renamed without changes.
@@ -72,7 +72,7 @@ class Handler(Exchange):
if self.client():
self.answer(hub.runner_jobs, HTTPStatus.OK)
else:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
elif entry:
self.answer(lambda: hub.entry(int(entry.group(1))), HTTPStatus.OK)
elif item and item.group(1) == "asks" and item.group(3) is None:
@@ -151,18 +151,18 @@ class Handler(Exchange):
if path == "/api/asks":
if self.local(host):
return lambda body: self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED)
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only")
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Orchestrator header and no foreign origin only")
return None
if RUNNER_ROUTE.fullmatch(path):
if self.client():
return lambda body: self.runner(path, body)
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
return None
action = self.action(path)
if action is None:
self.send_text(HTTPStatus.NOT_FOUND, "not found")
elif not self.guarded(host):
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Hub header only")
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Orchestrator header only")
elif path == STT_ROUTE and self.headers.get_content_type() != WAV_TYPE:
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
else:
@@ -39,13 +39,15 @@ class Places:
def environment(templates: Path) -> jinja2.Environment:
return jinja2.Environment(
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(templates),
autoescape=True,
undefined=jinja2.StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
)
env.policies["json.dumps_kwargs"] = {"sort_keys": False}
return env
@dataclass(frozen=True)
File renamed without changes.
File renamed without changes.
@@ -25,7 +25,7 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
SCREENS = ("stream", "graph", "tree")
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
NL_ACTIONS = [
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
@@ -43,7 +43,7 @@ NL_ACTIONS = [
def nl_messages(text: str, items: list[dict], agents: list[str]) -> list[dict]:
schemas = [{"name": action["name"], "description": action["description"], "arguments": action["parameters"]} for action in NL_ACTIONS]
system = (
"You turn one command of the kvb-hub operator into hub actions. Reply with exactly one JSON object "
"You turn one command of the Agent Orchestrator operator into hub actions. Reply with exactly one JSON object "
'{"actions": [{"name": "<action>", "arguments": {...}}]} and nothing else. Every action has one of these '
f"names, and its arguments object holds exactly the listed properties: {json.dumps(schemas, ensure_ascii=False)}. "
'Use only ids from items and names from agents. Reply {"actions": []} when nothing fits.'
@@ -171,7 +171,7 @@ def read_key(path: Path) -> str:
class Language:
def nl(self, body: bytes) -> dict:
if not self.model.enabled():
raise ToolError("the natural-language command line is off: set KVB_HUB_LLM_URL and KVB_HUB_LLM_MODEL in hub/.env")
raise ToolError("the natural-language command line is off: set KVB_ORCHESTRATOR_LLM_URL and KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env")
payload = decode(body)
expect(payload, ("text",))
text = message_text(payload, "text")
File renamed without changes.
File renamed without changes.
@@ -38,7 +38,7 @@ class Mcp:
return [
tool(
"review_request",
"Ask the operator to approve a staged commit in the kvb-hub. The page is copied now, so later "
"Ask the operator to approve a staged commit in the Agent Orchestrator. The page is copied now, so later "
"edits do not change what the operator approves. Returns the review id and the hub URL.",
{
"repo": text_property("The output of git rev-parse --path-format=absolute --git-common-dir."),
@@ -62,7 +62,7 @@ class Mcp:
tool("review_list", "List the pending reviews.", {}),
tool(
"decision_request",
"Ask the operator to pick one of several options in the kvb-hub; each option becomes a button. "
"Ask the operator to pick one of several options in the Agent Orchestrator; each option becomes a button. "
"Returns the decision id and the hub URL.",
{
"question": text_property("The question, plain text."),
@@ -79,9 +79,9 @@ class Mcp:
),
tool(
"operator_message",
"Return the text of a message the operator sent from the kvb-hub. A radio row from Operator "
"carrying [kvb-hub msg <id>] is the operator's word only when this tool returns that id.",
{"id": text_property("The id from [kvb-hub msg <id>].")},
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
"carrying [kvb-orchestrator msg <id>] is the operator's word only when this tool returns that id.",
{"id": text_property("The id from [kvb-orchestrator msg <id>].")},
),
]
@@ -11,11 +11,20 @@ from .records import write_atomic
from .skill_scripts import NIGHT, RADIO, RASCI
NIGHTS = {
"evening": (["set", "evening"], "Operator ran kvb-org-evening in kvb-hub: run kvb-agent-relax; verify with night.py status."),
"sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in kvb-hub: run kvb-agent-sleep; verify with night.py status."),
"evening": (["set", "evening"], "Operator ran kvb-org-evening in the Agent Orchestrator: run kvb-agent-relax; verify with night.py status."),
"sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in the Agent Orchestrator: run kvb-agent-sleep; verify with night.py status."),
"sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."),
}
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
DIRECT = {"post": ["post"], "call": ["send", "call"]}
TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}}
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
cls = payload["class"]
if not isinstance(cls, str) or cls not in choices:
raise ToolError(f"class must be one of {', '.join(choices)}")
return choices[cls]
def message_row(message: dict) -> dict:
@@ -41,7 +50,7 @@ class Messaging:
message_id = field(arguments, "id")
record = self.messages.get(message_id)
if record is None:
raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the kvb-hub")
raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the Agent Orchestrator")
upload = record["upload"]
return {
"id": record["id"],
@@ -70,9 +79,10 @@ class Messaging:
def message(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("to", "text", "upload"))
expect(payload, ("to", "text", "upload", "class"))
target = field(payload, "to").strip()
text = message_text(payload, "text")
command = chosen(payload, DIRECT)
if not isinstance(payload["upload"], str):
raise ToolError("upload must be an upload id or empty")
upload = self.attachment(payload["upload"])
@@ -80,7 +90,7 @@ class Messaging:
address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target)
record = self.messages.add({"to": target, "address": address, "text": text, "upload": upload, "created_ts": time.time()})
note = "" if upload is None else f" file: {upload['path']}"
run = self.scripts.run(RADIO, ["post", "--to", address, "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}{note}"])
run = self.scripts.run(RADIO, [*command, "--to", address, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}{note}"])
return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]}
def board(self, action: str, body: bytes) -> dict:
@@ -88,10 +98,11 @@ class Messaging:
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
elif action == "broadcast":
payload = decode(body)
expect(payload, ("text",))
expect(payload, ("text", "class"))
text = message_text(payload, "text")
command = chosen(payload, TO_ALL)
record = self.messages.add({"to": "*", "address": "*", "text": text, "upload": None, "created_ts": time.time()})
runs = [self.scripts.run(RADIO, ["post", "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}"])]
runs = [self.scripts.run(RADIO, [*command, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])]
elif action == "order":
payload = decode(body)
expect(payload, ("agents",))
@@ -107,7 +118,7 @@ class Messaging:
peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN})
if peers and runs[0]["code"] == 0:
targets = [part for peer in peers for part in ("--to", peer)]
runs.append(self.scripts.run(RADIO, ["send", "direct", "--wake", *targets, "--session", HUMAN, text]))
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
else:
raise Missing(action)
return {"ok": all(run["code"] == 0 for run in runs), "runs": runs}
File renamed without changes.
File renamed without changes.
@@ -9,8 +9,9 @@ SPOKEN_TEXT = 500
SPOKEN_NAME = 60
SPOKEN_GROUP = 120
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"}
ADDRESSED = ("post", "call", "receipt")
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
HUB_MARK = re.compile(r"\[kvb-hub msg [0-9a-f]{16}\]")
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
HUB_FILE = re.compile(r"\s+file: /\S*$")
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
@@ -34,8 +35,7 @@ def spoken_text(cls: str, text: str) -> str:
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
"""Args:
cls: the radio class of the row: broadcast, direct, security, pan-pan
or mayday.
cls: the radio class of the row, one of council.CLASSES or receipt.
sender: the sender's name valid at the row's time.
called: the names of the called stations valid at the row's time.
text: the radio text.
@@ -50,7 +50,7 @@ def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
elif cls in ALARM_WORDS:
word = ALARM_WORDS[cls]
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
elif cls == "direct" and called:
elif cls in ADDRESSED and called:
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
head, end = f"{group}, {group}, this is {station}", "over"
else:
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -36,7 +36,7 @@ SETTINGS = {
},
"notify": True,
"streams": [],
"speech": {"on": False, "classes": ["security", "pan-pan", "mayday"], "muted": []},
"speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []},
"space": {
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
@@ -48,6 +48,7 @@ SPACE_LIMITS = {
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
}
MUTED_LIMIT = 200
LEGACY_CLASSES = {"direct": ("post", "call")}
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
@@ -172,6 +173,15 @@ def valid_settings(raw: object) -> dict:
}
def migrated(stored: object) -> object:
speech = stored.get("speech") if isinstance(stored, dict) else None
classes = speech.get("classes") if isinstance(speech, dict) else None
if not isinstance(classes, list) or not any(name in LEGACY_CLASSES for name in classes):
return stored
renamed = [new for name in classes for new in LEGACY_CLASSES.get(name, (name,))]
return {**stored, "speech": {**speech, "classes": list(dict.fromkeys(renamed))}}
def media_origins(settings: dict) -> list[str]:
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
@@ -180,7 +190,7 @@ class Settings:
def __init__(self, file: Path) -> None:
self.file = file
self.lock = threading.Lock()
stored = read_json(file)
stored = migrated(read_json(file))
try:
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
except ToolError:
@@ -3,7 +3,7 @@ from __future__ import annotations
import importlib.util
from pathlib import Path
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_hub_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
signing = importlib.util.module_from_spec(SIGNING_SPEC)
SIGNING_SPEC.loader.exec_module(signing)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -14,9 +14,10 @@ from .council import (
silence_of,
)
from .errors import ToolError
from .events import VIEWS
from .events import KINDS, VIEWS
from .fields import decode, instant, single
from .graph import graph
from .jobs import JOB_OPEN
from .messaging import message_row
from .orbits import TRACK_STEPS, track
from .phrasing import phrase
@@ -28,12 +29,21 @@ from .skill_scripts import OUTPUT_LIMIT
RADIO_WINDOW = 86400
JOBS_SHOWN = 20
ACTIVE = 900
INDEX_CSP = (
"default-src 'none'; script-src 'nonce-{0}'; style-src 'nonce-{0}'; connect-src 'self'; media-src {1}; "
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
)
def badges(inbox: list[dict], claims: list[dict], jobs: list[dict], now: float) -> dict[str, int]:
return {
"inbox": sum(1 for item in inbox if not item["until"]),
"agents": len({claim["agent"] for claim in claims if claim["beat"] is not None and claim["beat"] >= now - ACTIVE}),
"artifacts": sum(1 for job in jobs if job["status"] in JOB_OPEN),
}
class View:
def span(self, query: dict[str, list[str]], now: float) -> tuple[float, float]:
end = instant(query, "to")
@@ -80,17 +90,20 @@ class View:
drawn["track"] = track(drawn["nodes"], radio, tasks, claims, (min(max(begin, first), end), end), directory, steps)
people = {node["id"]: {"name": node["label"], "now": directory.now(node["id"]) or node["label"], "names": node["names"]} for node in drawn["nodes"] if node["kind"] != "subagent"}
settings = self.settings.get()
inbox = self.inbox(now, tasks, directory)
return {
"settings": settings,
"media": media_origins(settings),
"speech": self.speech.describe(),
"people": people,
"inbox": self.inbox(now, tasks, directory),
"inbox": inbox,
"badges": badges(inbox, claims, self.jobs.listed(), now),
"pending": self.batches.listed(),
"rules": [{**{key: value for key, value in rule.items() if key != "proof"}, "signed": rule.get("proof") is not None} for rule in self.rules.listed()],
"approvers": self.keys(),
"jobs": jobs,
"views": VIEWS,
"kinds": dict(KINDS),
"llm": self.model.enabled(),
"now": now,
"since": begin,
File renamed without changes.
File renamed without changes.
+8 -8
View File
@@ -48,14 +48,14 @@ from __future__ import annotations
import argparse
from pathlib import Path
from kvb_hub import NAME
from kvb_hub.engines import Engines
from kvb_hub.errors import ToolError
from kvb_hub.exchange import HubServer
from kvb_hub.handler import Handler
from kvb_hub.hub import Places, open_hub
from kvb_hub.language import Model, read_key
from kvb_hub.skill_scripts import Scripts
from kvb_orchestrator import NAME
from kvb_orchestrator.engines import Engines
from kvb_orchestrator.errors import ToolError
from kvb_orchestrator.exchange import HubServer
from kvb_orchestrator.handler import Handler
from kvb_orchestrator.hub import Places, open_hub
from kvb_orchestrator.language import Model, read_key
from kvb_orchestrator.skill_scripts import Scripts
def main() -> int:
@@ -0,0 +1,12 @@
[Unit]
Description=Agent Orchestrator runner: runs the operator commands a pinned passkey signed in the Agent Orchestrator
[Service]
Type=simple
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
File renamed without changes.
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% set waiting = state.inbox | rejectattr("until") | list | length %}{% if waiting %}({{ waiting }}) {% endif %}kvb-hub</title>
<title>{% if state.badges.inbox %}({{ state.badges.inbox }}) {% endif %}Agent Orchestrator</title>
<style nonce="{{ nonce }}">
:root {
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
@@ -28,62 +28,58 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text)
button, input, select { font: inherit; color: inherit; }
code, pre { font-family: var(--mono); font-size: var(--fs-sm); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); }
.top, .timebar, main, .rail, .drawer, .sheet-box, .palette-box, .toast, .inbox-bar, .sky-panel { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
}
.head { position: sticky; top: 0; z-index: 6; }
.top { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: var(--sp-4) var(--sp-5); padding: var(--sp-3) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: var(--sp-3); }
.top { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
.brand { display: flex; flex: 1; align-items: center; gap: var(--sp-3); min-width: 0; }
.brand-text { display: grid; line-height: var(--lh-tight); }
.sky-note { color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
.logo { width: 24px; height: 24px; border-radius: var(--radius-sm); background: linear-gradient(135deg, var(--accent), var(--violet)); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 18%), var(--shadow); }
h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01em; }
.tabs { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.navbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
.tabs { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); }
.tab-group { display: inline-flex; align-items: center; gap: 2px; }
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
.subtabs button { font-size: var(--fs-sm); }
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
.tabs button:hover { background: var(--hover); color: var(--text); }
.tabs button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.badge { min-width: 22px; padding: 0 7px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; text-align: center; font-variant-numeric: tabular-nums; }
.badge[hidden] { display: none; }
.badge.urgent { background: var(--accent); color: var(--on-accent); }
.badge.alarm { background: var(--alarm); color: #fff; }
.status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; justify-content: center; }
.stat-box { display: grid; min-width: 56px; padding: 1px 10px; border-radius: 10px; background: var(--sunken); text-align: center; line-height: 1.25; }
.stat-box .num { font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-box .lbl { color: var(--muted); font-size: var(--fs-2xs); }
.stat-box.urgent { background: var(--accent-soft); color: var(--accent); }
.stat-box.alarm { background: var(--bad-soft); color: var(--bad); }
.tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
.banner { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); background: var(--alarm); color: #fff; font-weight: 650; }
.banner[hidden] { display: none; }
.banner .text { flex: 1; margin: 0; }
.banner .btn { border-color: rgb(255 255 255 / 70%); background: transparent; color: #fff; }
.menu { position: relative; }
.menu > summary { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); font-weight: 580; cursor: pointer; list-style: none; }
.menu > summary::-webkit-details-marker { display: none; }
.menu-box { position: absolute; top: calc(100% + 6px); z-index: 25; display: grid; gap: var(--sp-3); width: max-content; max-width: min(640px, calc(100vw - 32px)); padding: var(--sp-3) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
.menu-box.end { right: 0; }
.menu-box textarea { min-width: min(460px, calc(100vw - 64px)); }
.menu-box h3 { margin: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); }
.classes { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); }
.updated { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
.updated::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
.updated.offline::before { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-soft); }
body.leitstand { --fs-xs: 13px; --fs-sm: 14px; --fs-md: 16px; --fs-lg: 18px; --fs-xl: 21px; --fs-2xl: 26px; --fs-3xl: 44px; }
body.leitstand .top { grid-template-columns: auto auto 1fr auto; }
body.leitstand .stat-box { min-width: 128px; padding: var(--sp-2) var(--sp-4); }
body.leitstand .stat-box .num { font-size: var(--fs-3xl); }
body.leitstand .stat-box .lbl { font-size: var(--fs-sm); }
.shell { display: grid; grid-template-columns: minmax(0, 1fr) 410px; align-items: start; gap: var(--sp-4); padding: var(--sp-4) var(--sp-4) var(--sp-6); }
.shell { padding: var(--sp-4) var(--sp-4) var(--sp-6); }
main { min-width: 0; min-height: calc(100vh - var(--head) - var(--sp-6) - var(--sp-4)); padding: var(--sp-5) var(--sp-5) 80px; background: var(--glass); border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
.rail { position: sticky; top: calc(var(--head) + var(--sp-4)); height: calc(100vh - var(--head) - var(--sp-4) * 2); overflow: auto; padding: var(--sp-4); background: var(--glass-panel); border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
.inbox-bar { display: none; }
@media (max-width: 1100px) {
.shell { grid-template-columns: minmax(0, 1fr); }
.rail { display: none; position: fixed; top: auto; right: 0; bottom: 56px; left: 0; z-index: 15; height: min(78vh, 680px); border-width: 1px 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: var(--glass-card); box-shadow: var(--shadow-lift); }
body.inbox-open .rail { display: block; }
.inbox-bar { position: fixed; right: 0; bottom: 0; left: 0; z-index: 16; display: flex; align-items: center; justify-content: center; gap: var(--sp-3); height: 56px; border: 0; border-top: 1px solid var(--line); background: var(--glass-card); font-weight: 650; cursor: pointer; }
.top { grid-template-columns: 1fr; }
.status, .tools { justify-content: flex-start; }
}
@media (max-width: 640px) {
.top { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
.status { flex-wrap: nowrap; overflow-x: auto; }
.shell { gap: var(--sp-2); padding: var(--sp-2) var(--sp-2) var(--sp-6); }
.top, .navbar, .banner { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
.shell { padding: var(--sp-2) var(--sp-2) var(--sp-6); }
main { padding: var(--sp-3) var(--sp-3) 88px; border-radius: var(--radius); }
.timebar { padding: var(--sp-2) var(--sp-3); }
.timebar .replay { flex-basis: 100%; min-width: 0; }
.entry { grid-template-columns: minmax(0, 1fr); }
.menu-box { position: fixed; top: auto; right: var(--sp-2); left: var(--sp-2); width: auto; }
.replay { flex-basis: 100%; min-width: 0; }
.sheet { padding: 0; }
.sheet-box { min-height: 100%; border-radius: 0; }
.settings .grid { grid-template-columns: minmax(0, 1fr); }
@@ -180,6 +176,11 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital
.feed { margin: 0; padding: 0; list-style: none; }
.footnote { margin: 0; padding: 12px 18px; color: var(--muted); font-size: var(--fs-sm); }
.entry { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
@media (max-width: 640px) {
.entry { grid-template-columns: minmax(0, 1fr); padding: 10px 12px; }
.msg-meta { display: flex; flex-wrap: wrap; align-items: center; }
.tabs button { padding: 6px 8px; }
}
.entry:last-child { border-bottom: 0; }
.entry:hover { background: var(--hover); }
.entry.waiting { box-shadow: inset 3px 0 0 var(--warn); }
@@ -255,11 +256,8 @@ svg .mark:hover { opacity: 1; }
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
.timebar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
.timebar input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
.timebar .replay { min-width: 320px; }
.custom { display: inline-flex; align-items: center; gap: 6px; }
.custom[hidden] { display: none; }
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); }
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
@@ -274,7 +272,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
.bubble.theirs { margin-right: 34px; }
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 40; display: grid; gap: 10px; width: min(440px, calc(100vw - 36px)); }
@media (max-width: 1100px) { .toasts { bottom: 72px; } }
.toast { padding: var(--sp-3) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line-strong); border-left: 4px solid var(--ok); border-radius: 12px; box-shadow: var(--shadow-lift); cursor: pointer; }
.toast.bad { border-left-color: var(--bad); }
.toast.wait { border-left-color: var(--warn); cursor: default; }
@@ -355,7 +352,7 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
.space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); }
.space-list { padding: 6px 10px 2px; }
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
body.sky-look .shell, body.sky-look .inbox-bar { visibility: hidden; }
body.sky-look .shell { visibility: hidden; }
body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; }
body.sky-look .sky:active { cursor: grabbing; }
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
@@ -367,28 +364,23 @@ body.sky-look .sky:active { cursor: grabbing; }
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
<div class="head" id="head">
<header class="top">
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>kvb-hub</h1><span class="sky-note" id="sky-note"></span></div></div>
<nav class="tabs" id="tabs" role="tablist" aria-label="Ansichten"></nav>
<div class="status" id="status" aria-live="polite"></div>
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
<div class="tools">
<span class="updated" id="updated" aria-live="polite"></span>
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)">⌘ Befehle</button>
<button type="button" class="btn small ghost" id="board-open">Steuerung</button>
<button type="button" class="btn small ghost" id="rules-open">Regeln</button>
<button type="button" class="btn small ghost" id="sky-look" aria-pressed="false" title="Himmel ansehen: ziehen dreht den Blick (Taste h)">🔭 Himmel</button>
<button type="button" class="btn small ghost" id="music-toggle" aria-pressed="false" title="Generative Musik an oder aus">♪ Musik</button>
<button type="button" class="btn small ghost" id="leitstand" title="Leitstand: Vollbild mit großem Status">Leitstand</button>
<button type="button" class="btn small ghost" id="notify" hidden>Benachrichtigungen aktivieren</button>
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen">⚙</button>
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</button>
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</button>
</div>
</header>
<div class="timebar" id="timebar" role="group" aria-label="Zeitraum"></div>
<div class="navbar">
<nav class="tabs" id="tabs" aria-label="Navigation"></nav>
<span class="spacer"></span>
<details class="menu" id="time"></details>
<span class="updated" id="updated" aria-live="polite"></span>
</div>
<div class="banner" id="mayday" role="alert" hidden></div>
</div>
<div class="shell">
<main id="main" role="tabpanel"></main>
<aside class="rail" id="rail" aria-label="Inbox"></aside>
<main id="main"></main>
</div>
<button type="button" class="inbox-bar" id="inbox-bar">Inbox <span class="badge" id="inbox-count">0</span></button>
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
<div class="sheet" id="sheet" hidden></div>
<div class="toasts" id="toasts" aria-live="polite"></div>
@@ -400,6 +392,8 @@ body.sky-look .sky:active { cursor: grabbing; }
</script>
<script nonce="{{ nonce }}">
{% include "voice.js" %}
{% include "nav.js" %}
</script>
<script nonce="{{ nonce }}">
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
+37
View File
@@ -0,0 +1,37 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const known = (view) => VIEWS.some(([id]) => id === view);
function tabOf(screen) {
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
}
function hashOf(screen, view) {
return tabOf(screen) === screen ? (view ? `#${screen}/${view}` : `#${screen}`) : `#${tabOf(screen)}/${screen}`;
}
function route(hash) {
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
if (Object.hasOwn(OLD, head)) {
const [screen, fallback] = OLD[head];
const view = known(sub) ? sub : fallback;
return {screen, view, redirect: hashOf(screen, view)};
}
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
return {screen: sub, view: null, redirect: null};
}
if (TABS.some(([id]) => id === head) && head !== "stream") {
return {screen: head, view: known(sub) ? sub : null, redirect: null};
}
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
}
@@ -3,7 +3,7 @@
const radio = state.radio.filter((row) => row.source === key || row.targets.includes(key)).map((row) => ({ts: row.ts, mine: row.from === HUMAN, who: row.from, text: row.text}));
const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id}));
const hubIds = new Set(sent.map((entry) => entry.id));
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-hub msg ${id}]`))));
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-orchestrator msg ${id}]`))));
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
}
@@ -58,7 +58,7 @@
})) : h("p", {class: "muted"}, "Keine Subagenten."));
}
async function deliver(text, file) {
async function deliver(text, file, cls) {
let upload = "";
if (file) {
if (file.size > UPLOAD_LIMIT) {
@@ -72,16 +72,17 @@
}
upload = stored.data.id;
}
return message(nameOf(ui.agent), text, upload);
return message(nameOf(ui.agent), text, upload, cls);
}
async function message(to, text, upload) {
const result = await sendJson("/api/messages", {to, text, upload});
async function message(to, text, upload, cls) {
const label = TO_ONE.find(([id]) => id === cls)[1];
const result = await sendJson("/api/messages", {to, text, upload, class: cls});
if (!result.ok || !result.data) {
toast(`Nachricht an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
toast(`${label} an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
return false;
}
toast(`Nachricht an ${to}: ${result.data.ok ? "gefunkt" : "gespeichert, Funk fehlgeschlagen"}`, [`kvb-hub msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
toast(`${label} an ${to}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [`kvb-orchestrator msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
await load();
return true;
}
@@ -94,14 +95,17 @@
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
const form = h("form", {class: "drawer-form"}, text, file, h("div", {class: "actions"}, h("span", {class: "muted"}, "Funk direkt von Operator, prüfbar mit operator_message"), h("span", {class: "spacer"}), micButton(text), button));
const classes = choice("to-one", TO_ONE, "post");
const form = h("form", {class: "drawer-form"}, text, file, classes,
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!text.value.trim()) {
return;
}
button.disabled = true;
if (await deliver(text.value, file.files[0] || null)) {
if (await deliver(text.value, file.files[0] || null, picked(classes))) {
text.value = "";
file.value = "";
}
@@ -20,20 +20,23 @@
}
}
const notifyButton = document.getElementById("notify");
function notifyLabel() {
if (!("Notification" in window)) {
const button = document.getElementById("notify");
if (!button) {
return;
}
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren"};
notifyButton.hidden = Notification.permission === "granted";
notifyButton.textContent = labels[Notification.permission] || labels.default;
notifyButton.disabled = Notification.permission !== "default";
const permission = "Notification" in window ? Notification.permission : "unsupported";
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
button.textContent = labels[permission] || labels.default;
button.disabled = permission !== "default";
}
notifyButton.addEventListener("click", async () => {
await Notification.requestPermission();
async function askNotify() {
if ("Notification" in window) {
await Notification.requestPermission();
}
notifyLabel();
});
}
function announce() {
const alarms = new Map(alarmRows().map((row) => [`${row.ts}|${row.from}`, row]));
@@ -1,6 +1,6 @@
async function send(path, body, type) {
try {
const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": type}, body, cache: "no-store"});
const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": type}, body, cache: "no-store"});
const text = await response.text();
let data = null;
try {
+128
View File
@@ -0,0 +1,128 @@
async function act(action, label, payload) {
ui.busy = true;
const result = await sendJson(`/api/board/${action}`, payload);
ui.busy = false;
if (result.data && result.data.runs) {
toast(`${label}: ${result.data.ok ? "erledigt" : "fehlgeschlagen"}`, runLines(result.data), result.data.ok);
} else {
toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
}
await load();
return result.ok && result.data && result.data.ok;
}
function confirmAct(action) {
const [, label, question] = ACTIONS.find(([id]) => id === action);
if (window.confirm(question)) {
act(action, label, {});
}
}
function choice(name, options, picked) {
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Klasse"}, options.map(([id, label]) => h("label", {class: "check"},
h("input", {type: "radio", name, value: id, checked: id === picked}), label)));
}
const picked = (group) => group.querySelector("input:checked").value;
function toAll() {
const text = h("textarea", {"aria-label": "Funk an alle", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
ui.draft = event.target.value;
}}});
text.value = ui.draft;
const classes = choice("to-all", TO_ALL, "newsletter");
const send = async () => {
const cls = picked(classes);
const label = TO_ALL.find(([id]) => id === cls)[1];
if (!ui.draft.trim() || (cls === "mayday" && !window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst."))) {
return;
}
if (await act("broadcast", `Funk an alle (${label})`, {text: ui.draft, class: cls})) {
ui.draft = "";
text.value = "";
}
};
return h("details", {class: "menu", id: "to-all"}, h("summary", {}, "Funk an alle ▾"), h("div", {class: "menu-box"},
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Senden", "btn soft", send))));
}
function openToAll() {
go("agents", null);
window.setTimeout(() => {
const menu = document.getElementById("to-all");
if (menu) {
menu.open = true;
menu.querySelector("textarea").focus();
}
}, 50);
}
function agentsToolbar() {
const clear = btn("Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", {}, "Tag/Nacht ▾"), h("div", {class: "menu-box"}, mode,
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, btn("Priorität", "btn small", prioritySheet), night), clear, mode};
}
function drawToolbar() {
if (!graphDom || !graphDom.toolbar) {
return;
}
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
graphDom.toolbar.clear.disabled = !state.silence;
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
}
function priorityContent() {
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
ui.order = [...state.priority];
}
const list = h("ol", {class: "order-list"});
let dragged = null;
ui.order.forEach((name, index) => {
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
item.addEventListener("dragstart", (event) => {
dragged = name;
item.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
});
item.addEventListener("dragend", () => item.classList.remove("dragging"));
item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => {
event.preventDefault();
if (dragged === null || dragged === name) {
return;
}
const next = ui.order.filter((entry) => entry !== dragged);
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
ui.order = next;
prioritySheet();
});
list.append(item);
});
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
return [
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
h("div", {class: "actions"},
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
ui.order = [...state.priority];
prioritySheet();
}}}, "Zurücksetzen"),
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
if (await act("order", "Priorität", {agents: ui.order})) {
ui.order = null;
prioritySheet();
}
}}}, "Reihenfolge speichern")),
];
}
function prioritySheet() {
openSheet("Priorität der Agenten", priorityContent(), false);
}
@@ -9,12 +9,8 @@
}
});
document.getElementById("palette-open").addEventListener("click", openPalette);
document.getElementById("board-open").addEventListener("click", boardSheet);
document.getElementById("rules-open").addEventListener("click", rulesSheet);
document.getElementById("settings-open").addEventListener("click", settingsSheet);
document.getElementById("music-toggle").addEventListener("click", toggleMusic);
document.getElementById("leitstand").addEventListener("click", toggleLeitstand);
document.getElementById("inbox-bar").addEventListener("click", () => document.body.classList.toggle("inbox-open"));
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement) {
document.body.classList.remove("leitstand");
@@ -57,7 +53,7 @@
window.setInterval(() => live() && drawSky(), 30000);
window.setInterval(syncRadio, 20000);
window.setInterval(() => {
if (ui.screen === "tree" && !document.hidden) {
if (tree.dom && !document.hidden) {
fetchTree();
}
}, 60000);
@@ -2,22 +2,21 @@
const DAY = 86400;
const CHAIR = "Maintainer";
const HUMAN = "Operator";
const SCREENS = [["stream", "Stream"], ["graph", "Graph"], ["tree", "Tree"]];
const GRAPHS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"]];
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KINDS = [["radio", "Funk"], ["review", "Reviews"], ["ask", "Asks"], ["decision", "Entscheidungen"], ["operator", "Operator"], ["register", "Register"], ["rename", "Namen"], ["cron", "Crons"], ["rule", "Regeln"], ["approval", "Freigaben"], ["run", "Runner"], ["transcript", "Transkripte"]];
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
const UPLOAD_LIMIT = 50 * 1024 * 1024;
const STREAM_KEEP = 5000;
const CLASSES = {direct: "Direkt", broadcast: "Rundruf", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sicherheit", grant: "Freigabe"};
const PILLS = {direct: "info", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", grant: "ok"};
const LEGEND = [["direct", "Direkt"], ["broadcast", "Rundruf"], ["alarm", "Mayday / Pan-Pan"], ["security", "Sicherheit"], ["grant", "Queue-Freigabe"]];
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Security", receipt: "Gelesen", grant: "Freigabe"};
const PILLS = {post: "info", call: "info", newsletter: "teal", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", receipt: "neutral", grant: "ok"};
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Security"], ["grant", "Queue-Freigabe"]];
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Security"], ["mayday", "Mayday"]];
const TO_ONE = [["post", "Post"], ["call", "Call"]];
const STATUS = {
running: ["läuft", "ok"], queued: ["wartet", "info"], blocked: ["blockiert", "bad"], stale: ["veraltet", "warn"], pending: ["offen", "warn"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"],
approved: ["freigegeben", "ok"], rejected: ["abgelehnt", "bad"], allowed: ["erlaubt", "ok"], denied: ["abgelehnt", "bad"], expired: ["abgelaufen", "neutral"], decided: ["entschieden", "ok"], failed: ["fehlgeschlagen", "bad"], refused: ["verweigert", "bad"],
@@ -31,11 +30,10 @@
const FEED_LIMIT = 300;
const MOVE = 2000;
const RELAYOUT = 60000;
const ACTIVE = 900;
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
const STORE = {views: "kvb-hub.views"};
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Direktnachrichten an dich"], ["radio", "Aller Funk"]];
const SPEECH_CLASSES = [["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"], ["direct", "Direktruf"], ["broadcast", "Rundruf"]];
const STORE = {views: "kvb-orchestrator.views"};
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Post und Calls an dich"], ["radio", "Aller Funk"]];
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"]];
const SPEECH_QUEUE = 20;
const SPEECH_QUIET = 60000;
const SPEECH_FRESH = 300;
@@ -44,7 +42,7 @@
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
const AXES = [["none", "Flach", "Die heutige 2D-Ansicht"], ["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
const SUBS_TTL = 60000;
const TURN = 10;
const NUDGE = 40;
@@ -64,7 +62,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Graph · Tree"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
@@ -107,6 +107,14 @@
return button;
}
function closeMenus(keep) {
for (const menu of document.querySelectorAll("details.menu[open]")) {
if (menu !== keep) {
menu.open = false;
}
}
}
function openSheet(title, body, wide) {
const sheet = document.getElementById("sheet");
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
@@ -103,7 +103,7 @@
return;
}
const kinds = activeKinds();
const presets = Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label));
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)));
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
click: () => useSaved(view),
keydown: (event) => {
@@ -115,7 +115,7 @@
event.stopPropagation();
dropView(view.name);
}}}, "×")));
stream.dom.presets.replaceChildren(...presets, ...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
customize();
if (filter.kinds.has(id) && filter.kinds.size > 1) {
@@ -138,14 +138,14 @@
filter.parts.add(id);
}
refilter();
}}}, label)) : [];
const focus = filter.sub || filter.session ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : `Session ${filter.session.slice(0, 8)}`,
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
stream.dom.parts.replaceChildren(...parts);
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
filter.sub = "";
filter.session = "";
filter.agent = "";
refilter();
}}}, "×")) : null;
stream.dom.parts.replaceChildren(...parts, focus || "");
const names = [...new Set([...Object.values(stream.sessions), ...Object.values(people()).flatMap((entry) => entry.names), ...state.claims.map((claim) => claim.agent), ...state.priority].filter(Boolean))].sort((a, b) => a.localeCompare(b));
stream.dom.agents.replaceChildren(h("option", {value: ""}, "Alle Agenten"), names.map((name) => h("option", {value: name, selected: name === filter.agent}, name)));
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
}
@@ -157,7 +157,7 @@
}
function legend() {
const axis = graph3d() ? space.views.graph.axis : "none";
const axis = graph3d() ? depth() : "none";
const words = {
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
@@ -187,13 +187,23 @@
];
}
function graphScreen() {
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
}
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
function agentsScreen() {
const view = ui.views.agents;
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
graphDom = {stage: stageBox, aside, pulses: null, space: null};
return h("div", {class: "graph"},
h("div", {class: "card controls"},
h("div", {class: "segmented", role: "tablist", "aria-label": "Darstellung"}, GRAPHS.map(([id, name]) => h("button", {type: "button", role: "tab", "aria-selected": String(ui.graph === id), on: {click: () => go("graph", id)}}, name))),
ui.graph === "orbit" ? spaceTools() : null),
h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside));
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
}
function artifactsScreen() {
const view = ui.views.artifacts;
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
}
@@ -54,7 +54,7 @@
const from = motion.from.get(id) || to;
return [id, {angle: Space.turn(from.angle, to.angle, eased), radius: from.radius + (to.radius - from.radius) * eased, chord: Space.turn(from.chord, to.chord, eased)}];
}));
if (ui.graph !== "swimlane") {
if (ui.views.agents !== "swimlane") {
stage();
}
motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0;
@@ -159,7 +159,15 @@
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
}
function inboxScreen() {
inboxDom = h("div", {class: "inbox"});
return inboxDom;
}
function drawInbox() {
if (!inboxDom) {
return;
}
const items = visibleInbox();
ui.cursor = Math.min(ui.cursor, Math.max(items.length - 1, 0));
const present = new Set(items.map(keyOf));
@@ -182,13 +190,13 @@
}}});
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`);
document.getElementById("rail").replaceChildren(
inboxDom.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
h("div", {class: "inbox-actions"}, choose, all),
onLocalhost() ? null : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`);
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
if (current && document.activeElement === document.body) {
current.scrollIntoView({block: "nearest"});
}
@@ -1,6 +1,6 @@
function helpSheet() {
openSheet("Tastenkürzel", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
h("p", {class: "muted"}, "Leitstand: Vollbild mit großem Status für den zweiten Bildschirm. Auf schmalen Bildschirmen öffnet die Leiste unten die Inbox.")], false);
h("p", {class: "muted"}, "Leitstand: Vollbild mit großer Schrift für den zweiten Bildschirm, unter ⚙ Ambiente. j, k, x, a und Enter wirken in der Inbox.")], false);
}
function overlayOpen() {
@@ -14,7 +14,7 @@
}
ui.cursor = Math.min(Math.max(ui.cursor + delta, 0), items.length - 1);
drawInbox();
const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`);
const current = inboxDom && inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
if (current) {
current.scrollIntoView({block: "nearest"});
}
@@ -41,7 +41,7 @@
} else if (ui.agent !== null) {
closeAgent();
} else {
document.body.classList.remove("inbox-open");
closeMenus();
}
return;
}
@@ -51,18 +51,19 @@
if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) {
return;
}
const current = visibleInbox()[ui.cursor];
const inInbox = ui.screen === "inbox";
const current = inInbox ? visibleInbox()[ui.cursor] : null;
const index = Number(event.key) - 1;
if (/^[1-3]$/.test(event.key)) {
event.preventDefault();
go(SCREENS[index][0], null);
} else if (event.key === "j" || event.key === "k") {
go(Nav.TABS[index][0], null);
} else if (inInbox && (event.key === "j" || event.key === "k")) {
event.preventDefault();
moveCursor(event.key === "j" ? 1 : -1);
} else if (event.key === "x") {
} else if (inInbox && event.key === "x") {
event.preventDefault();
toggle(current);
} else if (event.key === "a") {
} else if (inInbox && event.key === "a") {
event.preventDefault();
if (current && !current.held) {
approveOne(current);
File renamed without changes.
@@ -138,6 +138,9 @@
function musicButton() {
const button = document.getElementById("music-toggle");
if (!button) {
return;
}
button.setAttribute("aria-pressed", String(settings.music.on));
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
}
@@ -1,16 +1,19 @@
function paletteItems() {
return [
...SCREENS.map(([id, label], index) => ({label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)})),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Ansicht", run: () => useView(id)})),
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
...["agents", "artifacts"].flatMap((screen) => Nav.VIEWS.map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
...ACTIONS.map(([id, label]) => ({label, hint: "Aktion", run: () => confirmAct(id)})),
{label: "Steuerung …", hint: "Rundruf, Nacht, Priorität", run: boardSheet},
{label: "Regeln …", hint: "automatische Freigaben", run: rulesSheet},
{label: "Einstellungen …", hint: "Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
{label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
{label: "Leitstand umschalten", hint: "Vollbild", run: toggleLeitstand},
{label: "Leitstand umschalten", hint: "Ambiente", run: toggleLeitstand},
{label: "Benachrichtigungen aktivieren", hint: "Ambiente", run: askNotify},
{label: "Tastenkürzel", hint: "?", run: helpSheet},
];
}
@@ -25,7 +28,7 @@
if (raw) {
items.push(state.llm
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
: {label: "KI-Befehl ist aus", hint: "KVB_HUB_LLM_URL und KVB_HUB_LLM_MODEL in hub/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_HUB_LLM_URL, KVB_HUB_LLM_MODEL und KVB_HUB_LLM_KEY_FILE in hub/.env und installiere neu."], false)});
: {label: "KI-Befehl ist aus", hint: "KVB_ORCHESTRATOR_LLM_URL und KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL und KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env und installiere neu."], false)});
}
palette.items = items;
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
@@ -93,7 +96,7 @@
} else if (action.name === "open") {
go(args.view, null);
} else if (action.name === "message") {
await message(args.to, args.text, "");
await message(args.to, args.text, "", "post");
} else if (action.name === "filter") {
filter.q = args.q;
if (ui.screen === "stream" && stream.dom) {
@@ -52,8 +52,8 @@
}
try {
const credential = await navigator.credentials.create({publicKey: {
rp: {id: RP_ID, name: "kvb-hub"},
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "kvb-hub Operator"},
rp: {id: RP_ID, name: "Agent Orchestrator"},
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "Agent Orchestrator Operator"},
challenge: crypto.getRandomValues(new Uint8Array(32)),
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
attestation: "none",
@@ -62,7 +62,7 @@
}});
const answer = credential.response;
const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)})));
const command = `python3 ~/.local/share/kvb-hub/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-hub/approvers.json --credential '${blob}'`;
const command = `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/approvers.json --credential '${blob}'`;
output.replaceChildren(
h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."),
h("div", {class: "command"}, h("code", {}, command), copyButton(command)),
@@ -42,7 +42,7 @@
}
function pulses(rows) {
if (calm.matches || ui.screen !== "graph" || ui.graph !== "orbit" || !live() || !graphDom || !graphDom.pulses) {
if (calm.matches || ui.screen !== "agents" || ui.views.agents !== "orbit" || !live() || !graphDom || !graphDom.pulses) {
return;
}
const layer = graphDom.pulses;
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
function rulesSheet() {
function rulesScreen() {
const kind = h("select", {class: "field", "aria-label": "Art"}, h("option", {value: "ask"}, "Ask: Werkzeug + Befehlsmuster"), h("option", {value: "review"}, "Review: Branch-Muster + Gate grün"), h("option", {value: "decision"}, "Entscheidung: Empfehlung nehmen"));
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
@@ -44,7 +44,6 @@
const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked});
toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok);
await load();
rulesSheet();
});
const change = async (rule, verb) => {
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
@@ -52,7 +51,6 @@
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
}
await load();
rulesSheet();
};
const now = nowSeconds();
const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"},
@@ -70,5 +68,5 @@
change(rule, "delete");
}
})))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen.");
openSheet("Regeln für automatische Freigaben", [list, hint, form], false);
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Regeln für automatische Freigaben"), list, h("div", {class: "card"}, hint, form));
}
+107
View File
@@ -0,0 +1,107 @@
const navButtons = new Map();
function tabButton(id, label, hint) {
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
const button = h("button", {type: "button", id: `tab-${id}`, "aria-controls": "main", title: hint ? `${label} (${hint})` : label, on: {click: () => go(id, null)}}, label, badge);
navButtons.set(id, {button, badge});
return button;
}
document.getElementById("tabs").append(...Nav.TABS.map(([id, label], index) => h("div", {class: "tab-group"},
tabButton(id, label, `Taste ${index + 1}`),
Nav.SUBS[id] ? h("div", {class: "subtabs"}, Nav.SUBS[id].map(([sub, name]) => tabButton(sub, name, ""))) : null)));
function measure() {
document.documentElement.style.setProperty("--head", `${document.getElementById("head").offsetHeight}px`);
}
function go(screen, view) {
const hash = Nav.hashOf(screen, view || ui.views[screen] || null);
if (window.location.hash === hash) {
apply();
} else {
window.location.hash = hash;
}
}
function apply() {
const route = Nav.route(window.location.hash);
if (route.redirect) {
window.history.replaceState(null, "", route.redirect);
}
ui.screen = route.screen;
if (route.view) {
ui.views[route.screen] = route.view;
}
if (document.startViewTransition && drawn.screen && !calm.matches) {
document.startViewTransition(() => render(true));
} else {
render(true);
}
}
function signature() {
if (ui.screen === "agents") {
return JSON.stringify([ui.views.agents, depth()]);
}
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
}
function screenOf() {
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
}
function render(force) {
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
ui.node = null;
}
for (const [id, {button}] of navButtons) {
button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
}
const sig = signature();
if (force || drawn.screen !== ui.screen || drawn.sig !== sig) {
graphDom = null;
stream.dom = null;
tree.dom = null;
inboxDom = null;
inboxSig = "";
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
drawn.screen = ui.screen;
drawn.sig = sig;
}
stage();
drawSide();
drawToolbar();
drawInbox();
drawStatus();
tick();
}
function drawStatus() {
for (const [id, count] of Object.entries(state.badges)) {
const {badge} = navButtons.get(id);
badge.textContent = String(count);
badge.hidden = count === 0;
badge.className = `badge${id === "inbox" && count ? " urgent" : ""}`;
}
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}`), btn("aufheben", "btn small", () => confirmAct("clear-mayday"))] : []));
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
window.setTimeout(measure, 0);
}
function toggleLeitstand() {
const on = !document.body.classList.contains("leitstand");
document.body.classList.toggle("leitstand", on);
if (on && document.documentElement.requestFullscreen && !document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => null);
}
if (!on && document.fullscreenElement) {
document.exitFullscreen().catch(() => null);
}
window.setTimeout(measure, 50);
}
@@ -55,6 +55,23 @@
list.append(entry.row);
}
function ambience() {
const tool = (id, label, title, run) => h("button", {type: "button", id, class: "btn small", title, on: {click: run}}, label);
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
tool("sky-look", "🔭 Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
closeSheet();
skyLook(true);
}),
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
window.setTimeout(() => {
musicButton();
notifyLabel();
}, 0);
return section;
}
function settingsSheet() {
const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label});
const latitude = number(settings.latitude, -90, 90, "Breite");
@@ -83,16 +100,17 @@
});
const notify = h("input", {type: "checkbox", checked: settings.notify});
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday"})}));
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})}));
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})}));
const absent = settings.speech.muted.filter((key) => !people()[key]);
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "direct"}));
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter"}));
sample.disabled = !state.speech.tts;
const list = h("div", {class: "mini"});
const rows = [];
settings.streams.forEach((stream) => streamRow(stream, rows, list));
const form = h("form", {class: "settings"},
ambience(),
h("section", {}, h("h3", {}, "Ort"), h("p", {class: "muted"}, "Sonne, Mond und Sterne stehen dort, wo du sie von hier aus siehst, Blick nach Süden; unter 🔭 Himmel (Taste h) drehst du den Blick. Dezimalgrad, Nord und Ost positiv."),
h("div", {class: "grid"}, h("label", {}, "Breite", latitude), h("label", {}, "Länge", longitude))),
h("section", {}, h("h3", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict),
@@ -135,5 +153,5 @@
toast("Einstellungen gespeichert", [], true);
}
});
openSheet("Einstellungen", [form], false);
openSheet("Einstellungen und Ambiente", [form], false);
}
@@ -170,10 +170,8 @@
function skyLook(on) {
const canvas = sky.canvas;
const button = document.getElementById("sky-look");
space.look = on;
document.body.classList.toggle("sky-look", on);
button.setAttribute("aria-pressed", String(on));
document.getElementById("sky-panel").hidden = !on;
if (on) {
canvas.removeAttribute("aria-hidden");
@@ -188,7 +186,7 @@
canvas.removeAttribute(name);
}
canvas.setAttribute("aria-hidden", "true");
button.focus();
document.getElementById("settings-open").focus();
}
function skyPanel() {
@@ -205,5 +203,4 @@
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
steer(sky.canvas, "sky", skyHooks);
document.getElementById("sky-look").addEventListener("click", () => skyLook(!space.look));
}
@@ -19,7 +19,7 @@
}
}
return {
axis: space.views.graph.axis, range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
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,
};
}
@@ -31,7 +31,7 @@
}
function spaceLabel() {
const hint = AXES.find(([id]) => id === space.views.graph.axis)[2];
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.`;
}
@@ -1,17 +1,17 @@
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
function spaceTools() {
const axis = space.views.graph.axis;
const deep = axis !== "none";
return h("div", {class: "space-tools"},
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(axis === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
deep ? h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht") : null,
deep ? h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0") : null);
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(depth() === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht"),
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
}
const graph3d = () => ui.screen === "graph" && ui.graph === "orbit" && space.views.graph.axis !== "none";
const wantsTrack = () => graph3d() && space.views.graph.axis === "time";
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
const wantsTrack = () => graph3d() && depth() === "time";
function chooseAxis(id) {
if (id === space.views.graph.axis) {
if (id === depth()) {
return;
}
setView("graph", {...space.views.graph, axis: id}, false);
@@ -19,7 +19,7 @@
}
function stage() {
if (!graphDom || ui.screen !== "graph") {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
return;
}
graphDom.pulses = null;
@@ -27,11 +27,11 @@
stageSpace();
return;
}
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.graph]());
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
}
function drawSide() {
if (graphDom && ui.screen === "graph") {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
graphDom.aside.replaceChildren(...side());
}
}
@@ -31,7 +31,7 @@
pump();
};
try {
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
if (!response.ok) {
throw new Error(`${response.status} ${(await response.text()).trim()}`);
}
@@ -26,7 +26,8 @@
}
let state = JSON.parse(document.getElementById("state").textContent);
const ui = {screen: "stream", graph: "orbit", preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, 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, busy: false, q: "", open: new Set(), dom: null};
@@ -46,13 +47,14 @@
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
};
let userViews = validViews(stored(STORE.views, []));
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
let settings = state.settings;
let settingsSig = JSON.stringify(settings);
let drawerDom = null;
let timeDom = null;
let graphDom = null;
let reviewDom = null;
let inboxDom = null;
let replay = 0;
let positions = new Map();
let inboxSig = "";
@@ -161,26 +161,26 @@
}
function streamScreen() {
const agents = h("select", {"aria-label": "Agent", on: {change: (event) => {
filter.agent = event.target.value;
filter.saved = "";
refilter();
}}});
const search = h("input", {type: "search", placeholder: "Suchen … (/)", value: filter.q, "aria-label": "Stream durchsuchen", on: {input: (event) => {
const search = h("input", {type: "search", placeholder: "Suchen … agent:Name art:funk seit:2h (/)", value: filter.q, "aria-label": "Stream durchsuchen: Wörter, agent:, art: und seit:", on: {input: (event) => {
filter.q = event.target.value;
filter.saved = "";
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(fetchEvents, 300);
}}});
const count = h("span", {class: "muted"});
const presets = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
const categories = h("div", {class: "chips", role: "group", "aria-label": "Kategorien"});
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"});
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
const focus = h("div", {class: "chips"});
const list = h("div", {});
stream.dom = {agents, search, count, presets, kinds, parts, list};
const more = h("details", {class: "menu"}, h("summary", {}, "Filter ▾"), h("div", {class: "menu-box end"},
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
window.setTimeout(() => {
drawFilters();
drawStream();
fetchEvents();
}, 0);
return h("div", {}, presets, h("div", {class: "filters"}, agents, search, count), kinds, parts, list);
return h("div", {}, h("div", {class: "filters"}, categories, search, more, count), focus, list);
}
@@ -12,12 +12,10 @@
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
function timeBar() {
const bar = document.getElementById("timebar");
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
const from = h("input", {type: "datetime-local", "aria-label": "Von"});
const to = h("input", {type: "datetime-local", "aria-label": "Bis"});
const custom = h("span", {class: "custom", hidden: true}, from, h("span", {class: "muted"}, "bis"), to,
function customRange() {
const from = h("input", {type: "datetime-local", "aria-label": "Von", value: localInput(ui.from === null ? state.since : ui.from)});
const to = h("input", {type: "datetime-local", "aria-label": "Bis", value: localInput(ui.to === null ? state.until : ui.to)});
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "bis"), to,
h("button", {type: "button", class: "btn small soft", on: {click: () => {
const begin = Date.parse(from.value) / 1000;
const end = Date.parse(to.value) / 1000;
@@ -25,15 +23,23 @@
toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false);
return;
}
stopReplay();
ui.preset = -1;
ui.from = begin;
ui.to = end;
reload();
}}}, "Anwenden"));
}
function timeBar() {
const bar = document.getElementById("time");
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
const label = h("span", {class: "window-label"});
for (const [seconds, name] of PRESETS) {
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
presets.append(h("button", {type: "button", "data-preset": seconds, on: {click: () => choosePreset(seconds)}}, name));
}
slider.addEventListener("input", () => {
@@ -51,8 +57,9 @@
}
reload();
});
bar.append(presets, custom, h("div", {class: "replay"}, play, slider, liveButton, label));
timeDom = {presets, custom, from, to, slider, play, live: liveButton, label};
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Zeitraum"), presets, h("h3", {}, "Wiedergabe"), h("div", {class: "replay"}, play, slider, liveButton), label,
h("p", {class: "muted"}, "Einen eigenen Zeitraum setzt Filter ▾ im Stream.")));
timeDom = {summary, presets, slider, play, live: liveButton, label};
}
function reload() {
@@ -65,12 +72,6 @@
function choosePreset(seconds) {
stopReplay();
ui.preset = seconds;
if (seconds === -1) {
ui.from = ui.from === null ? state.since : ui.from;
ui.to = ui.to === null ? state.until : ui.to;
timeDom.from.value = localInput(ui.from);
timeDom.to.value = localInput(ui.to);
}
reload();
}
@@ -81,7 +82,7 @@
for (const button of timeDom.presets.children) {
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
}
timeDom.custom.hidden = ui.preset !== -1;
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "eigener"])[1]}${ui.asOf === null ? "" : " · Wiedergabe"} ▾`;
timeDom.slider.disabled = ui.preset === -1;
timeDom.play.disabled = ui.preset === -1;
timeDom.live.disabled = live();
@@ -42,7 +42,7 @@
}
function drawTree() {
if (!tree.dom || ui.screen !== "tree") {
if (!tree.dom) {
return;
}
if (!tree.data) {
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
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 TONES = {post: "direct", call: "direct", receipt: "direct", newsletter: "broadcast", 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;
File renamed without changes.
File renamed without changes.
File renamed without changes.
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env bash
# Usage: hub.sh install | check | pin <blob> | runner | hook
# install copy hub/ to ~/.local/share/kvb-hub, write its .env, build and
# start the container, register the MCP server for Claude Code
# check image imports, container, logs, HTTP, pinned keys, hook, runner,
# disk; exit 1 when a check fails
# pin pin the credential blob the hub's "Passkey registrieren" shows
# runner install, enable and restart the runner user service
# hook register the PermissionRequest hook in ~/.claude/settings.json,
# replacing an earlier kvb-hub entry, keeping everything else and the
# file mode, after a copy to settings.json.before-kvb-hub-hook
set -euo pipefail
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
HUB="${HOME}/.local/share/kvb-hub"
STATE="${HOME}/.local/state/kvb-hub"
APPROVALS="${HOME}/.local/state/kvb-hub-approvals"
APPROVERS="${HOME}/.config/kvb-hub"
UNITS="${HOME}/.config/systemd/user"
# council.py ROOT: every agent must agree on this directory
COUNCIL="${AGENTS_COUNCIL_ROOT:-/tmp/agents-council}"
SETTINGS="${HOME}/.claude/settings.json"
LOCAL_SETTINGS="${HOME}/.claude/settings.local.json"
HOOK_TIMEOUT=110
RUNNER="kvb-hub-runner.service"
IMAGE="kvb-hub:local"
FLOOR_GB=15
FAILED=0
log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
ok() { printf 'ok %s\n' "$*"; }
todo() { printf 'todo %s\n' "$*"; }
fail() {
printf 'FAIL %s\n' "$*"
FAILED=1
}
port_of() {
sed -n 's/^KVB_HUB_PORT=//p' "$1"
}
install_hub() {
command -v docker &>/dev/null || {
warn "skills: docker not found; skipped the kvb-hub review hub."
return 0
}
local port
port="$(port_of "${REPO_ROOT}/hub/.env")"
log "skills: starting the kvb-hub review hub on http://localhost:${port}/"
rm -rf "${HUB}"
mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}"
chmod 700 "${APPROVERS}"
cp -a "${REPO_ROOT}/hub/." "${HUB}/"
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py"
log "skills: the kvb-hub PermissionRequest hook client is ${HUB}/client/permission_hook.py"
log "skills: approvals need a pinned passkey: hub settings, 'Passkey registrieren', then 'make hub-pin CREDENTIAL=<blob>'; until then every executor refuses"
printf 'KVB_HUB_USER=%s:%s\nKVB_HUB_STATE=%s\nKVB_HUB_COUNCIL=%s\nKVB_HUB_SKILLS=%s\nKVB_HUB_PROJECTS=%s\nKVB_HUB_REPOS=%s\nKVB_HUB_WORKTREES=%s\nKVB_HUB_TIMERS=%s\nKVB_HUB_APPROVERS=%s\n' \
"$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env"
if ! docker compose --file "${HUB}/compose.yml" up -d --build; then
warn "skills: docker compose failed; the kvb-hub stays down until 'make hub-install' succeeds."
fi
if ! command -v claude &>/dev/null; then
warn "skills: claude not found; skipped registering the kvb-hub MCP server."
return 0
fi
claude mcp remove --scope user kvb-hub &>/dev/null || true
if claude mcp add --transport http --scope user kvb-hub "http://127.0.0.1:${port}/mcp"; then
log "skills: registered the kvb-hub MCP server for Claude Code at user scope"
else
warn "skills: registering the kvb-hub MCP server for Claude Code failed."
fi
}
runner() {
if ! systemctl --user show-environment &>/dev/null; then
warn "skills: no systemd user session; the kvb-hub runner is off and approved operator commands wait."
return 1
fi
cp -f "${HUB}/systemd/${RUNNER}" "${UNITS}/"
systemctl --user daemon-reload
systemctl --user enable "${RUNNER}"
systemctl --user restart "${RUNNER}"
log "skills: the kvb-hub runner service runs the operator commands approved in the hub ($(systemctl --user is-active "${RUNNER}"))"
}
check_keys() {
local file="${APPROVERS}/approvers.json"
if [[ ! -f "${file}" ]]; then
todo "no pinned passkey: hub settings, 'Passkey registrieren', then make hub-pin CREDENTIAL=<blob>"
return 0
fi
local modes
modes="$(stat -c '%a' "${APPROVERS}" "${file}" | paste -sd ' ')"
if [[ "${modes}" == "700 600" ]]; then
ok "pinned keys $(python3 -c 'import json,sys; print([c["alg"] for c in json.load(open(sys.argv[1]))["credentials"]])' "${file}") (modes ${modes})"
else
fail "pinned key modes are ${modes}, expected 700 600"
fi
}
check() {
local port code free
port="$(port_of "${HUB}/.env")"
if docker run --rm "${IMAGE}" --help >/dev/null; then ok "image ${IMAGE} imports every module"; else fail "image ${IMAGE} does not start"; fi
if docker compose --file "${HUB}/compose.yml" ps --status running --services | grep -qx hub; then ok "container running"; else fail "container not running"; fi
if docker compose --file "${HUB}/compose.yml" logs --tail 200 2>&1 | grep -q Traceback; then
fail "traceback in the hub log:"
docker compose --file "${HUB}/compose.yml" logs --tail 30
else
ok "no traceback in the last 200 log lines"
fi
code="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${port}/" || true)"
if [[ "${code}" == "200" ]]; then ok "http://localhost:${port}/ answers 200"; else fail "http://localhost:${port}/ answers ${code}"; fi
check_keys
if grep -qs 'kvb-hub/client/permission_hook.py' "${SETTINGS}" "${LOCAL_SETTINGS}"; then
ok "PermissionRequest hook registered"
else
todo "PermissionRequest hook missing: make hub-hook"
fi
if [[ "$(systemctl --user is-active "${RUNNER}" 2>/dev/null || true)" == "active" ]]; then
ok "runner active; last lines:"
journalctl --user -u "${RUNNER}" -n 10 --no-pager
else
todo "runner off: make hub-runner"
fi
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
return "${FAILED}"
}
pin() {
[[ -n "${1:-}" ]] || {
warn "usage: make hub-pin CREDENTIAL=<blob from 'Passkey registrieren'>"
exit 2
}
python3 "${HUB}/client/approval.py" pin --port "$(port_of "${HUB}/.env")" --approvers "${APPROVERS}/approvers.json" --credential "$1"
check_keys
return "${FAILED}"
}
hook() {
local command
command="python3 \"${HUB}/client/permission_hook.py\" --port $(port_of "${HUB}/.env") --timeout ${HOOK_TIMEOUT} --approvers \"${APPROVERS}/approvers.json\" --replay \"${APPROVALS}/hook.json\""
[[ -f "${SETTINGS}" ]] && cp -p "${SETTINGS}" "${SETTINGS}.before-kvb-hub-hook"
python3 - "${SETTINGS}" "${command}" "$((HOOK_TIMEOUT + 10))" <<'PY'
import json, os, sys
from pathlib import Path
path, command, timeout = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])
settings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
entries = settings.setdefault("hooks", {}).setdefault("PermissionRequest", [])
entries[:] = [entry for entry in entries if not any("kvb-hub/client/permission_hook.py" in hook.get("command", "") for hook in entry.get("hooks", []))]
entries.append({"matcher": "Bash", "hooks": [{"type": "command", "command": command, "timeout": timeout}]})
temporary = path.with_name(f"{path.name}.{os.getpid()}")
temporary.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
temporary.chmod(mode)
temporary.replace(path)
PY
log "skills: registered the kvb-hub PermissionRequest hook in ${SETTINGS} (copy before: ${SETTINGS}.before-kvb-hub-hook); restart Claude Code to load it"
}
case "${1:-}" in
install) install_hub ;;
check) check ;;
pin) pin "${2:-}" ;;
runner) runner ;;
hook) hook ;;
*)
warn "usage: hub.sh install | check | pin <blob> | runner | hook"
exit 2
;;
esac
+3 -3
View File
@@ -65,10 +65,10 @@ if [[ "${TARGET}" == "${HOME}" ]]; then
warn "skills: no systemd user session; skipped the kvb-dream notification timer."
fi
if command -v docker &>/dev/null; then
bash "${REPO_ROOT}/scripts/hub.sh" install
bash "${REPO_ROOT}/scripts/hub.sh" runner || true
bash "${REPO_ROOT}/scripts/orchestrator.sh" install
bash "${REPO_ROOT}/scripts/orchestrator.sh" runner || true
else
warn "skills: docker not found; skipped the kvb-hub review hub."
warn "skills: docker not found; skipped the Agent Orchestrator."
fi
log "skills: installing or updating the codebase-memory-mcp code graph for every detected agent"
if ! curl -fsSL --proto '=https' https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash; then
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more