source dump of claude code
at main 1183 lines 33 kB view raw
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 10import { mkdir, readFile, writeFile } from 'fs/promises' 11import { join } from 'path' 12import { z } from 'zod/v4' 13import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js' 14import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js' 15import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js' 16import type { Message } from '../types/message.js' 17import { generateRequestId } from './agentId.js' 18import { count } from './array.js' 19import { logForDebugging } from './debug.js' 20import { getTeamsDir } from './envUtils.js' 21import { getErrnoCode } from './errors.js' 22import { lazySchema } from './lazySchema.js' 23import * as lockfile from './lockfile.js' 24import { logError } from './log.js' 25import { jsonParse, jsonStringify } from './slowOperations.js' 26import type { BackendType } from './swarm/backends/types.js' 27import { TEAM_LEAD_NAME } from './swarm/constants.js' 28import { sanitizePathComponent } from './tasks.js' 29import { 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. 35const LOCK_OPTIONS = { 36 retries: { 37 retries: 10, 38 minTimeout: 5, 39 maxTimeout: 100, 40 }, 41} 42 43export 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 */ 56export 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 */ 71async 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 */ 84export 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 */ 115export 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 */ 134export 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 */ 201export 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 */ 279export 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 */ 349export 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 */ 373export 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 */ 394export 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 */ 410export 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 */ 435export 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 */ 453export 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 */ 468export 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 */ 488export 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 */ 512export 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 */ 541export 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 */ 558export 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 */ 576export 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 */ 597export 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 */ 612export 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 */ 633export 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 */ 650export 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 */ 667export 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 */ 684export 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 695export 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 */ 702export 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 713export type PlanApprovalResponseMessage = z.infer< 714 ReturnType<typeof PlanApprovalResponseMessageSchema> 715> 716 717/** 718 * Shutdown request message sent from leader to teammate via mailbox 719 */ 720export 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 730export type ShutdownRequestMessage = z.infer< 731 ReturnType<typeof ShutdownRequestMessageSchema> 732> 733 734/** 735 * Shutdown approved message sent from teammate to leader via mailbox 736 */ 737export 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 748export type ShutdownApprovedMessage = z.infer< 749 ReturnType<typeof ShutdownApprovedMessageSchema> 750> 751 752/** 753 * Shutdown rejected message sent from teammate to leader via mailbox 754 */ 755export 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 765export type ShutdownRejectedMessage = z.infer< 766 ReturnType<typeof ShutdownRejectedMessageSchema> 767> 768 769/** 770 * Creates a shutdown request message to send to a teammate 771 */ 772export 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 */ 789export 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 */ 808export 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 */ 831export 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 */ 868export 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 */ 885export 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 */ 902export 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 */ 919export 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 */ 936export 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 */ 953export 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 */ 965export 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 */ 983export 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 */ 1001export 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 */ 1019export const ModeSetRequestMessageSchema = lazySchema(() => 1020 z.object({ 1021 type: z.literal('mode_set_request'), 1022 mode: PermissionModeSchema(), 1023 from: z.string(), 1024 }), 1025) 1026 1027export type ModeSetRequestMessage = z.infer< 1028 ReturnType<typeof ModeSetRequestMessageSchema> 1029> 1030 1031/** 1032 * Creates a mode set request message to send to a teammate 1033 */ 1034export 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 */ 1048export 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 */ 1073export 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 */ 1101export 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 */ 1149export 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}