@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
@@ -13,7 +13,7 @@ export const calibrateCommand = {
13
13
  ' 采集期间保持画面静止;结果给出可直接粘贴的 stream start 参数',
14
14
  options: { ...CONNECT_OPTIONS, frames: { type: 'string' }, interval: { type: 'string' } },
15
15
  async run(ctx) {
16
- const o = sessionOptions(ctx.values);
16
+ const o = sessionOptions(ctx);
17
17
  if (!o.project && !o.kind) {
18
18
  return fail('BAD_ARGUMENTS', '校准要设备:给 --project <dir>(推荐)或 --kind win32|adb|gamepad', undefined, EXIT.USAGE);
19
19
  }
@@ -26,7 +26,7 @@ export const calibrateCommand = {
26
26
  return fail('BAD_ARGUMENTS', '--interval 必须是毫秒数(默认 300)', undefined, EXIT.USAGE);
27
27
  }
28
28
  try {
29
- return await withDaemon(async (client) => {
29
+ return await withDaemon(ctx, async (client) => {
30
30
  const s = await ensureSession(client, o);
31
31
  const r = await calibrate(client, {
32
32
  ...(frames !== undefined ? { frames } : {}),
@@ -40,7 +40,7 @@ export const calibrateCommand = {
40
40
  return {
41
41
  exitCode: r.warn ? EXIT.FINDINGS : EXIT.OK,
42
42
  human: [
43
- ...describeSession(s),
43
+ ...describeSession(s, ctx.flags.verbose),
44
44
  '校准 ' + String(r.frames) + ' 帧(分块亮度差 / 全局位差率的噪声分布):',
45
45
  ' 分块差 p50=' + block.p50 + ' p99=' + block.p99 + ' max=' + block.max,
46
46
  ' 位差率 p50=' + global.p50 + ' p99=' + global.p99 + ' max=' + global.max,
@@ -15,7 +15,7 @@ export const colorCommand = {
15
15
  ' 一次性命令先自动截一帧再测;REPL 里测缓冲最新帧(先 screencap / stream)',
16
16
  options: { ...CONNECT_OPTIONS, roi: { type: 'string' }, seq: { type: 'string' } },
17
17
  async run(ctx) {
18
- const o = sessionOptions(ctx.values);
18
+ const o = sessionOptions(ctx);
19
19
  if (!o.project && !o.kind) {
20
20
  return fail('BAD_ARGUMENTS', '要设备才能探色:给 --project <dir>(推荐)或 --kind win32|adb|gamepad', '多步操作用 maafw-live repl(连接一次后连续观测)', EXIT.USAGE);
21
21
  }
@@ -30,7 +30,7 @@ export const colorCommand = {
30
30
  return fail('BAD_ARGUMENTS', '--seq 必须是数字', undefined, EXIT.USAGE);
31
31
  }
32
32
  try {
33
- return await withDaemon(async (client) => {
33
+ return await withDaemon(ctx, async (client) => {
34
34
  const s = await ensureSession(client, o);
35
35
  /* 一次性命令缓冲为空:先截一帧产生观测(screencap 现在进环,有捕获身份)。
36
36
  * 截帧文件用完即删——它只是观测载体,既不落用户 cwd,也不留在临时目录。 */
@@ -50,7 +50,7 @@ export const colorCommand = {
50
50
  return {
51
51
  exitCode: EXIT.OK,
52
52
  human: [
53
- ...describeSession(s),
53
+ ...describeSession(s, ctx.flags.verbose),
54
54
  '探色 seq=' + String(r.seq) + (roi ? ' roi=' + roi.join(',') : ' (整帧)'),
55
55
  ' 均值 RGB ' + mean.r + ',' + mean.g + ',' + mean.b + ' 灰度 ' + mean.gray +
56
56
  ' HSV ' + hsv.h + '°,' + hsv.s + '%,' + hsv.v + '%',
@@ -18,7 +18,7 @@ import { defaultFramesDir, resolveFrame } from '../runtime/keyframes.js';
18
18
  import { EXIT, fail } from '../protocol.js';
19
19
  import { seedObservation, tplCrop, withDaemon } from '../runtime/actions.js';
20
20
  import { describeSession, ensureSession, offlineResource } from '../runtime/session.js';
21
- import { CONNECT_OPTIONS, daemonFail, sessionOptions } from './runtime.js';
21
+ import { CONNECT_OPTIONS, daemonFail, argPath, sessionOptions } from './runtime.js';
22
22
  function nums(raw, count, what) {
23
23
  if (raw === undefined)
24
24
  return undefined;
@@ -70,7 +70,7 @@ export const cropCommand = {
70
70
  'keep-source': { type: 'boolean' },
71
71
  },
72
72
  async run(ctx) {
73
- const o = sessionOptions(ctx.values);
73
+ const o = sessionOptions(ctx);
74
74
  let roi;
75
75
  let point;
76
76
  try {
@@ -91,7 +91,7 @@ export const cropCommand = {
91
91
  if (pad !== undefined && !Number.isFinite(pad)) {
92
92
  return fail('BAD_ARGUMENTS', '--pad 必须是数字(点→ROI 外扩半径,默认 24)', undefined, EXIT.USAGE);
93
93
  }
94
- const out = typeof ctx.values.out === 'string' ? ctx.values.out : undefined;
94
+ const out = argPath(ctx, typeof ctx.values.out === 'string' ? ctx.values.out : undefined);
95
95
  const fromKf = typeof ctx.values['from-kf'] === 'string' ? ctx.values['from-kf'] : undefined;
96
96
  if (fromKf !== undefined && seq !== undefined) {
97
97
  return fail('BAD_ARGUMENTS', '--from-kf 与 --seq 互斥:前者从库内留存帧裁,后者从本会话 L0 热缓存裁', '取库里有哪些帧:maafw-live kf list', EXIT.USAGE);
@@ -103,7 +103,7 @@ export const cropCommand = {
103
103
  * 解析失败就在这一层如实报,不把不可复核的帧送进裁剪。 */
104
104
  let kfSource;
105
105
  if (fromKf !== undefined) {
106
- const dir = typeof ctx.values['frames-dir'] === 'string' ? ctx.values['frames-dir'] : defaultFramesDir();
106
+ const dir = argPath(ctx, typeof ctx.values['frames-dir'] === 'string' ? ctx.values['frames-dir'] : undefined) ?? defaultFramesDir();
107
107
  const r = resolveFrame(dir, fromKf);
108
108
  if (r.status !== 'available' || !r.path || !r.record) {
109
109
  const code = r.status === 'corrupt' ? 'KF_CORRUPT' : (r.status === 'unsupported' ? 'KF_UNSUPPORTED' : 'KF_MISSING');
@@ -118,14 +118,14 @@ export const cropCommand = {
118
118
  return fail('BAD_ARGUMENTS', '--cross 需要连接设备:给 --project <dir> 或 --kind', '不留 --cross 就是纯离线裁剪(留存帧的价值所在)', EXIT.USAGE);
119
119
  }
120
120
  try {
121
- return await withDaemon(async (client) => {
121
+ return await withDaemon(ctx, async (client) => {
122
122
  const s = offline ? { connected: false } : await ensureSession(client, o);
123
123
  /* 自匹配要加载资源(模板走 override_image,不写项目目录);项目模式用规划出的资源 */
124
- let resourceDir = (typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined)
124
+ let resourceDir = argPath(ctx, typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined)
125
125
  ?? s.plan?.resource?.paths[0];
126
126
  if (!resourceDir && offline && o.project) {
127
127
  /* 离线:项目只是资源的来源,不是连接的对象——只读 interface.json,不扫设备 */
128
- const off = offlineResource(o.project, o.controller, o.resource);
128
+ const off = offlineResource(o.project, o.controller, o.resource, ctx.cwd);
129
129
  if (off.error) {
130
130
  return fail('PLAN_FAILED', '离线解析项目资源失败:' + off.error, '也可以直接给 --resource-dir <dir>', EXIT.FINDINGS);
131
131
  }
@@ -175,7 +175,7 @@ export const cropCommand = {
175
175
  : ' 空间核对:未连接设备,未与当前控制器尺寸比对(模板空间 = 源帧捕获时尺寸 ' +
176
176
  String(r.ctrlW) + 'x' + String(r.ctrlH) + ')';
177
177
  const human = [
178
- ...describeSession(s),
178
+ ...describeSession(s, ctx.flags.verbose),
179
179
  ...(offline ? ['离线裁剪:未连接设备(留存帧是本机持久文件,裁剪不需要设备)'] : []),
180
180
  '模板 ' + String(r.w) + 'x' + String(r.h) + ' → ' + String(r.path),
181
181
  ' ' + srcLine,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 命令分发的唯一实现:argv → CommandResult。
3
+ *
4
+ * 为什么单列一个模块:一次性 CLI(index.ts)与命名会话常驻进程(repl --session-serve)
5
+ * 必须把 argv 走**同一条**解析→闸门→run 路径——会话里再写一份 switch 就必然漂移
6
+ *(设计审查 P0-3:REPL 的 connect project 选不了 controller、run 不吃 --option,就是这么来的)。
7
+ *
8
+ * 与入口(index.ts main)的分工:这里只做"argv 落到哪个命令、给它什么 ctx、拿回什么结果";
9
+ * 顶层 help/version/建议、--session 转发、emit 输出是入口的事,不在这里。
10
+ */
11
+ import { parseArgs } from 'node:util';
12
+ import { statSync } from 'node:fs';
13
+ import { GLOBAL_OPTIONS, readGlobalFlags } from '../flags.js';
14
+ import { EXIT, fail } from '../protocol.js';
15
+ /* 命令注册表延迟 import:commands/index 会把全部命令模块求值一遍(含 repl.ts——
16
+ * 而 repl.ts 又 import 本模块)。顶层静态 import 就是"谁先进来谁炸"的循环
17
+ * (Cannot access before initialization)。函数体里第一次调用时才加载注册表,
18
+ * 此时模块图早已稳定;同一条 import 只付一次。 */
19
+ let registryPromise = null;
20
+ function registry() {
21
+ registryPromise ??= import('./index.js');
22
+ return registryPromise;
23
+ }
24
+ function message(e) {
25
+ return e instanceof Error ? e.message : String(e);
26
+ }
27
+ /**
28
+ * 分发一条 argv(含命令名,即 `argv[0]` 是命令名)。
29
+ * 永不抛异常:BAD_ARGUMENTS / UNKNOWN_COMMAND / SESSION_REQUIRED / UNEXPECTED 都收敛成
30
+ * CommandResult——会话常驻进程不能因为一条坏命令挂掉。
31
+ */
32
+ export async function dispatchArgv(argv, env) {
33
+ const { findCommand, suggestCommand } = await registry();
34
+ const head = argv[0] ?? '';
35
+ const cmd = findCommand(head);
36
+ if (!cmd) {
37
+ const suggestion = suggestCommand(head);
38
+ return {
39
+ cmd: null,
40
+ flags: readGlobalFlags({}, env.cwd),
41
+ result: {
42
+ exitCode: EXIT.USAGE,
43
+ error: {
44
+ code: 'UNKNOWN_COMMAND',
45
+ message: `未知命令:${head}`,
46
+ ...(suggestion ? { hint: `是否想执行 ${suggestion}?` } : {}),
47
+ },
48
+ },
49
+ };
50
+ }
51
+ const options = { ...GLOBAL_OPTIONS, ...(cmd.options ?? {}) };
52
+ let parsed;
53
+ try {
54
+ parsed = parseArgs({
55
+ args: argv.slice(1),
56
+ options,
57
+ allowPositionals: true,
58
+ strict: true,
59
+ });
60
+ }
61
+ catch (e) {
62
+ return { cmd, flags: readGlobalFlags({}, env.cwd), result: fail('BAD_ARGUMENTS', message(e), `用法:${cmd.usage}`, EXIT.USAGE) };
63
+ }
64
+ const flags = readGlobalFlags(parsed.values, env.cwd);
65
+ /* 预留旗标没有消费者:传了就退 2 而不是静默吞掉——"以为在试跑其实已经落盘"
66
+ * 是 agent 最坏的失败模式。`--limit`/`--verbose` 例外:events 自己消费同名列;
67
+ * verbose 已接 describeSession 的 note 行门控。 */
68
+ const unimpl = [];
69
+ if (flags.dryRun)
70
+ unimpl.push('--dry-run');
71
+ if (parsed.values.limit !== undefined && !(cmd.options && 'limit' in cmd.options))
72
+ unimpl.push('--limit');
73
+ if (unimpl.length > 0) {
74
+ return {
75
+ cmd, flags,
76
+ result: fail('FLAG_NOT_IMPLEMENTED', `未实现的旗标:${unimpl.join(' ')}(接受但不生效是最坏的失败模式,所以直接报错)`, '要"只校验不落盘"用 validate;要截断输出用命令自己的分页参数', EXIT.USAGE),
77
+ };
78
+ }
79
+ if (flags.help) {
80
+ /* usage.ts 也 import commands/index(顶层 usage 列表):同一条环,同样延迟到
81
+ * 真用到才求值(只有 --help 才需要)。 */
82
+ const { commandUsage } = await import('../usage.js');
83
+ return { cmd, flags, result: { exitCode: EXIT.OK, human: commandUsage(cmd) } };
84
+ }
85
+ const ctx = {
86
+ positionals: parsed.positionals,
87
+ values: parsed.values,
88
+ flags,
89
+ cwd: env.cwd,
90
+ ...(env.client ? { client: env.client } : {}),
91
+ };
92
+ /* requiresSession 闸门:一次性形态(env.client 为空)下跑的是刚起的空 daemon,
93
+ * 这类命令只能给出假信号(stop 停空 daemon 回 ok、frame get 恒 NO_FRAME)——
94
+ * 退 2 + 指路,比"看起来成功了"安全得多。会话内(env.client 已注入)天然放行。 */
95
+ const needsSession = !env.client &&
96
+ (typeof cmd.requiresSession === 'function' ? cmd.requiresSession(ctx) : cmd.requiresSession === true);
97
+ if (needsSession) {
98
+ return {
99
+ cmd, flags,
100
+ result: fail('SESSION_REQUIRED', cmd.name + ' 需要会话态(一次性命令的 daemon 用完即关,这条命令依赖的会话内状态不存在)', '先起会话:maafw-live repl --session <name> --project <dir>,然后 ' + cmd.name + ' --session <name>(或直接在 REPL 里用)', EXIT.USAGE),
101
+ };
102
+ }
103
+ try {
104
+ return { cmd, flags, result: await cmd.run(ctx) };
105
+ }
106
+ catch (e) {
107
+ return {
108
+ cmd, flags,
109
+ result: fail('UNEXPECTED', message(e), '这是未预期异常,请把 --json 输出一并反馈', EXIT.FAIL),
110
+ };
111
+ }
112
+ }
113
+ export function isDirectory(p) {
114
+ try {
115
+ return statSync(p).isDirectory();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ //# sourceMappingURL=dispatch.js.map
@@ -67,6 +67,118 @@ function resolveMaaNode(from) {
67
67
  function selfDir() {
68
68
  return dirname(fileURLToPath(import.meta.url));
69
69
  }
70
+ /**
71
+ * 精确版本判定:`5.14.2` / `=5.14.2` / `==5.14.2` / `v5.14.2`,以及 PEP 440 的
72
+ * 预发布/后发布后缀(`5.15.0a1`、`5.15.0b1`、`5.15.0rc1`、`5.15.0.dev1`、`5.15.0.post1`)
73
+ * 才算「就是这个版本」。
74
+ *
75
+ * 后缀不改变「唯一确定」这一点:Python 绑定发布的正是 `MaaFW==5.15.0b1` 这种写法,
76
+ * 判成范围就等于对真正要防的那个不一致视而不见。
77
+ *
78
+ * `^5.14.2`、`>=5.14` 这类范围要 semver 解析才能判等,本工具不替 npm/pip 做这件事:
79
+ * 判错了比不判更坏,调用方会据此去改依赖。
80
+ */
81
+ function exactVersion(spec) {
82
+ const m = /^(?:==|=)?\s*v?(\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+|(?:a|b|c|rc|alpha|beta|pre|preview|post|dev)\d*)?)$/i.exec(spec.trim());
83
+ return m ? m[1] : null;
84
+ }
85
+ /**
86
+ * qualifier 归一化:同一个 pre-release 在两个生态里写法不同,比较前先落到同一形态。
87
+ * `-beta.1` / `beta.1` / `beta1` / `b1` → `b1`;`alpha` → `a`;`rc` / `pre` / `preview` → `rc`;
88
+ * `.dev1` → `dev1`;`.post1` → `post1`;没有后缀 → 空串(正式版)。
89
+ * 认不出的后缀原样保留:宁可把它判成不一致,也不要凭猜测把两个不相干的后缀当同一个。
90
+ */
91
+ export function normalizeQualifier(qualifier) {
92
+ const body = qualifier.trim().toLowerCase().replace(/^[-+_.]/, '');
93
+ if (body === '')
94
+ return '';
95
+ const m = /^(alpha|beta|pre|preview|rc|a|b|c|post|dev)\.?(\d*)$/.exec(body);
96
+ if (!m)
97
+ return body;
98
+ const kind = m[1];
99
+ const num = m[2] ?? '';
100
+ const canon = kind === 'alpha' ? 'a'
101
+ : kind === 'beta' ? 'b'
102
+ : kind === 'pre' || kind === 'preview' ? 'rc'
103
+ : kind;
104
+ return canon + num;
105
+ }
106
+ /**
107
+ * 把精确版本拆成 release 线与**归一化后的** qualifier:`5.15.0b1` → `5.15.0` + `b1`,
108
+ * `5.15.0-beta.1` 同样落到 `5.15.0` + `b1`。
109
+ *
110
+ * 对账要分别看两段:release 线不同是换了个版本;release 相同而 qualifier 不同是**同一条线上的
111
+ * 另一份构建**(正式版 vs 预发布、b1 vs rc1 都是)。只比字符串会把两种拼写当不一致;
112
+ * 只比 release 又会把正式版与预发布报成一致——两头都错。
113
+ */
114
+ export function splitRelease(version) {
115
+ const m = /^(\d+\.\d+\.\d+)(.*)$/.exec(version.trim());
116
+ return m ? { release: m[1], qualifier: normalizeQualifier(m[2] ?? '') } : null;
117
+ }
118
+ function pinFromPackageJson(root) {
119
+ const file = join(root, 'package.json');
120
+ if (!existsSync(file))
121
+ return null;
122
+ try {
123
+ const j = JSON.parse(readFileSync(file, 'utf8'));
124
+ for (const field of ['dependencies', 'devDependencies']) {
125
+ const group = j[field];
126
+ if (!group || typeof group !== 'object')
127
+ continue;
128
+ const v = group['@maaxyz/maa-node'];
129
+ if (typeof v === 'string' && v.trim()) {
130
+ return { source: 'package.json', declared: v.trim(), exact: exactVersion(v) };
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ /* package.json 坏了就当这个源没有 pin:探针不因环境残缺而失败 */
136
+ }
137
+ return null;
138
+ }
139
+ function pinFromRequirements(root) {
140
+ const file = join(root, 'requirements.txt');
141
+ if (!existsSync(file))
142
+ return null;
143
+ let text;
144
+ try {
145
+ text = readFileSync(file, 'utf8');
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ for (const raw of text.split(/\r?\n/)) {
151
+ /* 注释与 environment marker 都是环境噪音,只看「包名 + 版本声明」这一截。
152
+ PyPI 上的绑定包名是 `MaaFW`,老项目写成 `maa`,两种都是同一个包,大小写不敏感;
153
+ 负向前瞻保证只认独立的包名:maa-evidence-kit、maa_x、maafw2 是别的包。 */
154
+ const body = (raw.split('#')[0] ?? '').split(';')[0].trim();
155
+ const m = /^(?:maafw|maa)(?![\w.-])(?:\[[^\]]*\])?\s*(===|==|>=|<=|~=|!=|>|<|=)?\s*([^\s;#]*)$/i.exec(body);
156
+ if (!m)
157
+ continue;
158
+ return { source: 'requirements.txt', declared: body, exact: exactVersion((m[1] ?? '') + (m[2] ?? '')) };
159
+ }
160
+ return null;
161
+ }
162
+ function readMaaNodePin(root) {
163
+ const pkg = pinFromPackageJson(root);
164
+ const req = pinFromRequirements(root);
165
+ /* 两处都读:范围声明回答不了「跑的是不是同一版」这个等式,只能回答「落没落在区间里」,
166
+ 精确声明才能。所以按 package.json 精确 → requirements.txt 精确 → package.json 范围 →
167
+ requirements.txt 范围取第一个;见到 package.json 的任何声明就收工,会把另一处的精确 pin
168
+ 顶掉,而不一致正是靠那个精确 pin 才发现的。 */
169
+ for (const candidate of [pkg?.exact ? pkg : null, req?.exact ? req : null, pkg, req]) {
170
+ if (candidate)
171
+ return candidate;
172
+ }
173
+ /* 两个声明源都拿不到才记 uv.lock:它只说明「求解过」,不产生 warning */
174
+ return existsSync(join(root, 'uv.lock')) ? { source: 'uv.lock', declared: '', exact: null } : null;
175
+ }
176
+ /** Windows 的路径大小写不敏感:同一份依赖不该因为盘符/目录大小写而被报成两份 */
177
+ function samePath(a, b) {
178
+ const na = resolve(a);
179
+ const nb = resolve(b);
180
+ return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
181
+ }
70
182
  /**
71
183
  * 取 checkout 的 schema 快照版本。
72
184
  *
@@ -101,8 +213,10 @@ export const envCommand = {
101
213
  const deep = ctx.values.deep === true;
102
214
  const projectRoot = findProjectRoot(cwd);
103
215
  const dirs = projectRoot ? pipelineDirs(projectRoot) : [];
104
- /* 运行时绑定是本包自己的依赖:按包自身解析即可;项目目录里的副本优先(项目可能 pin 了别的版本) */
105
- const maaNode = resolveMaaNode(cwd) ?? resolveMaaNode(selfDir());
216
+ /* 运行时绑定是本包自己的依赖:self 才是 CLI 真正会 require 到的那份。
217
+ cwd 的副本只用来对照「项目可能 pin 了别的版本」,它不决定本 CLI 加载什么。 */
218
+ const self = resolveMaaNode(selfDir());
219
+ const cwdCopy = resolveMaaNode(cwd);
106
220
  const [checkoutVer, gitV, adbV, pyV] = await Promise.all([
107
221
  existsSync(maafw) ? checkoutVersion(maafw, gitBin) : Promise.resolve(''),
108
222
  run(gitBin, ['--version'], { timeout: 5000 }),
@@ -129,9 +243,84 @@ export const envCommand = {
129
243
  });
130
244
  items.push({
131
245
  key: 'maa-node',
132
- status: maaNode ? 'ok' : 'missing',
133
- detail: maaNode ? `${maaNode.version} (${maaNode.path})` : '未安装(运行时命令不可用)',
246
+ status: self ? 'ok' : 'missing',
247
+ detail: self ? `${self.version} (${self.path})` : '未安装(运行时命令不可用)',
134
248
  });
249
+ /* 并列而不是覆盖:全局安装时 cwd 的那份不会被加载,报成同一个版本会误导排查 */
250
+ if (cwdCopy && (!self || !samePath(self.path, cwdCopy.path))) {
251
+ items.push({
252
+ key: 'maa-node(cwd)',
253
+ status: 'warn',
254
+ detail: `${cwdCopy.version} (${cwdCopy.path}) — 项目目录里的副本,本 CLI 不加载它`,
255
+ });
256
+ }
257
+ /* 项目 pin 只与 self 对账:比的是「正在运行的那份」,不是项目里躺着的那份 */
258
+ const pin = projectRoot ? readMaaNodePin(projectRoot) : null;
259
+ const pinWarnings = [];
260
+ if (pin && pin.source !== 'uv.lock') {
261
+ if (pin.exact === null) {
262
+ items.push({
263
+ key: 'maa-node pin',
264
+ status: 'ok',
265
+ detail: `${pin.declared}(${pin.source})—— 范围声明,不比对`,
266
+ });
267
+ }
268
+ else if (!self) {
269
+ items.push({
270
+ key: 'maa-node pin',
271
+ status: 'warn',
272
+ detail: `${pin.declared}(${pin.source})—— CLI 实际加载的 maa-node 未安装,无法比对`,
273
+ });
274
+ }
275
+ else {
276
+ /* 两段都比、且 qualifier 先归一化:`5.15.0b1` 与 `5.15.0-beta.1` 是同一份构建的两种
277
+ 生态写法(不算不一致);而正式版 `5.14.2` 与预发布 `5.14.2b1` 是同一条线上的两份
278
+ 不同构建(要报)。只比字符串会把前者误报、只比 release 会把后者漏报。 */
279
+ const pinV = splitRelease(pin.exact);
280
+ const runV = splitRelease(self.version);
281
+ const mismatch = () => {
282
+ const detail = `项目 pin ${pin.declared}(${pin.source}),CLI 实际加载 ${self.version}`;
283
+ items.push({ key: 'maa-node pin', status: 'warn', detail });
284
+ pinWarnings.push(`maa-node 版本不一致:${detail}`);
285
+ };
286
+ if (!pinV || !runV) {
287
+ /* 有一边不是 x.y.z 形态(自定义构建号之类):没有 release 线可比,只能退回整串 */
288
+ if (pin.exact === self.version) {
289
+ items.push({
290
+ key: 'maa-node pin',
291
+ status: 'ok',
292
+ detail: `${pin.exact}(${pin.source})与 CLI 实际加载一致`,
293
+ });
294
+ }
295
+ else {
296
+ mismatch();
297
+ }
298
+ }
299
+ else if (pinV.release !== runV.release) {
300
+ mismatch();
301
+ }
302
+ else if (pinV.qualifier !== runV.qualifier) {
303
+ /* 同 release 线但 qualifier 不同 = 同一条线上的另一份构建(正式版 vs b1、b1 vs rc1),
304
+ * 仍然是"跑的不是 pin 的那份",要报。归一化只抹平两个生态的拼写(b1 / beta.1)。 */
305
+ mismatch();
306
+ }
307
+ else if (pin.exact === self.version) {
308
+ items.push({
309
+ key: 'maa-node pin',
310
+ status: 'ok',
311
+ detail: `${pin.exact}(${pin.source})与 CLI 实际加载一致`,
312
+ });
313
+ }
314
+ else {
315
+ items.push({
316
+ key: 'maa-node pin',
317
+ status: 'ok',
318
+ detail: `同版本 ${pinV.release}${pinV.qualifier ? '-' + pinV.qualifier : ''}:` +
319
+ `声明 ${pin.exact} / 实际 ${self.version}(两种拼写指向同一份构建)`,
320
+ });
321
+ }
322
+ }
323
+ }
135
324
  /* 显式给了源码目录才出现:应用开发者的机器上不该看到一个「缺一项」 */
136
325
  if (maafw)
137
326
  items.push({
@@ -187,6 +376,7 @@ export const envCommand = {
187
376
  ];
188
377
  const missing = items.filter((i) => i.status === 'missing');
189
378
  const warnings = missing.map((i) => `${i.key}: ${i.detail}`);
379
+ warnings.push(...pinWarnings);
190
380
  return {
191
381
  exitCode: missing.length > 0 ? EXIT.ENV : EXIT.OK,
192
382
  root: projectRoot,
@@ -196,11 +386,15 @@ export const envCommand = {
196
386
  runtime: { node: process.version, platform: process.platform, arch: process.arch },
197
387
  cwd,
198
388
  project: { root: projectRoot, pipelineDirs: dirs },
199
- maaNode,
389
+ maaNode: self,
390
+ /** cwd 那份副本,仅作对照:它不是 CLI 实际加载的那份 */
391
+ maaNodeCwd: cwdCopy,
392
+ /** 项目声明的 pin:只用 package.json / requirements.txt 下结论,uv.lock 仅记来源 */
393
+ maaNodePin: pin,
200
394
  framework: maafw
201
395
  ? {
202
396
  checkout: { path: maafw, found: existsSync(maafw), version: checkoutVer },
203
- native: maaNode?.version ?? null,
397
+ native: self?.version ?? null,
204
398
  }
205
399
  : null,
206
400
  tools: {
@@ -5,19 +5,25 @@ import { CROP_COMMANDS } from './crop.js';
5
5
  import { envCommand } from './env.js';
6
6
  import { INPUT_COMMANDS } from './input.js';
7
7
  import { KF_COMMANDS } from './kf.js';
8
+ import { screencapProbeCommand } from './probe.js';
8
9
  import { RECO_COMMANDS } from './reco.js';
9
10
  import { replCommand } from './repl.js';
11
+ import { ROI_CHECK_COMMANDS } from './roi-check.js';
12
+ import { ROI_COMPARE_COMMANDS } from './roi-compare.js';
10
13
  import { RUNTIME_COMMANDS } from './runtime.js';
11
14
  import { skillCommand } from './skill.js';
12
15
  import { TIMING_COMMANDS } from './timing.js';
13
16
  import { versionCommand } from './version.js';
14
- /** 展示顺序:探针 → 运行时/设备 → 输入 → 识别 → 探色 → 裁剪 → SoM → 校准 → 关键帧 → timing → 会话 → skill 自检。 */
17
+ /** 展示顺序:探针 → 运行时/设备 → 输入 → 识别 → 验真 → 探色 → 裁剪 → SoM → 校准 → 关键帧 → timing → 会话 → skill 自检。 */
15
18
  export const COMMANDS = [
16
19
  envCommand,
17
20
  versionCommand,
18
21
  ...RUNTIME_COMMANDS,
22
+ screencapProbeCommand,
19
23
  ...INPUT_COMMANDS,
20
24
  ...RECO_COMMANDS,
25
+ ...ROI_COMPARE_COMMANDS,
26
+ ...ROI_CHECK_COMMANDS,
21
27
  ...COLOR_COMMANDS,
22
28
  ...CROP_COMMANDS,
23
29
  ...ANNOTATE_COMMANDS,