@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
@@ -0,0 +1,172 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { CloudClient } from './cloud-client.mjs';
3
+
4
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ export const remoteInputDigest = (session, request, text) => digest([session,request,text]);
6
+ const boundText = (text, max=30000) => Buffer.from(String(text || '')).subarray(0,max).toString('utf8').replace(/\uFFFD$/, '');
7
+
8
+ /** Bounded projection; provider signatures, tool arguments/results and credentials stay local. */
9
+ export function projectRemoteEvent(event, turnId) {
10
+ const base={turn_id:turnId || 'local',text:''};
11
+ if(event.type==='user')return {...base,type:'message.user',text:boundText(event.text)};
12
+ if(event.type==='assistant')return {...base,type:'message.assistant',text:boundText((event.message?.content || []).filter(b=>b.type==='text').map(b=>b.text).join(''))};
13
+ if(event.type==='result'&&event.turns===0&&typeof event.result==='string')return {...base,type:'message.control',text:boundText(event.result)};
14
+ if(event.type==='tool_use')return {...base,type:'tool.started',tool_name:boundText(event.name,128)};
15
+ if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned'};
16
+ if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
17
+ return null;
18
+ }
19
+
20
+ const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
21
+ const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
22
+
23
+ export class RemoteHost {
24
+ constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
25
+ Object.assign(this,{client,onInput,onStop,onNotice,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
26
+ }
27
+ status(){return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only'}:{enabled:false};}
28
+ current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
29
+ assertCurrent(run,options){if(!this.current(run,options))throw cancelled();}
30
+ async revoke(run) {
31
+ // A stopped pending open may still succeed upstream. Its original authority
32
+ // remains bound to that attempt, never to a replacement account or session.
33
+ if(!run?.session || !run.account || !run.enablingToken)return;
34
+ if(!run.revoking)run.revoking=Promise.resolve().then(()=>this.client.request(run.account.origin,'remote.sessions.close',{remote_session_id:run.session.id},run.enablingToken,AbortSignal.timeout(10000))).catch(()=>{});
35
+ await run.revoking;
36
+ }
37
+ async start(session) {
38
+ if(this.starting && this.current(this.starting,{starting:true}))return this.starting.promise;
39
+ if(!this.stopped)return this.status();
40
+ const run={controller:new AbortController()};this.lifecycle=run;this.starting=run;
41
+ run.promise=(async()=>{
42
+ try {
43
+ const account=await this.client.credentials();this.assertCurrent(run,{starting:true});
44
+ if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
45
+ run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
46
+ const opened=await this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:['commands_v1','thinking_v1']},{expectedAccount:run.account,signal:run.controller.signal});
47
+ run.session=opened.session;
48
+ // Abort can race a successful response. Revoke a late-created grant even
49
+ // when the local stop completed before its identifier became available.
50
+ if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
51
+ this.account=run.account;this.enablingToken=run.enablingToken;this.session=opened.session;this.url=opened.url;this.stopped=false;
52
+ await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
53
+ this.transport='https-polling';this.connectSocket(run);
54
+ this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
55
+ for(const message of (session.historyMessages || []).slice(-20)){
56
+ const event=message.role==='user'&&(typeof message.content==='string'||Array.isArray(message.content)&&message.content.some(block=>block.type==='text'))?{type:'user',text:typeof message.content==='string'?message.content:message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n')}:message.role==='assistant'?{type:'assistant',message}:null;
57
+ if(event)this.observe(event);
58
+ }
59
+ this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.');
60
+ return this.status();
61
+ } catch(error) {
62
+ if(this.lifecycle===run)await this.stop();else await this.revoke(run);
63
+ throw error;
64
+ } finally {if(this.starting===run)this.starting=null;}
65
+ })();
66
+ return run.promise;
67
+ }
68
+ async authorize(run=this.lifecycle) {
69
+ this.assertCurrent(run);const session=this.session,account=this.account;
70
+ const ticket=await this.client.invoke('remote.stream.ticket',{remote_session_id:session.id,role:'host'},{expectedAccount:account,signal:lifetimeSignal(run)});
71
+ this.assertCurrent(run);
72
+ const access=await this.client.request(account.origin,'remote.stream.consume',{ticket:ticket.ticket,role:'host'},undefined,lifetimeSignal(run));
73
+ this.assertCurrent(run);this.access=access.access_token;this.expires=access.expires_at;
74
+ }
75
+ async call(operation,input={},run=this.lifecycle) {
76
+ this.assertCurrent(run);const account=this.account,session=this.session;
77
+ const current=await this.client.credentials();this.assertCurrent(run);
78
+ if(!current||current.origin!==account.origin||current.user_id!==account.user_id)throw Object.assign(Error('Comal account changed; Remote Control detached.'),{code:'REMOTE_REVOKED'});
79
+ if(this.expires<=Date.now()/1000+30)await this.authorize(run);
80
+ this.assertCurrent(run);
81
+ const result=await this.client.request(account.origin,operation,{remote_session_id:session.id,...input},this.access,lifetimeSignal(run));
82
+ this.assertCurrent(run);return result;
83
+ }
84
+ async connectSocket(run=this.lifecycle) {
85
+ if(!this.current(run)||this.socket||this.connecting)return;
86
+ const marker={run};this.connecting=marker;
87
+ try {
88
+ const account=this.account,session=this.session;
89
+ const ticket=await this.client.invoke('remote.stream.ticket',{remote_session_id:session.id,role:'host'},{expectedAccount:account,signal:lifetimeSignal(run)});
90
+ this.assertCurrent(run);
91
+ const url=new URL('/remote/host',account.origin);url.protocol=url.protocol==='https:'?'wss:':'ws:';
92
+ const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
93
+ const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
94
+ ws.addEventListener('open',()=>{if(!this.current(run)||this.socket!==ws){ws.close();return;}ws.send(JSON.stringify({protocol:'comal.remote.v1',kind:'auth',ticket:ticket.ticket,after:'0'}));});
95
+ ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';}}catch{ws.close();}});
96
+ ws.addEventListener('error',()=>{});
97
+ ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
98
+ }catch{/* HTTPS is already active. A later tick retries WSS. */}
99
+ finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
100
+ }
101
+ observe(event) {
102
+ if(this.stopped)return;
103
+ const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
104
+ if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
105
+ this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
106
+ }
107
+ if(event.type==='assistant'){
108
+ const thinking=(event.message?.content||[]).filter(block=>['thinking','openai_reasoning'].includes(block.type)).map(block=>block.thinking||block.text||'').join('\n');
109
+ if(thinking||this.thinkingActive)append({type:'thinking.finished',turn_id:this.turnId||'local',text:boundText(thinking||'Thinking complete.')});
110
+ this.thinkingActive=false;
111
+ }
112
+ if(['response_discarded','model_retry'].includes(event.type))this.thinkingActive=false;
113
+ if(event.type==='text_delta'){
114
+ if(this.transport==='websocket'&&this.socket?.readyState===1&&this.turnId){
115
+ this.delta=boundText((this.delta || '')+event.text,32000);
116
+ if(!this.deltaTimer){const run=this.lifecycle;this.deltaTimer=setTimeout(()=>{if(!this.current(run))return;this.deltaTimer=null;const text=this.delta;this.delta='';if(this.socket?.readyState===1)for(let offset=0;offset<text.length;offset+=1800)this.socket.send(JSON.stringify({protocol:'comal.remote.v1',kind:'delta',turn_id:this.turnId,text:text.slice(offset,offset+1800)}));},120);}
117
+ }
118
+ return;
119
+ }
120
+ if(['assistant','response_discarded','model_retry'].includes(event.type)){clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';}
121
+ const projected=projectRemoteEvent(event,this.turnId);if(!projected)return;
122
+ if(this.pending.length>=512){this.onNotice('Remote projection paused: outbound buffer full. Local work continues.');this.stop().catch(()=>{});return;}
123
+ append(projected);this.flush().catch(()=>{});
124
+ }
125
+ async flush(run=this.lifecycle){
126
+ if(!this.current(run))return;
127
+ if(this.sending)return this.sending;
128
+ const work=(async()=>{while(this.current(run)&&this.pending.length){await this.call('remote.events.publish',this.pending[0],run);this.assertCurrent(run);this.pending.shift();}})();
129
+ this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
130
+ }
131
+ pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(this.current(run))return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId},run);});this.pulsing=work;return work;}
132
+ async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
133
+ async endTurn(){
134
+ if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});}
135
+ clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
136
+ }
137
+ async validate(item){
138
+ if(!item.remote)return true;
139
+ const run=this.lifecycle;
140
+ if(this.stopped||item.remote.session!==this.session.id)throw Error('Remote grant detached; queued input was not executed.');
141
+ await this.call('remote.inputs.validate',{request_id:item.remote.request},run);this.assertCurrent(run);return true;
142
+ }
143
+ async tick(run=this.lifecycle){
144
+ if(!this.current(run)||this.processing)return this.processing;
145
+ const work=(async()=>{
146
+ await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
147
+ const page=await this.call('remote.inputs.claim',{},run);this.assertCurrent(run);
148
+ for(const input of page.inputs){
149
+ this.assertCurrent(run);let state='accepted_locally',queue_item_id='';
150
+ // Only admission errors are rejection. A lost ACK must redeliver the same input.
151
+ try {
152
+ if(input.kind==='stop'){if(input.turn_id!==this.turnId||!await this.onStop(input.turn_id))throw Error('Stale turn.');queue_item_id='stop:'+input.turn_id;}
153
+ else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
154
+ }catch{state='rejected';}
155
+ this.assertCurrent(run);await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id},run);
156
+ }
157
+ if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
158
+ })().catch(error=>{
159
+ if(!this.current(run))return;
160
+ if(['AUTH_INVALID','REMOTE_REVOKED','REMOTE_CLOSED','REMOTE_DENIED'].includes(error.code)){this.onNotice('Remote Control detached: '+error.message);this.stop().catch(()=>{});}
161
+ else if(Date.now()-(this.lastErrorAt||0)>30000){this.lastErrorAt=Date.now();this.onNotice('Remote relay reconnecting; local work continues.');}
162
+ });
163
+ this.processing=work;try{return await work;}finally{if(this.processing===work)this.processing=null;}
164
+ }
165
+ async stop(){
166
+ const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
167
+ this.lifecycle=null;this.starting=null;run.controller?.abort();
168
+ this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
169
+ this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
170
+ await this.revoke(run);return this.status();
171
+ }
172
+ }
package/src/sessions.mjs CHANGED
@@ -43,7 +43,7 @@ export class SessionStore {
43
43
  const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row)),parseDone=performance.now();
