| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187 |
- /**
- * Teammate Mailbox - File-based messaging system for agent swarms
- *
- * Each teammate has an inbox file at .claude/teams/{team_name}/inboxes/{agent_name}.json
- * Other teammates can write messages to it, and the recipient sees them as attachments.
- *
- * Note: Inboxes are keyed by agent name within a team.
- */
- import { mkdir, readFile, writeFile } from 'fs/promises'
- import { join } from 'path'
- import { z } from 'zod/v4'
- import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
- import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
- import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
- import type { Message } from '../types/message.js'
- import { generateRequestId } from './agentId.js'
- import { count } from './array.js'
- import { logForDebugging } from './debug.js'
- import { getTeamsDir } from './envUtils.js'
- import { getErrnoCode } from './errors.js'
- import { lazySchema } from './lazySchema.js'
- import * as lockfile from './lockfile.js'
- import { logError } from './log.js'
- import { jsonParse, jsonStringify } from './slowOperations.js'
- import type { BackendType } from './swarm/backends/types.js'
- import { TEAM_LEAD_NAME } from './swarm/constants.js'
- import { sanitizePathComponent } from './tasks.js'
- import { getAgentName, getTeammateColor, getTeamName } from './teammate.js'
- // Lock options: retry with backoff so concurrent callers (multiple Claudes
- // in a swarm) wait for the lock instead of failing immediately. The sync
- // lockSync API blocked the event loop; the async API needs explicit retries
- // to achieve the same serialization semantics.
- const LOCK_OPTIONS = {
- retries: {
- retries: 10,
- minTimeout: 5,
- maxTimeout: 100,
- },
- }
- export type TeammateMessage = {
- from: string
- text: string
- timestamp: string
- read: boolean
- color?: string // Sender's assigned color (e.g., 'red', 'blue', 'green')
- summary?: string // 5-10 word summary shown as preview in the UI
- }
- /**
- * Get the path to a teammate's inbox file
- * Structure: ~/.claude/teams/{team_name}/inboxes/{agent_name}.json
- */
- export function getInboxPath(agentName: string, teamName?: string): string {
- const team = teamName || getTeamName() || 'default'
- const safeTeam = sanitizePathComponent(team)
- const safeAgentName = sanitizePathComponent(agentName)
- const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
- const fullPath = join(inboxDir, `${safeAgentName}.json`)
- logForDebugging(
- `[TeammateMailbox] getInboxPath: agent=${agentName}, team=${team}, fullPath=${fullPath}`,
- )
- return fullPath
- }
- /**
- * Ensure the inbox directory exists for a team
- */
- async function ensureInboxDir(teamName?: string): Promise<void> {
- const team = teamName || getTeamName() || 'default'
- const safeTeam = sanitizePathComponent(team)
- const inboxDir = join(getTeamsDir(), safeTeam, 'inboxes')
- await mkdir(inboxDir, { recursive: true })
- logForDebugging(`[TeammateMailbox] Ensured inbox directory: ${inboxDir}`)
- }
- /**
- * Read all messages from a teammate's inbox
- * @param agentName - The agent name (not UUID) to read inbox for
- * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var or 'default')
- */
- export async function readMailbox(
- agentName: string,
- teamName?: string,
- ): Promise<TeammateMessage[]> {
- const inboxPath = getInboxPath(agentName, teamName)
- logForDebugging(`[TeammateMailbox] readMailbox: path=${inboxPath}`)
- try {
- const content = await readFile(inboxPath, 'utf-8')
- const messages = jsonParse(content) as TeammateMessage[]
- logForDebugging(
- `[TeammateMailbox] readMailbox: read ${messages.length} message(s)`,
- )
- return messages
- } catch (error) {
- const code = getErrnoCode(error)
- if (code === 'ENOENT') {
- logForDebugging(`[TeammateMailbox] readMailbox: file does not exist`)
- return []
- }
- logForDebugging(`Failed to read inbox for ${agentName}: ${error}`)
- logError(error)
- return []
- }
- }
- /**
- * Read only unread messages from a teammate's inbox
- * @param agentName - The agent name (not UUID) to read inbox for
- * @param teamName - Optional team name
- */
- export async function readUnreadMessages(
- agentName: string,
- teamName?: string,
- ): Promise<TeammateMessage[]> {
- const messages = await readMailbox(agentName, teamName)
- const unread = messages.filter(m => !m.read)
- logForDebugging(
- `[TeammateMailbox] readUnreadMessages: ${unread.length} unread of ${messages.length} total`,
- )
- return unread
- }
- /**
- * Write a message to a teammate's inbox
- * Uses file locking to prevent race conditions when multiple agents write concurrently
- * @param recipientName - The recipient's agent name (not UUID)
- * @param message - The message to write
- * @param teamName - Optional team name
- */
- export async function writeToMailbox(
- recipientName: string,
- message: Omit<TeammateMessage, 'read'>,
- teamName?: string,
- ): Promise<void> {
- await ensureInboxDir(teamName)
- const inboxPath = getInboxPath(recipientName, teamName)
- const lockFilePath = `${inboxPath}.lock`
- logForDebugging(
- `[TeammateMailbox] writeToMailbox: recipient=${recipientName}, from=${message.from}, path=${inboxPath}`,
- )
- // Ensure the inbox file exists before locking (proper-lockfile requires the file to exist)
- try {
- await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'wx' })
- logForDebugging(`[TeammateMailbox] writeToMailbox: created new inbox file`)
- } catch (error) {
- const code = getErrnoCode(error)
- if (code !== 'EEXIST') {
- logForDebugging(
- `[TeammateMailbox] writeToMailbox: failed to create inbox file: ${error}`,
- )
- logError(error)
- return
- }
- }
- let release: (() => Promise<void>) | undefined
- try {
- release = await lockfile.lock(inboxPath, {
- lockfilePath: lockFilePath,
- ...LOCK_OPTIONS,
- })
- // Re-read messages after acquiring lock to get the latest state
- const messages = await readMailbox(recipientName, teamName)
- const newMessage: TeammateMessage = {
- ...message,
- read: false,
- }
- messages.push(newMessage)
- await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
- logForDebugging(
- `[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
- )
- } catch (error) {
- logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
- logError(error)
- } finally {
- if (release) {
- await release()
- }
- }
- }
- /**
- * Mark a specific message in a teammate's inbox as read by index
- * Uses file locking to prevent race conditions
- * @param agentName - The agent name to mark message as read for
- * @param teamName - Optional team name
- * @param messageIndex - Index of the message to mark as read
- */
- export async function markMessageAsReadByIndex(
- agentName: string,
- teamName: string | undefined,
- messageIndex: number,
- ): Promise<void> {
- const inboxPath = getInboxPath(agentName, teamName)
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex called: agentName=${agentName}, teamName=${teamName}, index=${messageIndex}, path=${inboxPath}`,
- )
- const lockFilePath = `${inboxPath}.lock`
- let release: (() => Promise<void>) | undefined
- try {
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: acquiring lock...`,
- )
- release = await lockfile.lock(inboxPath, {
- lockfilePath: lockFilePath,
- ...LOCK_OPTIONS,
- })
- logForDebugging(`[TeammateMailbox] markMessageAsReadByIndex: lock acquired`)
- // Re-read messages after acquiring lock to get the latest state
- const messages = await readMailbox(agentName, teamName)
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: read ${messages.length} messages after lock`,
- )
- if (messageIndex < 0 || messageIndex >= messages.length) {
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: index ${messageIndex} out of bounds (${messages.length} messages)`,
- )
- return
- }
- const message = messages[messageIndex]
- if (!message || message.read) {
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: message already read or missing`,
- )
- return
- }
- messages[messageIndex] = { ...message, read: true }
- await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`,
- )
- } catch (error) {
- const code = getErrnoCode(error)
- if (code === 'ENOENT') {
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: file does not exist at ${inboxPath}`,
- )
- return
- }
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex FAILED for ${agentName}: ${error}`,
- )
- logError(error)
- } finally {
- if (release) {
- await release()
- logForDebugging(
- `[TeammateMailbox] markMessageAsReadByIndex: lock released`,
- )
- }
- }
- }
- /**
- * Mark all messages in a teammate's inbox as read
- * Uses file locking to prevent race conditions
- * @param agentName - The agent name to mark messages as read for
- * @param teamName - Optional team name
- */
- export async function markMessagesAsRead(
- agentName: string,
- teamName?: string,
- ): Promise<void> {
- const inboxPath = getInboxPath(agentName, teamName)
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`,
- )
- const lockFilePath = `${inboxPath}.lock`
- let release: (() => Promise<void>) | undefined
- try {
- logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`)
- release = await lockfile.lock(inboxPath, {
- lockfilePath: lockFilePath,
- ...LOCK_OPTIONS,
- })
- logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`)
- // Re-read messages after acquiring lock to get the latest state
- const messages = await readMailbox(agentName, teamName)
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`,
- )
- if (messages.length === 0) {
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead: no messages to mark`,
- )
- return
- }
- const unreadCount = count(messages, m => !m.read)
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`,
- )
- // messages comes from jsonParse — fresh, unshared objects safe to mutate
- for (const m of messages) m.read = true
- await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`,
- )
- } catch (error) {
- const code = getErrnoCode(error)
- if (code === 'ENOENT') {
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`,
- )
- return
- }
- logForDebugging(
- `[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`,
- )
- logError(error)
- } finally {
- if (release) {
- await release()
- logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`)
- }
- }
- }
- /**
- * Clear a teammate's inbox (delete all messages)
- * @param agentName - The agent name to clear inbox for
- * @param teamName - Optional team name
- */
- export async function clearMailbox(
- agentName: string,
- teamName?: string,
- ): Promise<void> {
- const inboxPath = getInboxPath(agentName, teamName)
- try {
- // flag 'r+' throws ENOENT if the file doesn't exist, so we don't
- // accidentally create an inbox file that wasn't there.
- await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'r+' })
- logForDebugging(`[TeammateMailbox] Cleared inbox for ${agentName}`)
- } catch (error) {
- const code = getErrnoCode(error)
- if (code === 'ENOENT') {
- return
- }
- logForDebugging(`Failed to clear inbox for ${agentName}: ${error}`)
- logError(error)
- }
- }
- /**
- * Format teammate messages as XML for attachment display
- */
- export function formatTeammateMessages(
- messages: Array<{
- from: string
- text: string
- timestamp: string
- color?: string
- summary?: string
- }>,
- ): string {
- return messages
- .map(m => {
- const colorAttr = m.color ? ` color="${m.color}"` : ''
- const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
- return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
- })
- .join('\n\n')
- }
- /**
- * Structured message sent when a teammate becomes idle (via Stop hook)
- */
- export type IdleNotificationMessage = {
- type: 'idle_notification'
- from: string
- timestamp: string
- /** Why the agent went idle */
- idleReason?: 'available' | 'interrupted' | 'failed'
- /** Brief summary of the last DM sent this turn (if any) */
- summary?: string
- completedTaskId?: string
- completedStatus?: 'resolved' | 'blocked' | 'failed'
- failureReason?: string
- }
- /**
- * Creates an idle notification message to send to the team leader
- */
- export function createIdleNotification(
- agentId: string,
- options?: {
- idleReason?: IdleNotificationMessage['idleReason']
- summary?: string
- completedTaskId?: string
- completedStatus?: 'resolved' | 'blocked' | 'failed'
- failureReason?: string
- },
- ): IdleNotificationMessage {
- return {
- type: 'idle_notification',
- from: agentId,
- timestamp: new Date().toISOString(),
- idleReason: options?.idleReason,
- summary: options?.summary,
- completedTaskId: options?.completedTaskId,
- completedStatus: options?.completedStatus,
- failureReason: options?.failureReason,
- }
- }
- /**
- * Checks if a message text contains an idle notification
- */
- export function isIdleNotification(
- messageText: string,
- ): IdleNotificationMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'idle_notification') {
- return parsed as IdleNotificationMessage
- }
- } catch {
- // Not JSON or not a valid idle notification
- }
- return null
- }
- /**
- * Permission request message sent from worker to leader via mailbox.
- * Field names align with SDK `can_use_tool` (snake_case).
- */
- export type PermissionRequestMessage = {
- type: 'permission_request'
- request_id: string
- agent_id: string
- tool_name: string
- tool_use_id: string
- description: string
- input: Record<string, unknown>
- permission_suggestions: unknown[]
- }
- /**
- * Permission response message sent from leader to worker via mailbox.
- * Shape mirrors SDK ControlResponseSchema / ControlErrorResponseSchema.
- */
- export type PermissionResponseMessage =
- | {
- type: 'permission_response'
- request_id: string
- subtype: 'success'
- response?: {
- updated_input?: Record<string, unknown>
- permission_updates?: unknown[]
- }
- }
- | {
- type: 'permission_response'
- request_id: string
- subtype: 'error'
- error: string
- }
- /**
- * Creates a permission request message to send to the team leader
- */
- export function createPermissionRequestMessage(params: {
- request_id: string
- agent_id: string
- tool_name: string
- tool_use_id: string
- description: string
- input: Record<string, unknown>
- permission_suggestions?: unknown[]
- }): PermissionRequestMessage {
- return {
- type: 'permission_request',
- request_id: params.request_id,
- agent_id: params.agent_id,
- tool_name: params.tool_name,
- tool_use_id: params.tool_use_id,
- description: params.description,
- input: params.input,
- permission_suggestions: params.permission_suggestions || [],
- }
- }
- /**
- * Creates a permission response message to send back to a worker
- */
- export function createPermissionResponseMessage(params: {
- request_id: string
- subtype: 'success' | 'error'
- error?: string
- updated_input?: Record<string, unknown>
- permission_updates?: unknown[]
- }): PermissionResponseMessage {
- if (params.subtype === 'error') {
- return {
- type: 'permission_response',
- request_id: params.request_id,
- subtype: 'error',
- error: params.error || 'Permission denied',
- }
- }
- return {
- type: 'permission_response',
- request_id: params.request_id,
- subtype: 'success',
- response: {
- updated_input: params.updated_input,
- permission_updates: params.permission_updates,
- },
- }
- }
- /**
- * Checks if a message text contains a permission request
- */
- export function isPermissionRequest(
- messageText: string,
- ): PermissionRequestMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'permission_request') {
- return parsed as PermissionRequestMessage
- }
- } catch {
- // Not JSON or not a valid permission request
- }
- return null
- }
- /**
- * Checks if a message text contains a permission response
- */
- export function isPermissionResponse(
- messageText: string,
- ): PermissionResponseMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'permission_response') {
- return parsed as PermissionResponseMessage
- }
- } catch {
- // Not JSON or not a valid permission response
- }
- return null
- }
- /**
- * Sandbox permission request message sent from worker to leader via mailbox
- * This is triggered when sandbox runtime detects a network access to a non-allowed host
- */
- export type SandboxPermissionRequestMessage = {
- type: 'sandbox_permission_request'
- /** Unique identifier for this request */
- requestId: string
- /** Worker's CLAUDE_CODE_AGENT_ID */
- workerId: string
- /** Worker's CLAUDE_CODE_AGENT_NAME */
- workerName: string
- /** Worker's CLAUDE_CODE_AGENT_COLOR */
- workerColor?: string
- /** The host pattern requesting network access */
- hostPattern: {
- host: string
- }
- /** Timestamp when request was created */
- createdAt: number
- }
- /**
- * Sandbox permission response message sent from leader to worker via mailbox
- */
- export type SandboxPermissionResponseMessage = {
- type: 'sandbox_permission_response'
- /** ID of the request this responds to */
- requestId: string
- /** The host that was approved/denied */
- host: string
- /** Whether the connection is allowed */
- allow: boolean
- /** Timestamp when response was created */
- timestamp: string
- }
- /**
- * Creates a sandbox permission request message to send to the team leader
- */
- export function createSandboxPermissionRequestMessage(params: {
- requestId: string
- workerId: string
- workerName: string
- workerColor?: string
- host: string
- }): SandboxPermissionRequestMessage {
- return {
- type: 'sandbox_permission_request',
- requestId: params.requestId,
- workerId: params.workerId,
- workerName: params.workerName,
- workerColor: params.workerColor,
- hostPattern: { host: params.host },
- createdAt: Date.now(),
- }
- }
- /**
- * Creates a sandbox permission response message to send back to a worker
- */
- export function createSandboxPermissionResponseMessage(params: {
- requestId: string
- host: string
- allow: boolean
- }): SandboxPermissionResponseMessage {
- return {
- type: 'sandbox_permission_response',
- requestId: params.requestId,
- host: params.host,
- allow: params.allow,
- timestamp: new Date().toISOString(),
- }
- }
- /**
- * Checks if a message text contains a sandbox permission request
- */
- export function isSandboxPermissionRequest(
- messageText: string,
- ): SandboxPermissionRequestMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'sandbox_permission_request') {
- return parsed as SandboxPermissionRequestMessage
- }
- } catch {
- // Not JSON or not a valid sandbox permission request
- }
- return null
- }
- /**
- * Checks if a message text contains a sandbox permission response
- */
- export function isSandboxPermissionResponse(
- messageText: string,
- ): SandboxPermissionResponseMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'sandbox_permission_response') {
- return parsed as SandboxPermissionResponseMessage
- }
- } catch {
- // Not JSON or not a valid sandbox permission response
- }
- return null
- }
- /**
- * Message sent when a teammate requests plan approval from the team leader
- */
- export const PlanApprovalRequestMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('plan_approval_request'),
- from: z.string(),
- timestamp: z.string(),
- planFilePath: z.string(),
- planContent: z.string(),
- requestId: z.string(),
- }),
- )
- export type PlanApprovalRequestMessage = z.infer<
- ReturnType<typeof PlanApprovalRequestMessageSchema>
- >
- /**
- * Message sent by the team leader in response to a plan approval request
- */
- export const PlanApprovalResponseMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('plan_approval_response'),
- requestId: z.string(),
- approved: z.boolean(),
- feedback: z.string().optional(),
- timestamp: z.string(),
- permissionMode: PermissionModeSchema().optional(),
- }),
- )
- export type PlanApprovalResponseMessage = z.infer<
- ReturnType<typeof PlanApprovalResponseMessageSchema>
- >
- /**
- * Shutdown request message sent from leader to teammate via mailbox
- */
- export const ShutdownRequestMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('shutdown_request'),
- requestId: z.string(),
- from: z.string(),
- reason: z.string().optional(),
- timestamp: z.string(),
- }),
- )
- export type ShutdownRequestMessage = z.infer<
- ReturnType<typeof ShutdownRequestMessageSchema>
- >
- /**
- * Shutdown approved message sent from teammate to leader via mailbox
- */
- export const ShutdownApprovedMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('shutdown_approved'),
- requestId: z.string(),
- from: z.string(),
- timestamp: z.string(),
- paneId: z.string().optional(),
- backendType: z.string().optional(),
- }),
- )
- export type ShutdownApprovedMessage = z.infer<
- ReturnType<typeof ShutdownApprovedMessageSchema>
- >
- /**
- * Shutdown rejected message sent from teammate to leader via mailbox
- */
- export const ShutdownRejectedMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('shutdown_rejected'),
- requestId: z.string(),
- from: z.string(),
- reason: z.string(),
- timestamp: z.string(),
- }),
- )
- export type ShutdownRejectedMessage = z.infer<
- ReturnType<typeof ShutdownRejectedMessageSchema>
- >
- /**
- * Creates a shutdown request message to send to a teammate
- */
- export function createShutdownRequestMessage(params: {
- requestId: string
- from: string
- reason?: string
- }): ShutdownRequestMessage {
- return {
- type: 'shutdown_request',
- requestId: params.requestId,
- from: params.from,
- reason: params.reason,
- timestamp: new Date().toISOString(),
- }
- }
- /**
- * Creates a shutdown approved message to send to the team leader
- */
- export function createShutdownApprovedMessage(params: {
- requestId: string
- from: string
- paneId?: string
- backendType?: BackendType
- }): ShutdownApprovedMessage {
- return {
- type: 'shutdown_approved',
- requestId: params.requestId,
- from: params.from,
- timestamp: new Date().toISOString(),
- paneId: params.paneId,
- backendType: params.backendType,
- }
- }
- /**
- * Creates a shutdown rejected message to send to the team leader
- */
- export function createShutdownRejectedMessage(params: {
- requestId: string
- from: string
- reason: string
- }): ShutdownRejectedMessage {
- return {
- type: 'shutdown_rejected',
- requestId: params.requestId,
- from: params.from,
- reason: params.reason,
- timestamp: new Date().toISOString(),
- }
- }
- /**
- * Sends a shutdown request to a teammate's mailbox.
- * This is the core logic extracted for reuse by both the tool and UI components.
- *
- * @param targetName - Name of the teammate to send shutdown request to
- * @param teamName - Optional team name (defaults to CLAUDE_CODE_TEAM_NAME env var)
- * @param reason - Optional reason for the shutdown request
- * @returns The request ID and target name
- */
- export async function sendShutdownRequestToMailbox(
- targetName: string,
- teamName?: string,
- reason?: string,
- ): Promise<{ requestId: string; target: string }> {
- const resolvedTeamName = teamName || getTeamName()
- // Get sender name (supports in-process teammates via AsyncLocalStorage)
- const senderName = getAgentName() || TEAM_LEAD_NAME
- // Generate a deterministic request ID for this shutdown request
- const requestId = generateRequestId('shutdown', targetName)
- // Create and send the shutdown request message
- const shutdownMessage = createShutdownRequestMessage({
- requestId,
- from: senderName,
- reason,
- })
- await writeToMailbox(
- targetName,
- {
- from: senderName,
- text: jsonStringify(shutdownMessage),
- timestamp: new Date().toISOString(),
- color: getTeammateColor(),
- },
- resolvedTeamName,
- )
- return { requestId, target: targetName }
- }
- /**
- * Checks if a message text contains a shutdown request
- */
- export function isShutdownRequest(
- messageText: string,
- ): ShutdownRequestMessage | null {
- try {
- const result = ShutdownRequestMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (result.success) return result.data
- } catch {
- // Not JSON
- }
- return null
- }
- /**
- * Checks if a message text contains a plan approval request
- */
- export function isPlanApprovalRequest(
- messageText: string,
- ): PlanApprovalRequestMessage | null {
- try {
- const result = PlanApprovalRequestMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (result.success) return result.data
- } catch {
- // Not JSON
- }
- return null
- }
- /**
- * Checks if a message text contains a shutdown approved message
- */
- export function isShutdownApproved(
- messageText: string,
- ): ShutdownApprovedMessage | null {
- try {
- const result = ShutdownApprovedMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (result.success) return result.data
- } catch {
- // Not JSON
- }
- return null
- }
- /**
- * Checks if a message text contains a shutdown rejected message
- */
- export function isShutdownRejected(
- messageText: string,
- ): ShutdownRejectedMessage | null {
- try {
- const result = ShutdownRejectedMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (result.success) return result.data
- } catch {
- // Not JSON
- }
- return null
- }
- /**
- * Checks if a message text contains a plan approval response
- */
- export function isPlanApprovalResponse(
- messageText: string,
- ): PlanApprovalResponseMessage | null {
- try {
- const result = PlanApprovalResponseMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (result.success) return result.data
- } catch {
- // Not JSON
- }
- return null
- }
- /**
- * Task assignment message sent when a task is assigned to a teammate
- */
- export type TaskAssignmentMessage = {
- type: 'task_assignment'
- taskId: string
- subject: string
- description: string
- assignedBy: string
- timestamp: string
- }
- /**
- * Checks if a message text contains a task assignment
- */
- export function isTaskAssignment(
- messageText: string,
- ): TaskAssignmentMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'task_assignment') {
- return parsed as TaskAssignmentMessage
- }
- } catch {
- // Not JSON or not a valid task assignment
- }
- return null
- }
- /**
- * Team permission update message sent from leader to teammates via mailbox
- * Broadcasts a permission update that applies to all teammates
- */
- export type TeamPermissionUpdateMessage = {
- type: 'team_permission_update'
- /** The permission update to apply */
- permissionUpdate: {
- type: 'addRules'
- rules: Array<{ toolName: string; ruleContent?: string }>
- behavior: 'allow' | 'deny' | 'ask'
- destination: 'session'
- }
- /** The directory path that was allowed */
- directoryPath: string
- /** The tool name this applies to */
- toolName: string
- }
- /**
- * Checks if a message text contains a team permission update
- */
- export function isTeamPermissionUpdate(
- messageText: string,
- ): TeamPermissionUpdateMessage | null {
- try {
- const parsed = jsonParse(messageText)
- if (parsed && parsed.type === 'team_permission_update') {
- return parsed as TeamPermissionUpdateMessage
- }
- } catch {
- // Not JSON or not a valid team permission update
- }
- return null
- }
- /**
- * Mode set request message sent from leader to teammate via mailbox
- * Uses SDK PermissionModeSchema for validated mode values
- */
- export const ModeSetRequestMessageSchema = lazySchema(() =>
- z.object({
- type: z.literal('mode_set_request'),
- mode: PermissionModeSchema(),
- from: z.string(),
- }),
- )
- export type ModeSetRequestMessage = z.infer<
- ReturnType<typeof ModeSetRequestMessageSchema>
- >
- /**
- * Creates a mode set request message to send to a teammate
- */
- export function createModeSetRequestMessage(params: {
- mode: string
- from: string
- }): ModeSetRequestMessage {
- return {
- type: 'mode_set_request',
- mode: params.mode as ModeSetRequestMessage['mode'],
- from: params.from,
- }
- }
- /**
- * Checks if a message text contains a mode set request
- */
- export function isModeSetRequest(
- messageText: string,
- ): ModeSetRequestMessage | null {
- try {
- const parsed = ModeSetRequestMessageSchema().safeParse(
- jsonParse(messageText),
- )
- if (parsed.success) {
- return parsed.data
- }
- } catch {
- // Not JSON or not a valid mode set request
- }
- return null
- }
- /**
- * Checks if a message text is a structured protocol message that should be
- * routed by useInboxPoller rather than consumed as raw LLM context.
- *
- * These message types have specific handlers in useInboxPoller that route them
- * to the correct queues (workerPermissions, workerSandboxPermissions, etc.).
- * If getTeammateMailboxAttachments consumes them first, they get bundled as
- * raw text in attachments and never reach their intended handlers.
- */
- export function isStructuredProtocolMessage(messageText: string): boolean {
- try {
- const parsed = jsonParse(messageText)
- if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
- return false
- }
- const type = (parsed as { type: unknown }).type
- return (
- type === 'permission_request' ||
- type === 'permission_response' ||
- type === 'sandbox_permission_request' ||
- type === 'sandbox_permission_response' ||
- type === 'shutdown_request' ||
- type === 'shutdown_approved' ||
- type === 'team_permission_update' ||
- type === 'mode_set_request' ||
- type === 'plan_approval_request' ||
- type === 'plan_approval_response'
- )
- } catch {
- return false
- }
- }
- /**
- * Marks only messages matching a predicate as read, leaving others unread.
- * Uses the same file-locking mechanism as markMessagesAsRead.
- */
- export async function markMessagesAsReadByPredicate(
- agentName: string,
- predicate: (msg: TeammateMessage) => boolean,
- teamName?: string,
- ): Promise<void> {
- const inboxPath = getInboxPath(agentName, teamName)
- const lockFilePath = `${inboxPath}.lock`
- let release: (() => Promise<void>) | undefined
- try {
- release = await lockfile.lock(inboxPath, {
- lockfilePath: lockFilePath,
- ...LOCK_OPTIONS,
- })
- const messages = await readMailbox(agentName, teamName)
- if (messages.length === 0) {
- return
- }
- const updatedMessages = messages.map(m =>
- !m.read && predicate(m) ? { ...m, read: true } : m,
- )
- await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')
- } catch (error) {
- const code = getErrnoCode(error)
- if (code === 'ENOENT') {
- return
- }
- logError(error)
- } finally {
- if (release) {
- try {
- await release()
- } catch {
- // Lock may have already been released
- }
- }
- }
- }
- /**
- * Extracts a "[to {name}] {summary}" string from the last assistant message
- * if it ended with a SendMessage tool_use targeting a peer (not the team lead).
- * Returns undefined when the turn didn't end with a peer DM.
- */
- export function getLastPeerDmSummary(messages: Message[]): string | undefined {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i]
- if (!msg) continue
- // Stop at wake-up boundary: a user prompt (string content), not tool results (array content)
- if (msg.type === 'user' && typeof msg.message.content === 'string') {
- break
- }
- if (msg.type !== 'assistant') continue
- const content = msg.message?.content
- if (!Array.isArray(content)) continue
- for (const block of content) {
- if (typeof block === 'string') continue
- const b = block as unknown as { type: string; name?: string; input?: Record<string, unknown>; [key: string]: unknown }
- if (
- b.type === 'tool_use' &&
- b.name === SEND_MESSAGE_TOOL_NAME &&
- typeof b.input === 'object' &&
- b.input !== null &&
- 'to' in b.input &&
- typeof b.input.to === 'string' &&
- b.input.to !== '*' &&
- b.input.to.toLowerCase() !== TEAM_LEAD_NAME.toLowerCase() &&
- 'message' in b.input &&
- typeof b.input.message === 'string'
- ) {
- const to = b.input.to as string
- const summary =
- 'summary' in b.input && typeof b.input.summary === 'string'
- ? b.input.summary as string
- : (b.input.message as string).slice(0, 80)
- return `[to ${to}] ${summary}`
- }
- }
- }
- return undefined
- }
|