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.
11 lines (8 loc) • 11.6 kB
JavaScript
import{spawn as P}from"node:child_process";import{randomUUID as $}from"node:crypto";import{EventEmitter as _}from"node:events";import{existsSync as S,readFileSync as y,readdirSync as w,statSync as k,mkdirSync as b,writeFileSync as x}from"node:fs";import{homedir as g}from"node:os";import{join as p,dirname as C,resolve as L}from"node:path";import{fileURLToPath as R}from"node:url";import{log as o}from"../../core/log/index.js";import{killProcessGroup as f}from"../../core/runtime/spawn.js";import{InternalApiServer as T}from"../../core/mcp/internal-api-server.js";import{syncDefaultSkillsToDir as j}from"../../default-skills/index.js";import{buildSimpleProbeReport as G}from"../shared/probe-util.js";const a="agy-adapter",m=p(g(),".gemini","antigravity-cli","log"),I=p(g(),".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"]),D=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(),o.info(a,"AgyAdapter started"))}async startInternalApiAndInjectMcp(){try{this.internalApi=new T,this.internalApi.setInvokeHandler(async(r,d)=>this.handleToolInvoke(r,d)),await this.internalApi.start(0),o.info(a,`Internal API started at ${this.internalApi.url}`);const t=this.getMcpConfig(),e=p(g(),".gemini","config","mcp_config.json");b(C(e),{recursive:!0});let s={};try{S(e)&&(s=JSON.parse(y(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"),o.info(a,`MCP config injected into ${e}`);const n=p(g(),".gemini","skills"),c=j(n);c.length>0&&o.info(a,`Synced connector skills to ${n}: [${c.join(", ")}]`)}catch(t){o.warn(a,`Failed to start MCP tools (non-fatal): ${t instanceof Error?t.message:String(t)}`)}}async handleToolInvoke(t,e){let s=e;D.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{f(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),o.info(a,"AgyAdapter stopped")}}isAlive(){return this.alive}async createSession(t){return t.cwd??$()}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;o.info(a,`cancelCurrentRun: killing process group for event ${t}`);try{f(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)){o.debug(a,`Skipping duplicate event: ${t.event_id}`);return}if(this.callbacks.sendEventAck(t.event_id,t.session_id),this.activeEventId){o.info(a,`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){o.info(a,`Stopping event ${t}`);try{f(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{f(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){o.warn(a,`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),o.info(a,`Processing event ${e} for session ${s}`);const n=this.buildAgyPrompt(t);this.spawnAgyPrint(e,s,n,i)}buildAgyPrompt(t){const s=[["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(([,r])=>r!=null&&r!=="").map(([r,d])=>`${r}="${d}"`).join(" "),i=this.renderContextBlock(t),n=i?`${i}
${t.content}`:t.content,c=`<channel source="grix-agy" ${s}>
${n}
</channel>`;return`${M}
${c}`}renderContextBlock(t){const e=t.context_messages_json;if(!e)return"";let s;try{const r=JSON.parse(e);if(!Array.isArray(r))return"";s=r}catch{return""}const i=String(t.session_type??"")==="2",n=String(t.msg_id??""),c=[];for(const r of s){const d=String(r?.msg_id??"");if(d&&d===n)continue;const h=String(r?.content??"").trim();if(!h)continue;const u=String(r?.sender_id??"");if(h.startsWith("[\u5F15\u7528\u6D88\u606F]")){const l=h.slice(6).replace(/^\s*\n?/,"").trim();c.push(i&&u?`[\u5F15\u7528\u6D88\u606F] (\u6765\u81EA ${u})\uFF1A${l}`:`[\u5F15\u7528\u6D88\u606F]\uFF1A${l}`)}else c.push(i&&u?`[${u}]\uFF1A${h}`:h)}return c.join(`
`)}spawnAgyPrint(t,e,s,i){const n=this.buildPrintArgs(s,i),c={...process.env,...this.config.env??{}};o.info(a,`Spawning: agy ${n.map(l=>l.includes(" ")?`"${l}"`:l).join(" ")}`);const r=this.getLatestLogFilePath(),d=P(this.config.command,n,{cwd:i.cwd,env:c,stdio:["pipe","pipe","pipe"],detached:!0});this.activeProcess=d;const h=[],u=[];d.stdout?.on("data",l=>{h.push(l)}),d.stderr?.on("data",l=>{u.push(l)}),d.on("error",l=>{o.error(a,`Process spawn error for event ${t}: ${l.message}`),this.handleProcessResult(t,e,"",`spawn error: ${l.message}`,i)}),d.on("close",l=>{if(o.info(a,`Process exited for event ${t} with code ${l}`),this.activeEventId!==t)return;const v=Buffer.concat(h).toString("utf-8"),A=Buffer.concat(u).toString("utf-8");if(l!==0){const E=A.trim()||`process exited with code ${l}`;this.handleProcessResult(t,e,v,E,i)}else v.trim()?this.handleProcessResult(t,e,v,"",i):this.activeEventSentViaTool?this.handleProcessResult(t,e,v,"",i):setTimeout(()=>{const E=this.extractLogError(r);this.handleProcessResult(t,e,v,E,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(S(I))return JSON.parse(y(I,"utf-8"))[t]||void 0}catch(e){o.debug(a,`readAgyConversationId: ${e}`)}}getLatestLogFilePath(){try{if(!S(m))return null;const t=w(m).filter(e=>e.startsWith("cli-")&&e.endsWith(".log")).map(e=>({name:e,path:p(m,e),mtime:k(p(m,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 o.info(a,"extractLogError: no log files found"),"agy completed without output";const s=y(e,"utf-8");if(!s)return o.info(a,`extractLogError: log file is empty: ${e}`),"agy completed without output";const i=s.split(`
`).filter(n=>n.startsWith("E")).map(n=>{const c=n.indexOf("] ");return c>=0?n.slice(c+2):n}).filter(n=>n.length>0);if(i.length>0){const n=i[i.length-1];return o.info(a,`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 o.info(a,`extractLogError: ${e}`),"agy completed without output"}}handleProcessResult(t,e,s,i,n){if(this.activeEventId===t){if(i&&!s.trim()){o.error(a,`Event ${t} failed: ${i}`);const r=i.includes("RESOURCE_EXHAUSTED")||i.includes("quota")?"The agy API quota has been exhausted. Please try again later.":`agy execution failed: ${i}`;this.callbacks.sendStreamChunk(t,e,r,1,!0),this.callbacks.sendEventResult(t,"failed",i)}else{const c=s.trim();if(c){const r=n.cwd,d=r?this.extractDelta(c,r):c;r&&this.conversationOutputCache.set(r,c),d&&!this.activeEventSentViaTool&&this.callbacks.sendStreamChunk(t,e,d,1,!0)}if(n.cwd){const r=this.readAgyConversationId(n.cwd);r&&r!==n.conversationId&&(this.sessionConversationMap.set(e,r),this.callbacks.persistConversationId(e,r))}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&&(o.info(a,`Draining queued event ${t.event_id}`),this.processEvent(t))}}export{X as AgyAdapter};