@atolljs/devtools 0.1.7 → 0.1.8

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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
package/app/main.js CHANGED
@@ -5,6 +5,10 @@
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';
8
12
 
9
13
  const $ = (id) => document.getElementById(id);
10
14
  const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
@@ -56,8 +60,10 @@ const MAX_ALERTS = 60;
56
60
 
57
61
  /* ── event reducer ───────────────────────────────────────────────────────── */
58
62
 
59
- const bump = (sid, metric, at, n = 1) => {
60
- const k = `${sid}|${metric}|${Math.floor(at / 1000)}`;
63
+ /** Per-second buckets on the dashboard clock: event `at` epochs differ per thread. */
64
+ const dashSec = () => Math.floor(performance.now() / 1000);
65
+ const bump = (sid, metric, _at, n = 1) => {
66
+ const k = `${sid}|${metric}|${dashSec()}`;
61
67
  state.series.set(k, (state.series.get(k) ?? 0) + n);
62
68
  };
63
69
 
@@ -69,7 +75,7 @@ function applyEvent(sess, e) {
69
75
  state.pools.set(key(sid, e.poolId), {
70
76
  poolId: e.poolId, label: e.label ?? e.poolId, size: e.poolSize,
71
77
  memoryBytes: e.memoryBytes, workers: new Set(), tasks: 0, failed: 0,
72
- dead: sess.closed === true,
78
+ dedicated: e.dedicated === true, dead: sess.closed === true,
73
79
  });
74
80
  break;
75
81
  case 'pool:terminate': {
@@ -141,7 +147,9 @@ function applyEvent(sess, e) {
141
147
  }
142
148
  }
143
149
  }
144
- const sec = Math.floor(e.at / 1000);
150
+ // Dashboard clock, like heapSeries: emit threads (and sessions) have
151
+ // unrelated performance.now() epochs, so `e.at` can't share an axis.
152
+ const sec = Math.floor(performance.now() / 1000);
145
153
  const bk = key(sid, sec);
146
154
  const b = state.tput.get(bk) ?? { n: 0, err: 0 };
147
155
  b.n++; if (e.outcome !== 'ok') b.err++;
@@ -160,7 +168,7 @@ function applyEvent(sess, e) {
160
168
  m.writes++; m.version = e.version; m.thread = e.thread; m.lastAt = e.at;
161
169
  // worker:* forwarded events carry slot attribution — pin the writer.
162
170
  if (e.worker) m.writer = `${e.worker.poolId}#${e.worker.slot}`;
163
- const sec = Math.floor(e.at / 1000);
171
+ const sec = dashSec();
164
172
  m.buckets.set(sec, (m.buckets.get(sec) ?? 0) + 1);
165
173
  if (m.buckets.size > 120) m.buckets.delete([...m.buckets.keys()][0]);
166
174
  state.mem.set(k, m);
@@ -235,7 +243,7 @@ function applyEvent(sess, e) {
235
243
  // filter, or worker-side performance.memory) stamped to the slot.
236
244
  const w = getWorker(sid, e.worker.poolId, e.worker.slot, e.at);
237
245
  w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes; w.lastAt = e.at;
238
- const sec = Math.floor(e.at / 1000);
246
+ const sec = dashSec();
239
247
  (w.heapSeries ??= new Map()).set(sec, e.heapBytes / 1048576); // MB for the chart
240
248
  if (w.heapSeries.size > 120) w.heapSeries.delete([...w.heapSeries.keys()][0]);
241
249
  }
@@ -306,6 +314,7 @@ function reconcileSessions(live) {
306
314
  if (state.sel && !ids.has(state.sel)) state.sel = null;
307
315
  if (state.inspect && !state.islands.has(state.inspect)) state.inspect = null;
308
316
  if (state.inspectWorker && !state.workers.has(state.inspectWorker)) state.inspectWorker = null;
317
+ for (const h of api.hooks.reconcile) h(ids);
309
318
  }
310
319
 
311
320
  /**
@@ -651,30 +660,78 @@ function drawThroughput() {
651
660
  const canvas = $('tput');
652
661
  const ctx = canvas.getContext('2d');
653
662
  const w = (canvas.width = canvas.clientWidth || 560);
654
- const h = (canvas.height = 80);
663
+ const h = (canvas.height = 110);
655
664
  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) {
665
+ const statsEl = $('tput-stats');
666
+ if (state.t0 === null) { statsEl.textContent = ''; return; }
667
+ // Buckets are on the dashboard clock: sum sessions per second, window the
668
+ // last 60s ending now (an idle stretch shows as empty seconds).
669
+ const bySec = new Map();
670
+ for (const [k, b] of state.tput) {
671
+ if (!inSel(k)) continue;
665
672
  const sec = Number(k.split('|')[1]);
666
- const x = (sec - lo) * (w / Math.max(span, 60));
667
- const bh = (b.n / max) * (h - 4);
673
+ const s = bySec.get(sec) ?? { n: 0, err: 0 };
674
+ s.n += b.n; s.err += b.err;
675
+ bySec.set(sec, s);
676
+ }
677
+ if (!bySec.size) { statsEl.textContent = 'no settled tasks yet'; return; }
678
+ const hi = Math.floor(performance.now() / 1000);
679
+ const lo = hi - 59;
680
+ const win = [...bySec].filter(([s]) => s >= lo);
681
+ const peak = Math.max(...win.map(([, b]) => b.n), 0);
682
+ const total = win.reduce((a, [, b]) => a + b.n, 0);
683
+ const errs = win.reduce((a, [, b]) => a + b.err, 0);
684
+ const first = win.length ? Math.min(...win.map(([s]) => s)) : hi;
685
+ const latest = bySec.get(hi - 1)?.n ?? 0;
686
+ statsEl.textContent =
687
+ `latest ${fmtN(latest)}/s · peak ${fmtN(peak)}/s · avg ${fmtN(total / (hi - first + 1))}/s · ` +
688
+ `${fmtN(total)} settled in 60s` + (errs ? ` · ${fmtN(errs)} failed` : '');
689
+
690
+ const scale = niceCeil(Math.max(peak, 1));
691
+ const L = 40, B = 16, T = 6;
692
+ const pw = w - L - 4, ph = h - B - T;
693
+ const bw = pw / 60;
694
+ ctx.font = '10px ui-monospace, monospace';
695
+ ctx.textBaseline = 'middle';
696
+ ctx.textAlign = 'right';
697
+ for (const f of [0, 0.5, 1]) {
698
+ const y = T + ph - f * ph;
699
+ ctx.strokeStyle = f === 0 ? '#30363d' : '#21262d';
700
+ ctx.lineWidth = 1;
701
+ ctx.beginPath(); ctx.moveTo(L, Math.round(y) + 0.5); ctx.lineTo(w - 4, Math.round(y) + 0.5); ctx.stroke();
702
+ ctx.fillStyle = '#8b949e';
703
+ ctx.fillText(fmtN(scale * f), L - 6, y);
704
+ }
705
+ ctx.textBaseline = 'top';
706
+ for (const [ago, align] of [[60, 'left'], [30, 'center'], [0, 'right']]) {
707
+ ctx.textAlign = align;
708
+ ctx.fillText(ago ? `-${ago}s` : 'now', L + pw * (1 - ago / 60), T + ph + 4);
709
+ }
710
+ for (const [sec, b] of win) {
711
+ const x = L + (sec - lo) * bw;
712
+ const bh = (b.n / scale) * ph;
668
713
  ctx.fillStyle = '#58a6ff';
669
- ctx.fillRect(x, h - bh, Math.max(1, bw - 1), bh);
714
+ ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), bh);
670
715
  if (b.err) {
671
- const eh = (b.err / max) * (h - 4);
672
716
  ctx.fillStyle = '#f85149';
673
- ctx.fillRect(x, h - bh, Math.max(1, bw - 1), eh);
717
+ ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), (b.err / scale) * ph);
674
718
  }
675
719
  }
676
720
  }
677
721
 
722
+ /** Round up to 1, 2 or 5 × 10^k, so axis ticks land on readable numbers. */
723
+ function niceCeil(v) {
724
+ const p = 10 ** Math.floor(Math.log10(v));
725
+ return [1, 2, 5, 10].find((m) => m * p >= v) * p;
726
+ }
727
+
728
+ /** Compact count: 950, 1.2k, 3.4M. */
729
+ function fmtN(v) {
730
+ if (v >= 1e6) return `${+(v / 1e6).toFixed(1)}M`;
731
+ if (v >= 1e3) return `${+(v / 1e3).toFixed(1)}k`;
732
+ return v >= 10 || Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1);
733
+ }
734
+
678
735
  /* ── tasks: waterfall + aggregates + table ───────────────────────────────── */
679
736
 
680
737
  function visibleCalls() {
@@ -987,6 +1044,8 @@ function renderInspector() {
987
1044
  <td class="${t.error ? 'error' : 'ok'}">${t.error ? 'error' : 'ok'}</td>
988
1045
  <td class="muted">${esc(t.error ?? '')}</td></tr>`)
989
1046
  .join('') || '<tr><td colspan="6" class="muted">no calls yet</td></tr>';
1047
+
1048
+ for (const h of api.hooks.inspectIsland) h(state.inspect, i, { extra: $('inspExtra'), actions: $('inspActions') });
990
1049
  }
991
1050
 
992
1051
  function drawOpTraffic(i) {
@@ -1013,20 +1072,225 @@ function drawOpTraffic(i) {
1013
1072
 
1014
1073
  const logEl = () => $('log');
1015
1074
  const pad = (n, w = 2) => String(n).padStart(w, '0');
1016
- function logLine(sess, e) {
1075
+
1076
+ /*
1077
+ * Event log: one DOM line per event, appended incrementally (never a full
1078
+ * re-render). Filters flip `hidden` on existing lines. Lines are grouped
1079
+ * for the chips by event-type prefix ('task', 'island', …), and `log`
1080
+ * events by level ('log.warn'). reset() empties #log; the reset hook
1081
+ * below drops the matching records.
1082
+ */
1083
+ const LOG_CAP = 2000;
1084
+ const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
1085
+ const LOG_LEVEL_COLOR = { trace: '#484f58', debug: '#8b949e', info: '#58a6ff', warn: '#d29922', error: '#f85149' };
1086
+ const logView = {
1087
+ recs: [], // [{ sid, group, hay, el, vis, sess, e }] oldest first, all in #log
1088
+ held: [], // records ingested while paused, appended on resume
1089
+ groups: new Map(), // group → on (bool)
1090
+ sessions: new Set(),
1091
+ q: '',
1092
+ sess: '',
1093
+ paused: false,
1094
+ hidden: 0,
1095
+ };
1096
+ const logGroupOf = (e) => (e.type === 'log' ? `log.${e.level}` : e.type.split(':')[0]);
1097
+ const logMatch = (r) =>
1098
+ logView.groups.get(r.group) !== false &&
1099
+ (!logView.sess || r.sid === logView.sess) &&
1100
+ (!logView.q || r.hay.includes(logView.q));
1101
+
1102
+ function logStatus() {
1103
+ const s = $('logstat');
1104
+ if (!s) return;
1105
+ const parts = [`${logView.recs.length} lines`];
1106
+ if (logView.hidden) parts.push(`${logView.hidden} hidden by filters`);
1107
+ if (logView.paused) parts.push(`${logView.held.length} held while paused`);
1108
+ s.textContent = parts.join(' · ');
1109
+ }
1110
+
1111
+ function logRefilter() {
1112
+ logView.hidden = 0;
1113
+ for (const r of logView.recs) {
1114
+ r.vis = logMatch(r);
1115
+ r.el.hidden = !r.vis;
1116
+ if (!r.vis) logView.hidden++;
1117
+ }
1118
+ logStatus();
1119
+ const el = logEl();
1120
+ el.scrollTop = el.scrollHeight;
1121
+ }
1122
+
1123
+ function logChip(group) {
1124
+ if (logView.groups.has(group)) return;
1125
+ logView.groups.set(group, true);
1126
+ const bar = $('logchips');
1127
+ if (!bar) return;
1128
+ const b = document.createElement('button');
1129
+ b.className = 'on';
1130
+ b.dataset.g = group;
1131
+ const lvl = group.startsWith('log.') ? group.slice(4) : null;
1132
+ b.innerHTML = lvl ? `<i style="background:${LOG_LEVEL_COLOR[lvl]}"></i>${esc(lvl)}` : esc(group);
1133
+ b.title = lvl ? `log entries at ${lvl}` : `${group}:* events`;
1134
+ // levels first (in severity order), then event groups alphabetically
1135
+ const rank = (g) => (g.startsWith('log.') ? LOG_LEVELS.indexOf(g.slice(4)) : 10);
1136
+ const after = [...bar.querySelectorAll('button[data-g]')].find(
1137
+ (x) => rank(x.dataset.g) > rank(group) || (rank(x.dataset.g) === rank(group) && x.dataset.g > group),
1138
+ );
1139
+ bar.insertBefore(b, after ?? null);
1140
+ }
1141
+
1142
+ function logSessOption(sess) {
1143
+ if (logView.sessions.has(sess.id)) return;
1144
+ logView.sessions.add(sess.id);
1145
+ const sel = $('logsess');
1146
+ if (!sel) return;
1147
+ const o = document.createElement('option');
1148
+ o.value = sess.id;
1149
+ o.textContent = sess.name ?? sess.id;
1150
+ sel.appendChild(o);
1151
+ sel.style.display = logView.sessions.size > 1 ? '' : 'none';
1152
+ }
1153
+
1154
+ function logRender(sess, e) {
1017
1155
  const div = document.createElement('div');
1018
1156
  const { type, at, thread, ...rest } = e;
1019
1157
  // Wall-clock is dashboard receive time — emit-side `at` is a per-thread
1020
1158
  // performance.now() epoch, so it's shown as elapsed instead.
1021
1159
  const now = new Date();
1022
1160
  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>`;
1161
+ const origin = e.worker ? ` ${e.worker.poolId}#${e.worker.slot}` : thread === 'worker' ? ' ⧉' : '';
1162
+ const head = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${esc(origin)}</span> `;
1163
+ let body, hay;
1164
+ if (type === 'log') {
1165
+ div.className = `ll-${e.level}`;
1166
+ body = `<b>[${esc(e.level)}]</b> ${esc(e.scope)}: ${esc(e.message)}${e.data !== undefined ? ' <span class="logx">▸ data</span>' : ''}`;
1167
+ hay = `log ${e.level} ${e.scope} ${e.message} ${e.data ?? ''}`;
1168
+ } else {
1169
+ const json = JSON.stringify(rest);
1170
+ const bad = type === 'worker:error' || (type === 'task:settle' && e.outcome !== 'ok');
1171
+ if (bad) div.className = 'll-error';
1172
+ body = `${esc(type)} <span class="muted">${esc(json.length > 400 ? `${json.slice(0, 400)}…` : json)}</span>`;
1173
+ hay = `${type} ${json}`;
1174
+ }
1175
+ div.innerHTML = `${head}${body} <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
1176
+ return { sid: sess.id, group: logGroupOf(e), hay: `${sess.name ?? sess.id} ${hay}`.toLowerCase(), el: div, vis: true, sess, e };
1177
+ }
1178
+
1179
+ function logAppend(batch) {
1024
1180
  const el = logEl();
1025
- el.appendChild(div);
1026
- while (el.children.length > 500) el.firstChild.remove();
1027
- el.scrollTop = el.scrollHeight;
1181
+ const stick = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
1182
+ const frag = document.createDocumentFragment();
1183
+ for (const r of batch) {
1184
+ r.vis = logMatch(r);
1185
+ r.el.hidden = !r.vis;
1186
+ if (!r.vis) logView.hidden++;
1187
+ frag.appendChild(r.el);
1188
+ logView.recs.push(r);
1189
+ }
1190
+ el.appendChild(frag);
1191
+ const over = logView.recs.length - LOG_CAP;
1192
+ if (over > 0) {
1193
+ for (const r of logView.recs.splice(0, over)) {
1194
+ if (!r.vis) logView.hidden--;
1195
+ r.el.remove();
1196
+ }
1197
+ }
1198
+ if (stick) el.scrollTop = el.scrollHeight;
1199
+ logStatus();
1028
1200
  }
1029
1201
 
1202
+ function logLine(sess, e) {
1203
+ logSessOption(sess);
1204
+ logChip(logGroupOf(e));
1205
+ const r = logRender(sess, e);
1206
+ if (logView.paused) {
1207
+ logView.held.push(r);
1208
+ if (logView.held.length > LOG_CAP) logView.held.shift();
1209
+ logStatus();
1210
+ return;
1211
+ }
1212
+ logAppend([r]);
1213
+ }
1214
+
1215
+ function logClear() {
1216
+ logView.recs.length = 0;
1217
+ logView.held.length = 0;
1218
+ logView.hidden = 0;
1219
+ logEl().innerHTML = '';
1220
+ logStatus();
1221
+ }
1222
+
1223
+ api.onReset(() => {
1224
+ logClear();
1225
+ logView.sessions.clear();
1226
+ const sel = $('logsess');
1227
+ if (sel) { sel.length = 1; sel.value = ''; sel.style.display = 'none'; }
1228
+ logView.sess = '';
1229
+ });
1230
+
1231
+ (function logControls() {
1232
+ const chips = $('logchips');
1233
+ if (!chips) return;
1234
+ chips.addEventListener('click', (ev) => {
1235
+ const b = ev.target.closest('button');
1236
+ if (!b) return;
1237
+ if (b.dataset.all !== undefined) {
1238
+ const on = b.dataset.all === '1';
1239
+ for (const g of logView.groups.keys()) logView.groups.set(g, on);
1240
+ for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', on);
1241
+ } else if (b.dataset.g) {
1242
+ const g = b.dataset.g;
1243
+ // alt/shift-click: solo this group
1244
+ if (ev.altKey || ev.shiftKey) {
1245
+ for (const k of logView.groups.keys()) logView.groups.set(k, k === g);
1246
+ for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', x.dataset.g === g);
1247
+ } else {
1248
+ const on = !(logView.groups.get(g) ?? true);
1249
+ logView.groups.set(g, on);
1250
+ b.classList.toggle('on', on);
1251
+ }
1252
+ } else return;
1253
+ logRefilter();
1254
+ });
1255
+ let qTimer = 0;
1256
+ $('logq').addEventListener('input', (ev) => {
1257
+ clearTimeout(qTimer);
1258
+ qTimer = setTimeout(() => { logView.q = ev.target.value.trim().toLowerCase(); logRefilter(); }, 150);
1259
+ });
1260
+ $('logsess').addEventListener('change', (ev) => { logView.sess = ev.target.value; logRefilter(); });
1261
+ $('logpause').addEventListener('click', (ev) => {
1262
+ logView.paused = !logView.paused;
1263
+ ev.target.textContent = logView.paused ? 'resume' : 'pause';
1264
+ ev.target.classList.toggle('on', logView.paused);
1265
+ if (!logView.paused && logView.held.length) logAppend(logView.held.splice(0));
1266
+ logStatus();
1267
+ });
1268
+ $('logclear').addEventListener('click', logClear);
1269
+ // Click a line to expand its full payload (log data, or the event JSON).
1270
+ logEl().addEventListener('click', (ev) => {
1271
+ const line = ev.target.closest('#log > div');
1272
+ if (!line) return;
1273
+ const open = line.querySelector(':scope > pre');
1274
+ if (open) { open.remove(); line.classList.remove('open'); return; }
1275
+ const r = logView.recs.find((x) => x.el === line);
1276
+ if (!r) return;
1277
+ const { e } = r;
1278
+ let text;
1279
+ if (e.type === 'log') {
1280
+ try { text = e.data !== undefined ? JSON.stringify(JSON.parse(e.data), null, 2) : '(no data)'; }
1281
+ catch { text = e.data; }
1282
+ } else {
1283
+ const { type, ...rest } = e;
1284
+ text = `${type}\n${JSON.stringify(rest, null, 2)}`;
1285
+ }
1286
+ const pre = document.createElement('pre');
1287
+ pre.className = 'body';
1288
+ pre.textContent = text;
1289
+ line.appendChild(pre);
1290
+ line.classList.add('open');
1291
+ });
1292
+ })();
1293
+
1030
1294
  /* ── app map — one world-space canvas; zoom/pan + zoom-tier clustering ───── */
1031
1295
 
1032
1296
  const APP_COLORS = ['#3fb950', '#f0883e', '#a371f7', '#79c0ff', '#d29922', '#f85149', '#56d4dd', '#e275ad'];
@@ -1046,6 +1310,21 @@ function renderTopology() {
1046
1310
  window.__topo = topo; // exposed for tests/debugging
1047
1311
  }
1048
1312
 
1313
+ /**
1314
+ * The worker that spawned a qualified pool: 'h1#s1~h2#s2~local' is a pool
1315
+ * minted inside worker h2#s2, whose own (qualified) pool is 'h1#s1~h2'.
1316
+ * Returns the `${sess}|${poolId}|${slot}` key or null for top-level ids.
1317
+ */
1318
+ function parentWorkerKey(sid, qpid) {
1319
+ const segs = qpid.split('~');
1320
+ if (segs.length < 2) return null;
1321
+ const host = segs[segs.length - 2];
1322
+ const hi = host.lastIndexOf('#');
1323
+ if (hi < 0) return null;
1324
+ const parentPool = [...segs.slice(0, -2), host.slice(0, hi)].join('~');
1325
+ return `${sid}|${parentPool}|${host.slice(hi + 1)}`;
1326
+ }
1327
+
1049
1328
  /** World-space layout: each session is one atoll cluster in a loose grid. */
1050
1329
  function layoutWorld(sids) {
1051
1330
  const clusters = [];
@@ -1064,23 +1343,39 @@ function layoutWorld(sids) {
1064
1343
  } else orphans.push({ ik, i });
1065
1344
  }
1066
1345
  // rim positions belong to pools, not workers — a size-N pool is one
1067
- // socket containing N worker dots
1346
+ // socket containing N worker dots. Nested pools (poolIds qualified as
1347
+ // 'hostPool#slot~localPool' by sub-worker forwarding) don't take rim
1348
+ // sockets — they hang off their host worker instead.
1068
1349
  const pools = [];
1350
+ const nested = new Map(); // parent worker key → [pool group]
1069
1351
  const byPool = new Map();
1070
1352
  for (const w of workers) {
1071
1353
  const pk = w[0].slice(0, w[0].lastIndexOf('|'));
1072
1354
  let g = byPool.get(pk);
1073
1355
  if (!g) {
1074
- g = { pk, poolId: pk.split('|')[1], workers: [] };
1356
+ g = {
1357
+ pk, poolId: pk.split('|')[1], workers: [], nested: pk.split('|')[1].includes('~'),
1358
+ dedicated: state.pools.get(pk)?.dedicated === true,
1359
+ };
1075
1360
  byPool.set(pk, g);
1076
1361
  pools.push(g);
1077
1362
  }
1078
1363
  g.workers.push(w);
1079
1364
  }
1365
+ for (const g of [...pools]) {
1366
+ if (!g.nested) continue;
1367
+ pools.splice(pools.indexOf(g), 1);
1368
+ const pkey = parentWorkerKey(sid, g.poolId);
1369
+ const l = nested.get(pkey) ?? [];
1370
+ if (!l.length) nested.set(pkey, l);
1371
+ l.push(g);
1372
+ }
1080
1373
  const rimN = Math.max(pools.length + orphans.length, 1);
1081
1374
  const rimR = Math.max(110, rimN * 15);
1082
- maxR = Math.max(maxR, rimR);
1083
- clusters.push({ sid, workers, pools, islands, hosted, orphans, rimN, rimR });
1375
+ // nested worker clusters hang ~120 world units outside the rim
1376
+ // (worker ring + sub-worker orbit + labels)
1377
+ maxR = Math.max(maxR, rimR + (nested.size ? 120 : 0));
1378
+ clusters.push({ sid, workers, pools, islands, hosted, orphans, nested, rimN, rimR });
1084
1379
  }
1085
1380
  const cell = maxR * 2 + 280;
1086
1381
  const cols = Math.min(Math.max(clusters.length, 1), 4);
@@ -1110,8 +1405,30 @@ const FW_BADGES = {
1110
1405
  leaflet: ['#19993b', 'L'],
1111
1406
  };
1112
1407
 
1408
+ /** Structural (non-island) nodes — a lettered disc, so pools and plain
1409
+ * workers never read as a framework island. */
1410
+ const ROLE_MARKS = {
1411
+ pool: { letter: 'P', fill: '#21262d', ring: '#8b949e88', text: '#8b949e' },
1412
+ worker: { letter: 'W', fill: '#30363d', ring: '#8b949e', text: '#c9d1d9' },
1413
+ subworker: { letter: 'W', fill: '#a371f733', ring: '#a371f7', text: '#d2a8ff' },
1414
+ };
1415
+
1416
+ function drawRoleMark(ctx, m, x, y, s, dead) {
1417
+ ctx.save();
1418
+ if (dead) ctx.globalAlpha = 0.35;
1419
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1420
+ ctx.fillStyle = m.fill; ctx.fill();
1421
+ ctx.strokeStyle = m.ring; ctx.lineWidth = s * 0.14; ctx.stroke();
1422
+ ctx.font = `bold ${s * 1.05}px ui-monospace, monospace`;
1423
+ ctx.fillStyle = m.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1424
+ ctx.fillText(m.letter, x, y + s * 0.06);
1425
+ ctx.restore();
1426
+ ctx.textBaseline = 'top';
1427
+ }
1428
+
1113
1429
  /** Real framework marks, drawn at icon size (s ≈ node radius). */
1114
1430
  function drawFwMark(ctx, fw, x, y, s, dead) {
1431
+ if (ROLE_MARKS[fw]) return drawRoleMark(ctx, ROLE_MARKS[fw], x, y, s, dead);
1115
1432
  ctx.save();
1116
1433
  if (dead) ctx.globalAlpha = 0.35;
1117
1434
  switch (fw) {
@@ -1204,18 +1521,27 @@ function drawFwMark(ctx, fw, x, y, s, dead) {
1204
1521
  ctx.textBaseline = 'top';
1205
1522
  }
1206
1523
 
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;
1524
+ /** The island node — the framework mark IS the dot. An untagged island
1525
+ * isn't a framework island, so it reads as its worker: a W (`role`
1526
+ * picks the nested variant). Ended islands dim. Returns the hit radius. */
1527
+ function drawIslandDot(ctx, i, x, y, inv, role = 'worker') {
1528
+ drawFwMark(ctx, i.fw?.toLowerCase() || role, x, y, 12 * inv, i.ended);
1529
+ return 12 * inv;
1530
+ }
1531
+
1532
+ /** The session hub (main thread): the shell's framework mark in a violet
1533
+ * ring when the session reports one (`initDevtools({ session: { framework } })`),
1534
+ * else the plain violet disc. */
1535
+ function drawHub(ctx, sess, x, y, r, inv) {
1536
+ if (sess?.framework) {
1537
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
1538
+ ctx.fillStyle = '#0d1117'; ctx.fill();
1539
+ ctx.strokeStyle = '#a371f7'; ctx.lineWidth = 2 * inv; ctx.stroke();
1540
+ drawFwMark(ctx, sess.framework.toLowerCase(), x, y, r * 0.72, sess.closed === true);
1541
+ } else {
1542
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
1543
+ ctx.fillStyle = '#a371f7'; ctx.fill();
1214
1544
  }
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
1545
  }
1220
1546
 
1221
1547
  function drawAppMap(canvas, sids) {
@@ -1253,8 +1579,7 @@ function drawAppMap(canvas, sids) {
1253
1579
  const a = (j / Math.max(cl.islands.length, 1)) * 2 * Math.PI - Math.PI / 2;
1254
1580
  drawIslandDot(ctx, i, cx + r * Math.cos(a), cy + r * Math.sin(a), mi);
1255
1581
  });
1256
- ctx.beginPath(); ctx.arc(cx, cy, 8 * inv, 0, 7);
1257
- ctx.fillStyle = '#a371f7'; ctx.fill();
1582
+ drawHub(ctx, sess, cx, cy, 8 * inv, inv);
1258
1583
  ctx.font = `${11 * inv}px ui-monospace, monospace`;
1259
1584
  ctx.fillStyle = '#c9d1d9';
1260
1585
  ctx.fillText(sess?.name ?? cl.sid, cx, cy + 14 * inv);
@@ -1264,11 +1589,11 @@ function drawAppMap(canvas, sids) {
1264
1589
  continue;
1265
1590
  }
1266
1591
 
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
1592
+ // outer ring — workers; an island-hosting worker IS its island(s)
1593
+ const workerR = cl.rimR + 38;
1594
+ // orbit lines — one per level, so pool/worker nodes sit ON them
1270
1595
  ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv;
1271
- for (const r of [cl.rimR, workerR, islandR]) {
1596
+ for (const r of [cl.rimR, workerR]) {
1272
1597
  ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.stroke();
1273
1598
  }
1274
1599
 
@@ -1279,83 +1604,248 @@ function drawAppMap(canvas, sids) {
1279
1604
  ctx.strokeStyle = '#21262d';
1280
1605
  ctx.lineWidth = 1 * inv;
1281
1606
  for (const p of ppos) {
1607
+ if (p.dedicated) continue; // its spoke runs to the worker, drawn with it
1282
1608
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); ctx.stroke();
1283
1609
  }
1284
1610
 
1285
1611
  const font = (px) => `${px * inv}px ui-monospace, monospace`;
1612
+
1613
+ const islandName = (ik) => ik.split('|').slice(1).join('|');
1614
+
1615
+ const anchors = new Map(); // island key → its node { x, y, r }: spawn links start here
1616
+ const badges = new Map(); // worker key → the W badge on a shared worker's ring
1617
+
1618
+ /** Untagged instance inside a shared worker — hollow disc + the app's initial. */
1619
+ const instanceMark = (ik) => ({
1620
+ letter: islandName(ik).split('~').pop()[0] ?? '?',
1621
+ fill: '#3fb9501f', ring: '#3fb950', text: '#3fb950',
1622
+ });
1623
+
1624
+ /** A label pushed outward from (x, y) along angle th, aligned to its side. */
1625
+ const labelOut = (text, x, y, th, dist, color) => {
1626
+ const c = Math.cos(th), s = Math.sin(th);
1627
+ ctx.save();
1628
+ ctx.font = font(10); ctx.fillStyle = color;
1629
+ ctx.textAlign = c > 0.35 ? 'left' : c < -0.35 ? 'right' : 'center';
1630
+ ctx.textBaseline = s > 0.35 ? 'top' : s < -0.35 ? 'bottom' : 'middle';
1631
+ ctx.fillText(text, x + dist * c, y + dist * s);
1632
+ ctx.restore();
1633
+ };
1634
+
1635
+ /**
1636
+ * A worker hosting ONE island is that island: its mark (or a W when
1637
+ * untagged) sits on the worker's spot. A worker hosting several
1638
+ * (one shared client) is a container: a ring with a W badge for the
1639
+ * worker itself, one node per instance inside (framework mark, or a
1640
+ * hollow initial when untagged), each labeled outward. Every instance
1641
+ * registers in `anchors` so a sub-island's spawn link starts at the
1642
+ * instance that spawned it. `ring` colors the sub-worker variant.
1643
+ * Returns the drawn radius.
1644
+ */
1645
+ const drawIslandWorker = (k, isl, x, y, ring, wid) => {
1646
+ const w = state.workers.get(k);
1647
+ const n = isl.length;
1648
+ const m = 12 * inv;
1649
+ const lc = ring ? '#a371f7' : '#8b949e';
1650
+ if (n === 1) {
1651
+ const { ik, i } = isl[0];
1652
+ drawIslandDot(ctx, i, x, y, inv, ring ? 'subworker' : 'worker');
1653
+ nodes.push({ x, y, r: m + 4 * inv, kind: 'island', key: ik, wk: k });
1654
+ anchors.set(ik, { x, y, r: m });
1655
+ let R = m;
1656
+ if (ring) {
1657
+ R += 3 * inv;
1658
+ ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
1659
+ ctx.strokeStyle = ring; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1660
+ }
1661
+ if (full) labelWorker(isl, wid, x, y, R, lc);
1662
+ return R;
1663
+ }
1664
+ const cr = (m + 4 * inv) / Math.sin(Math.PI / n);
1665
+ const R = cr + m + 5 * inv;
1666
+ ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
1667
+ ctx.fillStyle = '#0d111799'; ctx.fill();
1668
+ ctx.strokeStyle = ring ?? '#8b949e88'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1669
+ isl.forEach(({ ik, i }, j) => {
1670
+ const th = (j / n) * 2 * Math.PI - Math.PI / 2;
1671
+ const ix = x + cr * Math.cos(th), iy = y + cr * Math.sin(th);
1672
+ if (i.fw) drawIslandDot(ctx, i, ix, iy, inv);
1673
+ else drawRoleMark(ctx, instanceMark(ik), ix, iy, m, i.ended);
1674
+ nodes.push({ x: ix, y: iy, r: m + inv, kind: 'island', key: ik, wk: k });
1675
+ anchors.set(ik, { x: ix, y: iy, r: m, cx: x, cy: y });
1676
+ if (full) labelOut(islandName(ik), x, y, th, R + 5 * inv, '#c9d1d9');
1677
+ });
1678
+ // the worker itself — a W badge on the ring, in the first gap between instances
1679
+ const tb = -Math.PI / 2 + Math.PI / n;
1680
+ const bx = x + R * Math.cos(tb), by = y + R * Math.sin(tb), br = 7 * inv;
1681
+ drawFwMark(ctx, ring ? 'subworker' : 'worker', bx, by, br, w?.dead);
1682
+ nodes.push({ x: bx, y: by, r: br + 3 * inv, kind: 'worker', key: k });
1683
+ badges.set(k, { x: bx, y: by, r: br });
1684
+ if (full) labelOut(wid, bx, by, tb, br + 4 * inv, lc);
1685
+ return R;
1686
+ };
1687
+
1688
+ /** Under-node label for a 1:1 or plain worker: the island name (if any), then the worker id. */
1689
+ const labelWorker = (isl, wid, x, y, r, color = '#8b949e') => {
1690
+ let ly = y + r + 2 * inv;
1691
+ ctx.font = font(10);
1692
+ if (isl.length) {
1693
+ ctx.fillStyle = '#c9d1d9';
1694
+ // the spawn link already names a sub-island's parent: label its own segment
1695
+ ctx.fillText(islandName(isl[0].ik).split('~').pop(), x, ly);
1696
+ ly += 12 * inv;
1697
+ }
1698
+ ctx.fillStyle = color;
1699
+ ctx.fillText(wid, x, ly);
1700
+ };
1701
+
1702
+ /**
1703
+ * Nested workers: a pool minted inside a worker hangs off whatever
1704
+ * spawned it. A sub-island's key (`parent~app@N`) names the spawning
1705
+ * INSTANCE, so its sub-worker links from that instance's node; a
1706
+ * worker-level spawn (`connectSubWorker`, no island) links from the
1707
+ * host's W badge, or the host node itself. Sub-workers fan outward
1708
+ * from the anchor, nudged off the anchor's own outward label. Each is
1709
+ * a violet W, or its island(s) in a violet ring. Deeper pools recurse.
1710
+ */
1711
+ const drawBranch = (pwKey, px, py, a, pr) => {
1712
+ const ws = (cl.nested.get(pwKey) ?? [])
1713
+ .flatMap((g) => g.workers.map(([k, w]) => ({ k, w, g })));
1714
+ if (!ws.length) return;
1715
+ const groups = new Map(); // anchor key → sub-workers it spawned
1716
+ for (const s of ws) {
1717
+ let anc = null;
1718
+ for (const { ik } of cl.hosted.get(s.k) ?? []) {
1719
+ const t = ik.lastIndexOf('~');
1720
+ if (t > 0) { anc = ik.slice(0, t); break; }
1721
+ }
1722
+ // an anchor sitting on the host itself (a 1:1 island worker) joins
1723
+ // the host's own fan, so siblings spread together instead of overlapping
1724
+ const at = anc ? anchors.get(anc) : undefined;
1725
+ const gk = at && Math.hypot(at.x - px, at.y - py) > 1 ? anc : '';
1726
+ const l = groups.get(gk) ?? [];
1727
+ if (!l.length) groups.set(gk, l);
1728
+ l.push(s);
1729
+ }
1730
+ for (const [gk, raw] of groups) {
1731
+ // widest sibling (an island container) in the middle of the fan,
1732
+ // smaller ones alternating outward, so the bulk points straight out
1733
+ const size = (s) => (cl.hosted.get(s.k) ?? []).length;
1734
+ const bySize = [...raw].sort((x, y) => size(y) - size(x));
1735
+ const list = new Array(bySize.length);
1736
+ const mid = (bySize.length - 1) >> 1;
1737
+ bySize.forEach((s, i) => { list[mid + (i % 2 ? (i + 1) >> 1 : -(i >> 1))] = s; });
1738
+ const an = gk ? anchors.get(gk) : (badges.get(pwKey) ?? { x: px, y: py, r: pr });
1739
+ const off = Math.hypot(an.x - px, an.y - py) > 1;
1740
+ const dir = off ? Math.atan2(an.y - py, an.x - px) : a;
1741
+ // Each sibling takes the angle its own footprint needs at its
1742
+ // distance (a container is wide and sits further out; a W plus its
1743
+ // label is narrow), packed around one centre line. One or two
1744
+ // siblings rotate off the anchor's outward label toward the upper
1745
+ // side (labels hang below); a bigger fan points straight out.
1746
+ const geo = list.map(({ k }) => {
1747
+ const isl = cl.hosted.get(k) ?? [];
1748
+ // a container is wider than one mark: push its centre out by its radius
1749
+ const ext = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 5 * inv : 0;
1750
+ const dist = an.r + 56 + ext;
1751
+ const foot = (isl.length > 1 ? ext + 12 * inv : 7 * inv) + 14 * inv;
1752
+ return { isl, dist, hw: Math.asin(Math.min(0.95, foot / dist)) };
1753
+ });
1754
+ const total = geo.reduce((s, x) => s + 2 * x.hw, 0);
1755
+ const turn = list.length > 2 ? 0 : Math.max(0, Math.min(off ? 1 : 1.25, 1.4 - total / 2));
1756
+ const nudge = Math.sin(dir + turn) < Math.sin(dir - turn) ? turn : -turn;
1757
+ let cursor = dir + nudge - total / 2;
1758
+ list.forEach(({ k, w, g }, j) => {
1759
+ const { isl, dist, hw } = geo[j];
1760
+ const th = cursor + hw;
1761
+ cursor += 2 * hw;
1762
+ const nx = an.x + dist * Math.cos(th), ny = an.y + dist * Math.sin(th);
1763
+ const local = g.poolId.split('~').pop();
1764
+ const wid = g.dedicated ? local : `${local}#${k.split('|').pop()}`;
1765
+ let r;
1766
+ if (isl.length) r = drawIslandWorker(k, isl, nx, ny, '#a371f7aa', wid);
1767
+ else {
1768
+ r = 7 * inv;
1769
+ drawFwMark(ctx, 'subworker', nx, ny, r, w.dead);
1770
+ nodes.push({ x: nx, y: ny, r: r + 4 * inv, kind: 'worker', key: k });
1771
+ if (full) labelWorker([], wid, nx, ny, r, '#a371f7');
1772
+ }
1773
+ // spawn link — violet, anchor edge → sub-worker edge
1774
+ ctx.beginPath();
1775
+ ctx.moveTo(an.x + an.r * Math.cos(th), an.y + an.r * Math.sin(th));
1776
+ ctx.lineTo(nx - r * Math.cos(th), ny - r * Math.sin(th));
1777
+ ctx.strokeStyle = '#a371f799'; ctx.lineWidth = 1.4 * inv; ctx.stroke();
1778
+ drawBranch(k, nx, ny, th, r);
1779
+ });
1780
+ }
1781
+ };
1782
+
1286
1783
  for (const p of ppos) {
1287
1784
  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);
1785
+ // pool — a P socket on the rim, the container for its workers. A
1786
+ // dedicated runner (workers: 1, no pool) has no socket: its worker
1787
+ // keeps the worker-orbit spot and the hub spoke runs straight to it.
1788
+ const poolR = p.dedicated ? 0 : 10 * mark;
1789
+ if (!p.dedicated) {
1790
+ drawFwMark(ctx, 'pool', p.x, p.y, poolR);
1791
+ if (full) {
1792
+ ctx.font = font(10); ctx.fillStyle = '#8b949e';
1793
+ ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
1794
+ }
1296
1795
  }
1297
1796
  const n = p.workers.length;
1298
1797
  p.workers.forEach(([k, w], j) => {
1299
1798
  // workers spread around the pool's angle on the mid ring
1300
1799
  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
1800
  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();
1801
+ // a shared-worker container is wider than one mark — push it clear of its pool
1802
+ const span = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 17 * inv : 0;
1803
+ const wd = cl.rimR + Math.max(38, span + (p.dedicated ? 10 * mark : poolR) + 8 * inv);
1804
+ const wx = cx + wd * Math.cos(a2), wy = cy + wd * Math.sin(a2);
1805
+ const wid = p.dedicated ? p.poolId : `${p.poolId}#${k.split('|').pop()}`;
1806
+ let r;
1807
+ if (isl.length) r = drawIslandWorker(k, isl, wx, wy, undefined, wid);
1808
+ else {
1809
+ // plain worker — W, sized by heap when the probe reports it
1810
+ r = Math.max(8, 7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
1811
+ drawFwMark(ctx, 'worker', wx, wy, r, w.dead);
1812
+ nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
1315
1813
  }
1316
- if (full) {
1317
- ctx.font = font(10); ctx.fillStyle = '#8b949e';
1318
- ctx.fillText(`${p.poolId}#${k.split('|').pop()}`, wx, wy + r + 2 * inv);
1814
+ // pool → worker link, edge to edge so it never crosses a cluster
1815
+ const d = Math.hypot(wx - p.x, wy - p.y) || 1;
1816
+ const ux = (wx - p.x) / d, uy = (wy - p.y) / d;
1817
+ if (p.dedicated) {
1818
+ ctx.beginPath();
1819
+ ctx.moveTo(cx, cy); ctx.lineTo(wx - Math.cos(a2) * r, wy - Math.sin(a2) * r);
1820
+ ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv; ctx.stroke();
1821
+ } else if (d > poolR + r) {
1822
+ ctx.beginPath();
1823
+ ctx.moveTo(p.x + ux * poolR, p.y + uy * poolR); ctx.lineTo(wx - ux * r, wy - uy * r);
1824
+ ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1319
1825
  }
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
- });
1826
+ if (full && !isl.length) labelWorker([], wid, wx, wy, r);
1827
+ // nested pools branch off this worker — sub-workers / sub-islands
1828
+ drawBranch(k, wx, wy, a2, r);
1339
1829
  });
1340
1830
  }
1831
+ // islands whose worker never reported take a worker slot of their own
1341
1832
  cl.orphans.forEach(({ ik, i }, j) => {
1342
1833
  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);
1834
+ const ix = cx + workerR * Math.cos(a), iy = cy + workerR * Math.sin(a);
1344
1835
  const r = drawIslandDot(ctx, i, ix, iy, inv);
1345
1836
  if (full) {
1346
- ctx.fillStyle = '#8b949e';
1347
- ctx.fillText(ik.split('|').slice(1).join('|'), ix, iy + r + 2 * inv);
1837
+ ctx.font = font(10); ctx.fillStyle = '#c9d1d9';
1838
+ ctx.fillText(islandName(ik), ix, iy + r + 2 * inv);
1348
1839
  }
1349
1840
  nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
1350
1841
  });
1351
1842
 
1352
- ctx.beginPath(); ctx.arc(cx, cy, 16 * inv, 0, 7);
1353
- ctx.fillStyle = '#a371f7'; ctx.fill();
1843
+ drawHub(ctx, sess, cx, cy, 16 * inv, inv);
1354
1844
  ctx.font = font(11);
1355
1845
  ctx.fillStyle = '#c9d1d9';
1356
1846
  ctx.fillText(sess?.name ?? cl.sid, cx, cy + 19 * inv);
1357
1847
  ctx.fillStyle = '#8b949e';
1358
- ctx.fillText('main thread', cx, cy + 32 * inv);
1848
+ ctx.fillText(sess?.framework ? `main thread · ${sess.framework}` : 'main thread', cx, cy + 32 * inv);
1359
1849
  if (!cl.pools.length && !cl.orphans.length) ctx.fillText('no workers or islands yet', cx, cy + 46 * inv);
1360
1850
  // the session hub is clickable too — same drill-in as the far-zoom disc
1361
1851
  nodes.push({ x: cx, y: cy, r: 16 * inv, kind: 'session', key: cl.sid });
@@ -1364,7 +1854,7 @@ function drawAppMap(canvas, sids) {
1364
1854
  // selection highlight — ring whatever is under inspection / selected
1365
1855
  for (const n of nodes) {
1366
1856
  const sel = (n.kind === 'worker' && n.key === state.inspectWorker)
1367
- || (n.kind === 'island' && n.key === state.inspect)
1857
+ || (n.kind === 'island' && (n.key === state.inspect || (n.wk && n.wk === state.inspectWorker)))
1368
1858
  || (n.kind === 'session' && n.key === state.sel);
1369
1859
  if (!sel) continue;
1370
1860
  const rr = n.r + 2.5 * inv;
@@ -1409,7 +1899,7 @@ function renderWorkers() {
1409
1899
  <td class="${w.heap !== undefined ? '' : 'muted'}">${w.heap !== undefined ? fmtBytes(w.heap) : '—'}</td>
1410
1900
  <td>${hosted.length
1411
1901
  ? `<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>
1902
+ : `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('worker')}</span>`}</td>
1413
1903
  <td class="muted">${esc(w.lastTaskId || '—')}</td>
1414
1904
  <td class="${w.dead ? 'error' : 'ok'}">${w.dead ? 'down' : 'live'}</td></tr>`;
1415
1905
  }) || '<tr><td colspan="10" class="muted">no workers yet</td></tr>';
@@ -1492,6 +1982,8 @@ function renderWorkerInsp() {
1492
1982
  $('wkErrors').innerHTML = w.errors.length
1493
1983
  ? 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
1984
  : '<div class="muted">none</div>';
1985
+
1986
+ for (const h of api.hooks.inspectWorker) h(k, w, { actions: $('wkActions') });
1495
1987
  }
1496
1988
 
1497
1989
  /* ── render dispatch ─────────────────────────────────────────────────────── */
@@ -1541,6 +2033,19 @@ function render() {
1541
2033
  render();
1542
2034
  };
1543
2035
  }
2036
+ for (const h of api.hooks.render) h();
2037
+ updateBadges();
2038
+ }
2039
+
2040
+ /** Nav badges for registered views (`addView({ badge })`): counts, alerts. */
2041
+ function updateBadges() {
2042
+ for (const v of registeredViews) {
2043
+ if (!v.badge) continue;
2044
+ const el = document.querySelector(`.panels > nav button[data-view="${v.id}"] .nbadge`);
2045
+ if (!el) continue;
2046
+ const b = v.badge();
2047
+ el.textContent = b === null || b === undefined || b === 0 ? '' : String(b);
2048
+ }
1544
2049
  }
1545
2050
 
1546
2051
  let raf = 0;
@@ -1557,16 +2062,89 @@ setInterval(() => {
1557
2062
  if ($('view-memory').classList.contains('on')) renderMemory();
1558
2063
  if ($('view-dashboard').classList.contains('on')) renderTopology();
1559
2064
  if (BC) sweepClosed();
2065
+ for (const h of api.hooks.tick) h();
1560
2066
  }, 1000);
1561
2067
 
1562
2068
  /* ── tabs + waterfall interaction ────────────────────────────────────────── */
1563
2069
 
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
- };
2070
+ /** Switch the main view; panels and the palette route through this. */
2071
+ function openView(id) {
2072
+ const btn = document.querySelector(`.panels > nav button[data-view="${id}"]`);
2073
+ if (!btn) return;
2074
+ document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === btn));
2075
+ document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${id}`));
2076
+ render();
2077
+ navigated();
2078
+ }
2079
+
2080
+ /** Fire api.onNavigate hooks (routing, nav layout) after a view/subview switch. */
2081
+ function navigated() {
2082
+ for (const h of api.hooks.navigate ?? []) h();
2083
+ }
2084
+
2085
+ // Delegated, so views/subviews panels add later get the same handlers.
2086
+ document.querySelector('.panels > nav').addEventListener('click', (e) => {
2087
+ const b = e.target.closest('button[data-view]');
2088
+ if (b) openView(b.dataset.view);
2089
+ });
2090
+
2091
+ /** Registered panel views, in nav order. */
2092
+ const registeredViews = [];
2093
+ const CORE_VIEW_ORDER = { dashboard: 10, tasks: 20, network: 30, memory: 40, log: 90 };
2094
+ const viewOrder = (b) => Number(b.dataset.order ?? CORE_VIEW_ORDER[b.dataset.view] ?? 50);
2095
+
2096
+ /**
2097
+ * Add a top-level view: a nav button (ordered by `order`; core views are
2098
+ * 10 dashboard, 20 tasks, 30 network, 40 memory, 90 log) and a section.
2099
+ * `badge()` feeds a small count on the nav button each render.
2100
+ */
2101
+ function addView({ id, label, order = 50, html = '', badge, title }) {
2102
+ const nav = document.querySelector('.panels > nav');
2103
+ const btn = document.createElement('button');
2104
+ btn.dataset.view = id;
2105
+ btn.dataset.order = String(order);
2106
+ btn.innerHTML = `${esc(label)}<span class="nbadge"></span>`;
2107
+ if (title) btn.title = title;
2108
+ const after = [...nav.querySelectorAll('button[data-view]')].find((b) => viewOrder(b) > order);
2109
+ nav.insertBefore(btn, after ?? null);
2110
+ const section = document.createElement('section');
2111
+ section.className = 'view';
2112
+ section.id = `view-${id}`;
2113
+ section.innerHTML = html;
2114
+ document.querySelector('.panels').appendChild(section);
2115
+ registeredViews.push({ id, label, order, badge });
2116
+ return section;
2117
+ }
2118
+
2119
+ /**
2120
+ * Add a subview (sub-tab) under a view. A view without a subnav gets one,
2121
+ * and its existing content becomes the first subview (`<view>-main`).
2122
+ */
2123
+ function addSubview(viewId, { id, label, html = '', order = 50 }) {
2124
+ const view = $(`view-${viewId}`);
2125
+ let subnav = view.querySelector(':scope > .subnav');
2126
+ if (!subnav) {
2127
+ const main = document.createElement('div');
2128
+ main.className = 'subview on';
2129
+ main.id = `${viewId}-main`;
2130
+ main.append(...view.childNodes);
2131
+ subnav = document.createElement('nav');
2132
+ subnav.className = 'subnav';
2133
+ subnav.innerHTML = `<button class="on" data-sub="${viewId}-main" data-order="0">${esc(registeredViews.find((v) => v.id === viewId)?.label ?? 'Main')}</button>`;
2134
+ view.append(subnav, main);
2135
+ }
2136
+ const btn = document.createElement('button');
2137
+ btn.dataset.sub = id;
2138
+ btn.dataset.order = String(order);
2139
+ btn.textContent = label;
2140
+ const after = [...subnav.querySelectorAll('button[data-sub]')].find((b) => Number(b.dataset.order ?? 50) > order);
2141
+ subnav.insertBefore(btn, after ?? null);
2142
+ const div = document.createElement('div');
2143
+ div.className = 'subview';
2144
+ div.id = id;
2145
+ div.innerHTML = html;
2146
+ view.appendChild(div);
2147
+ return div;
1570
2148
  }
1571
2149
 
1572
2150
  /* ── app map interaction — wheel zoom, drag pan, click/double-click ─────── */
@@ -1649,18 +2227,20 @@ if (new URLSearchParams(location.search).has('mini')) {
1649
2227
  document.getElementById('tv-dash').classList.remove('on');
1650
2228
  }
1651
2229
 
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
- }
2230
+ document.addEventListener('click', (e) => {
2231
+ const b = e.target.closest?.('.subnav button[data-sub]');
2232
+ if (!b) return;
2233
+ const nav = b.closest('.subnav');
2234
+ nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
2235
+ nav.parentElement.querySelectorAll(':scope > .subview')
2236
+ .forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
2237
+ render();
2238
+ navigated();
2239
+ });
1661
2240
 
1662
2241
  /** Open a subtab under a main view — switches main tabs first if needed. */
1663
2242
  const openSub = (view, sub) => {
2243
+ if (!document.getElementById(`view-${view}`)) return;
1664
2244
  if (!document.getElementById(`view-${view}`).classList.contains('on')) {
1665
2245
  document.querySelector(`.panels > nav button[data-view="${view}"]`).click();
1666
2246
  }
@@ -1714,10 +2294,17 @@ let viewer = null;
1714
2294
  * BroadcastChannel the app publishes on. There is no server-side session
1715
2295
  * registry — this page builds it from hello/batch/bye frames and owns
1716
2296
  * retention (pins + TTL) itself.
2297
+ *
2298
+ * Extension mode (`__ATOLL_TRANSPORT === 'extension'`): the Chrome DevTools
2299
+ * panel (packages/devtools-extension) can't join the inspected page's
2300
+ * channel, so its bootstrap installs `__ATOLL_BRIDGE`, a BroadcastChannel-
2301
+ * shaped relay (postMessage / onmessage, plus onreset when the inspected
2302
+ * page navigates). Everything below treats it exactly like the channel.
1717
2303
  */
1718
- const BC = window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
2304
+ const BRIDGE = window.__ATOLL_TRANSPORT === 'extension' ? window.__ATOLL_BRIDGE ?? null : null;
2305
+ const BC = BRIDGE ?? (window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
1719
2306
  ? new BroadcastChannel('atoll-devtools')
1720
- : null;
2307
+ : null);
1721
2308
  // ?mini=1 — the overlay flyout: compact single-instance layout (CSS does
1722
2309
  // the hiding; the session sidebar is meaningless for one origin's app).
1723
2310
  if (new URLSearchParams(location.search).has('mini')) document.body.classList.add('mini');
@@ -1765,12 +2352,105 @@ const pinSession = (id, pinned) => {
1765
2352
  }
1766
2353
  };
1767
2354
 
2355
+ /**
2356
+ * The one ingest path — live transport frames and replayed recordings both
2357
+ * come through here, so panels see a single stream via api.hooks.
2358
+ */
2359
+ function ingest(session, events) {
2360
+ state.sessions.set(session.id, session);
2361
+ for (const e of events) {
2362
+ applyEvent(session, e);
2363
+ logLine(session, e);
2364
+ for (const h of api.hooks.event) h(session, e);
2365
+ // New runners / islands / contracts register new app-side commands.
2366
+ if (e.type === 'pool:init' || e.type === 'island:mount' || e.type === 'memory:bind') {
2367
+ refreshCommands(session.id);
2368
+ }
2369
+ }
2370
+ for (const h of api.hooks.batch) h(session, events);
2371
+ scheduleRender();
2372
+ }
2373
+
2374
+ /* ── control channel: dashboard → app commands ───────────────────────────── */
2375
+
2376
+ const pendingCtl = new Map(); // request id → { resolve, reject, timer }
2377
+
2378
+ /** Invoke a registered app-side command (`registerDevtoolsCommand`) on a live session. */
2379
+ function control(sessionId, cmd, args = {}, timeoutMs = 5000) {
2380
+ const id = `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2381
+ return new Promise((resolve, reject) => {
2382
+ const msg = { type: 'control', sessionId, id, cmd, args };
2383
+ if (BC) BC.postMessage(msg);
2384
+ else if (viewer?.readyState === WebSocket.OPEN) viewer.send(JSON.stringify(msg));
2385
+ else return reject(new Error('dashboard is not connected'));
2386
+ const timer = setTimeout(() => {
2387
+ pendingCtl.delete(id);
2388
+ reject(new Error(`${cmd}: no reply in ${timeoutMs}ms (app may predate the control channel)`));
2389
+ }, timeoutMs);
2390
+ pendingCtl.set(id, { resolve, reject, timer });
2391
+ });
2392
+ }
2393
+
2394
+ function settleControl(data) {
2395
+ const p = pendingCtl.get(data.id);
2396
+ if (!p) return; // another dashboard's request
2397
+ pendingCtl.delete(data.id);
2398
+ clearTimeout(p.timer);
2399
+ if (data.ok) p.resolve(data.result); else p.reject(new Error(data.error ?? 'command failed'));
2400
+ }
2401
+
2402
+ /** sid → Set of command names, from 'devtools.commands' (refreshed as runners appear). */
2403
+ const cmdCache = new Map();
2404
+ const cmdDirty = new Set();
2405
+ let cmdTimer = 0;
2406
+ function refreshCommands(sid) {
2407
+ if (state.sessions.get(sid)?.closed || api.livePaused) return;
2408
+ cmdDirty.add(sid);
2409
+ clearTimeout(cmdTimer);
2410
+ cmdTimer = setTimeout(() => {
2411
+ for (const s of cmdDirty) {
2412
+ control(s, 'devtools.commands', {}, 3000)
2413
+ .then((list) => { cmdCache.set(s, new Set(list)); scheduleRender(); })
2414
+ .catch(() => { /* app without the control channel: commands stay off */ });
2415
+ }
2416
+ cmdDirty.clear();
2417
+ }, 400);
2418
+ }
2419
+ const hasCommand = (sid, cmd) => cmdCache.get(sid)?.has(cmd) === true;
2420
+
2421
+ const STATE_INIT = { sel: null, drillTask: null, inspect: null, inspectWorker: null, inspectFetch: null, seq: 0, t0: null };
2422
+
2423
+ /** Clear every dashboard aggregate (the recorder's import/replay starts here). */
2424
+ function reset() {
2425
+ for (const v of Object.values(state)) {
2426
+ if (v instanceof Map) v.clear();
2427
+ else if (Array.isArray(v)) v.length = 0;
2428
+ }
2429
+ Object.assign(state, STATE_INIT);
2430
+ bcSessions.clear();
2431
+ cmdCache.clear();
2432
+ logEl().innerHTML = '';
2433
+ for (const h of api.hooks.reset) h();
2434
+ topo.fit = true;
2435
+ render();
2436
+ }
2437
+
2438
+ /** Ask live apps to re-identify and replay their tail (after a reset/unpause). */
2439
+ function reannounce() {
2440
+ if (BC) BC.postMessage({ type: 'view' });
2441
+ else viewer?.close(); // reconnect → the server replays its tail
2442
+ }
2443
+
1768
2444
  function connect() {
1769
2445
  if (BC) {
1770
- $('conn').textContent = 'local';
1771
- $('conn').className = 'on';
2446
+ if (!BRIDGE) { // the extension bridge owns #conn (page reachable, atoll seen, …)
2447
+ $('conn').textContent = 'local';
2448
+ $('conn').className = 'on';
2449
+ }
1772
2450
  BC.onmessage = (msg) => {
1773
2451
  const data = msg.data;
2452
+ if (data.type === 'control-result') return settleControl(data);
2453
+ if (api.livePaused) return;
1774
2454
  if (data.type === 'hello') {
1775
2455
  // a fresh hello means a new app instance — same id can't revive
1776
2456
  const prev = bcSessions.get(data.session.id);
@@ -1778,6 +2458,7 @@ function connect() {
1778
2458
  ...data.session, closed: false, closedAt: undefined, pinned: prev?.pinned,
1779
2459
  });
1780
2460
  bcSync();
2461
+ refreshCommands(data.session.id);
1781
2462
  } else if (data.type === 'bye') {
1782
2463
  const s = bcSessions.get(data.sessionId);
1783
2464
  if (s && !s.closed) { s.closed = true; s.closedAt = Date.now(); bcSync(); }
@@ -1786,14 +2467,12 @@ function connect() {
1786
2467
  bcSessions.set(data.session.id, data.session);
1787
2468
  bcSync();
1788
2469
  }
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
- }
2470
+ ingest(data.session, data.events);
1794
2471
  }
1795
2472
  scheduleRender();
1796
2473
  };
