@automagik/genie 4.260409.6 → 4.260409.7

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "genie",
13
- "version": "4.260409.6",
13
+ "version": "4.260409.7",
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
@@ -1534,8 +1534,9 @@ exec ${fullCommand}
1534
1534
  LEFT JOIN executors e ON e.id = s.executor_id
1535
1535
  WHERE s.id = ${resumeSessionId} OR s.claude_session_id = ${resumeSessionId}
1536
1536
  ORDER BY s.started_at DESC LIMIT 1
1537
- `,[]);if(byPgId&&byPgId.length>0){if(dbSessionId=byPgId[0].id,turnIndex=byPgId[0].total_turns??0,byPgId[0].csid)resolvedClaudeSessionId=byPgId[0].csid;await safePgCall("reopen-session",(sql)=>sql`UPDATE sessions SET status = 'active', updated_at = now() WHERE id = ${dbSessionId}`,void 0)}if(runtimeExtraOptions)runtimeExtraOptions.resume=resolvedClaudeSessionId}if(!dbSessionId)dbSessionId=await startSession2(safePgCall,spawnContext.executorId,void 0,ctx.agentIdentityId??null,ctx.validated.team,ctx.validated.role);if(dbSessionId)await recordTurn2(safePgCall,dbSessionId,turnIndex++,"user",prompt2);let streaming=streamOpts?.stream??!1,extraOptions={...streaming&&{includePartialMessages:!0},...runtimeExtraOptions},hasExtraOptions=Object.keys(extraOptions).length>0,{messages:messages2}=sdkProvider.runQuery(spawnContext,prompt2,permConfig,hasExtraOptions?extraOptions:void 0,sdkConfig);if(ctx.executorId)await updateExecutorState(ctx.executorId,"running").catch(()=>{});let resultText="",claudeSessionId,toolCalls=[],pendingToolName="",pendingToolInput=null;if(streaming){let{formatSdkMessage:formatSdkMessage2}=await Promise.resolve().then(() => exports_claude_sdk_stream),format=streamOpts?.streamFormat??"text";try{for await(let message of messages2){let formatted=formatSdkMessage2(message,format);if(formatted!==null){if(process.stdout.write(formatted),format==="json")process.stdout.write(`
1538
- `)}if(message.type==="system"&&message.session_id)claudeSessionId=message.session_id;if(message.type==="assistant"&&message.message)for(let block of message.message.content){if(block.type==="tool_use")pendingToolName=block.name??"",pendingToolInput=block.input;if(block.type==="text"&&block.text)resultText+=block.text}if(message.type==="tool_result"){let msg=message;toolCalls.push({name:pendingToolName,input:pendingToolInput,output:typeof msg.content==="string"?msg.content.slice(0,500):"",is_error:!!msg.is_error}),pendingToolName="",pendingToolInput=null}if(message.type==="result"&&message.subtype==="success"){if(message.result&&!resultText)resultText=message.result;if(message.session_id)claudeSessionId=message.session_id}}}catch(err){if(!(err instanceof Error&&err.name==="AbortError"))console.error(`SDK query error: ${err instanceof Error?err.message:err}`)}}else try{for await(let message of messages2){if(message.type==="assistant"&&message.message)for(let block of message.message.content){if(block.type==="tool_use")pendingToolName=block.name??"",pendingToolInput=block.input;if(block.type==="text"&&block.text)process.stdout.write(block.text),resultText+=block.text}if(message.type==="tool_result"){let msg=message;toolCalls.push({name:pendingToolName,input:pendingToolInput,output:typeof msg.content==="string"?msg.content.slice(0,500):"",is_error:!!msg.is_error}),pendingToolName="",pendingToolInput=null}if(message.type==="result"&&message.subtype==="success"){if(message.result){if(console.log(message.result),!resultText)resultText=message.result}if(message.session_id)claudeSessionId=message.session_id}}}catch(err){if(!(err instanceof Error&&err.name==="AbortError"))console.error(`SDK query error: ${err instanceof Error?err.message:err}`)}if(dbSessionId){if(resultText||toolCalls.length>0){let content=toolCalls.length>0?JSON.stringify({text:resultText,tools:toolCalls}):resultText;await recordTurn2(safePgCall,dbSessionId,turnIndex++,"assistant",content)}await updateTurnCount2(safePgCall,dbSessionId,turnIndex),await endSession2(safePgCall,dbSessionId,"completed")}if(claudeSessionId&&spawnContext.executorId)await safePgCall("update-claude-session-id",(sql)=>sql`UPDATE executors SET claude_session_id = ${claudeSessionId} WHERE id = ${spawnContext.executorId}`,void 0);if(claudeSessionId&&dbSessionId)await safePgCall("update-session-claude-id",(sql)=>sql`UPDATE sessions SET claude_session_id = ${claudeSessionId} WHERE id = ${dbSessionId}`,void 0)}async function launchSdkSpawn(ctx,permissionsConfig,streamOpts,sdkConfig,runtimeExtraOptions){if(ctx.agentIdentityId&&ctx.executorId)await createAndLinkExecutor2(ctx.agentIdentityId,"claude-sdk","process",{id:ctx.executorId,claudeSessionId:null,state:"spawning",repoPath:ctx.cwd});if(await registerSpawnWorker(ctx,"sdk"),ctx.executorId)process.env.GENIE_EXECUTOR_ID=ctx.executorId;if(console.log(`Agent "${ctx.workerId}" starting via Claude Agent SDK...`),console.log(` Provider: claude-sdk | Team: ${ctx.validated.team} | Role: ${ctx.validated.role??"-"}`),ctx.executorId)console.log(` Executor: ${ctx.executorId}`);console.log("");let{resolvePermissionConfig:resolvePermissionConfig2}=await Promise.resolve().then(() => (init_claude_sdk_permissions(),exports_claude_sdk_permissions)),permConfig=resolvePermissionConfig2(permissionsConfig);if(await runSdkQuery(ctx,permConfig,streamOpts,sdkConfig,runtimeExtraOptions),ctx.executorId)await updateExecutorState(ctx.executorId,"done").catch(()=>{});return await unregister(ctx.workerId),console.log(`
1537
+ `,[]);if(byPgId&&byPgId.length>0){if(dbSessionId=byPgId[0].id,turnIndex=byPgId[0].total_turns??0,byPgId[0].csid)resolvedClaudeSessionId=byPgId[0].csid;await safePgCall("reopen-session",(sql)=>sql`UPDATE sessions SET status = 'active', updated_at = now() WHERE id = ${dbSessionId}`,void 0)}if(runtimeExtraOptions)runtimeExtraOptions.resume=resolvedClaudeSessionId}if(!dbSessionId)dbSessionId=await startSession2(safePgCall,spawnContext.executorId,void 0,ctx.agentIdentityId??null,ctx.validated.team,ctx.validated.role);if(dbSessionId)await recordTurn2(safePgCall,dbSessionId,turnIndex++,"user",prompt2);let streaming=streamOpts?.stream??!1;if(dbSessionId&&streaming){let fmt=streamOpts?.streamFormat??"text";if(fmt==="ndjson"||fmt==="json")process.stdout.write(`${JSON.stringify({type:"genie_session",session_id:dbSessionId})}
1538
+ `)}let extraOptions={...streaming&&{includePartialMessages:!0},...runtimeExtraOptions},hasExtraOptions=Object.keys(extraOptions).length>0,{messages:messages2}=sdkProvider.runQuery(spawnContext,prompt2,permConfig,hasExtraOptions?extraOptions:void 0,sdkConfig);if(ctx.executorId)await updateExecutorState(ctx.executorId,"running").catch(()=>{});let claudeSessionId,toolNameById=new Map,record=async(role,content,toolName)=>{if(!dbSessionId)return;await recordTurn2(safePgCall,dbSessionId,turnIndex++,role,content,toolName)},processMessage=async(message)=>{if(message.type==="system"&&message.session_id)claudeSessionId=message.session_id;if(message.type==="assistant"&&message.message)for(let block of message.message.content){if(block.type==="tool_use"){let b2=block,name=String(b2.name??""),id=String(b2.id??"");if(id)toolNameById.set(id,name);await record("tool_input",JSON.stringify(b2.input??{}).slice(0,500),name)}if(block.type==="text"&&block.text)await record("assistant",block.text)}if(message.type==="user"&&message.message?.content&&Array.isArray(message.message.content))for(let block of message.message.content){let b2=block;if(b2.type==="tool_result"){let toolId=String(b2.tool_use_id??""),toolName=toolNameById.get(toolId)??"",output=typeof b2.content==="string"?b2.content.slice(0,500):"";await record("tool_output",output,toolName)}}if(message.type==="result"&&message.subtype==="success"){if(message.session_id)claudeSessionId=message.session_id}};if(streaming){let{formatSdkMessage:formatSdkMessage2}=await Promise.resolve().then(() => exports_claude_sdk_stream),format=streamOpts?.streamFormat??"text";try{for await(let message of messages2){let formatted=formatSdkMessage2(message,format);if(formatted!==null){if(process.stdout.write(formatted),format==="json")process.stdout.write(`
1539
+ `)}await processMessage(message)}}catch(err){if(!(err instanceof Error&&err.name==="AbortError"))console.error(`SDK query error: ${err instanceof Error?err.message:err}`)}}else try{for await(let message of messages2){if(message.type==="assistant"&&message.message){for(let block of message.message.content)if(block.type==="text"&&block.text)process.stdout.write(block.text)}if(message.type==="result"&&message.subtype==="success"&&message.result)console.log(message.result);await processMessage(message)}}catch(err){if(!(err instanceof Error&&err.name==="AbortError"))console.error(`SDK query error: ${err instanceof Error?err.message:err}`)}if(dbSessionId)await updateTurnCount2(safePgCall,dbSessionId,turnIndex),await endSession2(safePgCall,dbSessionId,"completed");if(claudeSessionId&&spawnContext.executorId){let csId=claudeSessionId;await safePgCall("update-claude-session-id",(sql)=>sql`UPDATE executors SET claude_session_id = ${csId} WHERE id = ${spawnContext.executorId}`,void 0)}if(claudeSessionId&&dbSessionId){let csId=claudeSessionId,sessId=dbSessionId;await safePgCall("update-session-claude-id",(sql)=>sql`UPDATE sessions SET claude_session_id = ${csId} WHERE id = ${sessId}`,void 0)}}async function launchSdkSpawn(ctx,permissionsConfig,streamOpts,sdkConfig,runtimeExtraOptions){if(ctx.agentIdentityId&&ctx.executorId)await createAndLinkExecutor2(ctx.agentIdentityId,"claude-sdk","process",{id:ctx.executorId,claudeSessionId:null,state:"spawning",repoPath:ctx.cwd});if(await registerSpawnWorker(ctx,"sdk"),ctx.executorId)process.env.GENIE_EXECUTOR_ID=ctx.executorId;if(console.log(`Agent "${ctx.workerId}" starting via Claude Agent SDK...`),console.log(` Provider: claude-sdk | Team: ${ctx.validated.team} | Role: ${ctx.validated.role??"-"}`),ctx.executorId)console.log(` Executor: ${ctx.executorId}`);console.log("");let{resolvePermissionConfig:resolvePermissionConfig2}=await Promise.resolve().then(() => (init_claude_sdk_permissions(),exports_claude_sdk_permissions)),permConfig=resolvePermissionConfig2(permissionsConfig);if(await runSdkQuery(ctx,permConfig,streamOpts,sdkConfig,runtimeExtraOptions),ctx.executorId)await updateExecutorState(ctx.executorId,"done").catch(()=>{});return await unregister(ctx.workerId),console.log(`
1539
1540
  Agent "${ctx.workerId}" SDK session ended.`),ctx.workerId}async function launchInlineSpawn(ctx){let nt=ctx.validated.nativeTeam,paneId="inline";if(ctx.agentIdentityId&&ctx.executorId)await createAndLinkExecutor2(ctx.agentIdentityId,ctx.validated.provider,resolveExecutorTransport2(ctx.validated.provider,"inline"),{id:ctx.executorId,claudeSessionId:ctx.claudeSessionId??null,state:"spawning",repoPath:ctx.cwd});let workerEntry=await registerSpawnWorker(ctx,"inline");if(await notifySpawnJoin(ctx,"inline"),console.log(`Agent "${ctx.workerId}" starting inline...`),console.log(` Provider: ${ctx.launch.provider} | Team: ${ctx.validated.team} | Role: ${ctx.validated.role??"-"}`),nt?.enabled)console.log(` Native: enabled | AgentID: ${workerEntry.nativeAgentId}`);console.log("");let{spawnSync:spawnSync3}=__require("child_process"),envVars={...process.env,...ctx.launch.env??{}},result2=spawnSync3("sh",["-c",ctx.launch.command],{env:envVars,stdio:"inherit"});if(ctx.agentIdentityId&&ctx.executorId)await updateExecutorState(ctx.executorId,"done").catch(()=>{});if(await unregister(ctx.workerId),nt?.enabled&&ctx.agentName)await clearNativeInbox(ctx.validated.team,ctx.agentName).catch(()=>{}),await unregisterNativeMember(ctx.validated.team,ctx.agentName).catch(()=>{});return console.log(`
