feat(orchestrator): S5.3 agent states, Status menu and 3D faces
Every Agents view marks each agent as working, alive, sleeping, suspending, unreachable or dead with an animated symbol; a Status menu hides states and switches the 3D faces, kept through a reload. The hub's container sees neither ~/.claude/sessions nor host processes, so the host timer kvb-orchestrator-sessions.timer runs client/sessions.py every 15 seconds while the page polls: it reads the session records without following symlinks, only regular files up to 64 KiB, keeps name, status and statusUpdatedAt within type and length bounds, checks the pid's process start, and writes agent-sessions.json atomically. The hub ignores a snapshot that is missing, malformed, older than five minutes or dated in the future. make orchestrator-install enables the timer and orchestrator-check reports it. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 86 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57ab29eb0d
commit
fdf8722fc1
24 files changed
+798
-13
No files matched your search
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--sessions: Claude Code's sessions directory, one <pid>.json per session,
|
||||
e.g. ~/.claude/sessions.
|
||||
--output: the snapshot the Agent Orchestrator reads, e.g.
|
||||
~/.local/state/kvb-orchestrator/agent-sessions.json.
|
||||
|
||||
Returns:
|
||||
Runs on the host, because the hub's container sees neither these files
|
||||
nor the host's processes: replaces --output with {"written": <epoch>,
|
||||
"sessions": {<session id>: {"name", "status", "statusUpdatedAt",
|
||||
"running"}}}, running being whether the record's pid still runs the
|
||||
process it started. The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROC = Path("/proc")
|
||||
KEPT = ("name", "status", "statusUpdatedAt")
|
||||
RECORD_BYTES = 65536
|
||||
VALUE_CHARS = 200
|
||||
|
||||
|
||||
def read_record(path: Path) -> object:
|
||||
"""Returns:
|
||||
The parsed JSON of path, read without following a symlink and only
|
||||
when it is a regular file of at most RECORD_BYTES.
|
||||
|
||||
Raises:
|
||||
OSError: path is missing, a symlink, no regular file or too big.
|
||||
ValueError, RecursionError: its content is no JSON or nested too deep.
|
||||
"""
|
||||
handle = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(handle, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > RECORD_BYTES:
|
||||
raise OSError(f"{path}: no regular file of at most {RECORD_BYTES} bytes")
|
||||
return json.loads(stream.read(RECORD_BYTES + 1).decode("utf-8"))
|
||||
|
||||
|
||||
def kept(record: dict) -> dict:
|
||||
return {
|
||||
key: record[key]
|
||||
for key in KEPT
|
||||
if isinstance(record.get(key), str | int | float) and not isinstance(record[key], bool) and len(str(record[key])) <= VALUE_CHARS
|
||||
}
|
||||
|
||||
|
||||
def read_sessions(folder: Path) -> dict[str, dict]:
|
||||
"""Returns:
|
||||
The session records under folder by session id; unreadable files and
|
||||
records without a session id are left out.
|
||||
"""
|
||||
found = {}
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
try:
|
||||
data = read_record(path)
|
||||
except (OSError, ValueError, RecursionError):
|
||||
continue
|
||||
if isinstance(data, dict) and isinstance(data.get("sessionId"), str):
|
||||
found[data["sessionId"]] = data
|
||||
return found
|
||||
|
||||
|
||||
def running(record: dict, proc: Path) -> bool:
|
||||
"""Returns:
|
||||
Whether the record's pid still runs the process it started: the
|
||||
start tick in /proc/<pid>/stat (field 22) equals procStart, so a
|
||||
reused pid counts as gone.
|
||||
"""
|
||||
pid, start = record.get("pid"), record.get("procStart")
|
||||
if isinstance(pid, bool) or not isinstance(pid, int) or not isinstance(start, str):
|
||||
return False
|
||||
try:
|
||||
fields = (proc / str(pid) / "stat").read_text(encoding="utf-8").rpartition(")")[2].split()
|
||||
except OSError:
|
||||
return False
|
||||
return len(fields) > 19 and fields[19] == start
|
||||
|
||||
|
||||
def snapshot(folder: Path, proc: Path, now: float) -> dict:
|
||||
return {
|
||||
"written": now,
|
||||
"sessions": {
|
||||
sid: {**kept(record), "running": running(record, proc)}
|
||||
for sid, record in read_sessions(folder).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write(output: Path, data: dict) -> None:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, prefix=f".{output.name}.", delete=False) as handle:
|
||||
try:
|
||||
json.dump(data, handle)
|
||||
except BaseException:
|
||||
os.unlink(handle.name)
|
||||
raise
|
||||
os.replace(handle.name, output)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Snapshot Claude Code's sessions for the Agent Orchestrator.")
|
||||
parser.add_argument("--sessions", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
write(args.output, snapshot(args.sessions, PROC, time.time()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -169,6 +169,25 @@ def night_mode(council: Path) -> str:
|
||||
return mode if mode in MODES else "day"
|
||||
|
||||
|
||||
def night_parts(council: Path) -> dict[str, str]:
|
||||
"""Args:
|
||||
council: the council directory with night.json.
|
||||
|
||||
Returns:
|
||||
Per session the part that differs from night_mode: drain or suspend
|
||||
for one set alone, day for the active and the resumed sessions while
|
||||
a mode holds.
|
||||
"""
|
||||
data = read_json(council / "night.json")
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
resumed = data.get("resumed", data.get("awake"))
|
||||
working = [data.get("active"), *(resumed if isinstance(resumed, list) else [])]
|
||||
parts = {sid: "day" for sid in working if isinstance(sid, str)} if night_mode(council) != "day" else {}
|
||||
alone = data.get("parts")
|
||||
return {**parts, **({sid: mode for sid, mode in alone.items() if mode in MODES} if isinstance(alone, dict) else {})}
|
||||
|
||||
|
||||
def silence_of(council: Path, now: float) -> dict | None:
|
||||
data = read_json(council / "radio-silence.json")
|
||||
if not isinstance(data, dict):
|
||||
|
||||
@@ -25,6 +25,7 @@ from .mcp import Mcp
|
||||
from .messaging import Messaging
|
||||
from .names import Names
|
||||
from .offices import Offices
|
||||
from .presence import SNAPSHOT
|
||||
from .redirects import Redirects
|
||||
from .requesting import Requesting
|
||||
from .reviewing import Reviewing
|
||||
@@ -85,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Reque
|
||||
names: Names
|
||||
watch: Watch
|
||||
tree: Tree
|
||||
agent_sessions: Path
|
||||
model: Model
|
||||
timers: Path
|
||||
settings: Settings
|
||||
@@ -132,6 +134,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
||||
redirects=redirects,
|
||||
watch=Watch(),
|
||||
tree=Tree(file=places.tree_file, heartbeat=state / TREE_HEARTBEAT, lock=threading.Lock(), cached=None, stamp=None),
|
||||
agent_sessions=state / SNAPSHOT,
|
||||
model=model,
|
||||
timers=places.timers,
|
||||
council=council,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .council import HUMAN, MODES, agent
|
||||
from .records import epoch, read_json
|
||||
|
||||
STATES = ("sleeping", "suspending", "alive", "working", "dead", "unreachable")
|
||||
STALE = 15 * 60
|
||||
UNANSWERED = 10 * 60
|
||||
SNAPSHOT = "agent-sessions.json"
|
||||
SNAPSHOT_STALE = 5 * 60
|
||||
|
||||
|
||||
def read_snapshot(path: Path, now: float) -> dict[str, dict]:
|
||||
"""Args:
|
||||
path: the snapshot client/sessions.py writes on the host.
|
||||
now: the moment to judge at.
|
||||
|
||||
Returns:
|
||||
Its session records by session id; empty when it is missing,
|
||||
malformed, written more than SNAPSHOT_STALE ago or dated in the
|
||||
future.
|
||||
"""
|
||||
data = read_json(path)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
|
||||
return {}
|
||||
written = data.get("written")
|
||||
if isinstance(written, bool) or not isinstance(written, int | float) or not 0 <= now - written <= SNAPSHOT_STALE:
|
||||
return {}
|
||||
return {sid: record for sid, record in data["sessions"].items() if isinstance(record, dict)}
|
||||
|
||||
|
||||
def unanswered(news: list[dict], now: float) -> list[tuple[str, float]]:
|
||||
"""Returns:
|
||||
(callee name, call time) of every call older than UNANSWERED that its
|
||||
callee never receipted.
|
||||
"""
|
||||
receipts = {(agent(row.get("from")), round(epoch(row.get("call")) or 0, 3)) for row in news if row.get("class") == "receipt"}
|
||||
late = []
|
||||
for row in news:
|
||||
ts = epoch(row.get("ts"))
|
||||
if row.get("class") != "call" or ts is None or now - ts < UNANSWERED or not isinstance(row.get("to"), list):
|
||||
continue
|
||||
late += [(name, ts) for name in map(agent, row["to"]) if name and (name, round(ts, 3)) not in receipts]
|
||||
return late
|
||||
|
||||
|
||||
def presence(sessions: dict[str, dict], parts: dict[str, str], mode: str, claims: list[dict], news: list[dict], now: float) -> dict[str, str]:
|
||||
"""Args:
|
||||
sessions: the session records of read_snapshot; running says
|
||||
whether a record's process still runs.
|
||||
parts: night_parts, a session's own part over the mode.
|
||||
mode: night_mode, drain, suspend or day.
|
||||
claims: the council claims; a session without a record shows alive
|
||||
or dead by its freshest beat.
|
||||
news: the radio rows, for calls without a receipt.
|
||||
now: the moment to judge at.
|
||||
|
||||
Returns:
|
||||
One of STATES per session id (decision 59).
|
||||
"""
|
||||
late = unanswered(news, now)
|
||||
beats: dict[str, list[float]] = {}
|
||||
names: dict[str, set[str]] = {}
|
||||
for claim in claims:
|
||||
if claim["session"] != "-" and claim["agent"] != HUMAN:
|
||||
beats.setdefault(claim["session"], []).append(claim["beat"] or 0)
|
||||
names.setdefault(claim["session"], set()).add(claim["agent"])
|
||||
states = {sid: "alive" if now - max(beat) < STALE else "dead" for sid, beat in beats.items() if sid not in sessions}
|
||||
for sid, record in sessions.items():
|
||||
if record.get("running") is not True:
|
||||
states[sid] = "dead"
|
||||
continue
|
||||
busy = record.get("status") == "busy"
|
||||
stamp = record.get("statusUpdatedAt")
|
||||
updated = (epoch(stamp / 1000) if isinstance(stamp, int | float) and not isinstance(stamp, bool) else None) or 0
|
||||
called = names.get(sid, set()) | {agent(record.get("name"))}
|
||||
if (busy and now - updated > STALE) or any(name in called and ts > updated for name, ts in late):
|
||||
states[sid] = "unreachable"
|
||||
continue
|
||||
part = parts.get(sid, mode)
|
||||
if part in MODES:
|
||||
states[sid] = "suspending" if busy else "sleeping"
|
||||
else:
|
||||
states[sid] = "working" if busy else "alive"
|
||||
return states
|
||||
@@ -8,6 +8,7 @@ from .council import (
|
||||
NEWS,
|
||||
grants,
|
||||
night_mode,
|
||||
night_parts,
|
||||
radio_rows,
|
||||
read_claims,
|
||||
sessions,
|
||||
@@ -22,6 +23,7 @@ from .jobs import JOB_OPEN
|
||||
from .messaging import message_row
|
||||
from .orbits import TRACK_STEPS, track
|
||||
from .phrasing import phrase
|
||||
from .presence import presence, read_snapshot
|
||||
from .records import epoch, iso_epoch, read_jsonl
|
||||
from .register import (
|
||||
AGENT_ORDER,
|
||||
@@ -138,6 +140,8 @@ class View:
|
||||
"claims": claims,
|
||||
"grants": grant_rows,
|
||||
"night": night_mode(self.council),
|
||||
"night_parts": night_parts(self.council),
|
||||
"presence": presence(read_snapshot(self.agent_sessions, now), night_parts(self.council), night_mode(self.council), claims, news, now),
|
||||
"silence": silence_of(self.council, now),
|
||||
"graph": drawn,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator sessions: Claude Code's sessions and whether their process runs, into the file the orchestrator reads, while its page polls
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=PATH=/usr/bin:/bin
|
||||
ExecCondition=/bin/sh -c 'test -n "$(find %h/.local/state/kvb-orchestrator/tree-heartbeat -mmin -5 2>/dev/null)"'
|
||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/sessions.py --sessions %h/.claude/sessions --output %h/.local/state/kvb-orchestrator/agent-sessions.json
|
||||
Nice=10
|
||||
TimeoutStartSec=60
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator sessions 15 seconds after its last run
|
||||
|
||||
[Timer]
|
||||
OnActiveSec=5s
|
||||
OnUnitInactiveSec=15s
|
||||
AccuracySec=1s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -15,6 +15,7 @@
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
agentDom.info.replaceChildren(
|
||||
...[presenceMark(ui.agent, true)].filter(Boolean),
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
|
||||
@@ -84,10 +84,32 @@
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||
return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""};
|
||||
}
|
||||
|
||||
function lookBox(id, label, on, run) {
|
||||
const state = Object.hasOwn(PRESENCE, id) ? {class: "check presence", "data-state": id, "data-tone": PRESENCE[id][2]} : {class: "check"};
|
||||
return h("label", state, h("input", {type: "checkbox", checked: on, "data-look": id, on: {change: (event) => run(event.target.checked)}}), state["data-state"] ? icon(PRESENCE[id][1], "glyph") : null, label);
|
||||
}
|
||||
|
||||
function setLook(change) {
|
||||
change();
|
||||
keep(STORE.status, {hidden: [...look.hidden], faces: look.faces});
|
||||
render(false);
|
||||
drawAgent();
|
||||
}
|
||||
|
||||
function statusMenu() {
|
||||
return h("details", {class: "menu"}, h("summary", named("Status", "Status: which agent states show their symbol, and faces in 3D"), icon("bi-activity"), caret()),
|
||||
h("div", {class: "menu-box"}, h("h3", {}, "Agent states"),
|
||||
Object.entries(PRESENCE).map(([id, [label]]) => lookBox(id, label, !look.hidden.has(id), (on) => setLook(() => (on ? look.hidden.delete(id) : look.hidden.add(id))))),
|
||||
h("h3", {}, "3D"),
|
||||
lookBox("faces", "Faces in 3D", look.faces, (on) => setLook(() => {
|
||||
look.faces = on;
|
||||
}))));
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
const toolbar = graphDom && graphDom.toolbar;
|
||||
if (!toolbar) {
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
const MOVE = 2000;
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const STORE = {views: "kvb-orchestrator.views", status: "kvb-orchestrator.status"};
|
||||
const PRESENCE = {
|
||||
working: ["Working", "bi-gear-fill", "info"],
|
||||
alive: ["Alive", "bi-heart-pulse-fill", "ok"],
|
||||
sleeping: ["Sleeping", "bi-moon-stars-fill", "violet"],
|
||||
suspending: ["Suspending", "bi-hourglass-split", "warn"],
|
||||
unreachable: ["Unreachable", "bi-wifi-off", "bad"],
|
||||
dead: ["Dead", "bi-x-octagon-fill", "bad"],
|
||||
};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
|
||||
@@ -75,6 +75,20 @@
|
||||
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
|
||||
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
|
||||
};
|
||||
const presenceOf = (key) => {
|
||||
const value = (state.presence || {})[key];
|
||||
return Object.hasOwn(PRESENCE, value) && !look.hidden.has(value) ? value : null;
|
||||
};
|
||||
const presenceMark = (key, labelled) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? h("span", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], role: "img", ...named(PRESENCE[value][0])}, icon(PRESENCE[value][1], "glyph"), labelled ? PRESENCE[value][0] : null) : null;
|
||||
};
|
||||
const presenceGlyph = (key, x, y, size) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? s("g", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], transform: `translate(${fixed(x)} ${fixed(y)})`},
|
||||
s("title", {}, PRESENCE[value][0]), s("rect", {class: "presence-dot", x: fixed(-size * 0.65), y: fixed(-size * 0.65), width: fixed(size * 1.3), height: fixed(size * 1.3), rx: fixed(size * 0.65)}),
|
||||
s("g", {class: "glyph"}, s("use", {href: `#${PRESENCE[value][1]}`, x: fixed(-size / 2), y: fixed(-size / 2), width: fixed(size), height: fixed(size)}))) : null;
|
||||
};
|
||||
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const people = () => state.people || {};
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
lanes.forEach((node, 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", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
|
||||
svg.append(...[presenceGlyph(node.id, left - 14, top + index * lane + lane / 2, 12)].filter(Boolean));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("circle", {r}),
|
||||
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
|
||||
node.kind === "subagent" ? null : presenceGlyph(node.id, r * 0.8, -r * 0.8, Math.max(10, r * 0.7)),
|
||||
caption(node, at, r, gap));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
const SPAWNED = new Set(["twig", "subagent"]);
|
||||
const DEPTH_GAP = 90;
|
||||
const TIME_DEPTH = 640;
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
|
||||
const TOP = {operator: 6, chair: 4, session: 2.5};
|
||||
const g3 = {lib: null, three: null, THREE: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false, badges: new Map(), faces: new Set(), textures: new Map(), pulse: 0};
|
||||
|
||||
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
|
||||
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
|
||||
@@ -84,6 +85,7 @@
|
||||
const nodes = state.graph.nodes.map((node) => ({
|
||||
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
|
||||
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
|
||||
face: look.faces && Object.hasOwn(TOP, node.kind), badge: node.kind === "chair" || node.kind === "session",
|
||||
}));
|
||||
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
|
||||
const walk = (twig, parent, depth) => {
|
||||
@@ -196,7 +198,135 @@
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone, node.face]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
}
|
||||
|
||||
function canvasTexture(size, paint) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
paint(canvas.getContext("2d"));
|
||||
const texture = new g3.THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = g3.THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function cached(key, make) {
|
||||
if (!g3.textures.has(key)) {
|
||||
g3.textures.set(key, make());
|
||||
}
|
||||
return g3.textures.get(key);
|
||||
}
|
||||
|
||||
function paintFace(context, key) {
|
||||
context.scale(128 / Faces.SIZE, 128 / Faces.SIZE);
|
||||
for (const [tag, part] of Faces.of(key).parts) {
|
||||
const path = tag === "path" ? new Path2D(part.d) : new Path2D();
|
||||
if (tag === "ellipse") {
|
||||
path.ellipse(part.cx, part.cy, part.rx, part.ry, 0, 0, 2 * Math.PI);
|
||||
} else if (tag === "rect") {
|
||||
path.roundRect(part.x, part.y, part.width, part.height, part.rx || 0);
|
||||
}
|
||||
context.globalAlpha = part.opacity ?? 1;
|
||||
if (part.fill && part.fill !== "none") {
|
||||
context.fillStyle = part.fill;
|
||||
context.fill(path);
|
||||
}
|
||||
if (part.stroke) {
|
||||
context.globalAlpha *= part["stroke-opacity"] ?? 1;
|
||||
context.strokeStyle = part.stroke;
|
||||
context.lineWidth = part["stroke-width"] || 1;
|
||||
context.lineCap = part["stroke-linecap"] || "butt";
|
||||
context.lineJoin = part["stroke-linejoin"] || "miter";
|
||||
context.stroke(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTexture(value) {
|
||||
const [, name, tone] = PRESENCE[value];
|
||||
const color = g3.colors[tone] || g3.colors.text;
|
||||
return cached(`${value}:${color}`, () => canvasTexture(64, (context) => {
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.92)";
|
||||
context.arc(32, 32, 31, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.fillStyle = color;
|
||||
context.translate(12, 12);
|
||||
context.scale(2.5, 2.5);
|
||||
for (const path of document.querySelectorAll(`#${name} path`)) {
|
||||
context.fill(new Path2D(path.getAttribute("d")), path.getAttribute("fill-rule") || "nonzero");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function count3d() {
|
||||
const box = g3.host && g3.host.querySelector(".graph3d");
|
||||
if (box) {
|
||||
box.dataset.faces = String(g3.faces.size);
|
||||
box.dataset.badges = String([...g3.badges.values()].filter((badge) => badge.visible).length);
|
||||
}
|
||||
if (!g3.pulse && g3.badges.size && !calm.matches) {
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
}
|
||||
|
||||
function paintBadge(key, badge) {
|
||||
const value = presenceOf(key);
|
||||
badge.visible = value !== null;
|
||||
badge.userData.state = value;
|
||||
const map = value ? badgeTexture(value) : null;
|
||||
if (map && badge.material.map !== map) {
|
||||
badge.material.map = map;
|
||||
badge.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function badges3d() {
|
||||
for (const [key, badge] of g3.badges) {
|
||||
paintBadge(key, badge);
|
||||
}
|
||||
count3d();
|
||||
}
|
||||
|
||||
function pulse3d(now) {
|
||||
g3.pulse = 0;
|
||||
if (!g3.graph || !g3.badges.size || calm.matches) {
|
||||
return;
|
||||
}
|
||||
for (const badge of g3.badges.values()) {
|
||||
const {state: value, size} = badge.userData;
|
||||
const beat = value === "alive" ? 1 + 0.2 * Math.max(0, Math.sin(now / 150)) : 1;
|
||||
badge.scale.set(size * beat, size * beat, 1);
|
||||
badge.material.rotation = value === "working" ? now / 500 : value === "suspending" ? Math.PI * (Math.floor(now / 1200) % 2) : 0;
|
||||
badge.material.opacity = ["sleeping", "dead", "unreachable"].includes(value) ? 0.6 + 0.4 * Math.cos(now / (value === "unreachable" ? 200 : 900)) ** 2 : 1;
|
||||
}
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
|
||||
function object3d(node) {
|
||||
const THREE = g3.THREE;
|
||||
if (!THREE || !(node.face || node.badge)) {
|
||||
return null;
|
||||
}
|
||||
const radius = Math.cbrt(TOP[node.kind]) * 5;
|
||||
const group = new THREE.Group();
|
||||
if (node.face) {
|
||||
const face = new THREE.Sprite(new THREE.SpriteMaterial({map: cached(`face:${node.id}`, () => canvasTexture(128, (context) => paintFace(context, node.id)))}));
|
||||
face.scale.set(radius * 2.4, radius * 2.4, 1);
|
||||
group.add(face);
|
||||
g3.faces.add(node.id);
|
||||
}
|
||||
if (node.badge) {
|
||||
const badge = new THREE.Sprite(new THREE.SpriteMaterial());
|
||||
badge.userData.size = radius * 1.1;
|
||||
badge.scale.set(badge.userData.size, badge.userData.size, 1);
|
||||
badge.position.set(0, radius * 1.7, 0);
|
||||
paintBadge(node.id, badge);
|
||||
g3.badges.set(node.id, badge);
|
||||
group.add(badge);
|
||||
}
|
||||
count3d();
|
||||
return group;
|
||||
}
|
||||
|
||||
async function stage3d(tab, host) {
|
||||
@@ -209,6 +339,7 @@
|
||||
const data = model3d(tab);
|
||||
const sig = signature3d(tab, data);
|
||||
if (g3.host === host && g3.sig === sig) {
|
||||
badges3d();
|
||||
return;
|
||||
}
|
||||
if (g3.host !== host) {
|
||||
@@ -232,6 +363,12 @@
|
||||
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
|
||||
return;
|
||||
}
|
||||
if (tab === "agents" && !g3.THREE) {
|
||||
g3.THREE = await threeModule();
|
||||
if (g3.host !== host) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!g3.graph) {
|
||||
try {
|
||||
g3.graph = Graph({controlType: "orbit"})(box);
|
||||
@@ -246,6 +383,7 @@
|
||||
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || 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)))
|
||||
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
|
||||
.nodeThreeObject(object3d).nodeThreeObjectExtend((node) => !node.face)
|
||||
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
|
||||
g3.fit = true;
|
||||
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||
@@ -257,6 +395,8 @@
|
||||
}
|
||||
const layout = layoutOf(tab);
|
||||
place(data.nodes, layout);
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
drawPlanes(layout, data.nodes);
|
||||
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
|
||||
@@ -271,10 +411,14 @@
|
||||
g3.graph._destructor();
|
||||
}
|
||||
}
|
||||
window.cancelAnimationFrame(g3.pulse);
|
||||
g3.pulse = 0;
|
||||
g3.graph = null;
|
||||
g3.host = null;
|
||||
g3.sig = "";
|
||||
g3.planes = [];
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
}
|
||||
|
||||
function refresh3d() {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
function hierarchySig() {
|
||||
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
|
||||
return JSON.stringify([held, state.agent_order, state.people]);
|
||||
return JSON.stringify([held, state.agent_order, state.people, state.presence, [...look.hidden]]);
|
||||
}
|
||||
|
||||
function drawHierarchy(force) {
|
||||
@@ -56,6 +56,7 @@
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
claim.session === "-" ? null : presenceMark(claim.session, false),
|
||||
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${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),
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, entry.kind === "session" || entry.kind === "chair" ? presenceMark(entry.id, false) : null, running, meta),
|
||||
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
|
||||
}
|
||||
|
||||
function validLook(value) {
|
||||
const hidden = value && Array.isArray(value.hidden) ? value.hidden.filter((name) => Object.hasOwn(PRESENCE, name)) : [];
|
||||
return {hidden: new Set(hidden), faces: !(value && value.faces === false)};
|
||||
}
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
@@ -46,6 +51,7 @@
|
||||
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", [])));
|
||||
const look = validLook(stored(STORE.status, null));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let agentDom = null;
|
||||
|
||||
@@ -25,6 +25,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
|
||||
}
|
||||
.presence { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
|
||||
.presence[data-tone="ok"] { color: var(--ok); }
|
||||
.presence[data-tone="bad"] { color: var(--bad); }
|
||||
.presence[data-tone="warn"] { color: var(--warn); }
|
||||
.presence[data-tone="info"] { color: var(--info); }
|
||||
.presence[data-tone="violet"] { color: var(--violet); }
|
||||
svg .presence { fill: currentColor; }
|
||||
svg .presence-dot { fill: var(--panel); stroke: currentColor; stroke-width: 1; }
|
||||
.presence .glyph { transform-box: fill-box; transform-origin: center; }
|
||||
.presence[data-state="working"] .glyph { animation: presence-spin 3s linear infinite; }
|
||||
.presence[data-state="alive"] .glyph { animation: presence-beat 1.6s ease-in-out infinite; }
|
||||
.presence[data-state="sleeping"] .glyph { animation: presence-drift 4s ease-in-out infinite; }
|
||||
.presence[data-state="suspending"] .glyph { animation: presence-flip 2.4s ease-in-out infinite; }
|
||||
.presence[data-state="unreachable"] .glyph { animation: presence-blink 1.2s ease-in-out infinite; }
|
||||
.presence[data-state="dead"] .glyph { animation: presence-fade 5s ease-in-out infinite; }
|
||||
@keyframes presence-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes presence-beat { 15%, 45% { transform: scale(1.25); } 30% { transform: scale(1); } }
|
||||
@keyframes presence-drift { 50% { transform: translateY(-15%); opacity: .6; } }
|
||||
@keyframes presence-flip { 50%, 100% { transform: rotate(180deg); } }
|
||||
@keyframes presence-blink { 50% { opacity: .25; } }
|
||||
@keyframes presence-fade { 50% { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.presence .glyph { animation: none; }
|
||||
}
|
||||
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
Reference in new issue
Block a user