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