aipeek 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,18 +3,48 @@
3
3
  // The patch code (console/fetch/XHR/errors) is in client-patch.ts,
4
4
  // injected as a synchronous inline script before any modules execute.
5
5
 
6
- import type { ActionArgs, ActionType } from '../core/action'
7
- import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
8
- import { INTERACTIVE, performAction, runEval, withDialogGuard } from '../core/action'
6
+ import type { ActionArgs, ActionResult, ActionType } from '../core/action'
7
+ import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, PerfBucketData, ScreenSnap } from '../core/types'
8
+ import { elementLabel, getDirectText, INTERACTIVE, isSensitive, performAction, reachable, runEval, safeValue, withDialogGuard } from '../core/action'
9
+ import { diffScreen, diffState, stringifyDomain } from '../core/diff'
10
+ import { formatActions } from '../core/util'
9
11
 
10
12
  declare global {
11
- interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
13
+ interface Window {
14
+ __AIPEEK_STORES__?: Record<string, unknown>
15
+ // The app's own domain projection — collapses the UI to a few domain variables
16
+ // that don't live in the DOM (是否流式 / 选了哪个模型 / 未读数). Optional; when
17
+ // absent /screen falls back to the generic view/modal/focus projection unchanged.
18
+ __AIPEEK_SCREEN__?: () => Record<string, unknown>
19
+ // Idempotency for shortest-path delivery: the server re-broadcasts an addressed action
20
+ // while a tab is mid self-heal, so the same action id can arrive more than once. Map id →
21
+ // its final result; on a repeat we replay the cached result instead of re-executing.
22
+ __AIPEEK_DONE_ACTIONS__?: Map<number, ActionResult>
23
+ }
24
+ }
25
+
26
+ // Internal perf buffer shape (from client-patch.ts) — differs from PerformanceData
27
+ interface PerfStatBuf { total: number, n: number, max: number }
28
+ interface PerfBucketBuf {
29
+ components: Record<string, PerfStatBuf>
30
+ frames: { total: number, long: number, max: number, samples: number[] }
31
+ lines: Record<string, PerfStatBuf>
32
+ }
33
+ interface PerfBuffer {
34
+ startedAt: number
35
+ hiddenFrames: number
36
+ mobxPatched: boolean
37
+ buckets: Record<string, PerfBucketBuf>
38
+ active: string
39
+ longtasks: { count: number, max: number }
12
40
  }
13
41
 
14
42
  interface AipeekBuffers {
15
43
  consoleLogs: LogEntry[]
16
44
  networkRequests: NetworkRequest[]
17
45
  errorEntries: ErrorEntry[]
46
+ actionEntries: ActionEntry[]
47
+ perf: PerfBuffer
18
48
  }
19
49
 
20
50
  // Read buffers from the synchronous patch script
@@ -22,6 +52,71 @@ const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
22
52
  const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
23
53
  const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
24
54
  const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
55
+ const actionEntries: ActionEntry[] = buffers?.actionEntries ?? []
56
+ const perfBuffer: PerfBuffer | undefined = buffers?.perf
57
+
58
+ // Project the runtime perf buffer into PerformanceData for collection.
59
+ // Top-20 components by total, capped samples already in buffer.
60
+ function collectPerformance(): PerformanceData | undefined {
61
+ if (!perfBuffer) return undefined
62
+ const buckets: PerfBucketData[] = []
63
+ // __all__ first, then named buckets
64
+ const names = Object.keys(perfBuffer.buckets).sort((a, b) => (a === '__all__' ? -1 : b === '__all__' ? 1 : a.localeCompare(b)))
65
+ for (const name of names) {
66
+ const b = perfBuffer.buckets[name]
67
+ const comps: Record<string, { total: number, n: number, max: number }> = {}
68
+ const sorted = Object.entries(b.components).sort((a, b) => b[1].total - a[1].total).slice(0, 20)
69
+ for (const [cname, stat] of sorted) {
70
+ comps[cname] = { total: stat.total, n: stat.n, max: stat.max }
71
+ }
72
+ const lines: Record<string, { total: number, n: number, max: number }> = {}
73
+ const sortedLines = Object.entries(b.lines ?? {}).sort((a, b) => b[1].total - a[1].total).slice(0, 30)
74
+ for (const [label, stat] of sortedLines) {
75
+ lines[label] = { total: stat.total, n: stat.n, max: stat.max }
76
+ }
77
+ buckets.push({
78
+ name,
79
+ components: comps,
80
+ frames: { total: b.frames.total, long: b.frames.long, max: b.frames.max, samples: [...b.frames.samples] },
81
+ lines,
82
+ })
83
+ }
84
+ return {
85
+ windowMs: Date.now() - perfBuffer.startedAt,
86
+ hiddenFrames: perfBuffer.hiddenFrames,
87
+ mobxPatched: perfBuffer.mobxPatched,
88
+ buckets,
89
+ longtasks: { ...perfBuffer.longtasks },
90
+ }
91
+ }
92
+
93
+ // Reset the perf buffer (clear window, start fresh)
94
+ function resetPerf() {
95
+ if (!perfBuffer) return
96
+ perfBuffer.startedAt = Date.now()
97
+ perfBuffer.hiddenFrames = 0
98
+ perfBuffer.buckets = {}
99
+ perfBuffer.longtasks = { count: 0, max: 0 }
100
+ }
101
+
102
+ // Stable per-tab id so commands can address one tab among many (?tab=). Lives in
103
+ // sessionStorage: one browser tab = one id, survives HMR/refresh, cleared on close.
104
+ // Falls back to an in-memory id if sessionStorage is unavailable (degrades to old
105
+ // broadcast behavior, never throws).
106
+ const TAB_ID = (() => {
107
+ try {
108
+ const k = '__aipeek_tab_id__'
109
+ let id = sessionStorage.getItem(k)
110
+ if (!id) {
111
+ id = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
112
+ sessionStorage.setItem(k, id)
113
+ }
114
+ return id
115
+ }
116
+ catch {
117
+ return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
118
+ }
119
+ })()
25
120
 
