Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
21 kB · 683 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684import { mkdirSync, readFileSync, writeFileSync } from 'fs'import { mkdir, readFile, rm, writeFile } from 'fs/promises'import { join } from 'path'import { z } from 'zod/v4'import { getSessionCreatedTeams } from '../../bootstrap/state.js'import { logForDebugging } from '../debug.js'import { getTeamsDir } from '../envUtils.js'import { errorMessage, getErrnoCode } from '../errors.js'import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'import { gitExe } from '../git.js'import { lazySchema } from '../lazySchema.js'import type { PermissionMode } from '../permissions/PermissionMode.js'import { jsonParse, jsonStringify } from '../slowOperations.js'import { getTasksDir, notifyTasksUpdated } from '../tasks.js'import { getAgentName, getTeamName, isTeammate } from '../teammate.js'import { type BackendType, isPaneBackend } from './backends/types.js'import { TEAM_LEAD_NAME } from './constants.js'
export const inputSchema = lazySchema(() => z.strictObject({ operation: z .enum(['spawnTeam', 'cleanup']) .describe( 'Operation: spawnTeam to create a team, cleanup to remove team and task directories.', ), agent_type: z .string() .optional() .describe( 'Type/role of the team lead (e.g., "researcher", "test-runner"). ' + 'Used for team file and inter-agent coordination.', ), team_name: z .string() .optional() .describe('Name for the new team to create (required for spawnTeam).'), description: z .string() .optional() .describe('Team description/purpose (only used with spawnTeam).'), }),)
// Output types for different operationsexport type SpawnTeamOutput = { team_name: string team_file_path: string lead_agent_id: string}
export type CleanupOutput = { success: boolean message: string team_name?: string}
export type TeamAllowedPath = { path: string // Directory path (absolute) toolName: string // The tool this applies to (e.g., "Edit", "Write") addedBy: string // Agent name who added this rule addedAt: number // Timestamp when added}
export type TeamFile = { name: string description?: string createdAt: number leadAgentId: string leadSessionId?: string // Actual session UUID of the leader (for discovery) hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking members: Array<{ agentId: string name: string agentType?: string model?: string prompt?: string color?: string planModeRequired?: boolean joinedAt: number tmuxPaneId: string cwd: string worktreePath?: string sessionId?: string subscriptions: string[] backendType?: BackendType isActive?: boolean // false when idle, undefined/true when active mode?: PermissionMode // Current permission mode for this teammate }>}
export type Input = z.infer<ReturnType<typeof inputSchema>>// Export SpawnTeamOutput as Output for backward compatibilityexport type Output = SpawnTeamOutput
/** * Sanitizes a name for use in tmux window names, worktree paths, and file paths. * Replaces all non-alphanumeric characters with hyphens and lowercases. */export function sanitizeName(name: string): string { return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}
/** * Sanitizes an agent name for use in deterministic agent IDs. * Replaces @ with - to prevent ambiguity in the agentName@teamName format. */export function sanitizeAgentName(name: string): string { return name.replace(/@/g, '-')}
/** * Gets the path to a team's directory */export function getTeamDir(teamName: string): string { return join(getTeamsDir(), sanitizeName(teamName))}
/** * Gets the path to a team's config.json file */export function getTeamFilePath(teamName: string): string { return join(getTeamDir(teamName), 'config.json')}
/** * Reads a team file by name (sync — for sync contexts like React render paths) * @internal Exported for team discovery UI */// sync IO: called from sync contextexport function readTeamFile(teamName: string): TeamFile | null { try { const content = readFileSync(getTeamFilePath(teamName), 'utf-8') return jsonParse(content) as TeamFile } catch (e) { if (getErrnoCode(e) === 'ENOENT') return null logForDebugging( `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`, ) return null }}
/** * Reads a team file by name (async — for tool handlers and other async contexts) */export async function readTeamFileAsync( teamName: string,): Promise<TeamFile | null> { try { const content = await readFile(getTeamFilePath(teamName), 'utf-8') return jsonParse(content) as TeamFile } catch (e) { if (getErrnoCode(e) === 'ENOENT') return null logForDebugging( `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`, ) return null }}
/** * Writes a team file (sync — for sync contexts) */// sync IO: called from sync contextfunction writeTeamFile(teamName: string, teamFile: TeamFile): void { const teamDir = getTeamDir(teamName) mkdirSync(teamDir, { recursive: true }) writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))}
/** * Writes a team file (async — for tool handlers) */export async function writeTeamFileAsync( teamName: string, teamFile: TeamFile,): Promise<void> { const teamDir = getTeamDir(teamName) await mkdir(teamDir, { recursive: true }) await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))}
/** * Removes a teammate from the team file by agent ID or name. * Used by the leader when processing shutdown approvals. */export function removeTeammateFromTeamFile( teamName: string, identifier: { agentId?: string; name?: string },): boolean { const identifierStr = identifier.agentId || identifier.name if (!identifierStr) { logForDebugging( '[TeammateTool] removeTeammateFromTeamFile called with no identifier', ) return false }
const teamFile = readTeamFile(teamName) if (!teamFile) { logForDebugging( `[TeammateTool] Cannot remove teammate ${identifierStr}: failed to read team file for "${teamName}"`, ) return false }
const originalLength = teamFile.members.length teamFile.members = teamFile.members.filter(m => { if (identifier.agentId && m.agentId === identifier.agentId) return false if (identifier.name && m.name === identifier.name) return false return true })
if (teamFile.members.length === originalLength) { logForDebugging( `[TeammateTool] Teammate ${identifierStr} not found in team file for "${teamName}"`, ) return false }
writeTeamFile(teamName, teamFile) logForDebugging( `[TeammateTool] Removed teammate from team file: ${identifierStr}`, ) return true}
/** * Adds a pane ID to the hidden panes list in the team file. * @param teamName - The name of the team * @param paneId - The pane ID to hide * @returns true if the pane was added to hidden list, false if team doesn't exist */export function addHiddenPaneId(teamName: string, paneId: string): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
const hiddenPaneIds = teamFile.hiddenPaneIds ?? [] if (!hiddenPaneIds.includes(paneId)) { hiddenPaneIds.push(paneId) teamFile.hiddenPaneIds = hiddenPaneIds writeTeamFile(teamName, teamFile) logForDebugging( `[TeammateTool] Added ${paneId} to hidden panes for team ${teamName}`, ) } return true}
/** * Removes a pane ID from the hidden panes list in the team file. * @param teamName - The name of the team * @param paneId - The pane ID to show (remove from hidden list) * @returns true if the pane was removed from hidden list, false if team doesn't exist */export function removeHiddenPaneId(teamName: string, paneId: string): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
const hiddenPaneIds = teamFile.hiddenPaneIds ?? [] const index = hiddenPaneIds.indexOf(paneId) if (index !== -1) { hiddenPaneIds.splice(index, 1) teamFile.hiddenPaneIds = hiddenPaneIds writeTeamFile(teamName, teamFile) logForDebugging( `[TeammateTool] Removed ${paneId} from hidden panes for team ${teamName}`, ) } return true}
/** * Removes a teammate from the team config file by pane ID. * Also removes from hiddenPaneIds if present. * @param teamName - The name of the team * @param tmuxPaneId - The pane ID of the teammate to remove * @returns true if the member was removed, false if team or member doesn't exist */export function removeMemberFromTeam( teamName: string, tmuxPaneId: string,): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
const memberIndex = teamFile.members.findIndex( m => m.tmuxPaneId === tmuxPaneId, ) if (memberIndex === -1) { return false }
// Remove from members array teamFile.members.splice(memberIndex, 1)
// Also remove from hiddenPaneIds if present if (teamFile.hiddenPaneIds) { const hiddenIndex = teamFile.hiddenPaneIds.indexOf(tmuxPaneId) if (hiddenIndex !== -1) { teamFile.hiddenPaneIds.splice(hiddenIndex, 1) } }
writeTeamFile(teamName, teamFile) logForDebugging( `[TeammateTool] Removed member with pane ${tmuxPaneId} from team ${teamName}`, ) return true}
/** * Removes a teammate from a team's member list by agent ID. * Use this for in-process teammates which all share the same tmuxPaneId. * @param teamName - The name of the team * @param agentId - The agent ID of the teammate to remove (e.g., "researcher@my-team") * @returns true if the member was removed, false if team or member doesn't exist */export function removeMemberByAgentId( teamName: string, agentId: string,): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
const memberIndex = teamFile.members.findIndex(m => m.agentId === agentId) if (memberIndex === -1) { return false }
// Remove from members array teamFile.members.splice(memberIndex, 1)
writeTeamFile(teamName, teamFile) logForDebugging( `[TeammateTool] Removed member ${agentId} from team ${teamName}`, ) return true}
/** * Sets a team member's permission mode. * Called when the team leader changes a teammate's mode via the TeamsDialog. * @param teamName - The name of the team * @param memberName - The name of the member to update * @param mode - The new permission mode */export function setMemberMode( teamName: string, memberName: string, mode: PermissionMode,): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
const member = teamFile.members.find(m => m.name === memberName) if (!member) { logForDebugging( `[TeammateTool] Cannot set member mode: member ${memberName} not found in team ${teamName}`, ) return false }
// Only write if the value is actually changing if (member.mode === mode) { return true }
// Create updated members array immutably const updatedMembers = teamFile.members.map(m => m.name === memberName ? { ...m, mode } : m, ) writeTeamFile(teamName, { ...teamFile, members: updatedMembers }) logForDebugging( `[TeammateTool] Set member ${memberName} in team ${teamName} to mode: ${mode}`, ) return true}
/** * Sync the current teammate's mode to config.json so team lead sees it. * No-op if not running as a teammate. * @param mode - The permission mode to sync * @param teamNameOverride - Optional team name override (uses env var if not provided) */export function syncTeammateMode( mode: PermissionMode, teamNameOverride?: string,): void { if (!isTeammate()) return const teamName = teamNameOverride ?? getTeamName() const agentName = getAgentName() if (teamName && agentName) { setMemberMode(teamName, agentName, mode) }}
/** * Sets multiple team members' permission modes in a single atomic operation. * Avoids race conditions when updating multiple teammates at once. * @param teamName - The name of the team * @param modeUpdates - Array of {memberName, mode} to update */export function setMultipleMemberModes( teamName: string, modeUpdates: Array<{ memberName: string; mode: PermissionMode }>,): boolean { const teamFile = readTeamFile(teamName) if (!teamFile) { return false }
// Build a map of updates for efficient lookup const updateMap = new Map(modeUpdates.map(u => [u.memberName, u.mode]))
// Create updated members array immutably let anyChanged = false const updatedMembers = teamFile.members.map(member => { const newMode = updateMap.get(member.name) if (newMode !== undefined && member.mode !== newMode) { anyChanged = true return { ...member, mode: newMode } } return member })
if (anyChanged) { writeTeamFile(teamName, { ...teamFile, members: updatedMembers }) logForDebugging( `[TeammateTool] Set ${modeUpdates.length} member modes in team ${teamName}`, ) } return true}
/** * Sets a team member's active status. * Called when a teammate becomes idle (isActive=false) or starts a new turn (isActive=true). * @param teamName - The name of the team * @param memberName - The name of the member to update * @param isActive - Whether the member is active (true) or idle (false) */export async function setMemberActive( teamName: string, memberName: string, isActive: boolean,): Promise<void> { const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { logForDebugging( `[TeammateTool] Cannot set member active: team ${teamName} not found`, ) return }
const member = teamFile.members.find(m => m.name === memberName) if (!member) { logForDebugging( `[TeammateTool] Cannot set member active: member ${memberName} not found in team ${teamName}`, ) return }
// Only write if the value is actually changing if (member.isActive === isActive) { return }
member.isActive = isActive await writeTeamFileAsync(teamName, teamFile) logForDebugging( `[TeammateTool] Set member ${memberName} in team ${teamName} to ${isActive ? 'active' : 'idle'}`, )}
/** * Destroys a git worktree at the given path. * First attempts to use `git worktree remove`, then falls back to rm -rf. * Safe to call on non-existent paths. */async function destroyWorktree(worktreePath: string): Promise<void> { // Read the .git file in the worktree to find the main repo const gitFilePath = join(worktreePath, '.git') let mainRepoPath: string | null = null
try { const gitFileContent = (await readFile(gitFilePath, 'utf-8')).trim() // The .git file contains something like: gitdir: /path/to/repo/.git/worktrees/worktree-name const match = gitFileContent.match(/^gitdir:\s*(.+)$/) if (match && match[1]) { // Extract the main repo .git directory (go up from .git/worktrees/name to .git) const worktreeGitDir = match[1] // Go up 2 levels from .git/worktrees/name to get to .git, then get parent for repo root const mainGitDir = join(worktreeGitDir, '..', '..') mainRepoPath = join(mainGitDir, '..') } } catch { // Ignore errors reading .git file (path doesn't exist, not a file, etc.) }
// Try to remove using git worktree remove command if (mainRepoPath) { const result = await execFileNoThrowWithCwd( gitExe(), ['worktree', 'remove', '--force', worktreePath], { cwd: mainRepoPath }, )
if (result.code === 0) { logForDebugging( `[TeammateTool] Removed worktree via git: ${worktreePath}`, ) return }
// Check if the error is "not a working tree" (already removed) if (result.stderr?.includes('not a working tree')) { logForDebugging( `[TeammateTool] Worktree already removed: ${worktreePath}`, ) return }
logForDebugging( `[TeammateTool] git worktree remove failed, falling back to rm: ${result.stderr}`, ) }
// Fallback: manually remove the directory try { await rm(worktreePath, { recursive: true, force: true }) logForDebugging( `[TeammateTool] Removed worktree directory manually: ${worktreePath}`, ) } catch (error) { logForDebugging( `[TeammateTool] Failed to remove worktree ${worktreePath}: ${errorMessage(error)}`, ) }}
/** * Mark a team as created this session so it gets cleaned up on exit. * Call this right after the initial writeTeamFile. TeamDelete should * call unregisterTeamForSessionCleanup to prevent double-cleanup. * Backing Set lives in bootstrap/state.ts so resetStateForTests() * clears it between tests (avoids the PR #17615 cross-shard leak class). */export function registerTeamForSessionCleanup(teamName: string): void { getSessionCreatedTeams().add(teamName)}
/** * Remove a team from session cleanup tracking (e.g., after explicit * TeamDelete — already cleaned, don't try again on shutdown). */export function unregisterTeamForSessionCleanup(teamName: string): void { getSessionCreatedTeams().delete(teamName)}
/** * Clean up all teams created this session that weren't explicitly deleted. * Registered with gracefulShutdown from init.ts. */export async function cleanupSessionTeams(): Promise<void> { const sessionCreatedTeams = getSessionCreatedTeams() if (sessionCreatedTeams.size === 0) return const teams = Array.from(sessionCreatedTeams) logForDebugging( `cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`, ) // Kill panes first — on SIGINT the teammate processes are still running; // deleting directories alone would orphan them in open tmux/iTerm2 panes. // (TeamDeleteTool's path doesn't need this — by then teammates have // gracefully exited and useInboxPoller has already closed their panes.) await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name))) await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name))) sessionCreatedTeams.clear()}
/** * Best-effort kill of all pane-backed teammate panes for a team. * Called from cleanupSessionTeams on ungraceful leader exit (SIGINT/SIGTERM). * Dynamic imports avoid adding registry/detection to this module's static * dep graph — this only runs at shutdown, so the import cost is irrelevant. */async function killOrphanedTeammatePanes(teamName: string): Promise<void> { const teamFile = readTeamFile(teamName) if (!teamFile) return
const paneMembers = teamFile.members.filter( m => m.name !== TEAM_LEAD_NAME && m.tmuxPaneId && m.backendType && isPaneBackend(m.backendType), ) if (paneMembers.length === 0) return
const [{ ensureBackendsRegistered, getBackendByType }, { isInsideTmux }] = await Promise.all([ import('./backends/registry.js'), import('./backends/detection.js'), ]) await ensureBackendsRegistered() const useExternalSession = !(await isInsideTmux())
await Promise.allSettled( paneMembers.map(async m => { // filter above guarantees these; narrow for the type system if (!m.tmuxPaneId || !m.backendType || !isPaneBackend(m.backendType)) { return } const ok = await getBackendByType(m.backendType).killPane( m.tmuxPaneId, useExternalSession, ) logForDebugging( `cleanupSessionTeams: killPane ${m.name} (${m.backendType} ${m.tmuxPaneId}) → ${ok}`, ) }), )}
/** * Cleans up team and task directories for a given team name. * Also cleans up git worktrees created for teammates. * Called when a swarm session is terminated. */export async function cleanupTeamDirectories(teamName: string): Promise<void> { const sanitizedName = sanitizeName(teamName)
// Read team file to get worktree paths BEFORE deleting the team directory const teamFile = readTeamFile(teamName) const worktreePaths: string[] = [] if (teamFile) { for (const member of teamFile.members) { if (member.worktreePath) { worktreePaths.push(member.worktreePath) } } }
// Clean up worktrees first for (const worktreePath of worktreePaths) { await destroyWorktree(worktreePath) }
// Clean up team directory (~/.claude/teams/{team-name}/) const teamDir = getTeamDir(teamName) try { await rm(teamDir, { recursive: true, force: true }) logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`) } catch (error) { logForDebugging( `[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`, ) }
// Clean up tasks directory (~/.claude/tasks/{taskListId}/) // The leader and teammates all store tasks under the sanitized team name. const tasksDir = getTasksDir(sanitizedName) try { await rm(tasksDir, { recursive: true, force: true }) logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`) notifyTasksUpdated() } catch (error) { logForDebugging( `[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`, ) }}