@milpa/comal 0.2.2 → 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-commands-v1.json +0 -12
- package/backend/contracts/remote-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +261 -62
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +283 -15
- 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-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
package/src/remote-control.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { CloudClient } from './cloud-client.mjs';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { PERMISSION_ACK_STATES, PERMISSION_OUTCOMES } from './remote-permissions.mjs';
|
|
5
|
+
import { fitStats, STATS_FLOOR_MS, STATS_INTERVAL_MS, STATS_MAX_BYTES } from './remote-stats.mjs';
|
|
3
6
|
|
|
4
7
|
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
5
8
|
export const remoteInputDigest = (session, request, text) => digest([session,request,text]);
|
|
@@ -17,19 +20,163 @@ export function projectRemoteEvent(event, turnId) {
|
|
|
17
20
|
}
|
|
18
21
|
if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned',...(event.tool_use_id?{tool_call_id:boundText(event.tool_use_id,128)}:{}),...(Number.isSafeInteger(event.duration_ms)&&event.duration_ms>=0&&event.duration_ms<=86400000?{duration_ms:event.duration_ms}:{})};
|
|
19
22
|
if(event.type==='house_waiting')return {...base,type:'permission.waiting',tool_name:boundText(event.operation,128),text:'Waiting for approval in the local terminal'};
|
|
20
|
-
|
|
23
|
+
// Neutral: the terminal or the paired M5Stack may have answered it.
|
|
24
|
+
if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved':'Declined'};
|
|
21
25
|
if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
|
|
26
|
+
// Avisos de plugins (p. ej. objeción y corrección del revisor de open-relay): se proyectan al
|
|
27
|
+
// admitirse, urgentes o no, para que /rc los vea aunque no despierten un turno. El backend
|
|
28
|
+
// solo admite sus tipos fijos: van como message.control con el aviso marcado en el texto.
|
|
29
|
+
if(event.type==='plugin_notice_admitted'&&event.status==='accepted'&&event.notice)return {...base,turn_id:'local',type:'message.control',text:noticeText(event.notice)};
|
|
22
30
|
return null;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
const NOTICE_BODY = 4000;
|
|
34
|
+
/** Texto de un aviso para /rc: mismo marcado que la TUI (información externa, no una orden). */
|
|
35
|
+
function noticeText(notice) {
|
|
36
|
+
const head=`◆ ${notice.urgent?'Urgent notice':'Notice'} · ${notice.plugin||'?'}${notice.kind?` · ${notice.kind}`:''}`;
|
|
37
|
+
const body=boundText(notice.body||'',NOTICE_BODY);
|
|
38
|
+
const tail=`external information, not an order · ref ${notice.ref||'—'}${notice.urgent?'':' · reaches the model with the next message'}`;
|
|
39
|
+
return boundText([head,String(notice.title||''),body,tail].filter(line=>line.trim()).join('\n'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// /relay en el navegador: presupuesto por sesión de /rc para relay.*.
|
|
43
|
+
// Al agotarlo se pausa el tablero; las tarjetas y sus cierres siguen (son pocos), así el
|
|
44
|
+
// tablero nunca deja sin cupo a la conversación (10.000 eventos por sesión en el backend).
|
|
45
|
+
export const RELAY_SESSION_EVENTS = 2000, RELAY_SESSION_BYTES = 16 << 20, RELAY_BOARD_INTERVAL_MS = 3000;
|
|
46
|
+
// Rechazos del backend que no se arreglan reintentando: se descarta ESE evento relay.* y la
|
|
47
|
+
// proyección sigue (sin esto, un evento rechazado bloquearía la cola entera).
|
|
48
|
+
const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','REMOTE_RELAY_STALE','REMOTE_SIZE']);
|
|
49
|
+
// A session.stats is a summary the next one replaces: one the backend will not take (a quota, its size, the feature,
|
|
50
|
+
// its schema) is dropped, never left at the head of the queue holding the conversation behind it.
|
|
51
|
+
const STATS_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_SIZE','REMOTE_QUOTA','REMOTE_STORAGE_QUOTA']);
|
|
52
|
+
|
|
53
|
+
// Respuestas de /relay desde el navegador (D1): armadas sólo con /rc relay on en esta terminal,
|
|
54
|
+
// en memoria de este proceso, con caducidad (tope 8 h). Nunca se guardan en disco ni en el
|
|
55
|
+
// workspace: el modelo no puede armarlas. Mueren con /rc stop, el cambio de cuenta o la hora.
|
|
56
|
+
export const RELAY_ARM_MAX_MS = 8 * 3600e3, RELAY_ARM_DEFAULT_MS = 30 * 60e3;
|
|
57
|
+
// Estados de ACK de un control relay (el backend sólo los admite para kind=relay).
|
|
58
|
+
const RELAY_ACK_STATES = new Set(['applied','stale','refused','expired']);
|
|
59
|
+
// Tarjetas de permiso para el M5Stack: sólo por el WebSocket del host y mientras el armado está vivo;
|
|
60
|
+
// el relay las reenvía al aparato emparejado y no las guarda, así que se reenvían cada 10 s mientras
|
|
61
|
+
// la pregunta sigue abierta (cubre que el aparato se reconecte) y al instante cuando se conecta.
|
|
62
|
+
export const CARD_RESEND_MS = 10000;
|
|
63
|
+
// After an arm, a kept card goes once the pulse landed and again this long after: past the relay's 2 s arm cache.
|
|
64
|
+
export const CARD_ARM_RECHECK_MS = 2500;
|
|
65
|
+
const CARD_CLOSES_KEPT = 32;
|
|
66
|
+
|
|
67
|
+
// Código de emparejamiento del M5Stack: Crockford en mayúsculas, XXXX-XXXX. O→0, I/L→1.
|
|
68
|
+
const DEVICE_CODE=/^[0-9A-HJKMNP-TV-Z]{8}$/;
|
|
69
|
+
export function normalizeDeviceCode(code){
|
|
70
|
+
const clean=String(code??'').toUpperCase().replace(/[\s-]+/g,'').replace(/O/g,'0').replace(/[IL]/g,'1');
|
|
71
|
+
if(!DEVICE_CODE.test(clean))throw Error('That is not a device code: expected 8 letters or digits (XXXX-XXXX).');
|
|
72
|
+
return clean.slice(0,4)+'-'+clean.slice(4);
|
|
73
|
+
}
|
|
74
|
+
|
|
25
75
|
const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
|
|
26
76
|
const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
27
77
|
|
|
78
|
+
let quickReplyList=null;
|
|
79
|
+
/** The three fixed replies come from the device contract, read once. */
|
|
80
|
+
function quickReplies(){return quickReplyList??=JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json',import.meta.url),'utf8')).inputs.quick_reply.properties.text.enum;}
|
|
28
81
|
export class RemoteHost {
|
|
29
|
-
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
30
|
-
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
82
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,onDeviceConnected=null,onPermissionsDisarmed=null,onPermission=null,getStats=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
83
|
+
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,onDeviceConnected,onPermissionsDisarmed,onPermission,getStats,now,socketFactory});this.cards=new Map();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
84
|
+
}
|
|
85
|
+
status(){const run=this.lifecycle;return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only',relay:run?.relay?(run.answers?'answers':'read_only'):'off',...(run?.answers?{relay_answers:{armed:this.armed(),until:this.armed()?this.answersUntil:null}}:{}),...(run?.device?{device:this.deviceInfo??null,permission_answers:{armed:this.permissionsArmed(),until:this.permissionsArmed()?this.permissionUntil:null}}:{})}:{enabled:false};}
|
|
86
|
+
deviceAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.device);}
|
|
87
|
+
permissionsArmed(){return this.deviceAvailable()&&this.permissionUntil>this.now()&&Boolean(this.deviceInfo)&&this.deviceInfo.id===this.permissionDevice;}
|
|
88
|
+
/** The arm belongs to the device it was granted for: when that device is gone or another takes its place, it ends and the book hears why. */
|
|
89
|
+
dropArm(reason){
|
|
90
|
+
const was=this.permissionUntil>0;
|
|
91
|
+
this.permissionUntil=0;this.permissionDevice=null;
|
|
92
|
+
if(!was)return;
|
|
93
|
+
this.hideCards();
|
|
94
|
+
if(this.deviceAvailable())this.pulse().catch(()=>{});
|
|
95
|
+
if(this.onPermissionsDisarmed)try{this.onPermissionsDisarmed(reason);}catch{}
|
|
96
|
+
}
|
|
97
|
+
/** Recuerda el dispositivo de la última lectura de remote.state (page.session) o del emparejamiento; gana lo más reciente. */
|
|
98
|
+
noteSession(session){
|
|
99
|
+
if(!session||!('device' in session))return false;
|
|
100
|
+
const before=this.deviceInfo?.connected_at??null;
|
|
101
|
+
this.deviceInfo=session.device?{id:session.device.id,label:session.device.label,connected_at:session.device.connected_at??null}:null;
|
|
102
|
+
if(this.permissionUntil>0&&(this.deviceInfo?.id??null)!==this.permissionDevice)this.dropArm(this.deviceInfo?'device_changed':'device_gone');
|
|
103
|
+
const after=this.deviceInfo?.connected_at??null,changed=after!==null&&after!==before;
|
|
104
|
+
if(changed&&this.onDeviceConnected)try{this.onDeviceConnected(this.deviceInfo);}catch{}
|
|
105
|
+
if(changed){this.sendCards();this.publishStats({device:true}).catch(()=>{});}
|
|
106
|
+
return changed;
|
|
107
|
+
}
|
|
108
|
+
async pairDevice(code){
|
|
109
|
+
const normalized=normalizeDeviceCode(code);
|
|
110
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
111
|
+
const run=this.lifecycle;
|
|
112
|
+
const result=await this.client.invoke('remote.devices.pair.approve',{remote_session_id:this.session.id,code:normalized},{expectedAccount:this.account,signal:lifetimeSignal(run)});
|
|
113
|
+
this.assertCurrent(run);
|
|
114
|
+
this.deviceInfo={id:result.device_id,label:result.label,connected_at:this.deviceInfo?.id===result.device_id?this.deviceInfo.connected_at:null};
|
|
115
|
+
if(this.permissionUntil>0&&result.device_id!==this.permissionDevice)this.dropArm('device_changed');
|
|
116
|
+
return {device_id:result.device_id,label:result.label};
|
|
117
|
+
}
|
|
118
|
+
async forgetDevice(){
|
|
119
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
120
|
+
const run=this.lifecycle;
|
|
121
|
+
await this.client.invoke('remote.devices.revoke',{remote_session_id:this.session.id},{expectedAccount:this.account,signal:lifetimeSignal(run)});
|
|
122
|
+
this.permissionUntil=0;this.permissionDevice=null;this.hideCards();this.deviceInfo=null;this.pulse(run).catch(()=>{});
|
|
123
|
+
}
|
|
124
|
+
/** /rc permissions on [duración]: el aparato emparejado puede responder tarjetas hasta now+ms (60 s…8 h, y no más allá de la sesión). */
|
|
125
|
+
armPermissions(ms=RELAY_ARM_DEFAULT_MS){
|
|
126
|
+
if(!this.deviceAvailable())throw Error('Open /rc first, with a Comal backend that supports devices.');
|
|
127
|
+
if(!this.deviceInfo)throw Error('Pair a device first with /rc device CODE.');
|
|
128
|
+
const now=this.now(),expires=Number(this.session?.expires_at)*1000;
|
|
129
|
+
if(Number.isFinite(expires)&&expires<=now)throw Error('The /rc session has expired; open /rc again.');
|
|
130
|
+
let until=now+Math.min(Math.max(60e3,Math.floor(Number(ms)||RELAY_ARM_DEFAULT_MS)),RELAY_ARM_MAX_MS);
|
|
131
|
+
if(Number.isFinite(expires))until=Math.min(until,expires);
|
|
132
|
+
this.permissionUntil=until;this.permissionDevice=this.deviceInfo.id;
|
|
133
|
+
// A kept card goes only once the backend holds the arm (the relay refuses a card it cannot confirm armed), and
|
|
134
|
+
// again CARD_ARM_RECHECK_MS later, when an arm the relay read before this pulse is no longer fresh enough to use.
|
|
135
|
+
const run=this.lifecycle;
|
|
136
|
+
this.pulse(run).catch(()=>{}).then(()=>{if(!this.current(run)||!this.sendCards())return;this.cardsSentAt=this.now()-CARD_RESEND_MS+CARD_ARM_RECHECK_MS;});
|
|
137
|
+
return until;
|
|
138
|
+
}
|
|
139
|
+
/** One frame over the host WebSocket, only when it is the live transport. Never over remote.events.publish. */
|
|
140
|
+
cardFrame(frame){
|
|
141
|
+
if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
|
|
142
|
+
try{this.socket.send(JSON.stringify({protocol:'comal.remote.v1',...frame}));return true;}catch{return false;}
|
|
143
|
+
}
|
|
144
|
+
/** Permission card for the paired device (Task 10's buildDeviceCard). Nothing is kept or sent while not armed. */
|
|
145
|
+
sendCard(card){
|
|
146
|
+
if(!card||typeof card.question_id!=='string'||!this.permissionsArmed())return false;
|
|
147
|
+
this.cards.set(card.question_id,card);return this.sendCards();
|
|
31
148
|
}
|
|
32
|
-
|
|
149
|
+
sendCards(){
|
|
150
|
+
if(!this.cards.size||!this.permissionsArmed())return false;
|
|
151
|
+
let sent=false;for(const card of this.cards.values())sent=this.cardFrame({kind:'card',card})||sent;
|
|
152
|
+
if(sent){this.cardsSentAt=this.now();this.cardsShown=true;}
|
|
153
|
+
return sent;
|
|
154
|
+
}
|
|
155
|
+
/** The arm ended (off, lapsed, the device gone): the device stops showing cards it can no longer answer.
|
|
156
|
+
* They are kept, so a new arm while the question is still open shows them again. */
|
|
157
|
+
hideCards(){
|
|
158
|
+
if(!this.cards.size||!this.cardsShown)return;
|
|
159
|
+
let sent=true;for(const id of this.cards.keys())sent=this.cardFrame({kind:'card.close',question_id:id})&&sent;
|
|
160
|
+
if(sent)this.cardsShown=false;
|
|
161
|
+
}
|
|
162
|
+
/** The question was decided or dropped: the device stops showing it (on the next connection if the socket is down). */
|
|
163
|
+
closeCard(questionId){
|
|
164
|
+
const had=this.cards.delete(questionId);
|
|
165
|
+
if(this.cardFrame({kind:'card.close',question_id:questionId})||!had)return;
|
|
166
|
+
this.cardCloses.push(questionId);if(this.cardCloses.length>CARD_CLOSES_KEPT)this.cardCloses.shift();
|
|
167
|
+
}
|
|
168
|
+
flushCardCloses(){while(this.cardCloses.length&&this.cardFrame({kind:'card.close',question_id:this.cardCloses[0]}))this.cardCloses.shift();}
|
|
169
|
+
disarmPermissions(){this.permissionUntil=0;this.permissionDevice=null;this.hideCards();if(this.deviceAvailable())this.pulse().catch(()=>{});}
|
|
170
|
+
/** ¿Puede esta sesión de /rc responder tarjetas de /relay? (backend con relay_answers_v1). */
|
|
171
|
+
answersAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.answers);}
|
|
172
|
+
armed(){return this.answersAvailable()&&this.answersUntil>this.now();}
|
|
173
|
+
/** /rc relay on [duración]: arma las respuestas del navegador hasta now+ms (tope 8 h). */
|
|
174
|
+
arm(ms=RELAY_ARM_DEFAULT_MS){
|
|
175
|
+
if(!this.answersAvailable())throw Error(this.lifecycle?.relay?'This Comal backend does not accept /relay answers from the browser yet.':'Open /rc in a session linked to /relay first.');
|
|
176
|
+
const span=Math.min(Math.max(60e3,Math.floor(Number(ms)||RELAY_ARM_DEFAULT_MS)),RELAY_ARM_MAX_MS);
|
|
177
|
+
this.answersUntil=this.now()+span;this.pulse().catch(()=>{});return this.answersUntil;
|
|
178
|
+
}
|
|
179
|
+
disarm(){this.answersUntil=0;if(this.current(this.lifecycle))this.pulse().catch(()=>{});}
|
|
33
180
|
current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
|
|
34
181
|
assertCurrent(run,options){if(!this.current(run,options))throw cancelled();}
|
|
35
182
|
async revoke(run) {
|
|
@@ -49,8 +196,22 @@ export class RemoteHost {
|
|
|
49
196
|
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
50
197
|
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
51
198
|
const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
|
|
52
|
-
const
|
|
199
|
+
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[]),'device_v1','stats_v1'];
|
|
200
|
+
const open=list=>this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:list},{expectedAccount:run.account,signal:run.controller.signal});
|
|
201
|
+
// Un backend anterior rechaza la capacidad desconocida en el esquema de Milpa (no se crea
|
|
202
|
+
// sesión): se reintenta sin relay_answers_v1 (backend de la fase «ver») y después sin
|
|
203
|
+
// relay_v1 (backend anterior a /relay). /rc se abre igual con lo que el backend admite.
|
|
204
|
+
const noDevice=features.filter(item=>item!=='device_v1'&&item!=='stats_v1');
|
|
205
|
+
const attempts=[features,noDevice,noDevice.filter(item=>item!=='relay_answers_v1'),noDevice.filter(item=>!item.startsWith('relay_'))].filter((list,index,all)=>index===0||list.length<all[index-1].length);
|
|
206
|
+
let opened,used=attempts[0];
|
|
207
|
+
for(const [index,list] of attempts.entries()){
|
|
208
|
+
try{used=list;opened=await open(list);break;}
|
|
209
|
+
catch(error){if(error.code!=='INPUT_SCHEMA'||index===attempts.length-1)throw error;this.assertCurrent(run,{starting:true});}
|
|
210
|
+
}
|
|
211
|
+
run.relayRefused=Boolean(this.relay)&&!used.includes('relay_v1');
|
|
53
212
|
run.session=opened.session;
|
|
213
|
+
const granted=feature=>used.includes(feature)&&(!Array.isArray(opened.session?.features)||opened.session.features.includes(feature));
|
|
214
|
+
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;run.device=granted('device_v1');run.stats=Boolean(this.getStats)&&granted('stats_v1');this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;
|
|
54
215
|
// Abort can race a successful response. Revoke a late-created grant even
|
|
55
216
|
// when the local stop completed before its identifier became available.
|
|
56
217
|
if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
|
|
@@ -58,12 +219,14 @@ export class RemoteHost {
|
|
|
58
219
|
await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
|
|
59
220
|
this.transport='https-polling';this.connectSocket(run);
|
|
60
221
|
if(workspace)this.publishWorkspace(workspace,run);
|
|
222
|
+
this.publishStats({},run).catch(()=>{});
|
|
223
|
+
if(run.relay){run.relaySink={extended:run.answers,relayBoard:(board,options)=>this.relayBoard(board,options,run),relayEvent:(type,relay)=>this.relayEvent(type,relay,run)};this.relay.attach(run.relaySink);}
|
|
61
224
|
this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
|
|
62
225
|
for(const message of (session.historyMessages || []).slice(-20)){
|
|
63
226
|
const event=message.role==='user'&&(typeof message.content==='string'||Array.isArray(message.content)&&message.content.some(block=>block.type==='text'))?{type:'user',text:typeof message.content==='string'?message.content:message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n')}:message.role==='assistant'?{type:'assistant',message}:null;
|
|
64
227
|
if(event)this.observe(event);
|
|
65
228
|
}
|
|
66
|
-
this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.');
|
|
229
|
+
this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.'+(run.answers?' /relay is visible there; to answer its cards from the browser run /rc relay on here (off by default).':run.relay?' /relay is visible there read-only; answer its cards here.':run.relayRefused?' This backend does not show /relay yet.':''));
|
|
67
230
|
return this.status();
|
|
68
231
|
} catch(error) {
|
|
69
232
|
if(this.lifecycle===run)await this.stop();else await this.revoke(run);
|
|
@@ -99,7 +262,7 @@ export class RemoteHost {
|
|
|
99
262
|
const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
|
|
100
263
|
const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
|
|
101
264
|
ws.addEventListener('open',()=>{if(!this.current(run)||this.socket!==ws){ws.close();return;}ws.send(JSON.stringify({protocol:'comal.remote.v1',kind:'auth',ticket:ticket.ticket,after:'0'}));});
|
|
102
|
-
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';}}catch{ws.close();}});
|
|
265
|
+
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';this.flushCardCloses();this.sendCards();}else if(m.kind==='state'&&run.device)this.noteSession(m.session);}catch{ws.close();}});
|
|
103
266
|
ws.addEventListener('error',()=>{});
|
|
104
267
|
ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
|
|
105
268
|
}catch{/* HTTPS is already active. A later tick retries WSS. */}
|
|
@@ -113,6 +276,73 @@ export class RemoteHost {
|
|
|
113
276
|
this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
|
|
114
277
|
run.workspaceDigest=key;this.flush(run).catch(()=>{});
|
|
115
278
|
}
|
|
279
|
+
/** session.stats (stats_v1). A change: at most once per 5 s. `force` (turn end): skips the 5 s but
|
|
280
|
+
* not the unchanged check. `device` (a device connected): publishes even unchanged, since it has
|
|
281
|
+
* not seen them. Never two publishes within 1 s: a request inside the gap waits for one timer,
|
|
282
|
+
* which carries the strongest reason asked for. Never oversize; a getStats that fails or gives null publishes nothing. */
|
|
283
|
+
publishStats({force=false,device=false}={},run=this.lifecycle){
|
|
284
|
+
if(!this.current(run)||!run.stats||!this.getStats)return Promise.resolve(false);
|
|
285
|
+
const step=(run.statsChain||Promise.resolve()).then(()=>this.publishStatsNow({force:force||device,device},run)).catch(()=>false);
|
|
286
|
+
run.statsChain=step;return step;
|
|
287
|
+
}
|
|
288
|
+
async publishStatsNow({force,device},run){
|
|
289
|
+
if(!this.current(run))return false;
|
|
290
|
+
const now=this.now(),wait=run.statsAt?run.statsAt+(force?STATS_FLOOR_MS:STATS_INTERVAL_MS)-now:0;
|
|
291
|
+
if(wait>0){
|
|
292
|
+
const asked=run.statsAsked||{force:false,device:false};run.statsAsked={force:asked.force||force,device:asked.device||device};
|
|
293
|
+
if(!run.statsTimer||now+wait<run.statsTimerAt){
|
|
294
|
+
clearTimeout(run.statsTimer);run.statsTimerAt=now+wait;
|
|
295
|
+
run.statsTimer=setTimeout(()=>{const again=run.statsAsked||{};run.statsAsked=null;run.statsTimer=null;this.publishStats(again,run).catch(()=>{});},wait);run.statsTimer.unref?.();
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const stats=fitStats(await this.getStats());
|
|
300
|
+
if(!this.current(run)||!stats)return false;
|
|
301
|
+
const key=JSON.stringify(stats);
|
|
302
|
+
if(Buffer.byteLength(key)>STATS_MAX_BYTES||(!device&&key===run.statsKey))return false;
|
|
303
|
+
const entry={event_id:randomUUID(),event:{type:'session.stats',text:'',turn_id:this.turnId||'local',stats}};
|
|
304
|
+
// The last summary wins in the buffer: an unsent one is replaced, never stacked.
|
|
305
|
+
const index=this.pending.findIndex((item,position)=>item.event.type==='session.stats'&&(position>0||!this.sending));
|
|
306
|
+
if(index>=0)this.pending[index]=entry;
|
|
307
|
+
else{if(this.pending.length>=512)return false;this.pending.push(entry);}
|
|
308
|
+
run.statsKey=key;run.statsAt=this.now();clearTimeout(run.statsTimer);run.statsTimer=null;run.statsAsked=null;
|
|
309
|
+
this.flush(run).catch(()=>{});return true;
|
|
310
|
+
}
|
|
311
|
+
/** Tablero de /swarm: coalescido (≥ 3 s), sin repetir huella y «el último gana» en el búfer. */
|
|
312
|
+
relayBoard(board,{now=false}={},run=this.lifecycle) {
|
|
313
|
+
if(!this.current(run)||!run.relay)return;
|
|
314
|
+
const {source,...content}=board,key=digest(content);
|
|
315
|
+
run.boardNext=key===run.boardDigest?null:board;run.boardNextKey=key;
|
|
316
|
+
if(!run.boardNext)return;
|
|
317
|
+
const wait=now?0:Math.max(0,(run.boardAt||0)+RELAY_BOARD_INTERVAL_MS-Date.now());
|
|
318
|
+
if(!wait){clearTimeout(run.boardTimer);run.boardTimer=null;this.pushRelayBoard(run);return;}
|
|
319
|
+
if(!run.boardTimer){run.boardTimer=setTimeout(()=>{run.boardTimer=null;this.pushRelayBoard(run);},wait);run.boardTimer.unref?.();}
|
|
320
|
+
}
|
|
321
|
+
pushRelayBoard(run=this.lifecycle) {
|
|
322
|
+
if(!this.current(run)||!run.boardNext||run.boardPaused)return;
|
|
323
|
+
let board=run.boardNext;const key=run.boardNextKey;run.boardNext=null;
|
|
324
|
+
if((run.relayEvents||0)>=RELAY_SESSION_EVENTS||(run.relayBytes||0)>=RELAY_SESSION_BYTES){
|
|
325
|
+
run.boardPaused=true;board={...board,swarms:[],paused:true,omitted:{swarms:board.swarms_total||0,tasks:0}};
|
|
326
|
+
this.onNotice('Remote /relay board paused: this /rc session reached its relay budget. Cards still reach the browser; /swarm here keeps the live board.');
|
|
327
|
+
}
|
|
328
|
+
// El backend no admite un tablero que retroceda en el tiempo de su foto.
|
|
329
|
+
const seen=Math.max(Number(board.source?.view_generated_at)||1,run.boardSeen||1);board={...board,source:{...board.source,view_generated_at:seen}};run.boardSeen=seen;
|
|
330
|
+
const entry={event_id:randomUUID(),event:{type:'relay.board',text:'',turn_id:'local',relay:board}};
|
|
331
|
+
if(Buffer.byteLength(JSON.stringify(board))>36000)return;
|
|
332
|
+
const index=this.pending.findIndex((item,position)=>item.event.type==='relay.board'&&(position>0||!this.sending));
|
|
333
|
+
if(index>=0)this.pending[index]=entry;
|
|
334
|
+
else{if(this.pending.length>=512)return;this.pending.push(entry);}
|
|
335
|
+
run.boardDigest=key;run.boardAt=Date.now();this.relayAccount(run,entry.event);this.flush(run).catch(()=>{});
|
|
336
|
+
}
|
|
337
|
+
/** relay.card, relay.card.page y relay.resolved: siempre salen (dentro del búfer de 512). */
|
|
338
|
+
relayEvent(type,relay,run=this.lifecycle) {
|
|
339
|
+
if(!this.current(run)||!run.relay)return false;
|
|
340
|
+
if(Buffer.byteLength(JSON.stringify(relay))>36000)return false;
|
|
341
|
+
if(this.pending.length>=512){this.onNotice('Remote /relay card skipped: outbound buffer full. The card stays in this terminal.');return false;}
|
|
342
|
+
const entry={event_id:randomUUID(),event:{type,text:'',turn_id:'local',relay}};
|
|
343
|
+
this.pending.push(entry);this.relayAccount(run,entry.event);this.flush(run).catch(()=>{});return true;
|
|
344
|
+
}
|
|
345
|
+
relayAccount(run,event){run.relayEvents=(run.relayEvents||0)+1;run.relayBytes=(run.relayBytes||0)+Buffer.byteLength(JSON.stringify(event));}
|
|
116
346
|
invalidateWorkspace() {
|
|
117
347
|
const run=this.lifecycle;if(!this.current(run)||!this.getWorkspace)return;
|
|
118
348
|
run.workspaceDirty=true;
|
|
@@ -130,6 +360,7 @@ export class RemoteHost {
|
|
|
130
360
|
if(this.stopped)return;
|
|
131
361
|
if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
|
|
132
362
|
const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
|
|
363
|
+
if(['result','context_projection','usage_accounting'].includes(event.type))this.publishStats().catch(()=>{});
|
|
133
364
|
if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
|
|
134
365
|
this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
|
|
135
366
|
}
|
|
@@ -157,14 +388,24 @@ export class RemoteHost {
|
|
|
157
388
|
async flush(run=this.lifecycle){
|
|
158
389
|
if(!this.current(run))return;
|
|
159
390
|
if(this.sending)return this.sending;
|
|
160
|
-
const work=(async()=>{while(this.current(run)&&this.pending.length){
|
|
391
|
+
const work=(async()=>{while(this.current(run)&&this.pending.length){
|
|
392
|
+
const item=this.pending[0];
|
|
393
|
+
try{await this.call('remote.events.publish',item,run);}
|
|
394
|
+
catch(error){
|
|
395
|
+
if(!this.current(run))throw error;
|
|
396
|
+
if(item.event?.type==='session.stats'&&STATS_REFUSALS.has(error.code)){/* dropped: the next summary replaces it */}
|
|
397
|
+
else if(String(item.event?.type).startsWith('relay.')&&RELAY_REFUSALS.has(error.code))this.onNotice('Remote /relay event refused by the backend ('+error.code+'); /relay and /swarm in this terminal are unaffected.');
|
|
398
|
+
else throw error;
|
|
399
|
+
}
|
|
400
|
+
this.assertCurrent(run);if(this.pending[0]===item)this.pending.shift();else{const index=this.pending.indexOf(item);if(index>=0)this.pending.splice(index,1);}
|
|
401
|
+
}})();
|
|
161
402
|
this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
|
|
162
403
|
}
|
|
163
|
-
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(this.current(run))return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId},run);});this.pulsing=work;return work;}
|
|
404
|
+
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(!this.current(run))return;const armed=run.answers&&this.answersUntil>this.now(),permitted=Boolean(run.device)&&this.permissionUntil>this.now();return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId,...(run.answers?{relay_answers:{armed,until:armed?Math.floor(this.answersUntil/1000):0}}:{}),...(run.device?{permission_answers:{armed:permitted,until:permitted?Math.floor(this.permissionUntil/1000):0}}:{})},run);});this.pulsing=work;return work;}
|
|
164
405
|
async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
|
|
165
406
|
async endTurn(){
|
|
166
407
|
this.invalidateWorkspace();
|
|
167
|
-
if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});}
|
|
408
|
+
if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});this.publishStats({force:true}).catch(()=>{});}
|
|
168
409
|
clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
|
|
169
410
|
}
|
|
170
411
|
async validate(item){
|
|
@@ -174,18 +415,26 @@ export class RemoteHost {
|
|
|
174
415
|
await this.call('remote.inputs.validate',{request_id:item.remote.request},run);this.assertCurrent(run);return true;
|
|
175
416
|
}
|
|
176
417
|
async tick(run=this.lifecycle){
|
|
418
|
+
if(this.current(run)&&this.cards.size){if(!this.permissionsArmed())this.hideCards();else if(this.now()-this.cardsSentAt>=CARD_RESEND_MS)this.sendCards();}
|
|
177
419
|
if(!this.current(run)||this.processing)return this.processing;
|
|
178
420
|
const work=(async()=>{
|
|
179
421
|
await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
|
|
180
422
|
const page=await this.call('remote.inputs.claim',{},run);this.assertCurrent(run);
|
|
181
423
|
for(const input of page.inputs){
|
|
182
|
-
this.assertCurrent(run);let state='accepted_locally',queue_item_id='';
|
|
424
|
+
this.assertCurrent(run);let state='accepted_locally',queue_item_id='',outcome=null;
|
|
183
425
|
// Only admission errors are rejection. A lost ACK must redeliver the same input.
|
|
184
|
-
|
|
426
|
+
if(input.kind==='relay'){({state,outcome}=await this.relayAnswer(input,run));}
|
|
427
|
+
else if(input.kind==='permission.answer'){({state,outcome}=await this.permissionAnswer(input,run));}
|
|
428
|
+
else try {
|
|
185
429
|
if(input.kind==='stop'){if(input.turn_id!==this.turnId||!await this.onStop(input.turn_id))throw Error('Stale turn.');queue_item_id='stop:'+input.turn_id;}
|
|
430
|
+
else if(input.kind==='quick.reply'){const text=input.control?.text;if(typeof text!=='string'||!quickReplies().includes(text))throw Error('Unknown quick reply.');const accepted=await this.onInput({...input,text},{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
186
431
|
else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
187
432
|
}catch{state='rejected';}
|
|
188
|
-
this.assertCurrent(run);
|
|
433
|
+
this.assertCurrent(run);
|
|
434
|
+
try{await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id,...(outcome?{outcome}:{})},run);}
|
|
435
|
+
// The backend already settled this device input (expired or its device gone after the claim):
|
|
436
|
+
// it is not retried nor applied again; the local book row stays the record of what was decided here.
|
|
437
|
+
catch(error){if(input.kind!=='permission.answer'||error?.code!=='REMOTE_CONFLICT')throw error;}
|
|
189
438
|
}
|
|
190
439
|
if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
|
|
191
440
|
})().catch(error=>{
|
|
@@ -195,10 +444,29 @@ export class RemoteHost {
|
|
|
195
444
|
});
|
|
196
445
|
this.processing=work;try{return await work;}finally{if(this.processing===work)this.processing=null;}
|
|
197
446
|
}
|
|
447
|
+
/** Control tipado del navegador sobre una tarjeta de /relay (kind=relay). Nunca entra en la cola
|
|
448
|
+
* del modelo: lo revalida y aplica el broker local con las funciones de la TUI. */
|
|
449
|
+
async relayAnswer(input,run=this.lifecycle){
|
|
450
|
+
if(!run?.answers||!this.onRelay)return {state:'refused',outcome:'host_legacy'};
|
|
451
|
+
try{
|
|
452
|
+
const result=await this.onRelay(input,{session:this.session.id,epoch:this.session.host_epoch,armed:()=>this.armed(),validate:()=>this.validate({remote:{session:this.session.id,request:input.request_id}})});
|
|
453
|
+
const state=RELAY_ACK_STATES.has(result?.state)?result.state:'refused';
|
|
454
|
+
return {state,outcome:/^[a-z_]{1,64}$/.test(String(result?.outcome||''))?result.outcome:'host_error'};
|
|
455
|
+
}catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'host_error'};}
|
|
456
|
+
}
|
|
457
|
+
/** Answer to a permission card from the paired M5Stack (kind=permission.answer). Never enters the model's
|
|
458
|
+
* queue: the PermissionDesk checks it again (arm, expiry, device, pending question, digest) and decides. */
|
|
459
|
+
async permissionAnswer(input,run=this.lifecycle){
|
|
460
|
+
if(!run?.device||!this.onPermission)return {state:'refused',outcome:'disarmed'};
|
|
461
|
+
try{
|
|
462
|
+
const result=await this.onPermission(input,{deviceId:this.permissionsArmed()?this.permissionDevice:null,armed:()=>this.permissionsArmed()});
|
|
463
|
+
return {state:PERMISSION_ACK_STATES.has(result?.state)?result.state:'refused',outcome:PERMISSION_OUTCOMES.has(result?.outcome)?result.outcome:'stale'};
|
|
464
|
+
}catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'stale'};}
|
|
465
|
+
}
|
|
198
466
|
async stop(){
|
|
199
467
|
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
200
|
-
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);
|
|
201
|
-
this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
468
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);clearTimeout(run.statsTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
469
|
+
this.stopped=true;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.cards.clear();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
202
470
|
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
203
471
|
await this.revoke(run);return this.status();
|
|
204
472
|
}
|
|
@@ -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
|
+
}
|