@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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { t, registerMessages } from '../i18n/index.mjs';
3
+ import { RELAY_ANSWERS } from '../remote-relay.mjs';
4
+
5
+ // /rc + /relay, fase «responder»: una tarjeta, dos superficies (la TUI y el navegador de /rc).
6
+ // Gana el primero que CONFIRMA (D3): abrir o mirar la tarjeta no reserva nada. El candado es
7
+ // síncrono en el event loop, por card key (la misma clave que follow.mjs y remote-relay.mjs), y
8
+ // sólo lo toma una mutación en curso. Una tarjeta decidida queda en `decided` y cualquier otro
9
+ // intento (otra superficie, un control repetido o viejo) responde already_decided sin mutar.
10
+ // Hace falta en Comal porque relayd admite rechazar un plan ya aprobado y aún no aplicado.
11
+ registerMessages({
12
+ 'Open /rc first, with a Comal backend that supports devices.': 'Abre /rc primero, con un backend de Comal que admita dispositivos.',
13
+ 'Device: {label} ({id}){connected}.': 'Dispositivo: {label} ({id}){connected}.',
14
+ ', connected {time}': ', conectado {time}',
15
+ 'No device paired.': 'Ningún dispositivo emparejado.',
16
+ 'Device removed; its permission answers are off.': 'Dispositivo quitado; sus respuestas de permisos quedan apagadas.',
17
+ 'Permission answers from the M5Stack: armed until {time}.': 'Respuestas de permisos desde el M5Stack: armadas hasta las {time}.',
18
+ 'Permission answers from the M5Stack: off.': 'Respuestas de permisos desde el M5Stack: apagadas.',
19
+ 'Pair a device first with /rc device CODE.': 'Empareja un dispositivo primero con /rc device CODE.',
20
+ 'The /rc session has expired; open /rc again.': 'La sesión de /rc caducó; abre /rc de nuevo.',
21
+ '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.': 'Permisos desde el M5Stack activos hasta {time}. El comando o la ruta de cada tarjeta pasa por comal.milpahq.com hacia el aparato y no se guarda.',
22
+ 'Device paired: {label} ({id}).': 'Dispositivo emparejado: {label} ({id}).',
23
+ 'Answered on the M5Stack: {decision} · {tool}': 'Contestada en el M5Stack: {decision} · {tool}',
24
+ 'allowed once': 'permitida una vez', 'denied': 'negada',
25
+ '{what} was decided from the browser ({outcome}).': '{what} se decidió desde el navegador ({outcome}).',
26
+ 'Already decided from the browser ({outcome}); nothing was sent.': 'Ya decidida desde el navegador ({outcome}); no se envió nada.',
27
+ 'This card is being decided in this terminal right now.': 'Esta tarjeta se está decidiendo ahora mismo en esta terminal.',
28
+ 'Plan {id}': 'El plan {id}', 'The question {id}': 'La pregunta {id}', 'The success criteria of {swarm}': 'Los criterios de éxito de {swarm}',
29
+ '/relay answers from the browser: armed until {time}.': 'Respuestas de /relay desde el navegador: armadas hasta las {time}.',
30
+ '/relay answers from the browser: off.': 'Respuestas de /relay desde el navegador: apagadas.',
31
+ 'Open /rc first; /rc relay on arms answers for that session only.': 'Abre /rc primero; /rc relay on arma las respuestas sólo para esa sesión.',
32
+ '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.': 'Aprobar planes y criterios de éxito exige además haber iniciado sesión en ese navegador hace ≤ 10 min; reintentar tareas bloqueadas sigue aquí. /rc relay off las apaga.',
33
+ 'approved': 'aprobado', 'rejected': 'rechazado', 'decided': 'decidida', 'answered': 'respondida', 'acked': 'dada por leída',
34
+ });
35
+
36
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
37
+ const KIND_OF = action => RELAY_ANSWERS.actions[action]?.card || null;
38
+ const APPLIED = new Set(RELAY_ANSWERS.outcomes.applied);
39
+
40
+ // Lo decidido se recuerda 10 min (el TTL de un control): pasado eso manda la re-lectura de relayd.
41
+ const DECIDED_MS = RELAY_ANSWERS.control_ttl_seconds * 1000;
42
+ export class RelayDecisionBroker {
43
+ constructor({ now = Date.now } = {}) { this.now = now; this.locks = new Map(); this.decided = new Map(); this.watchers = new Map(); }
44
+ /** Candado de una sola entrada. {ok} o {ok:false, holder|decided}. Lo decidido por la OTRA
45
+ * superficie bloquea; lo decidido por la misma lo juzga relayd al volver a leer. */
46
+ begin(key, source) {
47
+ const decided = this.outcome(key); if (decided && decided.by !== source) return { ok: false, decided };
48
+ const holder = this.locks.get(key); if (holder) return { ok: false, holder };
49
+ this.locks.set(key, source); return { ok: true };
50
+ }
51
+ release(key) { this.locks.delete(key); }
52
+ /** La tarjeta quedó decidida: suelta el candado, la recuerda y cierra la tarjeta abierta en la otra superficie. */
53
+ commit(key, { outcome, by, request_id = null }) {
54
+ this.locks.delete(key);
55
+ this.decided.set(key, { outcome, by, request_id, at: this.now() });
56
+ if (this.decided.size > 2000) this.decided.delete(this.decided.keys().next().value);
57
+ for (const controller of this.watchers.get(key) || []) controller.abort();
58
+ }
59
+ outcome(key) { const decided = this.decided.get(key); return decided && this.now() - decided.at < DECIDED_MS ? decided : null; }
60
+ /** Señal por tarjeta abierta en la TUI (combinada con la de la sesión): si la otra superficie
61
+ * decide, su setupChoice/setupInput se cierra solo. */
62
+ open(key, signal) {
63
+ const controller = new AbortController(), set = this.watchers.get(key) || new Set();
64
+ set.add(controller); this.watchers.set(key, set);
65
+ return { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, close: () => { set.delete(controller); if (!set.size) this.watchers.delete(key); } };
66
+ }
67
+ }
68
+
69
+ /** ¿El texto cumple la regla de la acción? (none | required | optional, en bytes UTF-8). */
70
+ export function relayText(action, text) {
71
+ const rule = RELAY_ANSWERS.actions[action], value = typeof text === 'string' ? text : '';
72
+ if (!rule) return false;
73
+ if (rule.text === 'none') return value === '';
74
+ if (Buffer.byteLength(value) > rule.text_max) return false;
75
+ return rule.text === 'optional' || value.trim() !== '';
76
+ }
77
+
78
+ /**
79
+ * Camino del navegador (RemoteHost → onRelay). Revalida en local, en este orden (§4.2 del
80
+ * diseño), y sólo entonces muta con las funciones de la TUI (RelayFlow.apply*):
81
+ * 1. libro: ¿ya se resolvió este request_id? → el mismo resultado, sin mutar (ACK perdido, reinicio);
82
+ * 2. sesión y época de /rc; 3. armado local vigente; 4. caducidad del control;
83
+ * 5. tarjeta pendiente en esta sesión, de la clase y el sujeto del control, acción permitida;
84
+ * candado libre (si ya está decidida: already_decided);
85
+ * 6. remote.inputs.validate (el login del navegador sigue vivo);
86
+ * 7. relayd en vivo (dentro de apply*: plan.get / msg.get+inbox / criteria.get) y mutación.
87
+ * Todo control deja su fila relay_control con source remote_browser y su request_id.
88
+ */
89
+ export class RemoteRelayAnswers {
90
+ constructor({ broker, flow, feed, rows = async () => [], now = Date.now }) {
91
+ Object.assign(this, { broker, flow, feed, rows, now });
92
+ this.ledger = null;
93
+ }
94
+ async #ledger() {
95
+ if (!this.ledger) {
96
+ this.ledger = new Map();
97
+ try { for (const row of await this.rows()) if (row?.type === 'relay_control' && row.source === 'remote_browser' && row.remote_request_id && row.remote_outcome) this.ledger.set(row.remote_request_id, { state: row.remote_state, outcome: row.remote_outcome }); } catch {}
98
+ }
99
+ return this.ledger;
100
+ }
101
+ async #settle(input, control, result, extra = {}) {
102
+ const ledger = await this.#ledger();
103
+ ledger.set(input.request_id, result);
104
+ if (ledger.size > 4096) ledger.delete(ledger.keys().next().value);
105
+ // Las mutaciones aplicadas ya dejaron su fila (apply*, con remote_*); el resto deja la suya aquí.
106
+ if (!extra.recorded) try { await this.flow.record({ action: 'remote.answer', source: 'remote_browser', remote_session: input.remote_session_id, remote_request_id: input.request_id, remote_action: control?.action || null, card_id: control?.card_id || null, remote_state: result.state, remote_outcome: result.outcome, ...extra.row }); } catch {}
107
+ return result;
108
+ }
109
+ async handle(input, { session, epoch, armed = () => false, validate = async () => true } = {}) {
110
+ const control = input?.control || {}, ledger = await this.#ledger();
111
+ // 1. Libro: el mismo request_id devuelve lo mismo y nunca vuelve a mutar.
112
+ if (ledger.has(input?.request_id)) return ledger.get(input.request_id);
113
+ const refuse = (state, outcome) => this.#settle(input, control, { state, outcome });
114
+ if (input?.kind !== 'relay' || !KIND_OF(control.action)) return refuse('refused', 'invalid');
115
+ // 2. Sesión y época.
116
+ if (input.remote_session_id !== session || control.host_epoch !== epoch) return refuse('stale', 'epoch');
117
+ // 3. Armado local (D1).
118
+ if (!armed()) return refuse('refused', 'local_arm_off');
119
+ // 4. Caducidad (10 min desde la admisión en Milpa).
120
+ if (!Number.isFinite(Number(input.expires_at)) || Number(input.expires_at) * 1000 <= this.now()) return refuse('expired', 'expired');
121
+ // 5. Tarjeta: publicada en esta sesión y todavía pendiente aquí.
122
+ const entry = this.feed?.card(control.card_id);
123
+ // Una tarjeta ya cerrada (decidida en cualquier superficie, o por otro control) no se vuelve a decidir.
124
+ if (!entry) return refuse('stale', this.feed?.resolution?.(control.card_id) ? 'already_decided' : 'card_gone');
125
+ const card = entry.card, rule = RELAY_ANSWERS.actions[control.action];
126
+ if (card.kind !== rule.card || card.subject !== control.subject || card.project !== control.project || card.swarm !== control.swarm) return refuse('stale', 'kind');
127
+ if (!card.answerable?.browser || !card.answerable.actions?.includes(control.action)) return refuse('refused', 'cap_missing');
128
+ if (!relayText(control.action, control.text ?? '')) return refuse('refused', 'invalid');
129
+ for (const field of rule.fields) if (control[field] === undefined || control[field] === null) return refuse('refused', 'invalid');
130
+ if (rule.fields.includes('digest') && control.digest !== card.body.digest) return refuse('stale', 'digest');
131
+ if (control.action === 'plan.approve' && control.projection_sha256 !== card.body.projection_sha256) return refuse('stale', 'projection');
132
+ const lock = this.broker.begin(entry.key, 'browser');
133
+ if (!lock.ok) return lock.decided ? refuse('stale', 'already_decided') : refuse('refused', 'local_in_progress');
134
+ let result;
135
+ try {
136
+ // 6. El navegador que lo pidió sigue con sesión (login padre).
137
+ try { await validate(); } catch { this.broker.release(entry.key); return refuse('refused', 'revoked'); }
138
+ // 7. relayd en vivo + mutación, con las funciones y claves de la TUI.
139
+ const remote = { source: 'remote_browser', remote_session: input.remote_session_id, remote_request_id: input.request_id, card_id: control.card_id };
140
+ result = await this.#apply(entry, control, remote);
141
+ } catch (error) {
142
+ this.broker.release(entry.key);
143
+ const delegation = ['NOT_DELEGATED', 'UNAUTHORIZED', 'RELAY_DELEGATE_MISSING', 'RELAY_TOKEN_INVALID', 'RELAY_TOKEN_UNSAFE'].includes(error?.code);
144
+ return refuse('refused', delegation ? 'delegation' : 'relay_unavailable');
145
+ }
146
+ if (APPLIED.has(result.outcome)) {
147
+ this.broker.commit(entry.key, { outcome: result.outcome, by: 'browser', request_id: input.request_id });
148
+ this.flow.ui?.notice?.(t('{what} was decided from the browser ({outcome}).', { what: this.#what(card), outcome: t(result.outcome) }));
149
+ return this.#settle(input, control, { state: 'applied', outcome: result.outcome }, { recorded: true });
150
+ }
151
+ // Obsoleta o ya decidida fuera de Comal (relayd lo dice): no se muta y la TUI decide con su propia re-lectura.
152
+ this.broker.release(entry.key);
153
+ return refuse('stale', result.outcome || 'stale');
154
+ }
155
+ #what(card) {
156
+ if (card.kind === 'plan') return t('Plan {id}', { id: card.subject });
157
+ if (card.kind === 'question') return t('The question {id}', { id: card.subject });
158
+ return t('The success criteria of {swarm}', { swarm: card.swarm });
159
+ }
160
+ async #apply(entry, control, remote) {
161
+ const item = entry.item, base = { project: item.project, root: item.root, swarm: item.swarm, ...remote };
162
+ if (entry.card.kind === 'plan') return this.flow.applyPlan({ ...base, proposal_id: item.proposal_id, digest: control.digest, choice: control.action === 'plan.approve' ? 'approve' : 'reject', reason: control.text, projection: control.projection_sha256 });
163
+ if (entry.card.kind === 'question') {
164
+ const mode = control.action.slice('question.'.length);
165
+ // La opción se identifica por índice Y por el sha256 de la etiqueta que vio el navegador.
166
+ const option = mode === 'option' ? entry.card.body.decision?.options?.[control.option_index] : null;
167
+ if (mode === 'option' && (!option || sha256(option.label) !== control.option_label_sha256)) return { outcome: 'option' };
168
+ return this.flow.applyQuestion({ ...base, message_id: item.message_id, mode, option_index: control.option_index, option_label_sha256: control.option_label_sha256, text: control.text || '' });
169
+ }
170
+ if (entry.card.kind === 'criteria') return this.flow.applyCriteria({ ...base, digest: control.digest, choice: control.action === 'criteria.accept' ? 'accept' : 'reject', reason: control.text });
171
+ return { outcome: 'kind' };
172
+ }
173
+ }