@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
package/src/provider.mjs CHANGED
@@ -3,6 +3,7 @@ import { normalizeUsage, bindUsageAccounting, accountingIdentity } from './usage
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { visionSettings } from './vision.mjs';
5
5
  import { normalizeEffort, providerEffortWire } from './model-controls.mjs';
6
+ import { modelDeadline, modelTimingSettings } from './model-deadline.mjs';
6
7
 
7
8
  export function mergeMessages(messages) {
8
9
  const merged = [];
@@ -62,37 +63,164 @@ function truncationDetails(content = []) {
62
63
  ...(last?.type === 'tool_use' && typeof last.name === 'string' && last.name ? { tool_name: last.name } : {}) };
63
64
  }
64
65
 
65
- function validateMessage(message) {
66
+ const clip = (value, max = 300) => typeof value === 'string' ? (value.length > max ? value.slice(0, max) + '…' : value) : undefined;
67
+
68
+ // Forma del request sin contenido (roles, tipos de bloque, breakpoints) para
69
+ // diagnosticar un 400. Nunca cabeceras, texto ni argumentos.
70
+ export function requestShape(wire = {}) {
71
+ const marks = blocks => Array.isArray(blocks) ? blocks.filter(block => block?.cache_control).length : 0;
72
+ const describe = block => `${block?.type}${block?.cache_control ? '*' : ''}${block?.type === 'thinking' ? (block.signature ? '+sig' : '-sig') : ''}${block?.type === 'text' && !block.text ? '(empty)' : ''}`;
73
+ const messages = (wire.messages || []).map(message => `${message.role?.[0]}:${Array.isArray(message.content) ? message.content.map(describe).join(',') : 'string'}`);
74
+ return { cache_breakpoints: marks(wire.tools) + marks(Array.isArray(wire.system) ? wire.system : []) + (wire.messages || []).reduce((sum, message) => sum + marks(message.content), 0),
75
+ messages: messages.length > 24 ? [...messages.slice(0, 4), `…${messages.length - 14} more…`, ...messages.slice(-10)] : messages };
76
+ }
77
+
78
+ // Conserva el cuerpo de error de la API (status, error.type, error.message) en
79
+ // vez de un mensaje genérico. Las cabeceras del request nunca se registran.
80
+ export async function providerHttpError(response, label = 'Anthropic', wire) {
81
+ let body = null;
82
+ try { const text = await response.text(); try { body = JSON.parse(text); } catch { body = { error: { type: 'non_json_body', message: clip(text, 200) } }; } } catch {}
83
+ const errorType = typeof body?.error?.type === 'string' ? body.error.type : 'unknown', apiMessage = clip(body?.error?.message) || '';
84
+ const requestId = response.headers?.get?.('request-id') || (typeof body?.request_id === 'string' ? body.request_id : null);
85
+ return Object.assign(new Error(`${label} HTTP ${response.status} ${errorType}: ${apiMessage || 'no message'}`), { code: 'PROVIDER_HTTP_ERROR', status: response.status,
86
+ details: { status: response.status, error_type: errorType, error_message: apiMessage, ...(requestId ? { request_id: clip(requestId, 80) } : {}), ...(wire ? { request_shape: requestShape(wire) } : {}) } });
87
+ }
88
+
89
+ function invalidToolCall(reason, index, block, message, trace) {
90
+ const name = typeof block?.name === 'string' ? clip(block.name, 64) : undefined;
91
+ return Object.assign(new Error(`Invalid tool call: block ${index}${name ? ` (${name})` : ''} ${reason}.`), { code: 'TOOL_CALL_INVALID', usage: message.usage, usage_accounting: normalizeUsage('anthropic', message.usage),
92
+ details: { block_index: index, block_type: block?.type, ...(name ? { tool_name: name } : {}), reason, ...(block?.truncated_input_chars !== undefined ? { input_chars: block.truncated_input_chars } : {}),
93
+ ...(block?.input_parse_error ? { parse_error: block.input_parse_error } : {}), stop_reason: message.stop_reason, ...(trace ? { stream_blocks: trace } : {}) } });
94
+ }
95
+
96
+ function validateMessage(message, trace) {
66
97
  if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
67
98
  if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage),details:truncationDetails(message.content)});
68
99
  const ids = new Set();
69
- for (const block of message.content) {
100
+ for (const [index, block] of message.content.entries()) {
70
101
  if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
71
102
  if (block.type === 'tool_use') {
72
- if (typeof block.id !== 'string' || !block.id || ids.has(block.id) || typeof block.name !== 'string' || !block.input || typeof block.input !== 'object' || Array.isArray(block.input)) throw new Error('Invalid tool call.');
103
+ const reason = typeof block.id !== 'string' || !block.id ? 'has no id' : ids.has(block.id) ? 'repeats an id' : typeof block.name !== 'string' || !block.name ? 'has no name'
104
+ : block.input_parse_error ? 'has unparseable input JSON' : !block.input || typeof block.input !== 'object' || Array.isArray(block.input) ? `has ${Array.isArray(block.input) ? 'an array' : block.input === null ? 'null' : typeof block.input} input` : null;
105
+ if (reason) throw invalidToolCall(reason, index, block, message, trace);
73
106
  ids.add(block.id);
74
107
  }
75
108
  }
76
109
  return message;
77
110
  }
