/ src / utils / swarm / teamHelpers.ts
teamHelpers.ts
  1  import { mkdirSync, readFileSync, writeFileSync } from 'fs'
  2  import { mkdir, readFile, rm, writeFile } from 'fs/promises'
  3  import { join } from 'path'
  4  import { z } from 'zod/v4'
  5  import { getSessionCreatedTeams } from '../../bootstrap/state.js'
  6  import { logForDebugging } from '../debug.js'
  7  import { getTeamsDir } from '../envUtils.js'
  8  import { errorMessage, getErrnoCode } from '../errors.js'
  9  import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
 10  import { gitExe } from '../git.js'
 11  import { lazySchema } from '../lazySchema.js'
 12  import type { PermissionMode } from '../permissions/PermissionMode.js'
 13  import { jsonParse, jsonStringify } from '../slowOperations.js'
 14  import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
 15  import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
 16  import { type BackendType, isPaneBackend } from './backends/types.js'
 17  import { TEAM_LEAD_NAME } from './constants.js'
 18  
 19  export const inputSchema = lazySchema(() =>
 20    z.strictObject({
 21      operation: z
 22        .enum(['spawnTeam', 'cleanup'])
 23        .describe(
 24          'Operation: spawnTeam to create a team, cleanup to remove team and task directories.',
 25        ),
 26      agent_type: z
 27        .string()
 28        .optional()
 29        .describe(
 30          'Type/role of the team lead (e.g., "researcher", "test-runner"). ' +
 31            'Used for team file and inter-agent coordination.',
 32        ),
 33      team_name: z
 34        .string()
 35        .optional()
 36        .describe('Name for the new team to create (required for spawnTeam).'),
 37      description: z
 38        .string()
 39        .optional()
 40        .describe('Team description/purpose (only used with spawnTeam).'),
 41    }),
 42  )
 43  
 44  // Output types for different operations
 45  export type SpawnTeamOutput = {
 46    team_name: string
 47    team_file_path: string
 48    lead_agent_id: string
 49  }
 50  
 51  export type CleanupOutput = {
 52    success: boolean
 53    message: string
 54    team_name?: string
 55  }
 56  
 57  export type TeamAllowedPath = {
 58    path: string // Directory path (absolute)
 59    toolName: string // The tool this applies to (e.g., "Edit", "Write")
 60    addedBy: string // Agent name who added this rule
 61    addedAt: number // Timestamp when added
 62  }
 63  
 64  export type TeamFile = {
 65    name: string
 66    description?: string
 67    createdAt: number
 68    leadAgentId: string
 69    leadSessionId?: string // Actual session UUID of the leader (for discovery)
 70    hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
 71    teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
 72    members: Array<{
 73      agentId: string
 74      name: string
 75      agentType?: string
 76      model?: string
 77      prompt?: string
 78      color?: string
 79      planModeRequired?: boolean
 80      joinedAt: number
 81      tmuxPaneId: string
 82      cwd: string
 83      worktreePath?: string
 84      sessionId?: string
 85      subscriptions: string[]
 86      backendType?: BackendType
 87      isActive?: boolean // false when idle, undefined/true when active
 88      mode?: PermissionMode // Current permission mode for this teammate
 89    }>
 90  }
 91  
 92  export type Input = z.infer<ReturnType<typeof inputSchema>>
 93  // Export SpawnTeamOutput as Output for backward compatibility
 94  export type Output = SpawnTeamOutput
 95  
 96  /**
 97   * Sanitizes a name for use in tmux window names, worktree paths, and file paths.
 98   * Replaces all non-alphanumeric characters with hyphens and lowercases.
 99   */
