@milpa/comal 0.2.3 → 0.2.4
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.
- package/README.md +1 -1
- package/backend/contracts/remote-device-v1.json +423 -0
- package/package.json +2 -1
- package/src/cli.mjs +51 -10
- package/src/qr-scan.mjs +240 -0
- package/src/relay/broker.mjs +13 -0
- package/src/remote-control.mjs +179 -14
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-stats.mjs +205 -0
- package/src/tui/discovery.mjs +9 -3
package/src/qr-scan.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// /rc device scan: reads the M5Stack's pairing QR with a local camera, only while the person asked for it, 30 s at most
|
|
2
|
+
// in total (finding the camera included). One ffmpeg stays open for the scan (so focus and exposure settle) and writes
|
|
3
|
+
// 2 frames a second into a private 0700 directory; each completed frame is decoded with zbarimg and deleted right
|
|
4
|
+
// after, and the directory goes at the end (and on process exit). Nothing is kept and nothing leaves this machine.
|
|
5
|
+
// Processes are spawned with argv arrays (no shell). `spawn`, `now`, `sleep`, `readdir` (of /dev) and `tmpRoot` are
|
|
6
|
+
// injectable so tests run without a camera.
|
|
7
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
8
|
+
import { rmSync } from 'node:fs';
|
|
9
|
+
import { chmod, lstat, mkdtemp, readdir as fsReaddir, rm } from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { t, registerMessages } from './i18n/index.mjs';
|
|
13
|
+
|
|
14
|
+
/** The QR on the device's pairing screen: this prefix plus the code as shown, uppercase Crockford XXXX-XXXX.
|
|
15
|
+
* It carries nothing secret (the device's poll token never goes in it). Same constant as
|
|
16
|
+
* devices/m5stack/lib/comal_core/pairing.h (PAIR_QR_PREFIX). */
|
|
17
|
+
export const PAIR_QR_PREFIX = 'comal-pair:v1:';
|
|
18
|
+
const PAIR_QR = /^comal-pair:v1:([0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4})$/;
|
|
19
|
+
const CAMERA = /^\/dev\/video[0-9]+$/;
|
|
20
|
+
export const SCAN_TIMEOUT_MS = 30000;
|
|
21
|
+
export const SCAN_INTERVAL_MS = 250; // how often the private directory is looked at for completed frames
|
|
22
|
+
const STREAM_SIZE = '1280x720';
|
|
23
|
+
const STREAM_FPS = 2;
|
|
24
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
25
|
+
const GRAB_TIMEOUT_MS = 10000;
|
|
26
|
+
const FRAME_MAX_BYTES = 32 * 1024 * 1024;
|
|
27
|
+
const FRAME = /^f[0-9]{6}\.png$/;
|
|
28
|
+
const DIR_PREFIX = 'comal-qr-';
|
|
29
|
+
const DIR_SHAPE = /^comal-qr-[A-Za-z0-9]{6}$/; // what mkdtemp makes of DIR_PREFIX: only these are swept
|
|
30
|
+
const STALE_MS = 120000; // a scan touches its directory every half second; one untouched for 2 min is left over
|
|
31
|
+
|
|
32
|
+
registerMessages({
|
|
33
|
+
'The camera must be a /dev/videoN device.': 'La cámara debe ser un dispositivo /dev/videoN.',
|
|
34
|
+
'COMAL_CAMERA must be a /dev/videoN device.': 'COMAL_CAMERA debe ser un dispositivo /dev/videoN.',
|
|
35
|
+
'No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.': 'No se encontró una cámara (/dev/video*). Indica una con /rc device scan /dev/videoN, o escribe el código: /rc device XXXX-XXXX.',
|
|
36
|
+
'Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.': 'Escanear necesita ffmpeg y zbarimg. Instala los paquetes ffmpeg y zbar, o escribe el código: /rc device XXXX-XXXX.',
|
|
37
|
+
'Could not read the camera {camera}: {reason}': 'No se pudo leer la cámara {camera}: {reason}',
|
|
38
|
+
'No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.': 'No se vio un QR de emparejamiento en {seconds} s. Acerca el M5Stack a 10-20 cm de la cámara, que el QR ocupe un tercio de la imagen, e inténtalo de nuevo, o escribe el código: /rc device XXXX-XXXX.',
|
|
39
|
+
'QR scan cancelled; the camera is off.': 'Escaneo del QR cancelado; la cámara está apagada.',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** zbarimg's stdout counts only when it is exactly one symbol: one matching line plus its newline. */
|
|
43
|
+
function onlyPairCode(stdout) {
|
|
44
|
+
const text = stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
45
|
+
return /[\r\n]/.test(text) ? null : pairCodeFromQr(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The code in a pairing QR's text, or null for anything else (which is never echoed). */
|
|
49
|
+
export function pairCodeFromQr(text) {
|
|
50
|
+
const match = PAIR_QR.exec(typeof text === 'string' ? text : '');
|
|
51
|
+
return match ? match[1] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const isCameraPath = value => typeof value === 'string' && CAMERA.test(value);
|
|
55
|
+
|
|
56
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
57
|
+
const aborted = () => Object.assign(new Error(t('QR scan cancelled; the camera is off.')), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
58
|
+
const missingTools = () => failure('QR_SCAN_TOOLS', t('Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.'));
|
|
59
|
+
const timedOut = seconds => failure('QR_SCAN_TIMEOUT', t('No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.', { seconds: String(seconds) }));
|
|
60
|
+
const oneLine = text => String(text || '').split(/\r?\n/).map(line => line.trim()).find(Boolean)?.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200) || 'no frame';
|
|
61
|
+
|
|
62
|
+
/** Runs one process to its end: {code, stdout, stderr}. Killed on abort or after `timeoutMs`; ENOENT → missing tools. */
|
|
63
|
+
function run(spawn, cmd, args, { signal, timeoutMs = GRAB_TIMEOUT_MS } = {}) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
if (signal?.aborted) { reject(aborted()); return; }
|
|
66
|
+
let child;
|
|
67
|
+
try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false }); }
|
|
68
|
+
catch (error) { reject(error.code === 'ENOENT' ? missingTools() : error); return; }
|
|
69
|
+
const out = [], err = [];
|
|
70
|
+
let size = 0, errSize = 0, settled = false;
|
|
71
|
+
const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); fn(value); };
|
|
72
|
+
const kill = () => { try { child.kill('SIGKILL'); } catch {} };
|
|
73
|
+
const onAbort = () => { kill(); settle(reject, aborted()); };
|
|
74
|
+
const timer = setTimeout(() => { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'timed out' }); }, timeoutMs);
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
77
|
+
child.stdout?.on('data', chunk => { size += chunk.length; if (size > FRAME_MAX_BYTES) { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'frame too large' }); } else out.push(chunk); });
|
|
78
|
+
child.stderr?.on('data', chunk => { if (errSize < 4096) { err.push(chunk); errSize += chunk.length; } });
|
|
79
|
+
child.on('error', error => settle(reject, error.code === 'ENOENT' ? missingTools() : error));
|
|
80
|
+
child.on('close', code => settle(resolve, { code, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8') }));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const grabArgs = device => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', '-i', device, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'];
|
|
85
|
+
const DECODE_ARGS = ['--raw', '-q', '-Sdisable', '-Sqrcode.enable'];
|
|
86
|
+
|
|
87
|
+
/** True when `v4l2-ctl --all` says this node itself captures video (Device Caps; Capabilities is the whole device's). */
|
|
88
|
+
function capturesVideo(text) {
|
|
89
|
+
const lines = String(text).split('\n');
|
|
90
|
+
const start = lines.findIndex(line => /^\s*Device Caps\s*:/.test(line));
|
|
91
|
+
if (start < 0) return false;
|
|
92
|
+
const indent = lines[start].match(/^\s*/)[0].length;
|
|
93
|
+
for (const line of lines.slice(start + 1)) {
|
|
94
|
+
if (!line.trim() || line.match(/^\s*/)[0].length <= indent) break;
|
|
95
|
+
if (/^\s*Video Capture(?: Multiplanar)?\s*$/.test(line)) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The camera: `camera` (argument) > env COMAL_CAMERA > the first /dev/videoN that v4l2-ctl reports as Video Capture
|
|
101
|
+
* (without v4l2-ctl: the first one ffmpeg can read a frame from). Anything that is not /dev/videoN is refused. */
|
|
102
|
+
export async function pickCamera({ camera, env = process.env, spawn = nodeSpawn, readdir = fsReaddir, signal, deadline = Infinity, now = Date.now, seconds = SCAN_TIMEOUT_MS / 1000 } = {}) {
|
|
103
|
+
if (camera !== undefined && camera !== null && camera !== '') {
|
|
104
|
+
if (!isCameraPath(camera)) throw failure('QR_SCAN_CAMERA_PATH', t('The camera must be a /dev/videoN device.'));
|
|
105
|
+
return camera;
|
|
106
|
+
}
|
|
107
|
+
const fromEnv = env?.COMAL_CAMERA?.trim();
|
|
108
|
+
if (fromEnv) {
|
|
109
|
+
if (!isCameraPath(fromEnv)) throw failure('QR_SCAN_CAMERA_PATH', t('COMAL_CAMERA must be a /dev/videoN device.'));
|
|
110
|
+
return fromEnv;
|
|
111
|
+
}
|
|
112
|
+
let names = [];
|
|
113
|
+
try { names = await readdir('/dev'); } catch {}
|
|
114
|
+
const candidates = names.filter(name => /^video[0-9]+$/.test(name)).sort((a, b) => Number(a.slice(5)) - Number(b.slice(5))).map(name => '/dev/' + name);
|
|
115
|
+
// Probing counts toward the scan's time: each probe gets what is left, and none starts past the deadline.
|
|
116
|
+
const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
|
|
117
|
+
let haveV4l2 = true;
|
|
118
|
+
for (const device of candidates) {
|
|
119
|
+
if (haveV4l2) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await run(spawn, 'v4l2-ctl', ['--device', device, '--all'], { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
|
|
122
|
+
if (result.code === 0 && capturesVideo(result.stdout.toString('utf8'))) return device;
|
|
123
|
+
continue;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.name === 'AbortError' || error.code === 'QR_SCAN_TIMEOUT') throw error;
|
|
126
|
+
if (error.code !== 'QR_SCAN_TOOLS') continue;
|
|
127
|
+
haveV4l2 = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const result = await run(spawn, 'ffmpeg', grabArgs(device), { signal, timeoutMs: left(GRAB_TIMEOUT_MS) });
|
|
131
|
+
if (result.code === 0 && result.stdout.length) return device;
|
|
132
|
+
}
|
|
133
|
+
throw failure('QR_SCAN_NO_CAMERA', t('No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const abortableSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
137
|
+
if (signal?.aborted) { reject(aborted()); return; }
|
|
138
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
139
|
+
const onAbort = () => { clearTimeout(timer); reject(aborted()); };
|
|
140
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// How the camera is opened, in order, each tried when the one before ends without a frame: 1280x720 MJPEG (a UVC
|
|
144
|
+
// camera often has 1280x720 only as MJPEG and silently gives 640x480 raw frames for a bare -video_size), 1280x720 in
|
|
145
|
+
// its default format, then its own default size.
|
|
146
|
+
const STREAM_ATTEMPTS = [['-input_format', 'mjpeg', '-video_size', STREAM_SIZE], ['-video_size', STREAM_SIZE], []];
|
|
147
|
+
// `-t` (an input option) is the seconds left to the scan, rounded up: if Comal dies without its exit hook (SIGKILL, OOM,
|
|
148
|
+
// a crash) ffmpeg still ends itself, and turns the camera off, at the scan's deadline.
|
|
149
|
+
const streamArgs = (device, dir, attempt, seconds) => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', ...STREAM_ATTEMPTS[attempt], '-t', String(seconds), '-i', device, '-vf', `fps=${STREAM_FPS}`, '-f', 'image2', path.join(dir, 'f%06d.png')];
|
|
150
|
+
|
|
151
|
+
/** The long-running ffmpeg of a scan: {child, exited, error, stderr, closed}. */
|
|
152
|
+
function startStream(spawn, args) {
|
|
153
|
+
const stream = { child: null, exited: false, error: null, stderr: '' };
|
|
154
|
+
stream.closed = new Promise(resolve => {
|
|
155
|
+
try { stream.child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'], shell: false }); }
|
|
156
|
+
catch (error) { stream.exited = true; stream.error = error; resolve(); return; }
|
|
157
|
+
stream.child.stderr?.on('data', chunk => { if (stream.stderr.length < 4096) stream.stderr += chunk.toString('utf8'); });
|
|
158
|
+
stream.child.on('error', error => { stream.error = error; stream.exited = true; resolve(); });
|
|
159
|
+
stream.child.on('close', () => { stream.exited = true; resolve(); });
|
|
160
|
+
});
|
|
161
|
+
return stream;
|
|
162
|
+
}
|
|
163
|
+
/** Kills a stream and waits (briefly) until it is gone, so nothing writes into the directory being removed. */
|
|
164
|
+
async function stopStream(stream) {
|
|
165
|
+
if (!stream || stream.exited) return;
|
|
166
|
+
try { stream.child.kill('SIGKILL'); } catch {}
|
|
167
|
+
let timer;
|
|
168
|
+
await Promise.race([stream.closed, new Promise(resolve => { timer = setTimeout(resolve, 2000); timer.unref?.(); })]);
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Removes this user's own comal-qr-* directories in `root` that no scan has touched for a while (a killed process). */
|
|
173
|
+
async function sweepStale(root) {
|
|
174
|
+
let names = [];
|
|
175
|
+
try { names = await fsReaddir(root); } catch { return; }
|
|
176
|
+
const uid = process.getuid?.();
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
if (!DIR_SHAPE.test(name)) continue;
|
|
179
|
+
try {
|
|
180
|
+
const info = await lstat(path.join(root, name));
|
|
181
|
+
if (!info.isDirectory() || (uid !== undefined && info.uid !== uid) || Date.now() - info.mtimeMs < STALE_MS) continue;
|
|
182
|
+
await rm(path.join(root, name), { recursive: true, force: true });
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Scans for the pairing QR for up to `timeoutMs` in total, finding the camera included. Resolves {code, camera}. */
|
|
188
|
+
export async function scanPairingQr({ camera, env = process.env, timeoutMs = SCAN_TIMEOUT_MS, intervalMs = SCAN_INTERVAL_MS, signal, spawn = nodeSpawn, now = Date.now, sleep, readdir = fsReaddir, tmpRoot = os.tmpdir() } = {}) {
|
|
189
|
+
if (signal?.aborted) throw aborted();
|
|
190
|
+
const deadline = now() + timeoutMs, seconds = Math.round(timeoutMs / 1000);
|
|
191
|
+
const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
|
|
192
|
+
const pause = sleep ?? (ms => abortableSleep(ms, signal));
|
|
193
|
+
// Both tools are checked before the camera turns on.
|
|
194
|
+
for (const [cmd, args] of [['ffmpeg', ['-hide_banner', '-version']], ['zbarimg', ['--version']]]) await run(spawn, cmd, args, { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
|
|
195
|
+
const root = path.resolve(tmpRoot);
|
|
196
|
+
await sweepStale(root);
|
|
197
|
+
const dir = await mkdtemp(path.join(root, DIR_PREFIX));
|
|
198
|
+
let stream = null;
|
|
199
|
+
const onExit = () => { try { stream?.child?.kill('SIGKILL'); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} };
|
|
200
|
+
process.once('exit', onExit);
|
|
201
|
+
try {
|
|
202
|
+
await chmod(dir, 0o700);
|
|
203
|
+
const device = await pickCamera({ camera, env, spawn, readdir, signal, deadline, now, seconds });
|
|
204
|
+
left(1);
|
|
205
|
+
let attempt = 0, seen = 0;
|
|
206
|
+
const secondsLeft = () => Math.max(1, Math.ceil((deadline - now()) / 1000));
|
|
207
|
+
stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft()));
|
|
208
|
+
for (;;) {
|
|
209
|
+
await pause(Math.max(0, Math.min(intervalMs, deadline - now())));
|
|
210
|
+
if (signal?.aborted) throw aborted();
|
|
211
|
+
// Read whether ffmpeg ended BEFORE listing: then every listed frame is complete. While it runs, the newest frame
|
|
212
|
+
// may still be being written, so only the ones older than it are complete. Of those only the newest is decoded:
|
|
213
|
+
// older ones are deleted unread, so a slow decode never builds a backlog of stale frames.
|
|
214
|
+
const ended = stream.exited;
|
|
215
|
+
const frames = (await fsReaddir(dir)).filter(name => FRAME.test(name)).sort();
|
|
216
|
+
const complete = ended ? frames : frames.slice(0, -1);
|
|
217
|
+
if (complete.length) {
|
|
218
|
+
seen += complete.length;
|
|
219
|
+
for (const name of complete.slice(0, -1)) await rm(path.join(dir, name), { force: true });
|
|
220
|
+
const file = path.join(dir, complete.at(-1));
|
|
221
|
+
let decoded;
|
|
222
|
+
try { decoded = await run(spawn, 'zbarimg', [...DECODE_ARGS, file], { signal, timeoutMs: Math.max(1, Math.min(PROBE_TIMEOUT_MS, deadline - now())) }); }
|
|
223
|
+
finally { await rm(file, { force: true }); }
|
|
224
|
+
const code = decoded.code === 0 ? onlyPairCode(decoded.stdout) : null;
|
|
225
|
+
if (code) return { code, camera: device };
|
|
226
|
+
}
|
|
227
|
+
if (ended && !frames.length) {
|
|
228
|
+
if (stream.error) throw stream.error.code === 'ENOENT' ? missingTools() : stream.error;
|
|
229
|
+
// A camera that refuses a format or size is opened again the next way, down to its own default.
|
|
230
|
+
if (!seen && attempt + 1 < STREAM_ATTEMPTS.length) { attempt += 1; left(1); stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft())); continue; }
|
|
231
|
+
throw failure('QR_SCAN_CAMERA', t('Could not read the camera {camera}: {reason}', { camera: device, reason: oneLine(stream.stderr) }));
|
|
232
|
+
}
|
|
233
|
+
if (now() >= deadline) throw timedOut(seconds);
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await stopStream(stream);
|
|
237
|
+
await rm(dir, { recursive: true, force: true });
|
|
238
|
+
process.removeListener('exit', onExit);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/relay/broker.mjs
CHANGED
|
@@ -9,6 +9,19 @@ import { RELAY_ANSWERS } from '../remote-relay.mjs';
|
|
|
9
9
|
// intento (otra superficie, un control repetido o viejo) responde already_decided sin mutar.
|
|
10
10
|
// Hace falta en Comal porque relayd admite rechazar un plan ya aprobado y aún no aplicado.
|
|
11
11
|
registerMessages({
|
|
12
|
+
'Open /rc first, with a Comal backend that supports devices.': 'Abre /rc primero, con un backend de Comal que admita dispositivos.',
|
|
13
|
+
'Device: {label} ({id}){connected}.': 'Dispositivo: {label} ({id}){connected}.',
|
|
14
|
+
', connected {time}': ', conectado {time}',
|
|
15
|
+
'No device paired.': 'Ningún dispositivo emparejado.',
|
|
16
|
+
'Device removed; its permission answers are off.': 'Dispositivo quitado; sus respuestas de permisos quedan apagadas.',
|
|
17
|
+
'Permission answers from the M5Stack: armed until {time}.': 'Respuestas de permisos desde el M5Stack: armadas hasta las {time}.',
|
|
18
|
+
'Permission answers from the M5Stack: off.': 'Respuestas de permisos desde el M5Stack: apagadas.',
|
|
19
|
+
'Pair a device first with /rc device CODE.': 'Empareja un dispositivo primero con /rc device CODE.',
|
|
20
|
+
'The /rc session has expired; open /rc again.': 'La sesión de /rc caducó; abre /rc de nuevo.',
|
|
21
|
+
'M5Stack permission answers are armed until {time}. Each card\'s command or path goes through comal.milpahq.com to the device and is not stored.': 'Permisos desde el M5Stack activos hasta {time}. El comando o la ruta de cada tarjeta pasa por comal.milpahq.com hacia el aparato y no se guarda.',
|
|
22
|
+
'Device paired: {label} ({id}).': 'Dispositivo emparejado: {label} ({id}).',
|
|
23
|
+
'Answered on the M5Stack: {decision} · {tool}': 'Contestada en el M5Stack: {decision} · {tool}',
|
|
24
|
+
'allowed once': 'permitida una vez', 'denied': 'negada',
|
|
12
25
|
'{what} was decided from the browser ({outcome}).': '{what} se decidió desde el navegador ({outcome}).',
|
|
13
26
|
'Already decided from the browser ({outcome}); nothing was sent.': 'Ya decidida desde el navegador ({outcome}); no se envió nada.',
|
|
14
27
|
'This card is being decided in this terminal right now.': 'Esta tarjeta se está decidiendo ahora mismo en esta terminal.',
|
package/src/remote-control.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { CloudClient } from './cloud-client.mjs';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { PERMISSION_ACK_STATES, PERMISSION_OUTCOMES } from './remote-permissions.mjs';
|
|
5
|
+
import { fitStats, STATS_FLOOR_MS, STATS_INTERVAL_MS, STATS_MAX_BYTES } from './remote-stats.mjs';
|
|
3
6
|
|
|
4
7
|
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
5
8
|
export const remoteInputDigest = (session, request, text) => digest([session,request,text]);
|
|
@@ -17,7 +20,8 @@ export function projectRemoteEvent(event, turnId) {
|
|
|
17
20
|
}
|
|
18
21
|
if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned',...(event.tool_use_id?{tool_call_id:boundText(event.tool_use_id,128)}:{}),...(Number.isSafeInteger(event.duration_ms)&&event.duration_ms>=0&&event.duration_ms<=86400000?{duration_ms:event.duration_ms}:{})};
|
|
19
22
|
if(event.type==='house_waiting')return {...base,type:'permission.waiting',tool_name:boundText(event.operation,128),text:'Waiting for approval in the local terminal'};
|
|
20
|
-
|
|
23
|
+
// Neutral: the terminal or the paired M5Stack may have answered it.
|
|
24
|
+
if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved':'Declined'};
|
|
21
25
|
if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
|
|
22
26
|
// Avisos de plugins (p. ej. objeción y corrección del revisor de open-relay): se proyectan al
|
|
23
27
|
// admitirse, urgentes o no, para que /rc los vea aunque no despierten un turno. El backend
|
|
@@ -42,6 +46,9 @@ export const RELAY_SESSION_EVENTS = 2000, RELAY_SESSION_BYTES = 16 << 20, RELAY_
|
|
|
42
46
|
// Rechazos del backend que no se arreglan reintentando: se descarta ESE evento relay.* y la
|
|
43
47
|
// proyección sigue (sin esto, un evento rechazado bloquearía la cola entera).
|
|
44
48
|
const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','REMOTE_RELAY_STALE','REMOTE_SIZE']);
|
|
49
|
+
// A session.stats is a summary the next one replaces: one the backend will not take (a quota, its size, the feature,
|
|
50
|
+
// its schema) is dropped, never left at the head of the queue holding the conversation behind it.
|
|
51
|
+
const STATS_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_SIZE','REMOTE_QUOTA','REMOTE_STORAGE_QUOTA']);
|
|
45
52
|
|
|
46
53
|
// Respuestas de /relay desde el navegador (D1): armadas sólo con /rc relay on en esta terminal,
|
|
47
54
|
// en memoria de este proceso, con caducidad (tope 8 h). Nunca se guardan en disco ni en el
|
|
@@ -49,15 +56,117 @@ const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','
|
|
|
49
56
|
export const RELAY_ARM_MAX_MS = 8 * 3600e3, RELAY_ARM_DEFAULT_MS = 30 * 60e3;
|
|
50
57
|
// Estados de ACK de un control relay (el backend sólo los admite para kind=relay).
|
|
51
58
|
const RELAY_ACK_STATES = new Set(['applied','stale','refused','expired']);
|
|
59
|
+
// Tarjetas de permiso para el M5Stack: sólo por el WebSocket del host y mientras el armado está vivo;
|
|
60
|
+
// el relay las reenvía al aparato emparejado y no las guarda, así que se reenvían cada 10 s mientras
|
|
61
|
+
// la pregunta sigue abierta (cubre que el aparato se reconecte) y al instante cuando se conecta.
|
|
62
|
+
export const CARD_RESEND_MS = 10000;
|
|
63
|
+
// After an arm, a kept card goes once the pulse landed and again this long after: past the relay's 2 s arm cache.
|
|
64
|
+
export const CARD_ARM_RECHECK_MS = 2500;
|
|
65
|
+
const CARD_CLOSES_KEPT = 32;
|
|
66
|
+
|
|
67
|
+
// Código de emparejamiento del M5Stack: Crockford en mayúsculas, XXXX-XXXX. O→0, I/L→1.
|
|
68
|
+
const DEVICE_CODE=/^[0-9A-HJKMNP-TV-Z]{8}$/;
|
|
69
|
+
export function normalizeDeviceCode(code){
|
|
70
|
+
const clean=String(code??'').toUpperCase().replace(/[\s-]+/g,'').replace(/O/g,'0').replace(/[IL]/g,'1');
|
|
71
|
+
if(!DEVICE_CODE.test(clean))throw Error('That is not a device code: expected 8 letters or digits (XXXX-XXXX).');
|
|
72
|
+
return clean.slice(0,4)+'-'+clean.slice(4);
|
|
73
|
+
}
|
|
52
74
|
|
|
53
75
|
const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
|
|
54
76
|
const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
55
77
|
|
|
78
|
+
let quickReplyList=null;
|
|
79
|
+
/** The three fixed replies come from the device contract, read once. */
|
|
80
|
+
function quickReplies(){return quickReplyList??=JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json',import.meta.url),'utf8')).inputs.quick_reply.properties.text.enum;}
|
|
56
81
|
export class RemoteHost {
|
|
57
|
-
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
58
|
-
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,now,socketFactory});this.answersUntil=0;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
82
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,onDeviceConnected=null,onPermissionsDisarmed=null,onPermission=null,getStats=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
83
|
+
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,onDeviceConnected,onPermissionsDisarmed,onPermission,getStats,now,socketFactory});this.cards=new Map();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
84
|
+
}
|
|
85
|
+
status(){const run=this.lifecycle;return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only',relay:run?.relay?(run.answers?'answers':'read_only'):'off',...(run?.answers?{relay_answers:{armed:this.armed(),until:this.armed()?this.answersUntil:null}}:{}),...(run?.device?{device:this.deviceInfo??null,permission_answers:{armed:this.permissionsArmed(),until:this.permissionsArmed()?this.permissionUntil:null}}:{})}:{enabled:false};}
|
|
86
|
+
deviceAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.device);}
|
|
87
|
+
permissionsArmed(){return this.deviceAvailable()&&this.permissionUntil>this.now()&&Boolean(this.deviceInfo)&&this.deviceInfo.id===this.permissionDevice;}
|
|
88
|
+
/** The arm belongs to the device it was granted for: when that device is gone or another takes its place, it ends and the book hears why. */
|
|
89
|
+
dropArm(reason){
|
|
90
|
+
const was=this.permissionUntil>0;
|
|
91
|
+
this.permissionUntil=0;this.permissionDevice=null;
|
|
92
|
+
if(!was)return;
|
|
93
|
+
this.hideCards();
|
|
94
|
+
if(this.deviceAvailable())this.pulse().catch(()=>{});
|
|
95
|
+
if(this.onPermissionsDisarmed)try{this.onPermissionsDisarmed(reason);}catch{}
|
|
96
|
+
}
|
|
97
|
+
/** Recuerda el dispositivo de la última lectura de remote.state (page.session) o del emparejamiento; gana lo más reciente. */
|
|
98
|
+
noteSession(session){
|
|
99
|
+
if(!session||!('device' in session))return false;
|
|
100
|
+
const before=this.deviceInfo?.connected_at??null;
|
|
101
|
+
this.deviceInfo=session.device?{id:session.device.id,label:session.device.label,connected_at:session.device.connected_at??null}:null;
|
|
102
|
+
if(this.permissionUntil>0&&(this.deviceInfo?.id??null)!==this.permissionDevice)this.dropArm(this.deviceInfo?'device_changed':'device_gone');
|
|
103
|
+
const after=this.deviceInfo?.connected_at??null,changed=after!==null&&after!==before;
|
|
104
|
+
if(changed&&this.onDeviceConnected)try{this.onDeviceConnected(this.deviceInfo);}catch{}
|
|
105
|
+
if(changed){this.sendCards();this.publishStats({device:true}).catch(()=>{});}
|
|
106
|
+
return changed;
|
|
107
|
+
}
|
|
108
|
+
async pairDevice(code){
|
|
109
|
+
const normalized=normalizeDeviceCode(code);
|
|
110
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
111
|
+
const run=this.lifecycle;
|
|
112
|
+
const result=await this.client.invoke('remote.devices.pair.approve',{remote_session_id:this.session.id,code:normalized},{expectedAccount:this.account,signal:lifetimeSignal(run)});
|
|
113
|
+
this.assertCurrent(run);
|
|
114
|
+
this.deviceInfo={id:result.device_id,label:result.label,connected_at:this.deviceInfo?.id===result.device_id?this.deviceInfo.connected_at:null};
|
|
115
|
+
if(this.permissionUntil>0&&result.device_id!==this.permissionDevice)this.dropArm('device_changed');
|
|
116
|
+
return {device_id:result.device_id,label:result.label};
|
|
117
|
+
}
|
|
118
|
+
async forgetDevice(){
|
|
119
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
120
|
+
const run=this.lifecycle;
|
|
121
|
+
await this.client.invoke('remote.devices.revoke',{remote_session_id:this.session.id},{expectedAccount:this.account,signal:lifetimeSignal(run)});
|
|
122
|
+
this.permissionUntil=0;this.permissionDevice=null;this.hideCards();this.deviceInfo=null;this.pulse(run).catch(()=>{});
|
|
123
|
+
}
|
|
124
|
+
/** /rc permissions on [duración]: el aparato emparejado puede responder tarjetas hasta now+ms (60 s…8 h, y no más allá de la sesión). */
|
|
125
|
+
armPermissions(ms=RELAY_ARM_DEFAULT_MS){
|
|
126
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
127
|
+
if(!this.deviceInfo)throw Error('Pair a device first with /rc device CODE.');
|
|
128
|
+
const now=this.now(),expires=Number(this.session?.expires_at)*1000;
|
|
129
|
+
if(Number.isFinite(expires)&&expires<=now)throw Error('The /rc session has expired; open /rc again.');
|
|
130
|
+
let until=now+Math.min(Math.max(60e3,Math.floor(Number(ms)||RELAY_ARM_DEFAULT_MS)),RELAY_ARM_MAX_MS);
|
|
131
|
+
if(Number.isFinite(expires))until=Math.min(until,expires);
|
|
132
|
+
this.permissionUntil=until;this.permissionDevice=this.deviceInfo.id;
|
|
133
|
+
// A kept card goes only once the backend holds the arm (the relay refuses a card it cannot confirm armed), and
|
|
134
|
+
// again CARD_ARM_RECHECK_MS later, when an arm the relay read before this pulse is no longer fresh enough to use.
|
|
135
|
+
const run=this.lifecycle;
|
|
136
|
+
this.pulse(run).catch(()=>{}).then(()=>{if(!this.current(run)||!this.sendCards())return;this.cardsSentAt=this.now()-CARD_RESEND_MS+CARD_ARM_RECHECK_MS;});
|
|
137
|
+
return until;
|
|
138
|
+
}
|
|
139
|
+
/** One frame over the host WebSocket, only when it is the live transport. Never over remote.events.publish. */
|
|
140
|
+
cardFrame(frame){
|
|
141
|
+
if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
|
|
142
|
+
try{this.socket.send(JSON.stringify({protocol:'comal.remote.v1',...frame}));return true;}catch{return false;}
|
|
59
143
|
}
|
|
60
|
-
|
|
144
|
+
/** Permission card for the paired device (Task 10's buildDeviceCard). Nothing is kept or sent while not armed. */
|
|
145
|
+
sendCard(card){
|
|
146
|
+
if(!card||typeof card.question_id!=='string'||!this.permissionsArmed())return false;
|
|
147
|
+
this.cards.set(card.question_id,card);return this.sendCards();
|
|
148
|
+
}
|
|
149
|
+
sendCards(){
|
|
150
|
+
if(!this.cards.size||!this.permissionsArmed())return false;
|
|
151
|
+
let sent=false;for(const card of this.cards.values())sent=this.cardFrame({kind:'card',card})||sent;
|
|
152
|
+
if(sent){this.cardsSentAt=this.now();this.cardsShown=true;}
|
|
153
|
+
return sent;
|
|
154
|
+
}
|
|
155
|
+
/** The arm ended (off, lapsed, the device gone): the device stops showing cards it can no longer answer.
|
|
156
|
+
* They are kept, so a new arm while the question is still open shows them again. */
|
|
157
|
+
hideCards(){
|
|
158
|
+
if(!this.cards.size||!this.cardsShown)return;
|
|
159
|
+
let sent=true;for(const id of this.cards.keys())sent=this.cardFrame({kind:'card.close',question_id:id})&&sent;
|
|
160
|
+
if(sent)this.cardsShown=false;
|
|
161
|
+
}
|
|
162
|
+
/** The question was decided or dropped: the device stops showing it (on the next connection if the socket is down). */
|
|
163
|
+
closeCard(questionId){
|
|
164
|
+
const had=this.cards.delete(questionId);
|
|
165
|
+
if(this.cardFrame({kind:'card.close',question_id:questionId})||!had)return;
|
|
166
|
+
this.cardCloses.push(questionId);if(this.cardCloses.length>CARD_CLOSES_KEPT)this.cardCloses.shift();
|
|
167
|
+
}
|
|
168
|
+
flushCardCloses(){while(this.cardCloses.length&&this.cardFrame({kind:'card.close',question_id:this.cardCloses[0]}))this.cardCloses.shift();}
|
|
169
|
+
disarmPermissions(){this.permissionUntil=0;this.permissionDevice=null;this.hideCards();if(this.deviceAvailable())this.pulse().catch(()=>{});}
|
|
61
170
|
/** ¿Puede esta sesión de /rc responder tarjetas de /relay? (backend con relay_answers_v1). */
|
|
62
171
|
answersAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.answers);}
|
|
63
172
|
armed(){return this.answersAvailable()&&this.answersUntil>this.now();}
|
|
@@ -87,12 +196,13 @@ export class RemoteHost {
|
|
|
87
196
|
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
88
197
|
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
89
198
|
const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
|
|
90
|
-
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[])];
|
|
199
|
+
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[]),'device_v1','stats_v1'];
|
|
91
200
|
const open=list=>this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:list},{expectedAccount:run.account,signal:run.controller.signal});
|
|
92
201
|
// Un backend anterior rechaza la capacidad desconocida en el esquema de Milpa (no se crea
|
|
93
202
|
// sesión): se reintenta sin relay_answers_v1 (backend de la fase «ver») y después sin
|
|
94
203
|
// relay_v1 (backend anterior a /relay). /rc se abre igual con lo que el backend admite.
|
|
95
|
-
const
|
|
204
|
+
const noDevice=features.filter(item=>item!=='device_v1'&&item!=='stats_v1');
|
|
205
|
+
const attempts=[features,noDevice,noDevice.filter(item=>item!=='relay_answers_v1'),noDevice.filter(item=>!item.startsWith('relay_'))].filter((list,index,all)=>index===0||list.length<all[index-1].length);
|
|
96
206
|
let opened,used=attempts[0];
|
|
97
207
|
for(const [index,list] of attempts.entries()){
|
|
98
208
|
try{used=list;opened=await open(list);break;}
|
|
@@ -101,7 +211,7 @@ export class RemoteHost {
|
|
|
101
211
|
run.relayRefused=Boolean(this.relay)&&!used.includes('relay_v1');
|
|
102
212
|
run.session=opened.session;
|
|
103
213
|
const granted=feature=>used.includes(feature)&&(!Array.isArray(opened.session?.features)||opened.session.features.includes(feature));
|
|
104
|
-
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;
|
|
214
|
+
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;run.device=granted('device_v1');run.stats=Boolean(this.getStats)&&granted('stats_v1');this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;
|
|
105
215
|
// Abort can race a successful response. Revoke a late-created grant even
|
|
106
216
|
// when the local stop completed before its identifier became available.
|
|
107
217
|
if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
|
|
@@ -109,6 +219,7 @@ export class RemoteHost {
|
|
|
109
219
|
await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
|
|
110
220
|
this.transport='https-polling';this.connectSocket(run);
|
|
111
221
|
if(workspace)this.publishWorkspace(workspace,run);
|
|
222
|
+
this.publishStats({},run).catch(()=>{});
|
|
112
223
|
if(run.relay){run.relaySink={extended:run.answers,relayBoard:(board,options)=>this.relayBoard(board,options,run),relayEvent:(type,relay)=>this.relayEvent(type,relay,run)};this.relay.attach(run.relaySink);}
|
|
113
224
|
this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
|
|
114
225
|
for(const message of (session.historyMessages || []).slice(-20)){
|
|
@@ -151,7 +262,7 @@ export class RemoteHost {
|
|
|
151
262
|
const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
|
|
152
263
|
const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
|
|
153
264
|
ws.addEventListener('open',()=>{if(!this.current(run)||this.socket!==ws){ws.close();return;}ws.send(JSON.stringify({protocol:'comal.remote.v1',kind:'auth',ticket:ticket.ticket,after:'0'}));});
|
|
154
|
-
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';}}catch{ws.close();}});
|
|
265
|
+
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';this.flushCardCloses();this.sendCards();}else if(m.kind==='state'&&run.device)this.noteSession(m.session);}catch{ws.close();}});
|
|
155
266
|
ws.addEventListener('error',()=>{});
|
|
156
267
|
ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
|
|
157
268
|
}catch{/* HTTPS is already active. A later tick retries WSS. */}
|
|
@@ -165,6 +276,38 @@ export class RemoteHost {
|
|
|
165
276
|
this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
|
|
166
277
|
run.workspaceDigest=key;this.flush(run).catch(()=>{});
|
|
167
278
|
}
|
|
279
|
+
/** session.stats (stats_v1). A change: at most once per 5 s. `force` (turn end): skips the 5 s but
|
|
280
|
+
* not the unchanged check. `device` (a device connected): publishes even unchanged, since it has
|
|
281
|
+
* not seen them. Never two publishes within 1 s: a request inside the gap waits for one timer,
|
|
282
|
+
* which carries the strongest reason asked for. Never oversize; a getStats that fails or gives null publishes nothing. */
|
|
283
|
+
publishStats({force=false,device=false}={},run=this.lifecycle){
|
|
284
|
+
if(!this.current(run)||!run.stats||!this.getStats)return Promise.resolve(false);
|
|
285
|
+
const step=(run.statsChain||Promise.resolve()).then(()=>this.publishStatsNow({force:force||device,device},run)).catch(()=>false);
|
|
286
|
+
run.statsChain=step;return step;
|
|
287
|
+
}
|
|
288
|
+
async publishStatsNow({force,device},run){
|
|
289
|
+
if(!this.current(run))return false;
|
|
290
|
+
const now=this.now(),wait=run.statsAt?run.statsAt+(force?STATS_FLOOR_MS:STATS_INTERVAL_MS)-now:0;
|
|
291
|
+
if(wait>0){
|
|
292
|
+
const asked=run.statsAsked||{force:false,device:false};run.statsAsked={force:asked.force||force,device:asked.device||device};
|
|
293
|
+
if(!run.statsTimer||now+wait<run.statsTimerAt){
|
|
294
|
+
clearTimeout(run.statsTimer);run.statsTimerAt=now+wait;
|
|
295
|
+
run.statsTimer=setTimeout(()=>{const again=run.statsAsked||{};run.statsAsked=null;run.statsTimer=null;this.publishStats(again,run).catch(()=>{});},wait);run.statsTimer.unref?.();
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const stats=fitStats(await this.getStats());
|
|
300
|
+
if(!this.current(run)||!stats)return false;
|
|
301
|
+
const key=JSON.stringify(stats);
|
|
302
|
+
if(Buffer.byteLength(key)>STATS_MAX_BYTES||(!device&&key===run.statsKey))return false;
|
|
303
|
+
const entry={event_id:randomUUID(),event:{type:'session.stats',text:'',turn_id:this.turnId||'local',stats}};
|
|
304
|
+
// The last summary wins in the buffer: an unsent one is replaced, never stacked.
|
|
305
|
+
const index=this.pending.findIndex((item,position)=>item.event.type==='session.stats'&&(position>0||!this.sending));
|
|
306
|
+
if(index>=0)this.pending[index]=entry;
|
|
307
|
+
else{if(this.pending.length>=512)return false;this.pending.push(entry);}
|
|
308
|
+
run.statsKey=key;run.statsAt=this.now();clearTimeout(run.statsTimer);run.statsTimer=null;run.statsAsked=null;
|
|
309
|
+
this.flush(run).catch(()=>{});return true;
|
|
310
|
+
}
|
|
168
311
|
/** Tablero de /swarm: coalescido (≥ 3 s), sin repetir huella y «el último gana» en el búfer. */
|
|
169
312
|
relayBoard(board,{now=false}={},run=this.lifecycle) {
|
|
170
313
|
if(!this.current(run)||!run.relay)return;
|
|
@@ -217,6 +360,7 @@ export class RemoteHost {
|
|
|
217
360
|
if(this.stopped)return;
|
|
218
361
|
if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
|
|
219
362
|
const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
|
|
363
|
+
if(['result','context_projection','usage_accounting'].includes(event.type))this.publishStats().catch(()=>{});
|
|
220
364
|
if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
|
|
221
365
|
this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
|
|
222
366
|
}
|
|
@@ -247,16 +391,21 @@ export class RemoteHost {
|
|
|
247
391
|
const work=(async()=>{while(this.current(run)&&this.pending.length){
|
|
248
392
|
const item=this.pending[0];
|
|
249
393
|
try{await this.call('remote.events.publish',item,run);}
|
|
250
|
-
catch(error){
|
|
394
|
+
catch(error){
|
|
395
|
+
if(!this.current(run))throw error;
|
|
396
|
+
if(item.event?.type==='session.stats'&&STATS_REFUSALS.has(error.code)){/* dropped: the next summary replaces it */}
|
|
397
|
+
else if(String(item.event?.type).startsWith('relay.')&&RELAY_REFUSALS.has(error.code))this.onNotice('Remote /relay event refused by the backend ('+error.code+'); /relay and /swarm in this terminal are unaffected.');
|
|
398
|
+
else throw error;
|
|
399
|
+
}
|
|
251
400
|
this.assertCurrent(run);if(this.pending[0]===item)this.pending.shift();else{const index=this.pending.indexOf(item);if(index>=0)this.pending.splice(index,1);}
|
|
252
401
|
}})();
|
|
253
402
|
this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
|
|
254
403
|
}
|
|
255
|
-
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(!this.current(run))return;const armed=run.answers&&this.answersUntil>this.now();return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId,...(run.answers?{relay_answers:{armed,until:armed?Math.floor(this.answersUntil/1000):0}}:{})},run);});this.pulsing=work;return work;}
|
|
404
|
+
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(!this.current(run))return;const armed=run.answers&&this.answersUntil>this.now(),permitted=Boolean(run.device)&&this.permissionUntil>this.now();return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId,...(run.answers?{relay_answers:{armed,until:armed?Math.floor(this.answersUntil/1000):0}}:{}),...(run.device?{permission_answers:{armed:permitted,until:permitted?Math.floor(this.permissionUntil/1000):0}}:{})},run);});this.pulsing=work;return work;}
|
|
256
405
|
async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
|
|
257
406
|
async endTurn(){
|
|
258
407
|
this.invalidateWorkspace();
|
|
259
|
-
if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});}
|
|
408
|
+
if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});this.publishStats({force:true}).catch(()=>{});}
|
|
260
409
|
clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
|
|
261
410
|
}
|
|
262
411
|
async validate(item){
|
|
@@ -266,6 +415,7 @@ export class RemoteHost {
|
|
|
266
415
|
await this.call('remote.inputs.validate',{request_id:item.remote.request},run);this.assertCurrent(run);return true;
|
|
267
416
|
}
|
|
268
417
|
async tick(run=this.lifecycle){
|
|
418
|
+
if(this.current(run)&&this.cards.size){if(!this.permissionsArmed())this.hideCards();else if(this.now()-this.cardsSentAt>=CARD_RESEND_MS)this.sendCards();}
|
|
269
419
|
if(!this.current(run)||this.processing)return this.processing;
|
|
270
420
|
const work=(async()=>{
|
|
271
421
|
await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
|
|
@@ -274,11 +424,17 @@ export class RemoteHost {
|
|
|
274
424
|
this.assertCurrent(run);let state='accepted_locally',queue_item_id='',outcome=null;
|
|
275
425
|
// Only admission errors are rejection. A lost ACK must redeliver the same input.
|
|
276
426
|
if(input.kind==='relay'){({state,outcome}=await this.relayAnswer(input,run));}
|
|
427
|
+
else if(input.kind==='permission.answer'){({state,outcome}=await this.permissionAnswer(input,run));}
|
|
277
428
|
else try {
|
|
278
429
|
if(input.kind==='stop'){if(input.turn_id!==this.turnId||!await this.onStop(input.turn_id))throw Error('Stale turn.');queue_item_id='stop:'+input.turn_id;}
|
|
430
|
+
else if(input.kind==='quick.reply'){const text=input.control?.text;if(typeof text!=='string'||!quickReplies().includes(text))throw Error('Unknown quick reply.');const accepted=await this.onInput({...input,text},{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
279
431
|
else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
280
432
|
}catch{state='rejected';}
|
|
281
|
-
this.assertCurrent(run);
|
|
433
|
+
this.assertCurrent(run);
|
|
434
|
+
try{await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id,...(outcome?{outcome}:{})},run);}
|
|
435
|
+
// The backend already settled this device input (expired or its device gone after the claim):
|
|
436
|
+
// it is not retried nor applied again; the local book row stays the record of what was decided here.
|
|
437
|
+
catch(error){if(input.kind!=='permission.answer'||error?.code!=='REMOTE_CONFLICT')throw error;}
|
|
282
438
|
}
|
|
283
439
|
if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
|
|
284
440
|
})().catch(error=>{
|
|
@@ -298,10 +454,19 @@ export class RemoteHost {
|
|
|
298
454
|
return {state,outcome:/^[a-z_]{1,64}$/.test(String(result?.outcome||''))?result.outcome:'host_error'};
|
|
299
455
|
}catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'host_error'};}
|
|
300
456
|
}
|
|
457
|
+
/** Answer to a permission card from the paired M5Stack (kind=permission.answer). Never enters the model's
|
|
458
|
+
* queue: the PermissionDesk checks it again (arm, expiry, device, pending question, digest) and decides. */
|
|
459
|
+
async permissionAnswer(input,run=this.lifecycle){
|
|
460
|
+
if(!run?.device||!this.onPermission)return {state:'refused',outcome:'disarmed'};
|
|
461
|
+
try{
|
|
462
|
+
const result=await this.onPermission(input,{deviceId:this.permissionsArmed()?this.permissionDevice:null,armed:()=>this.permissionsArmed()});
|
|
463
|
+
return {state:PERMISSION_ACK_STATES.has(result?.state)?result.state:'refused',outcome:PERMISSION_OUTCOMES.has(result?.outcome)?result.outcome:'stale'};
|
|
464
|
+
}catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'stale'};}
|
|
465
|
+
}
|
|
301
466
|
async stop(){
|
|
302
467
|
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
303
|
-
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
304
|
-
this.stopped=true;this.answersUntil=0;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
468
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);clearTimeout(run.statsTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
469
|
+
this.stopped=true;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.cards.clear();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
305
470
|
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
306
471
|
await this.revoke(run);return this.status();
|
|
307
472
|
}
|