@milpa/comal 0.2.3 → 0.2.5

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.
@@ -1,5 +1,9 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash, randomBytes, 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';
6
+ import { failurePhrase, speechFrames, SpeechPacer, SPEECH_MAX_BYTES, SPEECH_MAX_BUFFERED, SPEECH_QUIET_AFTER_DELTA_MS, SPEECH_WINDOW_CEILING, SPEECH_WINDOW_MS, turnPhrase } from './remote-speech.mjs';
3
7
 
4
8
  const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
9
  export const remoteInputDigest = (session, request, text) => digest([session,request,text]);
@@ -17,7 +21,8 @@ export function projectRemoteEvent(event, turnId) {
17
21
  }
18
22
  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
23
  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'};
24
+ // Neutral: the terminal or the paired M5Stack may have answered it.
25
+ if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved':'Declined'};
21
26
  if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
22
27
  // Avisos de plugins (p. ej. objeción y corrección del revisor de open-relay): se proyectan al
23
28
  // admitirse, urgentes o no, para que /rc los vea aunque no despierten un turno. El backend
@@ -42,6 +47,9 @@ export const RELAY_SESSION_EVENTS = 2000, RELAY_SESSION_BYTES = 16 << 20, RELAY_
42
47
  // Rechazos del backend que no se arreglan reintentando: se descarta ESE evento relay.* y la
43
48
  // proyección sigue (sin esto, un evento rechazado bloquearía la cola entera).
44
49
  const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','REMOTE_RELAY_STALE','REMOTE_SIZE']);
50
+ // A session.stats is a summary the next one replaces: one the backend will not take (a quota, its size, the feature,
51
+ // its schema) is dropped, never left at the head of the queue holding the conversation behind it.
52
+ const STATS_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_SIZE','REMOTE_QUOTA','REMOTE_STORAGE_QUOTA']);
45
53
 
46
54
  // Respuestas de /relay desde el navegador (D1): armadas sólo con /rc relay on en esta terminal,
47
55
  // en memoria de este proceso, con caducidad (tope 8 h). Nunca se guardan en disco ni en el
@@ -49,15 +57,158 @@ const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','
49
57
  export const RELAY_ARM_MAX_MS = 8 * 3600e3, RELAY_ARM_DEFAULT_MS = 30 * 60e3;
50
58
  // Estados de ACK de un control relay (el backend sólo los admite para kind=relay).
51
59
  const RELAY_ACK_STATES = new Set(['applied','stale','refused','expired']);
60
+ // Tarjetas de permiso para el M5Stack: sólo por el WebSocket del host y mientras el armado está vivo;
61
+ // el relay las reenvía al aparato emparejado y no las guarda, así que se reenvían cada 10 s mientras
62
+ // la pregunta sigue abierta (cubre que el aparato se reconecte) y al instante cuando se conecta.
63
+ export const CARD_RESEND_MS = 10000;
64
+ // After an arm, a kept card goes once the pulse landed and again this long after: past the relay's 2 s arm cache.
65
+ export const CARD_ARM_RECHECK_MS = 2500;
66
+ const CARD_CLOSES_KEPT = 32;
67
+ // What a phrase may be about (the contract's speech.schema.properties.about.enum).
68
+ const SPEECH_ABOUT = new Set(['permission','failure','turn']);
69
+
70
+ // Código de emparejamiento del M5Stack: Crockford en mayúsculas, XXXX-XXXX. O→0, I/L→1.
71
+ const DEVICE_CODE=/^[0-9A-HJKMNP-TV-Z]{8}$/;
72
+ export function normalizeDeviceCode(code){
73
+ const clean=String(code??'').toUpperCase().replace(/[\s-]+/g,'').replace(/O/g,'0').replace(/[IL]/g,'1');
74
+ if(!DEVICE_CODE.test(clean))throw Error('That is not a device code: expected 8 letters or digits (XXXX-XXXX).');
75
+ return clean.slice(0,4)+'-'+clean.slice(4);
76
+ }
52
77
 
