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:
1 parent
54f3725aa1
commit
6f940b4415
125 files changed
+1725
-782
No files matched your search
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: orchestrator.sh install | migrate | check | pin <blob> | runner | hook
|
||||
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
|
||||
# write its .env, build and start the container, register the MCP
|
||||
# server for Claude Code; re-enable the runner and re-register the
|
||||
# hook when the migration took them over
|
||||
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
||||
# once: container, image, code, state, approvals, pinned keys, MCP
|
||||
# server, PermissionRequest hook, runner unit; a second run changes
|
||||
# nothing
|
||||
# check image imports, container, logs, HTTP, pinned keys, hook, runner,
|
||||
# disk; exit 1 when a check fails
|
||||
# pin pin the credential blob the "Passkey registrieren" settings show
|
||||
# runner install, enable and restart the runner user service
|
||||
# hook register the PermissionRequest hook in ~/.claude/settings.json,
|
||||
# replacing an earlier entry, keeping everything else and the file
|
||||
# mode, after a copy to settings.json.before-kvb-orchestrator-hook
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
NAME="kvb-orchestrator"
|
||||
LEGACY="kvb-hub"
|
||||
HUB="${HOME}/.local/share/${NAME}"
|
||||
STATE="${HOME}/.local/state/${NAME}"
|
||||
APPROVALS="${HOME}/.local/state/${NAME}-approvals"
|
||||
APPROVERS="${HOME}/.config/${NAME}"
|
||||
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="${NAME}-runner.service"
|
||||
IMAGE="${NAME}:local"
|
||||
FLOOR_GB=15
|
||||
FAILED=0
|
||||
TOOK_RUNNER=0
|
||||
TOOK_HOOK=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_ORCHESTRATOR_PORT=//p' "$1"
|
||||
}
|
||||
|
||||
has_systemd() {
|
||||
command -v systemctl &>/dev/null && systemctl --user show-environment &>/dev/null
|
||||
}
|
||||
|
||||
# Args: settings file, marker every dropped hook command contains, then
|
||||
# optionally the command and timeout of the entry to append.
|
||||
rewrite_hooks() {
|
||||
python3 - "$@" <<'PY'
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
path, (marker, *added) = Path(sys.argv[1]), sys.argv[2:]
|
||||
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(marker in hook.get("command", "") for hook in entry.get("hooks", []))]
|
||||
if added:
|
||||
entries.append({"matcher": "Bash", "hooks": [{"type": "command", "command": added[0], "timeout": int(added[1])}]})
|
||||
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
|
||||
}
|
||||
|
||||
move_once() {
|
||||
local from="$1" to="$2"
|
||||
[[ -e "${from}" ]] || return 0
|
||||
if [[ -e "${to}" ]]; then
|
||||
warn "skills: kept ${from}: ${to} exists already; merge them by hand"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname -- "${to}")"
|
||||
mv "${from}" "${to}"
|
||||
log "skills: moved ${from} to ${to}"
|
||||
}
|
||||
|
||||
migrate() {
|
||||
local code="${HOME}/.local/share/${LEGACY}" unit="${LEGACY}-runner.service" marker="${LEGACY}/client/permission_hook.py"
|
||||
if [[ -f "${code}/compose.yml" ]] && command -v docker &>/dev/null; then
|
||||
docker compose --file "${code}/compose.yml" down --remove-orphans || {
|
||||
warn "skills: stopping the ${LEGACY} container failed; nothing migrated."
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
if command -v docker &>/dev/null && docker image inspect "${LEGACY}:local" &>/dev/null; then
|
||||
if docker image rm "${LEGACY}:local" >/dev/null; then log "skills: removed the image ${LEGACY}:local"; else warn "skills: kept the image ${LEGACY}:local: still in use"; fi
|
||||
fi
|
||||
if [[ -e "${code}" ]]; then
|
||||
rm -rf "${code}"
|
||||
log "skills: removed ${code}"
|
||||
fi
|
||||
move_once "${HOME}/.local/state/${LEGACY}" "${STATE}"
|
||||
move_once "${HOME}/.local/state/${LEGACY}-approvals" "${APPROVALS}"
|
||||
move_once "${HOME}/.config/${LEGACY}" "${APPROVERS}"
|
||||
if command -v claude &>/dev/null && claude mcp get "${LEGACY}" &>/dev/null && claude mcp remove --scope user "${LEGACY}" &>/dev/null; then
|
||||
log "skills: removed the ${LEGACY} MCP server"
|
||||
fi
|
||||
if grep -qs "${marker}" "${SETTINGS}"; then
|
||||
cp -p "${SETTINGS}" "${SETTINGS}.before-${NAME}-migration"
|
||||
rewrite_hooks "${SETTINGS}" "${marker}"
|
||||
TOOK_HOOK=1
|
||||
log "skills: dropped the ${LEGACY} PermissionRequest hook (copy before: ${SETTINGS}.before-${NAME}-migration)"
|
||||
fi
|
||||
if grep -qs "${marker}" "${LOCAL_SETTINGS}"; then
|
||||
warn "skills: ${LOCAL_SETTINGS} still runs the ${LEGACY} hook; replace it by hand"
|
||||
fi
|
||||
if [[ -e "${UNITS}/${unit}" ]]; then
|
||||
if has_systemd; then
|
||||
if [[ "$(systemctl --user is-enabled "${unit}" 2>/dev/null || true)" == "enabled" ]]; then TOOK_RUNNER=1; fi
|
||||
systemctl --user disable --now "${unit}" &>/dev/null || true
|
||||
fi
|
||||
rm -f "${UNITS}/${unit}" "${UNITS}/default.target.wants/${unit}"
|
||||
if has_systemd; then systemctl --user daemon-reload; fi
|
||||
log "skills: removed the ${unit} user service"
|
||||
fi
|
||||
}
|
||||
|
||||
install_hub() {
|
||||
command -v docker &>/dev/null || {
|
||||
warn "skills: docker not found; skipped the Agent Orchestrator."
|
||||
return 0
|
||||
}
|
||||
local port
|
||||
port="$(port_of "${REPO_ROOT}/orchestrator/.env")"
|
||||
log "skills: starting the Agent Orchestrator 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}/orchestrator/." "${HUB}/"
|
||||
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py"
|
||||
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py"
|
||||
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; until then every executor refuses"
|
||||
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_REPOS=%s\nKVB_ORCHESTRATOR_WORKTREES=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_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 Agent Orchestrator stays down until 'make orchestrator-install' succeeds."
|
||||
fi
|
||||
if ! command -v claude &>/dev/null; then
|
||||
warn "skills: claude not found; skipped registering the ${NAME} MCP server."
|
||||
return 0
|
||||
fi
|
||||
claude mcp remove --scope user "${NAME}" &>/dev/null || true
|
||||
if claude mcp add --transport http --scope user "${NAME}" "http://127.0.0.1:${port}/mcp"; then
|
||||
log "skills: registered the ${NAME} MCP server for Claude Code at user scope"
|
||||
else
|
||||
warn "skills: registering the ${NAME} MCP server for Claude Code failed."
|
||||
fi
|
||||
}
|
||||
|
||||
install_all() {
|
||||
migrate || return 0
|
||||
install_hub
|
||||
if ((TOOK_RUNNER)); then runner || true; fi
|
||||
if ((TOOK_HOOK)); then
|
||||
if [[ -f "${HUB}/client/permission_hook.py" ]]; then hook; else warn "skills: run 'make orchestrator-hook' once the Agent Orchestrator is installed"; fi
|
||||
fi
|
||||
}
|
||||
|
||||
runner() {
|
||||
if ! has_systemd; then
|
||||
warn "skills: no systemd user session; the 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 runner service runs the operator commands approved in the Agent Orchestrator ($(systemctl --user is-active "${RUNNER}"))"
|
||||
}
|
||||
|
||||
check_keys() {
|
||||
local file="${APPROVERS}/approvers.json"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
todo "no pinned passkey: settings, 'Passkey registrieren', then make orchestrator-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 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 "${NAME}/client/permission_hook.py" "${SETTINGS}" "${LOCAL_SETTINGS}"; then
|
||||
ok "PermissionRequest hook registered"
|
||||
else
|
||||
todo "PermissionRequest hook missing: make orchestrator-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 orchestrator-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 orchestrator-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-${NAME}-hook"
|
||||
rewrite_hooks "${SETTINGS}" "${NAME}/client/permission_hook.py" "${command}" "$((HOOK_TIMEOUT + 10))"
|
||||
log "skills: registered the PermissionRequest hook in ${SETTINGS} (copy before: ${SETTINGS}.before-${NAME}-hook); restart Claude Code to load it"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) install_all ;;
|
||||
migrate) migrate ;;
|
||||
check) check ;;
|
||||
pin) pin "${2:-}" ;;
|
||||
runner) runner ;;
|
||||
hook) hook ;;
|
||||
*)
|
||||
warn "usage: orchestrator.sh install | migrate | check | pin <blob> | runner | hook"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in new issue
Block a user