/ utils / tasks.ts
tasks.ts
  1  import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
  2  import { join } from 'path'
  3  import { z } from 'zod/v4'
  4  import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js'
  5  import { uniq } from './array.js'
  6  import { logForDebugging } from './debug.js'
  7  import { getClaudeConfigHomeDir, getTeamsDir, isEnvTruthy } from './envUtils.js'
  8  import { errorMessage, getErrnoCode } from './errors.js'
  9  import { lazySchema } from './lazySchema.js'
 10  import * as lockfile from './lockfile.js'
 11  import { logError } from './log.js'
 12  import { createSignal } from './signal.js'
 13  import { jsonParse, jsonStringify } from './slowOperations.js'
 14  import { getTeamName } from './teammate.js'
 15  import { getTeammateContext } from './teammateContext.js'
 16  
 17  // Listeners for task list updates (used for immediate UI refresh in same process)
 18  const 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   */
 25  let 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   */
 31  export 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   */
 43  export 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   */
 53  export 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   */
 61  export 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  
 69  export const TASK_STATUSES = ['pending', 'in_progress', 'completed'] as const
 70  
 71  export const TaskStatusSchema = lazySchema(() =>
 72    z.enum(['pending', 'in_progress', 'completed']),
 73  )
 74  export type TaskStatus = z.infer<ReturnType<typeof TaskStatusSchema>>
 75  
 76  export 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  )
 89  export type Task = z.infer<ReturnType<typeof TaskSchema>>
 90  
 91  // High water mark file name - stores the maximum task ID ever assigned
 92  const 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.
102  const LOCK_OPTIONS = {
103    retries: {
104      retries: 30,
105      minTimeout: 5,
106      maxTimeout: 100,
107    },
108  }
109  
110  function getHighWaterMarkPath(taskListId: string): string {
111    return join(getTasksDir(taskListId), HIGH_WATER_MARK_FILE)
112  }
113  
114  async 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  
125  async function writeHighWaterMark(
126    taskListId: string,
127    value: number,
128  ): Promise<void> {
129    const path = getHighWaterMarkPath(taskListId)
130    await writeFile(path, String(value))
131  }
132  
133  export 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   */
147  export 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   */
199  export 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   */
217  export function sanitizePathComponent(input: string): string {
218    return input.replace(/[^a-zA-Z0-9_-]/g, '-')
219  }
220  
221  export function getTasksDir(taskListId: string): string {
222    return join(
223      getClaudeConfigHomeDir(),
224      'tasks',
225      sanitizePathComponent(taskListId),
226    )
227  }
228  
229  export function getTaskPath(taskListId: string, taskId: string): string {
230    return join(getTasksDir(taskListId), `${sanitizePathComponent(taskId)}.json`)
231  }
232  
233  export 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   */
246  async 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   */
271  async 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   */
284  export 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  
310  export 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.).
354  async 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  
370  export 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  
393  export 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  
443  export 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  
458  export 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  
488  export 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   */
504  function getTaskListLockPath(taskListId: string): string {
505    return join(getTasksDir(taskListId), '.lock')
506  }
507  
508  /**
509   * Ensures the lock file exists for a task list
510   */
511  async 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  
525  export 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   */
541  export 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   */
618  async 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   */
697  export type TeamMember = {
698    agentId: string
699    name: string
700    agentType?: string
701  }
702  
703  /**
704   * Agent status based on task ownership
705   */
706  export 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   */
717  function 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   */
724  async 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   */
763  export 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   */
803  export 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   */
818  export 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  
862  export const DEFAULT_TASKS_MODE_TASK_LIST_ID = 'tasklist'