2026-11-06 22:20:06 +00:00
#!/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 } /"
2026-11-06 22:40:46 +00:00
# Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host
find " ${ HUB } " -name __pycache__ -type d -prune -exec rm -rf {} +
2026-11-06 22:20:06 +00:00
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