@milpa/comal 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -28
- package/backend/contracts/remote-commands-v1.json +125 -0
- package/bin/comal.mjs +16 -5
- package/docs/investigation.md +2 -2
- package/governance/chrome-task-v2.json +67 -0
- package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
- package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
- package/node_modules/quickjs-emscripten/LICENSE +21 -0
- package/node_modules/quickjs-emscripten/README.md +928 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
- package/node_modules/quickjs-emscripten/dist/index.js +2 -0
- package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/package.json +69 -0
- package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
- package/node_modules/quickjs-emscripten-core/README.md +341 -0
- package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/package.json +49 -0
- package/package.json +6 -2
- package/src/agent.mjs +17 -6
- package/src/artifact-tools.mjs +3 -2
- package/src/auto-guard.mjs +5 -1
- package/src/cli.mjs +267 -201
- package/src/cloud-client.mjs +58 -8
- package/src/code-run-worker.mjs +70 -0
- package/src/code-run.mjs +97 -0
- package/src/codex-provider.mjs +28 -28
- package/src/compaction.mjs +1 -1
- package/src/goals.mjs +2 -2
- package/src/house.mjs +24 -24
- package/src/i18n/cli-es.mjs +97 -0
- package/src/i18n/es.mjs +227 -0
- package/src/i18n/index.mjs +48 -0
- package/src/image-attachments.mjs +263 -0
- package/src/model-controls.mjs +4 -4
- package/src/onboarding.mjs +91 -0
- package/src/openai-provider.mjs +24 -24
- package/src/peer-tools.mjs +4 -3
- package/src/permissions.mjs +53 -17
- package/src/provider-auth.mjs +30 -30
- package/src/provider-profiles.mjs +27 -27
- package/src/provider.mjs +30 -30
- package/src/remote-commands.mjs +11 -0
- package/src/remote-control.mjs +180 -0
- package/src/sessions.mjs +3 -3
- package/src/subagents.mjs +7 -3
- package/src/tool-packs.mjs +5 -2
- package/src/tools.mjs +92 -34
- package/src/tui/brand.mjs +2 -1
- package/src/tui/clipboard.mjs +5 -4
- package/src/tui/composer.mjs +77 -11
- package/src/tui/controller.mjs +111 -29
- package/src/tui/discovery.mjs +105 -100
- package/src/tui/markdown.mjs +10 -9
- package/src/tui/message-queue.mjs +27 -9
- package/src/tui/screen.mjs +209 -118
- package/src/vision.mjs +31 -3
- package/src/work-assertions.mjs +7 -7
- package/src/work-plan.mjs +189 -21
- package/src/work-recovery-proof.mjs +13 -1
- package/src/workflows/chrome-task.mjs +24 -7
|
@@ -0,0 +1,180 @@
|
|
|
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'){const text=boundText((event.message?.content || []).filter(b=>b.type==='text').map(b=>b.text).join(''));return text.trim()?{...base,type:'message.assistant',text}:null;}
|
|
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'){
|
|
15
|
+
const detail=event.name==='WorkStepRun'&&['Read','Write','Edit','Bash','Glob','Grep','FileInfo','ListDirectory'].includes(event.input?.operation)?event.input.operation:event.name==='ToolPack'&&['search','list','acquire','release','status'].includes(event.input?.action)?event.input.action:undefined;
|
|
16
|
+
return {...base,type:'tool.started',tool_name:boundText(event.name,128),...(event.id?{tool_call_id:boundText(event.id,128)}:{}),...(detail?{tool_detail:detail}:{})};
|
|
17
|
+
}
|
|
18
|
+
if(event.type==='tool_result')return {...base,type:'tool.finished',tool_name:boundText(event.name,128),text:event.is_error?'failed':'returned',...(event.tool_use_id?{tool_call_id:boundText(event.tool_use_id,128)}:{}),...(Number.isSafeInteger(event.duration_ms)&&event.duration_ms>=0&&event.duration_ms<=86400000?{duration_ms:event.duration_ms}:{})};
|
|
19
|
+
if(event.type==='house_waiting')return {...base,type:'permission.waiting',tool_name:boundText(event.operation,128),text:'Waiting for approval in the local terminal'};
|
|
20
|
+
if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved locally':'Declined locally'};
|
|
21
|
+
if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
|
|
26
|
+
const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
27
|
+
|
|
28
|
+
export class RemoteHost {
|
|
29
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
30
|
+
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;
|
|
31
|
+
}
|
|
32
|
+
status(){return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only'}:{enabled:false};}
|
|
33
|
+
current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
|
|
34
|
+
assertCurrent(run,options){if(!this.current(run,options))throw cancelled();}
|
|
35
|
+
async revoke(run) {
|
|
36
|
+
// A stopped pending open may still succeed upstream. Its original authority
|
|
37
|
+
// remains bound to that attempt, never to a replacement account or session.
|
|
38
|
+
if(!run?.session || !run.account || !run.enablingToken)return;
|
|
39
|
+
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(()=>{});
|
|
40
|
+
await run.revoking;
|
|
41
|
+
}
|
|
42
|
+
async start(session) {
|
|
43
|
+
if(this.starting && this.current(this.starting,{starting:true}))return this.starting.promise;
|
|
44
|
+
if(!this.stopped)return this.status();
|
|
45
|
+
const run={controller:new AbortController()};this.lifecycle=run;this.starting=run;
|
|
46
|
+
run.promise=(async()=>{
|
|
47
|
+
try {
|
|
48
|
+
const account=await this.client.credentials();this.assertCurrent(run,{starting:true});
|
|
49
|
+
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
50
|
+
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
51
|
+
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});
|
|
52
|
+
run.session=opened.session;
|
|
53
|
+
// Abort can race a successful response. Revoke a late-created grant even
|
|
54
|
+
// when the local stop completed before its identifier became available.
|
|
55
|
+
if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
|
|
56
|
+
this.account=run.account;this.enablingToken=run.enablingToken;this.session=opened.session;this.url=opened.url;this.stopped=false;
|
|
57
|
+
await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
|
|
58
|
+
this.transport='https-polling';this.connectSocket(run);
|
|
59
|
+
this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
|
|
60
|
+
for(const message of (session.historyMessages || []).slice(-20)){
|
|
61
|
+
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;
|
|
62
|
+
if(event)this.observe(event);
|
|
63
|
+
}
|
|
64
|
+
this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.');
|
|
65
|
+
return this.status();
|
|
66
|
+
} catch(error) {
|
|
67
|
+
if(this.lifecycle===run)await this.stop();else await this.revoke(run);
|
|
68
|
+
throw error;
|
|
69
|
+
} finally {if(this.starting===run)this.starting=null;}
|
|
70
|
+
})();
|
|
71
|
+
return run.promise;
|
|
72
|
+
}
|
|
73
|
+
async authorize(run=this.lifecycle) {
|
|
74
|
+
this.assertCurrent(run);const session=this.session,account=this.account;
|
|
75
|
+
const ticket=await this.client.invoke('remote.stream.ticket',{remote_session_id:session.id,role:'host'},{expectedAccount:account,signal:lifetimeSignal(run)});
|
|
76
|
+
this.assertCurrent(run);
|
|
77
|
+
const access=await this.client.request(account.origin,'remote.stream.consume',{ticket:ticket.ticket,role:'host'},undefined,lifetimeSignal(run));
|
|
78
|
+
this.assertCurrent(run);this.access=access.access_token;this.expires=access.expires_at;
|
|
79
|
+
}
|
|
80
|
+
async call(operation,input={},run=this.lifecycle) {
|
|
81
|
+
this.assertCurrent(run);const account=this.account,session=this.session;
|
|
82
|
+
const current=await this.client.credentials();this.assertCurrent(run);
|
|
83
|
+
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'});
|
|
84
|
+
if(this.expires<=Date.now()/1000+30)await this.authorize(run);
|
|
85
|
+
this.assertCurrent(run);
|
|
86
|
+
const result=await this.client.request(account.origin,operation,{remote_session_id:session.id,...input},this.access,lifetimeSignal(run));
|
|
87
|
+
this.assertCurrent(run);return result;
|
|
88
|
+
}
|
|
89
|
+
async connectSocket(run=this.lifecycle) {
|
|
90
|
+
if(!this.current(run)||this.socket||this.connecting)return;
|
|
91
|
+
const marker={run};this.connecting=marker;
|
|
92
|
+
try {
|
|
93
|
+
const account=this.account,session=this.session;
|
|
94
|
+
const ticket=await this.client.invoke('remote.stream.ticket',{remote_session_id:session.id,role:'host'},{expectedAccount:account,signal:lifetimeSignal(run)});
|
|
95
|
+
this.assertCurrent(run);
|
|
96
|
+
const url=new URL('/remote/host',account.origin);url.protocol=url.protocol==='https:'?'wss:':'ws:';
|
|
97
|
+
const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
|
|
98
|
+
const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
|
|
99
|
+
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'}));});
|
|
100
|
+
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();}});
|
|
101
|
+
ws.addEventListener('error',()=>{});
|
|
102
|
+
ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
|
|
103
|
+
}catch{/* HTTPS is already active. A later tick retries WSS. */}
|
|
104
|
+
finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
|
|
105
|
+
}
|
|
106
|
+
observe(event) {
|
|
107
|
+
if(this.stopped)return;
|
|
108
|
+
const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
|
|
109
|
+
if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
|
|
110
|
+
this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
|
|
111
|
+
}
|
|
112
|
+
if(event.type==='assistant'){
|
|
113
|
+
const thinking=(event.message?.content||[]).filter(block=>['thinking','openai_reasoning'].includes(block.type)).map(block=>block.thinking||block.text||'').join('\n');
|
|
114
|
+
if(thinking||this.thinkingActive)append({type:'thinking.finished',turn_id:this.turnId||'local',text:boundText(thinking||'Thinking complete.')});
|
|
115
|
+
this.thinkingActive=false;
|
|
116
|
+
}
|
|
117
|
+
if(['response_discarded','model_retry'].includes(event.type))this.thinkingActive=false;
|
|
118
|
+
if(event.type==='text_delta'){
|
|
119
|
+
if(this.transport==='websocket'&&this.socket?.readyState===1&&this.turnId){
|
|
120
|
+
this.delta=boundText((this.delta || '')+event.text,32000);
|
|
121
|
+
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);}
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if(['assistant','response_discarded','model_retry'].includes(event.type)){clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';}
|
|
126
|
+
this.toolStarts??=new Map();
|
|
127
|
+
if(event.type==='tool_use'&&event.id)this.toolStarts.set(event.id,Date.now());
|
|
128
|
+
if(event.type==='tool_result'&&this.toolStarts.has(event.tool_use_id)){event={...event,duration_ms:Date.now()-this.toolStarts.get(event.tool_use_id)};this.toolStarts.delete(event.tool_use_id);}
|
|
129
|
+
const projected=projectRemoteEvent(event,this.turnId);if(!projected)return;
|
|
130
|
+
if(this.pending.length>=512){this.onNotice('Remote projection paused: outbound buffer full. Local work continues.');this.stop().catch(()=>{});return;}
|
|
131
|
+
append(projected);this.flush().catch(()=>{});
|
|
132
|
+
}
|
|
133
|
+
async flush(run=this.lifecycle){
|
|
134
|
+
if(!this.current(run))return;
|
|
135
|
+
if(this.sending)return this.sending;
|
|
136
|
+
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();}})();
|
|
137
|
+
this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
|
|
138
|
+
}
|
|
139
|
+
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;}
|
|
140
|
+
async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
|
|
141
|
+
async endTurn(){
|
|
142
|
+
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(()=>{});}
|
|
143
|
+
clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
|
|
144
|
+
}
|
|
145
|
+
async validate(item){
|
|
146
|
+
if(!item.remote)return true;
|
|
147
|
+
const run=this.lifecycle;
|
|
148
|
+
if(this.stopped||item.remote.session!==this.session.id)throw Error('Remote grant detached; queued input was not executed.');
|
|
149
|
+
await this.call('remote.inputs.validate',{request_id:item.remote.request},run);this.assertCurrent(run);return true;
|
|
150
|
+
}
|
|
151
|
+
async tick(run=this.lifecycle){
|
|
152
|
+
if(!this.current(run)||this.processing)return this.processing;
|
|
153
|
+
const work=(async()=>{
|
|
154
|
+
await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
|
|
155
|
+
const page=await this.call('remote.inputs.claim',{},run);this.assertCurrent(run);
|
|
156
|
+
for(const input of page.inputs){
|
|
157
|
+
this.assertCurrent(run);let state='accepted_locally',queue_item_id='';
|
|
158
|
+
// Only admission errors are rejection. A lost ACK must redeliver the same input.
|
|
159
|
+
try {
|
|
160
|
+
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;}
|
|
161
|
+
else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
162
|
+
}catch{state='rejected';}
|
|
163
|
+
this.assertCurrent(run);await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id},run);
|
|
164
|
+
}
|
|
165
|
+
if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
|
|
166
|
+
})().catch(error=>{
|
|
167
|
+
if(!this.current(run))return;
|
|
168
|
+
if(['AUTH_INVALID','REMOTE_REVOKED','REMOTE_CLOSED','REMOTE_DENIED'].includes(error.code)){this.onNotice('Remote Control detached: '+error.message);this.stop().catch(()=>{});}
|
|
169
|
+
else if(Date.now()-(this.lastErrorAt||0)>30000){this.lastErrorAt=Date.now();this.onNotice('Remote relay reconnecting; local work continues.');}
|
|
170
|
+
});
|
|
171
|
+
this.processing=work;try{return await work;}finally{if(this.processing===work)this.processing=null;}
|
|
172
|
+
}
|
|
173
|
+
async stop(){
|
|
174
|
+
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
175
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();
|
|
176
|
+
this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
177
|
+
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
178
|
+
await this.revoke(run);return this.status();
|
|
179
|
+
}
|
|
180
|
+
}
|
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))
|
|
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() {
|
package/src/tool-packs.mjs
CHANGED
|
@@ -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
|
|
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(`
|
|
29
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input
|
|
30
|
-
for (const key of definition.input_schema.required) if (!(key in input)) throw new Error(`
|
|
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(`
|
|
34
|
-
if (spec.type === 'integer' ? !Number.isInteger(value) : typeof value !== spec.type) throw new Error(`
|
|
35
|
-
if (spec.type === 'integer' && (value < spec.minimum || value > (spec.maximum ?? Number.MAX_SAFE_INTEGER))) throw new Error(`${key}
|
|
36
|
-
if (typeof value === 'string' && Buffer.byteLength(value) > FILE_LIMIT) throw new Error(`${key}
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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('
|
|
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.
|
|
@@ -163,7 +176,7 @@ export class WorkspaceTools {
|
|
|
163
176
|
}
|
|
164
177
|
if (name === 'Bash') {
|
|
165
178
|
const check = readOnlyBashCheck(input.command);
|
|
166
|
-
if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal });
|
|
179
|
+
if (!check) return runProcess('/bin/sh', ['-c', input.command], { cwd: this.cwd, timeout: input.timeout ?? 30000, signal, observe });
|
|
167
180
|
const files = new Map();
|
|
168
181
|
for (const name of check.files) { const file = await this.resolve(name); files.set(name, { file, before: hash(await this.text(file)) }); }
|
|
169
182
|
let output = '';
|
|
@@ -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('
|
|
176
|
-
if (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('
|
|
180
|
-
return output || '(
|
|
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
|
|
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
|
|
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') || '(
|
|
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';
|
|
@@ -228,10 +278,11 @@ function globRegex(pattern) {
|
|
|
228
278
|
return new RegExp(source + '$');
|
|
229
279
|
}
|
|
230
280
|
|
|
231
|
-
export function runProcess(command, args, { cwd, timeout = 30000, signal, allowNoMatch = false }) {
|
|
281
|
+
export function runProcess(command, args, { cwd, timeout = 30000, signal, allowNoMatch = false, observe = () => {} }) {
|
|
232
282
|
return new Promise((resolve, reject) => {
|
|
233
283
|
signal?.throwIfAborted();
|
|
234
284
|
const child = spawn(command, args, { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
285
|
+
observe({kind:'process_started',pid:child.pid,process_group:child.pid});
|
|
235
286
|
let output = '', truncated = false, failure, killTimer;
|
|
236
287
|
const terminate = reason => {
|
|
237
288
|
if (failure) return; failure = reason;
|
|
@@ -244,17 +295,24 @@ export function runProcess(command, args, { cwd, timeout = 30000, signal, allowN
|
|
|
244
295
|
if (chunk.length > available) truncated = true;
|
|
245
296
|
};
|
|
246
297
|
child.stdout.on('data', receive); child.stderr.on('data', receive);
|
|
247
|
-
const timer = setTimeout(() => terminate(new Error(`
|
|
298
|
+
const timer = setTimeout(() => terminate(new Error(`Command exceeded ${timeout} ms.`)), timeout);
|
|
248
299
|
const abort = () => terminate(new Error('Comando cancelado.'));
|
|
249
300
|
signal?.addEventListener('abort', abort, { once: true });
|
|
250
301
|
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
251
302
|
child.once('error', err => { cleanup(); clearTimeout(killTimer); reject(err); });
|
|
252
|
-
child.once('close', code => {
|
|
303
|
+
child.once('close', async (code, terminationSignal) => {
|
|
253
304
|
cleanup();
|
|
305
|
+
if(failure){
|
|
306
|
+
// Wait for the bounded escalation before recording a terminal receipt.
|
|
307
|
+
await new Promise(resolve=>setTimeout(resolve,250));
|
|
308
|
+
let absent=false;try{process.kill(-child.pid,0);}catch(error){absent=error.code==='ESRCH';}
|
|
309
|
+
observe({kind:'process_group_termination',process_group:child.pid,status:absent?'absent':'unconfirmed',scope:'local_process_group_only',external_effects:'unjudged'});
|
|
310
|
+
}
|
|
311
|
+
observe({kind:'process_exit',exit_code:code,termination_signal:terminationSignal || null,interrupted:Boolean(failure)});
|
|
254
312
|
// Leave the short kill timer alive after timeout to stop remaining descendants.
|
|
255
313
|
if (failure) reject(failure);
|
|
256
|
-
else if (code !== 0 && !(allowNoMatch && code === 1)) reject(new Error(`
|
|
257
|
-
else resolve(output + (truncated ? '\n[
|
|
314
|
+
else if (code !== 0 && !(allowNoMatch && code === 1)) reject(Object.assign(new Error(`Command exited with code ${code}: ${output}`),{code:'PROCESS_EXIT_NONZERO'}));
|
|
315
|
+
else resolve(output + (truncated ? '\n[Output limited to 64 KiB]' : '') || '(no output)');
|
|
258
316
|
});
|
|
259
317
|
});
|
|
260
318
|
}
|
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 = [
|
|
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
|
}
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|