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) • 16.9 kB
JavaScript
import{EventEmitter as k}from"node:events";import{randomUUID as p}from"node:crypto";import _ from"node:os";import m from"ws";import{log as c}from"../log/index.js";import{detectTailnetIPv4 as v,ensureServerAndGetPort as $}from"../files/file-serve.js";function b(g){return g.replace(/(?<=[\[:,\[]\s*)(\d{16,})(?=\s*[,}\]\n])/g,'"$1"')}function w(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 q="aibot-agent-api-v1",E=1;class l 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:w(e.capabilities),localActions:e.localActions??["exec_approve","exec_reject"],skills:e.skills}}get isConnected(){return this.connected}async connect(){let e,t;const n=(async()=>{try{if(e=await v(),e!==void 0)try{t=await $(e)}catch(r){c.warn("aibot",`file server pre-start failed: ${r}`)}}catch(r){c.warn("aibot",`tailnet detect failed: ${r}`)}})();return new Promise((r,i)=>{const u=new m(this.config.url);this.ws=u;const a=setTimeout(()=>{i(new Error("Auth timeout: no auth_ack received within 15s")),this.cleanupSocket()},15e3),o=++this.seq,d=setTimeout(()=>{this.pendingRequests.delete(o),i(new Error("Auth request timeout")),this.cleanupSocket()},15e3);this.pendingRequests.set(o,{expected:["auth_ack"],resolve:s=>{clearTimeout(a);const h=s.payload;h.code===0?(this.connected=!0,this.everConnected=!0,this.reconnectAttempts=0,h.heartbeat_sec&&(this.heartbeatSec=h.heartbeat_sec),h.ack_policy&&(this.ackPolicy=h.ack_policy,c.info("aibot",`ack_policy received: push_ack_timeout_ms=${h.ack_policy.push_ack_timeout_ms??"default"} max_retries=${h.ack_policy.max_retries??"default"} timeout_action=${h.ack_policy.timeout_action??"default"}`)),this.startHeartbeat(),this.flushOutboundBuffer(),this.emit("auth",h),r(h)):i(new Error(`Auth failed: code=${h.code} msg=${h.msg}`))},reject:s=>{clearTimeout(a),i(s)},timer:d}),u.on("open",async()=>{await n;const s={agent_id:this.config.agentId,api_key:this.config.apiKey,client_type:this.config.clientType,protocol_version:q,contract_version:E,capabilities:this.config.capabilities??[],local_actions:this.config.localActions,skills:this.config.skills};this.config.clientVersion&&(s.client="grix-connector",s.client_version=this.config.clientVersion,s.host_type=this.config.clientType,s.host_version=this.config.clientVersion),this.config.adapterHint&&(s.adapter_hint=this.config.adapterHint),s.host_meta={hostname:_.hostname(),platform:_.platform(),arch:_.arch(),os_release:_.release(),...e!==void 0&&{tailnet_ip:e},...t!==void 0&&t>0&&{file_server_port:t}},this.config.concurrency&&(s.concurrency=this.config.concurrency),this.sendPacket("auth",s,o)}),u.on("message",s=>{let h;try{h=JSON.parse(b(s.toString()))}catch{return}try{this.handlePacket(h)}catch(f){this.emitClientError(new Error(`handlePacket error: ${f}`))}}),u.on("close",(s,h)=>{this.connected=!1,this.stopHeartbeat(),this.rejectAllPendingRequests("websocket closed"),this.emit("close",s,h.toString());const f=s!==1e3&&this.everConnected;c.info("aibot",`ws closed agent=${this.config.clientType}:${this.config.agentId} code=${s} reason=${h.toString()||"<none>"} everConnected=${this.everConnected} reconnecting=${this.reconnecting} willReconnect=${f}`),f&&this.attemptReconnect()}),u.on("error",s=>{this.emitClientError(s instanceof Error?s:new Error(String(s))),this.connected||i(s)})})}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"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,n=[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}${n?` ${n}`:""}`));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 n=this.seqEventMap.get(e.seq);n&&(this.seqEventMap.delete(e.seq),this.purgeBufferedStreamChunks(n),c.warn("aibot",`stream chunk rejected (4003), purging buffered chunks for event=${n}`),this.emit("streamRejected",n,t.code))}break}case"local_action_ack":break;default:break}}sendEventAck(e){this.sendPacket("event_ack",e)||c.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&&(c.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)||c.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,n=15e3){return new Promise((r,i)=>{const u=p(),a=Math.max(1e3,Math.min(n,6e4)),o=setTimeout(()=>{this.pendingInvokes.delete(u),i(new Error(`agent_invoke timeout: ${e}`))},a);this.pendingInvokes.set(u,{resolve:r,reject:i,timer:o}),this.sendPacket("agent_invoke",{invoke_id:u,action:e,params:t,timeout_ms:a})})}sendMcpFrame(e,t){this.sendPacket("mcp_frame",{session_id:e,frame:t})}request(e,t,n){return new Promise((r,i)=>{const u=++this.seq,a=setTimeout(()=>{this.pendingRequests.delete(u),i(new Error(`request timeout: ${e} (expected ${n.expected.join("/")})`))},n.timeoutMs);this.pendingRequests.set(u,{expected:n.expected,resolve:r,reject:i,timer:a}),this.sendPacket(e,t,u)||(this.pendingRequests.delete(u),clearTimeout(a),i(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,n=2e4){return this.request("delete_msg",{session_id:e,msg_id:t},{expected:["send_ack","send_nack","error"],timeoutMs:n})}async sendEventResultRequest(e,t=5e3){return this.request("event_result",e,{expected:["send_ack","send_nack","error"],timeoutMs:t})}disconnect(){c.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,c.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(n=>setTimeout(n,e+t)),!this.reconnecting)return;try{const n=await this.connect(),r=this.reconnectAttempts;this.reconnectAttempts=0,this.reconnecting=!1,c.info("aibot",`reconnect succeeded agent=${this.config.clientType}:${this.config.agentId} attempt=${r}`),this.emit("auth",n);return}catch(n){c.warn("aibot",`reconnect failed agent=${this.config.clientType}:${this.config.agentId} attempt=${this.reconnectAttempts} err=${n instanceof Error?n.message:n}`)}}}sendPacket(e,t,n){if(this.ws&&this.ws.readyState===m.OPEN){const r=this.ws.bufferedAmount>l.BACKPRESSURE_THRESHOLD;if(!r||!l.DROPPABLE_COMMANDS.has(e)){if(r&&l.DROPPABLE_COMMANDS.has(e))return!1;const i=n??++this.seq;if(e==="client_stream_chunk"&&t&&typeof t=="object"){const a=t.event_id;if(a&&(this.seqEventMap.set(i,a),this.seqEventMap.size>200)){const o=this.seqEventMap.keys().next().value;o!==void 0&&this.seqEventMap.delete(o)}}const u={cmd:e,seq:i,payload:t};this.packetLog?.logOutboundPacket(e,i,t,"sent");try{const a=this.ws.readyState,o=this.ws.bufferedAmount;return this.ws.send(JSON.stringify(u),d=>{if(e==="event_result"){const s=t;d?c.warn("aibot",`event_result ws send callback failed event=${s.event_id??""} status=${s.status??""} seq=${i} readyState=${a} bufferedAmount=${o} err=${d.message}`):c.info("aibot",`event_result ws send callback ok event=${s.event_id??""} status=${s.status??""} seq=${i} readyState=${a} bufferedAmount=${o}`)}else if(e==="client_stream_chunk"){const s=t;d?c.warn("aibot",`stream_chunk ws send failed event=${s.event_id??""} session=${s.session_id??""} seq=${i} chunk_seq=${s.chunk_seq??""} is_finish=${s.is_finish??""} readyState=${a} bufferedAmount=${o} err=${d.message}`):c.info("aibot",`stream_chunk ws send ok event=${s.event_id??""} session=${s.session_id??""} seq=${i} chunk_seq=${s.chunk_seq??""} is_finish=${s.is_finish??""} readyState=${a} bufferedAmount=${o}`)}else if(e==="event_ack"){const s=t;d?c.warn("aibot",`event_ack ws send failed event=${s.event_id??""} seq=${i} readyState=${a} bufferedAmount=${o} err=${d.message}`):c.info("aibot",`event_ack ws send ok event=${s.event_id??""} seq=${i} readyState=${a} bufferedAmount=${o}`)}else if(e==="send_msg"){const s=t;d?c.warn("aibot",`send_msg ws send failed event=${s.event_id??""} session=${s.session_id??""} seq=${i} readyState=${a} bufferedAmount=${o} err=${d.message}`):c.info("aibot",`send_msg ws send ok event=${s.event_id??""} session=${s.session_id??""} seq=${i} readyState=${a} bufferedAmount=${o}`)}else if(d){const s=t;c.warn("aibot",`${e} ws send failed seq=${i} session=${s.session_id??""} event=${s.event_id??""} client_msg_id=${s.client_msg_id??""} readyState=${a} bufferedAmount=${o} err=${d.message}`)}}),!0}catch(a){return this.emitClientError(new Error(`sendPacket failed: ${a}`)),!1}}}if(l.DROPPABLE_COMMANDS.has(e))return this.packetLog?.logOutboundPacket(e,n??0,t,"dropped"),!1;if(n!==void 0)return this.packetLog?.logOutboundPacket(e,n,t,"dropped"),!1;if(this.outboundBuffer.length>=l.MAX_OUTBOUND_BUFFER_SIZE&&(this.outboundBuffer=this.outboundBuffer.filter(r=>l.BUFFER_OVERFLOW_RETAIN_COMMANDS.has(r.cmd)),this.outboundBuffer.length>=l.MAX_OUTBOUND_BUFFER_SIZE&&this.outboundBuffer.shift()),this.outboundBuffer.push({cmd:e,payload:t}),this.packetLog?.logOutboundPacket(e,n??0,t,"buffered"),e==="client_stream_chunk"){const r=t;c.info("aibot",`stream_chunk buffered (ws not open) event=${r.event_id??""} session=${r.session_id??""} chunk_seq=${r.chunk_seq??""} is_finish=${r.is_finish??""} ws=${this.ws?`state=${this.ws.readyState}`:"null"}`)}return!1}async sendEventResultReliable(e){const t=this.ackPolicy?.max_retries??3,n=this.ackPolicy?.push_ack_timeout_ms??5e3,r=750;for(let i=1;i<=t;i++){const u=this.ws?.readyState??-1,a=this.ws?.bufferedAmount??0;c.info("aibot",`event_result send attempt event=${e.event_id} status=${e.status} attempt=${i}/${t} readyState=${u} bufferedAmount=${a}`);try{const o=await this.sendEventResultRequest(e,n);if(o.cmd==="send_ack"){const s=o.payload;c.info("aibot",`event_result ack event=${e.event_id} status=${e.status} attempt=${i}/${t} ack_event=${s.event_id??""} ack_status=${s.status??""}`);return}const d=o.payload;if(c.warn("aibot",`event_result rejected event=${e.event_id} status=${e.status} attempt=${i}/${t} cmd=${o.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){c.warn("aibot",`event_result stopping retries: 4003 ownership denied event=${e.event_id}`);return}return}catch(o){const d=o instanceof Error?o.message:String(o);if(c.warn("aibot",`event_result attempt failed event=${e.event_id} status=${e.status} attempt=${i}/${t} err=${d}`),i===t){this.emitClientError(new Error(`event_result ack failed after ${t} attempts: event=${e.event_id} status=${e.status}`));return}await new Promise(s=>setTimeout(s,r*i))}}}purgeBufferedStreamChunks(e){const t=this.outboundBuffer.length;this.outboundBuffer=this.outboundBuffer.filter(n=>n.cmd!=="client_stream_chunk"?!0:n.payload?.event_id!==e),this.outboundBuffer.length<t&&c.info("aibot",`purged ${t-this.outboundBuffer.length} buffered stream chunks for event=${e}`)}emitClientError(e){if(this.listenerCount("error")===0){c.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:n}of e){const r=++this.seq;if(t==="client_stream_chunk"&&n&&typeof n=="object"){const u=n.event_id;u&&this.seqEventMap.set(r,u)}const i={cmd:t,seq:r,payload:n};try{this.ws.send(JSON.stringify(i))}catch{break}}if(this.seqEventMap.size>200){const t=[...this.seqEventMap.entries()].sort((n,r)=>n[0]-r[0]);this.seqEventMap.clear();for(const[n,r]of t.slice(-100))this.seqEventMap.set(n,r)}}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{l as AibotClient};