@milpa/comal 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ comal --help
28
28
 
29
29
  Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
30
30
 
31
- You can pin a version with `npx @milpa/comal@0.2.3` or `npm install --global @milpa/comal@0.2.3`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
31
+ You can pin a version with `npx @milpa/comal@0.2.4` or `npm install --global @milpa/comal@0.2.4`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
32
32
 
33
33
  ## Test without account or model
34
34
 
@@ -0,0 +1,423 @@
1
+ {
2
+ "protocol": "comal.remote.device.v1",
3
+ "description": "Contract between a Comal Remote Control (/rc) session and an M5Stack device: pairing, the two inputs a device may send, the ephemeral permission card, the session.stats projection and the reasons a device is refused. The device is a pre-check surface only: the local Comal host revalidates every answer (arm, expiry, card digest, session, device) before mutating. Owned by the Comal Node repository; the backend keeps a byte-identical copy.",
4
+ "pairing": {
5
+ "code_alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
6
+ "code_length": 8,
7
+ "code_ttl_seconds": 600,
8
+ "poll_interval_seconds": 5
9
+ },
10
+ "inputs": {
11
+ "permission_answer": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "properties": {
15
+ "question_id": {
16
+ "type": "string",
17
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
18
+ },
19
+ "card_digest": {
20
+ "type": "string",
21
+ "pattern": "^[a-f0-9]{64}$"
22
+ },
23
+ "decision": {
24
+ "type": "string",
25
+ "enum": [
26
+ "allow_once",
27
+ "deny"
28
+ ]
29
+ }
30
+ },
31
+ "required": [
32
+ "question_id",
33
+ "card_digest",
34
+ "decision"
35
+ ]
36
+ },
37
+ "quick_reply": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "properties": {
41
+ "text": {
42
+ "type": "string",
43
+ "enum": [
44
+ "continúa",
45
+ "sí",
46
+ "no"
47
+ ]
48
+ }
49
+ },
50
+ "required": [
51
+ "text"
52
+ ]
53
+ }
54
+ },
55
+ "stats": {
56
+ "maxBytes": 2048,
57
+ "schema": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "model": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "properties": {
65
+ "provider": {
66
+ "type": "string",
67
+ "maxLength": 64
68
+ },
69
+ "profile": {
70
+ "type": "string",
71
+ "maxLength": 64
72
+ },
73
+ "model": {
74
+ "type": "string",
75
+ "maxLength": 64
76
+ },
77
+ "effort": {
78
+ "type": "string",
79
+ "maxLength": 64
80
+ },
81
+ "effort_levels": {
82
+ "type": "array",
83
+ "maxItems": 8,
84
+ "items": {
85
+ "type": "string",
86
+ "maxLength": 64
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "capabilities": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "tools": {
96
+ "type": [
97
+ "boolean",
98
+ "null"
99
+ ]
100
+ },
101
+ "vision": {
102
+ "type": [
103
+ "boolean",
104
+ "null"
105
+ ]
106
+ },
107
+ "thinking": {
108
+ "type": [
109
+ "boolean",
110
+ "null"
111
+ ]
112
+ },
113
+ "cache": {
114
+ "type": [
115
+ "boolean",
116
+ "null"
117
+ ]
118
+ },
119
+ "sources": {
120
+ "type": "object",
121
+ "maxProperties": 8,
122
+ "additionalProperties": {
123
+ "type": "string",
124
+ "maxLength": 64
125
+ }
126
+ }
127
+ }
128
+ },
129
+ "permissions": {
130
+ "type": "object",
131
+ "additionalProperties": false,
132
+ "properties": {
133
+ "mode": {
134
+ "type": "string",
135
+ "enum": [
136
+ "default",
137
+ "acceptEdits",
138
+ "plan",
139
+ "auto",
140
+ "bypassPermissions"
141
+ ]
142
+ },
143
+ "device_armed": {
144
+ "type": "boolean"
145
+ },
146
+ "device_until": {
147
+ "type": [
148
+ "integer",
149
+ "null"
150
+ ],
151
+ "minimum": 0
152
+ },
153
+ "add_dirs": {
154
+ "type": "integer",
155
+ "minimum": 0
156
+ }
157
+ }
158
+ },
159
+ "context": {
160
+ "type": "object",
161
+ "additionalProperties": false,
162
+ "properties": {
163
+ "input_tokens": {
164
+ "type": "integer",
165
+ "minimum": 0
166
+ },
167
+ "window": {
168
+ "type": "integer",
169
+ "minimum": 0
170
+ },
171
+ "output_reserve": {
172
+ "type": "integer",
173
+ "minimum": 0
174
+ },
175
+ "percent": {
176
+ "type": "number",
177
+ "minimum": 0,
178
+ "maximum": 100
179
+ },
180
+ "compaction_window": {
181
+ "type": "integer",
182
+ "minimum": 0
183
+ },
184
+ "soft_threshold": {
185
+ "type": "integer",
186
+ "minimum": 0
187
+ },
188
+ "hard_threshold": {
189
+ "type": "integer",
190
+ "minimum": 0
191
+ },
192
+ "last_compaction": {
193
+ "type": [
194
+ "object",
195
+ "null"
196
+ ],
197
+ "additionalProperties": false,
198
+ "properties": {
199
+ "at": {
200
+ "type": "integer",
201
+ "minimum": 0
202
+ },
203
+ "trigger": {
204
+ "type": "string",
205
+ "maxLength": 64
206
+ },
207
+ "before": {
208
+ "type": "integer",
209
+ "minimum": 0
210
+ },
211
+ "after": {
212
+ "type": "integer",
213
+ "minimum": 0
214
+ }
215
+ }
216
+ },
217
+ "messages_active": {
218
+ "type": "integer",
219
+ "minimum": 0
220
+ },
221
+ "messages_original": {
222
+ "type": "integer",
223
+ "minimum": 0
224
+ }
225
+ }
226
+ },
227
+ "usage": {
228
+ "type": "object",
229
+ "additionalProperties": false,
230
+ "properties": {
231
+ "last": {
232
+ "type": "object",
233
+ "additionalProperties": false,
234
+ "properties": {
235
+ "input": {
236
+ "type": "integer",
237
+ "minimum": 0
238
+ },
239
+ "output": {
240
+ "type": "integer",
241
+ "minimum": 0
242
+ },
243
+ "cached": {
244
+ "type": "integer",
245
+ "minimum": 0
246
+ }
247
+ }
248
+ },
249
+ "session": {
250
+ "type": "object",
251
+ "additionalProperties": false,
252
+ "properties": {
253
+ "input": {
254
+ "type": "integer",
255
+ "minimum": 0
256
+ },
257
+ "output": {
258
+ "type": "integer",
259
+ "minimum": 0
260
+ },
261
+ "cached": {
262
+ "type": "integer",
263
+ "minimum": 0
264
+ }
265
+ }
266
+ },
267
+ "calls": {
268
+ "type": "integer",
269
+ "minimum": 0
270
+ },
271
+ "call_limit": {
272
+ "type": "integer",
273
+ "minimum": 0
274
+ }
275
+ }
276
+ },
277
+ "timing": {
278
+ "type": "object",
279
+ "additionalProperties": false,
280
+ "properties": {
281
+ "comal_ms": {
282
+ "type": "integer",
283
+ "minimum": 0
284
+ },
285
+ "reasoning_ms": {
286
+ "type": "integer",
287
+ "minimum": 0
288
+ },
289
+ "first_response_ms": {
290
+ "type": "integer",
291
+ "minimum": 0
292
+ },
293
+ "tools_ms": {
294
+ "type": "integer",
295
+ "minimum": 0
296
+ },
297
+ "human_wait_ms": {
298
+ "type": "integer",
299
+ "minimum": 0
300
+ }
301
+ }
302
+ },
303
+ "work": {
304
+ "type": "object",
305
+ "additionalProperties": false,
306
+ "properties": {
307
+ "objective": {
308
+ "type": "string",
309
+ "maxLength": 120
310
+ },
311
+ "done": {
312
+ "type": "integer",
313
+ "minimum": 0
314
+ },
315
+ "total": {
316
+ "type": "integer",
317
+ "minimum": 0
318
+ },
319
+ "current_task": {
320
+ "type": [
321
+ "string",
322
+ "null"
323
+ ],
324
+ "maxLength": 120
325
+ },
326
+ "checks_passing": {
327
+ "type": "integer",
328
+ "minimum": 0
329
+ },
330
+ "checks_failing": {
331
+ "type": "integer",
332
+ "minimum": 0
333
+ }
334
+ }
335
+ },
336
+ "connection": {
337
+ "type": "object",
338
+ "additionalProperties": false,
339
+ "properties": {
340
+ "transport": {
341
+ "type": "string",
342
+ "enum": [
343
+ "websocket",
344
+ "https-polling",
345
+ "disconnected"
346
+ ]
347
+ },
348
+ "host_online": {
349
+ "type": "boolean"
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ },
356
+ "card": {
357
+ "maxBytes": 4096,
358
+ "schema": {
359
+ "type": "object",
360
+ "additionalProperties": false,
361
+ "properties": {
362
+ "question_id": {
363
+ "type": "string",
364
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
365
+ },
366
+ "card_digest": {
367
+ "type": "string",
368
+ "pattern": "^[a-f0-9]{64}$"
369
+ },
370
+ "tool": {
371
+ "type": "string",
372
+ "maxLength": 64
373
+ },
374
+ "kind": {
375
+ "type": "string",
376
+ "enum": [
377
+ "bash",
378
+ "write",
379
+ "edit",
380
+ "summary",
381
+ "terminal"
382
+ ]
383
+ },
384
+ "lines": {
385
+ "type": "array",
386
+ "maxItems": 16,
387
+ "items": {
388
+ "type": "string",
389
+ "maxLength": 120
390
+ }
391
+ },
392
+ "truncated": {
393
+ "type": "boolean"
394
+ },
395
+ "overwrite": {
396
+ "type": "boolean"
397
+ }
398
+ },
399
+ "required": [
400
+ "question_id",
401
+ "card_digest",
402
+ "tool",
403
+ "kind",
404
+ "lines",
405
+ "truncated"
406
+ ]
407
+ }
408
+ },
409
+ "arm": {
410
+ "default_seconds": 1800,
411
+ "max_seconds": 28800
412
+ },
413
+ "refusals": [
414
+ "disarmed",
415
+ "expired",
416
+ "stale",
417
+ "terminal_only",
418
+ "other_device",
419
+ "plan_mode",
420
+ "confirm_always",
421
+ "offline"
422
+ ]
423
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Comal: governed operations, execution receipts, and programming assistance in any language",
5
5
  "type": "module",
6
6
  "private": false,
@@ -25,6 +25,7 @@
25
25
  "backend/contracts/remote-commands-v1.json",
26
26
  "backend/contracts/remote-relay-answers-v1.json",
27
27
  "backend/contracts/remote-workspace-v1.schema.json",
28
+ "backend/contracts/remote-device-v1.json",
28
29
  "governance/source-task-v1.json",
29
30
  "governance/source-task-v2.json",
30
31
  "governance/chrome-task-v1.json",
package/src/cli.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { forwardHouseRow, parseRcDevice, runRcDevice, runRcDeviceScan, deviceCodeChoice, parseRcPermissions, runRcPermissions } from './remote-device.mjs';
2
+ import { scanPairingQr } from './qr-scan.mjs';
1
3
  import { registerSecretEnv } from './child-env.mjs';
2
4
  import {prepareSourceTask,readSourceTask} from './workflows/source-task.mjs';
3
5
  import {savedSourceTask} from './source-task-proof.mjs';
@@ -5,6 +7,7 @@ import { t, registerMessages, configureLanguage, languageArguments } from './i18
5
7
  import cliSpanish from './i18n/cli-es.mjs';
6
8
  registerMessages(cliSpanish);
7
9
  import { RemoteHost } from './remote-control.mjs';
10
+ import { PermissionDesk, confirmWithDevice } from './remote-permissions.mjs';
8
11
  import { remoteCommandHelp, isRemoteCommand } from './remote-commands.mjs';
9
12
  import { codeRunDefinition } from './code-run.mjs';
10
13
  import {installCodeRunSession} from './code-run-session.mjs';
@@ -53,6 +56,7 @@ import {GitIntegrationOperations} from './git-integration-operations.mjs';
53
56
  import {prepareGitPageCliBridge,assertGitPageCliEntry} from './git-page-cli-bridge.mjs';
54
57
  import { registerGitSourceOperations } from './git-source.mjs';
55
58
  import { projectRemoteWorkspace } from './remote-workspace.mjs';
59
+ import { buildSessionStats, readContext, statsFromRows } from './remote-stats.mjs';
56
60
  import { evidenceOperations } from './work-state.mjs';
57
61
  import { auditExecution, auditReport } from './audit.mjs';
58
62
  import { comalEnvironment, defaultStateDir } from './compat.mjs';
@@ -371,10 +375,12 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
371
375
  const interactive = !print;
372
376
  const color = (code, text) => stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
373
377
  let remoteHost;
374
- let controller, authController, modelController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
378
+ // One desk per process: a permission card answered from the paired M5Stack races the terminal card.
379
+ const permissionDesk = new PermissionDesk({ sendCard: card => remoteHost?.sendCard(card), closeCard: id => remoteHost?.closeCard(id), record: row => store.append(session.id, { ...row, source: 'remote_device', at: new Date().toISOString() }) });
380
+ let controller, authController, modelController, scanController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
375
381
  const peerGuard=new PeerTurnGuard({cwd,resolveWrite:file=>tools.resolve(file,{create:true}),effects:name=>house.registry.get(name)?.contract?.effects});
376
382
  let lastModelProgress = 0;
377
- setInterrupt(() => { if(modelController)modelController.abort();else if(authController)authController.abort();else if (controller) controller.abort(); else if (runtime) runtime.exit().catch(error => ui.notice(error.message, true)); else if(listenFinish)listenFinish();else ui?.close(); });
383
+ setInterrupt(() => { if(scanController)scanController.abort();else if(modelController)modelController.abort();else if(authController)authController.abort();else if (controller) controller.abort(); else if (runtime) runtime.exit().catch(error => ui.notice(error.message, true)); else if(listenFinish)listenFinish();else ui?.close(); });
378
384
  // Private files named by flags join the default private locations: no /add-dir may cover them.
379
385
  const flagPrivate = [v['state-dir'], v['providers-config'], v['plugin-config']].filter(Boolean).map(item => path.resolve(item));
380
386
  const policy = new PermissionPolicy({ mode: permissionMode, dangerouslySkipPermissions: skipPermissions, allowed: csv(v['allowed-tools']), denied: csv(v['disallowed-tools']), cwd, readRoots, privateLocations: flagPrivate,
@@ -382,7 +388,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
382
388
  confirm: interactive ? async (name, input, options = {}) => {
383
389
  const signals = [options.signal, controller?.signal || startupSignal].filter(Boolean), signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
384
390
  const card = options.summary === undefined ? await house.confirmSummary(name, input, { signal }) : null;
385
- return ui.confirm(name, input, signal, { summary: options.summary ?? card?.summary, edit: card?.edit, remember: options.remember });
391
+ const summary = options.summary ?? card?.summary, terminal = shown => ui.confirm(name, input, shown, { summary, edit: card?.edit, remember: options.remember });
392
+ return confirmWithDevice({ armed: remoteHost?.permissionsArmed() === true, desk: permissionDesk, questionId: options.questionId, name, input, summary, signal, terminal, resolveWrite: (file, how) => tools.resolve(file, how), confirmAlways: () => policy.confirmAlways(name), mode: () => policy.mode, notice: text => ui.notice(text) });
386
393
  } : undefined });
387
394
  // /add-dir rows are the human's decisions in this session's book (under .comal, which the model cannot write);
388
395
  // on resume they open again if the directory still qualifies, otherwise one line says why.
@@ -400,7 +407,10 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
400
407
  }
401
408
  };
402
409
  const recordLaunchPermissions = () => store.append(session.id, { type: 'mode', source: 'human_cli', mode: policy.mode, dangerously_skip_permissions: skipPermissions, scope: 'process_launch' });
410
+ // Model calls of the current work and their limit, as model_progress reports them (session.stats).
411
+ let statsCalls = null;
403
412
  const onEvent = event => {
413
+ if (event.type === 'model_progress') statsCalls = { calls: event.turn, call_limit: event.maxTurns };
404
414
  remoteHost?.observe(event);
405
415
  if (ui) { ui.handleEvent(event); return; }
406
416
  if (format === 'stream-json') stdout.write(JSON.stringify(event) + '\n');
@@ -438,7 +448,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
438
448
  stderr.write(color('90', t("Session {v0} · {v1} calls · {v2} tokens", {v0: event.session_id, v1: event.turns, v2: event.usage.input_tokens + event.usage.output_tokens})) + '\n');
439
449
  }
440
450
  };
441
- const house = new House({ cwd, record: async event => { const row=await store.append(event.session_id,event); if(event.session_id===session.id)remoteHost?.invalidateWorkspace(); return row; } });
451
+ const house = new House({ cwd, record: async event => { const row=await store.append(event.session_id,event); if(event.session_id===session.id)remoteHost?.invalidateWorkspace(); forwardHouseRow(event,{sessionId:session.id,remoteHost}); return row; } });
442
452
  if (v.codemode) house.registry.add(codeRunDefinition);
443
453
  house.guard('peer-authority',view=>peerGuard.check(view),{revision:'peer-turn-v1',configuration_digest:digest({cwd})});
444
454
  const onPermission = decision => store.append(session.id, { type: 'permission', ...decision });
@@ -610,7 +620,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
610
620
  house.registry.add({name:'CloudAccountLogout',description:'Human detaches Remote Control and removes the local Comal account credential.',input_schema:emptyInput,audience:'human',surfaces:['tui'],permission:null,effects:{workspace:'none',external:'write',process:'none',authority:'credential_control'},run:async()=>{await remoteHost?.stop();return cloudClient.logout();}});
611
621
  const openRemoteControl=async()=>{
612
622
  if(!runtime)throw Error('Remote Control requires an interactive local session.');
613
- if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text),getWorkspace:async snapshot=>projectRemoteWorkspace(snapshot || await store.load(session.id),house.stateContext()),relay:relayRemote,onRelay:interactive?(input,binding)=>relayAnswers.handle(input,binding):null});
623
+ if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text),getWorkspace:async snapshot=>projectRemoteWorkspace(snapshot || await store.load(session.id),house.stateContext()),relay:relayRemote,onRelay:interactive?(input,binding)=>relayAnswers.handle(input,binding):null,onPermission:interactive?(input,binding)=>permissionDesk.handle(input,binding):null,onPermissionsDisarmed:reason=>{store.append(session.id,{type:'remote_control',action:'permissions.arm',armed:false,until:null,reason,source:'host',at:new Date().toISOString()}).catch(()=>{});},getStats:sessionStats});
614
624
  const connection = new AbortController(); authController = connection;
