@atolljs/devtools 0.1.7 → 0.1.9

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 (50) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/fw-logos.js +14 -0
  4. package/app/index.html +161 -2
  5. package/app/main.js +897 -222
  6. package/app/panels/actions.js +324 -0
  7. package/app/panels/audit-rules.js +728 -0
  8. package/app/panels/audits.js +438 -0
  9. package/app/panels/inspect-island.js +351 -0
  10. package/app/panels/memory-values.js +287 -0
  11. package/app/panels/performance.js +304 -0
  12. package/app/panels/reactivity.js +271 -0
  13. package/app/panels/recorder.js +344 -0
  14. package/app/panels/recording-format.js +166 -0
  15. package/app/panels/route.d.ts +44 -0
  16. package/app/panels/route.js +264 -0
  17. package/app/panels/shell.js +789 -0
  18. package/app/panels/toast.js +38 -0
  19. package/dist/bin.js +704 -5
  20. package/dist/chunk-57NGHQUL.js +344 -0
  21. package/dist/chunk-6E54FXKS.js +495 -0
  22. package/dist/chunk-WIL2P7HG.js +661 -0
  23. package/dist/client.d.ts +7 -0
  24. package/dist/control.d.ts +4 -0
  25. package/dist/index.js +2 -1
  26. package/dist/node.js +2 -1
  27. package/dist/nodeWs.d.ts +1 -0
  28. package/dist/otel.d.ts +35 -0
  29. package/dist/otel.js +87 -0
  30. package/dist/otelMap.d.ts +232 -0
  31. package/dist/otelTransport.d.ts +69 -0
  32. package/dist/overlay.d.ts +16 -2
  33. package/dist/probes.d.ts +7 -0
  34. package/dist/protocol.d.ts +46 -4
  35. package/dist/server.d.ts +19 -0
  36. package/dist/server.js +61 -3
  37. package/package.json +6 -2
  38. package/src/bin.ts +27 -2
  39. package/src/broadcast.ts +6 -0
  40. package/src/client.ts +26 -2
  41. package/src/control.ts +51 -0
  42. package/src/nodeWs.ts +7 -1
  43. package/src/otel.ts +140 -0
  44. package/src/otelMap.ts +661 -0
  45. package/src/otelTransport.ts +286 -0
  46. package/src/overlay.ts +239 -47
  47. package/src/probes.ts +155 -0
  48. package/src/protocol.ts +52 -4
  49. package/src/server.ts +83 -3
  50. package/dist/chunk-4N3NBFL3.js +0 -474
package/app/main.js CHANGED
@@ -5,6 +5,11 @@
5
5
  // Time basis: each event's `at` is performance.now() on its emitting
6
6
  // thread, so intra-session timelines (the waterfall) use `at` directly.
7
7
  // Cross-session ordering is only meaningful within one session.
8
+ //
9
+ // Feature panels live in ./panels/*.js and plug in through ./api.js (see
10
+ // PANELS at the bottom): this file owns the reducer, core views, transport.
11
+ import { api } from './api.js';
12
+ import { FW_LOGOS } from './fw-logos.js';
8
13
 
9
14
  const $ = (id) => document.getElementById(id);
10
15
  const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
@@ -56,8 +61,10 @@ const MAX_ALERTS = 60;
56
61
 
57
62
  /* ── event reducer ───────────────────────────────────────────────────────── */
58
63
 