100  export function sanitizeName(name: string): string {
101    return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
102  }
103  
104  /**
105   * Sanitizes an agent name for use in deterministic agent IDs.
106   * Replaces @ with - to prevent ambiguity in the agentName@teamName format.
107   */
108  export function sanitizeAgentName(name: string): string {
109    return name.replace(/@/g, '-')
110  }
111  
112  /**
113   * Gets the path to a team's directory
114   */
115  export function getTeamDir(teamName: string): string {
116    return join(getTeamsDir(), sanitizeName(teamName))
117  }
118  
119  /**
120   * Gets the path to a team's config.json file
121   */
122  export function getTeamFilePath(teamName: string): string {
123    return join(getTeamDir(teamName), 'config.json')
124  }
125  
126  /**
127   * Reads a team file by name (sync — for sync contexts like React render paths)
128   * @internal Exported for team discovery UI
129   */
130  // sync IO: called from sync context
131  export function readTeamFile(teamName: string): TeamFile | null {
132    try {
133      const content = readFileSync(getTeamFilePath(teamName), 'utf-8')
134      return jsonParse(content) as TeamFile
135    } catch (e) {
136      if (getErrnoCode(e) === 'ENOENT') return null
137      logForDebugging(
138        `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
139      )
140      return null
141    }
142  }
143  
144  /**
145   * Reads a team file by name (async — for tool handlers and other async contexts)
146   */
147  export async function readTeamFileAsync(
148    teamName: string,
149  ): Promise<TeamFile | null> {
150    try {
151      const content = await readFile(getTeamFilePath(teamName), 'utf-8')
152      return jsonParse(content) as TeamFile
153    } catch (e) {
154      if (getErrnoCode(e) === 'ENOENT') return null
155      logForDebugging(
156        `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
157      )
158      return null
159    }
160  }
161  
162  /**
163   * Writes a team file (sync — for sync contexts)
164   */
165  // sync IO: called from sync context
166  function writeTeamFile(teamName: string, teamFile: TeamFile): void {
167    const teamDir = getTeamDir(teamName)
168    mkdirSync(teamDir, { recursive: true })
169    writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
170  }
171  
172  /**
173   * Writes a team file (async — for tool handlers)
174   */
175  export async function writeTeamFileAsync(
176    teamName: string,
177    teamFile: TeamFile,
178  ): Promise<void> {
179    const teamDir = getTeamDir(teamName)
180    await mkdir(teamDir, { recursive: true })
181    await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
182  }
183  
184  /**
185   * Removes a teammate from the team file by agent ID or name.
186   * Used by the leader when processing shutdown approvals.
187   */
188  export function removeTeammateFromTeamFile(
189    teamName: string,
190    identifier: { agentId?: string; name?: string },
191  ): boolean {
192    const identifierStr = identifier.agentId || identifier.name
193    if (!identifierStr) {
194      logForDebugging(
195        '[TeammateTool] removeTeammateFromTeamFile called with no identifier',
196      )
197      return false
198    }
199  
200    const teamFile = readTeamFile(teamName)
201    if (!teamFile) {
202      logForDebugging(
203        `[TeammateTool] Cannot remove teammate ${identifierStr}: failed to read team file for "${teamName}"`,
204      )
205      return false
206    }
207  
208    const originalLength = teamFile.members.length
209    teamFile.members = teamFile.members.filter(m => {
210      if (identifier.agentId && m.agentId === identifier.agentId) return false
211      if (identifier.name && m.name === identifier.name) return false
212      return true
213    })
214  
215    if (teamFile.members.length === originalLength) {
216      logForDebugging(
217        `[TeammateTool] Teammate ${identifierStr} not found in team file for "${teamName}"`,
218      )
219      return false
220    }
221  
222    writeTeamFile(teamName, teamFile)
223    logForDebugging(
224      `[TeammateTool] Removed teammate from team file: ${identifierStr}`,
225    )
226    return true
227  }
228  
229  /**
230   * Adds a pane ID to the hidden panes list in the team file.
231   * @param teamName - The name of the team
232   * @param paneId - The pane ID to hide
233   * @returns true if the pane was added to hidden list, false if team doesn't exist
234   */
235  export function addHiddenPaneId(teamName: string, paneId: string): boolean {
236    const teamFile = readTeamFile(teamName)
237    if (!teamFile) {
238      return false
239    }
240  
241    const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
242    if (!hiddenPaneIds.includes(paneId)) {
243      hiddenPaneIds.push(paneId)
244      teamFile.hiddenPaneIds = hiddenPaneIds
245      writeTeamFile(teamName, teamFile)
246      logForDebugging(
247        `[TeammateTool] Added ${paneId} to hidden panes for team ${teamName}`,
248      )
249    }
250    return true
251  }
252  
253  /**
254   * Removes a pane ID from the hidden panes list in the team file.
255   * @param teamName - The name of the team
256   * @param paneId - The pane ID to show (remove from hidden list)
257   * @returns true if the pane was removed from hidden list, false if team doesn't exist
258   */
259  export function removeHiddenPaneId(teamName: string, paneId: string): boolean {
260    const teamFile = readTeamFile(teamName)
261    if (!teamFile) {
262      return false
263    }
264  
265    const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
266    const index = hiddenPaneIds.indexOf(paneId)
267    if (index !== -1) {
268      hiddenPaneIds.splice(index, 1)
269      teamFile.hiddenPaneIds = hiddenPaneIds
270      writeTeamFile(teamName, teamFile)
271      logForDebugging(
272        `[TeammateTool] Removed ${paneId} from hidden panes for team ${teamName}`,
273      )
274    }
275    return true
276  }
277  
278  /**
279   * Removes a teammate from the team config file by pane ID.
280   * Also removes from hiddenPaneIds if present.
281   * @param teamName - The name of the team
282   * @param tmuxPaneId - The pane ID of the teammate to remove
283   * @returns true if the member was removed, false if team or member doesn't exist
284   */
285  export function removeMemberFromTeam(
286    teamName: string,
287    tmuxPaneId: string,
288  ): boolean {
289    const teamFile = readTeamFile(teamName)
290    if (!teamFile) {
291      return false
292    }
293  
294    const memberIndex = teamFile.members.findIndex(
295      m => m.tmuxPaneId === tmuxPaneId,
296    )
297    if (memberIndex === -1) {
298      return false
299    }
300  
301    // Remove from members array
302    teamFile.members.splice(memberIndex, 1)
303  
304    // Also remove from hiddenPaneIds if present
305    if (teamFile.hiddenPaneIds) {
306      const hiddenIndex = teamFile.hiddenPaneIds.indexOf(tmuxPaneId)
307      if (hiddenIndex !== -1) {
308        teamFile.hiddenPaneIds.splice(hiddenIndex, 1)
309      }
310    }
311  
312    writeTeamFile(teamName, teamFile)
313    logForDebugging(
314      `[TeammateTool] Removed member with pane ${tmuxPaneId} from team ${teamName}`,
315    )
316    return true
317  }
318  
319  /**
320   * Removes a teammate from a team's member list by agent ID.
321   * Use this for in-process teammates which all share the same tmuxPaneId.
322   * @param teamName - The name of the team
323   * @param agentId - The agent ID of the teammate to remove (e.g., "researcher@my-team")
324   * @returns true if the member was removed, false if team or member doesn't exist
325   */
326  export function removeMemberByAgentId(
327    teamName: string,
328    agentId: string,
329  ): boolean {
330    const teamFile = readTeamFile(teamName)
331    if (!teamFile) {
332      return false
333    }
334  
335    const memberIndex = teamFile.members.findIndex(m => m.agentId === agentId)
336    if (memberIndex === -1) {
337      return false
338    }
339  
340    // Remove from members array
341    teamFile.members.splice(memberIndex, 1)
342  
343    writeTeamFile(teamName, teamFile)
344    logForDebugging(
345      `[TeammateTool] Removed member ${agentId} from team ${teamName}`,
346    )
347    return true
348  }
349  
350  /**
351   * Sets a team member's permission mode.
352   * Called when the team leader changes a teammate's mode via the TeamsDialog.
353   * @param teamName - The name of the team
354   * @param memberName - The name of the member to update
355   * @param mode - The new permission mode
356   */
357  export function setMemberMode(
358    teamName: string,
359    memberName: string,
360    mode: PermissionMode,
361  ): boolean {
362    const teamFile = readTeamFile(teamName)
363    if (!teamFile) {
364      return false
365    }
366  
367    const member = teamFile.members.find(m => m.name === memberName)
368    if (!member) {
369      logForDebugging(
370        `[TeammateTool] Cannot set member mode: member ${memberName} not found in team ${teamName}`,
371      )
372      return false
373    }
374  
375    // Only write if the value is actually changing
376    if (member.mode === mode) {
377      return true
378    }
379  
380    // Create updated members array immutably
381    const updatedMembers = teamFile.members.map(m =>
382      m.name === memberName ? { ...m, mode } : m,
383    )
384    writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
385    logForDebugging(
386      `[TeammateTool] Set member ${memberName} in team ${teamName} to mode: ${mode}`,
387    )
388    return true
389  }
390  
391  /**
392   * Sync the current teammate's mode to config.json so team lead sees it.
393   * No-op if not running as a teammate.
394   * @param mode - The permission mode to sync
395   * @param teamNameOverride - Optional team name override (uses env var if not provided)
396   */
397  export function syncTeammateMode(
398    mode: PermissionMode,
399    teamNameOverride?: string,
400  ): void {
401    if (!isTeammate()) return
402    const teamName = teamNameOverride ?? getTeamName()
403    const agentName = getAgentName()
404    if (teamName && agentName) {
405      setMemberMode(teamName, agentName, mode)
406    }
407  }
408  
409  /**
410   * Sets multiple team members' permission modes in a single atomic operation.
411   * Avoids race conditions when updating multiple teammates at once.
412   * @param teamName - The name of the team
413   * @param modeUpdates - Array of {memberName, mode} to update
414   */
415  export function setMultipleMemberModes(
416    teamName: string,
417    modeUpdates: Array<{ memberName: string; mode: PermissionMode }>,
418  ): boolean {
419    const teamFile = readTeamFile(teamName)
420    if (!teamFile) {
421      return false
422    }
423  
424    // Build a map of updates for efficient lookup
425    const updateMap = new Map(modeUpdates.map(u => [u.memberName, u.mode]))
426  
427    // Create updated members array immutably
428    let anyChanged = false
429    const updatedMembers = teamFile.members.map(member => {
430      const newMode = updateMap.get(member.name)
431      if (newMode !== undefined && member.mode !== newMode) {
432        anyChanged = true
433        return { ...member, mode: newMode }
434      }
435      return member
436    })
437  
438    if (anyChanged) {
439      writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
440      logForDebugging(
441        `[TeammateTool] Set ${modeUpdates.length} member modes in team ${teamName}`,
442      )
443    }
444    return true
445  }
446  
447  /**
448   * Sets a team member's active status.
449   * Called when a teammate becomes idle (isActive=false) or starts a new turn (isActive=true).
450   * @param teamName - The name of the team
451   * @param memberName - The name of the member to update
452   * @param isActive - Whether the member is active (true) or idle (false)
453   */
454  export async function setMemberActive(
455    teamName: string,
456    memberName: string,
457    isActive: boolean,
458  ): Promise<void> {
459    const teamFile = await readTeamFileAsync(teamName)
460    if (!teamFile) {
461      logForDebugging(
462        `[TeammateTool] Cannot set member active: team ${teamName} not found`,
463      )
464      return
465    }
466  
467    const member = teamFile.members.find(m => m.name === memberName)
468    if (!member) {
469      logForDebugging(
470        `[TeammateTool] Cannot set member active: member ${memberName} not found in team ${teamName}`,
471      )
472      return
473    }
474  
475    // Only write if the value is actually changing
476    if (member.isActive === isActive) {
477      return
478    }
479  
480    member.isActive = isActive
481    await writeTeamFileAsync(teamName, teamFile)
482    logForDebugging(
483      `[TeammateTool] Set member ${memberName} in team ${teamName} to ${isActive ? 'active' : 'idle'}`,
484    )
485  }
486  
487  /**
488   * Destroys a git worktree at the given path.
489   * First attempts to use `git worktree remove`, then falls back to rm -rf.
490   * Safe to call on non-existent paths.
491   */
492  async function destroyWorktree(worktreePath: string): Promise<void> {
493    // Read the .git file in the worktree to find the main repo
494    const gitFilePath = join(worktreePath, '.git')
495    let mainRepoPath: string | null = null
496  
497    try {
498      const gitFileContent = (await readFile(gitFilePath, 'utf-8')).trim()
499      // The .git file contains something like: gitdir: /path/to/repo/.git/worktrees/worktree-name
500      const match = gitFileContent.match(/^gitdir:\s*(.+)$/)
501      if (match && match[1]) {
502        // Extract the main repo .git directory (go up from .git/worktrees/name to .git)
503        const worktreeGitDir = match[1]
504        // Go up 2 levels from .git/worktrees/name to get to .git, then get parent for repo root
505        const mainGitDir = join(worktreeGitDir, '..', '..')
506        mainRepoPath = join(mainGitDir, '..')
507      }
508    } catch {
509      // Ignore errors reading .git file (path doesn't exist, not a file, etc.)
510    }
511  
512    // Try to remove using git worktree remove command
513    if (mainRepoPath) {
514      const result = await execFileNoThrowWithCwd(
515        gitExe(),
516        ['worktree', 'remove', '--force', worktreePath],
517        { cwd: mainRepoPath },
518      )
519  
520      if (result.code === 0) {
521        logForDebugging(
522          `[TeammateTool] Removed worktree via git: ${worktreePath}`,
523        )
524        return
525      }
526  
527      // Check if the error is "not a working tree" (already removed)
528      if (result.stderr?.includes('not a working tree')) {
529        logForDebugging(
530          `[TeammateTool] Worktree already removed: ${worktreePath}`,
531        )
532        return
533      }
534  
535      logForDebugging(
536        `[TeammateTool] git worktree remove failed, falling back to rm: ${result.stderr}`,
537      )
538    }
539  
540    // Fallback: manually remove the directory
541    try {
542      await rm(worktreePath, { recursive: true, force: true })
543      logForDebugging(
544        `[TeammateTool] Removed worktree directory manually: ${worktreePath}`,
545      )
546    } catch (error) {
547      logForDebugging(
548        `[TeammateTool] Failed to remove worktree ${worktreePath}: ${errorMessage(error)}`,
549      )
550    }
551  }
552  
553  /**
554   * Mark a team as created this session so it gets cleaned up on exit.
555   * Call this right after the initial writeTeamFile. TeamDelete should
556   * call unregisterTeamForSessionCleanup to prevent double-cleanup.
557   * Backing Set lives in bootstrap/state.ts so resetStateForTests()
558   * clears it between tests (avoids the PR #17615 cross-shard leak class).
559   */
560  export function registerTeamForSessionCleanup(teamName: string): void {
561    getSessionCreatedTeams().add(teamName)
562  }
563  
564  /**
565   * Remove a team from session cleanup tracking (e.g., after explicit
566   * TeamDelete — already cleaned, don't try again on shutdown).
567   */
568  export function unregisterTeamForSessionCleanup(teamName: string): void {
569    getSessionCreatedTeams().delete(teamName)
570  }
571  
572  /**
573   * Clean up all teams created this session that weren't explicitly deleted.
574   * Registered with gracefulShutdown from init.ts.
575   */
576  export async function cleanupSessionTeams(): Promise<void> {
577    const sessionCreatedTeams = getSessionCreatedTeams()
578    if (sessionCreatedTeams.size === 0) return
579    const teams = Array.from(sessionCreatedTeams)
580    logForDebugging(
581      `cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`,
582    )
583    // Kill panes first — on SIGINT the teammate processes are still running;
584    // deleting directories alone would orphan them in open tmux/iTerm2 panes.
585    // (TeamDeleteTool's path doesn't need this — by then teammates have
586    // gracefully exited and useInboxPoller has already closed their panes.)
587    await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name)))
588    await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name)))
589    sessionCreatedTeams.clear()
590  }
591  
592  /**
593   * Best-effort kill of all pane-backed teammate panes for a team.
594   * Called from cleanupSessionTeams on ungraceful leader exit (SIGINT/SIGTERM).
595   * Dynamic imports avoid adding registry/detection to this module's static
596   * dep graph — this only runs at shutdown, so the import cost is irrelevant.
597   */
598  async function killOrphanedTeammatePanes(teamName: string): Promise<void> {
599    const teamFile = readTeamFile(teamName)
600    if (!teamFile) return
601  
602    const paneMembers = teamFile.members.filter(
603      m =>
604        m.name !== TEAM_LEAD_NAME &&
605        m.tmuxPaneId &&
606        m.backendType &&
607        isPaneBackend(m.backendType),
608    )
609    if (paneMembers.length === 0) return
610  
611    const [{ ensureBackendsRegistered, getBackendByType }, { isInsideTmux }] =
612      await Promise.all([
613        import('./backends/registry.js'),
614        import('./backends/detection.js'),
615      ])
616    await ensureBackendsRegistered()
617    const useExternalSession = !(await isInsideTmux())
618  
619    await Promise.allSettled(
620      paneMembers.map(async m => {
621        // filter above guarantees these; narrow for the type system
622        if (!m.tmuxPaneId || !m.backendType || !isPaneBackend(m.backendType)) {
623          return
624        }
625        const ok = await getBackendByType(m.backendType).killPane(
626          m.tmuxPaneId,
627          useExternalSession,
628        )
629        logForDebugging(
630          `cleanupSessionTeams: killPane ${m.name} (${m.backendType} ${m.tmuxPaneId}) → ${ok}`,
631        )
632      }),
633    )
634  }
635  
636  /**
637   * Cleans up team and task directories for a given team name.
638   * Also cleans up git worktrees created for teammates.
639   * Called when a swarm session is terminated.
640   */
641  export async function cleanupTeamDirectories(teamName: string): Promise<void> {
642    const sanitizedName = sanitizeName(teamName)
643  
644    // Read team file to get worktree paths BEFORE deleting the team directory
645    const teamFile = readTeamFile(teamName)
646    const worktreePaths: string[] = []
647    if (teamFile) {
648      for (const member of teamFile.members) {
649        if (member.worktreePath) {
650          worktreePaths.push(member.worktreePath)
651        }
652      }
653    }
654  
655    // Clean up worktrees first
656    for (const worktreePath of worktreePaths) {
657      await destroyWorktree(worktreePath)
658    }
659  
660    // Clean up team directory (~/.claude/teams/{team-name}/)
661    const teamDir = getTeamDir(teamName)
662    try {
663      await rm(teamDir, { recursive: true, force: true })
664      logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`)
665    } catch (error) {
666      logForDebugging(
667        `[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`,
668      )
669    }
670  
671    // Clean up tasks directory (~/.claude/tasks/{taskListId}/)
672    // The leader and teammates all store tasks under the sanitized team name.
673    const tasksDir = getTasksDir(sanitizedName)
674    try {
675      await rm(tasksDir, { recursive: true, force: true })
676      logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`)
677      notifyTasksUpdated()
678    } catch (error) {
679      logForDebugging(
680        `[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`,
681      )
682    }
683  }