@milpa/comal 0.2.1 → 0.2.3

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 (121) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +232 -63
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +133 -2
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +99 -6
  45. package/src/plugin-catalog.mjs +19 -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/omarchy/catalog.mjs +96 -0
  55. package/src/plugins/omarchy/environment.mjs +78 -0
  56. package/src/plugins/omarchy/guide.mjs +54 -0
  57. package/src/plugins/omarchy/index.mjs +213 -0
  58. package/src/plugins/omarchy/runner.mjs +62 -0
  59. package/src/plugins/omarchy/text.mjs +26 -0
  60. package/src/plugins/omarchy/tiers.mjs +157 -0
  61. package/src/plugins/open-relay/actions.mjs +212 -16
  62. package/src/plugins/open-relay/client.mjs +93 -35
  63. package/src/plugins/open-relay/decision.mjs +89 -0
  64. package/src/plugins/open-relay/dossier.mjs +23 -5
  65. package/src/plugins/open-relay/index.mjs +68 -28
  66. package/src/plugins/open-relay/lookup.mjs +41 -4
  67. package/src/plugins/open-relay/notices.mjs +48 -4
  68. package/src/plugins/open-relay/panel.mjs +59 -0
  69. package/src/plugins/open-relay/watch.mjs +198 -85
  70. package/src/plugins.mjs +12 -1
  71. package/src/process-lock.mjs +20 -12
  72. package/src/provider-auth.mjs +6 -3
  73. package/src/provider-profiles.mjs +13 -3
  74. package/src/provider.mjs +175 -30
  75. package/src/read-only-check.mjs +12 -4
  76. package/src/relay/broker.mjs +160 -0
  77. package/src/relay/cards.mjs +702 -0
  78. package/src/relay/flow.mjs +886 -0
  79. package/src/relay/follow.mjs +119 -0
  80. package/src/relay/git.mjs +23 -0
  81. package/src/relay/hub-client.mjs +163 -0
  82. package/src/relay/state.mjs +55 -0
  83. package/src/remote-control.mjs +115 -12
  84. package/src/remote-relay.mjs +468 -0
  85. package/src/session-bus.mjs +13 -3
  86. package/src/session-provider-settings.mjs +2 -2
  87. package/src/skill-runtime.mjs +8 -6
  88. package/src/source-ownership.mjs +3 -1
  89. package/src/swarm-view.mjs +53 -0
  90. package/src/tool-manifest.mjs +71 -0
  91. package/src/tool-packs.mjs +22 -6
  92. package/src/tool-schema.mjs +14 -2
  93. package/src/tools.mjs +96 -27
  94. package/src/tui/connection-transition.mjs +10 -0
  95. package/src/tui/controller.mjs +35 -9
  96. package/src/tui/discovery.mjs +13 -8
  97. package/src/tui/inbound-blocks.mjs +5 -1
  98. package/src/tui/screen.mjs +78 -15
  99. package/src/tui/swarm-panel.mjs +217 -0
  100. package/src/turn-timing.mjs +82 -0
  101. package/src/usage-accounting.mjs +12 -1
  102. package/src/vision.mjs +6 -1
  103. package/src/visual/chrome.mjs +379 -0
  104. package/src/visual/fingerprint.mjs +30 -0
  105. package/src/visual/fixtures.mjs +201 -0
  106. package/src/visual/gate.mjs +92 -0
  107. package/src/visual/judge.mjs +114 -0
  108. package/src/visual/ledger.mjs +160 -0
  109. package/src/visual/measure.mjs +145 -0
  110. package/src/visual/names.mjs +7 -0
  111. package/src/visual/operations.mjs +211 -0
  112. package/src/visual/pixel-diff.mjs +96 -0
  113. package/src/visual/png.mjs +103 -0
  114. package/src/visual/registry.mjs +265 -0
  115. package/src/visual/report.mjs +64 -0
  116. package/src/visual/verify.mjs +292 -0
  117. package/src/work-assertions.mjs +15 -4
  118. package/src/work-plan.mjs +166 -30
  119. package/src/work-state.mjs +4 -4
  120. package/src/workflows/chrome-task-adoption.mjs +7 -3
  121. package/src/workspace-serve.mjs +59 -0
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { digest } from '../../operations.mjs';
3
3
  import { clean, excerpt } from './bound.mjs';
4
- import { messageOrNull, messageOfDelivery, taskForMessage, pendingDelivery, planOrNull } from './lookup.mjs';
4
+ import { messageOrNull, messageOfDelivery, operatorDelivery, taskForMessage, pendingDelivery, planOrNull } from './lookup.mjs';
5
+ import { METHOD_CAPS } from './client.mjs';
6
+ import { decisionBlock, chosenOption, adrScope, draftAdr, adrDigest } from './decision.mjs';
5
7
  import { t, registerMessages } from '../../i18n/index.mjs';
6
8
 
7
9
  // Fase 2: cuatro acciones sobre el enjambre. Cada una lleva una tarjeta de