1540
1541
  Agent "${ctx.workerId}" session ended.`),process.exit(result2.status??0)}function prependEnvVars(command,env){if(!env||Object.keys(env).length===0)return command;return`env ${Object.entries(env).map(([k,v])=>`${k}=${v}`).join(" ")} ${command}`}async function findDeadResumable(team,role){let candidate=(await list()).find((w)=>w.role===role&&w.team===team&&w.claudeSessionId&&w.provider==="claude");if(!candidate)return null;return await isPaneAlive(candidate.paneId)?null:candidate}async function rejectDuplicateRole(team,role){let existing=await list();for(let w of existing)if(w.role===role&&w.team===team){if(await isPaneAlive(w.paneId)&&w.session){if(await getPaneSession(w.paneId)!==w.session){await unregister(w.id);continue}console.error(`Error: Worker with role "${role}" already exists in team "${team}" (state: ${w.state}, pane: ${w.paneId})
1541
1542
  Use a different --role name for a second worker, e.g.: --role ${role}-2`),process.exit(1)}await unregister(w.id)}}async function getPaneSession(paneId){try{return(await executeTmux2(`display-message -t '${paneId}' -p '#{session_name}'`)).trim()||null}catch{return null}}async function resolveNativeTeam(team,_repoPath,options){let parentSessionId=(await getTeam(team))?.nativeTeamParentSessionId;if(!parentSessionId)parentSessionId=await discoverClaudeParentSessionId()??`genie-${team}`;await ensureNativeTeam(team,`Genie team: ${team}`,parentSessionId);let spawnColor=options.color??await assignColor(team),nativeTeam;if(options.provider==="claude")nativeTeam={enabled:!0,parentSessionId,color:spawnColor,agentType:options.role??"general-purpose",planModeRequired:options.planMode,permissionMode:options.permissionMode,agentName:options.role};return{parentSessionId,spawnColor,nativeTeam}}async function resolveAgentForSpawn(name,options){let resolved=await resolve3(name);if(!resolved)console.error(`Error: Agent "${name}" not found in directory or built-ins.`),console.error(` Register with: genie dir add ${name} --dir <path>`),console.error(" Or use a built-in: engineer, reviewer, qa, fix, ..."),process.exit(1);let entry=resolved.entry,identityPath=null;if(resolved.builtin)identityPath=resolveBuiltinAgentPath(name);else if(entry.dir)identityPath=loadIdentity(entry);let repoPath=resolveAgentWorkingDir(entry,options.cwd),model=options.model;if(!model){let ctx=buildSpawnResolveContext(name,entry);model=resolveField(entry,"model",ctx)}return{entry,repoPath,identityPath,model}}function buildSpawnResolveContext(agentName,_entry){let ctx={};try{let ws=findWorkspace();if(ws){let wsConfig=getWorkspaceConfig(ws.root);ctx.workspaceDefaults=wsConfig.agents?.defaults}}catch{}if(agentName.includes("/")){let parentName=agentName.split("/")[0];try{let{readFileSync:readFileSync16,existsSync:existsSync26}=__require("fs"),{join:join31}=__require("path"),ws=findWorkspace();if(ws){let parentAgentsMd=join31(ws.root,"agents",parentName,"AGENTS.md");if(existsSync26(parentAgentsMd)){let{parseFrontmatter:parseFrontmatter2}=(init_frontmatter(),__toCommonJS(exports_frontmatter)),parentFm=parseFrontmatter2(readFileSync16(parentAgentsMd,"utf-8"));ctx.parent={name:parentName,fields:parentFm}}}}catch{}}return ctx}function resolveAgentWorkingDir(entry,explicitCwd){if(explicitCwd)return explicitCwd;if(entry.dir)return entry.dir;let repo=entry.repo;if(repo&&__require("fs").existsSync(repo))return repo;return process.cwd()}async function buildSpawnParams2(name,team,options,agent){let resolvedProvider=options.provider??agent.entry.provider??"claude",params={provider:resolvedProvider,team,role:name,skill:options.skill,extraArgs:options.extraArgs,model:agent.model,systemPromptFile:agent.identityPath??void 0,promptMode:agent.entry.promptMode,initialPrompt:options.prompt??options.initialPrompt,newWindow:options.newWindow,windowTarget:options.window},{parentSessionId,spawnColor,nativeTeam}=await resolveNativeTeam(team,agent.repoPath,{...options,provider:resolvedProvider,role:name});if(nativeTeam)params.nativeTeam=nativeTeam;try{let{injectTeamHooks:injectTeamHooks2}=await Promise.resolve().then(() => (init_inject(),exports_inject));if(await injectTeamHooks2(team))console.log(` Hooks: injected genie hook dispatch into team "${team}"`)}catch(err){console.warn(`Warning: could not inject hooks for team "${team}": ${err instanceof Error?err.message:err}`)}if(params.provider==="claude")params.sessionId=crypto.randomUUID();if(params.provider==="claude"){if(await startOtelReceiver())params.otelPort=getOtelPort(),params.otelLogPrompts=!0}return{params,parentSessionId,spawnColor}}async function maybeStartOtelRelay(nt,validated,insideTmux){if(!nt?.enabled&&validated.provider==="codex"&&insideTmux)return ensureCodexOtelConfig(),await ensureOtelRelay(validated.team);return!1}function buildSdkRuntimeExtra(options){let extra={};if(options.sdkMaxTurns!=null)extra.maxTurns=options.sdkMaxTurns;if(options.sdkMaxBudget!=null)extra.maxBudgetUsd=options.sdkMaxBudget;if(options.sdkEffort)extra.effort=options.sdkEffort;if(options.sdkResume)extra.resume=options.sdkResume;return Object.keys(extra).length>0?extra:void 0}async function dispatchSpawn(ctx,validated,options,agent,insideTmux){if(validated.provider==="claude-sdk"){let streamFormat=options.streamFormat??"text",streamOpts=options.stream||options.sdkStream?{stream:!0,streamFormat}:void 0;return await launchSdkSpawn(ctx,agent.entry.permissions,streamOpts,agent.entry.sdk,buildSdkRuntimeExtra(options))}if(insideTmux)return await launchTmuxSpawn(ctx);return await launchInlineSpawn(ctx)}async function resolveTeamAndResume(effectiveRole,options){let teamWasExplicit=Boolean(options.team),team=options.team||await discoverTeamName();if(!team)return console.error("Error: --team is required (or set GENIE_TEAM, or run inside a genie session)"),process.exit(1);let deadResumable=await findDeadResumable(team,effectiveRole);if(deadResumable)return console.log(`Resuming existing session for "${effectiveRole}" (session: ${deadResumable.claudeSessionId?.slice(0,8)}...)`),await resumeAgent(deadResumable),{team,teamWasExplicit,resumed:deadResumable.id};return await rejectDuplicateRole(team,effectiveRole),{team,teamWasExplicit}}async function handleWorkerSpawn(name,options){let effectiveRole=options.role??name,agent=await resolveAgentForSpawn(name,options),{team,teamWasExplicit,resumed}=await resolveTeamAndResume(effectiveRole,options);if(resumed)return resumed;let teamConfig=await getTeam(team);if(teamConfig?.worktreePath&&!agent.entry?.dir)agent={...agent,repoPath:teamConfig.worktreePath};let{params,parentSessionId,spawnColor}=await buildSpawnParams2(effectiveRole,team,options,agent);if(!params.name)params.name=`${params.team}-${effectiveRole}`;let validated=validateSpawnParams(params),launch=buildLaunchCommand(validated),layoutMode=resolveLayoutMode(options.layout),workerId=await generateWorkerId2(validated.team,effectiveRole),insideTmux=Boolean(process.env.TMUX||options.session),nt=validated.nativeTeam,now=new Date().toISOString(),agentName=nt?.agentName??effectiveRole,agentIdentity=await findOrCreateAgent(agentName,team,effectiveRole);await terminateActiveExecutorWithCleanup(agentIdentity.id);let executorId=crypto.randomUUID(),otelRelayActive=await maybeStartOtelRelay(nt,validated,insideTmux),fullCommand=prependEnvVars(launch.command,launch.env),ctx={workerId,validated,launch,layoutMode,fullCommand,agentName,spawnColor,parentSessionId,claudeSessionId:validated.sessionId,otelRelayActive,now,transport:insideTmux?"tmux":"inline",extraArgs:options.extraArgs,cwd:agent.repoPath,spawnIntoCurrentWindow:!teamWasExplicit&&insideTmux&&!options.session,sessionOverride:options.session,autoResume:options.autoResume,agentIdentityId:agentIdentity.id,executorId};return recordAuditEvent("worker",workerId,"spawn",getActor(),{name,team:validated.team,provider:validated.provider}).catch(()=>{}),await dispatchSpawn(ctx,validated,options,agent,insideTmux)}async function cleanupWorkerNativeTeam(w){if(!w.team||!w.nativeAgentId)return;let agentName=w.nativeAgentId.split("@")[0];await clearNativeInbox(w.team,agentName).catch(()=>{}),await unregisterNativeMember(w.team,agentName).catch(()=>{})}function killWorkerPane(w){try{let{execSync:execSync9}=__require("child_process"),currentPane=execSync9(genieTmuxCmd("display-message -p '#{pane_id}'"),{encoding:"utf-8"}).trim();if(w.paneId&&/^(%\d+|inline)$/.test(w.paneId)&&w.paneId!==currentPane)execSync9(genieTmuxCmd(`kill-pane -t ${w.paneId}`),{stdio:"ignore"});else if(w.paneId===currentPane)console.log(" (skipped pane kill \u2014 would kill current session)")}catch{}}function cleanupRelayFiles(id){try{let{join:join31}=__require("path"),{homedir:homedir19}=__require("os"),{unlinkSync:unlinkSync7}=__require("fs"),relayDir=join31(homedir19(),".genie","relay");for(let suffix of["-pane","-meta"])try{unlinkSync7(join31(relayDir,`${id}${suffix}`))}catch{}}catch{}}async function resolveWorkerByName(name){let exact=await get(name);if(exact)return exact;let workers=await list(),byRole=workers.filter((w)=>w.role===name);if(byRole.length===1)return byRole[0];if(byRole.length>1){console.error(`Multiple agents with role "${name}". Specify full ID:`);for(let w of byRole)console.error(` ${w.id} (team: ${w.team})`);process.exit(1)}let bySuffix=workers.filter((w)=>w.id.endsWith(`-${name}`));if(bySuffix.length===1)return bySuffix[0];if(bySuffix.length>1){console.error(`Multiple agents matching "${name}". Specify full ID:`);for(let w of bySuffix)console.error(` ${w.id}`);process.exit(1)}console.error(`Agent "${name}" not found.`),console.error(" Run `genie agent list` to see agents."),process.exit(1)}async function handleWorkerKill(name){let w=await resolveWorkerByName(name);killWorkerPane(w),cleanupRelayFiles(w.id),await cleanupWorkerNativeTeam(w),await unregister(w.id),console.log(`Agent "${w.id}" killed and unregistered (template preserved).`),recordAuditEvent("worker",w.id,"kill",getActor(),{name}).catch(()=>{})}async function handleWorkerStop(name){let w=await resolveWorkerByName(name);if(w.state==="suspended"){console.log(`Agent "${w.id}" is already stopped.`);return}let{suspendWorker:suspendWorker2}=await Promise.resolve().then(() => (init_idle_timeout(),exports_idle_timeout));if(await suspendWorker2(w.id)){if(console.log(`Agent "${w.id}" stopped.`),w.claudeSessionId)console.log(` Session preserved: ${w.claudeSessionId}`);console.log(` Send a message to auto-resume: genie send '...' --to ${w.id}`),recordAuditEvent("worker",w.id,"stop",getActor(),{name}).catch(()=>{})}else console.error(`Failed to stop agent "${w.id}".`),process.exit(1)}async function isResumeEligible(w){if(!w.claudeSessionId)return!1;if(w.state==="done")return!1;let paneAlive=await isPaneAlive(w.paneId);if((w.state==="suspended"||w.state==="error")&&!paneAlive)return!0;if(!paneAlive&&(w.state==="working"||w.state==="idle"||w.state==="spawning"))return!0;return!1}async function resumeAllAgents(){let workers=await list(),toResume=[];for(let w of workers)if(await isResumeEligible(w))toResume.push(w);if(toResume.length===0){console.log("No eligible agents to resume.");return}console.log(`Resuming ${toResume.length} agent(s)...`);for(let w of toResume)try{await resumeAgent(w)}catch(err){console.error(` Failed to resume "${w.id}": ${err instanceof Error?err.message:err}`)}}async function handleWorkerResume(name,options){if(options.all)return resumeAllAgents();if(!name)console.error("Error: provide an agent name, or use --all to resume all eligible agents."),process.exit(1);let w=await resolveWorkerByName(name);if(!w.claudeSessionId)console.error(`Error: Agent "${w.id}" has no Claude session ID \u2014 cannot resume.`),console.error(" Only agents spawned with the Claude provider have resumable sessions."),process.exit(1);if(await isPaneAlive(w.paneId)){console.log(`Agent "${w.id}" is already running (pane ${w.paneId} is alive).`);return}await resumeAgent(w)}async function buildResumeParams(agent,template){let agentName=agent.role??agent.id,provider=template?.provider??agent.provider??"claude",team=template?.team??agent.team??"genie",systemPromptFile,promptMode,dirEntry=await get2(agentName);if(dirEntry?.dir)systemPromptFile=loadIdentity(dirEntry)??void 0,promptMode=dirEntry.promptMode;return{provider,team,role:agentName,skill:template?.skill??agent.skill,extraArgs:template?.extraArgs,resume:agent.claudeSessionId,name:`${team}-${agentName}`,model:dirEntry?.model,systemPromptFile,promptMode}}function formatGroupStatus(name,group,allGroups){let detail=group.status;if(group.completedAt)detail+=` (completed at ${group.completedAt})`;else if(group.startedAt)detail+=` (started at ${group.startedAt})`;if(group.status==="blocked"&&group.dependsOn.length>0){let pending=group.dependsOn.filter((dep)=>allGroups[dep]?.status!=="done");if(pending.length>0)detail+=` (depends on ${pending.join(", ")})`}return`Group ${name}: ${detail}`}async function buildResumeContext(agent){if((agent.role==="team-lead"||agent.team&&agent.role===await resolveTeamLeaderName(agent.team))&&agent.wishSlug)try{let state=await(await Promise.resolve().then(() => (init_wish_state(),exports_wish_state))).getState(agent.wishSlug,agent.repoPath);if(state){let groupLines=Object.entries(state.groups).map(([name,group])=>formatGroupStatus(name,group,state.groups));return["You were resumed after a crash. Here's where you left off:",`Wish: ${state.wish}`,"",...groupLines,"",`Continue from where you left off. Run \`genie status ${state.wish}\` to verify, then dispatch the next wave.`].join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260409.6",