53
78
  const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
54
79
  const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
55
80
 
81
+ let quickReplyList=null;
82
+ /** The three fixed replies come from the device contract, read once. */
83
+ 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
84
  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;
85
+ 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,speech=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
86
+ Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,onDeviceConnected,onPermissionsDisarmed,onPermission,getStats,speech,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;
87
+ // Granito's voice: frames sent over the host socket in the last SPEECH_WINDOW_MS, the last delta, the render in course.
88
+ this.sentFrames=[];this.lastDeltaAt=-Infinity;this.speechRender=null;
89
+ this.speechPacer=new SpeechPacer({send:frame=>this.speechAvailable()&&this.cardFrame(frame),room:()=>this.speechRoom(),now:this.now});
90
+ }
91
+ 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},speech:this.speechStatus()}:{})}:{enabled:false};}
92
+ deviceAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.device);}
93
+ permissionsArmed(){return this.deviceAvailable()&&this.permissionUntil>this.now()&&Boolean(this.deviceInfo)&&this.deviceInfo.id===this.permissionDevice;}
94
+ /** 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. */
95
+ dropArm(reason){
96
+ const was=this.permissionUntil>0;
97
+ this.permissionUntil=0;this.permissionDevice=null;
98
+ if(!was)return;
99
+ this.hideCards();
100
+ if(this.deviceAvailable())this.pulse().catch(()=>{});
101
+ if(this.onPermissionsDisarmed)try{this.onPermissionsDisarmed(reason);}catch{}
102
+ }
103
+ /** Recuerda el dispositivo de la última lectura de remote.state (page.session) o del emparejamiento; gana lo más reciente. */
104
+ noteSession(session){
105
+ if(!session||!('device' in session))return false;
106
+ const before=this.deviceInfo?.connected_at??null;
107
+ this.deviceInfo=session.device?{id:session.device.id,label:session.device.label,connected_at:session.device.connected_at??null}:null;
108
+ if(this.permissionUntil>0&&(this.deviceInfo?.id??null)!==this.permissionDevice)this.dropArm(this.deviceInfo?'device_changed':'device_gone');
109
+ const after=this.deviceInfo?.connected_at??null,changed=after!==null&&after!==before;
110
+ if(changed&&this.onDeviceConnected)try{this.onDeviceConnected(this.deviceInfo);}catch{}
111
+ if(changed){this.sendCards();this.publishStats({device:true}).catch(()=>{});}
112
+ return changed;
113
+ }
114
+ async pairDevice(code){
115
+ const normalized=normalizeDeviceCode(code);
116
+ if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
117
+ const run=this.lifecycle;
118
+ const result=await this.client.invoke('remote.devices.pair.approve',{remote_session_id:this.session.id,code:normalized},{expectedAccount:this.account,signal:lifetimeSignal(run)});
119
+ this.assertCurrent(run);
120
+ this.deviceInfo={id:result.device_id,label:result.label,connected_at:this.deviceInfo?.id===result.device_id?this.deviceInfo.connected_at:null};
121
+ if(this.permissionUntil>0&&result.device_id!==this.permissionDevice)this.dropArm('device_changed');
122
+ return {device_id:result.device_id,label:result.label};
123
+ }
124
+ async forgetDevice(){
125
+ if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
126
+ const run=this.lifecycle;
127
+ await this.client.invoke('remote.devices.revoke',{remote_session_id:this.session.id},{expectedAccount:this.account,signal:lifetimeSignal(run)});
128
+ this.permissionUntil=0;this.permissionDevice=null;this.hideCards();this.deviceInfo=null;this.pulse(run).catch(()=>{});
129
+ }
130
+ /** /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). */
131
+ armPermissions(ms=RELAY_ARM_DEFAULT_MS){
132
+ if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
133
+ if(!this.deviceInfo)throw Error('Pair a device first with /rc device CODE.');
134
+ const now=this.now(),expires=Number(this.session?.expires_at)*1000;
135
+ if(Number.isFinite(expires)&&expires<=now)throw Error('The /rc session has expired; open /rc again.');
136
+ let until=now+Math.min(Math.max(60e3,Math.floor(Number(ms)||RELAY_ARM_DEFAULT_MS)),RELAY_ARM_MAX_MS);
137
+ if(Number.isFinite(expires))until=Math.min(until,expires);
138
+ this.permissionUntil=until;this.permissionDevice=this.deviceInfo.id;
139
+ // A kept card goes only once the backend holds the arm (the relay refuses a card it cannot confirm armed), and
140
+ // again CARD_ARM_RECHECK_MS later, when an arm the relay read before this pulse is no longer fresh enough to use.
141
+ const run=this.lifecycle;
142
+ this.pulse(run).catch(()=>{}).then(()=>{if(!this.current(run)||!this.sendCards())return;this.cardsSentAt=this.now()-CARD_RESEND_MS+CARD_ARM_RECHECK_MS;});
143
+ return until;
144
+ }
145
+ /** One frame over the host WebSocket, only when it is the live transport. Never over remote.events.publish. */
146
+ cardFrame(frame){
147
+ if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
148
+ try{this.socket.send(JSON.stringify({protocol:'comal.remote.v1',...frame}));}catch{return false;}
149
+ this.noteFrame(frame.kind);return true;
150
+ }
151
+ /** Counts a frame sent over the host socket, for the relay's window; a delta also restarts the quiet speech waits for. */
152
+ noteFrame(kind){
153
+ const now=this.now();
154
+ this.sentFrames=this.sentFrames.filter(at=>now-at<SPEECH_WINDOW_MS);this.sentFrames.push(now);
155
+ if(kind==='delta')this.lastDeltaAt=now;
156
+ }
157
+ /** May a speech chunk go now without pushing the host past the relay's budget? */
158
+ speechRoom(){
159
+ if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
160
+ const now=this.now();
161
+ if(now-this.lastDeltaAt<SPEECH_QUIET_AFTER_DELTA_MS)return false;
162
+ this.sentFrames=this.sentFrames.filter(at=>now-at<SPEECH_WINDOW_MS);
163
+ if(this.sentFrames.length>=SPEECH_WINDOW_CEILING)return false;
164
+ return (this.socket.bufferedAmount??0)<SPEECH_MAX_BUFFERED;
59
165
  }
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};}
166
+ /** Speech goes only where it cannot hurt: speech_v1 granted, a relay that said it accepts speech (an older one closes
167
+ * the host socket on an unknown frame), the WebSocket transport, a paired device and a Piper voice. */
168
+ speechAvailable(){const run=this.lifecycle;return Boolean(this.current(run)&&run.speech&&run.speechRelay&&this.transport==='websocket'&&this.deviceInfo&&this.speech?.ready===true);}
169
+ speechStatus(){const run=this.lifecycle;if(this.speech?.ready!==true)return 'no_piper';if(!(run?.speech&&run?.speechRelay))return 'not_offered';return 'ready';}
170
+ /** Speaks a phrase on the paired device. Synchronous and fire-and-forget: rendering and sending never hold a turn or
171
+ * the permission flow, and every failure is swallowed. A newer phrase replaces the one rendering or playing. */
172
+ sendSpeech(text,about){
173
+ try{
174
+ if(typeof text!=='string'||!text.trim()||!SPEECH_ABOUT.has(about)||!this.speechAvailable())return false;
175
+ this.speechRender?.abort();
176
+ const controller=new AbortController(),run=this.lifecycle;this.speechRender=controller;
177
+ Promise.resolve(this.speech.render(text,{signal:controller.signal})).then(pcm8=>{
178
+ if(this.speechRender!==controller||controller.signal.aborted||!this.current(run))return;
179
+ this.speechRender=null;
180
+ if(!Buffer.isBuffer(pcm8)||!pcm8.length||!this.speechAvailable())return;
181
+ this.speechPacer.play({frames:speechFrames({id:randomBytes(8).toString('hex'),about,pcm8:pcm8.subarray(0,SPEECH_MAX_BYTES)})});
182
+ }).catch(()=>{});
183
+ return true;
184
+ }catch{return false;}
185
+ }
186
+ /** Permission card for the paired device (Task 10's buildDeviceCard). Nothing is kept or sent while not armed. */
187
+ sendCard(card){
188
+ if(!card||typeof card.question_id!=='string'||!this.permissionsArmed())return false;
189
+ this.cards.set(card.question_id,card);return this.sendCards();
190
+ }
191
+ sendCards(){
192
+ if(!this.cards.size||!this.permissionsArmed())return false;
193
+ let sent=false;for(const card of this.cards.values())sent=this.cardFrame({kind:'card',card})||sent;
194
+ if(sent){this.cardsSentAt=this.now();this.cardsShown=true;}
195
+ return sent;
196
+ }
197
+ /** The arm ended (off, lapsed, the device gone): the device stops showing cards it can no longer answer.
198
+ * They are kept, so a new arm while the question is still open shows them again. */
199
+ hideCards(){
200
+ if(!this.cards.size||!this.cardsShown)return;
201
+ let sent=true;for(const id of this.cards.keys())sent=this.cardFrame({kind:'card.close',question_id:id})&&sent;
202
+ if(sent)this.cardsShown=false;
203
+ }
204
+ /** The question was decided or dropped: the device stops showing it (on the next connection if the socket is down). */
205
+ closeCard(questionId){
206
+ const had=this.cards.delete(questionId);
207
+ if(this.cardFrame({kind:'card.close',question_id:questionId})||!had)return;
208
+ this.cardCloses.push(questionId);if(this.cardCloses.length>CARD_CLOSES_KEPT)this.cardCloses.shift();
209
+ }
210
+ flushCardCloses(){while(this.cardCloses.length&&this.cardFrame({kind:'card.close',question_id:this.cardCloses[0]}))this.cardCloses.shift();}
211
+ disarmPermissions(){this.permissionUntil=0;this.permissionDevice=null;this.hideCards();if(this.deviceAvailable())this.pulse().catch(()=>{});}
61
212
  /** ¿Puede esta sesión de /rc responder tarjetas de /relay? (backend con relay_answers_v1). */
