@automagik/genie 4.260406.5 → 4.260407.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +21 -14
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/services/__tests__/omni-bridge.test.ts +306 -0
- package/src/services/executors/__tests__/claude-sdk.test.ts +244 -4
- package/src/services/executors/claude-sdk.ts +107 -1
- package/src/services/executors/turn-based-prompt.ts +18 -11
- package/src/services/omni-bridge.ts +121 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "genie",
|
|
13
|
-
"version": "4.
|
|
13
|
+
"version": "4.260407.1",
|
|
14
14
|
"source": "./plugins/genie",
|
|
15
15
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project."
|
|
16
16
|
}
|
package/dist/genie.js
CHANGED
|
@@ -2007,30 +2007,37 @@ ${answer}
|
|
|
2007
2007
|
You are responding to a WhatsApp message from ${senderName}.
|
|
2008
2008
|
Your context is pre-set (instance: ${instanceId}, chat: ${chatId}) \u2014 do NOT use \`omni use\` or \`omni open\`.
|
|
2009
2009
|
|
|
2010
|
-
##
|
|
2010
|
+
## Reply Channels
|
|
2011
2011
|
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2012
|
+
You have two equivalent ways to send a reply to the user:
|
|
2013
|
+
|
|
2014
|
+
1. **SendMessage** (preferred): \`SendMessage(recipient: "omni", message: "your reply")\` \u2014
|
|
2015
|
+
intercepted by the omni bridge and delivered as a WhatsApp text message.
|
|
2016
|
+
You may call SendMessage multiple times in one turn for multi-message replies.
|
|
2017
|
+
2. **omni done text='...'** \u2014 closes the turn AND sends a final text in one call.
|
|
2018
|
+
|
|
2019
|
+
## Available Tools
|
|
2020
|
+
|
|
2021
|
+
- SendMessage(recipient: "omni", message: '...') \u2014 send a text reply (repeatable)
|
|
2022
|
+
- omni done text='...' \u2014 send final text + close turn (use as the LAST action)
|
|
2023
|
+
- omni done react='emoji' \u2014 react instead of replying, then close turn
|
|
2024
|
+
- omni done media='/path' caption='...' \u2014 send media + close turn
|
|
2025
|
+
- omni done skip=true \u2014 close turn silently
|
|
2018
2026
|
|
|
2019
2027
|
## Rules
|
|
2020
2028
|
|
|
2021
|
-
1. Use \`omni
|
|
2022
|
-
2.
|
|
2023
|
-
3.
|
|
2024
|
-
4. Do NOT generate bare text as your reply \u2014 it will go nowhere. Use omni say or omni done.
|
|
2029
|
+
1. Use \`SendMessage(recipient: "omni", ...)\` for normal text replies.
|
|
2030
|
+
2. ALWAYS call \`omni done\` as your LAST action to close the turn \u2014 even if you already sent SendMessage replies, call \`omni done skip=true\`.
|
|
2031
|
+
3. Do NOT generate bare text as your reply \u2014 it will go nowhere. Use SendMessage or omni done.
|
|
2025
2032
|
`.trim()}async function loadSystemPrompt(entry){let identityPath=loadIdentity(entry);if(!identityPath)return;let{readFileSync:readFileSync22}=await import("fs");try{return readFileSync22(identityPath,"utf-8")}catch{return}}async function resolveSystemPrompt(entry,state,message,chatId){let prompt2=await loadSystemPrompt(entry),isTurnBased=Boolean(state.env.OMNI_INSTANCE);if(isTurnBased){let turnPrompt=buildTurnBasedPrompt(message.sender,state.env.OMNI_INSTANCE,state.env.OMNI_CHAT??chatId);prompt2=prompt2?`${turnPrompt}
|
|
2026
2033
|
|
|
2027
2034
|
${prompt2}`:turnPrompt}return{prompt:prompt2,isTurnBased}}function extractTextFromAssistant(msg){if(!msg.message)return[];return msg.message.content.filter((b2)=>b2.type==="text"&&b2.text).map((b2)=>b2.text)}async function collectQueryResult(queryMessages){let textParts=[],sessionId;try{for await(let msg of queryMessages){if(msg.type==="assistant")textParts.push(...extractTextFromAssistant(msg));if(msg.type==="result"&&msg.subtype==="success")sessionId=msg.session_id}}catch(err){if(err.name==="AbortError")return{text:""};throw err}return{text:textParts.join(`
|
|
2028
|
-
`).trim(),sessionId}}function buildReplyPayload(agent,chatId,instanceId,extra={}){return JSON.stringify({content:"",agent,chat_id:chatId,instance_id:instanceId,timestamp:new Date().toISOString(),...extra})}function resolveAction(params,env){if(params.skip)return{type:"skip",extra:{reason:params.reason},label:"Turn closed (skip)."};if(params.react)return{type:"react",extra:{react:String(params.react),message_id:env.OMNI_MESSAGE??""},label:`Reacted ${params.react} + turn closed.`};if(params.media)return{type:"media",extra:{content:params.caption?String(params.caption):params.text?String(params.text):"",media:String(params.media)},label:"Media sent + turn closed."};if(params.text)return{type:"text",extra:{content:String(params.text)},label:"Turn closed. Message delivered."};return{type:"skip",extra:{},label:"Turn closed (skip)."}}function handleDoneTool(params,env,natsPublish){let instanceId=env.OMNI_INSTANCE??"",chatId=env.OMNI_CHAT??"",agent=env.OMNI_AGENT??"",action=resolveAction(params,env);if(action.type==="skip"){if(natsPublish&&instanceId&&chatId)natsPublish(`omni.turn.done.${instanceId}.${chatId}`,JSON.stringify({action:"skip",...action.extra}));return action.label}if(!natsPublish||!instanceId||!chatId)return console.warn("[claude-sdk] No NATS publish available \u2014 reply dropped"),"Turn close attempted but NATS publish not available.";return natsPublish(`omni.reply.${instanceId}.${chatId}`,buildReplyPayload(agent,chatId,instanceId,action.extra)),action.label}async function createDoneMcpServer(env,natsPublish){let{createSdkMcpServer,tool}=await import("@anthropic-ai/claude-agent-sdk");return createSdkMcpServer({name:"genie-omni-tools",tools:[tool("done","Close this turn. REQUIRED after processing the user message. Sends a final response, reacts, or skips. Call exactly once per turn.",{text:exports_external.string().optional().describe("Final message to the user"),media:exports_external.string().optional().describe("File path for media attachment"),caption:exports_external.string().optional().describe("Caption for media"),react:exports_external.string().optional().describe("Emoji reaction (instead of text)"),skip:exports_external.boolean().optional().describe("Close turn without sending anything"),reason:exports_external.string().optional().describe("Internal reason for skipping")},async(args)=>{return{content:[{type:"text",text:handleDoneTool(args,env,natsPublish)}]}})]})}class ClaudeSdkOmniExecutor{sessions=new Map;safePgCall=null;natsPublish=null;deliveryQueues=new Map;pendingNudges=new Map;setSafePgCall(fn){this.safePgCall=fn}setNatsPublish(fn){this.natsPublish=fn}async injectNudge(session,text){if(!this.sessions.has(session.id))return;this.pendingNudges.set(session.id,text)}async spawn(agentName,chatId,env){if(!await resolve3(agentName))throw Error(`Agent "${agentName}" not found in genie directory`);let provider=new ClaudeSdkProvider,abortController=new AbortController,sessionId=`${agentName}:${chatId}`,registration=await this.registerInWorldA(agentName,chatId,env.OMNI_INSTANCE??"");if(this.sessions.set(sessionId,{abortController,running:!0,provider,executorId:registration?.executorId??null,claudeSessionId:registration?.claudeSessionId,dbSessionId:null,turnIndex:0,env}),registration?.executorId)await this.updateState(registration.executorId,"running",chatId);let now=Date.now();return{id:sessionId,agentName,chatId,tmuxSession:"",tmuxWindow:"",paneId:`sdk-${chatId}`,createdAt:now,lastActivityAt:now}}async registerInWorldA(agentName,chatId,instanceId){if(!this.safePgCall)return null;let agent=await this.safePgCall("sdk-find-or-create-agent",()=>findOrCreateAgent(agentName,"omni","omni"),null,{chatId});if(!agent)return null;let existing=await this.safePgCall("sdk-find-existing-executor",()=>findLatestByMetadata({agentId:agent.id,source:"omni",chatId}),null,{chatId});if(existing)return await this.safePgCall("sdk-relink-executor",()=>relinkExecutorToAgent(existing.id,agent.id),void 0,{executorId:existing.id,chatId}),await recordAuditEvent2(this.safePgCall,"session.resumed",{executor_id:existing.id,agent_id:agentName,chat_id:chatId,claude_session_id:existing.claudeSessionId}),{executorId:existing.id,claudeSessionId:existing.claudeSessionId??void 0};let executor=await this.safePgCall("sdk-create-executor",()=>createAndLinkExecutor(agent.id,"claude","api",{claudeSessionId:void 0,metadata:{source:"omni",chat_id:chatId,instance_id:instanceId}}),null,{chatId});if(executor)await recordAuditEvent2(this.safePgCall,"session.created_fresh",{executor_id:executor.id,agent_id:agentName,chat_id:chatId});return executor?{executorId:executor.id}:null}async updateState(executorId,state,chatId){if(!this.safePgCall)return;await this.safePgCall("sdk-update-executor-state",()=>updateExecutorState(executorId,state),void 0,{executorId,chatId})}async deliver(session,message){let state=this.sessions.get(session.id);if(!state)throw Error(`No SDK session found for ${session.id}`);let current=(this.deliveryQueues.get(session.id)??Promise.resolve()).then(()=>this._processDelivery(session,state,message));this.deliveryQueues.set(session.id,current.catch(()=>{}))}async _processDelivery(session,state,message){let resolved=await resolve3(session.agentName);if(!resolved)throw Error(`Agent "${session.agentName}" not found in genie directory`);let entry=resolved.entry,permissionConfig=resolvePermissionConfig(entry.permissions),{prompt:systemPrompt,isTurnBased}=await resolveSystemPrompt(entry,state,message,session.chatId);if(state.executorId)await this.updateState(state.executorId,"working",session.chatId);if(this.safePgCall)await recordAuditEvent2(this.safePgCall,"deliver.start",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:message.chatId,instance_id:message.instanceId});let doneMcp=await createDoneMcpServer(state.env,this.natsPublish),extraOptions={abortController:state.abortController,mcpServers:{"genie-omni-tools":doneMcp}};if(state.claudeSessionId)extraOptions.resume=state.claudeSessionId;let queryContent=message.content,pendingNudge=this.pendingNudges.get(session.id);if(pendingNudge)queryContent=`[system] ${pendingNudge}
|
|
2035
|
+
`).trim(),sessionId}}function buildReplyPayload(agent,chatId,instanceId,extra={}){return JSON.stringify({content:"",agent,chat_id:chatId,instance_id:instanceId,timestamp:new Date().toISOString(),...extra})}function resolveAction(params,env){if(params.skip)return{type:"skip",extra:{reason:params.reason},label:"Turn closed (skip)."};if(params.react)return{type:"react",extra:{react:String(params.react),message_id:env.OMNI_MESSAGE??""},label:`Reacted ${params.react} + turn closed.`};if(params.media)return{type:"media",extra:{content:params.caption?String(params.caption):params.text?String(params.text):"",media:String(params.media)},label:"Media sent + turn closed."};if(params.text)return{type:"text",extra:{content:String(params.text)},label:"Turn closed. Message delivered."};return{type:"skip",extra:{},label:"Turn closed (skip)."}}function handleDoneTool(params,env,natsPublish){let instanceId=env.OMNI_INSTANCE??"",chatId=env.OMNI_CHAT??"",agent=env.OMNI_AGENT??"",action=resolveAction(params,env);if(action.type==="skip"){if(natsPublish&&instanceId&&chatId)natsPublish(`omni.turn.done.${instanceId}.${chatId}`,JSON.stringify({action:"skip",...action.extra}));return action.label}if(!natsPublish||!instanceId||!chatId)return console.warn("[claude-sdk] No NATS publish available \u2014 reply dropped"),"Turn close attempted but NATS publish not available.";return natsPublish(`omni.reply.${instanceId}.${chatId}`,buildReplyPayload(agent,chatId,instanceId,action.extra)),action.label}function parseSendMessageInput(input){if(!input||typeof input!=="object")return{};let obj=input,recipient=typeof obj.recipient==="string"?obj.recipient:typeof obj.to==="string"?obj.to:void 0,body=typeof obj.message==="string"?obj.message:typeof obj.content==="string"?obj.content:void 0;return{recipient,body}}function createSendMessageOmniHook(env,natsPublish){return async(input)=>{let hookInput=input;if(hookInput.tool_name!=="SendMessage")return{};let{recipient,body}=parseSendMessageInput(hookInput.tool_input);if(recipient!=="omni")return{};let instanceId=env.OMNI_INSTANCE??"",chatId=env.OMNI_CHAT??"",agent=env.OMNI_AGENT??"";if(!instanceId||!chatId)return{};if(!body||body.trim()==="")return{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:'SendMessage(recipient: "omni") requires a non-empty `message` field. Retry with the reply text.'}};if(!natsPublish)return console.warn("[claude-sdk] SendMessage(to: omni) intercepted but NATS publish unavailable \u2014 message dropped"),{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Omni bridge unavailable \u2014 message could not be delivered."}};return natsPublish(`omni.reply.${instanceId}.${chatId}`,buildReplyPayload(agent,chatId,instanceId,{content:body})),{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Message delivered to user via omni bridge."}}}}async function createDoneMcpServer(env,natsPublish){let{createSdkMcpServer,tool}=await import("@anthropic-ai/claude-agent-sdk");return createSdkMcpServer({name:"genie-omni-tools",tools:[tool("done","Close this turn. REQUIRED after processing the user message. Sends a final response, reacts, or skips. Call exactly once per turn.",{text:exports_external.string().optional().describe("Final message to the user"),media:exports_external.string().optional().describe("File path for media attachment"),caption:exports_external.string().optional().describe("Caption for media"),react:exports_external.string().optional().describe("Emoji reaction (instead of text)"),skip:exports_external.boolean().optional().describe("Close turn without sending anything"),reason:exports_external.string().optional().describe("Internal reason for skipping")},async(args)=>{return{content:[{type:"text",text:handleDoneTool(args,env,natsPublish)}]}})]})}class ClaudeSdkOmniExecutor{sessions=new Map;safePgCall=null;natsPublish=null;deliveryQueues=new Map;pendingNudges=new Map;setSafePgCall(fn){this.safePgCall=fn}setNatsPublish(fn){this.natsPublish=fn}async injectNudge(session,text){if(!this.sessions.has(session.id))return;this.pendingNudges.set(session.id,text)}async spawn(agentName,chatId,env){if(!await resolve3(agentName))throw Error(`Agent "${agentName}" not found in genie directory`);let provider=new ClaudeSdkProvider,abortController=new AbortController,sessionId=`${agentName}:${chatId}`,registration=await this.registerInWorldA(agentName,chatId,env.OMNI_INSTANCE??"");if(this.sessions.set(sessionId,{abortController,running:!0,provider,executorId:registration?.executorId??null,claudeSessionId:registration?.claudeSessionId,dbSessionId:null,turnIndex:0,env}),registration?.executorId)await this.updateState(registration.executorId,"running",chatId);let now=Date.now();return{id:sessionId,agentName,chatId,tmuxSession:"",tmuxWindow:"",paneId:`sdk-${chatId}`,createdAt:now,lastActivityAt:now}}async registerInWorldA(agentName,chatId,instanceId){if(!this.safePgCall)return null;let agent=await this.safePgCall("sdk-find-or-create-agent",()=>findOrCreateAgent(agentName,"omni","omni"),null,{chatId});if(!agent)return null;let existing=await this.safePgCall("sdk-find-existing-executor",()=>findLatestByMetadata({agentId:agent.id,source:"omni",chatId}),null,{chatId});if(existing)return await this.safePgCall("sdk-relink-executor",()=>relinkExecutorToAgent(existing.id,agent.id),void 0,{executorId:existing.id,chatId}),await recordAuditEvent2(this.safePgCall,"session.resumed",{executor_id:existing.id,agent_id:agentName,chat_id:chatId,claude_session_id:existing.claudeSessionId}),{executorId:existing.id,claudeSessionId:existing.claudeSessionId??void 0};let executor=await this.safePgCall("sdk-create-executor",()=>createAndLinkExecutor(agent.id,"claude","api",{claudeSessionId:void 0,metadata:{source:"omni",chat_id:chatId,instance_id:instanceId}}),null,{chatId});if(executor)await recordAuditEvent2(this.safePgCall,"session.created_fresh",{executor_id:executor.id,agent_id:agentName,chat_id:chatId});return executor?{executorId:executor.id}:null}async updateState(executorId,state,chatId){if(!this.safePgCall)return;await this.safePgCall("sdk-update-executor-state",()=>updateExecutorState(executorId,state),void 0,{executorId,chatId})}async deliver(session,message){let state=this.sessions.get(session.id);if(!state)throw Error(`No SDK session found for ${session.id}`);let current=(this.deliveryQueues.get(session.id)??Promise.resolve()).then(()=>this._processDelivery(session,state,message));this.deliveryQueues.set(session.id,current.catch(()=>{}))}async _processDelivery(session,state,message){let resolved=await resolve3(session.agentName);if(!resolved)throw Error(`Agent "${session.agentName}" not found in genie directory`);let entry=resolved.entry,permissionConfig=resolvePermissionConfig(entry.permissions),{prompt:systemPrompt,isTurnBased}=await resolveSystemPrompt(entry,state,message,session.chatId);if(state.executorId)await this.updateState(state.executorId,"working",session.chatId);if(this.safePgCall)await recordAuditEvent2(this.safePgCall,"deliver.start",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:message.chatId,instance_id:message.instanceId});let doneMcp=await createDoneMcpServer(state.env,this.natsPublish),sendMessageHooks=isTurnBased?{PreToolUse:[{matcher:"SendMessage",hooks:[createSendMessageOmniHook(state.env,this.natsPublish)]}]}:void 0,extraOptions={abortController:state.abortController,mcpServers:{"genie-omni-tools":doneMcp},...sendMessageHooks&&{hooks:sendMessageHooks}};if(state.claudeSessionId)extraOptions.resume=state.claudeSessionId;let queryContent=message.content,pendingNudge=this.pendingNudges.get(session.id);if(pendingNudge)queryContent=`[system] ${pendingNudge}
|
|
2029
2036
|
|
|
2030
|
-
${message.content}`,this.pendingNudges.delete(session.id);let{messages:queryMessages}=state.provider.runQuery({agentId:session.agentName,executorId:session.id,team:"",role:session.agentName,cwd:entry.dir||process.cwd(),model:entry.model,systemPrompt:state.claudeSessionId&&!isTurnBased?void 0:systemPrompt},queryContent,permissionConfig,extraOptions,entry.sdk);await this.captureUserTurn(state,session.agentName,session.id,message.content);let result2=await collectQueryResult(queryMessages);if(result2.sessionId)await this.reconcileSessionId(state,session,result2.sessionId);if(await this.captureAssistantTurn(state,session.id,result2.sessionId,session.agentName,result2.text),session.lastActivityAt=Date.now(),this.safePgCall)await recordAuditEvent2(this.safePgCall,"deliver.end",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:message.chatId,instance_id:message.instanceId,turn_count:state.turnIndex});if(state.executorId)await this.updateState(state.executorId,"idle",session.chatId)}async reconcileSessionId(state,session,returnedSessionId){if(state.claudeSessionId&&returnedSessionId!==state.claudeSessionId&&this.safePgCall)await recordAuditEvent2(this.safePgCall,"session.resume_rejected",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:session.chatId,old_session_id:state.claudeSessionId,new_session_id:returnedSessionId});let execId=state.executorId;if(execId&&this.safePgCall&&returnedSessionId!==state.claudeSessionId)await this.safePgCall("sdk-update-claude-session",()=>updateClaudeSessionId(execId,returnedSessionId),void 0,{executorId:execId,chatId:session.chatId});state.claudeSessionId=returnedSessionId}async captureUserTurn(state,agentName,_sessionKey,content){if(!this.safePgCall)return;if(!state.dbSessionId&&state.executorId)state.dbSessionId=await startSession(this.safePgCall,state.executorId,state.claudeSessionId,agentName);if(state.dbSessionId)await recordTurn(this.safePgCall,state.dbSessionId,state.turnIndex++,"user",content)}async captureAssistantTurn(state,sessionKey2,claudeSessionId,agentName,replyText){if(!this.safePgCall)return;if(claudeSessionId&&state.dbSessionId?.startsWith("sdk-")){let newId=await startSession(this.safePgCall,state.executorId??sessionKey2,claudeSessionId,agentName);if(newId)state.dbSessionId=newId}if(state.dbSessionId&&replyText)await recordTurn(this.safePgCall,state.dbSessionId,state.turnIndex++,"assistant",replyText),await updateTurnCount(this.safePgCall,state.dbSessionId,state.turnIndex)}async waitForDeliveries(sessionId){if(sessionId)await this.deliveryQueues.get(sessionId);else await Promise.all([...this.deliveryQueues.values()])}async shutdown(session){let state=this.sessions.get(session.id);if(!state)return;if(state.abortController.abort(),state.running=!1,state.dbSessionId&&this.safePgCall)await endSession(this.safePgCall,state.dbSessionId,"completed");if(state.executorId&&this.safePgCall)await this.safePgCall("sdk-terminate-executor",()=>terminateExecutor(state.executorId),void 0,{executorId:state.executorId,chatId:session.chatId});this.sessions.delete(session.id),this.deliveryQueues.delete(session.id)}async isAlive(session){let state=this.sessions.get(session.id);if(!state)return!1;return state.running&&!state.abortController.signal.aborted}}var init_claude_sdk2=__esm(()=>{init_zod();init_agent_directory();init_agent_registry();init_executor_registry();init_claude_sdk_permissions();init_claude_sdk()});class TurnTracker{turns=new Map;open(sessionKey2,turnId,messageId){this.turns.set(sessionKey2,{turnId,sessionKey:sessionKey2,messageId,startedAt:Date.now(),closed:!1})}close(sessionKey2,action){let turn=this.turns.get(sessionKey2);if(turn&&!turn.closed)turn.closed=!0,turn.closedAction=action}isOpen(sessionKey2){let turn=this.turns.get(sessionKey2);return turn!==void 0&&!turn.closed}getTurnId(sessionKey2){return this.turns.get(sessionKey2)?.turnId}getByTurnId(turnId){for(let turn of this.turns.values())if(turn.turnId===turnId)return turn;return}delete(sessionKey2){this.turns.delete(sessionKey2)}}var exports_omni_bridge={};__export(exports_omni_bridge,{getBridge:()=>getBridge,OmniBridge:()=>OmniBridge});function getBridge(){return bridgeInstance}function withTimeout(p,ms,label){return new Promise((resolve9,reject)=>{let timer2=setTimeout(()=>reject(Error(`${label} timed out after ${ms}ms`)),ms);timer2.unref?.(),p.then((v)=>{clearTimeout(timer2),resolve9(v)},(err)=>{clearTimeout(timer2),reject(err)})})}function isPgConnectionError(err){if(!err||typeof err!=="object")return!1;let e=err,code=e.code??"";if(["ECONNREFUSED","ECONNRESET","ETIMEDOUT","ENOTFOUND","EPIPE","EHOSTUNREACH"].includes(code))return!0;let msg=e.message??String(err);return/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EPIPE|connection terminated|connection closed|server closed the connection|the database system is shutting down/i.test(msg)}class OmniBridge{nc=null;sub=null;executor;turnTracker=new TurnTracker;sessions=new Map;messageQueue=[];idleCheckTimer=null;sc=import_nats3.StringCodec();sql=null;pgAvailable=!1;pgProvider;natsConnectFn;natsUrl;idleTimeoutMs;maxConcurrent;executorType;constructor(config={}){if(this.natsUrl=config.natsUrl??process.env.GENIE_NATS_URL??DEFAULT_NATS_URL,this.idleTimeoutMs=config.idleTimeoutMs??(process.env.GENIE_IDLE_TIMEOUT_MS?Number(process.env.GENIE_IDLE_TIMEOUT_MS):DEFAULT_IDLE_TIMEOUT_MS2),this.maxConcurrent=config.maxConcurrent??(process.env.GENIE_MAX_CONCURRENT?Number(process.env.GENIE_MAX_CONCURRENT):DEFAULT_MAX_CONCURRENT),this.pgProvider=config.pgProvider??(async()=>{let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db));return await getConnection2()}),this.natsConnectFn=config.natsConnectFn??import_nats3.connect,this.executorType=resolveExecutorType(config.executorType),this.executorType==="sdk")this.executor=new ClaudeSdkOmniExecutor;else this.executor=new ClaudeCodeOmniExecutor}async start(){if(this.nc){console.log("[omni-bridge] Already running");return}console.log(`[omni-bridge] Connecting to NATS at ${this.natsUrl}...`),this.nc=await this.natsConnectFn({servers:this.natsUrl,name:"genie-omni-bridge",reconnect:!0,maxReconnectAttempts:-1,reconnectTimeWait:2000}),console.log("[omni-bridge] Connected to NATS"),await this.probePg(),this.executor.setSafePgCall(this.safePgCall.bind(this));let sc3=this.sc,nc2=this.nc;this.executor.setNatsPublish((topic,payload)=>{nc2.publish(topic,sc3.encode(payload))}),this.sub=this.nc.subscribe("omni.message.>"),this.processSubscription();let turnSubs=["omni.turn.open.>","omni.turn.done.>","omni.turn.nudge.>","omni.turn.timeout.>"];for(let topic of turnSubs){let sub=this.nc.subscribe(topic);this.processTurnEvents(sub)}this.idleCheckTimer=setInterval(()=>this.checkIdleSessions(),IDLE_CHECK_INTERVAL_MS),bridgeInstance=this,console.log(`[omni-bridge] Listening on omni.message.> (max_concurrent=${this.maxConcurrent}, idle_timeout=${this.idleTimeoutMs}ms)`)}async stop(){if(!this.nc){console.log("[omni-bridge] Not running");return}if(console.log("[omni-bridge] Shutting down..."),this.idleCheckTimer)clearInterval(this.idleCheckTimer),this.idleCheckTimer=null;for(let[key,entry]of this.sessions){if(entry.idleTimer)clearTimeout(entry.idleTimer);if(!entry.spawning&&entry.session)try{await this.executor.shutdown(entry.session)}catch(err){console.warn(`[omni-bridge] Error shutting down session ${key}:`,err)}}if(this.sessions.clear(),this.sub)this.sub.unsubscribe(),this.sub=null;try{await this.nc.drain()}catch{}this.nc=null,this.sql=null,this.pgAvailable=!1,bridgeInstance=null,console.log("[omni-bridge] Stopped")}async status(){let now=Date.now(),activeFromPg=null,executorIds=[];if(this.pgAvailable&&this.sql){let rows=await this.safePgCall("status_active_count",async(sql)=>sql`
|
|
2037
|
+
${message.content}`,this.pendingNudges.delete(session.id);let{messages:queryMessages}=state.provider.runQuery({agentId:session.agentName,executorId:session.id,team:"",role:session.agentName,cwd:entry.dir||process.cwd(),model:entry.model,systemPrompt:state.claudeSessionId&&!isTurnBased?void 0:systemPrompt},queryContent,permissionConfig,extraOptions,entry.sdk);await this.captureUserTurn(state,session.agentName,session.id,message.content);let result2=await collectQueryResult(queryMessages);if(result2.sessionId)await this.reconcileSessionId(state,session,result2.sessionId);if(await this.captureAssistantTurn(state,session.id,result2.sessionId,session.agentName,result2.text),session.lastActivityAt=Date.now(),this.safePgCall)await recordAuditEvent2(this.safePgCall,"deliver.end",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:message.chatId,instance_id:message.instanceId,turn_count:state.turnIndex});if(state.executorId)await this.updateState(state.executorId,"idle",session.chatId)}async reconcileSessionId(state,session,returnedSessionId){if(state.claudeSessionId&&returnedSessionId!==state.claudeSessionId&&this.safePgCall)await recordAuditEvent2(this.safePgCall,"session.resume_rejected",{executor_id:state.executorId??session.id,agent_id:session.agentName,chat_id:session.chatId,old_session_id:state.claudeSessionId,new_session_id:returnedSessionId});let execId=state.executorId;if(execId&&this.safePgCall&&returnedSessionId!==state.claudeSessionId)await this.safePgCall("sdk-update-claude-session",()=>updateClaudeSessionId(execId,returnedSessionId),void 0,{executorId:execId,chatId:session.chatId});state.claudeSessionId=returnedSessionId}async captureUserTurn(state,agentName,_sessionKey,content){if(!this.safePgCall)return;if(!state.dbSessionId&&state.executorId)state.dbSessionId=await startSession(this.safePgCall,state.executorId,state.claudeSessionId,agentName);if(state.dbSessionId)await recordTurn(this.safePgCall,state.dbSessionId,state.turnIndex++,"user",content)}async captureAssistantTurn(state,sessionKey2,claudeSessionId,agentName,replyText){if(!this.safePgCall)return;if(claudeSessionId&&state.dbSessionId?.startsWith("sdk-")){let newId=await startSession(this.safePgCall,state.executorId??sessionKey2,claudeSessionId,agentName);if(newId)state.dbSessionId=newId}if(state.dbSessionId&&replyText)await recordTurn(this.safePgCall,state.dbSessionId,state.turnIndex++,"assistant",replyText),await updateTurnCount(this.safePgCall,state.dbSessionId,state.turnIndex)}async waitForDeliveries(sessionId){if(sessionId)await this.deliveryQueues.get(sessionId);else await Promise.all([...this.deliveryQueues.values()])}async shutdown(session){let state=this.sessions.get(session.id);if(!state)return;if(state.abortController.abort(),state.running=!1,state.dbSessionId&&this.safePgCall)await endSession(this.safePgCall,state.dbSessionId,"completed");if(state.executorId&&this.safePgCall)await this.safePgCall("sdk-terminate-executor",()=>terminateExecutor(state.executorId),void 0,{executorId:state.executorId,chatId:session.chatId});this.sessions.delete(session.id),this.deliveryQueues.delete(session.id)}async isAlive(session){let state=this.sessions.get(session.id);if(!state)return!1;return state.running&&!state.abortController.signal.aborted}}var init_claude_sdk2=__esm(()=>{init_zod();init_agent_directory();init_agent_registry();init_executor_registry();init_claude_sdk_permissions();init_claude_sdk()});class TurnTracker{turns=new Map;open(sessionKey2,turnId,messageId){this.turns.set(sessionKey2,{turnId,sessionKey:sessionKey2,messageId,startedAt:Date.now(),closed:!1})}close(sessionKey2,action){let turn=this.turns.get(sessionKey2);if(turn&&!turn.closed)turn.closed=!0,turn.closedAction=action}isOpen(sessionKey2){let turn=this.turns.get(sessionKey2);return turn!==void 0&&!turn.closed}getTurnId(sessionKey2){return this.turns.get(sessionKey2)?.turnId}getByTurnId(turnId){for(let turn of this.turns.values())if(turn.turnId===turnId)return turn;return}delete(sessionKey2){this.turns.delete(sessionKey2)}}var exports_omni_bridge={};__export(exports_omni_bridge,{getBridge:()=>getBridge,OmniBridge:()=>OmniBridge});function getBridge(){return bridgeInstance}function withTimeout(p,ms,label){return new Promise((resolve9,reject)=>{let timer2=setTimeout(()=>reject(Error(`${label} timed out after ${ms}ms`)),ms);timer2.unref?.(),p.then((v)=>{clearTimeout(timer2),resolve9(v)},(err)=>{clearTimeout(timer2),reject(err)})})}function isPgConnectionError(err){if(!err||typeof err!=="object")return!1;let e=err,code=e.code??"";if(["ECONNREFUSED","ECONNRESET","ETIMEDOUT","ENOTFOUND","EPIPE","EHOSTUNREACH"].includes(code))return!0;let msg=e.message??String(err);return/ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EPIPE|connection terminated|connection closed|server closed the connection|the database system is shutting down/i.test(msg)}class OmniBridge{nc=null;sub=null;executor;turnTracker=new TurnTracker;sessions=new Map;messageQueue=[];idleCheckTimer=null;sc=import_nats3.StringCodec();sql=null;pgAvailable=!1;pgProvider;natsConnectFn;natsUrl;idleTimeoutMs;maxConcurrent;executorType;constructor(config={}){if(this.natsUrl=config.natsUrl??process.env.GENIE_NATS_URL??DEFAULT_NATS_URL,this.idleTimeoutMs=config.idleTimeoutMs??(process.env.GENIE_IDLE_TIMEOUT_MS?Number(process.env.GENIE_IDLE_TIMEOUT_MS):DEFAULT_IDLE_TIMEOUT_MS2),this.maxConcurrent=config.maxConcurrent??(process.env.GENIE_MAX_CONCURRENT?Number(process.env.GENIE_MAX_CONCURRENT):DEFAULT_MAX_CONCURRENT),this.pgProvider=config.pgProvider??(async()=>{let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db));return await getConnection2()}),this.natsConnectFn=config.natsConnectFn??import_nats3.connect,this.executorType=resolveExecutorType(config.executorType),this.executorType==="sdk")this.executor=new ClaudeSdkOmniExecutor;else this.executor=new ClaudeCodeOmniExecutor}async start(){if(this.nc){console.log("[omni-bridge] Already running");return}console.log(`[omni-bridge] Connecting to NATS at ${this.natsUrl}...`),this.nc=await this.natsConnectFn({servers:this.natsUrl,name:"genie-omni-bridge",reconnect:!0,maxReconnectAttempts:-1,reconnectTimeWait:2000}),console.log("[omni-bridge] Connected to NATS"),await this.probePg(),this.executor.setSafePgCall(this.safePgCall.bind(this));let sc3=this.sc,nc2=this.nc;this.executor.setNatsPublish((topic,payload)=>{nc2.publish(topic,sc3.encode(payload))}),this.sub=this.nc.subscribe("omni.message.>"),this.processSubscription();let turnSubs=["omni.turn.open.>","omni.turn.done.>","omni.turn.nudge.>","omni.turn.timeout.>"];for(let topic of turnSubs){let sub=this.nc.subscribe(topic);this.processTurnEvents(sub)}let sessionResetSub=this.nc.subscribe("omni.session.reset.>");this.processSessionResetEvents(sessionResetSub),this.idleCheckTimer=setInterval(()=>this.checkIdleSessions(),IDLE_CHECK_INTERVAL_MS),bridgeInstance=this,console.log(`[omni-bridge] Listening on omni.message.> (max_concurrent=${this.maxConcurrent}, idle_timeout=${this.idleTimeoutMs}ms)`)}async stop(){if(!this.nc){console.log("[omni-bridge] Not running");return}if(console.log("[omni-bridge] Shutting down..."),this.idleCheckTimer)clearInterval(this.idleCheckTimer),this.idleCheckTimer=null;for(let[key,entry]of this.sessions){if(entry.idleTimer)clearTimeout(entry.idleTimer);if(!entry.spawning&&entry.session)try{await this.executor.shutdown(entry.session)}catch(err){console.warn(`[omni-bridge] Error shutting down session ${key}:`,err)}}if(this.sessions.clear(),this.sub)this.sub.unsubscribe(),this.sub=null;try{await this.nc.drain()}catch{}this.nc=null,this.sql=null,this.pgAvailable=!1,bridgeInstance=null,console.log("[omni-bridge] Stopped")}async status(){let now=Date.now(),activeFromPg=null,executorIds=[];if(this.pgAvailable&&this.sql){let rows=await this.safePgCall("status_active_count",async(sql)=>sql`
|
|
2031
2038
|
SELECT id FROM executors
|
|
2032
2039
|
WHERE ended_at IS NULL AND metadata->>'source' = 'omni'
|
|
2033
|
-
`,null);if(rows)activeFromPg=rows.length,executorIds=rows.map((r)=>r.id)}return{connected:this.nc!==null,natsUrl:this.natsUrl,pgAvailable:this.pgAvailable,activeSessions:activeFromPg??this.sessions.size,maxConcurrent:this.maxConcurrent,idleTimeoutMs:this.idleTimeoutMs,queueDepth:this.messageQueue.length,executorType:this.executorType,executorIds,sessions:Array.from(this.sessions.entries()).map(([key,entry])=>({id:key,agentName:entry.session.agentName,chatId:entry.session.chatId,instanceId:entry.instanceId,paneId:entry.session.paneId,spawning:entry.spawning,idleMs:now-entry.session.lastActivityAt,bufferSize:entry.buffer.length}))}}async probePg(){try{let sql=await withTimeout(this.pgProvider(),PG_STARTUP_PROBE_TIMEOUT_MS,"PG provider startup");await withTimeout(Promise.resolve(sql`SELECT 1`),PG_STARTUP_PROBE_TIMEOUT_MS,"PG SELECT 1 probe"),this.sql=sql,this.pgAvailable=!0,console.log("[omni-bridge] PG reachable \u2014 session recovery enabled")}catch(err){this.sql=null,this.pgAvailable=!1;let msg=err instanceof Error?err.message:String(err);if(isPgConnectionError(err)){console.warn(`[omni-bridge] PG unavailable \u2014 session recovery disabled (${msg})`);return}throw Error(`[omni-bridge] PG schema mismatch or setup error: ${msg}. ${"Run `bun run migrate` (or the equivalent migration command) and retry."}`)}}async safePgCall(op,fn,fallback,ctx){if(!this.pgAvailable||!this.sql)return fallback;let sql=this.sql;try{return await withTimeout(fn(sql),PG_RUNTIME_QUERY_TIMEOUT_MS,`safePgCall(${op})`)}catch(err){let msg=err instanceof Error?err.message:String(err),execPart=ctx?.executorId?` executor_id=${ctx.executorId}`:"",chatPart=ctx?.chatId?` chat_id=${ctx.chatId}`:"";if(console.warn(`[omni-bridge] safePgCall(${op}) failed${execPart}${chatPart}: ${msg}`),isPgConnectionError(err))this.pgAvailable=!1,this.sql=null,console.warn("[omni-bridge] PG connection lost \u2014 switching to degraded mode");return fallback}}async processSubscription(){if(!this.sub)return;for await(let msg of this.sub)try{let data=this.sc.decode(msg.data),parsed=JSON.parse(data),parts=msg.subject.split(".");if(parts.length>=4)parsed.instanceId=parsed.instanceId||parts[2],parsed.chatId=parsed.chatId||parts[3];if(!parsed.chatId||!parsed.agent){console.warn("[omni-bridge] Dropping message: missing chatId or agent",msg.subject);continue}await this.routeMessage(parsed)}catch(err){console.error("[omni-bridge] Error processing message:",err)}}async processTurnEvents(sub){for await(let msg of sub)try{let payload=JSON.parse(this.sc.decode(msg.data)),parts=msg.subject.split("."),eventType=parts[2],instanceId=parts[3],chatId=parts.slice(4).join("."),sessionKey2=this.findSessionKey(instanceId,chatId);if(sessionKey2)await this.routeTurnEvent(eventType,sessionKey2,payload)}catch(err){console.warn("[omni-bridge] Error processing turn event:",err)}}async routeTurnEvent(eventType,sessionKey2,payload){switch(eventType){case"open":this.turnTracker.open(sessionKey2,payload.turnId,payload.messageId);break;case"done":this.turnTracker.close(sessionKey2,payload.action);break;case"nudge":await this.handleTurnNudge(sessionKey2,payload.message);break;case"timeout":await this.handleTurnTimeout(sessionKey2);break}}findSessionKey(instanceId,chatId){for(let[key,entry]of this.sessions)if(entry.instanceId===instanceId&&entry.session?.chatId===chatId)return key;return}async handleTurnNudge(sessionKey2,nudgeText){let entry=this.sessions.get(sessionKey2);if(!entry?.session)return;try{await this.executor.injectNudge(entry.session,nudgeText)}catch(err){console.warn(`[omni-bridge] Failed to inject nudge for ${sessionKey2}:`,err)}}async handleTurnTimeout(sessionKey2){let entry=this.sessions.get(sessionKey2);if(!entry?.session)return;console.warn(`[omni-bridge] Turn timed out for ${sessionKey2}, evicting session`),this.turnTracker.close(sessionKey2,"timeout");try{await this.executor.shutdown(entry.session)}catch(err){console.warn(`[omni-bridge] Error shutting down timed-out session ${sessionKey2}:`,err)}this.sessions.delete(sessionKey2)}async routeMessage(message){let key=`${message.agent}:${message.chatId}`,entry=this.sessions.get(key);if(entry){if(entry.spawning){if(entry.buffer.length<MAX_BUFFER_PER_CHAT)entry.buffer.push(message);else console.warn(`[omni-bridge] Buffer full (${MAX_BUFFER_PER_CHAT}) for ${key}, dropping message from ${message.sender}`),await this.publishBufferFullReply(message);return}if(await this.executor.isAlive(entry.session)){await this.executor.deliver(entry.session,message),this.resetIdleTimer(key);return}this.removeSession(key)}await this.spawnSession(message)}async spawnSession(message){let key=`${message.agent}:${message.chatId}`;if(this.sessions.size>=this.maxConcurrent){this.messageQueue.push(message),await this.publishAutoReply(message),console.log(`[omni-bridge] Max concurrent (${this.maxConcurrent}) reached, queued message for ${key}`);return}let placeholder={session:null,instanceId:message.instanceId,spawning:!0,buffer:[message],idleTimer:null};this.sessions.set(key,placeholder);try{let raw=message,payloadEnv=raw.env,spawnEnv={OMNI_API_KEY:payloadEnv?.OMNI_API_KEY??process.env.OMNI_API_KEY??"",OMNI_INSTANCE:payloadEnv?.OMNI_INSTANCE??message.instanceId,OMNI_CHAT:payloadEnv?.OMNI_CHAT??message.chatId,OMNI_MESSAGE:payloadEnv?.OMNI_MESSAGE??raw.messageId??"",OMNI_TURN_ID:payloadEnv?.OMNI_TURN_ID??""};console.log(`[omni-bridge] Spawning session for ${key}...`);let session=await this.executor.spawn(message.agent,message.chatId,spawnEnv);placeholder.session=session,placeholder.spawning=!1;for(let buffered of placeholder.buffer)await this.executor.deliver(session,buffered);placeholder.buffer=[],this.resetIdleTimer(key),console.log(`[omni-bridge] Session active: ${key} (pane=${session.paneId})`)}catch(err){console.error(`[omni-bridge] Failed to spawn session for ${key}:`,err);let lostMessages=placeholder.buffer;if(lostMessages.length>0)console.warn(`[omni-bridge] Re-queuing ${lostMessages.length} buffered message(s) from failed spawn for ${key}`),this.messageQueue.push(...lostMessages);this.sessions.delete(key)}}resetIdleTimer(key){let entry=this.sessions.get(key);if(!entry)return;if(entry.idleTimer)clearTimeout(entry.idleTimer);entry.idleTimer=setTimeout(async()=>{console.log(`[omni-bridge] Idle timeout for ${key}, shutting down...`);try{await this.executor.shutdown(entry.session)}catch{}this.removeSession(key),await this.drainQueue()},this.idleTimeoutMs)}async checkIdleSessions(){let now=Date.now();for(let[key,entry]of this.sessions){if(entry.spawning)continue;if(!await this.executor.isAlive(entry.session)){console.log(`[omni-bridge] Dead session detected: ${key}`),this.removeSession(key);continue}let idleMs=now-entry.session.lastActivityAt;if(idleMs>this.idleTimeoutMs){console.log(`[omni-bridge] Forcing idle shutdown: ${key} (idle ${Math.round(idleMs/1000)}s)`);try{await this.executor.shutdown(entry.session)}catch{}this.removeSession(key)}}}removeSession(key){let entry=this.sessions.get(key);if(entry?.idleTimer)clearTimeout(entry.idleTimer);this.sessions.delete(key)}async drainQueue(){while(this.messageQueue.length>0){if(this.sessions.size>=this.maxConcurrent)break;let message=this.messageQueue.shift();if(message)await this.spawnSession(message)}}async publishBufferFullReply(message){if(!this.nc)return;let topic=`omni.reply.${message.instanceId}.${message.chatId}`,reply2={content:"Fila de mensagens cheia, por favor aguarde e tente novamente.",agent:message.agent,chat_id:message.chatId,instance_id:message.instanceId,timestamp:new Date().toISOString(),auto_reply:!0};this.nc.publish(topic,this.sc.encode(JSON.stringify(reply2)))}async publishAutoReply(message){if(!this.nc)return;let topic=`omni.reply.${message.instanceId}.${message.chatId}`,reply2={content:"Aguarde um momento, estou atendendo outros clientes.",agent:message.agent,chat_id:message.chatId,instance_id:message.instanceId,timestamp:new Date().toISOString(),auto_reply:!0};this.nc.publish(topic,this.sc.encode(JSON.stringify(reply2)))}}var import_nats3,DEFAULT_NATS_URL="localhost:4222",DEFAULT_IDLE_TIMEOUT_MS2=900000,DEFAULT_MAX_CONCURRENT=20,MAX_BUFFER_PER_CHAT=50,IDLE_CHECK_INTERVAL_MS=30000,PG_STARTUP_PROBE_TIMEOUT_MS=5000,PG_RUNTIME_QUERY_TIMEOUT_MS=2000,bridgeInstance=null;var init_omni_bridge=__esm(()=>{init_executor_config();init_claude_code2();init_claude_sdk2();import_nats3=__toESM(require_mod4(),1)});var exports_task_close_merged={};__export(exports_task_close_merged,{parseSinceDate:()=>parseSinceDate,matchPRsToSlugs:()=>matchPRsToSlugs,fetchMergedPRs:()=>fetchMergedPRs,extractWishSlug:()=>extractWishSlug,extractSlugFromBranch:()=>extractSlugFromBranch,extractSlugFromBody:()=>extractSlugFromBody,closeMergedTasks:()=>closeMergedTasks});import{execSync as execSync15}from"child_process";function extractSlugFromBody(body){if(!body)return null;let match=body.match(/(?:wish|slug):\s*(\S+)/i);return match?match[1]:null}function extractSlugFromBranch(branch){if(!branch)return null;let match=branch.match(/^(?:feat|fix|chore|docs|refactor|test|dream)\/(.+)$/);return match?match[1]:null}function extractWishSlug(pr){return extractSlugFromBody(pr.body)??extractSlugFromBranch(pr.headRefName)}function parseSinceDate(since){let match=since.match(/^(\d+)([hd])$/);if(!match)throw Error(`Invalid --since format: "${since}". Use e.g. "24h" or "7d".`);let amount=Number.parseInt(match[1],10),unit=match[2],now=new Date;if(unit==="h")now.setHours(now.getHours()-amount);else now.setDate(now.getDate()-amount);return now.toISOString()}function fetchMergedPRs(since,repo){let sinceDate=parseSinceDate(since),cmd=`gh pr list --state merged --json number,title,body,headRefName,mergedAt --limit 100 ${repo?`--repo ${repo}`:""}`.trim(),output;try{output=execSync15(cmd,{encoding:"utf-8",stdio:["pipe","pipe","pipe"]})}catch(err){let message=err instanceof Error?err.message:String(err);throw Error(`Failed to fetch merged PRs: ${message}`)}return JSON.parse(output).filter((pr)=>new Date(pr.mergedAt)>=new Date(sinceDate))}function matchPRsToSlugs(prs){let matches=[];for(let pr of prs){let slug=extractWishSlug(pr);if(slug)matches.push({prNumber:pr.number,slug,mergedAt:pr.mergedAt})}return matches}async function closeMergedTasks(options={}){let{since="24h",dryRun=!1,repo,repoPath}=options,ts3=await Promise.resolve().then(() => (init_task_service(),exports_task_service)),prs=fetchMergedPRs(since,repo),slugMatches=matchPRsToSlugs(prs),result2={closed:0,alreadyShipped:0,prsScanned:prs.length,details:[]};if(slugMatches.length===0)return result2;let actor={actorType:"local",actorId:process.env.GENIE_AGENT_NAME??"cli"},processedTaskIds=new Set;for(let{prNumber,slug}of slugMatches){let tasks=await findTasksByWishSlug(slug,repoPath);for(let task of tasks){if(processedTaskIds.has(task.id))continue;if(processedTaskIds.add(task.id),task.stage==="ship"){result2.alreadyShipped++;continue}if(!dryRun)await ts3.moveTask(task.id,"ship",actor,void 0,task.repoPath),await ts3.commentOnTask(task.id,actor,`Auto-closed: PR #${prNumber} merged to dev`,task.repoPath);result2.closed++,result2.details.push({taskSeq:task.seq,taskTitle:task.title,prNumber,slug})}}return result2}async function findTasksByWishSlug(slug,repoPath){let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db)),sql=await getConnection2(),repo=repoPath??resolveRepoPath(),pattern=`%${slug}%`;return(await sql`
|
|
2040
|
+
`,null);if(rows)activeFromPg=rows.length,executorIds=rows.map((r)=>r.id)}return{connected:this.nc!==null,natsUrl:this.natsUrl,pgAvailable:this.pgAvailable,activeSessions:activeFromPg??this.sessions.size,maxConcurrent:this.maxConcurrent,idleTimeoutMs:this.idleTimeoutMs,queueDepth:this.messageQueue.length,executorType:this.executorType,executorIds,sessions:Array.from(this.sessions.entries()).map(([key,entry])=>({id:key,agentName:entry.session.agentName,chatId:entry.session.chatId,instanceId:entry.instanceId,paneId:entry.session.paneId,spawning:entry.spawning,idleMs:now-entry.session.lastActivityAt,bufferSize:entry.buffer.length}))}}async probePg(){try{let sql=await withTimeout(this.pgProvider(),PG_STARTUP_PROBE_TIMEOUT_MS,"PG provider startup");await withTimeout(Promise.resolve(sql`SELECT 1`),PG_STARTUP_PROBE_TIMEOUT_MS,"PG SELECT 1 probe"),this.sql=sql,this.pgAvailable=!0,console.log("[omni-bridge] PG reachable \u2014 session recovery enabled")}catch(err){this.sql=null,this.pgAvailable=!1;let msg=err instanceof Error?err.message:String(err);if(isPgConnectionError(err)){console.warn(`[omni-bridge] PG unavailable \u2014 session recovery disabled (${msg})`);return}throw Error(`[omni-bridge] PG schema mismatch or setup error: ${msg}. ${"Run `bun run migrate` (or the equivalent migration command) and retry."}`)}}async safePgCall(op,fn,fallback,ctx){if(!this.pgAvailable||!this.sql)return fallback;let sql=this.sql;try{return await withTimeout(fn(sql),PG_RUNTIME_QUERY_TIMEOUT_MS,`safePgCall(${op})`)}catch(err){let msg=err instanceof Error?err.message:String(err),execPart=ctx?.executorId?` executor_id=${ctx.executorId}`:"",chatPart=ctx?.chatId?` chat_id=${ctx.chatId}`:"";if(console.warn(`[omni-bridge] safePgCall(${op}) failed${execPart}${chatPart}: ${msg}`),isPgConnectionError(err))this.pgAvailable=!1,this.sql=null,console.warn("[omni-bridge] PG connection lost \u2014 switching to degraded mode");return fallback}}async processSubscription(){if(!this.sub)return;for await(let msg of this.sub)try{let data=this.sc.decode(msg.data),parsed=JSON.parse(data),parts=msg.subject.split(".");if(parts.length>=4)parsed.instanceId=parsed.instanceId||parts[2],parsed.chatId=parsed.chatId||parts[3];if(!parsed.chatId||!parsed.agent){console.warn("[omni-bridge] Dropping message: missing chatId or agent",msg.subject);continue}await this.routeMessage(parsed)}catch(err){console.error("[omni-bridge] Error processing message:",err)}}async processTurnEvents(sub){for await(let msg of sub)try{let payload=JSON.parse(this.sc.decode(msg.data)),parts=msg.subject.split("."),eventType=parts[2],instanceId=parts[3],chatId=parts.slice(4).join("."),sessionKey2=this.findSessionKey(instanceId,chatId);if(sessionKey2)await this.routeTurnEvent(eventType,sessionKey2,payload)}catch(err){console.warn("[omni-bridge] Error processing turn event:",err)}}async routeTurnEvent(eventType,sessionKey2,payload){switch(eventType){case"open":this.turnTracker.open(sessionKey2,payload.turnId,payload.messageId);break;case"done":this.turnTracker.close(sessionKey2,payload.action);break;case"nudge":await this.handleTurnNudge(sessionKey2,payload.message);break;case"timeout":await this.handleTurnTimeout(sessionKey2);break}}findSessionKey(instanceId,chatId){for(let[key,entry]of this.sessions){if(entry.instanceId!==instanceId)continue;if(entry.session?.chatId===chatId)return key;if(entry.spawning&&key.endsWith(`:${chatId}`))return key}return}async handleTurnNudge(sessionKey2,nudgeText){let entry=this.sessions.get(sessionKey2);if(!entry?.session)return;try{await this.executor.injectNudge(entry.session,nudgeText)}catch(err){console.warn(`[omni-bridge] Failed to inject nudge for ${sessionKey2}:`,err)}}async processSessionResetEvents(sub){for await(let msg of sub)try{let parts=msg.subject.split(".");if(parts.length<5){console.warn(`[omni-bridge] Malformed session-reset subject: ${msg.subject}`);continue}let instanceId=parts[3],chatId=parts.slice(4).join("."),action;try{action=JSON.parse(this.sc.decode(msg.data)).action}catch{}await this.handleSessionReset(instanceId,chatId,action)}catch(err){console.warn("[omni-bridge] Error processing session reset event:",err)}}async handleSessionReset(instanceId,chatId,action){let sessionKey2=this.findSessionKey(instanceId,chatId);if(!sessionKey2){console.log(`[omni-bridge] Session reset for cold chat ${instanceId}/${chatId} \u2014 no-op`);return}let entry=this.sessions.get(sessionKey2);if(!entry)return;let actionTag=action?` (action=${action})`:"";if(entry.spawning){console.log(`[omni-bridge] Session reset for spawning ${sessionKey2}${actionTag}, marking cancelled`),entry.cancelled=!0,entry.buffer=[],this.turnTracker.close(sessionKey2,"reset"),this.removeSession(sessionKey2),await this.drainQueue();return}if(!entry.session)return;console.log(`[omni-bridge] Session reset for ${sessionKey2}${actionTag}, evicting`),this.turnTracker.close(sessionKey2,"reset");try{await this.executor.shutdown(entry.session)}catch(err){console.warn(`[omni-bridge] Error shutting down reset session ${sessionKey2}:`,err)}this.removeSession(sessionKey2),await this.drainQueue()}async handleTurnTimeout(sessionKey2){let entry=this.sessions.get(sessionKey2);if(!entry?.session)return;console.warn(`[omni-bridge] Turn timed out for ${sessionKey2}, evicting session`),this.turnTracker.close(sessionKey2,"timeout");try{await this.executor.shutdown(entry.session)}catch(err){console.warn(`[omni-bridge] Error shutting down timed-out session ${sessionKey2}:`,err)}this.sessions.delete(sessionKey2)}async routeMessage(message){let key=`${message.agent}:${message.chatId}`,entry=this.sessions.get(key);if(entry){if(entry.spawning){if(entry.buffer.length<MAX_BUFFER_PER_CHAT)entry.buffer.push(message);else console.warn(`[omni-bridge] Buffer full (${MAX_BUFFER_PER_CHAT}) for ${key}, dropping message from ${message.sender}`),await this.publishBufferFullReply(message);return}if(await this.executor.isAlive(entry.session)){await this.executor.deliver(entry.session,message),this.resetIdleTimer(key);return}this.removeSession(key)}await this.spawnSession(message)}async spawnSession(message){let key=`${message.agent}:${message.chatId}`;if(this.sessions.size>=this.maxConcurrent){this.messageQueue.push(message),await this.publishAutoReply(message),console.log(`[omni-bridge] Max concurrent (${this.maxConcurrent}) reached, queued message for ${key}`);return}let placeholder={session:null,instanceId:message.instanceId,spawning:!0,buffer:[message],idleTimer:null};this.sessions.set(key,placeholder);try{let raw=message,payloadEnv=raw.env,spawnEnv={OMNI_API_KEY:payloadEnv?.OMNI_API_KEY??process.env.OMNI_API_KEY??"",OMNI_INSTANCE:payloadEnv?.OMNI_INSTANCE??message.instanceId,OMNI_CHAT:payloadEnv?.OMNI_CHAT??message.chatId,OMNI_MESSAGE:payloadEnv?.OMNI_MESSAGE??raw.messageId??"",OMNI_TURN_ID:payloadEnv?.OMNI_TURN_ID??""};console.log(`[omni-bridge] Spawning session for ${key}...`);let session=await this.executor.spawn(message.agent,message.chatId,spawnEnv);if(placeholder.cancelled){console.log(`[omni-bridge] Spawn for ${key} completed but was cancelled by reset, shutting down`);try{await this.executor.shutdown(session)}catch(err){console.warn(`[omni-bridge] Error shutting down cancelled spawn for ${key}:`,err)}return}placeholder.session=session,placeholder.spawning=!1;for(let buffered of placeholder.buffer)await this.executor.deliver(session,buffered);placeholder.buffer=[],this.resetIdleTimer(key),console.log(`[omni-bridge] Session active: ${key} (pane=${session.paneId})`)}catch(err){console.error(`[omni-bridge] Failed to spawn session for ${key}:`,err);let lostMessages=placeholder.buffer;if(lostMessages.length>0)console.warn(`[omni-bridge] Re-queuing ${lostMessages.length} buffered message(s) from failed spawn for ${key}`),this.messageQueue.push(...lostMessages);this.sessions.delete(key)}}resetIdleTimer(key){let entry=this.sessions.get(key);if(!entry)return;if(entry.idleTimer)clearTimeout(entry.idleTimer);entry.idleTimer=setTimeout(async()=>{console.log(`[omni-bridge] Idle timeout for ${key}, shutting down...`);try{await this.executor.shutdown(entry.session)}catch{}this.removeSession(key),await this.drainQueue()},this.idleTimeoutMs)}async checkIdleSessions(){let now=Date.now();for(let[key,entry]of this.sessions){if(entry.spawning)continue;if(!await this.executor.isAlive(entry.session)){console.log(`[omni-bridge] Dead session detected: ${key}`),this.removeSession(key);continue}let idleMs=now-entry.session.lastActivityAt;if(idleMs>this.idleTimeoutMs){console.log(`[omni-bridge] Forcing idle shutdown: ${key} (idle ${Math.round(idleMs/1000)}s)`);try{await this.executor.shutdown(entry.session)}catch{}this.removeSession(key)}}}removeSession(key){let entry=this.sessions.get(key);if(entry?.idleTimer)clearTimeout(entry.idleTimer);this.sessions.delete(key)}async drainQueue(){while(this.messageQueue.length>0){if(this.sessions.size>=this.maxConcurrent)break;let message=this.messageQueue.shift();if(message)await this.spawnSession(message)}}async publishBufferFullReply(message){if(!this.nc)return;let topic=`omni.reply.${message.instanceId}.${message.chatId}`,reply2={content:"Fila de mensagens cheia, por favor aguarde e tente novamente.",agent:message.agent,chat_id:message.chatId,instance_id:message.instanceId,timestamp:new Date().toISOString(),auto_reply:!0};this.nc.publish(topic,this.sc.encode(JSON.stringify(reply2)))}async publishAutoReply(message){if(!this.nc)return;let topic=`omni.reply.${message.instanceId}.${message.chatId}`,reply2={content:"Aguarde um momento, estou atendendo outros clientes.",agent:message.agent,chat_id:message.chatId,instance_id:message.instanceId,timestamp:new Date().toISOString(),auto_reply:!0};this.nc.publish(topic,this.sc.encode(JSON.stringify(reply2)))}}var import_nats3,DEFAULT_NATS_URL="localhost:4222",DEFAULT_IDLE_TIMEOUT_MS2=900000,DEFAULT_MAX_CONCURRENT=20,MAX_BUFFER_PER_CHAT=50,IDLE_CHECK_INTERVAL_MS=30000,PG_STARTUP_PROBE_TIMEOUT_MS=5000,PG_RUNTIME_QUERY_TIMEOUT_MS=2000,bridgeInstance=null;var init_omni_bridge=__esm(()=>{init_executor_config();init_claude_code2();init_claude_sdk2();import_nats3=__toESM(require_mod4(),1)});var exports_task_close_merged={};__export(exports_task_close_merged,{parseSinceDate:()=>parseSinceDate,matchPRsToSlugs:()=>matchPRsToSlugs,fetchMergedPRs:()=>fetchMergedPRs,extractWishSlug:()=>extractWishSlug,extractSlugFromBranch:()=>extractSlugFromBranch,extractSlugFromBody:()=>extractSlugFromBody,closeMergedTasks:()=>closeMergedTasks});import{execSync as execSync15}from"child_process";function extractSlugFromBody(body){if(!body)return null;let match=body.match(/(?:wish|slug):\s*(\S+)/i);return match?match[1]:null}function extractSlugFromBranch(branch){if(!branch)return null;let match=branch.match(/^(?:feat|fix|chore|docs|refactor|test|dream)\/(.+)$/);return match?match[1]:null}function extractWishSlug(pr){return extractSlugFromBody(pr.body)??extractSlugFromBranch(pr.headRefName)}function parseSinceDate(since){let match=since.match(/^(\d+)([hd])$/);if(!match)throw Error(`Invalid --since format: "${since}". Use e.g. "24h" or "7d".`);let amount=Number.parseInt(match[1],10),unit=match[2],now=new Date;if(unit==="h")now.setHours(now.getHours()-amount);else now.setDate(now.getDate()-amount);return now.toISOString()}function fetchMergedPRs(since,repo){let sinceDate=parseSinceDate(since),cmd=`gh pr list --state merged --json number,title,body,headRefName,mergedAt --limit 100 ${repo?`--repo ${repo}`:""}`.trim(),output;try{output=execSync15(cmd,{encoding:"utf-8",stdio:["pipe","pipe","pipe"]})}catch(err){let message=err instanceof Error?err.message:String(err);throw Error(`Failed to fetch merged PRs: ${message}`)}return JSON.parse(output).filter((pr)=>new Date(pr.mergedAt)>=new Date(sinceDate))}function matchPRsToSlugs(prs){let matches=[];for(let pr of prs){let slug=extractWishSlug(pr);if(slug)matches.push({prNumber:pr.number,slug,mergedAt:pr.mergedAt})}return matches}async function closeMergedTasks(options={}){let{since="24h",dryRun=!1,repo,repoPath}=options,ts3=await Promise.resolve().then(() => (init_task_service(),exports_task_service)),prs=fetchMergedPRs(since,repo),slugMatches=matchPRsToSlugs(prs),result2={closed:0,alreadyShipped:0,prsScanned:prs.length,details:[]};if(slugMatches.length===0)return result2;let actor={actorType:"local",actorId:process.env.GENIE_AGENT_NAME??"cli"},processedTaskIds=new Set;for(let{prNumber,slug}of slugMatches){let tasks=await findTasksByWishSlug(slug,repoPath);for(let task of tasks){if(processedTaskIds.has(task.id))continue;if(processedTaskIds.add(task.id),task.stage==="ship"){result2.alreadyShipped++;continue}if(!dryRun)await ts3.moveTask(task.id,"ship",actor,void 0,task.repoPath),await ts3.commentOnTask(task.id,actor,`Auto-closed: PR #${prNumber} merged to dev`,task.repoPath);result2.closed++,result2.details.push({taskSeq:task.seq,taskTitle:task.title,prNumber,slug})}}return result2}async function findTasksByWishSlug(slug,repoPath){let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db)),sql=await getConnection2(),repo=repoPath??resolveRepoPath(),pattern=`%${slug}%`;return(await sql`
|
|
2034
2041
|
SELECT id, seq, title, stage, repo_path
|
|
2035
2042
|
FROM tasks
|
|
2036
2043
|
WHERE repo_path = ${repo}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.260407.1",
|
|
4
4
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Namastex Labs"
|
|
@@ -705,3 +705,309 @@ describe('OmniBridge — session lifecycle (Group 2)', () => {
|
|
|
705
705
|
}
|
|
706
706
|
});
|
|
707
707
|
});
|
|
708
|
+
|
|
709
|
+
// ============================================================================
|
|
710
|
+
// Session reset subscription — issue #1089
|
|
711
|
+
// ============================================================================
|
|
712
|
+
|
|
713
|
+
describe('OmniBridge — session reset (#1089)', () => {
|
|
714
|
+
/** Pre-populate a live session entry on the bridge for reset tests. */
|
|
715
|
+
function injectSession(
|
|
716
|
+
bridge: OmniBridge,
|
|
717
|
+
key: string,
|
|
718
|
+
instanceId: string,
|
|
719
|
+
session: OmniSession,
|
|
720
|
+
): { entry: { idleTimer: ReturnType<typeof setTimeout> | null } } {
|
|
721
|
+
const idleTimer = setTimeout(() => {}, 60_000);
|
|
722
|
+
const entry = {
|
|
723
|
+
session,
|
|
724
|
+
instanceId,
|
|
725
|
+
spawning: false,
|
|
726
|
+
buffer: [],
|
|
727
|
+
idleTimer,
|
|
728
|
+
};
|
|
729
|
+
(bridge as any).sessions.set(key, entry);
|
|
730
|
+
return { entry };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
it('shuts down the executor and removes the session on reset for a hot chat', async () => {
|
|
734
|
+
const { executor, calls, makeSession } = makeMockExecutor();
|
|
735
|
+
const bridge = new OmniBridge({
|
|
736
|
+
natsUrl: 'test://fake',
|
|
737
|
+
pgProvider: degradedPgProvider,
|
|
738
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
739
|
+
});
|
|
740
|
+
(bridge as any).executor = executor;
|
|
741
|
+
await bridge.start();
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
const session = makeSession('test-agent', 'chat-1');
|
|
745
|
+
injectSession(bridge, 'test-agent:chat-1', 'inst-1', session);
|
|
746
|
+
|
|
747
|
+
await (bridge as any).handleSessionReset('inst-1', 'chat-1', 'kill');
|
|
748
|
+
|
|
749
|
+
expect(calls.shutdown.length).toBe(1);
|
|
750
|
+
expect(calls.shutdown[0].chatId).toBe('chat-1');
|
|
751
|
+
expect((bridge as any).sessions.size).toBe(0);
|
|
752
|
+
} finally {
|
|
753
|
+
await bridge.stop();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('no-ops on reset for a cold chat (no live session)', async () => {
|
|
758
|
+
const { executor, calls } = makeMockExecutor();
|
|
759
|
+
const bridge = new OmniBridge({
|
|
760
|
+
natsUrl: 'test://fake',
|
|
761
|
+
pgProvider: degradedPgProvider,
|
|
762
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
763
|
+
});
|
|
764
|
+
(bridge as any).executor = executor;
|
|
765
|
+
await bridge.start();
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
// Sessions map is empty — reset must not throw and must not call shutdown.
|
|
769
|
+
await (bridge as any).handleSessionReset('inst-1', 'chat-cold');
|
|
770
|
+
|
|
771
|
+
expect(calls.shutdown.length).toBe(0);
|
|
772
|
+
expect((bridge as any).sessions.size).toBe(0);
|
|
773
|
+
} finally {
|
|
774
|
+
await bridge.stop();
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('clears the idle timer when evicting a reset session', async () => {
|
|
779
|
+
const { executor, makeSession } = makeMockExecutor();
|
|
780
|
+
const bridge = new OmniBridge({
|
|
781
|
+
natsUrl: 'test://fake',
|
|
782
|
+
pgProvider: degradedPgProvider,
|
|
783
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
784
|
+
});
|
|
785
|
+
(bridge as any).executor = executor;
|
|
786
|
+
await bridge.start();
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
const session = makeSession('test-agent', 'chat-1');
|
|
790
|
+
const { entry } = injectSession(bridge, 'test-agent:chat-1', 'inst-1', session);
|
|
791
|
+
const timerBefore = entry.idleTimer;
|
|
792
|
+
expect(timerBefore).not.toBeNull();
|
|
793
|
+
|
|
794
|
+
await (bridge as any).handleSessionReset('inst-1', 'chat-1');
|
|
795
|
+
|
|
796
|
+
// Session removed → idle timer no longer reachable from sessions map
|
|
797
|
+
expect((bridge as any).sessions.has('test-agent:chat-1')).toBe(false);
|
|
798
|
+
} finally {
|
|
799
|
+
await bridge.stop();
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('parses subject with dotted chatId (e.g. WhatsApp +5511...@s.whatsapp.net)', async () => {
|
|
804
|
+
const { executor, calls, makeSession } = makeMockExecutor();
|
|
805
|
+
const bridge = new OmniBridge({
|
|
806
|
+
natsUrl: 'test://fake',
|
|
807
|
+
pgProvider: degradedPgProvider,
|
|
808
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
809
|
+
});
|
|
810
|
+
(bridge as any).executor = executor;
|
|
811
|
+
await bridge.start();
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const dottedChat = '+5511999999999@s.whatsapp.net';
|
|
815
|
+
const session = makeSession('test-agent', dottedChat);
|
|
816
|
+
injectSession(bridge, `test-agent:${dottedChat}`, 'inst-x', session);
|
|
817
|
+
|
|
818
|
+
// Build a fake NATS message and feed it through the dispatch path the way
|
|
819
|
+
// processSessionResetEvents would: subject + JSON-encoded payload bytes.
|
|
820
|
+
const fakeMsg = {
|
|
821
|
+
subject: `omni.session.reset.inst-x.${dottedChat}`,
|
|
822
|
+
data: new TextEncoder().encode(JSON.stringify({ action: 'kill' })),
|
|
823
|
+
};
|
|
824
|
+
// Drive a single iteration through processSessionResetEvents using a one-shot iterator.
|
|
825
|
+
const oneShot = {
|
|
826
|
+
[Symbol.asyncIterator]: async function* () {
|
|
827
|
+
yield fakeMsg;
|
|
828
|
+
},
|
|
829
|
+
} as any;
|
|
830
|
+
await (bridge as any).processSessionResetEvents(oneShot);
|
|
831
|
+
|
|
832
|
+
expect(calls.shutdown.length).toBe(1);
|
|
833
|
+
expect(calls.shutdown[0].chatId).toBe(dottedChat);
|
|
834
|
+
} finally {
|
|
835
|
+
await bridge.stop();
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('tolerates malformed JSON payload by routing on subject alone', async () => {
|
|
840
|
+
const { executor, calls, makeSession } = makeMockExecutor();
|
|
841
|
+
const bridge = new OmniBridge({
|
|
842
|
+
natsUrl: 'test://fake',
|
|
843
|
+
pgProvider: degradedPgProvider,
|
|
844
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
845
|
+
});
|
|
846
|
+
(bridge as any).executor = executor;
|
|
847
|
+
await bridge.start();
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
const session = makeSession('test-agent', 'chat-1');
|
|
851
|
+
injectSession(bridge, 'test-agent:chat-1', 'inst-1', session);
|
|
852
|
+
|
|
853
|
+
const fakeMsg = {
|
|
854
|
+
subject: 'omni.session.reset.inst-1.chat-1',
|
|
855
|
+
data: new TextEncoder().encode('not-json-at-all'),
|
|
856
|
+
};
|
|
857
|
+
const oneShot = {
|
|
858
|
+
[Symbol.asyncIterator]: async function* () {
|
|
859
|
+
yield fakeMsg;
|
|
860
|
+
},
|
|
861
|
+
} as any;
|
|
862
|
+
await (bridge as any).processSessionResetEvents(oneShot);
|
|
863
|
+
|
|
864
|
+
// Subject-only routing still kills the session.
|
|
865
|
+
expect(calls.shutdown.length).toBe(1);
|
|
866
|
+
} finally {
|
|
867
|
+
await bridge.stop();
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('warns and skips on malformed subject with too few segments', async () => {
|
|
872
|
+
const { executor, calls } = makeMockExecutor();
|
|
873
|
+
const bridge = new OmniBridge({
|
|
874
|
+
natsUrl: 'test://fake',
|
|
875
|
+
pgProvider: degradedPgProvider,
|
|
876
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
877
|
+
});
|
|
878
|
+
(bridge as any).executor = executor;
|
|
879
|
+
await bridge.start();
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
const fakeMsg = {
|
|
883
|
+
subject: 'omni.session.reset',
|
|
884
|
+
data: new TextEncoder().encode('{}'),
|
|
885
|
+
};
|
|
886
|
+
const oneShot = {
|
|
887
|
+
[Symbol.asyncIterator]: async function* () {
|
|
888
|
+
yield fakeMsg;
|
|
889
|
+
},
|
|
890
|
+
} as any;
|
|
891
|
+
await (bridge as any).processSessionResetEvents(oneShot);
|
|
892
|
+
|
|
893
|
+
expect(calls.shutdown.length).toBe(0);
|
|
894
|
+
} finally {
|
|
895
|
+
await bridge.stop();
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('cancels a spawning session on reset and tears down the freshly-spawned executor', async () => {
|
|
900
|
+
// Hold the spawn promise open so we can fire reset mid-spawn.
|
|
901
|
+
let releaseSpawn!: (s: OmniSession) => void;
|
|
902
|
+
const spawnGate = new Promise<OmniSession>((resolve) => {
|
|
903
|
+
releaseSpawn = resolve;
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const { executor, calls, makeSession } = makeMockExecutor({
|
|
907
|
+
spawnFn: async (agentName, chatId) => {
|
|
908
|
+
// Block until the test releases the spawn.
|
|
909
|
+
const session = await spawnGate;
|
|
910
|
+
return session ?? makeSession(agentName, chatId);
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
const bridge = new OmniBridge({
|
|
914
|
+
natsUrl: 'test://fake',
|
|
915
|
+
pgProvider: degradedPgProvider,
|
|
916
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
917
|
+
});
|
|
918
|
+
(bridge as any).executor = executor;
|
|
919
|
+
await bridge.start();
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
// Kick off the spawn — routeMessage will block on spawnGate.
|
|
923
|
+
const routePromise = (bridge as any).routeMessage(makeMsg({ content: 'first' }));
|
|
924
|
+
|
|
925
|
+
// Yield so spawnSession installs the placeholder before we reset.
|
|
926
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
927
|
+
expect((bridge as any).sessions.has('test-agent:chat-1')).toBe(true);
|
|
928
|
+
expect((bridge as any).sessions.get('test-agent:chat-1').spawning).toBe(true);
|
|
929
|
+
|
|
930
|
+
// Fire the reset while spawn is still in flight.
|
|
931
|
+
await (bridge as any).handleSessionReset('inst-1', 'chat-1', 'kill');
|
|
932
|
+
|
|
933
|
+
// Placeholder is gone — the spawn-in-flight has been logically cancelled.
|
|
934
|
+
expect((bridge as any).sessions.has('test-agent:chat-1')).toBe(false);
|
|
935
|
+
|
|
936
|
+
// Now release the spawn — spawnSession should detect cancelled and tear it down.
|
|
937
|
+
releaseSpawn(makeSession('test-agent', 'chat-1'));
|
|
938
|
+
await routePromise;
|
|
939
|
+
|
|
940
|
+
// executor.shutdown was called for the freshly-spawned (cancelled) session.
|
|
941
|
+
expect(calls.shutdown.length).toBe(1);
|
|
942
|
+
expect(calls.shutdown[0].chatId).toBe('chat-1');
|
|
943
|
+
|
|
944
|
+
// Buffered triggering message must NOT be delivered to the killed session.
|
|
945
|
+
expect(calls.deliver.length).toBe(0);
|
|
946
|
+
} finally {
|
|
947
|
+
await bridge.stop();
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('drains the message queue after evicting a reset session', async () => {
|
|
952
|
+
const { executor, calls, makeSession } = makeMockExecutor();
|
|
953
|
+
const bridge = new OmniBridge({
|
|
954
|
+
natsUrl: 'test://fake',
|
|
955
|
+
pgProvider: degradedPgProvider,
|
|
956
|
+
natsConnectFn: (async () => makeFakeNats()) as any,
|
|
957
|
+
});
|
|
958
|
+
(bridge as any).executor = executor;
|
|
959
|
+
// Force the bridge to look fully saturated so a reset opens a slot.
|
|
960
|
+
(bridge as any).maxConcurrent = 1;
|
|
961
|
+
await bridge.start();
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
const session = makeSession('test-agent', 'chat-1');
|
|
965
|
+
injectSession(bridge, 'test-agent:chat-1', 'inst-1', session);
|
|
966
|
+
|
|
967
|
+
// Park a queued message that's waiting for a free slot.
|
|
968
|
+
(bridge as any).messageQueue.push(makeMsg({ chatId: 'chat-2', agent: 'agent-b' }));
|
|
969
|
+
|
|
970
|
+
await (bridge as any).handleSessionReset('inst-1', 'chat-1', 'kill');
|
|
971
|
+
|
|
972
|
+
// Original session is gone, queued message picked up its slot, drainQueue spawned it.
|
|
973
|
+
expect((bridge as any).sessions.has('test-agent:chat-1')).toBe(false);
|
|
974
|
+
expect((bridge as any).messageQueue.length).toBe(0);
|
|
975
|
+
expect(calls.spawn.length).toBe(1);
|
|
976
|
+
expect(calls.spawn[0].chatId).toBe('chat-2');
|
|
977
|
+
} finally {
|
|
978
|
+
await bridge.stop();
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('subscribes to omni.session.reset.> on start()', async () => {
|
|
983
|
+
const subscribeCalls: string[] = [];
|
|
984
|
+
const fakeSub: Partial<Subscription> & AsyncIterable<never> = {
|
|
985
|
+
unsubscribe: () => {},
|
|
986
|
+
[Symbol.asyncIterator]: async function* () {},
|
|
987
|
+
};
|
|
988
|
+
const nc: Partial<NatsConnection> = {
|
|
989
|
+
info: undefined,
|
|
990
|
+
closed: async () => undefined,
|
|
991
|
+
close: async () => undefined,
|
|
992
|
+
drain: async () => undefined,
|
|
993
|
+
publish: () => {},
|
|
994
|
+
subscribe: (subject: string) => {
|
|
995
|
+
subscribeCalls.push(subject);
|
|
996
|
+
return fakeSub as Subscription;
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const bridge = new OmniBridge({
|
|
1001
|
+
natsUrl: 'test://fake',
|
|
1002
|
+
pgProvider: degradedPgProvider,
|
|
1003
|
+
natsConnectFn: (async () => nc as NatsConnection) as any,
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
try {
|
|
1007
|
+
await bridge.start();
|
|
1008
|
+
expect(subscribeCalls).toContain('omni.session.reset.>');
|
|
1009
|
+
} finally {
|
|
1010
|
+
await bridge.stop();
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
@@ -26,7 +26,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
|
26
26
|
// — recordAuditEvent / session-capture route through safePgCall, which is
|
|
27
27
|
// mocked per-test via setSafePgCall() (or guarded null for no-bridge tests).
|
|
28
28
|
|
|
29
|
-
const { ClaudeSdkOmniExecutor } = await import('../claude-sdk.js');
|
|
29
|
+
const { ClaudeSdkOmniExecutor, createSendMessageOmniHook } = await import('../claude-sdk.js');
|
|
30
30
|
const directory = await import('../../../lib/agent-directory.js');
|
|
31
31
|
|
|
32
32
|
describe('ClaudeSdkOmniExecutor', () => {
|
|
@@ -474,7 +474,7 @@ describe('ClaudeSdkOmniExecutor', () => {
|
|
|
474
474
|
// Verify turn-based prompt content
|
|
475
475
|
expect(systemPrompt).toContain('WhatsApp');
|
|
476
476
|
expect(systemPrompt).toContain('Alice');
|
|
477
|
-
expect(systemPrompt).toContain('
|
|
477
|
+
expect(systemPrompt).toContain('SendMessage');
|
|
478
478
|
expect(systemPrompt).toContain('omni done');
|
|
479
479
|
expect(systemPrompt).toContain('inst-wb');
|
|
480
480
|
});
|
|
@@ -496,8 +496,248 @@ describe('ClaudeSdkOmniExecutor', () => {
|
|
|
496
496
|
const systemPrompt = callArgs.options?.systemPrompt ?? '';
|
|
497
497
|
|
|
498
498
|
expect(systemPrompt).not.toContain('WhatsApp');
|
|
499
|
-
expect(systemPrompt).not.toContain('
|
|
500
|
-
|
|
499
|
+
expect(systemPrompt).not.toContain('SendMessage');
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ==========================================================================
|
|
504
|
+
// SendMessage(to: omni) NATS routing — issue #1088
|
|
505
|
+
// ==========================================================================
|
|
506
|
+
|
|
507
|
+
describe('SendMessage omni interception', () => {
|
|
508
|
+
const omniEnv = { OMNI_INSTANCE: 'inst-x', OMNI_CHAT: 'chat-x', OMNI_AGENT: 'eugenia' };
|
|
509
|
+
const dummyMeta = { signal: new AbortController().signal };
|
|
510
|
+
|
|
511
|
+
it('publishes to omni.reply.{instance}.{chat} and denies the tool when recipient is "omni"', async () => {
|
|
512
|
+
const publishCalls: Array<[string, string]> = [];
|
|
513
|
+
const natsPublish = (subject: string, payload: string) => {
|
|
514
|
+
publishCalls.push([subject, payload]);
|
|
515
|
+
};
|
|
516
|
+
const hook = createSendMessageOmniHook(omniEnv, natsPublish);
|
|
517
|
+
|
|
518
|
+
const result = await hook(
|
|
519
|
+
{
|
|
520
|
+
hook_event_name: 'PreToolUse',
|
|
521
|
+
tool_name: 'SendMessage',
|
|
522
|
+
tool_input: { recipient: 'omni', message: 'Olá Cezar!' },
|
|
523
|
+
tool_use_id: 'tu-1',
|
|
524
|
+
} as never,
|
|
525
|
+
'tu-1',
|
|
526
|
+
dummyMeta,
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(publishCalls).toHaveLength(1);
|
|
530
|
+
expect(publishCalls[0][0]).toBe('omni.reply.inst-x.chat-x');
|
|
531
|
+
const payload = JSON.parse(publishCalls[0][1]);
|
|
532
|
+
expect(payload).toMatchObject({
|
|
533
|
+
agent: 'eugenia',
|
|
534
|
+
chat_id: 'chat-x',
|
|
535
|
+
instance_id: 'inst-x',
|
|
536
|
+
content: 'Olá Cezar!',
|
|
537
|
+
});
|
|
538
|
+
expect(result).not.toBeNull();
|
|
539
|
+
const out = result as { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string } };
|
|
540
|
+
expect(out.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
541
|
+
expect(out.hookSpecificOutput?.permissionDecisionReason).toMatch(/delivered.*omni bridge/i);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('accepts the alternate "to" + "content" field shape', async () => {
|
|
545
|
+
const publishCalls: Array<[string, string]> = [];
|
|
546
|
+
const hook = createSendMessageOmniHook(omniEnv, (s, p) => {
|
|
547
|
+
publishCalls.push([s, p]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
await hook(
|
|
551
|
+
{
|
|
552
|
+
hook_event_name: 'PreToolUse',
|
|
553
|
+
tool_name: 'SendMessage',
|
|
554
|
+
tool_input: { to: 'omni', content: 'second shape' },
|
|
555
|
+
tool_use_id: 'tu-2',
|
|
556
|
+
} as never,
|
|
557
|
+
'tu-2',
|
|
558
|
+
dummyMeta,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
expect(publishCalls).toHaveLength(1);
|
|
562
|
+
expect(JSON.parse(publishCalls[0][1]).content).toBe('second shape');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('passes through (no decision) when recipient is not "omni"', async () => {
|
|
566
|
+
const publishCalls: Array<[string, string]> = [];
|
|
567
|
+
const hook = createSendMessageOmniHook(omniEnv, (s, p) => {
|
|
568
|
+
publishCalls.push([s, p]);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const result = await hook(
|
|
572
|
+
{
|
|
573
|
+
hook_event_name: 'PreToolUse',
|
|
574
|
+
tool_name: 'SendMessage',
|
|
575
|
+
tool_input: { recipient: 'reviewer', message: 'peer ping' },
|
|
576
|
+
tool_use_id: 'tu-3',
|
|
577
|
+
} as never,
|
|
578
|
+
'tu-3',
|
|
579
|
+
dummyMeta,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
expect(publishCalls).toHaveLength(0);
|
|
583
|
+
expect((result as Record<string, unknown>).hookSpecificOutput).toBeUndefined();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('passes through for non-SendMessage tool calls', async () => {
|
|
587
|
+
const publishCalls: Array<[string, string]> = [];
|
|
588
|
+
const hook = createSendMessageOmniHook(omniEnv, (s, p) => {
|
|
589
|
+
publishCalls.push([s, p]);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const result = await hook(
|
|
593
|
+
{
|
|
594
|
+
hook_event_name: 'PreToolUse',
|
|
595
|
+
tool_name: 'Read',
|
|
596
|
+
tool_input: { file_path: '/tmp/x' },
|
|
597
|
+
tool_use_id: 'tu-4',
|
|
598
|
+
} as never,
|
|
599
|
+
'tu-4',
|
|
600
|
+
dummyMeta,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
expect(publishCalls).toHaveLength(0);
|
|
604
|
+
expect((result as Record<string, unknown>).hookSpecificOutput).toBeUndefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('denies with bridge-unavailable reason when natsPublish is null but env is set', async () => {
|
|
608
|
+
const hook = createSendMessageOmniHook(omniEnv, null);
|
|
609
|
+
|
|
610
|
+
const result = await hook(
|
|
611
|
+
{
|
|
612
|
+
hook_event_name: 'PreToolUse',
|
|
613
|
+
tool_name: 'SendMessage',
|
|
614
|
+
tool_input: { recipient: 'omni', message: 'oops' },
|
|
615
|
+
tool_use_id: 'tu-5',
|
|
616
|
+
} as never,
|
|
617
|
+
'tu-5',
|
|
618
|
+
dummyMeta,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const out = result as { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string } };
|
|
622
|
+
expect(out.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
623
|
+
expect(out.hookSpecificOutput?.permissionDecisionReason).toMatch(/bridge unavailable/i);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('denies with retry-prompting reason when message body is missing', async () => {
|
|
627
|
+
const publishCalls: Array<[string, string]> = [];
|
|
628
|
+
const hook = createSendMessageOmniHook(omniEnv, (s, p) => {
|
|
629
|
+
publishCalls.push([s, p]);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Model emits the wrong field name (e.g. `text` instead of `message`/`content`)
|
|
633
|
+
const result = await hook(
|
|
634
|
+
{
|
|
635
|
+
hook_event_name: 'PreToolUse',
|
|
636
|
+
tool_name: 'SendMessage',
|
|
637
|
+
tool_input: { recipient: 'omni', text: 'wrong field' },
|
|
638
|
+
tool_use_id: 'tu-empty-1',
|
|
639
|
+
} as never,
|
|
640
|
+
'tu-empty-1',
|
|
641
|
+
dummyMeta,
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
expect(publishCalls).toHaveLength(0);
|
|
645
|
+
const out = result as { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string } };
|
|
646
|
+
expect(out.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
647
|
+
expect(out.hookSpecificOutput?.permissionDecisionReason).toMatch(/non-empty.*message/i);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('denies with retry-prompting reason when message body is whitespace-only', async () => {
|
|
651
|
+
const publishCalls: Array<[string, string]> = [];
|
|
652
|
+
const hook = createSendMessageOmniHook(omniEnv, (s, p) => {
|
|
653
|
+
publishCalls.push([s, p]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const result = await hook(
|
|
657
|
+
{
|
|
658
|
+
hook_event_name: 'PreToolUse',
|
|
659
|
+
tool_name: 'SendMessage',
|
|
660
|
+
tool_input: { recipient: 'omni', message: ' \n\t ' },
|
|
661
|
+
tool_use_id: 'tu-empty-2',
|
|
662
|
+
} as never,
|
|
663
|
+
'tu-empty-2',
|
|
664
|
+
dummyMeta,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
expect(publishCalls).toHaveLength(0);
|
|
668
|
+
const out = result as { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string } };
|
|
669
|
+
expect(out.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
670
|
+
expect(out.hookSpecificOutput?.permissionDecisionReason).toMatch(/non-empty.*message/i);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('passes through when OMNI_INSTANCE is missing (non-bridge SDK session)', async () => {
|
|
674
|
+
const publishCalls: Array<[string, string]> = [];
|
|
675
|
+
const hook = createSendMessageOmniHook({}, (s, p) => {
|
|
676
|
+
publishCalls.push([s, p]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const result = await hook(
|
|
680
|
+
{
|
|
681
|
+
hook_event_name: 'PreToolUse',
|
|
682
|
+
tool_name: 'SendMessage',
|
|
683
|
+
tool_input: { recipient: 'omni', message: 'no bridge' },
|
|
684
|
+
tool_use_id: 'tu-6',
|
|
685
|
+
} as never,
|
|
686
|
+
'tu-6',
|
|
687
|
+
dummyMeta,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
expect(publishCalls).toHaveLength(0);
|
|
691
|
+
expect((result as Record<string, unknown>).hookSpecificOutput).toBeUndefined();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('wires the SendMessage hook into runQuery options when OMNI_INSTANCE is set', async () => {
|
|
695
|
+
const session = await executor.spawn('test-agent', 'chat-wire', {
|
|
696
|
+
OMNI_INSTANCE: 'inst-wire',
|
|
697
|
+
OMNI_CHAT: 'chat-wire',
|
|
698
|
+
OMNI_AGENT: 'wirebot',
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await executor.deliver(session, {
|
|
702
|
+
content: 'Hi',
|
|
703
|
+
sender: 'Alice',
|
|
704
|
+
instanceId: 'inst-wire',
|
|
705
|
+
chatId: 'chat-wire',
|
|
706
|
+
agent: 'test-agent',
|
|
707
|
+
});
|
|
708
|
+
await executor.waitForDeliveries(session.id);
|
|
709
|
+
|
|
710
|
+
expect(queryMock).toHaveBeenCalled();
|
|
711
|
+
const callArgs = (
|
|
712
|
+
queryMock.mock.calls.at(-1) as unknown as [{ options?: { hooks?: Record<string, unknown[]> } }]
|
|
713
|
+
)[0];
|
|
714
|
+
const preToolUseHooks = callArgs.options?.hooks?.PreToolUse;
|
|
715
|
+
expect(Array.isArray(preToolUseHooks)).toBe(true);
|
|
716
|
+
// permission gate matcher (*) + SendMessage matcher
|
|
717
|
+
const matchers = (preToolUseHooks as Array<{ matcher?: string }>).map((h) => h.matcher);
|
|
718
|
+
expect(matchers).toContain('SendMessage');
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('does NOT wire the SendMessage hook when OMNI_INSTANCE is absent', async () => {
|
|
722
|
+
const session = await executor.spawn('test-agent', 'chat-nowire', {});
|
|
723
|
+
|
|
724
|
+
await executor.deliver(session, {
|
|
725
|
+
content: 'Hi',
|
|
726
|
+
sender: 'bob',
|
|
727
|
+
instanceId: 'inst-1',
|
|
728
|
+
chatId: 'chat-nowire',
|
|
729
|
+
agent: 'test-agent',
|
|
730
|
+
});
|
|
731
|
+
await executor.waitForDeliveries(session.id);
|
|
732
|
+
|
|
733
|
+
const callArgs = (
|
|
734
|
+
queryMock.mock.calls.at(-1) as unknown as [
|
|
735
|
+
{ options?: { hooks?: Record<string, Array<{ matcher?: string }>> } },
|
|
736
|
+
]
|
|
737
|
+
)[0];
|
|
738
|
+
const preToolUseHooks = callArgs.options?.hooks?.PreToolUse ?? [];
|
|
739
|
+
const matchers = preToolUseHooks.map((h) => h.matcher);
|
|
740
|
+
expect(matchers).not.toContain('SendMessage');
|
|
501
741
|
});
|
|
502
742
|
});
|
|
503
743
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
HookCallback,
|
|
3
|
+
HookCallbackMatcher,
|
|
4
|
+
PreToolUseHookInput,
|
|
5
|
+
Query,
|
|
6
|
+
SyncHookJSONOutput,
|
|
7
|
+
} from '@anthropic-ai/claude-agent-sdk';
|
|
2
8
|
|
|
3
9
|
import { z } from 'zod';
|
|
4
10
|
import * as directory from '../../lib/agent-directory.js';
|
|
@@ -153,6 +159,95 @@ export function handleDoneTool(
|
|
|
153
159
|
return action.label;
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// SendMessage interception — route SendMessage(to: "omni") to NATS reply path
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract `recipient` and `message content` from a SendMessage tool input.
|
|
168
|
+
*
|
|
169
|
+
* Both field name variants are supported defensively:
|
|
170
|
+
* - recipient: `recipient` (CC native) or `to` (genie internal)
|
|
171
|
+
* - body: `message` (CC native) or `content` (genie internal)
|
|
172
|
+
*
|
|
173
|
+
* Returns `{ recipient: undefined }` when the input is not parseable.
|
|
174
|
+
*/
|
|
175
|
+
function parseSendMessageInput(input: unknown): { recipient?: string; body?: string } {
|
|
176
|
+
if (!input || typeof input !== 'object') return {};
|
|
177
|
+
const obj = input as Record<string, unknown>;
|
|
178
|
+
const recipient = typeof obj.recipient === 'string' ? obj.recipient : typeof obj.to === 'string' ? obj.to : undefined;
|
|
179
|
+
const body =
|
|
180
|
+
typeof obj.message === 'string' ? obj.message : typeof obj.content === 'string' ? obj.content : undefined;
|
|
181
|
+
return { recipient, body };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a PreToolUse hook that intercepts `SendMessage` to recipient "omni"
|
|
186
|
+
* and routes the message through NATS to the omni reply path.
|
|
187
|
+
*
|
|
188
|
+
* Mirrors `handleDoneTool`'s text-action publish so agents can use a single
|
|
189
|
+
* messaging interface (`SendMessage`) regardless of executor transport.
|
|
190
|
+
*
|
|
191
|
+
* Behavior:
|
|
192
|
+
* - tool_name !== 'SendMessage' → no decision (handler chain continues)
|
|
193
|
+
* - recipient !== 'omni' → no decision (native delivery proceeds)
|
|
194
|
+
* - OMNI_INSTANCE missing in env → no decision (not bridge mode)
|
|
195
|
+
* - matched → publish + deny with success-equivalent reason
|
|
196
|
+
*/
|
|
197
|
+
export function createSendMessageOmniHook(
|
|
198
|
+
env: Record<string, string>,
|
|
199
|
+
natsPublish: NatsPublishFn | null,
|
|
200
|
+
): HookCallback {
|
|
201
|
+
return async (input): Promise<SyncHookJSONOutput> => {
|
|
202
|
+
const hookInput = input as PreToolUseHookInput;
|
|
203
|
+
if (hookInput.tool_name !== 'SendMessage') return {};
|
|
204
|
+
|
|
205
|
+
const { recipient, body } = parseSendMessageInput(hookInput.tool_input);
|
|
206
|
+
if (recipient !== 'omni') return {};
|
|
207
|
+
|
|
208
|
+
const instanceId = env.OMNI_INSTANCE ?? '';
|
|
209
|
+
const chatId = env.OMNI_CHAT ?? '';
|
|
210
|
+
const agent = env.OMNI_AGENT ?? '';
|
|
211
|
+
|
|
212
|
+
if (!instanceId || !chatId) return {};
|
|
213
|
+
|
|
214
|
+
// Reject empty/missing message bodies with an explicit error so the agent
|
|
215
|
+
// retries with a real payload — otherwise we'd publish an empty reply and
|
|
216
|
+
// the model would believe delivery succeeded.
|
|
217
|
+
if (!body || body.trim() === '') {
|
|
218
|
+
return {
|
|
219
|
+
hookSpecificOutput: {
|
|
220
|
+
hookEventName: 'PreToolUse',
|
|
221
|
+
permissionDecision: 'deny',
|
|
222
|
+
permissionDecisionReason:
|
|
223
|
+
'SendMessage(recipient: "omni") requires a non-empty `message` field. Retry with the reply text.',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!natsPublish) {
|
|
229
|
+
console.warn('[claude-sdk] SendMessage(to: omni) intercepted but NATS publish unavailable — message dropped');
|
|
230
|
+
return {
|
|
231
|
+
hookSpecificOutput: {
|
|
232
|
+
hookEventName: 'PreToolUse',
|
|
233
|
+
permissionDecision: 'deny',
|
|
234
|
+
permissionDecisionReason: 'Omni bridge unavailable — message could not be delivered.',
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
natsPublish(`omni.reply.${instanceId}.${chatId}`, buildReplyPayload(agent, chatId, instanceId, { content: body }));
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
hookSpecificOutput: {
|
|
243
|
+
hookEventName: 'PreToolUse',
|
|
244
|
+
permissionDecision: 'deny',
|
|
245
|
+
permissionDecisionReason: 'Message delivered to user via omni bridge.',
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
156
251
|
async function createDoneMcpServer(env: Record<string, string>, natsPublish: NatsPublishFn | null) {
|
|
157
252
|
const { createSdkMcpServer, tool } = await import('@anthropic-ai/claude-agent-sdk');
|
|
158
253
|
return createSdkMcpServer({
|
|
@@ -350,9 +445,20 @@ export class ClaudeSdkOmniExecutor implements IExecutor {
|
|
|
350
445
|
}
|
|
351
446
|
|
|
352
447
|
const doneMcp = await createDoneMcpServer(state.env, this.natsPublish);
|
|
448
|
+
const sendMessageHooks: Partial<Record<string, HookCallbackMatcher[]>> | undefined = isTurnBased
|
|
449
|
+
? {
|
|
450
|
+
PreToolUse: [
|
|
451
|
+
{
|
|
452
|
+
matcher: 'SendMessage',
|
|
453
|
+
hooks: [createSendMessageOmniHook(state.env, this.natsPublish)],
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
}
|
|
457
|
+
: undefined;
|
|
353
458
|
const extraOptions: Record<string, unknown> = {
|
|
354
459
|
abortController: state.abortController,
|
|
355
460
|
mcpServers: { 'genie-omni-tools': doneMcp },
|
|
461
|
+
...(sendMessageHooks && { hooks: sendMessageHooks }),
|
|
356
462
|
};
|
|
357
463
|
if (state.claudeSessionId) {
|
|
358
464
|
extraOptions.resume = state.claudeSessionId;
|
|
@@ -12,20 +12,27 @@ export function buildTurnBasedPrompt(senderName: string, instanceId: string, cha
|
|
|
12
12
|
You are responding to a WhatsApp message from ${senderName}.
|
|
13
13
|
Your context is pre-set (instance: ${instanceId}, chat: ${chatId}) — do NOT use \`omni use\` or \`omni open\`.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Reply Channels
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
You have two equivalent ways to send a reply to the user:
|
|
18
|
+
|
|
19
|
+
1. **SendMessage** (preferred): \`SendMessage(recipient: "omni", message: "your reply")\` —
|
|
20
|
+
intercepted by the omni bridge and delivered as a WhatsApp text message.
|
|
21
|
+
You may call SendMessage multiple times in one turn for multi-message replies.
|
|
22
|
+
2. **omni done text='...'** — closes the turn AND sends a final text in one call.
|
|
23
|
+
|
|
24
|
+
## Available Tools
|
|
25
|
+
|
|
26
|
+
- SendMessage(recipient: "omni", message: '...') — send a text reply (repeatable)
|
|
27
|
+
- omni done text='...' — send final text + close turn (use as the LAST action)
|
|
28
|
+
- omni done react='emoji' — react instead of replying, then close turn
|
|
29
|
+
- omni done media='/path' caption='...' — send media + close turn
|
|
30
|
+
- omni done skip=true — close turn silently
|
|
23
31
|
|
|
24
32
|
## Rules
|
|
25
33
|
|
|
26
|
-
1. Use \`omni
|
|
27
|
-
2.
|
|
28
|
-
3.
|
|
29
|
-
4. Do NOT generate bare text as your reply — it will go nowhere. Use omni say or omni done.
|
|
34
|
+
1. Use \`SendMessage(recipient: "omni", ...)\` for normal text replies.
|
|
35
|
+
2. ALWAYS call \`omni done\` as your LAST action to close the turn — even if you already sent SendMessage replies, call \`omni done skip=true\`.
|
|
36
|
+
3. Do NOT generate bare text as your reply — it will go nowhere. Use SendMessage or omni done.
|
|
30
37
|
`.trim();
|
|
31
38
|
}
|
|
@@ -63,6 +63,12 @@ interface SessionEntry {
|
|
|
63
63
|
spawning: boolean;
|
|
64
64
|
buffer: OmniMessage[];
|
|
65
65
|
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
66
|
+
/**
|
|
67
|
+
* Set when a reset arrives while the entry is still in the spawning state.
|
|
68
|
+
* `spawnSession` checks this flag after `executor.spawn` resolves and tears
|
|
69
|
+
* down the freshly-created session instead of putting it into rotation.
|
|
70
|
+
*/
|
|
71
|
+
cancelled?: boolean;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export interface BridgeStatus {
|
|
@@ -263,6 +269,13 @@ export class OmniBridge {
|
|
|
263
269
|
this.processTurnEvents(sub);
|
|
264
270
|
}
|
|
265
271
|
|
|
272
|
+
// Session reset events from Omni — kill the agent's executor session when the user
|
|
273
|
+
// sends a reset token (e.g. 🗑️). Paired with omni-side publisher in
|
|
274
|
+
// automagik-dev/omni#361. Subject hierarchy: omni.session.reset.{instance}.{chat}
|
|
275
|
+
// (recursive `>` because WhatsApp chat ids contain dots).
|
|
276
|
+
const sessionResetSub = this.nc.subscribe('omni.session.reset.>');
|
|
277
|
+
this.processSessionResetEvents(sessionResetSub);
|
|
278
|
+
|
|
266
279
|
// Start idle session checker
|
|
267
280
|
this.idleCheckTimer = setInterval(() => this.checkIdleSessions(), IDLE_CHECK_INTERVAL_MS);
|
|
268
281
|
|
|
@@ -550,9 +563,14 @@ export class OmniBridge {
|
|
|
550
563
|
*/
|
|
551
564
|
private findSessionKey(instanceId: string, chatId: string): string | undefined {
|
|
552
565
|
for (const [key, entry] of this.sessions) {
|
|
553
|
-
if (entry.instanceId
|
|
554
|
-
|
|
555
|
-
|
|
566
|
+
if (entry.instanceId !== instanceId) continue;
|
|
567
|
+
// Live entry — match against the spawned session's chatId.
|
|
568
|
+
if (entry.session?.chatId === chatId) return key;
|
|
569
|
+
// Spawning entry — session is null, so match against the map key suffix
|
|
570
|
+
// (`${agent}:${chatId}`). Without this fallback, a reset arriving in the
|
|
571
|
+
// narrow window between placeholder insertion and spawn completion would
|
|
572
|
+
// be misclassified as a cold chat and ignored.
|
|
573
|
+
if (entry.spawning && key.endsWith(`:${chatId}`)) return key;
|
|
556
574
|
}
|
|
557
575
|
return undefined;
|
|
558
576
|
}
|
|
@@ -570,6 +588,93 @@ export class OmniBridge {
|
|
|
570
588
|
}
|
|
571
589
|
}
|
|
572
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Process incoming session reset events from NATS.
|
|
593
|
+
*
|
|
594
|
+
* Subject shape: `omni.session.reset.{instanceId}.{chatId}` — chatId may
|
|
595
|
+
* contain dots (WhatsApp ids look like `+5511...@s.whatsapp.net`), so we
|
|
596
|
+
* splice from index 4 onward.
|
|
597
|
+
*
|
|
598
|
+
* Payload is best-effort: malformed JSON is tolerated and treated as `{}`,
|
|
599
|
+
* because the routing decision lives in the subject, not the body. The
|
|
600
|
+
* `action` field is read for observability only — the only behavior today
|
|
601
|
+
* is "kill the session".
|
|
602
|
+
*/
|
|
603
|
+
private async processSessionResetEvents(sub: Subscription): Promise<void> {
|
|
604
|
+
for await (const msg of sub) {
|
|
605
|
+
try {
|
|
606
|
+
const parts = msg.subject.split('.');
|
|
607
|
+
if (parts.length < 5) {
|
|
608
|
+
console.warn(`[omni-bridge] Malformed session-reset subject: ${msg.subject}`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const instanceId = parts[3];
|
|
612
|
+
const chatId = parts.slice(4).join('.');
|
|
613
|
+
|
|
614
|
+
let action: string | undefined;
|
|
615
|
+
try {
|
|
616
|
+
const payload = JSON.parse(this.sc.decode(msg.data)) as { action?: string };
|
|
617
|
+
action = payload.action;
|
|
618
|
+
} catch {
|
|
619
|
+
// Malformed/empty payload — proceed with subject-only routing.
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
await this.handleSessionReset(instanceId, chatId, action);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
console.warn('[omni-bridge] Error processing session reset event:', err);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Handle a session reset request — evict the session and shut down the executor.
|
|
631
|
+
*
|
|
632
|
+
* Defensive: no-op when the session is unknown (cold chat), so a user can
|
|
633
|
+
* tap reset on a chat that has no live agent without producing an error.
|
|
634
|
+
*
|
|
635
|
+
* Mirrors `handleTurnTimeout`'s cleanup so the session map, idle timer, and
|
|
636
|
+
* executor stay coherent.
|
|
637
|
+
*/
|
|
638
|
+
private async handleSessionReset(instanceId: string, chatId: string, action?: string): Promise<void> {
|
|
639
|
+
const sessionKey = this.findSessionKey(instanceId, chatId);
|
|
640
|
+
if (!sessionKey) {
|
|
641
|
+
// Cold chat — nothing to reset, but acknowledge in logs for traceability.
|
|
642
|
+
console.log(`[omni-bridge] Session reset for cold chat ${instanceId}/${chatId} — no-op`);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const entry = this.sessions.get(sessionKey);
|
|
647
|
+
if (!entry) return;
|
|
648
|
+
|
|
649
|
+
const actionTag = action ? ` (action=${action})` : '';
|
|
650
|
+
|
|
651
|
+
// Spawning sessions: we cannot interrupt the in-flight executor.spawn call,
|
|
652
|
+
// but we can flag the entry so spawnSession tears down the freshly-created
|
|
653
|
+
// session as soon as the await resolves. The placeholder is removed from
|
|
654
|
+
// the map immediately so subsequent messages spawn a fresh session.
|
|
655
|
+
if (entry.spawning) {
|
|
656
|
+
console.log(`[omni-bridge] Session reset for spawning ${sessionKey}${actionTag}, marking cancelled`);
|
|
657
|
+
entry.cancelled = true;
|
|
658
|
+
entry.buffer = []; // Drop buffered messages — user explicitly reset.
|
|
659
|
+
this.turnTracker.close(sessionKey, 'reset');
|
|
660
|
+
this.removeSession(sessionKey);
|
|
661
|
+
await this.drainQueue();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!entry.session) return;
|
|
666
|
+
|
|
667
|
+
console.log(`[omni-bridge] Session reset for ${sessionKey}${actionTag}, evicting`);
|
|
668
|
+
this.turnTracker.close(sessionKey, 'reset');
|
|
669
|
+
try {
|
|
670
|
+
await this.executor.shutdown(entry.session);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
console.warn(`[omni-bridge] Error shutting down reset session ${sessionKey}:`, err);
|
|
673
|
+
}
|
|
674
|
+
this.removeSession(sessionKey);
|
|
675
|
+
await this.drainQueue();
|
|
676
|
+
}
|
|
677
|
+
|
|
573
678
|
/**
|
|
574
679
|
* Handle a turn timeout event — evict the session and shut down the executor.
|
|
575
680
|
*/
|
|
@@ -667,6 +772,19 @@ export class OmniBridge {
|
|
|
667
772
|
console.log(`[omni-bridge] Spawning session for ${key}...`);
|
|
668
773
|
const session = await this.executor.spawn(message.agent, message.chatId, spawnEnv);
|
|
669
774
|
|
|
775
|
+
// Reset arrived while spawn was in flight — tear down the freshly-created
|
|
776
|
+
// session and bail. The placeholder was already removed from the map by
|
|
777
|
+
// handleSessionReset, so we don't need to clean it up here.
|
|
778
|
+
if (placeholder.cancelled) {
|
|
779
|
+
console.log(`[omni-bridge] Spawn for ${key} completed but was cancelled by reset, shutting down`);
|
|
780
|
+
try {
|
|
781
|
+
await this.executor.shutdown(session);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.warn(`[omni-bridge] Error shutting down cancelled spawn for ${key}:`, err);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
670
788
|
placeholder.session = session;
|
|
671
789
|
placeholder.spawning = false;
|
|
672
790
|
|