3
+ "version": "4.260409.7",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260409.6",
3
+ "version": "4.260409.7",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260409.6",
3
+ "version": "4.260409.7",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared mock for `@anthropic-ai/claude-agent-sdk`.
3
+ *
4
+ * Bun's `mock.module()` is process-global and first-registration-wins. When
5
+ * multiple test files register competing mocks for the same module, whichever
6
+ * loads first locks the global cache, and other files' mocks become dead weight.
7
+ *
8
+ * This file provides a SINGLE `queryMock` instance used by both:
9
+ * - `src/__tests__/sdk-integration.test.ts`
10
+ * - `src/services/executors/__tests__/_sdk-mocks.ts`
11
+ *
12
+ * It ONLY mocks `@anthropic-ai/claude-agent-sdk` — no directory, registry, or
13
+ * other module mocks — so it's safe to import from any test file without
14
+ * polluting unrelated modules.
15
+ */
16
+
17
+ import { mock } from 'bun:test';
18
+
19
+ /** Default SDK query implementation — yields one assistant reply + success result with session_id. */
20
+ const defaultQueryImpl = () => {
21
+ const gen = (async function* () {
22
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'reply' }] } };
23
+ yield { type: 'result', subtype: 'success', session_id: 'sdk-session-aaa' };
24
+ })();
25
+ return Object.assign(gen, {
26
+ interrupt: mock(),
27
+ setPermissionMode: mock(),
28
+ setModel: mock(),
29
+ return: mock(async () => ({ value: undefined, done: true })),
30
+ throw: mock(async () => ({ value: undefined, done: true })),
31
+ });
32
+ };
33
+
34
+ export const queryMock = mock(defaultQueryImpl);
35
+
36
+ export function resetQueryMock(): void {
37
+ queryMock.mockReset();
38
+ queryMock.mockImplementation(defaultQueryImpl);
39
+ }
40
+
41
+ // ============================================================================
42
+ // Register the SDK mock — this is the single source of truth for the process.
43
+ // ============================================================================
44
+
45
+ mock.module('@anthropic-ai/claude-agent-sdk', () => ({
46
+ query: queryMock,
47
+ createSdkMcpServer: mock((opts: any) => ({
48
+ type: 'sdk' as const,
49
+ name: opts.name,
50
+ instance: {},
51
+ })),
52
+ tool: mock((_name: string, _desc: string, _schema: any, handler: any) => ({
53
+ name: _name,
54
+ description: _desc,
55
+ inputSchema: _schema,
56
+ handler,
57
+ })),
58
+ }));
@@ -6,33 +6,15 @@
6
6
  * permission gate, frontmatter parsing, and config priority layering.