62
213
  answersAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.answers);}
63
214
  armed(){return this.answersAvailable()&&this.answersUntil>this.now();}
@@ -87,12 +238,14 @@ export class RemoteHost {
87
238
  if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
88
239
  run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
89
240
  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']:[])];
241
+ 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','speech_v1'];
91
242
  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
243
  // Un backend anterior rechaza la capacidad desconocida en el esquema de Milpa (no se crea
93
244
  // sesión): se reintenta sin relay_answers_v1 (backend de la fase «ver») y después sin
94
245
  // 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);
246
+ // speech_v1 goes first (a backend before Granito's voice), then the device features with it.
247
+ const noDevice=features.filter(item=>item!=='device_v1'&&item!=='stats_v1'&&item!=='speech_v1');
248
+ const attempts=[features,features.filter(item=>item!=='speech_v1'),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
249
  let opened,used=attempts[0];
97
250
  for(const [index,list] of attempts.entries()){
98
251
  try{used=list;opened=await open(list);break;}
@@ -101,7 +254,7 @@ export class RemoteHost {
101
254
  run.relayRefused=Boolean(this.relay)&&!used.includes('relay_v1');
102
255
  run.session=opened.session;
103
256
  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;
257
+ 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.speech=run.device&&granted('speech_v1');run.speechRelay=false;run.stats=Boolean(this.getStats)&&granted('stats_v1');this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;
105
258
  // Abort can race a successful response. Revoke a late-created grant even
106
259
  // when the local stop completed before its identifier became available.
107
260
  if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
@@ -109,6 +262,7 @@ export class RemoteHost {
109
262
  await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
110
263
  this.transport='https-polling';this.connectSocket(run);
111
264
  if(workspace)this.publishWorkspace(workspace,run);
265
+ this.publishStats({},run).catch(()=>{});
112
266
  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
267
  this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
114
268
  for(const message of (session.historyMessages || []).slice(-20)){
@@ -151,9 +305,9 @@ export class RemoteHost {
151
305
  const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
152
306
  const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
153
307
  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();}});
308
+ 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);run.speechRelay=Array.isArray(m.accepts)&&m.accepts.includes('speech');this.transport='websocket';this.flushCardCloses();this.sendCards();}else if(m.kind==='state'&&run.device)this.noteSession(m.session);}catch{ws.close();}});
155
309
  ws.addEventListener('error',()=>{});
