@cotal-ai/cli 0.6.0 → 0.8.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 (135) hide show
  1. package/dist/commands/channels.d.ts +4 -2
  2. package/dist/commands/channels.d.ts.map +1 -1
  3. package/dist/commands/channels.js +17 -18
  4. package/dist/commands/channels.js.map +1 -1
  5. package/dist/commands/console.d.ts.map +1 -1
  6. package/dist/commands/console.js +13 -29
  7. package/dist/commands/console.js.map +1 -1
  8. package/dist/commands/down-manifest.d.ts +6 -0
  9. package/dist/commands/down-manifest.d.ts.map +1 -0
  10. package/dist/commands/down-manifest.js +282 -0
  11. package/dist/commands/down-manifest.js.map +1 -0
  12. package/dist/commands/down.d.ts +5 -3
  13. package/dist/commands/down.d.ts.map +1 -1
  14. package/dist/commands/down.js +30 -3
  15. package/dist/commands/down.js.map +1 -1
  16. package/dist/commands/history.d.ts.map +1 -1
  17. package/dist/commands/history.js +6 -13
  18. package/dist/commands/history.js.map +1 -1
  19. package/dist/commands/join.d.ts.map +1 -1
  20. package/dist/commands/join.js +20 -7
  21. package/dist/commands/join.js.map +1 -1
  22. package/dist/commands/meshes.d.ts +5 -0
  23. package/dist/commands/meshes.d.ts.map +1 -0
  24. package/dist/commands/meshes.js +25 -0
  25. package/dist/commands/meshes.js.map +1 -0
  26. package/dist/commands/mint.d.ts.map +1 -1
  27. package/dist/commands/mint.js +2 -1
  28. package/dist/commands/mint.js.map +1 -1
  29. package/dist/commands/setup.d.ts.map +1 -1
  30. package/dist/commands/setup.js +66 -16
  31. package/dist/commands/setup.js.map +1 -1
  32. package/dist/commands/spawn-manifest.d.ts +10 -0
  33. package/dist/commands/spawn-manifest.d.ts.map +1 -0
  34. package/dist/commands/spawn-manifest.js +197 -0
  35. package/dist/commands/spawn-manifest.js.map +1 -0
  36. package/dist/commands/spawn.d.ts +4 -2
  37. package/dist/commands/spawn.d.ts.map +1 -1
  38. package/dist/commands/spawn.js +84 -25
  39. package/dist/commands/spawn.js.map +1 -1
  40. package/dist/commands/topology.d.ts +10 -0
  41. package/dist/commands/topology.d.ts.map +1 -0
  42. package/dist/commands/topology.js +46 -0
  43. package/dist/commands/topology.js.map +1 -0
  44. package/dist/commands/up.d.ts +4 -0
  45. package/dist/commands/up.d.ts.map +1 -1
  46. package/dist/commands/up.js +223 -6
  47. package/dist/commands/up.js.map +1 -1
  48. package/dist/commands/use.d.ts +7 -0
  49. package/dist/commands/use.d.ts.map +1 -0
  50. package/dist/commands/use.js +27 -0
  51. package/dist/commands/use.js.map +1 -0
  52. package/dist/commands/web.d.ts.map +1 -1
  53. package/dist/commands/web.js +60 -28
  54. package/dist/commands/web.js.map +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +27 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/lib/connect.d.ts +74 -0
  59. package/dist/lib/connect.d.ts.map +1 -0
  60. package/dist/lib/connect.js +91 -0
  61. package/dist/lib/connect.js.map +1 -0
  62. package/dist/lib/delivery-proc.d.ts.map +1 -1
  63. package/dist/lib/delivery-proc.js +2 -1
  64. package/dist/lib/delivery-proc.js.map +1 -1
  65. package/dist/lib/manager-proc.d.ts +4 -0
  66. package/dist/lib/manager-proc.d.ts.map +1 -1
  67. package/dist/lib/manager-proc.js +17 -0
  68. package/dist/lib/manager-proc.js.map +1 -1
  69. package/dist/lib/manifest/apply.d.ts +29 -0
  70. package/dist/lib/manifest/apply.d.ts.map +1 -0
  71. package/dist/lib/manifest/apply.js +138 -0
  72. package/dist/lib/manifest/apply.js.map +1 -0
  73. package/dist/lib/manifest/errors.d.ts +21 -0
  74. package/dist/lib/manifest/errors.d.ts.map +1 -0
  75. package/dist/lib/manifest/errors.js +19 -0
  76. package/dist/lib/manifest/errors.js.map +1 -0
  77. package/dist/lib/manifest/index.d.ts +13 -0
  78. package/dist/lib/manifest/index.d.ts.map +1 -0
  79. package/dist/lib/manifest/index.js +21 -0
  80. package/dist/lib/manifest/index.js.map +1 -0
  81. package/dist/lib/manifest/ledger.d.ts +81 -0
  82. package/dist/lib/manifest/ledger.d.ts.map +1 -0
  83. package/dist/lib/manifest/ledger.js +213 -0
  84. package/dist/lib/manifest/ledger.js.map +1 -0
  85. package/dist/lib/manifest/live.d.ts +25 -0
  86. package/dist/lib/manifest/live.d.ts.map +1 -0
  87. package/dist/lib/manifest/live.js +61 -0
  88. package/dist/lib/manifest/live.js.map +1 -0
  89. package/dist/lib/manifest/model.d.ts +71 -0
  90. package/dist/lib/manifest/model.d.ts.map +1 -0
  91. package/dist/lib/manifest/model.js +2 -0
  92. package/dist/lib/manifest/model.js.map +1 -0
  93. package/dist/lib/manifest/preflight.d.ts +12 -0
  94. package/dist/lib/manifest/preflight.d.ts.map +1 -0
  95. package/dist/lib/manifest/preflight.js +43 -0
  96. package/dist/lib/manifest/preflight.js.map +1 -0
  97. package/dist/lib/manifest/prepare.d.ts +57 -0
  98. package/dist/lib/manifest/prepare.d.ts.map +1 -0
  99. package/dist/lib/manifest/prepare.js +95 -0
  100. package/dist/lib/manifest/prepare.js.map +1 -0
  101. package/dist/lib/manifest/render.d.ts +41 -0
  102. package/dist/lib/manifest/render.d.ts.map +1 -0
  103. package/dist/lib/manifest/render.js +177 -0
  104. package/dist/lib/manifest/render.js.map +1 -0
  105. package/dist/lib/manifest/resolve.d.ts +5 -0
  106. package/dist/lib/manifest/resolve.d.ts.map +1 -0
  107. package/dist/lib/manifest/resolve.js +185 -0
  108. package/dist/lib/manifest/resolve.js.map +1 -0
  109. package/dist/lib/manifest/schema.d.ts +103 -0
  110. package/dist/lib/manifest/schema.d.ts.map +1 -0
  111. package/dist/lib/manifest/schema.js +77 -0
  112. package/dist/lib/manifest/schema.js.map +1 -0
  113. package/dist/lib/manifest/spawn-plan.d.ts +87 -0
  114. package/dist/lib/manifest/spawn-plan.d.ts.map +1 -0
  115. package/dist/lib/manifest/spawn-plan.js +75 -0
  116. package/dist/lib/manifest/spawn-plan.js.map +1 -0
  117. package/dist/lib/meshes.d.ts +2 -0
  118. package/dist/lib/meshes.d.ts.map +1 -0
  119. package/dist/lib/meshes.js +6 -0
  120. package/dist/lib/meshes.js.map +1 -0
  121. package/dist/lib/onboard.js +6 -6
  122. package/dist/lib/onboard.js.map +1 -1
  123. package/dist/lib/paths.js +1 -1
  124. package/dist/lib/paths.js.map +1 -1
  125. package/dist/lib/status.d.ts.map +1 -1
  126. package/dist/lib/status.js +2 -1
  127. package/dist/lib/status.js.map +1 -1
  128. package/dist/lib/transient.d.ts +7 -6
  129. package/dist/lib/transient.d.ts.map +1 -1
  130. package/dist/lib/transient.js +6 -25
  131. package/dist/lib/transient.js.map +1 -1
  132. package/dist/web/graph.html +207 -0
  133. package/dist/web/graph.js +508 -0
  134. package/dist/web/index.html +6 -0
  135. package/package.json +6 -2
