@cotal-ai/web 0.12.0 → 0.13.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.
@@ -98,6 +98,11 @@
98
98
  #legend .sw-dot { width: 8px; height: 8px; border-radius: 50%; }
99
99
  #legend .ring { width: 8px; height: 8px; border-radius: 50%; flex: none;
100
100
  background: transparent; box-shadow: 0 0 0 1.5px currentColor inset; }
101
+ /* Offline is hollow dashed (shape channel), not a filled/inset-solid ring. */
102
+ #legend .ring.off {
103
+ background: transparent; box-shadow: none;
104
+ border: 1.5px dashed currentColor;
105
+ }
101
106
 
102
107
  /* ── detail panel (right, floating) ── */
103
108
  #detail {
@@ -135,6 +140,10 @@
135
140
  .d-row { display: flex; gap: 10px; font-size: 11.5px; line-height: 1.4; }
136
141
  .d-row .k { color: var(--faint); min-width: 64px; flex: none; }
137
142
  .d-row .v { color: var(--fg); word-break: break-word; }
143
+ .d-row .v.muted { color: var(--faint); font-style: italic; }
144
+ .d-row .v .hmark { margin-right: 4px; font-weight: 700; }
145
+ .d-row .v.att-dnd { color: #db6d28; font-weight: 600; }
146
+ .d-row .v.att-focus { color: var(--chat); font-weight: 600; }
138
147
  .d-msgs { display: flex; flex-direction: column; gap: 5px; }
139
148
  .d-msg { font-size: 11px; line-height: 1.45; padding: 7px 10px 8px; border-radius: 6px;
140
149
  background: #ffffff06; border-left: 2px solid var(--line); }
@@ -195,13 +204,16 @@
195
204
  <span class="ls">agents</span>
196
205
  <span class="row"><span class="ring" style="color:var(--working)"></span> working</span>
197
206
  <span class="row"><span class="ring" style="color:var(--waiting)"></span> waiting · needs input</span>
198
- <span class="row"><span class="ring" style="color:var(--idle)"></span> idle / offline</span>
207
+ <span class="row"><span class="ring" style="color:var(--idle)"></span> idle (filled)</span>
208
+ <span class="row"><span class="ring off" style="color:var(--idle);opacity:.85"></span> offline (hollow dashed)</span>
209
+ <span class="row"><span style="font-size:12px;color:#db6d28;font-weight:700">◼</span> dnd · <span style="font-size:12px;color:var(--chat);font-weight:700">◉</span> focus</span>
199
210
  </div>
200
211
  </div>
201
212
 
202
213
  <aside id="detail" class="glass"></aside>
203
214
  <div class="hint" id="hint">click a node for detail · scroll to zoom · drag to pan</div>
204
215
 
216
+ <script src="/harness.js"></script>
205
217
  <script src="/graph.js"></script>
206
218
  </body>
207
219
  </html>
package/dist/web/graph.js CHANGED
@@ -26,6 +26,33 @@
26
26
  const MEM_OFF = "#5a6472"; // a durable member whose presence is offline ("member, currently offline")
27
27
  const TRAFFIC_COLD = 0.02; // heat below which a NON-member (traffic-only) spoke is pruned
28
28
  const FEED_STALE_MS = 45000; // membership feed older than this reads "stale" (daemon polls ~15s)
29
+ // Harness branding from harness.js (one source with the monitor). Canvas uses .glyph; DOM uses .svg.
30
+ const HARNESS = window.COTAL_HARNESS || {};
31
+ const harnessLabel = (k) => (HARNESS[k] ? HARNESS[k].label : k);
32
+ const harnessColor = (k) => (HARNESS[k] ? HARNESS[k].color : "#8b949e");
33
+ const harnessGlyph = (k) => (HARNESS[k] ? HARNESS[k].glyph : "·");
34
+ // Attention: open/absent are identical (receives all) — only dnd/focus surface.
35
+ const attMark = (a) => (a === "dnd" ? "◼" : a === "focus" ? "◉" : "");
36
+ /** Fit `provider/model · variant` into maxChars. Prefer dropping the provider prefix over the
37
+ * variant — "gpt-5.6-sol · xhigh" carries more per pixel than "openai/gpt-5.6-sol ·…". */
38
+ function fitModelLabel(model, variant, maxChars) {
39
+ let core = String(model);
40
+ const withVar = (m) => (variant ? `${m} · ${variant}` : m);
41
+ let s = withVar(core);
42
+ if (s.length <= maxChars) return s;
43
+ if (core.includes("/")) {
44
+ core = core.slice(core.lastIndexOf("/") + 1);
45
+ s = withVar(core);
46
+ if (s.length <= maxChars) return s;
47
+ }
48
+ if (variant) {
49
+ const suf = ` · ${variant}`;
50
+ const room = maxChars - suf.length;
51
+ if (room >= 4) return `${core.slice(0, room - 1)}…${suf}`;
52
+ return String(variant).length <= maxChars ? String(variant) : `${String(variant).slice(0, maxChars - 1)}…`;
53
+ }
54
+ return core.length <= maxChars ? core : `${core.slice(0, maxChars - 1)}…`;
55
+ }
29
56
 
30
57
  // ── state ──
31
58
  const hubs = new Map(); // channel -> hub node
@@ -88,7 +115,7 @@
88
115
  const id = typeof ref === "object" ? ref.id || ref.name : ref;
89
116
  if (!id) return null;
90
117
  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(); }
118
+ 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, model: undefined, variant: undefined, attention: 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
119
  else if (typeof ref === "object" && ref.name) a.name = ref.name;
93
120
  return a;
94
121
  }