26
121
  // --- On-demand collectors ---
27
122
 
@@ -228,18 +323,6 @@ function collectDomFallback(): string {
228
323
  return lines.join('\n')
229
324
  }
230
325
 
231
- function getDirectText(el: Element): string {
232
- let text = ''
233
- for (const node of el.childNodes) {
234
- if (node.nodeType === Node.TEXT_NODE) {
235
- const t = node.textContent?.trim()
236
- if (t)
237
- text += (text ? ' ' : '') + t
238
- }
239
- }
240
- return text
241
- }
242
-
243
326
  // --- Semantic DOM collector ---
244
327
  // UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
245
328
  // tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
@@ -396,14 +479,28 @@ function baseState(el: Element): string[] {
396
479
  return parts
397
480
  }
398
481
 
482
+ // Heuristic: does opening this control need a *trusted* event (realclick), not a synthetic
483
+ // one? Synthetic dispatchEvent can't open a Radix portal popup reliably (and never a native
484
+ // right-click menu). aria-haspopup is the W3C marker Radix puts on menu/dropdown/popover/
485
+ // select triggers — the honest, low-false-positive signal. Tag it so the AI picks realclick
486
+ // up front instead of discovering it by a failed synthetic click. The `?` keeps it honest:
487
+ // a hint, not a guarantee (right-click-only handlers carry no DOM attribute, so they can't be
488
+ // detected here — those still surface only on a miss).
489
+ function needsTrusted(el: Element): boolean {
490
+ const hp = el.getAttribute('aria-haspopup')
491
+ return hp === 'menu' || hp === 'dialog' || hp === 'listbox' || hp === 'true'
492
+ }
493
+
399
494
  function domState(el: Element): string {
400
495
  const parts = baseState(el)
401
496
  if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
402
497
  parts.push('selected')
403
498
  if ((el as HTMLInputElement).value)
404
- parts.push(`value="${(el as HTMLInputElement).value.slice(0, 40)}"`)
499
+ parts.push(`value="${safeValue(el).slice(0, 40)}"`)
405
500
  if (el.hasAttribute('contenteditable'))
406
501
  parts.push('editable')
502
+ if (needsTrusted(el))
503
+ parts.push('needs-trusted?')
407
504
  return parts.length ? ` {${parts.join(' ')}}` : ''
408
505
  }
409
506
 
