@cotal-ai/cli 0.7.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 (114) hide show
  1. package/dist/commands/down-manifest.d.ts +6 -0
  2. package/dist/commands/down-manifest.d.ts.map +1 -0
  3. package/dist/commands/down-manifest.js +282 -0
  4. package/dist/commands/down-manifest.js.map +1 -0
  5. package/dist/commands/down.d.ts +5 -3
  6. package/dist/commands/down.d.ts.map +1 -1
  7. package/dist/commands/down.js +24 -4
  8. package/dist/commands/down.js.map +1 -1
  9. package/dist/commands/meshes.js +1 -1
  10. package/dist/commands/meshes.js.map +1 -1
  11. package/dist/commands/mint.d.ts.map +1 -1
  12. package/dist/commands/mint.js +2 -1
  13. package/dist/commands/mint.js.map +1 -1
  14. package/dist/commands/setup.d.ts.map +1 -1
  15. package/dist/commands/setup.js +66 -16
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/commands/spawn-manifest.d.ts +10 -0
  18. package/dist/commands/spawn-manifest.d.ts.map +1 -0
  19. package/dist/commands/spawn-manifest.js +197 -0
  20. package/dist/commands/spawn-manifest.js.map +1 -0
  21. package/dist/commands/spawn.d.ts.map +1 -1
  22. package/dist/commands/spawn.js +31 -4
  23. package/dist/commands/spawn.js.map +1 -1
  24. package/dist/commands/topology.d.ts +10 -0
  25. package/dist/commands/topology.d.ts.map +1 -0
  26. package/dist/commands/topology.js +46 -0
  27. package/dist/commands/topology.js.map +1 -0
  28. package/dist/commands/up.d.ts +4 -0
  29. package/dist/commands/up.d.ts.map +1 -1
  30. package/dist/commands/up.js +112 -2
  31. package/dist/commands/up.js.map +1 -1
  32. package/dist/commands/use.d.ts +1 -1
  33. package/dist/commands/use.d.ts.map +1 -1
  34. package/dist/commands/use.js +1 -1
  35. package/dist/commands/use.js.map +1 -1
  36. package/dist/commands/web.d.ts.map +1 -1
  37. package/dist/commands/web.js +3 -1
  38. package/dist/commands/web.js.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +10 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/lib/connect.d.ts +18 -16
  43. package/dist/lib/connect.d.ts.map +1 -1
  44. package/dist/lib/connect.js +27 -51
  45. package/dist/lib/connect.js.map +1 -1
  46. package/dist/lib/delivery-proc.d.ts.map +1 -1
  47. package/dist/lib/delivery-proc.js +2 -1
  48. package/dist/lib/delivery-proc.js.map +1 -1
  49. package/dist/lib/manager-proc.d.ts +4 -0
  50. package/dist/lib/manager-proc.d.ts.map +1 -1
  51. package/dist/lib/manager-proc.js +17 -0
  52. package/dist/lib/manager-proc.js.map +1 -1
  53. package/dist/lib/manifest/apply.d.ts +29 -0
  54. package/dist/lib/manifest/apply.d.ts.map +1 -0
  55. package/dist/lib/manifest/apply.js +138 -0
  56. package/dist/lib/manifest/apply.js.map +1 -0
  57. package/dist/lib/manifest/errors.d.ts +21 -0
  58. package/dist/lib/manifest/errors.d.ts.map +1 -0
  59. package/dist/lib/manifest/errors.js +19 -0
  60. package/dist/lib/manifest/errors.js.map +1 -0
  61. package/dist/lib/manifest/index.d.ts +13 -0
  62. package/dist/lib/manifest/index.d.ts.map +1 -0
  63. package/dist/lib/manifest/index.js +21 -0
  64. package/dist/lib/manifest/index.js.map +1 -0
  65. package/dist/lib/manifest/ledger.d.ts +81 -0
  66. package/dist/lib/manifest/ledger.d.ts.map +1 -0
  67. package/dist/lib/manifest/ledger.js +213 -0
  68. package/dist/lib/manifest/ledger.js.map +1 -0
  69. package/dist/lib/manifest/live.d.ts +25 -0
  70. package/dist/lib/manifest/live.d.ts.map +1 -0
  71. package/dist/lib/manifest/live.js +61 -0
  72. package/dist/lib/manifest/live.js.map +1 -0
  73. package/dist/lib/manifest/model.d.ts +71 -0
  74. package/dist/lib/manifest/model.d.ts.map +1 -0
  75. package/dist/lib/manifest/model.js +2 -0
  76. package/dist/lib/manifest/model.js.map +1 -0
  77. package/dist/lib/manifest/preflight.d.ts +12 -0
  78. package/dist/lib/manifest/preflight.d.ts.map +1 -0
  79. package/dist/lib/manifest/preflight.js +43 -0
  80. package/dist/lib/manifest/preflight.js.map +1 -0
  81. package/dist/lib/manifest/prepare.d.ts +57 -0
  82. package/dist/lib/manifest/prepare.d.ts.map +1 -0
  83. package/dist/lib/manifest/prepare.js +95 -0
  84. package/dist/lib/manifest/prepare.js.map +1 -0
  85. package/dist/lib/manifest/render.d.ts +41 -0
  86. package/dist/lib/manifest/render.d.ts.map +1 -0
  87. package/dist/lib/manifest/render.js +177 -0
  88. package/dist/lib/manifest/render.js.map +1 -0
  89. package/dist/lib/manifest/resolve.d.ts +5 -0
  90. package/dist/lib/manifest/resolve.d.ts.map +1 -0
  91. package/dist/lib/manifest/resolve.js +185 -0
  92. package/dist/lib/manifest/resolve.js.map +1 -0
  93. package/dist/lib/manifest/schema.d.ts +103 -0
  94. package/dist/lib/manifest/schema.d.ts.map +1 -0
  95. package/dist/lib/manifest/schema.js +77 -0
  96. package/dist/lib/manifest/schema.js.map +1 -0
  97. package/dist/lib/manifest/spawn-plan.d.ts +87 -0
  98. package/dist/lib/manifest/spawn-plan.d.ts.map +1 -0
  99. package/dist/lib/manifest/spawn-plan.js +75 -0
  100. package/dist/lib/manifest/spawn-plan.js.map +1 -0
  101. package/dist/lib/meshes.d.ts +1 -7
  102. package/dist/lib/meshes.d.ts.map +1 -1
  103. package/dist/lib/meshes.js +5 -14
  104. package/dist/lib/meshes.js.map +1 -1
  105. package/dist/lib/onboard.js +1 -1
  106. package/dist/lib/onboard.js.map +1 -1
  107. package/dist/lib/paths.js +1 -1
  108. package/dist/lib/paths.js.map +1 -1
  109. package/dist/lib/status.d.ts.map +1 -1
  110. package/dist/lib/status.js +2 -1
  111. package/dist/lib/status.js.map +1 -1
  112. package/dist/web/graph.html +3 -0
  113. package/dist/web/graph.js +76 -21
  114. package/package.json +6 -2
