@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,471 @@
1
+ /**
2
+ * 命名会话(`repl --session <name>`)的 IPC 面。
3
+ *
4
+ * 为什么需要它(本仓 G12):daemon 连接是**进程态**——帧流、L0 热缓存、事件环都活在
5
+ * repl 会话里,而一次性命令退出即销毁。此前想跨命令复用,只能自建常驻驱动
6
+ * (`repl-driver.mjs` 那种:spawn 一个 repl、持续喂 stdin、把 stdout 落盘、再读文件)。
7
+ * 每个使用者都重写一遍同一套东西,且各有各的坑(stdin 不是管道、shim 引号、EOF 即退出)。
8
+ *
9
+ * 这里把那条驱动收进工具本体:`--session <name>` 首次调用时**脱离父进程**起一个常驻
10
+ * repl(daemon 随之长活),后续同 name 的调用把命令行经命名管道送进去、把回执读回来。
11
+ *
12
+ * 三个设计约束(都是踩过的坑,不是偏好):
13
+ *
14
+ * ① **不能用 stdin 喂**:PowerShell 没法把命名管道当 stdin 递给子进程(本仓
15
+ * `.live/repl-driver.mjs` 头注释记的就是这条)。所以常驻进程走 `--session-serve`
16
+ * 从**命名管道**读命令,而不是从 stdin。
17
+ *
18
+ * ② **必须脱离父进程**:常驻进程若挂在调用方的作业对象下,父命令一退就被一起收走,
19
+ * 第二次调用就找不到会话了。Windows 上用 detached + unref。
20
+ *
21
+ * ③ **回执要有边界**:管道是字节流,多行回执之间必须能切分。用「一行一条 JSON 信封」,
22
+ * 每条命令带 id,调用方读到自己的 id 为止——不靠 sleep 猜,也不靠读空判断。
23
+ *
24
+ * 命名空间按 **用户 + 会话名** 隔离,会话名做白名单清洗(它要进文件路径与管道名,
25
+ * 不能让 `..` 或路径分隔符穿过去)。
26
+ */
27
+ import { createServer, connect } from 'node:net';
28
+ import { spawn, spawnSync } from 'node:child_process';
29
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import { join } from 'node:path';
32
+ import { cliPackage } from '../pkg.js';
33
+ /** 会话名白名单:字母数字与 `-` `_`,1..48 字符。要进管道名与文件路径,必须收紧。 */
34
+ export function validSessionName(name) {
35
+ return /^[A-Za-z0-9_-]{1,48}$/.test(name);
36
+ }
37
+ /** 会话目录:`~/.maafw-live/sessions/<name>/`(与 frames 同级,都是本工具的本地状态)。 */
38
+ export function sessionDir(name) {
39
+ return join(homedir(), '.maafw-live', 'sessions', name);
40
+ }
41
+ /** Windows 命名管道名;其它平台用 Unix domain socket 文件(同一份代码,两个平台都能跑)。
42
+ *
43
+ * ⚠️ 返回 Unix socket **路径**时会落在 `sessionDir(name)/sock` 下,所以调用方必须先确保
44
+ * 该目录存在(`ensureSessionDir`)——`listen()` 对不存在的父目录直接 ENOENT。
45
+ * Windows 命名管道在 `\\.\pipe\` 命名空间里,与文件系统无关,不需要建目录。 */
46
+ export function sessionPipePath(name) {
47
+ if (process.platform === 'win32')
48
+ return `\\\\.\\pipe\\maafw-live-${name}`;
49
+ return join(sessionDir(name), 'sock');
50
+ }
51
+ /** 平台无关的"管道父目录就绪":Windows 上是空操作(命名管道不走文件系统)。 */
52
+ export function ensureSessionDir(name) {
53
+ if (process.platform === 'win32')
54
+ return;
55
+ mkdirSync(sessionDir(name), { recursive: true });
56
+ }
57
+ export function sessionMetaPath(name) {
58
+ return join(sessionDir(name), 'meta.json');
59
+ }
60
+ export function readSessionMeta(name) {
61
+ const p = sessionMetaPath(name);
62
+ if (!existsSync(p))
63
+ return null;
64
+ try {
65
+ return JSON.parse(readFileSync(p, 'utf8'));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** 进程还活着吗(`process.kill(pid, 0)` 不真发信号,只做存在性检查)。 */
72
+ export function pidAlive(pid) {
73
+ if (!Number.isFinite(pid) || pid <= 0)
74
+ return false;
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ }
79
+ catch (e) {
80
+ /* EPERM = 进程存在但没权限发信号,仍算活着;ESRCH 才是没了 */
81
+ return e.code === 'EPERM';
82
+ }
83
+ }
84
+ /** 常驻侧:把 repl 的输出按"命令"切段回给调用方。 */
85
+ export class SessionServer {
86
+ onLine;
87
+ onExec;
88
+ server = null;
89
+ sockets = new Set();
90
+ /** 串行队列:会话共用一条设备连接,命令必须一条条跑完再下一条 */
91
+ pending = [];
92
+ buffer = [];
93
+ current = null;
94
+ /**
95
+ * @param onLine REPL 行解释器(旧 kind=line 路径)
96
+ * @param onExec argv 分发器(kind=exec):注入常驻 client 后走 dispatchArgv,
97
+ * 回执里带结构化 CommandResult——`--session` 下的 --json 信封靠它才存在。
98
+ * 不给时 exec 请求回 UNSUPPORTED(防"静默按 line 跑了别的命令")。
99
+ */
100
+ constructor(onLine, onExec) {
101
+ this.onLine = onLine;
102
+ this.onExec = onExec;
103
+ }
104
+ /** 供 repl 循环调用:把一行输出归到当前命令的回执里。
105
+ *
106
+ * **没有当前命令时不是丢弃,而是挂到"待认领"队列**:`run` 这类命令是异步收尾的
107
+ * (提交后立刻返回,`run 完成…` 稍后才打),而回执在那之前就发出去了——丢掉的话
108
+ * 调用方永远看不到任务结果。挂起来随下一条命令的回执一起给,轮询式用法正好能拿到。
109
+ * 队列有上限:没人认领时不能无限涨(常驻会话可能几天没人问)。 */
110
+ pushOutput(line) {
111
+ if (!this.current) {
112
+ this.unclaimed.push(line);
113
+ if (this.unclaimed.length > 200)
114
+ this.unclaimed.splice(0, this.unclaimed.length - 200);
115
+ return;
116
+ }
117
+ this.buffer.push(line);
118
+ }
119
+ /** 连接横幅等:等价于"还没有命令时打的输出",与异步收尾共用同一条待认领队列。 */
120
+ pushStartup(line) {
121
+ this.pushOutput(line);
122
+ }
123
+ unclaimed = [];
124
+ reply(r) {
125
+ const cur = this.current;
126
+ if (!cur)
127
+ return;
128
+ this.current = null;
129
+ /* 一律以服务端缓冲为准:调用方给的 lines 只是形状占位,不能覆盖实际输出 */
130
+ const payload = {
131
+ id: cur.id, ok: r.ok,
132
+ ...(r.error ? { error: r.error } : {}),
133
+ ...(r.closing ? { closing: true } : {}),
134
+ ...(r.result ? { result: r.result } : {}),
135
+ lines: this.buffer,
136
+ };
137
+ this.buffer = [];
138
+ try {
139
+ cur.socket.write(JSON.stringify(payload) + '\n');
140
+ }
141
+ catch {
142
+ /* 调用方可能已断开(超时先走),不影响会话继续 */
143
+ }
144
+ }
145
+ listen(pipe) {
146
+ return new Promise((resolve, reject) => {
147
+ /* POSIX 下 socket 是**文件**:进程被强杀时文件会留下,再 listen 就是 EADDRINUSE
148
+ * (Windows 命名管道随进程消失,没有这个问题)。所以先清掉残留的 socket 文件——
149
+ * 但只清"确实是 socket"的,不误删普通文件。 */
150
+ if (process.platform !== 'win32') {
151
+ try {
152
+ if (lstatSync(pipe).isSocket())
153
+ rmSync(pipe, { force: true });
154
+ }
155
+ catch {
156
+ /* 不存在或不是 socket:都不用管 */
157
+ }
158
+ }
159
+ const server = createServer((socket) => {
160
+ this.sockets.add(socket);
161
+ socket.setEncoding('utf8');
162
+ let buf = '';
163
+ socket.on('data', (chunk) => {
164
+ buf += chunk;
165
+ let i;
166
+ while ((i = buf.indexOf('\n')) >= 0) {
167
+ const raw = buf.slice(0, i);
168
+ buf = buf.slice(i + 1);
169
+ if (!raw.trim())
170
+ continue;
171
+ let req;
172
+ try {
173
+ req = JSON.parse(raw);
174
+ }
175
+ catch {
176
+ socket.write(JSON.stringify({ id: -1, ok: false, lines: [], error: '请求不是合法 JSON' }) + '\n');
177
+ continue;
178
+ }
179
+ /* 同一时刻只允许一条在跑:会话是**串行**的(共用一条设备连接,
180
+ * 并发会让两条命令交错读同一份状态)。后到的排队等前一条回执。 */
181
+ const run = () => {
182
+ this.current = { id: req.id, socket };
183
+ /* 待认领的输出(连接横幅、上一条异步命令的收尾)先给,再按本命令的
184
+ * 实测输出追加——顺序即时间序,调用方读起来才与交互式 repl 一致。 */
185
+ this.buffer = this.unclaimed.splice(0, this.unclaimed.length);
186
+ const finish = (r) => {
187
+ this.reply(r);
188
+ const next = this.pending.shift();
189
+ if (next)
190
+ next();
191
+ };
192
+ try {
193
+ if (req.kind === 'exec' && this.onExec) {
194
+ this.onExec(req.argv ?? [], finish);
195
+ }
196
+ else if (req.kind === 'exec') {
197
+ finish({ ok: false, error: '这个常驻进程不支持 exec 请求(请重启会话)' });
198
+ }
199
+ else {
200
+ this.onLine(String(req.line ?? ''), finish);
201
+ }
202
+ }
203
+ catch (e) {
204
+ finish({ ok: false, error: e instanceof Error ? e.message : String(e) });
205
+ }
206
+ };
207
+ if (this.current)
208
+ this.pending.push(run);
209
+ else
210
+ run();
211
+ }
212
+ });
213
+ socket.on('error', () => this.sockets.delete(socket));
214
+ socket.on('close', () => this.sockets.delete(socket));
215
+ });
216
+ server.on('error', reject);
217
+ server.listen(pipe, () => {
218
+ this.server = server;
219
+ resolve();
220
+ });
221
+ });
222
+ }
223
+ close() {
224
+ for (const s of this.sockets) {
225
+ try {
226
+ s.destroy();
227
+ }
228
+ catch {
229
+ /* ignore */
230
+ }
231
+ }
232
+ this.sockets.clear();
233
+ try {
234
+ this.server?.close();
235
+ }
236
+ catch {
237
+ /* ignore */
238
+ }
239
+ }
240
+ }
241
+ /** 调用侧:把一条命令送到已存在的会话,等它那条回执。 */
242
+ export async function sendToSession(meta, line, timeoutMs) {
243
+ return await new Promise((resolve, reject) => {
244
+ const sock = connect(meta.pipe);
245
+ const id = Date.now() % 1_000_000_000;
246
+ let buf = '';
247
+ const done = (fn) => {
248
+ clearTimeout(timer);
249
+ try {
250
+ sock.destroy();
251
+ }
252
+ catch {
253
+ /* ignore */
254
+ }
255
+ fn();
256
+ };
257
+ const timer = setTimeout(() => {
258
+ done(() => reject(new Error(`会话 '${meta.name}' 在 ${timeoutMs}ms 内没有回执(会话可能卡在一条长命令上;先 quit 再重试)`)));
259
+ }, timeoutMs);
260
+ sock.setEncoding('utf8');
261
+ sock.on('connect', () => sock.write(JSON.stringify({ id, line }) + '\n'));
262
+ sock.on('data', (chunk) => {
263
+ buf += chunk;
264
+ let i;
265
+ while ((i = buf.indexOf('\n')) >= 0) {
266
+ const raw = buf.slice(0, i);
267
+ buf = buf.slice(i + 1);
268
+ if (!raw.trim())
269
+ continue;
270
+ let rep;
271
+ try {
272
+ rep = JSON.parse(raw);
273
+ }
274
+ catch {
275
+ continue;
276
+ }
277
+ if (rep.id !== id)
278
+ continue;
279
+ done(() => resolve(rep));
280
+ return;
281
+ }
282
+ });
283
+ sock.on('error', (e) => done(() => reject(e)));
284
+ });
285
+ }
286
+ /** 调用侧(v2):把一条 argv 送到会话,等它那条带结构化 result 的回执。
287
+ * 与 sendToSession 共用同一条管道/协议,只是 kind/载荷不同。 */
288
+ export async function sendToSessionExec(meta, argv, timeoutMs) {
289
+ return await new Promise((resolve, reject) => {
290
+ const sock = connect(meta.pipe);
291
+ const id = Date.now() % 1_000_000_000;
292
+ let buf = '';
293
+ const done = (fn) => {
294
+ clearTimeout(timer);
295
+ try {
296
+ sock.destroy();
297
+ }
298
+ catch {
299
+ /* ignore */
300
+ }
301
+ fn();
302
+ };
303
+ const timer = setTimeout(() => {
304
+ done(() => reject(new Error(`会话 '${meta.name}' 在 ${timeoutMs}ms 内没有回执(会话可能卡在一条长命令上;先 quit 再重试)`)));
305
+ }, timeoutMs);
306
+ sock.setEncoding('utf8');
307
+ sock.on('connect', () => sock.write(JSON.stringify({ id, kind: 'exec', argv }) + '\n'));
308
+ sock.on('data', (chunk) => {
309
+ buf += chunk;
310
+ let i;
311
+ while ((i = buf.indexOf('\n')) >= 0) {
312
+ const raw = buf.slice(0, i);
313
+ buf = buf.slice(i + 1);
314
+ if (!raw.trim())
315
+ continue;
316
+ let rep;
317
+ try {
318
+ rep = JSON.parse(raw);
319
+ }
320
+ catch {
321
+ continue;
322
+ }
323
+ if (rep.id !== id)
324
+ continue;
325
+ done(() => resolve(rep));
326
+ return;
327
+ }
328
+ });
329
+ sock.on('error', (e) => done(() => reject(e)));
330
+ });
331
+ }
332
+ /** 起一个脱离父进程的常驻会话(`--session-serve` 模式)。
333
+ *
334
+ * `stdio` 不能是 `'ignore'`:常驻进程起不来时(最典型的是 POSIX 上残留 socket 导致
335
+ * listen 失败),调用方只会看到"管道 20s 内不可连",真因被吞掉——而 `listen` 的 EADDRINUSE
336
+ * 与"连接参数错"是完全不同的两件事。所以把它自己的 stdout/stderr 落到会话目录的
337
+ * `serve.log`,失败时读回来当诊断依据。 */
338
+ export function spawnSessionServer(name, cliEntry, connectArgs) {
339
+ const dir = sessionDir(name);
340
+ mkdirSync(dir, { recursive: true });
341
+ const pipe = sessionPipePath(name);
342
+ const logPath = join(dir, 'serve.log');
343
+ let out = 'ignore';
344
+ try {
345
+ out = openSync(logPath, 'a');
346
+ }
347
+ catch {
348
+ out = 'ignore';
349
+ }
350
+ const args = [cliEntry, 'repl', '--session-serve', name, ...connectArgsToArgv(connectArgs)];
351
+ const child = spawn(process.execPath, args, {
352
+ detached: true,
353
+ stdio: ['ignore', out, out],
354
+ windowsHide: true,
355
+ });
356
+ child.unref();
357
+ if (typeof out === 'number') {
358
+ try {
359
+ closeSync(out);
360
+ }
361
+ catch {
362
+ /* ignore */
363
+ }
364
+ }
365
+ const meta = {
366
+ name,
367
+ pid: child.pid ?? 0,
368
+ pipe,
369
+ startedAt: new Date().toISOString(),
370
+ connectArgs,
371
+ cliVersion: cliPackage().version,
372
+ };
373
+ writeFileSync(sessionMetaPath(name), JSON.stringify(meta, null, 2));
374
+ return meta;
375
+ }
376
+ /** 读常驻进程自己的日志尾部(诊断"为什么起不来")。 */
377
+ export function sessionServeLogTail(name, lines = 12) {
378
+ const p = join(sessionDir(name), 'serve.log');
379
+ if (!existsSync(p))
380
+ return [];
381
+ try {
382
+ const all = readFileSync(p, 'utf8').split(/\r?\n/).filter((l) => l.trim() !== '');
383
+ return all.slice(-lines);
384
+ }
385
+ catch {
386
+ return [];
387
+ }
388
+ }
389
+ /** 连接参数 → argv(会话常驻进程用同一套 CLI 旗标启动,不另造一套参数面)。
390
+ * 布尔值只发裸旗标:`--raw true` 里的 `true` 会被 parseArgs 当成位置参数(用法错误),
391
+ * 而且"false"是字符串真值,传成参数就把语义反了。 */
392
+ export function connectArgsToArgv(a) {
393
+ const out = [];
394
+ for (const [k, v] of Object.entries(a)) {
395
+ if (v === undefined || v === null || v === false)
396
+ continue;
397
+ if (v === true) {
398
+ out.push('--' + k);
399
+ continue;
400
+ }
401
+ out.push('--' + k, String(v));
402
+ }
403
+ return out;
404
+ }
405
+ /** 等管道可连(常驻进程起来要时间;轮询到超时为止,不靠固定 sleep)。 */
406
+ export async function waitPipeReady(pipe, timeoutMs) {
407
+ const deadline = Date.now() + timeoutMs;
408
+ for (;;) {
409
+ const ok = await new Promise((resolve) => {
410
+ const sock = connect(pipe);
411
+ sock.on('connect', () => {
412
+ sock.destroy();
413
+ resolve(true);
414
+ });
415
+ sock.on('error', () => {
416
+ sock.destroy();
417
+ resolve(false);
418
+ });
419
+ });
420
+ if (ok)
421
+ return true;
422
+ if (Date.now() > deadline)
423
+ return false;
424
+ await new Promise((r) => setTimeout(r, 100));
425
+ }
426
+ }
427
+ /** 收掉一条会话(清 meta 与目录,管道随进程退出消失)。
428
+ * 进程树:server → daemon → agent(uv → venv-python → uv-python)。process.kill 只打
429
+ * 第一层——daemon 来不及收摊就死,agent 树整体孤儿化(实测两层 python 残留)。
430
+ * taskkill /T 按父链整棵收;uv 重 exec 出的中层会脱离父链漏掉,再补一刀:命令行带
431
+ * agent/main.py 且父进程已死的全是孤儿(活会话的 agent 父链必健在,不会误伤)。 */
432
+ export function stopSession(name) {
433
+ const meta = readSessionMeta(name);
434
+ if (meta && pidAlive(meta.pid)) {
435
+ if (process.platform === 'win32') {
436
+ try {
437
+ spawnSync('taskkill', ['/pid', String(meta.pid), '/T', '/F'], { timeout: 10000 });
438
+ }
439
+ catch {
440
+ try {
441
+ process.kill(meta.pid);
442
+ }
443
+ catch { /* ignore */ }
444
+ }
445
+ try {
446
+ const ps = 'Get-CimInstance Win32_Process|Where-Object{$_.CommandLine -match \'agent/main\\.py\'}|' +
447
+ 'Where-Object{-not (Get-Process -Id $_.ParentProcessId -ErrorAction SilentlyContinue)}|' +
448
+ 'ForEach-Object{try{Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue}catch{}}';
449
+ spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 15000 });
450
+ }
451
+ catch {
452
+ /* ignore */
453
+ }
454
+ }
455
+ else {
456
+ try {
457
+ process.kill(meta.pid);
458
+ }
459
+ catch {
460
+ /* ignore */
461
+ }
462
+ }
463
+ }
464
+ try {
465
+ rmSync(sessionDir(name), { recursive: true, force: true });
466
+ }
467
+ catch {
468
+ /* ignore */
469
+ }
470
+ }
471
+ //# sourceMappingURL=session-ipc.js.map
@@ -0,0 +1,135 @@
1
+ import { loadInterface, resolveResourcePaths } from '../interface/index.js';
2
+ import { planProject, connect } from './actions.js';
3
+ import { runPretasks } from './pretask.js';
4
+ export class SessionError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = 'SessionError';
10
+ }
11
+ }
12
+ /** 需要设备时连一次;项目模式下先规划控制器再连(拒绝猜设备)。 */
13
+ export async function ensureSession(client, o) {
14
+ if (!o.project && !o.kind)
15
+ return { connected: false };
16
+ if (o.project) {
17
+ /* pretask 要先于 planProject 的设备扫描:'启动模拟器'类 pretask 跑完设备才出现,
18
+ * 排在扫描之后冷启动永远跑不到(P0-2)。过滤所需的 controller / resource 名都能离线推出来
19
+ * (controller 覆盖或第一个声明;resource 覆盖或第一个),不需要先扫设备。 */
20
+ const preNotes = [];
21
+ if (o.pretask === true) {
22
+ const loaded = loadInterface(o.project);
23
+ if (!loaded.file)
24
+ throw new SessionError(loaded.problems[0]?.message ?? '未找到 interface.json', 'PLAN_FAILED');
25
+ const pre = await runPretasks({
26
+ loaded,
27
+ controllerName: o.controller ?? loaded.controllers[0]?.name ?? null,
28
+ resourceName: o.resource ?? loaded.resources[0]?.name ?? null,
29
+ ...(o.optionCases ? { optionCases: o.optionCases } : {}),
30
+ ...(o.optionFields ? { optionFields: o.optionFields } : {}),
31
+ });
32
+ preNotes.push(...pre.notes, ...pre.warns.map((w) => 'pretask 警告:' + w));
33
+ if (!pre.ok && pre.error)
34
+ throw new SessionError(pre.error.message, pre.error.code);
35
+ }
36
+ const planned = await planProject(client, o.project, {
37
+ controller: o.controller,
38
+ hwnd: o.hwnd,
39
+ address: o.address,
40
+ resource: o.resource,
41
+ manualTarget: o.target ?? undefined,
42
+ ...(o.cwd ? { cwd: o.cwd } : {}),
43
+ });
44
+ if (!planned.plan)
45
+ throw new SessionError(planned.error ?? '控制器规划失败', 'PLAN_FAILED');
46
+ if (!planned.plan.ok) {
47
+ const cands = (planned.plan.candidates ?? []).map((d) => d.name || d.id).slice(0, 10);
48
+ throw new SessionError(planned.plan.error + (cands.length ? '(候选:' + cands.join(' / ') + ')' : ''), planned.plan.code || 'PLAN_FAILED');
49
+ }
50
+ /* interface.json 的加载告警:加载成功不代表没问题(缺字段、被跳过的 task/option 都在
51
+ * problems[])。此前整个文件加载失败才读第一条,加载成功的告警在 human 与 --json 都看不到。 */
52
+ const loadWarns = planned.loaded.problems.map((p) => 'interface 告警:' + p.message);
53
+ if (planned.resource?.error) {
54
+ throw new SessionError(planned.resource.error, 'RESOURCE_NOT_FOUND');
55
+ }
56
+ /* pretask 的位置是契约的一部分:PI 要求它在**连接 Controller 之前**跑完,非零退出即中止启动。
57
+ * 放到 connect 之后就不再是同一件事(控制器已经建出来了)。 */
58
+ const conn = await connect(client, { ...planned.plan.connect });
59
+ if (conn && conn.ok === false)
60
+ throw new SessionError(conn.error ?? '连接失败', 'CONNECT_FAILED');
61
+ return {
62
+ connected: true, plan: planned, session: conn.session ?? null,
63
+ /* 资源侧提示(多个条目适用、已替用户选了哪一个)与控制器提示同一条 human 通道 */
64
+ notes: [...(planned.plan.notes ?? []), ...(planned.resource?.notes ?? []), ...loadWarns, ...preNotes],
65
+ ...(conn.streamStopped === true ? { streamStopped: true } : {}),
66
+ };
67
+ }
68
+ const conn = await connect(client, {
69
+ kind: o.kind,
70
+ ...(o.target ? { target: o.target } : {}),
71
+ ...(o.shortSide ? { shortSide: o.shortSide } : {}),
72
+ ...(o.longSide ? { longSide: o.longSide } : {}),
73
+ ...(o.rawSize ? { rawSize: true } : {}),
74
+ });
75
+ if (conn && conn.ok === false)
76
+ throw new SessionError(conn.error ?? '连接失败', 'CONNECT_FAILED');
77
+ return {
78
+ connected: true, session: conn.session ?? null,
79
+ ...(conn.streamStopped === true ? { streamStopped: true } : {}),
80
+ };
81
+ }
82
+ /**
83
+ * 离线资源解析:只读 interface.json 拿资源目录,不扫设备、不连接。
84
+ *
85
+ * 为什么需要它:从关键帧库留存帧裁模板这条动线**不需要设备**(这正是"不可复现状态"的意义——
86
+ * 设备早已不在那个画面上)。但自匹配要加载资源,而资源声明在项目里。项目此刻只是资源的
87
+ * 来源,不是连接的对象,所以走 interface 的规划函数而不走 planProject(后者要扫设备)。
88
+ *
89
+ * 返回的是**整条链**(`resource.path[]` 按序叠加,末尾追加当前控制器的 `attach_resource_path`),
90
+ * 与在线规划同一份 `resolveResourcePaths`:只写 `resource.path` 会让 attach 进来的一段凭空消失。
91
+ */
92
+ export function offlineResource(project, controller, resource,
93
+ /** `--resource` 相对路径的解析基准(CLI 的 --cwd) */
94
+ cwd) {
95
+ const loaded = loadInterface(project);
96
+ if (!loaded.file) {
97
+ return {
98
+ paths: [], names: [], selected: null, controllerName: null,
99
+ error: loaded.problems[0]?.message ?? '未找到 interface.json',
100
+ };
101
+ }
102
+ const controllerName = controller ?? loaded.controllers[0]?.name ?? null;
103
+ const plan = resolveResourcePaths(loaded, controllerName ?? undefined, resource, cwd);
104
+ /* --resource 名字没匹配上且不是目录:离线路径同样拒绝(自匹配拿错资源集,
105
+ * 裁出来的模板整个不对)。 */
106
+ if (plan.error)
107
+ return { paths: [], names: [], selected: null, controllerName, error: plan.error };
108
+ return { paths: plan.paths, names: plan.names, selected: plan.selected, controllerName };
109
+ }
110
+ /**
111
+ * 连接结果的行渲染(human 输出与 --json 共用一份事实)。
112
+ * `verbose=false` 时压制 `note:` 行(多资源选择、连接来源等建议性说明)——
113
+ * 一次性命令每条都打同一串 note 是噪音;事实行(已连接/分辨率/资源路径)不受门控。
114
+ */
115
+ export function describeSession(s, verbose = false) {
116
+ const lines = [];
117
+ if (!s.connected)
118
+ return lines;
119
+ const sess = s.session ?? {};
120
+ lines.push('已连接:' + String(sess.kind ?? '?') + ' ' + String(sess.target ?? '') +
121
+ (sess.name ? ' (' + String(sess.name) + ')' : ''));
122
+ const res = sess.resolution;
123
+ if (res && res.w)
124
+ lines.push(' 分辨率:' + res.w + 'x' + res.h);
125
+ if (s.plan) {
126
+ lines.push(' 控制器:' + s.plan.controllerName + '(来自 interface.json)');
127
+ if (s.plan.resource?.paths.length)
128
+ lines.push(' 资源:' + s.plan.resource.paths.join(' , '));
129
+ }
130
+ if (verbose)
131
+ for (const n of s.notes ?? [])
132
+ lines.push(' note: ' + n);
133
+ return lines;
134
+ }
135
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * run 阻塞期信号处理(审计 #6/#9):Ctrl-C / SIGTERM 时不许让 daemon 里的任务裸跑。
3
+ *
4
+ * 第一下:向 daemon 发 run_stop(post_stop),run 回执照常落盘,退出码走 128+signo
5
+ * (SIGINT=130 / SIGTERM=143)。第二下:直接退出——第一次没杀掉说明 daemon 已不应答,
6
+ * 再等没有现场可留(daemon 侧另有 onHostGone 兜底清 agent 树)。
7
+ *
8
+ * 为什么抽出来:Windows 没法跨进程投递 Ctrl-C,内联在命令里就没法测;
9
+ * process.emit('SIGINT') 会同步触发监听,单测只验"旗标立了、run_stop 发了"。
10
+ */
11
+ import { EXIT } from '../protocol.js';
12
+ /** 挂载信号守卫;`runStop` 是"给 daemon 发停止"的那个异步调用。 */
13
+ export function installRunSignalGuard(runStop) {
14
+ let code = 0;
15
+ const on = (c) => () => {
16
+ if (code) {
17
+ process.exit(c);
18
+ } // 第二下:daemon 不应答,不再等
19
+ code = c;
20
+ void runStop().catch(() => { });
21
+ };
22
+ const sigint = on(EXIT.SIGINT);
23
+ const sigterm = on(EXIT.SIGTERM);
24
+ process.on('SIGINT', sigint);
25
+ process.on('SIGTERM', sigterm);
26
+ return {
27
+ get code() { return code; },
28
+ restore() {
29
+ process.off('SIGINT', sigint);
30
+ process.off('SIGTERM', sigterm);
31
+ return code;
32
+ },
33
+ };
34
+ }
35
+ //# sourceMappingURL=signal-guard.js.map