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.
8 lines (6 loc) • 10.9 kB
JavaScript
import{spawn as P}from"node:child_process";import{randomUUID as w}from"node:crypto";import{EventEmitter as _}from"node:events";import{existsSync as m,readFileSync as E,readdirSync as $,statSync as b,mkdirSync as k,writeFileSync as x}from"node:fs";import{homedir as v}from"node:os";import{join as h,dirname as C,resolve as L}from"node:path";import{fileURLToPath as R}from"node:url";import{log as r}from"../../core/log/index.js";import{killProcessGroup as p}from"../../core/runtime/spawn.js";import{InternalApiServer as T}from"../../core/mcp/internal-api-server.js";import{syncDefaultSkillsToDir as D}from"../../default-skills/index.js";import{buildSimpleProbeReport as G}from"../shared/probe-util.js";const o="agy-adapter",f=h(v(),".gemini","antigravity-cli","log"),I=h(v(),".gemini","antigravity-cli","cache","last_conversations.json"),M=["\u4F60\u63A5\u5165\u7684\u662F Grix \u804A\u5929\u3002\u7528\u6237\u6D88\u606F\u88AB\u5305\u5728 <channel ...> \u6807\u7B7E\u91CC\uFF0C\u6807\u7B7E\u5C5E\u6027\u643A\u5E26\u672C\u8F6E\u4E0A\u4E0B\u6587\uFF1A","chat_id\uFF08\u5F53\u524D\u4F1A\u8BDD\uFF09\u3001event_id\uFF08\u672C\u8F6E\u4E8B\u4EF6\uFF09\u3001message_id\uFF08\u7528\u6237\u8FD9\u6761\u6D88\u606F\u7684 ID\uFF09\u3001user_id\uFF08\u53D1\u9001\u8005\uFF09\u3002","\u91CD\u8981\uFF1A\u4F60\u7684\u56DE\u590D\u5FC5\u987B\u8C03\u7528 grix \u7684 `grix_message_send` \u5DE5\u5177\u53D1\u9001\uFF08sessionId \u53D6\u81EA channel \u7684 chat_id\uFF0Ccontent \u4E3A\u4F60\u7684\u56DE\u590D\u5185\u5BB9\uFF09\uFF0C","\u4E0D\u8981\u53EA\u628A\u7B54\u6848\u6253\u5370\u5230\u6807\u51C6\u8F93\u51FA\u2014\u2014\u53EA\u6709\u8C03\u7528\u5DE5\u5177\u624D\u4F1A\u628A\u6D88\u606F\u771F\u6B63\u53D1\u7ED9\u7528\u6237\u3002","grix_message_send \u4F1A\u8FD4\u56DE\u4F60\u521A\u53D1\u51FA\u6D88\u606F\u7684 msg_id\u3002\u9700\u8981\u65F6\u4F60\u53EF\u4EE5\u7528\u8FD9\u4E2A ID \u505A\u540E\u7EED\u64CD\u4F5C\uFF0C\u4F8B\u5982\u8C03\u7528 grix_message_unsend\uFF08\u4F20 msgId\uFF09\u64A4\u56DE\u67D0\u6761\u6D88\u606F\u3002"].join(""),O=new Set(["send_msg","grix_reply"]),j=new Set(["send_msg","grix_reply","delete_msg","session_send","grix_complete","grix_composing","grix_event_ack"]);class X extends _{type="agy";config;callbacks;runtimeResolver;alive=!1;stopped=!1;internalApi=null;activeEventId=null;activeEventSessionId=null;activeProcess=null;completedEventIds=new Set;activeEventSentViaTool=!1;sessionConversationMap=new Map;conversationOutputCache=new Map;eventQueue=[];constructor(t,e,s){super(),this.config=t,this.callbacks=e,this.runtimeResolver=s}async start(){this.alive||(this.alive=!0,this.stopped=!1,await this.startInternalApiAndInjectMcp(),r.info(o,"AgyAdapter started"))}async startInternalApiAndInjectMcp(){try{this.internalApi=new T,this.internalApi.setInvokeHandler(async(c,d)=>this.handleToolInvoke(c,d)),await this.internalApi.start(0),r.info(o,`Internal API started at ${this.internalApi.url}`);const t=this.getMcpConfig(),e=h(v(),".gemini","config","mcp_config.json");k(C(e),{recursive:!0});let s={};try{m(e)&&(s=JSON.parse(E(e,"utf8")))}catch{}const i=s.mcpServers&&typeof s.mcpServers=="object"?s.mcpServers:{};i[t.name]={command:t.command,args:t.args},s.mcpServers=i,x(e,`${JSON.stringify(s,null,2)}
`,"utf8"),r.info(o,`MCP config injected into ${e}`);const n=h(v(),".gemini","skills"),a=D(n);a.length>0&&r.info(o,`Synced connector skills to ${n}: [${a.join(", ")}]`)}catch(t){r.warn(o,`Failed to start MCP tools (non-fatal): ${t instanceof Error?t.message:String(t)}`)}}async handleToolInvoke(t,e){let s=e;j.has(t)&&this.activeEventSessionId&&(s.session_id==null||s.session_id==="")&&(s={...s,session_id:this.activeEventSessionId});const i=await this.callbacks.agentInvoke(t,s);return O.has(t)&&this.activeEventId&&(this.activeEventSentViaTool=!0),i}async stop(){if(!this.stopped){if(this.stopped=!0,this.alive=!1,this.activeProcess){try{p(this.activeProcess,"SIGKILL")}catch{}this.activeProcess=null}if(this.activeEventId&&this.activeEventSessionId)try{this.callbacks.sendEventResult(this.activeEventId,"canceled","adapter stopped")}catch{}if(this.activeEventId=null,this.activeEventSessionId=null,this.internalApi){try{await this.internalApi.stop()}catch{}this.internalApi=null}this.emit("exit",0),r.info(o,"AgyAdapter stopped")}}isAlive(){return this.alive}async createSession(t){return t.cwd??w()}async resumeSession(t,e){}async destroySession(t){}sendPrompt(t){const e=new _;return e.adapterSessionId=t.adapterSessionId,e.cancel=async()=>{e.emit("done",{status:"canceled"})},e}async cancel(t){}cancelCurrentRun(){if(!this.activeEventId||!this.activeProcess)return;const t=this.activeEventId;r.info(o,`cancelCurrentRun: killing process group for event ${t}`);try{p(this.activeProcess,"SIGKILL")}catch{}this.activeProcess=null,this.callbacks.sendEventResult(t,"canceled","restarted by user"),this.clearActiveEvent(t),this.drainQueue()}deliverInboundEvent(t){if(!this.stopped){if(this.completedEventIds.has(t.event_id)){r.debug(o,`Skipping duplicate event: ${t.event_id}`);return}if(this.callbacks.sendEventAck(t.event_id,t.session_id),this.activeEventId){r.info(o,`Queuing event ${t.event_id} (active: ${this.activeEventId})`),this.eventQueue.push(t);return}this.processEvent(t)}}deliverStopEvent(t,e){if(this.activeEventId===t&&this.activeProcess){r.info(o,`Stopping event ${t}`);try{p(this.activeProcess,"SIGKILL")}catch{}this.activeProcess=null,this.callbacks.sendEventResult(t,"canceled","stopped by user"),this.clearActiveEvent(t),this.drainQueue()}}setPermissionHandler(){}async ping(t){return this.alive}getStatus(){return{alive:this.alive,busy:this.activeEventId!==null,sessions:this.sessionConversationMap.size}}async probe(t){const e=this.getStatus();return G(this.config.command||"agy",{alive:e.alive,busy:e.busy,started:e.alive},t)}getActiveEventIds(){return this.activeEventId?[this.activeEventId]:[]}clearActiveEventForShutdown(){if(this.activeProcess)try{p(this.activeProcess,"SIGKILL")}catch{}this.activeEventId=null,this.activeEventSessionId=null,this.activeProcess=null,this.activeEventSentViaTool=!1}getMcpConfig(){if(!this.internalApi)return null;const t=L(R(import.meta.url),"../../../mcp/acp-mcp-server.js");return{name:"grix-connector-tools",command:process.execPath,args:[t,"--api-url",this.internalApi.url]}}processEvent(t){const{event_id:e,session_id:s}=t,i=this.runtimeResolver(s);if(!i.cwd){r.warn(o,`No working directory for session ${s}, event should have been intercepted by bridge`),this.callbacks.forceCompleteInternalEvent(e,s);return}this.activeEventId=e,this.activeEventSessionId=s,this.activeEventSentViaTool=!1,this.emit("eventStarted",e,s),r.info(o,`Processing event ${e} for session ${s}`);const n=this.buildAgyPrompt(t);this.spawnAgyPrint(e,s,n,i)}buildAgyPrompt(t){const i=`<channel source="grix-agy" ${[["chat_id",t.session_id],["event_id",t.event_id],["message_id",t.msg_id],["user_id",t.sender_id],["quoted_message_id",t.quoted_message_id]].filter(([,n])=>n!=null&&n!=="").map(([n,a])=>`${n}="${a}"`).join(" ")}>
${t.content}
</channel>`;return`${M}
${i}`}spawnAgyPrint(t,e,s,i){const n=this.buildPrintArgs(s,i),a={...process.env,...this.config.env??{}};r.info(o,`Spawning: agy ${n.map(l=>l.includes(" ")?`"${l}"`:l).join(" ")}`);const c=this.getLatestLogFilePath(),d=P(this.config.command,n,{cwd:i.cwd,env:a,stdio:["pipe","pipe","pipe"],detached:!0});this.activeProcess=d;const S=[],y=[];d.stdout?.on("data",l=>{S.push(l)}),d.stderr?.on("data",l=>{y.push(l)}),d.on("error",l=>{r.error(o,`Process spawn error for event ${t}: ${l.message}`),this.handleProcessResult(t,e,"",`spawn error: ${l.message}`,i)}),d.on("close",l=>{if(r.info(o,`Process exited for event ${t} with code ${l}`),this.activeEventId!==t)return;const u=Buffer.concat(S).toString("utf-8"),A=Buffer.concat(y).toString("utf-8");if(l!==0){const g=A.trim()||`process exited with code ${l}`;this.handleProcessResult(t,e,u,g,i)}else u.trim()?this.handleProcessResult(t,e,u,"",i):this.activeEventSentViaTool?this.handleProcessResult(t,e,u,"",i):setTimeout(()=>{const g=this.extractLogError(c);this.handleProcessResult(t,e,u,g,i)},300)}),d.stdin?.end()}buildPrintArgs(t,e){const s=[];return e.modelId&&s.push("--model",e.modelId),e.cwd&&s.push("--add-dir",e.cwd),s.push("--dangerously-skip-permissions"),e.conversationId&&s.push("--conversation",e.conversationId),s.push("-p",t),s}readAgyConversationId(t){try{if(m(I))return JSON.parse(E(I,"utf-8"))[t]||void 0}catch(e){r.debug(o,`readAgyConversationId: ${e}`)}}getLatestLogFilePath(){try{if(!m(f))return null;const t=$(f).filter(e=>e.startsWith("cli-")&&e.endsWith(".log")).map(e=>({name:e,path:h(f,e),mtime:b(h(f,e)).mtimeMs})).sort((e,s)=>s.mtime-e.mtime);return t.length>0?t[0].path:null}catch{return null}}extractLogError(t){try{const e=this.getLatestLogFilePath();if(!e)return r.info(o,"extractLogError: no log files found"),"agy completed without output";const s=E(e,"utf-8");if(!s)return r.info(o,`extractLogError: log file is empty: ${e}`),"agy completed without output";const i=s.split(`
`).filter(n=>n.startsWith("E")).map(n=>{const a=n.indexOf("] ");return a>=0?n.slice(a+2):n}).filter(n=>n.length>0);if(i.length>0){const n=i[i.length-1];return r.info(o,`Extracted error from agy log: ${n.slice(0,120)}`),n.length>500?n.slice(0,500)+"...":n}return"agy completed without output (possible auth or quota issue)"}catch(e){return r.info(o,`extractLogError: ${e}`),"agy completed without output"}}handleProcessResult(t,e,s,i,n){if(this.activeEventId===t){if(i&&!s.trim()){r.error(o,`Event ${t} failed: ${i}`);const c=i.includes("RESOURCE_EXHAUSTED")||i.includes("quota")?"agy API \u914D\u989D\u5DF2\u7528\u5C3D\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5":`agy \u6267\u884C\u5931\u8D25: ${i}`;this.callbacks.sendStreamChunk(t,e,c,1,!0),this.callbacks.sendEventResult(t,"failed",i)}else{const a=s.trim();if(a){const c=n.cwd,d=c?this.extractDelta(a,c):a;c&&this.conversationOutputCache.set(c,a),d&&!this.activeEventSentViaTool&&this.callbacks.sendStreamChunk(t,e,d,1,!0)}if(n.cwd){const c=this.readAgyConversationId(n.cwd);c&&c!==n.conversationId&&(this.sessionConversationMap.set(e,c),this.callbacks.persistConversationId(e,c))}this.callbacks.sendEventResult(t,"responded")}this.clearActiveEvent(t),this.drainQueue()}}extractDelta(t,e){const s=this.conversationOutputCache.get(e);return s&&t.startsWith(s)?t.slice(s.length).trim():t}clearActiveEvent(t){const e=t??this.activeEventId;if(e&&(this.completedEventIds.add(e),this.completedEventIds.size>1e3)){const s=Array.from(this.completedEventIds);this.completedEventIds.clear();for(let i=500;i<s.length;i++)this.completedEventIds.add(s[i])}this.activeEventId=null,this.activeEventSessionId=null,this.activeProcess=null,this.activeEventSentViaTool=!1,e&&this.emit("eventDone",e)}drainQueue(){if(this.stopped||this.activeEventId)return;const t=this.eventQueue.shift();t&&(r.info(o,`Draining queued event ${t.event_id}`),this.processEvent(t))}}export{X as AgyAdapter};