@automagik/genie 4.260507.7 → 4.260508.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/genie.js CHANGED
@@ -204,7 +204,7 @@ PANE_ID="$1"
204
204
  COLOR=$(${bin} display-message -p -t "$PANE_ID" '#{@genie_color}' 2>/dev/null)
205
205
  [ -z "$COLOR" ] && COLOR="default"
206
206
  ${bin} set-option -w pane-active-border-style "fg=$COLOR"
207
- `),chmodSync2(PANE_COLOR_SCRIPT,493)}async function applyPaneColor(paneId,color,windowId){let hex=TMUX_COLOR_MAP[color]??TMUX_COLOR_MAP.blue;try{ensurePaneColorScript(),await executeTmux2(`set-option -p -t '${paneId}' @genie_color '${hex}'`);try{let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db));await(await getConnection2())`UPDATE executors SET pane_color = ${hex} WHERE tmux_pane_id = ${paneId}`}catch{}if(windowId)await executeTmux2(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`)}catch{}}async function rehydratePaneColorHook(windowId){try{ensurePaneColorScript(),await executeTmux2(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`)}catch{}}async function resolveRepoSession(repoPath){let derived=basename(repoPath);try{let sessions=await listSessions(),exact=sessions.find((s)=>s.name===derived);if(exact)return exact.name;if(process.env.TMUX)try{let name=(await executeTmux2("display-message -p '#{session_name}'")).trim();if(name)return name}catch{}let partial=sessions.find((s)=>s.name.includes(derived));if(partial)return partial.name}catch{}return derived}function isTmuxSocketAlive(socketName){if(!socketName)return!1;let uid=process.getuid?.()??501;return existsSync7(join9(`/tmp/tmux-${uid}`,socketName))}async function isTmuxServerReachable(socketName){if(!socketName)return!1;if(!isTmuxSocketAlive(socketName))return!1;try{let{execSync:execSync2}=await import("child_process");return execSync2(`${tmuxBin()} -L ${shellQuote(socketName)} list-sessions -F ''`,{stdio:["ignore","ignore","ignore"],timeout:2000}),!0}catch{return!1}}async function isPaneAlive(paneId){if(!paneId||paneId==="inline")return!1;if(!/^%\d+$/.test(paneId))return!1;try{return(await executeTmux2(`display-message -t '${paneId}' -p '#{pane_dead}'`)).trim()==="0"}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("server exited")||message.includes("error connecting"))throw new TmuxUnreachableError(message);return!1}}async function isPaneProcessRunning(paneId,processName,execSyncFn){if(!paneId||paneId==="inline")return!1;if(!/^%\d+$/.test(paneId))return!1;try{let panePid=(await executeTmux2(`display-message -t '${paneId}' -p '#{pane_pid}'`)).trim();if(!panePid||!/^\d+$/.test(panePid))return!1;return(execSyncFn??(await import("child_process")).execSync)(`pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; done; true`,{encoding:"utf-8",timeout:5000}).toLowerCase().includes(processName.toLowerCase())}catch{return!1}}async function killWindow(sessionName,windowName){try{return await executeTmux2(`kill-window -t ${shellQuote(`${sessionName}:${windowName}`)}`),!0}catch{return!1}}var TMUX_COLOR_NAMES,TMUX_COLOR_MAP,PANE_COLOR_SCRIPT,TmuxUnreachableError;var init_tmux=__esm(()=>{init_genie_tokens();init_ensure_tmux();init_team_lead_command();TMUX_COLOR_NAMES=["red","blue","green","yellow","purple","orange","pink","cyan"],TMUX_COLOR_MAP=Object.fromEntries(TMUX_COLOR_NAMES.map((name,i2)=>[name,rotateHue(palette.accent,i2*45)])),PANE_COLOR_SCRIPT=`${__require("os").homedir()}/.genie/tmux-pane-color.sh`;TmuxUnreachableError=class TmuxUnreachableError extends Error{constructor(message){super(message);this.name="TmuxUnreachableError"}}});var exports_agent_registry={};__export(exports_agent_registry,{update:()=>update,unregister:()=>unregister,setCurrentExecutor:()=>setCurrentExecutor,saveTemplate:()=>saveTemplate,resolveAgentIdStrict:()=>resolveAgentIdStrict,resolveAgentId:()=>resolveAgentId,removeSubPane:()=>removeSubPane,register:()=>register,reconcileStaleSpawns:()=>reconcileStaleSpawns,listTemplates:()=>listTemplates,listForRender:()=>listForRender,listExhaustedZombies:()=>listExhaustedZombies,listAllExhaustedErrored:()=>listAllExhaustedErrored,listAgentsForRender:()=>listAgentsForRender,listAgents:()=>listAgents,list:()=>list,getTeamLeadEntry:()=>getTeamLeadEntry,getPane:()=>getPane,getElapsedTime:()=>getElapsedTime,getAgentResolverCounters:()=>getAgentResolverCounters,getAgentEffectiveState:()=>getAgentEffectiveState,getAgentByName:()=>getAgentByName,getAgent:()=>getAgent,get:()=>get,findOrCreateAgent:()=>findOrCreateAgent,findByWindow:()=>findByWindow,findByTask:()=>findByTask,findByPane:()=>findByPane,filterBySession:()=>filterBySession,auditAgentKind:()=>auditAgentKind,archiveExhaustedZombies:()=>archiveExhaustedZombies,archiveAllExhaustedErrored:()=>archiveAllExhaustedErrored,addSubPane:()=>addSubPane,_resetAgentResolverCountersForTests:()=>_resetAgentResolverCountersForTests});import{createHash,randomUUID}from"crypto";function resolveWorkerSocketName(){return process.env.GENIE_TMUX_SOCKET||"genie"}function ts(v){if(!v)return new Date().toISOString();return v instanceof Date?v.toISOString():v}function rowToAgent(r){let agent={id:r.id,paneId:r.pane_id,session:r.session,worktree:r.worktree??null,startedAt:ts(r.started_at),state:r.state,lastStateChange:ts(r.last_state_change),repoPath:r.repo_path};if(r.task_id!=null)agent.taskId=r.task_id;if(r.task_title!=null)agent.taskTitle=r.task_title;if(r.wish_slug!=null)agent.wishSlug=r.wish_slug;if(r.group_number!=null)agent.groupNumber=r.group_number;if(r.window_name!=null)agent.windowName=r.window_name;if(r.window_id!=null)agent.windowId=r.window_id;if(r.role!=null)agent.role=r.role;if(r.custom_name!=null)agent.customName=r.custom_name;if(r.sub_panes!=null){let sp=typeof r.sub_panes==="string"?JSON.parse(r.sub_panes):r.sub_panes;if(Array.isArray(sp)&&sp.length>0)agent.subPanes=sp}if(r.provider!=null)agent.provider=r.provider;if(r.transport!=null)agent.transport=r.transport;if(r.skill!=null)agent.skill=r.skill;if(r.team!=null)agent.team=r.team;if(r.tmux_window!=null)agent.window=r.tmux_window;if(r.native_agent_id!=null)agent.nativeAgentId=r.native_agent_id;if(r.native_color!=null)agent.nativeColor=r.native_color;if(r.native_team_enabled)agent.nativeTeamEnabled=r.native_team_enabled;if(r.parent_session_id!=null)agent.parentSessionId=r.parent_session_id;if(r.suspended_at!=null)agent.suspendedAt=ts(r.suspended_at);if(r.auto_resume!=null)agent.autoResume=r.auto_resume;if(r.resume_attempts!=null)agent.resumeAttempts=r.resume_attempts;if(r.last_resume_attempt!=null)agent.lastResumeAttempt=ts(r.last_resume_attempt);if(r.max_resume_attempts!=null)agent.maxResumeAttempts=r.max_resume_attempts;if(r.pane_color!=null)agent.paneColor=r.pane_color;if(agent.currentExecutorId=r.current_executor_id??null,agent.reportsTo=r.reports_to??null,agent.title=r.title??null,r.kind!=null)agent.kind=r.kind;return agent}function rowToTemplate(r){let tpl={id:r.id,provider:r.provider,team:r.team,cwd:r.cwd,lastSpawnedAt:ts(r.last_spawned_at)};if(r.role!=null)tpl.role=r.role;if(r.skill!=null)tpl.skill=r.skill;if(r.extra_args!=null){let ea=typeof r.extra_args==="string"?JSON.parse(r.extra_args):r.extra_args;if(Array.isArray(ea)&&ea.length>0)tpl.extraArgs=ea}if(r.native_team_enabled)tpl.nativeTeamEnabled=r.native_team_enabled;return tpl}function shortProjectHash(repoPath){return createHash("sha1").update(repoPath).digest("hex").slice(0,8)}function buildProjectTeamLeadEntryId(teamName,session,repoPath){return`team-lead:${session}:${shortProjectHash(repoPath)}:${teamName}`}function buildSessionTeamLeadEntryId(teamName,session){return`team-lead:${session}:${teamName}`}function buildLegacyTeamLeadEntryId(teamName){return`team-lead:${teamName}`}function assertRegisterableId(id){if(!REGISTER_ID_RE.test(id))throw Error(`register: refusing to insert non-UUID/non-dir agent id ${JSON.stringify(id)}. Spawn callers must resolve the durable identity row via findOrCreateAgent first and pass that UUID \u2014 the bare-name shadow path was retired in wish retire-session-names-id-only Group 3 (migration 061 enforces the same shape at the DB).`)}async function register(agent){assertRegisterableId(agent.id);let sql=await getConnection(),now=new Date().toISOString();if(agent.team){let existing=await sql`
207
+ `),chmodSync2(PANE_COLOR_SCRIPT,493)}async function applyPaneColor(paneId,color,windowId){let hex=TMUX_COLOR_MAP[color]??TMUX_COLOR_MAP.blue;try{ensurePaneColorScript(),await executeTmux2(`set-option -p -t '${paneId}' @genie_color '${hex}'`);try{let{getConnection:getConnection2}=await Promise.resolve().then(() => (init_db(),exports_db));await(await getConnection2())`UPDATE executors SET pane_color = ${hex} WHERE tmux_pane_id = ${paneId}`}catch{}if(windowId)await executeTmux2(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`)}catch{}}async function rehydratePaneColorHook(windowId){try{ensurePaneColorScript(),await executeTmux2(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`)}catch{}}async function resolveRepoSession(repoPath){let derived=basename(repoPath);try{let sessions=await listSessions(),exact=sessions.find((s)=>s.name===derived);if(exact)return exact.name;if(process.env.TMUX)try{let name=(await executeTmux2("display-message -p '#{session_name}'")).trim();if(name)return name}catch{}let partial=sessions.find((s)=>s.name.includes(derived));if(partial)return partial.name}catch{}return derived}function isTmuxSocketAlive(socketName){if(!socketName)return!1;let uid=process.getuid?.()??501;return existsSync7(join9(`/tmp/tmux-${uid}`,socketName))}async function isTmuxServerReachable(socketName){if(!socketName)return!1;if(!isTmuxSocketAlive(socketName))return!1;try{let{execSync:execSync2}=await import("child_process");return execSync2(`${tmuxBin()} -L ${shellQuote(socketName)} list-sessions -F ''`,{stdio:["ignore","ignore","ignore"],timeout:2000}),!0}catch{return!1}}async function isPaneAlive(paneId){if(!paneId||paneId==="inline")return!1;if(!/^%\d+$/.test(paneId))return!1;try{return(await executeTmux2(`display-message -t '${paneId}' -p '#{pane_dead}'`)).trim()==="0"}catch(error){let message=error instanceof Error?error.message:String(error);if(message.includes("no server running")||message.includes("server exited")||message.includes("error connecting"))throw new TmuxUnreachableError(message);return!1}}async function isPaneProcessRunning(paneId,processName,execSyncFn){if(!paneId||paneId==="inline")return!1;if(!/^%\d+$/.test(paneId))return!1;try{let panePid=(await executeTmux2(`display-message -t '${paneId}' -p '#{pane_pid}'`)).trim();if(!panePid||!/^\d+$/.test(panePid))return!1;return(execSyncFn??(await import("child_process")).execSync)(`pgrep -la -P ${panePid} 2>/dev/null; for cpid in $(pgrep -P ${panePid} 2>/dev/null); do pgrep -la -P "$cpid" 2>/dev/null; done; true`,{encoding:"utf-8",timeout:5000}).toLowerCase().includes(processName.toLowerCase())}catch{return!1}}async function killWindow(sessionName,windowName){try{return await executeTmux2(`kill-window -t ${shellQuote(`${sessionName}:${windowName}`)}`),!0}catch{return!1}}var TMUX_COLOR_NAMES,TMUX_COLOR_MAP,PANE_COLOR_SCRIPT,TmuxUnreachableError;var init_tmux=__esm(()=>{init_genie_tokens();init_ensure_tmux();init_team_lead_command();TMUX_COLOR_NAMES=["red","blue","green","yellow","purple","orange","pink","cyan"],TMUX_COLOR_MAP=Object.fromEntries(TMUX_COLOR_NAMES.map((name,i2)=>[name,rotateHue(palette.accent,i2*45)])),PANE_COLOR_SCRIPT=`${__require("os").homedir()}/.genie/tmux-pane-color.sh`;TmuxUnreachableError=class TmuxUnreachableError extends Error{constructor(message){super(message);this.name="TmuxUnreachableError"}}});var exports_agent_registry={};__export(exports_agent_registry,{update:()=>update,unregister:()=>unregister,setCurrentExecutor:()=>setCurrentExecutor,saveTemplate:()=>saveTemplate,resolveAgentIdStrict:()=>resolveAgentIdStrict,resolveAgentId:()=>resolveAgentId,removeSubPane:()=>removeSubPane,register:()=>register,reconcileStaleSpawns:()=>reconcileStaleSpawns,listTemplates:()=>listTemplates,listForRender:()=>listForRender,listExhaustedZombies:()=>listExhaustedZombies,listAllExhaustedErrored:()=>listAllExhaustedErrored,listAgentsForRender:()=>listAgentsForRender,listAgents:()=>listAgents,list:()=>list,getTeamLeadEntry:()=>getTeamLeadEntry,getPane:()=>getPane,getElapsedTime:()=>getElapsedTime,getAgentResolverCounters:()=>getAgentResolverCounters,getAgentEffectiveState:()=>getAgentEffectiveState,getAgentByName:()=>getAgentByName,getAgent:()=>getAgent,get:()=>get,findOrCreateAgent:()=>findOrCreateAgent,findByWindow:()=>findByWindow,findByTask:()=>findByTask,findByPane:()=>findByPane,filterBySession:()=>filterBySession,auditAgentKind:()=>auditAgentKind,archiveExhaustedZombies:()=>archiveExhaustedZombies,archiveAllExhaustedErrored:()=>archiveAllExhaustedErrored,addSubPane:()=>addSubPane,_resetAgentResolverCountersForTests:()=>_resetAgentResolverCountersForTests});import{createHash,randomUUID}from"crypto";function resolveWorkerSocketName(){return process.env.GENIE_TMUX_SOCKET||"genie"}function ts(v){if(!v)return new Date().toISOString();return v instanceof Date?v.toISOString():v}function rowToAgent(r){let agent={id:r.id,paneId:r.pane_id,session:r.session,worktree:r.worktree??null,startedAt:ts(r.started_at),state:r.state,lastStateChange:ts(r.last_state_change),repoPath:r.repo_path};if(r.task_id!=null)agent.taskId=r.task_id;if(r.task_title!=null)agent.taskTitle=r.task_title;if(r.wish_slug!=null)agent.wishSlug=r.wish_slug;if(r.group_number!=null)agent.groupNumber=r.group_number;if(r.window_name!=null)agent.windowName=r.window_name;if(r.window_id!=null)agent.windowId=r.window_id;if(r.role!=null)agent.role=r.role;if(r.custom_name!=null)agent.customName=r.custom_name;if(r.sub_panes!=null){let sp=typeof r.sub_panes==="string"?JSON.parse(r.sub_panes):r.sub_panes;if(Array.isArray(sp)&&sp.length>0)agent.subPanes=sp}if(r.provider!=null)agent.provider=r.provider;if(r.transport!=null)agent.transport=r.transport;if(r.skill!=null)agent.skill=r.skill;if(r.team!=null)agent.team=r.team;if(r.tmux_window!=null)agent.window=r.tmux_window;if(r.native_agent_id!=null)agent.nativeAgentId=r.native_agent_id;if(r.native_color!=null)agent.nativeColor=r.native_color;if(r.native_team_enabled)agent.nativeTeamEnabled=r.native_team_enabled;if(r.parent_session_id!=null)agent.parentSessionId=r.parent_session_id;if(r.suspended_at!=null)agent.suspendedAt=ts(r.suspended_at);if(r.auto_resume!=null)agent.autoResume=r.auto_resume;if(r.resume_attempts!=null)agent.resumeAttempts=r.resume_attempts;if(r.last_resume_attempt!=null)agent.lastResumeAttempt=ts(r.last_resume_attempt);if(r.max_resume_attempts!=null)agent.maxResumeAttempts=r.max_resume_attempts;if(r.pane_color!=null)agent.paneColor=r.pane_color;if(agent.currentExecutorId=r.current_executor_id??null,agent.reportsTo=r.reports_to??null,agent.title=r.title??null,r.kind!=null)agent.kind=r.kind;return agent}function rowToTemplate(r){let tpl={id:r.name??r.id,provider:r.provider,team:r.team,cwd:r.cwd,lastSpawnedAt:ts(r.last_spawned_at)};if(r.role!=null)tpl.role=r.role;if(r.skill!=null)tpl.skill=r.skill;if(r.extra_args!=null){let ea=typeof r.extra_args==="string"?JSON.parse(r.extra_args):r.extra_args;if(Array.isArray(ea)&&ea.length>0)tpl.extraArgs=ea}if(r.native_team_enabled)tpl.nativeTeamEnabled=r.native_team_enabled;return tpl}function shortProjectHash(repoPath){return createHash("sha1").update(repoPath).digest("hex").slice(0,8)}function buildProjectTeamLeadEntryId(teamName,session,repoPath){return`team-lead:${session}:${shortProjectHash(repoPath)}:${teamName}`}function buildSessionTeamLeadEntryId(teamName,session){return`team-lead:${session}:${teamName}`}function buildLegacyTeamLeadEntryId(teamName){return`team-lead:${teamName}`}function assertRegisterableId(id){if(!REGISTER_ID_RE.test(id))throw Error(`register: refusing to insert non-UUID/non-dir agent id ${JSON.stringify(id)}. Spawn callers must resolve the durable identity row via findOrCreateAgent first and pass that UUID \u2014 the bare-name shadow path was retired in wish retire-session-names-id-only Group 3 (migration 061 enforces the same shape at the DB).`)}async function register(agent){assertRegisterableId(agent.id);let sql=await getConnection(),now=new Date().toISOString();if(agent.team){let existing=await sql`
208
208
  SELECT team FROM agents WHERE id = ${agent.id} LIMIT 1
209
209
  `;if(existing.length>0&&existing[0].team!==null&&existing[0].team!==agent.team)throw Error(`register: cross-team id collision \u2014 agent id "${agent.id}" already exists in team "${existing[0].team}", refusing to re-register under team "${agent.team}". Unregister the stale row first, or pick a different id/suffix for this spawn.`)}await sql`INSERT INTO agents (id, pane_id, session, worktree, task_id, task_title, wish_slug, group_number, started_at, state, last_state_change, repo_path, window_name, window_id, role, custom_name, sub_panes, provider, transport, skill, team, tmux_window, native_agent_id, native_color, native_team_enabled, parent_session_id, suspended_at, auto_resume, resume_attempts, last_resume_attempt, max_resume_attempts, pane_color) VALUES (${agent.id}, ${agent.paneId}, ${agent.session}, ${agent.worktree??null}, ${agent.taskId??null}, ${agent.taskTitle??null}, ${agent.wishSlug??null}, ${agent.groupNumber??null}, ${agent.startedAt??now}, ${agent.state??"spawning"}, ${agent.lastStateChange??now}, ${agent.repoPath}, ${agent.windowName??null}, ${agent.windowId??null}, ${agent.role??null}, ${agent.customName??null}, ${sql.json(agent.subPanes??[])}, ${agent.provider??null}, ${agent.transport??null}, ${agent.skill??null}, ${agent.team??null}, ${agent.window??null}, ${agent.nativeAgentId??null}, ${agent.nativeColor??null}, ${agent.nativeTeamEnabled??!1}, ${agent.parentSessionId??null}, ${agent.suspendedAt??null}, ${agent.autoResume??!1}, ${agent.resumeAttempts??0}, ${agent.lastResumeAttempt??null}, ${agent.maxResumeAttempts??3}, ${agent.paneColor??null}) ON CONFLICT (id) DO UPDATE SET pane_id = EXCLUDED.pane_id, session = EXCLUDED.session, state = EXCLUDED.state, last_state_change = EXCLUDED.last_state_change, team = COALESCE(agents.team, EXCLUDED.team), role = COALESCE(agents.role, EXCLUDED.role), custom_name = COALESCE(agents.custom_name, EXCLUDED.custom_name), native_team_enabled = EXCLUDED.native_team_enabled, native_agent_id = COALESCE(EXCLUDED.native_agent_id, agents.native_agent_id), native_color = COALESCE(EXCLUDED.native_color, agents.native_color), parent_session_id = COALESCE(EXCLUDED.parent_session_id, agents.parent_session_id), provider = COALESCE(EXCLUDED.provider, agents.provider), transport = COALESCE(EXCLUDED.transport, agents.transport, 'tmux'), updated_at = now()`}async function unregister(id){await(await getConnection())`DELETE FROM agents WHERE id = ${id}`}async function get(id){let rows=await(await getConnection())`SELECT * FROM agents WHERE id = ${id}`;return rows.length>0?rowToAgent(rows[0]):null}async function list(){return(await(await getConnection())`SELECT * FROM agents`).map(rowToAgent)}function dedupeShadowRows(rows,opts){let groups=new Map;for(let r of rows){let k=opts.keyFor(r),arr=groups.get(k);if(arr)arr.push(r);else groups.set(k,[r])}let out=[];for(let arr of groups.values()){if(arr.length===1){out.push(arr[0]);continue}arr.sort((a,b)=>{let aExec=opts.hasExecutor(a)?1:0,bExec=opts.hasExecutor(b)?1:0;if(aExec!==bExec)return bExec-aExec;return opts.startedAt(b).localeCompare(opts.startedAt(a))}),out.push(arr[0])}return out}function shadowKey(customName,id,team){return`${customName??id}\x00${team??""}`}async function listForRender(){let all=await list();return dedupeShadowRows(all,{keyFor:(a)=>shadowKey(a.customName,a.id,a.team),hasExecutor:(a)=>a.currentExecutorId!=null,startedAt:(a)=>a.startedAt})}async function reconcileStaleSpawns(thresholdSeconds=60){try{let sql=await getConnection(),rows=await sql`
210
210
  UPDATE agents
@@ -2849,9 +2849,22 @@ Genie Serve`),console.log("\u2500".repeat(50)),console.log(` Status: ${runn
2849
2849
  WHERE p.proname IN ('notify_trigger', 'pg_notify')
2850
2850
  )
2851
2851
  ORDER BY source_table, trigger_name
2852
- `}),reply(sub.settings.get(ORG_ID),async()=>{let genieHome4=process.env.GENIE_HOME??join65(homedir43(),".genie"),configPath2=join65(genieHome4,"config.json"),wsConfigPath=join65(process.cwd(),".genie","workspace.json"),config={},wsConfig={};try{if(existsSync53(configPath2))config=JSON.parse(readFileSync35(configPath2,"utf-8"))}catch{}try{if(existsSync53(wsConfigPath))wsConfig=JSON.parse(readFileSync35(wsConfigPath,"utf-8"))}catch{}return{config,workspace:wsConfig}}),reply(sub.settings.set(ORG_ID),async(params)=>{let genieHome4=process.env.GENIE_HOME??join65(homedir43(),".genie"),configPath2=join65(genieHome4,"config.json"),config={};try{if(existsSync53(configPath2))config=JSON.parse(readFileSync35(configPath2,"utf-8"))}catch{}return config[params.key]=params.value,writeFileSync21(configPath2,JSON.stringify(config,null,2)),{ok:!0}}),reply(sub.settings.templates(ORG_ID),async()=>{return sql`SELECT * FROM agent_templates ORDER BY last_spawned_at DESC`}),reply(sub.settings.skills(ORG_ID),async()=>{let skillsDir=findSkillsDir();if(!skillsDir)return(await sql`SELECT DISTINCT skill FROM agents WHERE skill IS NOT NULL ORDER BY skill`).map((r)=>({name:r.skill,description:"",path:""}));return scanSkillsDirectory(skillsDir)}),reply(sub.settings.rules(ORG_ID),async()=>{return scanRuleDirs()}),reply(sub.settings.templateSave(ORG_ID),async(params)=>{if(!params.id)return{error:"id is required"};return await sql`
2852
+ `}),reply(sub.settings.get(ORG_ID),async()=>{let genieHome4=process.env.GENIE_HOME??join65(homedir43(),".genie"),configPath2=join65(genieHome4,"config.json"),wsConfigPath=join65(process.cwd(),".genie","workspace.json"),config={},wsConfig={};try{if(existsSync53(configPath2))config=JSON.parse(readFileSync35(configPath2,"utf-8"))}catch{}try{if(existsSync53(wsConfigPath))wsConfig=JSON.parse(readFileSync35(wsConfigPath,"utf-8"))}catch{}return{config,workspace:wsConfig}}),reply(sub.settings.set(ORG_ID),async(params)=>{let genieHome4=process.env.GENIE_HOME??join65(homedir43(),".genie"),configPath2=join65(genieHome4,"config.json"),config={};try{if(existsSync53(configPath2))config=JSON.parse(readFileSync35(configPath2,"utf-8"))}catch{}return config[params.key]=params.value,writeFileSync21(configPath2,JSON.stringify(config,null,2)),{ok:!0}}),reply(sub.settings.templates(ORG_ID),async()=>{return sql`
2853
+ SELECT
2854
+ name AS id,
2855
+ provider,
2856
+ team,
2857
+ role,
2858
+ skill,
2859
+ cwd,
2860
+ extra_args,
2861
+ native_team_enabled,
2862
+ last_spawned_at
2863
+ FROM agent_templates
2864
+ ORDER BY last_spawned_at DESC
2865
+ `}),reply(sub.settings.skills(ORG_ID),async()=>{let skillsDir=findSkillsDir();if(!skillsDir)return(await sql`SELECT DISTINCT skill FROM agents WHERE skill IS NOT NULL ORDER BY skill`).map((r)=>({name:r.skill,description:"",path:""}));return scanSkillsDirectory(skillsDir)}),reply(sub.settings.rules(ORG_ID),async()=>{return scanRuleDirs()}),reply(sub.settings.templateSave(ORG_ID),async(params)=>{if(!params.id)return{error:"id is required"};return await sql`
2853
2866
  INSERT INTO agent_templates (
2854
- id, provider, team, role, skill, cwd,
2867
+ name, provider, team, role, skill, cwd,
2855
2868
  extra_args, native_team_enabled, last_spawned_at
2856
2869
  ) VALUES (
2857
2870
  ${params.id},
@@ -2860,18 +2873,18 @@ Genie Serve`),console.log("\u2500".repeat(50)),console.log(` Status: ${runn
2860
2873
  ${params.role??null},
2861
2874
  ${params.skill??null},
2862
2875
  ${params.cwd??""},
2863
- ${JSON.stringify(params.extraArgs??[])},
2876
+ ${sql.json(params.extraArgs??[])},
2864
2877
  ${params.nativeTeamEnabled??!1},
2865
2878
  ${new Date().toISOString()}
2866
2879
  )
2867
- ON CONFLICT (id) DO UPDATE SET
2880
+ ON CONFLICT (name, team) WHERE name IS NOT NULL AND team IS NOT NULL DO UPDATE SET
2868
2881
  provider = EXCLUDED.provider,
2869
- team = EXCLUDED.team,
2870
2882
  role = EXCLUDED.role,
2871
2883
  skill = EXCLUDED.skill,
2872
2884
  cwd = EXCLUDED.cwd,
2873
2885
  extra_args = EXCLUDED.extra_args,
2874
- native_team_enabled = EXCLUDED.native_team_enabled
2886
+ native_team_enabled = EXCLUDED.native_team_enabled,
2887
+ last_spawned_at = EXCLUDED.last_spawned_at
2875
2888
  `,{ok:!0}}),reply(sub.settings.testPg(ORG_ID),async()=>{try{return await sql`SELECT 1 AS ok`,{ok:!0,message:"Connection successful"}}catch(err){return{ok:!1,message:err instanceof Error?err.message:String(err)}}}),reply(sub.pty.create(ORG_ID),async(params)=>{if(params.agentName){let session2=await spawnForAgent(params.agentName,{cwd:params.cwd,cols:params.cols,rows:params.rows});return{sessionId:session2.id,agentId:session2.agentId,executorId:session2.executorId}}return{sessionId:spawnBash(params.cwd).id,agentId:null,executorId:null}}),subscribePtyWildcard(`khal.${ORG_ID}.genie.pty.*.input`,async(sessionId,params)=>{writeTerminal(sessionId,params.data)}),subscribePtyWildcard(`khal.${ORG_ID}.genie.pty.*.resize`,async(sessionId,params)=>{resizeTerminal(sessionId,params.cols,params.rows)}),subscribePtyWildcard(`khal.${ORG_ID}.genie.pty.*.kill`,async(sessionId)=>{await killTerminal(sessionId)}),reply(sub.fs.list(ORG_ID),async(params)=>{let targetPath=resolve12(params.path);if(!existsSync53(targetPath))return{error:"not_found"};try{return readdirSync13(targetPath,{withFileTypes:!0}).map((e)=>({name:e.name,isDirectory:e.isDirectory(),path:join65(targetPath,e.name)}))}catch(err){return{error:err instanceof Error?err.message:String(err)}}}),reply(sub.fs.read(ORG_ID),async(params)=>{let targetPath=resolve12(params.path);if(!existsSync53(targetPath))return{error:"not_found"};try{return{content:readFileSync35(targetPath,"utf-8")}}catch(err){return{error:err instanceof Error?err.message:String(err)}}}),reply(sub.fs.write(ORG_ID),async(params)=>{let targetPath=resolve12(params.path);try{return writeFileSync21(targetPath,params.content),{ok:!0}}catch(err){return{error:err instanceof Error?err.message:String(err)}}}),reply(sub.approval.resolve(ORG_ID),async(params)=>{return{ok:await resolveApproval(params.id,params.decision,params.decided_by)}}),reply(sub.approval.list(ORG_ID),async(params)=>{return listPendingApprovals(params.agent_name)}),console.log("[genie-app] All request/reply handlers registered")}function decodeParams(data){if(data.length===0)return{};try{return JSON.parse(sc2.decode(data))}catch{return{}}}function subscribePtyWildcard(subject,handler){if(!nc)return;let subscription=nc.subscribe(subject);(async()=>{for await(let msg of subscription)try{let params=decodeParams(msg.data),sessionId=msg.subject.split(".")[4];if(sessionId)await handler(sessionId,params)}catch(err){console.error(`[genie-app] PTY wildcard error on ${msg.subject}:`,err instanceof Error?err.message:String(err))}})()}function reply(subject,handler){if(!nc)return;let subscription=nc.subscribe(subject);processSubscription(subscription,subject,handler)}async function processSubscription(subscription,subject,handler){for await(let msg of subscription)try{let params=decodeParams(msg.data),result2=await handler(params);if(msg.reply)msg.respond(sc2.encode(JSON.stringify(result2)))}catch(err){let errorMsg=err instanceof Error?err.message:String(err);if(console.error(`[genie-app] Handler error on ${subject}:`,errorMsg),msg.reply)msg.respond(sc2.encode(JSON.stringify({error:errorMsg})))}}async function shutdown2(){if(shutdownRequested)return;if(shutdownRequested=!0,console.log("[genie-app] Shutting down..."),await killAll(),await stopListening(),nc){try{await nc.drain()}catch{}nc=null}console.log("[genie-app] Shutdown complete"),process.exit(0)}var import_nats6,NATS_URL,ORG_ID,_PG_URL,sc2,nc=null,shutdownRequested=!1;var init_src_backend=__esm(()=>{init_agent_observability();init_db();init_claude_sdk_remote_approval();init_subjects();init_pg_bridge();init_pty();import_nats6=__toESM(require_mod4(),1);process.env.GENIE_APP="1";NATS_URL=process.env.GENIE_NATS_URL??"nats://localhost:4222",ORG_ID=process.env.GENIE_ORG_ID??"default",_PG_URL=process.env.GENIE_PG_URL??"postgresql://localhost:19642/genie",sc2=import_nats6.StringCodec();process.on("SIGTERM",()=>void shutdown2());process.on("SIGINT",()=>void shutdown2());start2().catch((err)=>{console.error("[genie-app] Fatal error:",err),process.exit(1)})});var exports_event_renderer={};__export(exports_event_renderer,{renderRuntimeEvent:()=>renderRuntimeEvent,renderAuditEvent:()=>renderAuditEvent,formatEventLine:()=>formatEventLine});function renderAuditEvent(input){let details=input.details;if(typeof details==="string")try{details=JSON.parse(details)}catch{details={}}let normalized={...input,details:details??{}},renderer=auditRenderers[input.event_type];if(renderer)return renderer(normalized);let event=normalized,json2=JSON.stringify(event.details);return{indicator:color("dim","\u25CB"),content:`${color("gray",event.event_type)} ${json2==="{}"?"":color("dim",json2)}`,context:`${event.entity_type}:${event.entity_id}`}}function renderRuntimeEvent(input){let renderer=runtimeRenderers[input.kind];if(renderer)return renderer(input);return{indicator:color("dim","\u25CB"),content:`${color("gray",input.kind)} ${input.text}`,context:input.agent}}function formatEventLine(timeStr,rendered){let termWidth=process.stdout.columns||120,timeCol=color("gray",timeStr),timeWidth=stripAnsi2(timeCol).length,iconWidth=stripAnsi2(rendered.indicator).length,prefixWidth=timeWidth+2+iconWidth+2,contextStr=rendered.context?color("dim",rendered.context):"",contextWidth=contextStr?stripAnsi2(contextStr).length+2:0,contentWidth=Math.max(20,termWidth-prefixWidth-contextWidth),content=stripAnsi2(rendered.content).replace(/[\r\n]+/g," ");if(content.length<=contentWidth){let paddedContent=padToWidth(rendered.content,contentWidth);return`${timeCol} ${rendered.indicator} ${paddedContent}${contextStr?` ${contextStr}`:""}`}let chunks=wrapText(content,contentWidth),indent2=" ".repeat(prefixWidth),firstLine=`${timeCol} ${rendered.indicator} ${padToWidth(chunks[0],contentWidth)}${contextStr?` ${contextStr}`:""}`,rest=chunks.slice(1).map((c)=>`${indent2}${c}`);return[firstLine,...rest].join(`
2876
2889
  `)}function padToWidth(coloredText,width){let visible=stripAnsi2(coloredText).length,pad=Math.max(0,width-visible);return coloredText+" ".repeat(pad)}function wrapText(text,width){let chunks=[],remaining=text;while(remaining.length>width){let breakAt=remaining.lastIndexOf(" ",width);if(breakAt<=0||breakAt<width/2)breakAt=width;chunks.push(remaining.slice(0,breakAt).trimEnd()),remaining=remaining.slice(breakAt).trimStart()}if(remaining.length>0)chunks.push(remaining);return chunks}function formatDuration3(ms){if(ms<1000)return`${ms}ms`;if(ms<60000)return`${(ms/1000).toFixed(1)}s`;let s2=Math.floor(ms/1000);return`${Math.floor(s2/60)}m${s2%60}s`}var shortEntity=(id)=>id.split(":")[0]??id,auditRenderers,runtimeRenderers;var init_event_renderer=__esm(()=>{init_term_format();auditRenderers={"sdk.assistant.message":(e)=>({indicator:color("brightCyan","\uD83D\uDCAC"),content:color("brightCyan",`"${e.details.textPreview??""}"`),context:shortEntity(e.entity_id)}),"sdk.user.message":(e)=>{let d=e.details,text=d.textPreview;if(typeof text==="string"&&text.length>0){let tag=d.isReplay?color("dim"," (replay)"):d.isSynthetic?color("dim"," (synthetic)"):"";return{indicator:color("cyan","\uD83D\uDC64"),content:`${color("cyan",`"${text}"`)}${tag}`,context:shortEntity(e.entity_id)}}let kind=d.isReplay?"replay":d.isSynthetic?"synthetic":"turn";return{indicator:color("cyan","\uD83D\uDC64"),content:color("dim",`user ${kind}`),context:shortEntity(e.entity_id)}},"sdk.hook.started":(e)=>({indicator:color("yellow","\uD83E\uDE9D"),content:`${e.details.hookName??"?"} ${color("dim","started")}`,context:shortEntity(e.entity_id)}),"sdk.hook.response":(e)=>{let outcome=e.details.outcome==="success"?color("green","\u2713"):color("red","\u2717"),hookId=e.details.hookId?color("dim",` ${String(e.details.hookId).slice(0,8)}`):"";return{indicator:color("yellow","\uD83E\uDE9D"),content:`${e.details.hookName??"?"} ${outcome}${hookId}`,context:shortEntity(e.entity_id)}},"sdk.system":(e)=>{let model=String(e.details.model??"?").replace(/^claude-/,""),tools=e.details.tools??0,session=e.details.sessionId?` \xB7 ${color("dim",String(e.details.sessionId).slice(0,8))}`:"";return{indicator:color("gray","\u2699"),content:`init ${color("cyan",model)} \xB7 ${tools} tools${session}`,context:shortEntity(e.entity_id)}},"sdk.result.success":(e)=>{let d=e.details,dur=d.durationMs?formatDuration3(d.durationMs):"",cost=typeof d.totalCostUsd==="number"?`$${d.totalCostUsd.toFixed(4)}`:"",preview=d.resultPreview?` \xB7 "${d.resultPreview}"`:"";return{indicator:color("green","\u2728"),content:`${color("brightGreen",dur)} \xB7 ${color("yellow",cost)}${color("dim",preview)}`,context:shortEntity(e.entity_id)}},"sdk.rate_limit":(e)=>({indicator:color("gray","\u23F1"),content:color("dim",`rate limit: ${e.details.status??"?"}`),context:shortEntity(e.entity_id)}),"sdk.api.retry":(e)=>{let d=e.details,attempt=d.attempt??"?",max=d.maxRetries??"?",delay=d.retryDelayMs?`${Math.round(d.retryDelayMs)}ms`:"";return{indicator:color("yellow","\u21BB"),content:color("yellow",`retry ${attempt}/${max} \xB7 ${d.error??"?"}${delay?` \xB7 ${delay}`:""}`),context:shortEntity(e.entity_id)}},"session.created_fresh":(e)=>({indicator:color("green","\u2726"),content:color("green",`session created \xB7 ${e.details.agent_id??"?"}`),context:shortEntity(e.entity_id)}),"session.resumed":(e)=>({indicator:color("cyan","\u21BB"),content:color("cyan",`session resumed \xB7 ${e.details.agent_id??"?"}`),context:shortEntity(e.entity_id)}),"deliver.start":(e)=>({indicator:color("blue","\u25B8"),content:color("dim",`deliver \u2192 ${e.details.agent_id??"?"}`),context:shortEntity(e.entity_id)}),"deliver.end":(e)=>{let turns=e.details.turn_count?` \xB7 turn ${e.details.turn_count}`:"";return{indicator:color("blue","\u25C2"),content:color("dim",`deliver done${turns}`),context:shortEntity(e.entity_id)}},state_changed:(e)=>({indicator:color("gray","\u25C9"),content:color("dim",`state \u2192 ${e.details.state??"?"}`),context:shortEntity(e.entity_id)}),"executor.spawn":(e)=>({indicator:color("green","\u25B6"),content:color("green",`spawn ${e.details.source??""}`),context:e.entity_id}),"executor.terminate":(e)=>({indicator:color("yellow","\u25A0"),content:color("yellow",`terminate ${e.details.source??""}`),context:e.entity_id}),"executor.deliver":(e)=>{let d=e.details,dur=d.durationMs?formatDuration3(d.durationMs):"",tokens2=d.tokens,tokStr=tokens2?`${tokens2.input??0}\u2192${tokens2.output??0}`:"",parts=[dur,tokStr].filter(Boolean);return{indicator:color("blue","\u2192"),content:parts.join(" \xB7 "),context:e.entity_id}},"task.error":(e)=>({indicator:color("red","\u2717"),content:color("red",String(e.details.error??"unknown error")),context:e.entity_id}),command_success:(e)=>({indicator:color("dim","\xB7"),content:color("dim",`${e.details.duration_ms??0}ms`),context:e.entity_id})};runtimeRenderers={user:(e)=>({indicator:color("brightCyan","\uD83D\uDC64"),content:e.text,context:e.agent}),assistant:(e)=>({indicator:color("cyan","\uD83E\uDD16"),content:color("cyan",e.text),context:e.agent}),message:(e)=>({indicator:color("magenta","\u2709"),content:e.text,context:e.agent}),tool_call:(e)=>({indicator:color("yellow","\uD83D\uDD27"),content:color("yellow",e.text),context:e.agent}),tool_result:(e)=>({indicator:color("gray","\u2B90"),content:color("dim",e.text),context:e.agent}),state:(e)=>({indicator:color("gray","\u25C9"),content:color("dim",e.text),context:e.agent}),system:(e)=>({indicator:color("gray","\u2699"),content:color("dim",e.text),context:e.agent}),qa:(e)=>({indicator:color("magenta","\uD83E\uDDEA"),content:e.text,context:e.agent})}});var exports_board_service={};__export(exports_board_service,{updateColumn:()=>updateColumn,updateBoard:()=>updateBoard,reorderColumns:()=>reorderColumns,removeColumn:()=>removeColumn,reconcileBoard:()=>reconcileBoard,listBoards:()=>listBoards,importBoard:()=>importBoard,getColumns:()=>getColumns,getBoard:()=>getBoard,exportBoard:()=>exportBoard,deleteBoard:()=>deleteBoard,createBoard:()=>createBoard,addColumn:()=>addColumn});function str3(v){return v!=null?String(v):null}function strOrDefault2(v,def){return v!=null?String(v):def}function parseJsonb(val,fallback){if(val==null)return fallback;if(typeof val==="string")try{return JSON.parse(val)}catch{return fallback}return val}function mapBoard(row2){return{id:row2.id,name:row2.name,projectId:str3(row2.project_id),description:str3(row2.description),status:strOrDefault2(row2.status,"active"),archivedAt:str3(row2.archived_at),columns:parseJsonb(row2.columns,[]),config:parseJsonb(row2.config,{}),createdAt:strOrDefault2(row2.created_at,""),updatedAt:strOrDefault2(row2.updated_at,"")}}function generateColumnId(){return crypto.randomUUID()}function fillColumnDefaults(col,position){return{id:col.id??generateColumnId(),name:col.name??`column-${position}`,label:col.label??col.name??`Column ${position}`,gate:col.gate??"human",action:col.action??null,auto_advance:col.auto_advance??!1,transitions:col.transitions??[],roles:col.roles??["*"],color:col.color??palette.textDim,parallel:col.parallel??!1,on_fail:col.on_fail??null,position}}async function createBoard(input){let sql=await getConnection(),columns=[];if(input.fromTemplate){let tmplRows=await sql`
2877
2890
  SELECT columns FROM board_templates WHERE name = ${input.fromTemplate} OR id = ${input.fromTemplate} LIMIT 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260507.7",
3
+ "version": "4.260508.2",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: the npm distribution is being soft-deprecated — the canonical install is `curl -fsSL https://get.automagik.dev/genie | bash` (cosign + SLSA verified). See https://automagik.dev/genie/security/distribution-sovereignty",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260507.7",
3
+ "version": "4.260508.2",
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.260507.7",
3
+ "version": "4.260508.2",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -23,48 +23,70 @@
23
23
 
24
24
  BEGIN;
25
25
 
26
- -- 1. Backfill: subagent rows inherit their parent's team.
27
- -- `child.id LIKE parent.id || '/%'` matches `parent/anything` whose parent
28
- -- name has no slash itself (avoids matching grandparent/parent/child).
29
- -- Skip when child.team already equals parent.team.
30
- UPDATE agent_templates AS child
31
- SET team = parent.team,
32
- updated_at = now()
33
- FROM agent_templates AS parent
34
- WHERE child.id LIKE parent.id || '/%'
35
- AND parent.id NOT LIKE '%/%'
36
- AND child.team IS DISTINCT FROM parent.team;
26
+ DO $$
27
+ DECLARE
28
+ builtin_names text[] := ARRAY[
29
+ -- BUILTIN_ROLES (plugins/genie/agents/*/AGENTS.md, category=role)
30
+ 'docs',
31
+ 'engineer',
32
+ 'fix',
33
+ 'pm',
34
+ 'qa',
35
+ 'refactor',
36
+ 'reviewer',
37
+ 'team-lead',
38
+ 'trace',
39
+ -- BUILTIN_COUNCIL_MEMBERS (plugins/genie/agents/council*, category=council)
40
+ 'council',
41
+ 'council--architect',
42
+ 'council--benchmarker',
43
+ 'council--deployer',
44
+ 'council--ergonomist',
45
+ 'council--measurer',
46
+ 'council--operator',
47
+ 'council--questioner',
48
+ 'council--sentinel',
49
+ 'council--simplifier',
50
+ 'council--tracer'
51
+ ];
52
+ has_name_column boolean;
53
+ BEGIN
54
+ SELECT EXISTS (
55
+ SELECT 1
56
+ FROM information_schema.columns
57
+ WHERE table_schema = current_schema()
58
+ AND table_name = 'agent_templates'
59
+ AND column_name = 'name'
60
+ ) INTO has_name_column;
37
61
 
38
- -- 2. Wipe sticky pins for built-in roles + council members.
39
- -- Built-ins are SHARED runtime resolution (GENIE_TEAM env, tmux
40
- -- discovery) decides the team per-spawn now. Keeping the rows would let
41
- -- `lookupTemplateTeam` keep returning a stale team for the first caller
42
- -- of every spawn until the rows are overwritten with a fresh team —
43
- -- which the runtime fix now refuses to do.
44
- DELETE FROM agent_templates
45
- WHERE id IN (
46
- -- BUILTIN_ROLES (plugins/genie/agents/*/AGENTS.md, category=role)
47
- 'docs',
48
- 'engineer',
49
- 'fix',
50
- 'pm',
51
- 'qa',
52
- 'refactor',
53
- 'reviewer',
54
- 'team-lead',
55
- 'trace',
56
- -- BUILTIN_COUNCIL_MEMBERS (plugins/genie/agents/council*, category=council)
57
- 'council',
58
- 'council--architect',
59
- 'council--benchmarker',
60
- 'council--deployer',
61
- 'council--ergonomist',
62
- 'council--measurer',
63
- 'council--operator',
64
- 'council--questioner',
65
- 'council--sentinel',
66
- 'council--simplifier',
67
- 'council--tracer'
68
- );
62
+ IF has_name_column THEN
63
+ -- Post-061 schema: agent_templates.id is a UUID PK; name is the text
64
+ -- template key used by roles, built-ins, and parent/child hierarchy.
65
+ EXECUTE $sql$
66
+ UPDATE agent_templates AS child
67
+ SET team = parent.team,
68
+ updated_at = now()
69
+ FROM agent_templates AS parent
70
+ WHERE child.name LIKE parent.name || '/%'
71
+ AND parent.name NOT LIKE '%/%'
72
+ AND child.team IS DISTINCT FROM parent.team
73
+ $sql$;
74
+
75
+ EXECUTE 'DELETE FROM agent_templates WHERE name = ANY ($1)' USING builtin_names;
76
+ ELSE
77
+ -- Pre-061 / fresh-install ordering: id is still the text template key.
78
+ EXECUTE $sql$
79
+ UPDATE agent_templates AS child
80
+ SET team = parent.team,
81
+ updated_at = now()
82
+ FROM agent_templates AS parent
83
+ WHERE child.id LIKE parent.id || '/%'
84
+ AND parent.id NOT LIKE '%/%'
85
+ AND child.team IS DISTINCT FROM parent.team
86
+ $sql$;
87
+
88
+ EXECUTE 'DELETE FROM agent_templates WHERE id = ANY ($1)' USING builtin_names;
89
+ END IF;
90
+ END $$;
69
91
 
70
92
  COMMIT;
@@ -0,0 +1,96 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { type Sql, getConnection } from '../../lib/db.js';
5
+ import { DB_AVAILABLE, setupTestDatabase } from '../../lib/test-db.js';
6
+
7
+ const MIGRATION_PATH = join(import.meta.dir, '054_fix_subagent_team_inheritance.sql');
8
+
9
+ async function applyMigration(): Promise<void> {
10
+ const sql = await getConnection();
11
+ const migration = await readFile(MIGRATION_PATH, 'utf-8');
12
+ await sql.begin(async (tx: Sql) => {
13
+ await tx.unsafe(migration);
14
+ });
15
+ }
16
+
17
+ describe.skipIf(!DB_AVAILABLE)('migration 054 — subagent team inheritance', () => {
18
+ let cleanup: () => Promise<void>;
19
+
20
+ beforeAll(async () => {
21
+ cleanup = await setupTestDatabase();
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await cleanup();
26
+ });
27
+
28
+ beforeEach(async () => {
29
+ const sql = await getConnection();
30
+ await sql`DROP TABLE IF EXISTS agent_templates CASCADE`;
31
+ });
32
+
33
+ test('heals pre-061 text-id schema during fresh install ordering', async () => {
34
+ const sql = await getConnection();
35
+ await sql`
36
+ CREATE TABLE agent_templates (
37
+ id TEXT PRIMARY KEY,
38
+ provider TEXT NOT NULL DEFAULT 'claude',
39
+ team TEXT NOT NULL,
40
+ cwd TEXT NOT NULL DEFAULT '/tmp',
41
+ last_spawned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
42
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
43
+ )
44
+ `;
45
+ await sql`
46
+ INSERT INTO agent_templates (id, team)
47
+ VALUES
48
+ ('genie-omni', 'genie'),
49
+ ('genie-omni/dog-fooder', 'felipe'),
50
+ ('engineer', 'felipe')
51
+ `;
52
+
53
+ await applyMigration();
54
+
55
+ const child = await sql<{ team: string }[]>`
56
+ SELECT team FROM agent_templates WHERE id = 'genie-omni/dog-fooder'
57
+ `;
58
+ expect(child[0].team).toBe('genie');
59
+
60
+ const builtin = await sql`SELECT 1 FROM agent_templates WHERE id = 'engineer'`;
61
+ expect(builtin.length).toBe(0);
62
+ });
63
+
64
+ test('heals post-061 UUID-id schema via template name', async () => {
65
+ const sql = await getConnection();
66
+ await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
67
+ await sql`
68
+ CREATE TABLE agent_templates (
69
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
70
+ name TEXT NOT NULL,
71
+ provider TEXT NOT NULL DEFAULT 'claude',
72
+ team TEXT NOT NULL,
73
+ cwd TEXT NOT NULL DEFAULT '/tmp',
74
+ last_spawned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
75
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
76
+ )
77
+ `;
78
+ await sql`
79
+ INSERT INTO agent_templates (name, team)
80
+ VALUES
81
+ ('genie-omni', 'genie'),
82
+ ('genie-omni/dog-fooder', 'felipe'),
83
+ ('engineer', 'felipe')
84
+ `;
85
+
86
+ await applyMigration();
87
+
88
+ const child = await sql<{ team: string }[]>`
89
+ SELECT team FROM agent_templates WHERE name = 'genie-omni/dog-fooder'
90
+ `;
91
+ expect(child[0].team).toBe('genie');
92
+
93
+ const builtin = await sql`SELECT 1 FROM agent_templates WHERE name = 'engineer'`;
94
+ expect(builtin.length).toBe(0);
95
+ });
96
+ });
@@ -19,21 +19,43 @@
19
19
  -- silent default-off that triggered the misleading message in the first
20
20
  -- place.
21
21
  --
22
+ -- Heal-not-wipe interaction with migration 061
23
+ -- --------------------------------------------
24
+ -- Migration 061 added `agents_id_shape_check` as `NOT VALID` so legacy
25
+ -- bare-name rows (archived by 050/053) stay in the table. Postgres still
26
+ -- enforces the constraint on every UPDATE — even when the id column is
27
+ -- not in the SET list — so an unconditional `UPDATE agents SET auto_resume
28
+ -- = true` errors out with `agents_id_shape_check` violation on any host
29
+ -- carrying archived bare-name rows. The whole boot path then fails,
30
+ -- because runMigrations runs inside getConnection's post-connect setup.
31
+ --
32
+ -- Filter the UPDATEs to UUID/dir-prefixed ids only. Bare-name rows are
33
+ -- already archived (state='archived', auto_resume=false locked by 050/053)
34
+ -- so flipping their auto_resume is meaningless — they are not resume
35
+ -- candidates regardless of the column value.
36
+ --
22
37
  -- Idempotent: re-running this migration is a no-op on rows that are already
23
38
  -- true and on a column whose default is already true.
24
39
 
25
40
  BEGIN;
26
41
 
27
- -- 1. Flip every existing false row. No WHERE narrowing Felipe directive
28
- -- is to flip ALL of them so resume works out-of-the-box.
42
+ -- 1. Flip every existing false row that satisfies the id shape constraint.
43
+ -- Bare-name rows (archived legacy) are skipped see header comment.
29
44
  UPDATE agents
30
45
  SET auto_resume = true
31
- WHERE auto_resume = false;
46
+ WHERE auto_resume = false
47
+ AND (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
48
+ OR id LIKE 'dir:%');
32
49
 
33
50
  -- 2. Change the column DEFAULT so future spawns inherit the safe value.
34
51
  -- NULLs (legacy rows that pre-date the column) become true — same as
35
- -- the safe-default the runtime treats them as.
52
+ -- the safe-default the runtime treats them as. Same id-shape filter
53
+ -- applies for the same reason.
36
54
  ALTER TABLE agents ALTER COLUMN auto_resume SET DEFAULT true;
37
- UPDATE agents SET auto_resume = true WHERE auto_resume IS NULL;
55
+ UPDATE agents
56
+ SET auto_resume = true
57
+ WHERE auto_resume IS NULL
58
+ AND (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
59
+ OR id LIKE 'dir:%');
38
60
 
39
61
  COMMIT;
@@ -0,0 +1,108 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { type Sql, getConnection } from '../../lib/db.js';
6
+ import { DB_AVAILABLE, setupTestDatabase } from '../../lib/test-db.js';
7
+
8
+ const MIGRATION_PATH = join(import.meta.dir, '055_default_auto_resume_true.sql');
9
+
10
+ async function runMigration(sql: Sql): Promise<void> {
11
+ const sqlText = await readFile(MIGRATION_PATH, 'utf-8');
12
+ // Mirror runMigrations() in db-migrations.ts: wrap in sql.begin so the
13
+ // pool sees a single reserved connection. The migration file's own
14
+ // BEGIN/COMMIT becomes a no-op inside the outer transaction (postgres
15
+ // emits a "there is already a transaction in progress" warning and
16
+ // COMMIT closes the outer txn, same as production).
17
+ await sql.begin(async (tx) => {
18
+ await tx.unsafe(sqlText);
19
+ });
20
+ }
21
+
22
+ describe.skipIf(!DB_AVAILABLE)('migration 055 — default auto_resume true', () => {
23
+ let cleanup: () => Promise<void>;
24
+
25
+ beforeAll(async () => {
26
+ cleanup = await setupTestDatabase();
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await cleanup();
31
+ });
32
+
33
+ test('does not fail when archived bare-name rows are present', async () => {
34
+ const sql = await getConnection();
35
+
36
+ // The template DB has migration 055 already applied. To replay it as if
37
+ // it were pending against a host that carries archived bare-name rows
38
+ // (legacy 050/053 grandfather), we have to re-create the failure shape:
39
+ // 1. Drop the id-shape CHECK so we can insert a bare-name row.
40
+ // 2. Insert one archived bare-name agent (mirrors production data).
41
+ // 3. Re-add the CHECK as NOT VALID (mirrors migration 061's pattern).
42
+ // 4. Reset a UUID row's auto_resume to false (something for 055 to flip).
43
+ // 5. Re-run 055.
44
+ // The pre-fix migration would error here with agents_id_shape_check;
45
+ // the post-fix migration filters bare-name rows out of the UPDATE.
46
+ await sql`ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_id_shape_check`;
47
+
48
+ const bareName = `legacy-bare-${Date.now()}`;
49
+ await sql`
50
+ INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume)
51
+ VALUES (${bareName}, 'legacy', NULL, 'legacy-team', '/tmp/legacy', now(), 'archived', false)
52
+ ON CONFLICT (id) DO NOTHING
53
+ `;
54
+
55
+ await sql.unsafe(
56
+ `ALTER TABLE agents
57
+ ADD CONSTRAINT agents_id_shape_check
58
+ CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' OR id LIKE 'dir:%')
59
+ NOT VALID`,
60
+ );
61
+
62
+ const liveId = randomUUID();
63
+ await sql`
64
+ INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume)
65
+ VALUES (${liveId}, 'engineer', 'live-engineer', 'live-team', '/tmp/live', now(), 'idle', false)
66
+ ON CONFLICT (id) DO NOTHING
67
+ `;
68
+
69
+ // The pre-fix migration would throw here with
70
+ // `new row for relation "agents" violates check constraint "agents_id_shape_check"`.
71
+ await expect(runMigration(sql)).resolves.toBeUndefined();
72
+
73
+ const live = await sql<{ auto_resume: boolean }[]>`
74
+ SELECT auto_resume FROM agents WHERE id = ${liveId}
75
+ `;
76
+ expect(live[0].auto_resume).toBe(true);
77
+
78
+ const bare = await sql<{ auto_resume: boolean }[]>`
79
+ SELECT auto_resume FROM agents WHERE id = ${bareName}
80
+ `;
81
+ // Bare-name rows are archived legacy; their auto_resume is intentionally
82
+ // not flipped because the row would fail the id-shape check.
83
+ expect(bare[0].auto_resume).toBe(false);
84
+ });
85
+
86
+ test('flips auto_resume on UUID and dir: rows, leaves NULL rows true', async () => {
87
+ const sql = await getConnection();
88
+
89
+ const uuidId = randomUUID();
90
+ const dirId = `dir:test-dir-${Date.now()}`;
91
+ await sql`
92
+ INSERT INTO agents (id, role, custom_name, team, repo_path, started_at, state, auto_resume)
93
+ VALUES
94
+ (${uuidId}, 'engineer', 'a', 'team-a', '/tmp/a', now(), 'idle', false),
95
+ (${dirId}, 'engineer', 'b', 'team-b', '/tmp/b', now(), 'idle', false)
96
+ ON CONFLICT (id) DO NOTHING
97
+ `;
98
+
99
+ await runMigration(sql);
100
+
101
+ const rows = await sql<{ id: string; auto_resume: boolean }[]>`
102
+ SELECT id, auto_resume FROM agents WHERE id IN (${uuidId}, ${dirId})
103
+ `;
104
+ for (const r of rows) {
105
+ expect(r.auto_resume).toBe(true);
106
+ }
107
+ });
108
+ });