UNPKG

grix-connector

Version:

Connect local AI coding agents (Claude, Codex, Gemini, Qwen, DeepSeek, Cursor, OpenCode, Pi, OpenHuman, Reasonix) to the Grix scheduling platform. Also serves as an OpenClaw plugin for Grix channel transport.

3 lines (2 loc) 17.1 kB
import{EventEmitter as k}from"node:events";import{randomUUID as v}from"node:crypto";import p from"node:os";import m from"ws";import{log as r}from"../log/index.js";import{getMachineName as $}from"../util/index.js";import{detectTailnetIPv4 as b,ensureServerAndGetPort as w,getFileServerHttpsPort as q}from"../files/file-serve.js";function E(g){return g.replace(/(?<=[\[:,\[]\s*)(\d{16,})(?=\s*[,}\]\n])/g,'"$1"')}function P(g){const e=[...g??["stream_chunk","local_action_v1","agent_invoke"]];return e.includes("agent_invoke")||e.push("agent_invoke"),e.includes("event_result_ack")||e.push("event_result_ack"),e}const S="aibot-agent-api-v1",y=1;class f extends k{static DROPPABLE_COMMANDS=new Set(["update_binding_card"]);static BUFFER_OVERFLOW_RETAIN_COMMANDS=new Set(["event_result","codex_event","client_stream_chunk"]);static MAX_OUTBOUND_BUFFER_SIZE=1e3;static BACKPRESSURE_THRESHOLD=64*1024;ws=null;seq=0;heartbeatTimer=null;heartbeatSec=30;connected=!1;reconnecting=!1;reconnectAttempts=0;everConnected=!1;config;packetLog;pendingInvokes=new Map;seqEventMap=new Map;pendingRequests=new Map;outboundBuffer=[];ackPolicy=null;constructor(e,t){super(),this.packetLog=t?.packetLog??null,this.config={url:e.url,agentId:e.agentId,apiKey:e.apiKey,clientType:e.clientType,clientVersion:e.clientVersion??"",adapterHint:e.adapterHint??"",capabilities:P(e.capabilities),localActions:e.localActions??["exec_approve","exec_reject"],skills:e.skills}}get isConnected(){return this.connected}async connect(){let e,t,s;const o=(async()=>{try{if(e=await b(),e!==void 0)try{t=await w(e);const n=q();n>0&&(s=n)}catch(n){r.warn("aibot",`file server pre-start failed: ${n}`)}}catch(n){r.warn("aibot",`tailnet detect failed: ${n}`)}})();return new Promise((n,h)=>{const c=new m(this.config.url);this.ws=c;const a=setTimeout(()=>{h(new Error("Auth timeout: no auth_ack received within 15s")),this.cleanupSocket()},15e3),d=++this.seq,i=setTimeout(()=>{this.pendingRequests.delete(d),h(new Error("Auth request timeout")),this.cleanupSocket()},15e3);this.pendingRequests.set(d,{expected:["auth_ack"],resolve:u=>{clearTimeout(a);const l=u.payload;l.code===0?(this.connected=!0,this.everConnected=!0,this.reconnectAttempts=0,l.heartbeat_sec&&(this.heartbeatSec=l.heartbeat_sec),l.ack_policy&&(this.ackPolicy=l.ack_policy,r.info("aibot",`ack_policy received: push_ack_timeout_ms=${l.ack_policy.push_ack_timeout_ms??"default"} max_retries=${l.ack_policy.max_retries??"default"} timeout_action=${l.ack_policy.timeout_action??"default"}`)),this.startHeartbeat(),this.flushOutboundBuffer(),this.emit("auth",l),n(l)):h(new Error(`Auth failed: code=${l.code} msg=${l.msg}`))},reject:u=>{clearTimeout(a),h(u)},timer:i}),c.on("open",async()=>{await o;const u={agent_id:this.config.agentId,api_key:this.config.apiKey,client_type:this.config.clientType,protocol_version:S,contract_version:y,capabilities:this.config.capabilities??[],local_actions:this.config.localActions,skills:this.config.skills};this.config.sharedOwnerId&&(u.shared_owner_id=this.config.sharedOwnerId),this.config.clientVersion&&(u.client="grix-connector",u.client_version=this.config.clientVersion,u.host_type=this.config.clientType,u.host_version=this.config.clientVersion),this.config.adapterHint&&(u.adapter_hint=this.config.adapterHint),u.host_meta={hostname:$(),platform:p.platform(),arch:p.arch(),os_release:p.release(),...e!==void 0&&{tailnet_ip:e},...t!==void 0&&t>0&&{file_server_port:t},...s!==void 0&&s>0&&{file_server_https_port:s}},this.config.concurrency&&(u.concurrency=this.config.concurrency),this.sendPacket("auth",u,d)}),c.on("message",u=>{let l;try{l=JSON.parse(E(u.toString()))}catch{return}try{this.handlePacket(l)}catch(_){this.emitClientError(new Error(`handlePacket error: ${_}`))}}),c.on("close",(u,l)=>{this.connected=!1,this.stopHeartbeat(),this.rejectAllPendingRequests("websocket closed"),this.emit("close",u,l.toString());const _=u!==1e3&&this.everConnected;r.info("aibot",`ws closed agent=${this.config.clientType}:${this.config.agentId} code=${u} reason=${l.toString()||"<none>"} everConnected=${this.everConnected} reconnecting=${this.reconnecting} willReconnect=${_}`),_&&this.attemptReconnect()}),c.on("error",u=>{this.emitClientError(u instanceof Error?u:new Error(String(u))),this.connected||h(u)})})}handlePacket(e){if(this.packetLog?.logInboundPacket(e.cmd,e.seq,e.payload),e.seq>0&&this.pendingRequests.has(e.seq)){const t=this.pendingRequests.get(e.seq);this.pendingRequests.delete(e.seq),clearTimeout(t.timer),t.expected.includes(e.cmd)?t.resolve(e):t.reject(new Error(`unexpected response: got ${e.cmd}, expected ${t.expected.join("/")}`));return}switch(e.cmd){case"auth_ack":break;case"ping":{this.sendPacket("pong",e.payload??{});break}case"event_msg":{this.emit("event",e.payload);break}case"local_action":{this.emit("localAction",e.payload);break}case"event_stop":{this.emit("stop",e.payload);break}case"event_revoke":{this.emit("revoke",e.payload);break}case"event_edit":{this.emit("edit",e.payload);break}case"event_cancel":{this.emit("eventCancel",e.payload);break}case"queue_clear":{this.emit("queueClear",e.payload);break}case"queue_snapshot_query":{this.emit("queueSnapshotQuery",e.payload);break}case"control_share_set":{this.emit("shareSet",e.payload);break}case"kicked":{if(this.emit("kicked",e.payload),this.connected=!1,this.stopHeartbeat(),this.rejectAllPendingRequests("kicked"),this.outboundBuffer.length=0,this.reconnectAttempts=Math.max(this.reconnectAttempts,3),this.ws){try{this.ws.close(4001,"kicked")}catch{}this.ws=null}break}case"error":{const t=e.payload,s=[t.ref_cmd?`ref_cmd=${t.ref_cmd}`:"",t.ref_id?`ref_id=${t.ref_id}`:""].filter(Boolean).join(" ");this.emitClientError(new Error(`Server error: code=${t.code} msg=${t.msg}${s?` ${s}`:""}`));break}case"agent_invoke_result":{this.handleInvokeResult(e.payload);break}case"mcp_frame":{const t=e.payload;this.emit("mcpFrame",t.session_id??"",t.frame??null);break}case"send_ack":break;case"send_nack":{const t=e.payload;if(t.code===4003&&e.seq>0){const s=this.seqEventMap.get(e.seq);s&&(this.seqEventMap.delete(e.seq),this.purgeBufferedStreamChunks(s),r.warn("aibot",`stream chunk rejected (4003), purging buffered chunks for event=${s}`),this.emit("streamRejected",s,t.code))}break}case"local_action_ack":break;default:break}}sendEventAck(e){this.sendPacket("event_ack",e)||r.warn("aibot",`event_ack NOT sent (ws not open) event=${e.event_id} ws=${this.ws?`state=${this.ws.readyState}`:"null"}`)}sendStreamChunk(e){!e.delta_content&&!e.is_finish&&(r.warn("aibot",`stream_chunk delta_content empty, patched to newline event=${e.event_id??""} session=${e.session_id} chunk_seq=${e.chunk_seq} is_finish=${e.is_finish}`),e={...e,delta_content:` `}),this.sendPacket("client_stream_chunk",e)}sendMsg(e){this.sendPacket("send_msg",e)||r.warn("aibot",`send_msg NOT sent (ws not open) event=${e.event_id??""} session=${e.session_id??""} ws=${this.ws?`state=${this.ws.readyState}`:"null"}`)}editMsg(e){this.sendPacket("edit_msg",e)}sendEventResult(e){if(!this.ws||this.ws.readyState!==m.OPEN){this.sendPacket("event_result",e);return}this.sendEventResultReliable(e)}sendLocalActionResult(e){this.sendPacket("local_action_result",e)}sendEventStopAck(e){this.sendPacket("event_stop_ack",e)}sendEventStopResult(e){this.sendPacket("event_stop_result",e)}sendSessionActivitySet(e){this.sendPacket("session_activity_set",e)}sendCodexEvent(e){this.sendPacket("codex_event",e)}sendUpdateBindingCard(e){this.sendPacket("update_binding_card",e)}sendSkillsUpdate(e){this.sendPacket("agent_skills_update",e)}sendPing(){this.sendPacket("ping",{})}sendEventState(e){this.sendPacket("event_state",e)}sendEventCancelResult(e){this.sendPacket("event_cancel_result",e)}sendQueueClearResult(e){this.sendPacket("queue_clear_result",e)}sendQueueSnapshot(e){this.sendPacket("queue_snapshot",e)}agentInvoke(e,t,s=15e3){return new Promise((o,n)=>{const h=v(),c=Math.max(1e3,Math.min(s,6e4)),a=setTimeout(()=>{this.pendingInvokes.delete(h),n(new Error(`agent_invoke timeout: ${e}`))},c);this.pendingInvokes.set(h,{resolve:o,reject:n,timer:a}),this.sendPacket("agent_invoke",{invoke_id:h,action:e,params:t,timeout_ms:c})})}sendMcpFrame(e,t){this.sendPacket("mcp_frame",{session_id:e,frame:t})}request(e,t,s){return new Promise((o,n)=>{const h=++this.seq,c=setTimeout(()=>{this.pendingRequests.delete(h),n(new Error(`request timeout: ${e} (expected ${s.expected.join("/")})`))},s.timeoutMs);this.pendingRequests.set(h,{expected:s.expected,resolve:o,reject:n,timer:c}),this.sendPacket(e,t,h)||(this.pendingRequests.delete(h),clearTimeout(c),n(new Error(`send failed: ${e}`)))})}async sendStreamChunkRequest(e,t=2e4){return this.request("client_stream_chunk",e,{expected:["send_ack","send_nack","error"],timeoutMs:t})}async sendText(e,t=2e4){return this.request("send_msg",{msg_type:1,...e},{expected:["send_ack","send_nack","error"],timeoutMs:t})}async sendMedia(e,t=2e4){return this.request("send_msg",{...e,msg_type:2},{expected:["send_ack","send_nack","error"],timeoutMs:t})}async editMessage(e,t=2e4){return this.request("edit_msg",e,{expected:["send_ack","send_nack","error"],timeoutMs:t})}async deleteMessage(e,t,s=2e4){return this.request("delete_msg",{session_id:e,msg_id:t},{expected:["send_ack","send_nack","error"],timeoutMs:s})}async sendEventResultRequest(e,t=5e3){return this.request("event_result",e,{expected:["send_ack","send_nack","error"],timeoutMs:t})}disconnect(){r.info("aibot",`disconnect() agent=${this.config.clientType}:${this.config.agentId} wasConnected=${this.connected} reconnecting=${this.reconnecting} reconnectAttempts=${this.reconnectAttempts}`),this.connected=!1,this.everConnected=!1,this.reconnecting=!1,this.reconnectAttempts=0,this.stopHeartbeat(),this.rejectAllPendingInvokes("disconnect"),this.rejectAllPendingRequests("disconnect"),this.outboundBuffer.length=0,this.ws&&(this.ws.close(1e3,"client disconnect"),this.ws=null)}async attemptReconnect(){if(!this.reconnecting)for(this.reconnecting=!0,r.info("aibot",`attemptReconnect start agent=${this.config.clientType}:${this.config.agentId} fromAttempts=${this.reconnectAttempts}`),this.emit("disconnected");this.reconnecting;){const e=Math.min(1e3*2**this.reconnectAttempts,3e4),t=Math.floor(e*.2*Math.random());if(this.reconnectAttempts++,await new Promise(s=>setTimeout(s,e+t)),!this.reconnecting)return;try{const s=await this.connect(),o=this.reconnectAttempts;this.reconnectAttempts=0,this.reconnecting=!1,r.info("aibot",`reconnect succeeded agent=${this.config.clientType}:${this.config.agentId} attempt=${o}`),this.emit("auth",s);return}catch(s){r.warn("aibot",`reconnect failed agent=${this.config.clientType}:${this.config.agentId} attempt=${this.reconnectAttempts} err=${s instanceof Error?s.message:s}`)}}}sendPacket(e,t,s){if(this.ws&&this.ws.readyState===m.OPEN){const o=this.ws.bufferedAmount>f.BACKPRESSURE_THRESHOLD;if(!o||!f.DROPPABLE_COMMANDS.has(e)){if(o&&f.DROPPABLE_COMMANDS.has(e))return!1;const n=s??++this.seq;if(e==="client_stream_chunk"&&t&&typeof t=="object"){const c=t.event_id;if(c&&(this.seqEventMap.set(n,c),this.seqEventMap.size>200)){const a=this.seqEventMap.keys().next().value;a!==void 0&&this.seqEventMap.delete(a)}}const h={cmd:e,seq:n,payload:t};this.packetLog?.logOutboundPacket(e,n,t,"sent");try{const c=this.ws.readyState,a=this.ws.bufferedAmount;return this.ws.send(JSON.stringify(h),d=>{if(e==="event_result"){const i=t;d?r.warn("aibot",`event_result ws send callback failed event=${i.event_id??""} status=${i.status??""} seq=${n} readyState=${c} bufferedAmount=${a} err=${d.message}`):r.info("aibot",`event_result ws send callback ok event=${i.event_id??""} status=${i.status??""} seq=${n} readyState=${c} bufferedAmount=${a}`)}else if(e==="client_stream_chunk"){const i=t;d?r.warn("aibot",`stream_chunk ws send failed event=${i.event_id??""} session=${i.session_id??""} seq=${n} chunk_seq=${i.chunk_seq??""} is_finish=${i.is_finish??""} readyState=${c} bufferedAmount=${a} err=${d.message}`):r.info("aibot",`stream_chunk ws send ok event=${i.event_id??""} session=${i.session_id??""} seq=${n} chunk_seq=${i.chunk_seq??""} is_finish=${i.is_finish??""} readyState=${c} bufferedAmount=${a}`)}else if(e==="event_ack"){const i=t;d?r.warn("aibot",`event_ack ws send failed event=${i.event_id??""} seq=${n} readyState=${c} bufferedAmount=${a} err=${d.message}`):r.info("aibot",`event_ack ws send ok event=${i.event_id??""} seq=${n} readyState=${c} bufferedAmount=${a}`)}else if(e==="send_msg"){const i=t;d?r.warn("aibot",`send_msg ws send failed event=${i.event_id??""} session=${i.session_id??""} seq=${n} readyState=${c} bufferedAmount=${a} err=${d.message}`):r.info("aibot",`send_msg ws send ok event=${i.event_id??""} session=${i.session_id??""} seq=${n} readyState=${c} bufferedAmount=${a}`)}else if(d){const i=t;r.warn("aibot",`${e} ws send failed seq=${n} session=${i.session_id??""} event=${i.event_id??""} client_msg_id=${i.client_msg_id??""} readyState=${c} bufferedAmount=${a} err=${d.message}`)}}),!0}catch(c){return this.emitClientError(new Error(`sendPacket failed: ${c}`)),!1}}}if(f.DROPPABLE_COMMANDS.has(e))return this.packetLog?.logOutboundPacket(e,s??0,t,"dropped"),!1;if(s!==void 0)return this.packetLog?.logOutboundPacket(e,s,t,"dropped"),!1;if(this.outboundBuffer.length>=f.MAX_OUTBOUND_BUFFER_SIZE&&(this.outboundBuffer=this.outboundBuffer.filter(o=>f.BUFFER_OVERFLOW_RETAIN_COMMANDS.has(o.cmd)),this.outboundBuffer.length>=f.MAX_OUTBOUND_BUFFER_SIZE&&this.outboundBuffer.shift()),this.outboundBuffer.push({cmd:e,payload:t}),this.packetLog?.logOutboundPacket(e,s??0,t,"buffered"),e==="client_stream_chunk"){const o=t;r.info("aibot",`stream_chunk buffered (ws not open) event=${o.event_id??""} session=${o.session_id??""} chunk_seq=${o.chunk_seq??""} is_finish=${o.is_finish??""} ws=${this.ws?`state=${this.ws.readyState}`:"null"}`)}return!1}async sendEventResultReliable(e){const t=this.ackPolicy?.max_retries??3,s=this.ackPolicy?.push_ack_timeout_ms??5e3,o=750;for(let n=1;n<=t;n++){const h=this.ws?.readyState??-1,c=this.ws?.bufferedAmount??0;r.info("aibot",`event_result send attempt event=${e.event_id} status=${e.status} attempt=${n}/${t} readyState=${h} bufferedAmount=${c}`);try{const a=await this.sendEventResultRequest(e,s);if(a.cmd==="send_ack"){const i=a.payload;r.info("aibot",`event_result ack event=${e.event_id} status=${e.status} attempt=${n}/${t} ack_event=${i.event_id??""} ack_status=${i.status??""}`);return}const d=a.payload;if(r.warn("aibot",`event_result rejected event=${e.event_id} status=${e.status} attempt=${n}/${t} cmd=${a.cmd} code=${d.code??""} msg=${d.msg??""}${d.ref_cmd?` ref_cmd=${d.ref_cmd}`:""}${d.ref_id?` ref_id=${d.ref_id}`:""}`),d.code===4003){r.warn("aibot",`event_result stopping retries: 4003 ownership denied event=${e.event_id}`);return}return}catch(a){const d=a instanceof Error?a.message:String(a);if(r.warn("aibot",`event_result attempt failed event=${e.event_id} status=${e.status} attempt=${n}/${t} err=${d}`),n===t){this.emitClientError(new Error(`event_result ack failed after ${t} attempts: event=${e.event_id} status=${e.status}`));return}await new Promise(i=>setTimeout(i,o*n))}}}purgeBufferedStreamChunks(e){const t=this.outboundBuffer.length;this.outboundBuffer=this.outboundBuffer.filter(s=>s.cmd!=="client_stream_chunk"?!0:s.payload?.event_id!==e),this.outboundBuffer.length<t&&r.info("aibot",`purged ${t-this.outboundBuffer.length} buffered stream chunks for event=${e}`)}emitClientError(e){if(this.listenerCount("error")===0){r.warn("aibot",`Client error (no listeners): ${e.message}`);return}this.emit("error",e)}flushOutboundBuffer(){if(this.outboundBuffer.length===0||!this.ws||this.ws.readyState!==m.OPEN)return;const e=this.outboundBuffer;this.outboundBuffer=[];for(const{cmd:t,payload:s}of e){const o=++this.seq;if(t==="client_stream_chunk"&&s&&typeof s=="object"){const h=s.event_id;h&&this.seqEventMap.set(o,h)}const n={cmd:t,seq:o,payload:s};try{this.ws.send(JSON.stringify(n))}catch{break}}if(this.seqEventMap.size>200){const t=[...this.seqEventMap.entries()].sort((s,o)=>s[0]-o[0]);this.seqEventMap.clear();for(const[s,o]of t.slice(-100))this.seqEventMap.set(s,o)}}handleInvokeResult(e){const t=this.pendingInvokes.get(e.invoke_id);t&&(this.pendingInvokes.delete(e.invoke_id),clearTimeout(t.timer),e.code===0?t.resolve(e.data??null):t.reject(new Error(`agent_invoke error code=${e.code}: ${e.msg??""}`)))}rejectAllPendingInvokes(e){for(const[,t]of this.pendingInvokes)clearTimeout(t.timer),t.reject(new Error(`agent_invoke canceled: ${e}`));this.pendingInvokes.clear()}rejectAllPendingRequests(e){for(const[,t]of this.pendingRequests)clearTimeout(t.timer),t.reject(new Error(`request canceled: ${e}`));this.pendingRequests.clear()}cleanupSocket(){if(this.ws){try{this.ws.close()}catch{}this.ws=null}}startHeartbeat(){this.stopHeartbeat(),this.heartbeatTimer=setInterval(()=>{this.connected&&this.request("ping",{ts:Date.now()},{expected:["pong"],timeoutMs:5e3}).catch(()=>{this.connected&&(this.cleanupSocket(),this.attemptReconnect())})},this.heartbeatSec*1e3)}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}}export{f as AibotClient};