615
625
  const cancel = () => remoteHost.stop().catch(() => {});
616
626
  connection.signal.addEventListener('abort', cancel, { once: true });
@@ -798,7 +808,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
798
808
  if (ui && error.usage) ui.handleEvent({ type: 'turn_usage', usage: error.usage });
799
809
  if (ui) ui.notice(error.message, true); else stderr.write(`\n${color('31', 'Error')}: ${clean(error.message)}\n`);
800
810
  return 1;
801
- } finally { await remoteHost?.endTurn(); controller = null; peerGuard.reset();await peerBus.setState('idle'); }
811
+ } finally { statsCalls = null; await remoteHost?.endTurn(); controller = null; peerGuard.reset();await peerBus.setState('idle'); }
802
812
  };
803
813
  const peerProbe = memoizedPeerProbe({
804
814
  stamp: async () => { const info = await stat(store.file(session.id)); return [session.id, info.size, info.mtimeMs, peerBus.inbound, peerBus.availability?.available].join(':'); },
@@ -815,7 +825,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
815
825
  if(format==='json')stdout.write(JSON.stringify(result)+'\n');
816
826
  return result.is_error?1:0;
817
827
  } catch(error){if(ui)ui.notice(error.message,true);else stderr.write(t("Pending message: {v0}\n", {v0: clean(error.message)}));return 1;}
818
- finally{await remoteHost?.endTurn();controller=null;peerGuard.reset();await peerBus.setState('idle');}
828
+ finally{statsCalls=null;await remoteHost?.endTurn();controller=null;peerGuard.reset();await peerBus.setState('idle');}
819
829
  };