156
- ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
310
+ ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';run.speechRelay=false;this.speechPacer.cancel();}});
157
311
  }catch{/* HTTPS is already active. A later tick retries WSS. */}
158
312
  finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
159
313
  }
@@ -165,6 +319,38 @@ export class RemoteHost {
165
319
  this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
166
320
  run.workspaceDigest=key;this.flush(run).catch(()=>{});
167
321
  }
322
+ /** session.stats (stats_v1). A change: at most once per 5 s. `force` (turn end): skips the 5 s but
323
+ * not the unchanged check. `device` (a device connected): publishes even unchanged, since it has
324
+ * not seen them. Never two publishes within 1 s: a request inside the gap waits for one timer,
325
+ * which carries the strongest reason asked for. Never oversize; a getStats that fails or gives null publishes nothing. */
326
+ publishStats({force=false,device=false}={},run=this.lifecycle){
327
+ if(!this.current(run)||!run.stats||!this.getStats)return Promise.resolve(false);
328
+ const step=(run.statsChain||Promise.resolve()).then(()=>this.publishStatsNow({force:force||device,device},run)).catch(()=>false);
329
+ run.statsChain=step;return step;
330
+ }
331
+ async publishStatsNow({force,device},run){
332
+ if(!this.current(run))return false;
333
+ const now=this.now(),wait=run.statsAt?run.statsAt+(force?STATS_FLOOR_MS:STATS_INTERVAL_MS)-now:0;
334
+ if(wait>0){
335
+ const asked=run.statsAsked||{force:false,device:false};run.statsAsked={force:asked.force||force,device:asked.device||device};
336
+ if(!run.statsTimer||now+wait<run.statsTimerAt){
337
+ clearTimeout(run.statsTimer);run.statsTimerAt=now+wait;
338
+ run.statsTimer=setTimeout(()=>{const again=run.statsAsked||{};run.statsAsked=null;run.statsTimer=null;this.publishStats(again,run).catch(()=>{});},wait);run.statsTimer.unref?.();
339
+ }
340
+ return false;
341
+ }
342
+ const stats=fitStats(await this.getStats());
343
+ if(!this.current(run)||!stats)return false;
344
+ const key=JSON.stringify(stats);
345
+ if(Buffer.byteLength(key)>STATS_MAX_BYTES||(!device&&key===run.statsKey))return false;
346
+ const entry={event_id:randomUUID(),event:{type:'session.stats',text:'',turn_id:this.turnId||'local',stats}};
347
+ // The last summary wins in the buffer: an unsent one is replaced, never stacked.
348
+ const index=this.pending.findIndex((item,position)=>item.event.type==='session.stats'&&(position>0||!this.sending));
349
+ if(index>=0)this.pending[index]=entry;
350
+ else{if(this.pending.length>=512)return false;this.pending.push(entry);}
351
+ run.statsKey=key;run.statsAt=this.now();clearTimeout(run.statsTimer);run.statsTimer=null;run.statsAsked=null;
352
+ this.flush(run).catch(()=>{});return true;
353
+ }
168
354
  /** Tablero de /swarm: coalescido (≥ 3 s), sin repetir huella y «el último gana» en el búfer. */
