@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +3 -2
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/__tests__/_shared-sdk-query-mock.ts +58 -0
- package/src/__tests__/sdk-integration.test.ts +4 -22
- package/src/services/executors/__tests__/_sdk-mocks.ts +11 -40
- package/src/services/executors/claude-sdk.ts +1 -0
- package/src/services/omni-bridge.ts +1 -0
- package/src/term-commands/agents.ts +68 -64
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "genie",
|
|
13
|
-
"version": "4.260409.
|
|
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
|
|
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")
|
|
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": "genie",
|
|
3
|
-
"version": "4.260409.
|
|
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"
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 (
|
|
1116
|
-
|
|
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
|
-
//
|
|
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 = ${
|
|
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 = ${
|
|
1166
|
+
(sql) => sql`UPDATE sessions SET claude_session_id = ${csId} WHERE id = ${sessId}`,
|
|
1163
1167
|
undefined,
|
|
1164
1168
|
);
|
|
1165
1169
|
}
|