aipeek 0.2.7 → 0.2.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.
@@ -10,7 +10,7 @@ var _url = require('url');
10
10
  var _esbuild = require('esbuild');
11
11
 
12
12
  // src/core/action.ts
13
- var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query"];
13
+ var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query", "assert", "drag", "scrollIntoView", "drop", "clipboard"];
14
14
  function resolveAction(type, args) {
15
15
  if (!TYPES.includes(type))
16
16
  return { valid: false, error: `unknown action: ${type}` };
@@ -34,6 +34,30 @@ function resolveAction(type, args) {
34
34
  return { valid: true };
35
35
  case "query":
36
36
  return args.sel ? { valid: true } : { valid: false, error: "query needs sel=" };
37
+ case "assert":
38
+ if (!args.screen && !args.sel)
39
+ return { valid: false, error: "assert needs screen= (a __AIPEEK_SCREEN__ key) or sel=" };
40
+ if (args.equals === void 0)
41
+ return { valid: false, error: "assert needs equals=" };
42
+ return { valid: true };
43
+ case "drag":
44
+ if (!hasTarget)
45
+ return { valid: false, error: "drag needs sel= or text= (the source)" };
46
+ if (!args.to)
47
+ return { valid: false, error: "drag needs to= (destination selector)" };
48
+ return { valid: true };
49
+ case "scrollIntoView":
50
+ return hasTarget ? { valid: true } : { valid: false, error: "scrollIntoView needs sel= or text=" };
51
+ case "drop":
52
+ if (!hasTarget)
53
+ return { valid: false, error: "drop needs sel= or text= (the drop target)" };
54
+ if (!args.files || !args.files.length)
55
+ return { valid: false, error: "drop needs files= (array of file names)" };
56
+ return { valid: true };
57
+ case "clipboard":
58
+ if (args.mode === "write" && args.value === void 0)
59
+ return { valid: false, error: "clipboard write needs value=" };
60
+ return { valid: true };
37
61
  default:
38
62
  return { valid: false, error: `unknown action: ${type}` };
39
63
  }
@@ -71,6 +95,156 @@ function check(raw) {
71
95
  };
72
96
  }
73
97
 
98
+ // src/core/perf.ts
99
+ var FRAME_NOISE_PCT = 3;
100
+ var SELF_NOISE_REL = 0.1;
101
+ var SELF_NOISE_MS = 5;
102
+ var avgOf = (samples) => samples.length > 0 ? samples.reduce((s, v) => s + v, 0) / samples.length : 0;
103
+ function summarizeStat(stat) {
104
+ const avg = stat.n > 0 ? stat.total / stat.n : 0;
105
+ return {
106
+ total: stat.total,
107
+ n: stat.n,
108
+ avg,
109
+ max: stat.max
110
+ };
111
+ }
112
+ function compactPerformance(p) {
113
+ const all = p.buckets.find((b) => b.name === "__all__");
114
+ if (!all) return "perf: (no data)";
115
+ const { total, long, samples } = all.frames;
116
+ const fps = total > 0 ? Math.round(1e3 / avgOf(samples) * 10) / 10 : 0;
117
+ const droppedPct = total > 0 ? Math.round(long / total * 100) : 0;
118
+ const parts = [`${fps}fps`, `${droppedPct}% dropped`];
119
+ if (p.longtasks.count > 0) {
120
+ parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`);
121
+ }
122
+ const topComps = Object.entries(all.components).map(([name, stat]) => ({ name, total: stat.total })).sort((a, b) => b.total - a.total).slice(0, 2);
123
+ if (topComps.length > 0 && p.mobxPatched) {
124
+ const hot = topComps.map((c) => `${c.name} ${Math.round(c.total)}ms`).join(", ");
125
+ parts.push(`hot: ${hot}`);
126
+ }
127
+ return `perf: ${parts.join(" \xB7 ")}`;
128
+ }
129
+ function detailPerformance(p) {
130
+ const lines = [];
131
+ const windowSec = (p.windowMs / 1e3).toFixed(1);
132
+ lines.push(`=== Performance (${windowSec}s window) ===`);
133
+ if (p.hiddenFrames > 0) {
134
+ lines.push(`\u26A0 Tab was backgrounded \u2014 ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`);
135
+ lines.push("For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing");
136
+ }
137
+ lines.push(`MobX attribution: ${p.mobxPatched ? "ON" : "OFF"}`);
138
+ if (!p.mobxPatched) {
139
+ lines.push("(Component attribution disabled \u2014 set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)");
140
+ }
141
+ lines.push("");
142
+ if (p.longtasks.count > 0) {
143
+ lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`);
144
+ lines.push("");
145
+ }
146
+ const showPerView = p.buckets.length > 1;
147
+ for (const bucket of p.buckets) {
148
+ if (bucket.name !== "__all__" && !showPerView) continue;
149
+ lines.push(`--- ${bucket.name} ---`);
150
+ const { total, long, max, samples } = bucket.frames;
151
+ const droppedPct = total > 0 ? (long / total * 100).toFixed(1) : "0.0";
152
+ const avgFrame = avgOf(samples).toFixed(1);
153
+ lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`);
154
+ const comps = Object.entries(bucket.components).map(([name, stat]) => ({ name, ...summarizeStat(stat) })).sort((a, b) => b.total - a.total).slice(0, 20);
155
+ if (comps.length > 0 && p.mobxPatched) {
156
+ lines.push("Components (by total self-time):");
157
+ for (const c of comps) {
158
+ lines.push(` ${c.name.padEnd(30)} ${Math.round(c.total)}ms / ${c.n}\xD7 = ${c.avg.toFixed(1)}ms avg, max ${Math.round(c.max)}ms`);
159
+ }
160
+ } else if (!p.mobxPatched) {
161
+ lines.push("(no component data \u2014 attribution OFF)");
162
+ }
163
+ const hotLines = Object.entries(_nullishCoalesce(bucket.lines, () => ( {}))).map(([label, stat]) => ({ label, ...summarizeStat(stat) })).sort((a, b) => b.total - a.total).slice(0, 15);
164
+ if (hotLines.length > 0) {
165
+ lines.push("Hot lines (by total inclusive time \u2014 contains nested calls):");
166
+ for (const l of hotLines) {
167
+ lines.push(` ${l.label.padEnd(36)} ${Math.round(l.total)}ms / ${l.n}\xD7 = ${l.avg.toFixed(1)}ms avg, max ${Math.round(l.max)}ms`);
168
+ }
169
+ }
170
+ lines.push("");
171
+ }
172
+ return lines.join("\n");
173
+ }
174
+ function diffPerformance(before, after) {
175
+ const out = [];
176
+ const beforeAll = before.buckets.find((b) => b.name === "__all__");
177
+ const afterAll = after.buckets.find((b) => b.name === "__all__");
178
+ const droppedPct = (b) => b && b.frames.total > 0 ? b.frames.long / b.frames.total * 100 : 0;
179
+ const beforeDrop = droppedPct(beforeAll);
180
+ const afterDrop = droppedPct(afterAll);
181
+ const dropDelta = afterDrop - beforeDrop;
182
+ const sumTotal = (b) => b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0;
183
+ const beforeSelf = sumTotal(beforeAll);
184
+ const afterSelf = sumTotal(afterAll);
185
+ const selfDelta = afterSelf - beforeSelf;
186
+ const bothSampledFrames = (_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _ => _.frames, 'access', _2 => _2.total]), () => ( 0))) > 0 && (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _3 => _3.frames, 'access', _4 => _4.total]), () => ( 0))) > 0;
187
+ const sawDrops = (_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _5 => _5.frames, 'access', _6 => _6.long]), () => ( 0))) > 0 || (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _7 => _7.frames, 'access', _8 => _8.long]), () => ( 0))) > 0;
188
+ const framesExercised = bothSampledFrames && sawDrops;
189
+ const afterBlind = (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _9 => _9.frames, 'access', _10 => _10.total]), () => ( 0))) === 0 && afterSelf === 0;
190
+ let verdict;
191
+ if (afterBlind) {
192
+ verdict = "NO DATA";
193
+ } else if (framesExercised) {
194
+ if (dropDelta < -FRAME_NOISE_PCT) verdict = "IMPROVED";
195
+ else if (dropDelta > FRAME_NOISE_PCT) verdict = "REGRESSED";
196
+ else verdict = "UNCHANGED";
197
+ } else {
198
+ const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL);
199
+ if (selfDelta < -floor) verdict = "IMPROVED";
200
+ else if (selfDelta > floor) verdict = "REGRESSED";
201
+ else verdict = "UNCHANGED";
202
+ }
203
+ out.push(`=== Perf diff: ${verdict} ===`);
204
+ if (afterBlind) {
205
+ out.push("after-window recorded no frames and no self-time \u2014 tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.");
206
+ out.push("");
207
+ return out.join("\n").trimEnd();
208
+ }
209
+ out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% \u2192 ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? "+" : ""}${dropDelta.toFixed(1)}%)`);
210
+ if (!framesExercised && Math.round(selfDelta) !== 0) {
211
+ out.push(`Total self-time: ${Math.round(beforeSelf)}ms \u2192 ${Math.round(afterSelf)}ms (${selfDelta >= 0 ? "+" : ""}${Math.round(selfDelta)}ms) \u2014 frames flat, verdict by self-time`);
212
+ }
213
+ if (before.longtasks.count !== after.longtasks.count) {
214
+ out.push(`Longtasks: ${before.longtasks.count} \u2192 ${after.longtasks.count}`);
215
+ }
216
+ out.push("");
217
+ const compDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _11 => _11.components]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _12 => _12.components]), () => ( {})));
218
+ if (compDelta.length > 0) {
219
+ out.push("Components (\u0394 total self-time):");
220
+ for (const d of compDelta) out.push(" " + formatDelta(d));
221
+ out.push("");
222
+ }
223
+ const lineDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _13 => _13.lines]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _14 => _14.lines]), () => ( {})));
224
+ if (lineDelta.length > 0) {
225
+ out.push("Hot lines (\u0394 total inclusive time):");
226
+ for (const d of lineDelta) out.push(" " + formatDelta(d));
227
+ }
228
+ return out.join("\n").trimEnd();
229
+ }
230
+ function keyedTotalDelta(a, b) {
231
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
232
+ const rows = [];
233
+ for (const key of keys) {
234
+ const beforeT = _nullishCoalesce(_optionalChain([a, 'access', _15 => _15[key], 'optionalAccess', _16 => _16.total]), () => ( 0));
235
+ const afterT = _nullishCoalesce(_optionalChain([b, 'access', _17 => _17[key], 'optionalAccess', _18 => _18.total]), () => ( 0));
236
+ const delta = afterT - beforeT;
237
+ if (Math.round(delta) === 0) continue;
238
+ rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] });
239
+ }
240
+ return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20);
241
+ }
242
+ function formatDelta(d) {
243
+ const sign = d.delta >= 0 ? "+" : "";
244
+ const tag = d.isNew ? " (new)" : d.gone ? " (gone)" : "";
245
+ return `${d.key.padEnd(36)} ${Math.round(d.before)}ms \u2192 ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`;
246
+ }
247
+
74
248
  // src/core/util.ts
