@milpa/comal 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -1,3 +1,4 @@
1
+ import { registerSecretEnv } from './child-env.mjs';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { OpenAIProvider, DEFAULT_LOCAL_URL } from './openai-provider.mjs';
@@ -5,11 +6,12 @@ import { AnthropicProvider } from './provider.mjs';
5
6
  import { GeminiProvider } from './gemini-provider.mjs';
6
7
  import { authProviderDefaults } from './provider-auth.mjs';
7
8
  import { normalizeEffort } from './model-controls.mjs';
9
+ import { normalizeFallbackSetting } from './provider.mjs';
8
10
  import {normalizeToolSchemaCapabilities} from './tool-schema.mjs';
9
11
 
10
12
  export const PROVIDER_CAPABILITIES = Object.freeze(['text', 'tools', 'thinking', 'vision', 'video', 'web_search']);
11
13
  const PROTOCOLS = ['openai', 'anthropic', 'gemini'];
12
- const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema']);
14
+ const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema', 'maxTokens', 'contextWindow', 'fallback']);
13
15
  const error = message => Object.assign(new Error(message), { code: 'PROVIDER_PROFILE_INVALID' });
14
16
 
15
17
  export function normalizeCapabilities(value = {}, { protocol = 'openai' } = {}) {
@@ -31,6 +33,7 @@ export function normalizeProviderProfile(input, name = input?.name) {
31
33
  if (input.name && input.name !== name) throw error('Conflicting profile name.');
32
34
  if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('Invalid authProvider.');
33
35
  if (input.effort !== undefined) normalizeEffort(input.effort);
36
+ if (input.fallback !== undefined) normalizeFallbackSetting(input.fallback);
34
37
  const toolSchema=input.toolSchema===undefined?undefined:normalizeToolSchemaCapabilities(input.toolSchema);
35
38
  const access=input.authProvider?authProviderDefaults(input.authProvider):null;
36
39
  const configuredCapabilities = input.capabilitiesConfigured || input.capabilities || {};
@@ -47,11 +50,17 @@ export function normalizeProviderProfile(input, name = input?.name) {
47
50
  }
48
51
  if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv must name an environment variable.');
49
52
  if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('Invalid description.');
53
+ // Output and window budgets are startup defaults for this endpoint; --max-tokens,
54
+ // --context-window, their variables and connection.json still win.
55
+ if (input.maxTokens !== undefined && (!Number.isSafeInteger(input.maxTokens) || input.maxTokens < 64 || input.maxTokens > 1000000)) throw error('maxTokens must be an integer between 64 and 1000000.');
56
+ if (input.contextWindow !== undefined && (!Number.isSafeInteger(input.contextWindow) || input.contextWindow < 4096 || input.contextWindow > 2000000)) throw error('contextWindow must be an integer between 4096 and 2000000.');
57
+ if (input.maxTokens !== undefined && input.contextWindow !== undefined && input.contextWindow < input.maxTokens * 2) throw error('contextWindow must be at least twice maxTokens.');
50
58
  return Object.freeze({ name, provider: protocol, protocol, ...((input.model || access?.model) ? { model: input.model || access.model } : {}),
51
59
  baseURL: input.baseURL || access?.baseURL || ({ openai: DEFAULT_LOCAL_URL, anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta' })[protocol],
52
60
  apiKeyEnv: input.apiKeyEnv || ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY' })[protocol],
53
61
  capabilities: normalizeCapabilities(input.capabilities, { protocol }), capabilitiesConfigured: Object.freeze({ ...configuredCapabilities }),...(toolSchema?{toolSchema:Object.freeze(toolSchema)}:{}),
54
- ...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {}) });
62
+ ...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {}),
63
+ ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), ...(input.contextWindow !== undefined ? { contextWindow: input.contextWindow } : {}), ...(input.fallback !== undefined ? { fallback: input.fallback } : {}) });
55
64
  }
56
65
 
57
66
  // Configuration is data only. Secrets are resolved in createProfileProvider and
