source dump of claude code
at main 862 lines 26 kB view raw
1import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 2import { join } from 'path' 3import { z } from 'zod/v4' 4import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js' 5import { uniq } from './array.js' 6import { logForDebugging } from './debug.js' 7import { getClaudeConfigHomeDir, getTeamsDir, isEnvTruthy } from './envUtils.js' 8import { errorMessage, getErrnoCode } from './errors.js' 9import { lazySchema } from './lazySchema.js' 10import * as lockfile from './lockfile.js' 11import { logError } from './log.js' 12import { createSignal } from './signal.js' 13import { jsonParse, jsonStringify } from './slowOperations.js' 14import { getTeamName } from './teammate.js' 15import { getTeammateContext } from './teammateContext.js' 16 17// Listeners for task list updates (used for immediate UI refresh in same process) 18const tasksUpdated = createSignal() 19 20/** 21 * Team name set by the leader when creating a team. 22 * Used by getTaskListId() so the leader's tasks are stored under the team name 23 * (matching where tmux/iTerm2 teammates look), not under the session ID. 24 */ 25let leaderTeamName: string | undefined 26 27/** 28 * Sets the leader's team name for task list resolution. 29 * Called by TeamCreateTool when a team is created. 30 */ 31export function setLeaderTeamName(teamName: string): void { 32 if (leaderTeamName === teamName) return 33 leaderTeamName = teamName 34 // Changing the task list ID is a "tasks updated" event for subscribers — 35 // they're now looking at a different directory. 36 notifyTasksUpdated() 37} 38 39/** 40 * Clears the leader's team name. 41 * Called when a team is deleted. 42 */ 43export function clearLeaderTeamName(): void { 44 if (leaderTeamName === undefined) return 45 leaderTeamName = undefined 46 notifyTasksUpdated() 47} 48 49/** 50 * Register a listener to be called when tasks are updated in this process. 51 * Returns an unsubscribe function. 52 */ 53export const onTasksUpdated = tasksUpdated.subscribe 54 55/** 56 * Notify listeners that tasks have been updated. 57 * Called internally after createTask, updateTask, etc. 58 * Wraps emit in try/catch so listener failures never propagate to callers 59 * (task mutations must succeed from the caller's perspective). 60 */ 61export function notifyTasksUpdated(): void { 62 try { 63 tasksUpdated.emit() 64 } catch { 65 // Ignore listener errors — task mutations must not fail due to notification issues 66 } 67} 68 69export const TASK_STATUSES = ['pending', 'in_progress', 'completed'] as const 70 71export const TaskStatusSchema = lazySchema(() => 72 z.enum(['pending', 'in_progress', 'completed']), 73) 74export type TaskStatus = z.infer<ReturnType<typeof TaskStatusSchema>> 75 76export const TaskSchema = lazySchema(() => 77 z.object({ 78 id: z.string(), 79 subject: z.string(), 80 description: z.string(), 81 activeForm: z.string().optional(), // present continuous form for spinner (e.g., "Running tests") 82 owner: z.string().optional(), // agent ID 83 status: TaskStatusSchema(), 84 blocks: z.array(z.string()), // task IDs this task blocks 85 blockedBy: z.array(z.string()), // task IDs that block this task 86 metadata: z.record(z.string(), z.unknown()).optional(), // arbitrary metadata 87 }), 88) 89export type Task = z.infer<ReturnType<typeof TaskSchema>> 90 91// High water mark file name - stores the maximum task ID ever assigned 92const HIGH_WATER_MARK_FILE = '.highwatermark' 93 94// Lock options: retry with backoff so concurrent callers (multiple Claudes 95// in a swarm) wait for the lock instead of failing immediately. The sync 96// lockSync API blocked the event loop; the async API needs explicit retries 97// to achieve the same serialization semantics. 98// 99// Budget sized for ~10+ concurrent swarm agents: each critical section does 100// readdir + N×readFile + writeFile (~50-100ms on slow disks), so the last 101// caller in a 10-way race needs ~900ms. retries=30 gives ~2.6s total wait. 102const LOCK_OPTIONS = { 103 retries: { 104 retries: 30, 105 minTimeout: 5, 106 maxTimeout: 100, 107 }, 108} 109 110function getHighWaterMarkPath(taskListId: string): string { 111 return join(getTasksDir(taskListId), HIGH_WATER_MARK_FILE) 112} 113 114async function readHighWaterMark(taskListId: string): Promise<number> { 115 const path = getHighWaterMarkPath(taskListId) 116 try { 117 const content = (await readFile(path, 'utf-8')).trim() 118 const value = parseInt(content, 10) 119 return isNaN(value) ? 0 : value 120 } catch { 121 return 0 122 } 123} 124 125async function writeHighWaterMark( 126 taskListId: string, 127 value: number, 128): Promise<void> { 129 const path = getHighWaterMarkPath(taskListId) 130 await writeFile(path, String(value)) 131} 132 133export function isTodoV2Enabled(): boolean { 134 // Force-enable tasks in non-interactive mode (e.g. SDK users who want Task tools over TodoWrite) 135 if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TASKS)) { 136 return true 137 } 138 return !getIsNonInteractiveSession() 139} 140 141/** 142 * Resets the task list for a new swarm - clears any existing tasks. 143 * Writes a high water mark file to prevent ID reuse after reset. 144 * Should be called when a new swarm is created to ensure task numbering starts at 1. 145 * Uses file locking to prevent race conditions when multiple Claudes run in parallel. 146 */ 147export async function resetTaskList(taskListId: string): Promise<void> { 148 const dir = getTasksDir(taskListId) 149 const lockPath = await ensureTaskListLockFile(taskListId) 150 151 let release: (() => Promise<void>) | undefined 152 try { 153 // Acquire exclusive lock on the task list 154 release = await lockfile.lock(lockPath, LOCK_OPTIONS) 155 156 // Find the current highest ID and save it to the high water mark file 157 const currentHighest = await findHighestTaskIdFromFiles(taskListId) 158 if (currentHighest > 0) { 159 const existingMark = await readHighWaterMark(taskListId) 160 if (currentHighest > existingMark) { 161 await writeHighWaterMark(taskListId, currentHighest) 162 } 163 } 164 165 // Delete all task files 166 let files: string[] 167 try { 168 files = await readdir(dir) 169 } catch { 170 files = [] 171 } 172 for (const file of files) { 173 if (file.endsWith('.json') && !file.startsWith('.')) { 174 const filePath = join(dir, file) 175 try { 176 await unlink(filePath) 177 } catch { 178 // Ignore errors, file may already be deleted 179 } 180 } 181 } 182 notifyTasksUpdated() 183 } finally { 184 if (release) { 185 await release() 186 } 187 } 188} 189 190/** 191 * Gets the task list ID based on the current context. 192 * Priority: 193 * 1. CLAUDE_CODE_TASK_LIST_ID - explicit task list ID 194 * 2. In-process teammate: leader's team name (so teammates share the leader's task list) 195 * 3. CLAUDE_CODE_TEAM_NAME - set when running as a process-based teammate 196 * 4. Leader team name - set when the leader creates a team via TeamCreate 197 * 5. Session ID - fallback for standalone sessions 198 */ 199export function getTaskListId(): string { 200 if (process.env.CLAUDE_CODE_TASK_LIST_ID) { 201 return process.env.CLAUDE_CODE_TASK_LIST_ID 202 } 203 // In-process teammates use the leader's team name so they share the same 204 // task list that tmux/iTerm2 teammates also resolve to. 205 const teammateCtx = getTeammateContext() 206 if (teammateCtx) { 207 return teammateCtx.teamName 208 } 209 return getTeamName() || leaderTeamName || getSessionId() 210} 211 212/** 213 * Sanitizes a string for safe use in file paths. 214 * Removes path traversal characters and other potentially dangerous characters. 215 * Only allows alphanumeric characters, hyphens, and underscores. 216 */ 217export function sanitizePathComponent(input: string): string { 218 return input.replace(/[^a-zA-Z0-9_-]/g, '-') 219} 220 221export function getTasksDir(taskListId: string): string { 222 return join( 223 getClaudeConfigHomeDir(), 224 'tasks', 225 sanitizePathComponent(taskListId), 226 ) 227} 228 229export function getTaskPath(taskListId: string, taskId: string): string { 230 return join(getTasksDir(taskListId), `${sanitizePathComponent(taskId)}.json`) 231} 232 233export async function ensureTasksDir(taskListId: string): Promise<void> { 234 const dir = getTasksDir(taskListId) 235 try { 236 await mkdir(dir, { recursive: true }) 237 } catch { 238 // Directory already exists or creation failed; callers will surface 239 // errors from subsequent operations. 240 } 241} 242 243/** 244 * Finds the highest task ID from existing task files (not including high water mark). 245 */ 246async function findHighestTaskIdFromFiles(taskListId: string): Promise<number> { 247 const dir = getTasksDir(taskListId) 248 let files: string[] 249 try { 250 files = await readdir(dir) 251 } catch { 252 return 0 253 } 254 let highest = 0 255 for (const file of files) { 256 if (!file.endsWith('.json')) { 257 continue 258 } 259 const taskId = parseInt(file.replace('.json', ''), 10) 260 if (!isNaN(taskId) && taskId > highest) { 261 highest = taskId 262 } 263 } 264 return highest 265} 266 267/** 268 * Finds the highest task ID ever assigned, considering both existing files 269 * and the high water mark (for deleted/reset tasks). 270 */ 271async function findHighestTaskId(taskListId: string): Promise<number> { 272 const [fromFiles, fromMark] = await Promise.all([ 273 findHighestTaskIdFromFiles(taskListId), 274 readHighWaterMark(taskListId), 275 ]) 276 return Math.max(fromFiles, fromMark) 277} 278 279/** 280 * Creates a new task with a unique ID. 281 * Uses file locking to prevent race conditions when multiple processes 282 * create tasks concurrently. 283 */ 284export async function createTask( 285 taskListId: string, 286 taskData: Omit<Task, 'id'>, 287): Promise<string> { 288 const lockPath = await ensureTaskListLockFile(taskListId) 289 290 let release: (() => Promise<void>) | undefined 291 try { 292 // Acquire exclusive lock on the task list 293 release = await lockfile.lock(lockPath, LOCK_OPTIONS) 294 295 // Read highest ID from disk while holding the lock 296 const highestId = await findHighestTaskId(taskListId) 297 const id = String(highestId + 1) 298 const task: Task = { id, ...taskData } 299 const path = getTaskPath(taskListId, id) 300 await writeFile(path, jsonStringify(task, null, 2)) 301 notifyTasksUpdated() 302 return id 303 } finally { 304 if (release) { 305 await release() 306 } 307 } 308} 309 310export async function getTask( 311 taskListId: string, 312 taskId: string, 313): Promise<Task | null> { 314 const path = getTaskPath(taskListId, taskId) 315 try { 316 const content = await readFile(path, 'utf-8') 317 const data = jsonParse(content) as { status?: string } 318 319 // TEMPORARY: Migrate old status names for existing sessions (ant-only) 320 if (process.env.USER_TYPE === 'ant') { 321 if (data.status === 'open') data.status = 'pending' 322 else if (data.status === 'resolved') data.status = 'completed' 323 // Migrate development task statuses to in_progress 324 else if ( 325 data.status && 326 ['planning', 'implementing', 'reviewing', 'verifying'].includes( 327 data.status, 328 ) 329 ) { 330 data.status = 'in_progress' 331 } 332 } 333 const parsed = TaskSchema().safeParse(data) 334 if (!parsed.success) { 335 logForDebugging( 336 `[Tasks] Task ${taskId} failed schema validation: ${parsed.error.message}`, 337 ) 338 return null 339 } 340 return parsed.data 341 } catch (e) { 342 const code = getErrnoCode(e) 343 if (code === 'ENOENT') { 344 return null 345 } 346 logForDebugging(`[Tasks] Failed to read task ${taskId}: ${errorMessage(e)}`) 347 logError(e) 348 return null 349 } 350} 351 352// Internal: no lock. Callers already holding a lock on taskPath must use this 353// to avoid deadlock (claimTask, deleteTask cascade, etc.). 354async function updateTaskUnsafe( 355 taskListId: string, 356 taskId: string, 357 updates: Partial<Omit<Task, 'id'>>, 358): Promise<Task | null> { 359 const existing = await getTask(taskListId, taskId) 360 if (!existing) { 361 return null 362 } 363 const updated: Task = { ...existing, ...updates, id: taskId } 364 const path = getTaskPath(taskListId, taskId) 365 await writeFile(path, jsonStringify(updated, null, 2)) 366 notifyTasksUpdated() 367 return updated 368} 369 370export async function updateTask( 371 taskListId: string, 372 taskId: string, 373 updates: Partial<Omit<Task, 'id'>>, 374): Promise<Task | null> { 375 const path = getTaskPath(taskListId, taskId) 376 377 // Check existence before locking — proper-lockfile throws if the 378 // target file doesn't exist, and we want a clean null result. 379 const taskBeforeLock = await getTask(taskListId, taskId) 380 if (!taskBeforeLock) { 381 return null 382 } 383 384 let release: (() => Promise<void>) | undefined 385 try { 386 release = await lockfile.lock(path, LOCK_OPTIONS) 387 return await updateTaskUnsafe(taskListId, taskId, updates) 388 } finally { 389 await release?.() 390 } 391} 392 393export async function deleteTask( 394 taskListId: string, 395 taskId: string, 396): Promise<boolean> { 397 const path = getTaskPath(taskListId, taskId) 398 399 try { 400 // Update high water mark before deleting to prevent ID reuse 401 const numericId = parseInt(taskId, 10) 402 if (!isNaN(numericId)) { 403 const currentMark = await readHighWaterMark(taskListId) 404 if (numericId > currentMark) { 405 await writeHighWaterMark(taskListId, numericId) 406 } 407 } 408 409 // Delete the task file 410 try { 411 await unlink(path) 412 } catch (e) { 413 const code = getErrnoCode(e) 414 if (code === 'ENOENT') { 415 return false 416 } 417 throw e 418 } 419 420 // Remove references to this task from other tasks 421 const allTasks = await listTasks(taskListId) 422 for (const task of allTasks) { 423 const newBlocks = task.blocks.filter(id => id !== taskId) 424 const newBlockedBy = task.blockedBy.filter(id => id !== taskId) 425 if ( 426 newBlocks.length !== task.blocks.length || 427 newBlockedBy.length !== task.blockedBy.length 428 ) { 429 await updateTask(taskListId, task.id, { 430 blocks: newBlocks, 431 blockedBy: newBlockedBy, 432 }) 433 } 434 } 435 436 notifyTasksUpdated() 437 return true 438 } catch { 439 return false 440 } 441} 442 443export async function listTasks(taskListId: string): Promise<Task[]> { 444 const dir = getTasksDir(taskListId) 445 let files: string[] 446 try { 447 files = await readdir(dir) 448 } catch { 449 return [] 450 } 451 const taskIds = files 452 .filter(f => f.endsWith('.json')) 453 .map(f => f.replace('.json', '')) 454 const results = await Promise.all(taskIds.map(id => getTask(taskListId, id))) 455 return results.filter((t): t is Task => t !== null) 456} 457 458export async function blockTask( 459 taskListId: string, 460 fromTaskId: string, 461 toTaskId: string, 462): Promise<boolean> { 463 const [fromTask, toTask] = await Promise.all([ 464 getTask(taskListId, fromTaskId), 465 getTask(taskListId, toTaskId), 466 ]) 467 if (!fromTask || !toTask) { 468 return false 469 } 470 471 // Update source task: A blocks B 472 if (!fromTask.blocks.includes(toTaskId)) { 473 await updateTask(taskListId, fromTaskId, { 474 blocks: [...fromTask.blocks, toTaskId], 475 }) 476 } 477 478 // Update target task: B is blockedBy A 479 if (!toTask.blockedBy.includes(fromTaskId)) { 480 await updateTask(taskListId, toTaskId, { 481 blockedBy: [...toTask.blockedBy, fromTaskId], 482 }) 483 } 484 485 return true 486} 487 488export type ClaimTaskResult = { 489 success: boolean 490 reason?: 491 | 'task_not_found' 492 | 'already_claimed' 493 | 'already_resolved' 494 | 'blocked' 495 | 'agent_busy' 496 task?: Task 497 busyWithTasks?: string[] // task IDs the agent is busy with (when reason is 'agent_busy') 498 blockedByTasks?: string[] // task IDs blocking this task (when reason is 'blocked') 499} 500 501/** 502 * Gets the lock file path for a task list (used for list-level locking) 503 */ 504function getTaskListLockPath(taskListId: string): string { 505 return join(getTasksDir(taskListId), '.lock') 506} 507 508/** 509 * Ensures the lock file exists for a task list 510 */ 511async function ensureTaskListLockFile(taskListId: string): Promise<string> { 512 await ensureTasksDir(taskListId) 513 const lockPath = getTaskListLockPath(taskListId) 514 // proper-lockfile requires the target file to exist. Create it with the 515 // 'wx' flag (write-exclusive) so concurrent callers don't both create it, 516 // and the first one to create wins silently. 517 try { 518 await writeFile(lockPath, '', { flag: 'wx' }) 519 } catch { 520 // EEXIST or other — file already exists, which is fine. 521 } 522 return lockPath 523} 524 525export type ClaimTaskOptions = { 526 /** 527 * If true, checks whether the agent is already busy (owns other open tasks) 528 * before allowing the claim. This check is performed atomically with the claim 529 * using a task-list-level lock to prevent TOCTOU race conditions. 530 */ 531 checkAgentBusy?: boolean 532} 533 534/** 535 * Attempts to claim a task for an agent with file locking to prevent race conditions. 536 * Returns success if the task was claimed, or a reason if it wasn't. 537 * 538 * When checkAgentBusy is true, uses a task-list-level lock to atomically check 539 * if the agent owns any other open tasks before claiming. 540 */ 541export async function claimTask( 542 taskListId: string, 543 taskId: string, 544 claimantAgentId: string, 545 options: ClaimTaskOptions = {}, 546): Promise<ClaimTaskResult> { 547 const taskPath = getTaskPath(taskListId, taskId) 548 549 // Check existence before locking — proper-lockfile.lock throws if the 550 // target file doesn't exist, and we want a clean task_not_found result. 551 const taskBeforeLock = await getTask(taskListId, taskId) 552 if (!taskBeforeLock) { 553 return { success: false, reason: 'task_not_found' } 554 } 555 556 // If we need to check agent busy status, use task-list-level lock 557 // to prevent TOCTOU race conditions 558 if (options.checkAgentBusy) { 559 return claimTaskWithBusyCheck(taskListId, taskId, claimantAgentId) 560 } 561 562 // Otherwise, use task-level lock (original behavior) 563 let release: (() => Promise<void>) | undefined 564 try { 565 // Acquire exclusive lock on the task file 566 release = await lockfile.lock(taskPath, LOCK_OPTIONS) 567 568 // Read current task state 569 const task = await getTask(taskListId, taskId) 570 if (!task) { 571 return { success: false, reason: 'task_not_found' } 572 } 573 574 // Check if already claimed by another agent 575 if (task.owner && task.owner !== claimantAgentId) { 576 return { success: false, reason: 'already_claimed', task } 577 } 578 579 // Check if already resolved 580 if (task.status === 'completed') { 581 return { success: false, reason: 'already_resolved', task } 582 } 583 584 // Check for unresolved blockers (open or in_progress tasks block) 585 const allTasks = await listTasks(taskListId) 586 const unresolvedTaskIds = new Set( 587 allTasks.filter(t => t.status !== 'completed').map(t => t.id), 588 ) 589 const blockedByTasks = task.blockedBy.filter(id => 590 unresolvedTaskIds.has(id), 591 ) 592 if (blockedByTasks.length > 0) { 593 return { success: false, reason: 'blocked', task, blockedByTasks } 594 } 595 596 // Claim the task (already holding taskPath lock — use unsafe variant) 597 const updated = await updateTaskUnsafe(taskListId, taskId, { 598 owner: claimantAgentId, 599 }) 600 return { success: true, task: updated! } 601 } catch (error) { 602 logForDebugging( 603 `[Tasks] Failed to claim task ${taskId}: ${errorMessage(error)}`, 604 ) 605 logError(error) 606 return { success: false, reason: 'task_not_found' } 607 } finally { 608 if (release) { 609 await release() 610 } 611 } 612} 613 614/** 615 * Claims a task with an atomic check for agent busy status. 616 * Uses a task-list-level lock to ensure the busy check and claim are atomic. 617 */ 618async function claimTaskWithBusyCheck( 619 taskListId: string, 620 taskId: string, 621 claimantAgentId: string, 622): Promise<ClaimTaskResult> { 623 const lockPath = await ensureTaskListLockFile(taskListId) 624 625 let release: (() => Promise<void>) | undefined 626 try { 627 // Acquire exclusive lock on the task list 628 release = await lockfile.lock(lockPath, LOCK_OPTIONS) 629 630 // Read all tasks to check agent status and task state atomically 631 const allTasks = await listTasks(taskListId) 632 633 // Find the task we want to claim 634 const task = allTasks.find(t => t.id === taskId) 635 if (!task) { 636 return { success: false, reason: 'task_not_found' } 637 } 638 639 // Check if already claimed by another agent 640 if (task.owner && task.owner !== claimantAgentId) { 641 return { success: false, reason: 'already_claimed', task } 642 } 643 644 // Check if already resolved 645 if (task.status === 'completed') { 646 return { success: false, reason: 'already_resolved', task } 647 } 648 649 // Check for unresolved blockers (open or in_progress tasks block) 650 const unresolvedTaskIds = new Set( 651 allTasks.filter(t => t.status !== 'completed').map(t => t.id), 652 ) 653 const blockedByTasks = task.blockedBy.filter(id => 654 unresolvedTaskIds.has(id), 655 ) 656 if (blockedByTasks.length > 0) { 657 return { success: false, reason: 'blocked', task, blockedByTasks } 658 } 659 660 // Check if agent is busy with other unresolved tasks 661 const agentOpenTasks = allTasks.filter( 662 t => 663 t.status !== 'completed' && 664 t.owner === claimantAgentId && 665 t.id !== taskId, 666 ) 667 if (agentOpenTasks.length > 0) { 668 return { 669 success: false, 670 reason: 'agent_busy', 671 task, 672 busyWithTasks: agentOpenTasks.map(t => t.id), 673 } 674 } 675 676 // Claim the task 677 const updated = await updateTask(taskListId, taskId, { 678 owner: claimantAgentId, 679 }) 680 return { success: true, task: updated! } 681 } catch (error) { 682 logForDebugging( 683 `[Tasks] Failed to claim task ${taskId} with busy check: ${errorMessage(error)}`, 684 ) 685 logError(error) 686 return { success: false, reason: 'task_not_found' } 687 } finally { 688 if (release) { 689 await release() 690 } 691 } 692} 693 694/** 695 * Team member info (subset of TeamFile member structure) 696 */ 697export type TeamMember = { 698 agentId: string 699 name: string 700 agentType?: string 701} 702 703/** 704 * Agent status based on task ownership 705 */ 706export type AgentStatus = { 707 agentId: string 708 name: string 709 agentType?: string 710 status: 'idle' | 'busy' 711 currentTasks: string[] // task IDs the agent owns 712} 713 714/** 715 * Sanitizes a name for use in file paths 716 */ 717function sanitizeName(name: string): string { 718 return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() 719} 720 721/** 722 * Reads team members from the team file 723 */ 724async function readTeamMembers( 725 teamName: string, 726): Promise<{ leadAgentId: string; members: TeamMember[] } | null> { 727 const teamsDir = getTeamsDir() 728 const teamFilePath = join(teamsDir, sanitizeName(teamName), 'config.json') 729 try { 730 const content = await readFile(teamFilePath, 'utf-8') 731 const teamFile = jsonParse(content) as { 732 leadAgentId: string 733 members: TeamMember[] 734 } 735 return { 736 leadAgentId: teamFile.leadAgentId, 737 members: teamFile.members.map(m => ({ 738 agentId: m.agentId, 739 name: m.name, 740 agentType: m.agentType, 741 })), 742 } 743 } catch (e) { 744 const code = getErrnoCode(e) 745 if (code === 'ENOENT') { 746 return null 747 } 748 logForDebugging( 749 `[Tasks] Failed to read team file for ${teamName}: ${errorMessage(e)}`, 750 ) 751 return null 752 } 753} 754 755/** 756 * Gets the status of all agents in a team based on task ownership. 757 * An agent is considered "idle" if they don't own any open tasks. 758 * An agent is considered "busy" if they own at least one open task. 759 * 760 * @param teamName - The name of the team (also used as taskListId) 761 * @returns Array of agent statuses, or null if team not found 762 */ 763export async function getAgentStatuses( 764 teamName: string, 765): Promise<AgentStatus[] | null> { 766 const teamData = await readTeamMembers(teamName) 767 if (!teamData) { 768 return null 769 } 770 771 const taskListId = sanitizeName(teamName) 772 const allTasks = await listTasks(taskListId) 773 774 // Get unresolved tasks grouped by owner (open or in_progress) 775 const unresolvedTasksByOwner = new Map<string, string[]>() 776 for (const task of allTasks) { 777 if (task.status !== 'completed' && task.owner) { 778 const existing = unresolvedTasksByOwner.get(task.owner) || [] 779 existing.push(task.id) 780 unresolvedTasksByOwner.set(task.owner, existing) 781 } 782 } 783 784 // Build status for each agent (leader is already in members) 785 return teamData.members.map(member => { 786 // Check both name (new) and agentId (legacy) for backwards compatibility 787 const tasksByName = unresolvedTasksByOwner.get(member.name) || [] 788 const tasksById = unresolvedTasksByOwner.get(member.agentId) || [] 789 const currentTasks = uniq([...tasksByName, ...tasksById]) 790 return { 791 agentId: member.agentId, 792 name: member.name, 793 agentType: member.agentType, 794 status: currentTasks.length === 0 ? 'idle' : 'busy', 795 currentTasks, 796 } 797 }) 798} 799 800/** 801 * Result of unassigning tasks from a teammate 802 */ 803export type UnassignTasksResult = { 804 unassignedTasks: Array<{ id: string; subject: string }> 805 notificationMessage: string 806} 807 808/** 809 * Unassigns all open tasks from a teammate and builds a notification message. 810 * Used when a teammate is killed or gracefully shuts down. 811 * 812 * @param teamName - The team/task list name 813 * @param teammateId - The teammate's agent ID 814 * @param teammateName - The teammate's display name 815 * @param reason - How the teammate exited ('terminated' | 'shutdown') 816 * @returns The unassigned tasks and a formatted notification message 817 */ 818export async function unassignTeammateTasks( 819 teamName: string, 820 teammateId: string, 821 teammateName: string, 822 reason: 'terminated' | 'shutdown', 823): Promise<UnassignTasksResult> { 824 const tasks = await listTasks(teamName) 825 const unresolvedAssignedTasks = tasks.filter( 826 t => 827 t.status !== 'completed' && 828 (t.owner === teammateId || t.owner === teammateName), 829 ) 830 831 // Unassign each task and reset status to open 832 for (const task of unresolvedAssignedTasks) { 833 await updateTask(teamName, task.id, { owner: undefined, status: 'pending' }) 834 } 835 836 if (unresolvedAssignedTasks.length > 0) { 837 logForDebugging( 838 `[Tasks] Unassigned ${unresolvedAssignedTasks.length} task(s) from ${teammateName}`, 839 ) 840 } 841 842 // Build notification message 843 const actionVerb = 844 reason === 'terminated' ? 'was terminated' : 'has shut down' 845 let notificationMessage = `${teammateName} ${actionVerb}.` 846 if (unresolvedAssignedTasks.length > 0) { 847 const taskList = unresolvedAssignedTasks 848 .map(t => `#${t.id} "${t.subject}"`) 849 .join(', ') 850 notificationMessage += ` ${unresolvedAssignedTasks.length} task(s) were unassigned: ${taskList}. Use TaskList to check availability and TaskUpdate with owner to reassign them to idle teammates.` 851 } 852 853 return { 854 unassignedTasks: unresolvedAssignedTasks.map(t => ({ 855 id: t.id, 856 subject: t.subject, 857 })), 858 notificationMessage, 859 } 860} 861 862export const DEFAULT_TASKS_MODE_TASK_LIST_ID = 'tasklist'