@@ -30,7 +32,38 @@ registerMessages({
30
32
  'Confirm delivery (msg.ack): {v0}': 'Confirmar la entrega (msg.ack): {v0}',
31
33
  'yes, the message is marked read': 'sí, el mensaje queda leído',
32
34
  'no': 'no',
33
- 'Decision note (only in the Comal receipt; phase 3 will store it as an ADR):': 'Nota de decisión (sólo en el recibo de Comal; la fase 3 la guardará como ADR):',
35
+ 'Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):': 'Nota de decisión (sólo en el recibo de Comal; Decide la registra como ADR en relayd):',
36
+ 'This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.': 'Es una pregunta estructurada: Reply sólo envía texto. Para registrar la opción elegida como un ADR que el enjambre respete, usa Decide.',
37
+ "Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.": 'La delegación de Comal no tiene la capacidad {v0}: relayd rechazará {v1} (NOT_DELEGATED). El humano debe emitir una delegación con ella.',
38
+ 'Structured question (agent text, untrusted): {v0}': 'Pregunta estructurada (texto del agente, no confiable): {v0}',
39
+ 'Options:': 'Opciones:',
40
+ ' {v0}. {v1}{v2}': ' {v0}. {v1}{v2}',
41
+ ' ← chosen': ' ← elegida',
42
+ ' · recommended by the agent': ' · la recomienda el agente',
43
+ ' consequences: {v0}': ' consecuencias: {v0}',
44
+ 'Agent recommendation: {v0} — {v1}': 'Recomendación del agente: {v0} — {v1}',
45
+ 'Evidence cited (references, not verified): {v0}': 'Evidencia citada (referencias, sin verificar): {v0}',
46
+ 'Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}': 'Alcance: {v0} · Reversible: {v1} · Coste de esperar: {v2}',
47
+ 'yes': 'sí',
48
+ 'not stated': 'no indicado',
49
+ 'Record a decision in open-relay · decision.propose + decision.record + msg.reply': 'Registrar una decisión en open-relay · decision.propose + decision.record + msg.reply',
50
+ 'Question {v0} from {v1} · swarm {v2}': 'Pregunta {v0} de {v1} · swarm {v2}',
51
+ 'The message is not a structured question (no decision block): Decide will fail. Use Reply.': 'El mensaje no es una pregunta estructurada (sin bloque decision): Decide fallará. Usa Reply.',
52
+ 'Option «{v0}» is not one of the question\'s options: Decide will fail.': 'La opción «{v0}» no es una de las de la pregunta: Decide fallará.',
53
+ 'ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):': 'ADR que redactó Comal y registrará relayd (aceptado, firmado «operador vía comal»):',
54
+ ' Title: {v0}': ' Título: {v0}',
55
+ ' Scope: {v0}': ' Alcance: {v0}',
56
+ ' Answers: {v0}': ' Responde a: {v0}',
57
+ ' Context:': ' Contexto:',
58
+ ' Decision:': ' Decisión:',
59
+ ' Discarded alternatives:': ' Alternativas descartadas:',
60
+ ' Consequences:': ' Consecuencias:',
61
+ ' ADR digest: {v0}': ' Digest del ADR: {v0}',
62
+ 'Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):': 'Respuesta que lleva el ADR (msg.reply con decision_id; {v0} caracteres · sha256 {v1}…):',
63
+ 'Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}': 'Pasos: 1. decision.propose (propuesto) · 2. decision.record (aceptado, vigente) · 3. msg.reply con decision_id{v0}',
64
+ ' · 4. msg.ack': ' · 4. msg.ack',
65
+ 'Already recorded for this question: {v0}': 'Ya registrado para esta pregunta: {v0}',
66
+ 'Idempotency keys: {v0}': 'Claves de idempotencia: {v0}',
34
67
  'Idempotency key: {v0}': 'Clave de idempotencia: {v0}',
35
68
  'Reply in open-relay · {v0}': 'Responder en open-relay · {v0}',
36
69
  'relayd does not know message {v0}.': 'relayd no conoce el mensaje {v0}.',
@@ -52,6 +85,9 @@ registerMessages({
52
85
  ' · seat {v0}': ' · asiento {v0}',
53
86
  ' · model {v0}': ' · modelo {v0}',
54
87
  ' … and {v0} more': ' … y {v0} más',
88
+ 'Retires (cancels and stops requiring) {v0}: {v1}': 'Retira (cancela y deja de exigir) {v0}: {v1}',
89
+ ' … and {v0} more': ' … y {v0} más',
90
+ 'On approval, relayd cancels these tasks and stops requiring them: {v0}.': 'Al aprobar, relayd cancela estas tareas y deja de exigirlas: {v0}.',
55
91
  'Steps relayd would apply ({v0}): {v1}': 'Pasos que aplicaría relayd ({v0}): {v1}',
56
92
  'The proposal is already {v0}: relayd will change nothing.': 'La propuesta ya está {v0}: relayd no cambiará nada.',
57
93
  'Applies immediately: on approval, relayd creates the roles, grants and tasks of the plan with no further step.': 'Se aplica en el acto: al aprobar, relayd crea los roles, grants y tareas del plan sin otro paso.',
@@ -76,15 +112,20 @@ const block = text => String(text).split('\n').map(line => ` │ ${line}`).join
76
112
  const fromLabel = message => message?.from?.role ? t('role {v0}', { v0: one(message.from.role) }) : message?.from?.kind ? one(message.from.kind) : t('operator');
77
113
  const bodyOf = message => message.pruned ? t('(body pruned by retention)') : quoted(message.body, 300);
78
114
 
79
- /** Un error de relayd queda con su código (INVALID_INPUT, NOT_FOUND…) en la conversación y en el recibo. */
80
- function relayFailure(method, error) {
115
+ /** Un error de relayd queda con su código (INVALID_INPUT, NOT_DELEGATED…) en la conversación y en el recibo. */
116
+ function relayFailure(method, error, extra = '') {
81
117
  if (!error?.rpc) return error;
82
- return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}`), { code: error.code, retryable: error.retryable === true });
118
+ return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}${extra}`), { code: error.code, retryable: error.retryable === true, ...(error.capability ? { capability: error.capability } : {}) });
119
+ }
120
+ /** Aviso en la tarjeta si la delegación vista en el hello no trae la capacidad que el método exige. */
121
+ function capabilityWarnings(caps, methods, warnings) {
122
+ if (!Array.isArray(caps)) return;
123
+ for (const method of methods) { const need = METHOD_CAPS[method]; if (need && !caps.includes(need)) warnings.push(t("Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.", { v0: need, v1: method })); }
83
124
  }
84
125
 
85
126
 
86
127
  // ── Reply ────────────────────────────────────────────────────────────────────
87
- export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal) {
128
+ export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal, { caps = () => null } = {}) {
88
129
  const warnings = [], lines = [], message = await messageOrNull(call, message_id, signal);
89
130
  if (!message) warnings.push(t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) }));
90
131
  const swarm = message?.swarm;
@@ -93,6 +134,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
93
134
  if (message) {
94
135
  lines.push(t('Original message {v0} ({v1}), agent text, untrusted:', { v0: one(message_id), v1: one(message.kind) }), ` ${bodyOf(message)}`);
95
136
  if (message.from?.kind === 'operator' || !message.from?.role) warnings.push(t('The original message does not come from a role: the reply unblocks no task.'));
137
+ const structured = decisionBlock(message);
138
+ if (structured) { lines.push(...decisionLines(structured)); warnings.push(t('This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.')); }
96
139
  }
97
140
  if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
98
141
  else if (linked) { lines.push(t('Task: {v0} ({v1}) already woke up from this message.', { v0: one(linked.task.id), v1: one(linked.task.state) })); warnings.push(t('Task {v0} is no longer blocked by this question.', { v0: one(linked.task.id) })); }
@@ -100,7 +143,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
100
143
  if (message && !delivery) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
101
144
  lines.push(t('Exact text to send ({v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
102
145
  lines.push(t('Confirm delivery (msg.ack): {v0}', { v0: ack ? t('yes, the message is marked read') : t('no') }));
103
- if (decision_note) lines.push(t('Decision note (only in the Comal receipt; phase 3 will store it as an ADR):'), block(decision_note));
146
+ if (decision_note) lines.push(t('Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):'), block(decision_note));
147
+ capabilityWarnings(caps(), ack ? ['msg.reply', 'msg.ack'] : ['msg.reply'], warnings);
104
148
  lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('reply', { in_reply_to: message_id, body }) }));