@@ -92,11 +101,12 @@ export function selectProfile(registry, name) {
92
101
 
93
102
  export function createProfileProvider(profile, { env = process.env, ...overrides } = {}) {
94
103
  profile = normalizeProviderProfile(profile);
104
+ registerSecretEnv(profile.apiKeyEnv);
95
105
  const capabilities = { ...profile.capabilities };
96
106
  if (overrides.vision === 'on') capabilities.vision = true;
97
107
  else if (overrides.vision === 'off') capabilities.vision = false;
98
108
  const options = { ...overrides, apiKey: overrides.apiKey ?? env[profile.apiKeyEnv], model: overrides.model || profile.model, baseURL: overrides.baseURL || profile.baseURL,
99
- capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
109
+ capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', fallback: overrides.fallback ?? profile.fallback ?? 'default', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
100
110
  if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Set the profile apiKeyEnv variable ${profile.apiKeyEnv}.`);
101
111
  const provider = profile.protocol === 'gemini' ? new GeminiProvider(options) : profile.protocol === 'anthropic' ? new AnthropicProvider(options) : new OpenAIProvider(options);
102
112
  const complete = provider.complete.bind(provider);
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
 
@@ -0,0 +1,240 @@
1
+ // /rc device scan: reads the M5Stack's pairing QR with a local camera, only while the person asked for it, 30 s at most
2
+ // in total (finding the camera included). One ffmpeg stays open for the scan (so focus and exposure settle) and writes
3
+ // 2 frames a second into a private 0700 directory; each completed frame is decoded with zbarimg and deleted right
4
+ // after, and the directory goes at the end (and on process exit). Nothing is kept and nothing leaves this machine.
5
+ // Processes are spawned with argv arrays (no shell). `spawn`, `now`, `sleep`, `readdir` (of /dev) and `tmpRoot` are
6
+ // injectable so tests run without a camera.
7
+ import { spawn as nodeSpawn } from 'node:child_process';
8
+ import { rmSync } from 'node:fs';
9
+ import { chmod, lstat, mkdtemp, readdir as fsReaddir, rm } from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { t, registerMessages } from './i18n/index.mjs';
13
+
14
+ /** The QR on the device's pairing screen: this prefix plus the code as shown, uppercase Crockford XXXX-XXXX.
15
+ * It carries nothing secret (the device's poll token never goes in it). Same constant as
16
+ * devices/m5stack/lib/comal_core/pairing.h (PAIR_QR_PREFIX). */
17
+ export const PAIR_QR_PREFIX = 'comal-pair:v1:';
18
+ const PAIR_QR = /^comal-pair:v1:([0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4})$/;
19
+ const CAMERA = /^\/dev\/video[0-9]+$/;
20
+ export const SCAN_TIMEOUT_MS = 30000;
21
+ export const SCAN_INTERVAL_MS = 250; // how often the private directory is looked at for completed frames
22
+ const STREAM_SIZE = '1280x720';
23
+ const STREAM_FPS = 2;
24
+ const PROBE_TIMEOUT_MS = 5000;
25
+ const GRAB_TIMEOUT_MS = 10000;
26
+ const FRAME_MAX_BYTES = 32 * 1024 * 1024;
27
+ const FRAME = /^f[0-9]{6}\.png$/;
28
+ const DIR_PREFIX = 'comal-qr-';
29
+ const DIR_SHAPE = /^comal-qr-[A-Za-z0-9]{6}$/; // what mkdtemp makes of DIR_PREFIX: only these are swept
30
+ const STALE_MS = 120000; // a scan touches its directory every half second; one untouched for 2 min is left over
31
+
32
+ registerMessages({
33
+ 'The camera must be a /dev/videoN device.': 'La cámara debe ser un dispositivo /dev/videoN.',
34
+ 'COMAL_CAMERA must be a /dev/videoN device.': 'COMAL_CAMERA debe ser un dispositivo /dev/videoN.',
35
+ 'No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.': 'No se encontró una cámara (/dev/video*). Indica una con /rc device scan /dev/videoN, o escribe el código: /rc device XXXX-XXXX.',
36
+ 'Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.': 'Escanear necesita ffmpeg y zbarimg. Instala los paquetes ffmpeg y zbar, o escribe el código: /rc device XXXX-XXXX.',
37
+ 'Could not read the camera {camera}: {reason}': 'No se pudo leer la cámara {camera}: {reason}',
38
+ 'No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.': 'No se vio un QR de emparejamiento en {seconds} s. Acerca el M5Stack a 10-20 cm de la cámara, que el QR ocupe un tercio de la imagen, e inténtalo de nuevo, o escribe el código: /rc device XXXX-XXXX.',
39
+ 'QR scan cancelled; the camera is off.': 'Escaneo del QR cancelado; la cámara está apagada.',
40
+ });
41
+
42
+ /** zbarimg's stdout counts only when it is exactly one symbol: one matching line plus its newline. */
43
+ function onlyPairCode(stdout) {
44
+ const text = stdout.toString('utf8').replace(/\r?\n$/, '');
45
+ return /[\r\n]/.test(text) ? null : pairCodeFromQr(text);
46
+ }
47
+
48
+ /** The code in a pairing QR's text, or null for anything else (which is never echoed). */
49
+ export function pairCodeFromQr(text) {
50
+ const match = PAIR_QR.exec(typeof text === 'string' ? text : '');
51
+ return match ? match[1] : null;
52
+ }
53
+
54
+ export const isCameraPath = value => typeof value === 'string' && CAMERA.test(value);
55
+
56
+ const failure = (code, message) => Object.assign(new Error(message), { code });
57
+ const aborted = () => Object.assign(new Error(t('QR scan cancelled; the camera is off.')), { name: 'AbortError', code: 'ABORT_ERR' });
58
+ const missingTools = () => failure('QR_SCAN_TOOLS', t('Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.'));
59
+ const timedOut = seconds => failure('QR_SCAN_TIMEOUT', t('No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.', { seconds: String(seconds) }));
60
+ const oneLine = text => String(text || '').split(/\r?\n/).map(line => line.trim()).find(Boolean)?.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200) || 'no frame';
61
+
62
+ /** Runs one process to its end: {code, stdout, stderr}. Killed on abort or after `timeoutMs`; ENOENT → missing tools. */
63
+ function run(spawn, cmd, args, { signal, timeoutMs = GRAB_TIMEOUT_MS } = {}) {
64
+ return new Promise((resolve, reject) => {
65
+ if (signal?.aborted) { reject(aborted()); return; }
66
+ let child;
67
+ try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false }); }
68
+ catch (error) { reject(error.code === 'ENOENT' ? missingTools() : error); return; }
69
+ const out = [], err = [];
70
+ let size = 0, errSize = 0, settled = false;
71
+ const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); fn(value); };
72
+ const kill = () => { try { child.kill('SIGKILL'); } catch {} };
73
+ const onAbort = () => { kill(); settle(reject, aborted()); };
74
+ const timer = setTimeout(() => { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'timed out' }); }, timeoutMs);
75
+ timer.unref?.();
76
+ signal?.addEventListener('abort', onAbort, { once: true });
77
+ child.stdout?.on('data', chunk => { size += chunk.length; if (size > FRAME_MAX_BYTES) { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'frame too large' }); } else out.push(chunk); });
78
+ child.stderr?.on('data', chunk => { if (errSize < 4096) { err.push(chunk); errSize += chunk.length; } });
79
+ child.on('error', error => settle(reject, error.code === 'ENOENT' ? missingTools() : error));
80
+ child.on('close', code => settle(resolve, { code, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8') }));
81
+ });
82
+ }
83
+
84
+ const grabArgs = device => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', '-i', device, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'];
85
+ const DECODE_ARGS = ['--raw', '-q', '-Sdisable', '-Sqrcode.enable'];
86
+
87
+ /** True when `v4l2-ctl --all` says this node itself captures video (Device Caps; Capabilities is the whole device's). */
88
+ function capturesVideo(text) {
89
+ const lines = String(text).split('\n');
90
+ const start = lines.findIndex(line => /^\s*Device Caps\s*:/.test(line));
91
+ if (start < 0) return false;
92
+ const indent = lines[start].match(/^\s*/)[0].length;
93
+ for (const line of lines.slice(start + 1)) {
94
+ if (!line.trim() || line.match(/^\s*/)[0].length <= indent) break;
95
+ if (/^\s*Video Capture(?: Multiplanar)?\s*$/.test(line)) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /** The camera: `camera` (argument) > env COMAL_CAMERA > the first /dev/videoN that v4l2-ctl reports as Video Capture
101
+ * (without v4l2-ctl: the first one ffmpeg can read a frame from). Anything that is not /dev/videoN is refused. */
102
+ export async function pickCamera({ camera, env = process.env, spawn = nodeSpawn, readdir = fsReaddir, signal, deadline = Infinity, now = Date.now, seconds = SCAN_TIMEOUT_MS / 1000 } = {}) {
103
+ if (camera !== undefined && camera !== null && camera !== '') {
104
+ if (!isCameraPath(camera)) throw failure('QR_SCAN_CAMERA_PATH', t('The camera must be a /dev/videoN device.'));
105
+ return camera;
106
+ }
107
+ const fromEnv = env?.COMAL_CAMERA?.trim();
108
+ if (fromEnv) {
109
+ if (!isCameraPath(fromEnv)) throw failure('QR_SCAN_CAMERA_PATH', t('COMAL_CAMERA must be a /dev/videoN device.'));
110
+ return fromEnv;
111
+ }
112
+ let names = [];
113
+ try { names = await readdir('/dev'); } catch {}
114
+ const candidates = names.filter(name => /^video[0-9]+$/.test(name)).sort((a, b) => Number(a.slice(5)) - Number(b.slice(5))).map(name => '/dev/' + name);
115
+ // Probing counts toward the scan's time: each probe gets what is left, and none starts past the deadline.
116
+ const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
117
+ let haveV4l2 = true;
118
+ for (const device of candidates) {
119
+ if (haveV4l2) {
120
+ try {
121
+ const result = await run(spawn, 'v4l2-ctl', ['--device', device, '--all'], { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
122
+ if (result.code === 0 && capturesVideo(result.stdout.toString('utf8'))) return device;
123
+ continue;
124
+ } catch (error) {
125
+ if (error.name === 'AbortError' || error.code === 'QR_SCAN_TIMEOUT') throw error;
126
+ if (error.code !== 'QR_SCAN_TOOLS') continue;
127
+ haveV4l2 = false;
128
+ }
129
+ }
130
+ const result = await run(spawn, 'ffmpeg', grabArgs(device), { signal, timeoutMs: left(GRAB_TIMEOUT_MS) });
131
+ if (result.code === 0 && result.stdout.length) return device;
132
+ }
133
+ throw failure('QR_SCAN_NO_CAMERA', t('No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.'));
134
+ }
135
+
136
+ const abortableSleep = (ms, signal) => new Promise((resolve, reject) => {
137
+ if (signal?.aborted) { reject(aborted()); return; }
138
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
139
+ const onAbort = () => { clearTimeout(timer); reject(aborted()); };
140
+ signal?.addEventListener('abort', onAbort, { once: true });
141
+ });
142
+
143
+ // How the camera is opened, in order, each tried when the one before ends without a frame: 1280x720 MJPEG (a UVC
144
+ // camera often has 1280x720 only as MJPEG and silently gives 640x480 raw frames for a bare -video_size), 1280x720 in
145
+ // its default format, then its own default size.
146
+ const STREAM_ATTEMPTS = [['-input_format', 'mjpeg', '-video_size', STREAM_SIZE], ['-video_size', STREAM_SIZE], []];
147
+ // `-t` (an input option) is the seconds left to the scan, rounded up: if Comal dies without its exit hook (SIGKILL, OOM,
148
+ // a crash) ffmpeg still ends itself, and turns the camera off, at the scan's deadline.
149
+ const streamArgs = (device, dir, attempt, seconds) => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', ...STREAM_ATTEMPTS[attempt], '-t', String(seconds), '-i', device, '-vf', `fps=${STREAM_FPS}`, '-f', 'image2', path.join(dir, 'f%06d.png')];
150
+
151
+ /** The long-running ffmpeg of a scan: {child, exited, error, stderr, closed}. */
152
+ function startStream(spawn, args) {
153
+ const stream = { child: null, exited: false, error: null, stderr: '' };
154
+ stream.closed = new Promise(resolve => {
155
+ try { stream.child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'], shell: false }); }
156
+ catch (error) { stream.exited = true; stream.error = error; resolve(); return; }
157
+ stream.child.stderr?.on('data', chunk => { if (stream.stderr.length < 4096) stream.stderr += chunk.toString('utf8'); });
158
+ stream.child.on('error', error => { stream.error = error; stream.exited = true; resolve(); });
159
+ stream.child.on('close', () => { stream.exited = true; resolve(); });
160
+ });
161
+ return stream;
162
+ }
163
+ /** Kills a stream and waits (briefly) until it is gone, so nothing writes into the directory being removed. */
164
+ async function stopStream(stream) {
165
+ if (!stream || stream.exited) return;
166
+ try { stream.child.kill('SIGKILL'); } catch {}
167
+ let timer;
168
+ await Promise.race([stream.closed, new Promise(resolve => { timer = setTimeout(resolve, 2000); timer.unref?.(); })]);
169
+ clearTimeout(timer);
170
+ }
171
+
172
+ /** Removes this user's own comal-qr-* directories in `root` that no scan has touched for a while (a killed process). */
173
+ async function sweepStale(root) {
174
+ let names = [];
175
+ try { names = await fsReaddir(root); } catch { return; }
176
+ const uid = process.getuid?.();
177
+ for (const name of names) {
178
+ if (!DIR_SHAPE.test(name)) continue;
179
+ try {
180
+ const info = await lstat(path.join(root, name));
181
+ if (!info.isDirectory() || (uid !== undefined && info.uid !== uid) || Date.now() - info.mtimeMs < STALE_MS) continue;
182
+ await rm(path.join(root, name), { recursive: true, force: true });
183
+ } catch {}
184
+ }
185
+ }
186
+
187
+ /** Scans for the pairing QR for up to `timeoutMs` in total, finding the camera included. Resolves {code, camera}. */
188
+ export async function scanPairingQr({ camera, env = process.env, timeoutMs = SCAN_TIMEOUT_MS, intervalMs = SCAN_INTERVAL_MS, signal, spawn = nodeSpawn, now = Date.now, sleep, readdir = fsReaddir, tmpRoot = os.tmpdir() } = {}) {
189
+ if (signal?.aborted) throw aborted();
190
+ const deadline = now() + timeoutMs, seconds = Math.round(timeoutMs / 1000);
191
+ const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
192
+ const pause = sleep ?? (ms => abortableSleep(ms, signal));
193
+ // Both tools are checked before the camera turns on.
194
+ for (const [cmd, args] of [['ffmpeg', ['-hide_banner', '-version']], ['zbarimg', ['--version']]]) await run(spawn, cmd, args, { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
195
+ const root = path.resolve(tmpRoot);
196
+ await sweepStale(root);
197
+ const dir = await mkdtemp(path.join(root, DIR_PREFIX));
198
+ let stream = null;
199
+ const onExit = () => { try { stream?.child?.kill('SIGKILL'); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} };
200
+ process.once('exit', onExit);
201
+ try {
202
+ await chmod(dir, 0o700);
203
+ const device = await pickCamera({ camera, env, spawn, readdir, signal, deadline, now, seconds });
204
+ left(1);
205
+ let attempt = 0, seen = 0;
206
+ const secondsLeft = () => Math.max(1, Math.ceil((deadline - now()) / 1000));
207
+ stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft()));
208
+ for (;;) {
209
+ await pause(Math.max(0, Math.min(intervalMs, deadline - now())));
210
+ if (signal?.aborted) throw aborted();
211
+ // Read whether ffmpeg ended BEFORE listing: then every listed frame is complete. While it runs, the newest frame
212
+ // may still be being written, so only the ones older than it are complete. Of those only the newest is decoded:
213
+ // older ones are deleted unread, so a slow decode never builds a backlog of stale frames.
214
+ const ended = stream.exited;
215
+ const frames = (await fsReaddir(dir)).filter(name => FRAME.test(name)).sort();
216
+ const complete = ended ? frames : frames.slice(0, -1);
217
+ if (complete.length) {
218
+ seen += complete.length;
219
+ for (const name of complete.slice(0, -1)) await rm(path.join(dir, name), { force: true });
220
+ const file = path.join(dir, complete.at(-1));
221
+ let decoded;
222
+ try { decoded = await run(spawn, 'zbarimg', [...DECODE_ARGS, file], { signal, timeoutMs: Math.max(1, Math.min(PROBE_TIMEOUT_MS, deadline - now())) }); }
223
+ finally { await rm(file, { force: true }); }
224
+ const code = decoded.code === 0 ? onlyPairCode(decoded.stdout) : null;
225
+ if (code) return { code, camera: device };
226
+ }
227
+ if (ended && !frames.length) {
228
+ if (stream.error) throw stream.error.code === 'ENOENT' ? missingTools() : stream.error;
229
+ // A camera that refuses a format or size is opened again the next way, down to its own default.
230
+ if (!seen && attempt + 1 < STREAM_ATTEMPTS.length) { attempt += 1; left(1); stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft())); continue; }
231
+ throw failure('QR_SCAN_CAMERA', t('Could not read the camera {camera}: {reason}', { camera: device, reason: oneLine(stream.stderr) }));
232
+ }
233
+ if (now() >= deadline) throw timedOut(seconds);
234
+ }
235
+ } finally {
236
+ await stopStream(stream);
237
+ await rm(dir, { recursive: true, force: true });
238
+ process.removeListener('exit', onExit);
239
+ }
240
+ }
@@ -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.
@@ -40,6 +41,10 @@ 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.
41
42
  const OMARCHY_READS = new Set(['Status', 'Commands', 'Classify', 'Read', 'Guide'].map(name => `plugin__omarchy__${name}`));
42
43
  export const omarchyObservation = operation => OMARCHY_READS.has(operation);
43
- 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)));
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)));
44
49
  export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
45
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'));