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.
@@ -1,6 +1,6 @@
1
1
  import type { Plugin, ViteDevServer } from 'vite'
2
2
  import type { ActionArgs, ActionResult } from '../core/action'
3
- import type { RawState } from '../core/types'
3
+ import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, RawState, ScreenSnap, TabInfo } from '../core/types'
4
4
  import { Buffer } from 'node:buffer'
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
6
  import { dirname, resolve } from 'node:path'
@@ -10,8 +10,10 @@ import { resolveAction } from '../core/action'
10
10
  import { check } from '../core/check'
11
11
  import { compact } from '../core/compact'
12
12
  import { detail } from '../core/detail'
13
- import { diffState } from '../core/diff'
13
+ import { diffScreen, diffState } from '../core/diff'
14
14
  import { emit, emitCheck, emitDiff, emitSummary } from '../core/emit'
15
+ import { diffPerformance } from '../core/perf'
16
+ import { appendAction, diagnose as diagnoseConn, formatActions, formatTabs, isLive } from '../core/util'
15
17
 
16
18
  function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): Promise<string> {
17
19
  return new Promise((resolve) => {
@@ -49,17 +51,36 @@ function compilePatch(): string {
49
51
  return result.code
50
52
  }
51
53
 
54
+ // What lands in CLAUDE.md, resident every session. It earns that residency only by the one
55
+ // behaviour it must trigger — "go look at the live page, don't guess, don't ask the user to
56
+ // open it" — plus where to get everything else. The full command catalogue is reference, not
57
+ // trigger: the model is already curling aipeek the moment it needs a command, so the catalogue
58
+ // rides along on `curl …/help` then, instead of sitting in context every session (the exact
59
+ // always-resident cost this tool exists to avoid). 7 lines, not 134.
52
60
  function aipeekSnippet(port: number) {
53
61
  const base = `http://localhost:${port}/__aipeek`
54
62
  return `
55
63
  # aipeek — Runtime Browser Inspector
56
64
 
65
+ 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 — you read it directly, including background tabs.
66
+
67
+ - \`curl ${base}/screen\` — state-machine view of the UI (start here). \`/ui\`, \`/dom?scope=X\` for detail; \`/console\`, \`/network\`, \`/errors\`, \`/check\` for health.
68
+ - \`curl ${base}/click?text=…\` (also \`/fill\`, \`/press\`, \`/wait\`) — drive the page. POST \`/chain\` for a whole scripted interaction.
69
+ - \`curl ${base}/help\` — full command reference (every endpoint, flags, federation, examples). Read it the first time you reach for a command you don't see above.`
70
+ }
71
+
72
+ function helpText(port: number) {
73
+ const base = `http://localhost:${port}/__aipeek`
74
+ return `
75
+ # aipeek — Runtime Browser Inspector
76
+
57
77
  IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS fetch the live app state first. Do NOT guess — look at the actual browser state.
58
78
 
59
79
  ## Read state — cheapest first
60
80
 
61
81
  \`\`\`bash
62
- curl ${base}/screen # state-machine projection {view, modal, focus, knobs} — START HERE
82
+ curl ${base}/screen # state-machine projection {view, modal, focus, knobs} — START HERE (returns token: tN)
83
+ curl '${base}/screen?since=tN' # only what moved since that token (view/modal/focus + new errors), not a full snapshot
63
84
  curl ${base}/ui # React component tree — deep-dive when /screen isn't enough
64
85
  curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component — UI as text, src locations
65
86
  curl ${base} # high-density summary (ok sections → 1 line, issues → expanded)
@@ -69,10 +90,24 @@ curl ${base}/console # console logs (errors, warnings, info)
69
90
  curl ${base}/network # fetch/XHR requests with status and timing
70
91
  curl ${base}/errors # uncaught errors and unhandled rejections
71
92
  curl ${base}/state # registered store snapshots
93
+ curl ${base}/tabs # list live tabs (id, visible/background, title) for ?tab= addressing
94
+ curl ${base}/timeline # interleaved action stream across all tabs (who clicked what, in order)
95
+ curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
96
+ curl ${base}/profile # performance profiler: which component/function is burning frames (+ source lines with AIPEEK_LINES=1)
97
+ curl ${base}/profile/reset # clear the profiler window, then reproduce the interaction
98
+ curl ${base}/profile/diff # closed loop: 1st call marks baseline, fix+reproduce, 2nd call → IMPROVED/REGRESSED verdict
72
99
  \`\`\`
73
100
 
101
+ \`/query\` is the read-side twin of click/fill's \`sel=\` — assert on a specific element
102
+ (how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
103
+ Secret fields (password inputs, API-key/token fields) show \`‹redacted N chars›\` instead of
104
+ their value across \`/dom\`, \`/query\` and \`/screen\` — length stays visible, the secret doesn't.
105
+
74
106
  \`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
75
- \`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
107
+ \`?full\` for untruncated output. Each read prints a \`token: tN\` line; pass it back as
108
+ \`/screen?since=tN\` to get only the transition since that read (view/modal/focus + new
109
+ errors/failed requests), \`(no state change)\` if nothing moved — the cheap "what changed
110
+ after I acted" read, without re-paying for the unchanged 99%.
76
111
 
77
112
  To inspect or edit a component, work top-down — the full DOM is huge, a scoped view is
78
113
  accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
@@ -89,11 +124,59 @@ curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add go
89
124
  curl '${base}/screenshot?out=shot.png' # DOM→PNG into .aipeek/ (html-to-image; lossy)
90
125
  \`\`\`
91
126
 
92
- \`click\`/\`fill\`/\`press\` settle the DOM and append the resulting UI tree (\`--- ui after ---\`)
93
- to the response no follow-up read needed. On a miss, the response lists the reachable
94
- clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
127
+ \`click\`/\`fill\`/\`press\` settle the DOM and append \`--- changed ---\`: only the state-machine
128
+ transition this action caused (\`view: a b\`, \`modal: opened X\`, \`focus: …\`) plus any new
129
+ errors/failed requests not a fresh snapshot. \`(no state change)\` means nothing moved. Read
130
+ the delta, then drill into /ui or /dom for detail if you need it. On a miss, the response lists
131
+ the reachable clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
95
132
  \`curl -G ${base}/click --data-urlencode 'sel=button[title="知识库"]'\`.
96
133
 
134
+ Each \`click\`/\`fill\`/\`press\` response also carries a \`--- recent actions ---\` timeline:
135
+ the semantic page actions (yours and the user's) in order, \`T\`=trusted human / \`S\`=synthetic
136
+ aipeek, each with its resulting UI change (\`→ 弹窗打开「…」\`/\`→ 弹窗关闭\`). Your own action is
137
+ bracketed by \`你当前的行为\` dividers. So if the user closed a dialog you just opened, you see
138
+ their \`T key:Escape → 弹窗关闭\` right after your \`S\` action — no need to query for it.
139
+
140
+ **Beyond click/fill/press** — four more interactions for what those can't reach:
141
+
142
+ \`\`\`bash
143
+ curl '${base}/scrollIntoView?text=Row 99' # scroll a target into view (off-screen list rows)
144
+ curl '${base}/drag?sel=.item&to=.slot' # synthetic pointer drag, source → destination
145
+ curl '${base}/drop?sel=.dropzone&files=a.png,b.pdf' # fire a file-drop (DataTransfer) on a target
146
+ curl '${base}/clipboard?mode=write&value=hi' # seed the clipboard (mode=read reports it back)
147
+ \`\`\`
148
+
149
+ \`drag\` fires a real pointer sequence (down → stepped moves past dnd-kit's activation
150
+ distance → up); if a dnd-kit reorder doesn't take, retry the same gesture via \`realclick\`
151
+ (trusted events). \`drop\` delivers the drop event with the named files (synthetic Files have
152
+ no byte content — fine for triggering handlers, not for real uploads). \`clipboard\` needs the
153
+ tab focused (browser security) and says so plainly when it isn't, rather than hanging.
154
+
155
+ A control tagged \`{needs-trusted?}\` in \`/screen\` or \`/dom\` opens a popup (\`aria-haspopup\`)
156
+ that a synthetic click may not trigger — reach for \`realclick\` on it from the start instead
157
+ of discovering it via a dead click. (Right-click-only menus carry no DOM marker, so they
158
+ still surface only on a miss — use \`realclick\` with \`button=right\` there.)
159
+
160
+ **Multiple tabs.** Every read/drive command takes \`?tab=<id>\` to address one specific tab —
161
+ including a **background** one (you can drive the Chat tab while the user is looking at a
162
+ different tab). Run \`${base}/tabs\` to see the live ids. With one tab open, omit \`?tab=\` and
163
+ it just works. With several tabs open and no \`?tab=\`, the command returns \`409\` + the tab
164
+ list (rather than randomly hitting one) — pick an id from it and retry with \`?tab=\`.
165
+
166
+ **Multiple servers (federation).** When several dev servers run at once — a micro-frontend,
167
+ separate front/back servers, or a teammate's machine — every command also takes
168
+ \`?host=<host:port>\` to reach a *sibling* aipeek. The plugin you curl reverse-proxies the
169
+ request to that peer (server-side, no browser): \`${base}/screen?host=localhost:5174\` reads
170
+ the app on :5174; combine with \`?tab=\` to point at one tab over there
171
+ (\`?host=192.168.1.9:5173&tab=t3\`). Omit \`?host=\` and it's the local server as always. There's
172
+ no registry — you name the peer, so list its tabs with \`/tabs?host=<host:port>\` first.
173
+
174
+ **Cross-tab timeline.** \`${base}/timeline\` interleaves the semantic actions of *every* tab
175
+ in time order — each line \`<tab> [T|S] <action> → <ui change>\` (\`T\`=trusted human,
176
+ \`S\`=synthetic aipeek). The per-action \`--- recent actions ---\` tail only shows the acting
177
+ tab; \`/timeline\` is the group view, so an A/B comparison across two tabs (drive A, watch B
178
+ react) is one read. \`?tab=<id>\` filters to one tab's history.
179
+
97
180
  **Chain — a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
98
181
  each step settles before the next, stops on first failure:
99
182
 
@@ -102,12 +185,21 @@ curl -X POST ${base}/chain -d '[
102
185
  {"type":"click","sel":"button[title=\\"知识库\\"]"},
103
186
  {"type":"wait","text":"Done"},
104
187
  {"type":"fill","sel":"textarea","value":"hi"},
188
+ {"type":"assert","screen":"流式中","equals":"false"},
105
189
  {"type":"press","key":"Enter"}
106
190
  ]'
107
191
  \`\`\`
108
192
 
193
+ \`assert\` is the chain's mid-step judge: \`{type,screen,equals}\` checks a domain variable
194
+ (from the app's \`window.__AIPEEK_SCREEN__\`), or \`{type,sel,equals}\` an element's text. On
195
+ mismatch the chain stops and reports \`asserted X=="Y", actual "Z"\` — a test, not a guess.
196
+ Domain variables also show up in \`/screen\`'s \`domain:\` block and in every \`--- changed ---\`
197
+ diff (e.g. \`流式中: false → true\`) — the app's own state machine, which a DOM-only inspector
198
+ can't see. The app opts in by setting \`window.__AIPEEK_SCREEN__ = () => ({...})\`.
199
+
109
200
  **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
110
- JS in the page and returns the result — for anything the typed endpoints can't do.
201
+ JS in the page and returns the result — for what the typed endpoints can't do (install listeners,
202
+ read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
111
203
 
112
204
  aipeek auto-detects errors after HMR and prints them to the terminal — watch for \`[aipeek]\` messages.
113
205
  `
@@ -116,44 +208,140 @@ aipeek auto-detects errors after HMR and prints them to the terminal — watch f
116
208
  export const START_TAG = '<!-- AIPEEK:START -->'
117
209
  export const END_TAG = '<!-- AIPEEK:END -->'
118
210
 
119
- // Marker-based injection the block lives between START_TAG and END_TAG, so
120
- // re-injection is a deterministic splice (find markers, replace between) rather
121
- // than fuzzy line matching. New file write markers + snippet. Existing markers
122
- // replace their contents. No markers yet → append a fresh marked block.
211
+ // Marker-based injection 的纯核心:existing(文件现内容,缺文件传 null)+ port 新内容。
212
+ // 块夹在 START_TAG..END_TAG 间,再注入是确定性 splice(找标记替换中间)而非模糊行匹配。
213
+ // 缺文件 仅块;有标记 替换其内容;无标记末尾追加新块。fs 读写是 injectClaudeMd 的边界,
214
+ // 这里 0 副作用——四条分支(新建/替换/追加/补换行)全可被快照锁死。
215
+ export function renderClaudeMd(existing: string | null, port: number): string {
216
+ const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
217
+ if (existing === null)
218
+ return block
219
+ const si = existing.indexOf(START_TAG)
220
+ const ei = existing.indexOf(END_TAG)
221
+ if (si !== -1 && ei !== -1)
222
+ return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length)
223
+ const sep = existing.endsWith('\n') ? '' : '\n'
224
+ return `${existing}${sep}\n${block}`
225
+ }
226
+
123
227
  export function injectClaudeMd(root: string, port: number) {
124
228
  const path = resolve(root, 'CLAUDE.md')
125
- const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
126
229
  try {
127
- if (!existsSync(path)) {
128
- writeFileSync(path, block)
129
- return
130
- }
131
- const content = readFileSync(path, 'utf-8')
132
- const si = content.indexOf(START_TAG)
133
- const ei = content.indexOf(END_TAG)
134
- if (si !== -1 && ei !== -1) {
135
- writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
136
- return
137
- }
138
- const sep = content.endsWith('\n') ? '' : '\n'
139
- writeFileSync(path, `${content}${sep}\n${block}`)
230
+ writeFileSync(path, renderClaudeMd(existsSync(path) ? readFileSync(path, 'utf-8') : null, port))
140
231
  }
141
232
  catch {}
142
233
  }
143
234
 
235
+ // The enriched /screen reply: rendered text for display + structured snap & buffers so the
236
+ // server can diff one read against an earlier one for /screen?since=<token>.
237
+ interface ScreenReply { screen: string, snap: ScreenSnap, console: LogEntry[], network: NetworkRequest[], errors: ErrorEntry[] }
238
+
144
239
  export function aipeekPlugin(): Plugin {
145
240
  let pendingResolve: ((data: RawState) => void) | null = null
146
241
  let server: ViteDevServer
147
242
  let lastRaw: RawState | null = null
243
+ let perfBaseline: PerformanceData | null = null // /profile/diff before-snapshot
148
244
  let pushTimer: ReturnType<typeof setTimeout> | undefined
149
245
  const pendingActions = new Map<number, (r: ActionResult) => void>()
150
246
  let actionId = 0
151
247
 
248
+ // Live-tab roster. Every reply carries the client's tab id; we upsert here so commands
249
+ // can address one tab (?tab=) and so a multi-tab session reports a "which tab?" list
250
+ // instead of racing N answers. lastSeen = server-side arrival time (Node clock).
251
+ const tabs = new Map<string, TabInfo>()
252
+ function seen(data: { tab?: string, url?: string, title?: string, visible?: boolean }) {
253
+ if (!data.tab)
254
+ return
255
+ const prev = tabs.get(data.tab)
256
+ tabs.set(data.tab, {
257
+ id: data.tab,
258
+ url: data.url ?? prev?.url ?? '',
259
+ title: data.title ?? prev?.title ?? '',
260
+ visible: data.visible ?? prev?.visible ?? false,
261
+ lastSeen: Date.now(),
262
+ })
263
+ }
264
+ const liveTabs = () => [...tabs.values()].filter(t => isLive(t, Date.now()))
265
+
266
+ // Bind the pure diagnose() (the single π, in core/util) to live server state. Every
267
+ // "didn't get an answer" path routes through here — twoPhase rejects, /tabs empty roster,
268
+ // /profile tab-absent — so the state→action split lives in exactly one tested function.
269
+ const diagnose = (tab?: string) =>
270
+ diagnoseConn(tab, [...tabs.values()], Date.now(), server.ws.clients.size, server.config.server.port || 5173)
271
+
272
+ // Cross-tab action timeline. Each tab already ships its full action ring inside every
273
+ // aipeek:state reply (the same `actions` the single-tab `recent actions` tail reads); we
274
+ // merge those into a global ring keyed by tab on each collect — no separate fire-and-forget
275
+ // report path, no dependency on client-patch (which only reloads on server restart).
276
+ // GET /timeline interleaves all tabs by ts, so a multi-tab A/B comparison sees who did what.
277
+ const actionLog: ActionEntry[] = []
278
+
279
+ // Self-heal handshake: a process-level id, fresh on every server start. The injected
280
+ // client-patch polls GET /ping and reloads the page when this id changes — so a full
281
+ // server restart (which kills the HMR socket the whole action chain rides on) heals
282
+ // itself instead of stranding the page on "connection lost" until a human hits ⌘R.
283
+ const BOOT_ID = Date.now().toString(36)
284
+
285
+ const mergeActions = (tab: string | undefined, actions?: ActionEntry[]) => {
286
+ if (!tab || !actions?.length)
287
+ return
288
+ for (const entry of actions) {
289
+ if (!actionLog.some(e => e.tab === tab && e.ts === entry.ts))
290
+ appendAction(actionLog, tab, entry, 200)
291
+ }
292
+ }
293
+
294
+ // Chrome real-input channel: synthetic events can't open a Radix ContextMenu, and the
295
+ // in-page script can't reach chrome.debugger. So for a plain browser tab, realclick is a
296
+ // two-step handshake — the page resolves the element to (x,y), then the server enqueues a
297
+ // CDP command here for the extension to execute with trusted input. The extension long-polls
298
+ // /cdp/poll for the next command and POSTs the verdict to /cdp/result. Electron never touches
299
+ // this (it fires sendInputEvent in-process from the page — see client.ts).
300
+ interface CdpCommand { id: number, x: number, y: number, button: 'left' | 'right' }
301
+ const cdpQueue: CdpCommand[] = []
302
+ let cdpWaiter: ((cmd: CdpCommand | null) => void) | null = null
303
+ const cdpResults = new Map<number, (r: { ok: boolean, error?: string }) => void>()
304
+ let cdpId = 0
305
+
306
+ function runCdpClick(x: number, y: number, button: 'left' | 'right'): Promise<{ ok: boolean, error?: string }> {
307
+ const id = ++cdpId
308
+ const cmd: CdpCommand = { id, x, y, button }
309
+ return new Promise((resolve, reject) => {
310
+ cdpResults.set(id, resolve)
311
+ // hand the command to a parked poller, else queue it for the next poll
312
+ if (cdpWaiter) {
313
+ cdpWaiter(cmd)
314
+ cdpWaiter = null
315
+ }
316
+ else {
317
+ cdpQueue.push(cmd)
318
+ }
319
+ setTimeout(() => {
320
+ if (cdpResults.delete(id))
321
+ reject(new Error('cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)'))
322
+ }, 10000)
323
+ })
324
+ }
325
+
152
326
  let pendingDom: ((dom: string) => void) | null = null
153
- let pendingScreen: ((screen: string) => void) | null = null
327
+ let pendingScreen: ((reply: ScreenReply) => void) | null = null
154
328
  const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
155
329
  let evalId = 0
156
330
 
331
+ // /screen?since=<token> diffs the current screen against an earlier one. We stash each
332
+ // /screen read's structured snap + buffers under a monotonic token; `since` looks the
333
+ // token up and renders only the transition (diffScreen). Ring-bounded so it can't grow.
334
+ interface Stash { snap: ScreenSnap, console: LogEntry[], network: NetworkRequest[], errors: ErrorEntry[] }
335
+ const screenStash = new Map<string, Stash>()
336
+ let screenToken = 0
337
+ function stashScreen(r: ScreenReply): string {
338
+ const token = `t${++screenToken}`
339
+ screenStash.set(token, { snap: r.snap, console: r.console, network: r.network, errors: r.errors })
340
+ if (screenStash.size > 32)
341
+ screenStash.delete(screenStash.keys().next().value!)
342
+ return token
343
+ }
344
+
157
345
  // Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
158
346
  // visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
159
347
  // two drops the guard and any tab replies. `arm` installs the pending slot and
@@ -164,6 +352,7 @@ export function aipeekPlugin(): Plugin {
164
352
  payload: Record<string, unknown>,
165
353
  arm: (resolve: (v: T) => void) => () => void,
166
354
  fullMs = 3000,
355
+ tab?: string,
167
356
  ): Promise<T> {
168
357
  return new Promise<T>((resolve, reject) => {
169
358
  let settled = false
@@ -171,6 +360,49 @@ export function aipeekPlugin(): Plugin {
171
360
  settled = true
172
361
  resolve(v)
173
362
  })
363
+ // Addressed at one tab: skip the requireVisible round entirely — only that tab
364
+ // answers (skip() matches on tab id), background or not. No visibility race.
365
+ //
366
+ // Shortest-path delivery: assume the tab always exists. TAB_ID is sessionStorage-backed
367
+ // and survives the self-heal location.reload(), so a tab keeps its id across a server
368
+ // restart — it's the same descendant. Two regimes, split on whether the tab is live now:
369
+ // live → present-but-silent past fullMs is a real miss (bad sel / hung handler) →
370
+ // reject fast, exactly as before. Never re-send to a live tab (double-exec hazard).
371
+ // absent → server just restarted / page mid self-heal. Keep re-delivering every RETRY_MS
372
+ // until it re-registers (self-heal brings it back ~2-4s), bounded by ABSENT_CEILING_MS.
373
+ // Re-delivery is made idempotent client-side (__AIPEEK_DONE_ACTIONS__ de-dups by action id).
374
+ if (tab) {
375
+ const RETRY_MS = 500
376
+ const ABSENT_CEILING_MS = 10000
377
+ const startedAt = Date.now()
378
+ const deliver = () => server.hot.send(event, { ...payload, tab })
379
+ deliver()
380
+ const iv = setInterval(() => {
381
+ if (settled) {
382
+ clearInterval(iv)
383
+ return
384
+ }
385
+ const t = tabs.get(tab)
386
+ const live = !!t && isLive(t, Date.now())
387
+ const elapsed = Date.now() - startedAt
388
+ if (live) {
389
+ if (elapsed > fullMs) {
390
+ clearInterval(iv)
391
+ clear()
392
+ reject(new Error(diagnose(tab)))
393
+ }
394
+ }
395
+ else if (elapsed > ABSENT_CEILING_MS) {
396
+ clearInterval(iv)
397
+ clear()
398
+ reject(new Error(diagnose(tab)))
399
+ }
400
+ else {
401
+ deliver()
402
+ }
403
+ }, RETRY_MS)
404
+ return
405
+ }
174
406
  server.hot.send(event, { ...payload, requireVisible: true })
175
407
  setTimeout(() => {
176
408
  if (settled)
@@ -181,40 +413,40 @@ export function aipeekPlugin(): Plugin {
181
413
  if (settled)
182
414
  return
183
415
  clear()
184
- reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`))
416
+ reject(new Error(diagnose(tab)))
185
417
  }, fullMs)
186
418
  }, VISIBLE_MS)
187
419
  })
188
420
  }
189
421
 
190
- function collectFromClient(): Promise<RawState> {
422
+ function collectFromClient(tab?: string): Promise<RawState> {
191
423
  return twoPhase<RawState>('aipeek:collect', {}, (resolve) => {
192
424
  pendingResolve = resolve
193
425
  return () => {
194
426
  pendingResolve = null
195
427
  }
196
- })
428
+ }, 3000, tab)
197
429
  }
198
430
 
199
- function collectDomFromClient(scope?: string, sel?: string): Promise<string> {
431
+ function collectDomFromClient(scope?: string, sel?: string, tab?: string): Promise<string> {
200
432
  return twoPhase<string>('aipeek:collect-dom', { scope, sel }, (resolve) => {
201
433
  pendingDom = resolve
202
434
  return () => {
203
435
  pendingDom = null
204
436
  }
205
- })
437
+ }, 3000, tab)
206
438
  }
207
439
 
208
- function collectScreenFromClient(): Promise<string> {
209
- return twoPhase<string>('aipeek:collect-screen', {}, (resolve) => {
440
+ function collectScreenFromClient(tab?: string): Promise<ScreenReply> {
441
+ return twoPhase<ScreenReply>('aipeek:collect-screen', {}, (resolve) => {
210
442
  pendingScreen = resolve
211
443
  return () => {
212
444
  pendingScreen = null
213
445
  }
214
- })
446
+ }, 3000, tab)
215
447
  }
216
448
 
217
- function sendAction(type: string, args: ActionArgs): Promise<ActionResult> {
449
+ function sendAction(type: string, args: ActionArgs, tab?: string): Promise<ActionResult> {
218
450
  const id = ++actionId
219
451
  // wait actions own their timeout; give the channel that long + slack
220
452
  const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
@@ -223,17 +455,35 @@ export function aipeekPlugin(): Plugin {
223
455
  return () => {
224
456
  pendingActions.delete(id)
225
457
  }
226
- }, fullMs)
458
+ }, fullMs, tab)
459
+ }
460
+
461
+ // sendAction + the Chrome realclick handshake, in one place so the single endpoint and
462
+ // /chain both get it. The page resolves realclick to (x,y): if result.fired, Electron
463
+ // already fired the trusted click in-process — done. Otherwise (plain Chrome tab) the page
464
+ // couldn't click, so drive the extension's CDP queue with the coords, then collect the
465
+ // settled screen. A CDP failure comes back as a normal ok:false result.
466
+ async function runAction(type: string, args: ActionArgs, tab?: string): Promise<ActionResult> {
467
+ const result = await sendAction(type, args, tab)
468
+ lastRaw = null // page mutated; force fresh collect next read
469
+ if (type === 'realclick' && result.ok && !result.fired) {
470
+ const cdp = await runCdpClick(result.x!, result.y!, args.button ?? 'left')
471
+ if (!cdp.ok)
472
+ return { ok: false, error: `cdp click failed: ${cdp.error ?? 'unknown'}` }
473
+ result.detail = `${result.detail} → clicked via extension`
474
+ result.screen = (await collectScreenFromClient(tab)).screen
475
+ }
476
+ return result
227
477
  }
228
478
 
229
- function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
479
+ function evalInClient(code: string, tab?: string): Promise<{ ok: boolean, value?: string, error?: string }> {
230
480
  const id = ++evalId
231
481
  return twoPhase('aipeek:eval', { id, code }, (resolve) => {
232
482
  pendingEvals.set(id, resolve)
233
483
  return () => {
234
484
  pendingEvals.delete(id)
235
485
  }
236
- }, 8000)
486
+ }, 8000, tab)
237
487
  }
238
488
 
239
489
  return {
@@ -265,13 +515,20 @@ export function aipeekPlugin(): Plugin {
265
515
  injectClaudeMd(server.config.root, server.config.server.port || 5173)
266
516
 
267
517
  server.hot.on('aipeek:state', (data: RawState) => {
518
+ seen(data)
519
+ mergeActions(data.tab, data.actions)
268
520
  if (pendingResolve) {
269
521
  pendingResolve(data)
270
522
  pendingResolve = null
271
523
  }
272
524
  })
273
525
 
274
- server.hot.on('aipeek:result', (data: ActionResult & { id: number }) => {
526
+ // Client announces itself on connect (and on visibilitychange) the registration
527
+ // edge the roster otherwise lacks, so /tabs is accurate without first being polled.
528
+ server.hot.on('aipeek:hello', (data: { tab?: string, url?: string, title?: string, visible?: boolean }) => seen(data))
529
+
530
+ server.hot.on('aipeek:result', (data: ActionResult & { id: number, tab?: string }) => {
531
+ seen(data)
275
532
  const resolve = pendingActions.get(data.id)
276
533
  if (resolve) {
277
534
  pendingActions.delete(data.id)
@@ -279,7 +536,8 @@ export function aipeekPlugin(): Plugin {
279
536
  }
280
537
  })
281
538
 
282
- server.hot.on('aipeek:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string }) => {
539
+ server.hot.on('aipeek:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string, tab?: string }) => {
540
+ seen(data)
283
541
  const resolve = pendingEvals.get(data.id)
284
542
  if (resolve) {
285
543
  pendingEvals.delete(data.id)
@@ -287,16 +545,18 @@ export function aipeekPlugin(): Plugin {
287
545
  }
288
546
  })
289
547
 
290
- server.hot.on('aipeek:dom', (data: { dom: string }) => {
548
+ server.hot.on('aipeek:dom', (data: { dom: string, tab?: string }) => {
549
+ seen(data)
291
550
  if (pendingDom) {
292
551
  pendingDom(data.dom)
293
552
  pendingDom = null
294
553
  }
295
554
  })
296
555
 
297
- server.hot.on('aipeek:screen', (data: { screen: string }) => {
556
+ server.hot.on('aipeek:screen', (data: ScreenReply & { tab?: string }) => {
557
+ seen(data)
298
558
  if (pendingScreen) {
299
- pendingScreen(data.screen)
559
+ pendingScreen(data)
300
560
  pendingScreen = null
301
561
  }
302
562
  })
@@ -326,8 +586,100 @@ export function aipeekPlugin(): Plugin {
326
586
  const url = new URL(req.url || '/', 'http://localhost')
327
587
  const parts = url.pathname.split('/').filter(Boolean)
328
588
  const full = url.searchParams.has('full')
589
+ const tab = url.searchParams.get('tab') || undefined
590
+
591
+ // Federation: ?host=<host:port> reverse-proxies this request to a *sibling*
592
+ // aipeek (another dev server — micro-frontend, separate front/back servers, a
593
+ // teammate's machine). N peers, no registry, no discovery, no central router:
594
+ // each plugin is already an HTTP server, so any one of them proxies to a named
595
+ // peer via a server-side fetch (no browser, no CORS). The forwarded URL drops
596
+ // host= so the peer treats it as local — `host===self` and re-forwarding both
597
+ // collapse to the normal path. ?host= is a routing directive, not stored state.
598
+ const host = url.searchParams.get('host') || undefined
599
+ const selfPort = server.config.server.port || 5173
600
+ const selfHosts = new Set([`localhost:${selfPort}`, `127.0.0.1:${selfPort}`, `:${selfPort}`, `${selfPort}`])
601
+ if (host && !selfHosts.has(host)) {
602
+ const fwd = new URL(url)
603
+ fwd.searchParams.delete('host')
604
+ const target = `http://${host}/__aipeek/${parts.join('/')}${fwd.search}`
605
+ try {
606
+ const body = req.method === 'POST' ? await readBody(req) : undefined
607
+ const r = await fetch(target, { method: req.method, body })
608
+ send(res, r.status, await r.text())
609
+ }
610
+ catch (e) {
611
+ // Split the failure fibers — each needs a different fix. node's fetch nests
612
+ // the syscall error under .cause.code.
613
+ const code = (e as { cause?: { code?: string } }).cause?.code
614
+ const why = code === 'ECONNREFUSED'
615
+ ? `nothing is listening on ${host} — its dev server isn't running (start it), or the port is wrong.`
616
+ : code === 'ENOTFOUND'
617
+ ? `host '${host}' doesn't resolve — check the hostname.`
618
+ : code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
619
+ ? `connection to ${host} timed out — it's unreachable (firewall, or wrong host).`
620
+ : `${(e as Error).message} (code ${code ?? 'unknown'}).`
621
+ send(res, 502, `cannot reach aipeek peer at ${host}: ${why}`)
622
+ }
623
+ return
624
+ }
625
+
626
+ // /__aipeek/ping — process-level BOOT_ID, polled by the injected client-patch
627
+ // to self-heal after a server restart (see BOOT_ID). Server-self health, no tab,
628
+ // highest frequency — short-circuit before everything else.
629
+ if (parts[0] === 'ping') {
630
+ send(res, 200, BOOT_ID)
631
+ return
632
+ }
633
+
634
+ // /__aipeek/help — the full command reference. Static, tab-independent: this is
635
+ // the body that used to sit in CLAUDE.md every session. It moved here so the model
636
+ // pulls it on demand (it's already curling aipeek when it needs a command) instead
637
+ // of paying for it resident. The injected snippet points here.
638
+ if (parts[0] === 'help') {
639
+ send(res, 200, helpText(selfPort).trim())
640
+ return
641
+ }
642
+
643
+ // Multi-tab guard: with >1 live tab and no ?tab=, a broadcast would race N
644
+ // answers and keep a random one. Refuse and show the roster so the caller
645
+ // picks. Single tab (or addressed) falls through to the normal path.
646
+ // /tabs, /timeline and /cdp/* are exempt (server-side aggregate reads / polling).
647
+ const ambiguous = () => !tab && !['tabs', 'timeline', 'cdp'].includes(parts[0]) && liveTabs().length > 1
648
+ const refuse = () => send(res, 409, `multiple live tabs — add ?tab=<id>:\n\n${formatTabs(liveTabs(), Date.now())}`)
329
649
 
330
650
  try {
651
+ // /__aipeek/tabs — list live clients (tab id, visibility, title, url, age).
652
+ // On an empty roster, defer to the single diagnose() projection so the
653
+ // "page open but not injected" vs "no browser at all" split is never re-derived
654
+ // here — one π, one place.
655
+ if (parts[0] === 'tabs') {
656
+ const roster = formatTabs(liveTabs(), Date.now())
657
+ send(res, 200, roster === '(no live tabs)' ? `(${diagnose()})` : roster)
658
+ return
659
+ }
660
+
661
+ // /__aipeek/timeline — interleaved action stream across all tabs (server's
662
+ // global ring), rendered by the same formatActions as the single-tab tail.
663
+ // With >1 tab, lines are prefixed with the tab id; ?tab= filters to one.
664
+ // Pull a fresh collect from each addressed tab first so the ring is current at
665
+ // read time — actions flush into actionLog via the aipeek:state merge, decoupled
666
+ // from whichever read happened before (a /screen wouldn't have flushed them).
667
+ if (parts[0] === 'timeline') {
668
+ // Sequential, not parallel: collectFromClient shares one module-level
669
+ // pendingResolve, so concurrent collects would clobber each other.
670
+ const targets = tab ? [tab] : liveTabs().map(t => t.id)
671
+ for (const id of targets)
672
+ await collectFromClient(id).catch(() => {})
673
+ const entries = tab ? actionLog.filter(e => e.tab === tab) : actionLog
674
+ send(res, 200, formatActions(entries))
675
+ return
676
+ }
677
+
678
+ if (ambiguous()) {
679
+ refuse()
680
+ return
681
+ }
682
+
331
683
  // /__aipeek/eval — run arbitrary JS in the page. POST body = code,
332
684
  // or ?code=. The page evaluates it and returns the result (or thrown
333
685
  // error). The escape hatch for anything the typed endpoints can't do:
@@ -340,7 +692,7 @@ export function aipeekPlugin(): Plugin {
340
692
  send(res, 400, 'eval needs ?code= or a POST body')
341
693
  return
342
694
  }
343
- const r = await evalInClient(code)
695
+ const r = await evalInClient(code, tab)
344
696
  send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
345
697
  return
346
698
  }
@@ -350,15 +702,80 @@ export function aipeekPlugin(): Plugin {
350
702
  const dom = await collectDomFromClient(
351
703
  url.searchParams.get('scope') || undefined,
352
704
  url.searchParams.get('sel') || undefined,
705
+ tab,
353
706
  )
354
707
  send(res, 200, dom || '(empty)')
355
708
  return
356
709
  }
357
710
 
358
- // /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
711
+ // /__aipeek/screen — state-machine projection {view, modal, focus, knobs}.
712
+ // Each read stashes its snap under a token and prints `token: tN`. With
713
+ // ?since=<token> we diff this read against that stashed snap and return only
714
+ // the transition (diffScreen) — what moved since you last looked, not a snapshot.
359
715
  if (parts[0] === 'screen') {
360
- const screen = await collectScreenFromClient()
361
- send(res, 200, screen || '(empty)')
716
+ const reply = await collectScreenFromClient(tab)
717
+ const since = url.searchParams.get('since')
718
+ const token = stashScreen(reply)
719
+ if (since) {
720
+ const prev = screenStash.get(since)
721
+ if (!prev) {
722
+ send(res, 422, `unknown since token "${since}" (expired or never issued) — read /screen first for a fresh token`)
723
+ return
724
+ }
725
+ const d = diffState(
726
+ { ui: '', console: prev.console, network: prev.network, errors: prev.errors, state: {}, url: '', timestamp: 0 },
727
+ { ui: '', console: reply.console, network: reply.network, errors: reply.errors, state: {}, url: '', timestamp: 0 },
728
+ )
729
+ const changed = diffScreen(prev.snap, reply.snap, d.newErrors, d.newExceptions, d.newFailedRequests)
730
+ send(res, 200, `token: ${token}\n${changed.length ? changed.join('\n') : '(no state change)'}`)
731
+ return
732
+ }
733
+ send(res, 200, reply.screen ? `token: ${token}\n${reply.screen}` : '(empty)')
734
+ return
735
+ }
736
+
737
+ // /__aipeek/cdp/poll — the Chrome extension long-polls here for the next
738
+ // trusted-input command. Returns the command as JSON, or 204 on timeout
739
+ // (the extension simply re-polls). Only one poller is parked at a time.
740
+ if (parts[0] === 'cdp' && parts[1] === 'poll') {
741
+ const queued = cdpQueue.shift()
742
+ if (queued) {
743
+ send(res, 200, JSON.stringify(queued))
744
+ return
745
+ }
746
+ const cmd = await new Promise<CdpCommand | null>((resolve) => {
747
+ cdpWaiter = resolve
748
+ setTimeout(() => {
749
+ if (cdpWaiter === resolve) {
750
+ cdpWaiter = null
751
+ resolve(null)
752
+ }
753
+ }, 25000)
754
+ })
755
+ if (cmd)
756
+ send(res, 200, JSON.stringify(cmd))
757
+ else
758
+ send(res, 204, '')
759
+ return
760
+ }
761
+
762
+ // /__aipeek/cdp/result — POST {id, ok, error?}; resolves the awaiting realclick.
763
+ if (parts[0] === 'cdp' && parts[1] === 'result') {
764
+ const body = await readBody(req)
765
+ let data: { id: number, ok: boolean, error?: string }
766
+ try {
767
+ data = JSON.parse(body)
768
+ }
769
+ catch {
770
+ send(res, 400, 'cdp/result needs a JSON body {id, ok, error?}')
771
+ return
772
+ }
773
+ const resolveCdp = cdpResults.get(data.id)
774
+ if (resolveCdp) {
775
+ cdpResults.delete(data.id)
776
+ resolveCdp({ ok: data.ok, error: data.error })
777
+ }
778
+ send(res, 200, 'ok')
362
779
  return
363
780
  }
364
781
 
@@ -379,7 +796,7 @@ export function aipeekPlugin(): Plugin {
379
796
  }
380
797
  lastRaw = null
381
798
  const lines: string[] = []
382
- let lastUi = ''
799
+ let lastActions = ''
383
800
  let allOk = true
384
801
  for (let i = 0; i < steps.length; i++) {
385
802
  const { type, ...args } = steps[i]
@@ -389,26 +806,26 @@ export function aipeekPlugin(): Plugin {
389
806
  allOk = false
390
807
  break
391
808
  }
392
- const r = await sendAction(type, args)
809
+ const r = await runAction(type, args, tab)
393
810
  lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
394
- // Per-step screen projection captures the transition each
395
- // mutating step caused, so a view change mid-chain is visible
396
- // at its source step rather than collapsed into the final tree.
811
+ // Per-step change — the state-machine transition each mutating step
812
+ // caused (view/modal/focus + new errors), shown at its source step.
397
813
  if (r.screen)
398
814
  lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
399
- if (r.ui)
400
- lastUi = r.ui
815
+ if (r.actions)
816
+ lastActions = r.actions
401
817
  if (!r.ok) {
402
818
  allOk = false
403
819
  break
404
820
  }
405
821
  }
406
- send(res, allOk ? 200 : 422, lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
822
+ const chainActions = lastActions ? `\n\n--- recent actions ---\n${lastActions}` : ''
823
+ send(res, allOk ? 200 : 422, `${lines.join('\n')}${chainActions}`)
407
824
  return
408
825
  }
409
826
 
410
- // action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
411
- if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
827
+ // action endpoints: /__aipeek/{click|fill|press|wait|screenshot|realclick}?...
828
+ if (['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query', 'assert', 'drag', 'scrollIntoView', 'drop', 'clipboard'].includes(parts[0])) {
412
829
  const q = url.searchParams
413
830
  const args: ActionArgs = {
414
831
  sel: q.get('sel') || undefined,
@@ -417,14 +834,21 @@ export function aipeekPlugin(): Plugin {
417
834
  key: q.get('key') || undefined,
418
835
  timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
419
836
  gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
837
+ button: q.get('button') === 'right' ? 'right' : q.get('button') === 'left' ? 'left' : undefined,
838
+ x: q.has('x') ? Number(q.get('x')) : undefined,
839
+ y: q.has('y') ? Number(q.get('y')) : undefined,
840
+ screen: q.get('screen') || undefined,
841
+ equals: q.has('equals') ? q.get('equals')! : undefined,
842
+ to: q.get('to') || undefined,
843
+ files: q.has('files') ? q.get('files')!.split(',').map(s => s.trim()).filter(Boolean) : undefined,
844
+ mode: q.get('mode') === 'write' ? 'write' : q.get('mode') === 'read' ? 'read' : undefined,
420
845
  }
421
846
  const check = resolveAction(parts[0], args)
422
847
  if (!check.valid) {
423
848
  send(res, 400, check.error ?? 'invalid action')
424
849
  return
425
850
  }
426
- const result = await sendAction(parts[0], args)
427
- lastRaw = null // page mutated; force fresh collect next read
851
+ const result = await runAction(parts[0], args, tab)
428
852
  if (parts[0] === 'screenshot' && result.dataUrl) {
429
853
  const dir = resolve(server.config.root, '.aipeek')
430
854
  mkdirSync(dir, { recursive: true })
@@ -435,13 +859,15 @@ export function aipeekPlugin(): Plugin {
435
859
  return
436
860
  }
437
861
  const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
438
- send(res, result.ok ? 200 : 422, result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
862
+ const actionsTail = result.actions ? `\n\n--- recent actions ---\n${result.actions}` : ''
863
+ const changedTail = result.screen ? `\n\n--- changed ---\n${result.screen}` : ''
864
+ send(res, result.ok ? 200 : 422, `${head}${actionsTail}${changedTail}`)
439
865
  return
440
866
  }
441
867
 
442
868
  // check endpoint
443
869
  if (parts[0] === 'check') {
444
- const raw = await collectFromClient()
870
+ const raw = await collectFromClient(tab)
445
871
  lastRaw = raw
446
872
  const result = check(raw)
447
873
  const output = emitCheck(result)
@@ -449,21 +875,107 @@ export function aipeekPlugin(): Plugin {
449
875
  return
450
876
  }
451
877
 
878
+ // /__aipeek/profile — performance profiler (always-on, semantic-bucketed).
879
+ // /profile reads the current window; /profile/reset clears it.
880
+ // Hidden tabs throttle rAF to ~1fps, making each hidden frame look like a
881
+ // 1000ms dropped frame — the profiler guards with document.hidden, but if
882
+ // hiddenFrames is high the data is suspect. Mention it.
883
+ if (parts[0] === 'profile') {
884
+ // Empty perf data is a recoverable state, not a dead end: the tab DID answer
885
+ // collectFromClient (so it's connected) — it's just backgrounded, where the
886
+ // browser throttles rAF to ~1fps and there are no real frames to sample. The
887
+ // bare "(no perf data)" reads to a model as "no browser, can't help", so it
888
+ // gives up. Say the page is already running and a 2s foreground fixes it —
889
+ // profiling is the only read that needs foreground (/screen, /dom work hidden).
890
+ // Empty perf data after a SUCCESSFUL collect is its own fiber, disjoint from
891
+ // diagnose() (which is the no-reply projection): the tab answered, so it's
892
+ // connected — it's just backgrounded, where the browser throttles rAF to ~1fps
893
+ // and there are no real frames to sample. Recoverable in 2s, not a dead end.
894
+ // The tab-absent fallback defers to diagnose() so the socket logic lives once.
895
+ const noPerfMsg = (t?: string) => {
896
+ const info = t ? tabs.get(t) : liveTabs()[0]
897
+ return info
898
+ ? `tab '${info.id}' is connected but BACKGROUNDED — 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 — /screen and /dom work backgrounded.)`
899
+ : `(${diagnose(t)})`
900
+ }
901
+ // Clear the client-side perf window and wait for ack. Used by /profile/reset
902
+ // and by /profile/diff when capturing a baseline — so "before" and "after"
903
+ // each measure ONLY their own reproduce, not an ever-growing running sum.
904
+ const resetPerfWindow = () => new Promise<void>((resolve, reject) => {
905
+ const timeout = setTimeout(() => reject(new Error('timeout waiting for perf-reset-ack')), 3000)
906
+ const handler = (data: { tab?: string }) => {
907
+ if (tab && data.tab !== tab) return
908
+ clearTimeout(timeout)
909
+ server.hot.off('aipeek:perf-reset-ack', handler)
910
+ resolve()
911
+ }
912
+ server.hot.on('aipeek:perf-reset-ack', handler)
913
+ server.hot.send('aipeek:perf-reset', { tab, requireVisible: false })
914
+ })
915
+ if (parts[1] === 'reset') {
916
+ await resetPerfWindow()
917
+ send(res, 200, 'perf window cleared — reproduce the interaction, then GET /profile')
918
+ return
919
+ }
920
+ if (parts[1] === 'diff') {
921
+ // Closed-loop verdict. First call captures a baseline; second diffs
922
+ // current vs baseline and clears it. Workflow: /profile/diff (mark before)
923
+ // → make a fix → reproduce → /profile/diff (get IMPROVED/REGRESSED verdict).
924
+ const raw = await collectFromClient(tab)
925
+ lastRaw = raw
926
+ if (!raw.performance) {
927
+ send(res, 200, noPerfMsg(tab))
928
+ return
929
+ }
930
+ if (!perfBaseline) {
931
+ perfBaseline = raw.performance
932
+ // Clear the window so the NEXT collect measures only the post-fix
933
+ // reproduce. Without this, "after" ⊇ "before" (samples append, total
934
+ // is a running sum) → self-time can only grow → IMPROVED impossible.
935
+ await resetPerfWindow()
936
+ send(res, 200, 'baseline captured + window cleared — make your fix, reproduce the interaction, then GET /profile/diff again for the verdict')
937
+ return
938
+ }
939
+ const report = diffPerformance(perfBaseline, raw.performance)
940
+ perfBaseline = null // consumed; next call starts a fresh baseline
941
+ send(res, 200, report)
942
+ return
943
+ }
944
+ // /profile — fresh collect, render detail
945
+ const raw = await collectFromClient(tab)
946
+ lastRaw = raw
947
+ if (!raw.performance) {
948
+ send(res, 200, noPerfMsg(tab))
949
+ return
950
+ }
951
+ const hiddenNote = raw.performance.hiddenFrames > 10
952
+ ? `\n\n⚠ ${raw.performance.hiddenFrames} frames skipped while tab was hidden — bring it to foreground and /profile/reset for accurate data.`
953
+ : ''
954
+ send(res, 200, detail(raw, 'profile', undefined, false) + hiddenNote)
955
+ return
956
+ }
957
+
452
958
  // detail: /__aipeek/{section}[/{index}][?full]
453
959
  if (parts.length >= 1) {
454
960
  if (!lastRaw)
455
- lastRaw = await collectFromClient()
961
+ lastRaw = await collectFromClient(tab)
456
962
  const result = detail(lastRaw, parts[0], parts[1], full)
457
963
  if (result !== null) {
458
964
  send(res, 200, result)
459
965
  return
460
966
  }
461
- send(res, 404, `not found: ${parts.join('/')}`)
967
+ // null splits two fibers: an unknown section name (→ fix the path) vs a
968
+ // known section that's simply empty (→ nothing to show, not an error). Name
969
+ // the valid sections so the caller can tell which it hit.
970
+ const SECTIONS = ['ui', 'console', 'network', 'errors', 'state', 'profile']
971
+ send(res, 404, SECTIONS.includes(parts[0])
972
+ ? `'${parts[0]}' is empty right now — nothing captured this window (not an error).`
973
+ : `unknown section '${parts[0]}'. Valid: ${SECTIONS.join(', ')}. (Or /screen, /dom, /tabs, /timeline, /check.)`)
462
974
  return
463
975
  }
464
976
 
465
977
  // summary or full: /__aipeek[?full]
466
- const raw = await collectFromClient()
978
+ const raw = await collectFromClient(tab)
467
979
  lastRaw = raw
468
980
  if (full) {
469
981
  const compacted = compact(raw)