820
830
  const directTool = async (name, input, text, userIntent) => {
821
831
  if (name === 'Agent' && v.demo) throw new Error(t("Subagents require a real model; --demo is a script."));
@@ -888,7 +898,17 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
888
898
  const format = { '.csv': 'csv', '.tsv': 'tsv', '.json': 'json', '.md': 'markdown' }[path.extname(file_path).toLowerCase()];
889
899
  if (!format) throw new Error(t("Use a .csv, .tsv, .json, or .md file.")); return JSON.stringify({ text, format });
890
900
  } });
891
- const changeMode = mode => { if (skipPermissions) throw new Error(t(BYPASS_LOCKED_MESSAGE)); return humanTools.execute('SessionMode', { mode }); };
901
+ const changeMode = async mode => { if (skipPermissions) throw new Error(t(BYPASS_LOCKED_MESSAGE)); const result = await humanTools.execute('SessionMode', { mode }); remoteHost?.publishStats().catch(() => {}); return result; };
902
+ // session.stats for /rc: the same sources /status, /context and the status bar read; buildSessionStats keeps only numbers, enums and bounded labels.
903
+ const sessionStats = async () => { try {
904
+ const current = await store.load(session.id), recorded = statsFromRows(current.rows), rc = remoteHost?.status() || {};
905
+ let work = null; try { work = projectRemoteWorkspace(current, house.stateContext()); } catch {}
906
+ const calls = statsCalls || (recorded.usage?.calls !== undefined ? { calls: recorded.usage.calls, call_limit: agent.workMaxCalls } : {});
907
+ return buildSessionStats({ metadata: metadata(), promptCaching: typeof provider?.promptCaching === 'boolean' ? provider.promptCaching : null,
908
+ ...readContext({ compactor: agent.compactor, system, tools: agent.tools.definitions(), maxTokens: requestProvider.maxTokens, session: current }),
909
+ lastCompaction: recorded.lastCompaction, usage: recorded.usage ? { ...recorded.usage, ...calls } : null, timing: recorded.timing, work,
910
+ permissions: rc.permission_answers || null, transport: rc.transport, addDirCount: policy.workRoots.length, cwd });
911
+ } catch { return null; } };
892
912
  let discoveredModels = [], discoveredModelKey = '', discoveredSessions = [], discoveredPeers = [];
