the claude code sourcemaps leaked march 31
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 683 lines 21 kB view raw
1import { mkdirSync, readFileSync, writeFileSync } from 'fs' 2import { mkdir, readFile, rm, writeFile } from 'fs/promises' 3import { join } from 'path' 4import { z } from 'zod/v4' 5import { getSessionCreatedTeams } from '../../bootstrap/state.js' 6import { logForDebugging } from '../debug.js' 7import { getTeamsDir } from '../envUtils.js' 8import { errorMessage, getErrnoCode } from '../errors.js' 9import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 10import { gitExe } from '../git.js' 11import { lazySchema } from '../lazySchema.js' 12import type { PermissionMode } from '../permissions/PermissionMode.js' 13import { jsonParse, jsonStringify } from '../slowOperations.js' 14import { getTasksDir, notifyTasksUpdated } from '../tasks.js' 15import { getAgentName, getTeamName, isTeammate } from '../teammate.js' 16import { type BackendType, isPaneBackend } from './backends/types.js' 17import { TEAM_LEAD_NAME } from './constants.js' 18 19export 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 45export type SpawnTeamOutput = { 46 team_name: string 47 team_file_path: string 48 lead_agent_id: string 49} 50 51export type CleanupOutput = { 52 success: boolean 53 message: string 54 team_name?: string 55} 56 57export 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 64export 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 92export type Input = z.infer<ReturnType<typeof inputSchema>> 93// Export SpawnTeamOutput as Output for backward compatibility 94export 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 */ 100export 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 */ 108export function sanitizeAgentName(name: string): string { 109 return name.replace(/@/g, '-') 110} 111 112/** 113 * Gets the path to a team's directory 114 */ 115export 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 */ 122export 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 131export 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 */ 147export 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 166function 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 */ 175export 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 */ 188export 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 */ 235export 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 */ 259export 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 */ 285export 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 */ 326export 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 */ 357export 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 */ 397export 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 */ 415export 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 */ 454export 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 */ 492async 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 */ 560export 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 */ 568export 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 */ 576export 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 */ 598async 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 */ 641export 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}