@windsland52/maafw-live 0.3.0 → 0.4.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 (118) hide show
  1. package/README.md +24 -9
  2. package/docs/daemon-protocol.md +15 -10
  3. package/docs/keyframe-retention-contract-v0.md +23 -34
  4. package/docs/session-unify-v0.md +115 -0
  5. package/lib/client/daemon.js +45 -3
  6. package/lib/commands/annotate.js +15 -8
  7. package/lib/commands/calibrate.js +3 -3
  8. package/lib/commands/color.js +3 -3
  9. package/lib/commands/crop.js +8 -8
  10. package/lib/commands/dispatch.js +121 -0
  11. package/lib/commands/env.js +200 -6
  12. package/lib/commands/index.js +7 -1
  13. package/lib/commands/input.js +71 -33
  14. package/lib/commands/kf.js +49 -13
  15. package/lib/commands/probe.js +70 -0
  16. package/lib/commands/reco.js +285 -56
  17. package/lib/commands/repl.js +1305 -502
  18. package/lib/commands/roi-check.js +273 -0
  19. package/lib/commands/roi-compare.js +388 -0
  20. package/lib/commands/runtime.js +295 -91
  21. package/lib/commands/timing.js +28 -10
  22. package/lib/daemon/framed.mjs +1014 -184
  23. package/lib/daemon/reco_child.mjs +150 -9
  24. package/lib/flags.js +2 -0
  25. package/lib/index.js +24 -20
  26. package/lib/interface/hotkey.js +142 -0
  27. package/lib/interface/index.js +1 -1
  28. package/lib/interface/load.js +107 -17
  29. package/lib/interface/override.js +218 -34
  30. package/lib/interface/plan.js +108 -17
  31. package/lib/option-args.js +29 -0
  32. package/lib/pkg.js +36 -0
  33. package/lib/protocol.js +4 -0
  34. package/lib/runtime/actions.js +127 -12
  35. package/lib/runtime/log-watch.js +160 -0
  36. package/lib/runtime/node-source.js +100 -0
  37. package/lib/runtime/ocr-model.js +45 -0
  38. package/lib/runtime/pi-env.js +34 -0
  39. package/lib/runtime/pretask.js +217 -0
  40. package/lib/runtime/prune.js +28 -0
  41. package/lib/runtime/receipts.js +57 -0
  42. package/lib/runtime/reco-args.js +38 -0
  43. package/lib/runtime/reco-verdict.js +82 -0
  44. package/lib/runtime/roi-compare.js +198 -0
  45. package/lib/runtime/session-forward.js +79 -0
  46. package/lib/runtime/session-ipc.js +471 -0
  47. package/lib/runtime/session.js +57 -8
  48. package/lib/runtime/signal-guard.js +35 -0
  49. package/lib/types/commands/dispatch.d.ts +22 -0
  50. package/lib/types/commands/env.d.ts +19 -0
  51. package/lib/types/commands/index.d.ts +1 -1
  52. package/lib/types/commands/probe.d.ts +15 -0
  53. package/lib/types/commands/reco.d.ts +11 -7
  54. package/lib/types/commands/repl.d.ts +8 -0
  55. package/lib/types/commands/roi-check.d.ts +3 -0
  56. package/lib/types/commands/roi-compare.d.ts +3 -0
  57. package/lib/types/commands/runtime.d.ts +20 -2
  58. package/lib/types/flags.d.ts +4 -0
  59. package/lib/types/interface/hotkey.d.ts +36 -0
  60. package/lib/types/interface/index.d.ts +1 -1
  61. package/lib/types/interface/load.d.ts +84 -7
  62. package/lib/types/interface/override.d.ts +58 -13
  63. package/lib/types/interface/plan.d.ts +23 -2
  64. package/lib/types/option-args.d.ts +19 -0
  65. package/lib/types/pkg.d.ts +8 -0
  66. package/lib/types/protocol.d.ts +16 -0
  67. package/lib/types/runtime/actions.d.ts +89 -7
  68. package/lib/types/runtime/log-watch.d.ts +28 -0
  69. package/lib/types/runtime/node-source.d.ts +25 -0
  70. package/lib/types/runtime/ocr-model.d.ts +8 -0
  71. package/lib/types/runtime/pi-env.d.ts +24 -0
  72. package/lib/types/runtime/pretask.d.ts +53 -0
  73. package/lib/types/runtime/prune.d.ts +4 -0
  74. package/lib/types/runtime/receipts.d.ts +7 -0
  75. package/lib/types/runtime/reco-args.d.ts +18 -0
  76. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  77. package/lib/types/runtime/roi-compare.d.ts +117 -0
  78. package/lib/types/runtime/session-forward.d.ts +10 -0
  79. package/lib/types/runtime/session-ipc.d.ts +117 -0
  80. package/lib/types/runtime/session.d.ts +26 -2
  81. package/lib/types/runtime/signal-guard.d.ts +19 -0
  82. package/lib/usage.js +3 -3
  83. package/package.json +17 -4
  84. package/skills/maafw-live/SKILL.md +32 -2
  85. package/skills/maafw-live/references/pitfalls.md +97 -4
  86. package/skills/maafw-live/references/workflows.md +5 -0
  87. package/docs/keyframe-evidence-contract-v0.md +0 -61
  88. package/lib/client/daemon.js.map +0 -1
  89. package/lib/commands/annotate.js.map +0 -1
  90. package/lib/commands/calibrate.js.map +0 -1
  91. package/lib/commands/color.js.map +0 -1
  92. package/lib/commands/crop.js.map +0 -1
  93. package/lib/commands/env.js.map +0 -1
  94. package/lib/commands/index.js.map +0 -1
  95. package/lib/commands/input.js.map +0 -1
  96. package/lib/commands/kf.js.map +0 -1
  97. package/lib/commands/reco.js.map +0 -1
  98. package/lib/commands/repl.js.map +0 -1
  99. package/lib/commands/runtime.js.map +0 -1
  100. package/lib/commands/skill.js.map +0 -1
  101. package/lib/commands/timing.js.map +0 -1
  102. package/lib/commands/version.js.map +0 -1
  103. package/lib/exec.js.map +0 -1
  104. package/lib/flags.js.map +0 -1
  105. package/lib/image.js.map +0 -1
  106. package/lib/index.js.map +0 -1
  107. package/lib/interface/index.js.map +0 -1
  108. package/lib/interface/jsonc.js.map +0 -1
  109. package/lib/interface/load.js.map +0 -1
  110. package/lib/interface/override.js.map +0 -1
  111. package/lib/interface/plan.js.map +0 -1
  112. package/lib/pkg.js.map +0 -1
  113. package/lib/protocol.js.map +0 -1
  114. package/lib/runtime/actions.js.map +0 -1
  115. package/lib/runtime/keyframes.js.map +0 -1
  116. package/lib/runtime/session.js.map +0 -1
  117. package/lib/update-check.js.map +0 -1
  118. package/lib/usage.js.map +0 -1
@@ -15,9 +15,11 @@
15
15
  * { kind:'frame', meta, preview } (每接受帧的元数据与预览帧路径)
16
16
  * { kind:'event', ev } (变化/稳定/节点事件)
17
17
  * { kind:'stream_error', error }
18
+ * { kind:'stream_stopped', reason } (控制器被销毁→帧流主动停止;
19
+ * 要看帧就重新 stream_start)
18
20
  */
19
21
  import { createRequire } from 'node:module'
20
- import { execFile, spawn } from 'node:child_process'
22
+ import { execFile, spawn, spawnSync } from 'node:child_process'
21
23
  import { fileURLToPath } from 'node:url'
22
24
  import { createHash, randomUUID } from 'node:crypto'
23
25
  import readline from 'node:readline'
@@ -116,6 +118,21 @@ function pngDecode(buf) {
116
118
  }
117
119
  return { w, h, ch, data: out }
118
120
  }
121
+ /** PNG 声明尺寸(只读 IHDR,不解像素)。
122
+ *
123
+ * 为什么要有这个:`--image` 分支的回执只带 `{source}`,调用方拿到图却不知道它多大,
124
+ * 于是 **roi 空间错了也无从判断**(ctrl 空间 roi 打到 L1 小图上,静默零例)。
125
+ * 与 pngDecode 的第二层同为「读 IHDR 才拿到 w/h」,但**不做解码**——这里只要尺寸,
126
+ * 真解码交给 reco 子进程;文件不可读 / 不是 PNG 时返回 null,让子进程去报真正的读取错误。
127
+ * 名字带 Header 以区别于 image.ts 里那个会完整解码的异步 pngSize。 */
128
+ function pngHeaderSize(buf) {
129
+ try {
130
+ if (buf[0] !== 0x89 || buf[1] !== 0x50) return null
131
+ const len = buf.readUInt32BE(8)
132
+ if (buf.toString('ascii', 12, 16) !== 'IHDR' || len < 8) return null
133
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
134
+ } catch (e) { return null }
135
+ }
119
136
  /** 归一化为 RGB(丢弃 alpha),返回新 Buffer */
