@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.
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.5` or `npm install --global @milpa/comal@0.2.5`. 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,470 @@
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, the ephemeral speech chunks (8-bit unsigned mono PCM the host sends the paired device through the relay; never stored) 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
+ "speech": {
410
+ "maxBytes": 6144,
411
+ "format": {
412
+ "encoding": "pcm_u8",
413
+ "channels": 1,
414
+ "sample_rate": 11025,
415
+ "chunk_max_bytes": 4096,
416
+ "phrase_max_seconds": 6
417
+ },
418
+ "schema": {
419
+ "type": "object",
420
+ "additionalProperties": false,
421
+ "properties": {
422
+ "id": {
423
+ "type": "string",
424
+ "pattern": "^[0-9a-f]{16}$"
425
+ },
426
+ "seq": {
427
+ "type": "string",
428
+ "pattern": "^(0|[1-9][0-9]?)$"
429
+ },
430
+ "last": {
431
+ "type": "boolean"
432
+ },
433
+ "about": {
434
+ "type": "string",
435
+ "enum": [
436
+ "permission",
437
+ "failure",
438
+ "turn"
439
+ ]
440
+ },
441
+ "audio": {
442
+ "type": "string",
443
+ "maxLength": 5464,
444
+ "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$"
445
+ }
446
+ },
447
+ "required": [
448
+ "id",
449
+ "seq",
450
+ "last",
451
+ "about",
452
+ "audio"
453
+ ]
454
+ }
455
+ },
456
+ "arm": {
457
+ "default_seconds": 1800,
458
+ "max_seconds": 28800
459
+ },
460
+ "refusals": [
461
+ "disarmed",
462
+ "expired",
463
+ "stale",
464
+ "terminal_only",
465
+ "other_device",
466
+ "plan_mode",
467
+ "confirm_always",
468
+ "offline"
469
+ ]
470
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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,8 @@ 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 { speechFromEnv, permissionSpeech } from './remote-speech.mjs';
11
+ import { PermissionDesk, confirmWithDevice } from './remote-permissions.mjs';
8
12
  import { remoteCommandHelp, isRemoteCommand } from './remote-commands.mjs';
9
13
  import { codeRunDefinition } from './code-run.mjs';
10
14
  import {installCodeRunSession} from './code-run-session.mjs';
@@ -53,6 +57,7 @@ import {GitIntegrationOperations} from './git-integration-operations.mjs';
53
57
  import {prepareGitPageCliBridge,assertGitPageCliEntry} from './git-page-cli-bridge.mjs';
54
58
  import { registerGitSourceOperations } from './git-source.mjs';
55
59
  import { projectRemoteWorkspace } from './remote-workspace.mjs';
60
+ import { buildSessionStats, readContext, statsFromRows } from './remote-stats.mjs';
56
61
  import { evidenceOperations } from './work-state.mjs';
57
62
  import { auditExecution, auditReport } from './audit.mjs';
58
63
  import { comalEnvironment, defaultStateDir } from './compat.mjs';
@@ -371,10 +376,13 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
371
376
  const interactive = !print;
372
377
  const color = (code, text) => stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
373
378
  let remoteHost;
374
- let controller, authController, modelController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
379
+ const remoteSpeech = speechFromEnv(process.env);
380
+ // One desk per process: a permission card answered from the paired M5Stack races the terminal card.
381
+ 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() }) });
382
+ let controller, authController, modelController, scanController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
375
383
  const peerGuard=new PeerTurnGuard({cwd,resolveWrite:file=>tools.resolve(file,{create:true}),effects:name=>house.registry.get(name)?.contract?.effects});
376
384
  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(); });
385
+ 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
386
  // Private files named by flags join the default private locations: no /add-dir may cover them.
379
387
  const flagPrivate = [v['state-dir'], v['providers-config'], v['plugin-config']].filter(Boolean).map(item => path.resolve(item));
380
388
  const policy = new PermissionPolicy({ mode: permissionMode, dangerouslySkipPermissions: skipPermissions, allowed: csv(v['allowed-tools']), denied: csv(v['disallowed-tools']), cwd, readRoots, privateLocations: flagPrivate,
@@ -382,7 +390,10 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
382
390
  confirm: interactive ? async (name, input, options = {}) => {
383
391
  const signals = [options.signal, controller?.signal || startupSignal].filter(Boolean), signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
384
392
  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 });
393
+ const summary = options.summary ?? card?.summary, terminal = shown => ui.confirm(name, input, shown, { summary, edit: card?.edit, remember: options.remember });
394
+ // Granito speaks the question whether or not the device is armed to answer it; synchronous, never awaited.
395
+ try { remoteHost?.sendSpeech(permissionSpeech({ name, input, armed: remoteHost?.permissionsArmed() === true, plan: policy.mode === 'plan', confirmAlways: policy.confirmAlways(name) === true }), 'permission'); } catch {}
396
+ 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
397
  } : undefined });
387
398
  // /add-dir rows are the human's decisions in this session's book (under .comal, which the model cannot write);
388
399
  // on resume they open again if the directory still qualifies, otherwise one line says why.
@@ -400,7 +411,10 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
400
411
  }
401
412
  };
402
413
  const recordLaunchPermissions = () => store.append(session.id, { type: 'mode', source: 'human_cli', mode: policy.mode, dangerously_skip_permissions: skipPermissions, scope: 'process_launch' });
414
+ // Model calls of the current work and their limit, as model_progress reports them (session.stats).
415
+ let statsCalls = null;
403
416
  const onEvent = event => {
417
+ if (event.type === 'model_progress') statsCalls = { calls: event.turn, call_limit: event.maxTurns };
404
418
  remoteHost?.observe(event);
405
419
  if (ui) { ui.handleEvent(event); return; }
406
420
  if (format === 'stream-json') stdout.write(JSON.stringify(event) + '\n');
@@ -438,7 +452,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
438
452
  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
453
  }
440
454
  };
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; } });
455
+ 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
456
  if (v.codemode) house.registry.add(codeRunDefinition);
443
457
  house.guard('peer-authority',view=>peerGuard.check(view),{revision:'peer-turn-v1',configuration_digest:digest({cwd})});
444
458
  const onPermission = decision => store.append(session.id, { type: 'permission', ...decision });
@@ -610,7 +624,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
610
624
  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
625
  const openRemoteControl=async()=>{
612
626
  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});
627
+ if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),speech:remoteSpeech,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
628
  const connection = new AbortController(); authController = connection;
615
629
  const cancel = () => remoteHost.stop().catch(() => {});
616
630
  connection.signal.addEventListener('abort', cancel, { once: true });
@@ -798,7 +812,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
798
812
  if (ui && error.usage) ui.handleEvent({ type: 'turn_usage', usage: error.usage });
799
813
  if (ui) ui.notice(error.message, true); else stderr.write(`\n${color('31', 'Error')}: ${clean(error.message)}\n`);
800
814
  return 1;
801
- } finally { await remoteHost?.endTurn(); controller = null; peerGuard.reset();await peerBus.setState('idle'); }
815
+ } finally { statsCalls = null; await remoteHost?.endTurn(); controller = null; peerGuard.reset();await peerBus.setState('idle'); }
802
816
  };
803
817
  const peerProbe = memoizedPeerProbe({
804
818
  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 +829,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
815
829
  if(format==='json')stdout.write(JSON.stringify(result)+'\n');
816
830
  return result.is_error?1:0;
817
831
  } 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');}
832
+ finally{statsCalls=null;await remoteHost?.endTurn();controller=null;peerGuard.reset();await peerBus.setState('idle');}
819
833
  };
820
834
  const directTool = async (name, input, text, userIntent) => {
821
835
  if (name === 'Agent' && v.demo) throw new Error(t("Subagents require a real model; --demo is a script."));
@@ -888,7 +902,17 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
888
902
  const format = { '.csv': 'csv', '.tsv': 'tsv', '.json': 'json', '.md': 'markdown' }[path.extname(file_path).toLowerCase()];
889
903
  if (!format) throw new Error(t("Use a .csv, .tsv, .json, or .md file.")); return JSON.stringify({ text, format });
890
904
  } });
891
- const changeMode = mode => { if (skipPermissions) throw new Error(t(BYPASS_LOCKED_MESSAGE)); return humanTools.execute('SessionMode', { mode }); };
905
+ 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; };
906
+ // session.stats for /rc: the same sources /status, /context and the status bar read; buildSessionStats keeps only numbers, enums and bounded labels.
907
+ const sessionStats = async () => { try {
908
+ const current = await store.load(session.id), recorded = statsFromRows(current.rows), rc = remoteHost?.status() || {};
909
+ let work = null; try { work = projectRemoteWorkspace(current, house.stateContext()); } catch {}
910
+ const calls = statsCalls || (recorded.usage?.calls !== undefined ? { calls: recorded.usage.calls, call_limit: agent.workMaxCalls } : {});
911
+ return buildSessionStats({ metadata: metadata(), promptCaching: typeof provider?.promptCaching === 'boolean' ? provider.promptCaching : null,
912
+ ...readContext({ compactor: agent.compactor, system, tools: agent.tools.definitions(), maxTokens: requestProvider.maxTokens, session: current }),
913
+ lastCompaction: recorded.lastCompaction, usage: recorded.usage ? { ...recorded.usage, ...calls } : null, timing: recorded.timing, work,
914
+ permissions: rc.permission_answers || null, transport: rc.transport, addDirCount: policy.workRoots.length, cwd });
915
+ } catch { return null; } };
892
916
  let discoveredModels = [], discoveredModelKey = '', discoveredSessions = [], discoveredPeers = [];
893
917
  let authStatus={};
894
918
  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 +979,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
955
979
  validateRemote: item => remoteHost?.validate(item) ?? Promise.reject(Error('Remote host detached.')),
956
980
  remoteTurnMatches: id => !remoteHost?.stopped && remoteHost?.turnId===id,
957
981
  detachRemote: () => remoteHost?.stop(),
958
- metadata, discovery, execute: run, interrupt: () => {controller?.abort();authController?.abort();modelController?.abort();}, cycleMode: () => changeMode(nextPermissionMode(policy.mode)),
982
+ metadata, discovery, execute: run, interrupt: () => {controller?.abort();authController?.abort();modelController?.abort();scanController?.abort();}, cycleMode: () => changeMode(nextPermissionMode(policy.mode)),
959
983
  hasPeerMessages:peerProbe,executePeer:runPeer,
960
984
  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
985
  goalControl: argument => runGoal(argument),
@@ -1012,7 +1036,28 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
1012
1036
  await relayFlow.record({action:'remote.arm',until:new Date(until).toISOString(),armed:true});
1013
1037
  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
1038
  }
1015
- if(!['','start','status','stop'].includes(action))throw Error('Use /rc [start|status|stop|relay on [30m|2h|8h]|relay off].');
1039
+ // /rc device CODE · /rc device · /rc device forget: empareja el M5Stack con esta sesión de /rc.
1040
+ const deviceCommand=parseRcDevice(action);
1041
+ const bookRecord=row=>store.append(session.id,{...row,source:interactive?'human_tui':'human_cli',at:new Date().toISOString()});
1042
+ // /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.
1043
+ if(deviceCommand?.kind==='scan'){
1044
+ const scan=async({camera})=>{
1045
+ scanController=new AbortController();
1046
+ const signal=controller?.signal?AbortSignal.any([controller.signal,scanController.signal]):scanController.signal;
1047
+ 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});
1048
+ ui.callbacks.onSetupCancel=()=>scanController?.abort();
1049
+ try{return await scanPairingQr({camera,signal});}
1050
+ finally{scanController=null;delete ui.callbacks.onSetupCancel;ui.clearSetup();}
1051
+ };
1052
+ const confirm=interactive&&ui?async({code})=>await ui.setupChoice({...deviceCodeChoice(code,t),signal:startupSignal})==='pair':undefined;
1053
+ try{return await runRcDeviceScan(deviceCommand,remoteHost,t,{record:bookRecord,scan,confirm});}
1054
+ catch(error){if(error.name==='AbortError')return error.message;throw error;}
1055
+ }
1056
+ if(deviceCommand)return runRcDevice(deviceCommand,remoteHost,t,{record:bookRecord});
1057
+ // /rc permissions [on [30m|2h]|off]: el aparato emparejado responde tarjetas de permisos, sólo mientras esté armado.
1058
+ const permissionsCommand=parseRcPermissions(action);
1059
+ if(permissionsCommand)return runRcPermissions(permissionsCommand,remoteHost,t,{record:bookRecord});
1060
+ 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
1061
  if(action==='stop')return humanTools.execute('RemoteControlClose',{});
1017
1062
  if(action==='status')return JSON.stringify(remoteHost?.status() || {enabled:false},null,2);
1018
1063
  if(!await cloudClient.credentials()) {