44
44
  if (rows[0]?.type !== 'session' || rows[0].cwd !== this.cwd) throw new Error('La sesión pertenece a otro directorio o es inválida.');
45
45
  this.lastLoadProfile.phase='fold';
46
- let messages = []; const historyMessages = []; let metadata = rows[0], goal, queue, draft = '', compaction, projection;
46
+ let messages = []; const historyMessages = []; let metadata = rows[0], goal, queue, draft = '', draftAttachments = [], compaction, projection;
47
47
  for (const row of rows.slice(1)) {
48
48
  if (row.type === 'reset') { messages = []; historyMessages.length = 0; compaction = undefined; projection = undefined; }
49
49
  if (row.type === 'compaction') { messages = row.messages.slice(); compaction = row; }
@@ -54,7 +54,7 @@ export class SessionStore {
54
54
  if (row.type === 'provider') metadata = { ...metadata, provider: row.provider, model: row.model, ...(row.profile !== undefined ? {provider_profile:row.profile} : {}),...(row.connection ? {provider_connection:row.connection} : {}) };
55
55
  if (row.type === 'goal') goal = row.goal;
56
56
  if (row.type === 'queue') queue = row.snapshot;
57
- if (row.type === 'draft') draft = row.text;
57
+ if (row.type === 'draft') { draft = row.text; draftAttachments = row.attachments || []; }
58
58
  if (row.type === 'name') metadata = { ...metadata, name: row.name };
59
59
  if (row.type === 'parent') metadata = { ...metadata, parent_session_id: row.parent_session_id, subagent_type: row.subagent_type, name: row.description };
60
60
  }
@@ -63,7 +63,7 @@ export class SessionStore {
63
63
  const journalAudit=cachedAudit || auditJournal(rows),auditDone=performance.now();
64
64
  if(!cachedAudit)this.#journalCache.set(id,bytes,journalAudit);
65
65
  this.lastLoadProfile={pid:process.pid,session_id:id,status:'returned',phase:'complete',bytes_read:bytes.length,rows:rows.length,wait_writers_ms:waitDone-began,read_ms:readDone-waitDone,parse_ms:parseDone-readDone,fold_ms:foldDone-parseDone,cache_compare_ms:cacheDone-foldDone,audit_ms:auditDone-cacheDone,total_ms:performance.now()-began,audit_source:cachedAudit?'verified_identical_bytes':'full_journal_replay',cache:this.#journalCache.status()};
66
- return { ...metadata, messages, historyMessages, compaction, projection, rows, journalAudit, goal, queue, draft };
66
+ return { ...metadata, messages, historyMessages, compaction, projection, rows, journalAudit, goal, queue, draft, draftAttachments };
67
67
  }catch(error){this.lastLoadProfile={...this.lastLoadProfile,status:'failed',error_code:error.code || error.name,total_ms:performance.now()-began,cache:this.#journalCache.status()};throw error;}
68
68
  }
69
69
  getLoadProfile(){return this.lastLoadProfile?structuredClone(this.lastLoadProfile):null;}
package/src/subagents.mjs CHANGED
@@ -212,14 +212,18 @@ export class SubagentManager {
212
212
  }));
213
213
  return { agents, continued_invocation_id: input.invocation_id, untrusted: true };
214
214
  }