893
913
  let authStatus={};
894
914
  const refreshConnections=async()=>{const statuses=await Promise.allSettled(AUTH_PROVIDERS.map(item=>authManager.status(item.id)));authStatus=Object.fromEntries(AUTH_PROVIDERS.map((item,index)=>[item.id,statuses[index].status==='fulfilled'?statuses[index].value.status:'unknown']));};
@@ -955,7 +975,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
955
975
  validateRemote: item => remoteHost?.validate(item) ?? Promise.reject(Error('Remote host detached.')),
956
976
  remoteTurnMatches: id => !remoteHost?.stopped && remoteHost?.turnId===id,
957
977
  detachRemote: () => remoteHost?.stop(),
958
- metadata, discovery, execute: run, interrupt: () => {controller?.abort();authController?.abort();modelController?.abort();}, cycleMode: () => changeMode(nextPermissionMode(policy.mode)),
978
+ metadata, discovery, execute: run, interrupt: () => {controller?.abort();authController?.abort();modelController?.abort();scanController?.abort();}, cycleMode: () => changeMode(nextPermissionMode(policy.mode)),
959
979
  hasPeerMessages:peerProbe,executePeer:runPeer,
960
980
  listSessions: async () => { discoveredSessions = await store.list(); return discoveredSessions; }, saveQueue: (id, snapshot) => store.append(id, { type: 'queue', snapshot }), saveDraft: (id, text, attachments = []) => store.append(id, { type: 'draft', text, attachments }),
