@milpa/comal 0.1.0 → 0.1.1

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 (185) hide show
  1. package/README.md +30 -28
  2. package/backend/contracts/remote-commands-v1.json +125 -0
  3. package/bin/comal.mjs +16 -5
  4. package/docs/investigation.md +2 -2
  5. package/governance/chrome-task-v2.json +67 -0
  6. package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
  7. package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
  8. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
  9. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
  10. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
  11. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
  12. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
  13. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
  14. package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
  15. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
  16. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
  17. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  18. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
  19. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
  20. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
  21. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  22. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
  23. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
  24. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
  25. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
  26. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
  27. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
  28. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
  29. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
  30. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
  31. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
  32. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
  33. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
  34. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
  35. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
  36. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
  37. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
  38. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
  39. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
  40. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
  41. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
  42. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
  43. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
  44. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
  45. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  46. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
  47. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
  48. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
  49. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
  50. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
  51. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
  52. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
  53. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
  54. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
  55. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
  56. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
  57. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
  58. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
  59. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
  60. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
  61. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
  62. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
  63. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
  64. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
  65. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  66. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
  67. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
  68. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
  69. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  70. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
  71. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
  72. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
  73. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
  74. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
  75. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
  76. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
  77. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
  78. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
  79. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
  80. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
  81. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
  82. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
  83. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
  84. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
  85. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
  86. package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
  87. package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
  88. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
  89. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
  90. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
  91. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
  92. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  93. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
  94. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
  95. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
  96. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
  97. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
  98. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
  99. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
  100. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
  101. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
  102. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
  103. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
  104. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
  105. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
  106. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
  107. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
  108. package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
  109. package/node_modules/quickjs-emscripten/LICENSE +21 -0
  110. package/node_modules/quickjs-emscripten/README.md +928 -0
  111. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
  112. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
  113. package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
  114. package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
  115. package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
  116. package/node_modules/quickjs-emscripten/dist/index.js +2 -0
  117. package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
  118. package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
  119. package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
  120. package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
  121. package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
  122. package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
  123. package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
  124. package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
  125. package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
  126. package/node_modules/quickjs-emscripten/package.json +69 -0
  127. package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
  128. package/node_modules/quickjs-emscripten-core/README.md +341 -0
  129. package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
  130. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
  131. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
  132. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
  133. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
  134. package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
  135. package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
  136. package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
  137. package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
  138. package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
  139. package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
  140. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
  141. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
  142. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
  143. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
  144. package/node_modules/quickjs-emscripten-core/package.json +49 -0
  145. package/package.json +6 -2
  146. package/src/agent.mjs +17 -6
  147. package/src/artifact-tools.mjs +3 -2
  148. package/src/cli.mjs +261 -199
  149. package/src/cloud-client.mjs +50 -8
  150. package/src/code-run-worker.mjs +70 -0
  151. package/src/code-run.mjs +97 -0
  152. package/src/codex-provider.mjs +27 -27
  153. package/src/compaction.mjs +1 -1
  154. package/src/goals.mjs +2 -2
  155. package/src/house.mjs +24 -24
  156. package/src/i18n/cli-es.mjs +97 -0
  157. package/src/i18n/es.mjs +227 -0
  158. package/src/i18n/index.mjs +48 -0
  159. package/src/image-attachments.mjs +263 -0
  160. package/src/model-controls.mjs +4 -4
  161. package/src/onboarding.mjs +91 -0
  162. package/src/openai-provider.mjs +24 -24
  163. package/src/permissions.mjs +52 -16
  164. package/src/provider-auth.mjs +30 -30
  165. package/src/provider-profiles.mjs +27 -27
  166. package/src/provider.mjs +30 -30
  167. package/src/remote-commands.mjs +11 -0
  168. package/src/remote-control.mjs +172 -0
  169. package/src/sessions.mjs +3 -3
  170. package/src/subagents.mjs +7 -3
  171. package/src/tool-packs.mjs +5 -2
  172. package/src/tools.mjs +81 -31
  173. package/src/tui/brand.mjs +2 -1
  174. package/src/tui/clipboard.mjs +5 -4
  175. package/src/tui/composer.mjs +77 -11
  176. package/src/tui/controller.mjs +110 -29
  177. package/src/tui/discovery.mjs +105 -100
  178. package/src/tui/markdown.mjs +10 -9
  179. package/src/tui/message-queue.mjs +27 -9
  180. package/src/tui/screen.mjs +209 -118
  181. package/src/vision.mjs +31 -3
  182. package/src/work-assertions.mjs +7 -7
  183. package/src/work-plan.mjs +104 -14
  184. package/src/work-recovery-proof.mjs +13 -1
  185. package/src/workflows/chrome-task.mjs +24 -7