@@ -0,0 +1,508 @@
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, hideOffline: true, hideEmpty: true };
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", present: false, 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
+ // while hiding, ghosts leave the sim entirely so the visible graph isn't laid out around invisible nodes (reheat on toggle re-settles)
113
+ const ns = [...hubs.values(), ...agents.values()].filter((n) => !isHidden(n));
114
+ for (let i = 0; i < ns.length; i++) {
115
+ const a = ns[i];
116
+ for (let j = i + 1; j < ns.length; j++) {
117
+ const b = ns[j];
118
+ let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
119
+ if (d2 < 1) { dx = (hash(a.name + i) % 11) - 5; dy = (hash(b.name + j) % 11) - 5; d2 = dx * dx + dy * dy || 1; }
120
+ const d = Math.sqrt(d2), q = ((a.charge * b.charge) / d2) * alpha;
121
+ a.vx += (dx / d) * q; a.vy += (dy / d) * q; b.vx -= (dx / d) * q; b.vy -= (dy / d) * q;
122
+ }
123
+ }
124
+ for (const e of edges.values()) { const h = hubs.get(e.chan); if (h && !isHidden(h) && !isHiddenMember(e)) link(e.a, h, 105, 0.08); }
125
+ for (const d of dms.values()) if (!isHidden(d.a) && !isHidden(d.b)) link(d.a, d.b, 165, 0.03);
126
+ // small graphs: faint tangential nudge so agents form a loose ring around their hub instead of a line (decays with alpha)
127
+ if (hubs.size <= 2) for (const a of agents.values()) { if (isHidden(a)) continue; 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; } }
128
+ // collision: position-based min-distance — prevents the 1/d² charge singularity + node overlap
129
+ const pad = 10;
130
+ for (let i = 0; i < ns.length; i++) {
131
+ const a = ns[i];
132
+ for (let j = i + 1; j < ns.length; j++) {
133
+ const b = ns[j];
134
+ let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 0.01;
135
+ const min = a.r + b.r + pad;
136
+ 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; }
137
+ }
138
+ }
139
+ for (const n of ns) {
140
+ n.vx += -n.x * 0.014 * alpha; n.vy += -n.y * 0.014 * alpha; // gravity toward center
141
+ 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
142
+ }
143
+ alpha += (0 - alpha) * 0.0228;
144
+ }
145
+
146
+ // ── traffic ──
147
+ const mk = (a, b, color, onArrive, curve) => ({ a, b, t: 0, dur: curve ? 1.4 : 1.1, color, onArrive: onArrive || null, curve: !!curve, trail: [] });
148
+ function onMessage({ mode, senderId, msg }) {
149
+ if (!msg) return;
150
+ const from = ensureAgent(senderId ? { id: senderId, name: msg.from?.name, role: msg.from?.role } : msg.from);
151
+ if (from) { from.ts = now(); from.present = true; } // a live sender is a live presence (roster event may lag)
152
+ const animate = !filter.paused && filter[mode];
153
+ let toName = null;
154
+ if (mode === "chat" && msg.channel) {
155
+ const h = ensureHub(msg.channel);
156
+ if (from) chatHit(from, msg.channel, now()).heat = 1;
157
+ // inbound: sender → hub, then the hub flashes and fans the post back out to every other member on
158
+ // the channel (their spokes glow as the wave reaches them) — a real broadcast.
159
+ if (animate && from && h) particles.push(mk(from, h, MODE.chat, () => {
160
+ blooms.push({ x: h.x, y: h.y, t: 0, dur: 0.95, color: MODE.chat, r0: h.r });
161
+ 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)); }
162
+ }));
163
+ } else if (mode === "unicast") {
164
+ const to = typeof msg.to === "string" ? agents.get(msg.to) : msg.to && agents.get(msg.to.id);
165
+ toName = to?.name || (typeof msg.to === "string" ? shortId(msg.to) : msg.to?.name);
166
+ if (from && to && from !== to) { dmHit(from, to, now()).heat = 1; if (animate) particles.push(mk(from, to, MODE.unicast, null, true)); }
167
+ } else if (mode === "anycast") {
168
+ toName = "@" + (msg.toService || "");
169
+ if (animate && from) blooms.push({ x: from.x, y: from.y, t: 0, dur: 1.0, color: MODE.anycast, r0: from.r });
170
+ }
171
+ recent.push({ mode, from: from?.name, fromId: from?.id, to: toName, chan: msg.channel, text: partsText(msg), ts: msg.ts || now() });
172
+ if (recent.length > 80) recent.shift();
173
+ if (sel) renderDetail();
174
+ }
175
+ function updateRoster(list) {
176
+ const seen = new Set();
177
+ for (const p of list) {
178
+ if (p.card?.kind === "endpoint") continue;
179
+ const a = ensureAgent({ id: p.card.id, name: p.card.name, role: p.card.role });
180
+ a.status = p.status; a.activity = p.activity || ""; a.role = p.card.role; a.harness = p.card.meta?.connector; a.ts = p.ts;
181
+ a.present = true; // in the roster = a live presence (the authority for isOffline)
182
+ seen.add(a.id);
183
+ }
184
+ // Drop an agent as soon as it goes offline OR leaves the roster (main's ghost fix, c9e9000) — EXCEPT
185
+ // keep it if it's still a feed member: a durable member whose presence is offline must persist to
186
+ // render as "member, currently offline" (the feed's durable arm survives offline). Membership is
187
+ // broker-truth, applied separately; presence no longer carries channels.
188
+ for (const [id, a] of agents) { if (!seen.has(id)) a.present = false; 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(); } }
189
+ }
190
+
191
+ // ── membership (authoritative spokes) ──
192
+ function applyMembership(snap) {
193
+ if (!snap) return;
194
+ feed.asOf = snap.asOf;
195
+ feed.available = snap.asOf !== undefined || (Array.isArray(snap.members) && snap.members.length > 0);
196
+ setFeed();
197
+ const known = [...hubs.keys()];
198
+ const present = new Set();
199
+ for (const m of snap.members || []) {
200
+ const a = ensureAgent({ id: m.id });
201
+ present.add(a.id);
202
+ a.live = m.live || []; a.durable = m.durable || [];
203
+ const mc = memberChannels(a.live, a.durable, known);
204
+ a.memberOf = mc.channels; a.wideReader = mc.wide;
205
+ for (const [ch, kind] of mc.channels) { ensureHub(ch); const e = ensureEdge(a, ch); e.mem = true; e.durableOnly = kind === "durable"; }
206
+ pruneMemberEdges(a, mc.channels);
207
+ }
208
+ // An agent that dropped out of the feed entirely is no longer a member of anything (incl. a wide reader,
209
+ // which carries the flag but no concrete edges).
210
+ 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; }
211
+ recomputeHubEmpty(); // these mutations change hub visibility — refresh before the detail/selection check below
212
+ if (sel) { if (isHidden(sel)) closeDetail(); else renderDetail(); }
213
+ }
214
+ // Drop this agent's membership edges that are no longer in `keep`; a still-warm one stays as a fading
215
+ // traffic-only edge (mem:false) and is pruned later when cold, so a comet in flight isn't orphaned.
216
+ function pruneMemberEdges(a, keep) {
217
+ 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(); }
218
+ }
219
+
220
+ // ── render loop ──
221
+ function frame(t) {
222
+ const dt = Math.min(0.05, (t - lastT) / 1000 || 0); lastT = t;
223
+ // Prune cold traffic-only spokes (a non-member's post that has faded). Membership spokes persist by
224
+ // membership, never on a timer — they're the resting skeleton, faint at rest, glowing on traffic.
225
+ for (const [k, e] of edges) if (!e.mem && e.heat <= TRAFFIC_COLD && now() - e.last > 1000) { edges.delete(k); reheat(); }
226
+ recomputeHubEmpty(); // no VISIBLE members = dormant (hidden offline ghosts don't keep it lit); drives hub hide + dim
227
+ if (sel && isHidden(sel)) closeDetail(); // a selection hidden by a membership/roster change (not just the toggle) closes its card
228
+ physics();
229
+ // re-frame only once the sim has cooled, so the camera doesn't chase the re-settle wobble
230
+ 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; }
231
+ 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; }
232
+
233
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
234
+ ctx.clearRect(0, 0, W, H);
235
+ drawStarfield(ctx, W, H, cam);
236
+ ctx.translate(cam.x, cam.y); ctx.scale(cam.scale, cam.scale);
237
+ drawSpokes(); drawDmEdges(); drawBlooms(dt); drawParticles(dt); drawNodes();
238
+ requestAnimationFrame(frame);
239
+ }
240
+
241
+ // Hover/select a hub or agent → highlight its membership fan, dim the rest (dandelion mitigation).
242
+ function fanFocus() { return hover && hover.kind === "hub" ? hover : sel && sel.kind === "hub" ? sel : hover && hover.kind === "agent" ? hover : sel && sel.kind === "agent" ? sel : null; }
243
+ function inFan(e, f) { if (!f) return true; return f.kind === "hub" ? e.chan === f.name : e.a === f; }
244
+ // Core offline-ness of an AGENT (toggle-INDEPENDENT): it is NOT a live presence — either presence reports it
245
+ // explicitly `offline`, or it is absent from the presence roster entirely (`present === false`). Presence is
246
+ // the authority here, NOT the membership feed. This is what fixes the "ghost flashes back for ~1s" report: a
247
+ // ghost that left EARLIER (its roster-absence already observed, `present === false`) stays hidden when a later
248
+ // snapshot empties its memberOf — there is no per-node `memberOf.size` heuristic to flip it back to VISIBLE on
249
+ // a membership change. (Membership and roster are independent SSE events; this covers the reported case, where
250
+ // a peer's channel join drops a long-departed ghost. A near-simultaneous disconnect whose membership-drop is
251
+ // processed before its roster-absence can still show the node for one roster interval — presence stays the
252
+ // authority and it self-resolves on the next roster event; review-ux/critic R2.) It also does NOT false-hide a
253
+ // genuinely-connected agent that just sits in no channel (e.g.
254
+ // `manager`/DM-only: roster-present, but absent from the channel feed, so its `live` is an empty DEFAULT, not
255
+ // proof of disconnection — review-freelance R2). `present` is set from the roster in updateRoster; a live
256
+ // message sender is marked present too.
257
+ const isOffline = (n) => n.kind === "agent" && (n.status === "offline" || !n.present);
258
+ // A membership edge that is offline FOR ITS channel: durable-only here, or the agent itself is offline.
259
+ const isOfflineMember = (e) => e.durableOnly || isOffline(e.a);
260
+ // Render/sim visibility. `hide offline` (on by default) collapses offline AGENTS. `hide empty` (on by
261
+ // default) collapses a HUB with no VISIBLE member under the current filters — it reads the cached `h.empty`
262
+ // (kept current by recomputeHubEmpty). So when `hide offline` is ON this
263
+ // means "no ONLINE member"; when it's OFF a channel that still shows offline members keeps its hub — the two
264
+ // toggles don't fight (review-critic R2). Gated on `feed.available`: without the authoritative feed there
265
+ // are no membership edges, so every hub would read empty — we can't tell a quiet channel from an unknown
266
+ // one, so we don't hide. Reading the cached flag keeps isHidden(hub) O(1), not an all-edges scan per call.
267
+ // Hiding suppresses node/spoke rendering, hit-testing, camera framing, and physics participation; the node
268
+ // stays in the model, so toggling reveals it instantly.
269
+ const isHidden = (n) => n.kind === "hub"
270
+ ? filter.hideEmpty && feed.available && n.empty
271
+ : filter.hideOffline && isOffline(n);
272
+ // Per-CHANNEL visibility of a membership spoke: hidden when `hide offline` is on AND this edge is offline
273
+ // for its channel (durable-only, or the agent is offline) — so "hide offline" holds per channel, not just
274
+ // per node (an agent live elsewhere keeps its node, but its dashed offline spokes + roster seats drop).
275
+ const isHiddenMember = (e) => filter.hideOffline && isOfflineMember(e);
276
+ // Recompute every hub's `empty` (no VISIBLE member under the current filters) in ONE O(hubs+edges) pass.
277
+ // isHidden(hub) reads this cached flag, so call this synchronously anywhere a hub's visibility is consulted
278
+ // outside the render loop — toggle cleanup + after applyMembership — so detail/selection logic never reads a
279
+ // stale flag (review-critic/freelance/ux R2). The frame loop calls it too, for roster/traffic-driven changes.
280
+ function recomputeHubEmpty() {
281
+ for (const h of hubs.values()) h.empty = true;
282
+ for (const e of edges.values()) if (e.mem && !isHiddenMember(e)) { const h = hubs.get(e.chan); if (h) h.empty = false; }
283
+ }
284
+
285
+ function drawSpokes() {
286
+ ctx.lineCap = "round";
287
+ const f = fanFocus();
288
+ // structure layer: a constant-faint spoke per membership (solid = live, dashed-dim = member-offline)
289
+ for (const e of edges.values()) {
290
+ if (!e.mem || isHiddenMember(e)) continue;
291
+ const h = hubs.get(e.chan); if (!h || isHidden(h)) continue;
292
+ const off = e.durableOnly || e.a.status === "offline";
293
+ const lit = inFan(e, f);
294
+ ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(h.x, h.y);
295
+ ctx.setLineDash(off ? [3, 4] : []);
296
+ ctx.strokeStyle = rgba(off ? MEM_OFF : MEM_LIVE, (off ? 0.3 : 0.42) * (lit ? 1 : 0.28));
297
+ ctx.lineWidth = 1.4; ctx.stroke();
298
+ }
299
+ ctx.setLineDash([]);
300
+ // activity layer: traffic glow on top (members + transient non-member posts)
301
+ ctx.globalCompositeOperation = "lighter";
302
+ for (const e of edges.values()) { const h = hubs.get(e.chan); if (!filter.chat || !h || isHidden(h) || e.heat <= 0.02 || isHiddenMember(e)) 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(); }
303
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
304
+ }
305
+ function drawDmEdges() {
306
+ if (!filter.unicast) return; // the `direct` chip filters DM traffic — including the persistent DM curves
307
+ ctx.setLineDash([3, 4]);
308
+ for (const d of dms.values()) {
309
+ if (isHidden(d.a) || isHidden(d.b)) continue;
310
+ 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;
311
+ const cx = mx + (nx / len) * 24, cy = my + (ny / len) * 24;
312
+ ctx.beginPath(); ctx.moveTo(d.a.x, d.a.y); ctx.quadraticCurveTo(cx, cy, d.b.x, d.b.y);
313
+ ctx.strokeStyle = rgba(MODE.unicast, 0.5 + d.heat * 0.45); ctx.lineWidth = 1.7 + d.heat * 1.6; ctx.stroke();
314
+ }
315
+ ctx.setLineDash([]); ctx.globalAlpha = 1;
316
+ }
317
+ function drawNodes() {
318
+ ctx.textAlign = "center"; ctx.textBaseline = "middle";
319
+ const t = performance.now() / 1000;
320
+ for (const h of hubs.values()) {
321
+ if (isHidden(h)) continue;
322
+ const focus = h === hover || h === sel, dim = h.empty ? 0.55 : 1; // dormant hubs read quieter, not gone
323
+ ctx.save(); ctx.shadowColor = MODE.chat; ctx.shadowBlur = (focus ? 28 : 16) * dim;
324
+ 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");
325
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(h.x, h.y, h.r, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
326
+ ctx.lineWidth = 1.5; ctx.strokeStyle = rgba(MODE.chat, 0.95 * dim); ctx.stroke();
327
+ 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);
328
+ }
329
+ for (const a of agents.values()) {
330
+ if (isHidden(a)) continue;
331
+ const col = STAT[a.status] || STAT.idle, focus = a === hover || a === sel, off = a.status === "offline";
332
+ const r = a.r + Math.sin(t * 0.8 + a.phase) * 0.4;
333
+ 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(); } }
334
+ // wide reader (subscribes `>`/`*`): a faint dashed halo — "reads all channels" without a spoke per hub
335
+ 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(); }
336
+ ctx.save(); ctx.shadowColor = col; ctx.shadowBlur = focus ? 20 : off ? 3 : 13;
337
+ 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");
338
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
339
+ ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, off ? 0.6 : 1); ctx.stroke();
340
+ 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); }
341
+ }
342
+ ctx.globalAlpha = 1;
343
+ }
344
+
345
+ // ── atmosphere + motion ──
346
+ 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; }; };
347
+ let stars = null, starW = 0, starH = 0;
348
+ 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; }
349
+ const drawStarfield = (ctx, W, H, cam) => {
350
+ if (!stars || starW !== W || starH !== H) buildStars(W, H);
351
+ const t = performance.now() / 1000;
352
+ 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);
353
+ ctx.globalCompositeOperation = "lighter";
354
+ 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); };
355
+ blob(0.28, 0.32, Math.max(W, H) * 0.45, "#1a3a5c"); blob(0.72, 0.68, Math.max(W, H) * 0.4, "#2a1a4a");
356
+ 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(); }
357
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
358
+ };
359
+ function drawParticles(dt) {
360
+ ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round";
361
+ for (let i = particles.length - 1; i >= 0; i--) {
362
+ const p = particles[i]; if (!filter.paused) p.t += dt / p.dur;
363
+ // a fan-out comet to a hidden offline member would fly to empty space — still tick + retire it, just don't draw
364
+ if (isHidden(p.a) || isHidden(p.b)) { if (p.t >= 1) { if (p.onArrive) p.onArrive(); particles.splice(i, 1); } continue; }
365
+ const t = ease(Math.min(1, p.t)); let x, y;
366
+ 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; }
367
+ else { x = p.a.x + (p.b.x - p.a.x) * t; y = p.a.y + (p.b.y - p.a.y) * t; }
368
+ if (!filter.paused) { p.trail.push(x, y); if (p.trail.length > 12) p.trail.splice(0, p.trail.length - 12); }
369
+ const n = p.trail.length >> 1;
370
+ 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(); }
371
+ 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();
372
+ if (p.t >= 1) { if (p.onArrive) p.onArrive(); particles.splice(i, 1); }
373
+ }
374
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
375
+ }
376
+ function drawBlooms(dt) {
377
+ ctx.globalCompositeOperation = "lighter";
378
+ for (let i = blooms.length - 1; i >= 0; i--) {
379
+ const b = blooms[i]; b.t += dt / b.dur; if (b.t >= 1) { blooms.splice(i, 1); continue; }
380
+ const flash = Math.max(0, 1 - b.t / 0.35);
381
+ 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(); }
382
+ 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(); };
383
+ ring(0); ring(0.15);
384
+ }
385
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
386
+ }
387
+
388
+ // ── camera + hit-testing ──
389
+ function fitTarget() {
390
+ const ns = [...hubs.values(), ...agents.values()]; if (!ns.length) return { x: W / 2, y: H / 2, scale: 1 };
391
+ // content nodes drive the frame; empty hubs only nudge the padding so one stray node can't shrink the live graph
392
+ const content = ns.filter((n) => !(n.kind === "hub" && n.empty) && !isHidden(n)), frame = content.length ? content : ns;
393
+ let a = 1e9, b = 1e9, c = -1e9, d = -1e9;
394
+ 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); }
395
+ for (const h of hubs.values()) if (h.empty && !isHidden(h)) { 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); }
396
+ const bw = c - a || 1, bh = d - b || 1, pad = 90, maxScale = ns.length <= 6 ? 2.4 : 1.6;
397
+ const scale = Math.max(0.35, Math.min(maxScale, Math.min((W - pad * 2) / bw, (H - pad * 2) / bh)));
398
+ return { x: W / 2 - ((a + c) / 2) * scale, y: H / 2 - ((b + d) / 2) * scale, scale };
399
+ }
400
+ const toWorld = (sx, sy) => ({ x: (sx - cam.x) / cam.scale, y: (sy - cam.y) / cam.scale });
401
+ function pick(sx, sy) { const w = toWorld(sx, sy); let best = null, bd = 1e9; for (const n of [...hubs.values(), ...agents.values()]) { if (isHidden(n)) continue; 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; }
402
+
403
+ // ── membership freshness pill ──
404
+ function setFeed() {
405
+ const el = $("feed"); if (!el) return;
406
+ el.hidden = false;
407
+ let cls, text;
408
+ if (!feed.available) { cls = "off"; text = "membership: traffic-only"; }
409
+ else { const age = feed.asOf ? now() - feed.asOf : Infinity; if (age < FEED_STALE_MS) { cls = ""; text = "membership: live"; } else { cls = "stale"; text = "membership: stale"; } }
410
+ el.className = "pill" + (cls ? " " + cls : "");
411
+ el.querySelector(".t").textContent = text;
412
+ }
413
+
414
+ // ── detail panel ──
415
+ function closeDetail() { sel = null; $("detail").classList.remove("open"); }
416
+ function recentRows(test) {
417
+ const ms = recent.filter(test).slice(-6).reverse();
418
+ 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>`;
419
+ }
420
+ function renderDetail() {
421
+ const el = $("detail"); if (!sel) { el.classList.remove("open"); return; }
422
+ if (sel.kind === "hub") {
423
+ // members from the broker feed (subscribed), split into live vs member-currently-offline; plus a
424
+ // "recently active" subset (who actually posted here) vs just-subscribed.
425
+ const memEdges = [...edges.values()].filter((e) => e.chan === sel.name && e.mem);
426
+ // hide per MEMBERSHIP edge: an agent live elsewhere but durable-only here is offline FOR THIS channel
427
+ const mem = memEdges.filter((e) => !isHiddenMember(e)).map((e) => e.a).sort((x, y) => x.name.localeCompare(y.name));
428
+ const hiddenOff = memEdges.length - mem.length;
429
+ const activeIds = new Set(recent.filter((m) => m.chan === sel.name && m.fromId).map((m) => m.fromId));
430
+ 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>`; };
431
+ const memberList = mem.length ? `<div class="d-tags">${mem.map(memberRow).join("")}</div>` : `<div class="d-block muted">${hiddenOff ? `${hiddenOff} member${hiddenOff === 1 ? "" : "s"} offline (hidden)` : "no subscribers yet"}</div>`;
432
+ el.innerHTML = `<span class="x" id="dx">✕</span>
433
+ <div class="d-kind">channel</div>
434
+ <div class="d-who">#${esc(sel.name)}</div>
435
+ ${sel.desc ? `<div class="d-block">${esc(sel.desc)}</div>` : ""}
436
+ <div class="d-rows">
437
+ <div class="d-row"><span class="k">subscribers</span><span class="v">${hiddenOff ? `${mem.length} shown <span style="color:var(--faint)">+${hiddenOff} offline hidden</span>` : `${mem.length} agent${mem.length === 1 ? "" : "s"}`}</span></div>
438
+ <div class="d-row"><span class="k">messages</span><span class="v">${sel.msgs || 0}</span></div>
439
+ </div>
440
+ <div class="d-section"><div class="d-label">members</div>${memberList}</div>
441
+ <div class="d-section"><div class="d-label">recent</div><div class="d-msgs">${recentRows((m) => m.chan === sel.name)}</div></div>`;
442
+ } else {
443
+ // an agent's FULL subscription set from the feed: live patterns + durable. A whole-breadth `>`/`*`
444
+ // grant shows as a single "all channels" chip, not literal `#>`; bounded subtrees show literally.
445
+ const wideChip = sel.wideReader ? `<span class="ctag">all channels</span>` : "";
446
+ const liveSet = (sel.live || []).filter((c) => c !== ">" && c !== "*").map((c) => `<span class="ctag">#${esc(c)}</span>`).join("");
447
+ const durOnly = (sel.durable || []).filter((c) => !(sel.live || []).includes(c)).map((c) => `<span class="ctag off">#${esc(c)}</span>`).join("");
448
+ const subs = wideChip || liveSet || durOnly ? `<div class="d-tags">${wideChip}${liveSet}${durOnly}</div>` : `<div class="d-block muted">no channel subscriptions</div>`;
449
+ el.innerHTML = `<span class="x" id="dx">✕</span>
450
+ <div class="d-kind">agent</div>
451
+ <div class="d-who">${esc(sel.name)}${sel.role ? `<span class="role">${esc(sel.role)}</span>` : ""}</div>
452
+ <div class="d-status ${sel.status}"><span class="dot"></span>${esc(sel.status)}</div>
453
+ <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>
454
+ <div class="d-section"><div class="d-label">subscribes</div>${subs}</div>
455
+ ${sel.harness ? `<div class="d-rows"><div class="d-row"><span class="k">harness</span><span class="v">${esc(sel.harness)}</span></div></div>` : ""}
456
+ <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>`;
457
+ }
458
+ el.classList.add("open"); $("dx").onclick = closeDetail;
459
+ }
460
+
461
+ // ── events ──
462
+ 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; } }
463
+ window.addEventListener("resize", resize);
464
+ let drag = null;
465
+ 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); });
466
+ canvas.addEventListener("mousedown", (e) => { drag = { sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false }; });
467
+ 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; });
468
+ 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 });
469
+ window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
470
+ $("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]); };
471
+ $("pause").onclick = () => { filter.paused = !filter.paused; $("pause").classList.toggle("on", filter.paused); $("pause").textContent = filter.paused ? "▶ resume" : "⏸ pause"; };
472
+ $("hideOffline").onclick = () => { filter.hideOffline = !filter.hideOffline; $("hideOffline").classList.toggle("on", filter.hideOffline); recomputeHubEmpty(); if (sel && isHidden(sel)) closeDetail(); if (hover && isHidden(hover)) hover = null; reheat(); if (sel) renderDetail(); };
473
+ $("hideEmpty").onclick = () => { filter.hideEmpty = !filter.hideEmpty; $("hideEmpty").classList.toggle("on", filter.hideEmpty); recomputeHubEmpty(); if (sel && isHidden(sel)) closeDetail(); if (hover && isHidden(hover)) hover = null; reheat(); if (sel) renderDetail(); };
474
+ $("legendToggle").onclick = () => $("legend").classList.toggle("collapsed");
475
+ function setConn(live) { const el = $("conn"); el.classList.toggle("down", !live); el.querySelector(".t").textContent = live ? "live" : "disconnected"; }
476
+
477
+ // ── boot ──
478
+ async function load() {
479
+ const [meta, roster, chans, membership, activity, dmHist] = await Promise.all([
480
+ fetch("/api/meta").then((r) => r.json()), fetch("/api/roster").then((r) => r.json()), fetch("/api/channels").then((r) => r.json()),
481
+ fetch("/api/membership").then((r) => r.json()).catch(() => ({ members: [] })),
482
+ fetch("/api/activity?limit=400").then((r) => r.json()).catch(() => []), fetch("/api/dms?limit=400").then((r) => r.json()).catch(() => []),
483
+ ]);
484
+ $("space").textContent = "· " + meta.space;
485
+ for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; }
486
+ updateRoster(roster);
487
+ applyMembership(membership); // authoritative spokes BEFORE traffic seeding (no skeleton flicker)
488
+ 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()); }
489
+ 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()); }
490
+ // Seed the `recent` buffer from the activity backfill so the channel detail's "recently active" tags +
491
+ // the "recent" section aren't empty until the first live SSE message arrives (norman).
492
+ for (const e of activity.slice(-80)) {
493
+ const m = e.msg; if (!m) continue;
494
+ 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;
495
+ recent.push({ mode: e.mode, from: m.from?.name, fromId: m.from?.id, to, chan: m.channel, text: partsText(m), ts: m.ts || now() });
496
+ }
497
+ recent.sort((a, b) => a.ts - b.ts);
498
+ if (recent.length > 80) recent.splice(0, recent.length - 80);
499
+ alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
500
+ const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
501
+ }
502
+ 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))); }
503
+
504
+ resize();
505
+ setInterval(setFeed, 5000); // age "live" → "stale" even without new events
506
+ load().then(connect).catch((err) => { console.error(err); setConn(false); });
507
+ requestAnimationFrame(frame);
508
+ })();
@@ -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.6.0",
4
+ "version": "0.8.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -21,7 +21,10 @@
21
21
  "@clack/prompts": "^1.0.0",
22
22
  "ink": "^6.0.0",
23
23
  "react": "^19.0.0",
24
- "@cotal-ai/core": "0.6.0"
24
+ "yaml": "^2.9.0",
25
+ "zod": "^4.4.3",
26
+ "@cotal-ai/workspace": "0.8.0",
27
+ "@cotal-ai/core": "0.8.0"
25
28
  },
26
29
  "optionalDependencies": {
27
30
  "@eplightning/nats-server-darwin-arm64": "^2.14.0",
@@ -42,6 +45,7 @@
42
45
  },
43
46
  "scripts": {
44
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "test": "tsx smoke/manifest.smoke.ts",
45
49
  "build": "tsc -p tsconfig.json && rm -rf dist/web && cp -R src/web dist/web"
46
50
  }
47
51
  }