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.
- package/README.md +92 -18
- package/dist/{chunk-37VLLZIU.js → chunk-4BPXH2SW.js} +620 -45
- package/dist/{chunk-STYCUT23.cjs → chunk-SDUTK75Y.cjs} +621 -46
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +326 -2
- package/src/client/client.ts +246 -44
- package/src/core/action.ts +199 -22
- package/src/core/compact.ts +2 -0
- package/src/core/detail.ts +3 -1
- package/src/core/diff.ts +55 -1
- package/src/core/emit.ts +14 -2
- package/src/core/perf.ts +239 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +463 -52
package/src/server/plugin.ts
CHANGED
|
@@ -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.
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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: ((
|
|
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(
|
|
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<
|
|
245
|
-
return twoPhase<
|
|
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
|
|
267
|
-
// already fired the trusted click in-process — done.
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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: {
|
|
556
|
+
server.hot.on('aipeek:screen', (data: ScreenReply & { tab?: string }) => {
|
|
557
|
+
seen(data)
|
|
352
558
|
if (pendingScreen) {
|
|
353
|
-
pendingScreen(data
|
|
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
|
|
415
|
-
|
|
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
|
|
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
|
|
494
|
-
//
|
|
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.
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|