78
111
 
112
+ // Prompt caching explícito (válido en todos los modelos Claude): tres breakpoints
113
+ // como máximo, de lo más estable a lo más reciente. 1) la última herramienta
114
+ // (caché de tools), 2) el system (tools+system), 3) el último bloque cacheable
115
+ // del último mensaje (prefijo completo de la conversación). El turno siguiente
116
+ // lee hasta el breakpoint previo y escribe sólo lo nuevo. Bloques thinking no
117
+ // admiten cache_control; se busca hacia atrás el último que sí.
118
+ export const adaptiveThinkingModel = model => /^claude-(?:opus|sonnet|haiku)-5(?:[-.]|$)/.test(model || '');
119
+ // Modelos cuyos clasificadores enrutan a un fallback del lado del servidor por
120
+ // categoría de rechazo (forma "default"). Sólo hacia api.anthropic.com.
121
+ const FALLBACK_MODELS = /^claude-(?:opus-5-5|sonnet-5-5|opus-5|fable-5-1)$/;
122
+ export const fallbackSupportedModel = model => FALLBACK_MODELS.test(model || '');
123
+ export function normalizeFallbackSetting(value) {
124
+ const mode = String(value ?? 'default');
125
+ if (mode !== 'default' && mode !== 'off') throw new Error('--anthropic-fallback must be "default" or "off".');
126
+ return mode;
127
+ }
128
+ // Rechazo del clasificador: HTTP 200 con stop_reason "refusal". Se ramifica por
129
+ // stop_reason, nunca por stop_details (puede ser null); explanation no está garantizado.
130
+ export function refusalInfo(message) {
131
+ if (!message || message.stop_reason !== 'refusal') return null;
132
+ const details = message.stop_details && typeof message.stop_details === 'object' ? message.stop_details : null;
133
+ return { category: typeof details?.category === 'string' ? details.category : null, explanation: typeof details?.explanation === 'string' ? details.explanation : null };
134
+ }
135
+ // La señal de "quién respondió" es un fallback_message en usage.iterations, no el
136
+ // bloque (los turnos pegajosos no traen bloque). El model de nivel superior nombra
137
+ // al modelo que produjo el mensaje.
138
+ export function fallbackServed(message) {
139
+ const iterations = Array.isArray(message?.usage?.iterations) ? message.usage.iterations : [];
140
+ if (!iterations.some(entry => entry?.type === 'fallback_message')) return null;
141
+ const switches = Array.isArray(message?.content) ? message.content.filter(block => block?.type === 'fallback') : [];
142
+ return { served_by: message.model || null, switches: switches.map(block => ({ from: block.from?.model || null, to: block.to?.model || null })) };
143
+ }
144
+ // Reenvío de un turno con fallback a mitad de salida: se omiten los bloques
145
+ // thinking/redacted_thinking/tool_use ANTERIORES al último bloque fallback; el
146
+ // texto y todo lo posterior al límite viajan normal. El bloque fallback es una
147
+ // marca de auditoría ignorada (se conserva). Determinista por contenido: la
148
+ // historia sigue siendo append-only.
149
+ export function echoFallbackContent(content) {
150
+ if (!Array.isArray(content)) return content;
151
+ let boundary = -1;
152
+ content.forEach((block, index) => { if (block?.type === 'fallback') boundary = index; });
153
+ if (boundary < 0) return content;
154
+ return content.filter((block, index) => !(index < boundary && (block?.type === 'thinking' || block?.type === 'redacted_thinking' || block?.type === 'tool_use')));
155
+ }
156
+ // Un rechazo termina como rechazo, no como éxito vacío. Un fallback que respondió
157
+ // se marca para que el libro, el timing y la contabilidad lo vean.
158
+ export function annotateOutcome(accounted, message, onText) {
159
+ const refusal = refusalInfo(message);
160
+ const served = fallbackServed(message);
161
+ if (refusal) { accounted.refusal = { ...refusal, fallback_ran: Boolean(served), served_model: message.model || null }; return accounted; }
162
+ if (served) accounted.fallback = served;
163
+ if (onText) for (const block of accounted.content || []) if (block.type === 'text') onText(block.text);
164
+ return accounted;
165
+ }
166
+ const CACHE = Object.freeze({ type: 'ephemeral' });
167
+ const CACHEABLE = new Set(['text', 'image', 'tool_use', 'tool_result', 'document']);
168
+ // Los cache_control que traiga el llamador se descartan (también dentro de un
169
+ // tool_result): la API rechaza más de 4 breakpoints, y aquí se ponen 3 exactos.
170
+ // cache_control no es contenido; quitarlo o moverlo no edita bloques anteriores.
171
+ const uncached = block => {
172
+ if (!block || typeof block !== 'object') return block;
173
+ const { cache_control: ignored, ...rest } = block;
174
+ return Array.isArray(rest.content) ? { ...rest, content: rest.content.map(uncached) } : rest;
175
+ };
176
+ export function withCacheBreakpoints({ system, tools = [], messages = [] }) {
177
+ const plainTools = tools.map(uncached);
178
+ const cachedTools = plainTools.length ? [...plainTools.slice(0, -1), { ...plainTools.at(-1), cache_control: CACHE }] : plainTools;
179
+ const plainSystem = Array.isArray(system) ? system.map(uncached) : system;
180
+ const cachedSystem = typeof system === 'string' && system ? [{ type: 'text', text: system, cache_control: CACHE }]
181
+ : Array.isArray(plainSystem) && plainSystem.length ? [...plainSystem.slice(0, -1), { ...plainSystem.at(-1), cache_control: CACHE }] : plainSystem;
182
+ const cachedMessages = messages.map(message => ({ ...message, content: Array.isArray(message.content) ? message.content.map(uncached) : message.content }));
183
+ for (let m = cachedMessages.length - 1; m >= 0; m--) {
184
+ const content = cachedMessages[m].content;
185
+ if (!Array.isArray(content)) continue;
186
+ const index = content.findLastIndex(block => CACHEABLE.has(block?.type) && !(block.type === 'text' && !block.text));
187
+ if (index >= 0) { content[index] = { ...content[index], cache_control: CACHE }; break; }
188
+ }
189
+ return { system: cachedSystem, tools: cachedTools, messages: cachedMessages };
190
+ }
191
+
79
192
  export class AnthropicProvider {
80
- constructor({ apiKey = process.env.ANTHROPIC_API_KEY, baseURL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', model = process.env.COMAL_MODEL || process.env.HARNESS_MODEL || 'claude-sonnet-4-6', maxTokens = 4096, effort = 'auto', capabilitiesConfigured = {}, vision = 'auto', imageTokens = 4096, timeoutMs = 90000, fetchImpl = fetch } = {}) {
193
+ constructor({ apiKey = process.env.ANTHROPIC_API_KEY, baseURL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', model = process.env.COMAL_MODEL || process.env.HARNESS_MODEL || 'claude-sonnet-5-5', maxTokens = 8192, effort = 'auto', capabilitiesConfigured = {}, vision = 'auto', imageTokens = 4096, timeoutMs, idleTimeoutMs, fallback = 'default', fetchImpl = fetch, promptCaching, thinkingDisplay = 'summarized' } = {}) {
81
194
  if (!apiKey) throw new Error('Set ANTHROPIC_API_KEY or use --demo to try without credentials.');
82
195
  const url = new URL(baseURL.replace(/\/$/, '') + '/v1/messages');
83
196
  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('The provider requires HTTPS; HTTP is allowed only on localhost.');
84
197
  if (url.username || url.password || url.search || url.hash) throw new Error('Invalid provider URL.');
85
198
  this.protocol = 'anthropic'; this.effort = normalizeEffort(effort); this.capabilitiesConfigured = { ...capabilitiesConfigured }; this.configuredCapabilities = this.capabilitiesConfigured;
86
- this.apiKey = apiKey; this.url = url; this.model = model; this.maxTokens = maxTokens; this.timeoutMs = timeoutMs; this.fetch = fetchImpl;
199
+ // Prompt caching por omisión sólo hacia la API de Anthropic: otros endpoints
200
+ // compatibles (MiniMax) pueden rechazar cache_control. promptCaching:true lo fuerza.
201
+ this.promptCaching = promptCaching ?? url.hostname === 'api.anthropic.com';
202
+ // Claude 5.x piensa de forma adaptativa y por omisión omite el texto del
203
+ // razonamiento (display: omitted). Se pide el resumen para que el libro y el
204
+ // progreso lo vean; nunca thinking.disabled ni budget_tokens.
205
+ this.thinkingDisplay = thinkingDisplay;
206
+ this.apiKey = apiKey; this.url = url; this.model = model; this.maxTokens = maxTokens; this.fetch = fetchImpl;
207
+ // Idle + total timeouts (shared with the other providers): abort only when no
208
+ // SSE bytes arrive for idleTimeoutMs, within a generous overall cap. Replaces
209
+ // the old fixed 90 s whole-request timeout that killed long high-effort turns.
210
+ Object.assign(this, modelTimingSettings({ timeoutMs, idleTimeoutMs }, {}));
211
+ this.setFallback(fallback);
87
212
  Object.assign(this, visionSettings({ vision, imageTokens }, {}));
88
213
  }
89
214
  getVisionCapability() { if (this.vision === 'auto' && this.modelCapabilities && this.modelCapabilities.model === this.model && typeof this.modelCapabilities.capabilities.vision === 'boolean') return { supported: this.modelCapabilities.capabilities.vision, source: this.modelCapabilities.provenance.vision?.source || 'model_discovery', model: this.model, imageTokens: this.imageTokens }; return { supported: this.vision === 'on', source: this.vision === 'on' ? 'explicit_user_configuration' : this.vision === 'off' ? 'disabled' : 'server_capability_unknown', model: this.model, imageTokens: this.imageTokens }; }
90
215
  getCapabilities() { return { text: true, tools: true, vision: this.vision === 'on', video: false, web_search: false, ...this.capabilitiesConfigured, ...(this.modelCapabilities && this.modelCapabilities.model === this.model ? Object.fromEntries(Object.entries(this.modelCapabilities.capabilities).filter(([,value]) => value !== null)) : {}), video: false, web_search: false }; }
91
216
  analyzeMedia(request) { return analyzeImages(this, request); }
92
- async complete({ system, messages, tools = [], schemaCatalog = [], contextIdentity, signal, onText = () => {}, maxTokens = this.maxTokens }) {
217
+ // Default form: on for supported models reached through api.anthropic.com; off
218
+ // otherwise. Keeps fallbackRequested so /config and profiles can persist intent.
219
+ setFallback(value) { this.fallbackRequested = normalizeFallbackSetting(value); this.fallbackEligible = this.fallbackRequested === 'default' && this.url.hostname === 'api.anthropic.com' && fallbackSupportedModel(this.model); this.fallback = this.fallbackEligible ? 'default' : 'off'; return this.fallback; }
220
+ async complete({ system, messages, tools = [], schemaCatalog = [], contextIdentity, signal, onText = () => {}, onProgress = () => {}, onReasoning = () => {}, onReasoningEnd = () => {}, onToolCall = () => {}, purpose = 'worker', maxTokens = this.maxTokens }) {
93
221
  const accountingRequest={system,messages,tools,maxTokens}, identity={...accountingIdentity(this),projection_epoch:contextIdentity?.projection_epoch ?? null};
94
- const schemaFrame=compileToolSchemas(tools,{provider:this.protocol,capabilities:{...(this.getCapabilities?.() || {}),...(this.toolSchemaCapabilities?{tool_schema:this.toolSchemaCapabilities}:{})},catalogue:schemaCatalog}); tools=schemaFrame.tools;
95
- messages = messages.map(message => !Array.isArray(message.content) ? message : { ...message, content: message.content.flatMap(original => {
222
+ const schemaFrame=compileToolSchemas(tools,{provider:this.protocol,capabilities:{...(this.getCapabilities?.() || {}),...(this.toolSchemaCapabilities?{tool_schema:this.toolSchemaCapabilities}:{})},catalogue:schemaCatalog,hoistTopLevelCombinators:true}); tools=schemaFrame.tools;
223
+ messages = messages.map(message => !Array.isArray(message.content) ? message : { ...message, content: echoFallbackContent(message.content).flatMap(original => {
96
224
  // Signatures belong to their issuing protocol. Preserve the durable book,
97
225
  // but omit another provider's private reasoning from the Anthropic wire.
98
226
  if (original.type === 'openai_reasoning' || original.type === 'thinking' && original.gemini_part) return [];
@@ -103,57 +231,74 @@ export class AnthropicProvider {
103
231
  if (!this.getVisionCapability().supported) throw new Error('Vision is not enabled for this provider; no pixels were sent.');
104
232
  return { type: 'image', source: block.source };
105
233
  }) }).filter(message => !Array.isArray(message.content) || message.content.length);
106
- const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) : AbortSignal.timeout(this.timeoutMs);
234
+ const deadline = modelDeadline({ signal, timeoutMs: this.timeoutMs, idleTimeoutMs: this.idleTimeoutMs, purpose, provider: 'Anthropic' });
107
235
  const effortWire = await providerEffortWire(this, { signal });
108
- const body = JSON.stringify({ ...effortWire, model: this.model, max_tokens: maxTokens, system, messages: mergeMessages(messages), ...(tools.length ? { tools } : {}), stream: true });
236
+ const wire = this.promptCaching ? withCacheBreakpoints({ system, tools, messages: mergeMessages(messages) }) : { system, tools, messages: mergeMessages(messages) };
237
+ const thinking = adaptiveThinkingModel(this.model) && this.thinkingDisplay ? { thinking: { type: 'adaptive', display: this.thinkingDisplay } } : {};
238
+ const body = JSON.stringify({ ...thinking, ...effortWire, model: this.model, max_tokens: maxTokens, system: wire.system, messages: wire.messages, ...(wire.tools.length ? { tools: wire.tools } : {}), stream: true, ...(this.fallback === 'default' ? { fallbacks: 'default' } : {}) });
109
239
  const account=result=>({...result,usage_accounting:bindUsageAccounting(normalizeUsage('anthropic',result.usage),accountingRequest,identity,body),schema_diagnostics:schemaFrame.diagnostics,schema_manifest:schemaFrame.manifest});
110
- const finish=result=>{try{return account(validateMessage(result));}catch(error){if(error.usage_accounting)error.usage_accounting=bindUsageAccounting(error.usage_accounting,accountingRequest,identity,body);throw error;}};
111
- if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
240
+ const finish=(result,trace)=>{try{return account(validateMessage(result,trace));}catch(error){if(error.usage_accounting)error.usage_accounting=bindUsageAccounting(error.usage_accounting,accountingRequest,identity,body);throw error;}};
112
241
  let response;
242
+ try {
243
+ if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
113
244
  for (let attempt = 0; attempt < 3; attempt++) {
114
- response = await this.fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01' }, body, signal: requestSignal });
245
+ response = await this.fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01', ...(this.fallback === 'default' ? { 'anthropic-beta': 'server-side-fallback-2026-07-01' } : {}) }, body, signal: deadline.signal });
115
246
  if (![429, 529, 503].includes(response.status) || attempt === 2) break;
116
247
  await response.body?.cancel();
117
248
  const retry = Number(response.headers.get('retry-after'));
118
- await delay(Number.isFinite(retry) && retry > 0 ? Math.min(retry * 1000, 5000) : 300 * 2 ** attempt, undefined, { signal: requestSignal });
119
- }
120
- if (!response.ok) {
121
- await response.body?.cancel();
122
- throw new Error(`API HTTP ${response.status}. Check the key, model, limits or endpoint.`);
249
+ await delay(Number.isFinite(retry) && retry > 0 ? Math.min(retry * 1000, 5000) : 300 * 2 ** attempt, undefined, { signal: deadline.signal });
123
250
  }
251
+ if (!response.ok) throw await providerHttpError(response, 'Anthropic', wire);
124
252
  if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
125
- const message = finish(await response.json());
126
- for (const block of message.content) if (block.type === 'text') onText(block.text);
127
- return account(message);
253
+ const raw = await response.json();
254
+ if (raw && raw.stop_reason === 'refusal') raw.content = [];
255
+ return annotateOutcome(finish(raw), raw, onText);
128
256
  }
129
- let message, stopped = false;
130
- const blocks = new Map(), partial = new Map(), closed = new Set();
257
+ let message, stopped = false, textChars = 0, reasoningChars = 0, argumentChars = 0, lastProgress = 0;
258
+ const blocks = new Map(), partial = new Map(), closed = new Set(), started = Date.now();
259
+ // Esqueleto del stream (índice, tipo, deltas) para diagnosticar sin volcar contenido.
260
+ const trace = new Map(), traced = (index, field) => { const entry = trace.get(index); if (entry) entry[field] = (entry[field] || 0) + 1; };
261
+ const progress = (force = false) => { if (force || Date.now() - lastProgress >= 750) { lastProgress = Date.now(); onProgress({ text_chars: textChars, reasoning_chars: reasoningChars, tool_argument_chars: argumentChars, elapsed_ms: Date.now() - started, purpose }); } };
131
262
  for await (const event of sseEvents(response.body)) {
132
- if (event.type === 'error') throw new Error(`Streaming error: ${event.error?.type || 'unknown'}`);
263
+ deadline.touch();
264
+ if (event.type === 'error') throw Object.assign(new Error(`Anthropic streaming error ${event.error?.type || 'unknown'}: ${clip(event.error?.message) || 'no message'}`), { code: 'PROVIDER_STREAM_ERROR', details: { error_type: event.error?.type || 'unknown', error_message: clip(event.error?.message) || '' } });
133
265
  if (event.type === 'message_start') { if (message) throw new Error('Duplicate message_start.'); message = structuredClone(event.message); }
134
266
  else if (event.type === 'content_block_start') {
135
267
  if (blocks.has(event.index)) throw new Error('Duplicate block.');
136
268
  blocks.set(event.index, structuredClone(event.content_block));
269
+ if (trace.size < 64) trace.set(event.index, { index: event.index, type: event.content_block?.type, order: trace.size });
270
+ if (event.content_block?.type === 'tool_use') onToolCall(); else if (/thinking/.test(event.content_block?.type || '')) onReasoning();
137
271
  } else if (event.type === 'content_block_delta') {
138
272
  const block = blocks.get(event.index);
139
273
  if (!block || closed.has(event.index)) throw new Error('Delta outside its block.');
140
274
  const d = event.delta;
141
- if (d.type === 'text_delta') { block.text = (block.text || '') + d.text; onText(d.text); }
142
- else if (d.type === 'input_json_delta') partial.set(event.index, (partial.get(event.index) || '') + d.partial_json);
143
- else if (d.type === 'thinking_delta') block.thinking = (block.thinking || '') + d.thinking;
275
+ traced(event.index, d?.type || 'unknown_delta');
276
+ if (d.type === 'text_delta') { block.text = (block.text || '') + d.text; textChars += d.text.length; onText(d.text); }
277
+ else if (d.type === 'input_json_delta') { partial.set(event.index, (partial.get(event.index) || '') + d.partial_json); argumentChars += d.partial_json.length; }
278
+ else if (d.type === 'thinking_delta') { block.thinking = (block.thinking || '') + d.thinking; reasoningChars += d.thinking.length; }
144
279
  else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
145
280
  } else if (event.type === 'content_block_stop') {
146
281
  if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
147
- if (partial.has(event.index)) { const block = blocks.get(event.index), raw = partial.get(event.index); try { block.input = JSON.parse(raw); } catch { delete block.input; block.truncated_input_chars = raw.length; } }
282
+ if (partial.has(event.index)) {
283
+ const block = blocks.get(event.index), raw = partial.get(event.index);
284
+ // Sin argumentos el stream puede traer partial_json vacío: se conserva el input de content_block_start.
285
+ if (raw.trim()) { try { block.input = JSON.parse(raw); } catch (error) { delete block.input; block.truncated_input_chars = raw.length; block.input_parse_error = clip(String(error.message).replace(/,? ".*$/s, ''), 120); } }
286
+ }
148
287
  closed.add(event.index);
288
+ if (/thinking/.test(blocks.get(event.index).type || '')) onReasoningEnd();
149
289
  } else if (event.type === 'message_delta') {
150
290
  if (!message) throw new Error('Delta without a message.');
151
291
  Object.assign(message, event.delta); message.usage = { ...message.usage, ...event.usage };
152
292
  } else if (event.type === 'message_stop') stopped = true;
293
+ progress();
153
294
  }
154
- if (!stopped || !message || closed.size !== blocks.size) throw new Error('Streaming interrupted; no tools from this response were executed.');
295
+ progress(true);
296
+ if (!stopped || !message || closed.size !== blocks.size) throw Object.assign(new Error('Streaming interrupted; no tools from this response were executed.'), { code: 'MODEL_STREAM_ERROR' });
155
297
  message.content = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block);
156
- return finish(message);
298
+ // Rechazo a mitad de stream: se descarta la salida parcial en vez de tratarla como completa.
299
+ if (message.stop_reason === 'refusal') message.content = [];
300
+ return annotateOutcome(finish(message, [...trace.values()]), message, null);
301
+ } catch (error) { throw deadline.error(error); } finally { deadline.close(); }
157
302
  }
158
303
  }
159
304
 
@@ -1,4 +1,5 @@
1
1
  import { shellCommands } from './auto-guard.mjs';
2
+ import { toolEffects } from './operations.mjs';
2
3
 
3
4
  // A deliberately small executable subset, not a claim that arbitrary Bash is safe.
4
5
  // The native executor runs these greps with fixed argv and no shell.
@@ -33,10 +34,17 @@ export function readOnlyBashCheck(command) {
33
34
  // Rechecking still goes through the live contract, guard and permission judge.
34
35
  const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console', 'WorkerDiagnostics'].map(name => `plugin__chrome-lab__${name}`));
35
36
  export const chromeLabObservation = operation => CHROME_LAB_READS.has(operation);
36
- // open-relay: lecturas del canal admin (sin mutación). Las acciones de la fase 2
37
- // (Reply, Ack, ApprovePlan, RejectPlan) no son observaciones: pasan por la tarjeta.
38
- const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier'].map(name => `plugin__open-relay__${name}`));
37
+ // open-relay: lecturas del canal admin (sin mutación). Las acciones (Reply, Ack,
38
+ // ApprovePlan, RejectPlan y Decide, que registra ADR) no son observaciones: pasan por la tarjeta.
39
+ const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier', 'Decisions', 'Decision'].map(name => `plugin__open-relay__${name}`));
39
40
  export const openRelayObservation = operation => OPEN_RELAY_READS.has(operation);
40
- export const readOnlyWorkCheck = (operation, args) => ['Read', 'Glob', 'Grep', 'ListDirectory', 'FileInfo', 'ChromeVerify','SourceVerify'].includes(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
41
+ // omarchy: catálogo, clasificación, lecturas revisadas y guía. Change y SystemChange no son observaciones.
42
+ const OMARCHY_READS = new Set(['Status', 'Commands', 'Classify', 'Read', 'Guide'].map(name => `plugin__omarchy__${name}`));
43
+ export const omarchyObservation = operation => OMARCHY_READS.has(operation);
44
+ // The operation contract (toolEffects) is the authority for pure local reads; this list only adds hosts' verifiers.
45
+ // Session introspection changes with every row: never a credited or auto-refreshed check.
46
+ const INTROSPECTION = new Set(['WorkSnapshot', 'HouseSnapshot', 'AuditStatus', 'OperationCatalog']);
47
+ const localRead = operation => { if (INTROSPECTION.has(operation)) return false; const effects = toolEffects(operation); return effects.workspace === 'read' && effects.external === 'none' && effects.process === 'none' && effects.authority === 'none'; };
48
+ export const readOnlyWorkCheck = (operation, args) => ['ChromeVerify','SourceVerify'].includes(operation) || localRead(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
41
49
  export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
42
50
  export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && (RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key)) || operation==='plugin__chrome-lab__WorkerDiagnostics'&&Object.hasOwn(args||{},'connection_id')&&Object.hasOwn(args||{},'browser_endpoint_id'));
@@ -0,0 +1,160 @@
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
+ '{what} was decided from the browser ({outcome}).': '{what} se decidió desde el navegador ({outcome}).',
13
+ 'Already decided from the browser ({outcome}); nothing was sent.': 'Ya decidida desde el navegador ({outcome}); no se envió nada.',
14
+ 'This card is being decided in this terminal right now.': 'Esta tarjeta se está decidiendo ahora mismo en esta terminal.',
15
+ 'Plan {id}': 'El plan {id}', 'The question {id}': 'La pregunta {id}', 'The success criteria of {swarm}': 'Los criterios de éxito de {swarm}',
16
+ '/relay answers from the browser: armed until {time}.': 'Respuestas de /relay desde el navegador: armadas hasta las {time}.',
17
+ '/relay answers from the browser: off.': 'Respuestas de /relay desde el navegador: apagadas.',
18
+ '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.',
19
+ '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.',
20
+ 'approved': 'aprobado', 'rejected': 'rechazado', 'decided': 'decidida', 'answered': 'respondida', 'acked': 'dada por leída',
21
+ });
22
+
23
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
24
+ const KIND_OF = action => RELAY_ANSWERS.actions[action]?.card || null;
25
+ const APPLIED = new Set(RELAY_ANSWERS.outcomes.applied);
26
+
27
+ // Lo decidido se recuerda 10 min (el TTL de un control): pasado eso manda la re-lectura de relayd.
28
+ const DECIDED_MS = RELAY_ANSWERS.control_ttl_seconds * 1000;
29
+ export class RelayDecisionBroker {
30
+ constructor({ now = Date.now } = {}) { this.now = now; this.locks = new Map(); this.decided = new Map(); this.watchers = new Map(); }
31
+ /** Candado de una sola entrada. {ok} o {ok:false, holder|decided}. Lo decidido por la OTRA
32
+ * superficie bloquea; lo decidido por la misma lo juzga relayd al volver a leer. */
33
+ begin(key, source) {
34
+ const decided = this.outcome(key); if (decided && decided.by !== source) return { ok: false, decided };
35
+ const holder = this.locks.get(key); if (holder) return { ok: false, holder };
36
+ this.locks.set(key, source); return { ok: true };
37
+ }
38
+ release(key) { this.locks.delete(key); }
39
+ /** La tarjeta quedó decidida: suelta el candado, la recuerda y cierra la tarjeta abierta en la otra superficie. */
40
+ commit(key, { outcome, by, request_id = null }) {
41
+ this.locks.delete(key);
42
+ this.decided.set(key, { outcome, by, request_id, at: this.now() });
43
+ if (this.decided.size > 2000) this.decided.delete(this.decided.keys().next().value);
44
+ for (const controller of this.watchers.get(key) || []) controller.abort();
45
+ }
46
+ outcome(key) { const decided = this.decided.get(key); return decided && this.now() - decided.at < DECIDED_MS ? decided : null; }
47
+ /** Señal por tarjeta abierta en la TUI (combinada con la de la sesión): si la otra superficie
48
+ * decide, su setupChoice/setupInput se cierra solo. */
49
+ open(key, signal) {
50
+ const controller = new AbortController(), set = this.watchers.get(key) || new Set();
51
+ set.add(controller); this.watchers.set(key, set);
52
+ return { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, close: () => { set.delete(controller); if (!set.size) this.watchers.delete(key); } };
53
+ }
54
+ }
55
+
56
+ /** ¿El texto cumple la regla de la acción? (none | required | optional, en bytes UTF-8). */
57
+ export function relayText(action, text) {
58
+ const rule = RELAY_ANSWERS.actions[action], value = typeof text === 'string' ? text : '';
59
+ if (!rule) return false;
60
+ if (rule.text === 'none') return value === '';
61
+ if (Buffer.byteLength(value) > rule.text_max) return false;
62
+ return rule.text === 'optional' || value.trim() !== '';
63
+ }
64
+
65
+ /**
66
+ * Camino del navegador (RemoteHost → onRelay). Revalida en local, en este orden (§4.2 del
67
+ * diseño), y sólo entonces muta con las funciones de la TUI (RelayFlow.apply*):
68
+ * 1. libro: ¿ya se resolvió este request_id? → el mismo resultado, sin mutar (ACK perdido, reinicio);
69
+ * 2. sesión y época de /rc; 3. armado local vigente; 4. caducidad del control;
70
+ * 5. tarjeta pendiente en esta sesión, de la clase y el sujeto del control, acción permitida;
71
+ * candado libre (si ya está decidida: already_decided);
72
+ * 6. remote.inputs.validate (el login del navegador sigue vivo);
73
+ * 7. relayd en vivo (dentro de apply*: plan.get / msg.get+inbox / criteria.get) y mutación.
74
+ * Todo control deja su fila relay_control con source remote_browser y su request_id.
75
+ */
76
+ export class RemoteRelayAnswers {
77
+ constructor({ broker, flow, feed, rows = async () => [], now = Date.now }) {
78
+ Object.assign(this, { broker, flow, feed, rows, now });
79
+ this.ledger = null;
80
+ }
81
+ async #ledger() {
82
+ if (!this.ledger) {
83
+ this.ledger = new Map();
84
+ 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 {}
85
+ }
86
+ return this.ledger;
87
+ }
88
+ async #settle(input, control, result, extra = {}) {
89
+ const ledger = await this.#ledger();
90
+ ledger.set(input.request_id, result);
91
+ if (ledger.size > 4096) ledger.delete(ledger.keys().next().value);
92
+ // Las mutaciones aplicadas ya dejaron su fila (apply*, con remote_*); el resto deja la suya aquí.
93
+ 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 {}
94
+ return result;
95
+ }
96
+ async handle(input, { session, epoch, armed = () => false, validate = async () => true } = {}) {
97
+ const control = input?.control || {}, ledger = await this.#ledger();
98
+ // 1. Libro: el mismo request_id devuelve lo mismo y nunca vuelve a mutar.
99
+ if (ledger.has(input?.request_id)) return ledger.get(input.request_id);
100
+ const refuse = (state, outcome) => this.#settle(input, control, { state, outcome });
101
+ if (input?.kind !== 'relay' || !KIND_OF(control.action)) return refuse('refused', 'invalid');
102
+ // 2. Sesión y época.
103
+ if (input.remote_session_id !== session || control.host_epoch !== epoch) return refuse('stale', 'epoch');
104
+ // 3. Armado local (D1).
105
+ if (!armed()) return refuse('refused', 'local_arm_off');
106
+ // 4. Caducidad (10 min desde la admisión en Milpa).
107
+ if (!Number.isFinite(Number(input.expires_at)) || Number(input.expires_at) * 1000 <= this.now()) return refuse('expired', 'expired');
108
+ // 5. Tarjeta: publicada en esta sesión y todavía pendiente aquí.
109
+ const entry = this.feed?.card(control.card_id);
110
+ // Una tarjeta ya cerrada (decidida en cualquier superficie, o por otro control) no se vuelve a decidir.
111
+ if (!entry) return refuse('stale', this.feed?.resolution?.(control.card_id) ? 'already_decided' : 'card_gone');
112
+ const card = entry.card, rule = RELAY_ANSWERS.actions[control.action];
113
+ if (card.kind !== rule.card || card.subject !== control.subject || card.project !== control.project || card.swarm !== control.swarm) return refuse('stale', 'kind');
114
+ if (!card.answerable?.browser || !card.answerable.actions?.includes(control.action)) return refuse('refused', 'cap_missing');
115
+ if (!relayText(control.action, control.text ?? '')) return refuse('refused', 'invalid');
116
+ for (const field of rule.fields) if (control[field] === undefined || control[field] === null) return refuse('refused', 'invalid');
117
+ if (rule.fields.includes('digest') && control.digest !== card.body.digest) return refuse('stale', 'digest');
118
+ if (control.action === 'plan.approve' && control.projection_sha256 !== card.body.projection_sha256) return refuse('stale', 'projection');
119
+ const lock = this.broker.begin(entry.key, 'browser');
120
+ if (!lock.ok) return lock.decided ? refuse('stale', 'already_decided') : refuse('refused', 'local_in_progress');
121
+ let result;
122
+ try {
123
+ // 6. El navegador que lo pidió sigue con sesión (login padre).
124
+ try { await validate(); } catch { this.broker.release(entry.key); return refuse('refused', 'revoked'); }
125
+ // 7. relayd en vivo + mutación, con las funciones y claves de la TUI.
126
+ const remote = { source: 'remote_browser', remote_session: input.remote_session_id, remote_request_id: input.request_id, card_id: control.card_id };
127
+ result = await this.#apply(entry, control, remote);
128
+ } catch (error) {
129
+ this.broker.release(entry.key);
130
+ const delegation = ['NOT_DELEGATED', 'UNAUTHORIZED', 'RELAY_DELEGATE_MISSING', 'RELAY_TOKEN_INVALID', 'RELAY_TOKEN_UNSAFE'].includes(error?.code);
131
+ return refuse('refused', delegation ? 'delegation' : 'relay_unavailable');
132
+ }
133
+ if (APPLIED.has(result.outcome)) {
134
+ this.broker.commit(entry.key, { outcome: result.outcome, by: 'browser', request_id: input.request_id });
135
+ this.flow.ui?.notice?.(t('{what} was decided from the browser ({outcome}).', { what: this.#what(card), outcome: t(result.outcome) }));
136
+ return this.#settle(input, control, { state: 'applied', outcome: result.outcome }, { recorded: true });
137
+ }
138
+ // Obsoleta o ya decidida fuera de Comal (relayd lo dice): no se muta y la TUI decide con su propia re-lectura.
139
+ this.broker.release(entry.key);
140
+ return refuse('stale', result.outcome || 'stale');
141
+ }
142
+ #what(card) {
143
+ if (card.kind === 'plan') return t('Plan {id}', { id: card.subject });
144
+ if (card.kind === 'question') return t('The question {id}', { id: card.subject });
145
+ return t('The success criteria of {swarm}', { swarm: card.swarm });
146
+ }
147
+ async #apply(entry, control, remote) {
148
+ const item = entry.item, base = { project: item.project, root: item.root, swarm: item.swarm, ...remote };
149
+ 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 });
150
+ if (entry.card.kind === 'question') {
151
+ const mode = control.action.slice('question.'.length);
152
+ // La opción se identifica por índice Y por el sha256 de la etiqueta que vio el navegador.
153
+ const option = mode === 'option' ? entry.card.body.decision?.options?.[control.option_index] : null;
154
+ if (mode === 'option' && (!option || sha256(option.label) !== control.option_label_sha256)) return { outcome: 'option' };
155
+ 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 || '' });
156
+ }
157
+ if (entry.card.kind === 'criteria') return this.flow.applyCriteria({ ...base, digest: control.digest, choice: control.action === 'criteria.accept' ? 'accept' : 'reject', reason: control.text });
158
+ return { outcome: 'kind' };
159
+ }
160
+ }