@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
@@ -5,13 +5,17 @@
5
5
  * (控制器规划、资源解析、时间线)都不许写在这里——REPL 与其它壳(编辑器插件、面板)调的是同一批动作函数。
6
6
  */
7
7
  import { mkdirSync } from 'node:fs';
8
- import { dirname, join } from 'node:path';
8
+ import { watchLogs } from '../runtime/log-watch.js';
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
9
10
  import { defaultRunDir } from '../client/daemon.js';
10
- import { computePipelineOverride, mergeOverride } from '../interface/index.js';
11
11
  import { EXIT, fail } from '../protocol.js';
12
+ import { parseOptionArgs } from '../option-args.js';
12
13
  import * as act from '../runtime/actions.js';
13
14
  import { withDaemon } from '../runtime/actions.js';
14
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';
15
19
  /** 连接类选项:凡是要设备的命令都接受同一组,保证"怎么连"只有一套说法。 */
16
20
  export const CONNECT_OPTIONS = {
17
21
  project: { type: 'string' },
@@ -24,15 +28,30 @@ export const CONNECT_OPTIONS = {
24
28
  'short-side': { type: 'string' },
25
29
  'long-side': { type: 'string' },
26
30
  raw: { type: 'boolean' },
31
+ /* 跳过 interface.json 的 pretask(它会连 Controller 之前跑一次,非零即中止连接) */
32
+ 'no-pretask': { type: 'boolean' },
27
33
  };
28
- export function sessionOptions(values) {
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;
29
47
  const num = (k) => {
30
48
  const v = values[k];
31
49
  return typeof v === 'string' && v !== '' ? Number(v) : undefined;
32
50
  };
33
51
  const str = (k) => (typeof values[k] === 'string' ? values[k] : undefined);
34
52
  return {
35
- project: str('project'),
53
+ project: argPath(ctx, str('project')),
54
+ cwd: ctx.cwd,
36
55
  controller: str('controller'),
37
56
  resource: str('resource'),
38
57
  hwnd: str('hwnd'),
@@ -42,22 +61,32 @@ export function sessionOptions(values) {
42
61
  shortSide: num('short-side'),
43
62
  longSide: num('long-side'),
44
63
  rawSize: values.raw === true,
64
+ ...(values['no-pretask'] === true ? { pretask: false } : {}),
45
65
  };
46
66
  }
47
- /** daemon 抛出的错误一律翻译成结构化失败,不让堆栈进模型上下文。 */
67
+ /** daemon 抛出的错误翻译成结构化失败。退出码按错误的**性质**分:
68
+ * 前置缺失(脚本找不到 / 会话规划失败)退 4;运行时故障(调用超时、进程退出、发送失败)退 1——
69
+ * 同一个 code 在所有路径退同一个码,agent 才不会按"缺环境"去查"daemon 卡住"。 */
48
70
  export function daemonFail(e, hint) {
49
71
  if (e instanceof SessionError)
50
72
  return fail(e.code, e.message, hint, EXIT.ENV);
51
73
  const msg = e instanceof Error ? e.message : String(e);
52
- return fail('DAEMON_ERROR', msg, hint ?? '用 maafw-live probe 检查运行时;连接类错误先 maafw-live device 看有没有设备', EXIT.ENV);
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);
53
82
  }
54
83
  export const probeCommand = {
55
84
  name: 'probe',
56
85
  summary: '自检 maa-node 运行时:绑定版本、adb/win32 设备发现数',
57
86
  usage: 'maafw-live probe',
58
- async run() {
87
+ async run(ctx) {
59
88
  try {
60
- return await withDaemon(async (client) => {
89
+ return await withDaemon(ctx, async (client) => {
61
90
  const info = await act.probe(client);
62
91
  const devices = await act.deviceList(client, 'all').catch(() => []);
63
92
  const human = [
@@ -87,7 +116,7 @@ export const deviceCommand = {
87
116
  return fail('BAD_ARGUMENTS', 'kind 只能是 all / adb / win32', undefined, EXIT.USAGE);
88
117
  }
89
118
  try {
90
- return await withDaemon(async (client) => {
119
+ return await withDaemon(ctx, async (client) => {
91
120
  const list = await act.deviceList(client, kind);
92
121
  const human = list.length
93
122
  ? list.map((d) => {
@@ -115,14 +144,14 @@ export const connectCommand = {
115
144
  usage: 'maafw-live connect --project <dir> | --kind win32|adb|gamepad [--target ...]',
116
145
  options: { ...CONNECT_OPTIONS },
117
146
  async run(ctx) {
118
- const o = sessionOptions(ctx.values);
147
+ const o = sessionOptions(ctx);
119
148
  if (!o.project && !o.kind) {
120
149
  return fail('BAD_ARGUMENTS', '要么给 --project <dir>(按 interface.json 规划),要么给 --kind', '推荐 --project:控制器类型/窗口正则/输入方法/识别缩放全跟项目走', EXIT.USAGE);
121
150
  }
122
151
  try {
123
- return await withDaemon(async (client) => {
152
+ return await withDaemon(ctx, async (client) => {
124
153
  const s = await ensureSession(client, o);
125
- const lines = describeSession(s);
154
+ const lines = describeSession(s, ctx.flags.verbose);
126
155
  if (s.streamStopped) {
127
156
  /* 重建控制器会让旧流失效:如实说,别让调用方以为是设备卡了(曾经是"流静默死掉") */
128
157
  lines.push('', '帧流:已停止(本次连接重建了控制器);要看帧就 stream start 重开。');
@@ -146,16 +175,18 @@ export const connectCommand = {
146
175
  };
147
176
  export const disconnectCommand = {
148
177
  name: 'disconnect',
149
- summary: '断开设备并销毁 Tasker/Controller(一次性命令,等价于结束 REPL 会话)',
150
- usage: 'maafw-live disconnect --project <dir> | --kind ...',
178
+ summary: '断开设备并销毁 Tasker/Controller(会话内命令,等价于结束 REPL 会话)',
179
+ usage: 'maafw-live disconnect --session <name>(一次性形态:为断开先新连一次,没意义)',
151
180
  options: { ...CONNECT_OPTIONS },
181
+ /* 为断开先新连一次是自相矛盾的操作——断开会话态才需要"断开"(P0-3 L4) */
182
+ requiresSession: true,
152
183
  async run(ctx) {
153
- const o = sessionOptions(ctx.values);
184
+ const o = sessionOptions(ctx);
154
185
  if (!o.project && !o.kind) {
155
186
  return fail('BAD_ARGUMENTS', '需要 --project 或 --kind 才能建立会话再断开', undefined, EXIT.USAGE);
156
187
  }
157
188
  try {
158
- return await withDaemon(async (client) => {
189
+ return await withDaemon(ctx, async (client) => {
159
190
  await ensureSession(client, o);
160
191
  const r = await act.disconnect(client);
161
192
  return { exitCode: EXIT.OK, human: ['已断开:' + JSON.stringify(r)], data: r };
@@ -166,27 +197,32 @@ export const disconnectCommand = {
166
197
  }
167
198
  },
168
199
  };
200
+ /** 缺省落点 png/ 的保留上限(与 runs/ 同量级的"不留着看"产物) */
201
+ const PNG_KEEP = 30;
169
202
  export const screencapCommand = {
170
203
  name: 'screencap',
171
204
  summary: '截一帧图并落盘(需要连接:给 --project 或 --kind)',
172
205
  usage: 'maafw-live screencap [--project <dir>|--kind ...] [--out <png>]',
173
206
  options: { ...CONNECT_OPTIONS, out: { type: 'string' } },
174
207
  async run(ctx) {
175
- const o = sessionOptions(ctx.values);
176
- const out = typeof ctx.values.out === 'string'
177
- ? ctx.values.out
178
- : join(defaultRunDir(), 'png', 'shot_' + Date.now() + '.png');
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');
179
211
  try {
180
212
  mkdirSync(dirname(out), { recursive: true });
181
213
  }
182
214
  catch { /* 落盘失败交给 daemon 报错 */ }
215
+ /* 只修剪缺省落点 png/——显式 --out 是用户指定的位置,不动它 */
216
+ if (dirname(out) === join(defaultRunDir(), 'png')) {
217
+ pruneDir(dirname(out), { keep: PNG_KEEP, suffix: '.png' });
218
+ }
183
219
  try {
184
- return await withDaemon(async (client) => {
220
+ return await withDaemon(ctx, async (client) => {
185
221
  const s = await ensureSession(client, o);
186
222
  const r = await act.screencap(client, out);
187
223
  return {
188
224
  exitCode: r.ok === false ? EXIT.FAIL : EXIT.OK,
189
- human: [...describeSession(s), '截图:' + String(r.path ?? out) + (r.bytes ? ' (' + r.bytes + ' bytes)' : '')],
225
+ human: [...describeSession(s, ctx.flags.verbose), '截图:' + String(r.path ?? out) + (r.bytes ? ' (' + r.bytes + ' bytes)' : '')],
190
226
  data: r,
191
227
  written: r.path ? [String(r.path)] : [out],
192
228
  };
@@ -197,21 +233,97 @@ export const screencapCommand = {
197
233
  }
198
234
  },
199
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
+ };
200
310
  export const frameCommand = {
201
311
  name: 'frame',
312
+ /* frame get 的环恒空(NO_FRAME);frame status 读的是流状态,空环也算有内容的回答 */
313
+ requiresSession: (ctx) => ctx.positionals[0] === 'get',
202
314
  summary: '帧流状态与历史帧取用(环形缓冲只在 REPL 会话里才有内容)',
203
315
  usage: 'maafw-live frame status | maafw-live frame get [seq] [--roi x,y,w,h] [--out <png>] [--src auto|full|ring]\n' +
204
316
  ' --src full 强制要控制器分辨率原图(L0 已淘汰则报错,绝不给缩小图冒充)',
205
317
  options: { ...CONNECT_OPTIONS, roi: { type: 'string' }, out: { type: 'string' }, src: { type: 'string' } },
206
318
  async run(ctx) {
207
319
  const sub = ctx.positionals[0] ?? 'status';
208
- const o = sessionOptions(ctx.values);
320
+ const o = sessionOptions(ctx);
209
321
  if (sub === 'status') {
210
322
  try {
211
- return await withDaemon(async (client) => {
323
+ return await withDaemon(ctx, async (client) => {
212
324
  const s = await ensureSession(client, o);
213
325
  const st = await act.streamStatus(client);
214
- return { exitCode: EXIT.OK, human: [...describeSession(s), '帧流:' + JSON.stringify(st)], data: st };
326
+ return { exitCode: EXIT.OK, human: [...describeSession(s, ctx.flags.verbose), '帧流:' + JSON.stringify(st)], data: st };
215
327
  });
216
328
  }
217
329
  catch (e) {
@@ -232,14 +344,14 @@ export const frameCommand = {
232
344
  if (roi && (roi.length !== 4 || roi.some((n) => !Number.isFinite(n)))) {
233
345
  return fail('BAD_ARGUMENTS', '--roi 需要 4 个数字:x,y,w,h(控制器分辨率坐标,与 pipeline 的 roi 同空间)', undefined, EXIT.USAGE);
234
346
  }
235
- const out = typeof ctx.values.out === 'string' ? ctx.values.out : undefined;
347
+ const out = argPath(ctx, typeof ctx.values.out === 'string' ? ctx.values.out : undefined);
236
348
  const srcRaw = typeof ctx.values.src === 'string' ? ctx.values.src.toLowerCase() : 'auto';
237
349
  if (!['auto', 'full', 'ring'].includes(srcRaw)) {
238
350
  return fail('BAD_ARGUMENTS', '--src 只支持 auto | full | ring', undefined, EXIT.USAGE);
239
351
  }
240
352
  const src = srcRaw;
241
353
  try {
242
- return await withDaemon(async (client) => {
354
+ return await withDaemon(ctx, async (client) => {
243
355
  await ensureSession(client, o);
244
356
  const r = await act.frameGet(client, { seq, roi, out, src });
245
357
  if (r.ok === false) {
@@ -269,6 +381,9 @@ export const frameCommand = {
269
381
  };
270
382
  export const waitCommand = {
271
383
  name: 'wait',
384
+ /* 预热截图不进环 → 恒 NO_FRAME 退 3;就算有帧也没流,stable 秒成立/change 必超时——
385
+ * 两种结果都是假信号(P0-3 L4) */
386
+ requiresSession: true,
272
387
  summary: '等状态谓词成立:stable=画面静下来 / change=画面动了(取代硬睡时钟)',
273
388
  usage: 'maafw-live wait stable|change [--timeout ms] [--quiet ms] [--roi x,y,w,h] [--threshold n]\n' +
274
389
  ' 超时不算命令失败(ok=true, satisfied=false, 退出码 3)——"没等到"是观测结果',
@@ -283,7 +398,7 @@ export const waitCommand = {
283
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);
284
399
  }
285
400
  const mode = modeRaw;
286
- const o = sessionOptions(ctx.values);
401
+ const o = sessionOptions(ctx);
287
402
  const timeout = ctx.values.timeout !== undefined ? Number(ctx.values.timeout) : undefined;
288
403
  if (timeout !== undefined && !Number.isFinite(timeout)) {
289
404
  return fail('BAD_ARGUMENTS', '--timeout 必须是毫秒数', undefined, EXIT.USAGE);
@@ -303,7 +418,7 @@ export const waitCommand = {
303
418
  return fail('BAD_ARGUMENTS', '--roi 需要 4 个数字:x,y,w,h', undefined, EXIT.USAGE);
304
419
  }
305
420
  try {
306
- return await withDaemon(async (client) => {
421
+ return await withDaemon(ctx, async (client) => {
307
422
  await ensureSession(client, o);
308
423
  const r = await act.waitState(client, {
309
424
  mode,
@@ -335,9 +450,15 @@ export const waitCommand = {
335
450
  export const runCommand = {
336
451
  name: 'run',
337
452
  summary: '运行 pipeline:项目模式(推荐)或 resource 目录模式;节点事件按帧序对齐。退出码按任务级 record.ok',
338
- usage: 'maafw-live run --entry <task> --project <dir> | --resource-dir <dir> [--timeout <ms|0>] [--override <json>] [--preset <name>] [--resource <名|路径>]\n' +
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' +
339
458
  ' --timeout 0 = 不自动停止(停止权交调用方:事件流判断 + maafw-live stop / 客户端 run_stop);>0 时超时自动 post_stop\n' +
340
- ' 项目模式自动合成 pipeline_override(global→resource→controller→task,preset/default 取值),--override 最后覆盖',
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)',
341
462
  options: {
342
463
  ...CONNECT_OPTIONS,
343
464
  entry: { type: 'string' },
@@ -345,17 +466,27 @@ export const runCommand = {
345
466
  timeout: { type: 'string' },
346
467
  override: { type: 'string' },
347
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 },
348
477
  },
349
478
  async run(ctx) {
350
- const o = sessionOptions(ctx.values);
479
+ const o = sessionOptions(ctx);
351
480
  const entry = (typeof ctx.values.entry === 'string' ? ctx.values.entry : ctx.positionals[0]) ?? undefined;
352
- const resourceDir = typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined;
481
+ const resourceDir = argPath(ctx, typeof ctx.values['resource-dir'] === 'string' ? ctx.values['resource-dir'] : undefined);
353
482
  if (!entry) {
354
483
  return fail('BAD_ARGUMENTS', '缺少入口任务名:--entry <task>', '项目模式=interface.json 的 task 名;bundle 模式=pipeline 首节点名', EXIT.USAGE);
355
484
  }
356
485
  if (!o.project && !resourceDir) {
357
486
  return fail('BAD_ARGUMENTS', '要么给 --project <dir>,要么给 --resource-dir <dir>', undefined, EXIT.USAGE);
358
487
  }
488
+ /* --override 收对象(=一层)或数组(=逐层):框架按数组逐层继承,对象会被摊成
489
+ * [obj] 等价一层。字段缺失继承前值——与 GUI 客户端同一语义(@8061d5b 已核)。 */
359
490
  let override = {};
360
491
  if (typeof ctx.values.override === 'string') {
361
492
  try {
@@ -369,73 +500,105 @@ export const runCommand = {
369
500
  if (!Number.isFinite(timeoutMs)) {
370
501
  return fail('BAD_ARGUMENTS', '--timeout 必须是毫秒数', undefined, EXIT.USAGE);
371
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;
372
519
  try {
373
- return await withDaemon(async (client) => {
520
+ return await withDaemon(ctx, async (client) => {
374
521
  const s = await ensureSession(client, o);
375
522
  let args;
376
523
  let resolvedEntry = entry;
377
- let chain = null;
378
524
  let extraWarns = [];
379
525
  let appliedLines = [];
380
526
  if (o.project) {
381
- const paths = s.plan?.resource?.paths ?? [];
382
- if (!paths.length) {
383
- return fail('RESOURCE_NOT_FOUND', '项目里没有解析出资源路径:检查 interface.json 的 resource[] 声明', undefined, EXIT.FINDINGS);
384
- }
385
- const task = s.plan?.loaded.tasks.find((t) => t.name === entry || t.entry === entry);
386
- resolvedEntry = task?.entry ?? entry;
387
- /* task 适用性(task.controller / task.resource 声明) */
388
- const ctrlName = s.plan?.controllerName ?? null;
389
- const resName = s.plan?.resource?.selected ?? null;
390
- if (task?.controller?.length && ctrlName && !task.controller.includes(ctrlName)) {
391
- return fail('TASK_NOT_APPLICABLE', '任务 ' + task.name + ' 不支持当前控制器 ' + ctrlName + '(声明:' + task.controller.join(' / ') + ')', '换 --controller ' + task.controller[0] + ',或选别的任务', EXIT.FINDINGS);
392
- }
393
- if (task?.resource?.length && resName && !task.resource.includes(resName)) {
394
- extraWarns.push('任务 ' + task.name + ' 声明资源 ' + task.resource.join(' / ') + ',当前选中的是 ' + resName);
395
- }
396
- /* pipeline_override 四级合并链(preset > default_case 取值) + 用户 --override 最后覆盖 */
397
- chain = computePipelineOverride({
398
- loaded: s.plan.loaded,
399
- controllerName: ctrlName,
400
- resourceName: resName,
401
- taskName: task?.name ?? null,
402
- presetName: typeof ctx.values.preset === 'string' ? ctx.values.preset : null,
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 } : {}),
403
536
  });
404
- extraWarns = [...extraWarns, ...chain.warns];
405
- appliedLines = chain.applied;
406
- const pt = s.plan.loaded.pretask;
407
- if (pt.length) {
408
- extraWarns.push('项目声明了 ' + pt.length + ' 条 pretask(' + pt.map((p) => p.exec ?? p.name ?? '?').join(' , ') + '),本 CLI 不执行——需自行保证前置条件');
409
- }
410
- const agents = s.plan.loaded.agents;
411
- if (agents.length) {
412
- extraWarns.push('项目声明了 ' + agents.length + ' 个 agent 子进程(' +
413
- agents.map((a) => [a.exec, ...a.args].join(' ')).join(' ; ') +
414
- '),run 时自动 spawn 并桥接——注意 maa-node 与 agent 侧 maa 库须同版本(协议握手要求)');
415
- }
416
- args = {
417
- resourceDirs: paths,
418
- entry: resolvedEntry,
419
- override: { ...chain.override },
420
- timeoutMs,
421
- /* agent 桥接:daemon spawn 子进程(identifier 追加为末参)并 bind/connect 到本次 Resource */
422
- agents: s.plan.loaded.agents.map((a) => ({ exec: a.exec, args: a.args })),
423
- agentCwd: s.plan.loaded.dir ?? undefined,
424
- };
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;
425
543
  }
426
544
  else {
427
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
+ }
428
553
  }
429
- if (o.project && chain) {
430
- /* 用户 --override 最后覆盖合成结果;与四级链共用同一份合并实现(非对象值原样替换) */
431
- mergeOverride(args.override, override);
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;
432
571
  }
433
- const r = await act.run(client, args, timeoutMs);
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;
434
597
  if (r.ok === false || !r.record) {
435
598
  /* 调用级失败:任务根本没跑起来(未连接 / 资源缺失 / 已有任务在跑)→ FINDINGS */
436
599
  return {
437
600
  exitCode: EXIT.FINDINGS,
438
- human: [...describeSession(s), '调用失败:' + String(r.error ?? 'run 未返回记录')],
601
+ human: [...describeSession(s, ctx.flags.verbose), '调用失败:' + String(r.error ?? 'run 未返回记录')],
439
602
  data: r,
440
603
  warnings: [...extraWarns, ...(r.warns ?? [])],
441
604
  };
@@ -445,7 +608,30 @@ export const runCommand = {
445
608
  const rec = r.record;
446
609
  const taskOk = rec.ok === true;
447
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
+ : '失败';
448
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
+ }
449
635
  if (rec.agent) {
450
636
  agentLines.push(rec.agent.ok
451
637
  ? ' agent 已连接' + (rec.agent.reused ? '(复用)' : '') + ':custom action ' +
@@ -453,9 +639,10 @@ export const runCommand = {
453
639
  : ' agent 连接失败:' + String(rec.agent.error ?? '?'));
454
640
  }
455
641
  const human = [
456
- ...describeSession(s),
457
- '任务 ' + resolvedEntry + ':' + (taskOk ? '完成' : (rec.stopped ? '超时被停止(视为失败)' : '失败')) +
458
- '(status=' + String(rec.status) + ',' + String(rec.durationMs) + 'ms,帧序 ' +
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' : '') + ',帧序 ' +
459
646
  String(rec.startSeq) + '..' + String(rec.endSeq) + ')',
460
647
  ...agentLines,
461
648
  ...appliedLines.map((l) => ' override: ' + l),
@@ -478,10 +665,25 @@ export const runCommand = {
478
665
  if (rec.agent && rec.agent.ok === false) {
479
666
  extraWarns.push('agent 连接失败:' + String(rec.agent.error ?? '?'));
480
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
+ }
481
681
  return {
482
- exitCode: taskOk ? EXIT.OK : EXIT.FAIL,
682
+ /* 中断优先于任务结论:退出码讲"我怎么走的",不讲"任务结果如何"(回执里有)。
683
+ * 130=SIGINT / 143=SIGTERM,都是 128+signo 惯例。 */
684
+ exitCode: interrupted || (taskOk ? EXIT.OK : EXIT.FAIL),
483
685
  human,
484
- data: r,
686
+ data: watchStats ? { ...r, watch: watchStats } : r,
485
687
  warnings: [...extraWarns, ...(r.warns ?? [])],
486
688
  };
487
689
  });
@@ -495,9 +697,11 @@ export const stopCommand = {
495
697
  name: 'stop',
496
698
  summary: '停止运行中的任务(post_stop)',
497
699
  usage: 'maafw-live stop',
498
- async run() {
700
+ /* 一次性形态没意义:停的是自己刚起的空 daemon,回 ok 误导(设计审查 P0-3 L4) */
701
+ requiresSession: true,
702
+ async run(ctx) {
499
703
  try {
500
- return await withDaemon(async (client) => {
704
+ return await withDaemon(ctx, async (client) => {
501
705
  const r = await act.runStop(client);
502
706
  return { exitCode: EXIT.OK, human: ['已请求停止:' + JSON.stringify(r)], data: r };
503
707
  });
@@ -509,6 +713,6 @@ export const stopCommand = {
509
713
  };
510
714
  export const RUNTIME_COMMANDS = [
511
715
  probeCommand, deviceCommand, connectCommand, disconnectCommand,
512
- screencapCommand, frameCommand, waitCommand, runCommand, stopCommand,
716
+ screencapCommand, frameCommand, eventsCommand, waitCommand, runCommand, stopCommand,
513
717
  ];
514
718
  //# sourceMappingURL=runtime.js.map