@@ -12,46 +12,82 @@ for (const name of ['ListAgents','SendMessage','ProviderCapabilities','ProviderS
12
12
  export const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'auto'];
13
13
  export function nextPermissionMode(mode) { return PERMISSION_MODES[(PERMISSION_MODES.indexOf(mode) + 1) % PERMISSION_MODES.length]; }
14
14
 
15
+ // A UI callback may be shared by resident and subagent policies. One visible
16
+ // question at a time prevents another invocation from replacing its authority.
17
+ const confirmationQueues = new WeakMap();
18
+ function waitWithAbort(promise, signal) {
19
+ const pending=Promise.resolve(promise);
20
+ if (!signal) return pending;
21
+ if(signal.aborted){pending.catch(()=>{});return Promise.reject(signal.reason);}
22
+ return new Promise((resolve, reject) => {
23
+ let settled=false;
24
+ const finish = (callback, value) => { if(settled)return;settled=true;signal.removeEventListener('abort', abort); callback(value); };
25
+ const abort = () => finish(reject, signal.reason);
26
+ signal.addEventListener('abort', abort, {once:true});
27
+ pending.then(value => finish(resolve,value), error => finish(reject,error));
28
+ });
29
+ }
30
+ function permissionStamp(policy) {
31
+ return JSON.stringify([policy.mode,[...policy.allowed].sort(),[...policy.denied].sort(),policy.context?.intentions]);
32
+ }
33
+
15
34
  export class PermissionPolicy {
16
35
  constructor({ mode = 'default', allowed = [], denied = [], confirm, classifier, cwd, onDecision = () => {} } = {}) {
17
- if (!PERMISSION_MODES.includes(mode)) throw new Error('Modo de permisos inválido. Usa default, plan, acceptEdits o auto.');
36
+ if (!PERMISSION_MODES.includes(mode)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto.');
18
37
  this.mode = mode; this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
19
38
  this.classifier = classifier; this.cwd = cwd; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
20
39
  }
21
40
  setContext(context) { this.context = context; }
22
41
  async authorize(name, input, { signal, onWaiting } = {}) {
23
42
  signal?.throwIfAborted();
24
- if (this.denied.has(name)) return { allowed: false, reason: 'Regla de denegación explícita' };
43
+ if (this.denied.has(name)) return { allowed: false, reason: 'Explicit denial rule' };
25
44
  const knownRead = READ_TOOLS.has(name) || this.allowed.has(name) && chromeLabObservation(name);
26
- if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegación: cada acción del hijo conserva los permisos de la sesión' };
27
- if (this.mode === 'plan' && !knownRead) return { allowed: false, reason: 'Modo plan: sólo lectura' };
28
- if (name === 'Bash' && readOnlyBashCheck(input.command)) return { allowed: true, reason: 'Comprobación de lectura nativa con argv fijo; no requiere clasificador.' };
45
+ if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegation: every child action retains the session permissions' };
46
+ if (this.mode === 'plan' && !knownRead) return { allowed: false, reason: 'Plan mode: read-only' };
47
+ if (name === 'Bash' && readOnlyBashCheck(input.command)) return { allowed: true, reason: 'Native read-only check with fixed argv; no classifier required.' };
29
48
  if (this.mode === 'auto' && !knownRead) return this.authorizeAuto(name, input, signal, onWaiting);
30
- if (knownRead || this.allowed.has(name) || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: 'Política de sesión' };
49
+ if (knownRead || this.allowed.has(name) || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: 'Session policy' };
31
50
  const allowed = await this.ask(name, input, signal, onWaiting);
32
- return { allowed, reason: allowed ? 'Confirmación del usuario' : 'Sin autorización para esta herramienta' };
51
+ return { allowed, reason: allowed ? 'User confirmation' : 'No authorization for this tool' };
33
52
  }
34
53
  async ask(name, input, signal, onWaiting) {
35
54
  if (!this.confirm) return false;
36
- const questionId = randomUUID(); let allowed = false;
37
- await onWaiting?.({ type: 'house_waiting', question_id: questionId });
38
- try { signal?.throwIfAborted(); allowed = Boolean(await this.confirm(name, input)); return allowed; }
39
- finally { await onWaiting?.({ type: 'house_answered', question_id: questionId, allowed }); }
55
+ const confirm=this.confirm, stamp=permissionStamp(this), previous=confirmationQueues.get(confirm) || Promise.resolve();
56
+ let release;
57
+ const slot=new Promise(resolve => {release=resolve;});
58
+ const tail=previous.catch(() => {}).then(() => slot);
59
+ confirmationQueues.set(confirm,tail);
60
+ const questionId=randomUUID();let allowed=false,displayed=false;
61
+ try {
62
+ await waitWithAbort(previous,signal);
63
+ signal?.throwIfAborted();
64
+ // Do not show a stale question. House observes the changed authority and
65
+ // calls authorize again, so newly denied/plan actions cannot reach a UI.
66
+ if(this.confirm!==confirm || stamp!==permissionStamp(this))return false;
67
+ await onWaiting?.({type:'house_waiting',question_id:questionId});
68
+ displayed=true;signal?.throwIfAborted();
69
+ allowed=Boolean(await waitWithAbort(confirm(name,input,{signal,questionId}),signal));
70
+ signal?.throwIfAborted();
71
+ return stamp===permissionStamp(this) && this.confirm===confirm ? allowed : false;
72
+ } finally {
73
+ try { if(displayed)await onWaiting?.({type:'house_answered',question_id:questionId,allowed:allowed && !signal?.aborted && stamp===permissionStamp(this)}); }
74
+ finally {release();if(confirmationQueues.get(confirm)===tail)tail.then(()=>{if(confirmationQueues.get(confirm)===tail)confirmationQueues.delete(confirm);});}
75
+ }
40
76
  }
41
77
  async authorizeAuto(name, input, signal, onWaiting) {
42
78
  const inspection = inspectAutoAction(name, input, this.cwd || process.cwd());
43
79
  const data = { workspace: this.cwd, human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
44
80
  let verdict;
45
81
  if (inspection.hard) verdict = { decision: 'deny', risk: 'destructive', reason: inspection.hard, intent_quote: '', source: 'guard' };
46
- else if (!this.classifier || !data.human_requests.length || Buffer.byteLength(JSON.stringify(data)) > 64000) verdict = { decision: 'ask', risk: 'unknown', reason: 'No hay clasificador o contexto de intención suficiente para aprobar automáticamente.', intent_quote: '', source: 'guard' };
82
+ else if (!this.classifier || !data.human_requests.length || Buffer.byteLength(JSON.stringify(data)) > 64000) verdict = { decision: 'ask', risk: 'unknown', reason: 'No classifier or insufficient intent context for automatic approval.', intent_quote: '', source: 'guard' };
47
83
  else try {
48
84
  verdict = { ...await this.classifier.classify(data, signal), source: 'classifier' };
49
- if (verdict.decision === 'allow' && (inspection.opaque || verdict.risk === 'unknown')) verdict = { ...verdict, decision: 'ask', reason: 'La acción contiene expansión, ejecución opaca o efectos desconocidos; requiere revisión.' };
50
- if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests)) verdict = { ...verdict, decision: 'deny', reason: 'La intención más reciente no autoriza explícitamente la acción destructiva y su destino.' };
51
- if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote))) verdict = { ...verdict, decision: 'ask', reason: 'Falta autorización explícita para el efecto externo.' };
85
+ if (verdict.decision === 'allow' && (inspection.opaque || verdict.risk === 'unknown')) verdict = { ...verdict, decision: 'ask', reason: 'The action contains expansion, opaque execution or unknown effects; review is required.' };
86
+ if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests)) verdict = { ...verdict, decision: 'deny', reason: 'The latest intent does not explicitly authorize the destructive action and its target.' };
87
+ if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote))) verdict = { ...verdict, decision: 'ask', reason: 'Explicit authorization for the external effect is missing.' };
52
88
  } catch (error) {
53
89
  if (signal?.aborted || error.code === 'GOAL_BUDGET') throw error;
54
- verdict = { decision: 'ask', risk: 'unknown', reason: `Verificador auto no disponible: ${error.message}`, intent_quote: '', source: 'classifier-error' };
90
+ verdict = { decision: 'ask', risk: 'unknown', reason: `Auto verifier unavailable: ${error.message}`, intent_quote: '', source: 'classifier-error' };
55
91
  }
56
92
  signal?.throwIfAborted();
