teamHelpers.ts 21 KB

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