7
7
  */
8
8
 
9
- import { beforeEach, describe, expect, it, mock } from 'bun:test';
9
+ import { beforeEach, describe, expect, it, type mock } from 'bun:test';
10
10
  import type { SpawnContext } from '../lib/executor-types.js';
11
11
 
12
12
  // ============================================================================
13
- // Mock the SDK module before any provider imports
13
+ // Use the shared SDK mock eliminates process-global mock.module() race with
14
+ // executor tests. See _shared-sdk-query-mock.ts for details.
14
15
  // ============================================================================
15
16
 
16
- const mockQuery = mock(() => {
17
- const gen = (async function* () {
18
- yield { type: 'assistant', message: { content: [{ type: 'text', text: 'hello' }] } };
19
- })();
20
- return Object.assign(gen, {
21
- interrupt: mock(),
22
- setPermissionMode: mock(),
23
- setModel: mock(),
24
- return: mock(async () => ({ value: undefined, done: true })),
25
- throw: mock(async () => ({ value: undefined, done: true })),
26
- });
27
- });
28
-
29
- mock.module('@anthropic-ai/claude-agent-sdk', () => ({
30
- query: mockQuery,
31
- }));
32
-
33
- // No audit mocking — routeSdkMessage is fire-and-forget (.catch(() => {}))
34
- // and these tests never iterate the message stream, so audit is never invoked.
35
- // Removing audit mocks prevents spyOn leaks that corrupt audit.test.ts in the same bun process.
17
+ import { queryMock as mockQuery } from './_shared-sdk-query-mock.js';
36
18
 
