@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
@@ -8,20 +8,41 @@
8
8
  * 既能交互用,也能被脚本/agent 用管道驱动:printf 'probe\nquit\n' | maafw-live repl
9
9
  */
10
10
  import readline from 'node:readline';
11
+ import { resolve } from 'node:path';
11
12
  import { spawnDaemon } from '../client/daemon.js';
12
- import { EXIT } from '../protocol.js';
13
+ import { EXIT, fail } from '../protocol.js';
13
14
  import * as act from '../runtime/actions.js';
14
15
  import { defaultFramesDir, describeRecord, loadManifest, resolveFrame } from '../runtime/keyframes.js';
16
+ import { roiOutOfBounds } from '../runtime/reco-args.js';
17
+ import { readNodeFile, readParamFile } from '../runtime/node-source.js';
18
+ import { describeMisses } from '../runtime/reco-verdict.js';
19
+ import { ocrModelStatus } from '../runtime/ocr-model.js';
20
+ import { ocrModelName, suggestThreshold } from './reco.js';
15
21
  import { ensureSession, SessionError } from '../runtime/session.js';
22
+ import { writeRunReceipt } from '../runtime/receipts.js';
23
+ import { ensureSessionDir, pidAlive, readSessionMeta, sendToSession, sendToSessionExec, SessionServer, sessionPipePath, sessionServeLogTail, spawnSessionServer, stopSession, validSessionName, waitPipeReady, } from '../runtime/session-ipc.js';
24
+ /* 不能 import './index.js' 拿 findCommand:commands/index 也 import 本模块(replCommand),
25
+ * 循环依赖会在"谁先进来"那条路径上炸(Cannot access before initialization)。
26
+ * 真命令名清单就地维护——新命令加进 commands/index 时在这里补一行。 */
27
+ const EXEC_HEADS = new Set([
28
+ 'annotate', 'calibrate', 'color', 'connect', 'crop', 'device', 'disconnect', 'env',
29
+ 'events', 'frame', 'kf', 'probe', 'reco', 'roi-check', 'roi-compare', 'run', 'screencap',
30
+ 'stop', 'timing', 'version', 'wait',
31
+ /* 手势命令与 input.ts 同名,直接走 exec */
32
+ 'click', 'swipe', 'key', 'keys', 'press', 'dbclick', 'scroll', 'move', 'text',
33
+ ]);
16
34
  import { CONNECT_OPTIONS, sessionOptions } from './runtime.js';