120
137
  function toRgb(dec) {
121
138
  if (dec.ch === 3) return dec.data
@@ -212,7 +229,10 @@ const KF_QUOTA_DEFAULT = (() => {
212
229
  return Number.isFinite(v) && v > 0 ? v : 1024 * 1024 * 1024
213
230
  })()
214
231
  const S = {
215
- ctrl: null, res: null, tasker: null,
232
+ ctrl: null, tasker: null,
233
+ /* 最近一次 run / --act 的 Resource:只作释放点(换新时 destroy 上一份、disconnect 时销毁),
234
+ * 不当"当前资源"共享——那会让下一次命令带着上一轮的 bundle 跑。 */
235
+ runRes: null,
216
236
  session: null, // { kind, target, method }
217
237
  daemonId: randomUUID(), // 本 daemon 实例身份(留存记录的 session.daemon)
218
238
  connGen: 0, // 连接代次:每次 connect 成功 +1(留存记录定位用)
@@ -264,6 +284,58 @@ async function cmdProbe() {
264
284
  return out
265
285
  }
266
286
 
287
+ /**
288
+ * 截图能力探测(示范轨迹契约 §3):决定能不能走 A 路(人在我们显示的帧上点)。
289
+ * 方法:两次 post_screencap 间隔抓帧,比 blockHash 与亮度。
290
+ * changed == true → 像素在变(遮挡下能变 = 后台可截)
291
+ * changed == false 且 mean 正常 → 静止(目标停止渲染?改应用设置,不是截图路径问题)
292
+ * mean < 8 → 黑图(截图方法不兼容,换一个)
293
+ * post_screencap 抛错/空 → 报错(如实带出)
294
+ * 探针不知道"此刻是否遮挡"——判据只在遮挡态成立,调用方(CLI)负责提示与如实标注。
295
+ */
296
+ async function cmdScreencapProbe(args) {
297
+ if (!S.ctrl) return { ok: false, error: '未连接设备(先 connect)' }
298
+ const interval = Math.min(20000, Math.max(500, Number(args.intervalMs ?? 3000)))
299
+ const grab = async () => {
300
+ try {
301
+ const j = S.ctrl.post_screencap()
302
+ await j.wait()
303
+ const buf = j.get()
304
+ if (!buf || buf.byteLength < 9) return { ok: false, error: '空截图' }
305
+ const dec = pngDecode(Buffer.from(buf))
306
+ const rgb = toRgb(dec)
307
+ const hash = blockHash(rgb, dec.w, dec.h)
308
+ /* 黑帧判定用平均亮度:全黑截图的 mean≈0,正常画面几十起步;阈值取 8 有充分余量 */
309
+ let sum = 0
310
+ for (let i = 0; i < rgb.length; i++) sum += rgb[i]
311
+ const mean = Math.round((sum / rgb.length) * 10) / 10
312
+ return { ok: true, w: dec.w, h: dec.h, hash: hashStr(hash), mean }
313
+ } catch (e) {
314
+ return { ok: false, error: String(e && e.message || e) }
315
+ }
316
+ }
317
+ const first = await grab()
318
+ await sleep(interval)
319
+ const second = await grab()
320
+ if (!first.ok || !second.ok) {
321
+ return { ok: true, intervalMs: interval, first, second, changed: null,
322
+ verdict: 'error',
323
+ note: 'post_screencap 报错或返回空——截图方法不兼容或控制器异常,换 win32.screencap 方法重试。' }
324
+ }
325
+ const changed = first.hash !== second.hash
326
+ let verdict = 'changing'
327
+ let note = '像素在变:若此刻窗口被完全遮挡,即"后台可截"(A 路前提成立)。'
328
+ if (!changed && second.mean < 8) {
329
+ verdict = 'black'
330
+ note = '截图近乎全黑:截图方法不兼容这台设备/这个窗口——换 win32.screencap 方法(FramePool / PrintWindow)重试。'
331
+ } else if (!changed) {
332
+ verdict = 'static'
333
+ note = '像素静止:目标停止渲染(改模拟器/应用设置),或两帧之间画面恰好没动——遮挡下再测一次确认。'
334
+ }
335
+ return { ok: true, intervalMs: interval, first, second, changed, verdict, note,
336
+ occluded: args.occluded === true ? true : null }
337
+ }
338
+
267
339
  async function cmdDeviceList(kind) {
268
340
  const m = loadMaa()
269
341
  const list = []
@@ -318,9 +390,76 @@ function resolveEnum(table, value, fallback, label, warns) {
318
390
  return String(fallback)
319
391
  }
320
392
 
393
+ /**
394
+ * LinuxController 的 config JSON 组装(maa-support-extension buildControllerRuntime /
395
+ * MXU build_linux_controller_config 同款规则,纯函数、与宿主平台无关,供单测直测):
396
+ * 1) args.target/config 是 JSON 串 → 原样用(字段全集归用户管,逃逸口);
397
+ * 2) 否则按 linuxDecl(interface.json linux{})+ args.linux(用户字段)组 JSON:
398
+ * 方法名→数值枚举、缺省 Wlr/Wlr、Wlr 必填 wlr_socket_path、pipewire_source:Portal
399
+ * 明确拒(portal helper 未接)、UInput/Libei 补各自字段、display_no 透传。
400
+ * 返回 { ok:true, json } 或 { ok:false, error }。
401
+ */
402
+ function buildLinuxConfig(m, args) {
403
+ const raw = String(args.config ?? args.target ?? '')
404
+ if (raw.trim().startsWith('{')) return { ok: true, json: raw.trim() }
405
+ const decl = (args.linuxDecl && typeof args.linuxDecl === 'object') ? args.linuxDecl : {}
406
+ const user = (args.linux && typeof args.linux === 'object') ? args.linux : {}
407
+ /* 双键名:user/linuxDecl 里驼峰与蛇形都认(interface.json 原生是蛇形,daemon 参数是驼峰) */
408
+ const pickL = (k, k2) => user[k] ?? user[k2] ?? decl[k] ?? decl[k2]
409
+ const resolveLinuxMethod = (tbl, name) => {
410
+ if (name === undefined || name === null || name === '') return undefined
411
+ if (tbl && typeof tbl === 'object' && name in tbl) return Number(tbl[name])
412
+ const n = Number(name)
413
+ return Number.isFinite(n) ? n : undefined
414
+ }
415
+ const screencapName = pickL('screencap', 'screencap') ?? 'Wlr'
416
+ const inputName = pickL('input', 'input') ?? 'Wlr'
417
+ const sc = resolveLinuxMethod(m.LinuxScreencapMethod, screencapName)
418
+ const im = resolveLinuxMethod(m.LinuxInputMethod, inputName)
419
+ if (sc === undefined || im === undefined) {
420
+ return { ok: false, error: 'linux 方法名不合法:screencap=' + screencapName + ' input=' + inputName
421
+ + '(可选 screencap: Wlr/ExtImage/PipeWire,input: Wlr/UInput/Libei;或直接给 config=<JSON>)' }
422
+ }
423
+ const conf = {
424
+ screencap_method: sc,
425
+ input_method: im,
426
+ use_win32_vk_code: !!(user.useWin32VkCode ?? decl.useWin32VkCode ?? decl.use_win32_vk_code),
427
+ }
428
+ if (screencapName === 'PipeWire') {
429
+ const src = pickL('pipewireSource', 'pipewire_source') ?? 'Gamescope'
430
+ if (src === 'Portal') {
431
+ return { ok: false, error: 'linux pipewire_source=Portal 暂不支持(portal helper 未接入)——换 Gamescope 或自管 fd' }
432
+ }
433
+ conf.pipewire_source = src
434
+ if (user.pwSocketFd !== undefined) conf.pw_socket_fd = user.pwSocketFd
435
+ if (user.pwNodeId !== undefined) conf.pw_node_id = user.pwNodeId
436
+ }
437
+ if (screencapName === 'Wlr' || inputName === 'Wlr') {
438
+ const sock = pickL('wlrSocketPath', 'wlr_socket_path')
439
+ if (!sock) {
440
+ return { ok: false, error: 'linux 方法含 Wlr:需要 wlr_socket_path(嵌套 compositor 的 socket)——在 linux config JSON 或 interface.json linux{} 里给' }
441
+ }
442
+ conf.wlr_socket_path = sock
443
+ }
444
+ if (inputName === 'UInput') {
445
+ if (user.uinputScreenWidth !== undefined) conf.uinput_screen_width = user.uinputScreenWidth
446
+ if (user.uinputScreenHeight !== undefined) conf.uinput_screen_height = user.uinputScreenHeight
447
+ }
448
+ if (inputName === 'Libei') {
449
+ const eis = pickL('eisSocketPath', 'eis_socket_path')
450
+ if (eis) conf.eis_socket_path = eis
451
+ }
452
+ const dno = pickL('displayNo', 'display_no')
453
+ if (dno !== undefined) conf.display_no = dno
454
+ return { ok: true, json: JSON.stringify(conf) }
455
+ }
456
+
321
457
  async function cmdConnect(args) {
322
458
  const m = loadMaa()
323
459
  if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
460
+ /* 旧控制器已销毁:分辨率缓存不能留到下一次连接——连接失败时它会是上一台的尺寸,
461
+ * 让 spaceCheck / stream_status 把旧空间当成当前空间。 */
462
+ S.fullW = 0; S.fullH = 0
324
463
  /* 控制器重建 = 旧流失效:显式停掉并在回执里说明(`streamStopped`),别让调用方以为流还在跑。
325
464
  * 曾经的行为是"tick 撞到空控制器后静默死掉、S.stream 仍为 true",于是重连后的 stream_start 被
326
465
  * "已启动"挡回去,表现为一帧都不来(实测:seq 卡死,而 screencap 正常)。 */
@@ -339,13 +478,24 @@ async function cmdConnect(args) {
339
478
  /* 指定了 address 却不在已发现列表里:明确报错,不静默连到别的设备上 */
340
479
  if (!dev) return { ok: false, error: 'adb 地址 ' + addr + ' 不在已发现设备中(共 ' + adbs.length + ' 台)' }
341
480
  const [name, adbPath, address, caps, inputs, config] = dev
481
+ /* caps/inputs 是 find() 给出的方法位掩码(Uint64 字符串),照原样透传——框架会按
482
+ * 固定优先级在掩码内自选。仅当 find() 没给时才退 Default(全方法减禁用项),
483
+ * 不许塞数组:ScreencapOrInputMethods 的绑定类型就是字符串。
484
+ * agent(maa-node 可选第 6 参):MXU 传 MaaAgentBinary 路径供 adb 侧 agent;
485
+ * 声明式 PI 的 agent 桥接走另一条通道(identifier 子进程),这里只透传显式参数。 */
342
486
  ctrl = new m.AdbController(adbPath || 'adb', String(address ?? addr),
343
- caps && caps.length ? caps : [1, 2], inputs && inputs.length ? inputs : [1, 2], config || '{}')
487
+ caps || String(m.AdbScreencapMethod?.Default ?? '0'),
488
+ inputs || String(m.AdbInputMethod?.Default ?? '0'), config || '{}',
489
+ args.agent ? String(args.agent) : undefined)
344
490
  session = { kind: 'adb', target: String(address ?? addr), name: String(name) }
345
491
  } else if (args.kind === 'win32' || args.kind === 'gamepad') {
346
492
  const wins = await cmdDeviceList('win32')
347
- const pick = pickWin32(args.target, wins)
348
- if (!pick) {
493
+ let pick = pickWin32(args.target, wins)
494
+ /* GamepadController(hwnd|null, gamepad_type, screencap):规范允许不配图——hwnd=null
495
+ * 即"仅虚拟手柄输入、不截图"。给了 target 没匹配上仍报错(防静默连错窗口);
496
+ * 没给 target 且一个可选窗口都没有时才走 null。 */
497
+ const gamepadNoWindow = args.kind === 'gamepad' && !pick && !args.target
498
+ if (!pick && !gamepadNoWindow) {
349
499
  return {
350
500
  ok: false,
351
501
  error: args.target
@@ -362,9 +512,13 @@ async function cmdConnect(args) {
362
512
  /* GamepadController 构造器签名(maa-node .d.ts):(hwnd, gamepad_type, screencap_method)。
363
513
  gamepad_type 枚举名→值照 m.GamepadType(Xbox360/DualShock4,缺省 Xbox360)。
364
514
  输入走虚拟手柄,需宿主装 ViGEm Bus Driver,否则 connect 会在原生层失败。 */
365
- const padType = resolveEnum(m.GamepadType, args.gamepadType, m.GamepadType?.Xbox360 ?? 0, 'gamepadType', warns)
366
- ctrl = new m.GamepadController(pick.id, padType, cap)
367
- session = { kind: 'gamepad', target: pick.id, name: pick.name || pick.cls, cls: pick.cls, method: { screencap: cap, gamepadType: padType } }
515
+ const padType = resolveEnum(m.GamepadType,
516
+ args.gamepadType === 'DS4' ? 'DualShock4' : args.gamepadType,
517
+ m.GamepadType?.Xbox360 ?? '0', 'gamepadType', warns)
518
+ ctrl = new m.GamepadController(pick ? pick.id : null, padType, cap)
519
+ session = { kind: 'gamepad', target: pick ? pick.id : null,
520
+ name: pick ? (pick.name || pick.cls) : '(无窗口:仅输入)',
521
+ cls: pick ? pick.cls : null, method: { screencap: cap, gamepadType: padType } }
368
522
  } else {
369
523
  const mouse = resolveEnum(m.Win32InputMethod, args.mouse, m.Win32InputMethod?.SendMessageWithCursorPos ?? 32, 'mouse', warns)
370
524
  const key = resolveEnum(m.Win32InputMethod, args.keyboard, m.Win32InputMethod?.SendMessage ?? 2, 'keyboard', warns)
@@ -378,55 +532,91 @@ async function cmdConnect(args) {
378
532
  return { ok: false, error: '控制器类型 ' + args.kind + ' 仅支持在 macOS 上使用;当前宿主是 ' + process.platform }
379
533
  }
380
534
  if (args.kind === 'playcover') {
381
- /* PlayCoverController(address, uuid):address 是 PlayCover 设备侧服务地址(如 127.0.0.1:port),
382
- uuid 是 PlayCover 显示的设备 UUID;两个都必须显式给,猜不了。 */
535
+ /* PlayCoverController(address, uuid):address 是设备侧服务地址,uuid 是应用的
536
+ Bundle Identifier。两者都有缺省(maa-support-extension 同款):
537
+ localhost:1717 / maa.playcover——不给 target 也按缺省构造。 */
383
538
  const parts = String(args.target ?? '').split('/')
384
- const address = parts[0] || ''
385
- const uuid = args.uuid ? String(args.uuid) : (parts[1] || '')
386
- if (!address || !uuid) {
387
- return { ok: false, error: 'playcover 需要 target=<address>/<uuid>(PlayCover 设置里可见,两者都必填)' }
388
- }
539
+ const address = args.address ? String(args.address) : (parts[0] || 'localhost:1717')
540
+ const uuid = args.uuid ? String(args.uuid) : (parts[1] || 'maa.playcover')
389
541
  ctrl = new m.PlayCoverController(address, uuid)
390
542
  session = { kind: 'playcover', target: address + '/' + uuid }
391
543
  } else {
392
- /* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。 */
544
+ /* MacOSController(window_id, screencap_method, input_method):窗口用 MacOSController.find() 枚举。
545
+ 匹配优先级:target(id 或标题子串)> macosTitleRegex(PI title_regex)> 第一个窗口。 */
393
546
  const wins = (await m.MacOSController.find()) || []
394
- const pick = args.target
395
- ? (wins.find((d) => String(d[0]) === String(args.target)) ??
396
- wins.find((d) => String(d[2] ?? '').includes(String(args.target))))
397
- : (wins[0] ?? null)
547
+ let pick = null
548
+ if (args.target) {
549
+ pick = wins.find((d) => String(d[0]) === String(args.target)) ??
550
+ wins.find((d) => String(d[2] ?? '').includes(String(args.target)))
551
+ } else if (args.macosTitleRegex) {
552
+ let re = null
553
+ try { re = new RegExp(String(args.macosTitleRegex)) } catch (e) { /* 坏正则按下个分支报错 */ }
554
+ if (re) pick = wins.find((d) => re.test(String(d[2] ?? '')))
555
+ else return { ok: false, error: 'macos title_regex 不是合法正则:' + args.macosTitleRegex }
556
+ } else {
557
+ pick = wins[0] ?? null
558
+ }
398
559
  if (!pick) {
399
- return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target : '未发现 macOS 窗口' }
560
+ return { ok: false, error: args.target ? 'macOS 窗口未找到:' + args.target
561
+ : args.macosTitleRegex ? 'macOS 窗口按 title_regex 未匹配到:' + args.macosTitleRegex
562
+ : '未发现 macOS 窗口' }
400
563
  }
401
- const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ?? 2, 'screencap', warns)
402
- const input = resolveEnum(m.MacOSScreencapMethod, args.keyboard, m.MacOSScreencapMethod?.ScreenCaptureKit ?? 2, 'keyboard', warns)
564
+ const cap = resolveEnum(m.MacOSScreencapMethod, args.screencap, m.MacOSScreencapMethod?.ScreenCaptureKit ?? '1', 'screencap', warns)
565
+ /* input 走 MacOSInputMethod 表(GlobalEvent/PostToPid),缺省 GlobalEvent(1)——
566
+ * 此前误用了 screencap 表+keyboard 参数,名字解析必错(ScreenCaptureKit 值恰好是 '1' 才没炸)。 */
567
+ const input = resolveEnum(m.MacOSInputMethod, args.input, m.MacOSInputMethod?.GlobalEvent ?? '1', 'input', warns)
403
568
  ctrl = new m.MacOSController(pick.id ?? pick[0], cap, input)
404
569
  session = { kind: 'macos', target: String(pick[0]), name: String(pick[2] ?? ''), method: { screencap: cap, input } }
405
570
  }
406
571
  } else if (args.kind === 'linux') {
407
- /* LinuxController(config JSON):wlroots / PipeWire / uinput / Libei 会话按配置串交给本体。
408
- config 必须显式给(含 screencap_method / input_method 等必填字段),工具不猜会话形态。 */
409
572
  if (process.platform !== 'linux') {
410
573
  return { ok: false, error: '控制器类型 linux 仅支持在 Linux 上使用;当前宿主是 ' + process.platform }
411
574
  }
412
- const cfg = String(args.config ?? args.target ?? '')
413
- if (!cfg.trim().startsWith('{')) {
414
- return { ok: false, error: 'linux 控制器需要 config=<JSON>(screencap_method/input_method 必填;wlroots 要 wlr_socket_path,PipeWire 要 pw_socket_fd+pw_node_id 等)' }
415
- }
416
- ctrl = new m.LinuxController(cfg)
575
+ const cfg = buildLinuxConfig(m, args)
576
+ if (cfg.ok === false) return { ok: false, error: cfg.error }
577
+ ctrl = new m.LinuxController(cfg.json)
417
578
  session = { kind: 'linux', target: '(config)' }
418
579
  } else {
419
580
  return { ok: false, error: '未知控制器类型:' + args.kind + '(支持 win32/adb/gamepad/playcover/macos/linux)' }
420
581
  }
421
582
  /* 截图缩放目标:默认不干预 —— maafw 本体按 interface.json 的 short_side(缺省最短边 720p)缩放。
422
- * 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。 */
583
+ * 只有用户显式传参才覆盖;注意这会改变 Tasker 识别图像的尺寸(模板/ROI 坐标系随之变化)。
584
+ * display_* 四种模式互斥(MXU DisplayOptions.validate 同款规则):rawSize / expand / longSide
585
+ * / shortSide 只允许给一个,多给是配置错误——静默多设几个 setter 的覆盖顺序不可观测,直接拒。 */
586
+ const displayModes = [!!args.rawSize, !!args.expand, !!args.longSide, !!args.shortSide].filter(Boolean).length
587
+ if (displayModes > 1) {
588
+ try { ctrl.destroy() } catch (e) { /* ignore */ }
589
+ return { ok: false, error: 'display_* 互斥:rawSize / expand / longSide / shortSide 只能给一个(同时声明了 ' + displayModes + ' 个)' }
590
+ }
423
591
  try {
424
- if (args.shortSide) ctrl.screenshot_target_short_side = Number(args.shortSide)
425
- if (args.longSide) ctrl.screenshot_target_long_side = Number(args.longSide)
426
592
  if (args.rawSize) ctrl.screenshot_use_raw_size = true
593
+ else if (args.expand) {
594
+ const [ew, eh] = Array.isArray(args.expand) ? args.expand : []
595
+ if (!Number.isFinite(ew) || !Number.isFinite(eh) || ew <= 0 || eh <= 0) {
596
+ try { ctrl.destroy() } catch (e) { /* ignore */ }
597
+ return { ok: false, error: 'display_expand 需要两个正整数 [w,h]:' + JSON.stringify(args.expand) }
598
+ }
599
+ ctrl.screenshot_target_expand = [Number(ew), Number(eh)]
600
+ } else if (args.longSide) ctrl.screenshot_target_long_side = Number(args.longSide)
601
+ else if (args.shortSide) ctrl.screenshot_target_short_side = Number(args.shortSide)
427
602
  } catch (e) { /* 绑定版本不支持 setter 时忽略 */ }
603
+ /* 连接判定:5.14.2 实测 `Job.wait()` 在连接失败时**不抛异常**,只把 status 置为 Failed(4000)。
604
+ * 不查 status 就会把失败当成功:S.ctrl/S.session 照设、connect 回 ok:true,下一条命令再踩
605
+ * 半残控制器(预热截图失败只写进 resolution.error,CLI 摘要里看不出来)。 */
606
+ let connJob = null
607
+ try {
608
+ connJob = ctrl.post_connection()
609
+ await connJob.wait()
610
+ } catch (e) {
611
+ try { ctrl.destroy() } catch (e2) { /* ignore */ }
612
+ return { ok: false, error: '连接失败:' + String(e && e.message || e) }
613
+ }
614
+ const connStatus = Number(connJob && connJob.status)
615
+ if (connStatus !== STATUS.succeeded) {
616
+ try { ctrl.destroy() } catch (e) { /* ignore */ }
617
+ return { ok: false, error: '连接失败:post_connection status=' + String(connStatus) + '(成功应为 ' + STATUS.succeeded + ')' }
618
+ }
428
619
  S.ctrl = ctrl
429
- await ctrl.post_connection().wait()
430
620
  S.session = session
431
621
  S.connGen++
432
622
  /* 预热一次截图,取控制器分辨率(默认短边 720 的坐标系,pipeline 的 roi/模板即此空间) */
@@ -452,6 +642,8 @@ async function cmdDisconnect() {
452
642
  if (S.stream) await cmdStreamStop()
453
643
  await cleanupAgents()
454
644
  if (S.tasker) { try { S.tasker.destroy() } catch (e) { /* ignore */ } S.tasker = null }
645
+ /* tasker 收掉之后再放 Resource:顺序反了会让还在引用的 tasker 拿到已销毁的句柄 */
646
+ if (S.runRes) { try { S.runRes.destroy() } catch (e) { /* ignore */ } S.runRes = null }
455
647
  if (S.ctrl) { try { S.ctrl.destroy() } catch (e) { /* ignore */ } S.ctrl = null }
456
648
  S.session = null
457
649
  return { ok: true }
@@ -574,19 +766,26 @@ function pushFrame(dec, png) {
574
766
  S.quietNow = false
575
767
  const last = S.events.length ? S.events[S.events.length - 1] : null
576
768
  if (last && last.type === 'change' && fr.t - last.t < 400) {
769
+ /* 合并同簇:seq/t/diff 前移,**frame 锚点必须跟着前移**——否则事件指向的是簇内第一帧,
770
+ * 而调用方看到的 t/seq 是最后一帧,锚点与内容对不上(同一类"回执与事实不符")。 */
577
771
  last.t = fr.t; last.seq = fr.seq; last.diff = Math.max(last.diff, diff)
772
+ l0AnchorAdd({ ...l0, source: 'change' })
773
+ last.frame = eventFrameAnchor(fr.seq)
578
774
  } else {
775
+ /* 顺序要紧:**先入锚区、再发事件**。事件里的 `frame.retained` 是按 seq 反查 L0 得出的,
776
+ * 反过来的话刚发出的 change/stable 事件会自报 `retained:'gone'`——明明它自己就是
777
+ * 触发入锚区的那一帧(实测就是这么错了一次)。 */
778
+ l0AnchorAdd({ ...l0, source: 'change' })
579
779
  pushEvent({
580
780
  type: 'change', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000,
581
781
  ...(changed ? { blocks: changed.blocks.length, region: changed.region } : {}),
582
782
  })
583
- l0AnchorAdd({ ...l0, source: 'change' })
584
783
  }
585
784
  } else if (prev) {
586
785
  if (!S.quietNow) {
587
786
  S.quietNow = true
588
- pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
589
787
  l0AnchorAdd({ ...l0, source: 'stable' })
788
+ pushEvent({ type: 'stable', seq: fr.seq, t: fr.t, diff: Math.round(diff * 1000) / 1000 })
590
789
  }
591
790
  }
592
791
  }
@@ -594,7 +793,29 @@ function pushFrame(dec, png) {
594
793
  * 主动抓一张原图并入观测流(seq 前进、进环、进 L0,可作动作/run 边界锚点)。
595
794
  * 采集失败如实上报,绝不阻断调用方的动作——动作结果与留存失败分别报告(契约 §9)。
596
795
  */
597
- async function captureNow(source) {
796
+ /**
797
+ * 动作回执帧落盘:一次性 CLI(daemon 随命令死)也能复核"动作前后画面"——
798
+ * 帧本身是 L0 原图字节,落 runDir/action-frames/(与关键帧库分开:这不是留存证据本体,
799
+ * 是回执附件,不占 kf 配额、不进 manifest)。
800
+ * 同一次 input 的 before/after 用同一对文件名;dir 不存在则跳过(静默降级为无路径回执)。
801
+ */
802
+ function actionFramesRoot() {
803
+ return S.runDir ? path.join(S.runDir, 'action-frames') : null
804
+ }
805
+ function persistActionFrame(seq, source) {
806
+ const root = actionFramesRoot()
807
+ if (!root) return null
808
+ const ent = S.l0.roll.find((e) => e.seq === seq)
809
+ if (!ent || !ent.png) return null
810
+ try {
811
+ fs.mkdirSync(root, { recursive: true })
812
+ const file = path.join(root, source + '-seq' + String(seq).padStart(6, '0') + '.png')
813
+ fs.writeFileSync(file, ent.png)
814
+ return file
815
+ } catch (e) { return null }
816
+ }
817
+
818
+ async function captureNow(source, persist = false) {
598
819
  if (!S.ctrl) return { ok: false, error: '未连接设备' }
599
820
  try {
600
821
  const j = S.ctrl.post_screencap()
@@ -606,37 +827,80 @@ async function captureNow(source) {
606
827
  pushFrame(dec, b)
607
828
  const ent = S.l0.roll[S.l0.roll.length - 1]
608
829
  l0AnchorAdd({ ...ent, source }, true)
609
- return { ok: true, seq: ent.seq, t: ent.t, w: ent.w, h: ent.h }
830
+ const out = { ok: true, seq: ent.seq, t: ent.t, w: ent.w, h: ent.h }
831
+ /* 动作帧持久化:一次性 CLI 下 seq 无消费者(daemon 死后 frame_get 不存),
832
+ * 路径才是回执里能看的东西;常驻会话里多写一份也无害(同字节、幂等文件名)。 */
833
+ if (persist) {
834
+ const file = persistActionFrame(ent.seq, source)
835
+ if (file) out.file = file
836
+ }
837
+ return out
610
838
  } catch (e) {
611
839
  return { ok: false, error: String(e && e.message || e) }
612
840
  }
613
841
  }
614
842
 
615
843
  /**
616
- * 用控制器缓存图入锚区(node-ok/fail 锚点的实现方式)。
844
+ * node-ok/fail 锚点:用控制器缓存图入锚区,带"上一张已留帧的 hash"去重。
617
845
  * 关键约束:不要在节点回调里发 post_screencap——add_sink 回调中并发截图会与 Tasker
618
846
  * 识别回路竞争控制器,实测触发原生崩溃 0xC0000005(maa-node 5.14.2 win32-x64,
619
847
  * 2026-10-05 真机:同任务关锚点正常 / 开锚点必崩)。
620
848
  * cached_image 是框架识别回路自己截的图:零新任务、无竞争,且节点回调时刻的缓存
621
849
  * 通常就是该节点识别所用的那张(bound 倾向);但下一节点识别可能已刷新缓存,
622
- * 关联强度按 adjacent 报,不冒称 bound(契约 §4)。 */
623
- function anchorFromCache(source) {
624
- if (!S.ctrl) return
850
+ * 关联强度按 adjacent 报,不冒称 bound(契约 §4)。
851
+ * 节点结束帧若与上一张已留帧相同(hashDist < NODE_FRAME_DEDUP)就不重复入锚区,
852
+ * 返回 { seq:null, skipped:'same' };不同则入锚区并返回 { seq, hash }(调用方拿 hash 记给下一次)。
853
+ * 阈值取小:目标是"同一个屏重复出现只留一张",不是"相似就丢"——近似画面各自留,别误并。
854
+ */
855
+ const NODE_FRAME_DEDUP = 0.02
856
+ function anchorFromCacheDedup(source, lastHash) {
857
+ if (!S.ctrl) return { seq: null, skipped: 'no-ctrl' }
625
858
  let img = null
626
- try { img = S.ctrl.cached_image } catch (e) { return }
627
- if (!img || img.byteLength < 9) return
859
+ try { img = S.ctrl.cached_image } catch (e) { return { seq: null, skipped: 'no-img' } }
860
+ if (!img || img.byteLength < 9) return { seq: null, skipped: 'no-img' }
628
861
  const b = Buffer.from(img)
629
862
  let dec = null
630
- try { dec = pngDecode(b) } catch (e) { return }
863
+ try { dec = pngDecode(b) } catch (e) { return { seq: null, skipped: 'decode' } }
864
+ const rgb = toRgb(dec)
865
+ const h = blockHash(rgb, dec.w, dec.h)
866
+ if (lastHash && hashDist(lastHash, h) < NODE_FRAME_DEDUP) return { seq: null, skipped: 'same' }
631
867
  pushFrame(dec, b)
632
868
  const ent = S.l0.roll[S.l0.roll.length - 1]
633
869
  l0AnchorAdd({ ...ent, source }, true)
870
+ return { seq: ent.seq, hash: h }
634
871
  }
635
- /** 事件入环 + 推送 host(cap 1200) */
872
+ /** 事件入环 + 推送 host(cap 1200)。
873
+ *
874
+ * 事件自带 `frame` 锚点:告诉调用方"这一刻的画面在哪能拿到"。
875
+ * 为什么必须有:事件流的消费者(LLM / 脚本)看到 `{"type":"stable","seq":2,"diff":0}` 时,
876
+ * 唯一想知道的是"那是什么画面"——旧回执只有 seq/t/diff,人得自己去 `frame get 2` 试,
877
+ * 而 L0 是**滚动淘汰**的,试的时候那张图可能已经没了(本仓 G9:事件无画面锚点,
878
+ * 只能拿 seq 去撞运气)。现在按 seq 反查 L0 两区,直接给出可取性:
879
+ *
880
+ * frame: { seq, retained: 'l0' | 'gone', source } // retained='l0' 才能 frame get / kf promote
881
+ *
882
+ * 只报事实,不代取图:取图要走 `frame get`(`--src full` 出控制器分辨率原图),
883
+ * 事件里塞 PNG 会让 1200 条环形缓冲变成内存炸弹。 */
884
+ function eventFrameAnchor(seq) {
885
+ if (typeof seq !== 'number') return undefined
886
+ const inRoll = S.l0.roll.find((x) => x.seq === seq)
887
+ const inAnchor = S.l0.anchor.find((x) => x.seq === seq)
888
+ const hit = inRoll ?? inAnchor
889
+ if (!hit) return { seq, retained: 'gone' }
890
+ return {
891
+ seq,
892
+ retained: 'l0',
893
+ /* 两区都可能有同一 seq(共享 Buffer):优先报锚区来源(锚区才是"值得留的"那张) */
894
+ source: (inAnchor ?? inRoll).source ?? null,
895
+ w: hit.w, h: hit.h,
896
+ }
897
+ }
898
+
636
899
  function pushEvent(ev) {
637
- S.events.push(ev)
900
+ const anchored = { ...ev, frame: eventFrameAnchor(ev.seq) }
901
+ S.events.push(anchored)
638
902
  if (S.events.length > 1200) S.events.splice(0, S.events.length - 1200)
639
- send({ kind: 'event', ev })
903
+ send({ kind: 'event', ev: anchored })
640
904
  }
641
905
 
642
906
  async function streamTick() {
@@ -702,6 +966,36 @@ function cmdStreamStatus() {
702
966
  }
703
967
  }
704
968
 
969
+ /**
970
+ * 事件环查询(顶层 CLI 用)。
971
+ *
972
+ * 为什么必须做成 daemon 命令:事件环 `S.events` 活在 daemon 进程里,而一次性 CLI 命令
973
+ * **退出即断连**——它拿不到"过去发生过什么",只能自己开着会话边跑边收。于是本仓 G7 的
974
+ * 诉求(`events` 不该只在 repl 里)落不到"客户端本地环"上,只能让 daemon 按需吐出它自己那份。
975
+ *
976
+ * 与 REPL 的 `events` 是**同一份数据**(同一个 `S.events`),只是取用方式不同:
977
+ * REPL 边收推送边留本地副本,CLI 每次现查。两处渲染共用 `frame` 锚点字段。
978
+ *
979
+ * `since` 支持按 seq 增量拉取(长跑时反复查询不必重传全量);`limit` 从尾部截取。
980
+ */
981
+ function cmdEventsList(args) {
982
+ const rawSince = Number(args.since)
983
+ const since = Number.isFinite(rawSince) ? rawSince : null
984
+ const rawLimit = Number(args.limit)
985
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : 20
986
+ const all = since === null ? S.events : S.events.filter((e) => typeof e.seq === 'number' && e.seq > since)
987
+ const picked = all.slice(-limit)
988
+ return {
989
+ ok: true,
990
+ /* 报总数与"环里还留着多少",让调用方知道有没有被 1200 上限截掉过 */
991
+ total: S.events.length,
992
+ matched: all.length,
993
+ cap: 1200,
994
+ truncated: all.length > picked.length,
995
+ events: picked,
996
+ }
997
+ }
998
+
705
999
  /* ────────────────────────── 历史帧取用(ROI 裁剪,像素按需出 worker) ────────────────────────── */
706
1000
  /**
707
1001
  * roi 参数校验:只接受 4 个**有限数字**。
@@ -782,6 +1076,9 @@ function cmdFrameGet(args) {
782
1076
  return {
783
1077
  ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: rw, h: rh,
784
1078
  seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
1079
+ /* 帧身份:观测面 blockHash 的 64bit 串——input 的 expectFrame 拿它核对
1080
+ * "现在这帧还是不是我当初看到的那帧"。同 seq 的两帧 hash 必同(同一份像素)。 */
1081
+ frameHash: hashStr(fr.hash),
785
1082
  source: 'l0-full', sourceResolution: 1, degraded: false,
786
1083
  requested: { w, h },
787
1084
  }
@@ -830,6 +1127,7 @@ function cmdFrameGet(args) {
830
1127
  return {
831
1128
  ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: full.w, h: full.h,
832
1129
  seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: full.w, ctrlH: full.h,
1130
+ frameHash: hashStr(fr.hash),
833
1131
  source: 'l0-full', sourceResolution: 1,
834
1132
  }
835
1133
  }
@@ -840,6 +1138,7 @@ function cmdFrameGet(args) {
840
1138
  return {
841
1139
  ok: true, path: outFile, bytes: fs.statSync(outFile).size, w: fr.w, h: fr.h,
842
1140
  seq: fr.seq, t: fr.t, diff: fr.diff, ctrlW: fr.fw, ctrlH: fr.fh,
1141
+ frameHash: hashStr(fr.hash),
843
1142
  source: 'l1-ring', sourceResolution: sx0,
844
1143
  /* 整帧小图是**文档写明的缺省行为**,不是降级:只标注来源与分辨率,不警告、不判 findings——
845
1144
  * 否则最普通的 `frame get`(不带 roi)会时不时退 3。要原图就显式 `--src full`。 */
@@ -968,26 +1267,54 @@ function cmdFrameDiff(args) {
968
1267
  }
969
1268
 
970
1269
  /* ────────────────────────── 资源 / 运行 / 识别 ────────────────────────── */
971
- async function ensureResource(dir) {
972
- if (!S.res) {
973
- const m = loadMaa()
974
- S.res = new m.Resource()
975
- }
976
- if (dir) await S.res.post_bundle(dir).wait()
977
- return S.res
978
- }
979
1270
  /**
980
- * 项目驱动的多路径资源:interface.json 的 resource.path[] + attach_resource_path
981
- * 按顺序叠加、后者覆盖前者(MaaPiCli Configurator 的语义),对应逐个 post_bundle。
982
- * 每次运行都新建 Resource——复用旧实例会把上一次运行的 bundle 残留进来
983
- * (比如上次加载了皮肤包这次没选,残留会让 override 关系错乱)。
1271
+ * 资源加载的唯一入口:`dirs` 是整条链(`resource.path[]` + attach,按序叠加、后者覆盖前者,
1272
+ * 对应逐个 `post_bundle`),单包就是长度为 1 的链。
1273
+ *
1274
+ * **每次都新建 Resource**:`post_bundle` 是叠加语义,复用旧实例会把上一次运行的 bundle 残留进来
1275
+ * (上次加载了皮肤包这次没选,残留会让 override 关系错乱);单包若复用会话资源,长活会话里
1276
+ * `run --resource-dir B` 还会带着上一轮 A 的节点/模板跑。会话不该有一份"当前资源"的共享态。
984
1277
  */
985
- async function buildResource(dirs) {
1278
+ async function buildResource(dirs, hashCheck) {
986
1279
  const m = loadMaa()
987
1280
  const res = new m.Resource()
988
- for (const d of dirs) await res.post_bundle(d).wait()
1281
+ /* hashCheck:PI v2.6 的 resource.hash 校验,时机是"只加载完 path[]"(attach_resource_path
1282
+ * 不能算进被校验对象)。两段在 plan 侧已分好:pathDirs 是资源自己的声明路径,
1283
+ * attachDirs 是控制器追加段;dirs 兜底=全链(无校验声明时调用方不传 hashCheck)。
1284
+ * 读出的实际值写回 hashCheck.actual——校验结果(匹配/不匹配)由调用方决定怎么报。 */
1285
+ const pathDirs = hashCheck ? hashCheck.pathDirs : dirs
1286
+ /* plan 侧 paths 做了整体去重(attach 若与 path 相同会消失),这里保持同一份:
1287
+ * attach 去重掉已在 pathDirs 里的项,保证加载序列与 paths 一一对应 */
1288
+ const attachDirs = hashCheck ? hashCheck.attachDirs.filter((d) => !pathDirs.includes(d)) : []
1289
+ try {
1290
+ for (const d of pathDirs) await res.post_bundle(d).wait()
1291
+ if (hashCheck) hashCheck.actual = res.hash || ''
1292
+ for (const d of attachDirs) await res.post_bundle(d).wait()
1293
+ } catch (e) {
1294
+ /* 加载中途失败也要把句柄收掉:否则每次失败的 run 都在 native 侧留一份 */
1295
+ try { res.destroy() } catch (e2) { /* ignore */ }
1296
+ throw e
1297
+ }
989
1298
  return res
990
1299
  }
1300
+
1301
+ /**
1302
+ * 换掉"最近一次 run / --act 的 Resource"并销毁上一份。
1303
+ *
1304
+ * 每次新建保证了资源隔离(单包不再叠上一轮 bundle),但 native 句柄必须有释放点——否则长活
1305
+ * REPL 里每 run 一次就多留一份(OCR 模型尤其重)。
1306
+ *
1307
+ * **调用前提由调用方保证**,不在这里重试:(1) 没有任务在飞(`cmdRun` 开头有 `runActive` 守卫、
1308
+ * `reco --act` 自己拒绝运行中的任务);(2) `tasker.resource` 已指向新对象。满足这两条才能立刻
1309
+ * 销毁上一份——早于 (2) 销毁会让 tasker 拿着死句柄,晚于 (1) 会踩到正在跑的任务。
1310
+ */
1311
+ function adoptRunResource(res) {
1312
+ const prev = S.runRes
1313
+ S.runRes = res
1314
+ if (!prev || prev === res) return
1315
+ try { prev.destroy() } catch (e) { /* ignore */ }
1316
+ }
1317
+
991
1318
  function ensureTasker() {
992
1319
  if (!S.tasker) {
993
1320
  const m = loadMaa()
@@ -1000,35 +1327,113 @@ function ensureTasker() {
1000
1327
  * 模式照 maa-support-extension 的 setupAgent:new Client() 自动生成 identifier,
1001
1328
  * 把 identifier 追加为子进程最后一个参数(agent 侧 sys.argv[-1] → AgentServer.start_up),
1002
1329
  * bind_resource 后 connect(与子进程退出/超时竞速)。custom action/recognition 经此
1003
- * 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。 */
1330
+ * 注册进绑定的 Resource——依赖 agent 的节点(M9A DisableNode 等)由此可用。
1331
+ *
1332
+ * 环境变量:调用方给的 `PI_*` 原样并入子进程 env。本层不解释取值也不补默认——
1333
+ * 协议允许逐项缺省、由 agent 容错;这里编一个默认值只会把"没注入"伪装成"注入了"。 */
1334
+ /* 收掉整棵 agent 进程树。真身在子孙层(uv run → venv-python → uv-python agent/main.py),
1335
+ * child.kill() 只到直接子进程(uv.exe)。顺序是硬约束:必须先杀再 disconnect——
1336
+ * disconnect 会让 uv.exe 自己优雅退出,它的 python 子孙瞬间脱离父链,taskkill /T
1337
+ * 再够不到(实测 disconnect 在先必留两个 python 孤儿)。
1338
+ *
1339
+ * 两刀:① taskkill /T /F 按父链整棵收(够用,但 uv 在 kill 途中重 exec 出的孙级
1340
+ * 会漏);② 用 identifier 扫命令行兜底——agent 树每层 argv 都带同一个
1341
+ * identifier(sys.argv[-1]),一次 -like 点名全树,与父子链无关。
1342
+ * 第②刀的 $PID 排除自身:否则 -like 会命中这个 powershell 自己的 CommandLine
1343
+ * (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。POSIX 用
1344
+ * detached 进程组(spawn 时 detached:true),负 pid 杀整组。 */
1345
+ function killTree(child, ident) {
1346
+ if (!child || !child.pid) return Promise.resolve()
1347
+ if (process.platform === 'win32') {
1348
+ const root = child.pid
1349
+ /* taskkill /T 收父链 + identifier 兜底:agent 树每层 argv 都带同一个 identifier
1350
+ * (sys.argv[-1]),一次 -like 点名全树,与父子链无关——taskkill 杀 uv.exe 时它
1351
+ * 重 exec 出的孙级会漏,这层兜住。$PID 排除自身:-like 会命中这个 powershell 自己
1352
+ * 的 CommandLine(字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。 */
1353
+ /* 一刀 taskkill 按父链收 + 一刀 ident sweep 点名全树(agent 每层 argv 带同一个
1354
+ * identifier,与父子链无关——taskkill 杀 uv.exe 时它重 exec 出的中层 venv-python
1355
+ * 会漏,这层兜住)。$PID 排除自身:-like 会命中这个 powershell 自己的 CommandLine
1356
+ * (字面量里带着 ident),自杀在中途把 python 留成孤儿(实测)。两刀都 await:
1357
+ * fire-and-forget 会让 daemon 在 sweep 还没跑完就 exit,python 照样孤儿。 */
1358
+ const sweep = () => new Promise((resolve) => {
1359
+ const like = '%' + String(ident || '').replace(/'/g, '') + '%'
1360
+ const ps =
1361
+ '$self=' + '$PID;' +
1362
+ `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like '${like}' } |` +
1363
+ 'ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} };' +
1364
+ 'try { Stop-Process -Id ' + root + ' -Force -ErrorAction SilentlyContinue } catch {}'
1365
+ try { execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 30000 }, () => resolve()) } catch (e) { resolve() }
1366
+ })
1367
+ const kill = () => new Promise((resolve) => {
1368
+ try {
1369
+ execFile('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 10000 }, () => resolve())
1370
+ } catch (e) {
1371
+ try { child.kill() } catch (e2) { /* ignore */ }
1372
+ resolve()
1373
+ }
1374
+ })
1375
+ return kill().then(() => sweep())
1376
+ }
1377
+ try { process.kill(-child.pid, 'SIGKILL') } catch (e) {
1378
+ try { child.kill('SIGKILL') } catch (e2) { /* ignore */ }
1379
+ }
1380
+ return Promise.resolve()
1381
+ }
1382
+
1004
1383
  async function cleanupAgents() {
1005
1384
  const bind = S.agentBind
1006
1385
  S.agentBind = null
1007
1386
  if (!bind) return
1008
1387
  for (const c of bind.clients) {
1388
+ /* 先树杀、后 disconnect:顺序反过来 disconnect 会抢先让 uv.exe 退掉,子孙脱离
1389
+ * 父链后 taskkill /T 够不到(实测留两个 python 孤儿)。 */
1390
+ try { process.stderr.write('[cleanupAgents] kill ' + (c.child && c.child.pid) + ' ident=' + c.ident + '\n') } catch (e) { /* ignore */ }
1391
+ await killTree(c.child, c.ident)
1009
1392
  try { c.client.disconnect() } catch (e) { /* ignore */ }
1010
1393
  try { c.client.destroy() } catch (e) { /* ignore */ }
1011
- try { c.child.kill() } catch (e) { /* ignore */ }
1012
1394
  }
1013
1395
  }
1014
1396
 
1015
- async function setupAgents(res, decls, cwd) {
1397
+ async function setupAgents(res, decls, cwd, piEnv, sinks, maaMod, spawnFn) {
1016
1398
  const key = JSON.stringify(decls)
1017
1399
  if (S.agentBind && S.agentBind.res === res && S.agentBind.key === key) {
1018
1400
  const alive = S.agentBind.clients.every((c) => {
1019
1401
  try { return c.client.connected && c.client.alive } catch (e) { return false }
1020
1402
  })
1021
- if (alive) return { ok: true, reused: true, clients: S.agentBind }
1403
+ if (alive) return { ok: true, reused: true, clients: S.agentBind, sinks: S.agentBind.sinks }
1022
1404
  }
1023
1405
  await cleanupAgents()
1024
- const m = loadMaa()
1406
+ const m = maaMod ?? loadMaa()
1407
+ const doSpawn = spawnFn ?? spawn
1025
1408
  const clients = []
1409
+ /* sink 注册是桥接的另一半:只 bind_resource 时 agent 侧 context.tasker.controller /
1410
+ * context.tasker 发出的 reverse request 会带空 controller_id / 找不到 tasker,
1411
+ * 框架日志报 controller not found 后 custom action 挂起(实测:patrol 的 post_click
1412
+ * 静默失败,角色原地等死)。三个 sink 必须在 connect() 之前注册。 */
1413
+ const sinkDefs = [
1414
+ ['resource', sinks && sinks.resource, 'register_resource_sink'],
1415
+ ['controller', sinks && sinks.controller, 'register_controller_sink'],
1416
+ ['tasker', sinks && sinks.tasker, 'register_tasker_sink'],
1417
+ ]
1026
1418
  for (const d of decls) {
1027
- const client = new m.Client()
1419
+ /* interface 声明固定 identifier 时原样交给 Client(schema:socket 同名,断线可重连)。
1420
+ * 代价:残留旧 socket 时 connect 会等满超时才败——项目声明了就是它要的语义,照做。 */
1421
+ const client = new m.Client(typeof d.identifier === 'string' && d.identifier ? d.identifier : undefined)
1028
1422
  const ident = client.identifier
1029
1423
  let child = null
1030
1424
  try {
1031
- child = spawn(d.exec, [...d.args, ident], { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
1425
+ const env = piEnv && typeof piEnv === 'object' ? Object.fromEntries(
1426
+ Object.entries(piEnv).filter(([, v]) => typeof v === 'string' && v !== ''),
1427
+ ) : {}
1428
+ child = doSpawn(d.exec, [...d.args, ident], {
1429
+ cwd,
1430
+ stdio: ['ignore', 'pipe', 'pipe'],
1431
+ windowsHide: true,
1432
+ /* POSIX 下脱离父进程组:killTree 靠负 pid 杀整组;Windows 无进程组概念,taskkill /T 兜底 */
1433
+ ...(process.platform !== 'win32' ? { detached: true } : {}),
1434
+ /* 空对象时干脆不传 env(子进程照常继承),别用空 env 覆盖掉 PATH */
1435
+ ...(Object.keys(env).length ? { env: { ...process.env, ...env } } : {}),
1436
+ })
1032
1437
  } catch (e) {
1033
1438
  try { client.destroy() } catch (e2) { /* ignore */ }
1034
1439
  return { ok: false, error: 'agent 子进程启动失败:' + d.exec + '(' + String(e && e.message || e) + ')' }
@@ -1043,12 +1448,27 @@ async function setupAgents(res, decls, cwd) {
1043
1448
  try { client.destroy() } catch (e2) { /* ignore */ }
1044
1449
  return { ok: false, error: 'agent 绑定资源失败:' + String(e && e.message || e) }
1045
1450
  }
1451
+ const sinksRegistered = []
1452
+ const sinkErrs = []
1453
+ for (const [name, obj, method] of sinkDefs) {
1454
+ if (!obj) continue
1455
+ try { client[method](obj); sinksRegistered.push(name) } catch (e) {
1456
+ sinkErrs.push(name + ':' + String(e && e.message || e))
1457
+ }
1458
+ }
1459
+ client.__sinks = { registered: sinksRegistered, ...(sinkErrs.length ? { errors: sinkErrs } : {}) }
1460
+ /* sink 注册失败不阻断:custom action 不碰 context.controller/tasker 的项目照跑,
1461
+ * 但回执必须点名哪一项没注上——半残不报就是"controller not found"那种排障半天。 */
1046
1462
  const exited = new Promise((r) => child.on('exit', () => r('exit')))
1463
+ let timer = null
1047
1464
  const ok = await Promise.race([
1048
1465
  client.connect().then(() => 'ok', () => 'err'),
1049
1466
  exited,
1050
- sleep(60000).then(() => 'timeout'),
1467
+ new Promise((r) => { timer = setTimeout(() => r('timeout'), 60000) }),
1051
1468
  ])
1469
+ /* connect 或子进程退出先赢时把超时就地收掉:不然这个 setTimeout 挂着 60s
1470
+ * 才让进程退(headless 调用方——比如单测——会被它拖住)。 */
1471
+ if (timer) clearTimeout(timer)
1052
1472
  /* connect() resolve 不代表可用:协议版本错配(maa-node 与 agent 侧 maa 版本不一致)时
1053
1473
  * 通道建立但握手被拒——必须复核 connected && alive(maa-support 同款检查)。 */
1054
1474
  let usable = ok === 'ok'
@@ -1062,14 +1482,19 @@ async function setupAgents(res, decls, cwd) {
1062
1482
  try { child.kill() } catch (e) { /* ignore */ }
1063
1483
  return { ok: false, error: 'agent ' + why + ':' + [d.exec, ...d.args].join(' ') + ';输出尾部:' + out.slice(-300) }
1064
1484
  }
1065
- clients.push({ client, child, name: [d.exec, ...d.args].join(' '), out })
1485
+ clients.push({ client, child, ident, name: [d.exec, ...d.args].join(' '), out })
1486
+ }
1487
+ /* 汇总所有 client 的 sink 结果:registered 取并集,errors 逐条带上是哪个 agent */
1488
+ const sinkReport = {
1489
+ registered: [...new Set(clients.flatMap((c) => c.client.__sinks?.registered ?? []))],
1490
+ errors: clients.flatMap((c) => (c.client.__sinks?.errors ?? []).map((e) => c.name + ':' + e)),
1066
1491
  }
1067
- S.agentBind = { res, key, clients }
1492
+ S.agentBind = { res, key, clients, sinks: sinkReport }
1068
1493
  let actions = null
1069
1494
  let recos = null
1070
1495
  try { actions = clients[0].client.custom_action_list } catch (e) { /* ignore */ }
1071
1496
  try { recos = clients[0].client.custom_recognition_list } catch (e) { /* ignore */ }
1072
- return { ok: true, clients: S.agentBind, actions, recos }
1497
+ return { ok: true, clients: S.agentBind, actions, recos, sinks: sinkReport }
1073
1498
  }
1074
1499
 
1075
1500
  const sab = new Int32Array(new SharedArrayBuffer(4))
@@ -1087,37 +1512,87 @@ function pickNodeName(m) {
1087
1512
  return { name: String(m.name || ('node#' + m.node_id)), source: 'entry' }
1088
1513
  }
1089
1514
 
1515
+ /** 识别半:从带 node_id 的通知里抽识别框——没有它 record.nodes 只有"哪个节点跑过",
1516
+ * 不知道"这一步看的是哪个元素"(示范轨迹缺口 #1)。字段全防御性取(不同版本字段会缺),
1517
+ * 一条都取不到就返回 null、不挂空对象。 */
1518
+ function pickRecoBox(m) {
1519
+ const d = m.reco_details
1520
+ if (!d || typeof d !== 'object') return null
1521
+ const o = {}
1522
+ if (d.name !== undefined) o.name = String(d.name)
1523
+ if (d.algorithm !== undefined) o.algorithm = String(d.algorithm)
1524
+ if (d.hit !== undefined) o.hit = !!d.hit
1525
+ if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
1526
+ /* detail.best.score 是命中质量分(TemplateMatch 有,OCR 没有)——能拿到就带,拿不到不编 */
1527
+ const best = d.detail && typeof d.detail === 'object' && !Array.isArray(d.detail) ? d.detail.best : null
1528
+ if (best && typeof best === 'object' && best.score !== undefined) o.score = Number(best.score)
1529
+ return Object.keys(o).length ? o : null
1530
+ }
1531
+
1532
+ /** 动作半:动作的落点框(action_details.box 是实际动作框,success 是动作自身成败)。 */
1533
+ function pickActionBox(m) {
1534
+ const d = m.action_details
1535
+ if (!d || typeof d !== 'object') return null
1536
+ const o = {}
1537
+ if (d.name !== undefined) o.name = String(d.name)
1538
+ if (d.action !== undefined) o.action = String(d.action)
1539
+ if (d.success !== undefined) o.success = !!d.success
1540
+ if (Array.isArray(d.box) && d.box.length === 4) o.box = d.box.map(Number)
1541
+ return Object.keys(o).length ? o : null
1542
+ }
1543
+
1090
1544
  async function cmdRun(args) {
1091
1545
  if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
1092
1546
  if (S.runActive) return { ok: false, error: '已有任务在运行(先 maa_run_stop)' }
1093
- let res
1094
- if (Array.isArray(args.resourceDirs) && args.resourceDirs.length) {
1095
- res = await buildResource(args.resourceDirs.map(String))
1096
- S.res = res
1097
- } else {
1098
- if (!args.resourceDir) return { ok: false, error: '缺少 resourceDir(或 resourceDirs 数组)' }
1099
- res = await ensureResource(args.resourceDir)
1100
- }
1547
+ const resDirs = Array.isArray(args.resourceDirs) && args.resourceDirs.length
1548
+ ? args.resourceDirs.map(String)
1549
+ : (args.resourceDir ? [String(args.resourceDir)] : [])
1550
+ if (!resDirs.length) return { ok: false, error: '缺少 resourceDir(或 resourceDirs 数组)' }
1551
+ /* 上一轮的 agent 子进程与 Resource 在这里收掉:走到这行必然没有任务在飞(runActive 守卫),
1552
+ * 这一轮也不存在可复用的资源对象。放在参数校验之后——调用参数错了不该把上一份资源也丢掉。 */
1553
+ /* 上一轮 agent 子进程先断掉:这一轮会新建 Resource,绑定不可能复用。 */
1554
+ await cleanupAgents()
1555
+ const hashCheck = args.resourceHashCheck && typeof args.resourceHashCheck === 'object' ? args.resourceHashCheck : null
1556
+ const res = await buildResource(resDirs, hashCheck)
1557
+ /* v2.6 一致性校验:不匹配只警告不阻断(协议原文"不应阻止继续使用"),结果如实进回执与 warns */
1558
+ const runWarns = []
1559
+ if (hashCheck && hashCheck.expected !== undefined) {
1560
+ hashCheck.match = hashCheck.actual === hashCheck.expected
1561
+ if (!hashCheck.match) {
1562
+ runWarns.push('资源完整性校验失败:' + String(hashCheck.resourceName) +
1563
+ ' 期望 hash=' + String(hashCheck.expected) + ' 实际=' + String(hashCheck.actual || '(读不到)') +
1564
+ '——建议重新下载资源包;按 PI v2.6 语义不阻断本次运行')
1565
+ }
1566
+ }
1567
+ /* tasker 要在 setupAgents 之前就绪:agent 侧 context.tasker / context.tasker.controller
1568
+ * 经 sink 反查的就是这个 tasker 与 S.ctrl——先注册、后 connect,缺一个 sink 就是
1569
+ * "controller not found"(见 setupAgents 注释)。 */
1570
+ const tasker = ensureTasker()
1101
1571
  /* agent 桥接:项目声明了 agent(PI interface 的 child_exec/child_args)就先连上——
1102
1572
  * custom action 注册进本次运行的 Resource;失败不阻断(非 agent 节点照跑),结果如实报告 */
1103
1573
  let agentInfo = null
1104
1574
  if (Array.isArray(args.agents) && args.agents.length) {
1105
- const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd()))
1575
+ const ag = await setupAgents(res, args.agents, String(args.agentCwd || process.cwd()), args.agentEnv,
1576
+ { resource: res, controller: S.ctrl, tasker })
1106
1577
  agentInfo = ag.ok
1107
- ? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null }
1578
+ ? { ok: true, reused: !!ag.reused, actions: ag.actions ?? null, recognitions: ag.recos ?? null,
1579
+ ...(ag.sinks ? { sinks: ag.sinks } : {}) }
1108
1580
  : { ok: false, error: ag.error }
1109
1581
  }
1110
- const tasker = ensureTasker()
1111
1582
  tasker.resource = res
1112
1583
  tasker.controller = S.ctrl
1584
+ /* 顺序是硬约束:先让 tasker 指向新对象,再销毁上一份。反过来(或加载失败前就销毁)会让还握着
1585
+ * 旧句柄的 tasker 拿到已销毁的资源。buildResource 抛错时上一份原样留着。 */
1586
+ adoptRunResource(res)
1113
1587
  /* timeoutMs=0:不自动 post_stop,停止权交调用方(LLM 看事件流自行判断 + run_stop)。
1114
1588
  * >0 时沿用超时保护(防 JumpBack 死循环)。300s 硬上限已移除——长任务用 0。 */
1115
1589
  const rawT = Number(args.timeoutMs)
1116
1590
  const timeoutMs = rawT === 0 ? 0 : (Number.isFinite(rawT) && rawT > 0 ? Math.max(500, rawT) : 30000)
1117
1591
  const t0 = Date.now()
1118
1592
  const retention = { start: await captureNow('run-start') }
1119
- const nodes = new Map() // node_id → {name, start, end, msg, success, seq}
1593
+ const nodes = new Map() // node_id → {name, start, end, msg, success, seq, reco?, action?, frameSeq?}
1120
1594
  const order = []
1595
+ let lastNodeHash = null // 按节点留帧的去重基准:上一张已留节点帧的 blockHash(新屏才留)
1121
1596
  const nextCandidates = [] // 框架本轮宣布过的候选(NextList 通知),按顺序
1122
1597
  let nextCandidateCount = 0 // 全部公告次数(含超出上限未记入数组的)
1123
1598
  const onMsg = (m) => {
@@ -1152,10 +1627,26 @@ async function cmdRun(args) {
1152
1627
  n.name = named.name
1153
1628
  n.nameSource = 'node_details'
1154
1629
  }
1155
- /* node-ok/fail 锚点:读控制器缓存图(不发新截图任务,规避并发崩溃,见 anchorFromCache) */
1630
+ /* 识别框/动作框:同一条通知里若带 details 就并入记录——后者覆盖前者,留下的是
1631
+ * 节点结束前最后一份(通常是 Succeeded 那次的判定结果)。 */
1632
+ const rec = pickRecoBox(m)
1633
+ if (rec) n.reco = rec
1634
+ const act = pickActionBox(m)
1635
+ if (act) n.action = act
1636
+ /* node-ok/fail 锚点:读控制器缓存图(不发新截图任务,规避并发崩溃,见 anchorFromCacheDedup);
1637
+ * 按节点留帧带去重——与上一张已留帧相同就跳过(画面没变重复留只是浪费锚区与字节),
1638
+ * 并把留下的 seq 记进节点(record.nodes[].frameSeq),消费方知道"这一节点留的是哪帧"。 */
1156
1639
  if (/Starting$/.test(m.msg)) { n.status = 'running'; n.start = t }
1157
- else if (/Succeeded$/.test(m.msg)) { n.status = 'ok'; n.end = t; anchorFromCache('node-ok') }
1158
- else if (/Failed$/.test(m.msg)) { n.status = 'fail'; n.end = t; anchorFromCache('node-fail') }
1640
+ else if (/Succeeded$/.test(m.msg)) {
1641
+ n.status = 'ok'; n.end = t
1642
+ const r = anchorFromCacheDedup('node-ok', lastNodeHash)
1643
+ if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
1644
+ }
1645
+ else if (/Failed$/.test(m.msg)) {
1646
+ n.status = 'fail'; n.end = t
1647
+ const r = anchorFromCacheDedup('node-fail', lastNodeHash)
1648
+ if (r.seq !== null) { n.frameSeq = r.seq; lastNodeHash = r.hash }
1649
+ }
1159
1650
  else if (n.msgs.length < 6) n.msgs.push(m.msg)
1160
1651
  n.seq = seq
1161
1652
  /* 节点级消息进事件流:maa_frame_poll / maa_timing_measure 直接消费 */
@@ -1197,6 +1688,10 @@ async function cmdRun(args) {
1197
1688
  const s2 = tasker.add_context_sink((_, m) => onMsg(m))
1198
1689
  S.runActive = true
1199
1690
  let status = null
1691
+ /* stopped 要覆盖两条停止路径:超时保护自停(timeoutMs 分支)与 run_stop 他停。
1692
+ * 后者靠 S.runStopped 旗标——cmdRunStop 先置旗再 post_stop,job.wait 解除后
1693
+ * 这条旗标已经立下;没有它,"run_stop 停的"在回执里会读成"失败"。 */
1694
+ S.runStopped = false
1200
1695
  let stopped = false
1201
1696
  try {
1202
1697
  const job = tasker.post_task(String(args.entry), args.override || {})
@@ -1216,6 +1711,8 @@ async function cmdRun(args) {
1216
1711
  } else {
1217
1712
  await job.wait()
1218
1713
  }
1714
+ /* run_stop 走并发命令置的旗标:wait 正常解除但其实是"被要求停的" */
1715
+ stopped = stopped || S.runStopped === true
1219
1716
  status = job.status
1220
1717
  } catch (e) {
1221
1718
  status = -1
@@ -1232,23 +1729,41 @@ async function cmdRun(args) {
1232
1729
  entry: String(args.entry),
1233
1730
  status,
1234
1731
  stopped,
1732
+ /* 本次实际生效的超时预算:回执要能自证"是不是跑满窗口被截断"。
1733
+ * 缺了它,`status:0 + stopped:true` 只能靠人回忆当初给的 --timeout 才分得清
1734
+ * 「跑满窗口」与「自己结束但没成功」——两者都是 stopped:true,而处置完全不同。 */
1735
+ timeoutMs,
1235
1736
  durationMs: Date.now() - t0,
1737
+ /* baseline 落账:节点坐标/识别框所在控制器空间的短边(契约 demo-trace §meta:
1738
+ * trace 元信息必带 baseline,跨项目迁移时与计划不一致即拒)。取控制器实际交付帧
1739
+ * 的短边——改了 shortSide / 换过设备时如实反映,不冒称"配置值"。 */
1740
+ baseline: Math.min(S.fullW, S.fullH) || null,
1741
+ space: S.fullW ? { w: S.fullW, h: S.fullH } : null,
1236
1742
  startSeq: nodes.size ? order[0].seq : null,
1237
1743
  endSeq: S.seq,
1238
1744
  framesCaptured: S.seq,
1239
1745
  retention,
1240
1746
  ...(agentInfo ? { agent: agentInfo } : {}),
1747
+ ...(hashCheck ? { hashCheck } : {}),
1241
1748
  ...(nextCandidateCount ? { nextCandidates, nextCandidateCount } : {}),
1242
1749
  nodes: order.map((n) => ({
1243
1750
  id: n.id, name: n.name, nameSource: n.nameSource, status: n.status,
1244
1751
  ms: (n.end ?? Date.now()) - n.start, seq: n.seq, msgs: n.msgs.slice(-6),
1752
+ /* 识别框/动作框/节点留存帧:只增不改(协议约定),消费方按"有就读、没有就当该节点没报"处理 */
1753
+ ...(n.reco ? { reco: n.reco } : {}),
1754
+ ...(n.action ? { action: n.action } : {}),
1755
+ /* 按节点留帧的 seq:画面与上一节点相同会跳过(不重复占锚区),此时没有 frameSeq */
1756
+ ...(n.frameSeq !== undefined ? { frameSeq: n.frameSeq } : {}),
1245
1757
  })),
1246
1758
  dump: dump ? { taskCount: dump.tasks ? dump.tasks.length : 0, nodes: Object.keys(dump.nodes || {}).length } : null,
1247
1759
  }
1248
- return { ok: true, record: rec }
1760
+ return { ok: true, record: rec, ...(runWarns.length ? { warns: runWarns } : {}) }
1249
1761
  }
1250
1762
 
1251
1763
  async function cmdRunStop() {
1764
+ /* 先立旗标再 post_stop:cmdRun 的 job.wait 解除后靠它把 record.stopped 补上,
1765
+ * 否则"run_stop 停的"会混进"任务失败"。 */
1766
+ S.runStopped = true
1252
1767
  if (S.tasker) {
1253
1768
  try { S.tasker.post_stop() } catch (e) { /* ignore */ }
1254
1769
  }
@@ -1259,13 +1774,43 @@ async function cmdRunStop() {
1259
1774
  /* 输入注入:全部经 maafw 控制器(adb/win32 由 maafw 本体选择注入方法),不直接调 adb。
1260
1775
  * 动作边界(前/后)各抓一张原图进 L0 锚区(契约 §1"采得到"):
1261
1776
  * 采集失败不阻断动作——动作结果与留存失败分别报告(契约 §9)。 */
1777
+ /** 帧身份守卫(纯函数,供单测):核对"现在这帧"与调用方期望的身份。
1778
+ * now = S.ring 最新帧({seq, hash});expectSeq 是单调帧号、expectFrame 是 64bit hash 串。
1779
+ * 返回 { ok:true } 放行;{ ok:false, error, detail } 拒发(detail 带 expected/actual 进回执)。 */
1780
+ function checkExpect(now, expectSeqRaw, expectFrameRaw) {
1781
+ const expectSeq = expectSeqRaw !== undefined && expectSeqRaw !== null ? Number(expectSeqRaw) : null
1782
+ const expectFrame = typeof expectFrameRaw === 'string' && expectFrameRaw ? expectFrameRaw : null
1783
+ if (expectSeq === null && expectFrame === null) return { ok: true }
1784
+ if (!now) return { ok: false, error: '帧身份守卫:取不到当前帧(环形缓冲空),不发输入', detail: {} }
1785
+ if (expectSeq !== null && now.seq !== expectSeq) {
1786
+ return { ok: false, error: '帧身份守卫不通过:期望 seq=' + expectSeq + ',当前 seq=' + now.seq
1787
+ + '——画面已经变了(' + (now.seq - expectSeq) + ' 帧),不发输入。重看再点。',
1788
+ detail: { expected: { seq: expectSeq }, actual: { seq: now.seq } } }
1789
+ }
1790
+ if (expectFrame !== null) {
1791
+ const nowHash = hashStr(now.hash)
1792
+ if (nowHash !== expectFrame) {
1793
+ return { ok: false, error: '帧身份守卫不通过:期望帧 hash 与当前帧不符(期望 ' + expectFrame.slice(0, 20)
1794
+ + '… 当前 ' + nowHash.slice(0, 20) + '…,seq=' + now.seq + ')——画面已经变了,不发输入。',
1795
+ detail: { expected: { frameHash: expectFrame }, actual: { seq: now.seq, frameHash: nowHash } } }
1796
+ }
1797
+ }
1798
+ return { ok: true }
1799
+ }
1800
+
1262
1801
  async function cmdInput(args) {
1263
1802
  if (!S.ctrl) return { ok: false, error: '未连接设备(先 maa_connect)' }
1264
1803
  const t0 = Date.now()
1265
1804
  const k = String(args.kind || 'click')
1266
1805
  const retention = {}
1267
1806
  const done = (job) => ({ ok: true, kind: k, ms: Date.now() - t0, retention })
1268
- retention.before = await captureNow('action-before')
1807
+ retention.before = await captureNow('action-before', true)
1808
+ /* 帧身份守卫:调用方说"我是在看到 seq=N 那帧之后决定要点的"——守卫核对**现在**这帧
1809
+ * 是不是同一帧。不是就不发输入(MPE validateClick 的拒点同义:画面已经变了,按旧画面
1810
+ * 算的坐标点下去就是误触)。seq 是单调计数器、expectFrame 是像素 hash——前者快(同 seq
1811
+ * 必同像素),后者跨 seq 也能核(同一画面重现)。回执里 code 让 CLI 落到 FINDINGS(3)。 */
1812
+ const chk = checkExpect(S.ring[S.ring.length - 1], args.expectSeq, args.expectFrame)
1813
+ if (!chk.ok) return { ok: false, code: 'EXPECT_FAILED', kind: k, error: chk.error, retention, ...chk.detail }
1269
1814
  let job = null
1270
1815
  if (k === 'click') {
1271
1816
  job = S.ctrl.post_click(Number(args.x), Number(args.y), Number(args.contact ?? 0), Number(args.pressure ?? 1))
@@ -1312,7 +1857,7 @@ async function cmdInput(args) {
1312
1857
  /* 与共享尾同一口径:**先 wait 再抓 after**。抓早了可能截在抬手落地之前,
1313
1858
  * 而手势类动作恰恰要验证"抬手之后画面变成什么样"。 */
1314
1859
  await job.wait()
1315
- retention.after = await captureNow('action-after')
1860
+ retention.after = await captureNow('action-after', true)
1316
1861
  return { ...done(null), path: pts, viaCount: via.length, stepMs: step }
1317
1862
  }
1318
1863
  job = S.ctrl.post_swipe(Number(args.x1), Number(args.y1), Number(args.x2), Number(args.y2),
@@ -1323,7 +1868,7 @@ async function cmdInput(args) {
1323
1868
  const contact = Number(args.contact ?? 0)
1324
1869
  job = S.ctrl.post_touch_move(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
1325
1870
  await job.wait()
1326
- retention.after = await captureNow('action-after')
1871
+ retention.after = await captureNow('action-after', true)
1327
1872
  return done(null)
1328
1873
  } else if (k === 'touch_down' || k === 'touch_up') {
1329
1874
  const contact = Number(args.contact ?? 0)
@@ -1331,7 +1876,7 @@ async function cmdInput(args) {
1331
1876
  ? S.ctrl.post_touch_down(contact, Number(args.x), Number(args.y), Number(args.pressure ?? 1))
1332
1877
  : S.ctrl.post_touch_up(contact)
1333
1878
  await job.wait()
1334
- retention.after = await captureNow('action-after')
1879
+ retention.after = await captureNow('action-after', true)
1335
1880
  return done(null)
1336
1881
  } else if (k === 'key') {
1337
1882
  job = S.ctrl.post_click_key(Number(args.code))
@@ -1344,7 +1889,7 @@ async function cmdInput(args) {
1344
1889
  for (const c of codes) await S.ctrl.post_key_down(c).wait()
1345
1890
  await sleep(hold)
1346
1891
  for (const c of codes.slice().reverse()) await S.ctrl.post_key_up(c).wait()
1347
- retention.after = await captureNow('action-after')
1892
+ retention.after = await captureNow('action-after', true)
1348
1893
  return done(null)
1349
1894
  } else if (k === 'scroll') {
1350
1895
  /* 滚轮:dx/dy 为格数,建议 120 的倍数(WHEEL_DELTA) */
@@ -1362,10 +1907,19 @@ async function cmdInput(args) {
1362
1907
  return { ok: false, error: 'unknown input kind: ' + k + '(click|dbclick|press|swipe|key|keys|scroll|move|text|app)' }
1363
1908
  }
1364
1909
  await job.wait()
1365
- retention.after = await captureNow('action-after')
1910
+ retention.after = await captureNow('action-after', true)
1366
1911
  return done(job)
1367
1912
  }
1368
1913
 
1914
+ /**
1915
+ * 子进程预算:用例数越多放得越宽(实测每例 ≈ 0.24s,留 5s/例的余量)。
1916
+ * 固定 90s 会让"几十个候选"在慢机器上被误杀,而误杀的表现是"识别子进程失败"——
1917
+ * 与真正的识别失败分不开。上限仍由 spawnRecoChild 自己封(120s)。
1918
+ */
1919
+ function recoChildBudget(cases) {
1920
+ return Math.max(90000, Number(cases) * 5000)
1921
+ }
1922
+
1369
1923
  /* 识别单测:子进程隔离执行(beta 绑定的 post_recognition 在无效模板上会原生崩溃,
1370
1924
  * 子进程炸掉只损失一次测试,绝不让宿主陪葬)。 */
1371
1925
  function spawnRecoChild(payload, timeoutMs) {
@@ -1406,12 +1960,19 @@ function stripDetail(d) {
1406
1960
  * 专测 target 框语义(框中心/偏移)与组合动作。识别输入 = 抓帧时的当前画面原图
1407
1961
  * (也是 action-before 边界锚点),动作执行后的画面另抓 action-after——效果可复核。
1408
1962
  */
1409
- async function recoActTest(args, node) {
1410
- const res = await ensureResource(String(args.resourceDir))
1963
+ async function recoActTest(args, node, dirs) {
1964
+ /* 有任务在飞时不换资源:act 会改写 tasker.resource,等于把正在跑的任务的资源换掉。
1965
+ * 这条路径本来也不该在 run 期间用(要真机动作就等任务结束)。 */
1966
+ if (S.runActive) return { ok: false, error: '有任务正在运行:先 maa_run_stop 再跑 --act(改资源会打断在跑的任务)' }
1967
+ /* 资源链与识别路径同一份:节点里的模板/被引用子节点可能只在 overlay 段里。
1968
+ * 同样每次新建,不与会话里任何资源共享(act 会注册/注销 custom action,复用更不可取)。 */
1969
+ const res = await buildResource(dirs)
1411
1970
  const tasker = ensureTasker()
1412
1971
  tasker.resource = res
1413
1972
  tasker.controller = S.ctrl
1414
- const before = await captureNow('action-before')
1973
+ /* 先让 tasker 指向新对象,再销毁上一份(见 adoptRunResource 的调用前提) */
1974
+ adoptRunResource(res)
1975
+ const before = await captureNow('action-before', true)
1415
1976
  if (!before.ok) return { ok: false, error: '识别输入抓帧失败:' + before.error }
1416
1977
  const imgBuf = S.l0.roll[S.l0.roll.length - 1].png
1417
1978
  const image = imgBuf.buffer.slice(imgBuf.byteOffset, imgBuf.byteOffset + imgBuf.byteLength)
@@ -1447,13 +2008,17 @@ async function recoActTest(args, node) {
1447
2008
  } finally {
1448
2009
  try { res.unregister_custom_action('@reco/act') } catch (e) { /* ignore */ }
1449
2010
  }
1450
- const after = await captureNow('action-after')
2011
+ const after = await captureNow('action-after', true)
1451
2012
  if (!out) return { ok: false, error: '动作探针未被执行(custom action 回调未触发)' }
1452
2013
  const rec = node && node.recognition
1453
2014
  const shownType = rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec ?? 'DirectHit')
1454
2015
  return {
1455
2016
  ok: out.ok,
1456
2017
  type: shownType,
2018
+ /* 与识别路径同一口径:这次用的是整条资源链(含 attach 段),layer=resource 表示这里没有
2019
+ * interface 的四级 pipeline_override(run --project 才会叠) */
2020
+ resource: { dirs },
2021
+ layer: 'resource',
1457
2022
  meta: { seq: before.seq, w: before.w, h: before.h, t: before.t },
1458
2023
  ...(out.error !== undefined ? { error: out.error } : {}),
1459
2024
  ...(out.stage ? { stage: out.stage } : {}),
@@ -1465,14 +2030,27 @@ async function recoActTest(args, node) {
1465
2030
  }
1466
2031
 
1467
2032
  async function cmdRecoTest(args) {
2033
+ /* 资源链:resourceDirs[] 是 interface.json 的 resource.path[](+ 控制器 attach_resource_path)
2034
+ * 按声明顺序叠加的结果;resourceDir 是单包写法。只有单包时测的就不是项目里那个节点——
2035
+ * overlay 段的节点改写、只存在于 overlay 的模板、被 And/Or 按名引用的子节点全会缺。 */
2036
+ const chain = Array.isArray(args.resourceDirs) ? args.resourceDirs.map((d) => String(d)).filter((d) => d) : []
2037
+ const dirs = chain.length ? chain : (args.resourceDir ? [String(args.resourceDir)] : [])
2038
+ if (!dirs.length) return { ok: false, error: '缺少资源:给 resourceDirs[](整链)或 resourceDir(单包)' }
2039
+ const nodeName = typeof args.nodeName === 'string' && args.nodeName !== '' ? args.nodeName : null
2040
+ if (nodeName && args.node && typeof args.node === 'object' && !Array.isArray(args.node)) {
2041
+ return { ok: false, error: 'node 与 nodeName 互斥:一次只测一个节点来源' }
2042
+ }
1468
2043
  /* --act:节点级单测(识别拿框 → 真机执行动作半),在 daemon 进程内跑(要真控制器) */
1469
2044
  const actNode = args.act === true && args.node && typeof args.node === 'object' && !Array.isArray(args.node) ? args.node : null
1470
2045
  if (args.act === true) {
2046
+ if (nodeName) return { ok: false, error: '--act 不接受 nodeName:按资源原名跑的是识别判据,动作半要真机桥接(那是 run)' }
1471
2047
  if (!actNode) return { ok: false, error: '--act 需要同时给 --node(整节点 JSON,含 action)' }
1472
2048
  if (!S.ctrl) return { ok: false, error: '--act 要在真机上执行动作:需要已连接设备(REPL 或 --project/--kind)' }
1473
- return await recoActTest(args, actNode)
2049
+ return await recoActTest(args, actNode, dirs)
1474
2050
  }
1475
- await ensureResource(args.resourceDir) // 校验资源可加载(子进程会重新加载)
2051
+ /* 资源链可加载性先验已挪进 reco_child(同一次加载里逐段点名失败段):原先这里
2052
+ * probeResourceChain 先加载一遍、子进程再加载一遍,识别一慢就是双倍预算,
2053
+ * 且永远是客户端先到点杀进程(设计审查 P0-1)。 */
1476
2054
  /* 图像来源三条:库内留存帧(kfSource:控制器分辨率原图、**可无设备**)/ 显式文件 / 会话缓冲帧。
1477
2055
  * daemon 自己写的那份临时 PNG **用完即删**——不删就是每跑一次漏一个(CLI 侧的截帧泄漏是同一类,
1478
2056
  * 现场实测攒过 42 张;这条 `--seq` 路径同理,只是本机还没被触发过)。 */
@@ -1488,7 +2066,10 @@ async function cmdRecoTest(args) {
1488
2066
  meta = { source: kf.source.id, sha256: kf.source.sha256, w: kf.dec.w, h: kf.dec.h, ctrlW: kf.dec.w, ctrlH: kf.dec.h }
1489
2067
  } else if (args.image) {
1490
2068
  imageFile = String(args.image)
1491
- meta = { source: args.image }
2069
+ /* 补 w/h:没有它,调用方无法判断 roi 用的是哪个空间(信息补齐,不改取图行为) */
2070
+ let size = null
2071
+ try { size = pngHeaderSize(fs.readFileSync(imageFile)) } catch (e) { /* 读不到留给子进程报错 */ }
2072
+ meta = { source: args.image, ...(size ? { w: size.w, h: size.h } : {}) }
1492
2073
  } else {
1493
2074
  const fr = args.seq !== undefined && args.seq !== null
1494
2075
  ? S.ring.find((r) => r.seq === Number(args.seq))
@@ -1507,8 +2088,32 @@ async function cmdRecoTest(args) {
1507
2088
  }
1508
2089
  const baseParam = args.param && typeof args.param === 'object' ? args.param : {}
1509
2090
  const sweep = args.sweep && typeof args.sweep === 'object' ? args.sweep : null
2091
+ /* --candidates:候选集合逐例换 roi(roi-compare 走的正是这条)。
2092
+ * 与 sweep 像但**不变量不同**:sweep 是"同键换值",候选是"逐例换 roi"——
2093
+ * 折叠进同一个 cases、走同一次 spawnRecoChild,不新开第二条子进程通道。 */
2094
+ const candidates = Array.isArray(args.candidates) ? args.candidates : null
2095
+ if (candidates && sweep && typeof sweep.min === 'number') {
2096
+ return { ok: false, error: 'candidates 与 sweep 互斥:一次识别单测只跑一种候选来源' }
2097
+ }
2098
+ if (candidates && !candidates.length) {
2099
+ return { ok: false, error: 'candidates 是空数组:至少给一个 [x,y,w,h] 候选' }
2100
+ }
2101
+ /* node / nodeName 与候选来源互斥:整条节点路径(dump + 节点自己的 recognition/param)
2102
+ * 已经是一次完整的"跑这个节点",cases 根本没有位置塞——同给会静默丢掉候选集合,
2103
+ * 调用方还以为 roi-compare 的逐例换 roi 生效了(P1-#5 协议面)。 */
2104
+ if ((node || nodeName) && (candidates || (sweep && typeof sweep.min === 'number'))) {
2105
+ return { ok: false, error: 'node/nodeName 与 candidates/sweep 互斥:整条节点路径有自己完整的 param,没有逐例换值的位置' }
2106
+ }
1510
2107
  const cases = []
1511
- if (sweep && typeof sweep.min === 'number' && typeof sweep.max === 'number') {
2108
+ if (candidates) {
2109
+ for (const c of candidates) {
2110
+ const roi = Array.isArray(c) ? c.map(Number) : null
2111
+ if (!roi || roi.length !== 4 || !roi.every(Number.isFinite)) {
2112
+ return { ok: false, error: 'candidates 每项都要是 [x,y,w,h] 四个数字,收到:' + JSON.stringify(c) }
2113
+ }
2114
+ cases.push({ ...baseParam, roi })
2115
+ }
2116
+ } else if (sweep && typeof sweep.min === 'number' && typeof sweep.max === 'number') {
1512
2117
  /* step 带方向:升序 0.5→0.9 / 降序 0.9→0.5 都合法;区间为空明确报错,不静默测个空 */
1513
2118
  const step = sweep.step !== undefined && Number(sweep.step) !== 0 ? Number(sweep.step) : 1
1514
2119
  const up = step > 0
@@ -1522,25 +2127,43 @@ async function cmdRecoTest(args) {
1522
2127
  let r = null
1523
2128
  try {
1524
2129
  r = await spawnRecoChild({
1525
- resourceDir: String(args.resourceDir),
2130
+ ...(chain.length ? { resourceDirs: dirs } : { resourceDir: dirs[0] }),
1526
2131
  type: String(args.type || 'TemplateMatch'),
1527
2132
  image: imageFile,
1528
2133
  /* 面板场景:模板由调用方裁好落盘传进来(子进程走 override_image),不写进任何资源目录 */
1529
2134
  ...(args.templateImage ? { templateImage: String(args.templateImage) } : {}),
1530
- ...(node ? { node } : { cases }),
1531
- }, 90000)
2135
+ ...(node ? { node } : (nodeName ? { nodeName } : { cases })),
2136
+ }, recoChildBudget(cases.length))
1532
2137
  } finally {
1533
2138
  /* 子进程已经读完这张图(spawnRecoChild 会等到它退出),此时删除是安全的 */
1534
2139
  if (tempImage) { try { fs.rmSync(tempImage, { force: true }) } catch (e) { /* ignore */ } }
1535
2140
  }
2141
+ /* 回执带资源链与层级:layer=resource 表示这里只到"资源合并结果",
2142
+ * run --project 还会再叠 interface 的四级 pipeline_override(GUI 选项改过同一字段时两者对不上是预期) */
2143
+ const resource = { dirs }
1536
2144
  if (r && r.ok) {
1537
2145
  const rec = node && node.recognition
1538
2146
  const shownType = node
1539
2147
  ? (rec && typeof rec === 'object' ? String(rec.type ?? 'DirectHit') : String(rec))
1540
- : args.type
1541
- return { ok: true, type: shownType, meta, results: r.results }
2148
+ : String(r.type ?? args.type ?? 'TemplateMatch')
2149
+ return {
2150
+ ok: true, type: shownType, meta, results: r.results, resource, layer: 'resource',
2151
+ ...(nodeName ? { nodeName } : {}),
2152
+ ...(r.nodeDump ? { nodeDump: r.nodeDump } : {}),
2153
+ }
2154
+ }
2155
+ return {
2156
+ ok: false,
2157
+ error: (r && r.error) || 'reco 失败',
2158
+ meta, resource, layer: 'resource',
2159
+ ...(r && r.code ? { code: String(r.code) } : {}),
2160
+ /* 近邻节点名:`--node-name` 拼错时给出"是不是想跑这个",CLI/REPL 直接把它变成 hint */
2161
+ ...(r && Array.isArray(r.candidates) && r.candidates.length ? { candidates: r.candidates.map(String) } : {}),
2162
+ /* 嵌套 custom 的命中位置:让人知道是节点自己,还是 And/Or 里的子识别 */
2163
+ ...(r && Array.isArray(r.customPaths) && r.customPaths.length ? { customPaths: r.customPaths.map(String) } : {}),
2164
+ ...(nodeName ? { nodeName } : {}),
2165
+ ...(r && r.nodeDump ? { nodeDump: r.nodeDump } : {}),
1542
2166
  }
1543
- return { ok: false, error: (r && r.error) || 'reco 失败', meta }
1544
2167
  }
1545
2168
 
1546
2169
  /* ────────────────────────── 关键帧库(L0 升格 + 登记,契约 §4) ──────────────────────────
@@ -1572,6 +2195,44 @@ function kfSave(m) {
1572
2195
  fs.renameSync(tmp, file)
1573
2196
  }
1574
2197
 
2198
+ /* ─── 关键帧库写锁(P1-#8)───────────────────────────────────────────────────
2199
+ * manifest 的读-改-写此前无锁:常驻会话与一次性 `crop --keep-source` 同秒升格时,
2200
+ * 两个进程都读 m.next=N、都写 l0/000N.png,manifest 后写者赢——已发出的 kf ID
2201
+ * 指向另一份像素或 sha 校验失败。锁是文件系统可见的(跨 daemon 进程);
2202
+ * O_EXCL 拿锁(原子,不会两人同时创建成功);持有者崩了留残渣时按 mtime 过期重拿。 */
2203
+ const KF_LOCK_STALE_MS = 60_000
2204
+ function kfLockPath() { return path.join(kfRoot(), 'manifest.lock') }
2205
+ function kfLockAcquire() {
2206
+ const p = kfLockPath()
2207
+ fs.mkdirSync(kfRoot(), { recursive: true })
2208
+ const deadline = Date.now() + 30_000
2209
+ while (Date.now() < deadline) {
2210
+ try {
2211
+ const fd = fs.openSync(p, 'wx') /* 'wx' = O_EXCL:存在即抛,原子拿锁 */
2212
+ fs.closeSync(fd)
2213
+ return true
2214
+ } catch (e) {
2215
+ if (e.code !== 'EEXIST') return false
2216
+ /* 锁在:看是不是崩溃残渣(mtime 太老 = 持有者死了没放) */
2217
+ try {
2218
+ const age = Date.now() - fs.statSync(p).mtimeMs
2219
+ if (age > KF_LOCK_STALE_MS) {
2220
+ try { fs.rmSync(p, { force: true }) } catch (e2) { /* ignore */ }
2221
+ continue /* 删掉残渣立即重试拿锁 */
2222
+ }
2223
+ } catch (e2) { /* stat 失败当新锁等 */ }
2224
+ /* 新锁:等持有者放。帧库写者并发本来就极低(常驻 + 一次性偶发),
2225
+ * 单次升格毫秒级——40ms 空转一拍就够。 */
2226
+ const t0 = Date.now()
2227
+ while (Date.now() - t0 < 40) { /* spin */ }
2228
+ }
2229
+ }
2230
+ return false
2231
+ }
2232
+ function kfLockRelease() {
2233
+ try { fs.rmSync(kfLockPath(), { force: true }) } catch (e) { /* ignore */ }
2234
+ }
2235
+
1575
2236
  /** 库目录现有 L0 字节(按 manifest 记录的文件实测;丢失文件计 0——磁盘占用只算真实存在的) */
1576
2237
  function kfUsageBytes(m) {
1577
2238
  let total = 0
@@ -1581,85 +2242,113 @@ function kfUsageBytes(m) {
1581
2242
  return total
1582
2243
  }
1583
2244
 
1584
- async function cmdKfPromote(args) {
1585
- /* 固定捕获身份:latest 在接受请求时解析成具体 seq;已淘汰则失败,绝不改取更新帧冒充 */
1586
- let seq = null
1587
- if (args.seq !== undefined && args.seq !== null) {
1588
- seq = Number(args.seq)
1589
- if (!Number.isFinite(seq)) return { ok: false, error: 'seq 必须是数字' }
1590
- } else if (args.latest === true) {
1591
- seq = S.seq
1592
- } else {
1593
- return { ok: false, error: '给 seq(数字)或 latest:true 指定要升格的捕获' }
1594
- }
1595
- if (seq <= 0) return { ok: false, error: '本会话尚无捕获(seq=0),无可升格' }
2245
+ async function cmdKfPromoteOne(seq, note) {
2246
+ /* 单个 seq 升格(供 cmdKfPromote 与批量模式共用)。返回 { ok, id?, error?, ... } */
2247
+ if (seq <= 0) return { ok: false, error: '本会话尚无捕获(seq=0),无可升格', seq }
1596
2248
  const ent = S.l0.anchor.find((x) => x.seq === seq) ?? S.l0.roll.find((x) => x.seq === seq)
1597
2249
  if (!ent) {
1598
2250
  const range = (l) => (l.length ? l[0].seq + '..' + l[l.length - 1].seq : '空')
1599
2251
  return {
1600
- ok: false,
2252
+ ok: false, seq,
1601
2253
  error: 'L0 已淘汰:seq=' + seq + '(滚动区 ' + range(S.l0.roll) + ',锚区 ' + range(S.l0.anchor) + ')。' +
1602
- 'L1 参考帧可用 frame_get 导出,但按契约不作 state 证据本体',
2254
+ '可降级导出参考帧:frame_get ' + seq + ' --out <file>(L1 参考帧,按契约不作 state 证据本体)',
1603
2255
  }
1604
2256
  }
2257
+ /* manifest 读-改-写外面加文件锁(P1-#8):常驻会话与一次性 crop --keep-source
2258
+ * 同秒升格时,两个进程不能各读各的 m.next=N、各写各的 l0/000N.png 后让 manifest
2259
+ * 后写者赢——那会让一个已发出的 kf ID 指向另一份像素。拿锁后重读 manifest,
2260
+ * 整个 RMW 都在临界区。 */
2261
+ if (!kfLockAcquire()) {
2262
+ return { ok: false, seq, error: '关键帧库写锁拿不到(30s):另一进程可能在升格/崩溃残留——稍候重试,或查 ' + kfLockPath() }
2263
+ }
1605
2264
  let m
1606
- try { m = kfOpen() } catch (e) { return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e) } }
2265
+ try { m = kfOpen() } catch (e) { kfLockRelease(); return { ok: false, error: '关键帧库不可用:' + String(e && e.message || e), seq } }
1607
2266
  const sess = S.session || {}
1608
2267
  const sessionRef = { daemon: S.daemonId, gen: S.connGen, kind: sess.kind ?? null, target: sess.target ?? null }
1609
2268
  const sha = createHash('sha256').update(ent.png).digest('hex')
1610
- /* 幂等:同一捕获(daemon + 代次 + seq)重试返回原对象;内容不符报冲突,不任选一份 */
1611
- const dup = m.frames.find((f) => f.session && f.session.daemon === sessionRef.daemon &&
1612
- Number(f.session.gen) === sessionRef.gen && Number(f.captureSeq) === seq)
1613
- if (dup) {
1614
- if (dup.sha256 === sha) return { ok: true, idempotent: true, id: dup.id, record: dup, path: kfAbsPath(dup.file), sha256: sha }
1615
- return { ok: false, error: '同一捕获已有内容不同的记录(' + dup.id + '):冲突,拒绝改写旧 ID 的含义' }
1616
- }
1617
- /* 磁盘配额(契约 §4.1):幂等重试在上面已返回,走到这里的都是新增字节。
1618
- * 满额拒绝并报告——已升格对象不后台清理,清理由用户决定。 */
1619
- if (S.kfQuota > 0) {
1620
- const used = kfUsageBytes(m)
1621
- const mb = (v) => (v / 1048576).toFixed(1) + 'MB'
1622
- if (used + ent.png.length > S.kfQuota) {
1623
- return {
1624
- ok: false,
1625
- error: '关键帧库配额不足:已用 ' + mb(used) + ' + 新增 ' + mb(ent.png.length) +
1626
- ' 超过配额 ' + mb(S.kfQuota) + '(' + m.frames.length + ' 帧留存于 ' + kfRoot() + ')。' +
1627
- '不会自动删除已升格帧——清理后重试,或调大配额(init kfQuotaBytes / MAAFW_KF_QUOTA_BYTES)',
2269
+ try {
2270
+ /* 幂等:同一捕获(daemon + 代次 + seq)重试返回原对象;内容不符报冲突,不任选一份 */
2271
+ const dup = m.frames.find((f) => f.session && f.session.daemon === sessionRef.daemon &&
2272
+ Number(f.session.gen) === sessionRef.gen && Number(f.captureSeq) === seq)
2273
+ if (dup) {
2274
+ if (dup.sha256 === sha) return { ok: true, idempotent: true, id: dup.id, record: dup, path: kfAbsPath(dup.file), sha256: sha, seq }
2275
+ return { ok: false, seq, error: '同一捕获已有内容不同的记录(' + dup.id + '):冲突,拒绝改写旧 ID 的含义' }
2276
+ }
2277
+ /* 磁盘配额(契约 §4.1):幂等重试在上面已返回,走到这里的都是新增字节。
2278
+ * 满额拒绝并报告——已升格对象不后台清理,清理由用户决定。 */
2279
+ if (S.kfQuota > 0) {
2280
+ const used = kfUsageBytes(m)
2281
+ const mb = (v) => (v / 1048576).toFixed(1) + 'MB'
2282
+ if (used + ent.png.length > S.kfQuota) {
2283
+ return {
2284
+ ok: false, seq,
2285
+ error: '关键帧库配额不足:已用 ' + mb(used) + ' + 新增 ' + mb(ent.png.length) +
2286
+ ' 超过配额 ' + mb(S.kfQuota) + '(' + m.frames.length + ' 帧留存于 ' + kfRoot() + ')。' +
2287
+ '不会自动删除已升格帧——清理后重试,或调大配额(init kfQuotaBytes / MAAFW_KF_QUOTA_BYTES)',
2288
+ }
1628
2289
  }
1629
2290
  }
2291
+ const num = Number(m.next) || m.frames.length + 1
2292
+ const id = 'kf:' + m.libraryId + ':' + String(num).padStart(4, '0')
2293
+ const fileRel = 'l0/' + String(num).padStart(4, '0') + '.png'
2294
+ const abs = kfAbsPath(fileRel)
2295
+ try {
2296
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
2297
+ const tmp = abs + '.tmp'
2298
+ fs.writeFileSync(tmp, ent.png)
2299
+ fs.renameSync(tmp, abs)
2300
+ } catch (e) {
2301
+ return { ok: false, seq, error: 'L0 文件写入失败:' + String(e && e.message || e) }
2302
+ }
2303
+ /* 小图信息只取环内同 seq 帧;已淘汰则明确为空,不借用最新帧数据(契约 §4) */
2304
+ const small = S.ring.find((r) => r.seq === seq)
2305
+ const rec = {
2306
+ id, file: fileRel, sha256: sha,
2307
+ session: sessionRef,
2308
+ capturedAt: new Date(ent.t).toISOString(),
2309
+ captureSeq: seq,
2310
+ ctrlW: ent.w, ctrlH: ent.h,
2311
+ smallW: small ? small.w : null, smallH: small ? small.h : null,
2312
+ scale: small && ent.w ? Math.round((small.w / ent.w) * 1000) / 1000 : null,
2313
+ source: ent.source || 'explicit',
2314
+ note: note != null ? String(note) : null,
2315
+ }
2316
+ m.frames.push(rec)
2317
+ m.next = num + 1
2318
+ try { kfSave(m) } catch (e) {
2319
+ try { fs.rmSync(abs, { force: true }) } catch (e2) { /* ignore */ }
2320
+ return { ok: false, seq, error: 'manifest 写入失败(L0 文件已回收,未发布引用):' + String(e && e.message || e) }
2321
+ }
2322
+ return { ok: true, id, path: abs, sha256: sha, retained: 'promoted', record: rec, seq }
2323
+ } finally {
2324
+ kfLockRelease()
1630
2325
  }
1631
- const num = Number(m.next) || m.frames.length + 1
1632
- const id = 'kf:' + m.libraryId + ':' + String(num).padStart(4, '0')
1633
- const fileRel = 'l0/' + String(num).padStart(4, '0') + '.png'
1634
- const abs = kfAbsPath(fileRel)
1635
- try {
1636
- fs.mkdirSync(path.dirname(abs), { recursive: true })
1637
- const tmp = abs + '.tmp'
1638
- fs.writeFileSync(tmp, ent.png)
1639
- fs.renameSync(tmp, abs)
1640
- } catch (e) {
1641
- return { ok: false, error: 'L0 文件写入失败:' + String(e && e.message || e) }
1642
- }
1643
- /* 小图信息只取环内同 seq 帧;已淘汰则明确为空,不借用最新帧数据(契约 §4) */
1644
- const small = S.ring.find((r) => r.seq === seq)
1645
- const rec = {
1646
- id, file: fileRel, sha256: sha,
1647
- session: sessionRef,
1648
- capturedAt: new Date(ent.t).toISOString(),
1649
- captureSeq: seq,
1650
- ctrlW: ent.w, ctrlH: ent.h,
1651
- smallW: small ? small.w : null, smallH: small ? small.h : null,
1652
- scale: small && ent.w ? Math.round((small.w / ent.w) * 1000) / 1000 : null,
1653
- source: ent.source || 'explicit',
1654
- note: args.note != null ? String(args.note) : null,
2326
+ }
2327
+
2328
+ async function cmdKfPromote(args) {
2329
+ /* 批量:args.seqs 为 seq 数组(CLI 的 "3909 3910" 或 "a..b" 已解析好)。
2330
+ * 逐条走 cmdKfPromoteOne,各自的成功/淘汰分开报告,不因一条失败中断整批。 */
2331
+ if (Array.isArray(args.seqs) && args.seqs.length > 0) {
2332
+ const results = []
2333
+ for (const s of args.seqs) {
2334
+ results.push(await cmdKfPromoteOne(Number(s), args.note))
2335
+ }
2336
+ const ok = results.every((r) => r.ok)
2337
+ const promoted = results.filter((r) => r.ok)
2338
+ const failed = results.filter((r) => !r.ok)
2339
+ return { ok, results, promoted: promoted.length, failed: failed.length }
1655
2340
  }
1656
- m.frames.push(rec)
1657
- m.next = num + 1
1658
- try { kfSave(m) } catch (e) {
1659
- try { fs.rmSync(abs, { force: true }) } catch (e2) { /* ignore */ }
1660
- return { ok: false, error: 'manifest 写入失败(L0 文件已回收,未发布引用):' + String(e && e.message || e) }
2341
+ /* 固定捕获身份:latest 在接受请求时解析成具体 seq;已淘汰则失败,绝不改取更新帧冒充 */
2342
+ let seq = null
2343
+ if (args.seq !== undefined && args.seq !== null) {
2344
+ seq = Number(args.seq)
2345
+ if (!Number.isFinite(seq)) return { ok: false, error: 'seq 必须是数字' }
2346
+ } else if (args.latest === true) {
2347
+ seq = S.seq
2348
+ } else {
2349
+ return { ok: false, error: '给 seq(数字)、latest:true,或 seqs 数组(批量)指定要升格的捕获' }
1661
2350
  }
1662
- return { ok: true, id, path: abs, sha256: sha, retained: 'promoted', record: rec }
2351
+ return cmdKfPromoteOne(seq, args.note)
1663
2352
  }
1664
2353
 
1665
2354
  function cmdL0Status() {
@@ -1863,20 +2552,39 @@ function buildL2Provenance(o) {
1863
2552
  level: 'L2',
1864
2553
  derivedFrom,
1865
2554
  transform: { op: 'crop', resize: false, scale: 1, loose: o.loose, crop: o.box, snapped: o.snapped },
1866
- selfMatch: { score: o.score, positionOk: o.positionOk, box: o.selfMatchBox ?? null, tries: o.tries },
2555
+ /* annotate --emit-crops 裁的是 SoM 候选,不走自匹配验证——selfMatch 如实为 null,
2556
+ * 不伪造"没测过的匹配度"(契约 §5 的变换与来源照样全)。 */
2557
+ selfMatch: o.selfMatch === null ? null : { score: o.score, positionOk: o.positionOk, box: o.selfMatchBox ?? null, tries: o.tries },
1867
2558
  cross: o.cross ?? null,
1868
2559
  output: { w: o.w, h: o.h },
1869
2560
  createdAt: o.createdAt,
1870
- tool: 'maafw-live crop',
2561
+ tool: o.tool ?? 'maafw-live crop',
1871
2562
  }
1872
2563
  }
1873
2564
 
1874
- /** 缺省落点:daemon 的 run 目录下 `out/`(一次性命令给的是临时目录)——**不写调用方的 cwd**。
1875
- * 调用方的 cwd 通常就是项目仓库:一次 `frame_get` / `annotate` 就把产物丢进仓库。
2565
+ /** 缺省落点 out/ 的保留上限(只进不出的产物,不让它无声膨胀) */
2566
+ const OUT_KEEP = 50
2567
+ /** out/ 目录保留修剪:按 mtime 留最新 OUT_KEEP 个 .png。与 client 侧 src/runtime/prune.ts 同逻辑的内联
2568
+ * 副本——framed.mjs 自包含、不能 import lib。失败不抛:清理从来不是任务结论。 */
2569
+ function pruneOutDir(dir) {
2570
+ try {
2571
+ const names = fs.readdirSync(dir)
2572
+ .filter((n) => n.endsWith('.png'))
2573
+ .map((n) => ({ n, t: fs.statSync(path.join(dir, n)).mtimeMs }))
2574
+ .sort((a, b) => b.t - a.t)
2575
+ for (const f of names.slice(OUT_KEEP)) {
2576
+ try { fs.unlinkSync(path.join(dir, f.n)) } catch { /* 单个删不掉就留 */ }
2577
+ }
2578
+ } catch { /* 目录不存在/读不了:没有要清的 */ }
2579
+ }
2580
+
2581
+ /** 缺省落点:daemon 的 run 目录下 `out/`(缺省 runDir 是 `~/.maafw-live`,不是临时目录)——
2582
+ * **不写调用方的 cwd**。调用方的 cwd 通常就是项目仓库:一次 `frame_get` / `annotate` 就把产物丢进仓库。
1876
2583
  * 实测踩过两次(`maa_frame_*.png` 十张进了 maafw-live 仓、`maa_som_*.png` 进了同一个根目录),
1877
2584
  * 而这几个命令本质是"看/量",产物只是顺便落盘——落哪儿由 `out` 说话,不落调用方家里。 */
1878
2585
  function defaultOutFile(runDir, prefix, ts = Date.now()) {
1879
2586
  const dir = path.join(runDir || os.tmpdir(), 'out')
2587
+ pruneOutDir(dir) /* 只在缺省落点修剪——显式 out 是用户指定的位置,不动它 */
1880
2588
  return { dir, file: path.join(dir, prefix + '-' + ts + '.png') }
1881
2589
  }
1882
2590
 
@@ -1993,12 +2701,19 @@ async function cmdTplCrop(args) {
1993
2701
  const r = await spawnRecoChild({
1994
2702
  resourceDir, type: 'TemplateMatch', image: frameFile, templateImage: candFile, cases: [{}],
1995
2703
  }, 60000)
1996
- if (!r || !r.ok) return { score: -1, posOk: false, at: null }
2704
+ if (!r || !r.ok) {
2705
+ /* 资源链加载不出来(reco 把这条明确报成 `RESOURCE_LOAD`)与"识别跑了但没命中"是两回事:
2706
+ * 前者是资源问题、处置是去修资源,后者才是去换框。别让它落进"低纹理/不独特"。 */
2707
+ if (r && (r.code === 'RESOURCE_LOAD' || /资源链加载失败/.test(String(r.error ?? '')))) {
2708
+ selfMatchDead = selfMatchDead ?? ('资源链加载不出来——' + String(r.error ?? '').trim())
2709
+ }
2710
+ return { score: -1, posOk: false, at: null }
2711
+ }
1997
2712
  /* 子进程回 ok 但**一个 case 结果都没有**:识别压根没跑起来(实测最常见的原因是资源目录里没有可加载的
1998
2713
  * pipeline,reco 会安静地回 `results: []`)。这与"识别跑了但没命中"是两回事——后者仍会有 1 条结果
1999
2714
  * (hit:false)。记下来,别让它被下面的判据翻译成"低纹理/不独特"。 */
2000
2715
  if (!Array.isArray(r.results) || r.results.length === 0) {
2001
- selfMatchDead = true
2716
+ selfMatchDead = selfMatchDead ?? '识别子进程一个结果都没回'
2002
2717
  return { score: -1, posOk: false, at: null, noResults: true }
2003
2718
  }
2004
2719
  const det = r.results && r.results[0] ? r.results[0].detail : null
@@ -2008,8 +2723,9 @@ async function cmdTplCrop(args) {
2008
2723
  return { score: Number(best.score), posOk: overlapOk(at, [b.x, b.y, b.w, b.h]), at }
2009
2724
  }
2010
2725
  const obj = (s) => (s.posOk ? 1000 : 0) + s.score
2011
- /** 自匹配有没有跑起来(子进程回空结果 = 没跑起来)。跑到这里说明资源加载不出识别节点。 */
2012
- let selfMatchDead = false
2726
+ /** 自匹配有没有跑起来:null = 跑起来了;字符串 = 没跑起来的具体原因(子进程回空结果 / 资源链加载不出来)。
2727
+ * 跑到这里还能不跑起来,说明资源那边加载不出识别节点。 */
2728
+ let selfMatchDead = null
2013
2729
  const clampBox = (b) => ({
2014
2730
  x: Math.max(0, b.x), y: Math.max(0, b.y),
2015
2731
  w: Math.min(b.w, dec.w - Math.max(0, b.x)), h: Math.min(b.h, dec.h - Math.max(0, b.y)),
@@ -2141,8 +2857,8 @@ async function cmdTplCrop(args) {
2141
2857
  const out = args.out || defaultOutFile(S.runDir, 'tpl').file
2142
2858
  fs.mkdirSync(path.dirname(out), { recursive: true })
2143
2859
  fs.writeFileSync(out, pngEncodeRGB(final.data, final.w, final.h))
2144
- const warn = selfMatchDead
2145
- ? '自匹配没跑起来:识别子进程一个结果都没回(实测最常见原因是资源目录里没有可加载的 pipeline)。' +
2860
+ const warn = selfMatchDead !== null
2861
+ ? '自匹配没跑起来:' + selfMatchDead + '(实测最常见原因是资源目录里没有可加载的 pipeline)。' +
2146
2862
  '这不是"模板不独特"——先写一个 pipeline 节点,或用 --resource-dir 指向真实资源,再重跑这次的框;' +
2147
2863
  '在此之前它**未经自匹配验证**,不要拿它去写 pipeline'
2148
2864
  : !best.posOk
@@ -2693,6 +3409,34 @@ async function cmdAnnotate(args) {
2693
3409
  drawRect(buf, draw.w, draw.h, [rb[0] + 1, rb[1] + 1, Math.max(2, rb[2] - 2), Math.max(2, rb[3] - 2)], [255, 255, 255])
2694
3410
  }
2695
3411
  fs.writeFileSync(out, pngEncodeRGB(buf, draw.w, draw.h))
3412
+
3413
+ /* --emit-crops <dir>:每个进表候选从 work 图(检测面:L0 全分辨率 or kf 原图 or 降级小图)
3414
+ * 切一张 PNG + 写 .prov.json 出处。box 是 work 坐标系——k 库帧与工作图同空间,x/y 直接裁。
3415
+ * selfMatch 为 null:这是"候选一键出模板"的批量出图,未经自匹配验证;要自匹配(选号)
3416
+ * 走正式 crop。 */
3417
+ const emitted = []
3418
+ const emitDir = typeof args.emitCrops === 'string' && args.emitCrops ? String(args.emitCrops) : null
3419
+ if (emitDir) {
3420
+ try {
3421
+ fs.mkdirSync(emitDir, { recursive: true })
3422
+ cands.forEach((c, i) => {
3423
+ const id = i + 1
3424
+ const b = { x: Math.round(c.box[0]), y: Math.round(c.box[1]), w: Math.round(c.box[2]), h: Math.round(c.box[3]) }
3425
+ const crop = cropRgb(work.rgb, work.w, work.h, b)
3426
+ const f = path.join(emitDir, 'cand-' + String(id).padStart(2, '0') + '.png')
3427
+ fs.writeFileSync(f, pngEncodeRGB(crop.data, crop.w, crop.h))
3428
+ const prov = buildL2Provenance({
3429
+ source, loose: b, box: [b.x, b.y, b.w, b.h], snapped: false, selfMatch: null,
3430
+ cross: null, w: crop.w, h: crop.h, createdAt: new Date().toISOString(),
3431
+ tool: 'maafw-live annotate --emit-crops',
3432
+ })
3433
+ const pf = f + '.prov.json'
3434
+ fs.writeFileSync(pf, JSON.stringify(prov, null, 2) + '\n')
3435
+ emitted.push({ id, path: f, provPath: pf, w: crop.w, h: crop.h, source: c.source })
3436
+ })
3437
+ } catch (e) { /* 逐条写失败不中断 annotate:emit 是可选增值,不是主结果 */ }
3438
+ }
3439
+
2696
3440
  const ctrlW = work.fw > 0 ? work.fw : work.w
2697
3441
  const ctrlH = work.fh > 0 ? work.fh : work.h
2698
3442
  /* 候选框的面积占比(控制器空间,与画面同尺度):调用方可据此再排一次序——
@@ -2728,6 +3472,7 @@ async function cmdAnnotate(args) {
2728
3472
  ...(args.kfSource ? { sourcesUnavailable: { diff: '库帧路径无会话事件:diff 源不可用(不拿别的区域顶替)' } } : {}),
2729
3473
  candidates: cands.map((c, i) => ({ id: i + 1, source: c.source, box: c.box, ctrl: toCtrl(c.box), ...feat(c), ...(c.extra ?? {}) })),
2730
3474
  ...(cands.length === 0 ? { warn: '无候选:画面可能静止且低对比;换帧或走点选路径' } : {}),
3475
+ ...(emitted.length ? { emitted: { dir: emitDir, count: emitted.length, files: emitted } } : {}),
2731
3476
  ...(pool.length > LIMIT
2732
3477
  ? { warn: '候选被上限截断:本区域池 ' + pool.length + ' 条,进表 ' + cands.length + ' 条。'
2733
3478
  + (roiApplied ? '用更小的 roi 继续收窄' : '给 roi 把搜索面收小(这是正解),或给 somLimit 放开上限') }
@@ -2787,16 +3532,33 @@ async function cmdCalibrate(args) {
2787
3532
 
2788
3533
  /* ────────────────────────── 消息分发 ────────────────────────── */
2789
3534
  const handlers = {
3535
+ /* 活性探针:客户端调用超时后先问这个——能应答说明 daemon 事件循环没死,只是这条
3536
+ * 调用超了;只失败该次调用,不杀进程(会话里的连接 / L0 / 事件环都保得住)。
3537
+ * 必须是无状态纯应答:probe 是设备扫描、run_stop 有副作用,都不能借来探活。 */
3538
+ ping: () => ({ ok: true, t: Date.now() }),
2790
3539
  init: (a) => {
2791
3540
  if (a.runDir) {
2792
3541
  S.runDir = String(a.runDir)
2793
3542
  S.previewPath = path.join(S.runDir, 'preview.png')
2794
3543
  try { fs.mkdirSync(S.runDir, { recursive: true }) } catch (e) { /* ignore */ }
3544
+ /* action-frames 无 kf 配额:一次性 CLI 每条 click 都落 2 张,纯累积会膨胀。
3545
+ * 不动当前运行——只清 >24h 的(上一个会话的残留)。 */
3546
+ try {
3547
+ const root = path.join(S.runDir, 'action-frames')
3548
+ if (fs.existsSync(root)) {
3549
+ const cutoff = Date.now() - 24 * 3600 * 1000
3550
+ for (const f of fs.readdirSync(root)) {
3551
+ const st = fs.statSync(path.join(root, f))
3552
+ if (st.mtimeMs < cutoff) fs.unlinkSync(path.join(root, f))
3553
+ }
3554
+ }
3555
+ } catch (e) { /* ignore */ }
2795
3556
  }
2796
3557
  if (Number(a.kfQuotaBytes) > 0) S.kfQuota = Math.floor(Number(a.kfQuotaBytes))
2797
3558
  return { ok: true, previewPath: S.previewPath, daemonId: S.daemonId, framesDir: kfRoot(), kfQuota: S.kfQuota }
2798
3559
  },
2799
3560
  probe: cmdProbe,
3561
+ screencap_probe: cmdScreencapProbe,
2800
3562
  device_list: (a) => cmdDeviceList(a.kind),
2801
3563
  connect: cmdConnect,
2802
3564
  disconnect: cmdDisconnect,
@@ -2804,6 +3566,7 @@ const handlers = {
2804
3566
  stream_start: cmdStreamStart,
2805
3567
  stream_stop: cmdStreamStop,
2806
3568
  stream_status: cmdStreamStatus,
3569
+ events_list: cmdEventsList,
2807
3570
  frame_get: cmdFrameGet,
2808
3571
  frame_diff: cmdFrameDiff,
2809
3572
  wait: cmdWait,
@@ -2822,7 +3585,72 @@ const handlers = {
2822
3585
 
2823
3586
  /* 子进程模式:stdin JSON 行协议(每行一条消息)。非 --child(headless 单测 import)不挂监听。 */
2824
3587
  if (CHILD) {
3588
+ /* 兜底回收 agent 子进程:cleanupAgents 正常走 shutdown / run 重入,但 daemon 被 kill、
3589
+ * 崩溃或宿主超时硬杀时没人收——实测会留下成批孤儿 agent 子进程占着设备连接。
3590
+ * exit 里同步走 spawnSync taskkill /T + ident sweep——正常路径走 cleanupAgents
3591
+ * 的完整版,这里是"进程突然死"的防泄漏,不能因为宿主死了就把 agent 树留给系统。 */
3592
+ const killAgentChildrenSync = () => {
3593
+ const bind = S.agentBind
3594
+ if (!bind) return
3595
+ for (const c of bind.clients) {
3596
+ if (process.platform === 'win32') {
3597
+ const root = c.child && c.child.pid
3598
+ if (!root) continue
3599
+ try { spawnSync('taskkill', ['/pid', String(root), '/T', '/F'], { timeout: 8000 }) } catch (e) { /* ignore */ }
3600
+ if (c.ident) {
3601
+ const like = '%' + String(c.ident).replace(/'/g, '') + '%'
3602
+ const ps = '$self=$PID;Get-CimInstance Win32_Process|Where-Object{$_.ProcessId -ne $self -and $_.CommandLine -and $_.CommandLine -like \'' + like + '\'}|ForEach-Object{try{Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue}catch{}}'
3603
+ try { spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 15000 }) } catch (e) { /* ignore */ }
3604
+ }
3605
+ } else {
3606
+ try { process.kill(-c.child.pid, 'SIGKILL') } catch (e) { try { c.child.kill('SIGKILL') } catch (e2) { /* ignore */ } }
3607
+ }
3608
+ }
3609
+ }
3610
+ process.on('exit', killAgentChildrenSync)
3611
+ process.on('uncaughtException', (e) => {
3612
+ killAgentChildrenSync()
3613
+ /* 不吞掉异常:打印后让进程照常死,exit 里再收一次(幂等) */
3614
+ console.error('daemon 未捕获异常:' + String(e && e.stack || e))
3615
+ process.exit(2)
3616
+ })
2825
3617
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
3618
+ /* 宿主已死 = stdin 管道断开:daemon 不自杀就成孤儿(实测 --session-stop 杀的是会话服务
3619
+ * 进程,daemon 还活着、它桥接的 agent 树也活着)。收到 close 就按 shutdown 同等规格
3620
+ * 收摊:停流 → 树杀 agent → 退。兜底 10s:cleanup 卡住也不许吊着。 */
3621
+ let hostGone = false
3622
+ const onHostGone = () => {
3623
+ if (hostGone) return
3624
+ hostGone = true
3625
+ Promise.resolve()
3626
+ .then(() => cmdStreamStop())
3627
+ .then(() => cleanupAgents())
3628
+ .catch(() => { /* ignore */ })
3629
+ .then(() => process.exit(0))
3630
+ setTimeout(() => process.exit(0), 10000).unref()
3631
+ }
3632
+ rl.on('close', onHostGone)
3633
+ /* 宿主已死 ≠ rl close / stdin end:实测两条都不触发(Node readline 在管道另一侧
3634
+ * 被 kill 时不给 end)。管道破开走 stdin 的 error/close;再补一条兜底轮询:
3635
+ * stdin 不再可读(readableEnded 或 destroyed)就按宿主死了走。 */
3636
+ process.stdin.on('end', onHostGone)
3637
+ process.stdin.on('error', onHostGone)
3638
+ process.stdin.on('close', onHostGone)
3639
+ /* 兜底轮询两条路:
3640
+ * ① stdin 状态:宿主正常退出(不是被 kill)时管道还给 end/close;
3641
+ * ② 宿主进程死了:Windows 下 process.kill(pid) = TerminateProcess,管道
3642
+ * 一侧直接没,任何 stdin 事件都不来(实测 end/close/error 全不触发)。
3643
+ * ppid 存活探测是唯一可靠信号——宿主死了 1s 内 daemon 自杀收摊。 */
3644
+ const hostPid = process.ppid
3645
+ const stdinProbe = setInterval(() => {
3646
+ try {
3647
+ if (process.stdin.destroyed || process.stdin.readableEnded) { onHostGone(); return }
3648
+ if (hostPid) {
3649
+ try { process.kill(hostPid, 0) } catch (e) { onHostGone() }
3650
+ }
3651
+ } catch (e) { /* ignore */ }
3652
+ }, 500)
3653
+ stdinProbe.unref()
2826
3654
  rl.on('line', (line) => {
2827
3655
  let m = null
2828
3656
  try { m = JSON.parse(line) } catch (e) {
@@ -2843,8 +3671,10 @@ if (CHILD) {
2843
3671
  /* 纯函数导出(供 headless 验证直接断言,子进程运行中不使用)。
2844
3672
  * S 一并导出:l0EvictToBudget 等维护函数作用于模块态,单测需直改后还原。 */
2845
3673
  export const __test = {
2846
- pngDecode, pngEncodeRGB, downscale, blockAnalyze, blockHash, hashDist,
3674
+ pngDecode, pngEncodeRGB, pngHeaderSize, downscale, blockAnalyze, blockHash, hashDist,
2847
3675
  regionOfBlocks, tightenBounds, tightenBoundsByEdges, edgeMap, cropRgb, connComponents, edgeDensityBoxes,
2848
3676
  readKfCropSource, buildL2Provenance, pickBalancedCandidates, pickNodeName, readRoi,
2849
- l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, S,
3677
+ l0EvictToBudget, cmdKfPromote, cmdTplCrop, kfUsageBytes, defaultOutFile, eventFrameAnchor, S,
3678
+ pickRecoBox, pickActionBox, anchorFromCacheDedup, checkExpect, blockHash, hashStr,
3679
+ setupAgents, buildLinuxConfig,
2850
3680
  }