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