169
355
  relayBoard(board,{now=false}={},run=this.lifecycle) {
170
356
  if(!this.current(run)||!run.relay)return;
@@ -215,8 +401,11 @@ export class RemoteHost {
215
401
  }
216
402
  observe(event) {
217
403
  if(this.stopped)return;
404
+ if(event.type==='tool_result'&&event.is_error===true&&!String(event.content??'').includes('HOUSE_REFUSED'))try{this.sendSpeech(failurePhrase(event.name),'failure');}catch{}
405
+ if(event.type==='result'&&event.turns>0&&typeof event.result==='string')try{const phrase=turnPhrase(event.result);if(phrase)this.sendSpeech(phrase,'turn');}catch{}
218
406
  if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
219
407
  const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
408
+ if(['result','context_projection','usage_accounting'].includes(event.type))this.publishStats().catch(()=>{});
220
409
  if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
221
410
  this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
222
411
  }
@@ -229,7 +418,7 @@ export class RemoteHost {
229
418
  if(event.type==='text_delta'){
230
419
  if(this.transport==='websocket'&&this.socket?.readyState===1&&this.turnId){
231
420
  this.delta=boundText((this.delta || '')+event.text,32000);
232
- if(!this.deltaTimer){const run=this.lifecycle;this.deltaTimer=setTimeout(()=>{if(!this.current(run))return;this.deltaTimer=null;const text=this.delta;this.delta='';if(this.socket?.readyState===1)for(let offset=0;offset<text.length;offset+=1800)this.socket.send(JSON.stringify({protocol:'comal.remote.v1',kind:'delta',turn_id:this.turnId,text:text.slice(offset,offset+1800)}));},120);}
421
+ if(!this.deltaTimer){const run=this.lifecycle;this.deltaTimer=setTimeout(()=>{if(!this.current(run))return;this.deltaTimer=null;const text=this.delta;this.delta='';if(this.socket?.readyState===1)for(let offset=0;offset<text.length;offset+=1800){this.socket.send(JSON.stringify({protocol:'comal.remote.v1',kind:'delta',turn_id:this.turnId,text:text.slice(offset,offset+1800)}));this.noteFrame('delta');}},120);}
233
422
  }
234
423
  return;
235
424
  }
@@ -247,16 +436,21 @@ export class RemoteHost {
247
436
  const work=(async()=>{while(this.current(run)&&this.pending.length){
248
437
  const item=this.pending[0];
249
438
  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.');}
439
+ catch(error){
440
+ if(!this.current(run))throw error;
441
+ if(item.event?.type==='session.stats'&&STATS_REFUSALS.has(error.code)){/* dropped: the next summary replaces it */}
442
+ 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.');
443
+ else throw error;
444
+ }
251
445
  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
446
  }})();
253
447
  this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
254
448
  }
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;}
449
+ 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
450
  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
451
  async endTurn(){
258
452
  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(()=>{});}
453
+ 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
454
  clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
261
455
  }
