@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
package/README.md CHANGED
@@ -46,21 +46,25 @@ npm link # 之后可直接 maafw-live --help
46
46
  | 命令 | 说明 |
47
47
  | --- | --- |
48
48
  | `maafw-live env` | 探测环境与能力:项目根、pipeline 目录、运行时绑定、外部工具(adb / python / git) |
49
+ | `maafw-live screencap-probe` | 截图能力探测:两次 `post_screencap` 比像素与亮度,判"后台可截"(A 路前提)——用窗口把目标完全盖住后带 `--occluded` 再测 |
49
50
  | `maafw-live version` | 打印 CLI 与依赖版本 |
50
51
  | `maafw-live probe` | 自检运行时:绑定版本、adb / win32 设备发现数 |
51
52
  | `maafw-live device` | 列出可连接的设备(adb 设备与 win32 窗口) |
52
53
  | `maafw-live connect` | 连接设备(进程态;多步操作用 repl) |
53
- | `maafw-live disconnect` | 断开并销毁 Tasker / Controller |
54
+ | `maafw-live disconnect` | 断开并销毁 Tasker / Resource / Controller |
54
55
  | `maafw-live screencap` | 截一帧并落盘(同时进 L0 缓存,有捕获身份可升格) |
55
56
  | `maafw-live frame` | 帧流状态与历史帧取用(可带 ROI,按该帧捕获时尺寸换算);`--src full` 取控制器分辨率原图,降级为小图会明说并判 findings |
56
57
  | `maafw-live wait` | 状态谓词:`stable`(画面静下来)/ `change`(画面动了)——取代调用方硬睡时钟;**超时不是失败**,是观测结果(退出 3) |
57
- | `maafw-live run` | 运行 pipeline;项目模式合成 pipeline_override 四级链;`--timeout 0` 不自动停;退出码按任务级 `record.ok` |
58
+ | `maafw-live events` | 查帧流事件(stable/change/run),每条带画面锚点与 retained 状态;`--watch <ms>` 自起帧流现场采集 |
59
+ | `maafw-live run` | 运行 pipeline;项目模式合成 pipeline_override 四级链;`--timeout 0` 不自动停;退出码按任务级 `record.ok`;`--watch` 跟随日志(文件 / glob / 目录取最新一个,日更轮转不丢),`--watch-include`/`--watch-exclude` 正则筛行 |
58
60
  | `maafw-live stop` | 停止运行中的任务 |
59
- | `maafw-live click` / `swipe` / `key` / `keys` / `press` / `dbclick` / `scroll` / `move` / `text` | 输入注入(经 maafw 控制器本体;动作边界帧自动入 L0 锚区) |
60
- | `maafw-live reco` | 识别单测(子进程隔离):`--param`/`--sweep` 阈值扫描,`--node` 整节点透传(V1/V2),`--act` 识别拿框 → 真机执行动作半 |
61
+ | `maafw-live click` / `swipe` / `key` / `keys` / `press` / `dbclick` / `scroll` / `move` / `text` | 输入注入(经 maafw 控制器本体;动作边界帧自动入 L0 锚区)。`click` 可带 `--expect-seq N` 或 `--expect-frame <hash>`:**画面已变就拒发输入**(FINDINGS 退出)——"我是在看到 seq=N 那帧后决定要点的",帧变了按旧坐标点就是误触;`frame get` 回执的 `frameHash` 供取期望 hash |
62
+ | `maafw-live reco` | 识别单测(子进程隔离):`--param`/`--param-file`/`--sweep` 阈值扫描,`--node`/`--node-file` 整节点透传(V1/V2),`--node-name` 按资源原名跑(整条资源链含 attach),`--act` 识别拿框 → 真机执行动作半;给 `--image` 即离线评估,不连设备 |
63
+ | `maafw-live roi-check` | ROI 验真:外来模板比对「本位置」与「偏移半周期」候选,分不出来时明确报 AMBIGUOUS(退出 3) |
64
+ | `maafw-live roi-compare` | 候选集合比较:外来模板在 `--rois`/`--offsets`/`--scan` 生成的一批位置假设上排序,报 bestGap 与并列带 |
61
65
  | `maafw-live color` | 探色:ROI 实测均值 / HSV / 主色,选色后用 reco ColorMatch 出框 |
62
66
  | `maafw-live crop` | 模板裁剪:宽松框或点 → snap 收紧 → 原图裁剪(L0 热缓存 / `--from-kf` 库内留存帧)→ 同帧自匹配逐边精修 + 跨帧验证;产出 L2 派生图与 `<out>.prov.json` 出处 |
63
- | `maafw-live annotate` | 轻量 SoM:OCR / diff / 连通域 / 边缘密度候选区域 + 编号回画(模型选号 → ctrl 坐标);`--from-kf` 可在关键帧库留存帧上离线跑 |
67
+ | `maafw-live annotate` | 轻量 SoM:OCR / diff / 连通域 / 边缘密度候选区域 + 编号回画(模型选号 → ctrl 坐标);`--from-kf` 可在关键帧库留存帧上离线跑;`--emit-crops <dir>` 每个进表候选批量出 `cand-NN.png` + `.prov.json`(出处完整、不经自匹配;选号验证走 `crop`) |
64
68
  | `maafw-live calibrate` | 变化检测阈值校准:静止画面定噪声地板,推荐 blockThresh / changeGlobal |
65
69
  | `maafw-live timing` | 跑任务反推 timeout / wait_freezes / delay 建议:节点时长 + 动作后画面稳定时间;`--runs n`(1-10)多次采样取 P50/P95 分布 |
66
70
  | `maafw-live kf` | 关键帧:status 看 L0 缓存,promote 升格原图进本地库,list / resolve 离线解析(库帧可直接作 crop 的源) |
