aihand 0.0.1 → 0.1.0

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 +136 -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-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.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,65 @@
1
+ // aihand/self —— 把 self-rep 的「状态/输出端」结构从 app 构建管线正向投影出来。
2
+ //
3
+ // 对偶于 knobSchema(旋钮/输入端):那个把旋钮态射(拨它 → store.{field=to})从 onClick
4
+ // 长出,这个把 store 的值域(`'chat'|'im'|'workbench'`)从类型注解长出。两者同形:build 时
5
+ // 静态分析(stategraph 神经图引擎,ts-morph type checker,Node 侧)、运行时纯 JSON、浏览器零 AST。
6
+ // 控制面板宪法 (旋钮 × 状态) 的两半,并列在 aihand() 一个插件里。
7
+ //
8
+ // 数学终点的最后一跳:值域是 TS 类型注解,tsc 编译后擦除,运行时 store 只剩 'chat' 这一个
9
+ // 字符串。要让 screen(store) 读到完整值域,信息必须在 build 时(类型还在场时)被捞回来、
10
+ // 注入运行时。这就是 discover/stategraph 引擎做的事:类型注解声明值域一次,插件投影成
11
+ // virtual:store-schema,运行时直读 —— 开发者一字不改 store,零重复声明。
12
+
13
+ import { discover } from './discover'
14
+ import type { Grown } from './grow'
15
+
16
+ const VIRTUAL = 'virtual:store-schema'
17
+ const RESOLVED = '\0' + VIRTUAL
18
+
19
+ interface Options {
20
+ // 要扫的根(绝对路径):目录(递归扫)或单文件,任意混合。aihand() 传 scan(config) 解出的
21
+ // 全仓文件清单 —— store 散落全仓(src/store + features/IM + ChatHeader + mobile + …),
22
+ // 不靠人列:引擎按返回类型的响应式 marker 认状态节点,非 store 文件自动过滤掉。
23
+ roots: string | string[]
24
+ // store 工厂注入的响应式指纹字段(构造无关判据);来自 config.runtime.storeMarker。默认 '_loading'。
25
+ storeMarker?: string
26
+ }
27
+
28
+ // vite 插件:dev/build 时扫 storeDir,把每个 create() store 的字段结构(名/值域/anchor)
29
+ // 长成 Grown[],经 virtual:store-schema 暴露给运行时。运行时 `import { schema } from
30
+ // 'virtual:store-schema'` 即得全仓 store 的可投影 schema,无需任何运行时 AST。
31
+ export function storeSchema(options: Options) {
32
+ let cache: Grown[] | null = null
33
+
34
+ const roots = Array.isArray(options.roots) ? options.roots : [options.roots]
35
+ const build = async (): Promise<Grown[]> => {
36
+ if (!cache)
37
+ cache = await discover(roots, options.storeMarker)
38
+ return cache
39
+ }
40
+
41
+ return {
42
+ name: 'aihand:store-schema',
43
+ resolveId(id: string) {
44
+ if (id === VIRTUAL)
45
+ return RESOLVED
46
+ },
47
+ async load(id: string) {
48
+ if (id !== RESOLVED)
49
+ return
50
+ const grown = await build()
51
+ // schema = store 名 → Grown。运行时按 store 名取自己的 schema 喂 synth。
52
+ const byStore: Record<string, Grown> = {}
53
+ for (const g of grown)
54
+ byStore[g.store] = g
55
+ return `export const schema = ${JSON.stringify(byStore)}\n`
56
+ },
57
+ // store 文件改动 → 失效缓存。下次 cold load 重跑引擎(~2s),值域改了 schema 跟着变。
58
+ // 不在 handleHotUpdate 里同步重投影:引擎离线纪律,绝不让 ts-morph 进同步 HMR 热路径 ——
59
+ // 值域是类型注解(改动罕见),下次 load 拿到即可,不值得为它在热路径养一个常驻 Project。
60
+ handleHotUpdate(ctx: { file: string }) {
61
+ if (roots.some(r => ctx.file.startsWith(r)))
62
+ cache = null
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,37 @@
1
+ // 合成:把两半接成一个完整 Self。
2
+ //
3
+ // 静态引擎给【schema】—— 字段名、值域、源码 anchor(stategraph 离线长出)。
4
+ // 运行时给【snapshot】—— 每个字段此刻的值(动态,从 store 读)。
5
+ // 完整的 self-rep = schema ⋉ snapshot:知道「能取哪些值」+「此刻取哪个值」。
6
+ //
7
+ // 关键:aipeek 手挑 `view: appUIStore.mode` —— 人来决定哪个字段是「主视图档位」。
8
+ // 但 schema 里这个判断是结构性的:domain 非空 = 这是个有限值域的档位(联合字面量),
9
+ // 就是 view 类。无需手挑,引擎已经把它标出来了。view = 第一个有 domain 的字段的当前值。
10
+ //
11
+ // 纯函数,无 fs/ts-morph —— 故浏览器安全(synth 在真页面里把 schema ⋉ live store 读)。
12
+ // 多 store 发现(discover)走 ts-morph 引擎,是 Node 端,拆到 discover.ts,不污染这里。
13
+
14
+ import type { Grown } from './grow'
15
+ import type { Self } from './self'
16
+
17
+ // store 当前的运行时快照:字段名 → 当前值。真页面里这就是 mobx store 的普通读取
18
+ // (appUIStore.mode 等),测试里直接喂对象。schema 决定读哪些 key,snapshot 给值。
19
+ export type Snapshot = Record<string, unknown>
20
+
21
+ // schema(一个 store 的 Grown)⋉ snapshot → Self。
22
+ // view = 第一个 domain 非空字段的当前值(结构性识别,非手挑);
23
+ // state = 全字段当前值;anchor = store 声明位置;modal/focus 留给运行时另投(DOM 焦点态,
24
+ // 不在 store schema 里 —— 那是 screen 的活,这里只合成 store 能声明的部分)。
25
+ export function synth(schema: Grown, snapshot: Snapshot): Self {
26
+ const viewField = schema.fields.find(f => f.domain.length > 0)
27
+ const state: Record<string, unknown> = {}
28
+ for (const f of schema.fields)
29
+ state[f.name] = snapshot[f.name]
30
+ return {
31
+ view: viewField ? String(snapshot[viewField.name] ?? '') : '',
32
+ modal: null,
33
+ focus: null,
34
+ state,
35
+ anchor: schema.anchor,
36
+ }
37
+ }
@@ -0,0 +1,102 @@
1
+ /* eslint-disable no-console -- this is a CLI; stdout is its output channel */
2
+ import process from 'node:process'
3
+ import pc from 'picocolors'
4
+
5
+ // aihand fusion: argv is injected by the unified router (src/cli.ts) after it
6
+ // strips the leading `ui` segment. Falls back to process.argv when run directly.
7
+ let args = process.argv.slice(2)
8
+ let flags = parseFlags(args)
9
+
10
+ function parseFlags(a: string[]) {
11
+ return Object.fromEntries(
12
+ a.filter(x => x.startsWith('--')).map((x) => {
13
+ const body = x.slice(2)
14
+ const eq = body.indexOf('=') // split on the FIRST = only — values may contain = (e.g. CSS sel)
15
+ return eq === -1 ? [body, 'true'] : [body.slice(0, eq), body.slice(eq + 1)]
16
+ }),
17
+ )
18
+ }
19
+
20
+ // argv → the /__aihand HTTP endpoint to hit. The subcommand is positional (screen/dom/click);
21
+ // every flag except port/help becomes a query param (?text=…&sel=…), so action commands work
22
+ // through the CLI, not just curl. `full` is a bare flag (?full). Values may contain `=` and
23
+ // non-ASCII (CSS selectors) — parseFlags splits on the first `=`, encodeURIComponent escapes them.
24
+ export function buildUiUrl(argv: string[]): { url: string, sub: string } {
25
+ const flags = parseFlags(argv)
26
+ const sub = argv.filter(a => !a.startsWith('--'))[0] || ''
27
+ const path = sub ? `/${sub}` : ''
28
+ const reserved = new Set(['port', 'help'])
29
+ const query = Object.entries(flags)
30
+ .filter(([k]) => !reserved.has(k))
31
+ .map(([k, v]) => (k === 'full' ? 'full' : `${k}=${encodeURIComponent(v)}`))
32
+ .join('&')
33
+ const port = flags.port || '5173'
34
+ return { url: `http://localhost:${port}/__aihand${path}${query ? `?${query}` : ''}`, sub }
35
+ }
36
+
37
+ async function main() {
38
+ if (flags.help) {
39
+ console.log(`
40
+ ${pc.bold('aihand runtime')} — runtime snapshot from Vite dev server
41
+
42
+ ${pc.dim('Inspect:')}
43
+ aihand runtime Full summary
44
+ aihand runtime screen State-machine view (start here)
45
+ aihand runtime check Health check (pass/fail)
46
+ aihand runtime console Console logs
47
+ aihand runtime errors/1 --full Error detail (untruncated)
48
+
49
+ ${pc.dim('Drive (params as flags — the CLI URL-encodes values for you, CJK included):')}
50
+ aihand runtime click --text=群聊 Click by visible text
51
+ aihand runtime fill --sel=input --value=hi Set an input value
52
+ aihand runtime wait --text=Done --timeout=8000
53
+
54
+ ${pc.dim('UI → code (reverse morphism):')}
55
+ aihand read source <file:line> Which symbol owns this line + its callers/callees
56
+ (also eats /dom's data-insp-path raw)
57
+
58
+ ${pc.dim('Options:')}
59
+ --port=<port> Dev server port (default: 5173)
60
+ --full Untruncated output
61
+ --help Show this help
62
+
63
+ ${pc.dim('Setup:')}
64
+ Add aihand() to your vite.config.ts plugins array.
65
+ `)
66
+ process.exit(0)
67
+ }
68
+
69
+ const { url: endpoint, sub } = buildUiUrl(args)
70
+
71
+ const resp = await fetch(endpoint)
72
+ if (!resp.ok && resp.status !== 417) {
73
+ const text = await resp.text()
74
+ console.error(pc.red(`Error ${resp.status}: ${text}`))
75
+ process.exit(1)
76
+ }
77
+
78
+ const text = await resp.text()
79
+ // color check results — assertions are one per line: "✓ name" / "✗ name: detail"
80
+ if (sub === 'check') {
81
+ const colored = text.split('\n').map((line) => {
82
+ if (line.startsWith('✓'))
83
+ return pc.green(line)
84
+ if (line.startsWith('✗'))
85
+ return pc.red(line)
86
+ return line
87
+ }).join('\n')
88
+ console.log(colored)
89
+ process.exit(resp.status === 417 ? 1 : 0)
90
+ }
91
+
92
+ console.log(text)
93
+ }
94
+
95
+ export async function run(argv: string[]) {
96
+ args = argv
97
+ flags = parseFlags(args)
98
+ await main().catch((err) => {
99
+ console.error(pc.red(`Error: ${err.message}`))
100
+ process.exit(1)
101
+ })
102
+ }
@@ -0,0 +1,276 @@
1
+ import type { ActionArgs, ActionResult } from '../core/action'
2
+ import type { ErrorEntry, LogEntry, NetworkRequest, ScreenSnap } from '../core/types'
3
+ import type { ServerBridge } from '../bridge/transport'
4
+ import { resolveAction, TYPES } from '../core/action'
5
+
6
+ // transport 无关的命令执行 + 渲染。出口1(Vite HMR)和出口2(CDP)同一份:
7
+ // 各自把自己的通道包成 ServerBridge 传进来,dispatch 只调 bridge.request + 内核渲染。
8
+ // 本轮只填 screen + 动作(click|fill|press|hover|wait)——证明同一内核可被第二出口复用即停。
9
+ // Vite HTTP 专属(?since= token、realclick CDP 握手、screenshot 落盘、federation)不进这里,
10
+ // 留 plugin.ts 原位 short-circuit。
11
+
12
+ export interface ScreenReply {
13
+ screen: string
14
+ canvas: string
15
+ snap: ScreenSnap
16
+ console: LogEntry[]
17
+ network: NetworkRequest[]
18
+ errors: ErrorEntry[]
19
+ }
20
+
21
+ export interface DispatchResult {
22
+ status: number
23
+ body: string
24
+ }
25
+
26
+ // executeAction is generic: it ships {type,args} over the bridge and in-page performAction handles
27
+ // any ActionType, returning a screen delta. So the HTTP/chain action set = the full TYPES source of
28
+ // truth (action.ts) MINUS the four whose output contract differs and which own their own route:
29
+ // · screenshot — binary PNG (server.ts:/screenshot, not a screen-delta reply)
30
+ // · realclick — electron-only trusted-event path (no electronAPI in a headless chromium parachute)
31
+ // · query/assert — return matched data / pass-fail, not the changed-screen delta these dispatch
32
+ // Deriving from TYPES (not a hand-kept second list) means a new action added to TYPES is reachable
33
+ // over HTTP automatically — the two exits can never drift (drag/scrollIntoView/drop/clipboard were
34
+ // silently unreachable on exit-2 for exactly that drift; exit-1 plugin.ts had them, this list didn't).
35
+ const SPECIAL_CONTRACT = new Set(['screenshot', 'realclick', 'query', 'assert'])
36
+ const ACTION_TYPES = TYPES.filter(t => !SPECIAL_CONTRACT.has(t))
37
+
38
+ export function isDispatchAction(type: string): boolean {
39
+ return ACTION_TYPES.includes(type as (typeof ACTION_TYPES)[number])
40
+ }
41
+
42
+ // URLSearchParams → ActionArgs。出口1(plugin.ts)与出口2(probe/server.ts)同一份解析,
43
+ // 不各抄一遍(避免 query→args 第三份漂移)。
44
+ export function argsFromQuery(q: URLSearchParams): ActionArgs {
45
+ return {
46
+ sel: q.get('sel') || undefined,
47
+ text: q.get('text') || undefined,
48
+ value: q.has('value') ? q.get('value')! : undefined,
49
+ key: q.get('key') || undefined,
50
+ timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
51
+ gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
52
+ enabled: q.has('enabled') ? q.get('enabled') !== 'false' : undefined,
53
+ button: q.get('button') === 'right' ? 'right' : q.get('button') === 'left' ? 'left' : undefined,
54
+ x: q.has('x') ? Number(q.get('x')) : undefined,
55
+ y: q.has('y') ? Number(q.get('y')) : undefined,
56
+ screen: q.get('screen') || undefined,
57
+ equals: q.has('equals') ? q.get('equals')! : undefined,
58
+ to: q.get('to') || undefined,
59
+ files: q.has('files') ? q.get('files')!.split(',').map(s => s.trim()).filter(Boolean) : undefined,
60
+ mode: q.get('mode') === 'write' ? 'write' : q.get('mode') === 'read' ? 'read' : undefined,
61
+ }
62
+ }
63
+
64
+ // /screen 的新鲜读(无 ?since)——语义布局树 + 字符画,两半同源一个 snap。
65
+ // since-diff 是 HTTP token ring 专属,留 plugin.ts。
66
+ export async function dispatchScreen(
67
+ bridge: ServerBridge,
68
+ opts?: { tab?: string, form?: string },
69
+ ): Promise<{ reply: ScreenReply, result: DispatchResult }> {
70
+ const reply = await bridge.request<ScreenReply>('aihand:collect-screen', 'aihand:screen', { form: opts?.form }, { tab: opts?.tab })
71
+ const body = [reply.canvas, reply.screen].filter(Boolean).join('\n\n')
72
+ return { reply, result: { status: 200, body: body || '(empty)' } }
73
+ }
74
+
75
+ // 跨整页导航回填:出口2 点链接触发 example→iana 整页导航时,探针随旧文档销毁——它的
76
+ // settleAndTrace after 帧在「导航已触发、旧文档未销毁」的窗口里采,采到的是旧页残影(focus 变了),
77
+ // 采不到新页。导航信号不靠事后轮询 url 猜:cdpBridge 派发动作撞整页导航(上下文销毁异常)时经
78
+ // request 的 onNav 回调同步交出——撞 = 确凿导航,没撞 = 同文档动作,100% 准确零成本。导航发生才
79
+ // settle 新文档(cdpBridge 已做) + renavScreen 采新页全貌回填。出口1(Vite)无整页导航,不传 afterNav。
80
+ export interface NavProbe {
81
+ // 动作前读 URL(回填 from 头需要)。O(1) 同步读,非轮询。
82
+ url: () => Promise<string>
83
+ // settle(等新文档 commit)后采新页一帧 + 读 toUrl,返回换页确认行(navigated 头 + 新页语义树)。
84
+ // toUrl 必须在 settle 内读:动作回执到达时(reply ~62ms)新文档尚未 commit,此刻 page.url() 仍是旧值。
85
+ renavScreen: (fromUrl: string) => Promise<string | undefined>
86
+ // SPA 同文档路由切换(Turbo/React Router pushState)事后等待。整页导航 onNav 闩在 reply 前竞速成功;
87
+ // 同文档导航上下文存活、reply 快速带残缺 screen 先回,真换页 morph 在 reply 后 ~0.2~1.4s 才到——故
88
+ // 这条信号必须 outlive reply。两段:arm() 在动作派发前调(订阅就位),返回的 wait(capMs) 在 reply
89
+ // 后调,与 capMs 竞速;URL 真变的 navigatedWithinDocument 在窗口内 fire → true(立即短路)。
90
+ armSameDocNav?: () => { wait: (capMs: number) => Promise<boolean>, cancel: () => void }
91
+ }
92
+
93
+ // chain 的单步执行结果(出口注入的 runStep 返回这个,runChain 只管循环+渲染+fail-fast)。
94
+ // ok/detail/error/screen/actions 对齐 ActionResult 的渲染字段;label 是步骤动词(type 或 'knob')。
95
+ export interface ChainStepResult {
96
+ ok: boolean
97
+ label: string
98
+ detail?: string
99
+ error?: string
100
+ screen?: string
101
+ actions?: string
102
+ }
103
+
104
+ export type ChainStep = { type: string } & ActionArgs
105
+
106
+ // chain:POST 一串动作,逐步 settle、fail-fast、一往返跑完整个交互。循环/渲染/skipped 汇报
107
+ // 是两出口共用的纯逻辑——抽进内核,出口只注入各自的单步执行器 runStep(出口1 带 knob 特例 +
108
+ // runAction,出口2 纯 dispatchAction)。渲染与 plugin.ts 旧内联逐字一致:每步 `[i] ✓/✗ label: …`,
109
+ // 失败带 clickable fallback、缩进 screen delta,末尾 skipped 汇总 + recent actions 尾。
110
+ export async function runChain(
111
+ steps: ChainStep[],
112
+ runStep: (type: string, args: ActionArgs, index: number) => Promise<ChainStepResult>,
113
+ ): Promise<DispatchResult> {
114
+ const lines: string[] = []
115
+ let lastActions = ''
116
+ let allOk = true
117
+ for (let i = 0; i < steps.length; i++) {
118
+ const { type, ...args } = steps[i]
119
+ const r = await runStep(type, args, i)
120
+ lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${r.label}: ${r.ok ? (r.detail || 'ok') : r.error}`)
121
+ // 失败步骤带 clickable fallback,与单发路径一致——chain 里的失败步不能比单发更瞎
122
+ // (丢 detail 会把「列出的 label 可重试」的可恢复态坍缩成死路)。
123
+ if (!r.ok && r.detail)
124
+ lines.push(` clickable: ${r.detail}`)
125
+ // 每步的状态机转移(view/modal/focus + 新 error)缩进显示在它的源步骤下。
126
+ if (r.screen)
127
+ lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
128
+ if (r.actions)
129
+ lastActions = r.actions
130
+ if (!r.ok) {
131
+ allOk = false
132
+ break
133
+ }
134
+ }
135
+ // ker(report) ⊆ ker(execute):fail-fast 跳过剩余步骤,就 SAY 跳过了——否则 agent 得回去数
136
+ // 输入数组才知道页面停在 N 步未跑的半成品态。
137
+ if (!allOk && lines.length) {
138
+ const ran = lines.filter(l => l.startsWith('[')).length
139
+ const skipped = steps.length - ran
140
+ if (skipped > 0)
141
+ lines.push(`[${ran}..${steps.length - 1}] skipped (${skipped} step${skipped > 1 ? 's' : ''} not run — chain stopped at first failure)`)
142
+ }
143
+ const chainActions = lastActions ? `\n\n--- recent actions ---\n${lastActions}` : ''
144
+ return { status: allOk ? 200 : 422, body: `${lines.join('\n')}${chainActions}` }
145
+ }
146
+
147
+ // 动作:校验 → 一次 request 等 aihand:result → 渲染 head + recent actions + changed + flow。
148
+ // 渲染与 plugin.ts 单发路径(~1294)逐字一致,故出口2 与出口1 输出同形。
149
+ // id 由调用方给(出口1 复用 plugin 的 actionId 计数器,与 runAction/chain 同一空间不撞;
150
+ // 出口2 自己的计数器),保证 progress ping 的 id 与 bridge.request 的 id 对得上。
151
+ // 执行动作并应用跨导航回填,返回结构化结果(invalid → status 400 + error,无 result)。
152
+ // 单发(dispatchAction)与 chain 单步共用此处:执行+nav-backfill 是一份,渲染各取所需,
153
+ // 不在 chain 里重解析已渲染的 body 串(那会把格式化输出当数据二次解析,脆)。
154
+ export interface ExecutedAction {
155
+ status: number
156
+ result?: ActionResult
157
+ changed?: string // result.screen,跨导航时被新页全貌覆盖
158
+ navigated?: boolean // 动作触发了整页导航 → 旧页动作环是噪音,抑制 recent actions 尾
159
+ }
160
+
161
+ // SPA 同文档路由切换的确认窗口:实测 GitHub Turbo 的 navigatedWithinDocument(URL 真变)在动作后
162
+ // ~0.2~1.4s 才到(冷热差异大),1500 留安全边际。仅在「门控命中」的 click/press 上付这个上限,且
163
+ // fire 即短路——故真换页通常远快于 1500ms 返回,只有「click/press 了但没换路由」才付满。
164
+ const SPA_NAV_CAP = 1500
165
+
166
+ // 该动作的即时 diff 是否「平凡」——空(no state change)或只有 focus 行。SPA 路由 click 的即时 diff
167
+ // 恰恰只有 focus(换页 morph 在 reply 后才到),而开面板/弹窗/换 view 的 click 会立刻产出 panel/
168
+ // modal/view 行。故「平凡 diff」是「这次 click 看起来什么都没发生 → 可能是慢路由切换」的门控:非平凡
169
+ // diff 立即返回零额外税,只有平凡 diff 才值得等 SPA 信号。纯结构判据,不写死站点。
170
+ export function trivialDiff(changed: string | undefined): boolean {
171
+ if (!changed)
172
+ return true
173
+ const lines = changed.split('\n').map(l => l.trim()).filter(Boolean)
174
+ return lines.length === 0
175
+ || lines.every(l => l === '(no state change)' || l.startsWith('focus:') || l.startsWith('--- '))
176
+ }
177
+
178
+ async function executeAction(
179
+ bridge: ServerBridge,
180
+ type: string,
181
+ args: ActionArgs,
182
+ ctx: { nextId: () => number, tab?: string, afterNav?: NavProbe },
183
+ ): Promise<ExecutedAction> {
184
+ const check = resolveAction(type, args)
185
+ if (!check.valid)
186
+ return { status: 400 }
187
+ const id = ctx.nextId()
188
+ // wait actions own their timeout; give the channel that long + slack (matches plugin.ts sendAction)
189
+ const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
190
+ const preUrl = ctx.afterNav ? await ctx.afterNav.url() : undefined
191
+ // SPA 同文档路由切换的事后监听必须在动作派发前 arm(否则 @0.2s 的快信号在 reply 后才订阅就漏)。
192
+ // 仅 click/press 可能触发路由,fill/hover/wait 不 arm → 零额外开销。
193
+ const spaArm = ctx.afterNav?.armSameDocNav && (type === 'click' || type === 'press')
194
+ ? ctx.afterNav.armSameDocNav()
195
+ : undefined
196
+ // navigated:动作派发若撞整页导航,cdpBridge 经 onNav 同步置位(出口1 永不调 → 恒 false)。
197
+ let navigated = false
198
+ const result = await bridge.request<ActionResult>('aihand:action', 'aihand:result', { id, type, args }, { tab: ctx.tab, timeoutMs: fullMs, onNav: () => { navigated = true } })
199
+ // 跨导航回填:探针给的 result.screen 是旧页残影;动作确实触发了整页导航才采新页全貌覆盖换页确认。
200
+ // 同文档动作 navigated 恒 false → 整块跳过 → 零额外延迟(不再盲等 2s 轮询窗口)。
201
+ let changed = result.screen
202
+ if (ctx.afterNav && result.ok && navigated) {
203
+ const renav = await ctx.afterNav.renavScreen(preUrl ?? '')
204
+ if (renav)
205
+ changed = renav
206
+ }
207
+ // SPA 路由切换回填:整页导航没撞(navigated 仍 false)、动作成功时分两情况:
208
+ // 平凡 diff(focus-only,看起来没发生什么 → 可能是慢路由切换):等满 SPA_NAV_CAP 一小窗,
209
+ // fire → renavScreen 采新路由全貌;只有「click 没换路由」才付满,fire 即短路。
210
+ // 非平凡 diff(开了面板/弹窗/换 view):不能等满税普通 click,但若 navigatedWithinDocument
211
+ // 已 fire(react.dev 这类即时客户端导航 ~40ms,内容同时变所以 diff 富)——用 wait(0) 零阻塞
212
+ // 探一下「是否已 fire」,已 fire 才回填 navigated:URL 头(内容富 diff 已在,URL 跃迁是额外信号,
213
+ // 绝不能因为内容也变了就吞掉「你换页了」)。没 fire → 退订零税。
214
+ else if (spaArm && result.ok && !navigated) {
215
+ const cap = trivialDiff(changed) ? SPA_NAV_CAP : 0
216
+ if (await spaArm.wait(cap)) {
217
+ navigated = true
218
+ const renav = await ctx.afterNav!.renavScreen(preUrl ?? '')
219
+ if (renav)
220
+ changed = renav
221
+ }
222
+ }
223
+ // arm 了但没走 wait 分支(整页导航命中)→ 直接退订,不留悬挂订阅。
224
+ else if (spaArm) {
225
+ spaArm.cancel()
226
+ }
227
+ return { status: result.ok ? 200 : 422, result, changed, navigated }
228
+ }
229
+
230
+ export async function dispatchAction(
231
+ bridge: ServerBridge,
232
+ type: string,
233
+ args: ActionArgs,
234
+ ctx: { nextId: () => number, tab?: string, afterNav?: NavProbe },
235
+ ): Promise<DispatchResult> {
236
+ const ex = await executeAction(bridge, type, args, ctx)
237
+ if (!ex.result) {
238
+ const check = resolveAction(type, args)
239
+ return { status: 400, body: check.error ?? 'invalid action' }
240
+ }
241
+ const { result } = ex
242
+ const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
243
+ // 跨整页导航后,result.actions 是旧页的动作环(常是 "(no actions)")——changed 的 navigated+新页全貌
244
+ // 已结清,旧页动作环纯噪音。同文档动作 navigated 为 false → 照常显示当前页真动作环,零回归。
245
+ const actionsTail = result.actions && !ex.navigated ? `\n\n--- recent actions ---\n${result.actions}` : ''
246
+ const changedTail = ex.changed ? `\n\n--- changed ---\n${ex.changed}` : ''
247
+ const flowTail = result.flow ? `\n\n--- flow ---\n${result.flow}` : ''
248
+ return { status: result.ok ? 200 : 422, body: `${head}${actionsTail}${changedTail}${flowTail}` }
249
+ }
250
+
251
+ // chain 单步执行器(出口2 用):纯动作 → 结构化 ChainStepResult(无 knob 特例)。导出供 server.ts 注入。
252
+ export async function dispatchChainStep(
253
+ bridge: ServerBridge,
254
+ type: string,
255
+ args: ActionArgs,
256
+ ctx: { nextId: () => number, tab?: string, afterNav?: NavProbe },
257
+ ): Promise<ChainStepResult> {
258
+ if (!isDispatchAction(type))
259
+ return { ok: false, label: type, error: `probe chain supports ${ACTION_TYPES.join('/')} only (got ${type})` }
260
+ const ex = await executeAction(bridge, type, args, ctx)
261
+ if (!ex.result) {
262
+ const check = resolveAction(type, args)
263
+ return { ok: false, label: type, error: check.error ?? 'invalid action' }
264
+ }
265
+ const { result } = ex
266
+ return {
267
+ ok: result.ok,
268
+ label: type,
269
+ detail: result.detail,
270
+ error: result.error,
271
+ // 单发把 flow 单列一段尾;chain 单步把 changed + flow 都当缩进 delta 显示在步下。
272
+ screen: [ex.changed, result.flow].filter(Boolean).join('\n') || undefined,
273
+ // 导航步的旧页动作环同样是噪音(与单发 dispatchAction 一致),抑制。
274
+ actions: ex.navigated ? undefined : result.actions,
275
+ }
276
+ }