/ src / utils / teammateMailbox.ts
teammateMailbox.ts
   1  /**
   2   * Teammate Mailbox - File-based messaging system for agent swarms
   3   *
   4   * Each teammate has an inbox file at .claude/teams/{team_name}/inboxes/{agent_name}.json
   5   * Other teammates can write messages to it, and the recipient sees them as attachments.
   6   *
   7   * Note: Inboxes are keyed by agent name within a team.
   8   */
   9  
  10  import { mkdir, readFile, writeFile } from 'fs/promises'
  11  import { join } from 'path'
  12  import { z } from 'zod/v4'
  13  import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
  14  import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
  15  import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
  16  import type { Message } from '../types/message.js'
  17  import { generateRequestId } from './agentId.js'
  18  import { count } from './array.js'
  19  import { logForDebugging } from './debug.js'
  20  import { getTeamsDir } from './envUtils.js'
  21  import { getErrnoCode } from './errors.js'
  22  import { lazySchema } from './lazySchema.js'
  23  import * as lockfile from './lockfile.js'
  24  import { logError } from './log.js'
  25  import { jsonParse, jsonStringify } from './slowOperations.js'
  26  import type { BackendType } from './swarm/backends/types.js'
  27  import { TEAM_LEAD_NAME } from './swarm/constants.js'
  28  import { sanitizePathComponent } from './tasks.js'
  29  import { getAgentName, getTeammateColor, getTeamName } from './teammate.js'
  30  
  31  // Lock options: retry with backoff so concurrent callers (multiple Claudes
  32  // in a swarm) wait for the lock instead of failing immediately. The sync
  33  // lockSync API blocked the event loop; the async API needs explicit retries
  34  // to achieve the same serialization semantics.
  35  const LOCK_OPTIONS = {
  36    retries: {
  37      retries: 10,
  38      minTimeout: 5,
  39      maxTimeout: 100,
  40    },
  41  }
  42  
  43  export type TeammateMessage = {
  44    from: string
  45    text: string
  46    timestamp: string
  47    read: boolean
  48    color?: string // Sender's assigned color (e.g., 'red', 'blue', 'green')
  49    summary?: string // 5-10 word summary shown as preview in the UI
  50  }
  51  
  52  /**
  53   * Get the path to a teammate's inbox file
  54   * Structure: ~/.claude/teams/{team_name}/inboxes/{agent_name}.json
  55   */
  56  export function getInboxPath(agentName: string, teamName?: string): string {
  57    const team = teamName || getTeamName() || 'default'
  58    const safeTeam = sanitizePathComponent(team)
  59    const safeAgentName = sanitizePathComponent(agentName)
  60    const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
  61    const fullPath = join(inboxDir, `${safeAgentName}.json`)
  62    logForDebugging(
  63      `[TeammateMailbox] getInboxPath: agent=${agentName}, team=${team}, fullPath=${fullPath}`,
  64    )
  65    return fullPath
  66  }
  67  
  68  /**
  69   * Ensure the inbox directory exists for a team
  70   */
  71  async function ensureInboxDir(teamName?: string): Promise<void> {
  72    const team = teamName || getTeamName() || 'default'
  73    const safeTeam = sanitizePathComponent(team)
  74    const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
  75    await mkdir(inboxDir, { recursive: true })
  76    logForDebugging(`[TeammateMailbox] Ensured inbox directory: ${inboxDir}`)
  77  }
  78  
  79  /**
  80   * Read all messages from a teammate's inbox
  81   * @param agentName - The agent name (not UUID) to read inbox for
  82   * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var or 'default')
  83   */
  84  export async function readMailbox(
  85    agentName: string,
  86    teamName?: string,
  87  ): Promise<TeammateMessage[]> {
  88    const inboxPath = getInboxPath(agentName, teamName)
  89    logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
  90  
  91    try {
  92      const content = await readFile(inboxPath, 'utf-8')
  93      const messages = jsonParse(content) as TeammateMessage[]
  94      logForDebugging(
  95        `[TeammateMailbox] readMailbox: read ${messages.length} message(s)`,
  96      )
  97      return messages
  98    } catch (error) {
  99      const code = getErrnoCode(error)
 100      if (code === 'ENOENT') {
 101        logForDebugging(`[TeammateMailbox] readMailbox: file does not exist`)
 102        return []
 103      }
 104      logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
 105      logError(error)
 106      return []
 107    }
 108  }
 109  
 110  /**
 111   * Read only unread messages from a teammate's inbox
 112   * @param agentName - The agent name (not UUID) to read inbox for
 113   * @param teamName - Optional team name
 114   */
 115  export async function readUnreadMessages(
 116    agentName: string,
 117    teamName?: string,
 118  ): Promise<TeammateMessage[]> {
 119    const messages = await readMailbox(agentName, teamName)
 120    const unread = messages.filter(m => !m.read)
 121    logForDebugging(
 122      `[TeammateMailbox] readUnreadMessages: ${unread.length} unread of ${messages.length} total`,
 123    )
 124    return unread
 125  }
 126  
 127  /**
 128   * Write a message to a teammate's inbox
 129   * Uses file locking to prevent race conditions when multiple agents write concurrently
 130   * @param recipientName - The recipient's agent name (not UUID)
 131   * @param message - The message to write
 132   * @param teamName - Optional team name
 133   */
 134  export async function writeToMailbox(
 135    recipientName: string,
 136    message: Omit<TeammateMessage, 'read'>,
 137    teamName?: string,
 138  ): Promise<void> {
 139    await ensureInboxDir(teamName)
 140  
 141    const inboxPath = getInboxPath(recipientName, teamName)
 142    const lockFilePath = `${inboxPath}.lock`
 143  
 144    logForDebugging(
 145      `[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}, path=${inboxPath}`,
 146    )
 147  
 148    // Ensure the inbox file exists before locking (proper-lockfile requires the file to exist)
 149    try {
 150      await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'wx' })
 151      logForDebugging(`[TeammateMailbox] writeToMailbox: created new inbox file`)
 152    } catch (error) {
 153      const code = getErrnoCode(error)
 154      if (code !== 'EEXIST') {
 155        logForDebugging(
 156          `[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
 157        )
 158        logError(error)
 159        return
 160      }
 161    }
 162  
 163    let release: (() => Promise<void>) | undefined
 164    try {
 165      release = await lockfile.lock(inboxPath, {
 166        lockfilePath: lockFilePath,
 167        ...LOCK_OPTIONS,
 168      })
 169  
 170      // Re-read messages after acquiring lock to get the latest state
 171      const messages = await readMailbox(recipientName, teamName)
 172  
 173      const newMessage: TeammateMessage = {
 174        ...message,
 175        read: false,
 176      }
 177  
 178      messages.push(newMessage)
 179  
 180      await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
 181      logForDebugging(
 182        `[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
 183      )
 184    } catch (error) {
 185      logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
 186      logError(error)
 187    } finally {
 188      if (release) {
 189        await release()
 190      }
 191    }
 192  }
 193  
 194  /**
 195   * Mark a specific message in a teammate's inbox as read by index
 196   * Uses file locking to prevent race conditions
 197   * @param agentName - The agent name to mark message as read for
 198   * @param teamName - Optional team name
 199   * @param messageIndex - Index of the message to mark as read
 200   */
 201  export async function markMessageAsReadByIndex(
 202    agentName: string,
 203    teamName: string | undefined,
 204    messageIndex: number,
 205  ): Promise<void> {
 206    const inboxPath = getInboxPath(agentName, teamName)
 207    logForDebugging(
 208      `[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, teamName=${teamName}, index=${messageIndex}, path=${inboxPath}`,
 209    )
 210  
 211    const lockFilePath = `${inboxPath}.lock`
 212  
 213    let release: (() => Promise<void>) | undefined
 214    try {
 215      logForDebugging(
 216        `[TeammateMailbox] markMessageAsReadByIndex: acquiring lock...`,
 217      )
 218      release = await lockfile.lock(inboxPath, {
 219        lockfilePath: lockFilePath,
 220        ...LOCK_OPTIONS,
 221      })
 222      logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: lock acquired`)
 223  
 224      // Re-read messages after acquiring lock to get the latest state
 225      const messages = await readMailbox(agentName, teamName)
 226      logForDebugging(
 227        `[TeammateMailbox] markMessageAsReadByIndex: read ${messages.length} messages after lock`,
 228      )
 229  
 230      if (messageIndex < 0 || messageIndex >= messages.length) {
 231        logForDebugging(
 232          `[TeammateMailbox] markMessageAsReadByIndex: index ${messageIndex} out of bounds (${messages.length} messages)`,
 233        )
 234        return
 235      }
 236  
 237      const message = messages[messageIndex]
 238      if (!message || message.read) {
 239        logForDebugging(
 240          `[TeammateMailbox] markMessageAsReadByIndex: message already read or missing`,
 241        )
 242        return
 243      }
 244  
 245      messages[messageIndex] = { ...message, read: true }
 246  
 247      await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
 248      logForDebugging(
 249        `[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
 250      )
 251    } catch (error) {
 252      const code = getErrnoCode(error)
 253      if (code === 'ENOENT') {
 254        logForDebugging(
 255          `[TeammateMailbox] markMessageAsReadByIndex: file does not exist at ${inboxPath}`,
 256        )
 257        return
 258      }
 259      logForDebugging(
 260        `[TeammateMailbox] markMessageAsReadByIndex FAILED for ${agentName}: ${error}`,
 261      )
 262      logError(error)
 263    } finally {
 264      if (release) {
 265        await release()
 266        logForDebugging(
 267          `[TeammateMailbox] markMessageAsReadByIndex: lock released`,
 268        )
 269      }
 270    }
 271  }
 272  
 273  /**
 274   * Mark all messages in a teammate's inbox as read
 275   * Uses file locking to prevent race conditions
 276   * @param agentName - The agent name to mark messages as read for
 277   * @param teamName - Optional team name
 278   */
 279  export async function markMessagesAsRead(
 280    agentName: string,
 281    teamName?: string,
 282  ): Promise<void> {
 283    const inboxPath = getInboxPath(agentName, teamName)
 284    logForDebugging(
 285      `[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`,
 286    )
 287  
 288    const lockFilePath = `${inboxPath}.lock`
 289  
 290    let release: (() => Promise<void>) | undefined
 291    try {
 292      logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`)
 293      release = await lockfile.lock(inboxPath, {
 294        lockfilePath: lockFilePath,
 295        ...LOCK_OPTIONS,
 296      })
 297      logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
 298  
 299      // Re-read messages after acquiring lock to get the latest state
 300      const messages = await readMailbox(agentName, teamName)
 301      logForDebugging(
 302        `[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
 303      )
 304  
 305      if (messages.length === 0) {
 306        logForDebugging(
 307          `[TeammateMailbox] markMessagesAsRead: no messages to mark`,
 308        )
 309        return
 310      }
 311  
 312      const unreadCount = count(messages, m => !m.read)
 313      logForDebugging(
 314        `[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`,
 315      )
 316  
 317      // messages comes from jsonParse — fresh, unshared objects safe to mutate
 318      for (const m of messages) m.read = true
 319  
 320      await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
 321      logForDebugging(
 322        `[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
 323      )
 324    } catch (error) {
 325      const code = getErrnoCode(error)
 326      if (code === 'ENOENT') {
 327        logForDebugging(
 328          `[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`,
 329        )
 330        return
 331      }
 332      logForDebugging(
 333        `[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`,
 334      )
 335      logError(error)
 336    } finally {
 337      if (release) {
 338        await release()
 339        logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`)
 340      }
 341    }
 342  }
 343  
 344  /**
 345   * Clear a teammate's inbox (delete all messages)
 346   * @param agentName - The agent name to clear inbox for
 347   * @param teamName - Optional team name
 348   */
 349  export async function clearMailbox(
 350    agentName: string,
 351    teamName?: string,
 352  ): Promise<void> {
 353    const inboxPath = getInboxPath(agentName, teamName)
 354  
 355    try {
 356      // flag 'r+' throws ENOENT if the file doesn't exist, so we don't
 357      // accidentally create an inbox file that wasn't there.
 358      await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'r+' })
 359      logForDebugging(`[TeammateMailbox] Cleared inbox for ${agentName}`)
 360    } catch (error) {
 361      const code = getErrnoCode(error)
 362      if (code === 'ENOENT') {
 363        return
 364      }
 365      logForDebugging(`Failed to clear inbox for ${agentName}: ${error}`)
 366      logError(error)
 367    }
 368  }
 369  
 370  /**
 371   * Format teammate messages as XML for attachment display
 372   */
 373  export function formatTeammateMessages(
 374    messages: Array<{
 375      from: string
 376      text: string
 377      timestamp: string
 378      color?: string
 379      summary?: string
 380    }>,
 381  ): string {
 382    return messages
 383      .map(m => {
 384        const colorAttr = m.color ? ` color="${m.color}"` : ''
 385        const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
 386        return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
 387      })
 388      .join('\n\n')
 389  }
 390  
 391  /**
 392   * Structured message sent when a teammate becomes idle (via Stop hook)
 393   */
 394  export type IdleNotificationMessage = {
 395    type: 'idle_notification'
 396    from: string
 397    timestamp: string
 398    /** Why the agent went idle */
 399    idleReason?: 'available' | 'interrupted' | 'failed'
 400    /** Brief summary of the last DM sent this turn (if any) */
 401    summary?: string
 402    completedTaskId?: string
 403    completedStatus?: 'resolved' | 'blocked' | 'failed'
 404    failureReason?: string
 405  }
 406  
 407  /**
 408   * Creates an idle notification message to send to the team leader
 409   */
 410  export function createIdleNotification(
 411    agentId: string,
 412    options?: {
 413      idleReason?: IdleNotificationMessage['idleReason']
 414      summary?: string
 415      completedTaskId?: string
 416      completedStatus?: 'resolved' | 'blocked' | 'failed'
 417      failureReason?: string
 418    },
 419  ): IdleNotificationMessage {
 420    return {
 421      type: 'idle_notification',
 422      from: agentId,
 423      timestamp: new Date().toISOString(),
 424      idleReason: options?.idleReason,
 425      summary: options?.summary,
 426      completedTaskId: options?.completedTaskId,
 427      completedStatus: options?.completedStatus,
 428      failureReason: options?.failureReason,
 429    }
 430  }
 431  
 432  /**
 433   * Checks if a message text contains an idle notification
 434   */
 435  export function isIdleNotification(
 436    messageText: string,
 437  ): IdleNotificationMessage | null {
 438    try {
 439      const parsed = jsonParse(messageText)
 440      if (parsed && parsed.type === 'idle_notification') {
 441        return parsed as IdleNotificationMessage
 442      }
 443    } catch {
 444      // Not JSON or not a valid idle notification
 445    }
 446    return null
 447  }
 448  
 449  /**
 450   * Permission request message sent from worker to leader via mailbox.
 451   * Field names align with SDK `can_use_tool` (snake_case).
 452   */
 453  export type PermissionRequestMessage = {
 454    type: 'permission_request'
 455    request_id: string
 456    agent_id: string
 457    tool_name: string
 458    tool_use_id: string
 459    description: string
 460    input: Record<string, unknown>
 461    permission_suggestions: unknown[]
 462  }
 463  
 464  /**
 465   * Permission response message sent from leader to worker via mailbox.
 466   * Shape mirrors SDK ControlResponseSchema / ControlErrorResponseSchema.
 467   */
 468  export type PermissionResponseMessage =
 469    | {
 470        type: 'permission_response'
 471        request_id: string
 472        subtype: 'success'
 473        response?: {
 474          updated_input?: Record<string, unknown>
 475          permission_updates?: unknown[]
 476        }
 477      }
 478    | {
 479        type: 'permission_response'
 480        request_id: string
 481        subtype: 'error'
 482        error: string
 483      }
 484  
 485  /**
 486   * Creates a permission request message to send to the team leader
 487   */
 488  export function createPermissionRequestMessage(params: {
 489    request_id: string
 490    agent_id: string
 491    tool_name: string
 492    tool_use_id: string
 493    description: string
 494    input: Record<string, unknown>
 495    permission_suggestions?: unknown[]
 496  }): PermissionRequestMessage {
 497    return {
 498      type: 'permission_request',
 499      request_id: params.request_id,
 500      agent_id: params.agent_id,
 501      tool_name: params.tool_name,
 502      tool_use_id: params.tool_use_id,
 503      description: params.description,
 504      input: params.input,
 505      permission_suggestions: params.permission_suggestions || [],
 506    }
 507  }
 508  
 509  /**
 510   * Creates a permission response message to send back to a worker
 511   */
 512  export function createPermissionResponseMessage(params: {
 513    request_id: string
 514    subtype: 'success' | 'error'
 515    error?: string
 516    updated_input?: Record<string, unknown>
 517    permission_updates?: unknown[]
 518  }): PermissionResponseMessage {
 519    if (params.subtype === 'error') {
 520      return {
 521        type: 'permission_response',
 522        request_id: params.request_id,
 523        subtype: 'error',
 524        error: params.error || 'Permission denied',
 525      }
 526    }
 527    return {
 528      type: 'permission_response',
 529      request_id: params.request_id,
 530      subtype: 'success',
 531      response: {
 532        updated_input: params.updated_input,
 533        permission_updates: params.permission_updates,
 534      },
 535    }
 536  }
 537  
 538  /**
 539   * Checks if a message text contains a permission request
 540   */
 541  export function isPermissionRequest(
 542    messageText: string,
 543  ): PermissionRequestMessage | null {
 544    try {
 545      const parsed = jsonParse(messageText)
 546      if (parsed && parsed.type === 'permission_request') {
 547        return parsed as PermissionRequestMessage
 548      }
 549    } catch {
 550      // Not JSON or not a valid permission request
 551    }
 552    return null
 553  }
 554  
 555  /**
 556   * Checks if a message text contains a permission response
 557   */
 558  export function isPermissionResponse(
 559    messageText: string,
 560  ): PermissionResponseMessage | null {
 561    try {
 562      const parsed = jsonParse(messageText)
 563      if (parsed && parsed.type === 'permission_response') {
 564        return parsed as PermissionResponseMessage
 565      }
 566    } catch {
 567      // Not JSON or not a valid permission response
 568    }
 569    return null
 570  }
 571  
 572  /**
 573   * Sandbox permission request message sent from worker to leader via mailbox
 574   * This is triggered when sandbox runtime detects a network access to a non-allowed host
 575   */
 576  export type SandboxPermissionRequestMessage = {
 577    type: 'sandbox_permission_request'
 578    /** Unique identifier for this request */
 579    requestId: string
 580    /** Worker's CLAUDE_CODE_AGENT_ID */
 581    workerId: string
 582    /** Worker's CLAUDE_CODE_AGENT_NAME */
 583    workerName: string
 584    /** Worker's CLAUDE_CODE_AGENT_COLOR */
 585    workerColor?: string
 586    /** The host pattern requesting network access */
 587    hostPattern: {
 588      host: string
 589    }
 590    /** Timestamp when request was created */
 591    createdAt: number
 592  }
 593  
 594  /**
 595   * Sandbox permission response message sent from leader to worker via mailbox
 596   */
 597  export type SandboxPermissionResponseMessage = {
 598    type: 'sandbox_permission_response'
 599    /** ID of the request this responds to */
 600    requestId: string
 601    /** The host that was approved/denied */
 602    host: string
 603    /** Whether the connection is allowed */
 604    allow: boolean
 605    /** Timestamp when response was created */
 606    timestamp: string
 607  }
 608  
 609  /**
 610   * Creates a sandbox permission request message to send to the team leader
 611   */
 612  export function createSandboxPermissionRequestMessage(params: {
 613    requestId: string
 614    workerId: string
 615    workerName: string
 616    workerColor?: string
 617    host: string
 618  }): SandboxPermissionRequestMessage {
 619    return {
 620      type: 'sandbox_permission_request',
 621      requestId: params.requestId,
 622      workerId: params.workerId,
 623      workerName: params.workerName,
 624      workerColor: params.workerColor,
 625      hostPattern: { host: params.host },
 626      createdAt: Date.now(),
 627    }
 628  }
 629  
 630  /**
 631   * Creates a sandbox permission response message to send back to a worker
 632   */
 633  export function createSandboxPermissionResponseMessage(params: {
 634    requestId: string
 635    host: string
 636    allow: boolean
 637  }): SandboxPermissionResponseMessage {
 638    return {
 639      type: 'sandbox_permission_response',
 640      requestId: params.requestId,
 641      host: params.host,
 642      allow: params.allow,
 643      timestamp: new Date().toISOString(),
 644    }
 645  }
 646  
 647  /**
 648   * Checks if a message text contains a sandbox permission request
 649   */
 650  export function isSandboxPermissionRequest(
 651    messageText: string,
 652  ): SandboxPermissionRequestMessage | null {
 653    try {
 654      const parsed = jsonParse(messageText)
 655      if (parsed && parsed.type === 'sandbox_permission_request') {
 656        return parsed as SandboxPermissionRequestMessage
 657      }
 658    } catch {
 659      // Not JSON or not a valid sandbox permission request
 660    }
 661    return null
 662  }
 663  
 664  /**
 665   * Checks if a message text contains a sandbox permission response
 666   */
 667  export function isSandboxPermissionResponse(
 668    messageText: string,
 669  ): SandboxPermissionResponseMessage | null {
 670    try {
 671      const parsed = jsonParse(messageText)
 672      if (parsed && parsed.type === 'sandbox_permission_response') {
 673        return parsed as SandboxPermissionResponseMessage
 674      }
 675    } catch {
 676      // Not JSON or not a valid sandbox permission response
 677    }
 678    return null
 679  }
 680  
 681  /**
 682   * Message sent when a teammate requests plan approval from the team leader
 683   */
 684  export const PlanApprovalRequestMessageSchema = lazySchema(() =>
 685    z.object({
 686      type: z.literal('plan_approval_request'),
 687      from: z.string(),
 688      timestamp: z.string(),
 689      planFilePath: z.string(),
 690      planContent: z.string(),
 691      requestId: z.string(),
 692    }),
 693  )
 694  
 695  export type PlanApprovalRequestMessage = z.infer<
 696    ReturnType<typeof PlanApprovalRequestMessageSchema>
 697  >
 698  
 699  /**
 700   * Message sent by the team leader in response to a plan approval request
 701   */
 702  export const PlanApprovalResponseMessageSchema = lazySchema(() =>
 703    z.object({
 704      type: z.literal('plan_approval_response'),
 705      requestId: z.string(),
 706      approved: z.boolean(),
 707      feedback: z.string().optional(),
 708      timestamp: z.string(),
 709      permissionMode: PermissionModeSchema().optional(),
 710    }),
 711  )
 712  
 713  export type PlanApprovalResponseMessage = z.infer<
 714    ReturnType<typeof PlanApprovalResponseMessageSchema>
 715  >
 716  
 717  /**
 718   * Shutdown request message sent from leader to teammate via mailbox
 719   */
 720  export const ShutdownRequestMessageSchema = lazySchema(() =>
 721    z.object({
 722      type: z.literal('shutdown_request'),
 723      requestId: z.string(),
 724      from: z.string(),
 725      reason: z.string().optional(),
 726      timestamp: z.string(),
 727    }),
 728  )
 729  
 730  export type ShutdownRequestMessage = z.infer<
 731    ReturnType<typeof ShutdownRequestMessageSchema>
 732  >
 733  
 734  /**
 735   * Shutdown approved message sent from teammate to leader via mailbox
 736   */
 737  export const ShutdownApprovedMessageSchema = lazySchema(() =>
 738    z.object({
 739      type: z.literal('shutdown_approved'),
 740      requestId: z.string(),
 741      from: z.string(),
 742      timestamp: z.string(),
 743      paneId: z.string().optional(),
 744      backendType: z.string().optional(),
 745    }),
 746  )
 747  
 748  export type ShutdownApprovedMessage = z.infer<
 749    ReturnType<typeof ShutdownApprovedMessageSchema>
 750  >
 751  
 752  /**
 753   * Shutdown rejected message sent from teammate to leader via mailbox
 754   */
 755  export const ShutdownRejectedMessageSchema = lazySchema(() =>
 756    z.object({
 757      type: z.literal('shutdown_rejected'),
 758      requestId: z.string(),
 759      from: z.string(),
 760      reason: z.string(),
 761      timestamp: z.string(),
 762    }),
 763  )
 764  
 765  export type ShutdownRejectedMessage = z.infer<
 766    ReturnType<typeof ShutdownRejectedMessageSchema>
 767  >
 768  
 769  /**
 770   * Creates a shutdown request message to send to a teammate
 771   */
 772  export function createShutdownRequestMessage(params: {
 773    requestId: string
 774    from: string
 775    reason?: string
 776  }): ShutdownRequestMessage {
 777    return {
 778      type: 'shutdown_request',
 779      requestId: params.requestId,
 780      from: params.from,
 781      reason: params.reason,
 782      timestamp: new Date().toISOString(),
 783    }
 784  }
 785  
 786  /**
 787   * Creates a shutdown approved message to send to the team leader
 788   */
 789  export function createShutdownApprovedMessage(params: {
 790    requestId: string
 791    from: string
 792    paneId?: string
 793    backendType?: BackendType
 794  }): ShutdownApprovedMessage {
 795    return {
 796      type: 'shutdown_approved',
 797      requestId: params.requestId,
 798      from: params.from,
 799      timestamp: new Date().toISOString(),
 800      paneId: params.paneId,
 801      backendType: params.backendType,
 802    }
 803  }
 804  
 805  /**
 806   * Creates a shutdown rejected message to send to the team leader
 807   */
 808  export function createShutdownRejectedMessage(params: {
 809    requestId: string
 810    from: string
 811    reason: string
 812  }): ShutdownRejectedMessage {
 813    return {
 814      type: 'shutdown_rejected',
 815      requestId: params.requestId,
 816      from: params.from,
 817      reason: params.reason,
 818      timestamp: new Date().toISOString(),
 819    }
 820  }
 821  
 822  /**
 823   * Sends a shutdown request to a teammate's mailbox.
 824   * This is the core logic extracted for reuse by both the tool and UI components.
 825   *
 826   * @param targetName - Name of the teammate to send shutdown request to
 827   * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var)
 828   * @param reason - Optional reason for the shutdown request
 829   * @returns The request ID and target name
 830   */
 831  export async function sendShutdownRequestToMailbox(
 832    targetName: string,
 833    teamName?: string,
 834    reason?: string,
 835  ): Promise<{ requestId: string; target: string }> {
 836    const resolvedTeamName = teamName || getTeamName()
 837  
 838    // Get sender name (supports in-process teammates via AsyncLocalStorage)
 839    const senderName = getAgentName() || TEAM_LEAD_NAME
 840  
 841    // Generate a deterministic request ID for this shutdown request
 842    const requestId = generateRequestId('shutdown', targetName)
 843  
 844    // Create and send the shutdown request message
 845    const shutdownMessage = createShutdownRequestMessage({
 846      requestId,
 847      from: senderName,
 848      reason,
 849    })
 850  
 851    await writeToMailbox(
 852      targetName,
 853      {
 854        from: senderName,
 855        text: jsonStringify(shutdownMessage),
 856        timestamp: new Date().toISOString(),
 857        color: getTeammateColor(),
 858      },
 859      resolvedTeamName,
 860    )
 861  
 862    return { requestId, target: targetName }
 863  }
 864  
 865  /**
 866   * Checks if a message text contains a shutdown request
 867   */
 868  export function isShutdownRequest(
 869    messageText: string,
 870  ): ShutdownRequestMessage | null {
 871    try {
 872      const result = ShutdownRequestMessageSchema().safeParse(
 873        jsonParse(messageText),
 874      )
 875      if (result.success) return result.data
 876    } catch {
 877      // Not JSON
 878    }
 879    return null
 880  }
 881  
 882  /**
 883   * Checks if a message text contains a plan approval request
 884   */
 885  export function isPlanApprovalRequest(
 886    messageText: string,
 887  ): PlanApprovalRequestMessage | null {
 888    try {
 889      const result = PlanApprovalRequestMessageSchema().safeParse(
 890        jsonParse(messageText),
 891      )
 892      if (result.success) return result.data
 893    } catch {
 894      // Not JSON
 895    }
 896    return null
 897  }
 898  
 899  /**
 900   * Checks if a message text contains a shutdown approved message
 901   */
 902  export function isShutdownApproved(
 903    messageText: string,
 904  ): ShutdownApprovedMessage | null {
 905    try {
 906      const result = ShutdownApprovedMessageSchema().safeParse(
 907        jsonParse(messageText),
 908      )
 909      if (result.success) return result.data
 910    } catch {
 911      // Not JSON
 912    }
 913    return null
 914  }
 915  
 916  /**
 917   * Checks if a message text contains a shutdown rejected message
 918   */
 919  export function isShutdownRejected(
 920    messageText: string,
 921  ): ShutdownRejectedMessage | null {
 922    try {
 923      const result = ShutdownRejectedMessageSchema().safeParse(
 924        jsonParse(messageText),
 925      )
 926      if (result.success) return result.data
 927    } catch {
 928      // Not JSON
 929    }
 930    return null
 931  }
 932  
 933  /**
 934   * Checks if a message text contains a plan approval response
 935   */
 936  export function isPlanApprovalResponse(
 937    messageText: string,
 938  ): PlanApprovalResponseMessage | null {
 939    try {
 940      const result = PlanApprovalResponseMessageSchema().safeParse(
 941        jsonParse(messageText),
 942      )
 943      if (result.success) return result.data
 944    } catch {
 945      // Not JSON
 946    }
 947    return null
 948  }
 949  
 950  /**
 951   * Task assignment message sent when a task is assigned to a teammate
 952   */
 953  export type TaskAssignmentMessage = {
 954    type: 'task_assignment'
 955    taskId: string
 956    subject: string
 957    description: string
 958    assignedBy: string
 959    timestamp: string
 960  }
 961  
 962  /**
 963   * Checks if a message text contains a task assignment
 964   */
 965  export function isTaskAssignment(
 966    messageText: string,
 967  ): TaskAssignmentMessage | null {
 968    try {
 969      const parsed = jsonParse(messageText)
 970      if (parsed && parsed.type === 'task_assignment') {
 971        return parsed as TaskAssignmentMessage
 972      }
 973    } catch {
 974      // Not JSON or not a valid task assignment
 975    }
 976    return null
 977  }
 978  
 979  /**
 980   * Team permission update message sent from leader to teammates via mailbox
 981   * Broadcasts a permission update that applies to all teammates
 982   */
 983  export type TeamPermissionUpdateMessage = {
 984    type: 'team_permission_update'
 985    /** The permission update to apply */
 986    permissionUpdate: {
 987      type: 'addRules'
 988      rules: Array<{ toolName: string; ruleContent?: string }>
 989      behavior: 'allow' | 'deny' | 'ask'
 990      destination: 'session'
 991    }
 992    /** The directory path that was allowed */
 993    directoryPath: string
 994    /** The tool name this applies to */
 995    toolName: string
 996  }
 997  
 998  /**
 999   * Checks if a message text contains a team permission update
1000   */
1001  export function isTeamPermissionUpdate(
1002    messageText: string,
1003  ): TeamPermissionUpdateMessage | null {
1004    try {
1005      const parsed = jsonParse(messageText)
1006      if (parsed && parsed.type === 'team_permission_update') {
1007        return parsed as TeamPermissionUpdateMessage
1008      }
1009    } catch {
1010      // Not JSON or not a valid team permission update
1011    }
1012    return null
1013  }
1014  
1015  /**
1016   * Mode set request message sent from leader to teammate via mailbox
1017   * Uses SDK PermissionModeSchema for validated mode values
1018   */
1019  export const ModeSetRequestMessageSchema = lazySchema(() =>
1020    z.object({
1021      type: z.literal('mode_set_request'),
1022      mode: PermissionModeSchema(),
1023      from: z.string(),
1024    }),
1025  )
1026  
1027  export type ModeSetRequestMessage = z.infer<
1028    ReturnType<typeof ModeSetRequestMessageSchema>
1029  >
1030  
1031  /**
1032   * Creates a mode set request message to send to a teammate
1033   */
1034  export function createModeSetRequestMessage(params: {
1035    mode: string
1036    from: string
1037  }): ModeSetRequestMessage {
1038    return {
1039      type: 'mode_set_request',
1040      mode: params.mode as ModeSetRequestMessage['mode'],
1041      from: params.from,
1042    }
1043  }
1044  
1045  /**
1046   * Checks if a message text contains a mode set request
1047   */
1048  export function isModeSetRequest(
1049    messageText: string,
1050  ): ModeSetRequestMessage | null {
1051    try {
1052      const parsed = ModeSetRequestMessageSchema().safeParse(
1053        jsonParse(messageText),
1054      )
1055      if (parsed.success) {
1056        return parsed.data
1057      }
1058    } catch {
1059      // Not JSON or not a valid mode set request
1060    }
1061    return null
1062  }
1063  
1064  /**
1065   * Checks if a message text is a structured protocol message that should be
1066   * routed by useInboxPoller rather than consumed as raw LLM context.
1067   *
1068   * These message types have specific handlers in useInboxPoller that route them
1069   * to the correct queues (workerPermissions, workerSandboxPermissions, etc.).
1070   * If getTeammateMailboxAttachments consumes them first, they get bundled as
1071   * raw text in attachments and never reach their intended handlers.
1072   */
1073  export function isStructuredProtocolMessage(messageText: string): boolean {
1074    try {
1075      const parsed = jsonParse(messageText)
1076      if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
1077        return false
1078      }
1079      const type = (parsed as { type: unknown }).type
1080      return (
1081        type === 'permission_request' ||
1082        type === 'permission_response' ||
1083        type === 'sandbox_permission_request' ||
1084        type === 'sandbox_permission_response' ||
1085        type === 'shutdown_request' ||
1086        type === 'shutdown_approved' ||
1087        type === 'team_permission_update' ||
1088        type === 'mode_set_request' ||
1089        type === 'plan_approval_request' ||
1090        type === 'plan_approval_response'
1091      )
1092    } catch {
1093      return false
1094    }
1095  }
1096  
1097  /**
1098   * Marks only messages matching a predicate as read, leaving others unread.
1099   * Uses the same file-locking mechanism as markMessagesAsRead.
1100   */
1101  export async function markMessagesAsReadByPredicate(
1102    agentName: string,
1103    predicate: (msg: TeammateMessage) => boolean,
1104    teamName?: string,
1105  ): Promise<void> {
1106    const inboxPath = getInboxPath(agentName, teamName)
1107  
1108    const lockFilePath = `${inboxPath}.lock`
1109    let release: (() => Promise<void>) | undefined
1110  
1111    try {
1112      release = await lockfile.lock(inboxPath, {
1113        lockfilePath: lockFilePath,
1114        ...LOCK_OPTIONS,
1115      })
1116  
1117      const messages = await readMailbox(agentName, teamName)
1118      if (messages.length === 0) {
1119        return
1120      }
1121  
1122      const updatedMessages = messages.map(m =>
1123        !m.read && predicate(m) ? { ...m, read: true } : m,
1124      )
1125  
1126      await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')
1127    } catch (error) {
1128      const code = getErrnoCode(error)
1129      if (code === 'ENOENT') {
1130        return
1131      }
1132      logError(error)
1133    } finally {
1134      if (release) {
1135        try {
1136          await release()
1137        } catch {
1138          // Lock may have already been released
1139        }
1140      }
1141    }
1142  }
1143  
1144  /**
1145   * Extracts a "[to {name}] {summary}" string from the last assistant message
1146   * if it ended with a SendMessage tool_use targeting a peer (not the team lead).
1147   * Returns undefined when the turn didn't end with a peer DM.
1148   */
1149  export function getLastPeerDmSummary(messages: Message[]): string | undefined {
1150    for (let i = messages.length - 1; i >= 0; i--) {
1151      const msg = messages[i]
1152      if (!msg) continue
1153  
1154      // Stop at wake-up boundary: a user prompt (string content), not tool results (array content)
1155      if (msg.type === 'user' && typeof msg.message.content === 'string') {
1156        break
1157      }
1158  
1159      if (msg.type !== 'assistant') continue
1160      for (const block of msg.message.content) {
1161        if (
1162          block.type === 'tool_use' &&
1163          block.name === SEND_MESSAGE_TOOL_NAME &&
1164          typeof block.input === 'object' &&
1165          block.input !== null &&
1166          'to' in block.input &&
1167          typeof block.input.to === 'string' &&
1168          block.input.to !== '*' &&
1169          block.input.to.toLowerCase() !== TEAM_LEAD_NAME.toLowerCase() &&
1170          'message' in block.input &&
1171          typeof block.input.message === 'string'
1172        ) {
1173          const to = block.input.to
1174          const summary =
1175            'summary' in block.input && typeof block.input.summary === 'string'
1176              ? block.input.summary
1177              : block.input.message.slice(0, 80)
1178          return `[to ${to}] ${summary}`
1179        }
1180      }
1181    }
1182    return undefined
1183  }