37
19
  // ============================================================================
38
20
  // Dynamic imports (must come after mock.module)
@@ -61,33 +61,16 @@ const directoryResolveMock = mock(async (name: string) => ({
61
61
  builtin: false,
62
62
  }));
63
63
 
64
- /** Default SDK query implementation yields one assistant reply + success result with session_id. */
65
- const defaultQueryImpl = () => {
66
- const gen = (async function* () {
67
- yield { type: 'assistant', message: { content: [{ type: 'text', text: 'reply' }] } };
68
- yield { type: 'result', subtype: 'success', session_id: 'sdk-session-aaa' };
69
- })();
70
- return Object.assign(gen, {
71
- interrupt: mock(),
72
- setPermissionMode: mock(),
73
- setModel: mock(),
74
- return: mock(async () => ({ value: undefined, done: true })),
75
- throw: mock(async () => ({ value: undefined, done: true })),
76
- });
77
- };
78
-
79
- export const queryMock = mock(defaultQueryImpl);
64
+ // SDK query mock is defined in a shared file so that sdk-integration.test.ts
65
+ // (which only needs the SDK mock, not directory/registry mocks) can import the
66
+ // same instance without triggering the registry mocks below.
67
+ import {
68
+ queryMock as _queryMock,
69
+ resetQueryMock as _resetQueryMock,
70
+ } from '../../../__tests__/_shared-sdk-query-mock.js';
80
71
 
