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.
@@ -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,14 +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)
72
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
73
99
  \`\`\`
74
100
 
75
101
  \`/query\` is the read-side twin of click/fill's \`sel=\` — assert on a specific element
76
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.
77
105
 
78
106
  \`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
79
- \`?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%.
80
111
 
81
112
  To inspect or edit a component, work top-down — the full DOM is huge, a scoped view is
82
113
  accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
@@ -93,11 +124,59 @@ curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add go
93
124
  curl '${base}/screenshot?out=shot.png' # DOM→PNG into .aipeek/ (html-to-image; lossy)
94
125
  \`\`\`
95
126
 
96
- \`click\`/\`fill\`/\`press\` settle the DOM and append the resulting UI tree (\`--- ui after ---\`)
97
- to the response no follow-up read needed. On a miss, the response lists the reachable
98
- 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:
99
132
  \`curl -G ${base}/click --data-urlencode 'sel=button[title="知识库"]'\`.
100
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
+
101
180
  **Chain — a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
102
181
  each step settles before the next, stops on first failure:
103
182
 
@@ -106,10 +185,18 @@ curl -X POST ${base}/chain -d '[
106
185
  {"type":"click","sel":"button[title=\\"知识库\\"]"},
107
186
  {"type":"wait","text":"Done"},
108
187
  {"type":"fill","sel":"textarea","value":"hi"},
188
+ {"type":"assert","screen":"流式中","equals":"false"},
109
189
  {"type":"press","key":"Enter"}
110
190
  ]'
111
191
  \`\`\`
112
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
+
113
200
  **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
114
201
  JS in the page and returns the result — for what the typed endpoints can't do (install listeners,
115
202
  read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
@@ -145,14 +232,65 @@ export function injectClaudeMd(root: string, port: number) {
145
232
  catch {}
146
233
  }
147
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
+
148
239
  export function aipeekPlugin(): Plugin {
149
240
  let pendingResolve: ((data: RawState) => void) | null = null
150
241
  let server: ViteDevServer
151
242
  let lastRaw: RawState | null = null
243
+ let perfBaseline: PerformanceData | null = null // /profile/diff before-snapshot
152
244
  let pushTimer: ReturnType<typeof setTimeout> | undefined
153
245
  const pendingActions = new Map<number, (r: ActionResult) => void>()
154
246
  let actionId = 0
155
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
+
156
294
  // Chrome real-input channel: synthetic events can't open a Radix ContextMenu, and the
157
295
  // in-page script can't reach chrome.debugger. So for a plain browser tab, realclick is a
158
296
  // two-step handshake — the page resolves the element to (x,y), then the server enqueues a
@@ -186,10 +324,24 @@ export function aipeekPlugin(): Plugin {
186
324
  }
187
325
 
188
326
  let pendingDom: ((dom: string) => void) | null = null
189
- let pendingScreen: ((screen: string) => void) | null = null
327
+ let pendingScreen: ((reply: ScreenReply) => void) | null = null
190
328
  const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
191
329
  let evalId = 0
192
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
+
193
345
  // Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
194
346
  // visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
195
347
  // two drops the guard and any tab replies. `arm` installs the pending slot and
@@ -200,6 +352,7 @@ export function aipeekPlugin(): Plugin {
200
352
  payload: Record<string, unknown>,
201
353
  arm: (resolve: (v: T) => void) => () => void,
202
354
  fullMs = 3000,
355
+ tab?: string,
203
356
  ): Promise<T> {
204
357
  return new Promise<T>((resolve, reject) => {
205
358
  let settled = false
@@ -207,6 +360,49 @@ export function aipeekPlugin(): Plugin {
207
360
  settled = true
208
361
  resolve(v)
209
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
+ }
210
406
  server.hot.send(event, { ...payload, requireVisible: true })
211
407
  setTimeout(() => {
212
408
  if (settled)
@@ -217,40 +413,40 @@ export function aipeekPlugin(): Plugin {
217
413
  if (settled)
218
414
  return
219
415
  clear()
220
- reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`))
416
+ reject(new Error(diagnose(tab)))
221
417
  }, fullMs)
222
418
  }, VISIBLE_MS)
223
419
  })
224
420
  }
225
421
 
226
- function collectFromClient(): Promise<RawState> {
422
+ function collectFromClient(tab?: string): Promise<RawState> {
227
423
  return twoPhase<RawState>('aipeek:collect', {}, (resolve) => {
228
424
  pendingResolve = resolve
229
425
  return () => {
230
426
  pendingResolve = null
231
427
  }
232
- })
428
+ }, 3000, tab)
233
429
  }
234
430
 
235
- function collectDomFromClient(scope?: string, sel?: string): Promise<string> {
431
+ function collectDomFromClient(scope?: string, sel?: string, tab?: string): Promise<string> {
236
432
  return twoPhase<string>('aipeek:collect-dom', { scope, sel }, (resolve) => {
237
433
  pendingDom = resolve
238
434
  return () => {
239
435
  pendingDom = null
240
436
  }
241
- })
437
+ }, 3000, tab)
242
438
  }
