feat(orchestrator): hierarchy view from council offices with a separate agent order

The Agents views gain a Hierarchie view: an org chart of the chair and
the line offices read from the council claims. The operator appoints,
hands over and recalls an office there; the page signs each change with
the passkey and a host unit (client/offices.py, offices path unit)
verifies the signature, removes a live holder itself and runs a plain
council.py claim for the successor. council.py loses the --operator
flag, so no agent can override a live holder.

The agent order is now separate from task priority: rasci.py agents
order stores it in agent-order.json beside the register through the
nofollow helpers and never writes a --prio pin. It only breaks ties of
equal task rank, also among the queue grants. The Hierarchie view edits
it as a drag list.

Host units run the installed skill scripts, so orchestrator.sh keeps the
offices unit off while the installed council.py, nofollow.py or rasci.py
differ from the tree. Job files, council files and hub records are read
with O_NOFOLLOW and O_NONBLOCK and size caps; nofollow.rewrite replaces
a claim without creating directories, so a beat never revives a released
office.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 79 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 17:46:39 +00:00
1 parent 8e180be256
commit 4540494c30
53 files changed
+1821 -211

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Args:
--port: port of the Agent Orchestrator; the signature's origin is
http://localhost:<port>.
--jobs: the directory the hub writes signed office orders into, e.g.
~/.local/state/kvb-orchestrator/offices/jobs.
--results: the directory each job's result goes to as <id>.json.
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: this runner's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-orchestrator-approvals/offices.json.
--council: the council directory every agent shares, e.g.
/tmp/agents-council.
--script: the installed council.py of the kvb-org-organize skill.
Returns:
Runs once over every job in --jobs, as the operator on the host: per item
it verifies the operator's passkey signature over action, office, holder,
session and work, refuses a council directory others can write and an
office path holding a link, and checks that the office still has the
signed holder. A takeover or recall then moves the office aside, reads its
claim there and removes it only while that claim names the signed holder,
else it puts the office back; an appoint or takeover ends in a plain
council.py claim for the successor, run as an argument list, never through
a shell: a live Claude Code successor goes in as CLAUDE_CODE_SESSION_ID,
any other as --session. Writes each item's result and removes the job.
The exit code is 0.
"""
from __future__ import annotations
import argparse
import errno
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import time
from pathlib import Path
import signing
import verifier
JOB = re.compile(r"[0-9a-f]{16}\.json")
SAFE_PATH = "/usr/local/bin:/usr/bin:/bin"
CLAIM = "claim.json"
CLAIM_SHAPE = (("session", str), ("runtime", str), ("beat", (int, float)))
DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
FILE = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
READ_LIMIT = 1 << 20
OUTPUT_LIMIT = 4000
TIMEOUT = 120
def read_regular(name: str | Path, dir_fd: int | None) -> bytes:
"""Args:
name: the file, relative to dir_fd or a path; a link, FIFO or other
non-regular file raises OSError, so does one over READ_LIMIT bytes.
dir_fd: the open directory name is relative to, or None.
Returns:
The file's bytes.
"""
fd = os.open(name, FILE, dir_fd=dir_fd)
with open(fd, "rb") as handle:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise OSError(errno.EINVAL, "not a regular file", str(name))
data = handle.read(READ_LIMIT + 1)
if len(data) > READ_LIMIT:
raise OSError(errno.EFBIG, f"larger than {READ_LIMIT} bytes", str(name))
return data
def claim_in(office_fd: int) -> dict | None:
"""Returns:
The claim of the open office directory in the shape council.py
reads, or None.
"""
try:
claim = json.loads(read_regular(CLAIM, office_fd))
except (OSError, ValueError, RecursionError):
return None
shaped = isinstance(claim, dict) and all(isinstance(claim.get(key), kind) for key, kind in CLAIM_SHAPE)
return claim if shaped else None
def parent_of(council: Path, office: str) -> tuple[int, str]:
"""Returns:
The open directory holding the office, without following a link on
the way, and the office's last name; the caller closes it.
"""
*dirs, name = office.split("/")
fd = os.open(council, DIRECTORY)
try:
for part in dirs:
child = os.open(part, DIRECTORY, dir_fd=fd)
os.close(fd)
fd = child
except BaseException:
os.close(fd)
raise
return fd, name
def unsafe(council: Path, office: str) -> str | None:
"""Returns:
Why the council directory or the office's path below it must not be
used, or None.
"""
try:
root = council.lstat()
except OSError as error:
return f"the council directory is unreadable: {error.strerror}"
if not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o022:
return f"{council} is no directory of uid {os.getuid()}, or others can write it"
path = council
for part in office.split("/"):
path = path / part
try:
found = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(found.st_mode):
return f"{path} is a link or no directory"
try:
found = (path / CLAIM).lstat()
except FileNotFoundError:
return None
return None if stat.S_ISREG(found.st_mode) else f"{path / CLAIM} is a link or no file"
def holder_of(council: Path, office: str) -> str | None:
"""Returns:
The session holding the office, empty while it is vacant, None while
its claim is missing, unreadable or not in the shape council.py reads.
"""
try:
parent, name = parent_of(council, office)
except FileNotFoundError:
return ""
except OSError:
return None
try:
fd = os.open(name, DIRECTORY, dir_fd=parent)
except FileNotFoundError:
return ""
except OSError:
return None
finally:
os.close(parent)
try:
claim = claim_in(fd)
finally:
os.close(fd)
return claim["session"] if claim else None
def vacate(council: Path, office: str, holder: str) -> str | None:
"""Args:
council: the council directory.
office: the office to empty.
holder: the session the operator signed as its holder.
Returns:
None once the office is gone, else why nothing changed.
"""
parent, name = parent_of(council, office)
try:
tomb = f"{name}.recalled-{os.getpid()}-{time.time_ns()}"
try:
os.rename(name, tomb, src_dir_fd=parent, dst_dir_fd=parent)
except FileNotFoundError:
return f"the holder changed since the signature: signed {holder}, now vacant; nothing changed"
try:
fd = os.open(tomb, DIRECTORY, dir_fd=parent)
except OSError:
claim = None
else:
try:
claim = claim_in(fd)
finally:
os.close(fd)
if claim is not None and claim["session"] == holder:
shutil.rmtree(tomb, dir_fd=parent)
return None
now_held = "unreadable" if claim is None else claim["session"]
try:
os.lstat(name, dir_fd=parent)
except FileNotFoundError:
# Python has no RENAME_NOREPLACE: rename(2) replaces an empty office a claimant made after the lstat.
os.rename(tomb, name, src_dir_fd=parent, dst_dir_fd=parent)
return f"the holder changed since the signature: signed {holder}, now {now_held}; nothing changed"
kept = council.joinpath(*office.split("/")[:-1], tomb)
return f"the holder changed since the signature: signed {holder}, now {now_held}; a new claim arrived meanwhile, the old office waits at {kept}"
finally:
os.close(parent)
def live(home: Path, session: str) -> bool:
for path in (home / ".claude" / "sessions").glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if isinstance(data, dict) and data.get("sessionId") == session:
return True
return False
class Offices:
def __init__(self, trust: verifier.Trust, council: Path, script: Path, home: Path) -> None:
self.trust = trust
self.council = council
self.script = script
self.home = home
def command(self, item: dict) -> tuple[list[str], dict[str, str]]:
argv = [sys.executable, "-I", "-B", str(self.script), "claim", item["office"], f"--work={item['work']}"]
env = {"PATH": SAFE_PATH, "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.council)}
if live(self.home, item["session"]):
return argv, {**env, "CLAUDE_CODE_SESSION_ID": item["session"]}
return [*argv, f"--session={item['session']}"], env
def item(self, item: dict, proof: object, now: float) -> dict:
"""Args:
item: one well-formed office order.
proof: the job's {"payload", "assertion"}.
now: the current time in epoch seconds.
Returns:
The order with status done, failed or refused, the reason, the
exit code and the output of council.py; a refused order changed
nothing.
"""
def ended(status: str, reason: str, code: int | None, output: str) -> dict:
return {**item, "status": status, "reason": reason, "code": code, "output": output}
try:
verifier.check(proof, signing.office_item(**item), "", self.trust, now)
except (signing.Refused, OSError) as error:
return ended("refused", f"no valid operator signature: {error}", None, "")
reason = unsafe(self.council, item["office"])
if reason:
return ended("refused", reason, None, "")
current = holder_of(self.council, item["office"])
if current != item["holder"]:
now_held = "unreadable" if current is None else current or "vacant"
return ended("refused", f"the holder changed since the signature: signed {item['holder'] or 'vacant'}, now {now_held}", None, "")
if item["action"] != "appoint":
try:
reason = vacate(self.council, item["office"], item["holder"])
except OSError as error:
return ended("failed", f"the office could not be emptied: {error.strerror or error}; nothing changed", None, "")
if reason:
return ended("refused", reason, None, "")
if item["action"] == "recall":
return ended("done", "", None, f"recalled {item['office']} from {item['holder']}")
argv, env = self.command(item)
try:
done = subprocess.run(argv, cwd="/", env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
return ended("failed", f"{error}; the office is vacant now", 127, "")
output = (done.stdout + done.stderr)[-OUTPUT_LIMIT:]
if done.returncode == 0:
return ended("done", "", 0, output)
vacant = "; the office is vacant now" if item["action"] == "takeover" else ""
return ended("failed", f"{output.strip()[-500:]}{vacant}", done.returncode, output)
def job(self, path: Path, now: float) -> dict:
try:
job = json.loads(read_regular(path, None))
except (OSError, ValueError, RecursionError) as error:
return {"id": path.stem, "finished": now, "items": [], "error": f"unreadable job: {error}"}
items = job.get("items") if isinstance(job, dict) else None
if not isinstance(items, list) or not items or any(signing.office_problem(item) for item in items) or job.get("id") != path.stem:
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing changed"}
results = [self.item(item, job.get("proof"), now) for item in items]
return {"id": path.stem, "finished": time.time(), "items": results}
def cycle(jobs: Path, results: Path, offices: Offices) -> int:
handled = 0
for path in sorted(jobs.iterdir()):
if JOB.fullmatch(path.name):
verifier.write_private(results / path.name, offices.job(path, time.time()))
handled += 1
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink(missing_ok=True)
return handled
def main() -> int:
parser = argparse.ArgumentParser(prog="offices")
parser.add_argument("--port", type=int, required=True)
for name in ("--jobs", "--results", "--approvers", "--replay", "--council", "--script"):
parser.add_argument(name, type=Path, required=True)
args = parser.parse_args()
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
args.results.mkdir(mode=0o700, parents=True, exist_ok=True)
cycle(args.jobs, args.results, Offices(trust, args.council, args.script, Path.home()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -16,6 +16,12 @@ HASH = re.compile(r"[0-9a-f]{64}")
NONCE = re.compile(r"[0-9a-f]{32}")
ID = re.compile(r"[0-9a-f]{16}")
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
OFFICE = re.compile(r"chair|[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}")
SESSION = re.compile(r"(?!-)[A-Za-z0-9_-]{1,128}")
WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}")
HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}")
OFFICE_ACTIONS = ("appoint", "takeover", "recall")
OFFICE_KEYS = ("action", "office", "holder", "session", "work")
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = (
@@ -69,6 +75,38 @@ def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict:
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
def office_item(action: str, office: str, holder: str, session: str, work: str) -> dict:
return {"kind": "office", "action": action, "office": office, "holder": holder, "session": session, "work": work}
def office_problem(order: object) -> str | None:
"""Args:
order: {"action", "office", "holder", "session", "work"}: appoint
gives a vacant office to the session, takeover hands the
holder's office to the session, recall takes it from the
holder; work is the claim's one-line --work.
Returns:
Why the order is malformed, or None.
"""
if not isinstance(order, dict) or set(order) != set(OFFICE_KEYS) or not all(isinstance(order[name], str) for name in OFFICE_KEYS):
return "an office order holds exactly action, office, holder, session and work as text"
action, office, holder, session, work = (order[name] for name in OFFICE_KEYS)
if action not in OFFICE_ACTIONS:
return f"action must be one of {', '.join(OFFICE_ACTIONS)}"
if not OFFICE.fullmatch(office):
return "office must be chair, <dimension>/<line>, cluster/<slug> or work/<slug>"
if not HOLDER.fullmatch(holder) or (action == "appoint") != (holder == ""):
return "appoint names no holder, takeover and recall the current one"
if action == "recall":
return None if session == work == "" else "a recall names no successor and no work"
if not SESSION.fullmatch(session) or session == holder:
return "session must be the successor's session id, not the holder's"
if not WORK.fullmatch(work):
return "work must be one line of 1 to 300 characters"
return None
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
+2
View File
@@ -52,6 +52,8 @@ services:
- /app/vendor
- --cleanup
- /state/cleanup
- --offices
- /state/offices
- --llm-url
- "${KVB_ORCHESTRATOR_LLM_URL}"
- --llm-model
+7 -1
View File
@@ -7,6 +7,7 @@ from .asking import ask_state
from .cleanup import cleanup_request
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field
from .offices import office_request
from .records import digest, shown
from .register import REGISTER, operator_rows, read_register, runnable
from .rules import rule_matches
@@ -144,7 +145,12 @@ class Approving:
payload = decode(body)
expect(payload, ("items",))
raw = payload["items"]
signed = self.deletable(raw) if cleanup_request(raw) else [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
if cleanup_request(raw):
signed = self.deletable(raw)
elif office_request(raw):
signed = self.appointable(raw)
else:
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
hashes = [signing.item_hash(item) for item in signed]
credentials = [key["id"] for key in self.keys()]
if not hashes:
+37 -4
View File
@@ -18,6 +18,10 @@ MODES = ("evening", "sunset")
RADIO_LIMIT = 5000
GRANT_LIMIT = 50
MAYDAY_ACTIVE = 7200
CHAIR_OFFICE = "chair"
GROUPS = ("work", "cluster")
CLAIM_FILES = ("*/claim.json", "*/*/claim.json")
OFFICE = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)?")
def agent(name: object) -> str:
@@ -74,10 +78,33 @@ def radio_rows(rows: list[dict], begin: float, end: float) -> list[dict]:
return sorted(found, key=lambda row: row["ts"])[-RADIO_LIMIT:]
def claim_row(data: dict) -> dict:
def office_kind(office: str) -> tuple[str, str, str]:
"""Args:
office: an office of one or two segments.
Returns:
(kind, dimension, line): kind is chair, line for <dimension>/<line>,
work or cluster for their groups, other for any other single
segment; dimension and line are empty unless the kind is line.
"""
head, _, rest = office.partition("/")
if office == CHAIR_OFFICE:
return "chair", "", ""
if not rest:
return "other", "", ""
if head in GROUPS:
return head, "", ""
return "line", head, rest
def claim_row(office: str, data: dict) -> dict:
name = data.get("name")
kind, dimension, line = office_kind(office)
return {
"office": shown(data.get("office")),
"office": office,
"kind": kind,
"dimension": dimension,
"line": line,
"agent": agent(name),
"address": name.strip() if isinstance(name, str) else "",
"session": shown(data.get("session")),
@@ -89,8 +116,14 @@ def claim_row(data: dict) -> dict:
def read_claims(council: Path) -> list[dict]:
paths = sorted([*council.glob("cluster/*/claim.json"), *council.glob("work/*/claim.json")])
return [claim_row(data) for data in map(read_json, paths) if isinstance(data, dict)]
found = []
for pattern in CLAIM_FILES:
for path in council.glob(pattern):
office = path.parent.relative_to(council).as_posix()
data = read_json(path)
if OFFICE.fullmatch(office) and isinstance(data, dict):
found.append(claim_row(office, data))
return sorted(found, key=lambda row: row["office"])
def sessions(claims: list[dict]) -> dict[str, str]:
+19 -7
View File
@@ -2,13 +2,23 @@ from __future__ import annotations
from collections import Counter
from .council import HUMAN, agent, ordered, subagent
from .council import CHAIR_OFFICE, HUMAN, agent, ordered, subagent
from .names import Directory
from .orbits import affinity
from .records import shown
from .register import ENDED, parties, status_of
CHAIR = "Maintainer"
def chair_of(claims: list[dict], directory: Directory, end: float) -> str:
"""Returns:
The node key of the chair office's holder, empty while it is vacant.
"""
claim = next((claim for claim in claims if claim["office"] == CHAIR_OFFICE), None)
if claim is None:
return ""
if claim["session"] != "-":
return claim["session"]
return directory.key(claim["agent"], end)
def parent_of(roles: dict[str, str], known: set[str], fallback: str) -> str:
@@ -36,13 +46,14 @@ def edges(radio: list[dict], grant_rows: list[dict], chair: str) -> list[dict]:
def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, directory: Directory) -> dict:
current = {task: row for task, row in sorted(tasks.items()) if status_of(row) not in ENDED}
chair = directory.key(CHAIR, end)
chair = chair_of(claims, directory, end)
top = chair or HUMAN
workers = {task: agent(row.get("worker")) for task, row in current.items()}
keys = {row["source"] for row in radio} | {key for row in radio for key in row["targets"]}
keys |= {claim["session"] for claim in claims if claim["agent"] and claim["session"] != "-"}
keys |= {directory.key(name, end) for name in workers.values() if not subagent(name)}
found = sorted(keys - {"", HUMAN, chair}, key=lambda key: (directory.label(key, end).lower(), key))
known = {chair, *found}
known = {chair, *found} - {""}
moons: dict[str, tuple[str, str]] = {}
holders: dict[str, dict[str, dict]] = {}
for task, row in current.items():
@@ -53,14 +64,15 @@ def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], cla
worker = workers[task]
holder = directory.key(worker, end)
if subagent(worker):
parent = parent_of(roles, known, chair)
parent = parent_of(roles, known, top)
holder = f"{worker} @ {parent}"
moons[holder] = (parent, worker)
if holder:
duties(holder, task, row, holders)["worker"] = True
for key, letters in roles.items():
duties(key, task, row, holders)["letters"] += letters
plan = [(HUMAN, "operator", None, HUMAN), (chair, "chair", None, directory.label(chair, end))]
plan = [(HUMAN, "operator", None, HUMAN)]
plan += [(chair, "chair", None, directory.label(chair, end))] if chair else []
plan += [(key, "session", None, directory.label(key, end)) for key in found]
plan += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))]
nodes = [
@@ -77,4 +89,4 @@ def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], cla
for key, node_kind, parent, label in plan
]
layout = affinity(nodes, radio, tasks, claims, end, lambda name: directory.key(name, end))
return {"nodes": nodes, "edges": edges(radio, grant_rows, chair), "layout": layout}
return {"nodes": nodes, "edges": edges(radio, grant_rows, top), "layout": layout}
+8 -1
View File
@@ -36,10 +36,12 @@ SIGN_ROUTE = "/api/sign"
RULE_SIGN_ROUTE = "/api/rules/sign"
CLEANUP_ROUTE = "/api/cleanup"
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
OFFICES_ROUTE = "/api/offices"
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
SCRIPT_TYPE = "text/javascript; charset=utf-8"
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE)
class Handler(Exchange):
@@ -59,6 +61,7 @@ class Handler(Exchange):
proof = PROOF_ROUTE.fullmatch(path)
vendored = VENDOR_ROUTE.fullmatch(path)
cleanup = CLEANUP_STATE.fullmatch(path)
office = OFFICE_STATE.fullmatch(path)
if self.host() is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
elif path == "/":
@@ -67,6 +70,8 @@ class Handler(Exchange):
self.vendored(VENDOR[vendored.group(1)])
elif cleanup:
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
elif office:
self.answer(lambda: hub.office_state(office.group(1)), HTTPStatus.OK)
elif proof:
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
elif path == "/api/state":
@@ -125,6 +130,8 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
if path == CLEANUP_ROUTE:
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
if path == OFFICES_ROUTE:
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
if inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo:
+5 -1
View File
@@ -22,6 +22,7 @@ from .ledger import Ledger, Marks, Reviews
from .mcp import Mcp
from .messaging import Messaging
from .names import Names
from .offices import Offices
from .reviewing import Reviewing
from .rules import Rules
from .settings import Settings
@@ -40,6 +41,7 @@ class Places:
timers: Path
vendor: Path
cleanup: Path
offices: Path
def environment(templates: Path) -> jinja2.Environment:
@@ -55,7 +57,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
@@ -84,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules,
vendor: Path
icons: Markup
spool: Spool
office_spool: Spool
touched: Touched
def hosts(self) -> tuple[str, str]:
@@ -101,6 +104,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
vendor=places.vendor,
icons=sheet(places.vendor, templates),
spool=Spool(places.cleanup).ready(),
office_spool=Spool(places.offices).ready(),
touched=Touched(reviews.stored),
approvers=approvers,
speech=Speech(engines, state / "tts"),
+2 -2
View File
@@ -11,7 +11,7 @@ from .errors import ToolError
from .events import VIEWS
from .fields import bounded, decode, expect, message_text
from .records import read_jsonl
from .register import REGISTER, priority, read_register
from .register import REGISTER, agent_order, read_register
NL_LIMIT = 2000
NL_ACTION_LIMIT = 20
@@ -183,6 +183,6 @@ class Language:
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
self.watch.see(tasks, now)
items = self.inbox(now, tasks, directory)
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(tasks)))
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(agent_order(tasks, [], [])))
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
+6 -1
View File
@@ -8,6 +8,7 @@ from .council import HUMAN, ordered, read_claims
from .errors import Missing, ToolError
from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
from .records import write_atomic
from .register import REGISTER, agent_order, read_register
from .skill_scripts import NIGHT, RADIO, RASCI
NIGHTS = {
@@ -111,7 +112,11 @@ class Messaging:
raise ToolError("agents must be a non-empty list of agent names without commas")
if len(set(names)) != len(names):
raise ToolError("agents must be distinct")
runs = [self.scripts.run(RASCI, ["order", ",".join(names)])]
known = agent_order(read_register(self.council / REGISTER), read_claims(self.council), [])
unknown = [name for name in names if name not in known]
if unknown:
raise ToolError(f"unknown agents: {', '.join(unknown)[:300]}; the register and the council know {', '.join(known)[:600] or 'none'}")
runs = [self.scripts.run(RASCI, ["agents", "order", ",".join(names)])]
elif action in NIGHTS:
command, text = NIGHTS[action]
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import secrets
import time
from .council import HUMAN, office_kind, read_claims
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect
from .records import read_json, stamp
from .signed import signing
ITEM_LIMIT = 20
JOB_VERSION = 1
LABELS = {"appoint": "besetzt", "takeover": "übergeben", "recall": "abberufen"}
def office_request(raw: object) -> bool:
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == "office" for entry in raw)
class Offices:
def office_sessions(self, claims: list[dict]) -> set[str]:
named = {entry["session"] for entry in self.names.listed()} | {claim["session"] for claim in claims}
return {session for session in named if signing.SESSION.fullmatch(session) and session != HUMAN}
def appointable(self, raw: object) -> list[dict]:
"""Args:
raw: the orders the page chose, each {"kind": "office", "action",
"office", "holder", "session", "work"}.
Returns:
The signed items: each order well formed, its holder the office's
current one, its successor a known session that represents no
other line of the same dimension.
"""
if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT:
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
claims = read_claims(self.council)
holders = {claim["office"]: claim["session"] for claim in claims}
sessions = self.office_sessions(claims)
chosen = []
for entry in raw:
if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind, action, office, holder, session and work")
expect(entry, ("kind", *signing.OFFICE_KEYS))
if entry["kind"] != "office":
raise ToolError("kind must be office")
order = {name: entry[name] for name in signing.OFFICE_KEYS}
problem = signing.office_problem(order)
if problem:
raise ToolError(problem)
if order["holder"] != holders.get(order["office"], ""):
raise Conflict({"office": order["office"], "holder": holders.get(order["office"], "")})
if order["action"] != "recall":
self.successor(order, sessions, claims)
item = signing.office_item(**order)
if item in chosen:
raise ToolError("items must be distinct")
chosen.append(item)
return chosen
def successor(self, order: dict, sessions: set[str], claims: list[dict]) -> None:
if order["session"] not in sessions:
raise ToolError("session must be a session the Agent Orchestrator knows")
kind, dimension, _ = office_kind(order["office"])
others = [claim["office"] for claim in claims if claim["session"] == order["session"] and claim["dimension"] == dimension and claim["office"] != order["office"]]
if kind == "line" and others:
raise ToolError(f"{order['session']} represents {others[0]} already: one line per dimension")
def appoint(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items", "proof"))
items = self.appointable(payload["items"])
proof = self.covered(payload["proof"], items, time.time())
job_id = secrets.token_hex(8)
job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{name: item[name] for name in signing.OFFICE_KEYS} for item in items], "proof": proof}
self.office_spool.put(job_id, job)
names = ", ".join(f"{item['office']} {LABELS[item['action']]}" for item in items)
lines = "\n".join(f"{item['action']} {item['office']}: {item['holder'] or '-'} -> {item['session'] or '-'}" for item in items)
self.log.add({"kind": "run", "title": f"Amt signiert: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}})
return {"id": job_id, "status": "queued", "items": job["items"]}
def office_state(self, job_id: str) -> dict:
result = read_json(self.office_spool.results / f"{job_id}.json")
if isinstance(result, dict) and isinstance(result.get("items"), list):
return {**result, "id": job_id, "status": "done"}
if (self.office_spool.jobs / f"{job_id}.json").is_file():
return {"id": job_id, "status": "queued", "items": []}
raise Missing(job_id)
+25 -2
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
import datetime
import errno
import hashlib
import json
import os
import stat
import tempfile
from pathlib import Path
READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
JSON_LIMIT = 1 << 24
def stamp() -> str:
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
@@ -20,9 +25,27 @@ def write_atomic(path: Path, data: bytes) -> None:
os.replace(handle.name, path)
def read_regular(path: Path, limit: int | None) -> str:
"""Args:
path: the file; a link, FIFO or other non-regular file raises OSError
without blocking.
limit: the most bytes to accept, more raise OSError; None for any size.
Returns:
The file's text, UTF-8 decoded.
"""
with open(os.open(path, READ_FLAGS), "rb") as handle:
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
raise OSError(errno.EINVAL, "not a regular file", str(path))
data = handle.read() if limit is None else handle.read(limit + 1)
if limit is not None and len(data) > limit:
raise OSError(errno.EFBIG, f"larger than {limit} bytes", str(path))
return data.decode("utf-8")
def read_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(read_regular(path, JSON_LIMIT))
except (OSError, ValueError, RecursionError):
return None
@@ -37,7 +60,7 @@ def parse_line(line: str) -> dict | None:
def read_jsonl(path: Path) -> list[dict]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
lines = read_regular(path, None).splitlines()
except (OSError, ValueError):
return []
return [row for row in map(parse_line, lines) if row is not None]
+26 -4
View File
@@ -2,11 +2,12 @@ from __future__ import annotations
from pathlib import Path
from .council import agent
from .council import agent, ordered
from .fields import ORDER_NAME
from .records import read_json, shown
REGISTER = "rasci.json"
AGENT_ORDER = "agent-order.json"
OPERATOR = "operator/"
ENDED = ("done", "stopped")
@@ -98,10 +99,31 @@ def register_rows(tasks: dict[str, dict]) -> list[dict]:
return [register_row(task, row) for task, row in sorted(tasks.items(), key=pinned) if is_open(task, row, False)]
def priority(tasks: dict[str, dict]) -> list[str]:
def stored_order(path: Path) -> list[str]:
data = read_json(path)
names = data.get("agents") if isinstance(data, dict) else None
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
return []
return names
def agent_order(tasks: dict[str, dict], claims: list[dict], stored: list[str]) -> list[str]:
"""Args:
tasks: the register.
claims: every office claim; its holder is an agent too.
stored: the operator's agent order, as `rasci.py agents order` wrote it.
Returns:
The agents `rasci.py agents order` accepts: the stored ones it still
knows first, in their order, then the workers of open tasks by pin,
then the remaining office holders by name.
"""
names: list[str] = []
for _, row in sorted(tasks.items(), key=pinned):
worker = row.get("worker")
if status_of(row) not in ENDED and isinstance(worker, str) and ORDER_NAME.fullmatch(worker) and worker not in names:
if status_of(row) not in ENDED and isinstance(worker, str) and worker not in names:
names.append(worker)
return names
names += [name for name in ordered({claim["address"] or claim["session"] for claim in claims}) if name not in names]
known = [name for name in names if ORDER_NAME.fullmatch(name)]
listed = [name for name in dict.fromkeys(stored) if name in known]
return [*listed, *(name for name in known if name not in listed)]
+10 -2
View File
@@ -22,7 +22,15 @@ from .messaging import message_row
from .orbits import TRACK_STEPS, track
from .phrasing import phrase
from .records import epoch, iso_epoch, read_jsonl
from .register import REGISTER, operator_rows, priority, read_register, register_rows
from .register import (
AGENT_ORDER,
REGISTER,
agent_order,
operator_rows,
read_register,
register_rows,
stored_order,
)
from .reviewing import review_row
from .settings import media_origins
from .skill_scripts import OUTPUT_LIMIT
@@ -117,7 +125,7 @@ class View:
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
"operator": operator_rows(tasks),
"register": register_rows(tasks),
"priority": priority(tasks),
"agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)),
"radio": radio,
"claims": claims,
"grants": grant_rows,
+6 -2
View File
@@ -27,6 +27,10 @@
--cleanup: host-mounted directory the hub writes signed branch deletions
into (jobs/) and reads their results from (results/); the host's
kvb-orchestrator-cleanup path unit runs them.
--offices: host-mounted directory the hub writes signed office orders
(appoint, take over, recall) into (jobs/) and reads their results
from (results/); the host's kvb-orchestrator-offices path unit runs
council.py for them.
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
natural-language command line off.
--llm-model: model name sent to that endpoint; empty turns it off.
@@ -66,7 +70,7 @@ def main() -> int:
parser.add_argument("--bind", required=True)
for name in ("--listen-port", "--public-port"):
parser.add_argument(name, type=int, required=True)
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--llm-key-file", "--approvers")
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--offices", "--llm-key-file", "--approvers")
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
parser.add_argument(name, type=Path, required=True)
for name in ("--llm-url", "--llm-model", "--operator-voice"):
@@ -82,7 +86,7 @@ def main() -> int:
transcripts=args.transcripts,
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
templates=args.templates,
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup),
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup, offices=args.offices),
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
approvers=args.approvers,
@@ -0,0 +1,10 @@
[Unit]
Description=Agent Orchestrator offices: wake on a signed office order the Agent Orchestrator wrote
[Path]
DirectoryNotEmpty=%h/.local/state/kvb-orchestrator/offices/jobs
MakeDirectory=true
DirectoryMode=0700
[Install]
WantedBy=default.target
@@ -0,0 +1,9 @@
[Unit]
Description=Agent Orchestrator offices: verify the operator signature, then appoint, hand over or recall exactly the signed office through council.py
[Service]
Type=oneshot
Environment=PATH=/usr/bin:/bin
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/offices.py --port ${KVB_ORCHESTRATOR_PORT} --jobs %h/.local/state/kvb-orchestrator/offices/jobs --results %h/.local/state/kvb-orchestrator/offices/results --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/offices.json --council ${KVB_ORCHESTRATOR_COUNCIL} --script ${KVB_ORCHESTRATOR_SKILLS}/kvb-org-organize/scripts/council.py
TimeoutStartSec=600
+7
View File
@@ -402,6 +402,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
.graph3d .scene-container { position: relative; }
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
{% include "style-hierarchy.css" %}
</style>
</head>
<body>
@@ -485,6 +486,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-spawn.js" %}
{% include "page-hierarchy.js" %}
{% include "page-agent-order.js" %}
{% include "page-graph3d.js" %}
{% include "page-artifacts.js" %}
@@ -493,6 +498,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-cleanup.js" %}
{% include "page-offices.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+7 -5
View File
@@ -3,12 +3,14 @@ const Nav = (() => {
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 OWN = {agents: [["hierarchy", "Hierarchie"]]};
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const known = (view) => VIEWS.some(([id]) => id === view);
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
function tabOf(screen) {
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
@@ -22,19 +24,19 @@ const Nav = (() => {
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
if (Object.hasOwn(OLD, head)) {
const [screen, fallback] = OLD[head];
const view = known(sub) ? sub : fallback;
const view = known(screen, 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: head, view: known(head, sub) ? sub : null, redirect: null};
}
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+102
View File
@@ -0,0 +1,102 @@
const orderDom = {box: null, dragged: null, focus: null};
function openAgentOrder() {
go("agents", "hierarchy");
window.setTimeout(() => {
const title = document.getElementById("agent-order-title");
if (title) {
title.scrollIntoView({block: "nearest"});
title.focus();
}
}, 50);
}
function syncOrder() {
const stored = state.agent_order;
if (ui.order === null || ui.order.length !== stored.length || ui.order.some((name) => !stored.includes(name))) {
ui.order = [...stored];
}
}
function placeAgent(next, focus) {
ui.order = next;
orderDom.focus = focus;
drawAgentOrder();
}
function moveAgent(name, step) {
const from = ui.order.indexOf(name);
const to = from + step;
if (from < 0 || to < 0 || to >= ui.order.length) {
return;
}
const next = ui.order.filter((entry) => entry !== name);
next.splice(to, 0, name);
placeAgent(next, `${step < 0 ? "up" : "down"}:${name}`);
}
function dropAgent(name) {
const dragged = orderDom.dragged;
orderDom.dragged = null;
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);
placeAgent(next, null);
}
function orderItem(name, index) {
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
up.disabled = index === 0;
down.disabled = index === ui.order.length - 1;
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
item.addEventListener("dragstart", (event) => {
orderDom.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();
dropAgent(name);
});
return item;
}
async function saveAgentOrder() {
if (await act("order", "Priorität", {agents: ui.order})) {
ui.order = null;
drawAgentOrder();
}
}
function drawAgentOrder() {
const box = orderDom.box;
if (!box || !box.isConnected) {
orderDom.box = null;
return;
}
syncOrder();
const key = orderDom.focus || focusKey(box);
orderDom.focus = null;
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
box.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
h("div", {class: "actions"},
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
if (target && target.disabled) {
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
} else {
refocus(box, key);
}
}
+1 -1
View File
@@ -135,6 +135,6 @@
}
function agentNames() {
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
return [...names].sort((a, b) => a.localeCompare(b));
}
+1 -52
View File
@@ -64,7 +64,7 @@
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), 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, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
}
function drawToolbar() {
@@ -75,54 +75,3 @@
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);
}
+16 -10
View File
@@ -47,30 +47,36 @@
return false;
}
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
followCleanup(result.data.id, outcome, Date.now());
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
fetchTree();
});
return true;
}
async function followCleanup(id, outcome, began) {
async function followJob(url, outcome, began, finish) {
let data = null;
try {
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
const response = await fetch(url, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
if (!data || data.status !== "done") {
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
window.setTimeout(() => followJob(url, outcome, began, finish), CLEANUP_POLL);
}
return;
}
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
finish(data);
}
function showJob(data, outcome, key, labels) {
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
const [label, tone] = labels[item.status] || [item.status, "neutral"];
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
})));
const good = !data.error && data.items.every((item) => item.status === "done");
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
fetchTree();
return {good: !data.error && data.items.every((item) => item.status === "done"), lines};
}
-1
View File
@@ -2,7 +2,6 @@
const NONCE = document.currentScript ? document.currentScript.nonce : "";
const ACTIVE_BEAT = 900;
const DAY = 86400;
const CHAIR = "Maintainer";
const HUMAN = "Operator";
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
+1 -1
View File
@@ -81,7 +81,7 @@
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: HUMAN}).id;
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
function toast(title, lines, good) {
+2 -2
View File
@@ -180,7 +180,7 @@
}
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
}, icon(Nav.ICONS[id]))));
}
@@ -191,6 +191,6 @@
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
}
+128
View File
@@ -0,0 +1,128 @@
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
function hierarchyScreen() {
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
orderDom.box = h("section", {class: "card agent-order", id: "agent-order", "aria-labelledby": "agent-order-title"});
hier.sig = "";
return h("div", {class: "hierarchy-body"}, hier.chart, orderDom.box);
}
function hierarchySig() {
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
return JSON.stringify([held, state.agent_order, state.people]);
}
function drawHierarchy(force) {
if (!hier.chart || !hier.chart.isConnected) {
hier.chart = null;
return;
}
const sig = hierarchySig();
if (!force && sig === hier.sig) {
return;
}
hier.sig = sig;
drawChart();
drawAgentOrder();
}
function focusKey(box) {
const at = document.activeElement;
return at && box.contains(at) ? at.dataset.focus || null : null;
}
function refocus(box, key) {
const found = key === null ? null : [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
if (found) {
found.focus();
}
}
function officeBtn(name, label, hint, run, focus) {
const button = iconBtn(name, label, "btn small ghost", run, hint);
button.dataset.focus = focus;
return button;
}
function holderOf(claim) {
return claim.agent || (claim.session === "-" ? "" : claim.session);
}
function officeRow(claim, label, title) {
const who = holderOf(claim);
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
return h("div", {class: "org-row", "data-office": claim.office},
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
h("span", {class: "org-actions"},
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
}
function vacantRow(office, label) {
return h("div", {class: "org-row vacant", "data-office": office},
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
pill("unbesetzt", "warn"),
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
}
function groupRow(glyph, label, count, note) {
return h("div", {class: "org-row group"}, h("span", {class: "org-office"}, icon(glyph), h("strong", {}, label)), counter(count), h("span", {class: "muted"}, note));
}
function orgNode(id, label, row, children) {
if (!children.length) {
return h("li", {}, h("div", {class: "org-line"}, h("span", {class: "org-gap", "aria-hidden": "true"}), row));
}
const open = !hier.closed.has(id);
hier.ids += 1;
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
toggle.addEventListener("click", () => {
const now = list.hidden;
list.hidden = !now;
toggle.setAttribute("aria-expanded", String(now));
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
if (now) {
hier.closed.delete(id);
} else {
hier.closed.add(id);
}
});
return h("li", {"data-node": id}, h("div", {class: "org-line"}, toggle, row), list);
}
function chartTree() {
const claims = state.claims;
const dimensions = new Map();
for (const claim of claims.filter((entry) => entry.kind === "line")) {
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
}
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
const chair = claims.find((entry) => entry.kind === "chair");
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
}
function drawChart() {
const key = focusKey(hier.chart);
hier.ids = 0;
const count = state.claims.length;
hier.chart.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
chartTree());
refocus(hier.chart, key);
}
+68
View File
@@ -0,0 +1,68 @@
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
const OFFICE_VERBS = {
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
};
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
function successors(office, holder) {
const [dimension, line] = office.split("/");
const lined = Boolean(line) && dimension !== "work" && dimension !== "cluster";
return state.graph.nodes
.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id) && node.id !== holder)
.filter((node) => !lined || !state.claims.some((claim) => claim.session === node.id && claim.kind === "line" && claim.dimension === dimension && claim.office !== office))
.map((node) => [node.id, `${labelOf(node.id)} · ${node.id.slice(0, 8)}`]);
}
function officeSheet(action, claim) {
const office = claim ? claim.office : "";
const holder = claim && action !== "appoint" ? claim.session : "";
const [verb, glyph, text] = OFFICE_VERBS[action];
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
const picks = successors(office, holder);
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
const recall = action === "recall";
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
confirm.addEventListener("click", async () => {
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
confirm.disabled = true;
if (!await signOffice([item], outcome, verb)) {
confirm.disabled = false;
}
});
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
h("div", {class: "office-form"},
h("label", {}, "Amt", name),
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
recall ? null : h("label", {}, "Arbeit", work)),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
], false);
(office ? (recall ? confirm : session) : name).focus();
}
async function signOffice(items, outcome, verb) {
const signed = await proofFor(items, verb);
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return false;
}
const result = await sendJson("/api/offices", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${verb}: nicht möglich`, fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return false;
}
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
load();
});
return true;
}
+2 -2
View File
@@ -1,13 +1,13 @@
function paletteItems() {
return [
...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)}))),
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).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)})),
{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: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
{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},
+1
View File
@@ -77,6 +77,7 @@
stage();
drawSide();
drawSpawn();
drawHierarchy(force);
drawToolbar();
drawInbox();
drawStatus();
+2 -2
View File
@@ -1,5 +1,5 @@
function stage() {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree" || ui.views.agents === "hierarchy") {
return;
}
graphDom.pulses = null;
@@ -11,7 +11,7 @@
}
function drawSide() {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
graphDom.aside.replaceChildren(...side());
}
}
@@ -0,0 +1,21 @@
.hierarchy-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; align-items: start; }
@media (max-width: 960px) { .hierarchy-body { grid-template-columns: minmax(0, 1fr); } }
.org-chart .inbox-head { flex-wrap: wrap; }
.org-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 16px; list-style: none; border-left: 1px solid var(--line); margin-left: 14px; }
.org-tree.root { margin-left: 0; padding-left: 0; border-left: 0; }
.org-tree[hidden] { display: none; }
.org-line { display: flex; align-items: flex-start; gap: 4px; }
.org-toggle { margin-top: 3px; }
.org-gap { flex: none; width: 30px; }
.org-row { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; padding: 5px 8px; border-radius: 8px; }
.org-row:hover { background: var(--hover); }
.org-row.vacant { border: 1px dashed var(--line-strong); }
.org-row.group .org-office { color: var(--muted); }
.org-office, .org-holder { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.org-work { flex: 1 1 140px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.org-actions { display: inline-flex; gap: 2px; margin-left: auto; }
.agent-order .order-list li { gap: 8px; padding: 6px 8px; }
.agent-order .order-list .linkish { min-width: 0; }
.agent-order .grip { flex: none; color: var(--muted); }
.office-form { display: grid; gap: 10px; margin: 10px 0; }
.office-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }