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.
Files changed (113) hide show
  1. package/README.md +152 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-3J7EYI6G.cjs +651 -0
  25. package/dist/cli-FIJLKAGI.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. 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
+ }