59
- const bump = (sid, metric, at, n = 1) => {
60
- const k = `${sid}|${metric}|${Math.floor(at / 1000)}`;
64
+ /** Per-second buckets on the dashboard clock: event `at` epochs differ per thread. */
65
+ const dashSec = () => Math.floor(performance.now() / 1000);
66
+ const bump = (sid, metric, _at, n = 1) => {
67
+ const k = `${sid}|${metric}|${dashSec()}`;
61
68
  state.series.set(k, (state.series.get(k) ?? 0) + n);
62
69
  };
63
70
 
@@ -69,7 +76,7 @@ function applyEvent(sess, e) {
69
76
  state.pools.set(key(sid, e.poolId), {
70
77
  poolId: e.poolId, label: e.label ?? e.poolId, size: e.poolSize,
71
78
  memoryBytes: e.memoryBytes, workers: new Set(), tasks: 0, failed: 0,
72
- dead: sess.closed === true,
79
+ dedicated: e.dedicated === true, dead: sess.closed === true,
73
80
  });
74
81
  break;
75
82
  case 'pool:terminate': {
@@ -141,7 +148,9 @@ function applyEvent(sess, e) {
141
148
  }
142
149
  }
143
150
  }
144
- const sec = Math.floor(e.at / 1000);
151
+ // Dashboard clock, like heapSeries: emit threads (and sessions) have
152
+ // unrelated performance.now() epochs, so `e.at` can't share an axis.
153
+ const sec = Math.floor(performance.now() / 1000);
145
154
  const bk = key(sid, sec);
146
155
  const b = state.tput.get(bk) ?? { n: 0, err: 0 };
147
156
  b.n++; if (e.outcome !== 'ok') b.err++;
@@ -160,7 +169,7 @@ function applyEvent(sess, e) {
160
169
  m.writes++; m.version = e.version; m.thread = e.thread; m.lastAt = e.at;
161
170
  // worker:* forwarded events carry slot attribution — pin the writer.
162
171
  if (e.worker) m.writer = `${e.worker.poolId}#${e.worker.slot}`;
163
- const sec = Math.floor(e.at / 1000);
172
+ const sec = dashSec();
164
173
  m.buckets.set(sec, (m.buckets.get(sec) ?? 0) + 1);
165
174
  if (m.buckets.size > 120) m.buckets.delete([...m.buckets.keys()][0]);
166
175
  state.mem.set(k, m);
@@ -235,7 +244,7 @@ function applyEvent(sess, e) {
235
244
  // filter, or worker-side performance.memory) stamped to the slot.
236
245
  const w = getWorker(sid, e.worker.poolId, e.worker.slot, e.at);
237
246
  w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes; w.lastAt = e.at;
238
- const sec = Math.floor(e.at / 1000);
247
+ const sec = dashSec();
239
248
  (w.heapSeries ??= new Map()).set(sec, e.heapBytes / 1048576); // MB for the chart
240
249
  if (w.heapSeries.size > 120) w.heapSeries.delete([...w.heapSeries.keys()][0]);
241
250
  }
@@ -306,6 +315,7 @@ function reconcileSessions(live) {
306
315
  if (state.sel && !ids.has(state.sel)) state.sel = null;
307
316
  if (state.inspect && !state.islands.has(state.inspect)) state.inspect = null;
308
317
  if (state.inspectWorker && !state.workers.has(state.inspectWorker)) state.inspectWorker = null;
318
+ for (const h of api.hooks.reconcile) h(ids);
309
319
  }
310
320
 
311
321
  /**
@@ -651,30 +661,78 @@ function drawThroughput() {
651
661
  const canvas = $('tput');
652
662
  const ctx = canvas.getContext('2d');
653
663
  const w = (canvas.width = canvas.clientWidth || 560);
654
- const h = (canvas.height = 80);
664
+ const h = (canvas.height = 110);
655
665
  ctx.clearRect(0, 0, w, h);
656
- if (state.t0 === null) return;
657
- const buckets = [...state.tput.entries()].filter(([k]) => inSel(k));
658
- const secs = buckets.map(([k]) => Number(k.split('|')[1]));
659
- if (!secs.length) return;
660
- const lo = Math.min(...secs), hi = Math.max(...secs);
661
- const span = Math.max(hi - lo + 1, 1);
662
- const bw = w / Math.max(span, 60);
663
- const max = Math.max(...buckets.map(([, b]) => b.n), 1);
664
- for (const [k, b] of buckets) {
666
+ const statsEl = $('tput-stats');
667
+ if (state.t0 === null) { statsEl.textContent = ''; return; }
668
+ // Buckets are on the dashboard clock: sum sessions per second, window the
669
+ // last 60s ending now (an idle stretch shows as empty seconds).
670
+ const bySec = new Map();
671
+ for (const [k, b] of state.tput) {
672
+ if (!inSel(k)) continue;
665
673
  const sec = Number(k.split('|')[1]);
666
- const x = (sec - lo) * (w / Math.max(span, 60));
667
- const bh = (b.n / max) * (h - 4);
674
+ const s = bySec.get(sec) ?? { n: 0, err: 0 };
675
+ s.n += b.n; s.err += b.err;
676
+ bySec.set(sec, s);
677
+ }
678
+ if (!bySec.size) { statsEl.textContent = 'no settled tasks yet'; return; }
679
+ const hi = Math.floor(performance.now() / 1000);
680
+ const lo = hi - 59;
681
+ const win = [...bySec].filter(([s]) => s >= lo);
682
+ const peak = Math.max(...win.map(([, b]) => b.n), 0);
683
+ const total = win.reduce((a, [, b]) => a + b.n, 0);
684
+ const errs = win.reduce((a, [, b]) => a + b.err, 0);
685
+ const first = win.length ? Math.min(...win.map(([s]) => s)) : hi;
686
+ const latest = bySec.get(hi - 1)?.n ?? 0;
687
+ statsEl.textContent =
688
+ `latest ${fmtN(latest)}/s · peak ${fmtN(peak)}/s · avg ${fmtN(total / (hi - first + 1))}/s · ` +
689
+ `${fmtN(total)} settled in 60s` + (errs ? ` · ${fmtN(errs)} failed` : '');
690
+
691
+ const scale = niceCeil(Math.max(peak, 1));
692
+ const L = 40, B = 16, T = 6;
693
+ const pw = w - L - 4, ph = h - B - T;
694
+ const bw = pw / 60;
695
+ ctx.font = '10px ui-monospace, monospace';
696
+ ctx.textBaseline = 'middle';
697
+ ctx.textAlign = 'right';
698
+ for (const f of [0, 0.5, 1]) {
699
+ const y = T + ph - f * ph;
700
+ ctx.strokeStyle = f === 0 ? '#30363d' : '#21262d';
701
+ ctx.lineWidth = 1;
702
+ ctx.beginPath(); ctx.moveTo(L, Math.round(y) + 0.5); ctx.lineTo(w - 4, Math.round(y) + 0.5); ctx.stroke();
703
+ ctx.fillStyle = '#8b949e';
704
+ ctx.fillText(fmtN(scale * f), L - 6, y);
705
+ }
706
+ ctx.textBaseline = 'top';
707
+ for (const [ago, align] of [[60, 'left'], [30, 'center'], [0, 'right']]) {
708
+ ctx.textAlign = align;
709
+ ctx.fillText(ago ? `-${ago}s` : 'now', L + pw * (1 - ago / 60), T + ph + 4);
710
+ }
711
+ for (const [sec, b] of win) {
712
+ const x = L + (sec - lo) * bw;
713
+ const bh = (b.n / scale) * ph;
668
714
  ctx.fillStyle = '#58a6ff';
669
- ctx.fillRect(x, h - bh, Math.max(1, bw - 1), bh);
715
+ ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), bh);
670
716
  if (b.err) {
671
- const eh = (b.err / max) * (h - 4);
672
717
  ctx.fillStyle = '#f85149';
673
- ctx.fillRect(x, h - bh, Math.max(1, bw - 1), eh);
718
+ ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), (b.err / scale) * ph);
674
719
  }
675
720
  }
676
721
  }
677
722
 
723
+ /** Round up to 1, 2 or 5 × 10^k, so axis ticks land on readable numbers. */
724
+ function niceCeil(v) {
725
+ const p = 10 ** Math.floor(Math.log10(v));
726
+ return [1, 2, 5, 10].find((m) => m * p >= v) * p;
727
+ }
728
+
729
+ /** Compact count: 950, 1.2k, 3.4M. */
730
+ function fmtN(v) {
731
+ if (v >= 1e6) return `${+(v / 1e6).toFixed(1)}M`;
732
+ if (v >= 1e3) return `${+(v / 1e3).toFixed(1)}k`;
733
+ return v >= 10 || Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1);
734
+ }
735
+
678
736
  /* ── tasks: waterfall + aggregates + table ───────────────────────────────── */
679
737
 
680
738
  function visibleCalls() {
@@ -987,6 +1045,8 @@ function renderInspector() {
987
1045
  <td class="${t.error ? 'error' : 'ok'}">${t.error ? 'error' : 'ok'}</td>
988
1046
  <td class="muted">${esc(t.error ?? '')}</td></tr>`)
989
1047
  .join('') || '<tr><td colspan="6" class="muted">no calls yet</td></tr>';
1048
+
1049
+ for (const h of api.hooks.inspectIsland) h(state.inspect, i, { extra: $('inspExtra'), actions: $('inspActions') });
990
1050
  }
991
1051
 
992
1052
  function drawOpTraffic(i) {
@@ -1013,20 +1073,225 @@ function drawOpTraffic(i) {
1013
1073
 
1014
1074
  const logEl = () => $('log');
1015
1075
  const pad = (n, w = 2) => String(n).padStart(w, '0');
1016
- function logLine(sess, e) {
1076
+
1077
+ /*
1078
+ * Event log: one DOM line per event, appended incrementally (never a full
1079
+ * re-render). Filters flip `hidden` on existing lines. Lines are grouped
1080
+ * for the chips by event-type prefix ('task', 'island', …), and `log`
1081
+ * events by level ('log.warn'). reset() empties #log; the reset hook
1082
+ * below drops the matching records.
1083
+ */
1084
+ const LOG_CAP = 2000;
1085
+ const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
1086
+ const LOG_LEVEL_COLOR = { trace: '#484f58', debug: '#8b949e', info: '#58a6ff', warn: '#d29922', error: '#f85149' };
1087
+ const logView = {
1088
+ recs: [], // [{ sid, group, hay, el, vis, sess, e }] oldest first, all in #log
1089
+ held: [], // records ingested while paused, appended on resume
1090
+ groups: new Map(), // group → on (bool)
1091
+ sessions: new Set(),
1092
+ q: '',
1093
+ sess: '',
1094
+ paused: false,
1095
+ hidden: 0,
1096
+ };
1097
+ const logGroupOf = (e) => (e.type === 'log' ? `log.${e.level}` : e.type.split(':')[0]);
1098
+ const logMatch = (r) =>
1099
+ logView.groups.get(r.group) !== false &&
1100
+ (!logView.sess || r.sid === logView.sess) &&
1101
+ (!logView.q || r.hay.includes(logView.q));
1102
+
1103
+ function logStatus() {
1104
+ const s = $('logstat');
1105
+ if (!s) return;
1106
+ const parts = [`${logView.recs.length} lines`];
1107
+ if (logView.hidden) parts.push(`${logView.hidden} hidden by filters`);
1108
+ if (logView.paused) parts.push(`${logView.held.length} held while paused`);
1109
+ s.textContent = parts.join(' · ');
1110
+ }
1111
+
1112
+ function logRefilter() {
1113
+ logView.hidden = 0;
1114
+ for (const r of logView.recs) {
1115
+ r.vis = logMatch(r);
1116
+ r.el.hidden = !r.vis;
1117
+ if (!r.vis) logView.hidden++;
1118
+ }
1119
+ logStatus();
1120
+ const el = logEl();
1121
+ el.scrollTop = el.scrollHeight;
1122
+ }
1123
+
1124
+ function logChip(group) {
1125
+ if (logView.groups.has(group)) return;
1126
+ logView.groups.set(group, true);
1127
+ const bar = $('logchips');
1128
+ if (!bar) return;
1129
+ const b = document.createElement('button');
1130
+ b.className = 'on';
1131
+ b.dataset.g = group;
1132
+ const lvl = group.startsWith('log.') ? group.slice(4) : null;
1133
+ b.innerHTML = lvl ? `<i style="background:${LOG_LEVEL_COLOR[lvl]}"></i>${esc(lvl)}` : esc(group);
1134
+ b.title = lvl ? `log entries at ${lvl}` : `${group}:* events`;
1135
+ // levels first (in severity order), then event groups alphabetically
1136
+ const rank = (g) => (g.startsWith('log.') ? LOG_LEVELS.indexOf(g.slice(4)) : 10);
1137
+ const after = [...bar.querySelectorAll('button[data-g]')].find(
1138
+ (x) => rank(x.dataset.g) > rank(group) || (rank(x.dataset.g) === rank(group) && x.dataset.g > group),
1139
+ );
1140
+ bar.insertBefore(b, after ?? null);
1141
+ }
1142
+
1143
+ function logSessOption(sess) {
1144
+ if (logView.sessions.has(sess.id)) return;
1145
+ logView.sessions.add(sess.id);
1146
+ const sel = $('logsess');
1147
+ if (!sel) return;
1148
+ const o = document.createElement('option');
1149
+ o.value = sess.id;
1150
+ o.textContent = sess.name ?? sess.id;
1151
+ sel.appendChild(o);
1152
+ sel.style.display = logView.sessions.size > 1 ? '' : 'none';
1153
+ }
1154
+
1155
+ function logRender(sess, e) {
1017
1156
  const div = document.createElement('div');
1018
1157
  const { type, at, thread, ...rest } = e;
1019
1158
  // Wall-clock is dashboard receive time — emit-side `at` is a per-thread
1020
1159
  // performance.now() epoch, so it's shown as elapsed instead.
1021
1160
  const now = new Date();
1022
1161
  const ts = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
1023
- div.innerHTML = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${thread === 'worker' ? ' ⧉' : ''}</span> ${esc(type)} <span class="muted">${esc(JSON.stringify(rest))}</span> <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
1162
+ const origin = e.worker ? ` ${e.worker.poolId}#${e.worker.slot}` : thread === 'worker' ? ' ⧉' : '';
1163
+ const head = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${esc(origin)}</span> `;
1164
+ let body, hay;
1165
+ if (type === 'log') {
1166
+ div.className = `ll-${e.level}`;
1167
+ body = `<b>[${esc(e.level)}]</b> ${esc(e.scope)}: ${esc(e.message)}${e.data !== undefined ? ' <span class="logx">▸ data</span>' : ''}`;
1168
+ hay = `log ${e.level} ${e.scope} ${e.message} ${e.data ?? ''}`;
1169
+ } else {
1170
+ const json = JSON.stringify(rest);
1171
+ const bad = type === 'worker:error' || (type === 'task:settle' && e.outcome !== 'ok');
1172
+ if (bad) div.className = 'll-error';
1173
+ body = `${esc(type)} <span class="muted">${esc(json.length > 400 ? `${json.slice(0, 400)}…` : json)}</span>`;
1174
+ hay = `${type} ${json}`;
1175
+ }
1176
+ div.innerHTML = `${head}${body} <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
1177
+ return { sid: sess.id, group: logGroupOf(e), hay: `${sess.name ?? sess.id} ${hay}`.toLowerCase(), el: div, vis: true, sess, e };
1178
+ }
1179
+
1180
+ function logAppend(batch) {
1024
1181
  const el = logEl();
1025
- el.appendChild(div);
1026
- while (el.children.length > 500) el.firstChild.remove();
1027
- el.scrollTop = el.scrollHeight;
1182
+ const stick = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
1183
+ const frag = document.createDocumentFragment();
1184
+ for (const r of batch) {
1185
+ r.vis = logMatch(r);
1186
+ r.el.hidden = !r.vis;
1187
+ if (!r.vis) logView.hidden++;
1188
+ frag.appendChild(r.el);
1189
+ logView.recs.push(r);
1190
+ }
1191
+ el.appendChild(frag);
1192
+ const over = logView.recs.length - LOG_CAP;
1193
+ if (over > 0) {
1194
+ for (const r of logView.recs.splice(0, over)) {
1195
+ if (!r.vis) logView.hidden--;
1196
+ r.el.remove();
1197
+ }
1198
+ }
1199
+ if (stick) el.scrollTop = el.scrollHeight;
1200
+ logStatus();
1201
+ }
1202
+
1203
+ function logLine(sess, e) {
1204
+ logSessOption(sess);
1205
+ logChip(logGroupOf(e));
1206
+ const r = logRender(sess, e);
1207
+ if (logView.paused) {
1208
+ logView.held.push(r);
1209
+ if (logView.held.length > LOG_CAP) logView.held.shift();
1210
+ logStatus();
1211
+ return;
1212
+ }
1213
+ logAppend([r]);
1214
+ }
1215
+
1216
+ function logClear() {
1217
+ logView.recs.length = 0;
1218
+ logView.held.length = 0;
1219
+ logView.hidden = 0;
1220
+ logEl().innerHTML = '';
1221
+ logStatus();
1028
1222
  }
1029
1223
 
1224
+ api.onReset(() => {
1225
+ logClear();
1226
+ logView.sessions.clear();
1227
+ const sel = $('logsess');
1228
+ if (sel) { sel.length = 1; sel.value = ''; sel.style.display = 'none'; }
1229
+ logView.sess = '';
1230
+ });
1231
+
1232
+ (function logControls() {
1233
+ const chips = $('logchips');
1234
+ if (!chips) return;
1235
+ chips.addEventListener('click', (ev) => {
1236
+ const b = ev.target.closest('button');
1237
+ if (!b) return;
1238
+ if (b.dataset.all !== undefined) {
1239
+ const on = b.dataset.all === '1';
1240
+ for (const g of logView.groups.keys()) logView.groups.set(g, on);
1241
+ for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', on);
1242
+ } else if (b.dataset.g) {
1243
+ const g = b.dataset.g;
1244
+ // alt/shift-click: solo this group
1245
+ if (ev.altKey || ev.shiftKey) {
1246
+ for (const k of logView.groups.keys()) logView.groups.set(k, k === g);
1247
+ for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', x.dataset.g === g);
1248
+ } else {
1249
+ const on = !(logView.groups.get(g) ?? true);
1250
+ logView.groups.set(g, on);
1251
+ b.classList.toggle('on', on);
1252
+ }
1253
+ } else return;
1254
+ logRefilter();
1255
+ });
1256
+ let qTimer = 0;
1257
+ $('logq').addEventListener('input', (ev) => {
1258
+ clearTimeout(qTimer);
1259
+ qTimer = setTimeout(() => { logView.q = ev.target.value.trim().toLowerCase(); logRefilter(); }, 150);
1260
+ });
1261
+ $('logsess').addEventListener('change', (ev) => { logView.sess = ev.target.value; logRefilter(); });
1262
+ $('logpause').addEventListener('click', (ev) => {
1263
+ logView.paused = !logView.paused;
1264
+ ev.target.textContent = logView.paused ? 'resume' : 'pause';
1265
+ ev.target.classList.toggle('on', logView.paused);
1266
+ if (!logView.paused && logView.held.length) logAppend(logView.held.splice(0));
1267
+ logStatus();
1268
+ });
1269
+ $('logclear').addEventListener('click', logClear);
1270
+ // Click a line to expand its full payload (log data, or the event JSON).
1271
+ logEl().addEventListener('click', (ev) => {
1272
+ const line = ev.target.closest('#log > div');
1273
+ if (!line) return;
1274
+ const open = line.querySelector(':scope > pre');
1275
+ if (open) { open.remove(); line.classList.remove('open'); return; }
1276
+ const r = logView.recs.find((x) => x.el === line);
1277
+ if (!r) return;
1278
+ const { e } = r;
1279
+ let text;
1280
+ if (e.type === 'log') {
1281
+ try { text = e.data !== undefined ? JSON.stringify(JSON.parse(e.data), null, 2) : '(no data)'; }
1282
+ catch { text = e.data; }
1283
+ } else {
1284
+ const { type, ...rest } = e;
1285
+ text = `${type}\n${JSON.stringify(rest, null, 2)}`;
1286
+ }
1287
+ const pre = document.createElement('pre');
1288
+ pre.className = 'body';
1289
+ pre.textContent = text;
1290
+ line.appendChild(pre);
1291
+ line.classList.add('open');
1292
+ });
1293
+ })();
1294
+
1030
1295
  /* ── app map — one world-space canvas; zoom/pan + zoom-tier clustering ───── */
1031
1296
 
1032
1297
  const APP_COLORS = ['#3fb950', '#f0883e', '#a371f7', '#79c0ff', '#d29922', '#f85149', '#56d4dd', '#e275ad'];
@@ -1046,6 +1311,21 @@ function renderTopology() {
1046
1311
  window.__topo = topo; // exposed for tests/debugging
1047
1312
  }
1048
1313
 
1314
+ /**
1315
+ * The worker that spawned a qualified pool: 'h1#s1~h2#s2~local' is a pool
1316
+ * minted inside worker h2#s2, whose own (qualified) pool is 'h1#s1~h2'.
1317
+ * Returns the `${sess}|${poolId}|${slot}` key or null for top-level ids.
1318
+ */
1319
+ function parentWorkerKey(sid, qpid) {
1320
+ const segs = qpid.split('~');
1321
+ if (segs.length < 2) return null;
1322
+ const host = segs[segs.length - 2];
1323
+ const hi = host.lastIndexOf('#');
1324
+ if (hi < 0) return null;
1325
+ const parentPool = [...segs.slice(0, -2), host.slice(0, hi)].join('~');
1326
+ return `${sid}|${parentPool}|${host.slice(hi + 1)}`;
1327
+ }
1328
+
1049
1329
  /** World-space layout: each session is one atoll cluster in a loose grid. */
1050
1330
  function layoutWorld(sids) {
1051
1331
  const clusters = [];
@@ -1064,23 +1344,39 @@ function layoutWorld(sids) {
1064
1344
  } else orphans.push({ ik, i });
1065
1345
  }
1066
1346
  // rim positions belong to pools, not workers — a size-N pool is one
1067
- // socket containing N worker dots
1347
+ // socket containing N worker dots. Nested pools (poolIds qualified as
1348
+ // 'hostPool#slot~localPool' by sub-worker forwarding) don't take rim
1349
+ // sockets — they hang off their host worker instead.
1068
1350
  const pools = [];
1351
+ const nested = new Map(); // parent worker key → [pool group]
1069
1352
  const byPool = new Map();
1070
1353
  for (const w of workers) {
1071
1354
  const pk = w[0].slice(0, w[0].lastIndexOf('|'));
1072
1355
  let g = byPool.get(pk);
1073
1356
  if (!g) {
1074
- g = { pk, poolId: pk.split('|')[1], workers: [] };
1357
+ g = {
1358
+ pk, poolId: pk.split('|')[1], workers: [], nested: pk.split('|')[1].includes('~'),
1359
+ dedicated: state.pools.get(pk)?.dedicated === true,
1360
+ };
1075
1361
  byPool.set(pk, g);
1076
1362
  pools.push(g);
1077
1363
  }
1078
1364
  g.workers.push(w);
1079
1365
  }
1366
+ for (const g of [...pools]) {
1367
+ if (!g.nested) continue;
1368
+ pools.splice(pools.indexOf(g), 1);
1369
+ const pkey = parentWorkerKey(sid, g.poolId);
1370
+ const l = nested.get(pkey) ?? [];
1371
+ if (!l.length) nested.set(pkey, l);
1372
+ l.push(g);
1373
+ }
1080
1374
  const rimN = Math.max(pools.length + orphans.length, 1);
1081
1375
  const rimR = Math.max(110, rimN * 15);
1082
- maxR = Math.max(maxR, rimR);
1083
- clusters.push({ sid, workers, pools, islands, hosted, orphans, rimN, rimR });
1376
+ // nested worker clusters hang ~120 world units outside the rim
1377
+ // (worker ring + sub-worker orbit + labels)
1378
+ maxR = Math.max(maxR, rimR + (nested.size ? 120 : 0));
1379
+ clusters.push({ sid, workers, pools, islands, hosted, orphans, nested, rimN, rimR });
1084
1380
  }
1085
1381
  const cell = maxR * 2 + 280;
1086
1382
  const cols = Math.min(Math.max(clusters.length, 1), 4);
@@ -1099,123 +1395,78 @@ function fitTopo(canvas, world) {
1099
1395
  topo.oy = (H - world.height * topo.scale) / 2;
1100
1396
  }
1101
1397
 
1102
- /** framework → [chip color, short tag] — HTML chips + fallback marks. */
1103
- const FW_BADGES = {
1104
- react: ['#61dafb', '⚛'],
1105
- vue: ['#42b883', 'V'],
1106
- svelte: ['#ff3e00', 'S'],
1107
- angular: ['#dd0031', 'A'],
1108
- solid: ['#4f88c6', 'So'],
1109
- vanilla: ['#f0db4f', 'JS'],
1110
- leaflet: ['#19993b', 'L'],
1398
+ /** Structural (non-island) nodes — a lettered disc, so pools and plain
1399
+ * workers never read as a framework island. */
1400
+ const ROLE_MARKS = {
1401
+ pool: { letter: 'P', fill: '#21262d', ring: '#8b949e88', text: '#8b949e' },
1402
+ worker: { letter: 'W', fill: '#30363d', ring: '#8b949e', text: '#c9d1d9' },
1403
+ subworker: { letter: 'W', fill: '#a371f733', ring: '#a371f7', text: '#d2a8ff' },
1111
1404
  };
1112
1405
 
1113
- /** Real framework marks, drawn at icon size (s ≈ node radius). */
1406
+ function drawRoleMark(ctx, m, x, y, s, dead) {
1407
+ ctx.save();
1408
+ if (dead) ctx.globalAlpha = 0.35;
1409
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1410
+ ctx.fillStyle = m.fill; ctx.fill();
1411
+ ctx.strokeStyle = m.ring; ctx.lineWidth = s * 0.14; ctx.stroke();
1412
+ ctx.font = `bold ${s * 1.05}px ui-monospace, monospace`;
1413
+ ctx.fillStyle = m.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1414
+ ctx.fillText(m.letter, x, y + s * 0.06);
1415
+ ctx.restore();
1416
+ ctx.textBaseline = 'top';
1417
+ }
1418
+
1419
+ /** Framework logos (FW_LOGOS paths in their brand colours) on a disc, drawn
1420
+ * at icon size (s ≈ node radius). Logos too dark for the dashboard sit on
1421
+ * a light disc. Unknown tags fall back to a lettered disc. */
1422
+ const fwPaths = {};
1114
1423
  function drawFwMark(ctx, fw, x, y, s, dead) {
1424
+ if (ROLE_MARKS[fw]) return drawRoleMark(ctx, ROLE_MARKS[fw], x, y, s, dead);
1425
+ const logo = FW_LOGOS[fw];
1115
1426
  ctx.save();
1116
1427
  if (dead) ctx.globalAlpha = 0.35;
1117
- switch (fw) {
1118
- case 'react': {
1119
- // the atom — nucleus + three orbital ellipses
1120
- ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1121
- ctx.fillStyle = '#22272e'; ctx.fill();
1122
- ctx.strokeStyle = '#61dafb'; ctx.lineWidth = s * 0.13;
1123
- for (const deg of [0, 60, 120]) {
1124
- ctx.beginPath();
1125
- ctx.ellipse(x, y, s * 0.95, s * 0.38, (deg * Math.PI) / 180, 0, 7);
1126
- ctx.stroke();
1127
- }
1128
- ctx.beginPath(); ctx.arc(x, y, s * 0.15, 0, 7);
1129
- ctx.fillStyle = '#61dafb'; ctx.fill();
1130
- break;
1131
- }
1132
- case 'vue': {
1133
- // nested V — green outer chevron, dark inner chevron
1134
- const h = s * 0.92;
1135
- ctx.beginPath();
1136
- ctx.moveTo(x - s, y - h); ctx.lineTo(x + s, y - h); ctx.lineTo(x, y + h);
1137
- ctx.closePath(); ctx.fillStyle = '#42b883'; ctx.fill();
1138
- ctx.beginPath();
1139
- ctx.moveTo(x - s * 0.58, y - h); ctx.lineTo(x + s * 0.58, y - h); ctx.lineTo(x, y + h * 0.14);
1140
- ctx.closePath(); ctx.fillStyle = '#35495e'; ctx.fill();
1141
- break;
1142
- }
1143
- case 'svelte': {
1144
- ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1145
- ctx.fillStyle = '#ff3e00'; ctx.fill();
1146
- ctx.font = `bold ${s * 1.3}px ui-monospace, monospace`;
1147
- ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1148
- ctx.fillText('S', x, y + s * 0.06);
1149
- break;
1150
- }
1151
- case 'angular': {
1152
- // shield + A
1153
- ctx.beginPath();
1154
- ctx.moveTo(x, y - s); ctx.lineTo(x + s * 0.95, y - s * 0.62);
1155
- ctx.lineTo(x + s * 0.78, y + s * 0.72); ctx.lineTo(x, y + s);
1156
- ctx.lineTo(x - s * 0.78, y + s * 0.72); ctx.lineTo(x - s * 0.95, y - s * 0.62);
1157
- ctx.closePath(); ctx.fillStyle = '#dd0031'; ctx.fill();
1158
- ctx.font = `bold ${s * 1.15}px ui-monospace, monospace`;
1159
- ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1160
- ctx.fillText('A', x, y + s * 0.1);
1161
- break;
1162
- }
1163
- case 'solid': {
1164
- ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1165
- ctx.fillStyle = '#4f88c6'; ctx.fill();
1166
- ctx.font = `bold ${s * 1.15}px ui-monospace, monospace`;
1167
- ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1168
- ctx.fillText('S', x, y + s * 0.06);
1169
- break;
1170
- }
1171
- case 'vanilla': {
1172
- // the JS badge — yellow square, black JS
1173
- ctx.beginPath();
1174
- ctx.roundRect(x - s, y - s, s * 2, s * 2, s * 0.14);
1175
- ctx.fillStyle = '#f0db4f'; ctx.fill();
1176
- ctx.font = `bold ${s * 0.85}px ui-monospace, monospace`;
1177
- ctx.fillStyle = '#0d1117'; ctx.textBaseline = 'middle';
1178
- ctx.fillText('JS', x, y + s * 0.18);
1179
- break;
1180
- }
1181
- case 'leaflet': {
1182
- // green rounded square + white leaf
1183
- ctx.beginPath();
1184
- ctx.roundRect(x - s, y - s, s * 2, s * 2, s * 0.25);
1185
- ctx.fillStyle = '#19993b'; ctx.fill();
1186
- ctx.beginPath();
1187
- ctx.ellipse(x, y - s * 0.08, s * 0.3, s * 0.58, Math.PI / 4, 0, 7);
1188
- ctx.fillStyle = '#fff'; ctx.fill();
1189
- ctx.beginPath();
1190
- ctx.moveTo(x - s * 0.45, y + s * 0.42); ctx.lineTo(x + s * 0.45, y - s * 0.48);
1191
- ctx.strokeStyle = '#19993b'; ctx.lineWidth = s * 0.07; ctx.stroke();
1192
- break;
1193
- }
1194
- default: {
1195
- // tagged but unknown framework — green disc + first letter
1196
- ctx.beginPath(); ctx.arc(x, y, s * 0.85, 0, 7);
1197
- ctx.fillStyle = dead ? '#484f58' : '#3fb950'; ctx.fill();
1198
- ctx.font = `bold ${s * 0.9}px ui-monospace, monospace`;
1199
- ctx.fillStyle = '#0d1117'; ctx.textBaseline = 'middle';
1200
- ctx.fillText(fw[0].toUpperCase(), x, y);
1201
- }
1428
+ if (logo && typeof Path2D === 'function') {
1429
+ const n = parseInt(logo.hex.slice(1), 16);
1430
+ const lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
1431
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1432
+ ctx.fillStyle = lum < 0.35 ? '#f0f3f6' : '#22272e'; ctx.fill();
1433
+ const k = (s * 1.3) / 24;
1434
+ ctx.translate(x - 12 * k, y - 12 * k);
1435
+ ctx.scale(k, k);
1436
+ ctx.fillStyle = logo.hex;
1437
+ ctx.fill(fwPaths[fw] ??= new Path2D(logo.d));
1438
+ } else {
1439
+ ctx.beginPath(); ctx.arc(x, y, s * 0.85, 0, 7);
1440
+ ctx.fillStyle = dead ? '#484f58' : '#3fb950'; ctx.fill();
1441
+ ctx.font = `bold ${s * 0.9}px ui-monospace, monospace`;
1442
+ ctx.fillStyle = '#0d1117'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1443
+ ctx.fillText(fw[0].toUpperCase(), x, y);
1202
1444
  }
1203
1445
  ctx.restore();
1204
1446
  ctx.textBaseline = 'top';
1205
1447
  }
1206
1448
 
1207
- /** The island node — the framework mark IS the dot. Untagged islands
1208
- * keep the plain green dot; ended islands dim. Returns the hit radius. */
1209
- function drawIslandDot(ctx, i, x, y, inv) {
1210
- const fw = i.fw?.toLowerCase();
1211
- if (fw) {
1212
- drawFwMark(ctx, fw, x, y, 12 * inv, i.ended);
1213
- return 12 * inv;
1449
+ /** The island node — the framework mark IS the dot. An untagged island
1450
+ * isn't a framework island, so it reads as its worker: a W (`role`
1451
+ * picks the nested variant). Ended islands dim. Returns the hit radius. */
1452
+ function drawIslandDot(ctx, i, x, y, inv, role = 'worker') {
1453
+ drawFwMark(ctx, i.fw?.toLowerCase() || role, x, y, 12 * inv, i.ended);
1454
+ return 12 * inv;
1455
+ }
1456
+
1457
+ /** The session hub (main thread): the shell's framework mark in a violet
1458
+ * ring when the session reports one (`initDevtools({ session: { framework } })`),
1459
+ * else the plain violet disc. */
1460
+ function drawHub(ctx, sess, x, y, r, inv) {
1461
+ if (sess?.framework) {
1462
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
1463
+ ctx.fillStyle = '#0d1117'; ctx.fill();
1464
+ ctx.strokeStyle = '#a371f7'; ctx.lineWidth = 2 * inv; ctx.stroke();
1465
+ drawFwMark(ctx, sess.framework.toLowerCase(), x, y, r * 0.72, sess.closed === true);
1466
+ } else {
1467
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
1468
+ ctx.fillStyle = '#a371f7'; ctx.fill();
1214
1469
  }
1215
- ctx.beginPath(); ctx.arc(x, y, 5.5 * inv, 0, 7);
1216
- ctx.fillStyle = i.ended ? '#484f58' : '#3fb950';
1217
- ctx.fill();
1218
- return 5.5 * inv;
1219
1470
  }
1220
1471
 
1221
1472
  function drawAppMap(canvas, sids) {
@@ -1253,8 +1504,7 @@ function drawAppMap(canvas, sids) {
1253
1504
  const a = (j / Math.max(cl.islands.length, 1)) * 2 * Math.PI - Math.PI / 2;
1254
1505
  drawIslandDot(ctx, i, cx + r * Math.cos(a), cy + r * Math.sin(a), mi);
1255
1506
  });
1256
- ctx.beginPath(); ctx.arc(cx, cy, 8 * inv, 0, 7);
1257
- ctx.fillStyle = '#a371f7'; ctx.fill();
1507
+ drawHub(ctx, sess, cx, cy, 8 * inv, inv);
1258
1508
  ctx.font = `${11 * inv}px ui-monospace, monospace`;
1259
1509
  ctx.fillStyle = '#c9d1d9';
1260
1510
  ctx.fillText(sess?.name ?? cl.sid, cx, cy + 14 * inv);
@@ -1264,11 +1514,11 @@ function drawAppMap(canvas, sids) {
1264
1514
  continue;
1265
1515
  }
1266
1516
 
1267
- const workerR = cl.rimR + 38; // mid ring — workers
1268
- const islandR = cl.rimR + 76; // outer ring — islands
1269
- // orbit lines — one per level, so pool/worker/island nodes sit ON them
1517
+ // outer ring — workers; an island-hosting worker IS its island(s)
1518
+ const workerR = cl.rimR + 38;
1519
+ // orbit lines — one per level, so pool/worker nodes sit ON them
1270
1520
  ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv;
1271
- for (const r of [cl.rimR, workerR, islandR]) {
1521
+ for (const r of [cl.rimR, workerR]) {
1272
1522
  ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.stroke();
1273
1523
  }
1274
1524
 
@@ -1279,83 +1529,248 @@ function drawAppMap(canvas, sids) {
1279
1529
  ctx.strokeStyle = '#21262d';
1280
1530
  ctx.lineWidth = 1 * inv;
1281
1531
  for (const p of ppos) {
1532
+ if (p.dedicated) continue; // its spoke runs to the worker, drawn with it
1282
1533
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); ctx.stroke();
1283
1534
  }
1284
1535
 
1285
1536
  const font = (px) => `${px * inv}px ui-monospace, monospace`;
1537
+
1538
+ const islandName = (ik) => ik.split('|').slice(1).join('|');
1539
+
1540
+ const anchors = new Map(); // island key → its node { x, y, r }: spawn links start here
1541
+ const badges = new Map(); // worker key → the W badge on a shared worker's ring
1542
+
1543
+ /** Untagged instance inside a shared worker — hollow disc + the app's initial. */
1544
+ const instanceMark = (ik) => ({
1545
+ letter: islandName(ik).split('~').pop()[0] ?? '?',
1546
+ fill: '#3fb9501f', ring: '#3fb950', text: '#3fb950',
1547
+ });
1548
+
1549
+ /** A label pushed outward from (x, y) along angle th, aligned to its side. */
1550
+ const labelOut = (text, x, y, th, dist, color) => {
1551
+ const c = Math.cos(th), s = Math.sin(th);
1552
+ ctx.save();
1553
+ ctx.font = font(10); ctx.fillStyle = color;
1554
+ ctx.textAlign = c > 0.35 ? 'left' : c < -0.35 ? 'right' : 'center';
1555
+ ctx.textBaseline = s > 0.35 ? 'top' : s < -0.35 ? 'bottom' : 'middle';
1556
+ ctx.fillText(text, x + dist * c, y + dist * s);
1557
+ ctx.restore();
1558
+ };
1559
+
1560
+ /**
1561
+ * A worker hosting ONE island is that island: its mark (or a W when
1562
+ * untagged) sits on the worker's spot. A worker hosting several
1563
+ * (one shared client) is a container: a ring with a W badge for the
1564
+ * worker itself, one node per instance inside (framework mark, or a
1565
+ * hollow initial when untagged), each labeled outward. Every instance
1566
+ * registers in `anchors` so a sub-island's spawn link starts at the
1567
+ * instance that spawned it. `ring` colors the sub-worker variant.
1568
+ * Returns the drawn radius.
1569
+ */
1570
+ const drawIslandWorker = (k, isl, x, y, ring, wid) => {
1571
+ const w = state.workers.get(k);
1572
+ const n = isl.length;
1573
+ const m = 12 * inv;
1574
+ const lc = ring ? '#a371f7' : '#8b949e';
1575
+ if (n === 1) {
1576
+ const { ik, i } = isl[0];
1577
+ drawIslandDot(ctx, i, x, y, inv, ring ? 'subworker' : 'worker');
1578
+ nodes.push({ x, y, r: m + 4 * inv, kind: 'island', key: ik, wk: k });
1579
+ anchors.set(ik, { x, y, r: m });
1580
+ let R = m;
1581
+ if (ring) {
1582
+ R += 3 * inv;
1583
+ ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
1584
+ ctx.strokeStyle = ring; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1585
+ }
1586
+ if (full) labelWorker(isl, wid, x, y, R, lc);
1587
+ return R;
1588
+ }
1589
+ const cr = (m + 4 * inv) / Math.sin(Math.PI / n);
1590
+ const R = cr + m + 5 * inv;
1591
+ ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
1592
+ ctx.fillStyle = '#0d111799'; ctx.fill();
1593
+ ctx.strokeStyle = ring ?? '#8b949e88'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1594
+ isl.forEach(({ ik, i }, j) => {
1595
+ const th = (j / n) * 2 * Math.PI - Math.PI / 2;
1596
+ const ix = x + cr * Math.cos(th), iy = y + cr * Math.sin(th);
1597
+ if (i.fw) drawIslandDot(ctx, i, ix, iy, inv);
1598
+ else drawRoleMark(ctx, instanceMark(ik), ix, iy, m, i.ended);
1599
+ nodes.push({ x: ix, y: iy, r: m + inv, kind: 'island', key: ik, wk: k });
1600
+ anchors.set(ik, { x: ix, y: iy, r: m, cx: x, cy: y });
1601
+ if (full) labelOut(islandName(ik), x, y, th, R + 5 * inv, '#c9d1d9');
1602
+ });
1603
+ // the worker itself — a W badge on the ring, in the first gap between instances
1604
+ const tb = -Math.PI / 2 + Math.PI / n;
1605
+ const bx = x + R * Math.cos(tb), by = y + R * Math.sin(tb), br = 7 * inv;
1606
+ drawFwMark(ctx, ring ? 'subworker' : 'worker', bx, by, br, w?.dead);
1607
+ nodes.push({ x: bx, y: by, r: br + 3 * inv, kind: 'worker', key: k });
1608
+ badges.set(k, { x: bx, y: by, r: br });
1609
+ if (full) labelOut(wid, bx, by, tb, br + 4 * inv, lc);
1610
+ return R;
1611
+ };
1612
+
1613
+ /** Under-node label for a 1:1 or plain worker: the island name (if any), then the worker id. */
1614
+ const labelWorker = (isl, wid, x, y, r, color = '#8b949e') => {
1615
+ let ly = y + r + 2 * inv;
1616
+ ctx.font = font(10);
1617
+ if (isl.length) {
1618
+ ctx.fillStyle = '#c9d1d9';
1619
+ // the spawn link already names a sub-island's parent: label its own segment
1620
+ ctx.fillText(islandName(isl[0].ik).split('~').pop(), x, ly);
1621
+ ly += 12 * inv;
1622
+ }
1623
+ ctx.fillStyle = color;
1624
+ ctx.fillText(wid, x, ly);
1625
+ };
1626
+
1627
+ /**
1628
+ * Nested workers: a pool minted inside a worker hangs off whatever
1629
+ * spawned it. A sub-island's key (`parent~app@N`) names the spawning
1630
+ * INSTANCE, so its sub-worker links from that instance's node; a
1631
+ * worker-level spawn (`connectSubWorker`, no island) links from the
1632
+ * host's W badge, or the host node itself. Sub-workers fan outward
1633
+ * from the anchor, nudged off the anchor's own outward label. Each is
1634
+ * a violet W, or its island(s) in a violet ring. Deeper pools recurse.
1635
+ */
1636
+ const drawBranch = (pwKey, px, py, a, pr) => {
1637
+ const ws = (cl.nested.get(pwKey) ?? [])
1638
+ .flatMap((g) => g.workers.map(([k, w]) => ({ k, w, g })));
1639
+ if (!ws.length) return;
1640
+ const groups = new Map(); // anchor key → sub-workers it spawned
1641
+ for (const s of ws) {
1642
+ let anc = null;
1643
+ for (const { ik } of cl.hosted.get(s.k) ?? []) {
1644
+ const t = ik.lastIndexOf('~');
1645
+ if (t > 0) { anc = ik.slice(0, t); break; }
1646
+ }
1647
+ // an anchor sitting on the host itself (a 1:1 island worker) joins
1648
+ // the host's own fan, so siblings spread together instead of overlapping
1649
+ const at = anc ? anchors.get(anc) : undefined;
1650
+ const gk = at && Math.hypot(at.x - px, at.y - py) > 1 ? anc : '';
1651
+ const l = groups.get(gk) ?? [];
1652
+ if (!l.length) groups.set(gk, l);
1653
+ l.push(s);
1654
+ }
1655
+ for (const [gk, raw] of groups) {
1656
+ // widest sibling (an island container) in the middle of the fan,
1657
+ // smaller ones alternating outward, so the bulk points straight out
1658
+ const size = (s) => (cl.hosted.get(s.k) ?? []).length;
1659
+ const bySize = [...raw].sort((x, y) => size(y) - size(x));
1660
+ const list = new Array(bySize.length);
1661
+ const mid = (bySize.length - 1) >> 1;
1662
+ bySize.forEach((s, i) => { list[mid + (i % 2 ? (i + 1) >> 1 : -(i >> 1))] = s; });
1663
+ const an = gk ? anchors.get(gk) : (badges.get(pwKey) ?? { x: px, y: py, r: pr });
1664
+ const off = Math.hypot(an.x - px, an.y - py) > 1;
1665
+ const dir = off ? Math.atan2(an.y - py, an.x - px) : a;
1666
+ // Each sibling takes the angle its own footprint needs at its
1667
+ // distance (a container is wide and sits further out; a W plus its
1668
+ // label is narrow), packed around one centre line. One or two
1669
+ // siblings rotate off the anchor's outward label toward the upper
1670
+ // side (labels hang below); a bigger fan points straight out.
1671
+ const geo = list.map(({ k }) => {
1672
+ const isl = cl.hosted.get(k) ?? [];
1673
+ // a container is wider than one mark: push its centre out by its radius
1674
+ const ext = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 5 * inv : 0;
1675
+ const dist = an.r + 56 + ext;
1676
+ const foot = (isl.length > 1 ? ext + 12 * inv : 7 * inv) + 14 * inv;
1677
+ return { isl, dist, hw: Math.asin(Math.min(0.95, foot / dist)) };
1678
+ });
1679
+ const total = geo.reduce((s, x) => s + 2 * x.hw, 0);
1680
+ const turn = list.length > 2 ? 0 : Math.max(0, Math.min(off ? 1 : 1.25, 1.4 - total / 2));
1681
+ const nudge = Math.sin(dir + turn) < Math.sin(dir - turn) ? turn : -turn;
1682
+ let cursor = dir + nudge - total / 2;
1683
+ list.forEach(({ k, w, g }, j) => {
1684
+ const { isl, dist, hw } = geo[j];
1685
+ const th = cursor + hw;
1686
+ cursor += 2 * hw;
1687
+ const nx = an.x + dist * Math.cos(th), ny = an.y + dist * Math.sin(th);
1688
+ const local = g.poolId.split('~').pop();
1689
+ const wid = g.dedicated ? local : `${local}#${k.split('|').pop()}`;
1690
+ let r;
1691
+ if (isl.length) r = drawIslandWorker(k, isl, nx, ny, '#a371f7aa', wid);
1692
+ else {
1693
+ r = 7 * inv;
1694
+ drawFwMark(ctx, 'subworker', nx, ny, r, w.dead);
1695
+ nodes.push({ x: nx, y: ny, r: r + 4 * inv, kind: 'worker', key: k });
1696
+ if (full) labelWorker([], wid, nx, ny, r, '#a371f7');
1697
+ }
1698
+ // spawn link — violet, anchor edge → sub-worker edge
1699
+ ctx.beginPath();
1700
+ ctx.moveTo(an.x + an.r * Math.cos(th), an.y + an.r * Math.sin(th));
1701
+ ctx.lineTo(nx - r * Math.cos(th), ny - r * Math.sin(th));
1702
+ ctx.strokeStyle = '#a371f799'; ctx.lineWidth = 1.4 * inv; ctx.stroke();
1703
+ drawBranch(k, nx, ny, th, r);
1704
+ });
1705
+ }
1706
+ };
1707
+
1286
1708
  for (const p of ppos) {
1287
1709
  const mark = Math.max(inv, 1);
1288
- // pool — a gray socket on the rim, the container for its workers
1289
- const poolR = 10 * mark;
1290
- ctx.beginPath(); ctx.arc(p.x, p.y, poolR, 0, 7);
1291
- ctx.fillStyle = '#21262d'; ctx.fill();
1292
- ctx.strokeStyle = '#8b949e55'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1293
- if (full) {
1294
- ctx.font = font(10); ctx.fillStyle = '#8b949e';
1295
- ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
1710
+ // pool — a P socket on the rim, the container for its workers. A
1711
+ // dedicated runner (workers: 1, no pool) has no socket: its worker
1712
+ // keeps the worker-orbit spot and the hub spoke runs straight to it.
1713
+ const poolR = p.dedicated ? 0 : 10 * mark;
1714
+ if (!p.dedicated) {
1715
+ drawFwMark(ctx, 'pool', p.x, p.y, poolR);
1716
+ if (full) {
1717
+ ctx.font = font(10); ctx.fillStyle = '#8b949e';
1718
+ ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
1719
+ }
1296
1720
  }
1297
1721
  const n = p.workers.length;
1298
1722
  p.workers.forEach(([k, w], j) => {
1299
1723
  // workers spread around the pool's angle on the mid ring
1300
1724
  const a2 = p.a + (j - (n - 1) / 2) * 0.22;
1301
- const wx = cx + workerR * Math.cos(a2), wy = cy + workerR * Math.sin(a2);
1302
- const r = (7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
1303
- // pool → worker link
1304
- ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(wx, wy);
1305
- ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1306
1725
  const isl = cl.hosted.get(k) ?? [];
1307
- if (isl.length === 0) {
1308
- // a worker hosting no islands is itself a vanilla island —
1309
- // same mark the islands column shows
1310
- drawFwMark(ctx, 'vanilla', wx, wy, Math.max(r, 9 * mark), w.dead);
1311
- } else {
1312
- ctx.beginPath(); ctx.arc(wx, wy, r, 0, 7);
1313
- ctx.fillStyle = w.dead ? '#484f58' : '#8b949e';
1314
- ctx.fill();
1726
+ // a shared-worker container is wider than one mark — push it clear of its pool
1727
+ const span = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 17 * inv : 0;
1728
+ const wd = cl.rimR + Math.max(38, span + (p.dedicated ? 10 * mark : poolR) + 8 * inv);
1729
+ const wx = cx + wd * Math.cos(a2), wy = cy + wd * Math.sin(a2);
1730
+ const wid = p.dedicated ? p.poolId : `${p.poolId}#${k.split('|').pop()}`;
1731
+ let r;
1732
+ if (isl.length) r = drawIslandWorker(k, isl, wx, wy, undefined, wid);
1733
+ else {
1734
+ // plain worker — W, sized by heap when the probe reports it
1735
+ r = Math.max(8, 7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
1736
+ drawFwMark(ctx, 'worker', wx, wy, r, w.dead);
1737
+ nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
1315
1738
  }
1316
- if (full) {
1317
- ctx.font = font(10); ctx.fillStyle = '#8b949e';
1318
- ctx.fillText(`${p.poolId}#${k.split('|').pop()}`, wx, wy + r + 2 * inv);
1739
+ // pool → worker link, edge to edge so it never crosses a cluster
1740
+ const d = Math.hypot(wx - p.x, wy - p.y) || 1;
1741
+ const ux = (wx - p.x) / d, uy = (wy - p.y) / d;
1742
+ if (p.dedicated) {
1743
+ ctx.beginPath();
1744
+ ctx.moveTo(cx, cy); ctx.lineTo(wx - Math.cos(a2) * r, wy - Math.sin(a2) * r);
1745
+ ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv; ctx.stroke();
1746
+ } else if (d > poolR + r) {
1747
+ ctx.beginPath();
1748
+ ctx.moveTo(p.x + ux * poolR, p.y + uy * poolR); ctx.lineTo(wx - ux * r, wy - uy * r);
1749
+ ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1319
1750
  }
1320
- nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
1321
- // this worker's islands spread around its angle on the outer ring —
1322
- // marks stay visible when zoomed out (the atoll look)
1323
- isl.forEach(({ ik, i }, jj) => {
1324
- const a3 = a2 + (jj - (isl.length - 1) / 2) * 0.18;
1325
- const ix = cx + islandR * Math.cos(a3), iy = cy + islandR * Math.sin(a3);
1326
- if (full) {
1327
- // host link — worker → its island
1328
- ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ix, iy);
1329
- ctx.strokeStyle = `${i.ended ? '#484f58' : '#3fb950'}55`;
1330
- ctx.lineWidth = 1.4 * inv; ctx.stroke();
1331
- }
1332
- const r = drawIslandDot(ctx, i, ix, iy, inv);
1333
- if (full) {
1334
- ctx.fillStyle = '#8b949e';
1335
- ctx.fillText(ik.split('|').slice(1).join('|'), ix, iy + r + 2 * inv);
1336
- }
1337
- nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
1338
- });
1751
+ if (full && !isl.length) labelWorker([], wid, wx, wy, r);
1752
+ // nested pools branch off this worker — sub-workers / sub-islands
1753
+ drawBranch(k, wx, wy, a2, r);
1339
1754
  });
1340
1755
  }
1756
+ // islands whose worker never reported take a worker slot of their own
1341
1757
  cl.orphans.forEach(({ ik, i }, j) => {
1342
1758
  const a = ((cl.pools.length + j) / cl.rimN) * 2 * Math.PI - Math.PI / 2;
1343
- const ix = cx + islandR * Math.cos(a), iy = cy + islandR * Math.sin(a);
1759
+ const ix = cx + workerR * Math.cos(a), iy = cy + workerR * Math.sin(a);
1344
1760
  const r = drawIslandDot(ctx, i, ix, iy, inv);
1345
1761
  if (full) {
1346
- ctx.fillStyle = '#8b949e';
1347
- ctx.fillText(ik.split('|').slice(1).join('|'), ix, iy + r + 2 * inv);
1762
+ ctx.font = font(10); ctx.fillStyle = '#c9d1d9';
1763
+ ctx.fillText(islandName(ik), ix, iy + r + 2 * inv);
1348
1764
  }
1349
1765
  nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
1350
1766
  });
1351
1767
 
1352
- ctx.beginPath(); ctx.arc(cx, cy, 16 * inv, 0, 7);
1353
- ctx.fillStyle = '#a371f7'; ctx.fill();
1768
+ drawHub(ctx, sess, cx, cy, 16 * inv, inv);
1354
1769
  ctx.font = font(11);
1355
1770
  ctx.fillStyle = '#c9d1d9';
1356
1771
  ctx.fillText(sess?.name ?? cl.sid, cx, cy + 19 * inv);
1357
1772
  ctx.fillStyle = '#8b949e';
1358
- ctx.fillText('main thread', cx, cy + 32 * inv);
1773
+ ctx.fillText(sess?.framework ? `main thread · ${sess.framework}` : 'main thread', cx, cy + 32 * inv);
1359
1774
  if (!cl.pools.length && !cl.orphans.length) ctx.fillText('no workers or islands yet', cx, cy + 46 * inv);
1360
1775
  // the session hub is clickable too — same drill-in as the far-zoom disc
1361
1776
  nodes.push({ x: cx, y: cy, r: 16 * inv, kind: 'session', key: cl.sid });
@@ -1364,7 +1779,7 @@ function drawAppMap(canvas, sids) {
1364
1779
  // selection highlight — ring whatever is under inspection / selected
1365
1780
  for (const n of nodes) {
1366
1781
  const sel = (n.kind === 'worker' && n.key === state.inspectWorker)
1367
- || (n.kind === 'island' && n.key === state.inspect)
1782
+ || (n.kind === 'island' && (n.key === state.inspect || (n.wk && n.wk === state.inspectWorker)))
1368
1783
  || (n.kind === 'session' && n.key === state.sel);
1369
1784
  if (!sel) continue;
1370
1785
  const rr = n.r + 2.5 * inv;
@@ -1409,7 +1824,7 @@ function renderWorkers() {
1409
1824
  <td class="${w.heap !== undefined ? '' : 'muted'}">${w.heap !== undefined ? fmtBytes(w.heap) : '—'}</td>
1410
1825
  <td>${hosted.length
1411
1826
  ? `<span class="ok">${hosted.map((h) => `${fwIconImg(h.fw)} ${esc(h.name)}`).join(', ')}</span>`
1412
- : `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('vanilla')}</span>`}</td>
1827
+ : `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('worker')}</span>`}</td>
1413
1828
  <td class="muted">${esc(w.lastTaskId || '—')}</td>
1414
1829
  <td class="${w.dead ? 'error' : 'ok'}">${w.dead ? 'down' : 'live'}</td></tr>`;
1415
1830
  }) || '<tr><td colspan="10" class="muted">no workers yet</td></tr>';
@@ -1492,6 +1907,8 @@ function renderWorkerInsp() {
1492
1907
  $('wkErrors').innerHTML = w.errors.length
1493
1908
  ? w.errors.slice().reverse().map((x) => `<div style="padding:2px 0;border-bottom:1px dashed #21262d"><span class="error">error</span> ${esc(x.message)}</div>`).join('')
1494
1909
  : '<div class="muted">none</div>';
1910
+
1911
+ for (const h of api.hooks.inspectWorker) h(k, w, { actions: $('wkActions') });
1495
1912
  }
1496
1913
 
1497
1914
  /* ── render dispatch ─────────────────────────────────────────────────────── */
@@ -1541,6 +1958,19 @@ function render() {
1541
1958
  render();
1542
1959
  };
1543
1960
  }
1961
+ for (const h of api.hooks.render) h();
1962
+ updateBadges();
1963
+ }
1964
+
1965
+ /** Nav badges for registered views (`addView({ badge })`): counts, alerts. */
1966
+ function updateBadges() {
1967
+ for (const v of registeredViews) {
1968
+ if (!v.badge) continue;
1969
+ const el = document.querySelector(`.panels > nav button[data-view="${v.id}"] .nbadge`);
1970
+ if (!el) continue;
1971
+ const b = v.badge();
1972
+ el.textContent = b === null || b === undefined || b === 0 ? '' : String(b);
1973
+ }
1544
1974
  }
1545
1975
 
1546
1976
  let raf = 0;
@@ -1557,16 +1987,89 @@ setInterval(() => {
1557
1987
  if ($('view-memory').classList.contains('on')) renderMemory();
1558
1988
  if ($('view-dashboard').classList.contains('on')) renderTopology();
1559
1989
  if (BC) sweepClosed();
1990
+ for (const h of api.hooks.tick) h();
1560
1991
  }, 1000);
1561
1992
 
1562
1993
  /* ── tabs + waterfall interaction ────────────────────────────────────────── */
1563
1994
 
1564
- for (const b of document.querySelectorAll('.panels > nav button')) {
1565
- b.onclick = () => {
1566
- document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === b));
1567
- document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${b.dataset.view}`));
1568
- render();
1569
- };
1995
+ /** Switch the main view; panels and the palette route through this. */
1996
+ function openView(id) {
1997
+ const btn = document.querySelector(`.panels > nav button[data-view="${id}"]`);
1998
+ if (!btn) return;
1999
+ document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === btn));
2000
+ document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${id}`));
2001
+ render();
2002
+ navigated();
2003
+ }
2004
+
2005
+ /** Fire api.onNavigate hooks (routing, nav layout) after a view/subview switch. */
2006
+ function navigated() {
2007
+ for (const h of api.hooks.navigate ?? []) h();
2008
+ }
2009
+
2010
+ // Delegated, so views/subviews panels add later get the same handlers.
2011
+ document.querySelector('.panels > nav').addEventListener('click', (e) => {
2012
+ const b = e.target.closest('button[data-view]');
2013
+ if (b) openView(b.dataset.view);
2014
+ });
2015
+
2016
+ /** Registered panel views, in nav order. */
2017
+ const registeredViews = [];
2018
+ const CORE_VIEW_ORDER = { dashboard: 10, tasks: 20, network: 30, memory: 40, log: 90 };
2019
+ const viewOrder = (b) => Number(b.dataset.order ?? CORE_VIEW_ORDER[b.dataset.view] ?? 50);
2020
+
2021
+ /**
2022
+ * Add a top-level view: a nav button (ordered by `order`; core views are
2023
+ * 10 dashboard, 20 tasks, 30 network, 40 memory, 90 log) and a section.
2024
+ * `badge()` feeds a small count on the nav button each render.
2025
+ */
2026
+ function addView({ id, label, order = 50, html = '', badge, title }) {
2027
+ const nav = document.querySelector('.panels > nav');
2028
+ const btn = document.createElement('button');
2029
+ btn.dataset.view = id;
2030
+ btn.dataset.order = String(order);
2031
+ btn.innerHTML = `${esc(label)}<span class="nbadge"></span>`;
2032
+ if (title) btn.title = title;
2033
+ const after = [...nav.querySelectorAll('button[data-view]')].find((b) => viewOrder(b) > order);
2034
+ nav.insertBefore(btn, after ?? null);
2035
+ const section = document.createElement('section');
2036
+ section.className = 'view';
2037
+ section.id = `view-${id}`;
2038
+ section.innerHTML = html;
2039
+ document.querySelector('.panels').appendChild(section);
2040
+ registeredViews.push({ id, label, order, badge });
2041
+ return section;
2042
+ }
2043
+
2044
+ /**
2045
+ * Add a subview (sub-tab) under a view. A view without a subnav gets one,
2046
+ * and its existing content becomes the first subview (`<view>-main`).
2047
+ */
2048
+ function addSubview(viewId, { id, label, html = '', order = 50 }) {
2049
+ const view = $(`view-${viewId}`);
2050
+ let subnav = view.querySelector(':scope > .subnav');
2051
+ if (!subnav) {
2052
+ const main = document.createElement('div');
2053
+ main.className = 'subview on';
2054
+ main.id = `${viewId}-main`;
2055
+ main.append(...view.childNodes);
2056
+ subnav = document.createElement('nav');
2057
+ subnav.className = 'subnav';
2058
+ subnav.innerHTML = `<button class="on" data-sub="${viewId}-main" data-order="0">${esc(registeredViews.find((v) => v.id === viewId)?.label ?? 'Main')}</button>`;
2059
+ view.append(subnav, main);
2060
+ }
2061
+ const btn = document.createElement('button');
2062
+ btn.dataset.sub = id;
2063
+ btn.dataset.order = String(order);
2064
+ btn.textContent = label;
2065
+ const after = [...subnav.querySelectorAll('button[data-sub]')].find((b) => Number(b.dataset.order ?? 50) > order);
2066
+ subnav.insertBefore(btn, after ?? null);
2067
+ const div = document.createElement('div');
2068
+ div.className = 'subview';
2069
+ div.id = id;
2070
+ div.innerHTML = html;
2071
+ view.appendChild(div);
2072
+ return div;
1570
2073
  }
1571
2074
 
1572
2075
  /* ── app map interaction — wheel zoom, drag pan, click/double-click ─────── */
@@ -1649,18 +2152,20 @@ if (new URLSearchParams(location.search).has('mini')) {
1649
2152
  document.getElementById('tv-dash').classList.remove('on');
1650
2153
  }
1651
2154
 
1652
- for (const b of document.querySelectorAll('.subnav button')) {
1653
- b.onclick = () => {
1654
- const nav = b.closest('.subnav');
1655
- nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
1656
- nav.parentElement.querySelectorAll(':scope > .subview')
1657
- .forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
1658
- render();
1659
- };
1660
- }
2155
+ document.addEventListener('click', (e) => {
2156
+ const b = e.target.closest?.('.subnav button[data-sub]');
2157
+ if (!b) return;
2158
+ const nav = b.closest('.subnav');
2159
+ nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
2160
+ nav.parentElement.querySelectorAll(':scope > .subview')
2161
+ .forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
2162
+ render();
2163
+ navigated();
2164
+ });
1661
2165
 
1662
2166
  /** Open a subtab under a main view — switches main tabs first if needed. */
1663
2167
  const openSub = (view, sub) => {
2168
+ if (!document.getElementById(`view-${view}`)) return;
1664
2169
  if (!document.getElementById(`view-${view}`).classList.contains('on')) {
1665
2170
  document.querySelector(`.panels > nav button[data-view="${view}"]`).click();
1666
2171
  }
@@ -1714,10 +2219,17 @@ let viewer = null;
1714
2219
  * BroadcastChannel the app publishes on. There is no server-side session
1715
2220
  * registry — this page builds it from hello/batch/bye frames and owns
1716
2221
  * retention (pins + TTL) itself.
2222
+ *
2223
+ * Extension mode (`__ATOLL_TRANSPORT === 'extension'`): the Chrome DevTools
2224
+ * panel (packages/devtools-extension) can't join the inspected page's
2225
+ * channel, so its bootstrap installs `__ATOLL_BRIDGE`, a BroadcastChannel-
2226
+ * shaped relay (postMessage / onmessage, plus onreset when the inspected
2227
+ * page navigates). Everything below treats it exactly like the channel.
1717
2228
  */
1718
- const BC = window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
2229
+ const BRIDGE = window.__ATOLL_TRANSPORT === 'extension' ? window.__ATOLL_BRIDGE ?? null : null;
2230
+ const BC = BRIDGE ?? (window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
1719
2231
  ? new BroadcastChannel('atoll-devtools')
1720
- : null;
2232
+ : null);
1721
2233
  // ?mini=1 — the overlay flyout: compact single-instance layout (CSS does
1722
2234
  // the hiding; the session sidebar is meaningless for one origin's app).
1723
2235
  if (new URLSearchParams(location.search).has('mini')) document.body.classList.add('mini');
@@ -1765,12 +2277,105 @@ const pinSession = (id, pinned) => {
1765
2277
  }
1766
2278
  };
1767
2279
 
2280
+ /**
2281
+ * The one ingest path — live transport frames and replayed recordings both
2282
+ * come through here, so panels see a single stream via api.hooks.
2283
+ */
2284
+ function ingest(session, events) {
2285
+ state.sessions.set(session.id, session);
2286
+ for (const e of events) {
2287
+ applyEvent(session, e);
2288
+ logLine(session, e);
2289
+ for (const h of api.hooks.event) h(session, e);
2290
+ // New runners / islands / contracts register new app-side commands.
2291
+ if (e.type === 'pool:init' || e.type === 'island:mount' || e.type === 'memory:bind') {
2292
+ refreshCommands(session.id);
2293
+ }
2294
+ }
2295
+ for (const h of api.hooks.batch) h(session, events);
2296
+ scheduleRender();
2297
+ }
2298
+
2299
+ /* ── control channel: dashboard → app commands ───────────────────────────── */
2300
+
2301
+ const pendingCtl = new Map(); // request id → { resolve, reject, timer }
2302
+
2303
+ /** Invoke a registered app-side command (`registerDevtoolsCommand`) on a live session. */
2304
+ function control(sessionId, cmd, args = {}, timeoutMs = 5000) {
2305
+ const id = `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2306
+ return new Promise((resolve, reject) => {
2307
+ const msg = { type: 'control', sessionId, id, cmd, args };
2308
+ if (BC) BC.postMessage(msg);
2309
+ else if (viewer?.readyState === WebSocket.OPEN) viewer.send(JSON.stringify(msg));
2310
+ else return reject(new Error('dashboard is not connected'));
2311
+ const timer = setTimeout(() => {
2312
+ pendingCtl.delete(id);
2313
+ reject(new Error(`${cmd}: no reply in ${timeoutMs}ms (app may predate the control channel)`));
2314
+ }, timeoutMs);
2315
+ pendingCtl.set(id, { resolve, reject, timer });
2316
+ });
2317
+ }
2318
+
2319
+ function settleControl(data) {
2320
+ const p = pendingCtl.get(data.id);
2321
+ if (!p) return; // another dashboard's request
2322
+ pendingCtl.delete(data.id);
2323
+ clearTimeout(p.timer);
2324
+ if (data.ok) p.resolve(data.result); else p.reject(new Error(data.error ?? 'command failed'));
2325
+ }
2326
+
2327
+ /** sid → Set of command names, from 'devtools.commands' (refreshed as runners appear). */
2328
+ const cmdCache = new Map();
2329
+ const cmdDirty = new Set();
2330
+ let cmdTimer = 0;
2331
+ function refreshCommands(sid) {
2332
+ if (state.sessions.get(sid)?.closed || api.livePaused) return;
2333
+ cmdDirty.add(sid);
2334
+ clearTimeout(cmdTimer);
2335
+ cmdTimer = setTimeout(() => {
2336
+ for (const s of cmdDirty) {
2337
+ control(s, 'devtools.commands', {}, 3000)
2338
+ .then((list) => { cmdCache.set(s, new Set(list)); scheduleRender(); })
2339
+ .catch(() => { /* app without the control channel: commands stay off */ });
2340
+ }
2341
+ cmdDirty.clear();
2342
+ }, 400);
2343
+ }
2344
+ const hasCommand = (sid, cmd) => cmdCache.get(sid)?.has(cmd) === true;
2345
+
2346
+ const STATE_INIT = { sel: null, drillTask: null, inspect: null, inspectWorker: null, inspectFetch: null, seq: 0, t0: null };
2347
+
2348
+ /** Clear every dashboard aggregate (the recorder's import/replay starts here). */
2349
+ function reset() {
2350
+ for (const v of Object.values(state)) {
2351
+ if (v instanceof Map) v.clear();
2352
+ else if (Array.isArray(v)) v.length = 0;
2353
+ }
2354
+ Object.assign(state, STATE_INIT);
2355
+ bcSessions.clear();
2356
+ cmdCache.clear();
2357
+ logEl().innerHTML = '';
2358
+ for (const h of api.hooks.reset) h();
2359
+ topo.fit = true;
2360
+ render();
2361
+ }
2362
+
2363
+ /** Ask live apps to re-identify and replay their tail (after a reset/unpause). */
2364
+ function reannounce() {
2365
+ if (BC) BC.postMessage({ type: 'view' });
2366
+ else viewer?.close(); // reconnect → the server replays its tail
2367
+ }
2368
+
1768
2369
  function connect() {
1769
2370
  if (BC) {
1770
- $('conn').textContent = 'local';
1771
- $('conn').className = 'on';
2371
+ if (!BRIDGE) { // the extension bridge owns #conn (page reachable, atoll seen, …)
2372
+ $('conn').textContent = 'local';
2373
+ $('conn').className = 'on';
2374
+ }
1772
2375
  BC.onmessage = (msg) => {
1773
2376
  const data = msg.data;
2377
+ if (data.type === 'control-result') return settleControl(data);
2378
+ if (api.livePaused) return;
1774
2379
  if (data.type === 'hello') {
1775
2380
  // a fresh hello means a new app instance — same id can't revive
1776
2381
  const prev = bcSessions.get(data.session.id);
@@ -1778,6 +2383,7 @@ function connect() {
1778
2383
  ...data.session, closed: false, closedAt: undefined, pinned: prev?.pinned,
1779
2384
  });
1780
2385
  bcSync();
2386
+ refreshCommands(data.session.id);
1781
2387
  } else if (data.type === 'bye') {
1782
2388
  const s = bcSessions.get(data.sessionId);
1783
2389
  if (s && !s.closed) { s.closed = true; s.closedAt = Date.now(); bcSync(); }
@@ -1786,14 +2392,12 @@ function connect() {
1786
2392
  bcSessions.set(data.session.id, data.session);
1787
2393
  bcSync();
1788
2394
  }
1789
- state.sessions.set(data.session.id, data.session);
1790
- for (const e of data.events) {
1791
- applyEvent(data.session, e);
1792
- logLine(data.session, e);
1793
- }
2395
+ ingest(data.session, data.events);
1794
2396
  }
1795
2397
  scheduleRender();
1796
2398
  };
2399
+ // inspected page navigated: its sessions are gone, start over and re-announce
2400
+ if (BRIDGE) BRIDGE.onreset = () => { reset(); reannounce(); };
1797
2401
  // announce — apps re-hello and replay their batch tail
1798
2402
  BC.postMessage({ type: 'view' });
1799
2403
  return;
@@ -1808,16 +2412,87 @@ function connect() {
1808
2412
  };
1809
2413
  ws.onmessage = (msg) => {
1810
2414
  const data = JSON.parse(msg.data);
2415
+ if (data.type === 'control-result') return settleControl(data);
2416
+ if (api.livePaused) return;
1811
2417
  if (data.type === 'sessions') {
1812
2418
  reconcileSessions(data.sessions);
2419
+ for (const s of data.sessions) if (!s.closed && !cmdCache.has(s.id)) refreshCommands(s.id);
1813
2420
  } else if (data.type === 'batch') {
1814
- state.sessions.set(data.session.id, data.session);
1815
- for (const e of data.events) {
1816
- applyEvent(data.session, e);
1817
- logLine(data.session, e);
1818
- }
2421
+ ingest(data.session, data.events);
1819
2422
  }
1820
2423
  scheduleRender();
1821
2424
  };
1822
2425
  }
2426
+
2427
+ /* ── panels ──────────────────────────────────────────────────────────────── */
2428
+
2429
+ const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
2430
+
2431
+ Object.assign(api, {
2432
+ state, $, esc, fmt, fmtBytes, fmtInt, key, sessOf, inSel, withGroups, kpi,
2433
+ sparkline, drawLine, drawMultiLine, fwIconImg,
2434
+ render, scheduleRender, addView, addSubview, openView, openSub,
2435
+ control, hasCommand, ingest, reset, reannounce,
2436
+ liveSessions: () => [...state.sessions.values()].filter((s) => !s.closed),
2437
+ /** The selected session, else the only live one, else null (ambiguous). */
2438
+ selectedOrOnlyLive: () => {
2439
+ if (state.sel) return state.sel;
2440
+ const live = [...state.sessions.values()].filter((s) => !s.closed);
2441
+ return live.length === 1 ? live[0].id : null;
2442
+ },
2443
+ isMini: new URLSearchParams(location.search).has('mini'),
2444
+ transport: BRIDGE ? 'extension' : BC ? 'broadcast' : 'ws',
2445
+ livePaused: false,
2446
+ /** () after every view/subview switch (openView, subnav clicks, openSub). */
2447
+ onNavigate(fn) { (api.hooks.navigate ??= []).push(fn); },
2448
+ /** viewId → intro html; the shell panel renders these under each view heading. */
2449
+ viewIntros: new Map(),
2450
+ /** One-line, dismissible "what this view shows" intro (callable before the shell loads). */
2451
+ setViewIntro(viewId, html) {
2452
+ api.viewIntros.set(viewId, html);
2453
+ for (const h of api.hooks.intro ?? []) h(viewId, html);
2454
+ },
2455
+ /** Transient message: kind 'ok' | 'error' | 'info' (panels/toast.js). */
2456
+ toast: (msg, kind, ms) => import('./panels/toast.js').then((m) => m.toast(msg, kind, ms)),
2457
+ });
2458
+
2459
+ /**
2460
+ * Header toolbar button (global actions: record, export, palette). Returns
2461
+ * the button; `order` sorts left→right.
2462
+ */
2463
+ api.addToolbarButton = ({ id, label, title, order = 50, onClick }) => {
2464
+ const bar = $('toolbar');
2465
+ const b = document.createElement('button');
2466
+ b.className = 'act';
2467
+ b.id = id;
2468
+ b.dataset.order = String(order);
2469
+ b.innerHTML = label;
2470
+ if (title) b.title = title;
2471
+ b.onclick = onClick;
2472
+ const after = [...bar.children].find((c) => Number(c.dataset.order ?? 50) > order);
2473
+ bar.insertBefore(b, after ?? null);
2474
+ return b;
2475
+ };
2476
+
2477
+ /** Feature panels: each module exports setup(api). Order = setup order. */
2478
+ const PANELS = [
2479
+ './panels/performance.js', // jank, frames, User Timing, message cost
2480
+ './panels/inspect-island.js',// island Elements / Props & events tabs
2481
+ './panels/memory-values.js', // shared-memory values, diff, watchpoints
2482
+ './panels/reactivity.js', // cross-thread reactivity graph
2483
+ './panels/actions.js', // live controls: kill, chaos, props, mode
2484
+ './panels/recorder.js', // record / export / import / replay
2485
+ './panels/audits.js', // recommendations
2486
+ './panels/shell.js', // palette, routing, shortcuts, help (load last)
2487
+ ];
2488
+
2489
+ for (const p of PANELS) {
2490
+ try {
2491
+ const mod = await import(p);
2492
+ mod.setup(api);
2493
+ } catch (err) {
2494
+ console.error(`[atoll devtools] panel ${p} failed to load`, err);
2495
+ }
2496
+ }
2497
+
1823
2498
  connect();