@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.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +232 -63
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +133 -2
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +99 -6
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- 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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
234
|
+
const deadline = modelDeadline({ signal, timeoutMs: this.timeoutMs, idleTimeoutMs: this.idleTimeoutMs, purpose, provider: 'Anthropic' });
|
|
107
235
|
const effortWire = await providerEffortWire(this, { signal });
|
|
108
|
-
const
|
|
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:
|
|
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:
|
|
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
|
|
126
|
-
|
|
127
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
else if (d.type === '
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/read-only-check.mjs
CHANGED
|
@@ -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
|
|
37
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|