57
93
  if (this.mode !== 'auto') return this.authorize(name, input, { signal, onWaiting });
@@ -16,7 +16,7 @@ const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Er
16
16
  function definition(name) {
17
17
  const id = String(name || '').toLowerCase();
18
18
  const found = AUTH_PROVIDERS.find(item => item.id === ({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi' }[id] || id));
19
- if (!found) throw failure('Proveedor de login desconocido: codex, kimi, minimax o grok.', 'PROVIDER_AUTH_UNKNOWN');
19
+ if (!found) throw failure('Unknown login provider: codex, kimi, minimax or grok.', 'PROVIDER_AUTH_UNKNOWN');
20
20
  return found;
21
21
  }
22
22
  export function authProviderDefaults(name) {
@@ -28,7 +28,7 @@ function tokenClaims(token) {
28
28
  }
29
29
  function secret(value) { return typeof value === 'string' && value.length > 0 && value.length <= 32768 && !/[\x00-\x20\x7f]/.test(value); }
30
30
  function tokenRecord(provider, payload, now, previous = {}) {
31
- if (!secret(payload?.access_token)) throw failure('El servidor no entregó un access_token válido.');
31
+ if (!secret(payload?.access_token)) throw failure('The server did not return a valid access_token.');
32
32
  const claims = tokenClaims(payload.access_token), identity = tokenClaims(payload.id_token || previous.idToken);
33
33
  const auth = claims['https://api.openai.com/auth'] || identity['https://api.openai.com/auth'] || {};
34
34
  const ttl = Number(payload.expires_in);
@@ -49,7 +49,7 @@ export class AuthManager {
49
49
  async _directory() {
50
50
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
51
51
  const info = await lstat(this.directory);
52
- if (!info.isDirectory() || info.isSymbolicLink() || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Directorio de credenciales inseguro.');
52
+ if (!info.isDirectory() || info.isSymbolicLink() || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Insecure credential directory.');
53
53
  await chmod(this.directory, 0o700);
54
54
  }
55
55
  async _read(provider) {
@@ -57,14 +57,14 @@ export class AuthManager {
57
57
  let handle;
58
58
  try {
59
59
  const dir = await lstat(this.directory);
60
- if (!dir.isDirectory() || dir.isSymbolicLink() || dir.mode & 0o077 || typeof process.getuid === 'function' && dir.uid !== process.getuid()) throw failure('Directorio de credenciales inseguro.');
60
+ if (!dir.isDirectory() || dir.isSymbolicLink() || dir.mode & 0o077 || typeof process.getuid === 'function' && dir.uid !== process.getuid()) throw failure('Insecure credential directory.');
61
61
  handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
62
62
  const info = await handle.stat();
63
- if (!info.isFile() || info.size > 65536 || info.mode & 0o077 || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Archivo de credenciales inseguro.');
63
+ if (!info.isFile() || info.size > 65536 || info.mode & 0o077 || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Insecure credential file.');
64
64
  const record = JSON.parse(await handle.readFile('utf8'));
65
- if (record.version !== 1 || record.provider !== provider || !secret(record.accessToken)) throw failure('Credenciales guardadas inválidas.');
65
+ if (record.version !== 1 || record.provider !== provider || !secret(record.accessToken)) throw failure('Invalid stored credentials.');
66
66
  return record;
67
- } catch (error) { if (error.code === 'ENOENT') return null; throw failure('No se pudieron leer credenciales privadas.', 'PROVIDER_AUTH_STORE'); }
67
+ } catch (error) { if (error.code === 'ENOENT') return null; throw failure('Could not read private credentials.', 'PROVIDER_AUTH_STORE'); }
68
68
  finally { await handle?.close(); }
69
69
  }
70
70
  async _write(record) {
@@ -84,11 +84,11 @@ export class AuthManager {
84
84
  signal?.throwIfAborted();
85
85
  try { await mkdir(lock, { mode: 0o700 }); break; }
86
86
  catch (error) {
87
- if (error.code !== 'EEXIST') throw failure('No se pudo bloquear el almacén de login.');
87
+ if (error.code !== 'EEXIST') throw failure('Could not lock the login store.');
88
88
  let info; try { info = await lstat(lock); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
89
- if (!info.isDirectory() || info.isSymbolicLink()) throw failure('Bloqueo de credenciales inseguro.');
89
+ if (!info.isDirectory() || info.isSymbolicLink()) throw failure('Insecure credential lock.');
90
90
  if (Date.now() - info.mtimeMs > 120000) { await rmdir(lock).catch(() => {}); continue; }
91
- if (Date.now() >= until) throw failure('Otro proceso está actualizando login; reintenta.', 'PROVIDER_AUTH_BUSY');
91
+ if (Date.now() >= until) throw failure('Another process is updating login; retry.', 'PROVIDER_AUTH_BUSY');
92
92
  await delay(50, undefined, { signal });
93
93
  }
94
94
  }
@@ -101,8 +101,8 @@ export class AuthManager {
101
101
  status: !record ? 'logged_out' : record.expiresAt && record.expiresAt <= this.now() ? 'expired' : record.method === 'device_code' ? 'authenticated' : 'configured',
102
102
  ...(record?.expiresAt ? { expiresAt: record.expiresAt } : {}),
103
103
  ...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}), documentation: p.documentation,
104
- ...(p.experimental ? { experimental: true, note: 'OAuth compatible con el código público Kimi CLI; la vía documentada para clientes externos es una clave Kimi Code (--api-key-env KIMI_CODE_API_KEY).' } : {}),
105
- ...(p.id === 'grok' ? { note: 'La API usa facturación API; no se encontró login público de suscripción Grok para Comal.' } : {}) };
104
+ ...(p.experimental ? { experimental: true, note: 'OAuth compatible with the public Kimi CLI code; the documented route for external clients is a Kimi Code key (--api-key-env KIMI_CODE_API_KEY).' } : {}),
105
+ ...(p.id === 'grok' ? { note: 'This API uses API billing; no public Grok subscription login was found for Comal.' } : {}) };
106
106
  }
107
107
  async _request(url, { signal, values, json = false, headers = {} } = {}) {
108
108
  const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
@@ -110,7 +110,7 @@ export class AuthManager {
110
110
  try { response = await this.fetch(url, { method: 'POST', redirect: 'error', signal: requestSignal,
111
111
  headers: { 'content-type': json ? 'application/json' : 'application/x-www-form-urlencoded', 'User-Agent': 'Comal/0.1.0', ...headers },
112
112
  body: json ? JSON.stringify(values) : new URLSearchParams(values).toString() }); }
113
- catch (error) { if (signal?.aborted) throw signal.reason; throw failure('No se pudo conectar con el servidor de autenticación.'); }
113
+ catch (error) { if (signal?.aborted) throw signal.reason; throw failure('Could not connect to the authentication server.'); }
114
114
  let data;
115
115
  try {
116
116
  const reader = response.body?.getReader();
@@ -121,18 +121,18 @@ export class AuthManager {
121
121
  data = JSON.parse(body + decoder.decode());
122
122
  } finally { await reader.cancel(); }
123
123
  }
124
- catch { throw failure(`Respuesta de autenticación inválida (HTTP ${response.status}).`); }
124
+ catch { throw failure(`Invalid authentication response (HTTP ${response.status}).`); }
125
125
  return { response, data };
126
126
  }
127
127
  async login(name, { signal, onEvent = () => {}, apiKeyEnv, secret: suppliedSecret } = {}) {
128
128
  const p = definition(name);
129
- if (apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) throw failure('Variable de credencial inválida.');
129
+ if (apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) throw failure('Invalid credential environment variable.');
130
130
  if (p.method !== 'device_code' || apiKeyEnv || suppliedSecret) {
131
- if (p.id === 'codex') throw failure('Codex subscription requiere device login; usa otro perfil para claves API.');
131
+ if (p.id === 'codex') throw failure('Codex subscription requires device login; use another profile for API keys.');
132
132
  const variable = apiKeyEnv || p.apiKeyEnv, value = suppliedSecret || this.env[variable];
133
133
  if (!value) return { ...await this.status(p.id), status: 'requires_secret', apiKeyEnv: variable,
134
- instructions: `Configura ${variable} con ${p.subscription ? 'la clave de tu suscripción' : 'tu clave API (facturación API)'} y ejecuta login de nuevo.` };
135
- if (!secret(value)) throw failure('Credencial inválida.');
134
+ instructions: `Set ${variable} to ${p.subscription ? 'your subscription key' : 'your API key (API billing)'} and run login again.` };
135
+ if (!secret(value)) throw failure('Invalid credential.');
136
136
  await this._locked(p.id, signal, () => this._write({ version: 1, provider: p.id, method: p.subscription ? 'subscription_key' : 'api_key', accessToken: value, updatedAt: this.now() }));
137
137
  // A key has been stored, not checked by making a billable inference request.
138
138
  return this.status(p.id);
@@ -141,13 +141,13 @@ export class AuthManager {
141
141
  const codex = p.id === 'codex', host = codex ? 'https://auth.openai.com' : 'https://auth.kimi.com';
142
142
  const initial = await this._request(host + (codex ? '/api/accounts/deviceauth/usercode' : '/api/oauth/device_authorization'),
143
143
  { signal, json: codex, values: { client_id: codex ? C : K } });
144
- if (!initial.response.ok) throw failure(`No se pudo iniciar login ${p.id} (HTTP ${initial.response.status}).`);
144
+ if (!initial.response.ok) throw failure(`Could not start ${p.id} login (HTTP ${initial.response.status}).`);
145
145
  const d = initial.data, code = d.user_code || d.usercode;
146
- if (!secret(code) || !secret(codex ? d.device_auth_id : d.device_code)) throw failure('Código de dispositivo inválido.');
146
+ if (!secret(code) || !secret(codex ? d.device_auth_id : d.device_code)) throw failure('Invalid device code.');
147
147
  const verification = codex ? host + '/codex/device' : d.verification_uri_complete || d.verification_uri;
148
148
  let verificationURL;
149
- try { verificationURL = new URL(verification); } catch { throw failure('URL de verificación inválida.'); }
150
- if (verificationURL.protocol !== 'https:' || verificationURL.username || verificationURL.password || !codex && !/^(?:[a-z0-9-]+\.)*kimi\.(?:com|ai)$/.test(verificationURL.hostname)) throw failure('Host de verificación inválido.');
149
+ try { verificationURL = new URL(verification); } catch { throw failure('Invalid verification URL.'); }
150
+ if (verificationURL.protocol !== 'https:' || verificationURL.username || verificationURL.password || !codex && !/^(?:[a-z0-9-]+\.)*kimi\.(?:com|ai)$/.test(verificationURL.hostname)) throw failure('Invalid verification host.');
151
151
  const expiresIn = Math.min(900, Math.max(1, Number(d.expires_in) || 900)), end = this.now() + expiresIn * 1000;
152
152
  let interval = Math.min(60, Math.max(1, Number(d.interval) || 5)) * 1000;
153
153
  await onEvent({ type: 'device_code', provider: p.id, verification_uri: verificationURL.href, user_code: code, expires_in: expiresIn });
@@ -159,26 +159,26 @@ export class AuthManager {
159
159
  if (polled.response.ok) {
160
160
  if (codex) {
161
161
  const q = polled.data;
162
- if (!secret(q.authorization_code) || !secret(q.code_verifier)) throw failure('Autorización Codex incompleta.');
162
+ if (!secret(q.authorization_code) || !secret(q.code_verifier)) throw failure('Incomplete Codex authorization.');
163
163
  const exchanged = await this._request(host + '/oauth/token', { signal, values: { client_id: C, grant_type: 'authorization_code', code: q.authorization_code, code_verifier: q.code_verifier, redirect_uri: host + '/deviceauth/callback' } });
164
- if (!exchanged.response.ok) throw failure(`Exchange Codex rechazado (HTTP ${exchanged.response.status}).`);
164
+ if (!exchanged.response.ok) throw failure(`Codex token exchange rejected (HTTP ${exchanged.response.status}).`);
165
165
  payload = exchanged.data;
166
166
  } else payload = polled.data;
167
167
  break;
168
168
  }
169
169
  const pending = codex ? [403, 404].includes(polled.response.status) : ['authorization_pending', 'slow_down'].includes(polled.data.error);
170
- if (!pending) throw failure(`Login ${p.id} rechazado o expirado (HTTP ${polled.response.status}).`);
170
+ if (!pending) throw failure(`${p.id} login rejected or expired (HTTP ${polled.response.status}).`);
171
171
  if (polled.data.error === 'slow_down') interval = Math.min(60000, interval + 5000);
172
172
  await this.sleep(Math.min(interval, Math.max(0, end - this.now())), signal);
173
173
  }
174
- if (!payload) throw failure('El código de dispositivo expiró.', 'PROVIDER_AUTH_EXPIRED');
174
+ if (!payload) throw failure('The device code expired.', 'PROVIDER_AUTH_EXPIRED');
175
175
  await this._locked(p.id, signal, () => this._write(tokenRecord(p.id, payload, this.now())));
176
176
  await onEvent({ type: 'authenticated', provider: p.id });
177
177
  return this.status(p.id);
178
178
  }
179
179
  async logout(name) {
180
180
  const p = definition(name);
181
- await this._locked(p.id, undefined, () => unlink(path.join(this.directory, `${p.id}.json`)).catch(error => { if (error.code !== 'ENOENT') throw failure('No se pudieron eliminar credenciales privadas.'); }));
181
+ await this._locked(p.id, undefined, () => unlink(path.join(this.directory, `${p.id}.json`)).catch(error => { if (error.code !== 'ENOENT') throw failure('Could not delete private credentials.'); }));
182
182
  return { provider: p.id, status: 'logged_out', remoteRevoked: false };
183
183
  }
184
184
  async resolve(name, { signal, forceRefresh = false } = {}) {
@@ -188,14 +188,14 @@ export class AuthManager {
188
188
  async _resolve(p, { signal, forceRefresh }) {
189
189
  if (this.pendingRefresh.has(p.id)) await this.pendingRefresh.get(p.id);
190
190
  let record = await this._read(p.id);
191
- if (!record) throw failure(`Primero ejecuta /login ${p.id}.`, 'PROVIDER_AUTH_REQUIRED');
191
+ if (!record) throw failure(`Run /login ${p.id} first.`, 'PROVIDER_AUTH_REQUIRED');
192
192
  if (record.method === 'device_code' && (forceRefresh || record.expiresAt <= this.now() + 60000)) {
193
- if (!record.refreshToken) throw failure('La sesión de login expiró; inicia sesión de nuevo.', 'PROVIDER_AUTH_EXPIRED');
193
+ if (!record.refreshToken) throw failure('The login session expired; log in again.', 'PROVIDER_AUTH_EXPIRED');
194
194
  const refresh = (async () => {
195
195
  const codex = p.id === 'codex';
196
196
  const { response, data } = await this._request(codex ? 'https://auth.openai.com/oauth/token' : 'https://auth.kimi.com/api/oauth/token', { signal,
197
197
  values: { client_id: codex ? C : K, grant_type: 'refresh_token', refresh_token: record.refreshToken } });
198
- if (!response.ok) throw failure(`No se pudo renovar login (HTTP ${response.status}); inicia sesión de nuevo.`, 'PROVIDER_AUTH_EXPIRED');
198
+ if (!response.ok) throw failure(`Could not refresh login (HTTP ${response.status}); log in again.`, 'PROVIDER_AUTH_EXPIRED');
199
199
  record = tokenRecord(p.id, data, this.now(), record); await this._write(record);
200
200
  })();
201
201
  this.pendingRefresh.set(p.id, refresh);
@@ -13,40 +13,40 @@ const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKe
13
13
  const error = message => Object.assign(new Error(message), { code: 'PROVIDER_PROFILE_INVALID' });
14
14
 
15
15
  export function normalizeCapabilities(value = {}, { protocol = 'openai' } = {}) {
16
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw error('capabilities debe ser un objeto de booleanos.');
16
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw error('capabilities must be an object of booleans.');
17
17
  const capabilities = { text: true, tools: true, vision: false, video: false, web_search: false };
18
18
  for (const [key, enabled] of Object.entries(value)) {
19
- if (!PROVIDER_CAPABILITIES.includes(key) || typeof enabled !== 'boolean') throw error(`Capacidad inválida: ${key}.`);
19
+ if (!PROVIDER_CAPABILITIES.includes(key) || typeof enabled !== 'boolean') throw error(`Invalid capability: ${key}.`);
20
20
  capabilities[key] = enabled;
21
21
  }
22
- if (!capabilities.text) throw error('Los perfiles de sesión requieren text:true.');
23
- if (protocol !== 'gemini' && (capabilities.video || capabilities.web_search)) throw error(`${protocol} no tiene adaptador nativo video/web_search en Comal; usa gemini o herramientas MCP.`);
22
+ if (!capabilities.text) throw error('Session profiles require text:true.');
23
+ if (protocol !== 'gemini' && (capabilities.video || capabilities.web_search)) throw error(`${protocol} has no native video/web_search adapter in Comal; use gemini or MCP tools.`);
24
24
  return Object.freeze(capabilities);
25
25
  }
26
26
 
27
27
  export function normalizeProviderProfile(input, name = input?.name) {
28
- if (!input || typeof input !== 'object' || Array.isArray(input)) throw error('Perfil inválido.');
29
- for (const key of Object.keys(input)) if (!KEYS.has(key)) throw error(`Campo no permitido en perfil: ${key}. Guarda sólo apiKeyEnv, nunca claves API.`);
30
- if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw error('Nombre de perfil inválido.');
31
- if (input.name && input.name !== name) throw error('Nombre de perfil contradictorio.');
32
- if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('authProvider inválido.');
28
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw error('Invalid profile.');
29
+ for (const key of Object.keys(input)) if (!KEYS.has(key)) throw error(`Field not allowed in profile: ${key}. Store only apiKeyEnv, never API keys.`);
30
+ if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw error('Invalid profile name.');
31
+ if (input.name && input.name !== name) throw error('Conflicting profile name.');
32
+ if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('Invalid authProvider.');
33
33
  if (input.effort !== undefined) normalizeEffort(input.effort);
34
34
  const toolSchema=input.toolSchema===undefined?undefined:normalizeToolSchemaCapabilities(input.toolSchema);
35
35
  const access=input.authProvider?authProviderDefaults(input.authProvider):null;
36
36
  const configuredCapabilities = input.capabilitiesConfigured || input.capabilities || {};
37
37
  normalizeCapabilities(configuredCapabilities, { protocol: input.protocol || input.provider || access?.protocol || 'openai' });
38
38
  const protocol = input.protocol || input.provider || access?.protocol || 'openai';
39
- if(access && protocol!==access.protocol)throw error('El protocolo del perfil no coincide con su login.');
40
- if (!PROTOCOLS.includes(protocol) || (input.protocol && input.provider && input.protocol !== input.provider)) throw error('Protocolo de proveedor inválido.');
41
- if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim() || input.model.length > 256)) throw error('Modelo de perfil inválido.');
42
- if (protocol !== 'openai' && !input.model && !access?.model) throw error(`${protocol} requiere model en su perfil.`);
39
+ if(access && protocol!==access.protocol)throw error('The profile protocol does not match its login.');
40
+ if (!PROTOCOLS.includes(protocol) || (input.protocol && input.provider && input.protocol !== input.provider)) throw error('Invalid provider protocol.');
41
+ if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim() || input.model.length > 256)) throw error('Invalid profile model.');
42
+ if (protocol !== 'openai' && !input.model && !access?.model) throw error(`${protocol} requires model in its profile.`);
43
43
  if (input.baseURL !== undefined) {
44
- if (typeof input.baseURL !== 'string') throw error('baseURL inválida.');
44
+ if (typeof input.baseURL !== 'string') throw error('Invalid baseURL.');
45
45
  const url = new URL(input.baseURL);
46
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw error('baseURL requiere HTTP(S), sin credenciales ni query.');
46
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw error('baseURL requires HTTP(S), without credentials or a query.');
47
47
  }
48
- if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv debe nombrar una variable de entorno.');
49
- if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('description inválida.');
48
+ 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
+ if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('Invalid description.');
50
50
  return Object.freeze({ name, provider: protocol, protocol, ...((input.model || access?.model) ? { model: input.model || access.model } : {}),
51
51
  baseURL: input.baseURL || access?.baseURL || ({ openai: DEFAULT_LOCAL_URL, anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta' })[protocol],
52
52
  apiKeyEnv: input.apiKeyEnv || ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY' })[protocol],
@@ -64,21 +64,21 @@ export async function loadProviderProfiles({ cwd = process.cwd(), configPath, en
64
64
  if (failure.code === 'ENOENT' && !configPath && !env.COMAL_PROVIDERS_CONFIG) return { profiles: [], defaultProfile: null, configPath: file };
65
65
  throw failure;
66
66
  }
67
- if (Buffer.byteLength(text) > 65536) throw error('La configuración de proveedores excede 64 KiB.');
67
+ if (Buffer.byteLength(text) > 65536) throw error('Provider configuration exceeds 64 KiB.');
68
68
  const config = JSON.parse(text);
69
- if (!config || typeof config !== 'object' || Array.isArray(config) || Object.keys(config).some(key => !['profiles', 'defaultProfile', 'default', 'version'].includes(key))) throw error('Formato de proveedores inválido.');
70
- if (config.version !== undefined && config.version !== 1) throw error('Versión de proveedores no soportada.');
69
+ if (!config || typeof config !== 'object' || Array.isArray(config) || Object.keys(config).some(key => !['profiles', 'defaultProfile', 'default', 'version'].includes(key))) throw error('Invalid provider configuration format.');
70
+ if (config.version !== undefined && config.version !== 1) throw error('Unsupported provider configuration version.');
71
71
  const raw = config.profiles;
72
- if (!raw || typeof raw !== 'object') throw error('Configura profiles como objeto o lista.');
72
+ if (!raw || typeof raw !== 'object') throw error('Configure profiles as an object or list.');
73
73
  const entries = Array.isArray(raw) ? raw.map(input => [input?.name, input]) : Object.entries(raw);
74
- if (entries.length > 32) throw error('Máximo 32 perfiles de proveedor.');
74
+ if (entries.length > 32) throw error('At most 32 provider profiles are allowed.');
75
75
  const names = new Set(), profiles = entries.map(([name, input]) => {
76
76
  const profile = normalizeProviderProfile(input, name);
77
- if (names.has(profile.name)) throw error('Nombre de perfil duplicado.');
77
+ if (names.has(profile.name)) throw error('Duplicate profile name.');
78
78
  names.add(profile.name); return profile;
79
79
  });
80
80
  const defaultProfile = config.defaultProfile ?? config.default ?? null;
81
- if (defaultProfile !== null && (typeof defaultProfile !== 'string' || !names.has(defaultProfile))) throw error('El perfil predeterminado no existe.');
81
+ if (defaultProfile !== null && (typeof defaultProfile !== 'string' || !names.has(defaultProfile))) throw error('The default profile does not exist.');
82
82
  return { profiles, defaultProfile, configPath: file };
83
83
  }
84
84
 
@@ -86,7 +86,7 @@ export function selectProfile(registry, name) {
86
86
  const requested = name || registry.defaultProfile;
87
87
  if (!requested) return null;
88
88
  const profile = registry.profiles.find(item => item.name === requested);
89
- if (!profile) throw Object.assign(new Error(`No existe el perfil ${requested}.`), { code: 'PROVIDER_PROFILE_NOT_FOUND' });
89
+ if (!profile) throw Object.assign(new Error(`Profile ${requested} does not exist.`), { code: 'PROVIDER_PROFILE_NOT_FOUND' });
90
90
  return profile;
91
91
  }
92
92
 
@@ -97,11 +97,11 @@ export function createProfileProvider(profile, { env = process.env, ...overrides
97
97
  else if (overrides.vision === 'off') capabilities.vision = false;
98
98
  const options = { ...overrides, apiKey: overrides.apiKey ?? env[profile.apiKeyEnv], model: overrides.model || profile.model, baseURL: overrides.baseURL || profile.baseURL,
99
99
  capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
100
- if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Define la variable apiKeyEnv ${profile.apiKeyEnv} del perfil.`);
100
+ if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Set the profile apiKeyEnv variable ${profile.apiKeyEnv}.`);
101
101
  const provider = profile.protocol === 'gemini' ? new GeminiProvider(options) : profile.protocol === 'anthropic' ? new AnthropicProvider(options) : new OpenAIProvider(options);
102
102
  const complete = provider.complete.bind(provider);
103
103
  provider.complete = request => {
104
- if (!capabilities.tools && request.tools?.length) throw Object.assign(new Error('El perfil no habilita tools.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
104
+ if (!capabilities.tools && request.tools?.length) throw Object.assign(new Error('The profile does not enable tools.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
105
105
  return complete(request);
106
106
  };
107
107
  provider.profileName = profile.name;
package/src/provider.mjs CHANGED
@@ -25,7 +25,7 @@ export async function* sseEvents(body) {
25
25
  };
26
26
  for await (const chunk of body) {
27
27
  total += chunk.byteLength;
28
- if (total > 8 * 1024 * 1024) throw new Error('La respuesta del modelo excedió 8 MiB.');
28
+ if (total > 8 * 1024 * 1024) throw new Error('The model response exceeded 8 MiB.');
29
29
  buffer += decoder.decode(chunk, { stream: true });
30
30
  // Keep a trailing CR until the next chunk so split CRLF is normalized correctly.
31
31
  buffer = buffer.replace(/\r\n/g, '\n');
@@ -40,27 +40,27 @@ export async function* sseEvents(body) {
40
40
  }
41
41
 
42
42
  export async function analyzeImages(provider, { prompt, media, signal, maxTokens } = {}) {
43
- if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 12000 || !Array.isArray(media) || !media.length || media.length > 4) throw new Error('Solicitud multimedia inválida.');
43
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 12000 || !Array.isArray(media) || !media.length || media.length > 4) throw new Error('Invalid media request.');
44
44
  const images = media.map(source => {
45
45
  const mimeType = source.mimeType || source.media_type;
46
- if (typeof mimeType !== 'string' || !/^image\/(?:png|jpeg|webp|gif)$/.test(mimeType)) throw Object.assign(new Error('Este adaptador admite imágenes; video requiere un perfil Gemini habilitado.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
47
- if (source.uri || source.file_uri || typeof source.data !== 'string' || !source.data || source.data.length % 4 || !/^[a-zA-Z0-9+/]*={0,2}$/.test(source.data)) throw new Error('La imagen requiere datos base64 válidos; no se admiten URI remotas en este adaptador.');
48
- if (source.data.length > 4 * 1024 * 1024) throw new Error('La imagen excede el límite de 4 MiB codificados.');
46
+ if (typeof mimeType !== 'string' || !/^image\/(?:png|jpeg|webp|gif)$/.test(mimeType)) throw Object.assign(new Error('This adapter supports images; video requires an enabled Gemini profile.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
47
+ if (source.uri || source.file_uri || typeof source.data !== 'string' || !source.data || source.data.length % 4 || !/^[a-zA-Z0-9+/]*={0,2}$/.test(source.data)) throw new Error('The image requires valid base64 data; this adapter does not accept remote URIs.');
48
+ if (source.data.length > 4 * 1024 * 1024) throw new Error('The image exceeds the 4 MiB encoded limit.');
49
49
  return { type: 'image', source: { type: 'base64', media_type: mimeType, data: source.data } };
50
50
  });
51
- if (!(await provider.getVisionCapability({ signal })).supported) throw Object.assign(new Error('El proveedor no habilita visión.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
51
+ if (!(await provider.getVisionCapability({ signal })).supported) throw Object.assign(new Error('The provider does not enable vision.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
52
52
  const result = await provider.complete({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...images] }], tools: [], signal, maxTokens });
53
53
  return { answer: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), usage: result.usage, model: provider.model };
54
54
  }
55
55
 
56
56
  function validateMessage(message) {
57
- if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Respuesta incompleta del modelo.');
58
- if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Respuesta Anthropic truncada; no se ejecutaron herramientas.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage)});
57
+ if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
58
+ 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)});
59
59
  const ids = new Set();
60
60
  for (const block of message.content) {
61
- if (!block || typeof block.type !== 'string') throw new Error('Bloque de contenido inválido.');
61
+ if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
62
62
  if (block.type === 'tool_use') {
63
- 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('Llamada de herramienta inválida.');
63
+ 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.');
64
64
  ids.add(block.id);
65
65
  }
66
66
  }
@@ -69,10 +69,10 @@ function validateMessage(message) {
69
69
 
70
70
  export class AnthropicProvider {
71
71
  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 } = {}) {
72
- if (!apiKey) throw new Error('Define ANTHROPIC_API_KEY o usa --demo para probar sin credenciales.');
72
+ if (!apiKey) throw new Error('Set ANTHROPIC_API_KEY or use --demo to try without credentials.');
73
73
  const url = new URL(baseURL.replace(/\/$/, '') + '/v1/messages');
74
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('El proveedor requiere HTTPS; HTTP sólo se permite en localhost.');
75
- if (url.username || url.password || url.search || url.hash) throw new Error('URL del proveedor inválida.');
74
+ 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.');
75
+ if (url.username || url.password || url.search || url.hash) throw new Error('Invalid provider URL.');
76
76
  this.protocol = 'anthropic'; this.effort = normalizeEffort(effort); this.capabilitiesConfigured = { ...capabilitiesConfigured }; this.configuredCapabilities = this.capabilitiesConfigured;
77
77
  this.apiKey = apiKey; this.url = url; this.model = model; this.maxTokens = maxTokens; this.timeoutMs = timeoutMs; this.fetch = fetchImpl;
78
78
  Object.assign(this, visionSettings({ vision, imageTokens }, {}));
@@ -88,10 +88,10 @@ export class AnthropicProvider {
88
88
  // but omit another provider's private reasoning from the Anthropic wire.
89
89
  if (original.type === 'openai_reasoning' || original.type === 'thinking' && original.gemini_part) return [];
90
90
  const { gemini_part: ignoredGeminiPart, ...block } = original;
91
- if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('El adaptador Anthropic no admite video; usa un perfil con transporte multimedia.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
92
- if (block.type === 'image_reference') throw new Error('Referencia visual sin resolver; no se envió al proveedor.');
91
+ if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('The Anthropic adapter does not support video; use a profile with media transport.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
92
+ if (block.type === 'image_reference') throw new Error('Unresolved image reference; it was not sent to the provider.');
93
93
  if (block.type !== 'image') return block;
94
- if (!this.getVisionCapability().supported) throw new Error('Visión no habilitada en el proveedor; no se enviaron píxeles.');
94
+ if (!this.getVisionCapability().supported) throw new Error('Vision is not enabled for this provider; no pixels were sent.');
95
95
  return { type: 'image', source: block.source };
96
96
  }) }).filter(message => !Array.isArray(message.content) || message.content.length);
97
97
  const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) : AbortSignal.timeout(this.timeoutMs);
@@ -99,7 +99,7 @@ export class AnthropicProvider {
99
99
  const body = JSON.stringify({ ...effortWire, model: this.model, max_tokens: maxTokens, system, messages: mergeMessages(messages), ...(tools.length ? { tools } : {}), stream: true });
100
100
  const account=result=>({...result,usage_accounting:bindUsageAccounting(normalizeUsage('anthropic',result.usage),accountingRequest,identity,body),schema_diagnostics:schemaFrame.diagnostics,schema_manifest:schemaFrame.manifest});
101
101
  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;}};
102
- if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('El contexto excede 8 MiB; inicia una sesión nueva con /clear.');
102
+ if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
103
103
  let response;
104
104
  for (let attempt = 0; attempt < 3; attempt++) {
105
105
  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 });
@@ -110,7 +110,7 @@ export class AnthropicProvider {
110
110
  }
111
111
  if (!response.ok) {
112
112
  await response.body?.cancel();
113
- throw new Error(`API HTTP ${response.status}. Revisa la clave, el modelo, los límites o el endpoint.`);
113
+ throw new Error(`API HTTP ${response.status}. Check the key, model, limits or endpoint.`);
114
114
  }
115
115
  if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
116
116
  const message = finish(await response.json());
@@ -120,29 +120,29 @@ export class AnthropicProvider {
120
120
  let message, stopped = false;
121
121
  const blocks = new Map(), partial = new Map(), closed = new Set();
122
122
  for await (const event of sseEvents(response.body)) {
123
- if (event.type === 'error') throw new Error(`Error de streaming: ${event.error?.type || 'unknown'}`);
124
- if (event.type === 'message_start') { if (message) throw new Error('message_start duplicado.'); message = structuredClone(event.message); }
123
+ if (event.type === 'error') throw new Error(`Streaming error: ${event.error?.type || 'unknown'}`);
124
+ if (event.type === 'message_start') { if (message) throw new Error('Duplicate message_start.'); message = structuredClone(event.message); }
125
125
  else if (event.type === 'content_block_start') {
126
- if (blocks.has(event.index)) throw new Error('Bloque duplicado.');
126
+ if (blocks.has(event.index)) throw new Error('Duplicate block.');
127
127
  blocks.set(event.index, structuredClone(event.content_block));
128
128
  } else if (event.type === 'content_block_delta') {
129
129
  const block = blocks.get(event.index);
130
- if (!block || closed.has(event.index)) throw new Error('Delta fuera de su bloque.');
130
+ if (!block || closed.has(event.index)) throw new Error('Delta outside its block.');
131
131
  const d = event.delta;
132
132
  if (d.type === 'text_delta') { block.text = (block.text || '') + d.text; onText(d.text); }
133
133
  else if (d.type === 'input_json_delta') partial.set(event.index, (partial.get(event.index) || '') + d.partial_json);
134
134
  else if (d.type === 'thinking_delta') block.thinking = (block.thinking || '') + d.thinking;
135
135
  else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
136
136
  } else if (event.type === 'content_block_stop') {
137
- if (!blocks.has(event.index)) throw new Error('Cierre de bloque desconocido.');
137
+ if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
138
138
  if (partial.has(event.index)) blocks.get(event.index).input = JSON.parse(partial.get(event.index));
139
139
  closed.add(event.index);
140
140
  } else if (event.type === 'message_delta') {
141
- if (!message) throw new Error('Delta sin mensaje.');
141
+ if (!message) throw new Error('Delta without a message.');
142
142
  Object.assign(message, event.delta); message.usage = { ...message.usage, ...event.usage };
143
143
  } else if (event.type === 'message_stop') stopped = true;
144
144
  }
145
- if (!stopped || !message || closed.size !== blocks.size) throw new Error('Streaming interrumpido; ninguna herramienta de esta respuesta fue ejecutada.');
145
+ if (!stopped || !message || closed.size !== blocks.size) throw new Error('Streaming interrupted; no tools from this response were executed.');
146
146
  message.content = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block);
147
147
  return finish(message);
148
148
  }
@@ -151,23 +151,23 @@ export class AnthropicProvider {
151
151
  export class DemoProvider {
152
152
  constructor(sessionId) { this.model = 'demo-scripted'; this.file = `comal-demo-${sessionId.slice(0, 8)}.md`; }
153
153
  async complete({ messages, onText = () => {}, signal }) {
154
- if (signal?.aborted) throw new Error('Demostración cancelada.');
154
+ if (signal?.aborted) throw new Error('Demo cancelled.');
155
155
  const projection = messages.find(m => typeof m.content === 'string' && m.content.startsWith('Estado de trabajo derivado determinísticamente por Comal'));
156
156
  const state = projection ? JSON.parse(projection.content.slice(projection.content.indexOf('\n') + 1)) : null;
157
157
  const progress = state?.tool_progress.recent.filter(item => /^demo_\d+$/.test(item.id));
158
158
  const calls = progress?.length ? Math.max(...progress.map(item => Number(item.id.slice(5)))) + 1 : messages.flatMap(m => Array.isArray(m.content) ? m.content : []).filter(b => b.type === 'tool_result').length;
159
159
  const steps = [
160
- ['Write', { file_path: this.file, content: '# Comal\n\nCiclo de herramientas pendiente.\n' }],
160
+ ['Write', { file_path: this.file, content: '# Comal\n\nTool cycle pending.\n' }],
161
161
  ['Read', { file_path: this.file }],
162
- ['Edit', { file_path: this.file, old_string: 'pendiente', new_string: 'verificado' }],
163
- ['Grep', { pattern: 'verificado', glob: this.file }]
162
+ ['Edit', { file_path: this.file, old_string: 'pending', new_string: 'verified' }],
163
+ ['Grep', { pattern: 'verified', glob: this.file }]
164
164
  ];
165
165
  if (calls < steps.length) {
166
166
  const [name, input] = steps[calls];
167
167
  return { role: 'assistant', content: [{ type: 'tool_use', id: `demo_${calls}`, name, input }], stop_reason: 'tool_use', usage: { input_tokens: 0, output_tokens: 0 } };
168
168
  }
169
169
  const failed = progress?.some(item => item.is_error) || messages.flatMap(m => Array.isArray(m.content) ? m.content : []).some(b => b.type === 'tool_result' && b.is_error);
170
- const text = failed ? 'La demo terminó con herramientas denegadas o errores. Consulta los eventos; no todos los cambios se completaron.' : `Demo sin modelo real: creé, leí, edité y busqué texto en ${this.file}. El historial puede reanudarse.`;
170
+ const text = failed ? 'The demo finished with denied tools or errors. Check the events; not all changes completed.' : `Demo without a real model: created, read, edited and searched text in ${this.file}. The history can be resumed.`;
171
171
  onText(text);
172
172
  return { role: 'assistant', content: [{ type: 'text', text }], stop_reason: 'end_turn', usage: { input_tokens: 0, output_tokens: 0 } };
173
173
  }
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ export const remoteCommandContract = JSON.parse(readFileSync(new URL('../backend/contracts/remote-commands-v1.json', import.meta.url), 'utf8'));
4
+ export function isRemoteCommand(text) {
5
+ const match = /^\/([a-z-]+)(?:[ \t]+([^\r\n]*))?$/.exec(text.trim());
6
+ if (!match) return false;
7
+ const command = remoteCommandContract.commands.find(item => item.name === match[1]);
8
+ const arg = (match[2] || '').trim();
9
+ return Boolean(command && (!arg || new RegExp('^(?:' + command.argument_pattern + ')$').test(arg)));
10
+ }
11
+ export const remoteCommandHelp = () => remoteCommandContract.commands.map(item => '/' + item.name + (item.arguments ? ' ' + item.arguments : '') + ' — ' + item.description).join('\n') + '\n\nPermission changes, credentials and session switching remain in the local terminal.';