@@ -143,30 +170,65 @@
143
170
  alpha += (0 - alpha) * 0.0228;
144
171
  }
145
172
 
146
- // ── traffic ──
173
+ // ── traffic (visuals are decoration; graph STATE is not) ──
174
+ // Particle/bloom arrays are bounded. A backgrounded tab stops rAF, so an unbounded queue + the
175
+ // chat onArrive fan-out (one comet per other member) detonates into thousands of particles on
176
+ // return — measured peak 8042 from 120 backlogged chats. Gate enqueue on visibility; hard-cap
177
+ // both arrays (drop oldest; dropped onArrive is skipped — heat already applied on the state path).
178
+ const PARTICLE_CAP = 240;
179
+ const BLOOM_CAP = 80;
180
+ const tabVisible = () => document.visibilityState === "visible";
147
181
  const mk = (a, b, color, onArrive, curve) => ({ a, b, t: 0, dur: curve ? 1.4 : 1.1, color, onArrive: onArrive || null, curve: !!curve, trail: [] });
182
+ function pushParticle(p) {
183
+ if (!tabVisible()) return false;
184
+ particles.push(p);
185
+ while (particles.length > PARTICLE_CAP) particles.shift(); // drop oldest; skip its onArrive
186
+ return true;
187
+ }
188
+ function pushBloom(b) {
189
+ if (!tabVisible()) return false;
190
+ blooms.push(b);
191
+ while (blooms.length > BLOOM_CAP) blooms.shift();
192
+ return true;
193
+ }
194
+ /** Apply fan-out spoke heat without enqueueing comets (used when visuals are gated off). */
195
+ function heatFanOut(channel, from) {
196
+ for (const e of edges.values()) if (e.chan === channel && e.a !== from) e.heat = 1;
197
+ }
148
198
  function onMessage({ mode, senderId, msg }) {
149
199
  if (!msg) return;
150
200
  const from = ensureAgent(senderId ? { id: senderId, name: msg.from?.name, role: msg.from?.role } : msg.from);
151
201
  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];
202
+ // Visual gate: pause chip, mode filter, AND tab visibility. State (heat/recent/roster) always applies.
203
+ const animate = !filter.paused && filter[mode] && tabVisible();
153
204
  let toName = null;
