@windsland52/maafw-live 0.0.0-stage → 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 (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/bin/maafw-live.mjs +25 -0
  4. package/docs/daemon-protocol.md +121 -0
  5. package/docs/keyframe-retention-contract-v0.md +225 -0
  6. package/docs/session-unify-v0.md +115 -0
  7. package/lib/client/daemon.js +276 -0
  8. package/lib/commands/annotate.js +179 -0
  9. package/lib/commands/calibrate.js +61 -0
  10. package/lib/commands/color.js +73 -0
  11. package/lib/commands/crop.js +228 -0
  12. package/lib/commands/dispatch.js +121 -0
  13. package/lib/commands/env.js +409 -0
  14. package/lib/commands/index.js +70 -0
  15. package/lib/commands/input.js +257 -0
  16. package/lib/commands/kf.js +167 -0
  17. package/lib/commands/probe.js +70 -0
  18. package/lib/commands/reco.js +440 -0
  19. package/lib/commands/repl.js +1501 -0
  20. package/lib/commands/roi-check.js +273 -0
  21. package/lib/commands/roi-compare.js +388 -0
  22. package/lib/commands/runtime.js +718 -0
  23. package/lib/commands/skill.js +169 -0
  24. package/lib/commands/timing.js +247 -0
  25. package/lib/commands/version.js +66 -0
  26. package/lib/daemon/framed.mjs +3680 -0
  27. package/lib/daemon/reco_child.mjs +287 -0
  28. package/lib/exec.js +96 -0
  29. package/lib/flags.js +44 -0
  30. package/lib/image.js +90 -0
  31. package/lib/index.js +143 -0
  32. package/lib/interface/hotkey.js +142 -0
  33. package/lib/interface/index.js +12 -0
  34. package/lib/interface/jsonc.js +95 -0
  35. package/lib/interface/load.js +397 -0
  36. package/lib/interface/override.js +287 -0
  37. package/lib/interface/plan.js +279 -0
  38. package/lib/option-args.js +29 -0
  39. package/lib/pkg.js +54 -0
  40. package/lib/protocol.js +94 -0
  41. package/lib/runtime/actions.js +225 -0
  42. package/lib/runtime/keyframes.js +73 -0
  43. package/lib/runtime/log-watch.js +160 -0
  44. package/lib/runtime/node-source.js +100 -0
  45. package/lib/runtime/ocr-model.js +45 -0
  46. package/lib/runtime/pi-env.js +34 -0
  47. package/lib/runtime/pretask.js +217 -0
  48. package/lib/runtime/prune.js +28 -0
  49. package/lib/runtime/receipts.js +57 -0
  50. package/lib/runtime/reco-args.js +38 -0
  51. package/lib/runtime/reco-verdict.js +82 -0
  52. package/lib/runtime/roi-compare.js +198 -0
  53. package/lib/runtime/session-forward.js +79 -0
  54. package/lib/runtime/session-ipc.js +471 -0
  55. package/lib/runtime/session.js +135 -0
  56. package/lib/runtime/signal-guard.js +35 -0
  57. package/lib/types/client/daemon.d.ts +86 -0
  58. package/lib/types/commands/annotate.d.ts +3 -0
  59. package/lib/types/commands/calibrate.d.ts +7 -0
  60. package/lib/types/commands/color.d.ts +9 -0
  61. package/lib/types/commands/crop.d.ts +3 -0
  62. package/lib/types/commands/dispatch.d.ts +22 -0
  63. package/lib/types/commands/env.d.ts +21 -0
  64. package/lib/types/commands/index.d.ts +13 -0
  65. package/lib/types/commands/input.d.ts +17 -0
  66. package/lib/types/commands/kf.d.ts +10 -0
  67. package/lib/types/commands/probe.d.ts +15 -0
  68. package/lib/types/commands/reco.d.ts +27 -0
  69. package/lib/types/commands/repl.d.ts +10 -0
  70. package/lib/types/commands/roi-check.d.ts +3 -0
  71. package/lib/types/commands/roi-compare.d.ts +3 -0
  72. package/lib/types/commands/runtime.d.ts +66 -0
  73. package/lib/types/commands/skill.d.ts +17 -0
  74. package/lib/types/commands/timing.d.ts +64 -0
  75. package/lib/types/commands/version.d.ts +3 -0
  76. package/lib/types/exec.d.ts +31 -0
  77. package/lib/types/flags.d.ts +62 -0
  78. package/lib/types/image.d.ts +27 -0
  79. package/lib/types/index.d.ts +1 -0
  80. package/lib/types/interface/hotkey.d.ts +36 -0
  81. package/lib/types/interface/index.d.ts +11 -0
  82. package/lib/types/interface/jsonc.d.ts +9 -0
  83. package/lib/types/interface/load.d.ts +190 -0
  84. package/lib/types/interface/override.d.ts +78 -0
  85. package/lib/types/interface/plan.d.ts +68 -0
  86. package/lib/types/option-args.d.ts +19 -0
  87. package/lib/types/pkg.d.ts +14 -0
  88. package/lib/types/protocol.d.ts +124 -0
  89. package/lib/types/runtime/actions.d.ts +248 -0
  90. package/lib/types/runtime/keyframes.d.ts +43 -0
  91. package/lib/types/runtime/log-watch.d.ts +28 -0
  92. package/lib/types/runtime/node-source.d.ts +25 -0
  93. package/lib/types/runtime/ocr-model.d.ts +8 -0
  94. package/lib/types/runtime/pi-env.d.ts +24 -0
  95. package/lib/types/runtime/pretask.d.ts +53 -0
  96. package/lib/types/runtime/prune.d.ts +4 -0
  97. package/lib/types/runtime/receipts.d.ts +7 -0
  98. package/lib/types/runtime/reco-args.d.ts +18 -0
  99. package/lib/types/runtime/reco-verdict.d.ts +50 -0
  100. package/lib/types/runtime/roi-compare.d.ts +117 -0
  101. package/lib/types/runtime/session-forward.d.ts +10 -0
  102. package/lib/types/runtime/session-ipc.d.ts +117 -0
  103. package/lib/types/runtime/session.d.ts +77 -0
  104. package/lib/types/runtime/signal-guard.d.ts +19 -0
  105. package/lib/types/update-check.d.ts +20 -0
  106. package/lib/types/usage.d.ts +3 -0
  107. package/lib/update-check.js +97 -0
  108. package/lib/usage.js +50 -0
  109. package/package.json +94 -4
  110. package/skills/README.md +51 -0
  111. package/skills/maafw-live/SKILL.md +293 -0
  112. package/skills/maafw-live/references/examples.md +238 -0
  113. package/skills/maafw-live/references/pitfalls.md +211 -0
  114. package/skills/maafw-live/references/workflows.md +164 -0
@@ -0,0 +1,718 @@
1
+ /**
2
+ * 设备与运行时的命令面:probe / device / connect / disconnect / screencap / frame / run / stop。
3
+ *
4
+ * 这一层只做三件事:解析选项、调 runtime/actions、把它翻译成 CLI 信封。任何领域规则
5
+ * (控制器规划、资源解析、时间线)都不许写在这里——REPL 与其它壳(编辑器插件、面板)调的是同一批动作函数。
6
+ */
7
+ import { mkdirSync } from 'node:fs';
8
+ import { watchLogs } from '../runtime/log-watch.js';
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
10
+ import { defaultRunDir } from '../client/daemon.js';
11
+ import { EXIT, fail } from '../protocol.js';
12
+ import { parseOptionArgs } from '../option-args.js';
13
+ import * as act from '../runtime/actions.js';
14
+ import { withDaemon } from '../runtime/actions.js';
15
+ import { describeSession, ensureSession, SessionError } from '../runtime/session.js';
16
+ import { writeRunReceipt } from '../runtime/receipts.js';
17
+ import { pruneDir } from '../runtime/prune.js';
18
+ import { installRunSignalGuard } from '../runtime/signal-guard.js';
19
+ /** 连接类选项:凡是要设备的命令都接受同一组,保证"怎么连"只有一套说法。 */
20
+ export const CONNECT_OPTIONS = {
21
+ project: { type: 'string' },
22
+ controller: { type: 'string' },
23
+ resource: { type: 'string' },
24
+ hwnd: { type: 'string' },
25
+ address: { type: 'string' },
26
+ kind: { type: 'string' },
27
+ target: { type: 'string' },
28
+ 'short-side': { type: 'string' },
29
+ 'long-side': { type: 'string' },
30
+ raw: { type: 'boolean' },
31
+ /* 跳过 interface.json 的 pretask(它会连 Controller 之前跑一次,非零即中止连接) */
32
+ 'no-pretask': { type: 'boolean' },
33
+ };
34
+ /**
35
+ * 路径参数的解析基准:调用方给的目录就是 `ctx.cwd`(CLI 的 --cwd),不是 process.cwd()。
36
+ * 只处理**纯路径参数**(--project / --resource-dir / --out / --image / --frames-dir 等)——
37
+ * `--resource` 同时是"资源名",能不能按路径解析要留给 resolveResourcePaths(名字优先),
38
+ * 所以它在 SessionOptions.cwd 里透传,不在这里 resolve。
39
+ */
40
+ export function argPath(ctx, v) {
41
+ if (v === undefined)
42
+ return undefined;
43
+ return isAbsolute(v) ? v : resolve(ctx.cwd, v);
44
+ }
45
+ export function sessionOptions(ctx) {
46
+ const values = ctx.values;
47
+ const num = (k) => {
48
+ const v = values[k];
49
+ return typeof v === 'string' && v !== '' ? Number(v) : undefined;
50
+ };
51
+ const str = (k) => (typeof values[k] === 'string' ? values[k] : undefined);
52
+ return {
53
+ project: argPath(ctx, str('project')),
54
+ cwd: ctx.cwd,
55
+ controller: str('controller'),
56
+ resource: str('resource'),
57
+ hwnd: str('hwnd'),
58
+ address: str('address'),
59
+ kind: str('kind'),
60
+ target: str('target'),
61
+ shortSide: num('short-side'),
62
+ longSide: num('long-side'),
63
+ rawSize: values.raw === true,
64
+ ...(values['no-pretask'] === true ? { pretask: false } : {}),
65
+ };
66
+ }
67
+ /** daemon 抛出的错误翻译成结构化失败。退出码按错误的**性质**分:
68
+ * 前置缺失(脚本找不到 / 会话规划失败)退 4;运行时故障(调用超时、进程退出、发送失败)退 1——
69
+ * 同一个 code 在所有路径退同一个码,agent 才不会按"缺环境"去查"daemon 卡住"。 */
70
+ export function daemonFail(e, hint) {
71
+ if (e instanceof SessionError)
72
+ return fail(e.code, e.message, hint, EXIT.ENV);
73
+ const msg = e instanceof Error ? e.message : String(e);
74
+ /* 超时与进程退出是运行时故障,不是前置缺失:退 1,让"重试 / 换参数"排在"装环境"之前 */
75
+ if (/^daemon 调用超时|^daemon 已退出|^daemon 请求发送失败/.test(msg)) {
76
+ return fail('DAEMON_ERROR', msg, hint ?? '重试或看 maafw-live probe;连续出现先 kill 会话重连', EXIT.FAIL);
77
+ }
78
+ if (/^找不到 daemon 脚本/.test(msg)) {
79
+ return fail('DAEMON_ERROR', msg, hint ?? 'daemon 脚本缺失:检查 npm 安装是否完整', EXIT.ENV);
80
+ }
81
+ return fail('DAEMON_ERROR', msg, hint ?? '用 maafw-live probe 检查运行时;连接类错误先 maafw-live device 看有没有设备', EXIT.FAIL);
82
+ }
83
+ export const probeCommand = {
84
+ name: 'probe',
85
+ summary: '自检 maa-node 运行时:绑定版本、adb/win32 设备发现数',
86
+ usage: 'maafw-live probe',
87
+ async run(ctx) {
88
+ try {
89
+ return await withDaemon(ctx, async (client) => {
90
+ const info = await act.probe(client);
91
+ const devices = await act.deviceList(client, 'all').catch(() => []);
92
+ const human = [
93
+ 'maa-node ' + String(info.version ?? '(未知)'),
94
+ 'adb 设备 ' + String(info.adb ?? '?'),
95
+ 'win32 窗口 ' + String(info.win32 ?? '?'),
96
+ '可用于连接的设备 ' + devices.length + ' 个',
97
+ ];
98
+ for (const err of info.errors ?? [])
99
+ human.push(' error: ' + err);
100
+ return { exitCode: EXIT.OK, human, data: { ...info, devices: devices.length } };
101
+ });
102
+ }
103
+ catch (e) {
104
+ return daemonFail(e, '探针失败通常意味着 @maaxyz/maa-node 未安装或原生包与平台不匹配');
105
+ }
106
+ },
107
+ };
108
+ export const deviceCommand = {
109
+ name: 'device',
110
+ summary: '列出可连接的设备:adb 设备与 win32 窗口',
111
+ usage: 'maafw-live device [--kind all|adb|win32]',
112
+ options: { kind: { type: 'string' } },
113
+ async run(ctx) {
114
+ const kind = (typeof ctx.values.kind === 'string' ? ctx.values.kind : 'all');
115
+ if (!['all', 'adb', 'win32'].includes(kind)) {
116
+ return fail('BAD_ARGUMENTS', 'kind 只能是 all / adb / win32', undefined, EXIT.USAGE);
117
+ }
118
+ try {
119
+ return await withDaemon(ctx, async (client) => {
120
+ const list = await act.deviceList(client, kind);
121
+ const human = list.length
122
+ ? list.map((d) => {
123
+ const r = d;
124
+ return ' ' + String(r.kind) + ' ' + String(r.id) + ' ' + String(r.name ?? '') +
125
+ (r.cls ? ' [' + r.cls + ']' : '');
126
+ })
127
+ : ['(未发现设备)'];
128
+ return {
129
+ exitCode: list.length ? EXIT.OK : EXIT.FINDINGS,
130
+ human: ['设备 ' + list.length + ' 个:', ...human],
131
+ data: { count: list.length, devices: list },
132
+ ...(list.length ? {} : { suggestedCommands: ['maafw-live device'] }),
133
+ };
134
+ });
135
+ }
136
+ catch (e) {
137
+ return daemonFail(e);
138
+ }
139
+ },
140
+ };
141
+ export const connectCommand = {
142
+ name: 'connect',
143
+ summary: '连接设备(进程态:命令退出即断开;多步操作用 maafw-live repl)',
144
+ usage: 'maafw-live connect --project <dir> | --kind win32|adb|gamepad [--target ...]',
145
+ options: { ...CONNECT_OPTIONS },
146
+ async run(ctx) {
147
+ const o = sessionOptions(ctx);
148
+ if (!o.project && !o.kind) {
149
+ return fail('BAD_ARGUMENTS', '要么给 --project <dir>(按 interface.json 规划),要么给 --kind', '推荐 --project:控制器类型/窗口正则/输入方法/识别缩放全跟项目走', EXIT.USAGE);
150
+ }
151
+ try {
152
+ return await withDaemon(ctx, async (client) => {
153
+ const s = await ensureSession(client, o);
154
+ const lines = describeSession(s, ctx.flags.verbose);
155
+ if (s.streamStopped) {
156
+ /* 重建控制器会让旧流失效:如实说,别让调用方以为是设备卡了(曾经是"流静默死掉") */
157
+ lines.push('', '帧流:已停止(本次连接重建了控制器);要看帧就 stream start 重开。');
158
+ }
159
+ lines.push('', '注意:连接是进程态,本命令退出即断开。多步操作请用 maafw-live repl(连接一次、命令复用)。');
160
+ return {
161
+ exitCode: EXIT.OK,
162
+ human: lines,
163
+ data: {
164
+ session: s.session ?? null,
165
+ controller: s.plan?.controllerName ?? null,
166
+ resourcePaths: s.plan?.resource?.paths ?? null,
167
+ },
168
+ };
169
+ });
170
+ }
171
+ catch (e) {
172
+ return daemonFail(e, '没有匹配的窗口/设备时:先 maafw-live device 看目标是否存在,或用 --hwnd 显式指定');
173
+ }
174
+ },
175
+ };
176
+ export const disconnectCommand = {
177
+ name: 'disconnect',
178
+ summary: '断开设备并销毁 Tasker/Controller(会话内命令,等价于结束 REPL 会话)',
179
+ usage: 'maafw-live disconnect --session <name>(一次性形态:为断开先新连一次,没意义)',
180
+ options: { ...CONNECT_OPTIONS },
181
+ /* 为断开先新连一次是自相矛盾的操作——断开会话态才需要"断开"(P0-3 L4) */
182
+ requiresSession: true,
183
+ async run(ctx) {
184
+ const o = sessionOptions(ctx);
185
+ if (!o.project && !o.kind) {
186
+ return fail('BAD_ARGUMENTS', '需要 --project 或 --kind 才能建立会话再断开', undefined, EXIT.USAGE);
187
+ }
188
+ try {
189
+ return await withDaemon(ctx, async (client) => {
190
+ await ensureSession(client, o);
191
+ const r = await act.disconnect(client);
192
+ return { exitCode: EXIT.OK, human: ['已断开:' + JSON.stringify(r)], data: r };
193
+ });
194
+ }
195
+ catch (e) {
196
+ return daemonFail(e);
197
+ }
198
+ },
199
+ };
200
+ /** 缺省落点 png/ 的保留上限(与 runs/ 同量级的"不留着看"产物) */
201
+ const PNG_KEEP = 30;
202
+ export const screencapCommand = {
203
+ name: 'screencap',
204
+ summary: '截一帧图并落盘(需要连接:给 --project 或 --kind)',
205
+ usage: 'maafw-live screencap [--project <dir>|--kind ...] [--out <png>]',
206
+ options: { ...CONNECT_OPTIONS, out: { type: 'string' } },
207
+ async run(ctx) {
208
+ const o = sessionOptions(ctx);
209
+ const out = argPath(ctx, typeof ctx.values.out === 'string' ? ctx.values.out : undefined)
210
+ ?? join(defaultRunDir(), 'png', 'shot_' + Date.now() + '.png');
211
+ try {
212
+ mkdirSync(dirname(out), { recursive: true });
213
+ }
214
+ catch { /* 落盘失败交给 daemon 报错 */ }
215
+ /* 只修剪缺省落点 png/——显式 --out 是用户指定的位置,不动它 */
216
+ if (dirname(out) === join(defaultRunDir(), 'png')) {
217
+ pruneDir(dirname(out), { keep: PNG_KEEP, suffix: '.png' });
218
+ }
219
+ try {
220
+ return await withDaemon(ctx, async (client) => {
221
+ const s = await ensureSession(client, o);
222
+ const r = await act.screencap(client, out);
223
+ return {
224
+ exitCode: r.ok === false ? EXIT.FAIL : EXIT.OK,
225
+ human: [...describeSession(s, ctx.flags.verbose), '截图:' + String(r.path ?? out) + (r.bytes ? ' (' + r.bytes + ' bytes)' : '')],
226
+ data: r,
227
+ written: r.path ? [String(r.path)] : [out],
228
+ };
229
+ });
230
+ }
231
+ catch (e) {
232
+ return daemonFail(e);
233
+ }
234
+ },
235
+ };
236
+ export const eventsCommand = {
237
+ name: 'events',
238
+ summary: '查帧流事件(stable/change/run),每条自带画面锚点;--watch 可自起帧流现场采集',
239
+ usage: 'maafw-live events [--limit <n>] [--since <seq>] [--watch <ms>] [--fps <n>]\n' +
240
+ ' --watch <ms>:本次调用内先起帧流、等 ms 毫秒、再查(**推荐**:daemon 随命令退出而销毁,\n' +
241
+ ' 事件环不跨进程存活,所以"查上一次留下的记录"是拿不到的)\n' +
242
+ ' 不带 --watch:只查当前 daemon 会话里已有的事件——一次性 CLI 里这通常为空,\n' +
243
+ ' 该用法主要用于 REPL 之外的脚本在同进程内先跑 run 再查\n' +
244
+ ' 每条事件 frame.retained=l0 表示画面还能取(frame get <seq> / kf promote),gone 表示已滚出',
245
+ options: { ...CONNECT_OPTIONS, limit: { type: 'string' }, since: { type: 'string' }, watch: { type: 'string' }, fps: { type: 'string' } },
246
+ async run(ctx) {
247
+ const o = sessionOptions(ctx);
248
+ const rawLimit = ctx.values.limit;
249
+ const limit = typeof rawLimit === 'string' ? Number(rawLimit) : 20;
250
+ if (!Number.isFinite(limit) || limit <= 0) {
251
+ return fail('BAD_ARGUMENTS', '--limit 必须是正整数', undefined, EXIT.USAGE);
252
+ }
253
+ const rawSince = ctx.values.since;
254
+ const since = typeof rawSince === 'string' ? Number(rawSince) : undefined;
255
+ if (rawSince !== undefined && !Number.isFinite(since)) {
256
+ return fail('BAD_ARGUMENTS', '--since 必须是数字(seq)', undefined, EXIT.USAGE);
257
+ }
258
+ const rawWatch = ctx.values.watch;
259
+ const watch = typeof rawWatch === 'string' ? Number(rawWatch) : undefined;
260
+ if (rawWatch !== undefined && (!Number.isFinite(watch) || watch <= 0)) {
261
+ return fail('BAD_ARGUMENTS', '--watch 必须是正毫秒数', undefined, EXIT.USAGE);
262
+ }
263
+ const rawFps = ctx.values.fps;
264
+ const fps = typeof rawFps === 'string' ? Number(rawFps) : undefined;
265
+ if (rawFps !== undefined && (!Number.isFinite(fps) || fps <= 0)) {
266
+ return fail('BAD_ARGUMENTS', '--fps 必须是正数', undefined, EXIT.USAGE);
267
+ }
268
+ try {
269
+ return await withDaemon(ctx, async (client) => {
270
+ const s = await ensureSession(client, o);
271
+ /* --watch:daemon 随命令退出而销毁,事件环不跨进程存活——所以"自己起流、现场采集"
272
+ * 是一次性 CLI 拿到事件的唯一可靠方式。用 `wait` 的 change/stable 语义不合适
273
+ * (它等到第一个满足就返回,而我们要的是"跑满这段窗口"),直接起流后按毫秒等。 */
274
+ let watched = null;
275
+ if (watch !== undefined) {
276
+ await act.streamStart(client, fps !== undefined ? { fps } : {});
277
+ await new Promise((r) => setTimeout(r, watch));
278
+ watched = { ...(fps !== undefined ? { fps } : {}), ms: watch };
279
+ }
280
+ const r = await act.eventsList(client, { limit, ...(since !== undefined ? { since } : {}) });
281
+ const evs = r.events ?? [];
282
+ const human = [...describeSession(s, ctx.flags.verbose), '事件 ' + evs.length + ' 条' +
283
+ (r.matched !== r.total ? '(匹配 ' + r.matched + ' / 环内共 ' + r.total + ')' : '') +
284
+ (r.truncated ? ',已按 --limit 截取' : '') +
285
+ (watched ? '(本次起流 ' + watched.ms + 'ms' + (watched.fps ? ' @' + watched.fps + 'fps' : '') + ' 现场采集)' : '')];
286
+ if (!evs.length) {
287
+ human.push(watched
288
+ ? ' (这 ' + watched.ms + 'ms 内画面没有变化:事件只在"动了"或"静下来"时产生,' +
289
+ '静止画面不产生事件。要抓变化就加长 --watch 或在等待期间对设备做操作)'
290
+ : ' (环内没有事件:daemon 随命令退出而销毁,事件环不跨进程存活——' +
291
+ '加 --watch <ms> 让它本次调用内起流采集,或改用 REPL 会话)');
292
+ }
293
+ for (const e of evs) {
294
+ const f = e.frame;
295
+ const anchor = !f
296
+ ? ' frame=?(无锚点)'
297
+ : f.retained === 'l0'
298
+ ? ' frame=l0:' + String(f.seq) + (f.source ? '(' + f.source + ')' : '') + ' → frame get ' + String(f.seq)
299
+ : ' frame=gone(seq ' + String(f.seq) + ' 已滚出 L0,取不到了)';
300
+ human.push(' ' + JSON.stringify(e) + anchor);
301
+ }
302
+ return { exitCode: EXIT.OK, human, data: r };
303
+ });
304
+ }
305
+ catch (e) {
306
+ return daemonFail(e, '事件环只在 daemon 存活期间保留;一次性 CLI 请用 --watch <ms> 现场采集');
307
+ }
308
+ },
309
+ };
310
+ export const frameCommand = {
311
+ name: 'frame',
312
+ /* frame get 的环恒空(NO_FRAME);frame status 读的是流状态,空环也算有内容的回答 */
313
+ requiresSession: (ctx) => ctx.positionals[0] === 'get',
314
+ summary: '帧流状态与历史帧取用(环形缓冲只在 REPL 会话里才有内容)',
315
+ usage: 'maafw-live frame status | maafw-live frame get [seq] [--roi x,y,w,h] [--out <png>] [--src auto|full|ring]\n' +
316
+ ' --src full 强制要控制器分辨率原图(L0 已淘汰则报错,绝不给缩小图冒充)',
317
+ options: { ...CONNECT_OPTIONS, roi: { type: 'string' }, out: { type: 'string' }, src: { type: 'string' } },
318
+ async run(ctx) {
319
+ const sub = ctx.positionals[0] ?? 'status';
320
+ const o = sessionOptions(ctx);
321
+ if (sub === 'status') {
322
+ try {
323
+ return await withDaemon(ctx, async (client) => {
324
+ const s = await ensureSession(client, o);
325
+ const st = await act.streamStatus(client);
326
+ return { exitCode: EXIT.OK, human: [...describeSession(s, ctx.flags.verbose), '帧流:' + JSON.stringify(st)], data: st };
327
+ });
328
+ }
329
+ catch (e) {
330
+ return daemonFail(e);
331
+ }
332
+ }
333
+ if (sub !== 'get') {
334
+ return fail('BAD_ARGUMENTS', '用法:maafw-live frame status | maafw-live frame get [seq]', undefined, EXIT.USAGE);
335
+ }
336
+ const rawSeq = ctx.positionals[1];
337
+ const seq = rawSeq !== undefined ? Number(rawSeq) : undefined;
338
+ if (rawSeq !== undefined && !Number.isFinite(seq)) {
339
+ return fail('BAD_ARGUMENTS', 'seq 必须是数字', undefined, EXIT.USAGE);
340
+ }
341
+ const roi = typeof ctx.values.roi === 'string'
342
+ ? ctx.values.roi.split(',').map((x) => Number(String(x).trim()))
343
+ : undefined;
344
+ if (roi && (roi.length !== 4 || roi.some((n) => !Number.isFinite(n)))) {
345
+ return fail('BAD_ARGUMENTS', '--roi 需要 4 个数字:x,y,w,h(控制器分辨率坐标,与 pipeline 的 roi 同空间)', undefined, EXIT.USAGE);
346
+ }
347
+ const out = argPath(ctx, typeof ctx.values.out === 'string' ? ctx.values.out : undefined);
348
+ const srcRaw = typeof ctx.values.src === 'string' ? ctx.values.src.toLowerCase() : 'auto';
349
+ if (!['auto', 'full', 'ring'].includes(srcRaw)) {
350
+ return fail('BAD_ARGUMENTS', '--src 只支持 auto | full | ring', undefined, EXIT.USAGE);
351
+ }
352
+ const src = srcRaw;
353
+ try {
354
+ return await withDaemon(ctx, async (client) => {
355
+ await ensureSession(client, o);
356
+ const r = await act.frameGet(client, { seq, roi, out, src });
357
+ if (r.ok === false) {
358
+ return fail('NO_FRAME', String(r.error ?? '取帧失败'), '帧流要先在 REPL 里 stream start;一次性命令的环形缓冲是空的', EXIT.FINDINGS);
359
+ }
360
+ /* 降级是**发现**不是失败:图能用,但分辨率与预期不符必须让人看见(退出 3)。
361
+ * 判据取自 daemon 的 `degraded`——它按"调用方要什么"判(带 roi 的 auto 退小图才算降级;
362
+ * 显式 ring 与整帧小图都是预期行为),比在命令面拿 `sourceResolution < 1` 反推准确。 */
363
+ const degraded = r.degraded === true;
364
+ return {
365
+ exitCode: degraded ? EXIT.FINDINGS : EXIT.OK,
366
+ human: [
367
+ '帧 ' + String(r.seq) + ' → ' + String(r.path) + ' (' + r.w + 'x' + r.h + ')'
368
+ + ' 来源 ' + String(r.source ?? '?') + ' 有效分辨率 ' + String(r.sourceResolution ?? '?'),
369
+ ...(r.warn ? [' 警告:' + String(r.warn)] : []),
370
+ ],
371
+ data: r,
372
+ warnings: r.warn ? [String(r.warn)] : [],
373
+ written: [String(r.path)],
374
+ };
375
+ });
376
+ }
377
+ catch (e) {
378
+ return daemonFail(e);
379
+ }
380
+ },
381
+ };
382
+ export const waitCommand = {
383
+ name: 'wait',
384
+ /* 预热截图不进环 → 恒 NO_FRAME 退 3;就算有帧也没流,stable 秒成立/change 必超时——
385
+ * 两种结果都是假信号(P0-3 L4) */
386
+ requiresSession: true,
387
+ summary: '等状态谓词成立:stable=画面静下来 / change=画面动了(取代硬睡时钟)',
388
+ usage: 'maafw-live wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]\n' +
389
+ ' 超时不算命令失败(ok=true, satisfied=false, 退出码 3)——"没等到"是观测结果',
390
+ options: {
391
+ ...CONNECT_OPTIONS,
392
+ timeout: { type: 'string' }, quiet: { type: 'string' },
393
+ roi: { type: 'string' }, threshold: { type: 'string' },
394
+ },
395
+ async run(ctx) {
396
+ const modeRaw = ctx.positionals[0];
397
+ if (modeRaw !== 'stable' && modeRaw !== 'change') {
398
+ return fail('BAD_ARGUMENTS', 'wait 只认 stable | change' + (modeRaw ? '(收到 ' + JSON.stringify(modeRaw) + ')' : ''), '用法:maafw-live wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]', EXIT.USAGE);
399
+ }
400
+ const mode = modeRaw;
401
+ const o = sessionOptions(ctx);
402
+ const timeout = ctx.values.timeout !== undefined ? Number(ctx.values.timeout) : undefined;
403
+ if (timeout !== undefined && !Number.isFinite(timeout)) {
404
+ return fail('BAD_ARGUMENTS', '--timeout 必须是毫秒数', undefined, EXIT.USAGE);
405
+ }
406
+ const quiet = ctx.values.quiet !== undefined ? Number(ctx.values.quiet) : undefined;
407
+ if (quiet !== undefined && !Number.isFinite(quiet)) {
408
+ return fail('BAD_ARGUMENTS', '--quiet 必须是毫秒数', undefined, EXIT.USAGE);
409
+ }
410
+ const threshold = ctx.values.threshold !== undefined ? Number(ctx.values.threshold) : undefined;
411
+ if (threshold !== undefined && !Number.isFinite(threshold)) {
412
+ return fail('BAD_ARGUMENTS', '--threshold 必须是数字', undefined, EXIT.USAGE);
413
+ }
414
+ const roi = typeof ctx.values.roi === 'string'
415
+ ? ctx.values.roi.split(',').map((x) => Number(String(x).trim()))
416
+ : undefined;
417
+ if (roi && (roi.length !== 4 || roi.some((n) => !Number.isFinite(n)))) {
418
+ return fail('BAD_ARGUMENTS', '--roi 需要 4 个数字:x,y,w,h', undefined, EXIT.USAGE);
419
+ }
420
+ try {
421
+ return await withDaemon(ctx, async (client) => {
422
+ await ensureSession(client, o);
423
+ const r = await act.waitState(client, {
424
+ mode,
425
+ ...(timeout !== undefined ? { timeout } : {}),
426
+ ...(quiet !== undefined ? { quiet } : {}),
427
+ ...(roi ? { roi } : {}),
428
+ ...(threshold !== undefined ? { threshold } : {}),
429
+ });
430
+ if (r.ok === false)
431
+ return fail('WAIT', String(r.error ?? '等待失败'), undefined, EXIT.FINDINGS);
432
+ const ok = r.satisfied === true;
433
+ return {
434
+ exitCode: ok ? EXIT.OK : EXIT.FINDINGS,
435
+ human: [
436
+ 'wait ' + mode + ':' + (ok ? '已成立' : '超时未成立')
437
+ + ' 等了 ' + String(r.waitedMs) + 'ms 当前帧 seq=' + String(r.seq),
438
+ ...(r.warn ? [' 警告:' + String(r.warn)] : []),
439
+ ],
440
+ data: r,
441
+ warnings: r.warn ? [String(r.warn)] : [],
442
+ };
443
+ });
444
+ }
445
+ catch (e) {
446
+ return daemonFail(e);
447
+ }
448
+ },
449
+ };
450
+ export const runCommand = {
451
+ name: 'run',
452
+ summary: '运行 pipeline:项目模式(推荐)或 resource 目录模式;节点事件按帧序对齐。退出码按任务级 record.ok',
453
+ usage: 'maafw-live run --entry <task> --project <dir> | --resource-dir <dir> [--timeout <ms|0>] [--override <json>] [--preset <name>] [--resource <名|路径>] [--option <键>=<值>|<键>.<字段>=<值>]…\n' +
454
+ ' --option 按次给选项取值(可多次):<键>=<case 名>(select/switch;checkbox 用逗号分隔)、\n' +
455
+ ' <键>.<字段>=<值>(input 的输入字段)。优先级 CLI > preset > default_case;\n' +
456
+ ' input/hotkey 的 pipeline_override 模板靠它替换 {字段名}(hotkey 按控制器 type 映射成键码)\n' +
457
+ ' 项目模式的 pretask 在连 Controller **之前**执行(非零退出即中止,--no-pretask 跳过)\n' +
458
+ ' --timeout 0 = 不自动停止(停止权交调用方:事件流判断 + maafw-live stop / 客户端 run_stop);>0 时超时自动 post_stop\n' +
459
+ ' 项目模式自动合成 pipeline_override(global→resource→controller→task,preset/default 取值),--override 最后覆盖\n' +
460
+ ' --watch <日志条目> = 跑的同时实时 tail(可多次给):文件 / glob("logs/*.txt")/ 目录(取每 tick 最新一个文件,日更轮转不丢)\n' +
461
+ ' --watch-include/--watch-exclude <regex> 筛选推送行;--watch-interval <ms> 推送间隔(默认 2000)',
462
+ options: {
463
+ ...CONNECT_OPTIONS,
464
+ entry: { type: 'string' },
465
+ 'resource-dir': { type: 'string' },
466
+ timeout: { type: 'string' },
467
+ override: { type: 'string' },
468
+ preset: { type: 'string' },
469
+ /* 按次取值(可多次给):<键>=<case 名>(select/switch/checkbox)、<键>.<字段>=<值>(input) */
470
+ option: { type: 'string', multiple: true },
471
+ /* 运行期日志跟随:跑的时候把项目 agent 写的日志实时打出来(可多次给)。
472
+ * 条目语义见 watchLogs.entries:文件 / glob / 目录(每 tick 取最新 mtime)。 */
473
+ watch: { type: 'string', multiple: true },
474
+ 'watch-interval': { type: 'string' },
475
+ 'watch-include': { type: 'string', multiple: true },
476
+ 'watch-exclude': { type: 'string', multiple: true },
477
+ },
478
+ async run(ctx) {
479
+ const o = sessionOptions(ctx);
480
+ const entry = (typeof ctx.values.entry === 'string' ? ctx.values.entry : ctx.positionals[0]) ?? undefined;
481
+ const resourceDir = argPath(ctx, typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined);
482
+ if (!entry) {
483
+ return fail('BAD_ARGUMENTS', '缺少入口任务名:--entry <task>', '项目模式=interface.json 的 task 名;bundle 模式=pipeline 首节点名', EXIT.USAGE);
484
+ }
485
+ if (!o.project && !resourceDir) {
486
+ return fail('BAD_ARGUMENTS', '要么给 --project <dir>,要么给 --resource-dir <dir>', undefined, EXIT.USAGE);
487
+ }
488
+ /* --override 收对象(=一层)或数组(=逐层):框架按数组逐层继承,对象会被摊成
489
+ * [obj] 等价一层。字段缺失继承前值——与 GUI 客户端同一语义(@8061d5b 已核)。 */
490
+ let override = {};
491
+ if (typeof ctx.values.override === 'string') {
492
+ try {
493
+ override = JSON.parse(ctx.values.override);
494
+ }
495
+ catch (e) {
496
+ return fail('BAD_ARGUMENTS', '--override 不是合法 JSON:' + String(e.message), undefined, EXIT.USAGE);
497
+ }
498
+ }
499
+ const timeoutMs = typeof ctx.values.timeout === 'string' ? Number(ctx.values.timeout) : 30000;
500
+ if (!Number.isFinite(timeoutMs)) {
501
+ return fail('BAD_ARGUMENTS', '--timeout 必须是毫秒数', undefined, EXIT.USAGE);
502
+ }
503
+ const presetName = typeof ctx.values.preset === 'string' ? ctx.values.preset : null;
504
+ const parsedOptions = parseOptionArgs(ctx.values.option);
505
+ if (parsedOptions.error) {
506
+ return fail('BAD_ARGUMENTS', parsedOptions.error, '例:--option taskOpt=b --option 章节数.章节号=4', EXIT.USAGE);
507
+ }
508
+ const { cases: optionCases, fields: optionFields } = parsedOptions;
509
+ /* run 是"启动工作"类命令:pretask 要跑(P0-2——观测/输入类命令不跑,run 保留)。
510
+ * --no-pretask 显式关。 */
511
+ if (o.pretask === undefined)
512
+ o.pretask = true;
513
+ /* pretask 在连接之前就跑,所以按次取值必须在 ensureSession 之前挂进 SessionOptions——
514
+ * 否则同一次 run 里 pretask 的末位 JSON 与 pipeline_override 用的不是同一份取值。 */
515
+ if (Object.keys(optionCases).length)
516
+ o.optionCases = optionCases;
517
+ if (Object.keys(optionFields).length)
518
+ o.optionFields = optionFields;
519
+ try {
520
+ return await withDaemon(ctx, async (client) => {
521
+ const s = await ensureSession(client, o);
522
+ let args;
523
+ let resolvedEntry = entry;
524
+ let extraWarns = [];
525
+ let appliedLines = [];
526
+ if (o.project) {
527
+ /* 入口解析 / task 适用性 / 四级 override / agent 桥接只有一份实现:
528
+ * REPL `run` 调的是同一个 prepareProjectRun,不再各长一套规则。 */
529
+ const prep = act.prepareProjectRun({
530
+ project: s.plan,
531
+ entry,
532
+ presetName,
533
+ override,
534
+ ...(Object.keys(optionCases).length ? { optionCases } : {}),
535
+ ...(Object.keys(optionFields).length ? { optionFields } : {}),
536
+ });
537
+ if (!prep.ok)
538
+ return fail(prep.code, prep.error, prep.hint, EXIT.FINDINGS);
539
+ args = prep.args;
540
+ resolvedEntry = prep.entry;
541
+ extraWarns = prep.warns;
542
+ appliedLines = prep.applied;
543
+ }
544
+ else {
545
+ args = { resourceDir, entry, override, timeoutMs };
546
+ /* bundle 模式不载入 interface,preset 没有可应用的四级链——静默丢参数会让调用方
547
+ * 以为 P1 已生效;与 REPL 同一句警告。 */
548
+ if (presetName)
549
+ extraWarns.push('--resource-dir 是 bundle 模式:--preset ' + presetName + ' 不生效(不叠 interface 四级 override)');
550
+ if (Object.keys(optionCases).length || Object.keys(optionFields).length) {
551
+ extraWarns.push('--resource-dir 是 bundle 模式:--option 不生效(取值只作用于 interface 的 option 链与模板替换)');
552
+ }
553
+ }
554
+ /* 运行期日志跟随:run 会阻塞到任务结束,期间帧流/事件都不可用(框架占住控制器),
555
+ * 唯一还在更新的只有项目 agent 自己写的日志。给了 --watch 就在这里边跑边推。 */
556
+ const watchRaw = ctx.values.watch;
557
+ const watchEntries = (Array.isArray(watchRaw) ? watchRaw : watchRaw === undefined ? [] : [watchRaw])
558
+ .filter((x) => typeof x === 'string' && x.length > 0)
559
+ .map((x) => (isAbsolute(x) ? x : resolve(ctx.cwd, x)));
560
+ const watchInterval = Number(ctx.values['watch-interval'] ?? 2000);
561
+ /* include/exclude 正则:编译失败直接报 BAD_ARGUMENTS,不静默当无过滤跑 */
562
+ const watchInclude = (Array.isArray(ctx.values['watch-include']) ? ctx.values['watch-include'] : ctx.values['watch-include'] === undefined ? [] : [ctx.values['watch-include']])
563
+ .filter((x) => typeof x === 'string');
564
+ const watchExclude = (Array.isArray(ctx.values['watch-exclude']) ? ctx.values['watch-exclude'] : ctx.values['watch-exclude'] === undefined ? [] : [ctx.values['watch-exclude']])
565
+ .filter((x) => typeof x === 'string');
566
+ let includeRes;
567
+ let excludeRes;
568
+ try {
569
+ includeRes = watchInclude.length ? watchInclude.map((r) => new RegExp(r)) : undefined;
570
+ excludeRes = watchExclude.length ? watchExclude.map((r) => new RegExp(r)) : undefined;
571
+ }
572
+ catch (e) {
573
+ return fail('BAD_ARGUMENTS', '--watch-include/--watch-exclude 不是合法正则:' + String(e.message), undefined, EXIT.USAGE);
574
+ }
575
+ const watcher = watchEntries.length
576
+ ? watchLogs({ entries: watchEntries, intervalMs: Number.isFinite(watchInterval) ? watchInterval : 2000, include: includeRes, exclude: excludeRes }, (lines) => {
577
+ /* 直写 stdout:--json 模式下也会混进人类可读行,所以下面的信封里另给 watch 统计,
578
+ * 并在 --json 时把人类行改走 stderr(stdout 只留信封)。 */
579
+ const sink = ctx.flags.json ? process.stderr : process.stdout;
580
+ for (const l of lines)
581
+ sink.write('[watch] ' + l + '\n');
582
+ })
583
+ : null;
584
+ /* 信号处理(审计 #6):Ctrl-C / kill 时不许让 daemon 里的任务裸跑——向 daemon 发
585
+ * run_stop(post_stop),run 回执照常落盘,退出码区分「我停的」与「它坏的」:
586
+ * SIGINT=130 / SIGTERM=143(128+signo),任务自身失败仍是 1。
587
+ * 第二次按信号直接 bail——第一次没杀掉说明 daemon 已不应答,再等没有现场可留。 */
588
+ /* 信号处理(审计 #6):Ctrl-C / kill 时不许让 daemon 里的任务裸跑——向 daemon 发
589
+ * run_stop,run 回执照常落盘,退出码区分「我停的」与「它坏的」(128+signo)。 */
590
+ /* 信号处理(审计 #6):Ctrl-C / kill 时不许让 daemon 里的任务裸跑——向 daemon 发
591
+ * run_stop,run 回执照常落盘,退出码区分「我停的」与「它坏的」(128+signo)。 */
592
+ const sigGuard = installRunSignalGuard(() => act.runStop(client).then(() => undefined));
593
+ /* finally 摘钩:act.run 抛错(daemon 死了)也不留监听残留 */
594
+ const r = await act.run(client, args, timeoutMs).finally(() => sigGuard.restore());
595
+ const interrupted = sigGuard.code;
596
+ const watchStats = watcher ? watcher.stop() : null;
597
+ if (r.ok === false || !r.record) {
598
+ /* 调用级失败:任务根本没跑起来(未连接 / 资源缺失 / 已有任务在跑)→ FINDINGS */
599
+ return {
600
+ exitCode: EXIT.FINDINGS,
601
+ human: [...describeSession(s, ctx.flags.verbose), '调用失败:' + String(r.error ?? 'run 未返回记录')],
602
+ data: r,
603
+ warnings: [...extraWarns, ...(r.warns ?? [])],
604
+ };
605
+ }
606
+ /* 任务级结果以 record.ok 为准(daemon 外层 ok 只代表调用完成)。
607
+ * 任务失败退出 FAIL——不能吞成 0,也不占用 FINDINGS(那是调用级失败的语义)。 */
608
+ const rec = r.record;
609
+ const taskOk = rec.ok === true;
610
+ const nodes = rec.nodes ?? [];
611
+ /* 三态结论词:`status:0 + stopped:true` 单看 status 会被读成"成功完成"(本仓 G21 实测误读)。
612
+ * 判据用 record 自带的 timeoutMs,不靠调用方回忆当初给了什么:
613
+ * 跑满窗口被截断 → 非成功非失败(是"没跑完",不是"跑完了没成功");
614
+ * stopped 但没跑满 → 是任务自己结束、只是没成功。
615
+ * 退出码仍按任务级 record.ok,不给截断开绿灯。 */
616
+ const budget = typeof rec.timeoutMs === 'number' ? rec.timeoutMs : null;
617
+ const ranFull = rec.stopped === true && budget !== null && budget > 0
618
+ && typeof rec.durationMs === 'number' && rec.durationMs >= budget;
619
+ const verdict = interrupted
620
+ ? (interrupted === EXIT.SIGINT ? '被 Ctrl-C 中断(SIGINT)' : '被终止信号中断(SIGTERM)')
621
+ : taskOk
622
+ ? '完成'
623
+ : ranFull
624
+ ? '超时截断(非成功,也非失败:任务没跑完就被 post_stop 了)'
625
+ : rec.stopped === true
626
+ ? '被停止(run_stop / 任务自身请求 post_stop,未成功)'
627
+ : '失败';
628
+ const agentLines = [];
629
+ if (rec.hashCheck?.expected !== undefined) {
630
+ agentLines.push(rec.hashCheck.match === true
631
+ ? ' 资源完整性:' + String(rec.hashCheck.resourceName) + ' hash 校验通过'
632
+ : ' 资源完整性:' + String(rec.hashCheck.resourceName) + ' hash 不匹配(期望 ' +
633
+ String(rec.hashCheck.expected) + ' / 实际 ' + String(rec.hashCheck.actual || '?') + '),按 v2.6 语义不阻断');
634
+ }
635
+ if (rec.agent) {
636
+ agentLines.push(rec.agent.ok
637
+ ? ' agent 已连接' + (rec.agent.reused ? '(复用)' : '') + ':custom action ' +
638
+ String(rec.agent.actions?.length ?? '?') + ' 个 / recognition ' + String(rec.agent.recognitions?.length ?? '?') + ' 个'
639
+ : ' agent 连接失败:' + String(rec.agent.error ?? '?'));
640
+ }
641
+ const human = [
642
+ ...describeSession(s, ctx.flags.verbose),
643
+ '任务 ' + resolvedEntry + ':' + verdict +
644
+ '(status=' + String(rec.status) + ',' + String(rec.durationMs) + 'ms' +
645
+ (budget !== null ? ' / timeout=' + String(budget) + 'ms' : '') + ',帧序 ' +
646
+ String(rec.startSeq) + '..' + String(rec.endSeq) + ')',
647
+ ...agentLines,
648
+ ...appliedLines.map((l) => ' override: ' + l),
649
+ ...nodes.map((n) => ' [' + n.status + '] ' + n.name + ' ' + n.ms + 'ms'),
650
+ ];
651
+ if (!taskOk) {
652
+ human.push('', 'daemon 原生日志尾部:', ...client.logTail(20).map((l) => ' ' + l));
653
+ /* agent 缺位的失败归因:原生层只会说 Action is null,翻译成可行动的诊断 */
654
+ if (/Action is null|Recognition is null/.test(client.logTail(40).join('\n'))) {
655
+ const agents = s.plan?.loaded.agents ?? [];
656
+ human.push('', '诊断:日志出现 Action/Recognition is null —— 节点依赖项目 agent 注册的 custom 回调。' +
657
+ (rec.agent?.ok
658
+ ? 'agent 已连接但该动作未注册,检查 custom_action 拼写或 agent 版本。'
659
+ : agents.length
660
+ ? 'agent 连接失败(版本错配?maa-node 与 agent 侧 maa 库须同版本)。'
661
+ : '项目未声明 agent,检查节点 custom_action/custom_recognition 拼写。'));
662
+ extraWarns.push('任务失败疑似缺 agent custom 回调(Action/Recognition is null)');
663
+ }
664
+ }
665
+ if (rec.agent && rec.agent.ok === false) {
666
+ extraWarns.push('agent 连接失败:' + String(rec.agent.error ?? '?'));
667
+ }
668
+ if (watchStats && watchStats.files.length === 0) {
669
+ extraWarns.push('--watch 没有命中任何文件(' + watchEntries.join(' , ') + '):检查路径是否存在、日志是否在跑起来之后才创建');
670
+ }
671
+ if (watchStats) {
672
+ human.push(' 运行期日志跟随:' + String(watchStats.pushed) + ' 行(来自 ' +
673
+ String(watchStats.files.length) + ' 个文件)');
674
+ }
675
+ /* 回执全量落盘(与 repl 同一助手、同一目录约定):W1 runs 存储的种子。
676
+ * 失败只降级为不打路径行,不影响任务结论与退出码。 */
677
+ const receipt = writeRunReceipt(r, resolvedEntry);
678
+ if (receipt.path !== undefined) {
679
+ human.push(' 完整回执(含全部节点与 msgs):' + receipt.path);
680
+ }
681
+ return {
682
+ /* 中断优先于任务结论:退出码讲"我怎么走的",不讲"任务结果如何"(回执里有)。
683
+ * 130=SIGINT / 143=SIGTERM,都是 128+signo 惯例。 */
684
+ exitCode: interrupted || (taskOk ? EXIT.OK : EXIT.FAIL),
685
+ human,
686
+ data: watchStats ? { ...r, watch: watchStats } : r,
687
+ warnings: [...extraWarns, ...(r.warns ?? [])],
688
+ };
689
+ });
690
+ }
691
+ catch (e) {
692
+ return daemonFail(e);
693
+ }
694
+ },
695
+ };
696
+ export const stopCommand = {
697
+ name: 'stop',
698
+ summary: '停止运行中的任务(post_stop)',
699
+ usage: 'maafw-live stop',
700
+ /* 一次性形态没意义:停的是自己刚起的空 daemon,回 ok 误导(设计审查 P0-3 L4) */
701
+ requiresSession: true,
702
+ async run(ctx) {
703
+ try {
704
+ return await withDaemon(ctx, async (client) => {
705
+ const r = await act.runStop(client);
706
+ return { exitCode: EXIT.OK, human: ['已请求停止:' + JSON.stringify(r)], data: r };
707
+ });
708
+ }
709
+ catch (e) {
710
+ return daemonFail(e, '一次性进程里通常没有正在运行的任务;该命令主要用于 REPL 会话内中断');
711
+ }
712
+ },
713
+ };
714
+ export const RUNTIME_COMMANDS = [
715
+ probeCommand, deviceCommand, connectCommand, disconnectCommand,
716
+ screencapCommand, frameCommand, eventsCommand, waitCommand, runCommand, stopCommand,
717
+ ];
718
+ //# sourceMappingURL=runtime.js.map