feat(orchestrator): Agents and Artifacts views with spawn tree, orbit, 3D and signed branch deletes

Phase 4 of requirement 001. Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D: the spawn tree (page-spawn.js) and the artifacts orbit (page-artifacts.js, kvb_orchestrator/artifacts.py) replace the "folgt" placeholders, 3D runs on the vendored 3d-force-graph and three.js (MIT, LICENSE files kept, served from the image under the unchanged CSP), and a Bootstrap Icons sprite (icons.py, MIT) replaces the emoji glyphs, also in the meditation page.

Signed branch deletes: the Tree view offers a delete only for merged-clean branches without a gc skip reason; the operator signs the exact item with the passkey, the hub writes a job without any command (kvb_orchestrator/cleanup.py), and the host path unit runs client/cleanup.py, which verifies the signature, dry-runs pkgmgr branch cleanup for that exact branch, refuses patterns, option names, changed tips, other worktrees and plans naming other branches, deletes, and rewrites the branch tree. pkgmgr runs with core.hooksPath=/dev/null, core.fsmonitor=false and the config hook events disabled through GIT_CONFIG_COUNT, because the cwd is a repository agents can write and pkgmgr releases before 4c8b137 delete without scan mode (test with a positive control that both hook kinds fire without it).

3D labels go into the tooltip as a text node: the vendored tooltip renders a string label as HTML.

Tests: 503 OK, Chromium (CDP) and Firefox (BiDi) walks over every screen at three widths.

MEDUSA acceptances (kvb-think-criticize skeptic 2026-11-07): WEB-FLASK-004 view.py:135 (file template, autoescape on, state via tojson), MEDUSA-MM-SCAN-058 server.py:69/:85 (substring pla in --templates and Places), RH005 client/cleanup.py:199 (raise SystemExit(main())), B704 icons.py:45 (sprite baked into the image, names checked against it), EXA002 page-artifacts.js:221 (openAgent only opens the drawer), STG002 page-constants.js:64 (U+2212 in a static label), PL002 page-graph3d.js:44 (CSS class space-tools; the real tooltip sink fixed), B603 client/cleanup.py:75 (argv without shell; hook hole closed).

🎯 Fix: 92 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 87
🚦 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 15:03:14 +00:00
1 parent db3c5264f2
commit 90c56cb786
64 files changed
+82829 -451

No files matched your search