105
149
  return { title: t('Reply in open-relay · {v0}', { v0: `msg.reply${ack ? ' + msg.ack' : ''}` }), lines, warnings, editable: 'body' };
106
150
  }
@@ -113,7 +157,7 @@ export async function reply(call, mutate, { message_id, body, ack = false, decis
113
157
  let acked = null;
114
158
  if (ack) {
115
159
  // La respuesta ya salió: un ack fallido se informa, no borra el envío.
116
- try { const result = await mutate('msg.ack', { idempotency_key: idempotencyKey('ack', { delivery_id: message_id }), delivery_id: message_id }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
160
+ try { const delivery_id = operatorDelivery(message_id); const result = await mutate('msg.ack', { idempotency_key: idempotencyKey('ack', { delivery_id }), delivery_id }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
117
161
  catch (error) { acked = { status: 'failed', code: error.code || 'ERROR', message: String(error.message).slice(0, 300) }; }
118
162
  }
119
163
  let task = null;
@@ -123,18 +167,20 @@ export async function reply(call, mutate, { message_id, body, ack = false, decis
123
167
  sent: { in_reply_to: message_id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, idempotency_key: key, body, body_chars: body.length, body_sha256: sha256(body) },
124
168
  ack: acked, task,
125
169
  decision_note: decision_note ?? null,
126
- ...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt (phase 2). Phase 3 will record it as an ADR in relayd (open-relay v2.8).' } : {}),
170
+ ...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt. To record a structured decision as an ADR in relayd, use Decide.' } : {}),
127
171
  ...(sent.outcome === 'idempotent_replay' ? { note: 'relayd recognized the key: this reply had already been sent and was not duplicated.' } : {}),
128
172
  };
129
173
  }
130
174
 
131
175
  // ── Ack ──────────────────────────────────────────────────────────────────────
132
- const ackTarget = ({ delivery_id, message_id }) => delivery_id || message_id;
133
- export async function ackSummary(call, input, signal) {
176
+ // Siempre el id de la ENTREGA: relayd confirma entregas; con message_id, la del buzón del operador.
177
+ const ackTarget = ({ delivery_id, message_id }) => delivery_id || operatorDelivery(message_id);
178
+ export async function ackSummary(call, input, signal, { caps = () => null } = {}) {
134
179
  const target = ackTarget(input), message_id = input.message_id || messageOfDelivery(input.delivery_id), warnings = [];
135
180
  const message = await messageOrNull(call, message_id, signal);
136
181
  if (!message) warnings.push(t('relayd does not know message {v0}.', { v0: one(message_id) }));
137
182
  else if (!(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t('The delivery is no longer pending: acknowledging it changes nothing.'));
183
+ capabilityWarnings(caps(), ['msg.ack'], warnings);
138
184
  return { title: t('Mark as read in open-relay · msg.ack'), warnings, lines: [
139
185
  t('Delivery: {v0}', { v0: one(target) }),
140
186
  ...(message ? [t('Message {v0} ({v1}) from {v2} · swarm {v3}, agent text, untrusted:', { v0: one(message_id), v1: one(message.kind), v2: fromLabel(message), v3: one(message.swarm) }), ` ${bodyOf(message)}`] : []),
@@ -149,12 +195,18 @@ export async function ack(mutate, input, signal) {
149
195
  }
150
196
 
151
197
  // ── ApprovePlan / RejectPlan ────────────────────────────────────────────────
198
+ // open-relay `plan.retire`: hijas de la tarea descompuesta que el plan cancela y deja de exigir al aprobarse.
199
+ const retireOf = plan => (Array.isArray(plan?.retire) ? plan.retire : []).filter(id => typeof id === 'string');
200
+ const retireText = retire => `${retire.slice(0, 20).map(one).join(', ')}${retire.length > 20 ? t(' … and {v0} more', { v0: retire.length - 20 }) : ''}`;
152
201
  function planLines(got, warnings) {
153
202
  const proposal = got.proposal || {}, plan = proposal.plan || {}, lines = [];
154
203
  lines.push(t('Proposal {v0} · swarm {v1} · team {v2} · parent task {v3}', { v0: one(proposal.proposal_id), v1: one(proposal.swarm_id), v2: one(proposal.team_id), v3: one(proposal.parent_task) }));
155
204
  lines.push(t('Proposed by: role {v0} · status {v1}', { v0: one(proposal.proposer_role), v1: one(proposal.status) }));
156
205
  if (proposal.summary || plan.summary) lines.push(t('Summary (agent text, untrusted): {v0}', { v0: quoted(proposal.summary || plan.summary, 300) }));
157
206
  const tasks = plan.tasks || [], roles = plan.roles || [];
207
+ // open-relay `plan.retire`, antes de la lista de tareas para que se vea sin desplazarse.
208
+ const retire = retireOf(plan);
209
+ if (retire.length) lines.push(t('Retires (cancels and stops requiring) {v0}: {v1}', { v0: retire.length, v1: retireText(retire) }));
158
210
  lines.push(t('New tasks ({v0}):', { v0: tasks.length }));
159
211
  for (const task of tasks.slice(0, 20)) lines.push(` · ${one(task.id)}${task.title ? ` «${one(task.title)}»` : ''}${t(task.max_attempts === 1 ? ' → role {v0} · max. {v1} attempt' : ' → role {v0} · max. {v1} attempts', { v0: one(task.role), v1: task.max_attempts })}${task.deps?.length ? t(' · after {v0}', { v0: task.deps.map(one).join(', ') }) : ''}${task.criteria?.length ? t(' · criteria {v0}', { v0: task.criteria.map(item => one(item.id)).join(', ') }) : ''}`);
160
212
  if (tasks.length > 20) lines.push(t(' … and {v0} more (plugin__open-relay__Plan lists them)', { v0: tasks.length - 20 }));
@@ -168,11 +220,14 @@ function planLines(got, warnings) {
168
220
  return lines;
169
221
  }
170
222
 
171
- export async function approveSummary(call, { proposal_id, digest: wanted }, signal) {
223
+ export async function approveSummary(call, { proposal_id, digest: wanted }, signal, { caps = () => null } = {}) {
172
224
  const warnings = [t('Applies immediately: on approval, relayd creates the roles, grants and tasks of the plan with no further step.')];
173
225
  const got = await planOrNull(call, proposal_id, signal);
226
+ capabilityWarnings(caps(), ['plan.approve'], warnings);
174
227
  if (!got) return { title: t('Approve plan in open-relay · plan.approve'), lines: [t('Proposal {v0}', { v0: one(proposal_id) }), t('Digest: {v0}', { v0: one(wanted) })], warnings: [...warnings, t('relayd does not know proposal {v0}: the approval will fail.', { v0: one(proposal_id) })] };
175
- const lines = planLines(got, warnings), current = got.proposal?.digest;
228
+ const lines = planLines(got, warnings), current = got.proposal?.digest, retire = retireOf(got.proposal?.plan);
229
+ // Sólo al aprobar: rechazar no cancela nada.
230
+ if (retire.length) warnings.push(t('On approval, relayd cancels these tasks and stops requiring them: {v0}.', { v0: retireText(retire) }));
176
231
  lines.push(t('Approved digest: {v0}', { v0: one(wanted) }) + (current === wanted ? t(' (matches the current one)') : ''));
177
232
  if (current !== wanted) warnings.push(t('The digest does not match the current one ({v0}): relayd will reject the approval. Review the plan again.', { v0: one(current) }));
178
233
  lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('approve', { proposal_id, digest: wanted }) }));
@@ -184,17 +239,19 @@ export async function approve(mutate, { proposal_id, digest: wanted }, signal) {
184
239
  catch (error) { throw relayFailure('plan.approve', error); }
185
240
  }
186
241
 
187
- export async function rejectSummary(call, { proposal_id, reason }, signal) {
242
+ export async function rejectSummary(call, { proposal_id, reason }, signal, { caps = () => null } = {}) {
188
243
  const warnings = [], got = await planOrNull(call, proposal_id, signal);
244
+ capabilityWarnings(caps(), ['plan.reject'], warnings);
189
245
  const lines = got ? planLines(got, warnings) : [t('Proposal {v0}', { v0: one(proposal_id) })];
190
246
  if (!got) warnings.push(t('relayd does not know proposal {v0}: the rejection will fail.', { v0: one(proposal_id) }));
191
247
  lines.push(t('Exact reason the lead receives (it is woken up with it):'), block(reason));
192
248
  lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('reject', { proposal_id, reason }) }));
193
249
  return { title: t('Reject plan in open-relay · plan.reject'), lines, warnings, editable: 'reason' };
194
250
  }
195
- export async function reject(mutate, { proposal_id, reason }, signal) {
251
+ export async function reject(mutate, { proposal_id, reason, expected_status }, signal) {
196
252
  const key = idempotencyKey('reject', { proposal_id, reason });
197
- try { return decision(await mutate('plan.reject', { idempotency_key: key, proposal_id, reason }, signal), key); }
253
+ // `expected_status` (D10, relayd v2.31): sólo viaja si quien llama lo pide; no cambia la clave.
254
+ try { return decision(await mutate('plan.reject', { idempotency_key: key, proposal_id, reason, ...(expected_status ? { expected_status } : {}) }, signal), key); }
198
255
  catch (error) { throw relayFailure('plan.reject', error); }
199
256
  }
200
257
  function decision(result, key) {
@@ -203,3 +260,142 @@ function decision(result, key) {
203
260
  proposal: { proposal_id: proposal.proposal_id, status: proposal.status, approver: proposal.approver ?? null, digest: proposal.digest, reason: proposal.reason ? excerpt(proposal.reason, 500) : null, tasks: proposal.tasks, roles: proposal.roles },
204
261
  steps: (result.steps || []).slice(0, 40).map(step => ({ kind: step.kind, id: step.id, method: step.method, action: step.action, result: step.result ?? null })) };
205
262
  }
263
+
264
+ // ── Decide (fase 3) ─────────────────────────────────────────────────────────
265
+ // Respuesta a una pregunta estructurada que queda como ADR en relayd. El HOST redacta
266
+ // el ADR (decision.mjs) con la pregunta tal como la guarda relayd, la opción elegida y
267
+ // la nota; la tarjeta lo muestra entero con su digest y sólo el humano la aprueba
268
+ // (confirm:'always'). Tres mutaciones con claves estables derivadas de la misma
269
+ // entrada: un reintento de la misma tarjeta retoma donde quedó sin duplicar nada.
270
+ const yesNo = value => value ? t('yes') : t('no');
271
+ /** Líneas legibles del bloque `decision` (texto del agente, ya limpio y acotado). */
272
+ export function decisionLines(block, chosen = null) {
273
+ const lines = [t('Structured question (agent text, untrusted): {v0}', { v0: `«${block.question}»` }), t('Options:')];
274
+ block.options.forEach((item, index) => {
275
+ lines.push(t(' {v0}. {v1}{v2}', { v0: index + 1, v1: `«${item.label}»`, v2: `${item.label === chosen?.label ? t(' ← chosen') : ''}${item.label === block.recommendation?.option ? t(' · recommended by the agent') : ''}` }));
276
+ if (item.consequences) lines.push(t(' consequences: {v0}', { v0: excerpt(item.consequences, 400) }));
277
+ });
278
+ if (block.recommendation) lines.push(t('Agent recommendation: {v0} — {v1}', { v0: `«${block.recommendation.option}»`, v1: excerpt(block.recommendation.why, 400) }));
279
+ if (block.evidence.length) lines.push(t('Evidence cited (references, not verified): {v0}', { v0: block.evidence.map(item => `${item.kind}:${one(item.ref)}`).join(' · ') }));
280
+ lines.push(t('Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}', { v0: block.scope, v1: yesNo(block.reversible), v2: block.cost_of_waiting ? excerpt(block.cost_of_waiting, 300) : t('not stated') }));
281
+ return lines;
282
+ }
283
+ const decideBase = ({ message_id, option, body, decision_note, scope }) => digest({ action: 'decide', message_id, option, body, decision_note: decision_note ?? null, scope: scope ?? null }).slice(0, 48);
284
+ export const decideKeys = input => { const base = decideBase(input); return { propose: `comal-adr-propose-${base}`, record: `comal-adr-record-${base}`, reply: `comal-decide-reply-${base}`, ack: idempotencyKey('ack', { delivery_id: operatorDelivery(input.message_id) }) }; };
285
+ const scopeText = scope => scope.id ? `${scope.kind}:${one(scope.id)}` : scope.kind;
286
+
287
+ /** Lo que la tarjeta y la ejecución comparten: mensaje, bloque, opción, tarea, alcance y ADR. */
288
+ async function prepareDecision(call, { message_id, option, decision_note, scope }, signal) {
289
+ const message = await messageOrNull(call, message_id, signal);
290
+ if (!message) return { message: null };
291
+ const block = decisionBlock(message), chosen = chosenOption(block, option);
292
+ const linked = block ? await taskForMessage(call, message.swarm, message_id, signal) : null;
293
+ const { scope: adrScopeValue, note } = block ? adrScope(block, { scope, message, task: linked?.task }) : { scope: null, note: null };
294
+ const gates = chosen ? await adrGates(call, signal) : false;
295
+ const adr = chosen ? draftAdr({ message, block, option, decision_note, scope: adrScopeValue, gates }) : null;
296
+ return { message, block, chosen, linked, adr, scopeNote: note };
297
+ }
298
+ /** ¿Hace cumplir relayd los ADR (`daemon.version.features`)? Uno anterior rechaza el campo `context_only`. */
299
+ async function adrGates(call, signal) {
300
+ try { const v = await call('daemon.version', {}, signal); return Array.isArray(v?.features) && v.features.includes('adr_enforcement'); }
301
+ // Sin respuesta (relayd anterior o simulado): sin compuertas; la mutación siguiente dirá si hay otro problema.
302
+ catch (error) { if (signal?.aborted) throw error; return false; }
303
+ }
304
+ async function existingAdrs(call, message, signal) {
305
+ try { const { decisions = [] } = await call('decision.list', { swarm_id: message.swarm, limit: 200 }, signal); return decisions.filter(item => item.answers === message.id); }
306
+ catch (error) { if (error.rpc) return []; throw error; }
307
+ }
308
+
309
+ export async function decideSummary(call, input, signal, { caps = () => null } = {}) {
310
+ const { message_id, option, body, ack = false } = input, warnings = [], lines = [];
311
+ const title = t('Record a decision in open-relay · decision.propose + decision.record + msg.reply');
312
+ const { message, block: structured, chosen, linked, adr, scopeNote } = await prepareDecision(call, input, signal);
313
+ if (!message) return { title, lines: [t('Delivery: {v0}', { v0: one(message_id) })], warnings: [t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) })] };
314
+ lines.push(t('Question {v0} from {v1} · swarm {v2}', { v0: one(message_id), v1: fromLabel(message), v2: one(message.swarm) }));
315
+ if (!structured) warnings.push(t('The message is not a structured question (no decision block): Decide will fail. Use Reply.'));
316
+ else {
317
+ lines.push(...decisionLines(structured, chosen));
318
+ if (!chosen) warnings.push(t('Option «{v0}» is not one of the question\'s options: Decide will fail.', { v0: one(option) }));
319
+ }
320
+ if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
321
+ if (scopeNote) warnings.push(scopeNote);
322
+ if (adr) {
323
+ lines.push(t('ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):'));
324
+ lines.push(t(' Title: {v0}', { v0: adr.title }), t(' Scope: {v0}', { v0: scopeText(adr.scope) }), t(' Answers: {v0}', { v0: one(adr.answers) }));
325
+ lines.push(t(' Context:'), block4(adr.context), t(' Decision:'), block4(adr.decision));
326
+ if (adr.alternatives.length) lines.push(t(' Discarded alternatives:'), block4(adr.alternatives.map(item => `${item.option}: ${item.why_rejected}`).join('\n')));
327
+ lines.push(t(' Consequences:'), block4(adr.consequences), t(' ADR digest: {v0}', { v0: adrDigest(adr) }));
328
+ for (const item of (await existingAdrs(call, message, signal)).slice(0, 4)) warnings.push(t('Already recorded for this question: {v0}', { v0: `${one(item.id)} (${one(item.status)}${item.in_force ? ', in force' : ''}) «${one(item.title)}»` }));
329
+ }
330
+ lines.push(t('Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
331
+ lines.push(t('Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}', { v0: ack ? t(' · 4. msg.ack') : '' }));
332
+ if (message && !(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
333
+ capabilityWarnings(caps(), ['decision.propose', 'decision.record', 'msg.reply', ...(ack ? ['msg.ack'] : [])], warnings);
334
+ const keys = decideKeys(input);
335
+ lines.push(t('Idempotency keys: {v0}', { v0: [keys.propose, keys.record, keys.reply].join(' · ') }));
336
+ return { title, lines, warnings, editable: 'body' };
337
+ }
338
+ const block4 = text => String(text).split('\n').map(line => ` │ ${line}`).join('\n');
339
+ const fail = (code, message) => Object.assign(new Error(message), { code });
340
+
341
+ export async function decide(call, mutate, input, signal) {
342
+ const { message_id, option, body, ack = false } = input, keys = decideKeys(input);
343
+ const { message, block: structured, chosen, linked, adr } = await prepareDecision(call, input, signal);
344
+ if (!message) throw fail('NOT_FOUND', `relayd does not know message ${one(message_id)}.`);
345
+ if (!structured) throw fail('DECISION_NOT_STRUCTURED', `Message ${one(message_id)} is not a structured question (it has no decision block): use Reply.`);
346
+ if (!chosen || !adr) throw fail('DECISION_UNKNOWN_OPTION', `«${one(option)}» is not one of the options: ${structured.options.map(item => `«${item.label}»`).join(', ')}.`);
347
+ let proposed, recorded, sent;
348
+ try { proposed = await mutate('decision.propose', { idempotency_key: keys.propose, adr }, signal); }
349
+ catch (error) { throw relayFailure('decision.propose', error); }
350
+ const id = proposed.decision?.id;
351
+ try { recorded = await mutate('decision.record', { idempotency_key: keys.record, decision_id: id }, signal); }
352
+ catch (error) { throw relayFailure('decision.record', error, ` (ADR ${id} stays proposed; retrying the same card resumes without duplicating it)`); }
353
+ try { sent = await mutate('msg.reply', { idempotency_key: keys.reply, in_reply_to: message_id, body, decision_id: id }, signal); }
354
+ catch (error) { throw relayFailure('msg.reply', error, ` (ADR ${id} is recorded and in force; retrying the same card sends the reply without duplicating anything)`); }
355
+ let acked = null;
356
+ if (ack) {
357
+ try { const result = await mutate('msg.ack', { idempotency_key: keys.ack, delivery_id: operatorDelivery(message_id) }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
358
+ catch (error) { acked = { status: 'failed', code: error.code || 'ERROR', message: String(error.message).slice(0, 300) }; }
359
+ }
360
+ const view = recorded.decision || proposed.decision || {};
361
+ const replayed = [proposed, recorded, sent].every(item => item.outcome === 'idempotent_replay');
362
+ return {
363
+ truncated: false, status: replayed ? 'replayed' : 'applied', outcome: sent.outcome,
364
+ decision: { id, status: view.status, in_force: view.in_force, title: view.title, scope: view.scope, answers: view.answers ?? null, proposed_via: view.proposed_via ?? null, accepted_via: view.accepted_via ?? null, adr_digest: adrDigest(adr), propose_outcome: proposed.outcome, record_outcome: recorded.outcome },
365
+ sent: { in_reply_to: message_id, decision_id: id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, body, body_chars: body.length, body_sha256: sha256(body) },
366
+ ack: acked, task: linked ? { id: linked.task.id, state: linked.task.state, link: linked.link } : null,
367
+ idempotency_keys: keys,
368
+ ...(replayed ? { note: 'relayd recognized the keys: this decision and its reply had already been recorded and were not duplicated.' } : {}),
369
+ };
370
+ }
371
+
372
+ // ── Revisor (open-relay F7) ─────────────────────────────────────────────────
373
+ // Decisiones del humano sobre el revisor, siempre desde una tarjeta suya (capacidad plans):
374
+ // aceptar o rechazar los criterios de éxito (el digest exacto que vio) y aceptar un candidato
375
+ // sobre la objeción del revisor (review.override, con motivo; queda en el log de relayd).
376
+ const outcomeOf = (result, key) => ({ status: result?.outcome === 'idempotent_replay' ? 'replayed' : result?.outcome === 'unchanged' ? 'unchanged' : 'applied', outcome: result?.outcome ?? null, head_seq: result?.head_seq ?? null, idempotency_key: key });
377
+ export async function acceptCriteria(mutate, { swarm_id, digest: wanted }, signal) {
378
+ const key = idempotencyKey('criteria-accept', { swarm_id, digest: wanted });
379
+ try { return outcomeOf(await mutate('criteria.accept', { idempotency_key: key, swarm_id, digest: wanted }, signal), key); }
380
+ catch (error) { throw relayFailure('criteria.accept', error); }
381
+ }
382
+ export async function rejectCriteria(mutate, { swarm_id, digest: wanted, reason }, signal) {
383
+ const key = idempotencyKey('criteria-reject', { swarm_id, digest: wanted, reason });
384
+ try { return outcomeOf(await mutate('criteria.reject', { idempotency_key: key, swarm_id, digest: wanted, reasons: [reason] }, signal), key); }
385
+ catch (error) { throw relayFailure('criteria.reject', error); }
386
+ }
387
+ export async function reopenCriteria(mutate, { swarm_id, digest: wanted, reason }, signal) {
388
+ const key = idempotencyKey('criteria-reopen', { swarm_id, digest: wanted, reason });
389
+ try { return outcomeOf(await mutate('criteria.reopen', { idempotency_key: key, swarm_id, digest: wanted, reasons: [reason] }, signal), key); }
390
+ catch (error) { throw relayFailure('criteria.reopen', error); }
391
+ }
392
+ export async function retryReview(mutate, { task_id, reason }, signal) {
393
+ const key = idempotencyKey('review-retry', { task_id, reason });
394
+ try { return outcomeOf(await mutate('review.retry', { idempotency_key: key, task_id, reason }, signal), key); }
395
+ catch (error) { throw relayFailure('review.retry', error); }
396
+ }
397
+ export async function overrideReview(mutate, { task_id, reason }, signal) {
398
+ const key = idempotencyKey('review-override', { task_id, reason });
399
+ try { return outcomeOf(await mutate('review.override', { idempotency_key: key, task_id, reason }, signal), key); }
400
+ catch (error) { throw relayFailure('review.override', error); }
401
+ }
@@ -1,57 +1,101 @@
1
1
  import net from 'node:net';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { constants } from 'node:fs';
4
- import { open } from 'node:fs/promises';
5
+ import { open, stat } from 'node:fs/promises';
5
6
 
6
7
  // Cliente JSON-RPC 2.0 por líneas sobre el socket administrativo de relayd
7
- // (`<control>/admin/relay.sock`). Las listas blancas de métodos viven aquí: 16
8
- // lecturas (`call`) y, desde la fase 2, cuatro mutaciones (`mutate`) que el plugin
9
- // sólo usa tras la tarjeta de confirmación humana. Cualquier otro método se rechaza
10
- // sin llegar al socket: ningún job, swarm.create, grant ni sesión.
8
+ // (`<control>/admin/relay.sock`). Desde la fase 3 (open-relay ≥ 2.8) se autentica con
9
+ // una sesión DELEGADA (`relay session issue-delegate --client comal`), nunca con
10
+ // `admin.token`: relayd firma lo que hace como «operador vía comal» y sólo admite los
11
+ // métodos de sus capacidades. Las listas blancas de métodos siguen aquí: 19 lecturas
12
+ // (`call`) y seis mutaciones (`mutate`) que el plugin sólo usa tras la tarjeta de
13
+ // confirmación humana. Cualquier otro método se rechaza sin llegar al socket.
11
14
  export const READ_METHODS = Object.freeze(new Set([
12
15
  'daemon.status', 'daemon.version', 'swarm.list', 'swarm.status', 'task.get', 'task.list',
13
16
  'msg.inbox', 'msg.get', 'msg.wait', 'plan.list', 'plan.get', 'job.list', 'job.get', 'job.logs',
14
- 'receipt.get', 'events.since',
17
+ 'receipt.get', 'events.since', 'events.wait', 'decision.list', 'decision.get', 'criteria.get',
15
18
  ]));
16
- export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject']));
17
- // El alcance de idempotencia del canal admin es compartido ("operator") por todos sus
18
- // clientes: las claves de Comal llevan prefijo propio y caben en los 128 de relayd.
19
+ export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject', 'decision.propose', 'decision.record', 'criteria.accept', 'criteria.reject', 'criteria.reopen', 'review.override', 'review.retry']));
20
+ // Capacidad de la delegación que relayd exige para cada mutación (lecturas: `read`).
21
+ export const METHOD_CAPS = Object.freeze({ 'msg.reply': 'converse', 'msg.ack': 'converse', 'plan.approve': 'plans', 'plan.reject': 'plans', 'decision.propose': 'decide', 'decision.record': 'decide', 'criteria.accept': 'plans', 'criteria.reject': 'plans', 'criteria.reopen': 'plans', 'review.override': 'plans', 'review.retry': 'plans' });
22
+ export const RECOMMENDED_CAPS = 'read,converse,plans,decide';
23
+ // El `client` del hello debe ser exactamente el de la delegación: así un token emitido
24
+ // para otro programa (p. ej. la web) no sirve a Comal.
25
+ export const DELEGATE_CLIENT = 'comal';
26
+ // Las claves de idempotencia son del alcance de la sesión delegada; el prefijo comal-
27
+ // se mantiene para reconocerlas en el log de relayd.
19
28
  export const IDEMPOTENCY_KEY = /^comal-[A-Za-z0-9][A-Za-z0-9_.:-]{0,121}$/;
20
29
  export const PROTOCOL_VERSION = 1;
30
+ const TOKEN_FORMAT = /^orly_[0-9a-f]{64}$/;
21
31
  // relayd acota cada frame a 1 MiB por omisión; las respuestas de lectura del CLI admiten 16×.
22
32
  const MAX_RESPONSE_BYTES = 16 << 20;
23
33
  const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
24
34
 
25
- export function controlPaths(controlDir) {
26
- const root = path.resolve(controlDir);
27
- return { root, admin: path.join(root, 'admin'), socket: path.join(root, 'admin', 'relay.sock'), token: path.join(root, 'admin', 'admin.token') };
35
+ export function controlPaths(controlDir, tokenFile) {
36
+ const root = path.resolve(controlDir), admin = path.join(root, 'admin');
37
+ return { root, admin, socket: path.join(admin, 'relay.sock'), adminToken: path.join(admin, 'admin.token'), token: tokenFile ? path.resolve(tokenFile) : path.join(root, 'comal.token') };
28
38
  }
29
39
 
30
- /** Lee el token en cada conexión: relayd lo reescribe en cada arranque. Nunca se expone. */
31
- async function readToken(file) {
40
+ const shellWord = value => /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
41
+ const principal = () => { try { return os.userInfo().username || 'operator'; } catch { return 'operator'; } };
42
+ /** El comando exacto que el humano corre (una vez) para emitir el token de Comal. */
43
+ export function issueCommand(paths, caps = RECOMMENDED_CAPS) {
44
+ return `relay --control-dir ${shellWord(paths.root)} session issue-delegate --principal ${shellWord(principal())} --client ${DELEGATE_CLIENT} --caps ${caps} --ttl 30d --out ${shellWord(paths.token)}`;
45
+ }
46
+ const reissue = paths => `Remove ${paths.token} and issue a new one: ${issueCommand(paths)}`;
47
+
48
+ /**
49
+ * Lee el token delegado en cada conexión (el humano puede reemitirlo sin recargar el
50
+ * plugin). Nunca lee `admin.token`: ni como respaldo ni si la ruta configurada lo es.
51
+ * El valor nunca sale de aquí salvo hacia el hello.
52
+ */
53
+ async function readToken(paths) {
54
+ if (paths.token === paths.adminToken || path.dirname(paths.token) === paths.admin) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `Comal never authenticates with relayd's admin token. Use a delegate token outside ${paths.admin}: ${issueCommand({ ...paths, token: path.join(paths.root, 'comal.token') })}`);
32
55
  let handle;
33
56
  try {
34
- handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
57
+ handle = await open(paths.token, constants.O_RDONLY | constants.O_NOFOLLOW);
35
58
  const info = await handle.stat(), uid = process.getuid?.();
36
- if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE', "relayd's admin token is not a private (0600) file owned by the user.");
37
- const token = (await handle.readFile('utf8')).trim();
38
- if (!token) throw fail('RELAY_UNAVAILABLE', "relayd's admin token is empty.");
59
+ if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is not a private (0600) file owned by the user.`);
60
+ // Un enlace duro a admin.token también se rechaza (sólo se mira su inodo, nunca su contenido).
61
+ const admin = await stat(paths.adminToken).catch(() => null);
62
+ if (admin && admin.ino === info.ino && admin.dev === info.dev) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `${paths.token} is relayd's admin token, not a delegate session. ${reissue(paths)}`);
63
+ const token = (await handle.readFile('utf8')).replace(/\r?\n$/, '');
64
+ if (!TOKEN_FORMAT.test(token)) throw fail('RELAY_TOKEN_INVALID', `The delegate token ${paths.token} does not have the orly_<64 hex> format. ${reissue(paths)}`);
39
65
  return token;
40
66
  } catch (error) {
41
- if (error.code === 'ENOENT') throw fail('RELAY_UNAVAILABLE', 'relayd is not running in that control directory (the admin token is missing).');
67
+ if (error.code === 'ENOENT') {
68
+ const running = await stat(paths.adminToken).then(() => true, () => false);
69
+ throw fail('RELAY_DELEGATE_MISSING', running
70
+ ? `Comal has no delegate token for relayd at ${paths.token} (Comal never uses admin.token). Ask the human to issue it once: ${issueCommand(paths)}`
71
+ : `No delegate token at ${paths.token}, and relayd does not seem to be running in ${paths.root}. Start relayd, then issue the token: ${issueCommand(paths)}`);
72
+ }
73
+ if (error.code === 'ELOOP') throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is a symlink; use a regular 0600 file.`);
42
74
  throw error;
43
75
  } finally { await handle?.close(); }
44
76
  }
45
77
 
78
+ /** Mensaje legible para un error de relayd; NOT_DELEGATED y UNAUTHORIZED dicen qué hacer. */
79
+ function rpcFailure(method, data, paths) {
80
+ const code = data.code || 'RELAY_ERROR', text = String(data.message || 'relayd error').slice(0, 500);
81
+ if (code === 'NOT_DELEGATED') {
82
+ const capability = METHOD_CAPS[method] || /capacidad (\w+)/.exec(text)?.[1] || (READ_METHODS.has(method) ? 'read' : null);
83
+ return fail(code, `${text} — Comal's delegate session cannot do ${method}${capability ? ` (missing capability: ${capability})` : ''}. Not retried: ask the human for a delegation with that capability. ${reissue(paths)}`, { retryable: false, rpc: true, capability });
84
+ }
85
+ // En una llamada, UNAUTHORIZED con «sesión revocada/expirada» es la delegación; en decision.get es «no existe».
86
+ if (code === 'UNAUTHORIZED' && (method === 'daemon.hello' || /revoc|expir/i.test(text))) return fail(code, `relayd rejected Comal's delegate token (${text}): it was revoked, expired, or issued for a client other than "${DELEGATE_CLIENT}". ${reissue(paths)}`, { retryable: false, rpc: true });
87
+ return fail(code, text, { retryable: data.retryable === true, rpc: true });
88
+ }
89
+
46
90
  /** Una conexión autenticada. Las esperas largas usan una conexión propia. */
47
91
  export class RelayConnection {
48
- constructor({ controlDir, client = 'comal', timeoutMs = 10000, connect = options => net.createConnection(options) }) {
49
- Object.assign(this, { paths: controlPaths(controlDir), client, timeoutMs, connect });
92
+ constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect = options => net.createConnection(options) }) {
93
+ Object.assign(this, { paths: controlPaths(controlDir, tokenFile), client, timeoutMs, connect });
50
94
  this.pending = new Map(); this.nextId = 1; this.closed = false; this.socket = null; this.session = null;
51
95
  }
52
96
  async open(signal) {
53
97
  signal?.throwIfAborted();
54
- const token = await readToken(this.paths.token);
98
+ const token = await readToken(this.paths);
55
99
  const socket = this.socket = this.connect({ path: this.paths.socket });
56
100
  let buffer = Buffer.alloc(0);
57
101
  await new Promise((resolve, reject) => {
@@ -75,10 +119,16 @@ export class RelayConnection {
75
119
  });
76
120
  socket.on('error', error => this.#fail(fail('RELAY_DISCONNECTED', `Connection to relayd lost (${error.code || error.message}).`)));
77
121
  socket.on('close', () => this.#fail(fail('RELAY_DISCONNECTED', 'relayd closed the connection.')));
78
- try { this.session = await this.#request('daemon.hello', { protocol_versions: [PROTOCOL_VERSION], token, client: this.client }, { signal }); }
79
- catch (error) { this.close(); throw error; }
122
+ try {
123
+ const session = await this.#request('daemon.hello', { protocol_versions: [PROTOCOL_VERSION], token, client: this.client }, { signal });
124
+ // Sin `delegate` el token es del operador directo (p. ej. una copia de admin.token): no se usa.
125
+ if (!session?.delegate) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `The token in ${this.paths.token} is a direct operator token, not a delegate session; Comal only operates through a delegation. ${reissue(this.paths)}`);
126
+ this.session = session;
127
+ } catch (error) { this.close(); throw error; }
80
128
  return this.session;
81
129
  }
130
+ /** Lo que relayd dijo de la delegación en el hello: principal, client, caps, persistent. */
131
+ get delegate() { return this.session?.delegate || null; }
82
132
  #fail(error) {
83
133
  if (this.closed) return; this.closed = true; this.error = error;
84
134
  for (const entry of this.pending.values()) entry.finish(null, error);
@@ -89,7 +139,7 @@ export class RelayConnection {
89
139
  if (!READ_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for reading: ${method}`));
90
140
  return this.#request(method, params, { signal, timeoutMs });
91
141
  }
92
- /** Sólo las cuatro mutaciones de la lista blanca y siempre con una clave de idempotencia de Comal. */
142
+ /** Sólo las mutaciones de la lista blanca y siempre con una clave de idempotencia de Comal. */
93
143
  mutate(method, params = {}, { signal, timeoutMs } = {}) {
94
144
  if (!WRITE_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for Comal: ${method}`));
95
145
  if (typeof params?.idempotency_key !== 'string' || !IDEMPOTENCY_KEY.test(params.idempotency_key)) return Promise.reject(fail('RELAY_IDEMPOTENCY_KEY', 'Every mutation needs a comal-… idempotency_key of up to 128 characters.'));
@@ -103,7 +153,7 @@ export class RelayConnection {
103
153
  const finish = (message, error) => {
104
154
  clearTimeout(timer); signal?.removeEventListener('abort', abort);
105
155
  if (error) return reject(error);
106
- if (message.error) { const data = message.error.data || {}; return reject(fail(data.code || 'RELAY_ERROR', String(data.message || message.error.message || 'relayd error').slice(0, 500), { retryable: data.retryable === true, rpc: true })); }
156
+ if (message.error) return reject(rpcFailure(method, { message: message.error.message, ...(message.error.data || {}) }, this.paths));
107
157
  resolve(message.result);
108
158
  };
109
159
  const abort = () => { if (this.pending.delete(id)) finish(null, signal.reason); };
@@ -116,21 +166,29 @@ export class RelayConnection {
116
166
  close() { this.#fail(fail('RELAY_DISCONNECTED', 'Connection closed by Comal.')); }
117
167
  }
118
168
 
119
- // Errores tras los cuales conviene reconectar (relayd reiniciado, sesión vencida, token nuevo).
120
- export const reconnectable = error => ['RELAY_DISCONNECTED', 'RELAY_UNAVAILABLE', 'RELAY_TIMEOUT', 'UNAUTHORIZED', 'RELAY_PROTOCOL'].includes(error?.code);
169
+ // Errores tras los cuales conviene reconectar (relayd reiniciado o recuperándose).
170
+ // UNAUTHORIZED, NOT_DELEGATED y los del archivo del token necesitan al humano.
171
+ export const reconnectable = error => ['RELAY_DISCONNECTED', 'RELAY_UNAVAILABLE', 'RELAY_TIMEOUT', 'RELAY_PROTOCOL', 'UNAVAILABLE'].includes(error?.code);
172
+ // Errores que ninguna espera arregla: hace falta otra delegación o que el humano corrija el token.
173
+ export const needsHuman = error => ['NOT_DELEGATED', 'UNAUTHORIZED', 'RELAY_DELEGATE_MISSING', 'RELAY_ADMIN_TOKEN_REFUSED', 'RELAY_TOKEN_UNSAFE', 'RELAY_TOKEN_INVALID'].includes(error?.code);
121
174
 
122
175
  /** Conexión de consultas compartida, con reconexión perezosa y un reintento (las lecturas son idempotentes). */
123
176
  export class RelayClient {
124
- constructor({ controlDir, client = 'comal', timeoutMs = 10000, connect }) {
125
- Object.assign(this, { controlDir, client, timeoutMs, connect });
126
- this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null;
177
+ constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect }) {
178
+ Object.assign(this, { controlDir, tokenFile, client, timeoutMs, connect, paths: controlPaths(controlDir, tokenFile) });
179
+ this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null; this.session = null;
127
180
  }
128
- connection_options() { return { controlDir: this.controlDir, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
129
- /** Conexión nueva y dedicada (para msg.wait / swarm.status con if_changed). */
181
+ connection_options() { return { controlDir: this.controlDir, tokenFile: this.tokenFile, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
182
+ /** La delegación vista en el último hello (sin el token). */
183
+ get delegate() { return this.session?.delegate || null; }
184
+ issueCommand() { return issueCommand(this.paths); }
185
+ /** Conexión nueva y dedicada (para events.wait). */
130
186
  async dedicated(signal) {
131
187
  if (this.closed) throw fail('RELAY_CLOSED', 'open-relay plugin closed.');
132
188
  const connection = new RelayConnection(this.connection_options());
133
- await connection.open(signal); this.connects++; return connection;
189
+ try { await connection.open(signal); }
190
+ catch (error) { this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() }; throw error; }
191
+ this.connects++; this.session = connection.session; return connection;
134
192
  }
135
193
  async #shared(signal) {
136
194
  if (this.connection && !this.connection.closed) return this.connection;
@@ -148,7 +206,7 @@ export class RelayClient {
148
206
  for (let attempt = 0; ; attempt++) {
149
207
  try { const connection = await this.#shared(options.signal); const result = await connection[kind](method, params, options); this.lastError = null; return result; }
150
208
  catch (error) {
151
- this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 300), at: new Date().toISOString() };
209
+ this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() };
152
210
  if (attempt || !reconnectable(error) || options.signal?.aborted) throw error;
153
211
  this.connection?.close(); this.connection = null;
154
212
  }