/** * Teammate utilities for agent swarm coordination * * These helpers identify whether this Claude Code instance is running as a * spawned teammate in a swarm. Teammates receive their identity via CLI * arguments (--agent-id, --team-name, etc.) which are stored in dynamicTeamContext. * * For in-process teammates (running in the same process), AsyncLocalStorage * provides isolated context per teammate, preventing concurrent overwrites. * * Priority order for identity resolution: * 1. AsyncLocalStorage (in-process teammates) - via teammateContext.ts * 2. dynamicTeamContext (tmux teammates via CLI args) */ // Re-export in-process teammate utilities from teammateContext.ts export { createTeammateContext, getTeammateContext, isInProcessTeammate, runWithTeammateContext, type TeammateContext, } from './teammateContext.js' import type { AppState } from '../state/AppState.js' import { isEnvTruthy } from './envUtils.js' import { getTeammateContext } from './teammateContext.js' /** * Returns the parent session ID for this teammate. * For in-process teammates, this is the team lead's session ID. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux teammates). */ export function getParentSessionId(): string | undefined { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.parentSessionId return dynamicTeamContext?.parentSessionId } /** * Dynamic team context for runtime team joining. * When set, these values take precedence over environment variables. */ let dynamicTeamContext: { agentId: string agentName: string teamName: string color?: string planModeRequired: boolean parentSessionId?: string } | null = null /** * Set the dynamic team context (called when joining a team at runtime) */ export function setDynamicTeamContext( context: { agentId: string agentName: string teamName: string color?: string planModeRequired: boolean parentSessionId?: string } | null, ): void { dynamicTeamContext = context } /** * Clear the dynamic team context (called when leaving a team) */ export function clearDynamicTeamContext(): void { dynamicTeamContext = null } /** * Get the current dynamic team context (for inspection/debugging) */ export function getDynamicTeamContext(): typeof dynamicTeamContext { return dynamicTeamContext } /** * Returns the agent ID if this session is running as a teammate in a swarm, * or undefined if running as a standalone session. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux via CLI args). */ export function getAgentId(): string | undefined { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.agentId return dynamicTeamContext?.agentId } /** * Returns the agent name if this session is running as a teammate in a swarm. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux via CLI args). */ export function getAgentName(): string | undefined { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.agentName return dynamicTeamContext?.agentName } /** * Returns the team name if this session is part of a team. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux via CLI args) > passed teamContext. * Pass teamContext from AppState to support leaders who don't have dynamicTeamContext set. * * @param teamContext - Optional team context from AppState (for leaders) */ export function getTeamName(teamContext?: { teamName: string }): string | undefined { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.teamName if (dynamicTeamContext?.teamName) return dynamicTeamContext.teamName return teamContext?.teamName } /** * Returns true if this session is running as a teammate in a swarm. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux via CLI args). * For tmux teammates, requires BOTH an agent ID AND a team name. */ export function isTeammate(): boolean { // In-process teammates run within the same process const inProcessCtx = getTeammateContext() if (inProcessCtx) return true // Tmux teammates require both agent ID and team name return !!(dynamicTeamContext?.agentId && dynamicTeamContext?.teamName) } /** * Returns the teammate's assigned color, * or undefined if not running as a teammate or no color assigned. * Priority: AsyncLocalStorage (in-process) > dynamicTeamContext (tmux teammates). */ export function getTeammateColor(): string | undefined { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.color return dynamicTeamContext?.color } /** * Returns true if this teammate session requires plan mode before implementation. * When enabled, the teammate must enter plan mode and get approval before writing code. * Priority: AsyncLocalStorage > dynamicTeamContext > env var. */ export function isPlanModeRequired(): boolean { const inProcessCtx = getTeammateContext() if (inProcessCtx) return inProcessCtx.planModeRequired if (dynamicTeamContext !== null) { return dynamicTeamContext.planModeRequired } return isEnvTruthy(process.env.CLAUDE_CODE_PLAN_MODE_REQUIRED) } /** * Check if this session is a team lead. * * A session is considered a team lead if: * 1. A team context exists with a leadAgentId, AND * 2. Either: * - Our CLAUDE_CODE_AGENT_ID matches the leadAgentId, OR * - We have no CLAUDE_CODE_AGENT_ID set (backwards compat: the original * session that created the team before agent IDs were standardized) * * @param teamContext - The team context from AppState, if any * @returns true if this session is the team lead */ export function isTeamLead( teamContext: | { leadAgentId: string } | undefined, ): boolean { if (!teamContext?.leadAgentId) { return false } // Use getAgentId() for AsyncLocalStorage support (in-process teammates) const myAgentId = getAgentId() const leadAgentId = teamContext.leadAgentId // If my agent ID matches the lead agent ID, I'm the lead if (myAgentId === leadAgentId) { return true } // Backwards compat: if no agent ID is set and we have a team context, // this is the original session that created the team (the lead) if (!myAgentId) { return true } return false } /** * Checks if there are any active in-process teammates running. * Used by headless/print mode to determine if we should wait for teammates * before exiting. */ export function hasActiveInProcessTeammates(appState: AppState): boolean { // Check for running in-process teammate tasks for (const task of Object.values(appState.tasks)) { if (task.type === 'in_process_teammate' && task.status === 'running') { return true } } return false } /** * Checks if there are in-process teammates still actively working on tasks. * Returns true if any teammate is running but NOT idle (still processing). * Used to determine if we should wait before sending shutdown prompts. */ export function hasWorkingInProcessTeammates(appState: AppState): boolean { for (const task of Object.values(appState.tasks)) { if ( task.type === 'in_process_teammate' && task.status === 'running' && !task.isIdle ) { return true } } return false } /** * Returns a promise that resolves when all working in-process teammates become idle. * Registers callbacks on each working teammate's task - they call these when idle. * Returns immediately if no teammates are working. */ export function waitForTeammatesToBecomeIdle( setAppState: (f: (prev: AppState) => AppState) => void, appState: AppState, ): Promise { const workingTaskIds: string[] = [] for (const [taskId, task] of Object.entries(appState.tasks)) { if ( task.type === 'in_process_teammate' && task.status === 'running' && !task.isIdle ) { workingTaskIds.push(taskId) } } if (workingTaskIds.length === 0) { return Promise.resolve() } // Create a promise that resolves when all working teammates become idle return new Promise(resolve => { let remaining = workingTaskIds.length const onIdle = (): void => { remaining-- if (remaining === 0) { // biome-ignore lint/nursery/noFloatingPromises: resolve is a callback, not a Promise resolve() } } // Register callback on each working teammate // Check current isIdle state to handle race where teammate became idle // between our initial snapshot and this callback registration setAppState(prev => { const newTasks = { ...prev.tasks } for (const taskId of workingTaskIds) { const task = newTasks[taskId] if (task && task.type === 'in_process_teammate') { // If task is already idle, call onIdle immediately if (task.isIdle) { onIdle() } else { newTasks[taskId] = { ...task, onIdleCallbacks: [...(task.onIdleCallbacks ?? []), onIdle], } } } } return { ...prev, tasks: newTasks } }) }) }