@@ -447,9 +544,12 @@ export function collectDom(scope?: string, sel?: string): string {
447
544
  // data-* minus framework noise. data-insp-path (code-inspector) is the source
448
545
  // location — keep it, but compacted to its file:line tail, shown only when the
449
546
  // node is itself meaningful (carries role/text/state), not on bare wrappers.
547
+ // On a secret field, redact data-* values too — an app could mirror the value into
548
+ // a data attr, and we never want that echoed (same stance as the value redaction).
549
+ const secret = isSensitive(el)
450
550
  const dataAttrs = Array.from(el.attributes)
451
551
  .filter(a => a.name.startsWith('data-') && a.name !== 'data-testid' && a.name !== 'data-insp-path')
452
- .map(a => a.value ? `${a.name}="${a.value.slice(0, 30)}"` : a.name)
552
+ .map(a => a.value ? `${a.name}="${secret ? `‹redacted ${a.value.length}›` : a.value.slice(0, 30)}"` : a.name)
453
553
  .slice(0, 3)
454
554
  const aria = el.getAttribute('aria-label') || ''
455
555
  const text = aria || getDirectText(el)
@@ -508,7 +608,9 @@ function collectState(): Record<string, unknown> {
508
608
  // — the few variables a human reads off a washing-machine panel to act in O(1).
509
609
  // knobs are clipped to what's reachable *now*: when a modal is open, only its
510
610
  // subtree counts (the layer beneath is unclickable).
511
- export function collectScreen(): string {
611
+ // The state-machine projection — just the three vars that move when the UI transitions.
612
+ // Shared by collectScreen (the /screen header) and the action handler's before/after diff.
613
+ export function screenSnap(): ScreenSnap {
512
614
  const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
513
615
  const view = stores?.appUIStore?.mode ?? '(unknown)'
514
616
 
@@ -518,22 +620,37 @@ export function collectScreen(): string {
518
620
  : ''
519
621
  const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
520
622
 
521
- // Icon buttons carry no direct text — fall back to title, then any nested text.
522
- const labelOf = (el: Element) => (el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
523
- const visible = (el: Element) => el.getBoundingClientRect().width > 0
524
-
525
623
  const active = document.activeElement
526
624
  const focus = active && active !== document.body
527
- ? `${labelOf(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
625
+ ? `${elementLabel(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
528
626
  : 'none'
529
627
 
628
+ let domain: Record<string, unknown> = {}
629
+ try {
630
+ domain = window.__AIPEEK_SCREEN__?.() ?? {}
631
+ }
632
+ catch {
633
+ domain = { '[error]': '__AIPEEK_SCREEN__ threw' }
634
+ }
635
+
636
+ return { view, modal, focus, domain }
637
+ }
638
+
639
+ export function collectScreen(): string {
640
+ const { view, modal, focus, domain } = screenSnap()
641
+ const domainLines = Object.entries(domain).map(([k, v]) => ` ${k}: ${stringifyDomain(v)}`)
642
+
643
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
644
+
530
645
  // Group by source location. A source rendered many times is a *list* (content),
531
646
  // not a control — collapse it to one "source ×N" line. The washing-machine
532
647
  // panel shows "the laundry", not every garment.
533
648
  const root = modalEl ?? document.body
534
649
  const bySource = new Map<string, Element[]>()
535
650
  for (const el of root.querySelectorAll(INTERACTIVE)) {
536
- if (!visible(el))
651
+ // reachable, not just isVisible: only list knobs a click can actually land on now —
652
+ // same predicate /click uses, so read and write agree (no "listed but unclickable").
653
+ if (!reachable(el))
537
654
  continue
538
655
  const loc = inspPath(el) || '?'
539
656
  if (!bySource.has(loc))
@@ -547,7 +664,7 @@ export function collectScreen(): string {
547
664
  continue
548
665
  }
549
666
  for (const el of els) {
550
- const label = labelOf(el).slice(0, 40)
667
+ const label = elementLabel(el).slice(0, 40)
551
668
  knobs.push(` ${label || `<${el.tagName.toLowerCase()}>`} [${el.tagName.toLowerCase()}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}`)
552
669
  }
553
670
  if (knobs.length >= 40)
@@ -556,6 +673,7 @@ export function collectScreen(): string {
556
673
 
557
674
  return [
558
675
  `view: ${view}`,
676
+ ...(domainLines.length ? [`domain:`, ...domainLines] : []),
559
677
  `modal: ${modal}`,
560
678
  `focus: ${focus}`,
561
679
  `knobs (${knobs.length}):`,
@@ -563,6 +681,22 @@ export function collectScreen(): string {
563
681
  ].join('\n')
564
682
  }
565
683
 
684
+ // Called from the action handler after the DOM has settled. The synthetic event aipeek
685
+ // just fired was captured into actionEntries by client-patch; find that entry (latest
686
+ // synthetic) and back-fill its snapshot synchronously off the now-settled DOM — not via
687
+ // client-patch's async 180ms stamp, which may not have run yet (Risk 1). Then render the
688
+ // timeline anchored on it, so the model sees its own action between before/after context.
689
+ function attachTimeline(): string {
690
+ const cur = [...actionEntries].reverse().find(e => !e.trusted)
691
+ if (cur) {
692
+ const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
693
+ cur.view = stores?.appUIStore?.mode ?? '(unknown)'
694
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
695
+ cur.modal = modalEl ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40) : 'none'
696
+ }
697
+ return formatActions([...actionEntries], cur?.ts)
698
+ }
699
+
566
700
  function boundedSnapshot(obj: unknown, depth = 0): unknown {
567
701
  if (depth > 3)
568
702
  return '[…]'
@@ -621,28 +755,76 @@ function waitForStable(quiet = 150, timeout = 2000): Promise<string> {
621
755
  // would answer. So the server sends requireVisible=true first; if it times out with
622
756
  // no answer it retries requireVisible=false, and every tab answers (the original
623
757
  // race). `skip` collapses that into one guard: hidden tab skips only round one.
624
- function skip(msg?: { requireVisible?: boolean }) {
758
+ function skip(msg?: { requireVisible?: boolean, tab?: string }) {
759
+ if (msg?.tab) // addressed by id: only that tab answers, regardless of visibility (incl. background)
760
+ return msg.tab !== TAB_ID
625
761
  return msg?.requireVisible !== false && document.visibilityState !== 'visible'
626
762
  }
627
763
 
628
764
  if (import.meta.hot) {
629
- import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean }) => {
765
+ // 主动报到:补上 roster 缺失的「注册边」。在此之前 roster 只在收到轮询回复时(aipeek:state/
766
+ // result/dom/screen)才登记一个 tab——于是会话里第一个命令若是 /tabs(纯 roster 读,自身不轮询),
767
+ // 即使前台开着健康 tab 也必然读到 (no live tabs)。连上即 hello、visibilitychange 重发,roster
768
+ // 实时为真。TAB_ID 存 sessionStorage 跨自愈 reload 存活,reload 后 hello 自然重放。
769
+ const hello = () => import.meta.hot!.send('aipeek:hello', {
770
+ tab: TAB_ID,
771
+ url: location.href,
772
+ title: document.title,
773
+ visible: document.visibilityState === 'visible',
774
+ })
775
+ hello()
776
+ addEventListener('visibilitychange', hello)
777
+
778
+ // WS-death beacon for the HTTP heartbeat in client-patch. The whole action chain rides this
779
+ // HMR socket; when it drops, Vite's own reconnect (client.mjs waitForSuccessfulPing) is
780
+ // GATED on document.visibilityState === 'visible' — so a backgrounded Electron window (we
781
+ // app.hide() on launch, and backgroundThrottling throttles it) never reconnects and never
782
+ // reloads, stranding the chain on a dead socket while BOOT_ID is unchanged. client-patch
783
+ // can't see import.meta.hot (it's a non-module inline script), so we surface liveness as a
784
+ // global flag it polls: WS dead + ping OK + same BOOT_ID → it forces the reload Vite won't.
785
+ import.meta.hot.on('vite:ws:disconnect', () => { (window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ = true })
786
+ import.meta.hot.on('vite:ws:connect', () => {
787
+ ;(window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ = false
788
+ hello() // socket came back without a page reload — re-register so roster doesn't go stale
789
+ })
790
+
791
+ import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean, tab?: string }) => {
630
792
  if (skip(msg))
631
793
  return
632
794
  import.meta.hot!.send('aipeek:state', {
795
+ tab: TAB_ID,
633
796
  url: location.href,
797
+ title: document.title,
798
+ visible: document.visibilityState === 'visible',
634
799
  ui: collectUI(),
635
800
  console: [...consoleLogs],
636
801
  network: [...networkRequests],
637
802
  errors: [...errorEntries],
803
+ actions: [...actionEntries],
638
804
  state: collectState(),
805
+ performance: collectPerformance(),
639
806
  timestamp: Date.now(),
640
807
  })
641
808
  })
642
809
 
643
- import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
810
+ import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean, tab?: string }) => {
644
811
  if (skip(msg))
645
812
  return
813
+ // Idempotency: the server re-broadcasts an addressed action while the tab is mid self-heal,
814
+ // so the same id can land twice. Execute once per id; on a repeat replay the cached result
815
+ // (re-settles the server's pendingActions slot if the original reply was lost in teardown).
816
+ const done = (window.__AIPEEK_DONE_ACTIONS__ ??= new Map<number, ActionResult>())
817
+ const cached = done.get(msg.id)
818
+ if (cached) {
819
+ import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...cached })
820
+ return
821
+ }
822
+ // Snapshot the state machine + error/network buffers *before* the action, so the
823
+ // post-settle diff is exactly what this action caused (see result.screen below).
824
+ const before = screenSnap()
825
+ const beforeConsole = [...consoleLogs]
826
+ const beforeNetwork = [...networkRequests]
827
+ const beforeErrors = [...errorEntries]
646
828
  // Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
647
829
  const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
648
830
  if (dialogs.length)
@@ -652,39 +834,72 @@ if (import.meta.hot) {
652
834
  // can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
653
835
  // in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
654
836
  // undefined and let the server drive its extension queue.
837
+ // realclick resolved (x,y) but synthetic events can't open a Radix ContextMenu — fire
838
+ // a trusted click via Electron's main process if reachable, else leave it for the
839
+ // server's extension queue. Either path then settles like any other mutating action.
655
840
  const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
656
841
  if (msg.type === 'realclick' && result.ok && electronAPI) {
657
842
  await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
658
- result.ui = await waitForStable()
659
- result.screen = collectScreen()
843
+ result.fired = true // server: trusted click already fired in-process, skip the extension queue
660
844
  }
661
- // For mutating actions, settle the DOM then ship both the full UI tree and
662
- // the compact screen projection the caller skips a round-trip to /ui, and
663
- // /chain uses the per-step screen so an interaction's every transition shows.
664
- else if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
665
- result.ui = await waitForStable()
666
- result.screen = collectScreen()
845
+
846
+ // For mutating actions, settle the DOM then ship *only the change this action caused*
847
+ // (state-machine transition + new errors/failed requests), not a fresh snapshot. AI
848
+ // reads the delta and drills into /ui or /dom for detail if needed. The timeline still
849
+ // surfaces concurrent user actions.
850
+ const mutated = msg.type === 'click' || msg.type === 'fill' || msg.type === 'press'
851
+ || (msg.type === 'realclick' && !!electronAPI)
852
+ if (result.ok && mutated) {
853
+ await waitForStable()
854
+ const after = screenSnap()
855
+ const d = diffState(
856
+ { ui: '', console: beforeConsole, network: beforeNetwork, errors: beforeErrors, state: {}, url: '', timestamp: 0 },
857
+ { ui: '', console: [...consoleLogs], network: [...networkRequests], errors: [...errorEntries], state: {}, url: '', timestamp: 0 },
858
+ )
859
+ const changed = diffScreen(before, after, d.newErrors, d.newExceptions, d.newFailedRequests)
860
+ result.screen = changed.length ? changed.join('\n') : '(no state change)'
861
+ result.actions = attachTimeline()
667
862
  }
668
- import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
863
+ done.set(msg.id, result)
864
+ if (done.size > 64)
865
+ done.delete(done.keys().next().value!)
866
+ import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...result })
669
867
  })
670
868
 
671
869
  // eval: run server-supplied code in the page with auto-return (see runEval).
672
- import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
870
+ import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean, tab?: string }) => {
673
871
  if (skip(msg))
674
872
  return
675
873
  const { ok, value, error } = await runEval(msg.code)
676
- import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
874
+ import.meta.hot!.send('aipeek:eval-result', { tab: TAB_ID, id: msg.id, ok, value, error })
875
+ })
876
+
877
+ import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean, tab?: string }) => {
878
+ if (skip(msg))
879
+ return
880
+ import.meta.hot!.send('aipeek:dom', { tab: TAB_ID, dom: collectDom(msg?.scope, msg?.sel) })
677
881
  })
678
882
 
679
- import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean }) => {
883
+ import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean, tab?: string }) => {
680
884
  if (skip(msg))
681
885
  return
682
- import.meta.hot!.send('aipeek:dom', { dom: collectDom(msg?.scope, msg?.sel) })
886
+ // Ship the rendered text *and* the structured snap + buffers, so the server can serve
887
+ // /screen?since=<token> by diffing this snap against a stashed prior one (diffScreen).
888
+ import.meta.hot!.send('aipeek:screen', {
889
+ tab: TAB_ID,
890
+ screen: collectScreen(),
891
+ snap: screenSnap(),
892
+ console: [...consoleLogs],
893
+ network: [...networkRequests],
894
+ errors: [...errorEntries],
895
+ })
683
896
  })
684
897
 
685
- import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean }) => {
898
+ // Perf profiler handlers
899
+ import.meta.hot.on('aipeek:perf-reset', (msg: { requireVisible?: boolean, tab?: string }) => {
686
900
  if (skip(msg))
687
901
  return
688
- import.meta.hot!.send('aipeek:screen', { screen: collectScreen() })
902
+ resetPerf()
903
+ import.meta.hot!.send('aipeek:perf-reset-ack', { tab: TAB_ID })
689
904
  })
690
905
  }