243
439
 
244
- function collectScreenFromClient(): Promise<string> {
245
- return twoPhase<string>('aipeek:collect-screen', {}, (resolve) => {
440
+ function collectScreenFromClient(tab?: string): Promise<ScreenReply> {
441
+ return twoPhase<ScreenReply>('aipeek:collect-screen', {}, (resolve) => {
246
442
  pendingScreen = resolve
247
443
  return () => {
248
444
  pendingScreen = null
249
445
  }
250
- })
446
+ }, 3000, tab)
251
447
  }
252
448
 
253
- function sendAction(type: string, args: ActionArgs): Promise<ActionResult> {
449
+ function sendAction(type: string, args: ActionArgs, tab?: string): Promise<ActionResult> {
254
450
  const id = ++actionId
255
451
  // wait actions own their timeout; give the channel that long + slack
256
452
  const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
@@ -259,35 +455,35 @@ export function aipeekPlugin(): Plugin {
259
455
  return () => {
260
456
  pendingActions.delete(id)
261
457
  }
262
- }, fullMs)
458
+ }, fullMs, tab)
263
459
  }
264
460
 
265
461
  // sendAction + the Chrome realclick handshake, in one place so the single endpoint and
266
- // /chain both get it. The page resolves realclick to (x,y): if it set result.ui, Electron
267
- // already fired the trusted click in-process — done. If ui is undefined (plain Chrome tab),
268
- // the page couldn't click, so drive the extension's CDP queue with the coords, then collect
269
- // the settled screen as the ui. A CDP failure comes back as a normal ok:false result.
270
- async function runAction(type: string, args: ActionArgs): Promise<ActionResult> {
271
- const result = await sendAction(type, args)
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)
272
468
  lastRaw = null // page mutated; force fresh collect next read
273
- if (type === 'realclick' && result.ok && result.ui === undefined) {
469
+ if (type === 'realclick' && result.ok && !result.fired) {
274
470
  const cdp = await runCdpClick(result.x!, result.y!, args.button ?? 'left')
275
471
  if (!cdp.ok)
276
472
  return { ok: false, error: `cdp click failed: ${cdp.error ?? 'unknown'}` }
277
473
  result.detail = `${result.detail} → clicked via extension`
278
- result.ui = await collectScreenFromClient()
474
+ result.screen = (await collectScreenFromClient(tab)).screen
279
475
  }
280
476
  return result
281
477
  }
282
478
 
283
- 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 }> {
284
480
  const id = ++evalId
285
481
  return twoPhase('aipeek:eval', { id, code }, (resolve) => {
286
482
  pendingEvals.set(id, resolve)
287
483
  return () => {
288
484
  pendingEvals.delete(id)
289
485
  }
290
- }, 8000)
486
+ }, 8000, tab)
291
487
  }
292
488
 
293
489
  return {
@@ -319,13 +515,20 @@ export function aipeekPlugin(): Plugin {
319
515
  injectClaudeMd(server.config.root, server.config.server.port || 5173)
320
516
 
321
517
  server.hot.on('aipeek:state', (data: RawState) => {
518
+ seen(data)
519
+ mergeActions(data.tab, data.actions)
322
520
  if (pendingResolve) {
323
521
  pendingResolve(data)
324
522
  pendingResolve = null
325
523
  }
326
524
  })
327
525
 
328
- 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)
329
532
  const resolve = pendingActions.get(data.id)
330
533
  if (resolve) {
331
534
  pendingActions.delete(data.id)
@@ -333,7 +536,8 @@ export function aipeekPlugin(): Plugin {
333
536
  }
334
537
  })
335
538
 
336
- 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)
337
541
  const resolve = pendingEvals.get(data.id)
338
542
  if (resolve) {
339
543
  pendingEvals.delete(data.id)
@@ -341,16 +545,18 @@ export function aipeekPlugin(): Plugin {
341
545
  }
342
546
  })
343
547
 