262
456
  async validate(item){
@@ -266,6 +460,7 @@ export class RemoteHost {
266
460
  await this.call('remote.inputs.validate',{request_id:item.remote.request},run);this.assertCurrent(run);return true;
267
461
  }
268
462
  async tick(run=this.lifecycle){
463
+ if(this.current(run)&&this.cards.size){if(!this.permissionsArmed())this.hideCards();else if(this.now()-this.cardsSentAt>=CARD_RESEND_MS)this.sendCards();}
269
464
  if(!this.current(run)||this.processing)return this.processing;
270
465
  const work=(async()=>{
271
466
  await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
@@ -274,11 +469,17 @@ export class RemoteHost {
274
469
  this.assertCurrent(run);let state='accepted_locally',queue_item_id='',outcome=null;
275
470
  // Only admission errors are rejection. A lost ACK must redeliver the same input.
276
471
  if(input.kind==='relay'){({state,outcome}=await this.relayAnswer(input,run));}
472
+ else if(input.kind==='permission.answer'){({state,outcome}=await this.permissionAnswer(input,run));}
277
473
  else try {
278
474
  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;}
475
+ 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
476
  else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
280
477
  }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);
478
+ this.assertCurrent(run);
479
+ try{await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id,...(outcome?{outcome}:{})},run);}
480
+ // The backend already settled this device input (expired or its device gone after the claim):
481
+ // it is not retried nor applied again; the local book row stays the record of what was decided here.
482
+ catch(error){if(input.kind!=='permission.answer'||error?.code!=='REMOTE_CONFLICT')throw error;}
282
483
  }
