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