Merge feature/orchestrator/s53-agent-presence into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/requirements/001-agent-orchestrator.md # orchestrator/templates/page-board.js # tests/e2e/test_orchestrator_e2e.py
This commit is contained in:
commit
afebf24703
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 61).
|
||||
"""
|
||||
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", {},
|
||||
|
||||
@@ -91,10 +91,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"}, 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"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||
return {bar, mode};
|
||||
}
|
||||
|
||||
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() {
|
||||
drawSpot();
|
||||
const toolbar = graphDom && graphDom.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;
|
||||
|
||||
@@ -18,6 +18,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.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