154
205
  if (mode === "chat" && msg.channel) {
155
206
  const h = ensureHub(msg.channel);
156
207
  if (from) chatHit(from, msg.channel, now()).heat = 1;
157
208
  // inbound: sender → hub, then the hub flashes and fans the post back out to every other member on
158
209
  // 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
- }));
210
+ if (animate && from && h) {
211
+ pushParticle(mk(from, h, MODE.chat, () => {
212
+ pushBloom({ x: h.x, y: h.y, t: 0, dur: 0.95, color: MODE.chat, r0: h.r });
213
+ for (const e of edges.values()) if (e.chan === msg.channel && e.a !== from) {
214
+ e.heat = 1;
215
+ pushParticle(mk(h, e.a, MODE.chat, null, false));
216
+ }
217
+ }));
218
+ } else if (from && h) {
219
+ // No comet: still heat every member spoke so the skeleton reflects the broadcast on return.
220
+ heatFanOut(msg.channel, from);
221
+ }
163
222
  } else if (mode === "unicast") {
164
223
  const to = typeof msg.to === "string" ? agents.get(msg.to) : msg.to && agents.get(msg.to.id);
165
224
  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)); }
225
+ if (from && to && from !== to) {
226
+ dmHit(from, to, now()).heat = 1;
227
+ if (animate) pushParticle(mk(from, to, MODE.unicast, null, true));
228
+ }
167
229
  } else if (mode === "anycast") {
168
230
  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 });
231
+ if (animate && from) pushBloom({ x: from.x, y: from.y, t: 0, dur: 1.0, color: MODE.anycast, r0: from.r });
170
232
  }
171
233
  recent.push({ mode, from: from?.name, fromId: from?.id, to: toName, chan: msg.channel, text: partsText(msg), ts: msg.ts || now() });
172
234
  if (recent.length > 80) recent.shift();
@@ -177,7 +239,12 @@
177
239
  for (const p of list) {
178
240
  if (p.card?.kind === "endpoint") continue;
179
241
  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;
242
+ a.status = p.status; a.activity = p.activity || ""; a.role = p.card.role;
243
+ a.harness = p.card.meta?.connector; a.model = p.card.meta?.model; a.variant = p.card.meta?.variant;
244
+ a.attention = p.attention; // open/absent both mean receives-all; only dnd/focus render
245
+ // Card legibility fields the detail panel renders (same source as the Monitor's Agent Detail).
246
+ a.description = p.card.description; a.tags = p.card.tags; a.channelModes = p.channelModes;
247
+ a.ts = p.ts;
181
248
  a.present = true; // in the roster = a live presence (the authority for isOffline)
182
249
  seen.add(a.id);
183
250
  }
@@ -333,22 +400,61 @@
333
400
  ctx.lineWidth = 1.5; ctx.strokeStyle = rgba(MODE.chat, 0.95 * dim); ctx.stroke();
334
401
  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);
335
402
  }
336
- // Label gate counts VISIBLE agents (what's actually drawn), not agents.size — the Map also holds
337
- // hidden offline ghosts (durable members kept for "member, currently offline"), which would push the
338
- // count past the threshold and suppress every label even with only a handful of agents online.
339
- let shown = 0; for (const a of agents.values()) if (!isHidden(a)) shown++;
340
403
  for (const a of agents.values()) {
341
404
  if (isHidden(a)) continue;
342
- const col = STAT[a.status] || STAT.idle, focus = a === hover || a === sel, off = a.status === "offline";
405
+ const col = STAT[a.status] || STAT.idle, focus = a === hover || a === sel, off = a.status === "offline", idle = a.status === "idle";
343
406
  const r = a.r + Math.sin(t * 0.8 + a.phase) * 0.4;
344
407
  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(); } }
345
408
  // wide reader (subscribes `>`/`*`): a faint dashed halo — "reads all channels" without a spoke per hub
346
409
  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(); }