215
- async start(run, provider, { signal, siblingSignal, resume = false } = {}) {
215
+ async start(run, provider, { signal, siblingSignal, resume = false, expectedTurnId } = {}) {
216
216
  if (this.closed) throw Object.assign(new Error('El manager está cerrado.'), { code: 'AGENT_MANAGER_CLOSED' });
217
- if (this.active.has(run.run_id)) return this.active.get(run.run_id);
217
+ if (this.active.has(run.run_id)) {
218
+ if(resume)throw Object.assign(new Error('El hijo ya tiene una reanudación activa.'),{code:'AGENT_IN_USE'});
219
+ return this.active.get(run.run_id);
220
+ }
218
221
  const lock = await acquireProcessLock(`${this.store.file(run.session_id)}.agent-run.lock`, { validate: () => this.store.validateForRecovery(run.session_id) });
219
222
  let fresh;
220
223
  try { fresh = await this.resolve({ run_id: run.run_id }, run.parent_session_id); }
221
224
  catch (error) { await lock.release(); throw error; }
222
225
  if (this.closed) { await lock.release(); throw Object.assign(new Error('El manager cerró antes de iniciar el hijo.'), { code: 'AGENT_MANAGER_CLOSED' }); }
226
+ if(resume && expectedTurnId!==undefined && fresh.turn_id!==expectedTurnId){await lock.release();throw Object.assign(new Error('El turno hijo cambió antes de admitir la reanudación.'),{code:'AGENT_IN_USE'});}
223
227
  if (fresh.manual_stop) { await lock.release(); throw Object.assign(new Error('Este hijo recibió un stop explícito; una notificación o resume no lo reactiva.'), { code: 'AGENT_MANUALLY_STOPPED' }); }
224
228
  if (!resume && ['exited', 'stopped'].includes(fresh.runner_state)) { await lock.release(); return { promise: Promise.resolve(fresh), detached: new Promise(() => {}), background: fresh.background }; }
225
229
  let detach;
@@ -466,7 +470,7 @@ export class SubagentManager {
466
470
  await ensureAgentSession(this.store, run);
467
471
  const captured = await this.restoreCapture(run.configuration, run.budget_owner);
468
472
  const next = { ...run, turn_id: randomUUID(), execution_process: processIdentity, background: Boolean(input.run_in_background) };
469
- const handle = await this.start(next, captured.provider || captured, { signal, resume: true });
473
+ const handle = await this.start(next, captured.provider || captured, { signal, resume: true, expectedTurnId: run.turn_id });
470
474
  return input.run_in_background ? { ...await this.status(input), async_launched: true } : publicAgentRun(await handle.promise);
471
475
  }
472
476
  async list() {
@@ -8,12 +8,15 @@ const CAPTURE = ['take_screenshot'];
8
8
  const ADMIN = ['WorkSnapshot','AuditStatus','HouseSnapshot','OperationCatalog'];
9
9
  const SCOPED = {'sessions.peers':['ListAgents','SendMessage'],'provider.features':['ProviderCapabilities','ProviderSearch','AnalyzeMedia'],'workspace.inspect':['ListDirectory','FileInfo']};
10
10
  const TEMPORARY=new Set([...ADMIN,...Object.values(SCOPED).flat()]);
11
+ // Native artifact binding needs the tiny metadata/hash contract on the initial
12
+ // surface. Search/acquire may still describe it; a lease never grants permission.
13
+ const BASE_READS = new Set(['FileInfo']);
11
14
  const packEffects = { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
12
15
  export class ToolPacks {
13
16
  constructor({ store, house, ensureBrowser, pinned = () => [], serverStates = () => [], refreshCatalogue }) { Object.assign(this, { store, house, ensureBrowser, pinned, serverStates, refreshCatalogue }); this.views = new Map(); }
14
17
  key(sessionId, actor) { return JSON.stringify([sessionId,actor.kind,actor.id]); }
15
- scoped(name){return name.startsWith('mcp__') || TEMPORARY.has(name) || this.house.registry.get(name)?.contract.origin?.tool_loading === 'deferred';}
16
- catalogue(actor) { return this.house.registry.catalogue({ actor, surface: 'agent' }).filter(op => this.scoped(op.name)); }
18
+ scoped(name){return !BASE_READS.has(name) && (name.startsWith('mcp__') || TEMPORARY.has(name) || this.house.registry.get(name)?.contract.origin?.tool_loading === 'deferred');}
19
+ catalogue(actor) { return this.house.registry.catalogue({ actor, surface: 'agent' }).filter(op => this.scoped(op.name) || BASE_READS.has(op.name)); }
17
20
  inPack(op, pack) { return Boolean(op) && (SCOPED[pack] ? SCOPED[pack].includes(op.name) : pack === 'house.audit' ? ADMIN.includes(op.name) : ['browser.verify','browser.capture'].includes(pack) ? (pack === 'browser.verify' ? BROWSER : CAPTURE).some(name => op.name === 'mcp__chrome-devtools__'+name) : pack.startsWith('plugin:') ? op.origin?.plugin === pack.slice(7) && op.origin?.tool_loading === 'deferred' : pack.startsWith('mcp:') && (op.origin?.kind === 'mcp' ? op.origin.server === pack.slice(4) : op.name.startsWith('mcp__'+pack.slice(4)+'__'))); }
18
21
  offers(actor) {
19
22
  const all = this.catalogue(actor), servers = new Map(), plugins = new Map();
package/src/tools.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile, rename, unlink, mkdir, realpath, stat, readdir, opendir, open } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -9,6 +10,7 @@ import { digest } from './operations.mjs';
9
10
  import { recoverFileEffects } from './file-recovery.mjs';
10
11
 
11
12
  const LIMIT = 64 * 1024, FILE_LIMIT = 1024 * 1024;
13
+ const HASH_LIMIT = 256 * 1024 * 1024, HASH_CHUNK = 128 * 1024, HASH_TIMEOUT = 10000;
12
14
  const hash = text => createHash('sha256').update(text).digest('hex');
13
15
  const schema = (name, description, properties, required) => ({ name, description, input_schema: { type: 'object', properties, required, additionalProperties: false } });
14
16
  const str = description => ({ type: 'string', description });
@@ -19,21 +21,21 @@ export const toolDefinitions = [
19
21
  schema('Glob', 'List workspace files matching *, ** or ?; omits .git, .comal, .harness, .analysis and node_modules.', { pattern: str('Example: src/**/*.mjs') }, ['pattern']),
20
22
  schema('Grep', 'Search workspace text with ripgrep regular expressions.', { pattern: str('Regular expression'), path: str('Workspace path; defaults to .'), glob: str('Optional file glob') }, ['pattern']),
21
23
  schema('ListDirectory', 'List one workspace directory without shell. Returns bounded names/types, not file contents; omits internal state. Use Glob for recursive matches.', { path: str('Workspace directory; defaults to .'), limit: { type: 'integer', minimum: 1, maximum: 1000 } }, []),
22
- schema('FileInfo', 'Inspect workspace file size/type and media signature without shell or reading full contents. Use instead of ls/stat/file. Does not decode media: send images/video directly to AnalyzeMedia; this check is optional.', { file_path: str('Workspace-relative or absolute path') }, ['file_path']),
24
+ schema('FileInfo', 'Inspect workspace file size/type and media signature without shell. By default reads at most 32 signature bytes. Set hash:true for bounded binary-safe full-file SHA-256 evidence (up to 256 MiB, 10 seconds); emits a file_read observation but grants no text inspection for editing. Does not decode media: send images/video directly to AnalyzeMedia.', { file_path: str('Workspace-relative or absolute path'), hash: { type: 'boolean', description: 'Stream the complete regular file to compute SHA-256 without returning contents.' } }, ['file_path']),
23
25
  schema('Bash', 'Run a shell command from the workspace after permission. This is NOT an OS sandbox.', { command: str('Shell command'), timeout: { type: 'integer', minimum: 1, maximum: 120000 } }, ['command']),
24
26
  ];
25
27
 
26
28
  function validate(name, input) {
27
29
  const definition = toolDefinitions.find(t => t.name === name);
28
- if (!definition) throw new Error(`Herramienta desconocida: ${name}`);
29
- if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input debe ser un objeto.');
30
- for (const key of definition.input_schema.required) if (!(key in input)) throw new Error(`Falta ${key}.`);
30
+ if (!definition) throw new Error(`Unknown tool: ${name}`);
31
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be an object.');
32
+ for (const key of definition.input_schema.required) if (!(key in input)) throw new Error(`Missing ${key}.`);
31
33
  for (const [key, value] of Object.entries(input)) {
32
34
  const spec = definition.input_schema.properties[key];
33
- if (!spec) throw new Error(`Campo desconocido: ${key}`);
34
- if (spec.type === 'integer' ? !Number.isInteger(value) : typeof value !== spec.type) throw new Error(`Tipo inválido para ${key}.`);
35
- if (spec.type === 'integer' && (value < spec.minimum || value > (spec.maximum ?? Number.MAX_SAFE_INTEGER))) throw new Error(`${key} fuera de rango.`);
36
- if (typeof value === 'string' && Buffer.byteLength(value) > FILE_LIMIT) throw new Error(`${key} excede 1 MiB.`);
35
+ if (!spec) throw new Error(`Unknown field: ${key}`);
36
+ if (spec.type === 'integer' ? !Number.isInteger(value) : typeof value !== spec.type) throw new Error(`Invalid type for ${key}.`);
37
+ if (spec.type === 'integer' && (value < spec.minimum || value > (spec.maximum ?? Number.MAX_SAFE_INTEGER))) throw new Error(`${key} is out of range.`);
38
+ if (typeof value === 'string' && Buffer.byteLength(value) > FILE_LIMIT) throw new Error(`${key} exceeds 1 MiB.`);
37
39
  }
38
40
  }
39
41
 
@@ -53,9 +55,9 @@ export class WorkspaceTools {
53
55
  recoverInterrupted({signal,session}={}) { return this.store && this.house.hasRecorder ? recoverFileEffects({store:this.store,house:this.house,executor:this.executor,sessionId:typeof this.recoverySessionId==='function'?this.recoverySessionId():this.recoverySessionId,actor:this.recoveryActor,signal,session}) : undefined; }
54
56
  inside(file) { const relative = path.relative(this.cwd, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
55
57
  async resolve(file, { create = false } = {}) {
56
- if (!file || file.includes('\0')) throw new Error('Ruta inválida.');
58
+ if (!file || file.includes('\0')) throw new Error('Invalid path.');
57
59
  const candidate = path.resolve(this.cwd, file);
58
- if (!this.inside(candidate)) throw new Error('Ruta fuera del workspace.');
60
+ if (!this.inside(candidate)) throw new Error('Path outside the workspace.');
59
61
  let canonical;
60
62
  try { canonical = await realpath(candidate); }
61
63
  catch (e) {
@@ -66,24 +68,24 @@ export class WorkspaceTools {
66
68
  catch (err) { if (err.code !== 'ENOENT') throw err; suffix.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); }
67
69
  }
68
70
  }
69
- if (!this.inside(canonical)) throw new Error('El enlace simbólico sale del workspace.');
71
+ if (!this.inside(canonical)) throw new Error('The symbolic link leaves the workspace.');
70
72
  const first = path.relative(this.cwd, canonical).split(path.sep)[0];
71
- if (['.comal', '.harness'].includes(first)) throw new Error('El estado interno de Comal está protegido.');
73
+ if (['.comal', '.harness'].includes(first)) throw new Error('Comal internal state is protected.');
72
74
  return canonical;
73
75
  }
74
76
  async text(file) {
75
77
  const info = await stat(file);
76
- if (!info.isFile() || info.size > FILE_LIMIT) throw new Error('Se requiere un archivo de texto de máximo 1 MiB.');
78
+ if (!info.isFile() || info.size > FILE_LIMIT) throw new Error('A text file of at most 1 MiB is required.');
77
79
  const buffer = await readFile(file);
78
- if (buffer.includes(0)) throw Object.assign(new Error('Archivo binario no admitido por Read de texto. Esto no es una denegación de permisos para analizar medios.'),{code:'READ_BINARY'});
80
+ if (buffer.includes(0)) throw Object.assign(new Error('Text Read does not support binary files. This does not deny permission to analyze media.'),{code:'READ_BINARY'});
79
81
  try{return new TextDecoder('utf-8', { fatal: true }).decode(buffer);}
80
- catch(error){throw Object.assign(new Error('El archivo no es UTF-8 de texto; Read no decodifica imágenes ni video. Esto no es una denegación para AnalyzeMedia.'),{code:'READ_BINARY',cause:error});}
82
+ catch(error){throw Object.assign(new Error('The file is not UTF-8 text; Read does not decode images or video. This does not deny AnalyzeMedia permission.'),{code:'READ_BINARY',cause:error});}
81
83
  }
82
84
  async atomicWrite(file, content) {
83
85
  await mkdir(path.dirname(file), { recursive: true });
84
86
  // Recheck the canonical parent after creating directories.
85
87
  const target = await this.resolve(file, { create: true });
86
- if (target !== file) throw new Error('La ruta cambió durante la operación.');
88
+ if (target !== file) throw new Error('The path changed during the operation.');
87
89
  let mode = 0o644; try { mode = (await stat(file)).mode & 0o777; } catch (e) { if (e.code !== 'ENOENT') throw e; }
88
90
  const temp = path.join(path.dirname(file), `.comal-write-${randomUUID()}`);
89
91
  try { await writeFile(temp, content, { flag: 'wx', mode }); await rename(temp, file); }
@@ -108,7 +110,18 @@ export class WorkspaceTools {
108
110
  return JSON.stringify({ path: relative, entries, truncated });
109
111
  }
110
112
  if (name === 'FileInfo') {
111
- const file = await this.resolve(input.file_path), info = await stat(file);
113
+ const file = await this.resolve(input.file_path), info = await stat(file, input.hash ? { bigint: true } : undefined);
114
+ if (input.hash) {
115
+ const hashed = await hashWorkspaceFile(file, { signal, expected: info, verifyPath: async () => {
116
+ if (await this.resolve(input.file_path) !== file) throw fileHashError('The file path changed while hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
117
+ } });
118
+ const result = { file_path: path.relative(this.cwd, file), type: 'file', bytes: hashed.bytes,
119
+ modified_at: new Date(Number(hashed.info.mtimeMs)).toISOString(), mime_type: hashed.mimeType,
120
+ mime_source: hashed.mimeType ? 'signature' : null, sha256: hashed.sha256 };
121
+ observe({ kind: 'file_metadata', ...result });
122
+ observe({ kind: 'file_read', file_path: result.file_path, sha256: result.sha256, bytes: result.bytes });
123
+ return JSON.stringify(result);
124
+ }
112
125
  let mimeType = null;
113
126
  if (info.isFile()) {
114
127
  const handle = await open(file, 'r');
@@ -140,15 +153,15 @@ export class WorkspaceTools {
140
153
  const proof = audit.journal.consistent && audit.state.find(item => item.path === relative)?.sha256 === currentHash && audit.receipts.findLast(receipt => receipt.status === 'returned' && ['Read', 'Write', 'Edit'].includes(receipt.operation) && receipt.actor?.id === actor?.id && receipt.actor?.kind === actor?.kind && !audit.violations.some(item => item.invocation_id === receipt.invocation_id) && !audit.unverified.some(item => item.invocation_id === receipt.invocation_id) && receipt.observations?.some(item => item.file_path === relative && (item.sha256 || item.after_sha256) === currentHash));
141
154
  if (proof) this.reads.set(file, currentHash);
142
155
  }
143
- if (before !== null && this.reads.get(file) !== hash(before)) throw new Error('Lee el archivo antes de modificarlo; pudo cambiar desde la última lectura.');
156
+ if (before !== null && this.reads.get(file) !== hash(before)) throw new Error('Read the file before modifying it; it may have changed since the last read.');
144
157
  let after = input.content;
145
158
  if (name === 'Edit') {
146
- if (!input.old_string) throw new Error('old_string no puede estar vacío.');
159
+ if (!input.old_string) throw new Error('old_string cannot be empty.');
147
160
  const count = before.split(input.old_string).length - 1;
148
- if (!count || (count > 1 && !input.replace_all)) throw new Error(`old_string aparece ${count} veces; usa texto único o replace_all.`);
161
+ if (!count || (count > 1 && !input.replace_all)) throw new Error(`old_string appears ${count} times; use unique text or replace_all.`);
149
162
  after = input.replace_all ? before.split(input.old_string).join(input.new_string) : before.replace(input.old_string, () => input.new_string);
150
163
  }
151
- if (Buffer.byteLength(after) > FILE_LIMIT) throw new Error('El resultado excede 1 MiB.');
164
+ if (Buffer.byteLength(after) > FILE_LIMIT) throw new Error('The result exceeds 1 MiB.');
152
165
  const output=JSON.stringify({ file_path: path.relative(this.cwd, file), status: before === null ? 'created' : 'updated', bytes: Buffer.byteLength(after), before_sha256: before === null ? null : hash(before), after_sha256: hash(after), ...(name === 'Edit' ? { removed: input.old_string.slice(0, 1000), added: input.new_string.slice(0, 1000) } : {}) });
153
166
  // Durable preparation is a prerequisite of the replacement, not a receipt
154
167
  // claiming that the handler returned. Recovery can only observe its target.
@@ -172,25 +185,25 @@ export class WorkspaceTools {
172
185
  else {
173
186
  let text;
174
187
  try { text = await runProcess('/usr/bin/grep', [...step.flags, '--', step.pattern, files.get(step.file).file], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal }); }
175
- catch (error) { if (error.message.startsWith('Comando terminó con código 1:')) throw Object.assign(new Error(`${error.message} No coincide el patrón ${JSON.stringify(step.pattern)} en ${JSON.stringify(step.file)}. Implementa el comportamiento del criterio; una coincidencia textual sólo es un check básico.`), { code: 'CHECK_FAILED' }); throw error; }
176
- if (text !== '(sin salida)') output += text;
188
+ catch (error) { if (error.message.startsWith('Command exited with code 1:')) throw Object.assign(new Error(`${error.message} Pattern ${JSON.stringify(step.pattern)} did not match in ${JSON.stringify(step.file)}. Implement the criterion behavior; a text match is only a basic check.`), { code: 'CHECK_FAILED' }); throw error; }
189
+ if (text !== '(no output)') output += text;
177
190
  }
178
191
  }
179
- for (const { file, before } of files.values()) { const after = hash(await this.text(file)); if (after !== before) throw new Error('El archivo cambió durante la comprobación; vuelve a verificar.'); observe({ kind: 'file_read', file_path: path.relative(this.cwd, file), sha256: after }); }
180
- return output || '(sin salida)';
192
+ for (const { file, before } of files.values()) { const after = hash(await this.text(file)); if (after !== before) throw new Error('The file changed during the check; verify again.'); observe({ kind: 'file_read', file_path: path.relative(this.cwd, file), sha256: after }); }
193
+ return output || '(no output)';
181
194
  }
182
195
  if (name === 'Grep') {
183
196
  const target = await this.resolve(input.path || '.');
184
197
  return runProcess('rg', ['--line-number', '--no-heading', '--color', 'never', '--max-count', '100', '--glob', '!.comal/**', '--glob', '!.harness/**', '--glob', '!.analysis/**', ...(input.glob ? ['--glob', input.glob] : []), '--', input.pattern, target], { cwd: this.cwd, timeout: 10000, signal, allowNoMatch: true });
185
198
  }
186
199
  if (name === 'Glob') {
187
- if (path.isAbsolute(input.pattern) || input.pattern.split('/').includes('..')) throw new Error('Glob debe ser relativo al workspace.');
200
+ if (path.isAbsolute(input.pattern) || input.pattern.split('/').includes('..')) throw new Error('Glob must be relative to the workspace.');
188
201
  const pattern = input.pattern.replace(/^\.\//, ''), matcher = globRegex(pattern), files = []; let visited = 0;
189
202
  const walk = async (dir, depth = 0) => {
190
203
  if (depth > 30) return;
191
204
  for (const entry of await readdir(dir, { withFileTypes: true })) {
192
205
  signal?.throwIfAborted();
193
- if (++visited > 20000) throw new Error('Glob excedió 20000 entradas; usa Grep o un workspace menor.');
206
+ if (++visited > 20000) throw new Error('Glob exceeded 20000 entries; use Grep or a smaller workspace.');
194
207
  if (['.git', '.comal', '.harness', '.analysis', 'node_modules'].includes(entry.name)) continue;
195
208
  const file = path.join(dir, entry.name), relative = path.relative(this.cwd, file).split(path.sep).join('/');
196
209
  if (entry.isDirectory()) await walk(file, depth + 1);
@@ -198,11 +211,48 @@ export class WorkspaceTools {
198
211
  }
199
212
  };
200
213
  await walk(this.cwd);
201
- return files.sort().slice(0, 1000).join('\n') || '(sin coincidencias)';
214
+ return files.sort().slice(0, 1000).join('\n') || '(no matches)';
202
215
  }
203
216
  }
204
217
  }
205
218
 
219
+ const fileHashError = (message, code) => Object.assign(new Error(message), { code });
220
+ const sameFileVersion = (left, right) => ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every(key => left[key] === right[key]);
221
+ async function hashWorkspaceFile(file, { signal, expected, verifyPath }) {
222
+ const deadline = AbortSignal.timeout(HASH_TIMEOUT), combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
223
+ const check = () => {
224
+ signal?.throwIfAborted();
225
+ if (deadline.aborted) throw fileHashError('File hashing exceeded 10 seconds. Try a smaller artifact.', 'FILE_HASH_TIMEOUT');
226
+ };
227
+ let handle, stream;
228
+ try {
229
+ check();
230
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
231
+ const before = await handle.stat({ bigint: true });
232
+ if (!before.isFile()) throw fileHashError('Hashing requires a regular file.', 'FILE_HASH_TYPE');
233
+ if (before.size > BigInt(HASH_LIMIT)) throw fileHashError('File hashing supports at most 256 MiB.', 'FILE_HASH_LIMIT');
234
+ if (before.dev !== BigInt(expected.dev) || before.ino !== BigInt(expected.ino)) throw fileHashError('The file changed before hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
235
+ await verifyPath();
236
+ if (!sameFileVersion(before, await stat(file, { bigint: true }))) throw fileHashError('The file changed before hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
237
+ const digest = createHash('sha256'); let bytes = 0, signature = Buffer.alloc(0);
238
+ stream = handle.createReadStream({ highWaterMark: HASH_CHUNK, autoClose: false, signal: combined });
239
+ for await (const chunk of stream) {
240
+ check(); bytes += chunk.length;
241
+ if (bytes > HASH_LIMIT) throw fileHashError('File hashing supports at most 256 MiB.', 'FILE_HASH_LIMIT');
242
+ if (signature.length < 32) signature = Buffer.concat([signature, chunk.subarray(0, 32 - signature.length)]);
243
+ digest.update(chunk);
244
+ }
245
+ check();
246
+ const after = await handle.stat({ bigint: true }); await verifyPath();
247
+ if (!sameFileVersion(before, after) || !sameFileVersion(before, await stat(file, { bigint: true })) || BigInt(bytes) !== before.size) throw fileHashError('The file changed while hashing. Retry FileInfo.', 'FILE_HASH_CHANGED');
248
+ check(); return { bytes, sha256: digest.digest('hex'), mimeType: mediaSignature(signature), info: after };
249
+ } catch (error) {
250
+ check();
251
+ if (error.code === 'ELOOP') throw fileHashError('The file changed to a symbolic link before hashing.', 'FILE_HASH_CHANGED');
252
+ throw error;
253
+ } finally { stream?.destroy(); await handle?.close(); }
254
+ }
255
+
206
256
  function mediaSignature(bytes) {
207
257
  if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
208
258
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
@@ -244,7 +294,7 @@ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowN
244
294
  if (chunk.length > available) truncated = true;
245
295
  };
246
296
  child.stdout.on('data', receive); child.stderr.on('data', receive);
247
- const timer = setTimeout(() => terminate(new Error(`Comando excedió ${timeout} ms.`)), timeout);
297
+ const timer = setTimeout(() => terminate(new Error(`Command exceeded ${timeout} ms.`)), timeout);
248
298
  const abort = () => terminate(new Error('Comando cancelado.'));
249
299
  signal?.addEventListener('abort', abort, { once: true });
250
300
  const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
@@ -253,8 +303,8 @@ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowN
253
303
  cleanup();
254
304
  // Leave the short kill timer alive after timeout to stop remaining descendants.
255
305
  if (failure) reject(failure);
256
- else if (code !== 0 && !(allowNoMatch && code === 1)) reject(new Error(`Comando terminó con código ${code}: ${output}`));
257
- else resolve(output + (truncated ? '\n[Salida limitada a 64 KiB]' : '') || '(sin salida)');
306
+ else if (code !== 0 && !(allowNoMatch && code === 1)) reject(new Error(`Command exited with code ${code}: ${output}`));
307
+ else resolve(output + (truncated ? '\n[Output limited to 64 KiB]' : '') || '(no output)');
258
308
  });
259
309
  });
260
310
  }
package/src/tui/brand.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { t } from '../i18n/index.mjs';
1
2
  import { clip, createTones, width } from './text.mjs';
2
3
 
3
4
  export const BRAND_FRAME_INTERVAL_MS = 240;
@@ -36,7 +37,7 @@ export function brandingWelcome({ columns = 80, frame = 0, active = false, motio
36
37
  const heat = ascii ? [' . . .', ' . .', ' . . .', ' . . .'] : [' · · ·', ' ˙ ·', ' · ˙ ·', ' ˙ · ˙'];
37
38
  const icon = [tones.muted(steam[phase]), ...mark.map(line => ' ' + tones.accent(line)), tones.tierra(plate[0]), tones.tierra(plate[1]), tones.secondary(heat[phase])];
38
39
  const label = tones.bold(tones.accent('COMAL'));
39
- const caption = ['Operaciones con intención.', 'Cada vuelta deja un recibo.'];
40
+ const caption = [t("Operations with intent."), t("Every turn leaves a receipt.")];
40
41
  const rows = room >= 61 ? icon.map((line, index) => line + ' '.repeat(Math.max(1, 24 - width(line))) + (index === 2 ? label : index === 4 ? tones.muted(caption[0]) : index === 5 ? tones.muted(caption[1]) : '')) : [...icon, '', label, ...caption.map(line => tones.muted(line))];
41
42
  return rows.map(line => clip(line, room));
42
43
  }
@@ -1,10 +1,11 @@
1
+ import { t } from '../i18n/index.mjs';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { safeText } from './text.mjs';
3
4
 
4
5
  // Write only. Text travels over stdin, never through shell arguments.
5
6
  export async function copyText(text, { env = process.env, platform = process.platform, spawnProcess = spawn, timeoutMs = 3000 } = {}) {
6
7
  const value = safeText(text, { preserveTabs: true });
7
- if (!value.trim()) throw new Error('No hay texto de respuesta para copiar.');
8
+ if (!value.trim()) throw new Error(t("No response text to copy."));
8
9
  const candidates = platform === 'darwin' ? [['pbcopy', []]] : [
9
10
  ...(env.WAYLAND_DISPLAY ? [['wl-copy', []]] : []),
10
11
  ...(env.DISPLAY ? [['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]] : []),
@@ -15,14 +16,14 @@ export async function copyText(text, { env = process.env, platform = process.pla
15
16
  const child = spawnProcess(command, args, { env, stdio: ['pipe', 'ignore', 'ignore'], shell: false });
16
17
  let settled = false;
17
18
  const finish = error => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(); };
18
- const timer = setTimeout(() => { child.kill(); finish(new Error('Tiempo de espera agotado.')); }, timeoutMs);
19
+ const timer = setTimeout(() => { child.kill(); finish(new Error(t("Timed out."))); }, timeoutMs);
19
20
  child.once('error', finish);
20
- child.once('close', code => finish(code === 0 ? null : new Error(`Código ${code}`)));
21
+ child.once('close', code => finish(code === 0 ? null : new Error(t("Code {v0}", {v0: code}))));
21
22
  child.stdin.on('error', error => { child.kill(); finish(error); });
22
23
  child.stdin.end(value);
23
24
  });
24
25
  return command;
25
26
  } catch { /* Try the next installed desktop clipboard provider. */ }
26
27
  }
27
- throw new Error('No se pudo escribir al portapapeles. Usa F5, selecciona con el mouse y copia desde tu terminal. En Linux puedes instalar wl-clipboard (Wayland) o xclip (X11).');
28
+ throw new Error(t("Could not write to the clipboard. Use F5, select with the mouse and copy from your terminal. On Linux you can install wl-clipboard (Wayland) or xclip (X11)."));
28
29
  }