@cotal-ai/cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +3 -2
- package/dist/command.js.map +1 -1
- package/dist/commands/channels.d.ts +4 -2
- package/dist/commands/channels.d.ts.map +1 -1
- package/dist/commands/channels.js +17 -18
- package/dist/commands/channels.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +5 -1
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/console.d.ts.map +1 -1
- package/dist/commands/console.js +13 -29
- package/dist/commands/console.js.map +1 -1
- package/dist/commands/down.d.ts +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +13 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/history.d.ts.map +1 -1
- package/dist/commands/history.js +6 -13
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/join.d.ts.map +1 -1
- package/dist/commands/join.js +20 -7
- package/dist/commands/join.js.map +1 -1
- package/dist/commands/meshes.d.ts +5 -0
- package/dist/commands/meshes.d.ts.map +1 -0
- package/dist/commands/meshes.js +25 -0
- package/dist/commands/meshes.js.map +1 -0
- package/dist/commands/mint.d.ts +4 -1
- package/dist/commands/mint.d.ts.map +1 -1
- package/dist/commands/mint.js +26 -3
- package/dist/commands/mint.js.map +1 -1
- package/dist/commands/send.d.ts +8 -15
- package/dist/commands/send.d.ts.map +1 -1
- package/dist/commands/send.js +41 -24
- package/dist/commands/send.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +23 -18
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/spawn.d.ts +4 -2
- package/dist/commands/spawn.d.ts.map +1 -1
- package/dist/commands/spawn.js +54 -22
- package/dist/commands/spawn.js.map +1 -1
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +133 -6
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/use.d.ts +7 -0
- package/dist/commands/use.d.ts.map +1 -0
- package/dist/commands/use.js +27 -0
- package/dist/commands/use.js.map +1 -0
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +57 -27
- package/dist/commands/web.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -37
- package/dist/index.js.map +1 -1
- package/dist/lib/connect.d.ts +72 -0
- package/dist/lib/connect.d.ts.map +1 -0
- package/dist/lib/connect.js +115 -0
- package/dist/lib/connect.js.map +1 -0
- package/dist/lib/delivery-proc.d.ts +35 -0
- package/dist/lib/delivery-proc.d.ts.map +1 -0
- package/dist/lib/delivery-proc.js +128 -0
- package/dist/lib/delivery-proc.js.map +1 -0
- package/dist/lib/manager-proc.d.ts +11 -2
- package/dist/lib/manager-proc.d.ts.map +1 -1
- package/dist/lib/manager-proc.js +43 -3
- package/dist/lib/manager-proc.js.map +1 -1
- package/dist/lib/meshes.d.ts +8 -0
- package/dist/lib/meshes.d.ts.map +1 -0
- package/dist/lib/meshes.js +15 -0
- package/dist/lib/meshes.js.map +1 -0
- package/dist/lib/onboard.js +6 -6
- package/dist/lib/onboard.js.map +1 -1
- package/dist/lib/transient.d.ts +7 -6
- package/dist/lib/transient.d.ts.map +1 -1
- package/dist/lib/transient.js +6 -25
- package/dist/lib/transient.js.map +1 -1
- package/dist/render.js +1 -1
- package/dist/web/graph.html +204 -0
- package/dist/web/graph.js +453 -0
- package/dist/web/index.html +6 -0
- package/package.json +2 -2
- package/dist/commands/signer.d.ts +0 -6
- package/dist/commands/signer.d.ts.map +0 -1
- package/dist/commands/signer.js +0 -30
- package/dist/commands/signer.js.map +0 -1
- package/dist/commands/watch.d.ts +0 -4
- package/dist/commands/watch.d.ts.map +0 -1
- package/dist/commands/watch.js +0 -7
- package/dist/commands/watch.js.map +0 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/* Cotal — live mesh graph. Channels and agents are nodes in a force-directed constellation:
|
|
2
|
+
* channel "spokes" pull an agent toward the channels it's subscribed to (so an agent on two channels
|
|
3
|
+
* floats between both hubs), DM springs pull peers together, and charge spreads everything out. A wire
|
|
4
|
+
* glows + fires a comet when a message flows. Fed by the same /feed SSE + REST the Monitor uses.
|
|
5
|
+
*
|
|
6
|
+
* Membership is AUTHORITATIVE and broker-sourced (not self-reported): the delivery daemon reads the
|
|
7
|
+
* broker's connection view (CONNZ) ∪ the durable members registry and publishes a derived feed; the
|
|
8
|
+
* observer serves it at /api/membership + a `membership` SSE event. So a spoke is drawn for every channel
|
|
9
|
+
* an agent is actually subscribed to — including SILENT readers and `live` channels that keep no
|
|
10
|
+
* enumerable roster. A `live` (connected) member draws solid-faint; a member that's only durable while its
|
|
11
|
+
* presence is offline draws dashed-dim ("member, currently offline"). Traffic glow rides on top: a post
|
|
12
|
+
* sends a comet to the hub, the hub blooms, then fans out to every other member. If the feed is absent
|
|
13
|
+
* (no daemon / a space provisioned before this feature), the graph degrades to traffic-only and says so.
|
|
14
|
+
*
|
|
15
|
+
* Stability: messages drive *glow*, not layout. The simulation cools to a rest state (alpha decay) and
|
|
16
|
+
* only gently re-heats when the node/edge SET changes — so nodes don't wander on every message. */
|
|
17
|
+
(() => {
|
|
18
|
+
const $ = (id) => document.getElementById(id);
|
|
19
|
+
const canvas = $("graph");
|
|
20
|
+
const ctx = canvas.getContext("2d");
|
|
21
|
+
|
|
22
|
+
// ── palette ──
|
|
23
|
+
const MODE = { chat: "#58a6ff", unicast: "#d29922", anycast: "#3fb950" };
|
|
24
|
+
const STAT = { working: "#46d35e", waiting: "#e9bf52", idle: "#9aa6b5", offline: "#5a6472" };
|
|
25
|
+
const MEM_LIVE = "#8493a8"; // a live (connected) membership spoke
|
|
26
|
+
const MEM_OFF = "#5a6472"; // a durable member whose presence is offline ("member, currently offline")
|
|
27
|
+
const TRAFFIC_COLD = 0.02; // heat below which a NON-member (traffic-only) spoke is pruned
|
|
28
|
+
const FEED_STALE_MS = 45000; // membership feed older than this reads "stale" (daemon polls ~15s)
|
|
29
|
+
|
|
30
|
+
// ── state ──
|
|
31
|
+
const hubs = new Map(); // channel -> hub node
|
|
32
|
+
const agents = new Map(); // id -> agent node
|
|
33
|
+
const edges = new Map(); // `${agentId}|${chan}` -> { a, chan, last, heat, mem, durableOnly }
|
|
34
|
+
const dms = new Map(); // `${idA}|${idB}` (sorted) -> { a, b, last, heat }
|
|
35
|
+
const particles = [];
|
|
36
|
+
const blooms = [];
|
|
37
|
+
const recent = [];
|
|
38
|
+
const feed = { asOf: undefined, available: false }; // membership-feed freshness
|
|
39
|
+
const cam = { x: 0, y: 0, scale: 1, ready: false, user: false };
|
|
40
|
+
const filter = { chat: true, unicast: true, anycast: true, window: 30, paused: false };
|
|
41
|
+
let W = 0, H = 0, DPR = 1, hover = null, sel = null, lastT = 0, alpha = 1;
|
|
42
|
+
|
|
43
|
+
// ── utils ──
|
|
44
|
+
const partsText = (m) => (m.parts || []).map((p) => (p.kind === "text" ? p.text : JSON.stringify(p.data))).join(" ");
|
|
45
|
+
const ease = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
|
|
46
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
47
|
+
const shortId = (x) => (/^[A-Z2-7]{32,}$/.test(x) ? x.slice(0, 6) + "…" : x);
|
|
48
|
+
const hash = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; };
|
|
49
|
+
const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
|
50
|
+
const rgba = (h, a) => { const [r, g, b] = hex(h); return `rgba(${r},${g},${b},${a})`; };
|
|
51
|
+
const now = () => Date.now();
|
|
52
|
+
const reheat = () => { alpha = Math.max(alpha, 0.55); };
|
|
53
|
+
|
|
54
|
+
// ── channel-subscription matching (ports core subjectMatches; `live` patterns keep wildcards) ──
|
|
55
|
+
const isWild = (ch) => ch.split(".").some((s) => s === "*" || s === ">");
|
|
56
|
+
function patternMatches(pattern, subject) {
|
|
57
|
+
const p = pattern.split("."), s = subject.split(".");
|
|
58
|
+
for (let i = 0; i < p.length; i++) { if (p[i] === ">") return i < s.length; if (i >= s.length) return false; if (p[i] === "*") continue; if (p[i] !== s[i]) return false; }
|
|
59
|
+
return p.length === s.length;
|
|
60
|
+
}
|
|
61
|
+
/** Expand an agent's {live patterns, durable channels} → { channels: channel→kind, wide }. Bounded
|
|
62
|
+
* wildcards (`team.>`) expand against the KNOWN channel set (registry hubs); concrete patterns stand
|
|
63
|
+
* alone; `live` wins over `durable`. A WHOLE-BREADTH pattern (`>` or `*` — e.g. the default persona's
|
|
64
|
+
* read-everything grant) is NOT expanded to a spoke per hub (that's a dandelion); it sets `wide`, and
|
|
65
|
+
* the agent renders as a "reads-all" node badge instead — truthful without per-channel noise. */
|
|
66
|
+
function memberChannels(live, durable, known) {
|
|
67
|
+
const out = new Map();
|
|
68
|
+
let wide = false;
|
|
69
|
+
for (const pat of live || []) {
|
|
70
|
+
if (pat === ">" || pat === "*") { wide = true; continue; }
|
|
71
|
+
if (isWild(pat)) { for (const ch of known) if (patternMatches(pat, ch)) out.set(ch, "live"); }
|
|
72
|
+
else out.set(pat, "live");
|
|
73
|
+
}
|
|
74
|
+
for (const ch of durable || []) if (!out.has(ch)) out.set(ch, "durable");
|
|
75
|
+
return { channels: out, wide };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── nodes ──
|
|
79
|
+
function spawn(id, seedR) { const a = (hash(id) % 628) / 100, r = seedR + (Math.abs(hash(id)) % 120); return { x: Math.cos(a) * r, y: Math.sin(a) * r, vx: 0, vy: 0 }; }
|
|
80
|
+
function ensureHub(name) {
|
|
81
|
+
if (!name) return null;
|
|
82
|
+
let h = hubs.get(name);
|
|
83
|
+
if (!h) { h = Object.assign({ kind: "hub", name, r: 14, charge: -560, mass: 3, msgs: 0, desc: "" }, spawn("#" + name, 150)); hubs.set(name, h); reheat(); onNewChannel(name); }
|
|
84
|
+
return h;
|
|
85
|
+
}
|
|
86
|
+
function ensureAgent(ref) {
|
|
87
|
+
if (!ref) return null;
|
|
88
|
+
const id = typeof ref === "object" ? ref.id || ref.name : ref;
|
|
89
|
+
if (!id) return null;
|
|
90
|
+
let a = agents.get(id);
|
|
91
|
+
if (!a) { a = Object.assign({ kind: "agent", id, name: (typeof ref === "object" && ref.name) || shortId(id), role: typeof ref === "object" ? ref.role : undefined, status: "idle", activity: "", harness: undefined, ts: 0, live: [], durable: [], memberOf: new Map(), r: 6.5, charge: -190, mass: 1, phase: (hash(id) % 1000) / 1000 * 6.283 }, spawn(id, 70)); agents.set(id, a); reheat(); }
|
|
92
|
+
else if (typeof ref === "object" && ref.name) a.name = ref.name;
|
|
93
|
+
return a;
|
|
94
|
+
}
|
|
95
|
+
const edgeKey = (id, chan) => id + "|" + chan;
|
|
96
|
+
const dmKey = (a, b) => [a, b].sort().join("|");
|
|
97
|
+
function ensureEdge(a, chan) { const k = edgeKey(a.id, chan); let e = edges.get(k); if (!e) { edges.set(k, (e = { a, chan, last: 0, heat: 0, mem: false, durableOnly: false })); reheat(); } return e; }
|
|
98
|
+
function chatHit(a, chan, ts) { const e = ensureEdge(a, chan); e.last = Math.max(e.last, ts); return e; }
|
|
99
|
+
function dmHit(a, b, ts) { const k = dmKey(a.id, b.id); let d = dms.get(k); if (!d) { dms.set(k, (d = { a, b, last: 0, heat: 0 })); reheat(); } d.last = Math.max(d.last, ts); return d; }
|
|
100
|
+
function primaryChan(a) { let best = null, bt = 0; for (const e of edges.values()) if (e.a === a && e.last > bt) { bt = e.last; best = e.chan; } return best; }
|
|
101
|
+
|
|
102
|
+
// When a channel first appears, retro-link any agent whose live WILDCARD covers it (so a `team.>`
|
|
103
|
+
// subscriber gains a spoke to a newly-created `team.backend` with no membership-feed round-trip).
|
|
104
|
+
function onNewChannel(name) {
|
|
105
|
+
for (const a of agents.values()) for (const pat of a.live || []) { if (pat === ">" || pat === "*") continue; if (isWild(pat) && patternMatches(pat, name)) { const e = ensureEdge(a, name); e.mem = true; e.durableOnly = false; a.memberOf.set(name, "live"); } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── force simulation (cooling; re-heated only on structural change) ──
|
|
109
|
+
function link(a, b, len, k) { let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1; const f = (d - len) * k * alpha, fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; }
|
|
110
|
+
function physics() {
|
|
111
|
+
if (alpha < 0.004 || filter.paused) return;
|
|
112
|
+
const ns = [...hubs.values(), ...agents.values()];
|
|
113
|
+
for (let i = 0; i < ns.length; i++) {
|
|
114
|
+
const a = ns[i];
|
|
115
|
+
for (let j = i + 1; j < ns.length; j++) {
|
|
116
|
+
const b = ns[j];
|
|
117
|
+
let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
|
|
118
|
+
if (d2 < 1) { dx = (hash(a.name + i) % 11) - 5; dy = (hash(b.name + j) % 11) - 5; d2 = dx * dx + dy * dy || 1; }
|
|
119
|
+
const d = Math.sqrt(d2), q = ((a.charge * b.charge) / d2) * alpha;
|
|
120
|
+
a.vx += (dx / d) * q; a.vy += (dy / d) * q; b.vx -= (dx / d) * q; b.vy -= (dy / d) * q;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const e of edges.values()) { const h = hubs.get(e.chan); if (h) link(e.a, h, 105, 0.08); }
|
|
124
|
+
for (const d of dms.values()) link(d.a, d.b, 165, 0.03);
|
|
125
|
+
// small graphs: faint tangential nudge so agents form a loose ring around their hub instead of a line (decays with alpha)
|
|
126
|
+
if (hubs.size <= 2) for (const a of agents.values()) { const h = (primaryChan(a) && hubs.get(primaryChan(a))) || [...hubs.values()][0]; if (h) { const dx = a.x - h.x, dy = a.y - h.y, d = Math.hypot(dx, dy) || 1; a.vx += (-dy / d) * 0.4 * alpha; a.vy += (dx / d) * 0.4 * alpha; } }
|
|
127
|
+
// collision: position-based min-distance — prevents the 1/d² charge singularity + node overlap
|
|
128
|
+
const pad = 10;
|
|
129
|
+
for (let i = 0; i < ns.length; i++) {
|
|
130
|
+
const a = ns[i];
|
|
131
|
+
for (let j = i + 1; j < ns.length; j++) {
|
|
132
|
+
const b = ns[j];
|
|
133
|
+
let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 0.01;
|
|
134
|
+
const min = a.r + b.r + pad;
|
|
135
|
+
if (d < min) { const push = (min - d) / 2, ux = dx / d, uy = dy / d; a.x -= ux * push; a.y -= uy * push; b.x += ux * push; b.y += uy * push; const va = a.vx * ux + a.vy * uy, vb = b.vx * ux + b.vy * uy; a.vx -= va * ux; a.vy -= va * uy; b.vx -= vb * ux; b.vy -= vb * uy; }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const n of ns) {
|
|
139
|
+
n.vx += -n.x * 0.014 * alpha; n.vy += -n.y * 0.014 * alpha; // gravity toward center
|
|
140
|
+
n.vx *= 0.6; n.vy *= 0.6; n.x += n.vx / (n.mass || 1); n.y += n.vy / (n.mass || 1); // heavier hubs resist the kick
|
|
141
|
+
}
|
|
142
|
+
alpha += (0 - alpha) * 0.0228;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── traffic ──
|
|
146
|
+
const mk = (a, b, color, onArrive, curve) => ({ a, b, t: 0, dur: curve ? 1.4 : 1.1, color, onArrive: onArrive || null, curve: !!curve, trail: [] });
|
|
147
|
+
function onMessage({ mode, senderId, msg }) {
|
|
148
|
+
if (!msg) return;
|
|
149
|
+
const from = ensureAgent(senderId ? { id: senderId, name: msg.from?.name, role: msg.from?.role } : msg.from);
|
|
150
|
+
if (from) from.ts = now();
|
|
151
|
+
const animate = !filter.paused && filter[mode];
|
|
152
|
+
let toName = null;
|
|
153
|
+
if (mode === "chat" && msg.channel) {
|
|
154
|
+
const h = ensureHub(msg.channel);
|
|
155
|
+
if (from) chatHit(from, msg.channel, now()).heat = 1;
|
|
156
|
+
// inbound: sender → hub, then the hub flashes and fans the post back out to every other member on
|
|
157
|
+
// the channel (their spokes glow as the wave reaches them) — a real broadcast.
|
|
158
|
+
if (animate && from && h) particles.push(mk(from, h, MODE.chat, () => {
|
|
159
|
+
blooms.push({ x: h.x, y: h.y, t: 0, dur: 0.95, color: MODE.chat, r0: h.r });
|
|
160
|
+
for (const e of edges.values()) if (e.chan === msg.channel && e.a !== from) { e.heat = 1; particles.push(mk(h, e.a, MODE.chat, null, false)); }
|
|
161
|
+
}));
|
|
162
|
+
} else if (mode === "unicast") {
|
|
163
|
+
const to = typeof msg.to === "string" ? agents.get(msg.to) : msg.to && agents.get(msg.to.id);
|
|
164
|
+
toName = to?.name || (typeof msg.to === "string" ? shortId(msg.to) : msg.to?.name);
|
|
165
|
+
if (from && to && from !== to) { dmHit(from, to, now()).heat = 1; if (animate) particles.push(mk(from, to, MODE.unicast, null, true)); }
|
|
166
|
+
} else if (mode === "anycast") {
|
|
167
|
+
toName = "@" + (msg.toService || "");
|
|
168
|
+
if (animate && from) blooms.push({ x: from.x, y: from.y, t: 0, dur: 1.0, color: MODE.anycast, r0: from.r });
|
|
169
|
+
}
|
|
170
|
+
recent.push({ mode, from: from?.name, fromId: from?.id, to: toName, chan: msg.channel, text: partsText(msg), ts: msg.ts || now() });
|
|
171
|
+
if (recent.length > 80) recent.shift();
|
|
172
|
+
if (sel) renderDetail();
|
|
173
|
+
}
|
|
174
|
+
function updateRoster(list) {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const p of list) {
|
|
177
|
+
if (p.card?.kind === "endpoint") continue;
|
|
178
|
+
const a = ensureAgent({ id: p.card.id, name: p.card.name, role: p.card.role });
|
|
179
|
+
a.status = p.status; a.activity = p.activity || ""; a.role = p.card.role; a.harness = p.card.meta?.connector; a.ts = p.ts;
|
|
180
|
+
seen.add(a.id);
|
|
181
|
+
}
|
|
182
|
+
// Drop an agent as soon as it goes offline OR leaves the roster (main's ghost fix, c9e9000) — EXCEPT
|
|
183
|
+
// keep it if it's still a feed member: a durable member whose presence is offline must persist to
|
|
184
|
+
// render as "member, currently offline" (the feed's durable arm survives offline). Membership is
|
|
185
|
+
// broker-truth, applied separately; presence no longer carries channels.
|
|
186
|
+
for (const [id, a] of agents) if ((!seen.has(id) || a.status === "offline") && !(a.wideReader || (a.memberOf && a.memberOf.size))) { agents.delete(id); for (const k of [...edges.keys()]) if (edges.get(k).a === a) edges.delete(k); for (const k of [...dms.keys()]) { const d = dms.get(k); if (d.a === a || d.b === a) dms.delete(k); } reheat(); if (sel === a) closeDetail(); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── membership (authoritative spokes) ──
|
|
190
|
+
function applyMembership(snap) {
|
|
191
|
+
if (!snap) return;
|
|
192
|
+
feed.asOf = snap.asOf;
|
|
193
|
+
feed.available = snap.asOf !== undefined || (Array.isArray(snap.members) && snap.members.length > 0);
|
|
194
|
+
setFeed();
|
|
195
|
+
const known = [...hubs.keys()];
|
|
196
|
+
const present = new Set();
|
|
197
|
+
for (const m of snap.members || []) {
|
|
198
|
+
const a = ensureAgent({ id: m.id });
|
|
199
|
+
present.add(a.id);
|
|
200
|
+
a.live = m.live || []; a.durable = m.durable || [];
|
|
201
|
+
const mc = memberChannels(a.live, a.durable, known);
|
|
202
|
+
a.memberOf = mc.channels; a.wideReader = mc.wide;
|
|
203
|
+
for (const [ch, kind] of mc.channels) { ensureHub(ch); const e = ensureEdge(a, ch); e.mem = true; e.durableOnly = kind === "durable"; }
|
|
204
|
+
pruneMemberEdges(a, mc.channels);
|
|
205
|
+
}
|
|
206
|
+
// An agent that dropped out of the feed entirely is no longer a member of anything (incl. a wide reader,
|
|
207
|
+
// which carries the flag but no concrete edges).
|
|
208
|
+
for (const a of agents.values()) if (!present.has(a.id) && (a.wideReader || (a.memberOf && a.memberOf.size))) { a.live = []; a.durable = []; a.wideReader = false; const empty = new Map(); pruneMemberEdges(a, empty); a.memberOf = empty; }
|
|
209
|
+
if (sel) renderDetail();
|
|
210
|
+
}
|
|
211
|
+
// Drop this agent's membership edges that are no longer in `keep`; a still-warm one stays as a fading
|
|
212
|
+
// traffic-only edge (mem:false) and is pruned later when cold, so a comet in flight isn't orphaned.
|
|
213
|
+
function pruneMemberEdges(a, keep) {
|
|
214
|
+
for (const [k, e] of edges) if (e.a === a && e.mem && !keep.has(e.chan)) { if (e.heat <= TRAFFIC_COLD) edges.delete(k); else { e.mem = false; e.durableOnly = false; } reheat(); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── render loop ──
|
|
218
|
+
function frame(t) {
|
|
219
|
+
const dt = Math.min(0.05, (t - lastT) / 1000 || 0); lastT = t;
|
|
220
|
+
// Prune cold traffic-only spokes (a non-member's post that has faded). Membership spokes persist by
|
|
221
|
+
// membership, never on a timer — they're the resting skeleton, faint at rest, glowing on traffic.
|
|
222
|
+
for (const [k, e] of edges) if (!e.mem && e.heat <= TRAFFIC_COLD && now() - e.last > 1000) { edges.delete(k); reheat(); }
|
|
223
|
+
for (const h of hubs.values()) h.empty = ![...edges.values()].some((e) => e.chan === h.name && e.mem); // no members = dormant (silent readers keep it live)
|
|
224
|
+
physics();
|
|
225
|
+
// re-frame only once the sim has cooled, so the camera doesn't chase the re-settle wobble
|
|
226
|
+
if (!cam.user && alpha < 0.12) { const f = fitTarget(); const e = 1 - Math.pow(0.02, dt); cam.x += (f.x - cam.x) * e; cam.y += (f.y - cam.y) * e; cam.scale += (f.scale - cam.scale) * e; }
|
|
227
|
+
if (!filter.paused) { const k = Math.exp(-dt / filter.window); for (const e of edges.values()) e.heat *= k; for (const d of dms.values()) d.heat *= k; }
|
|
228
|
+
|
|
229
|
+
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
230
|
+
ctx.clearRect(0, 0, W, H);
|
|
231
|
+
drawStarfield(ctx, W, H, cam);
|
|
232
|
+
ctx.translate(cam.x, cam.y); ctx.scale(cam.scale, cam.scale);
|
|
233
|
+
drawSpokes(); drawDmEdges(); drawBlooms(dt); drawParticles(dt); drawNodes();
|
|
234
|
+
requestAnimationFrame(frame);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Hover/select a hub or agent → highlight its membership fan, dim the rest (dandelion mitigation).
|
|
238
|
+
function fanFocus() { return hover && hover.kind === "hub" ? hover : sel && sel.kind === "hub" ? sel : hover && hover.kind === "agent" ? hover : sel && sel.kind === "agent" ? sel : null; }
|
|
239
|
+
function inFan(e, f) { if (!f) return true; return f.kind === "hub" ? e.chan === f.name : e.a === f; }
|
|
240
|
+
|
|
241
|
+
function drawSpokes() {
|
|
242
|
+
ctx.lineCap = "round";
|
|
243
|
+
const f = fanFocus();
|
|
244
|
+
// structure layer: a constant-faint spoke per membership (solid = live, dashed-dim = member-offline)
|
|
245
|
+
for (const e of edges.values()) {
|
|
246
|
+
if (!e.mem) continue;
|
|
247
|
+
const h = hubs.get(e.chan); if (!h) continue;
|
|
248
|
+
const off = e.durableOnly || e.a.status === "offline";
|
|
249
|
+
const lit = inFan(e, f);
|
|
250
|
+
ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(h.x, h.y);
|
|
251
|
+
ctx.setLineDash(off ? [3, 4] : []);
|
|
252
|
+
ctx.strokeStyle = rgba(off ? MEM_OFF : MEM_LIVE, (off ? 0.3 : 0.42) * (lit ? 1 : 0.28));
|
|
253
|
+
ctx.lineWidth = 1.4; ctx.stroke();
|
|
254
|
+
}
|
|
255
|
+
ctx.setLineDash([]);
|
|
256
|
+
// activity layer: traffic glow on top (members + transient non-member posts)
|
|
257
|
+
ctx.globalCompositeOperation = "lighter";
|
|
258
|
+
for (const e of edges.values()) { const h = hubs.get(e.chan); if (!h || e.heat <= 0.02) continue; const lit = inFan(e, f); ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(h.x, h.y); ctx.strokeStyle = rgba(MODE.chat, Math.min(0.55, e.heat * 0.55) * (lit ? 1 : 0.35)); ctx.lineWidth = 1 + e.heat * 1.6; ctx.stroke(); }
|
|
259
|
+
ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
|
|
260
|
+
}
|
|
261
|
+
function drawDmEdges() {
|
|
262
|
+
ctx.setLineDash([3, 4]);
|
|
263
|
+
for (const d of dms.values()) {
|
|
264
|
+
const mx = (d.a.x + d.b.x) / 2, my = (d.a.y + d.b.y) / 2, nx = -(d.b.y - d.a.y), ny = d.b.x - d.a.x, len = Math.hypot(nx, ny) || 1;
|
|
265
|
+
const cx = mx + (nx / len) * 24, cy = my + (ny / len) * 24;
|
|
266
|
+
ctx.beginPath(); ctx.moveTo(d.a.x, d.a.y); ctx.quadraticCurveTo(cx, cy, d.b.x, d.b.y);
|
|
267
|
+
ctx.strokeStyle = rgba(MODE.unicast, 0.5 + d.heat * 0.45); ctx.lineWidth = 1.7 + d.heat * 1.6; ctx.stroke();
|
|
268
|
+
}
|
|
269
|
+
ctx.setLineDash([]); ctx.globalAlpha = 1;
|
|
270
|
+
}
|
|
271
|
+
function drawNodes() {
|
|
272
|
+
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
273
|
+
const t = performance.now() / 1000;
|
|
274
|
+
for (const h of hubs.values()) {
|
|
275
|
+
const focus = h === hover || h === sel, dim = h.empty ? 0.55 : 1; // dormant hubs read quieter, not gone
|
|
276
|
+
ctx.save(); ctx.shadowColor = MODE.chat; ctx.shadowBlur = (focus ? 28 : 16) * dim;
|
|
277
|
+
const g = ctx.createRadialGradient(h.x, h.y, 0, h.x, h.y, h.r); g.addColorStop(0, h.empty ? "#16314f" : "#2b5a8f"); g.addColorStop(1, "#0c1726");
|
|
278
|
+
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(h.x, h.y, h.r, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
|
|
279
|
+
ctx.lineWidth = 1.5; ctx.strokeStyle = rgba(MODE.chat, 0.95 * dim); ctx.stroke();
|
|
280
|
+
ctx.fillStyle = rgba("#cfe2ff", dim); ctx.font = "600 12.5px var(--font), sans-serif"; ctx.fillText("#" + h.name, h.x, h.y + h.r + 13);
|
|
281
|
+
}
|
|
282
|
+
for (const a of agents.values()) {
|
|
283
|
+
const col = STAT[a.status] || STAT.idle, focus = a === hover || a === sel, off = a.status === "offline";
|
|
284
|
+
const r = a.r + Math.sin(t * 0.8 + a.phase) * 0.4;
|
|
285
|
+
if (a.status === "waiting") { const pulse = 0.5 + 0.5 * Math.sin(t * 1.7); for (const o of [0, 0.5]) { ctx.beginPath(); ctx.arc(a.x, a.y, r + 5 + ((pulse + o) % 1) * 9, 0, 2 * Math.PI); ctx.strokeStyle = rgba(STAT.waiting, (1 - ((pulse + o) % 1)) * 0.45); ctx.lineWidth = 1.6; ctx.stroke(); } }
|
|
286
|
+
// wide reader (subscribes `>`/`*`): a faint dashed halo — "reads all channels" without a spoke per hub
|
|
287
|
+
if (a.wideReader) { ctx.save(); ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.arc(a.x, a.y, r + 4.5, 0, 2 * Math.PI); ctx.strokeStyle = rgba(MEM_LIVE, off ? 0.3 : 0.6); ctx.lineWidth = 1.2; ctx.stroke(); ctx.restore(); }
|
|
288
|
+
ctx.save(); ctx.shadowColor = col; ctx.shadowBlur = focus ? 20 : off ? 3 : 13;
|
|
289
|
+
const g = ctx.createRadialGradient(a.x, a.y, 0, a.x, a.y, r); g.addColorStop(0, rgba(col, off ? 0.5 : 1)); g.addColorStop(0.55, rgba(col, off ? 0.2 : 0.55)); g.addColorStop(1, "#141b26");
|
|
290
|
+
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
|
|
291
|
+
ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, off ? 0.6 : 1); ctx.stroke();
|
|
292
|
+
if (focus || a.status === "waiting" || agents.size <= 16) { ctx.fillStyle = focus ? "#ffffff" : "#cdd6e2"; ctx.font = (focus ? "600 " : "500 ") + "11px var(--font), sans-serif"; ctx.fillText(a.name, a.x, a.y - r - 8); }
|
|
293
|
+
}
|
|
294
|
+
ctx.globalAlpha = 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── atmosphere + motion ──
|
|
298
|
+
const prng = (seed) => { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
|
|
299
|
+
let stars = null, starW = 0, starH = 0;
|
|
300
|
+
function buildStars(W, H) { const r = prng(0x9e3779b1); const arr = new Array(300); for (let i = 0; i < 300; i++) arr[i] = { x: r() * W, y: r() * H, size: 0.3 + r() * 1.0, alpha: 0.04 + r() * 0.26, depth: 0.04 + r() * 0.12, tw: r() < 0.2, ph: r() * Math.PI * 2, sp: 0.4 + r() * 0.9 }; stars = arr; starW = W; starH = H; }
|
|
301
|
+
const drawStarfield = (ctx, W, H, cam) => {
|
|
302
|
+
if (!stars || starW !== W || starH !== H) buildStars(W, H);
|
|
303
|
+
const t = performance.now() / 1000;
|
|
304
|
+
const g = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.75); g.addColorStop(0, "#202c40"); g.addColorStop(0.55, "#172030"); g.addColorStop(1, "#10161f"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
|
305
|
+
ctx.globalCompositeOperation = "lighter";
|
|
306
|
+
const blob = (fx, fy, rad, color) => { const ng = ctx.createRadialGradient(W * fx, H * fy, 0, W * fx, H * fy, rad); ng.addColorStop(0, color); ng.addColorStop(1, "transparent"); ctx.globalAlpha = 0.04; ctx.fillStyle = ng; ctx.fillRect(0, 0, W, H); };
|
|
307
|
+
blob(0.28, 0.32, Math.max(W, H) * 0.45, "#1a3a5c"); blob(0.72, 0.68, Math.max(W, H) * 0.4, "#2a1a4a");
|
|
308
|
+
for (const s of stars) { let sx = s.x + cam.x * s.depth, sy = s.y + cam.y * s.depth; sx = ((sx % W) + W) % W; sy = ((sy % H) + H) % H; let a = s.alpha; if (s.tw) a *= 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * s.sp + s.ph)); ctx.globalAlpha = a; ctx.fillStyle = "#cfe0ff"; ctx.beginPath(); ctx.arc(sx, sy, s.size, 0, 2 * Math.PI); ctx.fill(); }
|
|
309
|
+
ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
|
|
310
|
+
};
|
|
311
|
+
function drawParticles(dt) {
|
|
312
|
+
ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round";
|
|
313
|
+
for (let i = particles.length - 1; i >= 0; i--) {
|
|
314
|
+
const p = particles[i]; if (!filter.paused) p.t += dt / p.dur; const t = ease(Math.min(1, p.t)); let x, y;
|
|
315
|
+
if (p.curve) { const mx = (p.a.x + p.b.x) / 2, my = (p.a.y + p.b.y) / 2, nx = -(p.b.y - p.a.y), ny = p.b.x - p.a.x, len = Math.hypot(nx, ny) || 1, cx = mx + (nx / len) * 24, cy = my + (ny / len) * 24, u = 1 - t; x = u * u * p.a.x + 2 * u * t * cx + t * t * p.b.x; y = u * u * p.a.y + 2 * u * t * cy + t * t * p.b.y; }
|
|
316
|
+
else { x = p.a.x + (p.b.x - p.a.x) * t; y = p.a.y + (p.b.y - p.a.y) * t; }
|
|
317
|
+
if (!filter.paused) { p.trail.push(x, y); if (p.trail.length > 12) p.trail.splice(0, p.trail.length - 12); }
|
|
318
|
+
const n = p.trail.length >> 1;
|
|
319
|
+
for (let k = 0; k < n; k++) { const f = k / Math.max(1, n - 1); ctx.globalAlpha = f * f * 0.6; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.trail[k * 2], p.trail[k * 2 + 1], 1 + f * 2.2, 0, 2 * Math.PI); ctx.fill(); }
|
|
320
|
+
ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 20; ctx.globalAlpha = 1; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(x, y, 2, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(x, y, 4.4, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
|
|
321
|
+
if (p.t >= 1) { if (p.onArrive) p.onArrive(); particles.splice(i, 1); }
|
|
322
|
+
}
|
|
323
|
+
ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
|
|
324
|
+
}
|
|
325
|
+
function drawBlooms(dt) {
|
|
326
|
+
ctx.globalCompositeOperation = "lighter";
|
|
327
|
+
for (let i = blooms.length - 1; i >= 0; i--) {
|
|
328
|
+
const b = blooms[i]; b.t += dt / b.dur; if (b.t >= 1) { blooms.splice(i, 1); continue; }
|
|
329
|
+
const flash = Math.max(0, 1 - b.t / 0.35);
|
|
330
|
+
if (flash > 0) { const fg = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r0 + 18); fg.addColorStop(0, b.color); fg.addColorStop(1, "transparent"); ctx.globalAlpha = flash * 0.5; ctx.fillStyle = fg; ctx.beginPath(); ctx.arc(b.x, b.y, b.r0 + 18, 0, 2 * Math.PI); ctx.fill(); }
|
|
331
|
+
const ring = (off) => { const tt = b.t - off; if (tt <= 0 || tt >= 1) return; ctx.beginPath(); ctx.arc(b.x, b.y, b.r0 + ease(tt) * 28, 0, 2 * Math.PI); ctx.strokeStyle = b.color; ctx.globalAlpha = (1 - tt) * 0.7; ctx.lineWidth = 2; ctx.stroke(); };
|
|
332
|
+
ring(0); ring(0.15);
|
|
333
|
+
}
|
|
334
|
+
ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── camera + hit-testing ──
|
|
338
|
+
function fitTarget() {
|
|
339
|
+
const ns = [...hubs.values(), ...agents.values()]; if (!ns.length) return { x: W / 2, y: H / 2, scale: 1 };
|
|
340
|
+
// content nodes drive the frame; empty hubs only nudge the padding so one stray node can't shrink the live graph
|
|
341
|
+
const content = ns.filter((n) => !(n.kind === "hub" && n.empty)), frame = content.length ? content : ns;
|
|
342
|
+
let a = 1e9, b = 1e9, c = -1e9, d = -1e9;
|
|
343
|
+
for (const n of frame) { const r = n.r + 40; a = Math.min(a, n.x - r); c = Math.max(c, n.x + r); b = Math.min(b, n.y - r); d = Math.max(d, n.y + r); }
|
|
344
|
+
for (const h of hubs.values()) if (h.empty) { a = Math.min(a, h.x - 20); c = Math.max(c, h.x + 20); b = Math.min(b, h.y - 20); d = Math.max(d, h.y + 20); }
|
|
345
|
+
const bw = c - a || 1, bh = d - b || 1, pad = 90, maxScale = ns.length <= 6 ? 2.4 : 1.6;
|
|
346
|
+
const scale = Math.max(0.35, Math.min(maxScale, Math.min((W - pad * 2) / bw, (H - pad * 2) / bh)));
|
|
347
|
+
return { x: W / 2 - ((a + c) / 2) * scale, y: H / 2 - ((b + d) / 2) * scale, scale };
|
|
348
|
+
}
|
|
349
|
+
const toWorld = (sx, sy) => ({ x: (sx - cam.x) / cam.scale, y: (sy - cam.y) / cam.scale });
|
|
350
|
+
function pick(sx, sy) { const w = toWorld(sx, sy); let best = null, bd = 1e9; for (const n of [...hubs.values(), ...agents.values()]) { const d = Math.hypot(n.x - w.x, n.y - w.y); if (d < n.r + 8 && d < bd) { bd = d; best = n; } } return best; }
|
|
351
|
+
|
|
352
|
+
// ── membership freshness pill ──
|
|
353
|
+
function setFeed() {
|
|
354
|
+
const el = $("feed"); if (!el) return;
|
|
355
|
+
el.hidden = false;
|
|
356
|
+
let cls, text;
|
|
357
|
+
if (!feed.available) { cls = "off"; text = "membership: traffic-only"; }
|
|
358
|
+
else { const age = feed.asOf ? now() - feed.asOf : Infinity; if (age < FEED_STALE_MS) { cls = ""; text = "membership: live"; } else { cls = "stale"; text = "membership: stale"; } }
|
|
359
|
+
el.className = "pill" + (cls ? " " + cls : "");
|
|
360
|
+
el.querySelector(".t").textContent = text;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── detail panel ──
|
|
364
|
+
function closeDetail() { sel = null; $("detail").classList.remove("open"); }
|
|
365
|
+
function recentRows(test) {
|
|
366
|
+
const ms = recent.filter(test).slice(-6).reverse();
|
|
367
|
+
return ms.length ? ms.map((m) => `<div class="d-msg" style="border-color:${MODE[m.mode] || "#2a313c"}"><div class="mhead"><span class="m" style="color:${MODE[m.mode] || "#8b949e"}">${m.mode}</span><span class="who">${esc(m.from)}</span>${m.chan ? `<span class="tgt">#${esc(m.chan)}</span>` : m.to ? `<span class="tgt">→ ${esc(m.to)}</span>` : ""}</div><div class="body">${esc(m.text).slice(0, 160) || "—"}</div></div>`).join("") : `<div class="d-msg empty">no recent traffic</div>`;
|
|
368
|
+
}
|
|
369
|
+
function renderDetail() {
|
|
370
|
+
const el = $("detail"); if (!sel) { el.classList.remove("open"); return; }
|
|
371
|
+
if (sel.kind === "hub") {
|
|
372
|
+
// members from the broker feed (subscribed), split into live vs member-currently-offline; plus a
|
|
373
|
+
// "recently active" subset (who actually posted here) vs just-subscribed.
|
|
374
|
+
const mem = [...edges.values()].filter((e) => e.chan === sel.name && e.mem).map((e) => e.a);
|
|
375
|
+
mem.sort((x, y) => x.name.localeCompare(y.name));
|
|
376
|
+
const activeIds = new Set(recent.filter((m) => m.chan === sel.name && m.fromId).map((m) => m.fromId));
|
|
377
|
+
const memberRow = (a) => { const off = a.status === "offline" || (a.memberOf && a.memberOf.get(sel.name) === "durable"); const dotCol = STAT[a.status] || STAT.idle; return `<span class="mtag"><span class="dot" style="background:${off ? MEM_OFF : dotCol}"></span>${esc(a.name)}${activeIds.has(a.id) ? '<span class="act">active</span>' : ""}${off ? '<span class="off">offline</span>' : ""}</span>`; };
|
|
378
|
+
const memberList = mem.length ? `<div class="d-tags">${mem.map(memberRow).join("")}</div>` : `<div class="d-block muted">no subscribers yet</div>`;
|
|
379
|
+
el.innerHTML = `<span class="x" id="dx">✕</span>
|
|
380
|
+
<div class="d-kind">channel</div>
|
|
381
|
+
<div class="d-who">#${esc(sel.name)}</div>
|
|
382
|
+
${sel.desc ? `<div class="d-block">${esc(sel.desc)}</div>` : ""}
|
|
383
|
+
<div class="d-rows">
|
|
384
|
+
<div class="d-row"><span class="k">subscribers</span><span class="v">${mem.length} agent${mem.length === 1 ? "" : "s"}</span></div>
|
|
385
|
+
<div class="d-row"><span class="k">messages</span><span class="v">${sel.msgs || 0}</span></div>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="d-section"><div class="d-label">members</div>${memberList}</div>
|
|
388
|
+
<div class="d-section"><div class="d-label">recent</div><div class="d-msgs">${recentRows((m) => m.chan === sel.name)}</div></div>`;
|
|
389
|
+
} else {
|
|
390
|
+
// an agent's FULL subscription set from the feed: live patterns + durable. A whole-breadth `>`/`*`
|
|
391
|
+
// grant shows as a single "all channels" chip, not literal `#>`; bounded subtrees show literally.
|
|
392
|
+
const wideChip = sel.wideReader ? `<span class="ctag">all channels</span>` : "";
|
|
393
|
+
const liveSet = (sel.live || []).filter((c) => c !== ">" && c !== "*").map((c) => `<span class="ctag">#${esc(c)}</span>`).join("");
|
|
394
|
+
const durOnly = (sel.durable || []).filter((c) => !(sel.live || []).includes(c)).map((c) => `<span class="ctag off">#${esc(c)}</span>`).join("");
|
|
395
|
+
const subs = wideChip || liveSet || durOnly ? `<div class="d-tags">${wideChip}${liveSet}${durOnly}</div>` : `<div class="d-block muted">no channel subscriptions</div>`;
|
|
396
|
+
el.innerHTML = `<span class="x" id="dx">✕</span>
|
|
397
|
+
<div class="d-kind">agent</div>
|
|
398
|
+
<div class="d-who">${esc(sel.name)}${sel.role ? `<span class="role">${esc(sel.role)}</span>` : ""}</div>
|
|
399
|
+
<div class="d-status ${sel.status}"><span class="dot"></span>${esc(sel.status)}</div>
|
|
400
|
+
<div class="d-section"><div class="d-label">activity</div><div class="d-block ${sel.activity ? "" : "muted"}">${esc(sel.activity || "no current activity")}</div></div>
|
|
401
|
+
<div class="d-section"><div class="d-label">subscribes</div>${subs}</div>
|
|
402
|
+
${sel.harness ? `<div class="d-rows"><div class="d-row"><span class="k">harness</span><span class="v">${esc(sel.harness)}</span></div></div>` : ""}
|
|
403
|
+
<div class="d-section"><div class="d-label">recent</div><div class="d-msgs">${recentRows((m) => m.from === sel.name || m.to === sel.name)}</div></div>`;
|
|
404
|
+
}
|
|
405
|
+
el.classList.add("open"); $("dx").onclick = closeDetail;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── events ──
|
|
409
|
+
function resize() { DPR = window.devicePixelRatio || 1; W = window.innerWidth; H = window.innerHeight; canvas.width = W * DPR; canvas.height = H * DPR; if (!cam.ready) { cam.x = W / 2; cam.y = H / 2; cam.ready = true; } }
|
|
410
|
+
window.addEventListener("resize", resize);
|
|
411
|
+
let drag = null;
|
|
412
|
+
canvas.addEventListener("mousemove", (e) => { if (drag) { cam.x = drag.cx + (e.clientX - drag.sx); cam.y = drag.cy + (e.clientY - drag.sy); drag.moved = drag.moved || Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) > 4; if (drag.moved) cam.user = true; return; } hover = pick(e.clientX, e.clientY); canvas.classList.toggle("hover", !!hover); });
|
|
413
|
+
canvas.addEventListener("mousedown", (e) => { drag = { sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false }; });
|
|
414
|
+
window.addEventListener("mouseup", (e) => { if (drag && !drag.moved) { const n = pick(e.clientX, e.clientY); if (n) { sel = n; renderDetail(); $("hint").style.opacity = 0; } else closeDetail(); } drag = null; });
|
|
415
|
+
canvas.addEventListener("wheel", (e) => { e.preventDefault(); cam.user = true; const f = e.deltaY < 0 ? 1.1 : 1 / 1.1, ns = Math.max(0.3, Math.min(3, cam.scale * f)), w = toWorld(e.clientX, e.clientY); cam.scale = ns; cam.x = e.clientX - w.x * ns; cam.y = e.clientY - w.y * ns; }, { passive: false });
|
|
416
|
+
window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
|
|
417
|
+
$("modes").onclick = (e) => { const c = e.target.closest(".chip"); if (!c) return; const m = c.dataset.mode; filter[m] = !filter[m]; c.classList.toggle("on", filter[m]); };
|
|
418
|
+
$("pause").onclick = () => { filter.paused = !filter.paused; $("pause").classList.toggle("on", filter.paused); $("pause").textContent = filter.paused ? "▶ resume" : "⏸ pause"; };
|
|
419
|
+
$("legendToggle").onclick = () => $("legend").classList.toggle("collapsed");
|
|
420
|
+
function setConn(live) { const el = $("conn"); el.classList.toggle("down", !live); el.querySelector(".t").textContent = live ? "live" : "disconnected"; }
|
|
421
|
+
|
|
422
|
+
// ── boot ──
|
|
423
|
+
async function load() {
|
|
424
|
+
const [meta, roster, chans, membership, activity, dmHist] = await Promise.all([
|
|
425
|
+
fetch("/api/meta").then((r) => r.json()), fetch("/api/roster").then((r) => r.json()), fetch("/api/channels").then((r) => r.json()),
|
|
426
|
+
fetch("/api/membership").then((r) => r.json()).catch(() => ({ members: [] })),
|
|
427
|
+
fetch("/api/activity?limit=400").then((r) => r.json()).catch(() => []), fetch("/api/dms?limit=400").then((r) => r.json()).catch(() => []),
|
|
428
|
+
]);
|
|
429
|
+
$("space").textContent = "· " + meta.space;
|
|
430
|
+
for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; }
|
|
431
|
+
updateRoster(roster);
|
|
432
|
+
applyMembership(membership); // authoritative spokes BEFORE traffic seeding (no skeleton flicker)
|
|
433
|
+
for (const e of activity) { const m = e.msg; const a = m?.from?.id && agents.get(m.from.id); if (e.mode === "chat" && m?.channel && a) chatHit(a, m.channel, m.ts || now()); }
|
|
434
|
+
for (const m of dmHist) { const a = m.from?.id && agents.get(m.from.id), b = typeof m.to === "string" && agents.get(m.to); if (a && b && a !== b) dmHit(a, b, m.ts || now()); }
|
|
435
|
+
// Seed the `recent` buffer from the activity backfill so the channel detail's "recently active" tags +
|
|
436
|
+
// the "recent" section aren't empty until the first live SSE message arrives (norman).
|
|
437
|
+
for (const e of activity.slice(-80)) {
|
|
438
|
+
const m = e.msg; if (!m) continue;
|
|
439
|
+
const to = e.mode === "unicast" ? (typeof m.to === "string" ? (agents.get(m.to)?.name || shortId(m.to)) : m.to?.name) : e.mode === "anycast" ? "@" + (m.toService || "") : null;
|
|
440
|
+
recent.push({ mode: e.mode, from: m.from?.name, fromId: m.from?.id, to, chan: m.channel, text: partsText(m), ts: m.ts || now() });
|
|
441
|
+
}
|
|
442
|
+
recent.sort((a, b) => a.ts - b.ts);
|
|
443
|
+
if (recent.length > 80) recent.splice(0, recent.length - 80);
|
|
444
|
+
alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
|
|
445
|
+
const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
|
|
446
|
+
}
|
|
447
|
+
function connect() { const es = new EventSource("/feed"); es.onopen = () => setConn(true); es.onerror = () => setConn(false); es.addEventListener("roster", (e) => updateRoster(JSON.parse(e.data))); es.addEventListener("membership", (e) => applyMembership(JSON.parse(e.data))); es.addEventListener("message", (e) => onMessage(JSON.parse(e.data))); }
|
|
448
|
+
|
|
449
|
+
resize();
|
|
450
|
+
setInterval(setFeed, 5000); // age "live" → "stale" even without new events
|
|
451
|
+
load().then(connect).catch((err) => { console.error(err); setConn(false); });
|
|
452
|
+
requestAnimationFrame(frame);
|
|
453
|
+
})();
|
package/dist/web/index.html
CHANGED
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
.brand .mark { width: 10px; height: 10px; border-radius: 50%; background: var(--blue); }
|
|
38
38
|
.brand .title { font-size: 18px; font-weight: 700; color: var(--fg); }
|
|
39
39
|
.brand .space { font-size: 14px; color: var(--dim); }
|
|
40
|
+
.brand-graph {
|
|
41
|
+
font-size: 12px; font-weight: 600; color: var(--dim); text-decoration: none;
|
|
42
|
+
padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px;
|
|
43
|
+
}
|
|
44
|
+
.brand-graph:hover { color: var(--fg); border-color: var(--blue); }
|
|
40
45
|
.pill {
|
|
41
46
|
display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px;
|
|
42
47
|
border-radius: 20px; background: var(--tint-green);
|
|
@@ -322,6 +327,7 @@
|
|
|
322
327
|
<span class="title">Cotal</span>
|
|
323
328
|
<span class="space" id="space"></span>
|
|
324
329
|
<span class="pill down" id="conn"><span class="d"></span><span class="t">connecting</span></span>
|
|
330
|
+
<a class="brand-graph" href="/graph">Graph view →</a>
|
|
325
331
|
</span>
|
|
326
332
|
<div class="tiles" id="tiles"></div>
|
|
327
333
|
</header>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cotal-ai/cli",
|
|
3
3
|
"description": "Cotal mesh CLI: up, join, watch, console, web, spawn, mint, channels, history.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@clack/prompts": "^1.0.0",
|
|
22
22
|
"ink": "^6.0.0",
|
|
23
23
|
"react": "^19.0.0",
|
|
24
|
-
"@cotal-ai/core": "0.
|
|
24
|
+
"@cotal-ai/core": "0.7.0"
|
|
25
25
|
},
|
|
26
26
|
"optionalDependencies": {
|
|
27
27
|
"@eplightning/nats-server-darwin-arm64": "^2.14.0",
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** Emit a stripped signer file from this space's `auth.json`: only the account signing
|
|
2
|
-
* material (`space` + `account.pub` + `account.signingSeed`), no operator root-of-trust.
|
|
3
|
-
* Mount this into a containerized manager so it can mint per-agent creds without ever
|
|
4
|
-
* holding the key that mints new accounts. */
|
|
5
|
-
export declare function signer(argv: string[]): Promise<void>;
|
|
6
|
-
//# sourceMappingURL=signer.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../../src/commands/signer.ts"],"names":[],"mappings":"AAMA;;;+CAG+C;AAC/C,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB1D"}
|
package/dist/commands/signer.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { writeFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { parseArgs } from "node:util";
|
|
4
|
-
import { authDir, loadSpaceAuth, stripSpaceAuth } from "@cotal-ai/core";
|
|
5
|
-
import { c } from "../ui.js";
|
|
6
|
-
/** Emit a stripped signer file from this space's `auth.json`: only the account signing
|
|
7
|
-
* material (`space` + `account.pub` + `account.signingSeed`), no operator root-of-trust.
|
|
8
|
-
* Mount this into a containerized manager so it can mint per-agent creds without ever
|
|
9
|
-
* holding the key that mints new accounts. */
|
|
10
|
-
export async function signer(argv) {
|
|
11
|
-
const { values } = parseArgs({
|
|
12
|
-
args: argv,
|
|
13
|
-
options: { out: { type: "string" }, force: { type: "boolean" } },
|
|
14
|
-
});
|
|
15
|
-
const auth = loadSpaceAuth(authDir(process.cwd()));
|
|
16
|
-
if (!auth) {
|
|
17
|
-
console.error(c.red("no space auth found here — run `cotal up` first"));
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
const out = resolve(values.out ?? "signer.json");
|
|
21
|
-
if (existsSync(out) && !values.force) {
|
|
22
|
-
console.error(c.red(`${out} already exists — pass --force to overwrite`));
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
writeFileSync(out, JSON.stringify(stripSpaceAuth(auth), null, 2), { mode: 0o600 });
|
|
26
|
-
console.log(c.green(`✓ wrote signer for space "${auth.space}"`));
|
|
27
|
-
console.log(c.dim(` ${out}`));
|
|
28
|
-
console.log(c.dim(" mount read-only at /workspace/.cotal/auth/auth.json in the container"));
|
|
29
|
-
}
|
|
30
|
-
//# sourceMappingURL=signer.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"signer.js","sourceRoot":"","sources":["../../src/commands/signer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EAAE,CAAC,EAAE,MAAM,UAAU,CAAC;AAE7B;;;+CAG+C;AAC/C,MAAM,CAAC,KAAK,UAAU,MAAM,CAAC,IAAc;IACzC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;QAC3B,IAAI,EAAE,IAAI;QACV,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;KACjE,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IACnD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,iDAAiD,CAAC,CAAC,CAAC;QACxE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,IAAI,aAAa,CAAC,CAAC;IACjD,IAAI,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACrC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,6CAA6C,CAAC,CAAC,CAAC;QAC1E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACnF,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,6BAA6B,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACjE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC,CAAC;IAC/B,OAAO,CAAC,GAAG,CACT,CAAC,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAChF,CAAC;AACJ,CAAC"}
|
package/dist/commands/watch.d.ts
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
/** `watch` — the passive line stream of a space's activity. An alias of `console --plain`
|
|
2
|
-
* (same observer, same MeshView model, no full-screen takeover) — handy for pipes/CI. */
|
|
3
|
-
export declare function watch(argv: string[]): Promise<void>;
|
|
4
|
-
//# sourceMappingURL=watch.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/commands/watch.ts"],"names":[],"mappings":"AAEA;0FAC0F;AAC1F,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAEnD"}
|
package/dist/commands/watch.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { console_ } from "./console.js";
|
|
2
|
-
/** `watch` — the passive line stream of a space's activity. An alias of `console --plain`
|
|
3
|
-
* (same observer, same MeshView model, no full-screen takeover) — handy for pipes/CI. */
|
|
4
|
-
export function watch(argv) {
|
|
5
|
-
return console_([...argv, "--plain"]);
|
|
6
|
-
}
|
|
7
|
-
//# sourceMappingURL=watch.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"watch.js","sourceRoot":"","sources":["../../src/commands/watch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAExC;0FAC0F;AAC1F,MAAM,UAAU,KAAK,CAAC,IAAc;IAClC,OAAO,QAAQ,CAAC,CAAC,GAAG,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;AACxC,CAAC"}
|