75
249
  function truncate(s, max) {
76
250
  return s.length > max ? `${s.slice(0, max)}\u2026` : s;
@@ -126,6 +300,78 @@ function compactUrl(url, search) {
126
300
  return truncate(url, 80);
127
301
  }
128
302
  }
303
+ var STALE_MS = 3e4;
304
+ var isLive = (t, now) => now - t.lastSeen < STALE_MS;
305
+ function formatTabs(tabs, now) {
306
+ const live = tabs.filter((t) => isLive(t, now));
307
+ if (!live.length)
308
+ return "(no live tabs)";
309
+ return live.sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id)).map((t) => {
310
+ const age = Math.round((now - t.lastSeen) / 1e3);
311
+ const mark = t.visible ? "\u25CF visible" : "\u25CB background";
312
+ return `${t.id} ${mark} ${truncate(t.title || "(untitled)", 40)} ${compactUrl(t.url)} ${age}s ago`;
313
+ }).join("\n");
314
+ }
315
+ function diagnose(tab, tabs, now, socketCount, port) {
316
+ if (tab) {
317
+ const t = tabs.find((x) => x.id === tab);
318
+ return t && isLive(t, now) ? `tab '${tab}' is connected but didn't answer in time \u2014 the handler likely threw or hung (bad selector, infinite loop, or a read that needs the tab foreground). Check /__aipeek/console.` : `tab '${tab}' is not connected \u2014 it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aipeek/tabs for live ids; if the page is mid self-heal, retry in ~2s.`;
319
+ }
320
+ const live = tabs.filter((t) => isLive(t, now));
321
+ if (live.length)
322
+ return `${live.length} tab(s) connected but none answered \u2014 a handler threw, or they just closed. Check /__aipeek/tabs (still live?) and /__aipeek/console (errors?).`;
323
+ return socketCount > 0 ? `no aipeek tab registered, but ${socketCount} page(s) are connected to this dev server via HMR \u2014 the page is open yet the aipeek client didn't load. Is it served by THIS vite server on :${port}? A separate vite config / vite preview / production build won't inject it.` : `no browser tab connected to this dev server on :${port} \u2014 open the app, or check you're hitting the right port.`;
324
+ }
325
+ function formatActions(entries, anchorTs) {
326
+ if (!entries.length)
327
+ return "(no actions)";
328
+ const sorted = [...entries].sort((a, b) => a.ts - b.ts);
329
+ const multiTab = new Set(sorted.map((e) => e.tab).filter(Boolean)).size > 1;
330
+ const lines = [];
331
+ const prevByTab = /* @__PURE__ */ new Map();
332
+ let anchored = false;
333
+ for (const e of sorted) {
334
+ const isAnchor = anchorTs !== void 0 && !anchored && e.ts === anchorTs;
335
+ if (isAnchor) {
336
+ lines.push("\u2014\u2014\u2014\u2014\u2014\u2014 \u4F60\u5F53\u524D\u7684\u884C\u4E3A \u2014\u2014\u2014\u2014\u2014\u2014");
337
+ anchored = true;
338
+ }
339
+ const who = e.trusted ? "T" : "S";
340
+ const tag = multiTab ? `${_nullishCoalesce(e.tab, () => ( "?"))} ` : "";
341
+ const val = e.value ? ` value="${e.value}"` : "";
342
+ const change = uiChange(prevByTab.get(e.tab), e);
343
+ lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` \u2192 ${change}` : ""}`);
344
+ if (isAnchor)
345
+ lines.push("\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014");
346
+ prevByTab.set(e.tab, e);
347
+ }
348
+ return lines.join("\n");
349
+ }
350
+ function appendAction(log, tab, entry, max) {
351
+ const last = log[log.length - 1];
352
+ if (entry.type === "input" && _optionalChain([last, 'optionalAccess', _19 => _19.type]) === "input" && last.tab === tab && last.target === entry.target) {
353
+ log[log.length - 1] = { ...entry, tab };
354
+ return;
355
+ }
356
+ log.push({ ...entry, tab });
357
+ if (log.length > max)
358
+ log.shift();
359
+ }
360
+ function uiChange(prev, cur) {
361
+ if (cur.modal === void 0)
362
+ return "";
363
+ const prevModal = _nullishCoalesce(_optionalChain([prev, 'optionalAccess', _20 => _20.modal]), () => ( "none"));
364
+ if (cur.modal !== prevModal) {
365
+ if (cur.modal === "none")
366
+ return "\u5F39\u7A97\u5173\u95ED";
367
+ if (prevModal === "none")
368
+ return `\u5F39\u7A97\u6253\u5F00\u300C${cur.modal}\u300D`;
369
+ return `\u5F39\u7A97\u5207\u6362\u300C${cur.modal}\u300D`;
370
+ }
371
+ if (prev && cur.view !== void 0 && cur.view !== prev.view)
372
+ return `\u89C6\u56FE ${prev.view}\u2192${cur.view}`;
373
+ return "";
374
+ }
129
375
 
130
376
  // src/core/compact.ts
131
377
  var SLOW_THRESHOLD = 1e3;
@@ -362,6 +608,7 @@ function compact(raw) {
362
608
  network: compactNetwork(raw.network),
363
609
  errors: compactErrors(raw.errors),
364
610
  state: compactState(raw.state),
611
+ performance: raw.performance ? detailPerformance(raw.performance) : void 0,
365
612
  timestamp: raw.timestamp,
366
613
  counts: {
367
614
  console: raw.console.length,
@@ -385,6 +632,8 @@ function detail(raw, section, index, full) {
385
632
  return detailError(raw.errors, index, full);
386
633
  case "state":
387
634
  return detailState(raw.state, index, full);
635
+ case "profile":
636
+ return raw.performance ? detailPerformance(raw.performance) : "(no perf data \u2014 is the tab foreground?)";
388
637
  default:
389
638
  return null;
390
639
  }
@@ -582,6 +831,43 @@ function byteSize(s) {
582
831
  }
583
832
 
584
833
  // src/core/diff.ts
834
+ function diffScreen(before, after, newErrors, newExceptions, newFailedRequests) {
835
+ const lines = [];
836
+ if (after.view !== before.view)
837
+ lines.push(`view: ${before.view} \u2192 ${after.view}`);
838
+ if (after.modal !== before.modal) {
839
+ if (after.modal === "none")
840
+ lines.push(`modal: closed (${before.modal})`);
841
+ else if (before.modal === "none")
842
+ lines.push(`modal: opened ${after.modal}`);
843
+ else
844
+ lines.push(`modal: ${before.modal} \u2192 ${after.modal}`);
845
+ }
846
+ if (after.focus !== before.focus)
847
+ lines.push(`focus: ${after.focus}`);
848
+ const beforeDomain = _nullishCoalesce(before.domain, () => ( {}));
849
+ const afterDomain = _nullishCoalesce(after.domain, () => ( {}));
850
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(beforeDomain), ...Object.keys(afterDomain)])) {
851
+ const b = stringifyDomain(beforeDomain[key]);
852
+ const a = stringifyDomain(afterDomain[key]);
853
+ if (b !== a)
854
+ lines.push(`${key}: ${b} \u2192 ${a}`);
855
+ }
856
+ for (const e of newErrors)
857
+ lines.push(`+error: ${e.text}`);
858
+ for (const e of newExceptions)
859
+ lines.push(`+exception: ${e.message}`);
860
+ for (const r of newFailedRequests)
861
+ lines.push(`+failed: ${r.method} ${r.url} \u2192 ${r.status || "failed"}`);
862
+ return lines;
863
+ }
864
+ function stringifyDomain(v) {
865
+ if (v === null || v === void 0)
866
+ return String(v);
867
+ if (typeof v === "object")
868
+ return JSON.stringify(v).slice(0, 80);
869
+ return String(v).slice(0, 80);
870
+ }
585
871
  function diffState(prev, curr) {
586
872
  if (!prev) {
587
873
  return {
@@ -612,7 +898,7 @@ function diffState(prev, curr) {
612
898
 
613
899
  // src/core/emit.ts
614
900
  var _picocolors = require('picocolors'); var _picocolors2 = _interopRequireDefault(_picocolors);
615
- var SECTIONS = ["ui", "console", "network", "errors", "state"];
901
+ var SECTIONS = ["ui", "console", "network", "errors", "state", "performance"];
616
902
  var COUNTED_SECTIONS = {
617
903
  console: "console",
618
904
  network: "network",
@@ -624,7 +910,7 @@ function emit(state) {
624
910
  for (const key of SECTIONS) {
625
911
  if (state[key]) {
626
912
  const countKey = COUNTED_SECTIONS[key];
627
- const count = countKey ? _nullishCoalesce(_optionalChain([state, 'access', _ => _.counts, 'optionalAccess', _2 => _2[countKey]]), () => ( 0)) : 0;
913
+ const count = countKey ? _nullishCoalesce(_optionalChain([state, 'access', _21 => _21.counts, 'optionalAccess', _22 => _22[countKey]]), () => ( 0)) : 0;
628
914
  const attr = count ? ` count="${count}"` : "";
629
915
  sections.push(`<${key}${attr}>
630
916
  ${state[key]}
@@ -683,6 +969,15 @@ function emitSummary(raw) {
683
969
  lines.push(`errors (${raw.errors.length}):`);
684
970
  for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`);
685
971
  }
972
+ if (raw.performance) {
973
+ const all = raw.performance.buckets.find((b) => b.name === "__all__");
974
+ if (all) {
975
+ const droppedPct = all.frames.total > 0 ? all.frames.long / all.frames.total * 100 : 0;
976
+ if (droppedPct > 10) {
977
+ lines.push(compactPerformance(raw.performance));
978
+ }
979
+ }
980
+ }
686
981
  const storeNames = Object.keys(raw.state);
687
982
  if (storeNames.length) {
688
983
  const parts = storeNames.map((n) => {
@@ -790,12 +1085,24 @@ function aipeekSnippet(port) {
790
1085
  return `
791
1086
  # aipeek \u2014 Runtime Browser Inspector
792
1087
 
1088
+ IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS \`curl ${base}/screen\` to see the live app first. Do NOT guess, and do NOT ask the user to open or describe the page \u2014 you read it directly, including background tabs.
1089
+
1090
+ - \`curl ${base}/screen\` \u2014 state-machine view of the UI (start here). \`/ui\`, \`/dom?scope=X\` for detail; \`/console\`, \`/network\`, \`/errors\`, \`/check\` for health.
1091
+ - \`curl ${base}/click?text=\u2026\` (also \`/fill\`, \`/press\`, \`/wait\`) \u2014 drive the page. POST \`/chain\` for a whole scripted interaction.
1092
+ - \`curl ${base}/help\` \u2014 full command reference (every endpoint, flags, federation, examples). Read it the first time you reach for a command you don't see above.`;
1093
+ }
1094
+ function helpText(port) {
1095
+ const base = `http://localhost:${port}/__aipeek`;
1096
+ return `
1097
+ # aipeek \u2014 Runtime Browser Inspector
1098
+
793
1099
  IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS fetch the live app state first. Do NOT guess \u2014 look at the actual browser state.
794
1100
 
795
1101
  ## Read state \u2014 cheapest first
796
1102
 
797
1103
  \`\`\`bash
798
- curl ${base}/screen # state-machine projection {view, modal, focus, knobs} \u2014 START HERE
1104
+ curl ${base}/screen # state-machine projection {view, modal, focus, knobs} \u2014 START HERE (returns token: tN)
1105
+ curl '${base}/screen?since=tN' # only what moved since that token (view/modal/focus + new errors), not a full snapshot
799
1106
  curl ${base}/ui # React component tree \u2014 deep-dive when /screen isn't enough
800
1107
  curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component \u2014 UI as text, src locations
801
1108
  curl ${base} # high-density summary (ok sections \u2192 1 line, issues \u2192 expanded)
@@ -805,14 +1112,24 @@ curl ${base}/console # console logs (errors, warnings, info)
805
1112
  curl ${base}/network # fetch/XHR requests with status and timing
806
1113
  curl ${base}/errors # uncaught errors and unhandled rejections
807
1114
  curl ${base}/state # registered store snapshots
1115
+ curl ${base}/tabs # list live tabs (id, visible/background, title) for ?tab= addressing
1116
+ curl ${base}/timeline # interleaved action stream across all tabs (who clicked what, in order)
808
1117
  curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
1118
+ curl ${base}/profile # performance profiler: which component/function is burning frames (+ source lines with AIPEEK_LINES=1)
1119
+ curl ${base}/profile/reset # clear the profiler window, then reproduce the interaction
1120
+ curl ${base}/profile/diff # closed loop: 1st call marks baseline, fix+reproduce, 2nd call \u2192 IMPROVED/REGRESSED verdict
809
1121
  \`\`\`
810
1122
 
811
1123
  \`/query\` is the read-side twin of click/fill's \`sel=\` \u2014 assert on a specific element
812
1124
  (how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
1125
+ Secret fields (password inputs, API-key/token fields) show \`\u2039redacted N chars\u203A\` instead of
1126
+ their value across \`/dom\`, \`/query\` and \`/screen\` \u2014 length stays visible, the secret doesn't.
813
1127
 
814
1128
  \`/screen\` projects the whole UI to a few state variables \u2014 start there, not \`/ui\`. Append
815
- \`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
1129
+ \`?full\` for untruncated output. Each read prints a \`token: tN\` line; pass it back as
1130
+ \`/screen?since=tN\` to get only the transition since that read (view/modal/focus + new
1131
+ errors/failed requests), \`(no state change)\` if nothing moved \u2014 the cheap "what changed
1132
+ after I acted" read, without re-paying for the unchanged 99%.
816
1133
 
817
1134
  To inspect or edit a component, work top-down \u2014 the full DOM is huge, a scoped view is
818
1135
  accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
@@ -829,11 +1146,59 @@ curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add go
829
1146
  curl '${base}/screenshot?out=shot.png' # DOM\u2192PNG into .aipeek/ (html-to-image; lossy)
830
1147
  \`\`\`
831
1148
 
832
- \`click\`/\`fill\`/\`press\` settle the DOM and append the resulting UI tree (\`--- ui after ---\`)
833
- to the response \u2014 no follow-up read needed. On a miss, the response lists the reachable
834
- clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
1149
+ \`click\`/\`fill\`/\`press\` settle the DOM and append \`--- changed ---\`: only the state-machine
1150
+ transition this action caused (\`view: a \u2192 b\`, \`modal: opened X\`, \`focus: \u2026\`) plus any new
1151
+ errors/failed requests \u2014 not a fresh snapshot. \`(no state change)\` means nothing moved. Read
1152
+ the delta, then drill into /ui or /dom for detail if you need it. On a miss, the response lists
1153
+ the reachable clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
835
1154
  \`curl -G ${base}/click --data-urlencode 'sel=button[title="\u77E5\u8BC6\u5E93"]'\`.
836
1155
 
1156
+ Each \`click\`/\`fill\`/\`press\` response also carries a \`--- recent actions ---\` timeline:
1157
+ the semantic page actions (yours and the user's) in order, \`T\`=trusted human / \`S\`=synthetic
1158
+ aipeek, each with its resulting UI change (\`\u2192 \u5F39\u7A97\u6253\u5F00\u300C\u2026\u300D\`/\`\u2192 \u5F39\u7A97\u5173\u95ED\`). Your own action is
1159
+ bracketed by \`\u4F60\u5F53\u524D\u7684\u884C\u4E3A\` dividers. So if the user closed a dialog you just opened, you see
1160
+ their \`T key:Escape \u2192 \u5F39\u7A97\u5173\u95ED\` right after your \`S\` action \u2014 no need to query for it.
1161
+
1162
+ **Beyond click/fill/press** \u2014 four more interactions for what those can't reach:
1163
+
1164
+ \`\`\`bash
1165
+ curl '${base}/scrollIntoView?text=Row 99' # scroll a target into view (off-screen list rows)
1166
+ curl '${base}/drag?sel=.item&to=.slot' # synthetic pointer drag, source \u2192 destination
1167
+ curl '${base}/drop?sel=.dropzone&files=a.png,b.pdf' # fire a file-drop (DataTransfer) on a target
1168
+ curl '${base}/clipboard?mode=write&value=hi' # seed the clipboard (mode=read reports it back)
1169
+ \`\`\`
1170
+
1171
+ \`drag\` fires a real pointer sequence (down \u2192 stepped moves past dnd-kit's activation
1172
+ distance \u2192 up); if a dnd-kit reorder doesn't take, retry the same gesture via \`realclick\`
1173
+ (trusted events). \`drop\` delivers the drop event with the named files (synthetic Files have
1174
+ no byte content \u2014 fine for triggering handlers, not for real uploads). \`clipboard\` needs the
1175
+ tab focused (browser security) and says so plainly when it isn't, rather than hanging.
1176
+
1177
+ A control tagged \`{needs-trusted?}\` in \`/screen\` or \`/dom\` opens a popup (\`aria-haspopup\`)
1178
+ that a synthetic click may not trigger \u2014 reach for \`realclick\` on it from the start instead
1179
+ of discovering it via a dead click. (Right-click-only menus carry no DOM marker, so they
1180
+ still surface only on a miss \u2014 use \`realclick\` with \`button=right\` there.)
1181
+
1182
+ **Multiple tabs.** Every read/drive command takes \`?tab=<id>\` to address one specific tab \u2014
1183
+ including a **background** one (you can drive the Chat tab while the user is looking at a
1184
+ different tab). Run \`${base}/tabs\` to see the live ids. With one tab open, omit \`?tab=\` and
1185
+ it just works. With several tabs open and no \`?tab=\`, the command returns \`409\` + the tab
1186
+ list (rather than randomly hitting one) \u2014 pick an id from it and retry with \`?tab=\`.
1187
+
1188
+ **Multiple servers (federation).** When several dev servers run at once \u2014 a micro-frontend,
1189
+ separate front/back servers, or a teammate's machine \u2014 every command also takes
1190
+ \`?host=<host:port>\` to reach a *sibling* aipeek. The plugin you curl reverse-proxies the
1191
+ request to that peer (server-side, no browser): \`${base}/screen?host=localhost:5174\` reads
1192
+ the app on :5174; combine with \`?tab=\` to point at one tab over there
1193
+ (\`?host=192.168.1.9:5173&tab=t3\`). Omit \`?host=\` and it's the local server as always. There's
1194
+ no registry \u2014 you name the peer, so list its tabs with \`/tabs?host=<host:port>\` first.
1195
+
1196
+ **Cross-tab timeline.** \`${base}/timeline\` interleaves the semantic actions of *every* tab
1197
+ in time order \u2014 each line \`<tab> [T|S] <action> \u2192 <ui change>\` (\`T\`=trusted human,
1198
+ \`S\`=synthetic aipeek). The per-action \`--- recent actions ---\` tail only shows the acting
1199
+ tab; \`/timeline\` is the group view, so an A/B comparison across two tabs (drive A, watch B
1200
+ react) is one read. \`?tab=<id>\` filters to one tab's history.
1201
+
837
1202
  **Chain \u2014 a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
838
1203
  each step settles before the next, stops on first failure:
839
1204
 
@@ -842,10 +1207,18 @@ curl -X POST ${base}/chain -d '[
842
1207
  {"type":"click","sel":"button[title=\\"\u77E5\u8BC6\u5E93\\"]"},
843
1208
  {"type":"wait","text":"Done"},
844
1209
  {"type":"fill","sel":"textarea","value":"hi"},
1210
+ {"type":"assert","screen":"\u6D41\u5F0F\u4E2D","equals":"false"},
845
1211
  {"type":"press","key":"Enter"}
846
1212
  ]'
847
1213
  \`\`\`
848
1214
 
1215
+ \`assert\` is the chain's mid-step judge: \`{type,screen,equals}\` checks a domain variable
1216
+ (from the app's \`window.__AIPEEK_SCREEN__\`), or \`{type,sel,equals}\` an element's text. On
1217
+ mismatch the chain stops and reports \`asserted X=="Y", actual "Z"\` \u2014 a test, not a guess.
1218
+ Domain variables also show up in \`/screen\`'s \`domain:\` block and in every \`--- changed ---\`
1219
+ diff (e.g. \`\u6D41\u5F0F\u4E2D: false \u2192 true\`) \u2014 the app's own state machine, which a DOM-only inspector
1220
+ can't see. The app opts in by setting \`window.__AIPEEK_SCREEN__ = () => ({...})\`.
1221
+
849
1222
  **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
850
1223
  JS in the page and returns the result \u2014 for what the typed endpoints can't do (install listeners,
851
1224
  read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
@@ -881,9 +1254,35 @@ function aipeekPlugin() {
881
1254
  let pendingResolve = null;
882
1255
  let server;
883
1256
  let lastRaw = null;
1257
+ let perfBaseline = null;
884
1258
  let pushTimer;
885
1259
  const pendingActions = /* @__PURE__ */ new Map();
886
1260
  let actionId = 0;
1261
+ const tabs = /* @__PURE__ */ new Map();
1262
+ function seen(data) {
1263
+ if (!data.tab)
1264
+ return;
1265
+ const prev = tabs.get(data.tab);
1266
+ tabs.set(data.tab, {
1267
+ id: data.tab,
1268
+ url: _nullishCoalesce(_nullishCoalesce(data.url, () => ( _optionalChain([prev, 'optionalAccess', _23 => _23.url]))), () => ( "")),
1269
+ title: _nullishCoalesce(_nullishCoalesce(data.title, () => ( _optionalChain([prev, 'optionalAccess', _24 => _24.title]))), () => ( "")),
1270
+ visible: _nullishCoalesce(_nullishCoalesce(data.visible, () => ( _optionalChain([prev, 'optionalAccess', _25 => _25.visible]))), () => ( false)),
1271
+ lastSeen: Date.now()
1272
+ });
1273
+ }
1274
+ const liveTabs = () => [...tabs.values()].filter((t) => isLive(t, Date.now()));
1275
+ const diagnose2 = (tab) => diagnose(tab, [...tabs.values()], Date.now(), server.ws.clients.size, server.config.server.port || 5173);
1276
+ const actionLog = [];
1277
+ const BOOT_ID = Date.now().toString(36);
1278
+ const mergeActions = (tab, actions) => {
1279
+ if (!tab || !_optionalChain([actions, 'optionalAccess', _26 => _26.length]))
1280
+ return;
1281
+ for (const entry of actions) {
1282
+ if (!actionLog.some((e) => e.tab === tab && e.ts === entry.ts))
1283
+ appendAction(actionLog, tab, entry, 200);
1284
+ }
1285
+ };
887
1286
  const cdpQueue = [];
888
1287
  let cdpWaiter = null;
889
1288
  const cdpResults = /* @__PURE__ */ new Map();
@@ -909,14 +1308,53 @@ function aipeekPlugin() {
909
1308
  let pendingScreen = null;
910
1309
  const pendingEvals = /* @__PURE__ */ new Map();
911
1310
  let evalId = 0;
1311
+ const screenStash = /* @__PURE__ */ new Map();
1312
+ let screenToken = 0;
1313
+ function stashScreen(r) {
1314
+ const token = `t${++screenToken}`;
1315
+ screenStash.set(token, { snap: r.snap, console: r.console, network: r.network, errors: r.errors });
1316
+ if (screenStash.size > 32)
1317
+ screenStash.delete(screenStash.keys().next().value);
1318
+ return token;
1319
+ }
912
1320
  const VISIBLE_MS = 400;
913
- function twoPhase(event, payload, arm, fullMs = 3e3) {
1321
+ function twoPhase(event, payload, arm, fullMs = 3e3, tab) {
914
1322
  return new Promise((resolve2, reject) => {
915
1323
  let settled = false;
916
1324
  const clear = arm((v) => {
917
1325
  settled = true;
918
1326
  resolve2(v);
919
1327
  });
1328
+ if (tab) {
1329
+ const RETRY_MS = 500;
1330
+ const ABSENT_CEILING_MS = 1e4;
1331
+ const startedAt = Date.now();
1332
+ const deliver = () => server.hot.send(event, { ...payload, tab });
1333
+ deliver();
1334
+ const iv = setInterval(() => {
1335
+ if (settled) {
1336
+ clearInterval(iv);
1337
+ return;
1338
+ }
1339
+ const t = tabs.get(tab);
1340
+ const live = !!t && isLive(t, Date.now());
1341
+ const elapsed = Date.now() - startedAt;
1342
+ if (live) {
1343
+ if (elapsed > fullMs) {
1344
+ clearInterval(iv);
1345
+ clear();
1346
+ reject(new Error(diagnose2(tab)));
1347
+ }
1348
+ } else if (elapsed > ABSENT_CEILING_MS) {
1349
+ clearInterval(iv);
1350
+ clear();
1351
+ reject(new Error(diagnose2(tab)));
1352
+ } else {
1353
+ deliver();
1354
+ }
1355
+ }, RETRY_MS);
1356
+ return;
1357
+ }
920
1358
  server.hot.send(event, { ...payload, requireVisible: true });
921
1359
  setTimeout(() => {
922
1360
  if (settled)
@@ -926,36 +1364,36 @@ function aipeekPlugin() {
926
1364
  if (settled)
927
1365
  return;
928
1366
  clear();
929
- reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`));
1367
+ reject(new Error(diagnose2(tab)));
930
1368
  }, fullMs);