package/dist/web/graph.js CHANGED
@@ -37,7 +37,7 @@
37
37
  const recent = [];
38
38
  const feed = { asOf: undefined, available: false }; // membership-feed freshness
39
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 };
40
+ const filter = { chat: true, unicast: true, anycast: true, window: 30, paused: false, hideOffline: true, hideEmpty: true };
41
41
  let W = 0, H = 0, DPR = 1, hover = null, sel = null, lastT = 0, alpha = 1;
42
42
 
43
43
  // ── utils ──
@@ -88,7 +88,7 @@
88
88
  const id = typeof ref === "object" ? ref.id || ref.name : ref;
89
89
  if (!id) return null;
90
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(); }
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
92
  else if (typeof ref === "object" && ref.name) a.name = ref.name;
93
93
  return a;
94
94
  }
@@ -109,7 +109,8 @@
109
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
110
  function physics() {
111
111
  if (alpha < 0.004 || filter.paused) return;
112
- const ns = [...hubs.values(), ...agents.values()];
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));
113
114
  for (let i = 0; i < ns.length; i++) {
114
115
  const a = ns[i];
115
116
  for (let j = i + 1; j < ns.length; j++) {
@@ -120,10 +121,10 @@
120
121
  a.vx += (dx / d) * q; a.vy += (dy / d) * q; b.vx -= (dx / d) * q; b.vy -= (dy / d) * q;
121
122
  }
122
123
  }
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);
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);
125
126
  // 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