344
- server.hot.on('aipeek:dom', (data: { dom: string }) => {
548
+ server.hot.on('aipeek:dom', (data: { dom: string, tab?: string }) => {
549
+ seen(data)
345
550
  if (pendingDom) {
346
551
  pendingDom(data.dom)
347
552
  pendingDom = null
348
553
  }
349
554
  })
350
555
 
351
- server.hot.on('aipeek:screen', (data: { screen: string }) => {
556
+ server.hot.on('aipeek:screen', (data: ScreenReply & { tab?: string }) => {
557
+ seen(data)
352
558
  if (pendingScreen) {
353
- pendingScreen(data.screen)
559
+ pendingScreen(data)
354
560
  pendingScreen = null
355
561
  }
356
562
  })
@@ -380,8 +586,100 @@ export function aipeekPlugin(): Plugin {
380
586
  const url = new URL(req.url || '/', 'http://localhost')
381
587
  const parts = url.pathname.split('/').filter(Boolean)
382
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())}`)
383
649
 
384
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
+
385
683
  // /__aipeek/eval — run arbitrary JS in the page. POST body = code,
386
684
  // or ?code=. The page evaluates it and returns the result (or thrown
387
685
  // error). The escape hatch for anything the typed endpoints can't do:
@@ -394,7 +692,7 @@ export function aipeekPlugin(): Plugin {
394
692
  send(res, 400, 'eval needs ?code= or a POST body')
395
693
  return
396
694
  }
397
- const r = await evalInClient(code)
695
+ const r = await evalInClient(code, tab)
398
696
  send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
399
697
  return
400
698
  }
@@ -404,15 +702,35 @@ export function aipeekPlugin(): Plugin {
404
702
  const dom = await collectDomFromClient(
405
703
  url.searchParams.get('scope') || undefined,
406
704
  url.searchParams.get('sel') || undefined,
705
+ tab,
407
706
  )
408
707
  send(res, 200, dom || '(empty)')
409
708
  return
410
709
  }
411
710
 
412
- // /__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.
413
715
  if (parts[0] === 'screen') {
414
- const screen = await collectScreenFromClient()
415
- 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)')
416
734
  return
417
735
  }
418
736
 
@@ -478,7 +796,7 @@ export function aipeekPlugin(): Plugin {
478
796
  }
479
797
  lastRaw = null
480
798
  const lines: string[] = []
481
- let lastUi = ''
799
+ let lastActions = ''
482
800
  let allOk = true
483
801
  for (let i = 0; i < steps.length; i++) {
484
802
  const { type, ...args } = steps[i]
@@ -488,26 +806,26 @@ export function aipeekPlugin(): Plugin {
488
806
  allOk = false
489
807
  break
490
808
  }
491
- const r = await runAction(type, args)
809
+ const r = await runAction(type, args, tab)
492
810
  lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
493
- // Per-step screen projection captures the transition each
494
- // mutating step caused, so a view change mid-chain is visible
495
- // 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.
496
813
  if (r.screen)
497
814
  lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
498
- if (r.ui)
499
- lastUi = r.ui
815
+ if (r.actions)
816
+ lastActions = r.actions
500
817
  if (!r.ok) {
501
818
  allOk = false
502
819
  break
503
820
  }
504
821
  }
505
- 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}`)
506
824
  return
507
825
  }
508
826
 
509
827
  // action endpoints: /__aipeek/{click|fill|press|wait|screenshot|realclick}?...
510
- if (['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query'].includes(parts[0])) {
828
+ if (['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query', 'assert', 'drag', 'scrollIntoView', 'drop', 'clipboard'].includes(parts[0])) {
511
829
  const q = url.searchParams
512
830
  const args: ActionArgs = {
513
831
  sel: q.get('sel') || undefined,
@@ -519,13 +837,18 @@ export function aipeekPlugin(): Plugin {
519
837
  button: q.get('button') === 'right' ? 'right' : q.get('button') === 'left' ? 'left' : undefined,
520
838
  x: q.has('x') ? Number(q.get('x')) : undefined,
521
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,
522
845
  }
523
846
  const check = resolveAction(parts[0], args)
524
847
  if (!check.valid) {
525
848
  send(res, 400, check.error ?? 'invalid action')
526
849
  return
527
850
  }
528
- const result = await runAction(parts[0], args)
851
+ const result = await runAction(parts[0], args, tab)
529
852
  if (parts[0] === 'screenshot' && result.dataUrl) {
530
853
  const dir = resolve(server.config.root, '.aipeek')
531
854
  mkdirSync(dir, { recursive: true })
@@ -536,13 +859,15 @@ export function aipeekPlugin(): Plugin {
536
859
  return
537
860
  }
538
861
  const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
539
- 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}`)
540
865
  return
541
866
  }
542
867
 
543
868
  // check endpoint
544
869
  if (parts[0] === 'check') {
545
- const raw = await collectFromClient()
870
+ const raw = await collectFromClient(tab)
546
871
  lastRaw = raw
547
872
  const result = check(raw)
548
873
  const output = emitCheck(result)
@@ -550,21 +875,107 @@ export function aipeekPlugin(): Plugin {
550
875
  return
551
876
  }
552
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
+
553
958
  // detail: /__aipeek/{section}[/{index}][?full]
554
959
  if (parts.length >= 1) {
555
960
  if (!lastRaw)
556
- lastRaw = await collectFromClient()
961
+ lastRaw = await collectFromClient(tab)
557
962
  const result = detail(lastRaw, parts[0], parts[1], full)
558
963
  if (result !== null) {
559
964
  send(res, 200, result)
560
965
  return
561
966
  }
562
- 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.)`)
563
974
  return
564
975
  }
565
976
 
566
977
  // summary or full: /__aipeek[?full]
567
- const raw = await collectFromClient()
978
+ const raw = await collectFromClient(tab)
568
979
  lastRaw = raw
569
980
  if (full) {
570
981
  const compacted = compact(raw)