347
- ctx.save(); ctx.shadowColor = col; ctx.shadowBlur = focus ? 20 : off ? 3 : 13;
348
- 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");
349
- ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, 2 * Math.PI); ctx.fill(); ctx.restore();
350
- ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, off ? 0.6 : 1); ctx.stroke();
351
- if (focus || a.status === "waiting" || shown <= 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); }
410
+ // Shape channel (never colour alone): offline = hollow ring; idle = filled disc; working/waiting = filled + glow.
411
+ ctx.save(); ctx.shadowColor = col; ctx.shadowBlur = focus ? 20 : off ? 0 : idle ? 6 : 13;
412
+ if (off) {
413
+ ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, 2 * Math.PI);
414
+ ctx.fillStyle = "#141b26"; ctx.fill();
415
+ ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, 0.85); ctx.setLineDash([2.5, 2]); ctx.stroke(); ctx.setLineDash([]);
416
+ } else {
417
+ const g = ctx.createRadialGradient(a.x, a.y, 0, a.x, a.y, r); g.addColorStop(0, rgba(col, 1)); g.addColorStop(0.55, rgba(col, 0.55)); g.addColorStop(1, "#141b26");
418
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, 2 * Math.PI); ctx.fill();
419
+ ctx.lineWidth = 2; ctx.strokeStyle = rgba(col, 1); ctx.stroke();
420
+ }
421
+ ctx.restore();
422
+ // Attention mark (dnd/focus only) — glyph at node rim, sized for rest-scale legibility.
423
+ const am = attMark(a.attention);
424
+ if (am && !off) {
425
+ ctx.fillStyle = a.attention === "dnd" ? "#db6d28" : MODE.chat;
426
+ ctx.font = "700 11px var(--font), sans-serif";
427
+ ctx.fillText(am, a.x + r + 2, a.y - r - 1);
428
+ }
429
+ // Labels track SCREEN SPACE, not global node count. A count gate hid every name on a real
430
+ // 19-agent mesh (David's "don't hide model names") and zoom never helped. Rule:
431
+ // • viewport cull — off-screen nodes cost nothing
432
+ // • name when the node's on-screen footprint can hold text (or focus/waiting)
433
+ // • model/harness when zoomed further (or focus) — density follows cam.scale
434
+ const sx = cam.x + a.x * cam.scale, sy = cam.y + a.y * cam.scale;
435
+ const inView = sx >= -40 && sx <= W + 40 && sy >= -40 && sy <= H + 40;
436
+ const foot = 2 * r * cam.scale; // on-screen diameter in CSS px
437
+ const showName = focus || a.status === "waiting" || (inView && foot >= 8);
438
+ const showModel = focus || (inView && foot >= 16);
439
+ if (showName) {
440
+ ctx.fillStyle = focus ? "#ffffff" : "#cdd6e2";
441
+ ctx.font = (focus ? "600 " : "500 ") + "11px var(--font), sans-serif";
442
+ ctx.fillText(a.name, a.x, a.y - r - 8);
443
+ }
444
+ if (showModel) {
445
+ // Budget scales with on-screen footprint (same axis as the label gate). Never eat the
446
+ // variant to save chars — if we must shorten, drop the provider prefix first
447
+ // ("openai/gpt-5.6-sol · xhigh" → "gpt-5.6-sol · xhigh"), then trim the model id.
448
+ const maxChars = focus ? 56 : Math.max(20, Math.min(56, Math.round(foot * 2.2)));
449
+ let sub = "";
450
+ if (a.model) sub = fitModelLabel(a.model, a.variant, maxChars);
451
+ else if (a.harness) sub = harnessLabel(a.harness);
452
+ if (sub) {
453
+ ctx.fillStyle = focus ? "#b6c2d1" : "#8b949e";
454
+ ctx.font = "500 9.5px var(--font), sans-serif";
455
+ ctx.fillText(sub, a.x, a.y - r - (showName ? 19 : 8));
456
+ }
457
+ }
352
458
  }
353
459
  ctx.globalAlpha = 1;
354
460
  }
@@ -440,6 +546,16 @@
440
546
  const activeIds = new Set(recent.filter((m) => m.chan === sel.name && m.fromId).map((m) => m.fromId));
441
547
  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>`; };
442
548
  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>`;
