@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.
@@ -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
+ }
@@ -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.',
@@ -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
- if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved locally':'Declined locally'};
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
- 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}}:{})}:{enabled:false};}
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 attempts=[features,features.filter(item=>item!=='relay_answers_v1'),features.filter(item=>!item.startsWith('relay_'))].filter((list,index,all)=>index===0||list.length<all[index-1].length);
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){if(!this.current(run)||!String(item.event?.type).startsWith('relay.')||!RELAY_REFUSALS.has(error.code))throw error;this.onNotice('Remote /relay event refused by the backend ('+error.code+'); /relay and /swarm in this terminal are unaffected.');}
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);await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id,...(outcome?{outcome}:{})},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
  }