283
484
  if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
284
485
  })().catch(error=>{
@@ -298,10 +499,19 @@ export class RemoteHost {
298
499
  return {state,outcome:/^[a-z_]{1,64}$/.test(String(result?.outcome||''))?result.outcome:'host_error'};
299
500
  }catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'host_error'};}
300
501
  }
502
+ /** Answer to a permission card from the paired M5Stack (kind=permission.answer). Never enters the model's
503
+ * queue: the PermissionDesk checks it again (arm, expiry, device, pending question, digest) and decides. */
504
+ async permissionAnswer(input,run=this.lifecycle){
505
+ if(!run?.device||!this.onPermission)return {state:'refused',outcome:'disarmed'};
506
+ try{
507
+ const result=await this.onPermission(input,{deviceId:this.permissionsArmed()?this.permissionDevice:null,armed:()=>this.permissionsArmed()});
508
+ return {state:PERMISSION_ACK_STATES.has(result?.state)?result.state:'refused',outcome:PERMISSION_OUTCOMES.has(result?.outcome)?result.outcome:'stale'};
509
+ }catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'stale'};}
510
+ }
301
511
  async stop(){
302
512
  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';
513
+ this.lifecycle=null;this.starting=null;run.controller?.abort();this.speechRender?.abort();this.speechRender=null;this.speechPacer.cancel();this.sentFrames=[];clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);clearTimeout(run.statsTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
514
+ 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
515
  this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
306
516
  await this.revoke(run);return this.status();
307
517
  }