549
+ // Effective channel policy (from /api/channels, server-resolved). Delivery class = durability;
550
+ // replay = whether a join backfills. Omit a row the feed didn't carry (never guess a default).
551
+ const deliveryRow = sel.deliveryClass
552
+ ? `<div class="d-row"><span class="k">delivery</span><span class="v">${esc(sel.deliveryClass)} · ${sel.deliveryClass === "durable" ? "at-least-once for members" : "at-most-once"}</span></div>`
553
+ : "";
554
+ const replayRow = sel.replay === true
555
+ ? `<div class="d-row"><span class="k">replay</span><span class="v">${sel.replayWindow ? "on · " + esc(sel.replayWindow) : "on"}</span></div>`
556
+ : sel.replay === false
557
+ ? `<div class="d-row"><span class="k">replay</span><span class="v muted">off</span></div>`
558
+ : "";
443
559
  el.innerHTML = `<span class="x" id="dx">✕</span>
444
560
  <div class="d-kind">channel</div>
445
561
  <div class="d-who">#${esc(sel.name)}</div>
@@ -447,6 +563,8 @@
447
563
  <div class="d-rows">
448
564
  <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>
449
565
  <div class="d-row"><span class="k">messages</span><span class="v">${sel.msgs || 0}</span></div>
566
+ ${deliveryRow}
567
+ ${replayRow}
450
568
  </div>
451
569
  <div class="d-section"><div class="d-label">members</div>${memberList}</div>
452
570
  <div class="d-section"><div class="d-label">recent</div><div class="d-msgs">${recentRows((m) => m.chan === sel.name)}</div></div>`;
@@ -457,13 +575,41 @@
457
575
  const liveSet = (sel.live || []).filter((c) => c !== ">" && c !== "*").map((c) => `<span class="ctag">#${esc(c)}</span>`).join("");
458
576
  const durOnly = (sel.durable || []).filter((c) => !(sel.live || []).includes(c)).map((c) => `<span class="ctag off">#${esc(c)}</span>`).join("");
459
577
  const subs = wideChip || liveSet || durOnly ? `<div class="d-tags">${wideChip}${liveSet}${durOnly}</div>` : `<div class="d-block muted">no channel subscriptions</div>`;
578
+ // Identity rows: branded harness (not raw key); model · variant when known; "not reported" only for harness agents.
579
+ const hLabel = sel.harness ? harnessLabel(sel.harness) : "";
580
+ const hColor = sel.harness ? harnessColor(sel.harness) : "";
581
+ const harnessRow = sel.harness
582
+ ? `<div class="d-row"><span class="k">harness</span><span class="v"><span class="hmark" style="color:${hColor}">${esc(harnessGlyph(sel.harness))}</span> ${esc(hLabel)}</span></div>`
583
+ : "";
584
+ const modelRow = sel.harness
585
+ ? `<div class="d-row"><span class="k">model</span><span class="v${sel.model ? "" : " muted"}">${sel.model ? esc(sel.model) + (sel.variant ? ` · ${esc(sel.variant)}` : "") : "not reported"}</span></div>`
586
+ : sel.model
587
+ ? `<div class="d-row"><span class="k">model</span><span class="v">${esc(sel.model)}${sel.variant ? ` · ${esc(sel.variant)}` : ""}</span></div>`
588
+ : "";
589
+ const att = attMark(sel.attention);
590
+ const attRow = att
591
+ ? `<div class="d-row"><span class="k">attention</span><span class="v att-${esc(sel.attention)}">${att} ${esc(sel.attention)}</span></div>`
592
+ : "";
593
+ // The card's own description (AgentCard.description) — the same legibility text the Monitor shows.
594
+ const descBlock = sel.description ? `<div class="d-block">${esc(sel.description)}</div>` : "";
595
+ const tagsSection = (sel.tags || []).length
596
+ ? `<div class="d-section"><div class="d-label">tags</div><div class="d-tags">${sel.tags.map((t) => `<span class="ctag">${esc(t)}</span>`).join("")}</div></div>`
597
+ : "";
598
+ // Per-channel attention overrides (quiet / muted) — advisory receive-side, not ACL.
599
+ const modeEntries = Object.entries(sel.channelModes || {}).sort(([a], [b]) => a.localeCompare(b));
600
+ const modesSection = modeEntries.length
601
+ ? `<div class="d-section"><div class="d-label">channel modes</div><div class="d-tags">${modeEntries.map(([ch, m]) => `<span class="ctag${m === "muted" ? " off" : ""}">#${esc(ch)} · ${esc(m)}</span>`).join("")}</div></div>`
602
+ : "";
460
603
  el.innerHTML = `<span class="x" id="dx">✕</span>
461
604
  <div class="d-kind">agent</div>
462
605
  <div class="d-who">${esc(sel.name)}${sel.role ? `<span class="role">${esc(sel.role)}</span>` : ""}</div>
463
606
  <div class="d-status ${sel.status}"><span class="dot"></span>${esc(sel.status)}</div>
607
+ ${descBlock}
464
608
  <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>
609
+ ${(harnessRow || modelRow || attRow) ? `<div class="d-rows">${harnessRow}${modelRow}${attRow}</div>` : ""}
465
610
  <div class="d-section"><div class="d-label">subscribes</div>${subs}</div>
466
- ${sel.harness ? `<div class="d-rows"><div class="d-row"><span class="k">harness</span><span class="v">${esc(sel.harness)}</span></div></div>` : ""}
611
+ ${modesSection}
612
+ ${tagsSection}
467
613
  <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>`;