931
1369
  }, VISIBLE_MS);
932
1370
  });
933
1371
  }
934
- function collectFromClient() {
1372
+ function collectFromClient(tab) {
935
1373
  return twoPhase("aipeek:collect", {}, (resolve2) => {
936
1374
  pendingResolve = resolve2;
937
1375
  return () => {
938
1376
  pendingResolve = null;
939
1377
  };
940
- });
1378
+ }, 3e3, tab);
941
1379
  }
942
- function collectDomFromClient(scope, sel) {
1380
+ function collectDomFromClient(scope, sel, tab) {
943
1381
  return twoPhase("aipeek:collect-dom", { scope, sel }, (resolve2) => {
944
1382
  pendingDom = resolve2;
945
1383
  return () => {
946
1384
  pendingDom = null;
947
1385
  };
948
- });
1386
+ }, 3e3, tab);
949
1387
  }
950
- function collectScreenFromClient() {
1388
+ function collectScreenFromClient(tab) {
951
1389
  return twoPhase("aipeek:collect-screen", {}, (resolve2) => {
952
1390
  pendingScreen = resolve2;
953
1391
  return () => {
954
1392
  pendingScreen = null;
955
1393
  };
956
- });
1394
+ }, 3e3, tab);
957
1395
  }
958
- function sendAction(type, args) {
1396
+ function sendAction(type, args, tab) {
959
1397
  const id = ++actionId;
960
1398
  const fullMs = Math.max(_nullishCoalesce(args.timeout, () => ( 0)), 3e3) + 2e3;
961
1399
  return twoPhase("aipeek:action", { id, type, args }, (resolve2) => {
@@ -963,28 +1401,28 @@ function aipeekPlugin() {
963
1401
  return () => {
964
1402
  pendingActions.delete(id);
965
1403
  };
966
- }, fullMs);
1404
+ }, fullMs, tab);
967
1405
  }