2474
+ // inspected page navigated: its sessions are gone, start over and re-announce
2475
+ if (BRIDGE) BRIDGE.onreset = () => { reset(); reannounce(); };
1797
2476
  // announce — apps re-hello and replay their batch tail
1798
2477
  BC.postMessage({ type: 'view' });
1799
2478
  return;
@@ -1808,16 +2487,87 @@ function connect() {
1808
2487
  };
1809
2488
  ws.onmessage = (msg) => {
1810
2489
  const data = JSON.parse(msg.data);
2490
+ if (data.type === 'control-result') return settleControl(data);
2491
+ if (api.livePaused) return;
1811
2492
  if (data.type === 'sessions') {
1812
2493
  reconcileSessions(data.sessions);
2494
+ for (const s of data.sessions) if (!s.closed && !cmdCache.has(s.id)) refreshCommands(s.id);
1813
2495
  } 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
- }
2496
+ ingest(data.session, data.events);
1819
2497
  }
1820
2498
  scheduleRender();
1821
2499
  };
1822
2500
  }
2501
+
2502
+ /* ── panels ──────────────────────────────────────────────────────────────── */
2503
+
2504
+ const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
2505
+
2506
+ Object.assign(api, {
2507
+ state, $, esc, fmt, fmtBytes, fmtInt, key, sessOf, inSel, withGroups, kpi,
2508
+ sparkline, drawLine, drawMultiLine, fwIconImg,
2509
+ render, scheduleRender, addView, addSubview, openView, openSub,
2510
+ control, hasCommand, ingest, reset, reannounce,
2511
+ liveSessions: () => [...state.sessions.values()].filter((s) => !s.closed),
2512
+ /** The selected session, else the only live one, else null (ambiguous). */
2513
+ selectedOrOnlyLive: () => {
2514
+ if (state.sel) return state.sel;
2515
+ const live = [...state.sessions.values()].filter((s) => !s.closed);
2516
+ return live.length === 1 ? live[0].id : null;
2517
+ },
2518
+ isMini: new URLSearchParams(location.search).has('mini'),
2519
+ transport: BRIDGE ? 'extension' : BC ? 'broadcast' : 'ws',
2520
+ livePaused: false,
2521
+ /** () after every view/subview switch (openView, subnav clicks, openSub). */
2522
+ onNavigate(fn) { (api.hooks.navigate ??= []).push(fn); },
2523
+ /** viewId → intro html; the shell panel renders these under each view heading. */
2524
+ viewIntros: new Map(),
2525
+ /** One-line, dismissible "what this view shows" intro (callable before the shell loads). */
2526
+ setViewIntro(viewId, html) {
2527
+ api.viewIntros.set(viewId, html);
2528
+ for (const h of api.hooks.intro ?? []) h(viewId, html);
2529
+ },
2530
+ /** Transient message: kind 'ok' | 'error' | 'info' (panels/toast.js). */
2531
+ toast: (msg, kind, ms) => import('./panels/toast.js').then((m) => m.toast(msg, kind, ms)),
2532
+ });
2533
+
2534
+ /**
2535
+ * Header toolbar button (global actions: record, export, palette). Returns
2536
+ * the button; `order` sorts left→right.
2537
+ */
2538
+ api.addToolbarButton = ({ id, label, title, order = 50, onClick }) => {
2539
+ const bar = $('toolbar');
2540
+ const b = document.createElement('button');
2541
+ b.className = 'act';
2542
+ b.id = id;
2543
+ b.dataset.order = String(order);
2544
+ b.innerHTML = label;
2545
+ if (title) b.title = title;
2546
+ b.onclick = onClick;
2547
+ const after = [...bar.children].find((c) => Number(c.dataset.order ?? 50) > order);
2548
+ bar.insertBefore(b, after ?? null);
2549
+ return b;
2550
+ };
2551
+
2552
+ /** Feature panels: each module exports setup(api). Order = setup order. */
2553
+ const PANELS = [
2554
+ './panels/performance.js', // jank, frames, User Timing, message cost
2555
+ './panels/inspect-island.js',// island Elements / Props & events tabs
2556
+ './panels/memory-values.js', // shared-memory values, diff, watchpoints
2557
+ './panels/reactivity.js', // cross-thread reactivity graph
2558
+ './panels/actions.js', // live controls: kill, chaos, props, mode
2559
+ './panels/recorder.js', // record / export / import / replay
2560
+ './panels/audits.js', // recommendations
2561
+ './panels/shell.js', // palette, routing, shortcuts, help (load last)
2562
+ ];
2563
+
2564
+ for (const p of PANELS) {
2565
+ try {
2566
+ const mod = await import(p);
2567
+ mod.setup(api);
2568
+ } catch (err) {
2569
+ console.error(`[atoll devtools] panel ${p} failed to load`, err);
2570
+ }
2571
+ }
2572
+
1823
2573
  connect();