@@ -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,97 @@
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
+ 'voice: ready': 'voz: lista',
14
+ 'voice: no Piper (COMAL_PIPER_VOICE)': 'voz: sin Piper (COMAL_PIPER_VOICE)',
15
+ 'voice: not available on this connection': 'voz: no disponible en esta conexión',
16
+ });
17
+
18
+ // Forward the House's permission rows to the Remote Control projection.
19
+ // Only the current session's waits and answers cross; subagent sessions stay local.
20
+ const FORWARDED = new Set(['house_waiting', 'house_answered']);
21
+
22
+ export function forwardHouseRow(row, { sessionId, remoteHost } = {}) {
23
+ if (!row || !FORWARDED.has(row.type) || row.session_id !== sessionId) return;
24
+ remoteHost?.observe(row);
25
+ }
26
+
27
+ /** /rc device [CODE|forget|scan [/dev/videoN]] → {kind:'status'|'forget'|'pair'|'scan', code?, camera?} or null when
28
+ * it is not that command. A scan camera that is not /dev/videoN throws. */
29
+ export function parseRcDevice(action) {
30
+ const match = /^device(?:\s+(\S.*))?$/i.exec(String(action ?? '').trim());
31
+ if (!match) return null;
32
+ const arg = (match[1] || '').trim();
33
+ if (!arg) return { kind: 'status' };
34
+ if (arg.toLowerCase() === 'forget') return { kind: 'forget' };
35
+ const scan = /^scan(?:\s+(.+))?$/i.exec(arg);
36
+ if (scan) {
37
+ if (!scan[1]) return { kind: 'scan' };
38
+ const camera = scan[1].trim();
39
+ if (!isCameraPath(camera)) throw Error(translate('The camera must be a /dev/videoN device.'));
40
+ return { kind: 'scan', camera };
41
+ }
42
+ return { kind: 'pair', code: arg };
43
+ }
44
+
45
+ /** Runs a parsed /rc device command against the host; `t` translates. */
46
+ export async function runRcDevice(parsed, host, t, { record } = {}) {
47
+ if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
48
+ if (parsed.kind === 'status') {
49
+ const device = host.status().device;
50
+ const line = 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.');
51
+ const voice = { ready: t('voice: ready'), no_piper: t('voice: no Piper (COMAL_PIPER_VOICE)') }[host.speechStatus()] ?? t('voice: not available on this connection');
52
+ return line + '\n' + voice;
53
+ }
54
+ 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.'); }
55
+ const paired = await host.pairDevice(parsed.code);
56
+ return t('Device paired: {label} ({id}).', { label: paired.label, id: paired.device_id });
57
+ }
58
+
59
+ /** /rc device scan: the camera reads the device's pairing QR (only now, at most 30 s), the person confirms the code,
60
+ * and it pairs through runRcDevice's own path. `scan({camera})` → {code, camera}; `confirm({code, camera})` → boolean,
61
+ * absent without an interactive terminal (then it is refused before the camera turns on). */
62
+ export async function runRcDeviceScan(parsed, host, t, { scan, confirm, record } = {}) {
63
+ if (typeof confirm !== 'function') throw Error(t('Scanning the pairing QR needs an interactive terminal; type the code instead: /rc device CODE.'));
64
+ if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
65
+ const found = await scan({ camera: parsed.camera });
66
+ if (!await confirm({ code: found.code, camera: found.camera })) return t('Not paired; nothing was sent.');
67
+ return runRcDevice({ kind: 'pair', code: found.code }, host, t, { record });
68
+ }
69
+
70
+ /** The yes/no card for a scanned code: "Do not pair" comes first and is highlighted, so Enter never pairs. */
71
+ export function deviceCodeChoice(code, t) {
72
+ 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' };
73
+ }
74
+
75
+ const ARM_ROW = (armed, until) => ({ type: 'remote_control', action: 'permissions.arm', armed, until: armed ? new Date(until).toISOString() : null });
76
+ const clock = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
77
+
78
+ /** /rc permissions [on [N m|min|h]|off] → {kind:'status'|'off'|'on', ms} or null when it is not that command. */
79
+ export function parseRcPermissions(action) {
80
+ const match = /^permissions(?:\s+(?:(off)|(on)(?:\s+(\d+(?:[.,]\d+)?)\s*(m|min|h)?)?))?$/i.exec(String(action ?? '').trim());
81
+ if (!match) return null;
82
+ if (match[1]) return { kind: 'off' };
83
+ if (!match[2]) return { kind: 'status' };
84
+ if (!match[3]) return { kind: 'on', ms: null };
85
+ return { kind: 'on', ms: Math.round(Number(match[3].replace(',', '.')) * (String(match[4] || 'm').toLowerCase() === 'h' ? 3600e3 : 60e3)) };
86
+ }
87
+
88
+ /** Runs a parsed /rc permissions command; `record(row)` appends the book row. */
89
+ export async function runRcPermissions(parsed, host, t, { record } = {}) {
90
+ if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
91
+ const status = () => host.permissionsArmed() ? t('Permission answers from the M5Stack: armed until {time}.', { time: clock(host.permissionUntil) }) : t('Permission answers from the M5Stack: off.');
92
+ if (parsed.kind === 'status') return status();
93
+ if (parsed.kind === 'off') { host.disarmPermissions(); await record?.(ARM_ROW(false, 0)); return status(); }
94
+ const until = host.armPermissions(parsed.ms ?? undefined);
95
+ await record?.(ARM_ROW(true, until));
96
+ 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) });
97
+ }