+ 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; } }
127
128
  // collision: position-based min-distance — prevents the 1/d² charge singularity + node overlap
128
129
  const pad = 10;
129
130
  for (let i = 0; i < ns.length; i++) {
@@ -147,7 +148,7 @@
147
148
  function onMessage({ mode, senderId, msg }) {
148
149
  if (!msg) return;
149
150
  const from = ensureAgent(senderId ? { id: senderId, name: msg.from?.name, role: msg.from?.role } : msg.from);
150
- if (from) from.ts = now();
151
+ if (from) { from.ts = now(); from.present = true; } // a live sender is a live presence (roster event may lag)
151
152
  const animate = !filter.paused && filter[mode];
152
153
  let toName = null;
153
154
  if (mode === "chat" && msg.channel) {
@@ -177,13 +178,14 @@
177
178
  if (p.card?.kind === "endpoint") continue;
178
179
  const a = ensureAgent({ id: p.card.id, name: p.card.name, role: p.card.role });
179
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)
180
182
  seen.add(a.id);
181
183
  }
182
184
  // Drop an agent as soon as it goes offline OR leaves the roster (main's ghost fix, c9e9000) — EXCEPT
183
185
  // keep it if it's still a feed member: a durable member whose presence is offline must persist to
184
186
  // render as "member, currently offline" (the feed's durable arm survives offline). Membership is
185
187
  // 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(); }
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(); } }
187
189
  }
188
190
 
189
191
  // ── membership (authoritative spokes) ──
@@ -206,7 +208,8 @@
206
208
  // An agent that dropped out of the feed entirely is no longer a member of anything (incl. a wide reader,
207
209
  // which carries the flag but no concrete edges).
208
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; }
209
- if (sel) renderDetail();
211
+ recomputeHubEmpty(); // these mutations change hub visibility — refresh before the detail/selection check below
212
+ if (sel) { if (isHidden(sel)) closeDetail(); else renderDetail(); }
210
213
  }
211
214
  // Drop this agent's membership edges that are no longer in `keep`; a still-warm one stays as a fading
212
215
  // traffic-only edge (mem:false) and is pruned later when cold, so a comet in flight isn't orphaned.
@@ -220,7 +223,8 @@
220
223
  // Prune cold traffic-only spokes (a non-member's post that has faded). Membership spokes persist by
221
224
  // membership, never on a timer — they're the resting skeleton, faint at rest, glowing on traffic.
222
225
  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)
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
224
228
  physics();
225
229
  // re-frame only once the sim has cooled, so the camera doesn't chase the re-settle wobble
226
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; }
@@ -237,14 +241,54 @@
237
241
  // Hover/select a hub or agent → highlight its membership fan, dim the rest (dandelion mitigation).
238
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; }
239
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
+ }
240
284
 
241
285
  function drawSpokes() {
242
286
  ctx.lineCap = "round";
243
287
  const f = fanFocus();
244
288
  // structure layer: a constant-faint spoke per membership (solid = live, dashed-dim = member-offline)
245
289
  for (const e of edges.values()) {
246
- if (!e.mem) continue;
247
- const h = hubs.get(e.chan); if (!h) continue;
290
+ if (!e.mem || isHiddenMember(e)) continue;
291
+ const h = hubs.get(e.chan); if (!h || isHidden(h)) continue;
248
292
  const off = e.durableOnly || e.a.status === "offline";
249
293
  const lit = inFan(e, f);
250
294
  ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(h.x, h.y);
@@ -255,12 +299,14 @@
255
299
  ctx.setLineDash([]);
256
300
  // activity layer: traffic glow on top (members + transient non-member posts)
257
301
  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(); }
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(); }
259
303
  ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
260
304
  }
