grix-connector 4.12.0 → 4.12.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.
@@ -1,3 +1,3 @@
1
- import{isRequestTimeoutError as E}from"../core/aibot/errors.js";import{log as h}from"../core/log/index.js";import{splitTextForAibotProtocol as p,AIBOT_PROTOCOL_MAX_RUNES as k}from"../core/protocol/protocol-text.js";import{isThinkingEventDropped as C,isToolEventDropped as y,normalizeRuntimeConfigSnapshot as S}from"./runtime-config.js";const q={claude:!1,acp:!1,codex:!0,codewhale:!0,pi:!0,openhuman:!1,cursor:!1,opencode:!0,agy:!0,"deepseek-harness":!1};class _{handle;connectorRuntimeConfig;eventRuntimeConfigs=new Map;eventQuotedMessageIds=new Map;defaultQuoteTrigger=!1;thinkingSeqMap=new Map;thinkingSessionMap=new Map;toolCardSeq=0;bufferedEventTexts=new Map;singleMessageDeliveries=new Map;chunkSeqMap=new Map;openStreams=new Map;rejectedEvents=new Set;terminalEvents=new Set;static MAX_TERMINAL_EVENTS=1e3;stoppedEvents=new Set;streamCoalesce=new Map;streamCoalesceMs=T();static STREAM_COALESCE_MAX_RUNES=2048;recentToolCards=new Map;static TOOL_CARD_DEDUP_MS=100;constructor(e){this.connectorRuntimeConfig={...e}}bind(e){this.handle=e}markEventRejected(e){if(!e)return;this.rejectedEvents.add(e);const t=`${e}:`;for(const s of[...this.streamCoalesce.keys()])if(s.startsWith(t)){const i=this.streamCoalesce.get(s);i.timer&&clearTimeout(i.timer),this.streamCoalesce.delete(s)}h.info("send-ctrl",`event marked as stream-rejected, future chunks will be dropped event=${e}`)}markEventTerminal(e){if(!(!e||this.terminalEvents.has(e))&&(this.terminalEvents.add(e),this.terminalEvents.size>_.MAX_TERMINAL_EVENTS)){const t=this.terminalEvents.values().next().value;t!==void 0&&this.terminalEvents.delete(t)}}isEventClosed(e){return!!e&&(this.rejectedEvents.has(e)||this.terminalEvents.has(e))}markEventStopped(e){e&&(this.stoppedEvents.add(e),h.info("send-ctrl",`[stop-trace] event marked stopped; subsequent outbound will be flagged event=${e}`))}flagOutboundAfterStop(e,t,s,i,o,a){!e||!this.stoppedEvents.has(e)||h.warn("send-ctrl",`[stop-trace] OUTBOUND AFTER STOP ${s} event=${e} session=${t} seq=${i} is_finish=${o} len=${a}`)}getGlobalRuntimeConfig(){return this.connectorRuntimeConfig}setStreamCoalesceMs(e){this.streamCoalesceMs=Number.isFinite(e)&&e>0?Math.floor(e):0}captureEventRuntimeConfig(e){this.terminalEvents.delete(e.event_id);const t=S(e.connector_runtime_config);if(t||!this.eventRuntimeConfigs.has(e.event_id)){const s=t??this.connectorRuntimeConfig;this.eventRuntimeConfigs.set(e.event_id,{responseDelivery:s.responseDelivery,toolEvents:s.toolEvents,thinkingEvents:s.thinkingEvents}),h.info("send-ctrl",`[runtime-config] event=${e.event_id} response_delivery=${s.responseDelivery} tool_events=${s.toolEvents} thinking_events=${s.thinkingEvents} source=${t?"event":"global"}`)}e.msg_id&&!this.eventQuotedMessageIds.has(e.event_id)&&this.eventQuotedMessageIds.set(e.event_id,e.msg_id)}discardEventState(e){e&&(this.markEventTerminal(e),this.clearEventTrackingState(e))}clearEventTrackingState(e){this.eventRuntimeConfigs.delete(e),this.eventQuotedMessageIds.delete(e),this.rejectedEvents.delete(e),this.singleMessageDeliveries.delete(e),this.thinkingSeqMap.delete(e),this.thinkingSessionMap.delete(e),this.bufferedEventTexts.delete(e);for(const t of[...this.streamCoalesce.keys()])if(t.startsWith(`${e}:`)){const s=this.streamCoalesce.get(t);s.timer&&clearTimeout(s.timer),this.streamCoalesce.delete(t)}this.stoppedEvents.delete(e)&&h.info("send-ctrl",`[stop-trace] event tracking cleared stop flag event=${e}`);for(const t of[...this.openStreams.keys()])t.startsWith(`${e}:`)&&this.openStreams.delete(t);for(const t of this.chunkSeqMap.keys())t.startsWith(`${e}:`)&&this.chunkSeqMap.delete(t);for(const t of this.recentToolCards.keys())t.startsWith(`${e}:`)&&this.recentToolCards.delete(t)}setDefaultQuoteTrigger(e){this.defaultQuoteTrigger=e}getDefaultQuotedMessageId(e){if(!(!this.defaultQuoteTrigger||!e))return this.eventQuotedMessageIds.get(e)}resolveEventRuntimeConfig(e){if(e){const t=this.eventRuntimeConfigs.get(e);if(t)return t}return this.connectorRuntimeConfig}shouldDropToolDisplayEvent(e){return y(this.resolveEventRuntimeConfig(e))}shouldDropThinkingDisplayEvent(e){return C(this.resolveEventRuntimeConfig(e))}shouldDropCodexDisplayEvent(e,t){return this.shouldDropToolDisplayEvent(e)?t==="item/completed":!1}shouldDropAcpRawDisplayEvent(e,t){return!!(this.shouldDropToolDisplayEvent(e)&&(t==="tool_use"||t==="tool_result")||this.shouldDropThinkingDisplayEvent(e)&&t==="thinking")}sendReply(e,t,s,i,o,a){if(!a?.skipToolFilter&&this.shouldDropToolDisplayEvent(e)&&$(s,o)||this.isEventClosed(e))return;if(this.flagOutboundAfterStop(e,t,"reply",0,!1,(s||"").length),this.resolveEventRuntimeConfig(e).responseDelivery==="stream"&&!o){const n=p(s),l=e?`reply_stream_${e}`:`reply_stream_${Date.now()}`;if(n.length===0){this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:s||" ",quoted_message_id:i||void 0});return}for(let u=0;u<n.length;u++){const c=u===n.length-1;this.sendStreamChunk(e,t,n[u],u+1,c,l,i)}return}this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:s,quoted_message_id:i||void 0,...o?{extra:o}:{}})}sendStreamChunk(e,t,s,i,o,a,r){if(this.isEventClosed(e))return;if(r=r??this.getDefaultQuotedMessageId(e),this.flagOutboundAfterStop(e,t,"stream_chunk",i,o,(s||"").length),this.resolveEventRuntimeConfig(e).responseDelivery==="single_message"&&e){this.bufferStreamChunk(e,t,s,r,a);return}if(this.streamCoalesceMs<=0){this.emitStreamChunkParts(e,t,s,i,o,a,r);return}this.coalesceStreamChunk(e,t,s,o,a,r)}async sendFinalStreamChunkReliable(e,t,s,i,o=!1,a=!1){if(this.isEventClosed(e))throw new Error(`required agent output was already rejected or the event is terminal: event=${e}`);a||this.finalizeThinking(e,t);const r=this.resolveEventRuntimeConfig(e);if(!o&&r.responseDelivery==="single_message"&&e){if(this.flushCoalesceForEvent(e),await this.ensureSingleMessageDelivery(e,i),this.isEventClosed(e))throw new Error(`required agent output was rejected before terminal: event=${e}`);return}const n=`${e}:${s??"default"}`;this.flushCoalesce(n,!1),this.streamCoalesce.delete(n);const l=(this.chunkSeqMap.get(n)??0)+1;this.chunkSeqMap.set(n,l),this.openStreams.delete(n);const u=this.getDefaultQuotedMessageId(e),c={event_id:e,session_id:t,delta_content:"",chunk_seq:l,is_finish:!0,...s?{client_msg_id:s}:{},...u?{quoted_message_id:u}:{}};this.flagOutboundAfterStop(e,t,"stream_chunk_final",l,!0,0);const g=async()=>{const f=await this.handle.sendStreamChunkRequest(c,i);if(f.cmd!=="send_ack"){const m=f.payload;throw new Error(`final chunk rejected: cmd=${f.cmd} code=${m?.code??""} msg=${m?.msg??""}`)}};try{await g()}catch(f){if(E(f))h.warn("send-ctrl",`final chunk ACK timed out, retry request with same chunk_seq=${l} event=${e}`),await g();else throw h.warn("send-ctrl",`final chunk send failed (no resend) event=${e}: ${f}`),f}if(this.isEventClosed(e))throw new Error(`required agent output was rejected before terminal: event=${e}`)}async deliverAuxiliaryLargeText(e,t,s,i){if(!s||this.isEventClosed(e))return!1;if(this.resolveEventRuntimeConfig(e).responseDelivery==="single_message"&&e){const r=this.getDefaultQuotedMessageId(e),n=p(s);for(let l=0;l<n.length;l++)this.flagOutboundAfterStop(e,t,"aux_detail_msg",l+1,!1,n[l].length),this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:n[l],...l===0&&r?{quoted_message_id:r}:{},client_msg_id:n.length>1?`${i}_p${l+1}`:i});return!0}const a=p(s);for(const r of a)this.sendStreamChunk(e,t,r,0,!1,i);return await this.sendFinalStreamChunkReliable(e,t,i,void 0,!1,!0),!0}coalesceStreamChunk(e,t,s,i,o,a){if(!s&&!i)return;const r=`${e}:${o??"default"}`;let n=this.streamCoalesce.get(r);if(n||(n={eventId:e,sessionId:t,clientMsgId:o,quotedMessageId:a,text:"",flushedOnce:!1},this.streamCoalesce.set(r,n)),s&&(n.text+=s),n.sessionId=t,o!==void 0&&(n.clientMsgId=o),!n.quotedMessageId&&a&&(n.quotedMessageId=a),i){this.flushCoalesce(r,!0);return}if(!n.flushedOnce||n.text.length>=_.STREAM_COALESCE_MAX_RUNES){this.flushCoalesce(r,!1);return}n.timer||(n.timer=setTimeout(()=>this.flushCoalesce(r,!1),this.streamCoalesceMs))}flushCoalesce(e,t){const s=this.streamCoalesce.get(e);if(!s)return;s.timer&&(clearTimeout(s.timer),s.timer=void 0);const i=s.text;s.text="",s.flushedOnce=!0,t&&this.streamCoalesce.delete(e),!(!i&&!t)&&this.emitStreamChunkParts(s.eventId,s.sessionId,i,0,t,s.clientMsgId,s.quotedMessageId)}flushCoalesceForEvent(e){const t=`${e}:`;for(const s of[...this.streamCoalesce.keys()])s.startsWith(t)&&(this.flushCoalesce(s,!1),this.streamCoalesce.delete(s))}emitStreamChunkParts(e,t,s,i,o,a,r){const n=s||(o?`
2
- `:"");if(!n&&!o)return;const l=p(n,k);l.length===0&&l.push(n||`
3
- `);const u=`${e}:${a??"default"}`;for(let c=0;c<l.length;c++){const g=c===l.length-1,f=this.chunkSeqMap.get(u)??0,m=c===0&&i>f?i:f+1;this.chunkSeqMap.set(u,m),this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:l[c],chunk_seq:m,is_finish:g&&o,...a?{client_msg_id:a}:{},...r?{quoted_message_id:r}:{}})}e&&(o?this.openStreams.delete(u):this.openStreams.set(u,{eventId:e,sessionId:t,clientMsgId:a,quotedMessageId:r}))}finalizeOpenStreams(e){if(!e||this.isEventClosed(e))return;this.finalizeThinking(e,""),this.flushCoalesceForEvent(e);const t=`${e}:`;for(const[s,i]of[...this.openStreams]){if(!s.startsWith(t))continue;this.openStreams.delete(s);const o=(this.chunkSeqMap.get(s)??0)+1;this.chunkSeqMap.set(s,o),this.handle.sendStreamChunk({event_id:i.eventId,session_id:i.sessionId,delta_content:"",chunk_seq:o,is_finish:!0,...i.clientMsgId?{client_msg_id:i.clientMsgId}:{},...i.quotedMessageId?{quoted_message_id:i.quotedMessageId}:{}}),h.info("send-ctrl",`appended missing stream fence before terminal event=${i.eventId} client_msg_id=${i.clientMsgId??"default"} chunk_seq=${o}`)}}sendEventResult(e,t,s,i,o=!1){this.finalizeThinking(e,""),this.flushCoalesceForEvent(e);const a=this.ensureSingleMessageDelivery(e),r=(n,l=s,u=i)=>{n==="responded"&&this.rejectedEvents.has(e)&&(n="failed",u=u||"agent_output_rejected",l=l||"required agent output was rejected by the server"),this.markEventTerminal(e),this.handle.sendEventResult({event_id:e,status:n,...l?{msg:l}:{},...u?{code:u}:{},...o&&n===t?{error_surfaced:!0}:{},updated_at:Date.now()}),this.clearEventTrackingState(e)};if(!a){r(t);return}a.then(()=>r(t),n=>{const l=`final output delivery failed: ${n instanceof Error?n.message:String(n)}`;r(t==="responded"?"failed":t,t==="responded"?l:s,t==="responded"?"agent_output_delivery_failed":i)})}sendThinking(e,t,s){if(this.shouldDropThinkingDisplayEvent(e)||this.isEventClosed(e))return;this.flagOutboundAfterStop(e,t,"thinking",this.thinkingSeqMap.get(e)??0,!1,(s||"").length);const i=(this.thinkingSeqMap.get(e)??0)+1;this.thinkingSeqMap.set(e,i),this.thinkingSessionMap.set(e,t),this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:s,chunk_seq:i,is_finish:!1,client_msg_id:`${e}_thinking`,is_thinking:!0})}sendToolExecutionCard(e,t,s,i){if(this.shouldDropToolDisplayEvent(e)){h.info("send-ctrl",`[tool-card] DROP event=${e} session=${t} summary=${s.summaryText}`);return}const a=Date.now(),r=`${e}:${s.summaryText}:${s.detailText??""}`,n=this.recentToolCards.get(r);if(n!==void 0&&a-n<_.TOOL_CARD_DEDUP_MS){h.info("send-ctrl",`[tool-card] DEDUP event=${e} session=${t} summary=${s.summaryText}`);return}this.recentToolCards.set(r,a);const l={summary_text:s.summaryText};s.detailText&&(l.detail_text=s.detailText),h.info("send-ctrl",`[tool-card] SEND event=${e} session=${t} summary=${s.summaryText}`),this.handle.sendMsg({event_id:e,session_id:t,client_msg_id:i??`tool_${++this.toolCardSeq}_${Date.now()}`,msg_type:1,content:"",extra:{channel_data:{grix:{toolExecution:l}},agent_api_origin:!0}})}finalizeThinking(e,t){const s=this.thinkingSeqMap.get(e);s!==void 0&&(this.thinkingSeqMap.delete(e),t=t||this.thinkingSessionMap.get(e)||"",this.thinkingSessionMap.delete(e),t&&(this.isEventClosed(e)||this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:"",chunk_seq:s+1,is_finish:!0,client_msg_id:`${e}_thinking`,is_thinking:!0})))}bufferOnly(e,t,s,i,o){o=o??this.getDefaultQuotedMessageId(e),this.bufferStreamChunk(e,t,s,o)}bufferStreamChunk(e,t,s,i,o){const a=typeof o=="string"&&o.startsWith("reply_"),r=this.bufferedEventTexts.get(e);if(!r){this.bufferedEventTexts.set(e,{sessionId:t,text:s??"",quotedMessageId:i||void 0,replyReceived:a});return}a?r.replyReceived?s&&(r.text+=s):(r.text=s??"",r.replyReceived=!0):!r.replyReceived&&s&&(r.text+=s),!r.quotedMessageId&&i&&(r.quotedMessageId=i)}ensureSingleMessageDelivery(e,t){const s=this.singleMessageDeliveries.get(e);if(s)return s;const i=this.bufferedEventTexts.get(e);if(!i||(this.bufferedEventTexts.delete(e),!i.text))return;const o=(async()=>{const a={event_id:e,session_id:i.sessionId,content:i.text,client_msg_id:`agent_single_${e}`,...i.quotedMessageId?{quoted_message_id:i.quotedMessageId}:{}},r=async()=>{const n=t===void 0?await this.handle.sendText(a):await this.handle.sendText(a,t);if(n.cmd!=="send_ack"){const l=n.payload;throw new Error(`single-message output rejected: cmd=${n.cmd} code=${l?.code??""} msg=${l?.msg??""}`)}};try{await r()}catch(n){if(!E(n))throw n;h.warn("send-ctrl",`single-message ACK timed out, retry request with stable client_msg_id=${a.client_msg_id} event=${e}`),await r()}})();return this.singleMessageDeliveries.set(e,o),o}}function T(){const d=process.env.GRIX_STREAM_COALESCE_MS;if(d===void 0||d==="")return 500;const e=Number(d);return Number.isFinite(e)&&e>=0?Math.floor(e):500}function $(d,e){return e&&typeof e=="object"&&e.channel_data?.grix?.toolExecution?!0:/^\[tool\]/.test(d)||/^\[tool result\]/.test(d)}export{q as DEFAULT_QUOTE_TRIGGER_BY_ADAPTER,_ as SendController};
1
+ import{isRequestTimeoutError as E}from"../core/aibot/errors.js";import{log as h}from"../core/log/index.js";import{splitTextForAibotProtocol as _,AIBOT_PROTOCOL_MAX_RUNES as k}from"../core/protocol/protocol-text.js";import{isThinkingEventDropped as C,isToolEventDropped as y,normalizeRuntimeConfigSnapshot as S}from"./runtime-config.js";const v={claude:!1,acp:!1,codex:!0,codewhale:!0,pi:!0,openhuman:!1,cursor:!1,opencode:!0,agy:!0,"deepseek-harness":!1};class g{handle;connectorRuntimeConfig;eventRuntimeConfigs=new Map;eventQuotedMessageIds=new Map;defaultQuoteTrigger=!1;thinkingSeqMap=new Map;thinkingSessionMap=new Map;toolCardSeq=0;bufferedEventTexts=new Map;singleMessageDeliveries=new Map;chunkSeqMap=new Map;openStreams=new Map;rejectedEvents=new Set;terminalEvents=new Set;terminalResultEvents=new Set;static MAX_TERMINAL_EVENTS=1e3;stoppedEvents=new Set;streamCoalesce=new Map;streamCoalesceMs=T();static STREAM_COALESCE_MAX_RUNES=2048;recentToolCards=new Map;static TOOL_CARD_DEDUP_MS=100;constructor(e){this.connectorRuntimeConfig={...e}}bind(e){this.handle=e}markEventRejected(e){if(!e)return;this.rejectedEvents.add(e);const t=`${e}:`;for(const s of[...this.streamCoalesce.keys()])if(s.startsWith(t)){const i=this.streamCoalesce.get(s);i.timer&&clearTimeout(i.timer),this.streamCoalesce.delete(s)}h.info("send-ctrl",`event marked as stream-rejected, future chunks will be dropped event=${e}`)}markEventTerminal(e){if(!(!e||this.terminalEvents.has(e))&&(this.terminalEvents.add(e),this.terminalEvents.size>g.MAX_TERMINAL_EVENTS)){const t=this.terminalEvents.values().next().value;t!==void 0&&this.terminalEvents.delete(t)}}isEventClosed(e){return!!e&&(this.rejectedEvents.has(e)||this.terminalEvents.has(e))}hasTerminalResult(e){return!!e&&(this.terminalResultEvents.has(e)||this.terminalEvents.has(e))}rememberTerminalResult(e){if(e&&(this.terminalResultEvents.add(e),this.terminalResultEvents.size>g.MAX_TERMINAL_EVENTS)){const t=this.terminalResultEvents.values().next().value;t!==void 0&&this.terminalResultEvents.delete(t)}}markEventStopped(e){e&&(this.stoppedEvents.add(e),h.info("send-ctrl",`[stop-trace] event marked stopped; subsequent outbound will be flagged event=${e}`))}flagOutboundAfterStop(e,t,s,i,o,a){!e||!this.stoppedEvents.has(e)||h.warn("send-ctrl",`[stop-trace] OUTBOUND AFTER STOP ${s} event=${e} session=${t} seq=${i} is_finish=${o} len=${a}`)}getGlobalRuntimeConfig(){return this.connectorRuntimeConfig}setStreamCoalesceMs(e){this.streamCoalesceMs=Number.isFinite(e)&&e>0?Math.floor(e):0}captureEventRuntimeConfig(e){this.terminalEvents.delete(e.event_id),this.terminalResultEvents.delete(e.event_id);const t=S(e.connector_runtime_config);if(t||!this.eventRuntimeConfigs.has(e.event_id)){const s=t??this.connectorRuntimeConfig;this.eventRuntimeConfigs.set(e.event_id,{responseDelivery:s.responseDelivery,toolEvents:s.toolEvents,thinkingEvents:s.thinkingEvents}),h.info("send-ctrl",`[runtime-config] event=${e.event_id} response_delivery=${s.responseDelivery} tool_events=${s.toolEvents} thinking_events=${s.thinkingEvents} source=${t?"event":"global"}`)}e.msg_id&&!this.eventQuotedMessageIds.has(e.event_id)&&this.eventQuotedMessageIds.set(e.event_id,e.msg_id)}discardEventState(e){e&&(this.rememberTerminalResult(e),this.markEventTerminal(e),this.clearEventTrackingState(e))}clearEventTrackingState(e){this.eventRuntimeConfigs.delete(e),this.eventQuotedMessageIds.delete(e),this.rejectedEvents.delete(e),this.singleMessageDeliveries.delete(e),this.thinkingSeqMap.delete(e),this.thinkingSessionMap.delete(e),this.bufferedEventTexts.delete(e);for(const t of[...this.streamCoalesce.keys()])if(t.startsWith(`${e}:`)){const s=this.streamCoalesce.get(t);s.timer&&clearTimeout(s.timer),this.streamCoalesce.delete(t)}this.stoppedEvents.delete(e)&&h.info("send-ctrl",`[stop-trace] event tracking cleared stop flag event=${e}`);for(const t of[...this.openStreams.keys()])t.startsWith(`${e}:`)&&this.openStreams.delete(t);for(const t of this.chunkSeqMap.keys())t.startsWith(`${e}:`)&&this.chunkSeqMap.delete(t);for(const t of this.recentToolCards.keys())t.startsWith(`${e}:`)&&this.recentToolCards.delete(t)}setDefaultQuoteTrigger(e){this.defaultQuoteTrigger=e}getDefaultQuotedMessageId(e){if(!(!this.defaultQuoteTrigger||!e))return this.eventQuotedMessageIds.get(e)}resolveEventRuntimeConfig(e){if(e){const t=this.eventRuntimeConfigs.get(e);if(t)return t}return this.connectorRuntimeConfig}shouldDropToolDisplayEvent(e){return y(this.resolveEventRuntimeConfig(e))}shouldDropThinkingDisplayEvent(e){return C(this.resolveEventRuntimeConfig(e))}shouldDropCodexDisplayEvent(e,t){return this.shouldDropToolDisplayEvent(e)?t==="item/completed":!1}shouldDropAcpRawDisplayEvent(e,t){return!!(this.shouldDropToolDisplayEvent(e)&&(t==="tool_use"||t==="tool_result")||this.shouldDropThinkingDisplayEvent(e)&&t==="thinking")}sendReply(e,t,s,i,o,a){if(!a?.skipToolFilter&&this.shouldDropToolDisplayEvent(e)&&$(s,o)||this.isEventClosed(e))return;if(this.flagOutboundAfterStop(e,t,"reply",0,!1,(s||"").length),this.resolveEventRuntimeConfig(e).responseDelivery==="stream"&&!o){const n=_(s),l=e?`reply_stream_${e}`:`reply_stream_${Date.now()}`;if(n.length===0){this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:s||" ",quoted_message_id:i||void 0});return}for(let u=0;u<n.length;u++){const c=u===n.length-1;this.sendStreamChunk(e,t,n[u],u+1,c,l,i)}return}this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:s,quoted_message_id:i||void 0,...o?{extra:o}:{}})}sendStreamChunk(e,t,s,i,o,a,r){if(this.isEventClosed(e))return;if(r=r??this.getDefaultQuotedMessageId(e),this.flagOutboundAfterStop(e,t,"stream_chunk",i,o,(s||"").length),this.resolveEventRuntimeConfig(e).responseDelivery==="single_message"&&e){this.bufferStreamChunk(e,t,s,r,a);return}if(this.streamCoalesceMs<=0){this.emitStreamChunkParts(e,t,s,i,o,a,r);return}this.coalesceStreamChunk(e,t,s,o,a,r)}async sendFinalStreamChunkReliable(e,t,s,i,o=!1,a=!1){if(this.isEventClosed(e))throw new Error(`required agent output was already rejected or the event is terminal: event=${e}`);a||this.finalizeThinking(e,t);const r=this.resolveEventRuntimeConfig(e);if(!o&&r.responseDelivery==="single_message"&&e){if(this.flushCoalesceForEvent(e),await this.ensureSingleMessageDelivery(e,i),this.isEventClosed(e))throw new Error(`required agent output was rejected before terminal: event=${e}`);return}const n=`${e}:${s??"default"}`;this.flushCoalesce(n,!1),this.streamCoalesce.delete(n);const l=(this.chunkSeqMap.get(n)??0)+1;this.chunkSeqMap.set(n,l),this.openStreams.delete(n);const u=this.getDefaultQuotedMessageId(e),c={event_id:e,session_id:t,delta_content:"",chunk_seq:l,is_finish:!0,...s?{client_msg_id:s}:{},...u?{quoted_message_id:u}:{}};this.flagOutboundAfterStop(e,t,"stream_chunk_final",l,!0,0);const p=async()=>{const f=await this.handle.sendStreamChunkRequest(c,i);if(f.cmd!=="send_ack"){const d=f.payload;throw new Error(`final chunk rejected: cmd=${f.cmd} code=${d?.code??""} msg=${d?.msg??""}`)}};try{await p()}catch(f){if(E(f))h.warn("send-ctrl",`final chunk ACK timed out, retry request with same chunk_seq=${l} event=${e}`),await p();else throw h.warn("send-ctrl",`final chunk send failed (no resend) event=${e}: ${f}`),f}if(this.isEventClosed(e))throw new Error(`required agent output was rejected before terminal: event=${e}`)}async deliverAuxiliaryLargeText(e,t,s,i){if(!s||this.isEventClosed(e))return!1;if(this.resolveEventRuntimeConfig(e).responseDelivery==="single_message"&&e){const r=this.getDefaultQuotedMessageId(e),n=_(s);for(let l=0;l<n.length;l++)this.flagOutboundAfterStop(e,t,"aux_detail_msg",l+1,!1,n[l].length),this.handle.sendMsg({event_id:e,session_id:t,msg_type:1,content:n[l],...l===0&&r?{quoted_message_id:r}:{},client_msg_id:n.length>1?`${i}_p${l+1}`:i});return!0}const a=_(s);for(const r of a)this.sendStreamChunk(e,t,r,0,!1,i);return await this.sendFinalStreamChunkReliable(e,t,i,void 0,!1,!0),!0}coalesceStreamChunk(e,t,s,i,o,a){if(!s&&!i)return;const r=`${e}:${o??"default"}`;let n=this.streamCoalesce.get(r);if(n||(n={eventId:e,sessionId:t,clientMsgId:o,quotedMessageId:a,text:"",flushedOnce:!1},this.streamCoalesce.set(r,n)),s&&(n.text+=s),n.sessionId=t,o!==void 0&&(n.clientMsgId=o),!n.quotedMessageId&&a&&(n.quotedMessageId=a),i){this.flushCoalesce(r,!0);return}if(!n.flushedOnce||n.text.length>=g.STREAM_COALESCE_MAX_RUNES){this.flushCoalesce(r,!1);return}n.timer||(n.timer=setTimeout(()=>this.flushCoalesce(r,!1),this.streamCoalesceMs))}flushCoalesce(e,t){const s=this.streamCoalesce.get(e);if(!s)return;s.timer&&(clearTimeout(s.timer),s.timer=void 0);const i=s.text;s.text="",s.flushedOnce=!0,t&&this.streamCoalesce.delete(e),!(!i&&!t)&&this.emitStreamChunkParts(s.eventId,s.sessionId,i,0,t,s.clientMsgId,s.quotedMessageId)}flushCoalesceForEvent(e){const t=`${e}:`;for(const s of[...this.streamCoalesce.keys()])s.startsWith(t)&&(this.flushCoalesce(s,!1),this.streamCoalesce.delete(s))}emitStreamChunkParts(e,t,s,i,o,a,r){const n=s||(o?`
2
+ `:"");if(!n&&!o)return;const l=_(n,k);l.length===0&&l.push(n||`
3
+ `);const u=`${e}:${a??"default"}`;for(let c=0;c<l.length;c++){const p=c===l.length-1,f=this.chunkSeqMap.get(u)??0,d=c===0&&i>f?i:f+1;this.chunkSeqMap.set(u,d),this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:l[c],chunk_seq:d,is_finish:p&&o,...a?{client_msg_id:a}:{},...r?{quoted_message_id:r}:{}})}e&&(o?this.openStreams.delete(u):this.openStreams.set(u,{eventId:e,sessionId:t,clientMsgId:a,quotedMessageId:r}))}finalizeOpenStreams(e){if(!e||this.isEventClosed(e))return;this.finalizeThinking(e,""),this.flushCoalesceForEvent(e);const t=`${e}:`;for(const[s,i]of[...this.openStreams]){if(!s.startsWith(t))continue;this.openStreams.delete(s);const o=(this.chunkSeqMap.get(s)??0)+1;this.chunkSeqMap.set(s,o),this.handle.sendStreamChunk({event_id:i.eventId,session_id:i.sessionId,delta_content:"",chunk_seq:o,is_finish:!0,...i.clientMsgId?{client_msg_id:i.clientMsgId}:{},...i.quotedMessageId?{quoted_message_id:i.quotedMessageId}:{}}),h.info("send-ctrl",`appended missing stream fence before terminal event=${i.eventId} client_msg_id=${i.clientMsgId??"default"} chunk_seq=${o}`)}}sendEventResult(e,t,s,i,o=!1){if(!e||this.hasTerminalResult(e))return;this.rememberTerminalResult(e),this.finalizeThinking(e,""),this.flushCoalesceForEvent(e);const a=this.ensureSingleMessageDelivery(e),r=(n,l=s,u=i)=>{n==="responded"&&this.rejectedEvents.has(e)&&(n="failed",u=u||"agent_output_rejected",l=l||"required agent output was rejected by the server"),this.markEventTerminal(e),this.handle.sendEventResult({event_id:e,status:n,...l?{msg:l}:{},...u?{code:u}:{},...o&&n===t?{error_surfaced:!0}:{},updated_at:Date.now()}),this.clearEventTrackingState(e)};if(!a){r(t);return}a.then(()=>r(t),n=>{const l=`final output delivery failed: ${n instanceof Error?n.message:String(n)}`;r(t==="responded"?"failed":t,t==="responded"?l:s,t==="responded"?"agent_output_delivery_failed":i)})}sendThinking(e,t,s){if(this.shouldDropThinkingDisplayEvent(e)||this.isEventClosed(e))return;this.flagOutboundAfterStop(e,t,"thinking",this.thinkingSeqMap.get(e)??0,!1,(s||"").length);const i=(this.thinkingSeqMap.get(e)??0)+1;this.thinkingSeqMap.set(e,i),this.thinkingSessionMap.set(e,t),this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:s,chunk_seq:i,is_finish:!1,client_msg_id:`${e}_thinking`,is_thinking:!0})}sendToolExecutionCard(e,t,s,i){if(this.shouldDropToolDisplayEvent(e)){h.info("send-ctrl",`[tool-card] DROP event=${e} session=${t} summary=${s.summaryText}`);return}const a=Date.now(),r=`${e}:${s.summaryText}:${s.detailText??""}`,n=this.recentToolCards.get(r);if(n!==void 0&&a-n<g.TOOL_CARD_DEDUP_MS){h.info("send-ctrl",`[tool-card] DEDUP event=${e} session=${t} summary=${s.summaryText}`);return}this.recentToolCards.set(r,a);const l={summary_text:s.summaryText};s.detailText&&(l.detail_text=s.detailText),h.info("send-ctrl",`[tool-card] SEND event=${e} session=${t} summary=${s.summaryText}`),this.handle.sendMsg({event_id:e,session_id:t,client_msg_id:i??`tool_${++this.toolCardSeq}_${Date.now()}`,msg_type:1,content:"",extra:{channel_data:{grix:{toolExecution:l}},agent_api_origin:!0}})}finalizeThinking(e,t){const s=this.thinkingSeqMap.get(e);s!==void 0&&(this.thinkingSeqMap.delete(e),t=t||this.thinkingSessionMap.get(e)||"",this.thinkingSessionMap.delete(e),t&&(this.isEventClosed(e)||this.handle.sendStreamChunk({event_id:e,session_id:t,delta_content:"",chunk_seq:s+1,is_finish:!0,client_msg_id:`${e}_thinking`,is_thinking:!0})))}bufferOnly(e,t,s,i,o){o=o??this.getDefaultQuotedMessageId(e),this.bufferStreamChunk(e,t,s,o)}bufferStreamChunk(e,t,s,i,o){const a=typeof o=="string"&&o.startsWith("reply_"),r=this.bufferedEventTexts.get(e);if(!r){this.bufferedEventTexts.set(e,{sessionId:t,text:s??"",quotedMessageId:i||void 0,replyReceived:a});return}a?r.replyReceived?s&&(r.text+=s):(r.text=s??"",r.replyReceived=!0):!r.replyReceived&&s&&(r.text+=s),!r.quotedMessageId&&i&&(r.quotedMessageId=i)}ensureSingleMessageDelivery(e,t){const s=this.singleMessageDeliveries.get(e);if(s)return s;const i=this.bufferedEventTexts.get(e);if(!i||(this.bufferedEventTexts.delete(e),!i.text))return;const o=(async()=>{const a={event_id:e,session_id:i.sessionId,content:i.text,client_msg_id:`agent_single_${e}`,...i.quotedMessageId?{quoted_message_id:i.quotedMessageId}:{}},r=async()=>{const n=t===void 0?await this.handle.sendText(a):await this.handle.sendText(a,t);if(n.cmd!=="send_ack"){const l=n.payload;throw new Error(`single-message output rejected: cmd=${n.cmd} code=${l?.code??""} msg=${l?.msg??""}`)}};try{await r()}catch(n){if(!E(n))throw n;h.warn("send-ctrl",`single-message ACK timed out, retry request with stable client_msg_id=${a.client_msg_id} event=${e}`),await r()}})();return this.singleMessageDeliveries.set(e,o),o}}function T(){const m=process.env.GRIX_STREAM_COALESCE_MS;if(m===void 0||m==="")return 500;const e=Number(m);return Number.isFinite(e)&&e>=0?Math.floor(e):500}function $(m,e){return e&&typeof e=="object"&&e.channel_data?.grix?.toolExecution?!0:/^\[tool\]/.test(m)||/^\[tool result\]/.test(m)}export{v as DEFAULT_QUOTE_TRIGGER_BY_ADAPTER,g as SendController};
@@ -0,0 +1,339 @@
1
+ # Connecting your own agent to Grix over ACP
2
+
3
+ This guide is for developers who have an agent that speaks the
4
+ [Agent Client Protocol](https://agentclientprotocol.com) (ACP) over stdio
5
+ JSON-RPC and want it to appear in Grix as a chat participant — no Grix code
6
+ change, no Grix release.
7
+
8
+ It covers the whole surface: how to connect, how your output reaches the user,
9
+ how to ask the user a question and get the answer back, what the toolbar can
10
+ show, and how to render rich cards. Every claim below is the behavior of the
11
+ code in this repository at the time of writing; where something is *not*
12
+ supported, this guide says so instead of leaving you to discover it.
13
+
14
+ ---
15
+
16
+ ## 1. Connect
17
+
18
+ Add one entry to the grix-connector agent config and restart the connector.
19
+
20
+ ```json
21
+ {
22
+ "name": "my-acp-agent",
23
+ "ws_url": "wss://ws.grix.im/v1/agent-api/ws",
24
+ "agent_id": "<agent-id from the Grix app>",
25
+ "api_key": "<api key from the Grix app>",
26
+ "client_type": "acp",
27
+ "command": "my-agent",
28
+ "args": ["--acp"],
29
+ "cwd": "/srv/my-agent"
30
+ }
31
+ ```
32
+
33
+ | Field | Required | Meaning |
34
+ | --- | --- | --- |
35
+ | `client_type` | yes | Must be `"acp"`. This is the vendor-neutral type; it is what makes the rest of this guide apply. |
36
+ | `command` | yes | The executable the connector spawns. Nothing is injected for you. |
37
+ | `args` | no | Passed verbatim. Put whatever flag starts your ACP mode here. |
38
+ | `cwd` | no | The fixed working directory. See below. |
39
+ | `env` | no | Extra environment for the spawned process. |
40
+ | `acp_new_session_params` | no | Extra fields merged into your `session/new` params, for arguments the protocol does not define. |
41
+
42
+ ### Working directory
43
+
44
+ ACP's `session/new` requires an absolute `cwd`, but Grix never asks the owner
45
+ to pick one for a generic ACP agent. The connector supplies it from `cwd` in
46
+ the config, falling back to the home directory. It is fixed:
47
+
48
+ - `/grix open` and `/grix unbind` are rejected in chat.
49
+ - The toolbar has no binding entry.
50
+ - To move the agent, edit `cwd` and restart the connector.
51
+ - A `cwd` that does not exist is logged as an error and falls back to the home
52
+ directory, so a typo cannot strand the agent.
53
+
54
+ Every chat session still gets its own ACP session (its own `session/new`); they
55
+ simply all share the same directory.
56
+
57
+ ### What you get without writing anything
58
+
59
+ - **Grix MCP server**, injected into your `session/new` `mcpServers` list. Your
60
+ agent can call the Grix platform directly — see §6.
61
+ - **Skills**: discovery falls back to the Gemini layout, so the connector reads
62
+ `~/.gemini/skills`. This is deliberate and will not be changed; an agent that
63
+ needs its own skill directory has to be registered as its own `client_type`.
64
+ - **Streaming**: your `session/update` text chunks are forwarded incrementally
65
+ and the client shows them as they arrive.
66
+ - **Stop**: the toolbar's stop button calls ACP `session/cancel`.
67
+
68
+ ---
69
+
70
+ ## 2. Talking to the user
71
+
72
+ Plain agent output is the normal path. Emit `session/update` notifications with
73
+ `agent_message_chunk` and the text appears in the chat, streamed.
74
+
75
+ A user message arrives as an ACP `session/prompt` call. That is the whole
76
+ request/response loop; you do not implement any Grix protocol.
77
+
78
+ ---
79
+
80
+ ## 3. Asking the user a question
81
+
82
+ **This is the part most people look for, so read it carefully: you ask a
83
+ question through ACP's permission channel, not through a Grix API.**
84
+
85
+ Send `session/request_permission` shaped as a question and the connector turns
86
+ it into a tappable choice card in chat. The user's tap comes back as the return
87
+ value of that same `session/request_permission` call — your turn simply resumes.
88
+
89
+ The connector recognizes a permission request as a question when **all** of the
90
+ following hold:
91
+
92
+ 1. The tool name or tool title, lowercased with non-letters stripped, contains
93
+ `askuserquestion`. In practice: name your tool `AskUserQuestion`.
94
+ 2. `toolCall.content` carries non-empty text. This becomes the prompt shown to
95
+ the user.
96
+ 3. At least one option has a `kind` **outside** the standard permission
97
+ vocabulary `allow_once`, `allow_always`, `reject_once`, `reject_always`.
98
+ A request whose options are entirely standard kinds is treated as an
99
+ ordinary permission gate (§4), not a question — two options alone do not
100
+ distinguish them, since `allow_once` + `reject_once` is also two options.
101
+
102
+ ```jsonc
103
+ // session/request_permission params
104
+ {
105
+ "sessionId": "...",
106
+ "toolCall": {
107
+ "toolCallId": "q-1",
108
+ "title": "AskUserQuestion",
109
+ "content": [{ "type": "content", "content": { "type": "text", "text": "Which environment should I deploy to?" } }]
110
+ },
111
+ "options": [
112
+ { "optionId": "staging", "name": "Staging", "kind": "answer" },
113
+ { "optionId": "production", "name": "Production", "kind": "answer" }
114
+ ]
115
+ }
116
+ ```
117
+
118
+ The user sees a card with "Staging" and "Production". Tapping one resolves the
119
+ call with that `optionId`.
120
+
121
+ Notes that will save you time:
122
+
123
+ - **Free text depends on a dismissal option.** When the request carries a
124
+ kimi-style `q{n}_skip` option, the card declares `allow_free_text` and shows
125
+ a text input; an answer matching no option dismisses the question via that
126
+ skip option and is then delivered to the agent as a follow-up
127
+ `session/prompt`. Without a `q{n}_skip` option the answer has to match one
128
+ of your options by `name` or `optionId`; anything else is rejected with
129
+ *"please tap one of the options on the card"* and the question stays open.
130
+ - An option whose `optionId` ends in `_skip`, or whose `kind` contains
131
+ `reject`, is treated as the cancel path rather than an answer.
132
+ - The question does not pause the idle watchdog. If nobody answers, the turn
133
+ fails after the 15-minute inactivity cap rather than hanging forever.
134
+ - One question at a time per request id; the card is resolved once.
135
+
136
+ ---
137
+
138
+ ## 4. Permission gates
139
+
140
+ An ordinary `session/request_permission` — options drawn from `allow_once`,
141
+ `allow_always`, `reject_once`, `reject_always` — renders as an approval card
142
+ showing the tool name and its input. Approve/reject comes back as the return
143
+ value of your call. Nothing to implement.
144
+
145
+ If the agent is configured in `yolo` or `autoEdit` approval mode, the connector
146
+ auto-approves and never shows a card — including for question-shaped requests,
147
+ because those modes mean "never stop for an interactive prompt".
148
+
149
+ ---
150
+
151
+ ## 5. The toolbar
152
+
153
+ The toolbar is the row of controls above the chat input. For `client_type:
154
+ "acp"` it is deliberately minimal, and **it is empty — and therefore hidden —
155
+ while the agent is idle.**
156
+
157
+ What can appear:
158
+
159
+ | Control | Where it comes from | What the user's choice does |
160
+ | --- | --- | --- |
161
+ | Stop | Shown while a turn is running | ACP `session/cancel` |
162
+ | Mode selector | The `modes` your `session/new` response reports | ACP `session/set_mode` with the chosen mode id |
163
+ | Model selector | The `models` your `session/new` response reports | ACP `session/set_model` with the chosen model id |
164
+ | Skills | Skills discovered on disk | Skill enable/disable |
165
+ | Browse files, queue | Added automatically whenever the toolbar is visible | Handled by the connector |
166
+
167
+ **So the toolbar already supports one kind of multiple-choice control, and it
168
+ costs you nothing:** report `modes` and/or `models` in your `session/new`
169
+ response and implement `session/set_mode` / `session/set_model`. The
170
+ selector renders itself, remembers the current value, and calls you back. If
171
+ you report neither, no selector is rendered — Grix will not hang a control on
172
+ your agent that it cannot honor.
173
+
174
+ ### Your own toolbar controls
175
+
176
+ Beyond modes and models you can declare **your own** controls with the
177
+ `grix_toolbar_set` MCP tool (§6). Two kinds, deliberately:
178
+
179
+ ```jsonc
180
+ grix_toolbar_set({
181
+ items: [
182
+ { id: "env", kind: "select", label: "Environment",
183
+ value: "staging",
184
+ options: [ {id: "staging", label: "Staging"}, {id: "prod", label: "Production"} ] },
185
+ { id: "howto", kind: "info", label: "How to use",
186
+ info_title: "Deploy flow",
187
+ info_text: "Pick an environment, then just say \"deploy\" — I run a dry-run first." }
188
+ ]
189
+ })
190
+ ```
191
+
192
+ - **`select`** renders a dropdown. When the user picks an option, you receive a
193
+ message whose content is exactly
194
+ `grix://toolbar/select?item=<item id>&option=<option id>` — an ordinary
195
+ `session/prompt` turn, so your reply streams back into the chat as usual.
196
+ It is delivered only while you are idle (see the limits below), and it does
197
+ **not** appear in the chat as a user message.
198
+ - **`info`** renders a button that shows `info_text` in a dialog when tapped.
199
+ It is entirely client-side: nothing is sent anywhere, and you never hear
200
+ about the tap. Use it for a usage hint or a legend.
201
+
202
+ Call `grix_toolbar_set` again with the full desired list to change the toolbar,
203
+ or with `items: []` to remove your controls. The declaration is per chat
204
+ session, and the tool defaults `session_id` to the session your ACP session is
205
+ bound to.
206
+
207
+ **Limits, all enforced. A payload that breaks any of them is rejected whole —
208
+ you will not get a half-rendered toolbar:**
209
+
210
+ | Rule | Limit |
211
+ | --- | --- |
212
+ | Items per session | 6 |
213
+ | `id` (items and options) | `[a-z0-9_]+`, max 32 chars, unique |
214
+ | `label` | 1–20 characters |
215
+ | Options per `select` | 1–20 |
216
+ | Option `label` | 1–40 characters |
217
+ | `info_text` | 1–500 characters, plain text |
218
+ | `value` | must be one of that item's option ids |
219
+
220
+ Two more behaviors worth knowing:
221
+
222
+ - Your ids are namespaced (`env` becomes `custom_env`), so you cannot collide
223
+ with or displace a built-in control such as stop.
224
+ - A `select` is **disabled while a turn is running**, because an ACP session
225
+ runs one turn at a time. Choices arrive between turns.
226
+
227
+ Anything beyond a dropdown and an info button — free text input, a button that
228
+ runs an action, a progress bar — is not available. Ask through a question card
229
+ (§3) instead.
230
+
231
+ ## 6. Calling the Grix platform from your agent
232
+
233
+ The connector injects a Grix MCP server into your ACP session by default, so
234
+ your agent sees these as ordinary MCP tools. The ones you are most likely to
235
+ want:
236
+
237
+ | Tool | Use |
238
+ | --- | --- |
239
+ | `grix_message_send` | Send a message into any session the owner can see (max 10 000 chars). |
240
+ | `grix_reply` | Send the final answer to the session you are handling (max 50 000 chars). |
241
+ | `grix_session_send` | Speak as the owner in a session. |
242
+ | `grix_file_upload` / `grix_file_link` | Hand the user a file, as an attachment or a link. |
243
+ | `grix_query` | Look up contacts, sessions, message history. |
244
+ | `grix_call_owner` | Pull the owner into the session for a voice talk or approval. |
245
+ | `grix_dispatch_agent` | Hand work to one of the owner's other agents. |
246
+ | `grix_composing` | Show the typing indicator during long work. |
247
+ | `grix_toolbar_set` | Declare your own toolbar controls (§5). `client_type: "acp"` only; other client types are rejected with an explicit error rather than silently ignored. |
248
+ | `grix_webhook_create` / `grix_webhook_list` / `grix_webhook_delete` | Give yourself a standing HTTP trigger. |
249
+
250
+ Do not use `grix_message_send` to answer the message you are currently
251
+ handling — answer through your normal ACP output, or the user gets the reply
252
+ twice.
253
+
254
+ Set `"acp_mcp_tools": false` in `adapterOptions` if you want the session
255
+ without them.
256
+
257
+ ---
258
+
259
+ ## 7. Cards
260
+
261
+ Cards are the rich message blocks in the Grix client — approval prompts,
262
+ question forms, status blocks. The wire format is a markdown link whose target
263
+ is a `grix://card/` URI:
264
+
265
+ ```
266
+ [fallback text](grix://card/{type}?d={url-encoded json payload})
267
+ ```
268
+
269
+ The client parses this **strictly and only when the whole message is exactly
270
+ that one link** — no surrounding prose, no second paragraph. If it does not
271
+ match, the user sees the raw text. So send a card as its own message, most
272
+ easily with `grix_message_send`.
273
+
274
+ Types the client renders: `agent_question`, `agent_status`, `agent_pairing`,
275
+ `exec_approval`, `exec_status`, `tool_execution`, `tool_execution_group`,
276
+ `user_profile`, `conversation`, `egg_install_status`, `call_owner`, `thinking`,
277
+ `progress`, `agent_open_session`.
278
+
279
+ `agent_status` is the useful one to hand-build — a titled status block with no
280
+ interaction:
281
+
282
+ ```jsonc
283
+ // grix_message_send content:
284
+ // [Deploy](grix://card/agent_status?d=<urlencoded>)
285
+ {
286
+ "category": "deploy", // required
287
+ "status": "success", // required; success | warning | error style the card, anything else renders neutral
288
+ "summary": "Deployed to staging", // required
289
+ "detail_text": "7 services updated"
290
+ }
291
+ ```
292
+
293
+ ### Hand-built cards are display-only
294
+
295
+ Emit whichever of those types you like — but only the non-interactive ones
296
+ actually work end to end. `agent_question` renders if you build it by hand, yet
297
+ its answer will not reach you: the reply comes back carrying your `request_id`,
298
+ and the connector only honours a `request_id` it issued itself from a
299
+ `session/request_permission`. A hand-built id is unknown to it, so the user
300
+ gets *"The question is no longer pending"* and your agent hears nothing.
301
+
302
+ This is by design — interaction has exactly one supported route so there is
303
+ only one thing to get right. **Ask through §3, offer standing choices through
304
+ the toolbar (§5), and keep hand-built cards to display types like
305
+ `agent_status`.**
306
+
307
+ ---
308
+
309
+ ## 8. Limits of the generic entry
310
+
311
+ These are deliberate. An agent that needs any of them must be registered as its
312
+ own `client_type`, which is a Grix code change and release:
313
+
314
+ - Its own skill directory (the generic entry shares `~/.gemini/skills`).
315
+ - Session usage / token accounting (`get_session_usage` is not declared —
316
+ there is no parser for an unknown CLI's usage output).
317
+ - A quota card, context-window readout, or built-in slash command list. (A
318
+ toolbar you control yourself is available — see §5 — but a *branded* toolbar
319
+ with bespoke controls is not.)
320
+ - A branded installation guide in the app.
321
+ - Session history scanning (the session list button is not offered, because
322
+ scanning needs a known CLI's on-disk history layout).
323
+
324
+ ---
325
+
326
+ ## 9. Checklist
327
+
328
+ - [ ] Agent speaks ACP over stdio JSON-RPC and implements `session/new`,
329
+ `session/prompt`, `session/cancel`.
330
+ - [ ] Config entry added with `client_type: "acp"`, `command`, and a `cwd` that
331
+ exists.
332
+ - [ ] `session/new` reports `modes` / `models` if you want toolbar selectors,
333
+ with `session/set_mode` / `session/set_model` implemented.
334
+ - [ ] Your own toolbar controls, if any, declared with `grix_toolbar_set`, and
335
+ `grix://toolbar/select?...` prompts handled.
336
+ - [ ] Questions asked through `session/request_permission` with an
337
+ `AskUserQuestion`-named tool, non-empty `toolCall.content`, and at least
338
+ one option with a non-standard `kind`.
339
+ - [ ] Nothing depends on the owner choosing a working directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grix-connector",
3
- "version": "4.12.0",
3
+ "version": "4.12.2",
4
4
  "description": "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.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "NOTICE",
12
12
  "LICENSE",
13
+ "docs/guide-acp-agent-integration.md",
13
14
  "dist",
14
15
  "!dist/**/*.map",
15
16
  "!dist/**/*.d.ts",