35
+ import { dispatchArgv } from './dispatch.js';
36
+ import { parseOptionArgs } from '../option-args.js';
37
+ import { cliPackage } from '../pkg.js';
17
38
  const HELP = [
18
39
  '命令:',
19
40
  ' probe 自检运行时',
20
41
  ' device [all|adb|win32] 列设备',
21
42
  ' connect <win32|adb|gamepad> [target] 手动连接',
22
43
  ' connect project <dir> [hwnd|address] 按 interface.json 规划连接(推荐)',
23
- ' disconnect 断开并销毁 Tasker/Controller',
24
- ' screencap [out.png] 截一帧落盘(同时进 L0,可升格)',
44
+ ' disconnect 断开并销毁 Tasker/Resource/Controller',
45
+ ' screencap [out.png] | screencap --out out.png 截一帧落盘(同时进 L0,可升格)',
25
46
  ' stream start [--fps n] [--scale n] [--l0-roll n] [--l0-anchor n] [--max-frames n] [--block-thresh n] [--change-global n] [--l0-bytes n]',
26
47
  ' stream stop | stream status',
27
48
  ' frame [seq] [--roi x,y,w,h] [--src auto|full|ring] [--out f.png] 取帧(--src full = 控制器分辨率原图)',
@@ -32,36 +53,89 @@ const HELP = [
32
53
  ' annotate [--out f.png] [--som-limit n] [--som-max-area-ratio r] [--from-kf kf:...] SoM 候选与编号回画(回画优先全分辨率;库帧路径离线、diff 不可用)',
33
54
  ' events [n] 最近 n 条帧流事件(默认 10)',
34
55
  ' logs [n] daemon 原生 stderr 尾部(默认 20)',
35
- ' run <entry> [--timeout ms|0] [--override json] [--resource-dir d] (异步执行,不阻塞提示符)',
56
+ ' run <entry> [--timeout ms|0] [--preset name] [--override json] [--resource-dir d] [--watch] (异步执行,不阻塞提示符;--watch 持续打节点转移;项目模式叠四级 override)',
36
57
  ' stop 停止运行中的任务',
37
58
  ' click <x> <y> | swipe <x1> <y1> <x2> <y2> [--duration ms] [--via "x,y;x,y"] | key <code> | text <string>',
38
59
  ' swipe --via 走 touch_down→逐点 touch_move→touch_up:**一条笔画画折线**(中途不抬手)',
39
60
  ' press <x> <y> [--duration ms] | dbclick <x> <y> | keys <c[,c+..]> | scroll <dx> <dy> | move <dx> <dy>',
40
61
  ' touchdown <x> <y> | touchmove <x> <y> | touchup 裸触点原语(跨命令维持同一次按压)',
41
- ' reco <type> [k=v ...] [--sweep json] 识别单测(用缓冲最新帧)',
62
+ ' reco <type> [k=v ...] [--param-file p.json] [--image f.png|kf:…] [--seq n] [--from-kf kf:…] [--resource-dir d] [--sweep json]',
63
+ ' reco [--node-file node.json | --node-name <节点名>] (资源取整条链;节点来源与参数来源互斥)',
64
+ ' 识别单测。缺省用会话缓冲最新帧(L1 小图,roi 要按该图尺寸给);--image 走文件',
65
+ ' (控制器空间,roi 用 ctrl 坐标);--from-kf 走库内留存帧、离线。roi 必须给数组:',
66
+ ' `roi=1,2,3,4`(逗号写法即可,不必写方括号)。',
42
67
  ' kf status | kf promote [seq|latest] [--note s] | kf list | kf resolve <kf:...>',
43
68
  ' help | quit',
44
69
  ];
45
- /** 极简分词:空格分隔,支持 --flag value 与 key=value;text 用整行剩余内容。
70
+ /** 分词:空格分隔,支持 --flag value 与 key=value;text 用整行剩余内容。
46
71
  *
47
- * **成对引号要剥掉**:脚本与 agent 习惯把带逗号的参数写进引号(`--roi "470,10,180,110"`),
72
+ * **引号要剥掉**:脚本与 agent 习惯把带逗号的参数写进引号(`--roi "470,10,180,110"`),
48
73
  * 不剥就整串当参数:`Number('"470')` = NaN → 走 JSON 变 null → daemon `Number(null)` = 0,
49
74
  * 于是退化成一个**合法但错误**的框(实测 `--roi "400,20,200,60"` 变成 `w=200,h=0`)且不报错。
50
- * 剥引号让"带引号"与"不带引号"等价——这是调用方最自然的写法。 */
75
+ * 剥引号让"带引号"与"不带引号"等价——这是调用方最自然的写法。
76
+ *
77
+ * **引号内的空格不是分隔符**:只按 `/\s+/` 切再逐 token 剥首尾引号,会把带空格的引号参数切碎,
78
+ * 且残片各自都不成对、于是**原样留着引号**:`--note "活动大厅 主界面"` 变成
79
+ * `--note '"活动大厅'`(`readFlag` 只取下一个 token)——数据侧被污染成"截断 + 残留引号",
80
+ * 而 `kf promote` 照收不误,`kf list` 里看起来像是显示层截断。同一个坑对
81
+ * `--expected "选择 领队"`、`--override '{...带空格...}'` 一样成立。
82
+ *
83
+ * 所以这里按字符扫描:引号内的空白并入当前 token,引号本身不产出。
84
+ * `\` 只转义引号与 `\` 自身(Windows 路径里的反斜杠必须原样保留)。
85
+ * 未闭合的引号按"到行尾"收,并把内容原样给出——参数校验层会拿它报错,不在这里静默吞掉。 */
51
86
  function tokenize(line) {
52
- return line.trim().split(/\s+/).filter((t) => t !== '').map((t) => {
53
- if (t.length >= 2) {
54
- const a = t[0], z = t[t.length - 1];
55
- if ((a === '"' && z === '"') || (a === "'" && z === "'"))
56
- return t.slice(1, -1);
87
+ const tokens = [];
88
+ let cur = '';
89
+ let quote = null;
90
+ let has = false;
91
+ for (let i = 0; i < line.length; i++) {
92
+ const ch = line[i];
93
+ if (quote) {
94
+ if (ch === '\\' && (line[i + 1] === quote || line[i + 1] === '\\')) {
95
+ cur += line[++i];
96
+ continue;
97
+ }
98
+ if (ch === quote) {
99
+ quote = null;
100
+ continue;
101
+ }
102
+ cur += ch;
103
+ continue;
57
104
  }
58
- return t;
59
- });
105
+ if (ch === '"' || ch === "'") {
106
+ quote = ch;
107
+ has = true;
108
+ continue;
109
+ }
110
+ if (/\s/.test(ch)) {
111
+ if (has) {
112
+ tokens.push(cur);
113
+ cur = '';
114
+ has = false;
115
+ }
116
+ continue;
117
+ }
118
+ cur += ch;
119
+ has = true;
120
+ }
121
+ if (has)
122
+ tokens.push(cur);
123
+ return tokens;
60
124
  }
61
125
  function readFlag(tokens, name) {
62
126
  const i = tokens.indexOf(name);
63
127
  return i >= 0 ? tokens[i + 1] : undefined;
64
128
  }
129
+ /** `--option` 这类**可多次出现**的旗标:readFlag 只返回第一次出现的值(后面几次静默丢)。
130
+ * REPL 里 run --option a=b --option c=d 丢一半配置是最坏的假信号,必须收全。 */
131
+ function readFlagAll(tokens, name) {
132
+ const out = [];
133
+ for (let i = 0; i < tokens.length; i++) {
134
+ if (tokens[i] === name && tokens[i + 1] !== undefined)
135
+ out.push(tokens[i + 1]);
136
+ }
137
+ return out;
138
+ }
65
139
  /**
66
140
  * 取 count 个数字参数;任何一个不是有限数就返回 null。
67
141
  *
@@ -87,6 +161,77 @@ function numFlag(tokens, name, def) {
87
161
  const n = Number(raw);
88
162
  return Number.isFinite(n) ? n : null;
89
163
  }
164
+ /** reco 的 k=v 里**期望数组**的参数:单值写法(`roi=558,68,140,43`)必须落成数组。
165
+ *
166
+ * 为什么不能靠"看起来像数字"自动判断:`Number("558,68,140,43")` = NaN,于是旧代码把整串
167
+ * 当字符串发出去,而 MaaFW 只认 `is_array()` —— 字符串被当**节点名**解析,回执 `ok:false`
168
+ * 且无置信度,看起来像"模板没读到"。本文件里另外 8 处(frame/crop/color/wait/annotate/swipe)
169
+ * 早就按逗号拆了,`reco` 是唯一遗漏。
170
+ *
171
+ * 为什么只列 `roi`:它是 MaaFW 里唯一"不给数组就必然用错空间"的参数(`roi` 也覆盖了
172
+ * `roi_offset` 这类同族标量写法,那些在 pipeline 里可以是单值,不擅自升级)。`expected`
173
+ * 不动——OCR 的 expected 字符串与数组**都命中**(实测),按逗号拆文本是凭空造规则。 */
174
+ const RECO_ARRAY_PARAMS = ['roi'];
175
+ /** `[...]` / `a,b,c` 两种数组写法都收;有一个不是有限数就退回原字符串(不猜、不静默改成 NaN)。 */
176
+ function coerceRecoList(v) {
177
+ const body = v.startsWith('[') && v.endsWith(']') ? v.slice(1, -1) : v;
178
+ const parts = body.split(',').map((x) => x.trim());
179
+ if (parts.some((x) => x === ''))
180
+ return null;
181
+ const nums = parts.map((x) => Number(x));
182
+ return nums.every((n) => Number.isFinite(n)) ? nums : null;
183
+ }
184
+ /** repl `reco <type> k=v …` 的参数解析:沿用既有惯例(数字转 number),列表参数转数组。 */
185
+ export function recoParam(tokens) {
186
+ const param = {};
187
+ for (const t of tokens.slice(2)) {
188
+ const eq = t.indexOf('=');
189
+ if (eq <= 0)
190
+ continue;
191
+ const k = t.slice(0, eq);
192
+ const v = t.slice(eq + 1);
193
+ if (RECO_ARRAY_PARAMS.includes(k) && (v.includes(',') || (v.startsWith('[') && v.endsWith(']')))) {
194
+ param[k] = coerceRecoList(v) ?? v;
195
+ continue;
196
+ }
197
+ param[k] = Number.isFinite(Number(v)) && v !== '' ? Number(v) : v;
198
+ }
199
+ return param;
200
+ }
201
+ /** 库帧来源解析(`--from-kf kf:…` 与 `--image kf:…` 共用):与一次性 CLI 同一份离线读侧。 */
202
+ function kfSourceFrom(id, tokens) {
203
+ const dir = readFlag(tokens, '--frames-dir') ?? defaultFramesDir();
204
+ const rr = resolveFrame(dir, id);
205
+ if (rr.status !== 'available' || !rr.path || !rr.record) {
206
+ return { error: '库帧不可用(' + rr.status + '):' + String(rr.reason ?? '') + '(库 ' + dir + ')' };
207
+ }
208
+ return {
209
+ source: {
210
+ id: rr.record.id, path: rr.path, sha256: rr.record.sha256,
211
+ ctrlW: rr.record.ctrlW, ctrlH: rr.record.ctrlH,
212
+ captureSeq: rr.record.captureSeq, capturedAt: rr.record.capturedAt,
213
+ },
214
+ };
215
+ }
216
+ /** `screencap` 的参数面:**只认 `--out`**。
217
+ *
218
+ * 为什么在这里单独做白名单、而不是全局"位置参数不许以 -- 开头":本文件的 `text` 吃整行剩余,
219
+ * `frame`/`crop`/`wait`/`swipe --via` 各有自己的旗标表,全局规则会误伤。而 screencap 的坏参数
220
+ * 特别贵:旧写法把 `--out` 当**位置参数**,于是真写出一个名为 `--out` 的文件(`ok:true` 掩盖),
221
+ * 而 CLI 的 `screencap --out x.png` 是对的——同一条链路上两套规则,谁都记不住哪边对。 */
222
+ function screencapArgs(tokens) {
223
+ const out = readFlag(tokens, '--out');
224
+ const positionals = tokens.slice(1).filter((t) => !t.startsWith('--') && t !== out);
225
+ const unknown = tokens.slice(1).filter((t) => t.startsWith('--') && t !== '--out');
226
+ if (unknown.length) {
227
+ return { error: 'screencap 只认 --out <png>(收到未知选项 ' + unknown.join(' ') + ');位置参数写法同样可用' };
228
+ }
229
+ if (positionals.length > 1) {
230
+ return { error: 'screencap 只写一个文件(收到 ' + positionals.length + ' 个位置参数:' + positionals.join(' ') + ')' };
231
+ }
232
+ const path = out ?? positionals[0];
233
+ return path === undefined ? {} : { out: path };
234
+ }
90
235
  function short(v, max = 400) {
91
236
  const s = typeof v === 'string' ? v : JSON.stringify(v);
92
237
  return s.length > max ? s.slice(0, max) + ' …' : s;
@@ -131,9 +276,42 @@ function suggestHead(name, maxDistance = 3) {
131
276
  export const replCommand = {
132
277
  name: 'repl',
133
278
  summary: '交互/管道会话:连接一次,后续命令复用同一个 daemon 与设备会话',
134
- usage: 'maafw-live repl [--project <dir>|--kind win32|adb|gamepad [--target ...]]',
135
- options: { ...CONNECT_OPTIONS },
279
+ usage: 'maafw-live repl [--project <dir>|--kind win32|adb|gamepad [--target ...]]\n' +
280
+ ' maafw-live repl --session <name> [--cmd <命令>]… 命名常驻会话:首次起、之后复用(不必自建驱动)\n' +
281
+ ' 不带 --cmd:进入该会话的交互式提示符(等价于 repl,但连接状态跨调用保持)\n' +
282
+ ' 带 --cmd:把命令送进常驻会话并打印回执;可给多个 --cmd,逐条串行执行\n' +
283
+ ' maafw-live repl --session cc --cmd "screencap a.png"\n' +
284
+ ' maafw-live repl --session cc --cmd "run CritterCrashEntry --timeout 0" --cmd "events 20"\n' +
285
+ ' **必须用 --cmd 传带选项的命令**:不加 --cmd 时参数会先被 repl 自己的选项表解析,\n' +
286
+ ' `repl --session cc run X --timeout 0` 里的 `--timeout` 会被当成 repl 的选项而报错\n' +
287
+ ' maafw-live repl --session <name> --session-status 看会话状态\n' +
288
+ ' maafw-live repl --session <name> --session-stop 收掉会话\n' +
289
+ ' --session-serve <name> 内部用:常驻侧(从命名管道读命令,不要手敲)',
290
+ options: {
291
+ ...CONNECT_OPTIONS,
292
+ session: { type: 'string' },
293
+ cmd: { type: 'string', multiple: true },
294
+ 'session-status': { type: 'boolean' },
295
+ 'session-stop': { type: 'boolean' },
296
+ 'session-serve': { type: 'string' },
297
+ },
136
298
  async run(ctx) {
299
+ /* ── 常驻侧:`--session-serve` 从命名管道收命令,把 repl 的输出按条回给调用方 ──
300
+ * 这条分支不读 stdin(PowerShell 递不了管道给子进程),而是自己 listen。 */
301
+ const serveName = typeof ctx.values['session-serve'] === 'string' ? ctx.values['session-serve'] : undefined;
302
+ if (serveName !== undefined) {
303
+ if (!validSessionName(serveName)) {
304
+ return fail('BAD_ARGUMENTS', '会话名只允许字母数字与 - _(1..48 字符)', undefined, EXIT.USAGE);
305
+ }
306
+ return await runSessionServer(serveName, ctx);
307
+ }
308
+ const name = typeof ctx.values.session === 'string' ? ctx.values.session : undefined;
309
+ if (name !== undefined) {
310
+ if (!validSessionName(name)) {
311
+ return fail('BAD_ARGUMENTS', '会话名只允许字母数字与 - _(1..48 字符):' + JSON.stringify(name), undefined, EXIT.USAGE);
312
+ }
313
+ return await runNamedSession(name, ctx);
314
+ }
137
315
  const client = spawnDaemon();
138
316
  const out = (line) => { process.stdout.write(line + '\n'); };
139
317
  const state = { session: '(未连接)', plan: null };
@@ -141,8 +319,12 @@ export const replCommand = {
141
319
  state.session = String(s.session?.target ?? '(已连接)');
142
320
  state.plan = s.plan ?? null;
143
321
  };
144
- const o = sessionOptions(ctx.values);
322
+ const o = sessionOptions(ctx);
145
323
  if (o.project || o.kind) {
324
+ /* 起连 = 启动工作:pretask 要跑(--no-pretask 关)。项目 pretask 先于设备扫描,
325
+ * "启动模拟器"类脚本冷启动也到得了(P0-2)。 */
326
+ if (o.pretask === undefined)
327
+ o.pretask = true;
146
328
  try {
147
329
  const s = await ensureSession(client, o);
148
330
  remember(s);
@@ -189,510 +371,1131 @@ export const replCommand = {
189
371
  };
190
372
  try {
191
373
  for (;;) {
374
+ /* 提示符带连接状态:启动连接失败 / 主动 disconnect / daemon 层重连失败后
375
+ * 显示「未连接」,别让一个裸 maa> 看起来像会话可用。 */
192
376
  if (process.stdin.isTTY)
193
- process.stdout.write('maa> ');
377
+ process.stdout.write(state.session === '(未连接)' ? 'maa(未连接)> ' : 'maa> ');
194
378
  const line = await ask();
195
379
  if (line === null)
196
380
  break;
197
- const tokens = tokenize(line);
198
- if (!tokens.length)
199
- continue;
200
- const head = tokens[0];
201
- if (head === 'quit' || head === 'exit')
381
+ const step = await runReplLine(line, { client, out, state, remember });
382
+ if (step === 'quit')
202
383
  break;
203
- if (head === 'help') {
204
- for (const h of HELP)
205
- out(h);
206
- continue;
207
- }
384
+ }
385
+ }
386
+ finally {
387
+ rl.close();
388
+ client.close();
389
+ }
390
+ return { exitCode: EXIT.OK, human: ['会话结束。'] };
391
+ },
392
+ };
393
+ async function runReplLine(line, rt) {
394
+ const { client, out, state, remember } = rt;
395
+ const tokens = tokenize(line);
396
+ if (!tokens.length)
397
+ return 'continue';
398
+ const head = tokens[0];
399
+ if (head === 'quit' || head === 'exit')
400
+ return 'quit';
401
+ if (head === 'help') {
402
+ for (const h of HELP)
403
+ out(h);
404
+ return 'continue';
405
+ }
406
+ try {
407
+ switch (head) {
408
+ case 'probe':
409
+ out(short(await act.probe(client)));
410
+ break;
411
+ case 'device':
412
+ out(short(await act.deviceList(client, tokens[1] ?? 'all')));
413
+ break;
414
+ case 'connect': {
415
+ let s;
208
416
  try {
209
- switch (head) {
210
- case 'probe':
211
- out(short(await act.probe(client)));
212
- break;
213
- case 'device':
214
- out(short(await act.deviceList(client, tokens[1] ?? 'all')));
215
- break;
216
- case 'connect': {
217
- let s;
218
- if (tokens[1] === 'project') {
219
- s = await ensureSession(client, { project: tokens[2], hwnd: tokens[3], address: tokens[3] });
220
- }
221
- else {
222
- s = await ensureSession(client, { kind: tokens[1], target: tokens[2] });
223
- }
224
- remember(s);
225
- out('已连接:' + state.session);
226
- break;
227
- }
228
- case 'disconnect':
229
- out(short(await act.disconnect(client)));
230
- state.session = '(未连接)';
231
- break;
232
- case 'screencap':
233
- out(short(await act.screencap(client, tokens[1])));
234
- break;
235
- case 'stream': {
236
- const sub = tokens[1] ?? 'status';
237
- if (sub === 'start') {
238
- const fps = readFlag(tokens, '--fps');
239
- const scale = readFlag(tokens, '--scale');
240
- const blockThresh = readFlag(tokens, '--block-thresh');
241
- const changeGlobal = readFlag(tokens, '--change-global');
242
- const l0Bytes = readFlag(tokens, '--l0-bytes');
243
- /* 观测策略参数:l0-roll 决定"我有多久回看窗口"(默认 16 帧 ≈ 1.6s @10fps),
244
- * max-frames 决定环形缓冲长度。协议本就支持,命令面必须可达——
245
- * 不可达的参数会被调用方当成"没有"。 */
246
- const maxFrames = readFlag(tokens, '--max-frames');
247
- const l0Roll = readFlag(tokens, '--l0-roll');
248
- const l0Anchor = readFlag(tokens, '--l0-anchor');
249
- out(short(await act.streamStart(client, {
250
- ...(fps ? { fps: Number(fps) } : {}),
251
- ...(scale ? { scale: Number(scale) } : {}),
252
- ...(blockThresh ? { blockThresh: Number(blockThresh) } : {}),
253
- ...(changeGlobal ? { changeGlobal: Number(changeGlobal) } : {}),
254
- ...(l0Bytes ? { l0Bytes: Number(l0Bytes) } : {}),
255
- ...(maxFrames ? { maxFrames: Number(maxFrames) } : {}),
256
- ...(l0Roll ? { l0Roll: Number(l0Roll) } : {}),
257
- ...(l0Anchor ? { l0Anchor: Number(l0Anchor) } : {}),
258
- })));
259
- }
260
- else if (sub === 'stop')
261
- out(short(await act.streamStop(client)));
262
- else
263
- out(short(await act.streamStatus(client)));
264
- break;
265
- }
266
- case 'wait': {
267
- /* 状态谓词:等到条件成立再往下读,取代硬睡时钟。超时不算错误(satisfied=false)。 */
268
- const modeRaw = tokens[1];
269
- if (modeRaw !== 'stable' && modeRaw !== 'change') {
270
- out('用法:wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]'
271
- + '(模式只认 stable / change'
272
- + (modeRaw ? ';收到 ' + JSON.stringify(modeRaw)
273
- + (suggestHead(modeRaw) ? ',是想输入 ' + String(suggestHead(modeRaw)) + ' 吗' : '') : '')
274
- + ')');
275
- break;
276
- }
277
- /* 数值 flag 必须校验:`--timeout abc` 会变成 NaN 一路传下去,
278
- * 客户端超时算成 NaN → 调用秒超时,看起来像"工具坏了"。 */
279
- const numOr = (name) => {
280
- const raw = readFlag(tokens, name);
281
- if (raw === undefined)
282
- return undefined;
283
- const n = Number(raw);
284
- return Number.isFinite(n) ? n : null;
285
- };
286
- const tmo = numOr('--timeout');
287
- const qt = numOr('--quiet');
288
- const thr = numOr('--threshold');
289
- if (tmo === null || qt === null || thr === null) {
290
- out('--timeout / --quiet / --threshold 必须是数字'
291
- + (tmo === null ? '(--timeout 收到 ' + JSON.stringify(readFlag(tokens, '--timeout')) + ')' : ''));
292
- break;
293
- }
294
- const roiRaw = readFlag(tokens, '--roi');
295
- const r = await act.waitState(client, {
296
- mode: modeRaw,
297
- ...(tmo !== undefined ? { timeout: tmo } : {}),
298
- ...(qt !== undefined ? { quiet: qt } : {}),
299
- ...(thr !== undefined ? { threshold: thr } : {}),
300
- ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
301
- });
302
- out(short(r, 500));
303
- break;
304
- }
305
- case 'frame': {
306
- const roiRaw = readFlag(tokens, '--roi');
307
- const srcRaw = readFlag(tokens, '--src');
308
- const seq = tokens[1] && !tokens[1].startsWith('--') ? Number(tokens[1]) : undefined;
309
- if (tokens[1] === 'diff') {
310
- /* 两帧在若干 ROI 上的差分:离散网格要的是"哪几格变了",不是整帧 bbox。 */
311
- const a = readFlag(tokens, '--a');
312
- const b = readFlag(tokens, '--b');
313
- const roisRaw = readFlag(tokens, '--rois');
314
- if (a === undefined || b === undefined || !roisRaw) {
315
- out('用法:frame diff --a <seq> --b <seq> --rois "x,y,w,h;x,y,w,h"');
316
- break;
317
- }
318
- const rois = roisRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
319
- if (rois.some((r) => r.length !== 4 || r.some((n) => !Number.isFinite(n)))) {
320
- out('--rois 每段要 4 个数字,段间用 ; 分隔');
321
- break;
322
- }
323
- const d = await act.frameDiff(client, { a: Number(a), b: Number(b), rois });
324
- out(short(d, 900));
325
- break;
326
- }
327
- out(short(await act.frameGet(client, {
328
- ...(seq !== undefined ? { seq } : {}),
329
- ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
330
- ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
331
- ...(srcRaw ? { src: srcRaw } : {}),
332
- })));
333
- break;
334
- }
335
- case 'color': {
336
- const roiRaw = readFlag(tokens, '--roi');
337
- out(short(await act.colorProbe(client, {
338
- ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
339
- }), 400));
340
- break;
341
- }
342
- case 'crop': {
343
- const roiRaw = readFlag(tokens, '--roi');
344
- const pointRaw = readFlag(tokens, '--point');
345
- const padRaw = readFlag(tokens, '--pad');
346
- /* --from-kf:源换成库内留存帧(跨会话可用)。解析走与一次性 CLI 同一份离线读侧。 */
347
- const kfId = readFlag(tokens, '--from-kf');
348
- let kfSource;
349
- if (kfId) {
350
- const dir = readFlag(tokens, '--frames-dir') ?? defaultFramesDir();
351
- const rr = resolveFrame(dir, kfId);
352
- if (rr.status !== 'available' || !rr.path || !rr.record) {
353
- out('库帧不可用(' + rr.status + '):' + String(rr.reason ?? '') + '(库 ' + dir + ')');
354
- break;
355
- }
356
- kfSource = {
357
- id: rr.record.id, path: rr.path, sha256: rr.record.sha256,
358
- ctrlW: rr.record.ctrlW, ctrlH: rr.record.ctrlH,
359
- captureSeq: rr.record.captureSeq, capturedAt: rr.record.capturedAt,
360
- };
361
- }
362
- const ck = await act.tplCrop(client, {
363
- ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
364
- ...(pointRaw ? { point: pointRaw.split(',').map((x) => Number(x)) } : {}),
365
- ...(padRaw ? { pad: Number(padRaw) } : {}),
366
- ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
367
- ...(kfSource ? { kfSource, cross: false } : {}),
368
- ...(readFlag(tokens, '--resource-dir')
369
- ? { resourceDir: readFlag(tokens, '--resource-dir') }
370
- : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
371
- });
372
- out(short(ck, 600));
373
- if (ck.ok !== false && ck.provPath)
374
- out('L2 出处:' + String(ck.provPath));
375
- break;
376
- }
377
- case 'annotate': {
378
- /* --from-kf:源换成库内留存帧(离线;diff 源不可用,工具会如实报) */
379
- const kfSom = readFlag(tokens, '--from-kf');
380
- let somKf;
381
- if (kfSom) {
382
- const dir = readFlag(tokens, '--frames-dir') ?? defaultFramesDir();
383
- const rr = resolveFrame(dir, kfSom);
384
- if (rr.status !== 'available' || !rr.path || !rr.record) {
385
- out('库帧不可用(' + rr.status + '):' + String(rr.reason ?? '') + '(库 ' + dir + ')');
386
- break;
387
- }
388
- somKf = {
389
- id: rr.record.id, path: rr.path, sha256: rr.record.sha256,
390
- ctrlW: rr.record.ctrlW, ctrlH: rr.record.ctrlH,
391
- captureSeq: rr.record.captureSeq, capturedAt: rr.record.capturedAt,
392
- };
393
- }
394
- const somLimitRaw = readFlag(tokens, '--som-limit');
395
- const somLimit = somLimitRaw === undefined ? undefined : Number(somLimitRaw);
396
- if (somLimitRaw !== undefined && (!Number.isFinite(somLimit) || somLimit <= 0)) {
397
- out('--som-limit 必须是正整数(收到 ' + JSON.stringify(somLimitRaw) + ');0 不等于"不限量"');
398
- break;
399
- }
400
- const somRatioRaw = readFlag(tokens, '--som-max-area-ratio');
401
- const somRatio = somRatioRaw === undefined ? undefined : Number(somRatioRaw);
402
- if (somRatioRaw !== undefined && (!Number.isFinite(somRatio) || somRatio <= 0 || somRatio >= 1)) {
403
- out('--som-max-area-ratio 要在 (0,1) 之间(收到 ' + JSON.stringify(somRatioRaw) + ');0 不等于"关闭剔除"');
404
- break;
405
- }
406
- const r = await act.annotate(client, {
407
- ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
408
- ...(somKf ? { kfSource: somKf } : {}),
409
- ...(somLimit !== undefined ? { somLimit } : {}),
410
- ...(somRatio !== undefined ? { somMaxAreaRatio: somRatio } : {}),
411
- ...(readFlag(tokens, '--roi') ? { roi: String(readFlag(tokens, '--roi')).split(',').map((x) => Number(x)) } : {}),
412
- ...(readFlag(tokens, '--resource-dir')
413
- ? { resourceDir: readFlag(tokens, '--resource-dir') }
414
- : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
415
- });
416
- if (r.ok === false) {
417
- out('annotate 失败:' + String(r.error));
418
- break;
419
- }
420
- out('SoM ' + String(r.count) + ' 候选 → ' + String(r.out) + (somKf ? '(源:留存帧)' : ''));
421
- /* 可用性三列:不读它就会把"30 条"当成"画面就这些可点区域"。 */
422
- const av = r.availability;
423
- if (av) {
424
- out(' 可用性:合并池 ' + String(av.mergedTotal ?? '?') + ' → 进表 ' + String(r.count)
425
- + '(上限 ' + String(av.limit ?? '?') + ',截断 ' + String(av.truncated ?? 0) + ')'
426
- + ';剔除 巨框 ' + String(av.dropped?.tooBig ?? 0) + ' / 重复 ' + String(av.dropped?.duplicated ?? 0));
427
- if (av.byBand)
428
- out(' 分带(上/中/下)进表/池:' + av.byBand.map((b) => String(b.inTable) + '/' + String(b.inPool)).join(' '));
429
- if (av.outResolution)
430
- out(' 回画分辨率:' + String(av.outResolution.w) + 'x' + String(av.outResolution.h) + '(' + String(av.outResolution.source) + ')');
431
- }
432
- for (const c of r.candidates ?? []) {
433
- out(' #' + c.id + ' [' + c.source + '] ctrl=' + c.ctrl.join(',')
434
- + (typeof c.areaRatio === 'number' ? ' 面积比=' + c.areaRatio : '')
435
- + (typeof c.score === 'number' ? ' score=' + c.score : '')
436
- + (c.text ? ' "' + c.text + '"' : ''));
437
- }
438
- break;
439
- }
440
- case 'events': {
441
- const n = Number(tokens[1] ?? 10);
442
- const evs = client.events.events.slice(-n);
443
- out('事件 ' + evs.length + ' 条(共 ' + client.events.events.length + ')');
444
- for (const e of evs)
445
- out(' ' + short(e, 200));
446
- break;
447
- }
448
- case 'logs': {
449
- const n = Number(tokens[1] ?? 20);
450
- for (const l of client.logTail(n))
451
- out(' ' + l);
452
- break;
453
- }
454
- case 'run': {
455
- const entry = tokens[1];
456
- if (!entry) {
457
- out('用法:run <entry> [--timeout ms|0] [--override json] [--resource-dir d]');
458
- break;
459
- }
460
- /* 任务名 → pipeline 入口解析(与一次性 CLI 同规则):interface 的 task 名(如中文
461
- * "启动游戏")不是 pipeline 节点名,不解析会 task not exist 秒败 */
462
- const task = state.plan?.loaded.tasks.find((t) => t.name === entry || t.entry === entry);
463
- const resolvedEntry = task?.entry ?? entry;
464
- const timeout = Number(readFlag(tokens, '--timeout') ?? 30000);
465
- const overrideRaw = readFlag(tokens, '--override');
466
- let override = {};
467
- if (overrideRaw) {
468
- try {
469
- override = JSON.parse(overrideRaw);
470
- }
471
- catch {
472
- out('--override 不是合法 JSON');
473
- break;
474
- }
475
- }
476
- /* 资源目录:--resource-dir 优先,其次当前项目规划(connect project 的 interface.json) */
477
- const resDirs = state.plan?.resource?.paths ?? [];
478
- const explicitDir = readFlag(tokens, '--resource-dir');
479
- if (!explicitDir && !resDirs.length) {
480
- out('缺资源:给 --resource-dir <dir>,或先 connect project <dir>');
481
- break;
482
- }
483
- /* 异步执行不阻塞提示符:--timeout 0 的长任务靠 stop 中断(同一条客户端连接) */
484
- out('已提交 ' + resolvedEntry + (task && task.name !== resolvedEntry ? '(任务 ' + task.name + ')' : '') + '(timeout=' + timeout + ',结果异步打印;stop 可中断)');
485
- const plan = state.plan;
486
- act.run(client, explicitDir
487
- ? { resourceDir: explicitDir, entry: resolvedEntry, override, timeoutMs: timeout }
488
- : {
489
- resourceDirs: resDirs, entry: resolvedEntry, override, timeoutMs: timeout,
490
- ...(plan?.loaded.agents.length
491
- ? { agents: plan.loaded.agents.map((a) => ({ exec: a.exec, args: a.args })), agentCwd: plan.loaded.dir ?? undefined }
492
- : {}),
493
- }, timeout)
494
- .then((r) => {
495
- const rec = r.record;
496
- out('run 完成:任务级 ' + (rec ? (rec.ok ? 'ok' : 'FAIL') : '未知') +
497
- (rec?.agent ? ',agent ' + (rec.agent.ok ? '已连接' : '失败') : '') + ' ' + short(r, 700));
498
- })
499
- .catch((e) => out('run 失败:' + (e instanceof Error ? e.message : String(e))));
500
- break;
501
- }
502
- case 'stop':
503
- out(short(await act.runStop(client)));
504
- break;
505
- case 'click': {
506
- const a = nums(tokens, 1, 2);
507
- if (!a) {
508
- out('用法:click <x> <y>');
509
- break;
510
- }
511
- out(short(await act.input(client, { kind: 'click', x: a[0], y: a[1] })));
512
- break;
513
- }
514
- case 'swipe': {
515
- const a = nums(tokens, 1, 4);
516
- const duration = numFlag(tokens, '--duration', 300);
517
- if (!a || duration === null) {
518
- out('用法:swipe <x1> <y1> <x2> <y2> [--duration ms] [--via "x,y;x,y"]');
519
- break;
520
- }
521
- /* --via:中途路径点。走的是 touch_down→逐点 touch_move→touch_up —— **一条笔画画折线**,
522
- * 中途不抬手。连线/谱曲/拖拽排序这类玩法要的正是这个;只有两端点时走原生 post_swipe。 */
523
- const viaRaw = readFlag(tokens, '--via');
524
- let via;
525
- if (viaRaw) {
526
- via = viaRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
527
- if (via.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
528
- out('--via 每段要 2 个数字(x,y),段间用 ; 分隔');
529
- break;
530
- }
531
- }
532
- out(short(await act.input(client, {
533
- kind: 'swipe', x1: a[0], y1: a[1], x2: a[2], y2: a[3], duration,
534
- ...(via ? { via } : {}),
535
- })));
536
- break;
537
- }
538
- case 'touchdown': {
539
- const a = nums(tokens, 1, 2);
540
- if (!a) {
541
- out('用法:touchdown <x> <y>');
542
- break;
543
- }
544
- out(short(await act.input(client, { kind: 'touch_down', x: a[0], y: a[1] })));
545
- break;
546
- }
547
- case 'touchmove': {
548
- const a = nums(tokens, 1, 2);
549
- if (!a) {
550
- out('用法:touchmove <x> <y>');
551
- break;
552
- }
553
- out(short(await act.input(client, { kind: 'touch_move', x: a[0], y: a[1] })));
554
- break;
555
- }
556
- case 'touchup':
557
- out(short(await act.input(client, { kind: 'touch_up' })));
558
- break;
559
- case 'key': {
560
- const a = nums(tokens, 1, 1);
561
- if (!a) {
562
- out('用法:key <code>(Android KeyEvent 码:3=Home、4=返回)');
563
- break;
564
- }
565
- out(short(await act.input(client, { kind: 'key', code: a[0] })));
566
- break;
567
- }
568
- case 'keys': {
569
- /* 键码只保留正整数:与 CLI/daemon 同一条口径,空集合直接报用法 */
570
- const codes = String(tokens[1] ?? '').split(/[+,]/)
571
- .map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0);
572
- if (!codes.length) {
573
- out('用法:keys <code[,code+...]>(至少一个正整数键码,如 17,67)');
574
- break;
575
- }
576
- out(short(await act.input(client, { kind: 'keys', codes })));
577
- break;
578
- }
579
- case 'press': {
580
- const a = nums(tokens, 1, 2);
581
- const duration = numFlag(tokens, '--duration', 800);
582
- if (!a || duration === null) {
583
- out('用法:press <x> <y> [--duration ms]');
584
- break;
585
- }
586
- out(short(await act.input(client, { kind: 'press', x: a[0], y: a[1], duration })));
587
- break;
588
- }
589
- case 'dbclick': {
590
- const a = nums(tokens, 1, 2);
591
- if (!a) {
592
- out('用法:dbclick <x> <y>');
593
- break;
594
- }
595
- out(short(await act.input(client, { kind: 'dbclick', x: a[0], y: a[1] })));
596
- break;
597
- }
598
- case 'scroll': {
599
- const a = nums(tokens, 1, 2);
600
- if (!a) {
601
- out('用法:scroll <dx> <dy>(建议 120 的倍数)');
602
- break;
417
+ if (tokens[1] === 'project') {
418
+ /* 会话内手动重连也是一次"启动":pretask 照跑(非零中止、超时 120s)。
419
+ * --controller/--resource 补的是"连接 project 但挑哪套 controller/resource"——
420
+ * 此前会话里选不了(P0-3 漂移点),一次性 CLI 早就有。 */
421
+ s = await ensureSession(client, {
422
+ project: tokens[2],
423
+ ...(readFlag(tokens, '--controller') ? { controller: readFlag(tokens, '--controller') } : {}),
424
+ ...(readFlag(tokens, '--resource') ? { resource: readFlag(tokens, '--resource') } : {}),
425
+ hwnd: readFlag(tokens, '--hwnd') ?? tokens[3],
426
+ address: readFlag(tokens, '--address') ?? tokens[3],
427
+ pretask: !tokens.includes('--no-pretask'),
428
+ });
429
+ }
430
+ else {
431
+ s = await ensureSession(client, {
432
+ kind: tokens[1],
433
+ target: tokens[2],
434
+ ...(readFlag(tokens, '--short-side') ? { shortSide: Number(readFlag(tokens, '--short-side')) } : {}),
435
+ ...(readFlag(tokens, '--long-side') ? { longSide: Number(readFlag(tokens, '--long-side')) } : {}),
436
+ ...(tokens.includes('--raw') ? { rawSize: true } : {}),
437
+ });
438
+ }
439
+ }
440
+ catch (e) {
441
+ /* daemon 层连接失败时旧控制器已被销毁:摘要退回未连接(提示符会显示),
442
+ * 但保留 state.plan——它只是项目规划,离线 reco/crop 仍可用。规划阶段的失败
443
+ * 发生在 connect 之前,旧会话/控制器还在,摘要保持原样。 */
444
+ if (e instanceof SessionError && e.code === 'CONNECT_FAILED')
445
+ state.session = '(未连接)';
446
+ throw e;
447
+ }
448
+ remember(s);
449
+ out('已连接:' + state.session);
450
+ break;
451
+ }
452
+ case 'disconnect':
453
+ out(short(await act.disconnect(client)));
454
+ state.session = '(未连接)';
455
+ break;
456
+ case 'screencap': {
457
+ /* --out 与位置参数都收(与 CLI 一致);未知选项按上面白名单报错,不再写脏文件。 */
458
+ const a = screencapArgs(tokens);
459
+ if (a.error) {
460
+ out(a.error);
461
+ break;
462
+ }
463
+ const r = await act.screencap(client, a.out);
464
+ out(short(r));
465
+ /* 回执路径与请求路径不一致要说:调用方拿到的"写到哪了"必须可信 */
466
+ if (a.out && r.path && resolve(String(r.path)) !== resolve(a.out)) {
467
+ out('警告:实际写入 ' + String(r.path) + ',与请求的 ' + a.out + ' 不一致');
468
+ }
469
+ break;
470
+ }
471
+ case 'stream': {
472
+ const sub = tokens[1] ?? 'status';
473
+ if (sub === 'start') {
474
+ const fps = readFlag(tokens, '--fps');
475
+ const scale = readFlag(tokens, '--scale');
476
+ const blockThresh = readFlag(tokens, '--block-thresh');
477
+ const changeGlobal = readFlag(tokens, '--change-global');
478
+ const l0Bytes = readFlag(tokens, '--l0-bytes');
479
+ /* 观测策略参数:l0-roll 决定"我有多久回看窗口"(默认 16 帧 ≈ 1.6s @10fps),
480
+ * max-frames 决定环形缓冲长度。协议本就支持,命令面必须可达——
481
+ * 不可达的参数会被调用方当成"没有"。 */
482
+ const maxFrames = readFlag(tokens, '--max-frames');
483
+ const l0Roll = readFlag(tokens, '--l0-roll');
484
+ const l0Anchor = readFlag(tokens, '--l0-anchor');
485
+ out(short(await act.streamStart(client, {
486
+ ...(fps ? { fps: Number(fps) } : {}),
487
+ ...(scale ? { scale: Number(scale) } : {}),
488
+ ...(blockThresh ? { blockThresh: Number(blockThresh) } : {}),
489
+ ...(changeGlobal ? { changeGlobal: Number(changeGlobal) } : {}),
490
+ ...(l0Bytes ? { l0Bytes: Number(l0Bytes) } : {}),
491
+ ...(maxFrames ? { maxFrames: Number(maxFrames) } : {}),
492
+ ...(l0Roll ? { l0Roll: Number(l0Roll) } : {}),
493
+ ...(l0Anchor ? { l0Anchor: Number(l0Anchor) } : {}),
494
+ })));
495
+ }
496
+ else if (sub === 'stop')
497
+ out(short(await act.streamStop(client)));
498
+ else
499
+ out(short(await act.streamStatus(client)));
500
+ break;
501
+ }
502
+ case 'wait': {
503
+ /* 状态谓词:等到条件成立再往下读,取代硬睡时钟。超时不算错误(satisfied=false)。 */
504
+ const modeRaw = tokens[1];
505
+ if (modeRaw !== 'stable' && modeRaw !== 'change') {
506
+ out('用法:wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]'
507
+ + '(模式只认 stable / change'
508
+ + (modeRaw ? ';收到 ' + JSON.stringify(modeRaw)
509
+ + (suggestHead(modeRaw) ? ',是想输入 ' + String(suggestHead(modeRaw)) + ' 吗' : '') : '')
510
+ + ')');
511
+ break;
512
+ }
513
+ /* 数值 flag 必须校验:`--timeout abc` 会变成 NaN 一路传下去,
514
+ * 客户端超时算成 NaN → 调用秒超时,看起来像"工具坏了"。 */
515
+ const numOr = (name) => {
516
+ const raw = readFlag(tokens, name);
517
+ if (raw === undefined)
518
+ return undefined;
519
+ const n = Number(raw);
520
+ return Number.isFinite(n) ? n : null;
521
+ };
522
+ const tmo = numOr('--timeout');
523
+ const qt = numOr('--quiet');
524
+ const thr = numOr('--threshold');
525
+ if (tmo === null || qt === null || thr === null) {
526
+ out('--timeout / --quiet / --threshold 必须是数字'
527
+ + (tmo === null ? '(--timeout 收到 ' + JSON.stringify(readFlag(tokens, '--timeout')) + ')' : ''));
528
+ break;
529
+ }
530
+ const roiRaw = readFlag(tokens, '--roi');
531
+ const r = await act.waitState(client, {
532
+ mode: modeRaw,
533
+ ...(tmo !== undefined ? { timeout: tmo } : {}),
534
+ ...(qt !== undefined ? { quiet: qt } : {}),
535
+ ...(thr !== undefined ? { threshold: thr } : {}),
536
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
537
+ });
538
+ out(short(r, 500));
539
+ break;
540
+ }
541
+ case 'frame': {
542
+ const roiRaw = readFlag(tokens, '--roi');
543
+ const srcRaw = readFlag(tokens, '--src');
544
+ const seq = tokens[1] && !tokens[1].startsWith('--') ? Number(tokens[1]) : undefined;
545
+ if (tokens[1] === 'diff') {
546
+ /* 两帧在若干 ROI 上的差分:离散网格要的是"哪几格变了",不是整帧 bbox。 */
547
+ const a = readFlag(tokens, '--a');
548
+ const b = readFlag(tokens, '--b');
549
+ const roisRaw = readFlag(tokens, '--rois');
550
+ if (a === undefined || b === undefined || !roisRaw) {
551
+ out('用法:frame diff --a <seq> --b <seq> --rois "x,y,w,h;x,y,w,h"');
552
+ break;
553
+ }
554
+ const rois = roisRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
555
+ if (rois.some((r) => r.length !== 4 || r.some((n) => !Number.isFinite(n)))) {
556
+ out('--rois 每段要 4 个数字,段间用 ; 分隔');
557
+ break;
558
+ }
559
+ const d = await act.frameDiff(client, { a: Number(a), b: Number(b), rois });
560
+ out(short(d, 900));
561
+ break;
562
+ }
563
+ out(short(await act.frameGet(client, {
564
+ ...(seq !== undefined ? { seq } : {}),
565
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
566
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
567
+ ...(srcRaw ? { src: srcRaw } : {}),
568
+ })));
569
+ break;
570
+ }
571
+ case 'color': {
572
+ const roiRaw = readFlag(tokens, '--roi');
573
+ out(short(await act.colorProbe(client, {
574
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
575
+ }), 400));
576
+ break;
577
+ }
578
+ case 'crop': {
579
+ const roiRaw = readFlag(tokens, '--roi');
580
+ const pointRaw = readFlag(tokens, '--point');
581
+ const padRaw = readFlag(tokens, '--pad');
582
+ /* --from-kf:源换成库内留存帧(跨会话可用)。解析走与一次性 CLI 同一份离线读侧。 */
583
+ const kfId = readFlag(tokens, '--from-kf');
584
+ let kfSource;
585
+ if (kfId) {
586
+ const k = kfSourceFrom(kfId, tokens);
587
+ if (k.error) {
588
+ out(k.error);
589
+ break;
590
+ }
591
+ kfSource = k.source;
592
+ }
593
+ const ck = await act.tplCrop(client, {
594
+ ...(roiRaw ? { roi: roiRaw.split(',').map((x) => Number(x)) } : {}),
595
+ ...(pointRaw ? { point: pointRaw.split(',').map((x) => Number(x)) } : {}),
596
+ ...(padRaw ? { pad: Number(padRaw) } : {}),
597
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
598
+ ...(kfSource ? { kfSource, cross: false } : {}),
599
+ ...(readFlag(tokens, '--resource-dir')
600
+ ? { resourceDir: readFlag(tokens, '--resource-dir') }
601
+ : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
602
+ });
603
+ out(short(ck, 600));
604
+ if (ck.ok !== false && ck.provPath)
605
+ out('L2 出处:' + String(ck.provPath));
606
+ break;
607
+ }
608
+ case 'annotate': {
609
+ /* --from-kf:源换成库内留存帧(离线;diff 源不可用,工具会如实报) */
610
+ const kfSom = readFlag(tokens, '--from-kf');
611
+ let somKf;
612
+ if (kfSom) {
613
+ const k = kfSourceFrom(kfSom, tokens);
614
+ if (k.error) {
615
+ out(k.error);
616
+ break;
617
+ }
618
+ somKf = k.source;
619
+ }
620
+ const somLimitRaw = readFlag(tokens, '--som-limit');
621
+ const somLimit = somLimitRaw === undefined ? undefined : Number(somLimitRaw);
622
+ if (somLimitRaw !== undefined && (!Number.isFinite(somLimit) || somLimit <= 0)) {
623
+ out('--som-limit 必须是正整数(收到 ' + JSON.stringify(somLimitRaw) + ');0 不等于"不限量"');
624
+ break;
625
+ }
626
+ const somRatioRaw = readFlag(tokens, '--som-max-area-ratio');
627
+ const somRatio = somRatioRaw === undefined ? undefined : Number(somRatioRaw);
628
+ if (somRatioRaw !== undefined && (!Number.isFinite(somRatio) || somRatio <= 0 || somRatio >= 1)) {
629
+ out('--som-max-area-ratio 要在 (0,1) 之间(收到 ' + JSON.stringify(somRatioRaw) + ');0 不等于"关闭剔除"');
630
+ break;
631
+ }
632
+ const r = await act.annotate(client, {
633
+ ...(readFlag(tokens, '--out') ? { out: readFlag(tokens, '--out') } : {}),
634
+ ...(somKf ? { kfSource: somKf } : {}),
635
+ ...(somLimit !== undefined ? { somLimit } : {}),
636
+ ...(somRatio !== undefined ? { somMaxAreaRatio: somRatio } : {}),
637
+ ...(readFlag(tokens, '--roi') ? { roi: String(readFlag(tokens, '--roi')).split(',').map((x) => Number(x)) } : {}),
638
+ ...(readFlag(tokens, '--resource-dir')
639
+ ? { resourceDir: readFlag(tokens, '--resource-dir') }
640
+ : (state.plan?.resource?.paths.length ? { resourceDir: state.plan.resource.paths[0] } : {})),
641
+ });
642
+ if (r.ok === false) {
643
+ out('annotate 失败:' + String(r.error));
644
+ break;
645
+ }
646
+ out('SoM ' + String(r.count) + ' 候选 → ' + String(r.out) + (somKf ? '(源:留存帧)' : ''));
647
+ /* 可用性三列:不读它就会把"30 条"当成"画面就这些可点区域"。 */
648
+ const av = r.availability;
649
+ if (av) {
650
+ out(' 可用性:合并池 ' + String(av.mergedTotal ?? '?') + ' → 进表 ' + String(r.count)
651
+ + '(上限 ' + String(av.limit ?? '?') + ',截断 ' + String(av.truncated ?? 0) + ')'
652
+ + ';剔除 巨框 ' + String(av.dropped?.tooBig ?? 0) + ' / 重复 ' + String(av.dropped?.duplicated ?? 0));
653
+ if (av.byBand)
654
+ out(' 分带(上/中/下)进表/池:' + av.byBand.map((b) => String(b.inTable) + '/' + String(b.inPool)).join(' '));
655
+ if (av.outResolution)
656
+ out(' 回画分辨率:' + String(av.outResolution.w) + 'x' + String(av.outResolution.h) + '(' + String(av.outResolution.source) + ')');
657
+ }
658
+ for (const c of r.candidates ?? []) {
659
+ out(' #' + c.id + ' [' + c.source + '] ctrl=' + c.ctrl.join(',')
660
+ + (typeof c.areaRatio === 'number' ? ' 面积比=' + c.areaRatio : '')
661
+ + (typeof c.score === 'number' ? ' score=' + c.score : '')
662
+ + (c.text ? ' "' + c.text + '"' : ''));
663
+ }
664
+ break;
665
+ }
666
+ case 'events': {
667
+ const n = Number(tokens[1] ?? 10);
668
+ const evs = client.events.events.slice(-n);
669
+ out('事件 ' + evs.length + ' 条(共 ' + client.events.events.length + ')');
670
+ /* 每条事件显式带出画面锚点:seq 单独给没有用(L0 是滚动淘汰的,得知道还能不能取到)。
671
+ * 保留 `short(e, 200)` 的其余字段,但锚点不能被截断掉——它是这条事件唯一可行动的部分。 */
672
+ for (const e of evs) {
673
+ const ev = e;
674
+ const f = ev.frame;
675
+ const anchor = !f
676
+ ? ' frame=?(旧 daemon 回执,无锚点)'
677
+ : f.retained === 'l0'
678
+ ? ' frame=l0:' + String(f.seq) + (f.source ? '(' + f.source + ')' : '') + ' → frame get ' + String(f.seq)
679
+ : ' frame=gone(seq ' + String(f.seq) + ' 已滚出 L0,取不到了)';
680
+ out(' ' + short(e, 200) + anchor);
681
+ }
682
+ break;
683
+ }
684
+ case 'logs': {
685
+ const n = Number(tokens[1] ?? 20);
686
+ for (const l of client.logTail(n))
687
+ out(' ' + l);
688
+ break;
689
+ }
690
+ case 'run': {
691
+ const entry = tokens[1];
692
+ if (!entry) {
693
+ out('用法:run <entry> [--timeout ms|0] [--preset name] [--override json] [--resource-dir d] [--option k=v 可多次] [--watch]');
694
+ break;
695
+ }
696
+ const wantWatch = tokens.includes('--watch');
697
+ const rawTimeout = readFlag(tokens, '--timeout');
698
+ const timeout = rawTimeout === undefined ? 30000 : Number(rawTimeout);
699
+ if (!Number.isFinite(timeout)) {
700
+ out('--timeout 必须是毫秒数(收到 ' + JSON.stringify(rawTimeout) + ')');
701
+ break;
702
+ }
703
+ const overrideRaw = readFlag(tokens, '--override');
704
+ let override = {};
705
+ if (overrideRaw) {
706
+ try {
707
+ override = JSON.parse(overrideRaw);
708
+ }
709
+ catch {
710
+ out('--override 不是合法 JSON');
711
+ break;
712
+ }
713
+ }
714
+ /* --option 按次取值(可多次):<键>=<case>、<键>.<字段>=<值>。
715
+ * 与一次性 CLI 同一解析器(parseOptionArgs),判错口径一致。 */
716
+ const optList = readFlagAll(tokens, '--option');
717
+ let optCases;
718
+ let optFields;
719
+ if (optList.length) {
720
+ const parsed = parseOptionArgs(optList);
721
+ if (parsed.error) {
722
+ out(parsed.error);
723
+ break;
724
+ }
725
+ optCases = parsed.cases;
726
+ optFields = parsed.fields;
727
+ }
728
+ /* 资源与 override:--resource-dir 显式单包(bundle 模式);否则走 connect project
729
+ * 的项目规划,与一次性 CLI 共用 prepareProjectRun(入口解析 / task 适用性 /
730
+ * 四级 pipeline_override / agent 桥接都在那一个函数里)。 */
731
+ const explicitDir = readFlag(tokens, '--resource-dir');
732
+ const presetRaw = readFlag(tokens, '--preset');
733
+ const plan = state.plan;
734
+ /* --resource-dir 只换资源来源,**不换入口解析**:connect project 之后即使覆盖了
735
+ * 资源目录,interface 的中文任务名仍要解析成 task.entry(旧行为,别在短路分支里丢)。 */
736
+ const task = plan?.loaded.tasks.find((t) => t.name === entry || t.entry === entry);
737
+ let args;
738
+ let resolvedEntry = task?.entry ?? entry;
739
+ let taskName = task?.name ?? null;
740
+ if (explicitDir) {
741
+ /* bundle 模式不叠 interface 四级链:--preset 没有地方生效,必须说出来,
742
+ * 否则调用方会以为 P1 已经选中(静默丢参数比报错更难查)。 */
743
+ if (presetRaw !== undefined) {
744
+ out('警告:--resource-dir 是 bundle 模式,不叠 interface 四级 override——--preset ' + presetRaw + ' 不生效');
745
+ }
746
+ args = { resourceDir: explicitDir, entry: resolvedEntry, override, timeoutMs: timeout };
747
+ }
748
+ else if (plan) {
749
+ const prep = act.prepareProjectRun({
750
+ project: plan,
751
+ entry,
752
+ presetName: presetRaw ?? null,
753
+ override,
754
+ ...(optCases ? { optionCases: optCases } : {}),
755
+ ...(optFields ? { optionFields: optFields } : {}),
756
+ });
757
+ if (!prep.ok) {
758
+ out('run 拒绝:' + prep.error + (prep.hint ? '\n hint: ' + prep.hint : ''));
759
+ for (const w of prep.warns)
760
+ out(' 警告:' + w);
761
+ break;
762
+ }
763
+ args = prep.args;
764
+ resolvedEntry = prep.entry;
765
+ taskName = prep.taskName;
766
+ /* 生效记录与警示:与一次性 CLI 同一份。REPL 过去只有裸 --override,
767
+ * 项目里的 GUI 选项在会话里静默不生效——调用方看不到任何差异。 */
768
+ for (const l of prep.applied)
769
+ out(' override: ' + l);
770
+ for (const w of prep.warns)
771
+ out(' 警告:' + w);
772
+ }
773
+ else {
774
+ out('缺资源:给 --resource-dir <dir>,或先 connect project <dir>');
775
+ break;
776
+ }
777
+ /* 异步执行不阻塞提示符:--timeout 0 的长任务靠 stop 中断(同一条客户端连接) */
778
+ out('已提交 ' + resolvedEntry + (taskName && taskName !== resolvedEntry ? '(任务 ' + taskName + ')' : '') + '(timeout=' + timeout + ',结果异步打印;stop 可中断)');
779
+ /* --watch:把这次 run 的节点转移事件持续打出来(一次性 CLI 的 --watch 在
780
+ * REPL 里的等价物——没有它就只能手写轮询 events N,8 分钟轮询真机撞出来的账)。
781
+ * 事件源是 daemon 推进来的 client.events.events 数组,只能轮询尾巴;
782
+ * 数组有 2000 上限会从头滚,发现指针越过当前长度就重置。run 结束即停。 */
783
+ let watchTimer = null;
784
+ if (wantWatch) {
785
+ let cursor = client.events.events.length;
786
+ watchTimer = setInterval(() => {
787
+ const evs = client.events.events;
788
+ if (cursor > evs.length)
789
+ cursor = 0; /* 滚出上限重置 */
790
+ for (; cursor < evs.length; cursor++) {
791
+ const e = evs[cursor];
792
+ if (e && e.msg) {
793
+ out(' [watch] ' + String(e.node ?? '-') + ' ' + String(e.msg) +
794
+ (e.id != null ? ' #' + String(e.id) : '') + ' seq=' + String(e.seq ?? '?'));
603
795
  }
604
- out(short(await act.input(client, { kind: 'scroll', dx: a[0], dy: a[1] })));
605
- break;
606
796
  }
607
- case 'move': {
608
- const a = nums(tokens, 1, 2);
609
- if (!a) {
610
- out('用法:move <dx> <dy>');
611
- break;
612
- }
613
- out(short(await act.input(client, { kind: 'move', dx: a[0], dy: a[1] })));
797
+ }, 500);
798
+ watchTimer.unref();
799
+ }
800
+ act.run(client, args, timeout)
801
+ .then((r) => {
802
+ if (watchTimer) {
803
+ clearInterval(watchTimer);
804
+ watchTimer = null;
805
+ }
806
+ /* 回执不再 short() 截断(0.3.x 实测:截断让 record.nodes[] 在会话里永远不可见,
807
+ * "跑失败"变成"看不懂为什么失败")。三件套替代:结论行(保住「run 完成:任务级 X」
808
+ * 首词口径)→ 非 ok 节点点名 → 全量回执落盘给路径。落盘失败不吞结论,退化为完整 JSON。 */
809
+ const rec = r.record;
810
+ /* 与一次性 CLI 同一份三态判据:`status:0 + stopped:true` 不是"完成"。
811
+ * 判据用 record 自带的 timeoutMs,不靠调用方回忆刚才给了什么。 */
812
+ const budget = rec && typeof rec.timeoutMs === 'number' ? rec.timeoutMs : null;
813
+ const ranFull = rec?.stopped === true && budget !== null && budget > 0
814
+ && typeof rec.durationMs === 'number' && rec.durationMs >= budget;
815
+ const verdict = !rec ? '未知'
816
+ : rec.ok ? 'ok'
817
+ : ranFull ? '超时截断(非成功,也非失败)'
818
+ : rec.stopped === true ? '被停止(未成功)'
819
+ : 'FAIL';
820
+ const nodes = rec?.nodes ?? [];
821
+ const okCount = nodes.filter((n) => n.status === 'ok').length;
822
+ const notOk = nodes.filter((n) => n.status !== 'ok');
823
+ out('run 完成:任务级 ' + verdict +
824
+ (rec?.agent ? ',agent ' + (rec.agent.ok ? '已连接' : '失败') : '') +
825
+ ' 节点 ' + nodes.length + ' 例(ok ' + okCount +
826
+ (notOk.length ? ' / 其他 ' + notOk.length : '') + ')');
827
+ for (const n of notOk) {
828
+ out(' [' + String(n.status) + '] ' + String(n.name) + ' ' + String(n.ms) + 'ms #' + String(n.seq ?? '?'));
829
+ }
830
+ /* 落盘走共享助手(目录可用 MAAFW_LIVE_RUNS_DIR 覆盖,单测不写真实 home);
831
+ * 失败不吞结论,退化为完整 JSON。 */
832
+ const receipt = writeRunReceipt(r, resolvedEntry);
833
+ out(receipt.path !== undefined
834
+ ? '完整回执(含全部节点与 msgs):' + receipt.path
835
+ : '回执落盘失败,完整内容:' + JSON.stringify(r));
836
+ })
837
+ .catch((e) => { if (watchTimer) {
838
+ clearInterval(watchTimer);
839
+ watchTimer = null;
840
+ } out('run 失败:' + (e instanceof Error ? e.message : String(e))); });
841
+ break;
842
+ }
843
+ case 'stop':
844
+ out(short(await act.runStop(client)));
845
+ break;
846
+ case 'click': {
847
+ const a = nums(tokens, 1, 2);
848
+ if (!a) {
849
+ out('用法:click <x> <y>');
850
+ break;
851
+ }
852
+ out(short(await act.input(client, { kind: 'click', x: a[0], y: a[1] })));
853
+ break;
854
+ }
855
+ case 'swipe': {
856
+ const a = nums(tokens, 1, 4);
857
+ const duration = numFlag(tokens, '--duration', 300);
858
+ if (!a || duration === null) {
859
+ out('用法:swipe <x1> <y1> <x2> <y2> [--duration ms] [--via "x,y;x,y"]');
860
+ break;
861
+ }
862
+ /* --via:中途路径点。走的是 touch_down→逐点 touch_move→touch_up —— **一条笔画画折线**,
863
+ * 中途不抬手。连线/谱曲/拖拽排序这类玩法要的正是这个;只有两端点时走原生 post_swipe。 */
864
+ const viaRaw = readFlag(tokens, '--via');
865
+ let via;
866
+ if (viaRaw) {
867
+ via = viaRaw.split(';').map((s) => s.split(',').map((x) => Number(x)));
868
+ if (via.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
869
+ out('--via 每段要 2 个数字(x,y),段间用 ; 分隔');
870
+ break;
871
+ }
872
+ }
873
+ out(short(await act.input(client, {
874
+ kind: 'swipe', x1: a[0], y1: a[1], x2: a[2], y2: a[3], duration,
875
+ ...(via ? { via } : {}),
876
+ })));
877
+ break;
878
+ }
879
+ case 'touchdown': {
880
+ const a = nums(tokens, 1, 2);
881
+ if (!a) {
882
+ out('用法:touchdown <x> <y>');
883
+ break;
884
+ }
885
+ out(short(await act.input(client, { kind: 'touch_down', x: a[0], y: a[1] })));
886
+ break;
887
+ }
888
+ case 'touchmove': {
889
+ const a = nums(tokens, 1, 2);
890
+ if (!a) {
891
+ out('用法:touchmove <x> <y>');
892
+ break;
893
+ }
894
+ out(short(await act.input(client, { kind: 'touch_move', x: a[0], y: a[1] })));
895
+ break;
896
+ }
897
+ case 'touchup':
898
+ out(short(await act.input(client, { kind: 'touch_up' })));
899
+ break;
900
+ case 'key': {
901
+ const a = nums(tokens, 1, 1);
902
+ if (!a) {
903
+ out('用法:key <code>(Android KeyEvent 码:3=Home、4=返回)');
904
+ break;
905
+ }
906
+ out(short(await act.input(client, { kind: 'key', code: a[0] })));
907
+ break;
908
+ }
909
+ case 'keys': {
910
+ /* 键码只保留正整数:与 CLI/daemon 同一条口径,空集合直接报用法 */
911
+ const codes = String(tokens[1] ?? '').split(/[+,]/)
912
+ .map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0);
913
+ if (!codes.length) {
914
+ out('用法:keys <code[,code+...]>(至少一个正整数键码,如 17,67)');
915
+ break;
916
+ }
917
+ out(short(await act.input(client, { kind: 'keys', codes })));
918
+ break;
919
+ }
920
+ case 'press': {
921
+ const a = nums(tokens, 1, 2);
922
+ const duration = numFlag(tokens, '--duration', 800);
923
+ if (!a || duration === null) {
924
+ out('用法:press <x> <y> [--duration ms]');
925
+ break;
926
+ }
927
+ out(short(await act.input(client, { kind: 'press', x: a[0], y: a[1], duration })));
928
+ break;
929
+ }
930
+ case 'dbclick': {
931
+ const a = nums(tokens, 1, 2);
932
+ if (!a) {
933
+ out('用法:dbclick <x> <y>');
934
+ break;
935
+ }
936
+ out(short(await act.input(client, { kind: 'dbclick', x: a[0], y: a[1] })));
937
+ break;
938
+ }
939
+ case 'scroll': {
940
+ const a = nums(tokens, 1, 2);
941
+ if (!a) {
942
+ out('用法:scroll <dx> <dy>(建议 120 的倍数)');
943
+ break;
944
+ }
945
+ out(short(await act.input(client, { kind: 'scroll', dx: a[0], dy: a[1] })));
946
+ break;
947
+ }
948
+ case 'move': {
949
+ const a = nums(tokens, 1, 2);
950
+ if (!a) {
951
+ out('用法:move <dx> <dy>');
952
+ break;
953
+ }
954
+ out(short(await act.input(client, { kind: 'move', dx: a[0], dy: a[1] })));
955
+ break;
956
+ }
957
+ case 'text':
958
+ out(short(await act.input(client, { kind: 'text', text: line.trim().slice(5) })));
959
+ break;
960
+ case 'reco': {
961
+ /* 类型是位置参数:下一个 token 以 `--` 开头的是选项、含 `=` 的是 k=v 参数,都不是类型。
962
+ * 不做这一步,`reco --param-file p.json` 会把 type 设成字符串 "--param-file"(frame 早就
963
+ * 按同一惯例跳过 `--` 开头)。 */
964
+ const first = tokens[1];
965
+ const type = first !== undefined && !first.startsWith('--') && !first.includes('=')
966
+ ? first
967
+ : 'TemplateMatch';
968
+ const param = recoParam(tokens);
969
+ const sweepRaw = readFlag(tokens, '--sweep');
970
+ /* 节点/参数来源:与一次性 CLI 同一组互斥、同一份 JSONC 读取(判据只有一份)。
971
+ * REPL 的 stdin 是**命令流**,所以 `-`(stdin)在这里没有意义:直接拒绝,不去读会话输入。 */
972
+ const nodeFileSpec = readFlag(tokens, '--node-file');
973
+ const nodeName = readFlag(tokens, '--node-name');
974
+ const paramFileSpec = readFlag(tokens, '--param-file');
975
+ if (nodeFileSpec === '-' || paramFileSpec === '-') {
976
+ out('REPL 里 --node-file / --param-file 不接受 -:- 的意思是 stdin,而 REPL 的 stdin 是命令流。'
977
+ + '给文件路径,或改用一次性命令由 shell 管 stdin');
978
+ break;
979
+ }
980
+ if (nodeFileSpec !== undefined && nodeName !== undefined) {
981
+ out('--node-file 与 --node-name 互斥:一个测文件里的节点,一个按资源原名跑');
982
+ break;
983
+ }
984
+ let node;
985
+ if (nodeFileSpec !== undefined) {
986
+ const f = readNodeFile(nodeFileSpec);
987
+ if (f.error) {
988
+ out(f.error + (f.hint ? '\n hint: ' + f.hint : ''));
989
+ break;
990
+ }
991
+ node = f.node;
992
+ }
993
+ let paramFromFile;
994
+ if (paramFileSpec !== undefined) {
995
+ if (Object.keys(param).length > 0) {
996
+ out('k=v 参数与 --param-file 互斥:一个给字面值,一个给文件');
997
+ break;
998
+ }
999
+ const f = readParamFile(paramFileSpec);
1000
+ if (f.error) {
1001
+ out(f.error);
1002
+ break;
1003
+ }
1004
+ paramFromFile = f.param;
1005
+ }
1006
+ const effectiveParam = paramFromFile ?? param;
1007
+ if ((node || nodeName !== undefined) && (Object.keys(effectiveParam).length > 0 || sweepRaw !== undefined)) {
1008
+ out('k=v 参数 / --param-file / --sweep 只作用于 <type> 路径:与 --node-file / --node-name 同给时不会生效');
1009
+ break;
1010
+ }
1011
+ /* 资源链:--resource-dir 是显式单包;否则取 connect project 规划出的**整条链**
1012
+ * (resource.path[] + 控制器 attach 段——只取 [0] 会让 overlay/attach 里的节点改写、
1013
+ * 只存在于 overlay 的模板、被 And/Or 按名引用的子节点全部消失)。缺了资源 daemon 只会
1014
+ * 给出零例数(模板/OCR 模型都读不到)——这是 G16 证据里 `[]` 与 `ok:false` 两种签名
1015
+ * 并存的第三个混淆变量,所以**前置拦下并说清**,不再让它静默。 */
1016
+ const explicitDir = readFlag(tokens, '--resource-dir');
1017
+ const chain = explicitDir !== undefined ? [explicitDir] : (state.plan?.resource?.paths ?? []);
1018
+ if (chain.length === 0) {
1019
+ out('缺资源目录:reco 的模板/OCR 模型要靠它加载,不给只会得到零例数(看不出病因)。'
1020
+ + '先给 --resource-dir <dir>,或 connect project <dir>');
1021
+ break;
1022
+ }
1023
+ const resources = chain.length > 1 ? { resourceDirs: chain } : { resourceDir: chain[0] };
1024
+ /* --image / --seq / --from-kf:图像来源三条与一次性 CLI 一致,由 daemon 按
1025
+ * 「kfSource → image → 缓冲帧」的次序取(`--image` 优先于 `--seq`,不另行报错——
1026
+ * 那会再造一套 repl/CLI 规则差)。 */
1027
+ const imageRaw = readFlag(tokens, '--image');
1028
+ const seqRaw = readFlag(tokens, '--seq');
1029
+ const kfId = readFlag(tokens, '--from-kf') ?? (imageRaw?.startsWith('kf:') ? imageRaw : undefined);
1030
+ let kfSource;
1031
+ if (kfId) {
1032
+ const k = kfSourceFrom(kfId, tokens);
1033
+ if (k.error) {
1034
+ out(k.error);
1035
+ break;
1036
+ }
1037
+ kfSource = k.source;
1038
+ }
1039
+ const seqNum = seqRaw === undefined ? undefined : Number(seqRaw);
1040
+ if (seqRaw !== undefined && !Number.isFinite(seqNum)) {
1041
+ out('--seq 必须是数字(收到 ' + JSON.stringify(seqRaw) + ')');
1042
+ break;
1043
+ }
1044
+ const nodeSource = node !== undefined || nodeName !== undefined;
1045
+ const r = await act.recoTest(client, {
1046
+ ...resources, type,
1047
+ ...(node ? { node } : {}),
1048
+ ...(nodeName !== undefined ? { nodeName } : {}),
1049
+ ...(imageRaw && !kfId ? { image: imageRaw } : {}),
1050
+ ...(seqNum !== undefined ? { seq: seqNum } : {}),
1051
+ ...(kfSource ? { kfSource } : {}),
1052
+ ...(nodeSource ? {} : { param: effectiveParam }),
1053
+ ...(nodeSource ? {} : (sweepRaw ? { sweep: JSON.parse(sweepRaw) } : {})),
1054
+ });
1055
+ /* 能力边界与"查无此节点"是明确结论,不该落进"看起来像 miss" */
1056
+ if (r.ok === false && (r.code === 'CUSTOM_NODE' || r.code === 'NODE_MISSING')) {
1057
+ const cands = Array.isArray(r.candidates) ? r.candidates.map(String) : [];
1058
+ out(String(r.error) + (r.code === 'CUSTOM_NODE'
1059
+ ? '\n hint: custom/agent 识别要桥接 agent 子进程,用 run --project 验;reco 只测资源合并后的普通节点'
1060
+ : (cands.length
1061
+ ? '\n hint: 资源里最接近的节点名:' + cands.join(' / ')
1062
+ : '\n hint: 确认资源名与链(connect project 的 --resource)是否正确;手写节点用 --node-file')));
1063
+ break;
1064
+ }
1065
+ /* roi 越界:repl 没有单命令退出码(末行统一 EXIT.OK),照本文件既有惯例
1066
+ * 「打印显式错误 + break」;一次性 CLI 侧同一判据走 EXIT.FINDINGS。 */
1067
+ const meta = r.meta;
1068
+ const oob = roiOutOfBounds(nodeSource ? undefined : (typeof effectiveParam.roi === 'object' ? effectiveParam.roi : undefined), meta?.w, meta?.h);
1069
+ if (oob) {
1070
+ out(oob);
1071
+ break;
1072
+ }
1073
+ /* 离线评估(静态图/库帧)标记:防"拿旧截图出分冒充实测"(G21 同族事故)。
1074
+ * 分数可用于调参/回归,不构成真机验收证据。 */
1075
+ if ((imageRaw && !kfId) || kfSource) {
1076
+ out('⚠ 离线评估(' + (kfSource ? '库帧 --from-kf' : '静态图 --image') +
1077
+ '):分数可用于调参/回归,不构成真机验收证据——验收只认真机 run 的 record.ok');
1078
+ }
1079
+ if (nodeName !== undefined) {
1080
+ out('layer=resource:只到资源合并结果(run --project 还会再叠 interface 的四级 pipeline_override)');
1081
+ }
1082
+ /* miss 原因与计数:与一次性 CLI 同一份判据(runtime/reco-verdict)。daemon 只回原始
1083
+ * detail,`missReason` 是本地派生的——不派生就等于没有任何输出,只能回去翻 all/filtered。 */
1084
+ const results = r.results ?? [];
1085
+ const th = suggestThreshold(results);
1086
+ const shownType = String(r.type ?? type);
1087
+ /* OCR 空结果先查模型在不在链上:模型没加载时框架只回空结果,与"roi 里没有字"同形 */
1088
+ const ocrModel = shownType === 'OCR'
1089
+ ? ocrModelStatus(chain, ocrModelName(node, effectiveParam, r.nodeDump))
1090
+ : undefined;
1091
+ const verdict = describeMisses(results, shownType, th ? { bestScore: th.bestScore, lowMatch: th.lowMatch } : null, ocrModel ? { ocrModel } : {});
1092
+ /* 调用报错与未命中分开读:前者是"没跑成",别混进"未命中原因"那一行;
1093
+ * 两者可以同时出现(扫描里一例报错、另一例真未命中),不能因为报了错就吞掉后者。 */
1094
+ const failed = verdict.perResult.filter((d) => d.failed);
1095
+ if (failed.length > 0) {
1096
+ const msgs = [...new Set(failed.map((d) => String(d.missReason ?? '')))];
1097
+ out('识别调用报错 ' + failed.length + ' 例([报错],不是未命中):' +
1098
+ msgs.slice(0, 2).join(' / ') + (msgs.length > 2 ? ' …' : ''));
1099
+ }
1100
+ const missReasons = [...new Set(verdict.perResult
1101
+ .filter((d) => !d.failed && d.missReason)
1102
+ .map((d) => String(d.missReason)))];
1103
+ if (missReasons.length > 0)
1104
+ out('未命中原因:' + missReasons.join(' / '));
1105
+ if (verdict.rawCount !== null) {
1106
+ out(' 候选 all=' + verdict.rawCount +
1107
+ (verdict.filteredCount !== null ? ' filtered=' + verdict.filteredCount : ''));
1108
+ }
1109
+ out(short(r, 800));
1110
+ break;
1111
+ }
1112
+ case 'kf': {
1113
+ const sub = tokens[1] ?? 'status';
1114
+ if (sub === 'status') {
1115
+ out(short(await act.l0Status(client), 600));
1116
+ }
1117
+ else if (sub === 'promote') {
1118
+ const note = readFlag(tokens, '--note');
1119
+ /* 批量:kf promote 1 2 3 | kf promote 10..15 | kf promote 1,2,3。逐个升格,
1120
+ * 淘汰的 seq 附 frame_get 降级命令(daemon 已把 hint 编进 error 文案)。 */
1121
+ const seqToks = tokens.slice(2).filter((t) => t !== '--note' && t !== String(note)).flatMap((t) => t.split(',')).filter((t) => t !== '');
1122
+ const range = seqToks.length === 1 && /^(\d+)\.\.(\d+)$/.exec(seqToks[0]);
1123
+ let pArgs;
1124
+ if (range) {
1125
+ const a = Number(range[1]), b = Number(range[2]);
1126
+ if (a > b || b - a > 200) {
1127
+ out('范围非法(a..b 且跨度 ≤200)');
614
1128
  break;
615
1129
  }
616
- case 'text':
617
- out(short(await act.input(client, { kind: 'text', text: line.trim().slice(5) })));
618
- break;
619
- case 'reco': {
620
- const type = tokens[1] ?? 'TemplateMatch';
621
- const param = {};
622
- for (const t of tokens.slice(2)) {
623
- const eq = t.indexOf('=');
624
- if (eq > 0) {
625
- const k = t.slice(0, eq);
626
- const v = t.slice(eq + 1);
627
- param[k] = Number.isFinite(Number(v)) && v !== '' ? Number(v) : v;
628
- }
629
- }
630
- const sweepRaw = readFlag(tokens, '--sweep');
631
- const resourceDir = readFlag(tokens, '--resource-dir') ?? '';
632
- out(short(await act.recoTest(client, {
633
- resourceDir, type, param,
634
- ...(sweepRaw ? { sweep: JSON.parse(sweepRaw) } : {}),
635
- }), 800));
1130
+ pArgs = { seqs: Array.from({ length: b - a + 1 }, (_, i) => a + i), ...(note ? { note } : {}) };
1131
+ }
1132
+ else if (seqToks.length > 1) {
1133
+ const nums = seqToks.map((t) => Number(t));
1134
+ if (nums.some((n) => !Number.isInteger(n) || n <= 0)) {
1135
+ out('seq 必须是正整数');
636
1136
  break;
637
1137
  }
638
- case 'kf': {
639
- const sub = tokens[1] ?? 'status';
640
- if (sub === 'status') {
641
- out(short(await act.l0Status(client), 600));
642
- }
643
- else if (sub === 'promote') {
644
- const what = tokens[2];
645
- const note = readFlag(tokens, '--note');
646
- const r = await act.kfPromote(client, /^\d+$/.test(String(what))
647
- ? { seq: Number(what), ...(note ? { note } : {}) }
648
- : { latest: true, ...(note ? { note } : {}) });
649
- if (r.ok === false)
650
- out('升格失败:' + String(r.error));
651
- else
652
- out('已升格 ' + String(r.id) + ' → ' + String(r.path) + (r.idempotent ? '(幂等重试)' : ''));
653
- }
654
- else if (sub === 'list') {
655
- const { manifest, error } = loadManifest(defaultFramesDir());
656
- if (error) {
657
- out('库不可读:' + error);
658
- break;
659
- }
660
- if (!manifest) {
661
- out('关键帧库尚无留存');
662
- break;
663
- }
664
- out('关键帧库 ' + manifest.frames.length + ' 条(libraryId ' + manifest.libraryId + ')');
665
- for (const f of manifest.frames)
666
- out(' ' + describeRecord(f));
667
- }
668
- else if (sub === 'resolve') {
669
- const r = resolveFrame(defaultFramesDir(), String(tokens[2] ?? ''));
670
- out(r.status + (r.reason ? '(' + r.reason + ')' : '') + (r.path ? ' ' + r.path : ''));
671
- }
672
- else {
673
- out('用法:kf status | kf promote [seq|latest] [--note s] | kf list | kf resolve <kf:...>');
674
- }
675
- break;
1138
+ pArgs = { seqs: nums, ...(note ? { note } : {}) };
1139
+ }
1140
+ else if (seqToks.length === 1 && /^\d+$/.test(seqToks[0])) {
1141
+ pArgs = { seq: Number(seqToks[0]), ...(note ? { note } : {}) };
1142
+ }
1143
+ else {
1144
+ pArgs = { latest: true, ...(note ? { note } : {}) };
1145
+ }
1146
+ const r = await act.kfPromote(client, pArgs);
1147
+ if (Array.isArray(r.results)) {
1148
+ for (const it of r.results) {
1149
+ if (it.ok)
1150
+ out(' seq=' + it.seq + ' → ' + it.id);
1151
+ else
1152
+ out(' seq=' + it.seq + ' ✗ ' + it.error);
676
1153
  }
677
- default:
678
- out('未知命令:' + head
679
- + (suggestHead(head) ? '(是想输入 ' + String(suggestHead(head)) + ' 吗?)' : '')
680
- + '(输入 help 看命令表)');
1154
+ out('批量:' + String(r.promoted) + ' 成功 / ' + String(r.failed) + ' 失败');
681
1155
  }
682
- }
683
- catch (e) {
684
- if (e instanceof SessionError)
685
- out('会话错误 [' + e.code + ']:' + e.message);
1156
+ else if (r.ok === false)
1157
+ out('升格失败:' + String(r.error));
686
1158
  else
687
- out('错误:' + (e instanceof Error ? e.message : String(e)));
1159
+ out('已升格 ' + String(r.id) + ' → ' + String(r.path) + (r.idempotent ? '(幂等重试)' : ''));
1160
+ }
1161
+ else if (sub === 'list') {
1162
+ const { manifest, error } = loadManifest(defaultFramesDir());
1163
+ if (error) {
1164
+ out('库不可读:' + error);
1165
+ break;
1166
+ }
1167
+ if (!manifest) {
1168
+ out('关键帧库尚无留存');
1169
+ break;
1170
+ }
1171
+ out('关键帧库 ' + manifest.frames.length + ' 条(libraryId ' + manifest.libraryId + ')');
1172
+ for (const f of manifest.frames)
1173
+ out(' ' + describeRecord(f));
1174
+ }
1175
+ else if (sub === 'resolve') {
1176
+ const r = resolveFrame(defaultFramesDir(), String(tokens[2] ?? ''));
1177
+ out(r.status + (r.reason ? '(' + r.reason + ')' : '') + (r.path ? ' ' + r.path : ''));
1178
+ }
1179
+ else {
1180
+ out('用法:kf status | kf promote [seq|latest] [--note s] | kf list | kf resolve <kf:...>');
688
1181
  }
1182
+ break;
1183
+ }
1184
+ default:
1185
+ out('未知命令:' + head
1186
+ + (suggestHead(head) ? '(是想输入 ' + String(suggestHead(head)) + ' 吗?)' : '')
1187
+ + '(输入 help 看命令表)');
1188
+ }
1189
+ }
1190
+ catch (e) {
1191
+ if (e instanceof SessionError)
1192
+ out('会话错误 [' + e.code + ']:' + e.message);
1193
+ else
1194
+ out('错误:' + (e instanceof Error ? e.message : String(e)));
1195
+ }
1196
+ return 'continue';
1197
+ }
1198
+ /* ─────────────────── 命名常驻会话(`--session <name>`)─────────────────── */
1199
+ /** 调用侧:复用已存在的会话,或首次起一个再送命令。 */
1200
+ async function runNamedSession(name, ctx) {
1201
+ const wantStatus = ctx.values['session-status'] === true;
1202
+ const wantStop = ctx.values['session-stop'] === true;
1203
+ /* 命令收集:**只认 `--cmd`**。
1204
+ *
1205
+ * 为什么不用裸位置参数:位置参数会先被 repl 自己的选项表解析,于是
1206
+ * `repl --session cc run X --timeout 0`
1207
+ * 里的 `--timeout` 被当成 repl 的选项直接 BAD_ARGUMENTS 拒掉。而绕开的写法
1208
+ * (整条命令加引号)是个"必须记住"的坑,且退化形态更危险——实测
1209
+ * `repl --session cc screencap out.png`
1210
+ * 里的位置参数被当成**两条命令**:`screencap`(用默认路径真截了一张、丢掉结果)
1211
+ * + `out.png`(报未知命令),也就是**静默执行了错误的动作**。
1212
+ * 所以这里只收 `--cmd`(可重复),并把裸位置参数当成明确的用法错误报出来,
1213
+ * 不给"看起来成功了"的歧义空间。 */
1214
+ const rawCmds = ctx.values.cmd;
1215
+ const cmds = Array.isArray(rawCmds)
1216
+ ? rawCmds.filter((c) => typeof c === 'string')
1217
+ : typeof rawCmds === 'string' ? [rawCmds] : [];
1218
+ if (ctx.positionals.length) {
1219
+ return fail('BAD_ARGUMENTS', '命名会话的命令要用 --cmd 传(收到位置参数 ' + JSON.stringify(ctx.positionals) + ')', '例:maafw-live repl --session ' + name + ' --cmd "run X --timeout 0"\n' +
1220
+ ' 位置参数不加 --cmd 时会被当成多条独立命令(`screencap out.png` 会被拆成两条),\n' +
1221
+ ' 所以这里直接拒掉,不猜你的意图', EXIT.USAGE);
1222
+ }
1223
+ if (wantStop) {
1224
+ const meta = readSessionMeta(name);
1225
+ if (!meta)
1226
+ return { exitCode: EXIT.OK, human: ['会话 ' + name + ' 不存在(无需收)'], data: { name, existed: false } };
1227
+ stopSession(name);
1228
+ return { exitCode: EXIT.OK, human: ['已收掉会话 ' + name + '(pid ' + meta.pid + ')'], data: { name, pid: meta.pid } };
1229
+ }
1230
+ const existing = readSessionMeta(name);
1231
+ if (wantStatus) {
1232
+ if (!existing)
1233
+ return { exitCode: EXIT.OK, human: ['会话 ' + name + ':不存在'], data: { name, exists: false } };
1234
+ const alive = pidAlive(existing.pid);
1235
+ return {
1236
+ exitCode: EXIT.OK,
1237
+ human: [
1238
+ '会话 ' + name + ':' + (alive ? '在跑' : '**已死**(meta 还在,但进程没了——用 --session-stop 清掉重起)'),
1239
+ ' pid ' + existing.pid + ',起于 ' + existing.startedAt,
1240
+ ' 管道 ' + existing.pipe,
1241
+ ' 连接参数 ' + JSON.stringify(existing.connectArgs),
1242
+ ' CLI 版本 ' + (existing.cliVersion ?? '(旧 meta,未记)') +
1243
+ (existing.cliVersion && existing.cliVersion !== cliPackage().version
1244
+ ? ' —— 当前 ' + cliPackage().version + ',混版'
1245
+ : ''),
1246
+ ],
1247
+ data: { ...existing, alive },
1248
+ };
1249
+ }
1250
+ /* 起会话:把本次的连接参数原样交给常驻进程(同一套 CLI 旗标,不另造参数面) */
1251
+ let meta = existing;
1252
+ if (!meta || !pidAlive(meta.pid)) {
1253
+ if (meta)
1254
+ stopSession(name);
1255
+ /* 连接参数透传 = CONNECT_OPTIONS 全集:之前只传 7 个字符串项,--no-pretask /
1256
+ * --short-side / --long-side / --raw 在 --session 下被静默丢掉(P0-2)。
1257
+ * 布尔值以裸旗标形态进 argv(connectArgsToArgv 对 true 只发 --k)。 */
1258
+ const connectArgs = {};
1259
+ for (const k of ['project', 'controller', 'resource', 'hwnd', 'address', 'kind', 'target', 'short-side', 'long-side']) {
1260
+ const v = ctx.values[k];
1261
+ if (typeof v === 'string')
1262
+ connectArgs[k] = v;
1263
+ }
1264
+ for (const k of ['raw', 'no-pretask']) {
1265
+ if (ctx.values[k] === true)
1266
+ connectArgs[k] = true;
1267
+ }
1268
+ meta = spawnSessionServer(name, process.argv[1] ?? '', connectArgs);
1269
+ const ready = await waitPipeReady(meta.pipe, 20000);
1270
+ if (!ready) {
1271
+ /* 真因几乎都在常驻进程自己的日志里(最常见的两种:POSIX 残留 socket 的 EADDRINUSE、
1272
+ * 连接参数不合法)。不读回来就只剩"20s 不可连"这种没法行动的说法。 */
1273
+ const tail = sessionServeLogTail(name);
1274
+ stopSession(name);
1275
+ return fail('SESSION_NOT_READY', '会话 ' + name + ' 起了但管道 ' + meta.pipe + ' 20s 内不可连(常驻进程启动即失败)', (tail.length ? '常驻进程日志尾部:\n ' + tail.join('\n ') + '\n' : '') +
1276
+ ' 常见原因:POSIX 上残留 socket 未被清(删掉 ' + meta.pipe + ' 重试);连接参数不合法(多设备未给 --address)', EXIT.FINDINGS);
1277
+ }
1278
+ }
1279
+ /* 不带 --cmd = 交互式:把本进程的 stdin 逐行送进常驻会话,输出照原样打印。
1280
+ * 这样 `repl --session x` 的手感与普通 repl 一致,但连接状态跨调用保持。 */
1281
+ if (!cmds.length) {
1282
+ const rl = readline.createInterface({ input: process.stdin, terminal: process.stdin.isTTY === true });
1283
+ const lines = [];
1284
+ let wake = null;
1285
+ let ended = false;
1286
+ rl.on('line', (l) => { lines.push(l); if (wake) {
1287
+ const w = wake;
1288
+ wake = null;
1289
+ w();
1290
+ } });
1291
+ rl.on('close', () => { ended = true; if (wake) {
1292
+ const w = wake;
1293
+ wake = null;
1294
+ w();
1295
+ } });
1296
+ const ask = async () => {
1297
+ for (;;) {
1298
+ if (lines.length)
1299
+ return lines.shift() ?? null;
1300
+ if (ended)
1301
+ return null;
1302
+ await new Promise((r) => { wake = r; });
1303
+ }
1304
+ };
1305
+ try {
1306
+ for (;;) {
1307
+ if (process.stdin.isTTY)
1308
+ process.stdout.write('maa[' + name + ']> ');
1309
+ const l = await ask();
1310
+ if (l === null)
1311
+ break;
1312
+ if (!l.trim())
1313
+ continue;
1314
+ const rep = await sendToSession(meta, l, 600000);
1315
+ for (const o of rep.lines)
1316
+ process.stdout.write(o + '\n');
1317
+ if (rep.error)
1318
+ process.stdout.write('错误:' + rep.error + '\n');
1319
+ if (rep.closing || /^(quit|exit)\s*$/i.test(l.trim()))
1320
+ break;
689
1321
  }
690
1322
  }
691
1323
  finally {
692
1324
  rl.close();
1325
+ }
1326
+ return { exitCode: EXIT.OK, human: ['会话 ' + name + ' 保持运行(用 --session-stop ' + name + ' 收掉)'], data: { name, pid: meta.pid } };
1327
+ }
1328
+ /* 带 --cmd:逐条串行送进会话。能翻成 argv 的走 exec(结构化 result 随回执回来——
1329
+ * --json 下信封才有命令数据,不再只剩 {name,pid});REPL 方言(quit/stream/logs/
1330
+ * wait stable 这类)仍走 line 路径由常驻侧 switch 解释。 */
1331
+ const human = [];
1332
+ const results = [];
1333
+ let exitCode = EXIT.OK;
1334
+ for (const line of cmds) {
1335
+ const argv = replToArgv(line);
1336
+ if (argv) {
1337
+ const rep = await sendToSessionExec(meta, argv, 600000);
1338
+ /* rep.lines = 待认领输出(连接横幅、上一条异步收尾)+ 本命令 human——并入
1339
+ * result.human,否则 --json 下横幅与异步尾巴整条丢(forwardToSession 同款逻辑) */
1340
+ if (rep.result && rep.lines.length)
1341
+ rep.result.human = rep.lines;
1342
+ human.push(...rep.lines);
1343
+ if (rep.error)
1344
+ human.push('错误:' + rep.error);
1345
+ results.push(rep.result ?? null);
1346
+ if (exitCode === EXIT.OK && rep.result && rep.result.exitCode !== EXIT.OK) {
1347
+ exitCode = rep.result.exitCode;
1348
+ }
1349
+ if (exitCode === EXIT.OK && !rep.result && rep.error)
1350
+ exitCode = EXIT.FAIL;
1351
+ continue;
1352
+ }
1353
+ const rep = await sendToSession(meta, line, 600000);
1354
+ human.push(...rep.lines);
1355
+ results.push(null);
1356
+ if (rep.error) {
1357
+ human.push('错误:' + rep.error);
1358
+ if (exitCode === EXIT.OK)
1359
+ exitCode = EXIT.FAIL;
1360
+ }
1361
+ if (rep.closing || /^(quit|exit)\s*$/i.test(line.trim())) {
1362
+ stopSession(name);
1363
+ break;
1364
+ }
1365
+ }
1366
+ return {
1367
+ exitCode,
1368
+ human,
1369
+ /* results 与 cmds 对齐;REPL 方言条目为 null(line 路径没有结构化结果) */
1370
+ data: { name, pid: meta.pid, results },
1371
+ };
1372
+ }
1373
+ /**
1374
+ * REPL 行 → argv 的方言翻译表。翻不了(REPL 私有命令)返回 null,调用方走 line 路径。
1375
+ * 这是会话"两个实现漂移"的止血带:翻译完进的是 dispatchArgv,与一次性 CLI 同一份
1376
+ * 实现——`run T --option` 在 --cmd 里从此生效(P0-3)。
1377
+ */
1378
+ export function replToArgv(line) {
1379
+ const t = tokenize(line);
1380
+ const head = t[0] ?? '';
1381
+ switch (head) {
1382
+ case 'connect': {
1383
+ if (t[1] === 'project')
1384
+ return ['connect', '--project', ...(t.slice(2).length ? t.slice(2) : [])];
1385
+ if (t[1] === 'win32') {
1386
+ if (!t[2])
1387
+ return null;
1388
+ return ['connect', '--kind', 'win32', '--target', t[2], ...t.slice(3)];
1389
+ }
1390
+ if (t[1] === 'adb') {
1391
+ if (!t[2])
1392
+ return null;
1393
+ return ['connect', '--kind', 'adb', '--address', t[2], ...t.slice(3)];
1394
+ }
1395
+ /* `connect --project d` 这类直接就是 argv 形态 */
1396
+ if (t[1]?.startsWith('--'))
1397
+ return t;
1398
+ return null;
1399
+ }
1400
+ case 'wait':
1401
+ if (t[1] === 'stable' || t[1] === 'change')
1402
+ return ['wait', '--mode', t[1], ...t.slice(2)];
1403
+ return null;
1404
+ case 'help':
1405
+ case 'quit':
1406
+ case 'exit':
1407
+ case 'stream':
1408
+ case 'logs':
1409
+ case 'repl':
1410
+ return null;
1411
+ default:
1412
+ /* 真命令名直接放行(EXEC_HEADS 与 commands/index 同步维护);
1413
+ * REPL 私有的 touchdown/touchmove/touchup 手势等留 line 路径。 */
1414
+ return EXEC_HEADS.has(head) ? t : null;
1415
+ }
1416
+ }
1417
+ /** 常驻侧:从命名管道收命令,交给与交互式 repl 同一份 `runReplLine` 执行。 */
1418
+ async function runSessionServer(name, ctx) {
1419
+ const client = spawnDaemon();
1420
+ const state = { session: '(未连接)', plan: null };
1421
+ const remember = (s) => {
1422
+ state.session = String(s.session?.target ?? '(已连接)');
1423
+ state.plan = s.plan ?? null;
1424
+ };
1425
+ const server = new SessionServer((line, reply) => {
1426
+ void runReplLine(line, { client, out: (l) => server.pushOutput(l), state, remember })
1427
+ .then((step) => {
1428
+ reply({ ok: true, lines: [], ...(step === 'quit' ? { closing: true } : {}) });
1429
+ if (step === 'quit') {
1430
+ /* 收尾延后一拍:先把回执写出去,调用方才能读到"会话已结束"而不是连接被掐 */
1431
+ setTimeout(() => {
1432
+ server.close();
1433
+ try {
1434
+ client.close();
1435
+ }
1436
+ catch { /* ignore */ }
1437
+ process.exit(0);
1438
+ }, 150);
1439
+ }
1440
+ })
1441
+ .catch((e) => reply({ ok: false, lines: [], error: e instanceof Error ? e.message : String(e) }));
1442
+ },
1443
+ /* exec 路径:argv → dispatchArgv(与一次性 CLI 同一份实现),result 结构化回给调用方。
1444
+ * human 行同时推进回执缓冲——非 --json 的调用方要的文本、会话日志都靠它。 */
1445
+ (argv, reply) => {
1446
+ void dispatchArgv(argv, { client, cwd: process.cwd() })
1447
+ .then(({ result }) => {
1448
+ for (const l of result.human ?? [])
1449
+ server.pushOutput(l);
1450
+ reply({
1451
+ ok: result.exitCode === EXIT.OK || result.exitCode === EXIT.FINDINGS,
1452
+ result,
1453
+ ...(result.error ? { error: result.error.message } : {}),
1454
+ });
1455
+ })
1456
+ .catch((e) => reply({ ok: false, error: e instanceof Error ? e.message : String(e) }));
1457
+ });
1458
+ /* 先建连接、再 listen:反过来的话命令可能在 `ensureSession` 完成**之前**到达,
1459
+ * 那条命令会跑在一个还没连接的会话上(表现为"第一条命令报未连接,第二条才好")。
1460
+ * 而调用侧有 `waitPipeReady` 轮询,所以推迟 listen 不会让它空等——
1461
+ * 它只是在管道就绪前一直重试,拿到的一定是连接完成的会话。 */
1462
+ const o = sessionOptions(ctx);
1463
+ const startup = [];
1464
+ if (o.project || o.kind) {
1465
+ /* 命名会话的起连同样算"启动"(--no-pretask 已可经 connectArgs 透传进来) */
1466
+ if (o.pretask === undefined)
1467
+ o.pretask = true;
1468
+ try {
1469
+ const s = await ensureSession(client, o);
1470
+ remember(s);
1471
+ startup.push('已连接:' + state.session);
1472
+ }
1473
+ catch (e) {
1474
+ startup.push('连接失败:' + (e instanceof Error ? e.message : String(e)));
1475
+ }
1476
+ }
1477
+ else {
1478
+ startup.push('未连接设备。先 connect project <dir> 或 connect win32 <target>。');
1479
+ }
1480
+ const pipe = sessionPipePath(name);
1481
+ try {
1482
+ /* POSIX 下管道是文件系统里的 socket 文件,父目录必须在 listen 之前就存在,
1483
+ * 否则 listen 直接 ENOENT(Windows 命名管道不走文件系统,这里是空操作)。 */
1484
+ ensureSessionDir(name);
1485
+ await server.listen(pipe);
1486
+ }
1487
+ catch (e) {
1488
+ try {
693
1489
  client.close();
694
1490
  }
695
- return { exitCode: EXIT.OK, human: ['会话结束。'] };
696
- },
697
- };
1491
+ catch { /* ignore */ }
1492
+ return fail('SESSION_PIPE_FAILED', '命名管道 ' + pipe + ' 监听失败:' + (e instanceof Error ? e.message : String(e)), '同名会话可能已在跑;用 --session-status ' + name + ' 查,或 --session-stop ' + name + ' 收掉', EXIT.FINDINGS);
1493
+ }
1494
+ /* 横幅在 listen 之后入待认领队列:它在第一条命令的回执里出现,与交互式 repl 的"先报连接"一致 */
1495
+ for (const l of startup)
1496
+ server.pushStartup(l);
1497
+ /* 常驻:等管道上的命令,直到收到 quit/exit 或父侧 stopSession 把进程杀掉 */
1498
+ await new Promise(() => { });
1499
+ return { exitCode: EXIT.OK, human: [] };
1500
+ }
698
1501
  //# sourceMappingURL=repl.js.map