+1
View File
@@ -38,5 +38,6 @@ COPY server.py /app/server.py
COPY kvb_orchestrator /app/kvb_orchestrator
COPY client/signing.py /app/client/signing.py
COPY templates /app/templates
COPY vendor /app/vendor
ENTRYPOINT ["python3", "/app/server.py"]
+199
View File
@@ -0,0 +1,199 @@
#!/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 jobs into, e.g.
~/.local/state/kvb-orchestrator/cleanup/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/cleanup.json.
--tree: the tree file `pkgmgr branch tree` writes; it names the
repositories pkgmgr knows and is rewritten after a deletion.
--pkgmgr: the pkgmgr executable built from a signed tag.
Returns:
Runs once over every job in --jobs, as the operator on the host: per item
it verifies the operator's passkey signature over repository, branch,
worktree and tip, then in the repository runs `pkgmgr branch cleanup
--json --local --branch <name>` as a dry run and, only when that plan
names exactly this branch as merged-clean without skip reason at the
signed tip and worktree, `pkgmgr branch cleanup --apply --json --local
--branch <name>`. Every other item is refused and nothing changes for
it. Writes each item's result, rewrites the tree file after a deletion
and removes the job. The exit code is 0.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import time
from pathlib import Path
import signing
import verifier
JOB = re.compile(r"[0-9a-f]{16}\.json")
ITEM_KEYS = ("repository", "branch", "worktree", "tip")
TIP = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{")
OUTPUT_LIMIT = 16 << 10
TIMEOUT = 600
# the cwd is a repository agents can write: its config may name hooks, and
# pkgmgr releases before 4c8b137 run git branch -D without scan mode
GIT_OVERRIDES = (
("core.hooksPath", "/dev/null"),
("core.fsmonitor", "false"),
("hook.reference-transaction.enabled", "false"),
("hook.post-checkout.enabled", "false"),
("hook.post-index-change.enabled", "false"),
)
def git_overrides() -> dict[str, str]:
env = {"GIT_CONFIG_COUNT": str(len(GIT_OVERRIDES))}
for index, (name, value) in enumerate(GIT_OVERRIDES):
env[f"GIT_CONFIG_KEY_{index}"] = name
env[f"GIT_CONFIG_VALUE_{index}"] = value
return env
def known_repositories(tree: Path) -> set[str]:
try:
data = json.loads(tree.read_text(encoding="utf-8"))
return {entry["path"] for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return set()
def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]:
try:
done = subprocess.run([str(pkgmgr), *argv], cwd=cwd, env={**os.environ, **git_overrides()}, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
return 127, "", str(error)
return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:]
def plan_items(stdout: str) -> list[dict] | None:
try:
data = json.loads(stdout)
except ValueError:
return None
items = data.get("items") if isinstance(data, dict) else data
if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items):
return None
return items
def shaped(item: object) -> bool:
if not isinstance(item, dict) or set(item) != set(ITEM_KEYS) or not all(isinstance(item[key], str) for key in ITEM_KEYS):
return False
return item["repository"].startswith("/") and bool(TIP.fullmatch(item["tip"])) and (item["worktree"] == "" or item["worktree"].startswith("/"))
def mismatch(entry: dict, item: dict) -> str | None:
if entry.get("state") != "merged-clean":
return f"state is {entry.get('state')}, not merged-clean"
if entry.get("skip"):
return f"gc skip reason: {entry['skip']}"
tip = entry.get("local_sha") or entry.get("head")
if tip != item["tip"]:
return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}"
if (entry.get("worktree") or "") != item["worktree"]:
return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}"
return None
class Cleanup:
def __init__(self, trust: verifier.Trust, tree: Path, pkgmgr: Path) -> None:
self.trust = trust
self.tree = tree
self.pkgmgr = pkgmgr
def item(self, item: dict, proof: object, known: set[str], now: float) -> dict:
"""Args:
item: one signed item with repository, branch, worktree and tip.
proof: the job's {"payload", "assertion"}.
known: the repositories the tree file says pkgmgr knows.
now: the current time in epoch seconds.
Returns:
The item with status done, refused or failed and the reason;
refused and failed items changed nothing.
"""
def refused(reason: str) -> dict:
return {**item, "status": "refused", "reason": reason}
try:
verifier.check(proof, signing.cleanup_item(item["repository"], item["branch"], item["worktree"], item["tip"]), "", self.trust, now)
except (signing.Refused, OSError) as error:
return refused(f"no valid operator signature: {error}")
if item["repository"] not in known or not Path(item["repository"]).is_dir():
return refused("unknown repository: pkgmgr does not list it")
if UNSAFE.search(item["branch"]) or not item["branch"]:
return refused("the branch name holds pattern or option characters; only an exact branch name is deleted")
selector = ["--json", "--local", "--branch", item["branch"]]
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"])
plan = plan_items(stdout) if code == 0 else None
if plan is None:
return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}")
others = sorted({str(entry.get("branch")) for entry in plan} - {item["branch"]})
if others:
return refused(f"the dry run names other branches too, a pattern match: {', '.join(others)[:500]}")
if len(plan) != 1:
return refused(f"the dry run names {len(plan)} items for {item['branch']}")
reason = mismatch(plan[0], item)
if reason:
return refused(reason)
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"])
applied = plan_items(stdout)
status = "done" if code == 0 else "failed"
return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []}
def job(self, path: Path, now: float) -> dict:
try:
job = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) 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 not all(map(shaped, items)) or job.get("id") != path.stem:
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"}
known = known_repositories(self.tree)
results = [self.item(item, job.get("proof"), known, now) for item in items]
result = {"id": path.stem, "finished": time.time(), "items": results}
if any(entry["status"] == "done" for entry in results):
code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent))
result["tree"] = {"code": code, "error": stderr.strip()[-500:]}
return result
def cycle(jobs: Path, results: Path, cleanup: Cleanup) -> int:
handled = 0
for path in sorted(jobs.iterdir()):
if not JOB.fullmatch(path.name) or not path.is_file():
path.unlink(missing_ok=True)
continue
verifier.write_private(results / path.name, cleanup.job(path, time.time()))
path.unlink(missing_ok=True)
handled += 1
return handled
def main() -> int:
parser = argparse.ArgumentParser(prog="cleanup")
parser.add_argument("--port", type=int, required=True)
for name in ("--jobs", "--results", "--approvers", "--replay", "--tree", "--pkgmgr"):
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, Cleanup(trust, args.tree, args.pkgmgr))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -65,6 +65,10 @@ def operator_item(task: str, command: str, directory: str) -> dict:
return {"kind": "operator", "id": task, "command": command, "directory": directory}
def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict:
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
+4
View File
@@ -48,6 +48,10 @@ services:
- /tree/branch-tree.json
- --timers
- /timers
- --vendor
- /app/vendor
- --cleanup
- /state/cleanup
- --llm-url
- "${KVB_ORCHESTRATOR_LLM_URL}"
- --llm-model
+4 -1
View File
@@ -4,6 +4,7 @@ import secrets
import time
from .asking import ask_state
from .cleanup import cleanup_request
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field
from .records import digest, shown
@@ -142,7 +143,9 @@ class Approving:
def sign(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items",))
hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None]
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]
hashes = [signing.item_hash(item) for item in signed]
credentials = [key["id"] for key in self.keys()]
if not hashes:
return {"payload": None, "challenge": None, "credentials": credentials}
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import itertools
import re
import threading
from collections.abc import Callable
from pathlib import Path
from .records import epoch, iso_epoch, read_json, shown
from .register import ENDED, status_of
from .tree import inside, owns
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b")
GATES = ("gate", "test")
LANE_LIMIT = 200
SHARED_SHOWN = 20
TITLE_LIMIT = 160
def artefacts_of(task: str, row: dict) -> list[dict]:
listed = row.get("artifacts")
found = []
for entry in listed if isinstance(listed, list) else []:
ref = entry.get("ref") if isinstance(entry, dict) else None
if isinstance(ref, str) and ":" in ref.strip(":"):
kind, _, name = ref.partition(":")
found.append({"task": task, "ref": ref, "kind": kind, "name": name, "added": shown(entry.get("added")), "ended": status_of(row) in ENDED})
return found
def lane(paths: list[str], reviews: list[dict], history: list[dict], jobs: list[dict]) -> list[dict]:
"""Args:
paths: the worktree paths of one branch.
reviews: the hub's reviews; an approved one stands for the commit
the agent makes right after the approval.
history: queue-history.jsonl rows; test and gate kinds count as gates.
jobs: the runner jobs; a push command counts as a push.
Returns:
The branch's swimlane events, oldest first, each with ts, kind
(commit, review, gate or push), title and ok (true, false or None).
"""
def mine(path: object) -> bool:
return isinstance(path, str) and any(inside(path, folder) for folder in paths)
events = []
for review in (review for review in reviews if mine(review.get("worktree"))):
created, title = iso_epoch(review.get("created")), shown(review.get("title"))[:TITLE_LIMIT]
events.append({"ts": created, "kind": "review", "title": title, "ok": None})
if review.get("gate") in ("pass", "fail"):
events.append({"ts": created, "kind": "gate", "title": f"Gate {review['gate']}: {title}", "ok": review["gate"] == "pass"})
if review.get("status") == "approved":
events.append({"ts": iso_epoch(review.get("decided")), "kind": "commit", "title": title, "ok": True})
for row in (row for row in history if mine(row.get("workdir")) and str(row.get("kind", "")).startswith(GATES)):
events.append({"ts": epoch(row.get("end")) or epoch(row.get("start")), "kind": "gate", "title": shown(row.get("kind")), "ok": row.get("exit") == 0})
for job in (job for job in jobs if mine(job.get("directory")) and PUSH.search(str(job.get("command", "")))):
finished = job.get("status") in ("done", "failed", "refused")
when = epoch(job.get("finished")) or epoch(job.get("started")) or epoch(job.get("created_ts"))
events.append({"ts": when, "kind": "push", "title": shown(job.get("command"))[:TITLE_LIMIT], "ok": job.get("status") == "done" if finished else None})
return sorted((event for event in events if event["ts"] is not None), key=lambda event: event["ts"])[-LANE_LIMIT:]
class Touched:
def __init__(self, stored: Callable[[str, str], Path]) -> None:
"""Args:
stored: maps a review id and suffix to its stored file; the
.json lists the files the review touched and never changes,
so each is read once.
"""
self.stored = stored
self.lock = threading.Lock()
self.known: dict[str, frozenset[str]] = {}
def of(self, review_id: str) -> frozenset[str]:
with self.lock:
if review_id not in self.known:
data = read_json(self.stored(review_id, ".json"))
files = data.get("files") if isinstance(data, dict) else None
self.known[review_id] = frozenset(entry["path"] for entry in files or [] if isinstance(entry, dict) and isinstance(entry.get("path"), str) and entry["path"])
return self.known[review_id]
def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Touched, history: list[dict], jobs: list[dict]) -> dict:
"""Returns:
The scan with each worktree's register artefacts, the tasks whose
artefacts belong to no worktree, each branch's touched files and
swimlane events, and per repository the file overlaps between
branches and the merges the gc classification names.
"""
placed: set[str] = set()
repos = []
for entry in scan["repos"]:
branches, files = [], {}
for item in entry["branches"]:
trees = []
for tree in item["worktrees"]:
held = [(task, row) for task, row in sorted(tasks.items()) if owns(row, tree["path"], item["name"])]
placed.update(task for task, _ in held)
trees.append({**tree, "artefacts": [found for task, row in held for found in artefacts_of(task, row)]})
paths = [tree["path"] for tree in trees]
files[item["name"]] = frozenset().union(*(touched.of(review["id"]) for review in reviews if isinstance(review.get("worktree"), str) and any(inside(review["worktree"], path) for path in paths)))
branches.append({**item, "worktrees": trees, "files": len(files[item["name"]]), "events": lane(paths, reviews, history, jobs)})
overlaps = []
for a, b in itertools.combinations(sorted(files), 2):
shared = sorted(files[a] & files[b])
if shared:
overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]})
merges = [{"from": item["name"], "into": item["merged_into"] or entry.get("default") or "", "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
repos.append({**entry, "branches": branches, "overlaps": overlaps, "merges": merges})
loose = [{"task": task, "title": shown(row.get("title")), "status": status_of(row), "artefacts": artefacts_of(task, row)} for task, row in sorted(tasks.items()) if task not in placed and artefacts_of(task, row)]
return {**scan, "repos": repos, "tasks": loose}
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import json
import os
import secrets
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from .errors import Missing, ToolError
from .fields import decode, expect
from .records import read_json, stamp
from .tree import offered
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
JOB_KEYS = ("repository", "branch", "worktree", "tip")
JOB_VERSION = 1
ITEM_LIMIT = 100
RESULTS_SHOWN = 20
@dataclass(frozen=True)
class Spool:
"""Args:
root: the host-mounted directory; the hub writes signed jobs into
jobs/ by rename, the host runner writes each result into
results/<id>.json and removes the job.
"""
root: Path
@property
def jobs(self) -> Path:
return self.root / "jobs"
@property
def results(self) -> Path:
return self.root / "results"
def ready(self) -> Spool:
for folder in (self.root, self.jobs, self.results):
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
return self
def put(self, job_id: str, job: dict) -> None:
# written beside jobs/ and renamed in: the path unit wakes on a non-empty jobs/, never on a half-written file
with tempfile.NamedTemporaryFile("w", dir=self.root, prefix=f".{job_id}-", delete=False, encoding="utf-8") as handle:
json.dump(job, handle, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(handle.name, self.jobs / f"{job_id}.json")
def cleanup_request(raw: object) -> bool:
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == "cleanup" for entry in raw)
class Cleaning:
def deletable(self, raw: object) -> list[dict]:
"""Args:
raw: the items the page chose, each {"kind": "cleanup",
"repository", "branch", "worktree", "tip"} as /api/tree
offered it.
Returns:
The signed items, each still a merged-clean branch without a gc
skip reason at the same tip in the current tree file.
"""
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")
scan = self.tree.get()
if scan.get("delete_ready") is not True:
raise ToolError(f"löschen gesperrt: {scan.get('delete_reason') or scan.get('error') or 'delete_ready ist false'}")
known = offered(scan)
chosen = []
for entry in raw:
if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind, repository, branch, worktree and tip")
expect(entry, ITEM_KEYS)
if entry not in known:
raise ToolError(f"{entry.get('branch')!s:.200} in {entry.get('repository')!s:.200} is no merged-clean branch without skip reason at that tip in the tree file")
if entry in chosen:
raise ToolError("items must be distinct")
chosen.append(entry)
return chosen
def cleanup(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items", "proof"))
items = self.deletable(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": [{key: item[key] for key in JOB_KEYS} for item in items], "proof": proof}
self.spool.put(job_id, job)
names = ", ".join(item["branch"] for item in items)
self.log.add({"kind": "run", "title": f"Löschen signiert: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}})
return {"id": job_id, "status": "queued", "items": job["items"]}
def cleanup_state(self, job_id: str) -> dict:
result = read_json(self.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.spool.jobs / f"{job_id}.json").is_file():
return {"id": job_id, "status": "queued", "items": []}
raise Missing(job_id)
def cleanup_results(self) -> list[dict]:
try:
paths = sorted(self.spool.results.glob("*.json"), key=lambda path: path.stat().st_mtime)[-RESULTS_SHOWN:]
except OSError:
return []
found = [read_json(path) for path in paths]
return [result for result in found if isinstance(result, dict) and isinstance(result.get("items"), list)]
+18 -1
View File
@@ -34,7 +34,12 @@ RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
SIGN_ROUTE = "/api/sign"
RULE_SIGN_ROUTE = "/api/rules/sign"
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE)
CLEANUP_ROUTE = "/api/cleanup"
CLEANUP_STATE = re.compile(r"/api/cleanup/([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)
class Handler(Exchange):
@@ -52,10 +57,16 @@ class Handler(Exchange):
item = ITEM.fullmatch(path)
entry = ENTRY.fullmatch(path)
proof = PROOF_ROUTE.fullmatch(path)
vendored = VENDOR_ROUTE.fullmatch(path)
cleanup = CLEANUP_STATE.fullmatch(path)
if self.host() is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
elif path == "/":
self.index()
elif vendored and vendored.group(1) in VENDOR:
self.vendored(VENDOR[vendored.group(1)])
elif cleanup:
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
elif proof:
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
elif path == "/api/state":
@@ -112,6 +123,8 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
if path == RULE_SIGN_ROUTE:
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 inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo:
@@ -188,6 +201,10 @@ class Handler(Exchange):
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
def vendored(self, name: str) -> None:
headers = {"Content-Security-Policy": PLAIN_CSP, "Cache-Control": "private, max-age=86400"}
self.attempt((self.server.hub.vendor / name).read_bytes, lambda data: self.send(HTTPStatus.OK, SCRIPT_TYPE, data, headers))
def stored(self, review_id: str, name: str) -> None:
suffix, content_type, csp = STORED[name]
reviews = self.server.hub.reviews
+17 -2
View File
@@ -5,12 +5,16 @@ from dataclasses import dataclass
from pathlib import Path
import jinja2
from markupsafe import Markup
from .approving import Approving
from .artifacts import Touched
from .asking import Asking
from .batches import Pending
from .cleanup import Cleaning, Spool
from .engines import Engines, Speech
from .events import Log, Watch
from .icons import sheet
from .inbox import Inbox
from .jobs import Jobs
from .language import Language, Model
@@ -34,6 +38,8 @@ from .view import View
class Places:
tree_file: Path
timers: Path
vendor: Path
cleanup: Path
def environment(templates: Path) -> jinja2.Environment:
@@ -49,7 +55,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
@@ -75,6 +81,10 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
settings: Settings
speech: Speech
approvers: Path
vendor: Path
icons: Markup
spool: Spool
touched: Touched
def hosts(self) -> tuple[str, str]:
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
@@ -86,7 +96,12 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub:
uploads = state / "uploads"
uploads.mkdir(exist_ok=True)
reviews = Reviews(state)
return Hub(
vendor=places.vendor,
icons=sheet(places.vendor, templates),
spool=Spool(places.cleanup).ready(),
touched=Touched(reviews.stored),
approvers=approvers,
speech=Speech(engines, state / "tts"),
rules=Ledger(state / "rules.json"),
@@ -103,7 +118,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
council=council,
pages=pages,
public_port=public_port,
reviews=Reviews(state),
reviews=reviews,
asks=Ledger(state / "asks.json"),
decisions=Ledger(state / "decisions.json"),
messages=Ledger(state / "messages.json"),
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import re
from pathlib import Path
from markupsafe import Markup
from .errors import ToolError
SPRITE = Path("bootstrap-icons") / "bootstrap-icons.svg"
SYMBOL = re.compile(r'<symbol class="bi bi-[a-z0-9-]+" viewBox="0 0 16 16" id="([a-z0-9-]+)">(.*?)</symbol>', re.DOTALL)
NAME = re.compile(r'"#?bi-([a-z0-9]+(?:-[a-z0-9]+)*)"')
SOURCES = ("*.js", "*.j2")
def symbols(sprite: Path) -> dict[str, str]:
return dict(SYMBOL.findall(sprite.read_text(encoding="utf-8")))
def used(templates: Path) -> list[str]:
names: set[str] = set()
for pattern in SOURCES:
for path in sorted(templates.glob(pattern)):
names.update(NAME.findall(path.read_text(encoding="utf-8")))
return sorted(names)
def sheet(vendor: Path, templates: Path) -> Markup:
"""Args:
vendor: the directory holding the vendored Bootstrap Icons sprite.
templates: the page sources; every string literal "bi-<name>" or
"#bi-<name>" in them names an icon the page shows.
Returns:
A hidden inline SVG holding exactly those symbols, each as
bi-<name>, so the page draws them with <use href="#bi-<name>"> and
loads nothing.
"""
known = symbols(vendor / SPRITE)
names = used(templates)
missing = [name for name in names if name not in known]
if missing:
raise ToolError(f"icons missing from {vendor / SPRITE}: {', '.join(missing)}")
body = "".join(f'<symbol id="bi-{name}" viewBox="0 0 16 16">{known[name]}</symbol>' for name in names)
return Markup(f'<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true" focusable="false">{body}</svg>')
+6 -2
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
import time
from .artifacts import enrich
from .council import HUMAN, agent, read_claims
from .crons import cron_state, queue_rows, timer_rows
from .errors import ToolError
from .fields import single
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
from .records import epoch, iso_epoch
from .records import epoch, iso_epoch, read_jsonl
from .register import REGISTER, read_register
from .tree import assign
@@ -45,7 +46,10 @@ class Sources:
def repositories(self) -> dict:
tasks = read_register(self.council / REGISTER)
return assign(self.tree.get(), tasks, read_claims(self.council), self.reviews.listed())
reviews = self.reviews.listed()
scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews)
history = read_jsonl(self.council / "queue-history.jsonl")
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results()}
def schedules(self, claims: list[dict], now: float) -> list[dict]:
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
+44 -7
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .council import agent
from .records import iso_epoch
from .register import ENDED, status_of
from .signed import signing
LABEL_PARTS = 3
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
@@ -31,19 +32,47 @@ def worktree(item: dict) -> dict:
}
def branch(item: dict) -> dict:
def dry_run(item: dict) -> list[str]:
"""Returns:
The lines `pkgmgr branch cleanup --local` would act on for one tree
item, read from the gc plan the tree file carries; a remote branch
always stays.
"""
tip = (item["local_sha"] or item["head"] or "")[:12]
merged = f", gemergt durch {item['merged_by']}" if item["merged_by"] else ""
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"]
for action in item["actions"]:
if action == "worktree":
lines.append(f"entfernt den Worktree {item['worktree']}")
elif action == "local-branch":
lines.append(f"löscht den lokalen Branch {item['branch']}")
elif action.startswith("remote"):
lines.append(f"bleibt: {action} (nur lokal)")
else:
lines.append(action)
lines += [f"stoppt den Compose-Stack {name} ohne -v" for name in item["compose_projects"]]
lines += [f"entfernt das Image {name}" for name in item["images"]]
return lines
def branch(item: dict, path: str) -> dict:
remote = next(iter(item["remotes"].values()), "")
head = item["local_sha"] or item["head"] or remote
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"])
return {
"name": item["branch"],
"head": item["local_sha"] or item["head"] or remote,
"head": head,
"upstream": item["upstream"] or "",
"ahead": item["ahead"] or 0,
"behind": item["behind"] or 0,
"gone": item["gone"],
"state": item["state"],
"merged_by": item["merged_by"],
"merged_into": item["merged_into"],
"skip": item["skip"],
"reason": item["reason"],
"plan": dry_run(item),
"cleanup": signing.cleanup_item(path, item["branch"], item["worktree"] or "", item["local_sha"]) if open_to_delete else None,
"worktrees": [worktree(item)] if item["worktree"] else [],
}
@@ -53,11 +82,15 @@ def repo(entry: dict) -> dict:
return {
**{key: value for key, value in entry.items() if key != "items"},
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
"branches": [branch(item) for item in items if item["branch"]],
"branches": [branch(item, entry["path"]) for item in items if item["branch"]],
"detached": [worktree(item) for item in items if item["detached"]],
}
def offered(scan: dict) -> list[dict]:
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None]
def read_tree(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
@@ -75,6 +108,13 @@ def inside(path: str, folder: str) -> bool:
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
def owns(row: dict, path: str, name: str) -> bool:
workdir = row.get("workdir")
if isinstance(workdir, str):
return workdir.startswith("/") and inside(workdir, path)
return row.get("branch") == name
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
holders: dict[str, set[str]] = {}
@@ -92,10 +132,7 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
for tree in item["worktrees"]:
workers = set()
for task, row in current.items():
workdir, name = row.get("workdir"), row.get("branch")
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
by_branch = not isinstance(workdir, str) and name == item["name"]
if by_dir or by_branch:
if owns(row, tree["path"], item["name"]):
workers |= holders[task]
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
branches.append({**item, "worktrees": trees})
+1 -1
View File
@@ -132,5 +132,5 @@ class View:
def render(self, nonce: str) -> tuple[str, str]:
now = time.time()
state = self.state(now - RADIO_WINDOW, now, now, 0)
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state)
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state, icons=self.icons)
return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]]))
+7 -2
View File
@@ -22,6 +22,11 @@
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
read-only; read again whenever it changes.
--timers: directory holding the systemd user units (*.timer).
--vendor: directory holding the vendored Bootstrap Icons sprite,
3d-force-graph and three with their licence texts.
--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.
--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.
@@ -61,7 +66,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", "--llm-key-file", "--approvers")
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--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"):
@@ -77,7 +82,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),
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup),
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 cleanup: wake on a signed branch deletion the Agent Orchestrator wrote
[Path]
DirectoryNotEmpty=%h/.local/state/kvb-orchestrator/cleanup/jobs
MakeDirectory=true
DirectoryMode=0700
[Install]
WantedBy=default.target
@@ -0,0 +1,9 @@
[Unit]
Description=Agent Orchestrator cleanup: verify the operator signature, then delete exactly the signed merged branches locally through pkgmgr
[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/cleanup.py --port ${KVB_ORCHESTRATOR_PORT} --jobs %h/.local/state/kvb-orchestrator/cleanup/jobs --results %h/.local/state/kvb-orchestrator/cleanup/results --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/cleanup.json --tree %h/.local/state/pkgmgr/tree/branch-tree.json --pkgmgr %h/.local/share/kvb-orchestrator/pkgmgr/bin/pkgmgr
TimeoutStartSec=1800
+55 -4
View File
@@ -46,7 +46,11 @@ h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01e
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
.subtabs button { font-size: var(--fs-sm); }
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.bi { flex: none; width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
.icon-only { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
.btn.small.icon-only { min-width: 30px; padding: 4px 8px; font-size: var(--fs-md); }
.bi.caret { width: .7em; height: .7em; opacity: .7; }
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
.tabs button:hover { background: var(--hover); color: var(--text); }
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
@@ -256,6 +260,23 @@ svg .mark:hover { opacity: 1; }
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
svg .node.a-repo circle, svg .node.sun circle { fill: color-mix(in srgb, var(--warn) 70%, var(--panel)); stroke: var(--warn); stroke-width: 3; }
svg .node.a-worktree circle { fill: var(--sunken); stroke: var(--muted); }
svg .node.a-worktree.dirty circle { fill: var(--warn-soft); stroke: var(--warn); }
svg .node.satellite circle { fill: var(--accent); stroke: none; }
svg .node.cargo circle { fill: var(--teal); stroke: none; }
svg .node.tone-ok circle { fill: var(--ok-soft); stroke: var(--ok); }
svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); }
svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); }
svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); }
svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); }
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
svg .edge.merge { stroke-dasharray: 5 5; }
svg .lane-mark { stroke-width: 1.2; }
svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); }
svg .lane-mark.tone-bad { fill: var(--bad); stroke: var(--bad); }
svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); }
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
@@ -358,16 +379,40 @@ body.sky-look .sky:active { cursor: grabbing; }
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
.sky-panel[hidden] { display: none; }
.compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; }
.states { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.state { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; white-space: nowrap; }
.state[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); }
.state[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); }
.state[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); }
.state[data-tone="info"] { background: var(--info-soft); color: var(--info); }
.state[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); }
.wt-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; width: 100%; }
.leaves { display: grid; gap: 4px; width: 100%; margin: 0; padding: 0 0 0 18px; list-style: none; }
.leaf { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-sm); }
.hint { color: var(--muted); font-size: var(--fs-xs); }
.delete-all { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.dry-run { margin: 4px 0 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-family: var(--mono); font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.spawn-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 18px; list-style: none; }
.spawn-tree.root { padding-left: 0; }
.spawn-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 8px; border-radius: 8px; }
.spawn-row:hover { background: var(--hover); }
.graph3d { position: relative; width: 100%; height: clamp(360px, 68vh, 820px); overflow: hidden; border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
.graph3d:active { cursor: grabbing; }
.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); }
</style>
</head>
<body>
{{ icons }}
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
<div class="head" id="head">
<header class="top">
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
<div class="tools">
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</button>
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</button>
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
</div>
</header>
<div class="navbar">
@@ -438,10 +483,16 @@ body.sky-look .sky:active { cursor: grabbing; }
{% include "page-space-steer.js" %}
{% include "page-space-scene.js" %}
{% include "page-spawn.js" %}
{% include "page-graph3d.js" %}
{% include "page-artifacts.js" %}
{% include "page-tree.js" %}
{% include "page-cleanup.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+5 -1
View File
@@ -3,6 +3,10 @@ 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 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",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const known = (view) => VIEWS.some(([id]) => id === view);
@@ -30,7 +34,7 @@ const Nav = (() => {
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+2 -2
View File
@@ -94,7 +94,7 @@
ui.agent = key;
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
const classes = choice("to-one", TO_ONE, "post");
const form = h("form", {class: "drawer-form"}, text, file, classes,
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
@@ -118,7 +118,7 @@
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
drawer.replaceChildren(
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeAgent)),
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
form);
drawer.hidden = false;
+1 -1
View File
@@ -27,7 +27,7 @@
}
const permission = "Notification" in window ? Notification.permission : "unsupported";
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
button.textContent = labels[permission] || labels.default;
relabel(button, "bi-bell", labels[permission] || labels.default);
button.disabled = permission !== "default";
}
+312
View File
@@ -0,0 +1,312 @@
const art = {repo: "", node: null};
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artefakt", "bi-box-seam"], task: ["Aufgabe", "bi-card-checklist"]};
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
const tail = (path, parts) => String(path).split("/").filter(Boolean).slice(-parts).join("/");
const epochOf = (text) => {
const value = Date.parse(text);
return Number.isFinite(value) ? value / 1000 : null;
};
function artifactModel(data) {
const nodes = [];
const links = [];
const add = (node) => {
nodes.push(node);
if (node.parent) {
links.push({source: node.parent, target: node.id, kind: "contains"});
}
return node.id;
};
const leaves = (holder, wt, depth) => {
for (const name of wt.workers || []) {
add({id: `agent:${name}@${wt.path}`, kind: "agent", label: name, depth, ts: holder.ts, parent: holder.id, data: {name}, repo: holder.repo});
}
for (const found of wt.artefacts || []) {
add({id: `artefact:${holder.id}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth, ts: epochOf(found.added) || holder.ts, parent: holder.id, data: found, repo: holder.repo});
}
};
for (const repo of data.repos || []) {
const base = epochOf(repo.scanned_at) || data.scanned || nowSeconds();
const rid = add({id: `repo:${repo.path}`, kind: "repo", label: repo.name, depth: 0, ts: base, parent: null, data: repo, repo: repo.path});
for (const branch of repo.branches) {
const last = branch.events && branch.events.length ? branch.events[branch.events.length - 1].ts : base;
const bid = add({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name, depth: 1, ts: last, parent: rid, data: branch, repo: repo.path});
for (const wt of branch.worktrees) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 2, ts: last, parent: bid, data: {...wt, branch: branch.name}, repo: repo.path};
add(holder);
leaves(holder, wt, 3);
}
}
for (const wt of repo.detached) {
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 1, ts: base, parent: rid, data: {...wt, branch: ""}, repo: repo.path};
add(holder);
leaves(holder, wt, 2);
}
for (const merge of repo.merges || []) {
links.push({source: `branch:${repo.path}\u0000${merge.from}`, target: `branch:${repo.path}\u0000${merge.into}`, kind: "merge", by: merge.by});
}
for (const overlap of repo.overlaps || []) {
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
}
}
for (const task of data.tasks || []) {
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
for (const found of task.artefacts) {
add({id: `artefact:${tid}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth: 1, ts: epochOf(found.added) || nowSeconds(), parent: tid, data: found, repo: ""});
}
}
const ids = new Set(nodes.map((node) => node.id));
return {nodes, links: links.filter((link) => ids.has(link.source) && ids.has(link.target)), byId: new Map(nodes.map((node) => [node.id, node]))};
}
function chosenRepo() {
const repos = tree.data && !tree.data.error ? tree.data.repos : [];
const weight = (repo) => repo.branches.length + repo.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0);
const found = repos.find((repo) => repo.path === art.repo) || [...repos].sort((a, b) => weight(b) - weight(a))[0] || null;
art.repo = found ? found.path : "";
return found;
}
function artSelect(id) {
art.node = art.node === id ? null : id;
drawArtifacts();
}
function artMark(node, at, radius, extra, caption) {
const [kindName] = ART_KINDS[node.kind];
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
return s("g", {
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
on: {
click: () => artSelect(node.id),
keydown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
artSelect(node.id);
}
},
},
}, s("title", {}, `${node.label} · ${kindName}`), s("circle", {r: radius}), caption ? s("text", {class: "label", y: radius + 14, "text-anchor": "middle"}, caption) : null);
}
function around(base, index, count, distance) {
const angle = (base.angle || 0) + ((index - (count - 1) / 2) * Math.PI) / Math.max(count + 2, 5);
return {x: base.x + distance * Math.cos(angle), y: base.y + distance * Math.sin(angle), angle};
}
const toneOf = (branch) => (branch.state === "merged-clean" ? "ok" : branch.state === "dirty" ? "warn" : branch.state === "protected" ? "violet" : branch.state === "stale" ? "warn" : branch.gone ? "bad" : "info");
function artOrbit(repo, model) {
const svg = chartFrame(420, `Orbit: ${repo.name} als Sonne, Branches als Planeten (Größe = Commits voraus), Worktrees als Monde, Agenten als Satelliten`);
const rings = repo.branches.length > 16 ? [200, 310] : [250];
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
const layer = s("g", {});
repo.branches.forEach((branch, index) => {
const ring = rings[index % rings.length];
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(repo.branches.length, 1);
const at = {x: ring * Math.cos(angle), y: ring * Math.sin(angle), angle};
const radius = Math.min(11 + 4 * Math.log2(1 + branch.ahead), 30);
const node = model.byId.get(`branch:${repo.path}\u0000${branch.name}`);
branch.worktrees.forEach((wt, slot) => {
const moon = around(at, slot, branch.worktrees.length, radius + 18);
const holder = model.byId.get(`wt:${wt.path}`);
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
});
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
});
layer.append(artMark(model.byId.get(`repo:${repo.path}`), {x: 0, y: 0, angle: 0}, 34, "sun", repo.name.split("/").pop()));
svg.append(layer);
return svg;
}
function artChord(repo) {
const radius = 300;
const branches = repo.branches;
const spots = new Map(branches.map((branch, index) => {
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(branches.length, 1);
return [branch.name, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
}));
const svg = chartFrame(440, `Chord: Überlappung der Branches von ${repo.name}; Band = gemeinsam geänderte Dateien, gestrichelt = Merge`);
const arcs = s("g", {});
for (const overlap of repo.overlaps) {
const from = spots.get(overlap.a);
const to = spots.get(overlap.b);
if (from && to) {
arcs.append(s("path", {class: "edge t-alarm", d: `M${fixed(from.x)},${fixed(from.y)} Q0,0 ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": fixed(width(overlap.count))},
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} gemeinsame Dateien (Konfliktrisiko)\n${overlap.files.join("\n")}`)));
}
}
for (const merge of repo.merges) {
const from = spots.get(merge.from);
const to = spots.get(merge.into);
if (from && to && from !== to) {
const control = {x: (from.x + to.x) * 0.3, y: (from.y + to.y) * 0.3};
arcs.append(s("path", {class: "edge t-grant merge", d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-grant)"},
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
}
}
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
const labels = branches.map((branch) => {
const at = spots.get(branch.name);
const cos = Math.cos(at.angle);
return s("text", {class: "label", x: fixed(at.x * 1.07), y: fixed(at.y * 1.07), dy: "0.35em", "text-anchor": cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"}, short(branch.name).slice(0, 26));
});
svg.append(arcs, s("g", {}, marks), s("g", {}, labels));
return svg;
}
function artSwimlane(repo) {
const lanes = repo.branches;
const begin = state.since;
const end = Math.max(state.until, state.since + 1);
const [left, right, top, lane, full] = [200, 24, 40, 34, 1120];
const height = top + lanes.length * lane + 12;
const x = (ts) => left + ((Math.min(Math.max(ts, begin), end) - begin) / (end - begin)) * (full - left - right);
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: Commits, Reviews, Gates und Pushes je Branch von ${repo.name}`}, defs());
lanes.forEach((branch, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
}
const marks = s("g", {});
lanes.forEach((branch, index) => {
const y = top + index * lane + lane / 2;
const shapes = {commit: `M-4,0 a4,4 0 1,0 8,0 a4,4 0 1,0 -8,0`, review: "M-4,-4 h8 v8 h-8 z", gate: "M0,-5 L5,4 L-5,4 z", push: "M0,-6 L5,0 L2,0 L2,5 L-2,5 L-2,0 L-5,0 z"};
for (const event of branch.events.filter((entry) => entry.ts >= begin && entry.ts <= end)) {
const tone = event.ok === false ? "bad" : event.kind === "push" ? "info" : event.kind === "commit" ? "ok" : event.ok ? "ok" : "neutral";
marks.append(s("g", {class: `lane-mark tone-${tone}`, transform: `translate(${fixed(x(event.ts))} ${y})`},
s("title", {}, `${clock.format(stampOf(event.ts))} · ${LANE_MARKS[event.kind][0]}: ${event.title}`), s("path", {d: shapes[event.kind]})));
}
});
svg.append(marks);
return svg;
}
function artLegend(view) {
const words = {
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
};
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
}
function artPanel(model) {
const node = model.byId.get(art.node);
if (!node) {
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf ein Repository, einen Branch, einen Worktree, einen Agenten oder ein Artefakt.")];
}
const [kindName, kindIcon] = ART_KINDS[node.kind];
const data = node.data;
const body = [];
if (node.kind === "repo") {
body.push(meta([["Pfad", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["Stand", data.scanned_at]]),
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
body.push(h("div", {class: "actions"}, btn("Agent öffnen", "btn small soft", () => openAgent(data.name))));
} else if (node.kind === "artefact") {
body.push(meta([["Ref", data.ref], ["Art", data.kind], ["Aufgabe", data.task], ["Seit", data.added]]), data.ended ? pill("Aufgabe beendet: aufräumen", "warn") : null);
} else {
body.push(meta([["Titel", data.title], ["Status", data.status]]));
}
return [
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => artSelect(node.id))),
...body,
];
}
function artifactsScreen() {
const view = ui.views.artifacts;
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
art.repo = event.target.value;
art.node = null;
drawArtifacts();
}}});
const count = h("span", {class: "muted"});
const reload = iconBtn("bi-arrow-clockwise", "Neu laden", "btn small soft", fetchTree, "Neu laden: den Tree-Stand von pkgmgr lesen");
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const legendBox = h("div", {});
const list = h("div", {class: "repos"});
const search = view === "tree" ? h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
tree.q = event.target.value;
drawArtifacts();
}}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
window.setTimeout(() => {
drawArtifacts();
fetchTree();
}, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
return h("div", {class: "graph"}, controls, body);
}
async function fetchTree() {
if (tree.busy) {
return;
}
tree.busy = true;
try {
const response = await fetch("/api/tree", {cache: "no-store"});
if (response.ok) {
tree.data = await response.json();
tree.model = artifactModel(tree.data);
} else {
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
}
} catch {
ui.offline = true;
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
}
tree.busy = false;
drawArtifacts();
}
function drawArtifacts() {
if (!tree.dom) {
return;
}
if (tree.dom.view === "tree") {
drawTree();
return;
}
const empty = !tree.data || tree.data.error;
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) {
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
tree.dom.aside.replaceChildren();
return;
}
const model = tree.model;
if (art.node !== null && !model.byId.has(art.node)) {
art.node = null;
}
tree.dom.aside.replaceChildren(...artPanel(model));
if (tree.dom.view === "3d") {
stage3d("artifacts", tree.dom.stage);
return;
}
const repo = chosenRepo();
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("Kein Repository", "pkgmgr nennt kein Repository."));
}
+5 -5
View File
@@ -42,9 +42,9 @@
text.value = "";
}
};
return h("details", {class: "menu", id: "to-all"}, h("summary", {}, "Funk an alle ▾"), h("div", {class: "menu-box"},
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Funk an alle"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Senden", "btn soft", send))));
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
}
function openToAll() {
@@ -59,12 +59,12 @@
}
function agentsToolbar() {
const clear = btn("Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
const clear = iconBtn("bi-life-preserver", "Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", {}, "Tag/Nacht ▾"), h("div", {class: "menu-box"}, mode,
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, btn("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", prioritySheet), night), clear, mode};
}
function drawToolbar() {
+1 -1
View File
@@ -25,7 +25,7 @@
window.addEventListener("hashchange", apply);
window.addEventListener("resize", measure);
window.addEventListener("resize", redrawSky);
window.addEventListener("resize", requestSpace);
window.addEventListener("resize", refresh3d);
skyPanel();
radio.element = h("audio", {preload: "none"});
radio.element.addEventListener("error", () => {
+76
View File
@@ -0,0 +1,76 @@
const CLEANUP_POLL = 2000;
const CLEANUP_PATIENCE = 900000;
const RESULT_LABELS = {done: ["gelöscht", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
function planOf(item) {
const repo = (tree.data.repos || []).find((entry) => entry.path === item.repository);
const entry = repo ? repo.branches.find((candidate) => candidate.name === item.branch) : null;
return {repo, entry};
}
function deleteDialog(items) {
if (!items.length) {
return;
}
const rows = items.map((item) => {
const {repo, entry} = planOf(item);
return h("li", {class: "dry-run"},
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)),
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
const confirm = h("button", {type: "button", class: "btn danger", ...named("Löschen", "Löschen: mit deiner Passkey-Signatur an den Host übergeben")}, icon("bi-trash3"), "Löschen");
confirm.addEventListener("click", async () => {
confirm.disabled = true;
if (!await removeSigned(items, outcome)) {
confirm.disabled = false;
}
});
openSheet(items.length > 1 ? `${items.length} gemergte Branches löschen` : `${items[0].branch} löschen`, [
h("p", {class: "muted"}, "Trockenlauf von pkgmgr branch cleanup --local für genau diese Einträge: Worktree, lokaler Branch, Compose-Stack ohne -v und seine Images. Kein Remote-Branch. Der Host prüft deine Signatur und verweigert jeden Eintrag, dessen Stand sich seither geändert hat."),
h("ul", {class: "mini", "aria-label": "Trockenlauf"}, rows),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
], false);
}
async function removeSigned(items, outcome) {
const signed = await proofFor(items, "Löschen");
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return false;
}
const result = await sendJson("/api/cleanup", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast("Löschen: 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", "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());
return true;
}
async function followCleanup(id, outcome, began) {
let data = null;
try {
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {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);
}
return;
}
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_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 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();
}
+4 -5
View File
@@ -1,4 +1,6 @@
const NS = "http://www.w3.org/2000/svg";
const NONCE = document.currentScript ? document.currentScript.nonce : "";
const ACTIVE_BEAT = 900;
const DAY = 86400;
const CHAIR = "Maintainer";
const HUMAN = "Operator";
@@ -7,6 +9,7 @@
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
@@ -42,15 +45,11 @@
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
const SUBS_TTL = 60000;
const TURN = 10;
const NUDGE = 40;
const ZOOM = 1.2;
const GLIDE = 320;
const PLANE = 450;
const SAVE_DELAY = 700;
const TRACK_RETRY = 5000;
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
const BASE = 110;
@@ -62,7 +61,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
+15 -7
View File
@@ -17,6 +17,10 @@
}
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
const icon = (name, cls) => s("svg", {class: cls ? `bi ${cls}` : "bi", "aria-hidden": "true", focusable: "false"}, s("use", {href: `#${name}`}));
const caret = () => icon("bi-caret-down-fill", "caret");
const named = (label, hint) => ({title: hint || label, "aria-label": label});
const iconBtn = (name, label, cls, run, hint) => h("button", {type: "button", class: `${cls} icon-only`, ...named(label, hint), on: {click: run}}, icon(name));
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
const fixed = (value) => value.toFixed(1);
@@ -91,18 +95,22 @@
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
}
function relabel(button, name, label, hint) {
button.replaceChildren(icon(name));
button.setAttribute("aria-label", label);
button.title = hint || label;
}
function copyButton(text) {
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
const button = iconBtn("bi-clipboard", "Kopieren", "btn small ghost", null, "Kopieren: in die Zwischenablage");
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(text);
button.textContent = "Kopiert";
relabel(button, "bi-clipboard-check", "Kopiert");
} catch {
button.textContent = "Nicht möglich";
relabel(button, "bi-x-circle", "Nicht möglich");
}
window.setTimeout(() => {
button.textContent = "Kopieren";
}, 1600);
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
});
return button;
}
@@ -118,7 +126,7 @@
function openSheet(title, body, wide) {
const sheet = document.getElementById("sheet");
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeSheet)),
h("div", {class: "sheet-body"}, body)));
sheet.hidden = false;
}
+8 -6
View File
@@ -103,7 +103,9 @@
return;
}
const kinds = activeKinds();
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)));
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {
type: "button", class: "chip preset icon-only", ...named(view.label), "aria-pressed": String(filter.view === id), on: {click: () => useView(id)},
}, icon(VIEW_ICONS[id] || "bi-funnel"))));
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
click: () => useSaved(view),
keydown: (event) => {
@@ -111,11 +113,11 @@
useSaved(view);
}
},
}}, view.name, h("button", {type: "button", class: "x", "aria-label": `${view.name} löschen`, on: {click: (event) => {
}}, view.name, h("button", {type: "button", class: "x", ...named(`${view.name} löschen`), on: {click: (event) => {
event.stopPropagation();
dropView(view.name);
}}}, "×")));
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
}}}, icon("bi-x-lg"))));
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Ansicht speichern", "btn small ghost", saveView));
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
customize();
if (filter.kinds.has(id) && filter.kinds.size > 1) {
@@ -141,11 +143,11 @@
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
stream.dom.parts.replaceChildren(...parts);
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
filter.sub = "";
filter.session = "";
filter.agent = "";
refilter();
}}}, "×")) : null;
}}}, icon("bi-x-lg"))) : null;
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
}
+9 -22
View File
@@ -157,28 +157,20 @@
}
function legend() {
const axis = graph3d() ? depth() : "none";
const words = {
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream",
};
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
h("span", {class: "muted"}, words[axis]));
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
}
function side() {
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
if (!node) {
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
}
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
return [
h("div", {class: "panel-head"},
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
h("div", {class: "row"}, klass(row.class), when(row.ts)),
@@ -188,22 +180,17 @@
}
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.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]))));
}
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
function agentsScreen() {
const view = ui.views.agents;
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
}
function artifactsScreen() {
const view = ui.views.artifacts;
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
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);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
}
+248
View File
@@ -0,0 +1,248 @@
const LAYOUTS = [
["none", "Kraft", "bi-magnet", "Kräfte ordnen frei: Verbundenes zieht sich an, alles stößt sich ab"],
["time", "Zeit", "bi-clock", "Tiefe = Zeit: Früheres liegt hinten, Jüngeres vorn"],
["spawn", "Spawn-Tiefe", "bi-layers", "Tiefe = Spawn-Tiefe: jede Ebene eine Generation"],
];
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
const DEPTH_GAP = 90;
const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""};
const layoutOf = () => space.views.graph.axis;
function loadScript(src) {
return new Promise((resolve, reject) => {
const tag = document.createElement("script");
tag.src = src;
tag.nonce = NONCE;
tag.async = true;
tag.addEventListener("load", resolve);
tag.addEventListener("error", () => reject(new Error(`${src} nicht geladen`)));
document.head.append(tag);
});
}
function forceGraph() {
if (!g3.lib) {
g3.lib = loadScript("/vendor/3d-force-graph.min.js").then(() => window.ForceGraph3D);
}
return g3.lib;
}
function threeModule() {
if (!g3.three) {
g3.three = import("/vendor/three.module.js").catch(() => null);
}
return g3.three;
}
function layoutTools(tab) {
const group = h("div", {class: "segmented", role: "group", "aria-label": "Anordnung"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
}, icon(name))));
return h("div", {class: "space-tools"}, group,
iconBtn("bi-arrow-counterclockwise", "Ansicht zurücksetzen", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
h("span", {class: "muted"}, "Ziehen dreht · rechts ziehen verschiebt · Rad zoomt · Klick öffnet das Panel"));
}
function chooseLayout(tab, id, group) {
for (const button of group.querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button.dataset.layout === id));
}
if (id !== layoutOf()) {
setView("graph", {...space.views.graph, axis: id}, false);
g3.sig = "";
if (ui.screen === tab) {
stage3d(tab, g3.host);
}
}
}
function agentModel() {
const depthOf = {operator: 0, chair: 1, session: 2, subagent: 3};
const nodes = state.graph.nodes.map((node) => ({
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: (node.timeline[0] || {}).since || state.since,
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
}));
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
const walk = (twig, parent, depth) => {
nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.started, parent, session: twig.session, sub: twig.row.id});
twig.children.forEach((child) => walk(child, twig.id, depth + 1));
};
twigs(node.id).forEach((twig) => walk(twig, node.id, depthOf[node.kind] + 1));
}
const ids = new Set(nodes.map((node) => node.id));
const links = nodes.filter((node) => node.parent && ids.has(node.parent)).map((node) => ({source: node.parent, target: node.id, kind: "spawn"}));
for (const link of aggregate().filter((entry) => entry.target !== "*" && ids.has(entry.source) && ids.has(entry.target) && entry.source !== entry.target)) {
links.push({source: link.source, target: link.target, kind: tone(link.cls), count: link.count});
}
return {nodes, links};
}
function model3d(tab) {
if (tab === "agents") {
return agentModel();
}
const built = tree.model || {nodes: [], links: []};
return {nodes: built.nodes.map((node) => ({id: node.id, kind: node.kind, label: node.label, depth: node.depth, ts: node.ts, parent: node.parent})), links: built.links.map((link) => ({...link}))};
}
function place(nodes, layout) {
const times = nodes.map((node) => node.ts).filter(Number.isFinite);
const [low, high] = [Math.min(...times), Math.max(...times)];
const span = high - low;
for (const node of nodes) {
if (layout === "spawn") {
node.fz = -node.depth * DEPTH_GAP;
} else if (layout === "time") {
node.fz = span > 0 && Number.isFinite(node.ts) ? ((node.ts - low) / span - 0.5) * TIME_DEPTH : 0;
} else {
delete node.fz;
}
}
}
function colors3d() {
const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]);
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
return g3.colors;
}
async function drawPlanes(layout, nodes) {
const graph = g3.graph;
const scene = graph.scene();
for (const plane of g3.planes) {
scene.remove(plane);
}
g3.planes = [];
if (layout === "none") {
return;
}
const THREE = await threeModule();
if (!THREE || g3.graph !== graph) {
return;
}
const levels = [...new Set(nodes.map((node) => node.fz))].filter(Number.isFinite).sort((a, b) => a - b);
const shown = layout === "spawn" ? levels : [levels[0], levels[levels.length - 1]].filter(Number.isFinite);
for (const z of new Set(shown)) {
const grid = new THREE.GridHelper(520, 8, g3.colors["line-strong"], g3.colors["line-strong"]);
grid.rotation.x = Math.PI / 2;
grid.position.z = z;
grid.material.transparent = true;
grid.material.opacity = 0.18;
scene.add(grid);
g3.planes.push(grid);
}
}
function pick3d(tab, node) {
if (tab === "artifacts") {
artSelect(node.id);
} else if (node.kind === "twig") {
focusSub(node.session, node.sub);
} else {
select(node.id);
}
}
function list3d(tab, data) {
const pickable = data.nodes.filter((node) => node.kind !== "operator");
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} Knoten als Liste`),
h("div", {class: "chips", role: "group", "aria-label": "Knoten"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
}
function signature3d(tab, data) {
return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
async function stage3d(tab, host) {
if (!host) {
return;
}
if (tab === "agents") {
loadSpawn();
}
const data = model3d(tab);
const sig = signature3d(tab, data);
if (g3.host === host && g3.sig === sig) {
return;
}
if (g3.host !== host) {
dispose3d();
g3.host = host;
g3.tab = tab;
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D-Graph ${tab === "agents" ? "der Agenten" : "der Artefakte"}; die Knoten stehen auch darunter als Liste`}), h("div", {class: "space-note"}, "Lade 3D …"));
}
g3.sig = sig;
const box = host.querySelector(".graph3d");
let Graph = null;
try {
Graph = await forceGraph();
} catch (error) {
g3.failed = String(error.message || error);
}
if (g3.host !== host) {
return;
}
if (!Graph) {
host.replaceChildren(emptyState("3D nicht geladen", g3.failed || "3d-force-graph fehlt."));
return;
}
if (!g3.graph) {
try {
g3.graph = Graph({controlType: "orbit"})(box);
} catch (error) {
host.replaceChildren(emptyState("3D nicht verfügbar", `Dieser Browser gibt kein WebGL: ${error.message || error}`));
g3.host = null;
return;
}
const colors = colors3d();
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1))
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
g3.graph.warmupTicks(calm.matches ? 160 : 80);
if (calm.matches) {
g3.graph.cooldownTicks(0);
} else {
g3.graph.cooldownTime(4000);
}
}
const layout = layoutOf();
place(data.nodes, layout);
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} Knoten · ${data.links.length} Kanten · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · ohne Bewegung (prefers-reduced-motion)" : ""}`;
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
}
function dispose3d() {
if (g3.graph) {
g3.graph.pauseAnimation();
if (typeof g3.graph._destructor === "function") {
g3.graph._destructor();
}
}
g3.graph = null;
g3.host = null;
g3.sig = "";
g3.planes = [];
}
function refresh3d() {
if (!g3.graph || !g3.host) {
return;
}
if (!g3.host.isConnected) {
dispose3d();
return;
}
colors3d();
const box = g3.host.querySelector(".graph3d");
g3.graph.width(box.clientWidth).height(box.clientHeight).nodeColor(g3.graph.nodeColor()).linkColor(g3.graph.linkColor());
}
+10 -10
View File
@@ -22,7 +22,7 @@
function undoToast(batch, label) {
const left = h("span", {class: "muted"});
const button = h("button", {type: "button", class: "btn small"}, "Rückgängig");
const button = h("button", {type: "button", class: "btn small", ...named("Rückgängig")}, icon("bi-arrow-counterclockwise"), "Rückgängig");
const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button));
let timer = 0;
const count = () => {
@@ -117,11 +117,11 @@
}
const buttons = [];
if (item.kind === "review" || item.kind === "ask") {
buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item)));
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
} else if (item.kind === "decision" && item.recommended) {
buttons.push(btn(`Empfehlung: ${item.recommended}`, "btn small primary", () => approveOne(item)));
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
} else if (item.kind === "operator" && item.runnable) {
buttons.push(btn("Ausführen", "btn small primary", () => approveOne(item)));
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
}
return buttons;
}
@@ -153,10 +153,10 @@
actionsFor(item),
item.kind === "operator" ? copyButton(item.command) : null,
h("span", {class: "spacer"}),
btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)),
btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)),
item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null,
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
}
function inboxScreen() {
@@ -188,8 +188,8 @@
ui.snoozed = event.target.checked;
drawInbox();
}}});
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`);
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Auswahl freigeben (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, icon("bi-check2-square"), String(chosen.length));
const all = h("button", {type: "button", class: "btn small primary", ...named(`Alle grünen freigeben (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, icon("bi-check2-all"), String(green.length));
inboxDom.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
+1 -1
View File
@@ -1,6 +1,6 @@
async function load() {
try {
const response = await fetch(`/api/state?${windowQuery()}${wantsTrack() ? "&track=1" : ""}`, {cache: "no-store"});
const response = await fetch(`/api/state?${windowQuery()}`, {cache: "no-store"});
if (!response.ok) {
throw new Error(String(response.status));
}
+1 -1
View File
@@ -142,7 +142,7 @@
return;
}
button.setAttribute("aria-pressed", String(settings.music.on));
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
relabel(button, "bi-music-note-beamed", settings.music.on ? "Musik an" : "Musik", settings.music.on ? "Musik an: generative Musik ausschalten" : "Musik: generative Musik einschalten");
}
function toggleMusic() {
+1 -1
View File
@@ -46,7 +46,7 @@
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
h("div", {class: "actions"},
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
h("a", {class: "btn ghost icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Vollständige Seite öffnen", "Vollständige Seite öffnen (neuer Tab)")}, icon("bi-box-arrow-up-right")),
h("span", {class: "spacer"}),
item ? actionsFor(item).map((node) => {
node.addEventListener("click", closeSheet);
+7 -3
View File
@@ -2,7 +2,7 @@
function tabButton(id, label, hint) {
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
const button = h("button", {type: "button", id: `tab-${id}`, "aria-controls": "main", title: hint ? `${label} (${hint})` : label, on: {click: () => go(id, null)}}, label, badge);
const button = h("button", {type: "button", id: `tab-${id}`, class: "icon-only", "aria-controls": "main", ...named(label, hint ? `${label} (${hint})` : label), on: {click: () => go(id, null)}}, icon(Nav.ICONS[id]), badge);
navButtons.set(id, {button, badge});
return button;
}
@@ -42,7 +42,7 @@
function signature() {
if (ui.screen === "agents") {
return JSON.stringify([ui.views.agents, depth()]);
return JSON.stringify([ui.views.agents]);
}
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
}
@@ -70,9 +70,13 @@
main.replaceChildren(screenOf());
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
dispose3d();
}
}
stage();
drawSide();
drawSpawn();
drawToolbar();
drawInbox();
drawStatus();
@@ -89,7 +93,7 @@
const banner = document.getElementById("mayday");
const silence = state.silence;
banner.hidden = !silence;
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), btn("aufheben", "btn small", () => confirmAct("clear-mayday"))] : []));
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "aufheben", "btn small", () => confirmAct("clear-mayday"), "aufheben: Mayday beenden (radio.py clear)")] : []));
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
window.setTimeout(measure, 0);
}
+6 -6
View File
@@ -4,7 +4,7 @@
if (space.edits === space.saved) {
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
space.glides = {graph: null, sky: null};
requestSpace();
refresh3d();
}
sky.mode = "";
drawSky();
@@ -56,15 +56,15 @@
}
function ambience() {
const tool = (id, label, title, run) => h("button", {type: "button", id, class: "btn small", title, on: {click: run}}, label);
const tool = (id, name, label, title, run) => h("button", {type: "button", id, class: "btn small icon-only", ...named(label, title), on: {click: run}}, icon(name));
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
tool("sky-look", "🔭 Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
tool("sky-look", "bi-stars", "Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
closeSheet();
skyLook(true);
}),
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
window.setTimeout(() => {
musicButton();
notifyLabel();
+10 -10
View File
@@ -22,7 +22,7 @@
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
root.style.colorScheme = mode;
root.dataset.mode = mode;
requestSpace();
refresh3d();
}
function drawMoon(paint, at, radius, moon) {
@@ -191,16 +191,16 @@
function skyPanel() {
const panel = document.getElementById("sky-panel");
const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text);
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
space.compass = h("span", {class: "compass", "aria-live": "polite"});
panel.append(space.compass,
step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
steer(sky.canvas, "sky", skyHooks);
}
-197
View File
@@ -1,197 +0,0 @@
function shownCursor() {
const glide = space.cursor;
if (glide.to === null) {
return state.until;
}
const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1);
return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress);
}
function sceneInput() {
const span = [Math.min(Math.max(state.since, state.first), state.until), state.until];
const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span;
const lives = {};
const subagents = {};
for (const [key, entry] of space.subs) {
if (entry.data) {
lives[key] = [entry.data.since, entry.data.until];
subagents[key] = entry.data.subagents;
}
}
return {
axis: depth(), range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
};
}
function buildSpace() {
const input = sceneInput();
space.sceneCursor = input.cursor;
space.scene = Space.scene(input);
}
function spaceLabel() {
const hint = AXES.find(([id]) => id === depth())[2];
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
}
function stageSpace() {
if (!graphDom.space) {
const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"});
steer(canvas, "graph", graphHooks);
graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false};
graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list);
}
if (space.cursor.to !== state.until) {
space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()};
}
if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) {
space.trackAsked = Date.now();
load();
}
buildSpace();
const held = graphDom.space;
if (held.data !== state || held.version !== space.version || held.node !== ui.node) {
held.data = state;
held.version = space.version;
held.node = ui.node;
drawSpaceList();
refreshSubs();
}
drawSpaceNote();
requestSpace();
}
function drawSpaceNote() {
const built = space.scene;
const format = (ts) => absolute.format(stampOf(ts));
const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`;
const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`;
if (graphDom.space.words === words) {
return;
}
graphDom.space.words = words;
const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : [];
graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped);
}
function drawSpaceList() {
const built = space.scene;
const held = graphDom.space;
const chip = (line) => {
const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]);
const former = [...new Set(line.timeline.map((entry) => entry.name))];
const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`;
return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]);
};
const twigs = built.lines.filter((line) => line.kind === "twig");
const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null;
if (more) {
more.addEventListener("toggle", () => {
held.open = more.open;
});
}
held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || "");
}
async function refreshSubs() {
if (!graph3d() || space.loading) {
return;
}
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
.filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL);
if (!due.length) {
return;
}
space.loading = true;
await Promise.all(due.map(async (node) => {
let data = null;
try {
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
space.subs.set(node.id, {at: Date.now(), data});
}));
space.loading = false;
space.version += 1;
stage();
}
function spaceStyle() {
if (!space.style || space.styleKey !== sky.mode) {
const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim();
const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"};
space.styleKey = sky.mode;
space.style = {
colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])),
font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe",
};
}
const built = space.scene;
return {
...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand",
format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)),
};
}
function requestSpace() {
if (!space.frame) {
space.frame = window.requestAnimationFrame(paintSpace);
}
}
function paintSpace() {
space.frame = 0;
if (!graph3d() || !graphDom || !graphDom.space || !space.scene) {
return;
}
const canvas = graphDom.space.canvas;
const paint = canvas.getContext("2d");
if (!paint) {
return;
}
const cursor = shownCursor();
if (cursor !== space.sceneCursor) {
buildSpace();
}
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
}
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
space.cam = Space.camera(seenView("graph"), width, height);
Space.paint(paint, space.scene, space.cam, spaceStyle());
if (space.glides.graph || cursor !== space.cursor.to) {
requestSpace();
}
}
function pickLine(line) {
if (line.kind === "twig") {
focusSub(line.session, line.sub);
} else {
select(line.node);
}
}
function pickSpace(x, y) {
const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null;
if (!hit) {
return;
}
if (hit.kind === "line") {
pickLine(hit.line);
} else if (hit.arc.kind === "spawn") {
focusSub(hit.arc.source, hit.arc.sub);
} else if (hit.arc.kind === "grant") {
select(hit.arc.target);
} else {
useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)});
}
}
+3 -35
View File
@@ -1,30 +1,10 @@
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
function spaceTools() {
return h("div", {class: "space-tools"},
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(depth() === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht"),
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
}
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
const wantsTrack = () => graph3d() && depth() === "time";
function chooseAxis(id) {
if (id === depth()) {
return;
}
setView("graph", {...space.views.graph, axis: id}, false);
render(false);
}
function stage() {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
return;
}
graphDom.pulses = null;
if (graph3d()) {
stageSpace();
if (ui.views.agents === "3d") {
stage3d("agents", graphDom.stage);
return;
}
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
@@ -59,9 +39,7 @@
space.edits += 1;
window.clearTimeout(space.timer);
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
if (kind === "graph") {
requestSpace();
} else {
if (kind === "sky") {
redrawSky();
}
}
@@ -168,16 +146,6 @@
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
}
const graphHooks = {
rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false),
pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false),
zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false),
turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true),
panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true),
zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true),
pick: (x, y) => pickSpace(x, y),
};
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
const skyHooks = {
+121
View File
@@ -0,0 +1,121 @@
const spawn = {subs: new Map(), loading: false, dom: null};
const SPAWN_KINDS = {operator: "bi-person-circle", chair: "bi-award", session: "bi-robot", subagent: "bi-cpu", twig: "bi-cpu"};
function sessionRunning(node) {
const life = (spawn.subs.get(node.id) || {}).data;
if (life && life.died) {
return false;
}
return node.claims.some((claim) => claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT);
}
function twigs(session) {
const data = (spawn.subs.get(session) || {}).data;
const rows = data ? data.subagents : [];
const roots = [];
const open = [];
for (const row of rows) {
const twig = {id: `${session}/${row.id}`, kind: "twig", label: `${row.type}: ${row.description}`, running: !row.finished, row, session, children: []};
open.length = Math.min(open.length, Math.max(row.depth - 1, 0));
(open.length ? open[open.length - 1].children : roots).push(twig);
open.push(twig);
}
return roots;
}
function spawnTree() {
const nodes = state.graph.nodes;
const chair = nodes.find((node) => node.kind === "chair");
const entry = (node) => ({
id: node.id, kind: node.kind, label: node.label, node,
running: node.kind === "operator" ? null : node.kind === "subagent" ? node.tasks.some((task) => task.status === "running") : sessionRunning(node),
children: [],
});
const root = entry(nodes.find((node) => node.kind === "operator"));
const byId = new Map([[root.id, root]]);
if (chair) {
byId.set(chair.id, entry(chair));
root.children.push(byId.get(chair.id));
}
for (const node of nodes.filter((item) => item.kind === "session")) {
byId.set(node.id, entry(node));
(byId.get(chairKey()) || root).children.push(byId.get(node.id));
}
for (const node of nodes.filter((item) => item.kind === "subagent")) {
(byId.get(node.parent) || byId.get(chairKey()) || root).children.push(entry(node));
}
for (const [id, held] of byId) {
if (held.kind === "session" || held.kind === "chair") {
held.children.push(...twigs(id));
}
}
return root;
}
function spawnFlat(root) {
const found = [];
const walk = (entry, depth, parent) => {
found.push({entry, depth, parent});
entry.children.forEach((child) => walk(child, depth + 1, entry));
};
walk(root, 0, null);
return found;
}
async function loadSpawn() {
if (spawn.loading) {
return;
}
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
.filter((node) => !spawn.subs.has(node.id) || Date.now() - spawn.subs.get(node.id).at > SUBS_TTL);
if (!due.length) {
return;
}
spawn.loading = true;
await Promise.all(due.map(async (node) => {
let data = null;
try {
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
spawn.subs.set(node.id, {at: Date.now(), data});
}));
spawn.loading = false;
drawSpawn();
stage();
}
function spawnItem(entry) {
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "läuft", "ok") : chip("bi-check-circle", "fertig", "neutral");
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Transkript im Stream zeigen" : `${entry.label} öffnen`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Tiefe ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
}
function spawnScreen() {
const list = h("div", {class: "card spawn"});
spawn.dom = {list};
window.setTimeout(() => {
drawSpawn();
loadSpawn();
}, 0);
return list;
}
function drawSpawn() {
if (!spawn.dom || !spawn.dom.list.isConnected) {
spawn.dom = null;
return;
}
const root = spawnTree();
const all = spawnFlat(root).map(({entry}) => entry).filter((entry) => entry.running !== null);
const running = all.filter((entry) => entry.running).length;
spawn.dom.list.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn-Baum", counter(all.length)), h("span", {class: "muted"}, `${running} laufen · ${all.length - running} fertig · Operator → Chair → Sessions → Subagenten`)),
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
}
+2 -2
View File
@@ -60,13 +60,13 @@
}
function micButton(field) {
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Spracheingabe", "Spracheingabe: gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet")}, icon("bi-mic"));
const reason = !window.isSecureContext || !navigator.mediaDevices
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
if (reason) {
button.disabled = true;
button.title = reason;
button.title = `Spracheingabe: ${reason}`;
return button;
}
const press = (event) => {
+2 -4
View File
@@ -30,7 +30,7 @@
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null};
const known = {inbox: null, alarms: null, radio: null, batches: new Set()};
const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined};
@@ -43,9 +43,7 @@
const speech = {pending: [], busy: false, warned: 0};
const mic = {recorder: null, held: false, busy: false, timer: 0};
const space = {
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0,
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
};
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
let settings = state.settings;
+2 -2
View File
@@ -115,7 +115,7 @@
return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
}
if (entry.kind === "review") {
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, btn("Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
}
if (entry.kind === "cron") {
const ref = entry.ref;
@@ -174,7 +174,7 @@
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
const focus = h("div", {class: "chips"});
const list = h("div", {});
const more = h("details", {class: "menu"}, h("summary", {}, "Filter ▾"), h("div", {class: "menu-box end"},
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: Arten, Transkript-Teile, gespeicherte Ansichten, eigener Zeitraum"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
window.setTimeout(() => {
+3 -3
View File
@@ -36,7 +36,7 @@
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
const play = iconBtn("bi-play-fill", "Wiedergabe", "btn small soft", null);
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
const label = h("span", {class: "window-label"});
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
@@ -102,7 +102,7 @@
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
let progress = resume ? (ui.asOf - began) / span : 0;
ui.playing = true;
timeDom.play.textContent = "❚❚ Pause";
relabel(timeDom.play, "bi-pause-fill", "Pause");
const advance = async () => {
if (!ui.playing) {
return;
@@ -127,6 +127,6 @@
ui.playing = false;
window.clearTimeout(replay);
if (timeDom) {
timeDom.play.textContent = "▶ Wiedergabe";
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
}
}
+93 -44
View File
@@ -1,44 +1,84 @@
function treeScreen() {
const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
tree.q = event.target.value;
drawTree();
}}});
const count = h("span", {class: "muted"});
const list = h("div", {class: "repos"});
tree.dom = {list, count};
window.setTimeout(() => {
drawTree();
fetchTree();
}, 0);
return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list);
const chip = (name, label, tone, count) => h("span", {class: "state", "data-tone": tone, role: "img", ...named(label)}, icon(name), count === undefined ? null : String(count));
function branchStates(entry) {
return [
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `gemergt${entry.merged_by ? ` durch ${entry.merged_by}` : ""}${entry.merged_into ? ` in ${entry.merged_into}` : ""}`, "ok") : null,
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
];
}
async function fetchTree() {
if (tree.busy) {
return;
function worktreeStates(wt) {
const counts = [wt.staged, wt.unstaged, wt.untracked];
const changed = counts.every((value) => typeof value === "number") ? counts.reduce((sum, value) => sum + value, 0) : undefined;
return [
wt.state === "clean" ? chip("bi-check-circle", "sauber", "ok") : null,
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "nicht committete Änderungen" : `nicht committet: ${wt.staged} gestaged, ${wt.unstaged} geändert, ${wt.untracked} neu`, "warn", changed) : null,
wt.state === "missing" ? chip("bi-question-circle", "Worktree fehlt", "neutral") : null,
wt.locked ? chip("bi-lock-fill", `gesperrt: ${wt.locked}`, "warn") : null,
];
}
function deleteButton(entry) {
if (!entry.cleanup) {
return null;
}
tree.busy = true;
try {
const response = await fetch("/api/tree", {cache: "no-store"});
if (response.ok) {
tree.data = await response.json();
}
} catch {
ui.offline = true;
}
tree.busy = false;
drawTree();
const ready = tree.data.delete_ready === true;
const hint = ready ? `Löschen: ${entry.name} lokal entfernen, nach Trockenlauf und Signatur` : `Löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}`;
const button = iconBtn("bi-trash3", "Löschen", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button);
}
function deleteAll() {
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
const ready = tree.data.delete_ready === true && items.length > 0;
const hint = tree.data.delete_ready !== true ? `Alle gemergten löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}` : items.length ? `Alle gemergten löschen: ${items.length} Branches nach Trockenlauf und Signatur` : "Alle gemergten löschen: kein gemergter Branch ohne Skip-Grund";
const button = iconBtn("bi-trash3-fill", "Alle gemergten löschen", "btn small danger", () => deleteDialog(items), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
}
function leafRow(found) {
return h("li", {class: "leaf"}, h("span", {class: "state", "data-tone": found.ended ? "warn" : "neutral", role: "img", ...named(found.kind)}, icon(ARTEFACT_ICONS[found.kind] || "bi-paperclip")),
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
}
function worktreeRow(wt) {
const review = wt.review;
return h("div", {class: "wt"},
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path));
h("div", {class: "wt-head"},
h("span", {class: "states"}, worktreeStates(wt)),
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path)),
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
}
function resultOf(repo, entry) {
const found = (tree.data.cleanups || []).flatMap((result) => result.items.map((item) => ({...item, finished: result.finished}))).filter((item) => item.repository === repo.path && item.branch === entry.name);
return found.length ? found[found.length - 1] : null;
}
function resultChip(result) {
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "gelöscht", "ok"] : result.status === "refused" ? ["bi-x-octagon", "verweigert", "bad"] : ["bi-exclamation-triangle", "fehlgeschlagen", "bad"];
return chip(name, `${label}${result.reason ? `: ${result.reason}` : ""}`, tone);
}
function branchRow(repo, entry) {
const result = resultOf(repo, entry);
return h("div", {class: "branch-row"},
h("div", {class: "branch-name"}, h("strong", {}, entry.name), h("span", {class: "states"}, branchStates(entry)), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : null,
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), deleteButton(entry)),
entry.worktrees.map(worktreeRow));
}
function drawTree() {
@@ -47,9 +87,11 @@
}
if (!tree.data || tree.data.error) {
tree.dom.count.textContent = "";
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
tree.dom.removeAll.replaceChildren();
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
return;
}
tree.dom.removeAll.replaceChildren(deleteAll());
const q = tree.q.trim().toLowerCase();
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
const repos = [];
@@ -60,21 +102,22 @@
repos.push({repo, branches, detached});
}
}
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
if (!repos.length) {
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
const listed = new Set(tree.data.repos.flatMap((repo) => repo.branches.map((entry) => `${repo.path}\u0000${entry.name}`)));
const gone = (tree.data.cleanups || []).flatMap((result) => result.items).filter((item) => !listed.has(`${item.repository}\u0000${item.branch}`) && hit(item.repository, item.branch));
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Löschen gesperrt: ${tree.data.delete_reason}`}`;
if (!repos.length && !tasks.length && !gone.length) {
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
return;
}
tree.dom.list.replaceChildren(...repos.map(({repo, branches, detached}) => {
const cards = repos.map(({repo, branches, detached}) => {
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
const dirty = trees.filter((wt) => wt.dirty).length;
const merged = repo.branches.filter((entry) => entry.cleanup).length;
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null),
branches.map((entry) => h("div", {class: "branch-row"},
h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"),
entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null,
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))),
entry.worktrees.map(worktreeRow))),
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
if (details.open) {
@@ -84,5 +127,11 @@
}
});
return details;
}));
});
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Aufgaben ohne Worktree"), counter(tasks.length)),
tasks.map((task) => h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, task.task), h("span", {class: "muted"}, task.title), status(task.status)),
h("ul", {class: "leaves", "aria-label": "Artefakte"}, task.artefacts.map(leafRow))))) : null;
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Zuletzt gelöscht"), counter(gone.length)),
gone.map((item) => h("div", {class: "branch-row", "data-result": item.status}, h("div", {class: "branch-name"}, h("strong", {}, item.branch), h("code", {}, item.repository), resultChip(item))))) : null;
tree.dom.list.replaceChildren(...cards, loose || "", past || "");
}
@@ -0,0 +1,5 @@
// Version 1.80.1 3d-force-graph - https://github.com/vasturiano/3d-force-graph
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).ForceGraph3D=t()}(this,function(){"use strict";!function(e,t){void 0===t&&(t={});var n=t.insertAt;if("undefined"!=typeof document){var i=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css","top"===n&&i.firstChild?i.insertBefore(r,i.firstChild):i.appendChild(r),r.styleSheet?r.styleSheet.cssText=e:r.appendChild(document.createTextNode(e))}}(".graph-info-msg {\n top: 50%;\n width: 100%;\n text-align: center;\n color: lavender;\n opacity: 0.7;\n font-size: 22px;\n position: absolute;\n font-family: Sans-serif;\n}\n\n.scene-container .clickable {\n cursor: pointer;\n}\n\n.scene-container .grabbable {\n cursor: move;\n cursor: grab;\n cursor: -moz-grab;\n cursor: -webkit-grab;\n}\n\n.scene-container .grabbable:active {\n cursor: grabbing;\n cursor: -moz-grabbing;\n cursor: -webkit-grabbing;\n}");const e="186",t=0,n=1,i=2,r=0,s=1,a=2,o=3,l=1,u=3,c=0,h=1,d=2,p=100,f=101,m=102,g=103,_=104,v=200,y=201,b=202,x=203,S=204,T=205,M=206,w=207,E=208,A=209,R=210,C=211,N=212,P=213,L=214,D=0,I=1,U=2,F=3,O=4,B=5,k=6,z=7,V=0,G=1,H=2,W=1,j=2,$=3,X=4,q=5,Y=6,K=7,Z=301,Q=302,J=303,ee=304,te=306,ne=1e3,ie=1001,re=1002,se=1003,ae=1004,oe=1005,le=1006,ue=1007,ce=1008,he=1009,de=1010,pe=1011,fe=1012,me=1013,ge=1014,_e=1015,ve=1016,ye=1017,be=1018,xe=1020,Se=35902,Te=35899,Me=1021,we=1022,Ee=1023,Ae=1026,Re=1027,Ce=1028,Ne=1029,Pe=1030,Le=1031,De=1032,Ie=1033,Ue=33776,Fe=33777,Oe=33778,Be=33779,ke=35840,ze=35841,Ve=35842,Ge=35843,He=36196,We=37492,je=37496,$e=37488,Xe=37489,qe=37490,Ye=37491,Ke=37808,Ze=37809,Qe=37810,Je=37811,et=37812,tt=37813,nt=37814,it=37815,rt=37816,st=37817,at=37818,ot=37819,lt=37820,ut=37821,ct=36492,ht=36494,dt=36495,pt=36283,ft=36284,mt=36285,gt=36286,_t="",vt="srgb",yt="srgb-linear",bt="linear",xt="srgb",St=7680,Tt=512,Mt=513,wt=514,Et=515,At=516,Rt=517,Ct=518,Nt=519,Pt=35044,Lt=35048,Dt="300 es",It=2e3,Ut=2001,Ft="compute",Ot="render",Bt="depthTextureCompare",kt=0,zt=1,Vt=2;function Gt(e){return ArrayBuffer.isView(e)&&!(e instanceof DataView)}function Ht(e){return document.createElementNS("http://www.w3.org/1999/xhtml",e)}function Wt(){const e=Ht("canvas");return e.style.display="block",e}const jt={};function $t(...e){const t="THREE."+e.shift();console.log(t,...e)}function Xt(e){const t=e[0];if("string"==typeof t&&t.startsWith("TSL:")){const t=e[1];t&&t.isStackTrace?e[0]+=" "+t.getLocation():e[1]='Stack trace not available. Enable "THREE.Node.captureStackTrace" to capture stack traces.'}return e}function qt(...e){const t="THREE."+(e=Xt(e)).shift();{const n=e[0];n&&n.isStackTrace?console.warn(n.getError(t)):console.warn(t,...e)}}function Yt(...e){const t="THREE."+(e=Xt(e)).shift();{const n=e[0];n&&n.isStackTrace?console.error(n.getError(t)):console.error(t,...e)}}function Kt(...e){const t=e.join(" ");t in jt||(jt[t]=!0,qt(...e))}function Zt(){return"undefined"!=typeof self&&void 0!==self.scheduler&&void 0!==self.scheduler.yield?self.scheduler.yield():new Promise(e=>{requestAnimationFrame(e)})}const Qt={[D]:1,[U]:6,[O]:7,[F]:5,[I]:0,[k]:2,[z]:4,[B]:3};class Jt{addEventListener(e,t){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[e]&&(n[e]=[]),-1===n[e].indexOf(t)&&n[e].push(t)}hasEventListener(e,t){const n=this._listeners;return void 0!==n&&(void 0!==n[e]&&-1!==n[e].indexOf(t))}removeEventListener(e,t){const n=this._listeners;if(void 0===n)return;const i=n[e];if(void 0!==i){const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}dispatchEvent(e){const t=this._listeners;if(void 0===t)return;const n=t[e.type];if(void 0!==n){e.target=this;const t=n.slice(0);for(let n=0,i=t.length;n<i;n++)t[n].call(this,e);e.target=null}}}const en=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","Line truncated
// <http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef (WCAG Version 2)
// Analyze the 2 colors and returns the color contrast defined by (WCAG Version 2)
op.readability=function(e,t){var n=op(e),i=op(t);return(Math.max(n.getLuminance(),i.getLuminance())+.05)/(Math.min(n.getLuminance(),i.getLuminance())+.05)},op.isReadable=function(e,t,n){var i,r,s=op.readability(e,t);switch(r=!1,(i=function(e){var t,n;t=((e=e||{level:"AA",size:"small"}).level||"AA").toUpperCase(),n=(e.size||"small").toLowerCase(),"AA"!==t&&"AAA"!==t&&(t="AA");"small"!==n&&"large"!==n&&(n="small");return{level:t,size:n}}(n)).level+i.size){case"AAsmall":case"AAAlarge":r=s>=4.5;break;case"AAlarge":r=s>=3;break;case"AAAsmall":r=s>=7}return r},op.mostReadable=function(e,t,n){var i,r,s,a,o=null,l=0;r=(n=n||{}).includeFallbackColors,s=n.level,a=n.size;for(var u=0;u<t.length;u++)(i=op.readability(e,t[u]))>l&&(l=i,o=op(t[u]));return op.isReadable(e,o,{level:s,size:a})||!r?o:(n.includeFallbackColors=!1,op.mostReadable(e,["#fff","#000"],n))};var Mp=op.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"},wp=op.hexNames=function(e){var t={};for(var n in e)e.hasOwnProperty(n)&&(t[e[n]]=n);return t}(Mp);function Ep(e){return e=parseFloat(e),(isNaN(e)||e<0||e>1)&&(e=1),e}function Ap(e,t){(function(e){return"string"==typeof e&&-1!=e.indexOf(".")&&1===parseFloat(e)})(e)&&(e="100%");var n=function(e){return"string"==typeof e&&-1!=e.indexOf("%")}(e);return e=Math.min(t,Math.max(0,parseFloat(e))),n&&(e=parseInt(e*t,10)/100),Math.abs(e-t)<1e-6?1:e%t/parseFloat(t)}function Rp(e){return Math.min(1,Math.max(0,e))}function Cp(e){return parseInt(e,16)}function Np(e){return 1==e.length?"0"+e:""+e}function Pp(e){return e<=1&&(e=100*e+"%"),e}function Lp(e){return Math.round(255*parseFloat(e)).toString(16)}function Dp(e){return Cp(e)/255}var Ip,Up,Fp,Op=(Up="[\\s|\\(]+("+(Ip="(?:[-\\+]?\\d*\\.\\d+%?)|(?:[-\\+]?\\d+%?)")+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")\\s*\\)?",Fp="[\\s|\\(]+("+Ip+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")\\s*\\)?",{CSS_UNIT:new RegExp(Ip),rgb:new RegExp("rgb"+Up),rgba:new RegExp("rgba"+Fp),hsl:new RegExp("hsl"+Up),hsla:new RegExp("hsla"+Fp),hsv:new RegExp("hsv"+Up),hsva:new RegExp("hsva"+Fp),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]Line truncated
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Vasco Asturiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019-2024 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol class="bi bi-0-circle" viewBox="0 0 16 16" id="0-circle"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8"/></symbol><symbol class="bi bi-0-circle-fill" viewBox="0 0 16 16" id="0-circle-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-8.012 4.158c1.858 0 2.96-1.582 2.96-3.99V7.84c0-2.426-1.079-3.996-2.936-3.996-1.864 0-2.965 1.588-2.965 3.996v.328c0 2.42 1.09 3.99 2.941 3.99"/></symbol><symbol class="bi bi-0-square" viewBox="0 0 16 16" id="0-square"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-0-square-fill" viewBox="0 0 16 16" id="0-square-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm5.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99"/></symbol><symbol class="bi bi-1-circle" viewBox="0 0 16 16" id="1-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-1-circle-fill" viewBox="0 0 16 16" id="1-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002H7.971L6.072 5.385v1.271l1.834-1.318h.065V12h1.312z"/></symbol><symbol class="bi bi-1-square" viewBox="0 0 16 16" id="1-square"><path d="M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-1-square-fill" viewBox="0 0 16 16" id="1-square-fill"><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm7.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-123" viewBox="0 0 16 16" id="123"><path d="M2.873 11.297V4.142H1.699L0 5.379v1.137l1.64-1.18h.06v5.961zm3.213-5.09v-.063c0-.618.44-1.169 1.196-1.169.676 0 1.174.44 1.174 1.106 0 .624-.42 1.101-.807 1.526L4.99 10.553v.744h4.78v-.99H6.643v-.069L8.41 8.252c.65-.724 1.237-1.332 1.237-2.27C9.646 4.849 8.723 4 7.308 4c-1.573 0-2.36 1.064-2.36 2.15v.057zm6.559 1.883h.786c.823 0 1.374.481 1.379 1.179.01.707-.55 1.216-1.421 1.21-.77-.005-1.326-.419-1.379-.953h-1.095c.042 1.053.938 1.918 2.464 1.918 1.478 0 2.642-.839 2.62-2.144-.02-1.143-.922-1.651-1.551-1.714v-.063c.535-.09 1.347-.66 1.326-1.678-.026-1.053-.933-1.855-2.359-1.845-1.5.005-2.317.88-2.348 1.898h1.116c.032-.498.498-.944 1.206-.944.703 0 1.206.435 1.206 1.07.005.64-.504 1.106-1.2 1.106h-.75z"/></symbol><symbol class="bi bi-2-circle" viewBox="0 0 16 16" id="2-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/></symbol><symbol class="bi bi-2-circle-fill" viewBox="0 0 16 16" id="2-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24c0-.691.493-1.306 1.336-1.306.756 0 1.313.492 1.313 1.236 0 .697-.469 1.23-.902 1.705l-2.971 3.293V12h5.344v-1.107H7.268v-.077l1.974-2.22.096-.107c.688-.763 1.287-1.428 1.287-2.43 0-1.266-1.031-2.215-2.613-2.215-1.758 0-2.637 1.19-2.637 2.402v.065h1.271v-.07Z"/></symbol><symbol class="bi bi-2-square" viewBox="0 0 16 16" id="2-square"><path d="M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-2-square-fill" viewBox="0 0 Line truncated

After

Width:  |  Height:  |  Size: 1.1 MiB

+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff