@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.
Files changed (90) hide show
  1. package/dist/command.d.ts.map +1 -1
  2. package/dist/command.js +3 -2
  3. package/dist/command.js.map +1 -1
  4. package/dist/commands/channels.d.ts +4 -2
  5. package/dist/commands/channels.d.ts.map +1 -1
  6. package/dist/commands/channels.js +17 -18
  7. package/dist/commands/channels.js.map +1 -1
  8. package/dist/commands/completion.d.ts.map +1 -1
  9. package/dist/commands/completion.js +5 -1
  10. package/dist/commands/completion.js.map +1 -1
  11. package/dist/commands/console.d.ts.map +1 -1
  12. package/dist/commands/console.js +13 -29
  13. package/dist/commands/console.js.map +1 -1
  14. package/dist/commands/down.d.ts +1 -1
  15. package/dist/commands/down.d.ts.map +1 -1
  16. package/dist/commands/down.js +13 -1
  17. package/dist/commands/down.js.map +1 -1
  18. package/dist/commands/history.d.ts.map +1 -1
  19. package/dist/commands/history.js +6 -13
  20. package/dist/commands/history.js.map +1 -1
  21. package/dist/commands/join.d.ts.map +1 -1
  22. package/dist/commands/join.js +20 -7
  23. package/dist/commands/join.js.map +1 -1
  24. package/dist/commands/meshes.d.ts +5 -0
  25. package/dist/commands/meshes.d.ts.map +1 -0
  26. package/dist/commands/meshes.js +25 -0
  27. package/dist/commands/meshes.js.map +1 -0
  28. package/dist/commands/mint.d.ts +4 -1
  29. package/dist/commands/mint.d.ts.map +1 -1
  30. package/dist/commands/mint.js +26 -3
  31. package/dist/commands/mint.js.map +1 -1
  32. package/dist/commands/send.d.ts +8 -15
  33. package/dist/commands/send.d.ts.map +1 -1
  34. package/dist/commands/send.js +41 -24
  35. package/dist/commands/send.js.map +1 -1
  36. package/dist/commands/setup.d.ts.map +1 -1
  37. package/dist/commands/setup.js +23 -18
  38. package/dist/commands/setup.js.map +1 -1
  39. package/dist/commands/spawn.d.ts +4 -2
  40. package/dist/commands/spawn.d.ts.map +1 -1
  41. package/dist/commands/spawn.js +54 -22
  42. package/dist/commands/spawn.js.map +1 -1
  43. package/dist/commands/up.d.ts.map +1 -1
  44. package/dist/commands/up.js +133 -6
  45. package/dist/commands/up.js.map +1 -1
  46. package/dist/commands/use.d.ts +7 -0
  47. package/dist/commands/use.d.ts.map +1 -0
  48. package/dist/commands/use.js +27 -0
  49. package/dist/commands/use.js.map +1 -0
  50. package/dist/commands/web.d.ts.map +1 -1
  51. package/dist/commands/web.js +57 -27
  52. package/dist/commands/web.js.map +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +24 -37
  55. package/dist/index.js.map +1 -1
  56. package/dist/lib/connect.d.ts +72 -0
  57. package/dist/lib/connect.d.ts.map +1 -0
  58. package/dist/lib/connect.js +115 -0
  59. package/dist/lib/connect.js.map +1 -0
  60. package/dist/lib/delivery-proc.d.ts +35 -0
  61. package/dist/lib/delivery-proc.d.ts.map +1 -0
  62. package/dist/lib/delivery-proc.js +128 -0
  63. package/dist/lib/delivery-proc.js.map +1 -0
  64. package/dist/lib/manager-proc.d.ts +11 -2
  65. package/dist/lib/manager-proc.d.ts.map +1 -1
  66. package/dist/lib/manager-proc.js +43 -3
  67. package/dist/lib/manager-proc.js.map +1 -1
  68. package/dist/lib/meshes.d.ts +8 -0
  69. package/dist/lib/meshes.d.ts.map +1 -0
  70. package/dist/lib/meshes.js +15 -0
  71. package/dist/lib/meshes.js.map +1 -0
  72. package/dist/lib/onboard.js +6 -6
  73. package/dist/lib/onboard.js.map +1 -1
  74. package/dist/lib/transient.d.ts +7 -6
  75. package/dist/lib/transient.d.ts.map +1 -1
  76. package/dist/lib/transient.js +6 -25
  77. package/dist/lib/transient.js.map +1 -1
  78. package/dist/render.js +1 -1
  79. package/dist/web/graph.html +204 -0
  80. package/dist/web/graph.js +453 -0
  81. package/dist/web/index.html +6 -0
  82. package/package.json +2 -2
  83. package/dist/commands/signer.d.ts +0 -6
  84. package/dist/commands/signer.d.ts.map +0 -1
  85. package/dist/commands/signer.js +0 -30
  86. package/dist/commands/signer.js.map +0 -1
  87. package/dist/commands/watch.d.ts +0 -4
  88. package/dist/commands/watch.d.ts.map +0 -1
  89. package/dist/commands/watch.js +0 -7
  90. 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
+ })();
@@ -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.5.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.5.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"}
@@ -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"}
@@ -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"}
@@ -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"}