aihand 0.0.1 → 0.1.1
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 +152 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-3J7EYI6G.cjs +651 -0
- package/dist/cli-FIJLKAGI.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer } from 'vite'
|
|
2
|
+
import type { ActionArgs, ActionResult } from '../core/action'
|
|
3
|
+
import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, RawState, ScreenSnap, TabInfo } from '../core/types'
|
|
4
|
+
import { Buffer } from 'node:buffer'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { transformSync } from 'esbuild'
|
|
9
|
+
import { sourceLocPlugin } from '../babel/source-loc'
|
|
10
|
+
import { viteBridge } from '../bridge/vite-bridge'
|
|
11
|
+
import { misroutedAction, resolveAction, TYPES } from '../core/action'
|
|
12
|
+
import { check } from '../core/check'
|
|
13
|
+
import { compact } from '../core/compact'
|
|
14
|
+
import { detail } from '../core/detail'
|
|
15
|
+
import { diffScreen, diffState } from '../core/diff'
|
|
16
|
+
import { emit, emitCheck, emitDiff, emitSummary } from '../core/emit'
|
|
17
|
+
import { diffPerformance } from '../core/perf'
|
|
18
|
+
import { appendAction, diagnose as diagnoseConn, formatActions, formatTabs, isLive } from '../core/util'
|
|
19
|
+
import { argsFromQuery, dispatchAction, dispatchScreen, isDispatchAction, runChain } from './dispatch'
|
|
20
|
+
import type { ChainStep } from './dispatch'
|
|
21
|
+
import { helpText } from './help-text'
|
|
22
|
+
|
|
23
|
+
function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): Promise<string> {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let s = ''
|
|
26
|
+
req.on('data', c => s += c)
|
|
27
|
+
req.on('end', () => resolve(s))
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 所有端点都回文本响应——writeHead(Content-Type) + end 二连写了 15 遍(且有 2 处漏 charset)。收敛成一处。
|
|
32
|
+
function send(res: { writeHead: (s: number, h: Record<string, string>) => void, end: (b: string) => void }, status: number, body: string) {
|
|
33
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
34
|
+
res.end(body)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
38
|
+
// client/core sources are read at runtime (client.ts served via Vite transform,
|
|
39
|
+
// client-patch.ts compiled by esbuild). aihand fuses ui under src/ui/, so:
|
|
40
|
+
// source consumption: __dirname=src/ui/server → ../client (= src/ui/client)
|
|
41
|
+
// published package: __dirname=dist → ../src/ui/client
|
|
42
|
+
// First existing dir wins.
|
|
43
|
+
const clientDir = [
|
|
44
|
+
resolve(__dirname, '../client'),
|
|
45
|
+
resolve(__dirname, '../src/ui/client'),
|
|
46
|
+
].find(existsSync) ?? resolve(__dirname, '../src/ui/client')
|
|
47
|
+
const clientPath = resolve(clientDir, 'client.ts')
|
|
48
|
+
const patchPath = resolve(clientDir, 'client-patch.ts')
|
|
49
|
+
|
|
50
|
+
// Compile patch code to JS at plugin init (synchronous, runs once)
|
|
51
|
+
function compilePatch(): string {
|
|
52
|
+
const source = readFileSync(patchPath, 'utf-8')
|
|
53
|
+
const result = transformSync(source, {
|
|
54
|
+
loader: 'ts',
|
|
55
|
+
target: 'es2020',
|
|
56
|
+
format: 'iife',
|
|
57
|
+
minify: false,
|
|
58
|
+
})
|
|
59
|
+
return result.code
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The enriched /screen reply: rendered text for display + structured snap & buffers so the
|
|
63
|
+
// server can diff one read against an earlier one for /screen?since=<token>.
|
|
64
|
+
interface ScreenReply { screen: string, canvas: string, snap: ScreenSnap, console: LogEntry[], network: NetworkRequest[], errors: ErrorEntry[] }
|
|
65
|
+
|
|
66
|
+
// Source-location injection: a `pre`-enforced transform that stamps `data-insp-path` onto
|
|
67
|
+
// every host JSX element so the client can map a DOM node back to its source. Isolated in its own
|
|
68
|
+
// plugin so it touches only .jsx/.tsx (and only at serve time) — the main aihand plugin's HTTP
|
|
69
|
+
// /HMR machinery is untouched. babel is loaded lazily (it's external; a top-level import would
|
|
70
|
+
// pull it into both bundle formats and break the dual-format contract).
|
|
71
|
+
function sourceInjectPlugin(): Plugin {
|
|
72
|
+
let babel: typeof import('@babel/core') | null = null
|
|
73
|
+
let plugin: import('@babel/core').PluginObj | null = null
|
|
74
|
+
let root = ''
|
|
75
|
+
return {
|
|
76
|
+
name: 'aihand:source-loc',
|
|
77
|
+
apply: 'serve',
|
|
78
|
+
enforce: 'pre',
|
|
79
|
+
async configResolved(config) {
|
|
80
|
+
root = config.root
|
|
81
|
+
},
|
|
82
|
+
async transform(code, id) {
|
|
83
|
+
const file = id.split('?')[0]
|
|
84
|
+
if (!file.endsWith('.tsx') && !file.endsWith('.jsx'))
|
|
85
|
+
return
|
|
86
|
+
if (file.includes('node_modules') || file.includes('packages/aidev/'))
|
|
87
|
+
return
|
|
88
|
+
if (!babel) {
|
|
89
|
+
babel = await import('@babel/core')
|
|
90
|
+
plugin = sourceLocPlugin(babel.types)
|
|
91
|
+
}
|
|
92
|
+
const result = await babel.transformAsync(code, {
|
|
93
|
+
filename: file,
|
|
94
|
+
root,
|
|
95
|
+
plugins: [plugin!],
|
|
96
|
+
parserOpts: { plugins: ['jsx', 'typescript'] },
|
|
97
|
+
sourceMaps: true,
|
|
98
|
+
configFile: false,
|
|
99
|
+
babelrc: false,
|
|
100
|
+
})
|
|
101
|
+
if (!result?.code)
|
|
102
|
+
return
|
|
103
|
+
return { code: result.code, map: result.map }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function aihandPlugin(): Plugin[] {
|
|
109
|
+
return [aihandMainPlugin(), sourceInjectPlugin()]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function aihandMainPlugin(): Plugin {
|
|
113
|
+
let pendingResolve: ((data: RawState) => void) | null = null
|
|
114
|
+
let server: ViteDevServer
|
|
115
|
+
let lastRaw: RawState | null = null
|
|
116
|
+
let perfBaseline: PerformanceData | null = null // /profile/diff before-snapshot
|
|
117
|
+
let pushTimer: ReturnType<typeof setTimeout> | undefined
|
|
118
|
+
const pendingActions = new Map<number, (r: ActionResult) => void>()
|
|
119
|
+
// An async flow (send a message → stream → done) legitimately keeps a mutating action open for
|
|
120
|
+
// seconds while the client samples the trajectory. The client pings `aihand:action-progress`
|
|
121
|
+
// each frame; we stamp the latest ping here so twoPhase's live-tab deadline counts from the last
|
|
122
|
+
// sign of life, not from dispatch — a real hung handler still rejects (no pings → deadline fires).
|
|
123
|
+
const actionProgressAt = new Map<number, number>()
|
|
124
|
+
let actionId = 0
|
|
125
|
+
|
|
126
|
+
// 融合反向态射的运行时半边:/source 把点中的 DOM 坐标缝回静态符号(FUSION §4)。
|
|
127
|
+
// CallGraph 全仓构建 ~1s,/source 是交互路径(人点一下等一下),故惰性建一次 + 文件变更失效,
|
|
128
|
+
// 不每次重建。运行时态不缓存(易腐,§3),但**静态调用图是 Log 类**——文件没改它不变,缓存安全。
|
|
129
|
+
let graphCache: import('../../read/graph.js').CallGraph | null = null
|
|
130
|
+
let graphBuilding: Promise<import('../../read/graph.js').CallGraph> | null = null
|
|
131
|
+
async function cachedGraph(): Promise<import('../../read/graph.js').CallGraph> {
|
|
132
|
+
if (graphCache)
|
|
133
|
+
return graphCache
|
|
134
|
+
if (!graphBuilding) {
|
|
135
|
+
graphBuilding = (async () => {
|
|
136
|
+
const { loadConfig, isTsFile } = await import('../../read/config.js')
|
|
137
|
+
const { scan } = await import('../../read/scan.js')
|
|
138
|
+
const { buildCallGraph } = await import('../../read/graph.js')
|
|
139
|
+
const config = await loadConfig(server.config.root)
|
|
140
|
+
const g = await buildCallGraph(scan(config).filter(isTsFile))
|
|
141
|
+
graphCache = g
|
|
142
|
+
graphBuilding = null
|
|
143
|
+
return g
|
|
144
|
+
})()
|
|
145
|
+
}
|
|
146
|
+
return graphBuilding
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Live-tab roster. Every reply carries the client's tab id; we upsert here so commands
|
|
150
|
+
// can address one tab (?tab=) and so a multi-tab session reports a "which tab?" list
|
|
151
|
+
// instead of racing N answers. lastSeen = server-side arrival time (Node clock).
|
|
152
|
+
const tabs = new Map<string, TabInfo>()
|
|
153
|
+
function seen(data: { tab?: string, url?: string, title?: string, visible?: boolean }) {
|
|
154
|
+
if (!data.tab)
|
|
155
|
+
return
|
|
156
|
+
const prev = tabs.get(data.tab)
|
|
157
|
+
tabs.set(data.tab, {
|
|
158
|
+
id: data.tab,
|
|
159
|
+
url: data.url ?? prev?.url ?? '',
|
|
160
|
+
title: data.title ?? prev?.title ?? '',
|
|
161
|
+
visible: data.visible ?? prev?.visible ?? false,
|
|
162
|
+
lastSeen: Date.now(),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
const liveTabs = () => [...tabs.values()].filter(t => isLive(t, Date.now()))
|
|
166
|
+
|
|
167
|
+
// Bind the pure diagnose() (the single π, in core/util) to live server state. Every
|
|
168
|
+
// "didn't get an answer" path routes through here — twoPhase rejects, /tabs empty roster,
|
|
169
|
+
// /profile tab-absent — so the state→action split lives in exactly one tested function.
|
|
170
|
+
const diagnose = (tab?: string) =>
|
|
171
|
+
diagnoseConn(tab, [...tabs.values()], Date.now(), server.ws.clients.size, server.config.server.port || 5173)
|
|
172
|
+
|
|
173
|
+
// Cross-tab action timeline. Each tab already ships its full action ring inside every
|
|
174
|
+
// aihand:state reply (the same `actions` the single-tab `recent actions` tail reads); we
|
|
175
|
+
// merge those into a global ring keyed by tab on each collect — no separate fire-and-forget
|
|
176
|
+
// report path, no dependency on client-patch (which only reloads on server restart).
|
|
177
|
+
// GET /timeline interleaves all tabs by ts, so a multi-tab A/B comparison sees who did what.
|
|
178
|
+
const actionLog: ActionEntry[] = []
|
|
179
|
+
|
|
180
|
+
// Self-heal handshake: a process-level id, fresh on every server start. The injected
|
|
181
|
+
// client-patch polls GET /ping and reloads the page when this id changes — so a full
|
|
182
|
+
// server restart (which kills the HMR socket the whole action chain rides on) heals
|
|
183
|
+
// itself instead of stranding the page on "connection lost" until a human hits ⌘R.
|
|
184
|
+
const BOOT_ID = Date.now().toString(36)
|
|
185
|
+
|
|
186
|
+
// Per-tab head-eviction count, reported by the client (its 30-entry ring drops the oldest
|
|
187
|
+
// between collects — entries the server never saw). Monotonic, so the latest report wins.
|
|
188
|
+
// Summed at /timeline read so a clipped causal chain announces the clip (diagnostic-fiber law).
|
|
189
|
+
const droppedByTab = new Map<string, number>()
|
|
190
|
+
const mergeActions = (tab: string | undefined, actions?: ActionEntry[], dropped?: number) => {
|
|
191
|
+
if (!tab)
|
|
192
|
+
return
|
|
193
|
+
if (typeof dropped === 'number')
|
|
194
|
+
droppedByTab.set(tab, dropped)
|
|
195
|
+
if (!actions?.length)
|
|
196
|
+
return
|
|
197
|
+
for (const entry of actions) {
|
|
198
|
+
if (!actionLog.some(e => e.tab === tab && e.ts === entry.ts))
|
|
199
|
+
appendAction(actionLog, tab, entry, 200)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Chrome real-input channel: synthetic events can't open a Radix ContextMenu, and the
|
|
204
|
+
// in-page script can't reach chrome.debugger. So for a plain browser tab, realclick is a
|
|
205
|
+
// two-step handshake — the page resolves the element to (x,y), then the server enqueues a
|
|
206
|
+
// CDP command here for the extension to execute with trusted input. The extension long-polls
|
|
207
|
+
// /cdp/poll for the next command and POSTs the verdict to /cdp/result. Electron never touches
|
|
208
|
+
// this (it fires sendInputEvent in-process from the page — see client.ts).
|
|
209
|
+
interface CdpCommand { id: number, x: number, y: number, button: 'left' | 'right' }
|
|
210
|
+
const cdpQueue: CdpCommand[] = []
|
|
211
|
+
let cdpWaiter: ((cmd: CdpCommand | null) => void) | null = null
|
|
212
|
+
const cdpResults = new Map<number, (r: { ok: boolean, error?: string }) => void>()
|
|
213
|
+
let cdpId = 0
|
|
214
|
+
|
|
215
|
+
function runCdpClick(x: number, y: number, button: 'left' | 'right'): Promise<{ ok: boolean, error?: string }> {
|
|
216
|
+
const id = ++cdpId
|
|
217
|
+
const cmd: CdpCommand = { id, x, y, button }
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
cdpResults.set(id, resolve)
|
|
220
|
+
// hand the command to a parked poller, else queue it for the next poll
|
|
221
|
+
if (cdpWaiter) {
|
|
222
|
+
cdpWaiter(cmd)
|
|
223
|
+
cdpWaiter = null
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
cdpQueue.push(cmd)
|
|
227
|
+
}
|
|
228
|
+
setTimeout(() => {
|
|
229
|
+
if (cdpResults.delete(id))
|
|
230
|
+
reject(new Error('cdp timeout: no extension result within 10s (is the aihand extension loaded and the debugger attached?)'))
|
|
231
|
+
}, 10000)
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let pendingDom: ((dom: string) => void) | null = null
|
|
236
|
+
let pendingStatePath: ((reply: { ok: boolean, value?: unknown, error?: string }) => void) | null = null
|
|
237
|
+
let pendingScreen: ((reply: ScreenReply) => void) | null = null
|
|
238
|
+
const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string, hint?: string }) => void>()
|
|
239
|
+
let evalId = 0
|
|
240
|
+
|
|
241
|
+
const pendingSemantic = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
|
|
242
|
+
let semanticId = 0
|
|
243
|
+
|
|
244
|
+
const pendingKnob = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
|
|
245
|
+
let knobId = 0
|
|
246
|
+
const knobProgressAt = new Map<number, number>()
|
|
247
|
+
|
|
248
|
+
// /screen?since=<token> diffs the current screen against an earlier one. We stash each
|
|
249
|
+
// /screen read's structured snap + buffers under a monotonic token; `since` looks the
|
|
250
|
+
// token up and renders only the transition (diffScreen). Ring-bounded so it can't grow.
|
|
251
|
+
interface Stash { snap: ScreenSnap, console: LogEntry[], network: NetworkRequest[], errors: ErrorEntry[] }
|
|
252
|
+
const screenStash = new Map<string, Stash>()
|
|
253
|
+
let screenToken = 0
|
|
254
|
+
function stashScreen(r: ScreenReply): string {
|
|
255
|
+
const token = `t${++screenToken}`
|
|
256
|
+
screenStash.set(token, { snap: r.snap, console: r.console, network: r.network, errors: r.errors })
|
|
257
|
+
if (screenStash.size > 32)
|
|
258
|
+
screenStash.delete(screenStash.keys().next().value!)
|
|
259
|
+
return token
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
|
|
263
|
+
// visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
|
|
264
|
+
// two drops the guard and any tab replies. `arm` installs the pending slot and
|
|
265
|
+
// returns a clearer; the slot's resolve settles the promise on either round.
|
|
266
|
+
const VISIBLE_MS = 400
|
|
267
|
+
function twoPhase<T>(
|
|
268
|
+
event: string,
|
|
269
|
+
payload: Record<string, unknown>,
|
|
270
|
+
arm: (resolve: (v: T) => void) => () => void,
|
|
271
|
+
fullMs = 3000,
|
|
272
|
+
tab?: string,
|
|
273
|
+
// Optional: latest sign-of-life timestamp for this call (an in-flight flow pings to extend
|
|
274
|
+
// its own deadline). When set, the live-tab deadline counts from max(startedAt, lastProgress).
|
|
275
|
+
lastProgress?: () => number | undefined,
|
|
276
|
+
): Promise<T> {
|
|
277
|
+
return new Promise<T>((resolve, reject) => {
|
|
278
|
+
let settled = false
|
|
279
|
+
const clear = arm((v) => {
|
|
280
|
+
settled = true
|
|
281
|
+
resolve(v)
|
|
282
|
+
})
|
|
283
|
+
// Addressed at one tab: skip the requireVisible round entirely — only that tab
|
|
284
|
+
// answers (skip() matches on tab id), background or not. No visibility race.
|
|
285
|
+
//
|
|
286
|
+
// Shortest-path delivery: assume the tab always exists. TAB_ID is sessionStorage-backed
|
|
287
|
+
// and survives the self-heal location.reload(), so a tab keeps its id across a server
|
|
288
|
+
// restart — it's the same descendant. Two regimes, split on whether the tab is live now:
|
|
289
|
+
// live → present-but-silent past fullMs is a real miss (bad sel / hung handler) →
|
|
290
|
+
// reject fast, exactly as before. Never re-send to a live tab (double-exec hazard).
|
|
291
|
+
// absent → server just restarted / page mid self-heal. Keep re-delivering every RETRY_MS
|
|
292
|
+
// until it re-registers (self-heal brings it back ~2-4s), bounded by ABSENT_CEILING_MS.
|
|
293
|
+
// Re-delivery is made idempotent client-side (__AIPEEK_DONE_ACTIONS__ de-dups by action id).
|
|
294
|
+
if (tab) {
|
|
295
|
+
const RETRY_MS = 500
|
|
296
|
+
const ABSENT_CEILING_MS = 10000
|
|
297
|
+
const startedAt = Date.now()
|
|
298
|
+
const deliver = () => server.hot.send(event, { ...payload, tab })
|
|
299
|
+
deliver()
|
|
300
|
+
const iv = setInterval(() => {
|
|
301
|
+
if (settled) {
|
|
302
|
+
clearInterval(iv)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
const t = tabs.get(tab)
|
|
306
|
+
const live = !!t && isLive(t, Date.now())
|
|
307
|
+
const progressAt = lastProgress?.()
|
|
308
|
+
const sinceLife = Date.now() - Math.max(startedAt, progressAt ?? 0)
|
|
309
|
+
const elapsed = Date.now() - startedAt
|
|
310
|
+
if (live) {
|
|
311
|
+
if (sinceLife > fullMs) {
|
|
312
|
+
clearInterval(iv)
|
|
313
|
+
clear()
|
|
314
|
+
reject(new Error(diagnose(tab)))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (elapsed > ABSENT_CEILING_MS) {
|
|
318
|
+
clearInterval(iv)
|
|
319
|
+
clear()
|
|
320
|
+
reject(new Error(diagnose(tab)))
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
deliver()
|
|
324
|
+
}
|
|
325
|
+
}, RETRY_MS)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
server.hot.send(event, { ...payload, requireVisible: true })
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
if (settled)
|
|
331
|
+
return
|
|
332
|
+
// no visible tab answered — ask everyone
|
|
333
|
+
server.hot.send(event, { ...payload, requireVisible: false })
|
|
334
|
+
setTimeout(() => {
|
|
335
|
+
if (settled)
|
|
336
|
+
return
|
|
337
|
+
clear()
|
|
338
|
+
reject(new Error(diagnose(tab)))
|
|
339
|
+
}, fullMs)
|
|
340
|
+
}, VISIBLE_MS)
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function collectFromClient(tab?: string): Promise<RawState> {
|
|
345
|
+
return twoPhase<RawState>('aihand:collect', {}, (resolve) => {
|
|
346
|
+
pendingResolve = resolve
|
|
347
|
+
return () => {
|
|
348
|
+
pendingResolve = null
|
|
349
|
+
}
|
|
350
|
+
}, 3000, tab)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function collectDomFromClient(scope?: string, sel?: string, tab?: string): Promise<string> {
|
|
354
|
+
return twoPhase<string>('aihand:collect-dom', { scope, sel }, (resolve) => {
|
|
355
|
+
pendingDom = resolve
|
|
356
|
+
return () => {
|
|
357
|
+
pendingDom = null
|
|
358
|
+
}
|
|
359
|
+
}, 3000, tab)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function collectStatePathFromClient(path: string, tab?: string): Promise<{ ok: boolean, value?: unknown, error?: string }> {
|
|
363
|
+
return twoPhase('aihand:collect-state-path', { path }, (resolve) => {
|
|
364
|
+
pendingStatePath = resolve
|
|
365
|
+
return () => {
|
|
366
|
+
pendingStatePath = null
|
|
367
|
+
}
|
|
368
|
+
}, 3000, tab)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function collectScreenFromClient(tab?: string, form?: string): Promise<ScreenReply> {
|
|
372
|
+
return twoPhase<ScreenReply>('aihand:collect-screen', { form }, (resolve) => {
|
|
373
|
+
pendingScreen = resolve
|
|
374
|
+
return () => {
|
|
375
|
+
pendingScreen = null
|
|
376
|
+
}
|
|
377
|
+
}, 3000, tab)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function sendAction(type: string, args: ActionArgs, tab?: string): Promise<ActionResult> {
|
|
381
|
+
const id = ++actionId
|
|
382
|
+
// wait actions own their timeout; give the channel that long + slack
|
|
383
|
+
const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
|
|
384
|
+
return twoPhase<ActionResult>('aihand:action', { id, type, args }, (resolve) => {
|
|
385
|
+
pendingActions.set(id, resolve)
|
|
386
|
+
return () => {
|
|
387
|
+
pendingActions.delete(id)
|
|
388
|
+
actionProgressAt.delete(id)
|
|
389
|
+
}
|
|
390
|
+
}, fullMs, tab, () => actionProgressAt.get(id))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// sendAction + the Chrome realclick handshake, in one place so the single endpoint and
|
|
394
|
+
// /chain both get it. The page resolves realclick to (x,y): if result.fired, Electron
|
|
395
|
+
// already fired the trusted click in-process — done. Otherwise (plain Chrome tab) the page
|
|
396
|
+
// couldn't click, so drive the extension's CDP queue with the coords, then collect the
|
|
397
|
+
// settled screen. A CDP failure comes back as a normal ok:false result.
|
|
398
|
+
async function runAction(type: string, args: ActionArgs, tab?: string): Promise<ActionResult> {
|
|
399
|
+
const result = await sendAction(type, args, tab)
|
|
400
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
401
|
+
if (type === 'realclick' && result.ok && !result.fired) {
|
|
402
|
+
const cdp = await runCdpClick(result.x!, result.y!, args.button ?? 'left')
|
|
403
|
+
if (!cdp.ok)
|
|
404
|
+
return { ok: false, error: `cdp click failed: ${cdp.error ?? 'unknown'}` }
|
|
405
|
+
result.detail = `${result.detail} → clicked via extension`
|
|
406
|
+
result.screen = (await collectScreenFromClient(tab)).screen
|
|
407
|
+
}
|
|
408
|
+
return result
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function evalInClient(code: string, tab?: string): Promise<{ ok: boolean, value?: string, error?: string, hint?: string }> {
|
|
412
|
+
const id = ++evalId
|
|
413
|
+
return twoPhase('aihand:eval', { id, code }, (resolve) => {
|
|
414
|
+
pendingEvals.set(id, resolve)
|
|
415
|
+
return () => {
|
|
416
|
+
pendingEvals.delete(id)
|
|
417
|
+
}
|
|
418
|
+
}, 8000, tab)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// The green channel: call a named semantic action the app mounted on __AIHAND_ACTIONS__.
|
|
422
|
+
// Same twoPhase shape as eval, but the page invokes a declared fn (not arbitrary code) and
|
|
423
|
+
// returns its delta. The action may kick off an async flow (sendPrompt → stream); give it
|
|
424
|
+
// the same generous window a streaming /click gets.
|
|
425
|
+
function semanticInClient(name: string, args: unknown[], tab?: string): Promise<{ ok: boolean, value?: string, error?: string }> {
|
|
426
|
+
const id = ++semanticId
|
|
427
|
+
return twoPhase('aihand:semantic-action', { id, name, args }, (resolve) => {
|
|
428
|
+
pendingSemantic.set(id, resolve)
|
|
429
|
+
return () => {
|
|
430
|
+
pendingSemantic.delete(id)
|
|
431
|
+
}
|
|
432
|
+
}, 15000, tab)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The god-tier green channel: replay a statically-extracted knob morphism (no hand-mounting).
|
|
436
|
+
// Server stays dumb — it forwards label+file+value; the browser resolves knob→ops from the
|
|
437
|
+
// build-time schema (virtual:aihand-knobs) and writes the store directly, then samples the
|
|
438
|
+
// resulting trajectory (same waitForStable/diffScreen/traceFlow path as /click). The action
|
|
439
|
+
// may kick off an async flow; pings extend the deadline like a streaming click does.
|
|
440
|
+
function knobInClient(knob: string, file: string | undefined, value: string | undefined, tab?: string): Promise<{ ok: boolean, value?: string, error?: string }> {
|
|
441
|
+
const id = ++knobId
|
|
442
|
+
return twoPhase('aihand:knob-action', { id, knob, file, value }, (resolve) => {
|
|
443
|
+
pendingKnob.set(id, resolve)
|
|
444
|
+
return () => {
|
|
445
|
+
pendingKnob.delete(id)
|
|
446
|
+
knobProgressAt.delete(id)
|
|
447
|
+
}
|
|
448
|
+
}, 15000, tab, () => knobProgressAt.get(id))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
name: 'aihand',
|
|
453
|
+
apply: 'serve',
|
|
454
|
+
|
|
455
|
+
transformIndexHtml() {
|
|
456
|
+
const patchCode = compilePatch()
|
|
457
|
+
return [
|
|
458
|
+
// Synchronous inline script — patches console/fetch/XHR/errors
|
|
459
|
+
// BEFORE any ES modules execute
|
|
460
|
+
{
|
|
461
|
+
tag: 'script',
|
|
462
|
+
children: patchCode,
|
|
463
|
+
injectTo: 'head-prepend',
|
|
464
|
+
},
|
|
465
|
+
// Module script — collectors + HMR channel (can be deferred)
|
|
466
|
+
{
|
|
467
|
+
tag: 'script',
|
|
468
|
+
attrs: { type: 'module' },
|
|
469
|
+
children: `import '/@fs/${clientPath}'`,
|
|
470
|
+
injectTo: 'body',
|
|
471
|
+
},
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
configureServer(_server) {
|
|
476
|
+
server = _server
|
|
477
|
+
|
|
478
|
+
// 出口1 适配:把 Vite HMR WS 包成 ServerBridge,/screen 与动作端点经 dispatch 复用它
|
|
479
|
+
// ——出口2(CDP)走同一 dispatch、换 cdpBridge。bridge 自己持久监听 aihand:action-progress
|
|
480
|
+
// 盖时戳;下面的命名 pending 监听只管 seen/mergeActions 等副作用,resolve 让给 bridge 的
|
|
481
|
+
// 一次性监听(同一 event 多监听器都触发,bridge 路由的 id 在旧命名槽里查不到 → 旧监听无害空转)。
|
|
482
|
+
const bridge = viteBridge(server, { tabs, diagnose })
|
|
483
|
+
|
|
484
|
+
// 静态调用图随源码变更失效(只清不重建,下次 /source 惰性重建)。任一 .ts/.tsx 改/增/删即脏。
|
|
485
|
+
// 扩展名判定用 endsWith 词法比较(禁正则铁律),与 read/config.ts isTsFile 同口径。
|
|
486
|
+
const isTs = (f: string) => f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')
|
|
487
|
+
const invalidateGraph = (f: string) => { if (isTs(f)) graphCache = null }
|
|
488
|
+
server.watcher.on('change', invalidateGraph)
|
|
489
|
+
server.watcher.on('add', invalidateGraph)
|
|
490
|
+
server.watcher.on('unlink', invalidateGraph)
|
|
491
|
+
|
|
492
|
+
server.hot.on('aihand:state', (data: RawState) => {
|
|
493
|
+
seen(data)
|
|
494
|
+
mergeActions(data.tab, data.actions, data.actionsDropped)
|
|
495
|
+
if (pendingResolve) {
|
|
496
|
+
pendingResolve(data)
|
|
497
|
+
pendingResolve = null
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// Client announces itself on connect (and on visibilitychange) — the registration
|
|
502
|
+
// edge the roster otherwise lacks, so /tabs is accurate without first being polled.
|
|
503
|
+
// Echo an ack so the client can stop its hello-retry loop. The client resends hello on
|
|
504
|
+
// a short interval (its first send often races the socket-open and is dropped); the ack
|
|
505
|
+
// is how it learns the roster finally has it. Keyed by tab so a client only stops once
|
|
506
|
+
// ITS registration landed.
|
|
507
|
+
server.hot.on('aihand:hello', (data: { tab?: string, url?: string, title?: string, visible?: boolean }) => {
|
|
508
|
+
seen(data)
|
|
509
|
+
if (data.tab)
|
|
510
|
+
server.hot.send('aihand:hello-ack', { tab: data.tab })
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
server.hot.on('aihand:result', (data: ActionResult & { id: number, tab?: string }) => {
|
|
514
|
+
seen(data)
|
|
515
|
+
const resolve = pendingActions.get(data.id)
|
|
516
|
+
if (resolve) {
|
|
517
|
+
pendingActions.delete(data.id)
|
|
518
|
+
resolve(data)
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
// An in-flight flow pings each frame so its deadline counts from the last sign of life,
|
|
523
|
+
// not from dispatch — lets a legitimate multi-second stream finish without a false miss.
|
|
524
|
+
server.hot.on('aihand:action-progress', (data: { id: number, tab?: string, kind?: string }) => {
|
|
525
|
+
seen(data)
|
|
526
|
+
if (data.kind === 'knob') {
|
|
527
|
+
if (pendingKnob.has(data.id))
|
|
528
|
+
knobProgressAt.set(data.id, Date.now())
|
|
529
|
+
}
|
|
530
|
+
else if (pendingActions.has(data.id)) {
|
|
531
|
+
actionProgressAt.set(data.id, Date.now())
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
server.hot.on('aihand:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string, hint?: string, tab?: string }) => {
|
|
536
|
+
seen(data)
|
|
537
|
+
const resolve = pendingEvals.get(data.id)
|
|
538
|
+
if (resolve) {
|
|
539
|
+
pendingEvals.delete(data.id)
|
|
540
|
+
resolve(data)
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
server.hot.on('aihand:semantic-result', (data: { id: number, ok: boolean, value?: string, error?: string, tab?: string }) => {
|
|
545
|
+
seen(data)
|
|
546
|
+
const resolve = pendingSemantic.get(data.id)
|
|
547
|
+
if (resolve) {
|
|
548
|
+
pendingSemantic.delete(data.id)
|
|
549
|
+
resolve(data)
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
server.hot.on('aihand:knob-result', (data: { id: number, ok: boolean, value?: string, error?: string, tab?: string }) => {
|
|
554
|
+
seen(data)
|
|
555
|
+
const resolve = pendingKnob.get(data.id)
|
|
556
|
+
if (resolve) {
|
|
557
|
+
pendingKnob.delete(data.id)
|
|
558
|
+
resolve(data)
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
server.hot.on('aihand:dom', (data: { dom: string, tab?: string }) => {
|
|
563
|
+
seen(data)
|
|
564
|
+
if (pendingDom) {
|
|
565
|
+
pendingDom(data.dom)
|
|
566
|
+
pendingDom = null
|
|
567
|
+
}
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
server.hot.on('aihand:state-path', (data: { ok: boolean, value?: unknown, error?: string, tab?: string }) => {
|
|
571
|
+
seen(data)
|
|
572
|
+
if (pendingStatePath) {
|
|
573
|
+
pendingStatePath(data)
|
|
574
|
+
pendingStatePath = null
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
server.hot.on('aihand:screen', (data: ScreenReply & { tab?: string }) => {
|
|
579
|
+
seen(data)
|
|
580
|
+
if (pendingScreen) {
|
|
581
|
+
pendingScreen(data)
|
|
582
|
+
pendingScreen = null
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// push mode: auto-detect errors after HMR
|
|
587
|
+
server.hot.on('vite:afterUpdate', () => {
|
|
588
|
+
clearTimeout(pushTimer)
|
|
589
|
+
pushTimer = setTimeout(async () => {
|
|
590
|
+
try {
|
|
591
|
+
const raw = await collectFromClient()
|
|
592
|
+
const diff = diffState(lastRaw, raw)
|
|
593
|
+
lastRaw = raw
|
|
594
|
+
if (!diff.clean) {
|
|
595
|
+
const msg = emitDiff(diff)
|
|
596
|
+
if (msg)
|
|
597
|
+
server.config.logger.warn(msg)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch {}
|
|
601
|
+
}, 500)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
// /__aihand — summary
|
|
605
|
+
// /__aihand/{section}/{index} — detail
|
|
606
|
+
// /__aihand/check — health check
|
|
607
|
+
server.middlewares.use('/__aihand', async (req, res) => {
|
|
608
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
609
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
610
|
+
const full = url.searchParams.has('full')
|
|
611
|
+
// Resolve the target tab. Explicit ?tab= wins. Otherwise, when exactly one tab is
|
|
612
|
+
// live, adopt it: this routes the action through the tab-ADDRESSED path (skip() by id,
|
|
613
|
+
// answers regardless of visibility) instead of the no-tab broadcast path. The addressed
|
|
614
|
+
// path is the only one that honors per-action progress pings — a long async flow
|
|
615
|
+
// (send → stream → done) pings to extend its deadline, but the broadcast path rejects
|
|
616
|
+
// flat at fullMs and would cut a >5s stream short. One live tab is the common case, so
|
|
617
|
+
// without this default the flow trace is lost on every plain (no ?tab=) drive call.
|
|
618
|
+
// Ambiguity (>1 tab) still falls through to refuse() below — no silent wrong-tab pick.
|
|
619
|
+
const explicitTab = url.searchParams.get('tab') || undefined
|
|
620
|
+
const live = liveTabs()
|
|
621
|
+
const tab = explicitTab ?? (live.length === 1 ? live[0].id : undefined)
|
|
622
|
+
|
|
623
|
+
// Federation: ?host=<host:port> reverse-proxies this request to a *sibling*
|
|
624
|
+
// aihand (another dev server — micro-frontend, separate front/back servers, a
|
|
625
|
+
// teammate's machine). N peers, no registry, no discovery, no central router:
|
|
626
|
+
// each plugin is already an HTTP server, so any one of them proxies to a named
|
|
627
|
+
// peer via a server-side fetch (no browser, no CORS). The forwarded URL drops
|
|
628
|
+
// host= so the peer treats it as local — `host===self` and re-forwarding both
|
|
629
|
+
// collapse to the normal path. ?host= is a routing directive, not stored state.
|
|
630
|
+
const host = url.searchParams.get('host') || undefined
|
|
631
|
+
const selfPort = server.config.server.port || 5173
|
|
632
|
+
const selfHosts = new Set([`localhost:${selfPort}`, `127.0.0.1:${selfPort}`, `:${selfPort}`, `${selfPort}`])
|
|
633
|
+
if (host && !selfHosts.has(host)) {
|
|
634
|
+
const fwd = new URL(url)
|
|
635
|
+
fwd.searchParams.delete('host')
|
|
636
|
+
const target = `http://${host}/__aihand/${parts.join('/')}${fwd.search}`
|
|
637
|
+
try {
|
|
638
|
+
const body = req.method === 'POST' ? await readBody(req) : undefined
|
|
639
|
+
const r = await fetch(target, { method: req.method, body })
|
|
640
|
+
send(res, r.status, await r.text())
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
// Split the failure fibers — each needs a different fix. node's fetch nests
|
|
644
|
+
// the syscall error under .cause.code.
|
|
645
|
+
const code = (e as { cause?: { code?: string } }).cause?.code
|
|
646
|
+
const why = code === 'ECONNREFUSED'
|
|
647
|
+
? `nothing is listening on ${host} — its dev server isn't running (start it), or the port is wrong.`
|
|
648
|
+
: code === 'ENOTFOUND'
|
|
649
|
+
? `host '${host}' doesn't resolve — check the hostname.`
|
|
650
|
+
: code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
|
|
651
|
+
? `connection to ${host} timed out — it's unreachable (firewall, or wrong host).`
|
|
652
|
+
: `${(e as Error).message} (code ${code ?? 'unknown'}).`
|
|
653
|
+
send(res, 502, `cannot reach aihand peer at ${host}: ${why}`)
|
|
654
|
+
}
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// /__aihand/ping — process-level BOOT_ID, polled by the injected client-patch
|
|
659
|
+
// to self-heal after a server restart (see BOOT_ID). Server-self health, no tab,
|
|
660
|
+
// highest frequency — short-circuit before everything else.
|
|
661
|
+
if (parts[0] === 'ping') {
|
|
662
|
+
send(res, 200, BOOT_ID)
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// /__aihand/help — the full command reference. Static, tab-independent: this is
|
|
667
|
+
// the body that used to sit in CLAUDE.md every session. It moved here so the model
|
|
668
|
+
// pulls it on demand (it's already curling aihand when it needs a command) instead
|
|
669
|
+
// of paying for it resident. The injected snippet points here.
|
|
670
|
+
if (parts[0] === 'help') {
|
|
671
|
+
send(res, 200, helpText(selfPort).trim())
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// /__aihand/open?file=…&line=…&column=… — open a source location in the editor.
|
|
676
|
+
// The page's Alt/Ctrl-click overlay (client.ts) hits this with the data-insp-path
|
|
677
|
+
// it read off the clicked element. Server-side only — no client round-trip, no tab.
|
|
678
|
+
// Guarded to files under the project root: this is a dev-only serve plugin, but
|
|
679
|
+
// launchIDE shells out to an editor, so we never open an arbitrary absolute path.
|
|
680
|
+
if (parts[0] === 'open') {
|
|
681
|
+
const file = url.searchParams.get('file') || ''
|
|
682
|
+
const line = Number(url.searchParams.get('line')) || 1
|
|
683
|
+
const column = Number(url.searchParams.get('column')) || 1
|
|
684
|
+
const root = server.config.root
|
|
685
|
+
const abs = isAbsolute(file) ? file : resolve(root, file)
|
|
686
|
+
const rel = relative(root, abs)
|
|
687
|
+
if (!file || rel.startsWith('..') || isAbsolute(rel)) {
|
|
688
|
+
send(res, 400, `refusing to open '${file}' — not under project root`)
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const { launchIDE } = await import('launch-ide')
|
|
693
|
+
launchIDE({ file: abs, line, column })
|
|
694
|
+
send(res, 200, `opening ${rel}:${line}:${column}`)
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
send(res, 500, `launch-ide failed: ${err instanceof Error ? err.message : 'unknown'}`)
|
|
698
|
+
}
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// /__aihand/source?path=<data-insp-path> — 融合反向态射的静态孪生(FUSION §4)。
|
|
703
|
+
// /open 的运行时半边把点中坐标送进编辑器;这里送回**代码原因**:该行属于哪个符号 + callers/
|
|
704
|
+
// callees。?path 直接喂 /dom 吐的 data-insp-path(`rel:line:col:Name`),
|
|
705
|
+
// 也吃裸 `rel:line`。静态侧用现成 CallGraph(缓存),运行时侧不掺——点对点缝,无第四数据结构。
|
|
706
|
+
// 触发端是运行时(人/AI 在页面点了元素),故 join 挂在 ui 探针,不在 read CLI。AI 仍可走
|
|
707
|
+
// `curl /dom → aihand read source`;此 endpoint 闭的是**人**侧环(点击页面直接看代码原因)。
|
|
708
|
+
if (parts[0] === 'source') {
|
|
709
|
+
const path = url.searchParams.get('path') || ''
|
|
710
|
+
if (!path) {
|
|
711
|
+
send(res, 400, 'usage: /__aihand/source?path=<file:line> (path 可直接是 /dom 的 data-insp-path)')
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
const { parseInspPath, locate, renderLocate } = await import('../../read/locate.js')
|
|
715
|
+
const { file, line } = parseInspPath(path)
|
|
716
|
+
if (!file || !Number.isFinite(line)) {
|
|
717
|
+
send(res, 400, `bad path '${path}' — expected file:line (or data-insp-path)`)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
const graph = await cachedGraph()
|
|
722
|
+
const result = locate(graph, file, line)
|
|
723
|
+
if (!result) {
|
|
724
|
+
send(res, 404, `no symbol covers ${file}:${line} — file not in scan scope, or line outside any symbol`)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
send(res, 200, renderLocate(result, url.searchParams.has('json')))
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
send(res, 500, `source lookup failed: ${err instanceof Error ? err.message : 'unknown'}`)
|
|
731
|
+
}
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Multi-tab guard: with >1 live tab and no ?tab=, a broadcast would race N
|
|
736
|
+
// answers and keep a random one. Refuse and show the roster so the caller
|
|
737
|
+
// picks. Single tab (or addressed) falls through to the normal path.
|
|
738
|
+
// /tabs, /timeline and /cdp/* are exempt (server-side aggregate reads / polling).
|
|
739
|
+
const ambiguous = () => !tab && !['tabs', 'timeline', 'cdp'].includes(parts[0]) && liveTabs().length > 1
|
|
740
|
+
const refuse = () => send(res, 409, `multiple live tabs — add ?tab=<id>:\n\n${formatTabs(liveTabs(), Date.now())}`)
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
// /__aihand/tabs — list live clients (tab id, visibility, title, url, age).
|
|
744
|
+
// On an empty roster, defer to the single diagnose() projection so the
|
|
745
|
+
// "page open but not injected" vs "no browser at all" split is never re-derived
|
|
746
|
+
// here — one π, one place.
|
|
747
|
+
if (parts[0] === 'tabs') {
|
|
748
|
+
const roster = formatTabs(liveTabs(), Date.now())
|
|
749
|
+
send(res, 200, roster === '(no live tabs)' ? `(${diagnose()})` : roster)
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// /__aihand/timeline — interleaved action stream across all tabs (server's
|
|
754
|
+
// global ring), rendered by the same formatActions as the single-tab tail.
|
|
755
|
+
// With >1 tab, lines are prefixed with the tab id; ?tab= filters to one.
|
|
756
|
+
// Pull a fresh collect from each addressed tab first so the ring is current at
|
|
757
|
+
// read time — actions flush into actionLog via the aihand:state merge, decoupled
|
|
758
|
+
// from whichever read happened before (a /screen wouldn't have flushed them).
|
|
759
|
+
if (parts[0] === 'timeline') {
|
|
760
|
+
// Sequential, not parallel: collectFromClient shares one module-level
|
|
761
|
+
// pendingResolve, so concurrent collects would clobber each other.
|
|
762
|
+
const targets = tab ? [tab] : liveTabs().map(t => t.id)
|
|
763
|
+
for (const id of targets)
|
|
764
|
+
await collectFromClient(id).catch(() => {})
|
|
765
|
+
const entries = tab ? actionLog.filter(e => e.tab === tab) : actionLog
|
|
766
|
+
const dropped = targets.reduce((n, id) => n + (droppedByTab.get(id) ?? 0), 0)
|
|
767
|
+
send(res, 200, formatActions(entries, undefined, dropped))
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (ambiguous()) {
|
|
772
|
+
refuse()
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// /__aihand/eval — run arbitrary JS in the page. POST body = code,
|
|
777
|
+
// or ?code=. The page evaluates it and returns the result (or thrown
|
|
778
|
+
// error). The escape hatch for anything the typed endpoints can't do:
|
|
779
|
+
// install event listeners, read closures, probe real event flow.
|
|
780
|
+
if (parts[0] === 'eval') {
|
|
781
|
+
let code = url.searchParams.get('code') || ''
|
|
782
|
+
if (!code && req.method === 'POST')
|
|
783
|
+
code = await readBody(req)
|
|
784
|
+
if (!code) {
|
|
785
|
+
send(res, 400, 'eval needs ?code= or a POST body')
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
const r = await evalInClient(code, tab)
|
|
789
|
+
const body = r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`
|
|
790
|
+
// refine the eval fiber to the action fiber: when the code is a hand-rolled
|
|
791
|
+
// /state or /query, or the error is an illegal selector, point at the typed twin.
|
|
792
|
+
send(res, r.ok ? 200 : 422, r.hint ? `${body}\n\nhint: ${r.hint}` : body)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// /__aihand/action?name=sendPrompt — the green channel. Calls a named semantic
|
|
797
|
+
// action the app mounted on __AIHAND_ACTIONS__ (the store-write + real semantic fn
|
|
798
|
+
// a button's onClick runs, minus the DOM). Returns its before/after delta directly
|
|
799
|
+
// — operation IS observation, no /screen round-trip. args= is a JSON array (or POST
|
|
800
|
+
// body); a name= the app didn't declare lists the ones it did.
|
|
801
|
+
if (parts[0] === 'action') {
|
|
802
|
+
// ?knob= — the god-tier path: replay a statically-extracted morphism with
|
|
803
|
+
// zero hand-mounting. The browser resolves knob→ops from virtual:aihand-knobs
|
|
804
|
+
// and writes the store directly. ?value= fills param knobs; ?file= disambiguates
|
|
805
|
+
// a label that two components share. context-dependent knobs are refused (422).
|
|
806
|
+
const knob = url.searchParams.get('knob') || ''
|
|
807
|
+
if (knob) {
|
|
808
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
809
|
+
const r = await knobInClient(
|
|
810
|
+
knob,
|
|
811
|
+
url.searchParams.get('file') || undefined,
|
|
812
|
+
url.searchParams.get('value') ?? undefined,
|
|
813
|
+
tab,
|
|
814
|
+
)
|
|
815
|
+
send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const name = url.searchParams.get('name') || ''
|
|
819
|
+
if (!name) {
|
|
820
|
+
const hint = misroutedAction([...url.searchParams.keys()])
|
|
821
|
+
const base = 'action needs ?knob= (a panel label) or ?name= (a __AIHAND_ACTIONS__ key)'
|
|
822
|
+
send(res, 400, hint ? `${base}\n\nhint: ${hint}` : base)
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
let args: unknown[] = []
|
|
826
|
+
const rawArgs = url.searchParams.get('args') || (req.method === 'POST' ? await readBody(req) : '')
|
|
827
|
+
if (rawArgs) {
|
|
828
|
+
try {
|
|
829
|
+
const parsed = JSON.parse(rawArgs)
|
|
830
|
+
args = Array.isArray(parsed) ? parsed : [parsed]
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
send(res, 400, `action args= must be a JSON array, got: ${rawArgs.slice(0, 80)}`)
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
838
|
+
const r = await semanticInClient(name, args, tab)
|
|
839
|
+
send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// /__aihand/dom[?scope=Component|?sel=CSS] — semantic DOM, scoped, on-demand
|
|
844
|
+
if (parts[0] === 'dom') {
|
|
845
|
+
const dom = await collectDomFromClient(
|
|
846
|
+
url.searchParams.get('scope') || undefined,
|
|
847
|
+
url.searchParams.get('sel') || undefined,
|
|
848
|
+
tab,
|
|
849
|
+
)
|
|
850
|
+
send(res, 200, dom || '(empty)')
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// /__aihand/state?path=store.field.0 — the point-drill of the state axis, twin
|
|
855
|
+
// of /dom?sel=. /state and /state/<store> both go through boundedSnapshot (every
|
|
856
|
+
// array → "Array(N)", depth-3 cliff); ?path= walks the raw live store and expands
|
|
857
|
+
// the leaf one bound deeper, so a nested list is reachable without a hand-rolled
|
|
858
|
+
// /eval. (Domain drill /state/<store> still rides the generic detail() path below.)
|
|
859
|
+
if (parts[0] === 'state' && url.searchParams.has('path')) {
|
|
860
|
+
const path = url.searchParams.get('path') || ''
|
|
861
|
+
const r = await collectStatePathFromClient(path, tab)
|
|
862
|
+
if (!r.ok) {
|
|
863
|
+
send(res, 404, `path not found: ${r.error}`)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
let body: string
|
|
867
|
+
try {
|
|
868
|
+
body = JSON.stringify(r.value, null, 2) ?? String(r.value)
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
body = String(r.value)
|
|
872
|
+
}
|
|
873
|
+
send(res, 200, body)
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// /__aihand/screen — state-machine projection {view, modal, focus, knobs}.
|
|
878
|
+
// Each read stashes its snap under a token and prints `token: tN`. With
|
|
879
|
+
// ?since=<token> we diff this read against that stashed snap and return only
|
|
880
|
+
// the transition (diffScreen) — what moved since you last looked, not a snapshot.
|
|
881
|
+
if (parts[0] === 'screen') {
|
|
882
|
+
// dispatch(viteBridge) 跑 collect-screen + 渲染——与出口2 同一份。?since= token
|
|
883
|
+
// ring 是 HTTP 专属,留在这里:用 dispatchScreen 顺手拿回的结构化 reply 去 stash/diff。
|
|
884
|
+
const { reply, result } = await dispatchScreen(bridge, { tab, form: url.searchParams.get('form') || undefined })
|
|
885
|
+
const since = url.searchParams.get('since')
|
|
886
|
+
const token = stashScreen(reply)
|
|
887
|
+
if (since) {
|
|
888
|
+
const prev = screenStash.get(since)
|
|
889
|
+
if (!prev) {
|
|
890
|
+
send(res, 422, `unknown since token "${since}" (expired or never issued) — read /screen first for a fresh token`)
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
const d = diffState(
|
|
894
|
+
{ ui: '', console: prev.console, network: prev.network, errors: prev.errors, state: {}, url: '', timestamp: 0 },
|
|
895
|
+
{ ui: '', console: reply.console, network: reply.network, errors: reply.errors, state: {}, url: '', timestamp: 0 },
|
|
896
|
+
)
|
|
897
|
+
const changed = diffScreen(prev.snap, reply.snap, d.newErrors, d.newExceptions, d.newFailedRequests)
|
|
898
|
+
send(res, 200, `token: ${token}\n${changed.length ? changed.join('\n') : '(no state change)'}`)
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
send(res, 200, result.body === '(empty)' ? '(empty)' : `token: ${token}\n${result.body}`)
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// /__aihand/cdp/poll — the Chrome extension long-polls here for the next
|
|
906
|
+
// trusted-input command. Returns the command as JSON, or 204 on timeout
|
|
907
|
+
// (the extension simply re-polls). Only one poller is parked at a time.
|
|
908
|
+
if (parts[0] === 'cdp' && parts[1] === 'poll') {
|
|
909
|
+
const queued = cdpQueue.shift()
|
|
910
|
+
if (queued) {
|
|
911
|
+
send(res, 200, JSON.stringify(queued))
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
const cmd = await new Promise<CdpCommand | null>((resolve) => {
|
|
915
|
+
cdpWaiter = resolve
|
|
916
|
+
setTimeout(() => {
|
|
917
|
+
if (cdpWaiter === resolve) {
|
|
918
|
+
cdpWaiter = null
|
|
919
|
+
resolve(null)
|
|
920
|
+
}
|
|
921
|
+
}, 25000)
|
|
922
|
+
})
|
|
923
|
+
if (cmd)
|
|
924
|
+
send(res, 200, JSON.stringify(cmd))
|
|
925
|
+
else
|
|
926
|
+
send(res, 204, '')
|
|
927
|
+
return
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// /__aihand/cdp/result — POST {id, ok, error?}; resolves the awaiting realclick.
|
|
931
|
+
if (parts[0] === 'cdp' && parts[1] === 'result') {
|
|
932
|
+
const body = await readBody(req)
|
|
933
|
+
let data: { id: number, ok: boolean, error?: string }
|
|
934
|
+
try {
|
|
935
|
+
data = JSON.parse(body)
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
send(res, 400, 'cdp/result needs a JSON body {id, ok, error?}')
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
const resolveCdp = cdpResults.get(data.id)
|
|
942
|
+
if (resolveCdp) {
|
|
943
|
+
cdpResults.delete(data.id)
|
|
944
|
+
resolveCdp({ ok: data.ok, error: data.error })
|
|
945
|
+
}
|
|
946
|
+
send(res, 200, 'ok')
|
|
947
|
+
return
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// /__aihand/chain — POST a JSON array of actions, run them in
|
|
951
|
+
// sequence (each settles the DOM before the next), stop on first
|
|
952
|
+
// failure. One round-trip for a whole interaction.
|
|
953
|
+
if (parts[0] === 'chain') {
|
|
954
|
+
const body = await readBody(req)
|
|
955
|
+
let steps: ChainStep[]
|
|
956
|
+
try {
|
|
957
|
+
steps = JSON.parse(body)
|
|
958
|
+
if (!Array.isArray(steps))
|
|
959
|
+
throw new Error('body must be a JSON array')
|
|
960
|
+
}
|
|
961
|
+
catch (e) {
|
|
962
|
+
send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
lastRaw = null
|
|
966
|
+
// 循环/fail-fast/skipped 汇报走内核 runChain(与出口2 共用一份)。出口1 的单步执行器
|
|
967
|
+
// 比出口2 多 knob 特例:knob 步骤经绿色通道 replay 一个 panel 态射(/action?knob= 的
|
|
968
|
+
// store-direct 最优路,现在能进 batch,不被迫降级 click text=)。非 knob 走 runAction。
|
|
969
|
+
const dr = await runChain(steps, async (type, args) => {
|
|
970
|
+
if (type === 'knob') {
|
|
971
|
+
const k = args as { knob?: string, file?: string, value?: string }
|
|
972
|
+
if (!k.knob)
|
|
973
|
+
return { ok: false, label: 'knob', error: 'needs knob=<panel label>' }
|
|
974
|
+
const kr = await knobInClient(k.knob, k.file, k.value, tab)
|
|
975
|
+
return { ok: kr.ok, label: 'knob', detail: kr.ok ? `replayed ${k.knob}` : undefined, error: kr.ok ? undefined : kr.error, screen: kr.ok ? kr.value : undefined }
|
|
976
|
+
}
|
|
977
|
+
const check = resolveAction(type, args)
|
|
978
|
+
if (!check.valid)
|
|
979
|
+
return { ok: false, label: type, error: check.error }
|
|
980
|
+
const r = await runAction(type, args, tab)
|
|
981
|
+
return { ok: r.ok, label: type, detail: r.detail, error: r.error, screen: r.screen, actions: r.actions }
|
|
982
|
+
})
|
|
983
|
+
send(res, dr.status, dr.body)
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// action endpoints — the full TYPES source of truth (no hand-kept second list that
|
|
988
|
+
// drifts; dblclick was missing here before). isDispatchAction ones go through the
|
|
989
|
+
// shared kernel (dispatchAction), the SPECIAL_CONTRACT rest fall through to runAction.
|
|
990
|
+
if (TYPES.includes(parts[0] as (typeof TYPES)[number])) {
|
|
991
|
+
const args = argsFromQuery(url.searchParams)
|
|
992
|
+
// 最小闭环动作(click|fill|press|hover|wait)经 dispatch(viteBridge)——与出口2 同一
|
|
993
|
+
// 内核路径。dispatchAction 内部自校验 + 渲染,id 复用 plugin 的 actionId 计数器(与
|
|
994
|
+
// runAction/chain 同空间不撞)。realclick/screenshot/query/assert/drag 等仍走 runAction
|
|
995
|
+
// (CDP 握手、落盘等出口1 专属副作用),fall through。
|
|
996
|
+
if (isDispatchAction(parts[0])) {
|
|
997
|
+
const dr = await dispatchAction(bridge, parts[0], args, { nextId: () => ++actionId, tab })
|
|
998
|
+
lastRaw = null // page mutated; force fresh collect next read (runAction parity)
|
|
999
|
+
send(res, dr.status, dr.body)
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
const check = resolveAction(parts[0], args)
|
|
1003
|
+
if (!check.valid) {
|
|
1004
|
+
send(res, 400, check.error ?? 'invalid action')
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
const result = await runAction(parts[0], args, tab)
|
|
1008
|
+
if (parts[0] === 'screenshot' && result.dataUrl) {
|
|
1009
|
+
const dir = resolve(server.config.root, '.aidev')
|
|
1010
|
+
mkdirSync(dir, { recursive: true })
|
|
1011
|
+
const name = url.searchParams.get('out') || `shot-${result.dataUrl.length}.png`
|
|
1012
|
+
const file = resolve(dir, name)
|
|
1013
|
+
writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
|
|
1014
|
+
send(res, 200, `saved: ${file}`)
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
|
|
1018
|
+
const actionsTail = result.actions ? `\n\n--- recent actions ---\n${result.actions}` : ''
|
|
1019
|
+
const changedTail = result.screen ? `\n\n--- changed ---\n${result.screen}` : ''
|
|
1020
|
+
const flowTail = result.flow ? `\n\n--- flow ---\n${result.flow}` : ''
|
|
1021
|
+
send(res, result.ok ? 200 : 422, `${head}${actionsTail}${changedTail}${flowTail}`)
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// check endpoint
|
|
1026
|
+
if (parts[0] === 'check') {
|
|
1027
|
+
const raw = await collectFromClient(tab)
|
|
1028
|
+
lastRaw = raw
|
|
1029
|
+
const result = check(raw)
|
|
1030
|
+
const output = emitCheck(result)
|
|
1031
|
+
send(res, result.pass ? 200 : 417, output)
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// /__aihand/profile — performance profiler (always-on, semantic-bucketed).
|
|
1036
|
+
// /profile reads the current window; /profile/reset clears it.
|
|
1037
|
+
// Hidden tabs throttle rAF to ~1fps, making each hidden frame look like a
|
|
1038
|
+
// 1000ms dropped frame — the profiler guards with document.hidden, but if
|
|
1039
|
+
// hiddenFrames is high the data is suspect. Mention it.
|
|
1040
|
+
if (parts[0] === 'profile') {
|
|
1041
|
+
// Empty perf data is a recoverable state, not a dead end: the tab DID answer
|
|
1042
|
+
// collectFromClient (so it's connected) — it's just backgrounded, where the
|
|
1043
|
+
// browser throttles rAF to ~1fps and there are no real frames to sample. The
|
|
1044
|
+
// bare "(no perf data)" reads to a model as "no browser, can't help", so it
|
|
1045
|
+
// gives up. Say the page is already running and a 2s foreground fixes it —
|
|
1046
|
+
// profiling is the only read that needs foreground (/screen, /dom work hidden).
|
|
1047
|
+
// Empty perf data after a SUCCESSFUL collect is its own fiber, disjoint from
|
|
1048
|
+
// diagnose() (which is the no-reply projection): the tab answered, so it's
|
|
1049
|
+
// connected — it's just backgrounded, where the browser throttles rAF to ~1fps
|
|
1050
|
+
// and there are no real frames to sample. Recoverable in 2s, not a dead end.
|
|
1051
|
+
// The tab-absent fallback defers to diagnose() so the socket logic lives once.
|
|
1052
|
+
const noPerfMsg = (t?: string) => {
|
|
1053
|
+
const info = t ? tabs.get(t) : liveTabs()[0]
|
|
1054
|
+
return info
|
|
1055
|
+
? `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.)`
|
|
1056
|
+
: `(${diagnose(t)})`
|
|
1057
|
+
}
|
|
1058
|
+
// Clear the client-side perf window and wait for ack. Used by /profile/reset
|
|
1059
|
+
// and by /profile/diff when capturing a baseline — so "before" and "after"
|
|
1060
|
+
// each measure ONLY their own reproduce, not an ever-growing running sum.
|
|
1061
|
+
const resetPerfWindow = () => new Promise<void>((resolve, reject) => {
|
|
1062
|
+
const timeout = setTimeout(() => reject(new Error('timeout waiting for perf-reset-ack')), 3000)
|
|
1063
|
+
const handler = (data: { tab?: string }) => {
|
|
1064
|
+
if (tab && data.tab !== tab) return
|
|
1065
|
+
clearTimeout(timeout)
|
|
1066
|
+
server.hot.off('aihand:perf-reset-ack', handler)
|
|
1067
|
+
resolve()
|
|
1068
|
+
}
|
|
1069
|
+
server.hot.on('aihand:perf-reset-ack', handler)
|
|
1070
|
+
server.hot.send('aihand:perf-reset', { tab, requireVisible: false })
|
|
1071
|
+
})
|
|
1072
|
+
if (parts[1] === 'reset') {
|
|
1073
|
+
await resetPerfWindow()
|
|
1074
|
+
send(res, 200, 'perf window cleared — reproduce the interaction, then GET /profile')
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (parts[1] === 'diff') {
|
|
1078
|
+
// Closed-loop verdict. First call captures a baseline; second diffs
|
|
1079
|
+
// current vs baseline and clears it. Workflow: /profile/diff (mark before)
|
|
1080
|
+
// → make a fix → reproduce → /profile/diff (get IMPROVED/REGRESSED verdict).
|
|
1081
|
+
const raw = await collectFromClient(tab)
|
|
1082
|
+
lastRaw = raw
|
|
1083
|
+
if (!raw.performance) {
|
|
1084
|
+
send(res, 200, noPerfMsg(tab))
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
if (!perfBaseline) {
|
|
1088
|
+
perfBaseline = raw.performance
|
|
1089
|
+
// Clear the window so the NEXT collect measures only the post-fix
|
|
1090
|
+
// reproduce. Without this, "after" ⊇ "before" (samples append, total
|
|
1091
|
+
// is a running sum) → self-time can only grow → IMPROVED impossible.
|
|
1092
|
+
await resetPerfWindow()
|
|
1093
|
+
send(res, 200, 'baseline captured + window cleared — make your fix, reproduce the interaction, then GET /profile/diff again for the verdict')
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
const report = diffPerformance(perfBaseline, raw.performance)
|
|
1097
|
+
perfBaseline = null // consumed; next call starts a fresh baseline
|
|
1098
|
+
send(res, 200, report)
|
|
1099
|
+
return
|
|
1100
|
+
}
|
|
1101
|
+
// /profile — fresh collect, render detail
|
|
1102
|
+
const raw = await collectFromClient(tab)
|
|
1103
|
+
lastRaw = raw
|
|
1104
|
+
if (!raw.performance) {
|
|
1105
|
+
send(res, 200, noPerfMsg(tab))
|
|
1106
|
+
return
|
|
1107
|
+
}
|
|
1108
|
+
const hiddenNote = raw.performance.hiddenFrames > 10
|
|
1109
|
+
? `\n\n⚠ ${raw.performance.hiddenFrames} frames skipped while tab was hidden — bring it to foreground and /profile/reset for accurate data.`
|
|
1110
|
+
: ''
|
|
1111
|
+
send(res, 200, detail(raw, 'profile', undefined, false) + hiddenNote)
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// detail: /__aihand/{section}[/{index}][?full]
|
|
1116
|
+
if (parts.length >= 1) {
|
|
1117
|
+
if (!lastRaw)
|
|
1118
|
+
lastRaw = await collectFromClient(tab)
|
|
1119
|
+
const result = detail(lastRaw, parts[0], parts[1], full)
|
|
1120
|
+
if (result !== null) {
|
|
1121
|
+
send(res, 200, result)
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
// null splits two fibers: an unknown section name (→ fix the path) vs a
|
|
1125
|
+
// known section that's simply empty (→ nothing to show, not an error). Name
|
|
1126
|
+
// the valid sections so the caller can tell which it hit.
|
|
1127
|
+
const SECTIONS = ['ui', 'console', 'network', 'errors', 'state', 'profile']
|
|
1128
|
+
send(res, 404, SECTIONS.includes(parts[0])
|
|
1129
|
+
? `'${parts[0]}' is empty right now — nothing captured this window (not an error).`
|
|
1130
|
+
: `unknown section '${parts[0]}'. Valid: ${SECTIONS.join(', ')}. (Or /screen, /dom, /tabs, /timeline, /check.)`)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// summary or full: /__aihand[?full]
|
|
1135
|
+
const raw = await collectFromClient(tab)
|
|
1136
|
+
lastRaw = raw
|
|
1137
|
+
if (full) {
|
|
1138
|
+
const compacted = compact(raw)
|
|
1139
|
+
send(res, 200, emit(compacted))
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
send(res, 200, emitSummary(raw))
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
send(res, 504, err instanceof Error ? err.message : 'unknown error')
|
|
1147
|
+
}
|
|
1148
|
+
})
|
|
1149
|
+
},
|
|
1150
|
+
}
|
|
1151
|
+
}
|