81
- /**
82
- * Reset queryMock to its default implementation. Use in beforeEach for tests
83
- * that care about the default behavior — some tests override via
84
- * mockImplementation() and that override persists across files because
85
- * queryMock is shared.
86
- */
87
- export function resetQueryMock(): void {
88
- queryMock.mockReset();
89
- queryMock.mockImplementation(defaultQueryImpl);
90
- }
72
+ export const queryMock = _queryMock;
73
+ export const resetQueryMock = _resetQueryMock;
91
74
 
92
75
  /**
93
76
  * Reset ALL shared mocks to their default implementations and clear call
@@ -155,17 +138,5 @@ mock.module('../../../lib/executor-registry.js', () => ({
155
138
  terminateExecutor: terminateExecutorMock,
156
139
  }));
157
140
 
158
- mock.module('@anthropic-ai/claude-agent-sdk', () => ({
159
- query: queryMock,
160
- createSdkMcpServer: mock((opts: any) => ({
161
- type: 'sdk' as const,
162
- name: opts.name,
163
- instance: {},
164
- })),
165
- tool: mock((_name: string, _desc: string, _schema: any, handler: any) => ({
166
- name: _name,
167
- description: _desc,
168
- inputSchema: _schema,
169
- handler,
170
- })),
171
- }));
141
+ // SDK mock.module registration is handled by _shared-sdk-query-mock.ts
142
+ // (imported above via re-export). No need to register again here.
@@ -435,6 +435,7 @@ export class ClaudeSdkOmniExecutor implements IExecutor {
435
435
  );
436
436
  }
437
437
 
438
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: delivery pipeline with prompt assembly, MCP setup, and session capture
438
439
  private async _processDelivery(
439
440
  session: ExecutorSession,
440
441
  state: SdkSessionState,
@@ -826,6 +826,7 @@ export class OmniBridge {
826
826
  /**
827
827
  * Spawn a new agent session for a chat.
828
828
  */