961
981
  goalControl: argument => runGoal(argument),
@@ -1012,7 +1032,28 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
1012
1032
  await relayFlow.record({action:'remote.arm',until:new Date(until).toISOString(),armed:true});
1013
1033
  return relayStatus()+' '+t('Plans and success criteria also need a sign-in from the last 10 minutes in that browser; retrying blocked tasks stays here. /rc relay off disarms.');
1014
1034
  }
1015
- if(!['','start','status','stop'].includes(action))throw Error('Use /rc [start|status|stop|relay on [30m|2h|8h]|relay off].');
1035
+ // /rc device CODE · /rc device · /rc device forget: empareja el M5Stack con esta sesión de /rc.
1036
+ const deviceCommand=parseRcDevice(action);
1037
+ const bookRecord=row=>store.append(session.id,{...row,source:interactive?'human_tui':'human_cli',at:new Date().toISOString()});
1038
+ // /rc device scan [/dev/videoN]: la cámara lee el QR del M5Stack sólo durante este escaneo (≤30 s) y la persona confirma el código.
1039
+ if(deviceCommand?.kind==='scan'){
1040
+ const scan=async({camera})=>{
1041
+ scanController=new AbortController();
1042
+ const signal=controller?.signal?AbortSignal.any([controller.signal,scanController.signal]):scanController.signal;
1043
+ ui.setupStatus({title:t('Scanning the M5Stack pairing QR'),description:t('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.'),signal});
1044
+ ui.callbacks.onSetupCancel=()=>scanController?.abort();
1045
+ try{return await scanPairingQr({camera,signal});}
1046
+ finally{scanController=null;delete ui.callbacks.onSetupCancel;ui.clearSetup();}
1047
+ };
1048
+ const confirm=interactive&&ui?async({code})=>await ui.setupChoice({...deviceCodeChoice(code,t),signal:startupSignal})==='pair':undefined;
1049
+ try{return await runRcDeviceScan(deviceCommand,remoteHost,t,{record:bookRecord,scan,confirm});}
1050
+ catch(error){if(error.name==='AbortError')return error.message;throw error;}
1051
+ }
1052
+ if(deviceCommand)return runRcDevice(deviceCommand,remoteHost,t,{record:bookRecord});
1053
+ // /rc permissions [on [30m|2h]|off]: el aparato emparejado responde tarjetas de permisos, sólo mientras esté armado.
1054
+ const permissionsCommand=parseRcPermissions(action);
1055
+ if(permissionsCommand)return runRcPermissions(permissionsCommand,remoteHost,t,{record:bookRecord});
1056
+ if(!['','start','status','stop'].includes(action))throw Error('Use /rc [start|status|stop|relay on [30m|2h|8h]|relay off|device [CODE|scan [/dev/videoN]|forget]|permissions [on [30m|2h]|off]].');
1016
1057
  if(action==='stop')return humanTools.execute('RemoteControlClose',{});
1017
1058
  if(action==='status')return JSON.stringify(remoteHost?.status() || {enabled:false},null,2);
1018
1059
  if(!await cloudClient.credentials()) {