@@ -178,7 +182,7 @@ try {
178
182
  | 0 | 正常完成,未发现问题(run 的任务级 `record.ok=true`) |
179
183
  | 1 | 命令自身失败(未预期异常、IO 错误);**run 的任务失败**(`record.ok=false`,含超时被停止) |
180
184
  | 2 | 参数 / 用法错误 |
181
- | 3 | 跑成了,但发现了问题:run 的**调用级**失败(未连接 / 资源缺失 / 已有任务在跑)、reco 识别未命中(含 `--act` 单测失败)、crop / color / annotate / calibrate 的失败或警告、kf manifest / promote 失败、device 未发现设备、frame 取不到帧(帧流未开 / 环形缓冲为空)与**带 `roi` 时帧降级为小图**(`--src auto/full` 下 L0 已淘汰;显式 `--src ring` 与不带 `roi` 的整帧小图都是预期行为,不算)、wait **超时未成立**("没等到"是观测结果,不是命令失败)、timing 采样未跑起来(资源缺失 / 任务没跑起来 / `--runs` 次全未成功)、`version --check` 查到新版、`skill --check` 检出已装副本漂移 |
185
+ | 3 | 跑成了,但发现了问题:run 的**调用级**失败(未连接 / 资源缺失 / 已有任务在跑)、reco 识别未命中(含 `--act` 单测失败;`--sweep` / 多例里**任一例**未中就退 3,所以阈值扫描通常几乎必然 3)、crop / color / annotate / calibrate 的失败或警告、kf manifest / promote 失败、device 未发现设备、frame 取不到帧(帧流未开 / 环形缓冲为空)与**带 `roi` 时帧降级为小图**(`--src auto/full` 下 L0 已淘汰;显式 `--src ring` 与不带 `roi` 的整帧小图都是预期行为,不算)、wait **超时未成立**("没等到"是观测结果,不是命令失败)、timing 采样未跑起来(资源缺失 / 任务没跑起来 / `--runs` 次全未成功)、`version --check` 查到新版、`skill --check` 检出已装副本漂移 |
182
186
  | 4 | 前置环境缺失 |
183
187
 
184
188
  关键是 **1 与 3 的区分**:1 是「没跑成」(含任务失败),3 是「跑成了但有问题」。任务级结果看
@@ -190,8 +194,9 @@ JSON 信封的 `data.record.ok`,不是外层 `ok`——外层只代表调用
190
194
  场景的硬要求:ANSI 转义会混进 JSON(非 TTY 下自动无色)。`--no-interactive` 与 `--yes` 当前无门控——
191
195
  本工具没有交互式提问与确认步骤,照传即可,防后续版本引入交互提问挂住自动化调用。
192
196
 
193
- `--dry-run` / `--limit <n>` / `--verbose` 是**预留位、当前未实现**:传了不报错,但不改变任何命令的行为
194
- (`--dry-run` 照样落盘)——别把它们写进脚本当保证。
197
+ `--dry-run` / 全局 `--limit <n>` 未实现:传了直接退 2(`FLAG_NOT_IMPLEMENTED`)——
198
+ "接受但不生效"会让 agent 以为在试跑、其实已经落盘。`events` 有自己的 `--limit`(命令私有,生效);
199
+ `--verbose` 已接 `describeSession` 的 `note:` 建议行门控(默认压制,传了才打出建议性说明)。
195
200
 
196
201
  ## 代码结构
197
202
 
@@ -224,7 +229,10 @@ scripts/ 回归与构建辅助
224
229
  ## 回归
225
230
 
226
231
  ```bash
227
- npm test # 纯函数单测:变化检测/L0 淘汰/snap 收紧/SoM 候选/interface 四级 override 链/库配额
232
+ npm test # unit-* 单测:解析/合成/帧流/L0 淘汰/snap/SoM/interface override 链/库配额(纯 headless)
233
+ npm run test:all # unit + device + contract 三组全跑(CI 用这个)
234
+ npm run test:device # device-*:起真 daemon 连真控制器(内部 t.skip 自守卫,环境不符跳过)
235
+ npm run test:contract # contract-*:设计态契约的可执行验收(自带参考实现,落地后改测真身)
228
236
  npm run verify # 装配级回归:全部通过真实子进程断言外部契约
229
237
  node scripts/accept-kf-offline.mjs # 契约验收(离线):库读侧——同 ID 跨库、manifest 各类坏法、半写
230
238
  node scripts/accept-emulator.mjs --target 127.0.0.1:16384 # 契约验收(需模拟器在线):改分辨率旧帧 ROI、升格路径、磁盘失败注入
@@ -255,6 +263,13 @@ interface.json 的解析与控制器规划内建在 `src/interface/`(无外部
255
263
  节点级单测(reco `--act`)、模板裁剪(crop,位置优先判据 + 跨帧验证)、轻量 SoM(annotate)与
256
264
  timing 反推(节点时长 + 动作后稳定时间 → delay/timeout 建议,`--runs n` 取分布:P95 × 余量,失败 run 不计入建议)。
257
265
 
266
+ **在设计中(未实现):示范轨迹(demo-trace)**——把一次示范(人做的 / 旧脚本跑的 / 框架跑的)变成
267
+ 「操作前帧 + 手势 + 落点帧 + 屏判别式 + 派生识别条件」的有序轨迹,供 agent 翻译成计划与脚本,
268
+ 不再靠猜坐标。契约 [`docs/demo-trace-contract-v0.md`](docs/demo-trace-contract-v0.md)(含**反定位**:
269
+ 五条同时成立才有意义),离线夹具 [`test/contract-demo-trace.mjs`](test/contract-demo-trace.mjs)。采集三路按
270
+ **C(框架自报动作:旧脚本迁移 / 回归基线)→ A(自绘点击面,需截图支持后台)→ B(前台限制下的
271
+ 外部观测:`adb getevent` 优先,win32 需原生)**推进——前台限制的代价是 UI 从"实时"降级为"事后"。
272
+
258
273
  不在本包范围(各有归属):
259
274
 
260
275
  | 域 | 归属 |
@@ -16,7 +16,9 @@
16
16
  - **stderr**:同样是原生日志。
17
17
  - 不带 `--child` 时该模块无副作用,可直接 import(`__test` 导出纯函数用于无设备回归)。
18
18
 
19
- 一进程一会话:**一个 daemon 子进程 = 一个设备会话**(一个 Controller + Resource + Tasker + 帧流)。
19
+ 一进程一会话:**一个 daemon 子进程 = 一个设备会话**(一个 Controller + Tasker + 帧流)。
20
+ Resource 不是会话态:每次 `run` / `reco_test --act` 都按本次声明的资源来源新建,加载完的旧句柄
21
+ 会被销毁——同一份 Resource 上反复 `post_bundle` 是叠加语义,留着它会让下一次命令带着上一轮的 bundle 跑。
20
22
  同一台设备同一时刻只允许一个所有者;需要多设备就起多个子进程,但占用规则由调用方负责。
21
23
 
22
24
  ## 消息
@@ -25,7 +27,7 @@
25
27
  |---|---|---|
26
28
  | `{ id, cmd, ...args }` | 调用方 → daemon | 请求;`id` 由调用方自增,`-1` 可作一次性通知 |
27
29
  | `{ kind:'reply', id, ok, data }` | daemon → 调用方 | 应答成功 |
28
- | `{ kind:'reply', id, ok:false, error }` | daemon → 调用方 | 应答失败(业务失败也是这一形状,不抛异常) |
30
+ | `{ kind:'reply', id, ok:false, error }` | daemon → 调用方 | 命令在 daemon 侧**抛异常**(客户端 `call` 会 reject);**业务失败通常是外层 `ok:true` + 回执 `data.ok:false`**(各命令在回执里自述 error),消费方按 `data.ok` 判业务结果,别把外层 `ok` 当结论 |
29
31
  | `{ kind:'frame', meta, preview? }` | daemon → 调用方 | 每接受一帧的元数据;`preview` 是预览帧 PNG 路径 |
30
32
  | `{ kind:'event', ev }` | daemon → 调用方 | 画面变化/稳定事件与 Tasker 节点消息(按帧序对齐) |
31
33
  | `{ kind:'stream_error', error }` | daemon → 调用方 | 帧流自身出错(设备掉线等),流可能已停 |
@@ -41,14 +43,17 @@
41
43
 
42
44
  | cmd | 参数 | 返回要点 |
43
45
  |---|---|---|
46
+ | `ping` | — | `{ ok, t }`;无状态活性探针:客户端调用超时后先问它——能应答 = 事件循环没死、只是那条调用慢(只失败该次调用,不杀进程),无应答才按卡死硬杀重生。复用宿主做活性探测请用这条,别借 `probe`(设备扫描)或 `run_stop`(有副作用) |
44
47
  | `init` | `runDir`、`kfQuotaBytes?`(字节,>0 生效;缺省 1GiB,env `MAAFW_KF_QUOTA_BYTES` 亦可覆盖;env `MAAFW_NOTIFY_DUMP=<file>` 把框架原始通知逐行 JSON 落盘,用于诊断"记录字段到底是谁填的",默认关) | `{ ok, previewPath, daemonId, framesDir, kfQuota }`;设定预览帧落盘路径与关键帧库磁盘配额。应在 spawn 后立刻发一次——本仓客户端把它当**握手**(`client.init()`):回执里的 framesDir / daemonId / kfQuota 对宿主可见,无应答或失败会如实回报并进错误环,不再被静默丢弃 |
45
48
  | `probe` | — | `{ version, adb, win32, errors[] }`:绑定版本与设备发现数 |
46
- | `device_list` | `kind: all\|adb\|win32` | 设备数组:`{ kind, id, name, cls?, adbPath? }` |
47
- | `connect` | `kind: adb\|win32\|gamepad`,`target`,可选 `screencap`/`mouse`/`keyboard`/`gamepadType`(枚举名),可选 `shortSide`/`longSide`/`rawSize` | `{ ok, session, streamStopped? }`;`session` 含 `kind/target/name/cls/method/resolution/warns`。**会先销毁已有 Controller**——因此若上一轮在跑帧流,本次连接会**显式停掉它并回 `streamStopped:true`**,同时向订阅者推一条 `stream_stopped`(要看帧就重新 `stream_start`;旧行为是流循环静默死掉而状态仍报"在跑") |
49
+ | `screencap_probe` | `intervalMs?`(500–20000,默认 3000)、`occluded?`(用户声明"窗口被完全盖住") | `{ ok, intervalMs, first{ok,w,h,hash,mean,error}, second, changed, verdict, note, occluded }`;两次 `post_screencap` 比 `blockHash` 与亮度。`verdict`:`changing`(像素在变——`occluded:true` 时即"后台可截",A 路前提)、`static`(像素静止:目标停渲染或恰好没动)、`black`(近全黑:截图方法不兼容,换 `win32.screencap`)、`error`(`post_screencap` 抛错/空)。探针不验"是否真的遮挡"——`occluded` 如实回传用户声明(默认 `null`),判据只在遮挡态成立 |
50
+ | `device_list` | `kind: all\|adb\|win32` | 设备数组:`{ kind, id, name, cls?, adbPath? }`;只发现 adb/win32(macos/playcover/linux 没有自动发现,靠 `connect` 的 target/config 显式指定) |
51
+ | `connect` | `kind: adb\|win32\|gamepad\|playcover\|macos\|linux`(playcover/macos 仅 macOS,linux 仅 Linux),`target`(adb 地址 / win32·gamepad hwnd·窗口标题·类名 / playcover `<address>/<BundleId>`(BundleId 如 `com.playgame.app`,PI 缺省地址 `localhost:1717`)/ macos 窗口 id·标题 / linux config JSON;`device_list` 只枚举 adb/win32,macos/playcover/linux 没有自动发现,必须显式给 target/config),可选 `screencap`/`mouse`/`keyboard`/`input`/`gamepadType`/`macosTitleRegex`(枚举名;`screencap` 用于 win32/gamepad/macos,`mouse`/`keyboard` 仅 win32,`input` 仅 macos(`MacOSInputMethod` 表,缺省 `GlobalEvent`),`gamepadType` 仅 gamepad(`Xbox360`/`DualShock4`,`DS4` 映射 `DualShock4`),`macosTitleRegex` 仅 macos——PI `macos.title_regex`,daemon 侧 `MacOSController.find()` 按正则选窗)、`uuid?`/`address?`(playcover:BundleId 与服务地址,缺省 `maa.playcover` / `localhost:1717`,不给 target 也按缺省构造)、`agent?`(adb:MaaAgentBinary 路径,maa-node 可选第 6 参)、`linuxDecl?`/`linux?`(linux 声明式 config:interface.json linux{} + 用户字段,daemon 按 maa-support/MXU 规则组 JSON——方法名转数值、缺省 Wlr/Wlr、Wlr 必填 `wlr_socket_path`、`pipewire_source:Portal` 拒绝)、`config?`(linux,等价于 target 的 JSON),可选 `shortSide`/`longSide`/`rawSize`/`expand`(**display_* 互斥**:rawSize / expand / longSide / shortSide 只允许给一个,MXU DisplayOptions.validate 同款规则,多给直接拒)。<br>**gamepad 可不配图**:不给 target 且没有可选窗口时 `GamepadController` 以 `hwnd:null` 构造(仅虚拟手柄输入、不截图),给 target 没匹配上仍报错 | `{ ok, session, streamStopped? }`;`session` 含 `kind/target/name/cls/method/resolution/warns`。**会先销毁已有 Controller**——因此若上一轮在跑帧流,本次连接会**显式停掉它并回 `streamStopped:true`**,同时向订阅者推一条 `stream_stopped`(要看帧就重新 `stream_start`;旧行为是流循环静默死掉而状态仍报"在跑");连接失败(`post_connection` 非成功)回 `data.ok:false` 的 `{ok:false,error}`,不登记控制器/会话 |
48
52
  | `disconnect` | — | `{ ok }`;停流、销毁 Tasker/Controller。adb 的 destroy 偶发阻塞 → 客户端超时后硬杀自愈 |
49
53
  | `screencap` | `out` | `{ ok, path, bytes }` 或 `{ error }` |
50
54
  | `stream_start` | `fps`(1-30)、`scale`(160-1280)、`maxFrames`(20-600);可选 `l0Roll`/`l0Anchor`/`l0Bytes`/`blockThresh`/`changeGlobal`(L0 与变化检测调优) | `{ ok, fps, scale, maxFrames }` |
51
55
  | `stream_stop` / `stream_status` | — | 状态含 `running, fps, scale, maxFrames, seq, ring, events, full, session` |
56
+ | `events_list` | `since?`(只取 `seq > since` 的事件)、`limit?`(只取尾部,默认 20) | `{ ok, total, matched, cap, truncated, events[] }`;事件环活在 daemon 里,而一次性 CLI 退出即断连——所以由 daemon 按需吐出(与 REPL 的 `events` 是同一份 `S.events`,上限 1200 条)。`total` 是环内总数、`matched` 是 `since` 过滤后的条数、`truncated` 表示被 `limit` 截过(只截尾部);`events[].frame` 是画面锚点(`{seq, retained: 'l0'\|'gone', source?, w?, h?}`;`l0` 时带尺寸、`gone` 时只有 seq),据此再 `frame_get`。CLI `maafw-live events` 走这条命令 |
52
57
  | `frame_get` | `seq?`(缺省最新)、`roi?: [x,y,w,h]`、`out?`、**`src?: auto\|full\|ring`**(缺省 `auto`) | `{ ok, path, bytes, w, h, seq, t, diff, ctrlW, ctrlH, source, sourceResolution, degraded, requested?, warn? }`。`roi` 是**控制器分辨率坐标**,与 pipeline 里的 roi 同空间。<br>**带 `roi` 时**:`auto` 先从该 seq 的 L0 全分辨率原图 1:1 裁(`source:"l0-full"`、`sourceResolution:1`),L0 已淘汰才退环形缓冲小图(`source:"l1-ring"`)并**附 warn**;`full` 只要原图,淘汰即报错——**绝不拿一张缩小图冒充**;`ring` 强制小图(显式选择,不附警告)。<br>**不带 `roi`(整帧)时**:**只有显式 `full` 才是 L0 原图**;`auto` / `ring` 一律给环形缓冲那一帧(既有契约,`sourceResolution` 如实标注)——整帧小图是缺省行为而非降级,**不警告、不判 findings**,否则最普通的 `frame get`(不带 roi)会时不时退 3。<br>**`degraded`** 是**相对"调用方要什么"**的判断,不是"分辨率 < 1":带 `roi` 的 `auto` 退成小图才算降级(`true` + warn,CLI 退出 3);显式 `ring` 与整帧小图都是预期行为(`false`)。消费方按这个字段决定要不要报警,别拿 `sourceResolution` 自己反推。<br>**⚠ 行为变更(0.2.0 → 未发布)**:**仅**带 `roi` 的缺省路径由"必走 L1 小图"变为"有 L0 就用控制器分辨率原图";不带 `roi` 的整帧行为**未变**。依赖 `w/h` 恒为小图尺寸的消费方请显式传 `src=ring`,或改读 `sourceResolution` |
53
58
  | `frame_diff` | `a`、`b`(两个缓冲 seq)、`rois: [[x,y,w,h],…]`(控制器分辨率) | `{ ok, a, b, dtMs, rois[{roi,meanDiff,maxDiff,pixels}], ranked, sourceResolution, note }`。**回答"这一步究竟改了哪几块 / 哪几格"**——帧流事件只给整帧 bbox,而离散网格(棋盘 / 背包 / 技能格)要的是每格变了多少。`ranked` 按 `meanDiff` 降序给 roi;`meanDiff` 是 0..255 的平均亮度差,**判"变了/没变"的闸门要先用静止画面实测**(同 calibrate 的思路),不当普适常数用 |
54
59
  | `wait` | `mode: stable\|change`、`timeout?`(ms,200–120000,默认 10000)、`quiet?`(ms,默认 600)、`roi?`、`threshold?`(默认 3) | `{ ok, mode, satisfied, waitedMs, seq, quietMs, roi?, threshold?, lastRoiMeanDiff?, warn? }`。**状态谓词:等到条件成立再往下读,取代调用方硬睡时钟**。判据复用帧流同一套(最近 `quietMs` 内有无 `change` 事件 + 可选 ROI 内逐帧均值差 `>= threshold` 记为"在动"),不另立阈值口径。**超时不是命令失败**:`ok:true` + `satisfied:false`(CLI 退出码 3)——"没等到"是观测结果。环形缓冲一帧都没有时**直接报错**,不空转到超时再给"画面持续变化"这种假原因。`change` 的回看窗口含命令开始前 `quietMs` 内的事件,所以紧接 `click` 之后的 `wait change` 可能秒满足——它说的是"**动了**",不说明"到了目标界面";到了哪一屏要用识别或留存帧复核 |
@@ -56,12 +61,12 @@
56
61
  | `kf_promote` | `seq`(数字)或 `latest:true` | `{ ok, id, record, path, sha256, idempotent? }`;捕获身份在接受请求时固定,L0 已淘汰则失败(不改取新帧冒充);同一捕获重试幂等返回原对象。契约见 [keyframe-retention-contract-v0.md](keyframe-retention-contract-v0.md) |
57
62
  | `tpl_crop` | `roi:[x,y,w,h]` 或 `point:[x,y]` + `pad?`、`resourceDir`(自匹配要加载资源)、`seq?`(缺省最新 L0 原图)、**`kfSource?`**(关键帧库留存帧:`{id,path,sha256,ctrlW,ctrlH,captureSeq,capturedAt}`,与 `seq` 二选一)、`out?`、`cross?`(`false` 关跨帧验证;库帧路径缺省关,`true` 才做)、`prov?`(`false` 不写 L2 出处侧车)、**`provOut?`**(出处侧车改写到该目录下、保持同名;缺省写在模板旁——用于"模板进资源包、出处留包外"的项目,此时不变量是同一相对路径而非同一目录)、**`keepSource?`**(热缓存路径下,把**本次裁剪实际用的那一帧**升格进关键帧库,出处随即带上可复核的 `kf:` 身份;不取最新帧。来源本就是 `kfSource` 时无意义)、**`snapTol?`/`snapFrac?`**(snap 收紧阈值的**测量用覆盖**,默认即生产常数 90 / 0.12;定标时 A/B 用)、**`edge?`**(**边缘收紧那一路,默认 `false`**:`true` 时把"局部对比度"判据也放进候选池,常数可再覆盖 `edgeZ?`(环上对比度 p90 的倍数,默认 2)/`edgeMin?`(绝对地板,默认 24)/`edgeFrac?`(行/列命中比例门槛,默认 0.02)。**定标结论:不采用为默认**——在 `scripts/truth/1999-720p.json`(12 例)上它单独用 IoU 均值只有 0.50–0.59,而背景差分是 0.804;边缘判据找到的是**元素内部结构**(文字笔画/图标细节)而不是元素范围,所以"取更紧者"会把框收进元素内部(实测 0005 物品卡片 0.866 → 0.449,严口径 precision 91.7% → 83.3%)。旋钮留着是为了可复跑这张对照表) | `{ ok, path, seq, source, provPath, provenance, spaceCheck, box, loose, tighten, snapped, score, positionOk, selfMatchBox?, cross?, tries, w, h, ctrlW, ctrlH, keptSource?, warn?, warns? }`;snap 收紧 + 同帧自匹配逐边精修,判据**位置正确优先于得分**(纯色模板的 CCOEFF_NORMED 得分是噪声)。`kfSource` 路径**不需要设备**,裁剪前校 sha256 与像素尺寸(不符即拒),`seq` 为 `null`;`source.kind` 是 `kf` / `l0-cache`;`spaceCheck` 是源帧空间与当前控制器尺寸的比对(未连接时为 `{current:null,match:null}`);`provenance` 同时写 `<out>.prov.json`(契约 §5)。**`tighten`** 是贴合度信号:`{from:[w,h], to:[w,h], areaRatio, grew, looseMatch?}`——`grew:true` 表示"收紧反而放大"(背景差分没收到元素边界,留白仍在)并会带一条警告;`looseMatch` 是宽松框自己的自匹配结果,**有才带**(池内提前命中就会跳过它)。**别拿"宽松框 vs 最终框的得分"判贴合度**:自匹配是自洽指标,实测同一元素在 40×61 / 80×80 / 120×120 三种窗口下都是 1.0 且都落回自己——几何比例才分辨得出 |
58
63
  | `color_probe` | `roi?: [x,y,w,h]`(控制器分辨率,缺省全帧)、`seq?` | `{ ok, seq, roi?, pixelRoi, count, mean, hsv, dominant, space }` |
59
- | `annotate` | `resourceDir?`(OCR 候选源需要)、`seq?`(会话内环)、**`kfSource?`**(关键帧库留存帧,与 `seq` 二选一:控制器分辨率、**不需要设备**)、`out?`、**`roi?: [x,y,w,h]`**(只保留落在该区域内的候选,**在取上限之前过滤**——区域内的候选只跟自己竞争)、**`somScale?: full\|small`**(检测面;缺省 `full`)、**`somLimit?`/`somMaxAreaRatio?`/`somEdgeZ?`/`somEdgeMin?`/`somIoU?`**(候选上限、巨框剔除阈值、边缘 z 门槛、边缘绝对地板、合并 IoU 的**测量用覆盖**,默认 30 / 0.12 / 1.2 / 2000 / 0.6) | `{ ok, out, seq, source, detectionSource, count, limit, mergedTotal, sources, filtered, availability, sourcesUnavailable?, small, ctrl, candidates[] }`;候选 = OCR / 连通域 / 边缘密度 / diff 区域(IoU>0.6 去重合并),`candidates[].ctrl` 是控制器坐标,另带 `areaRatio`(控制器空间面积占比,供排序:越小越像小按钮)与 OCR 的 `score`。<br>**检测面分辨率决定小目标能不能被找到**:缺省优先用同一 seq 的 **L0 全分辨率原图**做检测(`detectionSource:"l0-full"`),L0 已淘汰才退降级小图(`"l1-ring"` + `source.degraded` 与说明)。**为什么必须这样**:实测主页面顶部一个 42×55 的图标,在 480 宽的 L1 上四个源一个都没探到(合并池仅 30 条、零截断却仍然没有它),在 1280 宽原图上连通域一次就命中——**漏报的根因是分辨率,不是上限**。代价是耗时上升(单次约 20–40 秒),要旧行为给 `somScale:"small"`。<br>**上限截断按横向分带轮转,带内再按来源轮转**(旧行为是带内保持源优先级,OCR 几十条会把 conn/edge 挤光,而图标类目标恰多来自 conn/edge)。`mergedTotal` 是去重后的真实规模,与 `count` 不等即表示有截断。`filtered` 是被门槛拒掉的计数(`conn: {passed,tooSmall,tooLarge,tooNarrow}`、`edge: {passed,belowZ,belowMin,singleBlock}`)。<br>**`availability` 是"看起来有结果 ≠ 有结果"的护栏**:`{limit, mergedTotal, truncated, dropped:{tooSmall,tooBig,duplicated}, maxAreaRatio, roi?, byBand[{band,yFrom,yTo,inPool,inTable}], outResolution, detectionResolution}`——分带三列一起读才分得清"这一带真没有"与"这一带被门槛/上限拿掉了"。**注意 `tooSmall` 的 8px 门槛在检测面空间**:检测面从 480 升到 1280 后,同一门槛在控制器空间相当于松了约 2.7×,跨检测面的 `dropped`/`filtered` 数字不可直接比(`detectionResolution` 就是用来对冲这件事的)。库帧路径 `seq` 为 `null`、`small` 与 `ctrl` 同为帧尺寸(换算系数 1),且 **diff 源不可用**(没有会话事件,如实报在 `sourcesUnavailable`,不拿别的区域顶替) |
64
+ | `annotate` | `resourceDir?`(OCR 候选源需要)、`seq?`(会话内环)、**`kfSource?`**(关键帧库留存帧,与 `seq` 二选一:控制器分辨率、**不需要设备**)、`out?`、**`roi?: [x,y,w,h]`**(只保留落在该区域内的候选,**在取上限之前过滤**——区域内的候选只跟自己竞争)、**`somScale?: full\|small`**(检测面;缺省 `full`)、**`somLimit?`/`somMaxAreaRatio?`/`somEdgeZ?`/`somEdgeMin?`/`somIoU?`**(候选上限、巨框剔除阈值、边缘 z 门槛、边缘绝对地板、合并 IoU 的**测量用覆盖**,默认 30 / 0.12 / 1.2 / 2000 / 0.6)、**`emitCrops?: <dir>`**(每个进表候选从 work 图批量切 PNG + 写 `.prov.json`;出处复用 L2 契约,`selfMatch:null`——这是批量出图不是"选号并验证",要自匹配仍走正式 `tpl_crop`) | `{ ok, out, seq, source, detectionSource, count, limit, mergedTotal, sources, filtered, availability, sourcesUnavailable?, emitted?:{dir,count,files[]}, small, ctrl, candidates[] }`;候选 = OCR / 连通域 / 边缘密度 / diff 区域(IoU>0.6 去重合并),`candidates[].ctrl` 是控制器坐标,另带 `areaRatio`(控制器空间面积占比,供排序:越小越像小按钮)与 OCR 的 `score`。<br>**检测面分辨率决定小目标能不能被找到**:缺省优先用同一 seq 的 **L0 全分辨率原图**做检测(`detectionSource:"l0-full"`),L0 已淘汰才退降级小图(`"l1-ring"` + `source.degraded` 与说明)。**为什么必须这样**:实测主页面顶部一个 42×55 的图标,在 480 宽的 L1 上四个源一个都没探到(合并池仅 30 条、零截断却仍然没有它),在 1280 宽原图上连通域一次就命中——**漏报的根因是分辨率,不是上限**。代价是耗时上升(单次约 20–40 秒),要旧行为给 `somScale:"small"`。<br>**上限截断按横向分带轮转,带内再按来源轮转**(旧行为是带内保持源优先级,OCR 几十条会把 conn/edge 挤光,而图标类目标恰多来自 conn/edge)。`mergedTotal` 是去重后的真实规模,与 `count` 不等即表示有截断。`filtered` 是被门槛拒掉的计数(`conn: {passed,tooSmall,tooLarge,tooNarrow}`、`edge: {passed,belowZ,belowMin,singleBlock}`)。<br>**`availability` 是"看起来有结果 ≠ 有结果"的护栏**:`{limit, mergedTotal, truncated, dropped:{tooSmall,tooBig,duplicated}, maxAreaRatio, roi?, byBand[{band,yFrom,yTo,inPool,inTable}], outResolution, detectionResolution}`——分带三列一起读才分得清"这一带真没有"与"这一带被门槛/上限拿掉了"。**注意 `tooSmall` 的 8px 门槛在检测面空间**:检测面从 480 升到 1280 后,同一门槛在控制器空间相当于松了约 2.7×,跨检测面的 `dropped`/`filtered` 数字不可直接比(`detectionResolution` 就是用来对冲这件事的)。库帧路径 `seq` 为 `null`、`small` 与 `ctrl` 同为帧尺寸(换算系数 1),且 **diff 源不可用**(没有会话事件,如实报在 `sourcesUnavailable`,不拿别的区域顶替) |
60
65
  | `calibrate` | `frames?`(6-60,默认 24)、`interval?`(ms,默认 300) | `{ ok, frames, block, global, recommended{blockThresh,changeGlobal}, apply, warn? }`;静止画面定噪声地板 |
61
- | `run` | `entry`、`resourceDir` 或 `resourceDirs[]`、`override?`、`agents?`(PI 声明的 agent 桥接)、`timeoutMs?`(缺省 30000,下限 500;`0` 不自动停,停止权交调用方) | `{ ok, record }`;`record.nodes[]` 含每节点 `id/name/nameSource/status/ms/seq/msgs`,`startSeq/endSeq` 用于把节点事件对回帧序,`record.retention` 是任务边界帧。`name` 取自框架的 `node_details.name`(`nameSource: "node_details"` = 权威);框架在 `PipelineNode.*` 顶层填的是**任务入口名**,所以完成前该字段可能是 `entry` 兜底值(`nameSource: "entry"` = 未确认,此时别拿它当"跑了哪个节点")。`record.nextCandidates` 是框架本轮**宣布过的候选序列**(`{seq, name, polls, jumpBack?, anchor?}`,连续重复折叠成 `polls` 计数,上限 50 条;`nextCandidateCount` 是公告总次数)——分支可见性用它,配合节点名即可还原"轮询了哪些出口、最后走了哪个"。超时内置 `post_stop`,防 JumpBack 死循环 |
66
+ | `run` | `entry`、`resourceDir` 或 `resourceDirs[]`、`override?`、`agents?`(PI 声明的 agent 桥接)、`timeoutMs?`(缺省 30000,下限 500;`0` 不自动停,停止权交调用方) | `{ ok, record }`;`record.nodes[]` 含每节点 `id/name/nameSource/status/ms/seq/msgs` + **`reco`/`action`**(框架 `reco_details`/`action_details` 的透出,有才带):`reco` = `{name,algorithm,hit,box:[x,y,w,h],score?}`(识别框,`hit:false` 也要如实带出——miss 是结论性证据),`action` = `{name,action,success,box:[x,y,w,h]}`(动作落点框),`frameSeq` = 该节点结束帧的锚区 seq(**按节点留帧**:节点结束时读 `cached_image` 入锚区;画面与上一张已留帧相同(blockHash 去重,hashDist<0.02)就跳过——此时没有 `frameSeq`,消费方按"有才读")。`startSeq/endSeq` 用于把节点事件对回帧序,`record.retention` 是任务边界帧。**`baseline`/`space` 落账**:`baseline` = 控制器交付帧的短边(`min(space)`,跨项目迁移时与计划 `baseline` 不一致即拒——trace 契约 §meta 的同一条),`space` = `{w,h}` 控制器实际分辨率。`name` 取自框架的 `node_details.name`(`nameSource: "node_details"` = 权威);框架在 `PipelineNode.*` 顶层填的是**任务入口名**,所以完成前该字段可能是 `entry` 兜底值(`nameSource: "entry"` = 未确认,此时别拿它当"跑了哪个节点")。`record.nextCandidates` 是框架本轮**宣布过的候选序列**(`{seq, name, polls, jumpBack?, anchor?}`,连续重复折叠成 `polls` 计数,上限 50 条;`nextCandidateCount` 是公告总次数)——分支可见性用它,配合节点名即可还原"轮询了哪些出口、最后走了哪个"。超时内置 `post_stop`,防 JumpBack 死循环 |
62
67
  | `run_stop` | — | `{ ok }` |
63
- | `input` | `kind: click\|dbclick\|press\|swipe\|key\|keys\|scroll\|move\|text\|app\|touch_down\|touch_move\|touch_up` + 对应参数(见下) | `{ ok, kind, ms, retention, path?, viaCount?, stepMs? }`;`retention.before/.after` 是动作边界帧,自动入 L0 锚区。**after 帧统一在 `job.wait()` 之后抓**,所以它含动作落地后的画面(手势类尤其要紧:要验的是"抬手之后变成什么样") |
64
- | `reco_test` | `resourceDir`、`type`、`image` / `seq` / **`kfSource?`**(关键帧库留存帧:`{id,path,sha256,ctrlW,ctrlH,captureSeq,capturedAt}`,控制器分辨率、**不需要设备**,与 `image`/`seq` 三选一)、`param?`、`sweep?{key,min,max,step}`、`node?`(整节点 JSON 透传,V1/V2 由框架解析)、`act?`(配 `node`:识别拿框 → 真机执行动作半)、`templateImage?`(调用方裁好的模板,不写资源目录) | `{ ok, type, meta, results[] }`;`meta.source` 是 kf ID / 文件路径 / `seq`(库帧路径如实报 kf 身份)。`act` 时含 `reco/action/stage/retention`。除 `act` 外在**一次性子进程**里执行:beta 绑定遇到无效模板会原生崩溃,子进程炸掉只损失一次测试。**识别跑了但一个置信度都没带回来**(`results[].detail.detail.best` 空、`all` 为空)与"分数低的未命中"是两种形态:前者是模板/资源当时读不到,别当画面不匹配处置 |
68
+ | `input` | `kind: click\|dbclick\|press\|swipe\|key\|keys\|scroll\|move\|text\|app\|touch_down\|touch_move\|touch_up` + 对应参数(见下) + **`expectSeq?`/`expectFrame?`(帧身份守卫,`click` 用)** | `{ ok, kind, ms, retention, path?, viaCount?, stepMs? }`;`retention.before/.after` 是动作边界帧,自动入 L0 锚区,**同时落 PNG 到 `runDir/action-frames/`**(收据附件,不占 kf 配额、不进 manifest;init 时清 >24h 的);`retention.before/.after` 字段 `{seq, file}`,一次性命令下 `seq` 无消费者(daemon 死后 frame_get 不可达)——看**file 路径**。**after 帧统一在 `job.wait()` 之后抓**,所以它含动作落地后的画面(手势类尤其要紧:要验的是"抬手之后变成什么样")。<br>**`expectSeq`/`expectFrame`(click 的"我是在看到 seq=N 那帧后决定要点的"守卫)**:抓完 `retention.before` 后、派发输入前核对**当前帧**与期望身份。`expectSeq` 核帧号(单调计数器,同 seq 必同像素)、`expectFrame` 核 64bit `blockHash` 串(跨 seq 也能核——同一画面重现时 seq 变了但像素没变)。**不符就不发输入**,`ok:false` + `code:'EXPECT_FAILED'`("帧身份守卫不通过:期望 seq=… 当前 seq=…——画面已经变了,不发输入"),CLI 落 FINDINGS(3)。**一次性(非 --session)命令直接拒 expect 参数**:daemon 每条命令重启 seq 从 1 起,守卫永远核不到——要 expect 就走 `repl --session <名> --cmd ...`。`frame get` 回执带 `frameHash` 供调用方取期望 hash |
69
+ | `reco_test` | `resourceDir`(单包)或 **`resourceDirs[]`**(整条资源链:interface.json 的 `resource.path[]` 按声明序叠加 + 控制器 `attach_resource_path`;`reco --project` 发的就是它)、`type`、`image` / `seq` / **`kfSource?`**(关键帧库留存帧:`{id,path,sha256,ctrlW,ctrlH,captureSeq,capturedAt}`,控制器分辨率、**不需要设备**,与 `image`/`seq` 三选一)、`param?`、`sweep?{key,min,max,step}`、`node?`(整节点 JSON 透传,V1/V2 由框架解析)、**`nodeName?`**(按资源里的**原名**跑,与 `node` 互斥;dump 只进回执、不参与识别)、`act?`(配 `node`:识别拿框 → 真机执行动作半)、`templateImage?`(调用方裁好的模板,不写资源目录)、**`candidates?: [[x,y,w,h],…]`**(候选 ROI 集合:每例换一个 roi 跑同一条识别,`roi-compare` 走的就是这条;与 `sweep` **互斥**——sweep 是"同键换值"、candidates 是"逐例换 roi",同给会静默丢候选,所以直接报错;**`node`/`nodeName` 与 `candidates`/`sweep` 同给同样互斥**——整条节点路径有自己完整的 param,没有"逐例换值"的位置) | `{ ok, type, meta, results[], resource:{dirs}, layer:'resource' }`;`nodeName` 路径另带 `nodeName` 与 `nodeDump`(资源合并后的 DumpTask,只给人看)。`meta.source` 是 kf ID / 文件路径 / `seq`(库帧路径如实报 kf 身份)。`act` 时含 `reco/action/stage/retention`。除 `act` 外在**一次性子进程**里执行:beta 绑定遇到无效模板会原生崩溃,子进程炸掉只损失一次测试。`nodeName` 查无此节点 → `ok:false, code:'NODE_MISSING'`(回执带 `candidates[]`:按编辑距离给最接近的资源节点名);识别是 `Custom` → `ok:false, code:'CUSTOM_NODE'`(本通道不桥接 agent 子进程,别当成 miss)。**识别跑了但一个置信度都没带回来**(`results[].detail.detail.best` 空、`all` 为空)与"分数低的未命中"是两种形态:前者是模板/资源当时读不到,别当画面不匹配处置 |
65
70
  | `shutdown` | — | 停流 + 断开 + 退出进程;回执 `{ ok:true }` **先落地再退**(`process.exit` 不冲 stdout,而调用方在等这条应答) |
66
71
 
67
72
  `input` 的参数:`click{x,y,contact?,pressure?}`、`dbclick{x,y,gap?}`、`press{x,y,duration?,contact?,pressure?}`、
@@ -87,14 +92,14 @@
87
92
  ## 落盘约定(`out` 类参数)
88
93
 
89
94
  会产出文件的命令(`screencap` / `frame_get` / `tpl_crop` / `annotate`)都接受 `out` 指定路径。
90
- **不传 `out` 时落在 daemon 的 run 目录下 `out/`(一次性命令给的是临时目录),不写调用方的 cwd**——
95
+ **不传 `out` 时落在 daemon 的 run 目录下 `out/`(缺省是 `~/.maafw-live/`,不是临时目录),不写调用方的 cwd**——
91
96
  调用方的 cwd 通常就是项目仓库,而这些命令本质是"看/量",产物只是顺便落盘(实测踩过两次:
92
97
  `maa_frame_*.png`、`maa_som_*.png` 进了仓库根)。要留住产物就显式给 `out`。
93
98
 
94
99
  ## 稳定性约定
95
100
 
96
101
  - 命令**只增不改**:参数名与返回字段名一律不重命名,删字段等于静默破坏所有消费者。
97
- - 新增命令请同时更新本文件与 `src/client/daemon.ts` 的类型。
102
+ - 新增命令请同时更新本文件与 `src/runtime/actions.ts` 的封装/类型(客户端是泛型 `call`;逐命令回执类型在 actions 层)。
98
103
  - 帧元数据与事件的字段同样只增不改(面板与 timing 反推都按字段名读取)。
99
104
 
100
105
  ## 复用示例
@@ -6,11 +6,11 @@
6
6
  > 验收记录见 §9;接口对已落地部分视为冻结,
7
7
  > 未落地项(跨库副本冲突的读侧检测等)仍标 v0 边界。缓存数值为实测环境(MuMu v5 + maa-node 5.14.2)结论。
8
8
  >
9
- > 主契约以本文为准。[源码核查与对齐备注](keyframe-evidence-contract-v0.md) 只记录实现基线和补充裁决,不定义第二套接口。技能侧文档在 MaaTutorial 仓(`skills/maafw-pipeline/references/state-plan.md`、`skills/docs/state-plan-review-brief.md`),同步消费本契约。
9
+ > 主契约以本文为准。接口字段见 [daemon-protocol.md](daemon-protocol.md)。技能侧文档在 MaaTutorial 仓(`skills/maafw-pipeline/references/state-plan.md`、`skills/docs/state-plan-review-brief.md`),同步消费本契约。
10
10
  >
11
- > 文中源码行号对应重构前基线 commit `85dae0e`(2026-10-03 核查),仅用于定位;结论不依赖行号,重构后需重定位。
11
+ > 文中残留的源码行号对应重构前基线 commit `85dae0e`(2026-10-03 核查),仅用于定位;结论不依赖行号。
12
12
 
13
- > 需求来源:state-plan v0 的 evidence 引用(`kf:xxxx`)此前在工具侧没有落点。运行工具(maafw-live)有实时观察底座——常驻 daemon:连续采样 + 变化检测 + 环形缓冲——但环内只保留降采样小图,原图不留存;**当事后决定保留某帧时,那次捕获的原图往往已经不存在**(现调 screencap 拿到的是另一个时刻,对消失的一次性状态没有帮助)。
13
+ > 需求来源:state-plan v0 的 evidence 引用(`kf:xxxx`)此前在工具侧没有落点。当时的运行工具已有实时观察底座——常驻 daemon:连续采样 + 变化检测 + 环形缓冲——但环内只保留降采样小图,原图不留存;**当事后决定保留某帧时,那次捕获的原图往往已经不存在**(现调 screencap 拿到的是另一个时刻,对消失的一次性状态没有帮助)。这一缺口已由 L0 缓存与显式升格补上,见文首状态与 §9。
14
14
  >
15
15
  > 本契约只定一件事:**如何把短期实时信息,选择性地留存为可跨工作会话复用的开发证据(以 L0 观测帧为载体)**。沿用既有架构裁决("持续观察、同源关联";独立轨迹记录层已被否决——"agent 边探索边写,写下的 pipeline 就是记录"),因此这是**在现有观测流上补关联和留存**,不造第二套采集链,不造资产平台,不重建轨迹系统。
16
16
 
@@ -102,7 +102,9 @@
102
102
  - `adjacent`:时间邻近关联——节点事件记录的是回调时最新帧序(`framed.mjs:550-565`),属于此类。
103
103
  - 关联强度属于“事件与图像的关系”,不是图片文件的属性,也不等于业务验证结果。当前节点事件关联按 adjacent 处理;转移级引用若只有邻近帧,只能作为线索,不能证明动作导致了目标状态。
104
104
 
105
- ### 4.1 最小操作语义(名称及传输格式待定)
105
+ ### 4.1 最小操作语义
106
+
107
+ 命令面已落地:CLI `kf promote` / `kf list` / `kf resolve`,daemon `kf_promote`。下面是语义;字段以协议为准。
106
108
 
107
109
  - **留存选定捕获**:输入 session / 连接代次 / captureSeq;若选择“最新”,必须在接受请求时固定具体捕获身份。已淘汰则失败,绝不重新截图或改取更新帧冒充指定帧。同一捕获与图像规格的重试可以返回原对象,不得改写旧 ID 的含义。
108
110
  - **发布留存结果**:先固定像素并写 L0 文件,再发布 manifest 记录;半写文件、磁盘失败、配额不足不能返回成功引用。路径由本地库管理,不能覆盖 preview 或既有留存。
@@ -143,51 +145,38 @@
143
145
 
144
146
  留存帧不可复核不抹去历史观测和实现进度;只是不能继续以它为当前落笔依据。需补观测或恢复像素后核对。L1-only 按本文较严格的策略只作为参考,不得上采样冒充 L0。
145
147
 
146
- ## 7. 工具侧现状与待修(2026-10-03 源码核查快照)
147
-
148
- > **现状(2026-10-08 复核)**:下表 #1、#2 两条**已修**,别再当现行缺陷读——`reco_test` 的参数用例改由父进程
149
- > 折叠好整包下发(`reco_child.mjs` 只透传、不再自行折叠),`run` 的退出码按任务级 `record.ok` 定。
150
- > 表内的位置与影响列是**当时的核查记录**,行号已随之后的重构漂移。
151
- > 另:#4 的"原图留存"已由 L0 缓存(滚动区/锚区,输入边界帧自动入锚区)落地;#5 的跨会话帧身份已由
152
- > 关键帧库 ID 补上("节点事件 seq 为回调时最新帧"这一半仍成立)。
148
+ ## 7. 消费口径
153
149
 
154
- | # | 问题 | 位置 | 对契约的影响 |
155
- | --- | --- | --- | --- |
156
- | 1 | **(已修)reco 参数静默丢失**:父进程构造 cases 下发,子进程只读 param/sweep → 一切识别参数退化为 {} 单例、阈值扫描失效;不报错、结果照出 | `framed.mjs:697-712` / `reco_child.mjs:25-37` | 修复前识别单测结果**不可作为状态证据**(测的参数不是你以为的参数) |
157
- | 2 | **(已修)CLI 退出码不反映任务结果**:任务结果在 `record.ok`,daemon 返回外层 `ok:true`,CLI 按外层定退出码 → 任务失败仍退出 0,human 摘要还打"完成" | `framed.mjs:604,618` / `runtime.ts:325,334` | tested 判定输入必须是任务级 `record.ok`(JSON 信封 `data.record.ok`),不是退出码;EXIT.FINDINGS(3) 只用于调用级失败(资源未解析、缓冲为空等) |
158
- | 3 | **历史帧 ROI 用当前全局尺寸换算** | (已修;原基线 `framed.mjs:480-481`) | §5 的契约要求(per-frame 尺寸),R1 验收通过 |
159
- | 4 | 环内只存小图、原图不留存;输入不触发抓帧/提频 | `framed.mjs:195,383-390,429-432,630-653` | §1"留得住/采得到"的缺口本体,即本契约的新增能力诉求 |
160
- | 5 | seq 为进程内序号,无跨会话帧身份;节点事件 seq 为回调时最新帧 | `framed.mjs:387,550-565` | §4 关联强度术语;captureSeq 仅考古用 |
150
+ 协议字段以 [daemon-protocol.md](daemon-protocol.md) 为准。这里只保留会改变留存结论的口径。
161
151
 
162
- 动线纪律(非缺陷,写入防误用):
152
+ 已经落地,不要再当成缺口:
163
153
 
164
- - **单会话单控制器**:一次性命令每次 spawn 自己的 daemon,与面板 daemon 并存 = 同设备两控制器互扰(定案红线)。交互探索用 repl,验证用一次性 `run --project`,不并发。
165
- - **一次性 run 不起流,seq 恒为 0**:帧序对齐只在 repl / client 订阅路径有效。state-plan 证据引用不依赖 seq(§4 captureSeq 仅考古)。
166
- - **长流程分段跑**:`run --timeout` 缺省 30s、`0` 表示不自动停(300s 硬上限已移除,停止权交调用方);
167
- 长流程仍按 subflow 分段——与滚动开发动线天然一致。
154
+ - L0 原图缓存(滚动区 + 锚区;输入与 `run` 的边界帧入锚区)、显式升格、跨会话 `kf:` ID、按该帧捕获时尺寸换算 ROI。验收见 §9。
155
+ - `reco_test` 的参数由父进程折叠后下发,子进程只透传。
156
+ - `run` 的 CLI 退出码按任务级 `record.ok`:任务失败退出 1。调用级失败(未连接、资源缺失、已有任务在跑)退出 3。业务结果看 JSON 信封的 `data.record.ok`,不看外层 `ok`。
168
157
 
169
- **消费注意(现有接口,核查基线)**
158
+ 读留存和取帧时仍按这些来:
170
159
 
171
- 新能力落地前,消费现有接口注意:
160
+ - 节点事件的 seq 是回调当时的最新帧序,后续回调可能改写它。关联强度按 `adjacent`,不许冒称 `bound`。`startSeq` 不是严格的开跑前截图,`framesCaptured` 是进程累计值,不据此算本次无损覆盖。
161
+ - `frame_get` 带 `roi` 时,缺省用该 seq 的 L0 原图(控制器分辨率);L0 已淘汰才退环形缓冲小图,并警告。不带 `roi` 的整帧,缺省仍是环形缓冲小图。小图上的 box 不是 pipeline 坐标。字段见协议里的 `frame_get`。
162
+ - 帧订阅回调里的 hash 是感知哈希,不是文件 SHA-256。
163
+ - 一次性命令不起流,`seq` 恒为 0。帧序对齐只在 repl / 客户端订阅路径有效。证据引用用完整 `kf:` ID,不依赖 seq。
164
+ - **单会话单控制器**。一次性命令每次 spawn 自己的 daemon,与已开的面板并存就是同一台设备上两个控制器。交互用 repl,验证用一次性命令,不并发。
165
+ - 长流程按 subflow 分段。`run --timeout` 缺省 30s,`0` 表示不自动停。
166
+ - 传输层 `ok:false` 只表示 daemon 抛了异常。业务失败通常是外层 `ok:true` 加上 `data.ok:false`(连接失败即如此,而且不登记会话)。`run` 的任务成败在 `record.ok`。
172
167
 
173
- - 优先接 `@windsland52/maafw-live/client` 的 `spawnDaemon`、`call`、`subscribe`,不要解析会截断长结果的 REPL 文本。
174
- - frame 订阅回调拿到 `{seq, t, hash, diff, w, h}`;hash 是感知哈希,不是文件 SHA-256。
175
- - frame_get 返回 `{ok, path, bytes, w, h, seq, t, diff}`,导出的是小图或其裁剪。
176
- - reco_test 返回结果列表,每项有 param / ms / ok / detail;输入小图时 box 属于小图空间,不自动等于 pipeline 坐标。
177
- - run 返回 `{ok, record}`,业务成功在 `record.ok`;外层调用完成不等于任务成功。
178
- - 节点 seq 可被后续回调更新;startSeq 不是严格的开跑前截图,framesCaptured 是进程累计值,不据此计算本次无损覆盖。
179
- - 传输回复与 data 内有不同层级的 ok;业务失败不一定让客户端 Promise reject。
168
+ 优先接 `@windsland52/maafw-live/client` 的 `spawnDaemon`、`call`、`subscribe`。REPL 文本会截断长结果,不要靠解析它取字段。
180
169
 
181
170
  ## 8. 对 state-plan v0 的落点
182
171
 
183
- 1. `frames:` 定位为证据引用登记,完整 ID 与本契约对齐;不复制本地 manifest,不把原图缓存描述为已实现。
172
+ 1. `frames:` 定位为证据引用登记,完整 ID 与本契约对齐;不复制本地 manifest。L0 缓存与关键帧库已实现,计划侧仍只登记完整 ID,不把库目录或原图收进计划。
184
173
  2. checkpoint 仅表示开发断点,由 agent 维护。设备最后确认位置另存本地,至少含目标 / session、state、证据与确认时间;**record.nodes 是执行记录,不是设备位置断言**。新动作、画面变化、重连、重启或人工接管后,操作前重新观测;歧义允许 unknown。
185
174
  3. tested 必须结合验证对象、范围、实际结果及被测实现指纹;任务级 `record.ok` 是流程结果的一项输入,不自动升级沿途所有 state。静态识别、节点、局部流程和端到端分别说明;实现变化后提示待复验。
186
175
  4. transitions 可选 `evidence` 在 v0 仅辅助人工核对,不自动门禁;既有“观测源状态、动作目标、动作后实际出口才落笔”的护栏不放松。只有 adjacent 帧不能独自证明边,多出口须逐项核对。选定动作回执及转移观测记录 schema 待后续定义,不重建全量轨迹层。
187
176
 
188
177
  ## 9. 实施与验收边界
189
178
 
190
- 建议先完成捕获身份、per-frame 尺寸、L0 缓存和显式升格 / 离线解析,再接 state-plan。自动候选仍由生产端及时入锚区,升格决策保留在 harness / agent / 人;16+32 的容量只是起点,还需字节上限和实测吞吐,不能据此承诺历史保留时长。
179
+ 捕获身份、per-frame 尺寸、L0 缓存、显式升格与离线解析已落地,验收记录在下面。state-plan 消费这些完整 ID,不在本工具里重建轨迹层。自动候选由生产端入锚区,升格决策保留在 harness / agent / 人;16+32 的容量只是起点,另有字节上限与库配额,不能据此承诺历史保留时长。
191
180
 
192
181
  ### 验收记录(2026-10-05,MuMu v5 模拟器 + maa-node 5.14.2 win32)
193
182
 
@@ -0,0 +1,115 @@
1
+ # 会话与一次性命令统一方案 v0(设计审查 P0-3)
2
+
3
+ > 状态:方案稿,未实现。来源:2026-11-07 设计审查 P0-3 + M9A 会话复盘。
4
+ > 一句话:**让所有命令只在一份 `Command.run` 上实现**,会话只是"daemon 常驻"的一种部署形态,
5
+ > `--session <name>` 决定把命令发给本地新 daemon 还是发给常驻会话的 daemon。
6
+
7
+ ## 0. 现状的三个伤口
8
+
9
+ 1. **一次性命令拿不到会话态**:每条命令新起 daemon 用完即关,L0 热缓存、帧流、事件环、
10
+ 在跑任务全在临时进程里。`stop` 停的是自己刚起的空 daemon(回 `exitCode 0`);`wait` /
11
+ `frame get` / `events` / `kf promote` 恒失败;`disconnect` 为了断开先新连一次。
12
+ 2. **命名会话丢信封**:`repl --session x --cmd "frame 999" --json` 只回 `{name,pid}`,
13
+ 命令结果整条丢;去掉 `--json` 打印 `{"ok":false}` 退出码仍是 0。
14
+ 根因:`ServeReply` 只带 `lines[]`(文本行),没有结构化 `CommandResult`。
15
+ 3. **REPL switch 是第二份实现**(`repl.ts` ~600 行 29 个 case):已与一次性命令漂移——
16
+ `connect project` 选不了 controller/resource,`run` 不吃 `--option`。两份实现必继续漂。
17
+
18
+ ## 1. 目标形态
19
+
20
+ ```
21
+ maafw-live run T --project . # 一次性:本地新 daemon(现状不变)
22
+ maafw-live run T --session m9a # 转发:送到常驻会话的 daemon,同一信封同一退出码
23
+ maafw-live repl --session m9a --cmd "run T" # 与上面完全同一条路径,只是入口不同
24
+ maa> run T # REPL 行 → tokenize → argv → 同一 Command.run
25
+ ```
26
+
27
+ 判断"这条命令需不需要会话态"是命令自己声明的,不是用户记的。
28
+
29
+ ## 2. 分四层落地(每层独立提交、独立可回滚)
30
+
31
+ ### L1 客户端注入(地基,~2h)
32
+
33
+ - `CommandContext` 增 `client?: DaemonClient`。`withDaemon`(唯一 spawn 点):
34
+ `ctx.client` 有则借用、不关;没有才 spawn。
35
+ - REPL / session-serve 进程在启动时建好 `client`,dispatch 时注入——同进程内复用连接。
36
+ - 改 `withDaemon` 签名意味着所有 `withDaemon(client => …)` 调用点改成
37
+ `withDaemon(ctx, client => …)`。一次机械的签名改动。
38
+
39
+ ### L2 `--session` 全局旗标 + 结构化回执(核心,~半天)
40
+
41
+ - **协议 v2**:`ServeRequest` 增 `kind`:`{id, kind:'line', line}`(旧,兼容)/
42
+ `{id, kind:'exec', argv:string[]}`。`ServeReply` 增 `v` 与 `result`:
43
+ `{id, v:2, result: CommandResult}`。旧 `lines[]` 形态保留(REPL 文本糖仍走 line)。
44
+ - **session server 侧**:收到 `exec` → `dispatchArgv(argv, ctx{client})` →
45
+ `findCommand(argv[0]).run(ctx)` → 把 `CommandResult` 原样回。串行队列照旧
46
+ (共用一条设备连接,排队是对的)。
47
+ - **客户端侧**:`cli.ts` main 在 `parseArgs` 后、`findCommand.run` 前拦 `--session <name>`:
48
+ `readSessionMeta` → `pidAlive(meta.pid)` → `sendToSessionExec(meta, argv, budget)` →
49
+ 拿到的 `CommandResult` 直接作为本进程输出(exitCode / data / human 全套)。
50
+ - meta 存在、pid 死了:用 `meta.connectArgs` **自动重起**(死会话自愈,不必让用户手动复活)。
51
+ - meta 不存在:退 2 + `先起会话:maafw-live repl --session <name> --project <dir>`。
52
+ - 转发预算 = `max(命令自身预算, 60s)`;`--timeout` 原样透传进 argv。
53
+ - **`repl --cmd --json` 修复(白得)**:`--cmd` 的每行走 tokenize→dispatchArgv→CommandResult,
54
+ 信封里放 `result` 字段;`--json` 输出 `{name, pid, results: CommandResult[]}`。
55
+ human 路径把 `result.human` 打出来(与交互式同文本)。
56
+
57
+ ### L3 REPL switch → argv 翻译(消灭第二实现,~半天,最大风险层)
58
+
59
+ - `runReplLine(line)` → `tokenize` → **`replToArgv(tokens)`**(纯函数,别名表)→ `dispatchArgv`。
60
+ - 需要别名/升格的命令:
61
+ | REPL 写法 | argv | 备注 |
62
+ |---|---|---|
63
+ | `connect project d` | `connect --project d` | 直接映射 |
64
+ | `connect win32 <t>` | `connect --kind win32 --target <t>` | |
65
+ | `wait stable/change` | `wait --mode stable/change` | |
66
+ | `frame get/status` | `frame get/status` | 已是子命令 |
67
+ | `stream start/stop/status` | **升格为真 Command** `stream` | daemon 侧 `stream_*` 已有 |
68
+ | `logs [n]` | **升格为真 Command** `logs` | 读 `client.events.logs` 尾部 |
69
+ | `kf list/promote/…` | `kf …` | 对齐 kf.ts 子命令表 |
70
+ | 手势 `click/swipe/key/…` | 同名 input 命令 | 已同名 |
71
+ - REPL 保留的本地糖:`help` / `quit` / `exit` / 命令名建议(suggestHead)/ 提示符。
72
+ - **判据**:switch 全部 case 删掉后,REPL 行为通过 `unit-session-named` + 交互冒烟验证。
73
+ 漂移从结构上消失——因为不再存在第二份实现。
74
+
75
+ ### L4 会话必需命令的空会话闸门(~1h)
76
+
77
+ `Command` 增 `requiresSession?: boolean`。命中条件:无 `--session`、非 REPL 上下文
78
+ (即一次性 daemon)→ 退 2(USAGE)+ `这条命令需要会话态,用 --session <name> 或 repl`。
79
+
80
+ | 命令 | 一次性形态为什么没意义 |
81
+ |---|---|
82
+ | `stop` | 停的是刚起的空 daemon,回 ok 误导 |
83
+ | `disconnect` | 为断开先新连一次 |
84
+ | `wait` | 预热截图不进环 → 恒 NO_FRAME;就算有帧也没流 |
85
+ | `frame get` | 环恒空 → 恒 NO_FRAME |
86
+ | `events`(无 --watch) | 恒空 |
87
+ | `kf promote` | 本进程 seq=0 → 恒失败 |
88
+ | `logs`、`stream` | 新 Command,只有会话态才有环可停/日志可读 |
89
+
90
+ ## 3. 语义边界(先写死,免得实现时再猜)
91
+
92
+ 1. **`run --session x` 按 Ctrl-C**:中断的是转发方 CLI,**任务在会话里继续跑**
93
+ (signal-guard 装在本地 run 路径;会话内 run 由 server 进程持有)。
94
+ 停任务用 `maafw-live stop --session x`。这是"任务属于会话"语义的自然结果。
95
+ 2. **watch 类命令经会话转发**:占用串行队列直到返回(`run --watch 60s` 期间后续命令排队)。
96
+ 转发预算按命令 `--timeout` 透传;文档写清"会话内 watch 会阻塞队列",
97
+ 需要并行观察就开第二个会话或直接在会话外用 `events --watch`。
98
+ 3. **异步 `run`(post_task 返回后事件后到)**:完成事件落 `unclaimed`,
99
+ 随下一条命令的回执回来——现有机制已支持,转发的 `run` 同样走这条。
100
+ 4. **`requiresSession` 命令在 REPL 内**:天然有 client,直接跑,闸门不拦。
101
+
102
+ ## 4. 验收
103
+
104
+ - `maafw-live run 8bitEntry --session m9a --json`:exitCode/data 与直接 `run` 同形。
105
+ - `maafw-live repl --session m9a --cmd "frame get" --json`:data 含真实 frame 结果(不再只有 name/pid)。
106
+ - `maafw-live stop --json`(无会话):退 2 + requiresSession 提示。
107
+ - `repl` 内 `run X --option k=v`:生效(漂移修复点)。
108
+ - `repl --session m9a` 死会话自愈:kill serve pid 后下一条 `--session` 命令自动重起。
109
+ - `unit-session-named` 全过;新增 dispatchArgv / replToArgv / requiresSession 闸门单测。
110
+
111
+ ## 5. 不做什么
112
+
113
+ - 不做多 daemon 路由(一会话一设备一 daemon 的模型不动)。
114
+ - 不做 REPL 与一次性的 help 统一(usage 文本已是同一份)。
115
+ - 不动 daemon 协议(`framed.mjs` handlers 不变),只动客户端与会话 IPC 层。
@@ -74,6 +74,7 @@ export function spawnDaemon(options = {}) {
74
74
  }
75
75
  const kind = String(m.kind ?? '');
76
76
  if (kind === 'reply') {
77
+ lastReplyAt = Date.now();
77
78
  const id = Number(m.id);
78
79
  const p = pending.get(id);
79
80
  if (!p)
@@ -172,6 +173,36 @@ export function spawnDaemon(options = {}) {
172
173
  }
173
174
  catch { /* ignore */ }
174
175
  };
176
+ /* 任意回执到达的时间戳:超时判活用。回复来了=事件循环在转=daemon 没死,
177
+ * 哪怕这条回复不是超时那条调用的。 */
178
+ let lastReplyAt = 0;
179
+ /**
180
+ * 超时后的活性探针(设计审查 P0-1):以前超时即 kill(),REPL / 命名会话里设备连接、
181
+ * 事件环、L0 热缓存(还没升格的不可复现状态)一起陪葬。现在先问 ping——应答说明
182
+ * daemon 活着只是这条调用慢,只失败该次调用;不应答才按"卡死"硬杀重生。
183
+ * 3s 窗:本地管道应答应在毫秒级,3s 已经足够区分"活着但慢"和"事件循环死了"。
184
+ */
185
+ const probeAlive = () => new Promise((resolve) => {
186
+ const c = child;
187
+ if (!c || exited) {
188
+ resolve(false);
189
+ return;
190
+ }
191
+ const mark = lastReplyAt;
192
+ const id = ++reqId;
193
+ const timer = setTimeout(() => {
194
+ pending.delete(id);
195
+ /* 超时窗内任何回执都算活着(ping 排在大消息后面时,先到的回执同样是证据) */
196
+ resolve(lastReplyAt > mark);
197
+ }, 3000);
198
+ pending.set(id, { resolve: () => { clearTimeout(timer); resolve(true); }, reject: () => { clearTimeout(timer); resolve(lastReplyAt > mark); }, timer });
199
+ try {
200
+ c.stdin.write(JSON.stringify({ id, cmd: 'ping' }) + '\n');
201
+ }
202
+ catch {
203
+ resolve(false);
204
+ }
205
+ });
175
206
  const call = (cmd, args = {}, timeoutMs = defaultTimeout) => new Promise((resolve, reject) => {
176
207
  let c;
177
208
  try {
@@ -185,9 +216,20 @@ export function spawnDaemon(options = {}) {
185
216
  calls += 1;
186
217
  const timer = setTimeout(() => {
187
218
  pending.delete(id);
188
- /* 卡死自愈:maa-node 的 wait() 可能同步阻塞 daemon,超时即硬杀,下次调用自动重生 */
189
- kill();
190
- reject(new Error('daemon 调用超时:' + cmd + '(' + timeoutMs + 'ms 无应答,已重启 daemon,会话需重新 connect)'));
219
+ /* 先探活再定生死:ping 有应答 = daemon 活着只是这条调用超了,只失败该次调用
220
+ * (调用方侧的超时,比如 reco 例数多算得慢);ping 也没应答才按卡死硬杀。 */
221
+ void probeAlive().then((alive) => {
222
+ if (alive) {
223
+ reject(new Error('daemon 调用超时:' + cmd + '(' + timeoutMs +
224
+ 'ms 无应答,daemon 仍可应答、进程保留——只是这条调用超了;在跑的任务未受影响,会话无需重连)'));
225
+ }
226
+ else {
227
+ /* 卡死自愈:事件循环死了(maa-node 的 wait() 可能同步阻塞),硬杀,下次调用自动重生 */
228
+ kill();
229
+ reject(new Error('daemon 调用超时:' + cmd + '(' + timeoutMs +
230
+ 'ms 无应答,且 ping 探活失败,已重启 daemon,会话需重新 connect)'));
231
+ }
232
+ });
191
233
  }, timeoutMs);
192
234
  pending.set(id, { resolve: resolve, reject, timer });
193
235
  try {
@@ -13,7 +13,7 @@ import { defaultFramesDir, resolveFrame } from '../runtime/keyframes.js';
13
13
  import { EXIT, fail } from '../protocol.js';
14
14
  import { annotate, seedObservation, withDaemon } from '../runtime/actions.js';
15
15
  import { describeSession, ensureSession, offlineResource } from '../runtime/session.js';
16
- import { CONNECT_OPTIONS, daemonFail, sessionOptions } from './runtime.js';
16
+ import { CONNECT_OPTIONS, daemonFail, argPath, sessionOptions } from './runtime.js';
17
17
  export const annotateCommand = {
18
18
  name: 'annotate',
19
19
  summary: '轻量 SoM:OCR/diff/连通域/边缘密度候选区域 + 编号回画(源可为会话内帧或关键帧库留存帧)',
@@ -23,6 +23,7 @@ export const annotateCommand = {
23
23
  ' --from-kf 在库里留存帧上离线跑(不需要设备;该路径没有会话事件,diff 源不可用)\n' +
24
24
  ' --som-limit n 放开候选上限(缺省 30;网格/表格类界面必给)\n' +
25
25
  ' --som-max-area-ratio r 巨框剔除阈值(缺省 0.12,按画面面积比;OCR 框不参与)\n' +
26
+ ' --emit-crops <dir> 每个进表候选批量出 PNG + .prov.json(出处完整;不跑自匹配,选号验证走正式 crop)\\n' +
26
27
  ' 回画优先落在 L0 全分辨率原图上;回执带 availability(截断/剔除/分带),别把"30 条"读成"画面就这些"',
27
28
  options: {
28
29
  ...CONNECT_OPTIONS,
@@ -33,15 +34,16 @@ export const annotateCommand = {
33
34
  'frames-dir': { type: 'string' },
34
35
  'som-limit': { type: 'string' },
35
36
  'som-max-area-ratio': { type: 'string' },
37
+ 'emit-crops': { type: 'string' },
36
38
  roi: { type: 'string' },
37
39
  },
38
40
  async run(ctx) {
39
- const o = sessionOptions(ctx.values);
41
+ const o = sessionOptions(ctx);
40
42
  const seq = typeof ctx.values.seq === 'string' ? Number(ctx.values.seq) : undefined;
41
43
  if (ctx.values.seq !== undefined && !Number.isFinite(seq)) {
42
44
  return fail('BAD_ARGUMENTS', '--seq 必须是数字', undefined, EXIT.USAGE);
43
45
  }
44
- const out = typeof ctx.values.out === 'string' ? ctx.values.out : undefined;
46
+ const out = argPath(ctx, typeof ctx.values.out === 'string' ? ctx.values.out : undefined);
45
47
  const roi = typeof ctx.values.roi === 'string'
46
48
  ? ctx.values.roi.split(',').map((x) => Number(String(x).trim()))
47
49
  : undefined;
@@ -69,7 +71,7 @@ export const annotateCommand = {
69
71
  /* 库帧来源:先走离线读侧解析(契约 §4.1),daemon 只接身份与路径 */
70
72
  let kfSource;
71
73
  if (fromKf !== undefined) {
72
- const dir = typeof ctx.values['frames-dir'] === 'string' ? ctx.values['frames-dir'] : defaultFramesDir();
74
+ const dir = argPath(ctx, typeof ctx.values['frames-dir'] === 'string' ? ctx.values['frames-dir'] : undefined) ?? defaultFramesDir();
73
75
  const r = resolveFrame(dir, fromKf);
74
76
  if (r.status !== 'available' || !r.path || !r.record) {
75
77
  const code = r.status === 'corrupt' ? 'KF_CORRUPT' : (r.status === 'unsupported' ? 'KF_UNSUPPORTED' : 'KF_MISSING');
@@ -83,13 +85,13 @@ export const annotateCommand = {
83
85
  }
84
86
  const offline = kfSource !== undefined;
85
87
  try {
86
- return await withDaemon(async (client) => {
88
+ return await withDaemon(ctx, async (client) => {
87
89
  const s = offline ? { connected: false } : await ensureSession(client, o);
88
- let resourceDir = (typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined)
90
+ let resourceDir = argPath(ctx, typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined)
89
91
  ?? s.plan?.resource?.paths[0];
90
92
  if (!resourceDir && offline && o.project) {
91
93
  /* 离线:项目只作资源的来源(OCR 模型),不扫设备不连接 */
92
- const off = offlineResource(o.project, o.controller, o.resource);
94
+ const off = offlineResource(o.project, o.controller, o.resource, ctx.cwd);
93
95
  if (off.error) {
94
96
  return fail('PLAN_FAILED', '离线解析项目资源失败:' + off.error, '也可以直接给 --resource-dir <dir>', EXIT.FINDINGS);
95
97
  }
@@ -99,6 +101,7 @@ export const annotateCommand = {
99
101
  if (!offline && seq === undefined) {
100
102
  await seedObservation(client, 'annotate');
101
103
  }
104
+ const emitCrops = argPath(ctx, typeof ctx.values['emit-crops'] === 'string' ? ctx.values['emit-crops'] : undefined);
102
105
  const r = await annotate(client, {
103
106
  ...(offline ? { kfSource } : {}),
104
107
  ...(!offline && seq !== undefined ? { seq } : {}),
@@ -107,6 +110,7 @@ export const annotateCommand = {
107
110
  ...(roi ? { roi } : {}),
108
111
  ...(somLimit !== undefined ? { somLimit } : {}),
109
112
  ...(ratio !== undefined ? { somMaxAreaRatio: ratio } : {}),
113
+ ...(emitCrops ? { emitCrops } : {}),
110
114
  });
111
115
  if (r.ok === false) {
112
116
  return fail('ANNOTATE', String(r.error ?? '候选生成失败'), offline
@@ -142,7 +146,7 @@ export const annotateCommand = {
142
146
  return {
143
147
  exitCode: EXIT.OK,
144
148
  human: [
145
- ...describeSession(s),
149
+ ...describeSession(s, ctx.flags.verbose),
146
150
  ...(offline ? ['离线候选:未连接设备(库帧是本机持久文件)'] : []),
147
151
  'SoM 候选 ' + String(r.count) + ' 个(ocr ' + String(sources?.ocr ?? 0) + ' / diff ' + String(sources?.diff ?? 0) +
148
152
  ' / conn ' + String(sources?.conn ?? 0) + ' / edge ' + String(sources?.edge ?? 0) + ')→ ' + String(r.out),
@@ -155,6 +159,9 @@ export const annotateCommand = {
155
159
  + (typeof c.score === 'number' ? ' score=' + c.score : '')
156
160
  + (c.text ? ' "' + c.text + '"' : '')),
157
161
  ...(r.warn ? [' 警告:' + String(r.warn)] : []),
162
+ ...(r.emitted?.count
163
+ ? [' emit-crops:' + String(r.emitted.count) + ' 条已落到 ' + String(r.emitted.dir) + '(含 .prov.json;选号验证仍走正式 crop --roi/--from-kf)']
164
+ : []),
158
165
  '选号后:click / reco --node(roi 用 ctrl 坐标)/ crop --roi(可再 crop --from-kf 于同一帧)',
159
166
  ],
160
167
  data: r,