468
614
  }
469
615
  el.classList.add("open"); $("dx").onclick = closeDetail;
@@ -487,6 +633,19 @@
487
633
  // is normalized to px so a mouse notch and a trackpad swipe both feel right.
488
634
  canvas.addEventListener("wheel", (e) => { e.preventDefault(); cam.user = true; const px = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? H : 1), f = Math.exp(-px * 0.0015), 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 });
489
635
  window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
636
+ // Backgrounded tab: drop in-flight decoration (rAF is frozen, so mid-flight comets would otherwise
637
+ // detonate their onArrive fan-out on return). State (edges/roster/recent) is untouched. On return:
638
+ // reset lastT so the first frame doesn't integrate a multi-minute dt, and reheat physics so a node
639
+ // set that changed while hidden can re-settle instead of sitting cold.
640
+ document.addEventListener("visibilitychange", () => {
641
+ if (document.visibilityState === "hidden") {
642
+ particles.length = 0;
643
+ blooms.length = 0;
644
+ } else {
645
+ lastT = performance.now();
646
+ reheat();
647
+ }
648
+ });
490
649
  $("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]); };
491
650
  $("pause").onclick = () => { filter.paused = !filter.paused; $("pause").classList.toggle("on", filter.paused); $("pause").textContent = filter.paused ? "▶ resume" : "⏸ pause"; };
492
651
  $("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(); };
@@ -502,7 +661,7 @@
502
661
  fetch("/api/activity?limit=400").then((r) => r.json()).catch(() => []), fetch("/api/dms?limit=400").then((r) => r.json()).catch(() => []),
503
662
  ]);
504
663
  $("space").textContent = "· " + meta.space;
505
- for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; }
664
+ for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; h.deliveryClass = c.deliveryClass; h.replay = c.replay; h.replayWindow = c.replayWindow; }
506
665
  updateRoster(roster);
507
666
  applyMembership(membership); // authoritative spokes BEFORE traffic seeding (no skeleton flicker)
508
667
  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()); }
@@ -0,0 +1,33 @@
1
+ // Shared harness (host connector) branding for the monitor and graph surfaces.
2
+ // One source of truth: label + colour + SVG (DOM badges) + glyph (canvas text — canvas
3
+ // cannot draw the inline SVG). Loaded as a plain script before app.js / graph.js.
4
+ //
5
+ // Claude and OpenCode marks: public-domain SVG data from Simple Icons (CC0).
6
+ // Hermes / Pi: custom glyphs (no clean official mark). Unknown connectors are not listed
7
+ // here — callers degrade to a neutral name with no invented icon.
8
+ window.COTAL_HARNESS = {
9
+ claude: {
10
+ label: "Claude Code",
11
+ color: "#d97757", // official Claude clay
12
+ glyph: "◆",
13
+ svg: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>`,
14
+ },
15
+ opencode: {
16
+ label: "OpenCode",
17
+ color: "#cdd6e0", // monochrome by brand; rendered light on the dark UI
18
+ glyph: "▣",
19
+ svg: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>`,
20
+ },
21
+ hermes: {
22
+ label: "Hermes",
23
+ color: "#a78bfa", // no official mark — custom messenger glyph, violet
24
+ glyph: "➤",
25
+ svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z"/><path d="M21 3 10 13"/></svg>`,
26
+ },
27
+ pi: {
28
+ label: "Pi",
29
+ color: "#7dd3fc", // no official mark — simple π glyph, sky
30
+ glyph: "π",
31
+ svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M9 7v12"/><path d="M15 7v12c0 1.5 1.5 2 3 1"/></svg>`,
32
+ },
33
+ };