968
- async function runAction(type, args) {
969
- const result = await sendAction(type, args);
1406
+ async function runAction(type, args, tab) {
1407
+ const result = await sendAction(type, args, tab);
970
1408
  lastRaw = null;
971
- if (type === "realclick" && result.ok && result.ui === void 0) {
1409
+ if (type === "realclick" && result.ok && !result.fired) {
972
1410
  const cdp = await runCdpClick(result.x, result.y, _nullishCoalesce(args.button, () => ( "left")));
973
1411
  if (!cdp.ok)
974
1412
  return { ok: false, error: `cdp click failed: ${_nullishCoalesce(cdp.error, () => ( "unknown"))}` };
975
1413
  result.detail = `${result.detail} \u2192 clicked via extension`;
976
- result.ui = await collectScreenFromClient();
1414
+ result.screen = (await collectScreenFromClient(tab)).screen;
977
1415
  }
978
1416
  return result;
979
1417
  }
980
- function evalInClient(code) {
1418
+ function evalInClient(code, tab) {
981
1419
  const id = ++evalId;
982
1420
  return twoPhase("aipeek:eval", { id, code }, (resolve2) => {
983
1421
  pendingEvals.set(id, resolve2);
984
1422
  return () => {
985
1423
  pendingEvals.delete(id);
986
1424
  };
987
- }, 8e3);
1425
+ }, 8e3, tab);
988
1426
  }
989
1427
  return {
990
1428
  name: "aipeek",
@@ -1012,12 +1450,16 @@ function aipeekPlugin() {
1012
1450
  server = _server;
1013
1451
  injectClaudeMd(server.config.root, server.config.server.port || 5173);
1014
1452
  server.hot.on("aipeek:state", (data) => {
1453
+ seen(data);
1454
+ mergeActions(data.tab, data.actions);
1015
1455
  if (pendingResolve) {
1016
1456
  pendingResolve(data);
1017
1457
  pendingResolve = null;
1018
1458
  }
1019
1459
  });
1460
+ server.hot.on("aipeek:hello", (data) => seen(data));
1020
1461
  server.hot.on("aipeek:result", (data) => {
1462
+ seen(data);
1021
1463
  const resolve2 = pendingActions.get(data.id);
1022
1464
  if (resolve2) {
1023
1465
  pendingActions.delete(data.id);
@@ -1025,6 +1467,7 @@ function aipeekPlugin() {
1025
1467
  }
1026
1468
  });
1027
1469
  server.hot.on("aipeek:eval-result", (data) => {
1470
+ seen(data);
1028
1471
  const resolve2 = pendingEvals.get(data.id);
1029
1472
  if (resolve2) {
1030
1473
  pendingEvals.delete(data.id);
@@ -1032,14 +1475,16 @@ function aipeekPlugin() {
1032
1475
  }
1033
1476
  });
1034
1477
  server.hot.on("aipeek:dom", (data) => {
1478
+ seen(data);
1035
1479
  if (pendingDom) {
1036
1480
  pendingDom(data.dom);
1037
1481
  pendingDom = null;
1038
1482
  }
1039
1483
  });
1040
1484
  server.hot.on("aipeek:screen", (data) => {
1485
+ seen(data);
1041
1486
  if (pendingScreen) {
1042
- pendingScreen(data.screen);
1487
+ pendingScreen(data);
1043
1488
  pendingScreen = null;
1044
1489
  }
1045
1490
  });
@@ -1063,7 +1508,56 @@ function aipeekPlugin() {
1063
1508
  const url = new URL(req.url || "/", "http://localhost");
1064
1509
  const parts = url.pathname.split("/").filter(Boolean);
1065
1510
  const full = url.searchParams.has("full");
1511
+ const tab = url.searchParams.get("tab") || void 0;
1512
+ const host = url.searchParams.get("host") || void 0;
1513
+ const selfPort = server.config.server.port || 5173;
1514
+ const selfHosts = /* @__PURE__ */ new Set([`localhost:${selfPort}`, `127.0.0.1:${selfPort}`, `:${selfPort}`, `${selfPort}`]);
1515
+ if (host && !selfHosts.has(host)) {
1516
+ const fwd = new URL(url);
1517
+ fwd.searchParams.delete("host");
1518
+ const target = `http://${host}/__aipeek/${parts.join("/")}${fwd.search}`;
1519
+ try {
1520
+ const body = req.method === "POST" ? await readBody(req) : void 0;
1521
+ const r = await fetch(target, { method: req.method, body });
1522
+ send(res, r.status, await r.text());
1523
+ } catch (e) {
1524
+ const code = _optionalChain([e, 'access', _27 => _27.cause, 'optionalAccess', _28 => _28.code]);
1525
+ const why = code === "ECONNREFUSED" ? `nothing is listening on ${host} \u2014 its dev server isn't running (start it), or the port is wrong.` : code === "ENOTFOUND" ? `host '${host}' doesn't resolve \u2014 check the hostname.` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" ? `connection to ${host} timed out \u2014 it's unreachable (firewall, or wrong host).` : `${e.message} (code ${_nullishCoalesce(code, () => ( "unknown"))}).`;
1526
+ send(res, 502, `cannot reach aipeek peer at ${host}: ${why}`);
1527
+ }
1528
+ return;
1529
+ }
1530
+ if (parts[0] === "ping") {
1531
+ send(res, 200, BOOT_ID);
1532
+ return;
1533
+ }
1534
+ if (parts[0] === "help") {
1535
+ send(res, 200, helpText(selfPort).trim());
1536
+ return;
1537
+ }
1538
+ const ambiguous = () => !tab && !["tabs", "timeline", "cdp"].includes(parts[0]) && liveTabs().length > 1;
1539
+ const refuse = () => send(res, 409, `multiple live tabs \u2014 add ?tab=<id>:
1540
+
1541
+ ${formatTabs(liveTabs(), Date.now())}`);
1066
1542
  try {
1543
+ if (parts[0] === "tabs") {
1544
+ const roster = formatTabs(liveTabs(), Date.now());
1545
+ send(res, 200, roster === "(no live tabs)" ? `(${diagnose2()})` : roster);
1546
+ return;
1547
+ }
1548
+ if (parts[0] === "timeline") {
1549
+ const targets = tab ? [tab] : liveTabs().map((t) => t.id);
1550
+ for (const id of targets)
1551
+ await collectFromClient(id).catch(() => {
1552
+ });
1553
+ const entries = tab ? actionLog.filter((e) => e.tab === tab) : actionLog;
1554
+ send(res, 200, formatActions(entries));
1555
+ return;
1556
+ }
1557
+ if (ambiguous()) {
1558
+ refuse();
1559
+ return;
1560
+ }
1067
1561
  if (parts[0] === "eval") {
1068
1562
  let code = url.searchParams.get("code") || "";
1069
1563
  if (!code && req.method === "POST")
@@ -1072,21 +1566,40 @@ function aipeekPlugin() {
1072
1566
  send(res, 400, "eval needs ?code= or a POST body");
1073
1567
  return;
1074
1568
  }
1075
- const r = await evalInClient(code);
1569
+ const r = await evalInClient(code, tab);
1076
1570
  send(res, r.ok ? 200 : 422, r.ok ? _nullishCoalesce(r.value, () => ( "undefined")) : `error: ${r.error}`);
1077
1571
  return;
1078
1572
  }
1079
1573
  if (parts[0] === "dom") {
1080
1574
  const dom = await collectDomFromClient(
1081
1575
  url.searchParams.get("scope") || void 0,
1082
- url.searchParams.get("sel") || void 0
1576
+ url.searchParams.get("sel") || void 0,
1577
+ tab
1083
1578
  );
1084
1579
  send(res, 200, dom || "(empty)");
1085
1580
  return;
1086
1581
  }
1087
1582
  if (parts[0] === "screen") {
1088
- const screen = await collectScreenFromClient();
1089
- send(res, 200, screen || "(empty)");
1583
+ const reply = await collectScreenFromClient(tab);
1584
+ const since = url.searchParams.get("since");
1585
+ const token = stashScreen(reply);
1586
+ if (since) {
1587
+ const prev = screenStash.get(since);
1588
+ if (!prev) {
1589
+ send(res, 422, `unknown since token "${since}" (expired or never issued) \u2014 read /screen first for a fresh token`);
1590
+ return;
1591
+ }
1592
+ const d = diffState(
1593
+ { ui: "", console: prev.console, network: prev.network, errors: prev.errors, state: {}, url: "", timestamp: 0 },
1594
+ { ui: "", console: reply.console, network: reply.network, errors: reply.errors, state: {}, url: "", timestamp: 0 }
1595
+ );
1596
+ const changed = diffScreen(prev.snap, reply.snap, d.newErrors, d.newExceptions, d.newFailedRequests);
1597
+ send(res, 200, `token: ${token}
1598
+ ${changed.length ? changed.join("\n") : "(no state change)"}`);
1599
+ return;
1600
+ }
1601
+ send(res, 200, reply.screen ? `token: ${token}
1602
+ ${reply.screen}` : "(empty)");
1090
1603
  return;
1091
1604
  }
1092
1605
  if (parts[0] === "cdp" && parts[1] === "poll") {
@@ -1140,7 +1653,7 @@ function aipeekPlugin() {
1140
1653
  }
1141
1654
  lastRaw = null;
1142
1655
  const lines = [];
1143
- let lastUi = "";
1656
+ let lastActions = "";
1144
1657
  let allOk = true;
1145
1658
  for (let i = 0; i < steps.length; i++) {
1146
1659
  const { type, ...args } = steps[i];
@@ -1150,24 +1663,25 @@ function aipeekPlugin() {
1150
1663
  allOk = false;
1151
1664
  break;
1152
1665
  }
1153
- const r = await runAction(type, args);
1666
+ const r = await runAction(type, args, tab);
1154
1667
  lines.push(`[${i}] ${r.ok ? "\u2713" : "\u2717"} ${type}: ${r.ok ? r.detail || "ok" : r.error}`);
1155
1668
  if (r.screen)
1156
1669
  lines.push(r.screen.split("\n").map((l) => ` ${l}`).join("\n"));
1157
- if (r.ui)
1158
- lastUi = r.ui;
1670
+ if (r.actions)
1671
+ lastActions = r.actions;
1159
1672
  if (!r.ok) {
1160
1673
  allOk = false;
1161
1674
  break;
1162
1675
  }
1163
1676
  }
1164
- send(res, allOk ? 200 : 422, lastUi ? `${lines.join("\n")}
1677
+ const chainActions = lastActions ? `
1165
1678
 
1166
- --- ui after ---
1167
- ${lastUi}` : lines.join("\n"));
1679
+ --- recent actions ---
1680
+ ${lastActions}` : "";
1681
+ send(res, allOk ? 200 : 422, `${lines.join("\n")}${chainActions}`);
1168
1682
  return;
1169
1683
  }
1170
- if (["click", "fill", "press", "wait", "screenshot", "realclick", "query"].includes(parts[0])) {
1684
+ if (["click", "fill", "press", "wait", "screenshot", "realclick", "query", "assert", "drag", "scrollIntoView", "drop", "clipboard"].includes(parts[0])) {
1171
1685
  const q = url.searchParams;
1172
1686
  const args = {
1173
1687
  sel: q.get("sel") || void 0,
@@ -1178,14 +1692,19 @@ ${lastUi}` : lines.join("\n"));
1178
1692
  gone: q.has("gone") ? q.get("gone") !== "false" : void 0,
1179
1693
  button: q.get("button") === "right" ? "right" : q.get("button") === "left" ? "left" : void 0,
1180
1694
  x: q.has("x") ? Number(q.get("x")) : void 0,
1181
- y: q.has("y") ? Number(q.get("y")) : void 0
1695
+ y: q.has("y") ? Number(q.get("y")) : void 0,
1696
+ screen: q.get("screen") || void 0,
1697
+ equals: q.has("equals") ? q.get("equals") : void 0,
1698
+ to: q.get("to") || void 0,
1699
+ files: q.has("files") ? q.get("files").split(",").map((s) => s.trim()).filter(Boolean) : void 0,
1700
+ mode: q.get("mode") === "write" ? "write" : q.get("mode") === "read" ? "read" : void 0
1182
1701
  };
1183
1702
  const check2 = resolveAction(parts[0], args);
1184
1703
  if (!check2.valid) {
1185
1704
  send(res, 400, _nullishCoalesce(check2.error, () => ( "invalid action")));
1186
1705
  return;
1187
1706
  }
1188
- const result = await runAction(parts[0], args);
1707
+ const result = await runAction(parts[0], args, tab);
1189
1708
  if (parts[0] === "screenshot" && result.dataUrl) {
1190
1709
  const dir = _path.resolve.call(void 0, server.config.root, ".aipeek");
1191
1710
  _fs.mkdirSync.call(void 0, dir, { recursive: true });
@@ -1198,32 +1717,89 @@ ${lastUi}` : lines.join("\n"));
1198
1717
  const head = result.ok ? result.detail || "ok" : `${result.error}${result.detail ? `
1199
1718
 
1200
1719
  clickable: ${result.detail}` : ""}`;
1201
- send(res, result.ok ? 200 : 422, result.ui ? `${head}
1720
+ const actionsTail = result.actions ? `
1202
1721
 
1203
- --- ui after ---
1204
- ${result.ui}` : head);
1722
+ --- recent actions ---
1723
+ ${result.actions}` : "";
1724
+ const changedTail = result.screen ? `
1725
+
1726
+ --- changed ---
1727
+ ${result.screen}` : "";
1728
+ send(res, result.ok ? 200 : 422, `${head}${actionsTail}${changedTail}`);
1205
1729
  return;
1206
1730
  }
1207
1731
  if (parts[0] === "check") {
1208
- const raw2 = await collectFromClient();
1732
+ const raw2 = await collectFromClient(tab);
1209
1733
  lastRaw = raw2;
1210
1734
  const result = check(raw2);
1211
1735
  const output = emitCheck(result);
1212
1736
  send(res, result.pass ? 200 : 417, output);
1213
1737
  return;
1214
1738
  }
1739
+ if (parts[0] === "profile") {
1740
+ const noPerfMsg = (t) => {
1741
+ const info = t ? tabs.get(t) : liveTabs()[0];
1742
+ return info ? `tab '${info.id}' is connected but BACKGROUNDED \u2014 the browser throttles rAF to ~1fps for hidden tabs, so there are no real frames to profile. You don't need to open anything: the page is already running. Ask the user to click that browser tab to the foreground and keep it there ~2s, then re-run /profile. (Profiling is the only read needing foreground \u2014 /screen and /dom work backgrounded.)` : `(${diagnose2(t)})`;
1743
+ };
1744
+ const resetPerfWindow = () => new Promise((resolve2, reject) => {
1745
+ const timeout = setTimeout(() => reject(new Error("timeout waiting for perf-reset-ack")), 3e3);
1746
+ const handler = (data) => {
1747
+ if (tab && data.tab !== tab) return;
1748
+ clearTimeout(timeout);
1749
+ server.hot.off("aipeek:perf-reset-ack", handler);
1750
+ resolve2();
1751
+ };
1752
+ server.hot.on("aipeek:perf-reset-ack", handler);
1753
+ server.hot.send("aipeek:perf-reset", { tab, requireVisible: false });
1754
+ });
1755
+ if (parts[1] === "reset") {
1756
+ await resetPerfWindow();
1757
+ send(res, 200, "perf window cleared \u2014 reproduce the interaction, then GET /profile");
1758
+ return;
1759
+ }
1760
+ if (parts[1] === "diff") {
1761
+ const raw3 = await collectFromClient(tab);
1762
+ lastRaw = raw3;
1763
+ if (!raw3.performance) {
1764
+ send(res, 200, noPerfMsg(tab));
1765
+ return;
1766
+ }
1767
+ if (!perfBaseline) {
1768
+ perfBaseline = raw3.performance;
1769
+ await resetPerfWindow();
1770
+ send(res, 200, "baseline captured + window cleared \u2014 make your fix, reproduce the interaction, then GET /profile/diff again for the verdict");
1771
+ return;
1772
+ }
1773
+ const report = diffPerformance(perfBaseline, raw3.performance);
1774
+ perfBaseline = null;
1775
+ send(res, 200, report);
1776
+ return;
1777
+ }
1778
+ const raw2 = await collectFromClient(tab);
1779
+ lastRaw = raw2;
1780
+ if (!raw2.performance) {
1781
+ send(res, 200, noPerfMsg(tab));
1782
+ return;
1783
+ }
1784
+ const hiddenNote = raw2.performance.hiddenFrames > 10 ? `
1785
+
1786
+ \u26A0 ${raw2.performance.hiddenFrames} frames skipped while tab was hidden \u2014 bring it to foreground and /profile/reset for accurate data.` : "";
1787
+ send(res, 200, detail(raw2, "profile", void 0, false) + hiddenNote);
1788
+ return;
1789
+ }
1215
1790
  if (parts.length >= 1) {
1216
1791
  if (!lastRaw)
1217
- lastRaw = await collectFromClient();
1792
+ lastRaw = await collectFromClient(tab);
1218
1793
  const result = detail(lastRaw, parts[0], parts[1], full);
1219
1794
  if (result !== null) {
1220
1795
  send(res, 200, result);
1221
1796
  return;
1222
1797
  }
1223
- send(res, 404, `not found: ${parts.join("/")}`);
1798
+ const SECTIONS2 = ["ui", "console", "network", "errors", "state", "profile"];
1799
+ send(res, 404, SECTIONS2.includes(parts[0]) ? `'${parts[0]}' is empty right now \u2014 nothing captured this window (not an error).` : `unknown section '${parts[0]}'. Valid: ${SECTIONS2.join(", ")}. (Or /screen, /dom, /tabs, /timeline, /check.)`);
1224
1800
  return;
1225
1801
  }
1226
- const raw = await collectFromClient();
1802
+ const raw = await collectFromClient(tab);
1227
1803
  lastRaw = raw;
1228
1804
  if (full) {
1229
1805
  const compacted = compact(raw);