829
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: spawn orchestration with concurrent guard, env resolution, and error recovery
829
830
  private async spawnSession(message: OmniMessage): Promise<void> {
830
831
  const key = `${message.agent}:${message.chatId}`;
831
832
 
@@ -1029,6 +1029,15 @@ async function runSdkQuery(
1029
1029
  }
1030
1030
 
1031
1031
  const streaming = streamOpts?.stream ?? false;
1032
+
1033
+ // Emit session ID for streaming JSON consumers (ndjson/json)
1034
+ if (dbSessionId && streaming) {
1035
+ const fmt = streamOpts?.streamFormat ?? 'text';
1036
+ if (fmt === 'ndjson' || fmt === 'json') {
1037
+ process.stdout.write(`${JSON.stringify({ type: 'genie_session', session_id: dbSessionId })}\n`);
1038
+ }
1039
+ }
1040
+
1032
1041
  // Runtime overrides: streaming + CLI --sdk-* flags (highest priority, over directory sdkConfig)
1033
1042
  const extraOptions: Record<string, unknown> = {
1034
1043
  ...(streaming && { includePartialMessages: true }),
@@ -1047,11 +1056,54 @@ async function runSdkQuery(
1047
1056
  await executorRegistry.updateExecutorState(ctx.executorId, 'running').catch(() => {});
1048
1057
  }
1049
1058
 
1050
- let resultText = '';
1051
1059
  let claudeSessionId: string | undefined;
1052
- const toolCalls: Array<{ name: string; input: unknown; output?: string; is_error?: boolean }> = [];
1053
- let pendingToolName = '';
1054
- let pendingToolInput: unknown = null;
1060
+ // Map tool_use IDs to tool names for correlating tool_result rows
1061
+ const toolNameById = new Map<string, string>();
1062
+
1063
+ const record = async (
1064
+ role: 'user' | 'assistant' | 'tool_input' | 'tool_output',
1065
+ content: string,
1066
+ toolName?: string,
1067
+ ) => {
1068
+ if (!dbSessionId) return;
1069
+ await recordTurn(safePgCall, dbSessionId, turnIndex++, role, content, toolName);
1070
+ };
1071
+
1072
+ // Process SDK messages — same logic for streaming and non-streaming
1073
+ const processMessage = async (message: import('@anthropic-ai/claude-agent-sdk').SDKMessage) => {
1074
+ if (message.type === 'system' && (message as Record<string, unknown>).session_id) {
1075
+ claudeSessionId = (message as Record<string, unknown>).session_id as string;
1076
+ }
1077
+ if (message.type === 'assistant' && message.message) {
1078
+ for (const block of message.message.content) {
1079
+ if (block.type === 'tool_use') {
1080
+ const b = block as unknown as Record<string, unknown>;
1081
+ const name = String(b.name ?? '');
1082
+ const id = String(b.id ?? '');
1083
+ if (id) toolNameById.set(id, name);
1084
+ await record('tool_input', JSON.stringify(b.input ?? {}).slice(0, 500), name);
1085
+ }
1086
+ if (block.type === 'text' && block.text) {
1087
+ await record('assistant', block.text);
1088
+ }
1089
+ }
1090
+ }
1091
+ // Tool results come as user messages with tool_result content blocks
1092
+ if (message.type === 'user' && message.message?.content && Array.isArray(message.message.content)) {
1093
+ for (const block of message.message.content) {
1094
+ const b = block as unknown as Record<string, unknown>;
1095
+ if (b.type === 'tool_result') {
1096
+ const toolId = String(b.tool_use_id ?? '');
1097
+ const toolName = toolNameById.get(toolId) ?? '';
1098
+ const output = typeof b.content === 'string' ? b.content.slice(0, 500) : '';
1099
+ await record('tool_output', output, toolName);
1100
+ }
1101
+ }
1102
+ }
1103
+ if (message.type === 'result' && message.subtype === 'success') {
1104
+ if (message.session_id) claudeSessionId = message.session_id;
1105
+ }
1106
+ };
1055
1107
 
1056
1108
  if (streaming) {
1057
1109
  const { formatSdkMessage } = await import('../lib/providers/claude-sdk-stream.js');
@@ -1063,34 +1115,7 @@ async function runSdkQuery(
1063
1115
  process.stdout.write(formatted);
1064
1116
  if (format === 'json') process.stdout.write('\n');
1065
1117
  }
1066
- if (message.type === 'system' && (message as Record<string, unknown>).session_id) {
1067
- claudeSessionId = (message as Record<string, unknown>).session_id as string;
1068
- }
1069
- // Collect tool calls
1070
- if (message.type === 'assistant' && message.message) {
1071
- for (const block of message.message.content) {
1072
- if (block.type === 'tool_use') {
1073
- pendingToolName = ((block as unknown as Record<string, unknown>).name as string) ?? '';
1074
- pendingToolInput = (block as unknown as Record<string, unknown>).input;
1075
- }
1076
- if (block.type === 'text' && block.text) resultText += block.text;
1077
- }
1078
- }
1079
- if ((message as unknown as Record<string, unknown>).type === 'tool_result') {
1080
- const msg = message as Record<string, unknown>;
1081
- toolCalls.push({
1082
- name: pendingToolName,
1083
- input: pendingToolInput,
1084
- output: typeof msg.content === 'string' ? msg.content.slice(0, 500) : '',
1085
- is_error: !!msg.is_error,
1086
- });
1087
- pendingToolName = '';
1088
- pendingToolInput = null;
1089
- }
1090
- if (message.type === 'result' && message.subtype === 'success') {
1091
- if (message.result && !resultText) resultText = message.result;
1092
- if (message.session_id) claudeSessionId = message.session_id;
1093
- }
1118
+ await processMessage(message);
1094
1119
  }
1095
1120
  } catch (err) {
1096
1121
  if (!(err instanceof Error && err.name === 'AbortError')) {
@@ -1100,36 +1125,16 @@ async function runSdkQuery(
1100
1125
  } else {
1101
1126
  try {
1102
1127
  for await (const message of messages) {
1128
+ // Print text to stdout
1103
1129
  if (message.type === 'assistant' && message.message) {
1104
1130
  for (const block of message.message.content) {
1105
- if (block.type === 'tool_use') {
1106
- pendingToolName = ((block as unknown as Record<string, unknown>).name as string) ?? '';
1107
- pendingToolInput = (block as unknown as Record<string, unknown>).input;
1108
- }
1109
- if (block.type === 'text' && block.text) {
1110
- process.stdout.write(block.text);
1111
- resultText += block.text;
1112
- }
1131
+ if (block.type === 'text' && block.text) process.stdout.write(block.text);
1113
1132
  }
1114
1133
  }
1115
- if ((message as unknown as Record<string, unknown>).type === 'tool_result') {
1116
- const msg = message as Record<string, unknown>;
1117
- toolCalls.push({
1118
- name: pendingToolName,
1119
- input: pendingToolInput,
1120
- output: typeof msg.content === 'string' ? msg.content.slice(0, 500) : '',
1121
- is_error: !!msg.is_error,
1122
- });
1123
- pendingToolName = '';
1124
- pendingToolInput = null;
1125
- }
1126
- if (message.type === 'result' && message.subtype === 'success') {
1127
- if (message.result) {
1128
- console.log(message.result);
1129
- if (!resultText) resultText = message.result;
1130
- }
1131
- if (message.session_id) claudeSessionId = message.session_id;
1134
+ if (message.type === 'result' && message.subtype === 'success' && message.result) {
1135
+ console.log(message.result);
1132
1136
  }
1137
+ await processMessage(message);
1133
1138
  }
1134
1139
  } catch (err) {
1135
1140
  if (!(err instanceof Error && err.name === 'AbortError')) {
@@ -1138,28 +1143,27 @@ async function runSdkQuery(
1138
1143
  }
1139
1144
  }
1140
1145
 
1141
- // Record assistant response (with tools embedded) and end session
1146
+ // End session
1142
1147
  if (dbSessionId) {
1143
- if (resultText || toolCalls.length > 0) {
1144
- const content = toolCalls.length > 0 ? JSON.stringify({ text: resultText, tools: toolCalls }) : resultText;
1145
- await recordTurn(safePgCall, dbSessionId, turnIndex++, 'assistant', content);
1146
- }
1147
1148
  await updateTurnCount(safePgCall, dbSessionId, turnIndex);
1148
1149
  await endSession(safePgCall, dbSessionId, 'completed');
1149
1150
  }
1150
1151
 
1151
1152
  // Persist Claude SDK session ID on executor AND session for resume lookup
1152
1153
  if (claudeSessionId && spawnContext.executorId) {
1154
+ const csId = claudeSessionId;
1153
1155
  await safePgCall(
1154
1156
  'update-claude-session-id',
1155
- (sql) => sql`UPDATE executors SET claude_session_id = ${claudeSessionId} WHERE id = ${spawnContext.executorId}`,
1157
+ (sql) => sql`UPDATE executors SET claude_session_id = ${csId} WHERE id = ${spawnContext.executorId}`,
1156
1158
  undefined,
1157
1159
  );
1158
1160
  }
1159
1161
  if (claudeSessionId && dbSessionId) {
1162
+ const csId = claudeSessionId;
1163
+ const sessId = dbSessionId;
1160
1164
  await safePgCall(
1161
1165
  'update-session-claude-id',
1162
- (sql) => sql`UPDATE sessions SET claude_session_id = ${claudeSessionId} WHERE id = ${dbSessionId}`,
1166
+ (sql) => sql`UPDATE sessions SET claude_session_id = ${csId} WHERE id = ${sessId}`,
1163
1167
  undefined,
1164
1168
  );
1165
1169
  }