@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Permission card for the paired M5Stack: what Comal wants to run, trimmed and sanitized so the
|
|
2
|
+
// person sees what they approve. Pure module; the host wires it where the permission is armed.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { digest } from './operations.mjs';
|
|
6
|
+
|
|
7
|
+
const CARD = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8')).card;
|
|
8
|
+
const MAX_BYTES = CARD.maxBytes;
|
|
9
|
+
const MAX_LINES = CARD.schema.properties.lines.maxItems;
|
|
10
|
+
const LINE_CHARS = CARD.schema.properties.lines.items.maxLength;
|
|
11
|
+
const TOOL_CHARS = CARD.schema.properties.tool.maxLength;
|
|
12
|
+
const BASH_CHARS = 600;
|
|
13
|
+
const BODY_LINES = 12;
|
|
14
|
+
const SUMMARY_TITLE = 'Resumen declarado por la operación';
|
|
15
|
+
|
|
16
|
+
// Same set as the terminal card (src/tui/present.mjs INVISIBLE): controls, bidi and format
|
|
17
|
+
// characters. The terminal escapes them; the device strips them (tab and newline handled apart).
|
|
18
|
+
const INVISIBLE = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
|
|
19
|
+
export const stripForDevice = text => String(text ?? '').replace(/\t/g, ' ').replace(INVISIBLE, '');
|
|
20
|
+
|
|
21
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
22
|
+
const cpLength = text => Array.from(text).length;
|
|
23
|
+
|
|
24
|
+
/** Pieces of at most `limit` code points; a grapheme cluster is kept whole when it fits. */
|
|
25
|
+
function pieces(text, limit) {
|
|
26
|
+
const out = [];
|
|
27
|
+
let current = '', used = 0;
|
|
28
|
+
const push = (part, size) => {
|
|
29
|
+
if (used + size > limit) { out.push(current); current = ''; used = 0; }
|
|
30
|
+
current += part; used += size;
|
|
31
|
+
};
|
|
32
|
+
for (const { segment } of segmenter.segment(text)) {
|
|
33
|
+
const size = cpLength(segment);
|
|
34
|
+
if (size <= limit) push(segment, size);
|
|
35
|
+
else for (const point of Array.from(segment)) push(point, 1);
|
|
36
|
+
}
|
|
37
|
+
if (current || !out.length) out.push(current);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** First `limit` code points, ending on a grapheme boundary. */
|
|
41
|
+
function cut(text, limit) {
|
|
42
|
+
let used = 0, out = '';
|
|
43
|
+
for (const { segment } of segmenter.segment(text)) {
|
|
44
|
+
const size = cpLength(segment);
|
|
45
|
+
if (used + size > limit) return { text: out, cut: true };
|
|
46
|
+
out += segment; used += size;
|
|
47
|
+
}
|
|
48
|
+
return { text: out, cut: false };
|
|
49
|
+
}
|
|
50
|
+
// Characters removed by the strip are reported (state.stripped) so the card says it is not the raw text.
|
|
51
|
+
const strip = (text, state) => { const raw = String(text ?? ''); const clean = stripForDevice(raw); if (raw.replace(INVISIBLE, '') !== raw) state.stripped = true; return clean; };
|
|
52
|
+
const oneLine = (text, state) => strip(text, state).replace(/\n/g, ' ');
|
|
53
|
+
const countLines = text => text === '' ? 0 : text.split('\n').length;
|
|
54
|
+
|
|
55
|
+
function wrapped(text, maxChars, maxLines) {
|
|
56
|
+
const head = cut(text, maxChars);
|
|
57
|
+
const lines = head.text.split('\n').flatMap(line => pieces(line, LINE_CHARS));
|
|
58
|
+
return { lines: lines.slice(0, maxLines), truncated: head.cut || lines.length > maxLines };
|
|
59
|
+
}
|
|
60
|
+
/** Header line plus up to BODY_LINES prefixed lines, each cut to one device line. */
|
|
61
|
+
function headed(header, body) {
|
|
62
|
+
const lines = [], flags = [];
|
|
63
|
+
const head = cut(header, LINE_CHARS); lines.push(head.text); flags.push(head.cut);
|
|
64
|
+
for (const [prefix, text] of body.slice(0, BODY_LINES)) {
|
|
65
|
+
const line = cut(prefix + text, LINE_CHARS); lines.push(line.text); flags.push(line.cut);
|
|
66
|
+
}
|
|
67
|
+
return { lines, truncated: flags.some(Boolean) || body.length > BODY_LINES };
|
|
68
|
+
}
|
|
69
|
+
const plainObject = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
70
|
+
|
|
71
|
+
function render({ tool, input, summary, overwrite }, state) {
|
|
72
|
+
if (typeof summary === 'string' && summary.trim()) {
|
|
73
|
+
const body = wrapped(strip(summary.trim(), state), BASH_CHARS, MAX_LINES - 1);
|
|
74
|
+
return { kind: 'summary', lines: [SUMMARY_TITLE, ...body.lines], truncated: body.truncated };
|
|
75
|
+
}
|
|
76
|
+
if (plainObject(input) && tool === 'Bash' && typeof input.command === 'string') return { kind: 'bash', ...wrapped(strip(input.command, state), BASH_CHARS, MAX_LINES) };
|
|
77
|
+
if (plainObject(input) && tool === 'Write' && typeof input.content === 'string') {
|
|
78
|
+
const content = strip(input.content, state);
|
|
79
|
+
const path = oneLine(input.file_path ?? '', state);
|
|
80
|
+
const body = (content === '' ? [] : content.split('\n')).map(line => ['', line]);
|
|
81
|
+
return { kind: 'write', ...headed(`${path} · ${overwrite === true ? 'reemplaza' : overwrite === false ? 'nueva' : '?'} · ${countLines(content)} líneas`, body) };
|
|
82
|
+
}
|
|
83
|
+
if (plainObject(input) && tool === 'Edit' && typeof input.old_string === 'string' && typeof input.new_string === 'string') {
|
|
84
|
+
const removed = strip(input.old_string, state), added = strip(input.new_string, state);
|
|
85
|
+
const body = [...(removed === '' ? [] : removed.split('\n')).map(line => ['- ', line]), ...(added === '' ? [] : added.split('\n')).map(line => ['+ ', line])];
|
|
86
|
+
return { kind: 'edit', ...headed(`${oneLine(input.file_path ?? '', state)} · +${countLines(added)} −${countLines(removed)}${input.replace_all === true ? ' · todas' : ''}`, body) };
|
|
87
|
+
}
|
|
88
|
+
return { kind: 'terminal', lines: [], truncated: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildDeviceCard({ questionId, name, input, summary, overwrite, terminalOnly = false } = {}) {
|
|
92
|
+
// Only the exact built-in names render natively: a namespaced tool (mcp__x__Bash) is never a bash card.
|
|
93
|
+
// terminalOnly: a card the device may not answer (confirm:'always', plan mode) says only the tool name.
|
|
94
|
+
const state = { stripped: false };
|
|
95
|
+
const tool = String(name ?? '');
|
|
96
|
+
const rendered = terminalOnly ? { kind: 'terminal', lines: [], truncated: true } : render({ tool, input, summary, overwrite }, state);
|
|
97
|
+
const toolName = cut(strip(tool, state), TOOL_CHARS);
|
|
98
|
+
const card = {
|
|
99
|
+
question_id: questionId,
|
|
100
|
+
card_digest: createHash('sha256').update(JSON.stringify(['device-card/1', name, digest(input)])).digest('hex'),
|
|
101
|
+
tool: toolName.text,
|
|
102
|
+
kind: rendered.kind,
|
|
103
|
+
lines: rendered.lines,
|
|
104
|
+
truncated: rendered.truncated || toolName.cut || state.stripped,
|
|
105
|
+
};
|
|
106
|
+
if (rendered.kind === 'write' && typeof overwrite === 'boolean') card.overwrite = overwrite;
|
|
107
|
+
const size = () => Buffer.byteLength(JSON.stringify(card));
|
|
108
|
+
while (size() > MAX_BYTES && card.lines.length > 1) { card.lines.pop(); card.truncated = true; }
|
|
109
|
+
if (size() > MAX_BYTES) { card.kind = 'terminal'; card.lines = []; card.truncated = true; delete card.overwrite; }
|
|
110
|
+
return card;
|
|
111
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { t as translate, registerMessages } from './i18n/index.mjs';
|
|
2
|
+
import { isCameraPath } from './qr-scan.mjs';
|
|
3
|
+
|
|
4
|
+
registerMessages({
|
|
5
|
+
'Scanning the pairing QR needs an interactive terminal; type the code instead: /rc device CODE.': 'Escanear el QR de emparejamiento necesita una terminal interactiva; escribe el código: /rc device CODE.',
|
|
6
|
+
'Not paired; nothing was sent.': 'No se emparejó; no se envió nada.',
|
|
7
|
+
'Scanning the M5Stack pairing QR': 'Escaneando el QR de emparejamiento del M5Stack',
|
|
8
|
+
'Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture. Up to 30 s; Esc cancels. Frames stay on this machine and are deleted at once.': 'Acerca el M5Stack a 10-20 cm de la cámara, que el QR ocupe un tercio de la imagen. Hasta 30 s; Esc cancela. Las imágenes se quedan en esta máquina y se borran al instante.',
|
|
9
|
+
'Pair this M5Stack?': '¿Emparejar este M5Stack?',
|
|
10
|
+
'The camera read the code {code}. Pair only if it is the code on the M5Stack\'s screen.': 'La cámara leyó el código {code}. Empareja sólo si es el código de la pantalla del M5Stack.',
|
|
11
|
+
'Pair {code}': 'Emparejar {code}',
|
|
12
|
+
'Do not pair': 'No emparejar',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Forward the House's permission rows to the Remote Control projection.
|
|
16
|
+
// Only the current session's waits and answers cross; subagent sessions stay local.
|
|
17
|
+
const FORWARDED = new Set(['house_waiting', 'house_answered']);
|
|
18
|
+
|
|
19
|
+
export function forwardHouseRow(row, { sessionId, remoteHost } = {}) {
|
|
20
|
+
if (!row || !FORWARDED.has(row.type) || row.session_id !== sessionId) return;
|
|
21
|
+
remoteHost?.observe(row);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** /rc device [CODE|forget|scan [/dev/videoN]] → {kind:'status'|'forget'|'pair'|'scan', code?, camera?} or null when
|
|
25
|
+
* it is not that command. A scan camera that is not /dev/videoN throws. */
|
|
26
|
+
export function parseRcDevice(action) {
|
|
27
|
+
const match = /^device(?:\s+(\S.*))?$/i.exec(String(action ?? '').trim());
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const arg = (match[1] || '').trim();
|
|
30
|
+
if (!arg) return { kind: 'status' };
|
|
31
|
+
if (arg.toLowerCase() === 'forget') return { kind: 'forget' };
|
|
32
|
+
const scan = /^scan(?:\s+(.+))?$/i.exec(arg);
|
|
33
|
+
if (scan) {
|
|
34
|
+
if (!scan[1]) return { kind: 'scan' };
|
|
35
|
+
const camera = scan[1].trim();
|
|
36
|
+
if (!isCameraPath(camera)) throw Error(translate('The camera must be a /dev/videoN device.'));
|
|
37
|
+
return { kind: 'scan', camera };
|
|
38
|
+
}
|
|
39
|
+
return { kind: 'pair', code: arg };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Runs a parsed /rc device command against the host; `t` translates. */
|
|
43
|
+
export async function runRcDevice(parsed, host, t, { record } = {}) {
|
|
44
|
+
if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
|
|
45
|
+
if (parsed.kind === 'status') {
|
|
46
|
+
const device = host.status().device;
|
|
47
|
+
return device ? t('Device: {label} ({id}){connected}.', { label: device.label, id: device.id, connected: device.connected_at ? t(', connected {time}', { time: String(device.connected_at) }) : '' }) : t('No device paired.');
|
|
48
|
+
}
|
|
49
|
+
if (parsed.kind === 'forget') { const wasArmed = host.permissionsArmed(); await host.forgetDevice(); if (wasArmed) await record?.(ARM_ROW(false, 0)); return t('Device removed; its permission answers are off.'); }
|
|
50
|
+
const paired = await host.pairDevice(parsed.code);
|
|
51
|
+
return t('Device paired: {label} ({id}).', { label: paired.label, id: paired.device_id });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** /rc device scan: the camera reads the device's pairing QR (only now, at most 30 s), the person confirms the code,
|
|
55
|
+
* and it pairs through runRcDevice's own path. `scan({camera})` → {code, camera}; `confirm({code, camera})` → boolean,
|
|
56
|
+
* absent without an interactive terminal (then it is refused before the camera turns on). */
|
|
57
|
+
export async function runRcDeviceScan(parsed, host, t, { scan, confirm, record } = {}) {
|
|
58
|
+
if (typeof confirm !== 'function') throw Error(t('Scanning the pairing QR needs an interactive terminal; type the code instead: /rc device CODE.'));
|
|
59
|
+
if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
|
|
60
|
+
const found = await scan({ camera: parsed.camera });
|
|
61
|
+
if (!await confirm({ code: found.code, camera: found.camera })) return t('Not paired; nothing was sent.');
|
|
62
|
+
return runRcDevice({ kind: 'pair', code: found.code }, host, t, { record });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The yes/no card for a scanned code: "Do not pair" comes first and is highlighted, so Enter never pairs. */
|
|
66
|
+
export function deviceCodeChoice(code, t) {
|
|
67
|
+
return { title: t('Pair this M5Stack?'), description: t('The camera read the code {code}. Pair only if it is the code on the M5Stack\'s screen.', { code }), items: [{ id: 'cancel', label: t('Do not pair') }, { id: 'pair', label: t('Pair {code}', { code }) }], selected: 'cancel' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ARM_ROW = (armed, until) => ({ type: 'remote_control', action: 'permissions.arm', armed, until: armed ? new Date(until).toISOString() : null });
|
|
71
|
+
const clock = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
72
|
+
|
|
73
|
+
/** /rc permissions [on [N m|min|h]|off] → {kind:'status'|'off'|'on', ms} or null when it is not that command. */
|
|
74
|
+
export function parseRcPermissions(action) {
|
|
75
|
+
const match = /^permissions(?:\s+(?:(off)|(on)(?:\s+(\d+(?:[.,]\d+)?)\s*(m|min|h)?)?))?$/i.exec(String(action ?? '').trim());
|
|
76
|
+
if (!match) return null;
|
|
77
|
+
if (match[1]) return { kind: 'off' };
|
|
78
|
+
if (!match[2]) return { kind: 'status' };
|
|
79
|
+
if (!match[3]) return { kind: 'on', ms: null };
|
|
80
|
+
return { kind: 'on', ms: Math.round(Number(match[3].replace(',', '.')) * (String(match[4] || 'm').toLowerCase() === 'h' ? 3600e3 : 60e3)) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runs a parsed /rc permissions command; `record(row)` appends the book row. */
|
|
84
|
+
export async function runRcPermissions(parsed, host, t, { record } = {}) {
|
|
85
|
+
if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
|
|
86
|
+
const status = () => host.permissionsArmed() ? t('Permission answers from the M5Stack: armed until {time}.', { time: clock(host.permissionUntil) }) : t('Permission answers from the M5Stack: off.');
|
|
87
|
+
if (parsed.kind === 'status') return status();
|
|
88
|
+
if (parsed.kind === 'off') { host.disarmPermissions(); await record?.(ARM_ROW(false, 0)); return status(); }
|
|
89
|
+
const until = host.armPermissions(parsed.ms ?? undefined);
|
|
90
|
+
await record?.(ARM_ROW(true, until));
|
|
91
|
+
return t('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.', { time: clock(until) });
|
|
92
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Permission cards answered from the paired M5Stack, racing the terminal card: the first surface
|
|
2
|
+
// that DECIDES wins. The desk is the single owner of "decided": a question is settled exactly once
|
|
3
|
+
// (terminal or device), synchronously on the event loop, and every later answer reads stale.
|
|
4
|
+
// A device answer is only ever "allow once" or "deny": it never saves a rule and never widens the
|
|
5
|
+
// policy. It is checked again here, whatever the backend and the relay already checked:
|
|
6
|
+
// arm on → not expired → from the armed device → the question still pending here → the card the
|
|
7
|
+
// person saw (digest) → a card the device may answer → no confirm:'always' → not plan → a decision.
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { stat } from 'node:fs/promises';
|
|
10
|
+
import { t } from './i18n/index.mjs';
|
|
11
|
+
import { buildDeviceCard } from './remote-device-card.mjs';
|
|
12
|
+
|
|
13
|
+
const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8'));
|
|
14
|
+
const DECISIONS = new Set(CONTRACT.inputs.permission_answer.properties.decision.enum);
|
|
15
|
+
/** What a permission.answer ACK may say: its verdict states, and a decision or a contract refusal. */
|
|
16
|
+
export const PERMISSION_ACK_STATES = new Set(['applied', 'stale', 'refused', 'expired', 'rejected']);
|
|
17
|
+
export const PERMISSION_OUTCOMES = new Set([...DECISIONS, ...CONTRACT.refusals]);
|
|
18
|
+
const LEDGER_MAX = 512;
|
|
19
|
+
const live = value => typeof value === 'function' ? value() : value;
|
|
20
|
+
|
|
21
|
+
export class PermissionDesk {
|
|
22
|
+
constructor({ now = Date.now, sendCard = () => {}, closeCard = () => {}, record = async () => {} } = {}) {
|
|
23
|
+
Object.assign(this, { now, sendCard, closeCard, record });
|
|
24
|
+
this.pending = new Map();
|
|
25
|
+
// Same request_id (a lost ACK redelivers it) → the same answer, never a second decision.
|
|
26
|
+
this.ledger = new Map();
|
|
27
|
+
}
|
|
28
|
+
/** One open question. `decision` resolves once, with whichever surface settled it first. */
|
|
29
|
+
open({ questionId, name, input, confirmAlways = false, mode = 'default', card }) {
|
|
30
|
+
if (this.pending.has(questionId)) throw Error('This permission question is already open.');
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
let resolve, reject;
|
|
33
|
+
const decision = new Promise((done, fail) => { resolve = done; reject = fail; });
|
|
34
|
+
decision.catch(() => {});
|
|
35
|
+
const entry = { questionId, name, input, confirmAlways, mode, card, controller, decided: false, closed: false };
|
|
36
|
+
const settle = (result, failed = false) => {
|
|
37
|
+
if (entry.decided) return false;
|
|
38
|
+
entry.decided = true;
|
|
39
|
+
this.#closeCard(entry);
|
|
40
|
+
if (failed) reject(result); else resolve(result);
|
|
41
|
+
controller.abort();
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
entry.settle = settle;
|
|
45
|
+
this.pending.set(questionId, entry);
|
|
46
|
+
try { this.sendCard(card); } catch {}
|
|
47
|
+
return {
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
decision,
|
|
50
|
+
/** The terminal card answered (or rejected): first wins, a later device answer reads stale. */
|
|
51
|
+
terminal: value => settle({ allowed: Boolean(value && typeof value === 'object' ? value.allowed : value), by: 'terminal', value }),
|
|
52
|
+
fail: error => settle(error, true),
|
|
53
|
+
close: () => {
|
|
54
|
+
if (this.pending.get(questionId) === entry) this.pending.delete(questionId);
|
|
55
|
+
settle(Object.assign(Error('The permission question closed.'), { name: 'AbortError' }), true);
|
|
56
|
+
this.#closeCard(entry);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
#closeCard(entry) {
|
|
61
|
+
if (entry.closed) return;
|
|
62
|
+
entry.closed = true;
|
|
63
|
+
try { this.closeCard(entry.questionId); } catch {}
|
|
64
|
+
}
|
|
65
|
+
#remember(requestId, result) {
|
|
66
|
+
if (typeof requestId !== 'string' || !requestId) return result;
|
|
67
|
+
this.ledger.set(requestId, result);
|
|
68
|
+
if (this.ledger.size > LEDGER_MAX) this.ledger.delete(this.ledger.keys().next().value);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** A claimed device input (kind permission.answer). Returns the ACK verdict {state, outcome}. */
|
|
72
|
+
async handle(input, { deviceId = null, armed = () => false } = {}) {
|
|
73
|
+
const requestId = input?.request_id;
|
|
74
|
+
if (typeof requestId === 'string' && this.ledger.has(requestId)) return this.ledger.get(requestId);
|
|
75
|
+
const refuse = (state, outcome) => this.#remember(requestId, { state, outcome });
|
|
76
|
+
const control = input?.control && typeof input.control === 'object' ? input.control : {};
|
|
77
|
+
let isArmed = false;
|
|
78
|
+
try { isArmed = armed() === true; } catch {}
|
|
79
|
+
if (!isArmed) return refuse('refused', 'disarmed');
|
|
80
|
+
const expires = Number(input?.expires_at);
|
|
81
|
+
if (input?.expires_at === undefined || input?.expires_at === null || !Number.isFinite(expires) || expires * 1000 <= this.now()) return refuse('expired', 'expired');
|
|
82
|
+
if (typeof deviceId !== 'string' || !deviceId || input?.device_id !== deviceId) return refuse('refused', 'other_device');
|
|
83
|
+
const entry = typeof control.question_id === 'string' ? this.pending.get(control.question_id) : undefined;
|
|
84
|
+
if (!entry || entry.decided) return refuse('stale', 'stale');
|
|
85
|
+
if (typeof control.card_digest !== 'string' || control.card_digest !== entry.card?.card_digest) return refuse('stale', 'stale');
|
|
86
|
+
if (entry.card.kind === 'terminal') return refuse('refused', 'terminal_only');
|
|
87
|
+
let always = true, mode = 'plan';
|
|
88
|
+
try { always = live(entry.confirmAlways) === true; mode = live(entry.mode); } catch {}
|
|
89
|
+
if (always) return refuse('refused', 'confirm_always');
|
|
90
|
+
if (mode === 'plan') return refuse('refused', 'plan_mode');
|
|
91
|
+
if (!DECISIONS.has(control.decision)) return refuse('refused', 'stale');
|
|
92
|
+
// Settled synchronously: nothing else can decide this question after this line.
|
|
93
|
+
if (!entry.settle({ allowed: control.decision === 'allow_once', by: 'device' })) return refuse('stale', 'stale');
|
|
94
|
+
const result = this.#remember(requestId, { state: 'applied', outcome: control.decision });
|
|
95
|
+
try { await this.record({ type: 'remote_control', action: 'permissions.answer', question_id: entry.questionId, decision: control.decision, device_id: input.device_id }); } catch {}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The confirm path with both surfaces: the terminal card (terminal(signal) → ui.confirm) and the
|
|
102
|
+
* device card. Returns what PermissionPolicy expects from confirm: the terminal's own value, or
|
|
103
|
+
* the device's boolean (never a remember choice).
|
|
104
|
+
*/
|
|
105
|
+
export async function answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice = () => {} }) {
|
|
106
|
+
let entry;
|
|
107
|
+
// A desk that cannot open the question leaves the terminal card, as before the device existed.
|
|
108
|
+
try { entry = desk.open({ questionId, name, input, confirmAlways, mode, card }); } catch { return terminal(signal); }
|
|
109
|
+
try {
|
|
110
|
+
let shown;
|
|
111
|
+
try { shown = Promise.resolve(terminal(signal ? AbortSignal.any([signal, entry.signal]) : entry.signal)); }
|
|
112
|
+
catch (error) { shown = Promise.reject(error); }
|
|
113
|
+
shown.then(value => entry.terminal(value), error => entry.fail(error));
|
|
114
|
+
const result = await entry.decision;
|
|
115
|
+
if (result.by !== 'device') return result.value;
|
|
116
|
+
// The terminal card closed because the device answered: the terminal says so.
|
|
117
|
+
try { notice(deviceAnswerNotice({ allowed: result.allowed, tool: card?.tool ?? name })); } catch {}
|
|
118
|
+
return result.allowed;
|
|
119
|
+
} finally { entry.close(); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The terminal's line when the M5Stack decided a card. */
|
|
123
|
+
export const deviceAnswerNotice = ({ allowed, tool }) => t('Answered on the M5Stack: {decision} · {tool}', { decision: t(allowed ? 'allowed once' : 'denied'), tool: String(tool ?? '') });
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* PermissionPolicy's confirm when /rc may carry the card to the M5Stack. Only an armed host with a
|
|
127
|
+
* question id engages the device; an aborted question, or any failure while preparing the device
|
|
128
|
+
* card, shows the plain terminal card instead (the device is a convenience, never a gate).
|
|
129
|
+
*/
|
|
130
|
+
export async function confirmWithDevice({ armed, desk, questionId, name, input, summary, signal, terminal, resolveWrite, confirmAlways, mode, notice }) {
|
|
131
|
+
if (!armed || !questionId || signal?.aborted) return terminal(signal);
|
|
132
|
+
let card;
|
|
133
|
+
try {
|
|
134
|
+
// confirm:'always' and plan mode stay in the terminal: the device gets a card it cannot answer, and nothing of
|
|
135
|
+
// the command, the change or a summary leaves the machine (an unreadable policy counts as terminal-only too).
|
|
136
|
+
let terminalOnly = true;
|
|
137
|
+
try { terminalOnly = live(confirmAlways) === true || live(mode) === 'plan'; } catch {}
|
|
138
|
+
card = terminalOnly ? buildDeviceCard({ questionId, name, input, terminalOnly: true }) : buildDeviceCard({ questionId, name, input, summary, overwrite: await writeOverwrite(name, input, resolveWrite) });
|
|
139
|
+
}
|
|
140
|
+
catch { return terminal(signal); }
|
|
141
|
+
if (signal?.aborted) return terminal(signal);
|
|
142
|
+
return answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const OVERWRITE_TIMEOUT_MS = 250;
|
|
146
|
+
/** Write only: does the path the Write tool would write already exist? undefined when it cannot tell,
|
|
147
|
+
* including when resolving and stat take longer than `timeoutMs` (the terminal card must not wait). */
|
|
148
|
+
export async function writeOverwrite(name, input, resolve, { timeoutMs = OVERWRITE_TIMEOUT_MS } = {}) {
|
|
149
|
+
if (name !== 'Write' || typeof input?.file_path !== 'string') return undefined;
|
|
150
|
+
const probe = (async () => {
|
|
151
|
+
let file;
|
|
152
|
+
try { file = await resolve(input.file_path, { create: true }); } catch { return undefined; }
|
|
153
|
+
try { await stat(file); return true; }
|
|
154
|
+
catch (error) { return error?.code === 'ENOENT' ? false : undefined; }
|
|
155
|
+
})();
|
|
156
|
+
let timer;
|
|
157
|
+
const late = new Promise(done => { timer = setTimeout(() => done(undefined), timeoutMs); timer.unref?.(); });
|
|
158
|
+
try { return await Promise.race([probe, late]); } finally { clearTimeout(timer); }
|
|
159
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// session.stats for /rc surfaces (browser and paired M5Stack): a bounded summary built from what
|
|
2
|
+
// /status, /context and the status bar already compute. Pure module: numbers, enums and bounded
|
|
3
|
+
// labels only — never paths, file names, tool arguments or the cwd.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { stripForDevice } from './remote-device-card.mjs';
|
|
6
|
+
|
|
7
|
+
const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8')).stats;
|
|
8
|
+
export const STATS_SCHEMA = CONTRACT.schema;
|
|
9
|
+
export const STATS_MAX_BYTES = CONTRACT.maxBytes;
|
|
10
|
+
// A change publishes a durable session.stats at most once a minute (each one is a stored row against the session's
|
|
11
|
+
// 10,000-event cap and the account's storage); a turn end and a device connecting publish at once (1 s floor).
|
|
12
|
+
export const STATS_INTERVAL_MS = 60000;
|
|
13
|
+
export const STATS_FLOOR_MS = 1000;
|
|
14
|
+
|
|
15
|
+
const GROUPS = STATS_SCHEMA.properties;
|
|
16
|
+
const limit = (group, field) => GROUPS[group].properties[field].maxLength;
|
|
17
|
+
const MODES = GROUPS.permissions.properties.mode.enum;
|
|
18
|
+
const TRANSPORTS = GROUPS.connection.properties.transport.enum;
|
|
19
|
+
const EFFORT_LEVELS = GROUPS.model.properties.effort_levels.maxItems;
|
|
20
|
+
const SOURCES = GROUPS.capabilities.properties.sources.maxProperties;
|
|
21
|
+
const SOURCE_NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
22
|
+
|
|
23
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
24
|
+
const flag = value => typeof value === 'boolean' ? value : null;
|
|
25
|
+
const cut = (text, max) => Array.from(text).slice(0, max).join('');
|
|
26
|
+
/** A label as the device shows it: invisible characters out, one line, cut to the schema's length. */
|
|
27
|
+
function label(value, max) {
|
|
28
|
+
if (typeof value !== 'string') return undefined;
|
|
29
|
+
const text = cut(stripForDevice(value).replace(/\s+/g, ' ').trim(), max).trim();
|
|
30
|
+
return text || undefined;
|
|
31
|
+
}
|
|
32
|
+
// A token is path-like when it holds a separator, starts at home, or names a file (word.ext).
|
|
33
|
+
const PATHISH = /[\\/]|^~|^[A-Za-z]:/;
|
|
34
|
+
const FILENAME = /\w\.[A-Za-z][A-Za-z0-9]{0,7}$/;
|
|
35
|
+
const MODEL_FILE = /\w\.(gguf|ggml|bin|safetensors|onnx|pt|pth|mlx|llamafile|json|ya?ml|toml)$/i;
|
|
36
|
+
/** Free text (objective, task title) without the cwd, paths or file names: each such token becomes «…». */
|
|
37
|
+
function redacted(value, cwd, max) {
|
|
38
|
+
if (typeof value !== 'string') return undefined;
|
|
39
|
+
let text = stripForDevice(value).replace(/\s+/g, ' ');
|
|
40
|
+
if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('…');
|
|
41
|
+
text = text.split(' ').map(token => {
|
|
42
|
+
const bare = token.replace(/^[("'«“‘[]+|[)"'»”’\],.;:!?]+$/g, '');
|
|
43
|
+
return bare && (PATHISH.test(bare) || FILENAME.test(bare)) ? token.replace(bare, '…') : token;
|
|
44
|
+
}).join(' ');
|
|
45
|
+
return label(text, max);
|
|
46
|
+
}
|
|
47
|
+
/** A model or profile name. Local providers name a weights or profile FILE: only its bare name is shown, never the
|
|
48
|
+
* path (or the cwd) — `/home/x/models/qwen-7b.gguf` reads `qwen-7b`; an id that is no path stays whole. */
|
|
49
|
+
function nameLabel(value, cwd, max) {
|
|
50
|
+
if (typeof value !== 'string') return undefined;
|
|
51
|
+
let text = stripForDevice(value).replace(/\s+/g, ' ').trim();
|
|
52
|
+
if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('');
|
|
53
|
+
text = text.split(' ').map(token => {
|
|
54
|
+
const path = PATHISH.test(token);
|
|
55
|
+
let name = path ? token.split(/[\\/]+/).filter(Boolean).at(-1) ?? '' : token;
|
|
56
|
+
if (/^~|^[A-Za-z]:$/.test(name)) name = '';
|
|
57
|
+
// The last segment of a path loses any extension; an id that is no path only a model file's (meta.llama3 stays).
|
|
58
|
+
return (path ? FILENAME.test(name) : MODEL_FILE.test(name)) ? name.replace(/\.[A-Za-z][A-Za-z0-9]{0,11}$/, '') : name;
|
|
59
|
+
}).filter(Boolean).join(' ');
|
|
60
|
+
return label(text, max);
|
|
61
|
+
}
|
|
62
|
+
const compact = group => {
|
|
63
|
+
for (const key of Object.keys(group)) if (group[key] === undefined) delete group[key];
|
|
64
|
+
return Object.keys(group).length ? group : undefined;
|
|
65
|
+
};
|
|
66
|
+
const tally = value => {
|
|
67
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
68
|
+
return compact({ input: count(value.input), output: count(value.output), cached: count(value.cached) });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sources (each optional; a missing source omits its group or field):
|
|
73
|
+
* metadata (cli metadata()), promptCaching, contextStatus / contextPlan (compactor status/plan as
|
|
74
|
+
* /context computes them), messages {active, original}, lastCompaction (context_projection row),
|
|
75
|
+
* usage {last, session, calls, call_limit}, timing (result.timing), work (projectRemoteWorkspace),
|
|
76
|
+
* permissions {armed, until ms} (RemoteHost status().permission_answers), transport, addDirCount, cwd.
|
|
77
|
+
*/
|
|
78
|
+
export function buildSessionStats({ metadata, promptCaching, contextStatus, contextPlan, messages, lastCompaction, usage, timing, work, permissions, transport, addDirCount, cwd } = {}) {
|
|
79
|
+
const stats = {};
|
|
80
|
+
if (metadata && typeof metadata === 'object') {
|
|
81
|
+
const levels = metadata.modelCapabilities?.effort?.levels;
|
|
82
|
+
stats.model = compact({
|
|
83
|
+
provider: label(metadata.provider, limit('model', 'provider')),
|
|
84
|
+
profile: nameLabel(metadata.profile, cwd, limit('model', 'profile')),
|
|
85
|
+
model: nameLabel(metadata.model, cwd, limit('model', 'model')),
|
|
86
|
+
effort: label(metadata.effort, limit('model', 'effort')),
|
|
87
|
+
effort_levels: Array.isArray(levels) ? levels.map(level => label(level, GROUPS.model.properties.effort_levels.items.maxLength)).filter(Boolean).slice(0, EFFORT_LEVELS) : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const reported = metadata?.modelCapabilities?.capabilities;
|
|
91
|
+
if ((reported && typeof reported === 'object') || typeof promptCaching === 'boolean') {
|
|
92
|
+
const provenance = metadata?.modelCapabilities?.provenance || {}, sources = {};
|
|
93
|
+
const capabilities = {};
|
|
94
|
+
if (reported && typeof reported === 'object') for (const name of ['tools', 'vision', 'thinking']) {
|
|
95
|
+
capabilities[name] = flag(reported[name]);
|
|
96
|
+
const source = provenance[name]?.source;
|
|
97
|
+
if (typeof source === 'string' && SOURCE_NAME.test(source)) sources[name] = source;
|
|
98
|
+
}
|
|
99
|
+
capabilities.cache = typeof promptCaching === 'boolean' ? promptCaching : null;
|
|
100
|
+
if (typeof promptCaching === 'boolean') sources.cache = 'provider';
|
|
101
|
+
if (Object.keys(sources).length) capabilities.sources = Object.fromEntries(Object.entries(sources).slice(0, SOURCES));
|
|
102
|
+
stats.capabilities = capabilities;
|
|
103
|
+
}
|
|
104
|
+
{
|
|
105
|
+
const until = Number(permissions?.until);
|
|
106
|
+
const group = compact({
|
|
107
|
+
mode: MODES.includes(metadata?.mode) ? metadata.mode : undefined,
|
|
108
|
+
device_armed: typeof permissions?.armed === 'boolean' ? permissions.armed : undefined,
|
|
109
|
+
device_until: permissions ? (permissions.armed && Number.isFinite(until) && until > 0 ? Math.floor(until / 1000) : null) : undefined,
|
|
110
|
+
add_dirs: count(addDirCount),
|
|
111
|
+
});
|
|
112
|
+
if (group) stats.permissions = group;
|
|
113
|
+
}
|
|
114
|
+
if (contextStatus || contextPlan || messages || lastCompaction) {
|
|
115
|
+
const status = contextStatus || {}, plan = contextPlan || status;
|
|
116
|
+
const percent = typeof status.percent === 'number' && Number.isFinite(status.percent) ? Math.min(100, Math.max(0, status.percent)) : undefined;
|
|
117
|
+
const at = Date.parse(lastCompaction?.timestamp ?? lastCompaction?.at ?? '');
|
|
118
|
+
const group = compact({
|
|
119
|
+
input_tokens: count(status.input_tokens), window: count(status.context_window), output_reserve: count(status.output_reserve), percent,
|
|
120
|
+
compaction_window: count(plan.compaction_window), soft_threshold: count(plan.soft_threshold), hard_threshold: count(plan.hard_threshold),
|
|
121
|
+
last_compaction: lastCompaction ? compact({
|
|
122
|
+
at: Number.isFinite(at) && at >= 0 ? Math.floor(at / 1000) : undefined,
|
|
123
|
+
trigger: label(lastCompaction.compaction_trigger ?? lastCompaction.trigger, GROUPS.context.properties.last_compaction.properties.trigger.maxLength),
|
|
124
|
+
before: count(lastCompaction.before_tokens), after: count(lastCompaction.after_tokens),
|
|
125
|
+
}) ?? null : undefined,
|
|
126
|
+
messages_active: count(messages?.active), messages_original: count(messages?.original),
|
|
127
|
+
});
|
|
128
|
+
if (group) stats.context = group;
|
|
129
|
+
}
|
|
130
|
+
if (usage && typeof usage === 'object') {
|
|
131
|
+
const group = compact({ last: tally(usage.last), session: tally(usage.session), calls: count(usage.calls), call_limit: count(usage.call_limit) });
|
|
132
|
+
if (group) stats.usage = group;
|
|
133
|
+
}
|
|
134
|
+
if (timing && typeof timing === 'object') {
|
|
135
|
+
const group = compact({ comal_ms: count(timing.comal_ms), reasoning_ms: count(timing.reasoning_ms), first_response_ms: count(timing.first_output_ms ?? timing.first_text_ms), tools_ms: count(timing.tools_ms), human_wait_ms: count(timing.human_wait_ms) });
|
|
136
|
+
if (group) stats.timing = group;
|
|
137
|
+
}
|
|
138
|
+
if (work && typeof work === 'object' && (work.plan || work.tasks_total)) {
|
|
139
|
+
const tasks = Array.isArray(work.tasks) ? work.tasks : [], checks = tasks.flatMap(task => Array.isArray(task?.checks) ? task.checks : []);
|
|
140
|
+
const current = tasks.find(task => task?.status === 'in_progress');
|
|
141
|
+
const group = compact({
|
|
142
|
+
objective: redacted(work.plan?.objective, cwd, limit('work', 'objective')),
|
|
143
|
+
done: count(work.counts?.done), total: count(work.tasks_total),
|
|
144
|
+
current_task: current ? redacted(current.title, cwd, limit('work', 'current_task')) ?? null : null,
|
|
145
|
+
checks_passing: checks.filter(check => check?.status === 'passed').length,
|
|
146
|
+
checks_failing: checks.filter(check => ['failed', 'refused', 'interrupted', 'not_dispatched'].includes(check?.status)).length,
|
|
147
|
+
});
|
|
148
|
+
if (group) stats.work = group;
|
|
149
|
+
}
|
|
150
|
+
if (TRANSPORTS.includes(transport)) stats.connection = { transport, host_online: transport !== 'disconnected' };
|
|
151
|
+
for (const key of Object.keys(stats)) if (stats[key] === undefined) delete stats[key];
|
|
152
|
+
return fitStats(stats) ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// What goes first when a summary does not fit: free text, then timing, then the rest that is not essential.
|
|
156
|
+
const DROPS = [
|
|
157
|
+
stats => { delete stats.work?.objective; },
|
|
158
|
+
stats => { delete stats.work?.current_task; },
|
|
159
|
+
stats => { delete stats.timing; },
|
|
160
|
+
stats => { delete stats.capabilities?.sources; },
|
|
161
|
+
stats => { delete stats.model?.effort_levels; },
|
|
162
|
+
stats => { delete stats.work; },
|
|
163
|
+
stats => { delete stats.usage; },
|
|
164
|
+
];
|
|
165
|
+
/** The summary itself when it fits in `max` bytes; otherwise a copy with optional parts dropped; null if nothing fits. */
|
|
166
|
+
export function fitStats(stats, max = STATS_MAX_BYTES) {
|
|
167
|
+
if (!stats || typeof stats !== 'object') return null;
|
|
168
|
+
const size = value => Buffer.byteLength(JSON.stringify(value));
|
|
169
|
+
if (size(stats) <= max) return stats;
|
|
170
|
+
const copy = structuredClone(stats);
|
|
171
|
+
for (const drop of DROPS) {
|
|
172
|
+
drop(copy);
|
|
173
|
+
for (const key of Object.keys(copy)) if (copy[key] && typeof copy[key] === 'object' && !Object.keys(copy[key]).length) delete copy[key];
|
|
174
|
+
if (size(copy) <= max) return copy;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Context numbers exactly as /context computes them (same compactor calls). */
|
|
180
|
+
export function readContext({ compactor, system, tools, maxTokens, session }) {
|
|
181
|
+
const contextStatus = compactor.status({ system, messages: session.messages, tools }, maxTokens);
|
|
182
|
+
const contextPlan = compactor.plan(contextStatus.output_reserve, contextStatus.overhead_tokens);
|
|
183
|
+
return { contextStatus, contextPlan, messages: { active: session.messages.length, original: session.historyMessages.length } };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tokens = usage => ({ input: count(usage?.input_tokens) ?? 0, output: count(usage?.output_tokens) ?? 0 });
|
|
187
|
+
const cacheRead = row => count(row?.usage_accounting?.cache_read_tokens) ?? 0;
|
|
188
|
+
/** Usage, last timing and last compaction from the session book. Session totals count what the status
|
|
189
|
+
* bar counts (results not already including subagents, and failed requests with usage). */
|
|
190
|
+
export function statsFromRows(rows = []) {
|
|
191
|
+
const counted = rows.filter(row => (row.type === 'result' && !row.usage_includes_subagents) || (row.type === 'error' && row.usage));
|
|
192
|
+
const turns = rows.filter(row => row.type === 'result' && !row.usage_includes_subagents && row.usage && row.turns > 0);
|
|
193
|
+
const last = turns.at(-1), reads = rows.filter(row => row.type === 'model_usage');
|
|
194
|
+
const session = counted.reduce((sum, row) => { const n = tokens(row.usage); return { input: sum.input + n.input, output: sum.output + n.output }; }, { input: 0, output: 0 });
|
|
195
|
+
const usage = counted.length || reads.length ? {
|
|
196
|
+
...(last ? { last: { ...tokens(last.usage), cached: last.turn_id ? reads.filter(row => row.turn_id === last.turn_id).reduce((n, row) => n + cacheRead(row), 0) : 0 } } : {}),
|
|
197
|
+
session: { ...session, cached: reads.reduce((n, row) => n + cacheRead(row), 0) },
|
|
198
|
+
...(last ? { calls: last.turns } : {}),
|
|
199
|
+
} : null;
|
|
200
|
+
return {
|
|
201
|
+
usage,
|
|
202
|
+
timing: rows.findLast(row => row.type === 'result' && row.timing && typeof row.timing === 'object')?.timing ?? null,
|
|
203
|
+
lastCompaction: rows.findLast(row => row.type === 'context_projection') ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/tui/discovery.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { graphemes, safeText } from './text.mjs';
|
|
|
6
6
|
|
|
7
7
|
const COMMANDS = [
|
|
8
8
|
['cloud', '[login|status|logout]', 'Connect your Comal account for Artifacts and Remote Control'],
|
|
9
|
-
['remote-control', '[start|status|stop|relay on|off]', 'Open this local session on the web'], ['rc', '[start|status|stop|relay on|off]', 'Remote Control shortcut'],
|
|
9
|
+
['remote-control', '[start|status|stop|relay on|off|device CODE|scan|forget|permissions on|off]', 'Open this local session on the web'], ['rc', '[start|status|stop|relay on|off|device CODE|scan|forget|permissions on|off]', 'Remote Control shortcut'],
|
|
10
10
|
['help', '', "Help and keyboard"], ['status', '', "Session, provider and budget"],
|
|
11
11
|
['tools', '', "Tools available at this time"], ['house', '', "Receipts and interrupted operations"],
|
|
12
12
|
['operations', '', "Contracts and effects of operations"], ['sessions', '', "Open the session selector"],
|
|
@@ -122,9 +122,15 @@ export function commandCatalog(context = {}) {
|
|
|
122
122
|
|
|
123
123
|
function argumentItems(command, args, context) {
|
|
124
124
|
const first = args[0] || '', position = args.length - 1;
|
|
125
|
-
if (
|
|
125
|
+
if (command === 'rc' || command === 'remote-control') {
|
|
126
|
+
const durations = () => named(['30m', '2h', '8h'], 'How long the arm lasts (30 min by default, 8 h at most)');
|
|
127
|
+
if (position === 1 && first === 'relay') return named(['on', 'off'], 'on [30m|2h|8h] arms in memory for this /rc session; off disarms');
|
|
128
|
+
if (position === 1 && first === 'device') return [...named(['scan'], 'Read the pairing QR with the camera (at most 30 s); you confirm the code'), ...named(['forget'], 'Unpair the M5Stack')];
|
|
129
|
+
if (position === 1 && first === 'permissions') return named(['on', 'off'], 'on [30m|2h|8h] lets the paired M5Stack allow once or deny; off disarms');
|
|
130
|
+
if (position === 2 && (first === 'relay' || first === 'permissions') && args[1] === 'on') return durations();
|
|
131
|
+
}
|
|
126
132
|
if (position === 0) {
|
|
127
|
-
if (command === 'rc' || command === 'remote-control')return [...named(['start','status','stop'],'Remote session projection; local tools and permissions'), ...named(['relay'], 'Answers to /relay cards from the browser (off by default)')];
|
|
133
|
+
if (command === 'rc' || command === 'remote-control')return [...named(['start','status','stop'],'Remote session projection; local tools and permissions'), ...named(['relay'], 'Answers to /relay cards from the browser (off by default)'), ...named(['device'], 'Pair an M5Stack: type its CODE, or scan its QR'), ...named(['permissions'], 'Let the paired M5Stack answer permission cards (off by default)')];
|
|
128
134
|
if (command === 'cloud') return named(['login','status','logout'],'Comal account; separate from the model provider');
|
|
129
135
|
if (command === 'permissions') return context.mode === BYPASS_PERMISSION_MODE ? [] : [...named(['default', 'plan', 'acceptEdits', 'auto'], t("Permissions mode; does not change the model")), ...named(['allow', 'ask', 'deny', 'remove'], t("Saved permission rule; deny wins"))];
|
|
130
136
|
if (command === 'autonomy') return context.mode === BYPASS_PERMISSION_MODE ? [] : named(languageInfo().language === 'es' ? ['observar', 'asistir', 'editar', 'autonomo'] : ['observe', 'assist', 'edit', 'autonomous'], t("Autonomy level; never enables the startup bypass"));
|