261
305
  function drawDmEdges() {
306
+ if (!filter.unicast) return; // the `direct` chip filters DM traffic — including the persistent DM curves
262
307
  ctx.setLineDash([3, 4]);
263
308
  for (const d of dms.values()) {
309
+ if (isHidden(d.a) || isHidden(d.b)) continue;
264
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;
265
311
  const cx = mx + (nx / len) * 24, cy = my + (ny / len) * 24;
266
312
  ctx.beginPath(); ctx.moveTo(d.a.x, d.a.y); ctx.quadraticCurveTo(cx, cy, d.b.x, d.b.y);
@@ -272,6 +318,7 @@
272
318
  ctx.textAlign = "center"; ctx.textBaseline = "middle";
273
319
  const t = performance.now() / 1000;
274
320
  for (const h of hubs.values()) {
321
+ if (isHidden(h)) continue;
275
322
  const focus = h === hover || h === sel, dim = h.empty ? 0.55 : 1; // dormant hubs read quieter, not gone
276
323
  ctx.save(); ctx.shadowColor = MODE.chat; ctx.shadowBlur = (focus ? 28 : 16) * dim;
277
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");
@@ -280,6 +327,7 @@
280
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);
281
328
  }
282
329
  for (const a of agents.values()) {
330
+ if (isHidden(a)) continue;
283
331
  const col = STAT[a.status] || STAT.idle, focus = a === hover || a === sel, off = a.status === "offline";
284
332
  const r = a.r + Math.sin(t * 0.8 + a.phase) * 0.4;
285
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(); } }
@@ -311,7 +359,10 @@
311
359
  function drawParticles(dt) {
312
360
  ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round";
313
361
  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;
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;
315
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; }
316
367
  else { x = p.a.x + (p.b.x - p.a.x) * t; y = p.a.y + (p.b.y - p.a.y) * t; }
317
368
  if (!filter.paused) { p.trail.push(x, y); if (p.trail.length > 12) p.trail.splice(0, p.trail.length - 12); }
@@ -338,16 +389,16 @@
338
389
  function fitTarget() {
339
390
  const ns = [...hubs.values(), ...agents.values()]; if (!ns.length) return { x: W / 2, y: H / 2, scale: 1 };
340
391
  // 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;
392
+ const content = ns.filter((n) => !(n.kind === "hub" && n.empty) && !isHidden(n)), frame = content.length ? content : ns;
342
393
  let a = 1e9, b = 1e9, c = -1e9, d = -1e9;
343
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); }
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); }
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); }
345
396
  const bw = c - a || 1, bh = d - b || 1, pad = 90, maxScale = ns.length <= 6 ? 2.4 : 1.6;
346
397
  const scale = Math.max(0.35, Math.min(maxScale, Math.min((W - pad * 2) / bw, (H - pad * 2) / bh)));
347
398
  return { x: W / 2 - ((a + c) / 2) * scale, y: H / 2 - ((b + d) / 2) * scale, scale };
348
399
  }
349
400
  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; }
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; }
351
402
 
352
403
  // ── membership freshness pill ──
353
404
  function setFeed() {
@@ -371,17 +422,19 @@
371
422
  if (sel.kind === "hub") {
372
423
  // members from the broker feed (subscribed), split into live vs member-currently-offline; plus a
373
424
  // "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));
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;
376
429
  const activeIds = new Set(recent.filter((m) => m.chan === sel.name && m.fromId).map((m) => m.fromId));
377
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>`; };
378
- const memberList = mem.length ? `<div class="d-tags">${mem.map(memberRow).join("")}</div>` : `<div class="d-block muted">no subscribers yet</div>`;
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>`;
379
432
  el.innerHTML = `<span class="x" id="dx">✕</span>
380
433
  <div class="d-kind">channel</div>
381
434
  <div class="d-who">#${esc(sel.name)}</div>
382
435
  ${sel.desc ? `<div class="d-block">${esc(sel.desc)}</div>` : ""}
383
436
  <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>
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>
385
438
  <div class="d-row"><span class="k">messages</span><span class="v">${sel.msgs || 0}</span></div>
386
439
  </div>
387
440
  <div class="d-section"><div class="d-label">members</div>${memberList}</div>
@@ -416,6 +469,8 @@
416
469
  window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
417
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]); };
418
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(); };
419
474
  $("legendToggle").onclick = () => $("legend").classList.toggle("collapsed");
420
475
  function setConn(live) { const el = $("conn"); el.classList.toggle("down", !live); el.querySelector(".t").textContent = live ? "live" : "disconnected"; }
421
476
 
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.7.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.7.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
  }