Files
claude-code/src/utils/teammateMailbox.ts
Dosion 52b61c2c06 fix: bound agent communication memory growth (#369)
* fix: bound agent communication memory growth

UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue.

Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories.

Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74.
Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths.
Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets.
Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame.
Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary.
Confidence: high
Scope-risk: moderate
Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces.
Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock.
Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts
Tested: bunx tsc --noEmit --pretty false
Tested: bun run lint
Tested: bunx biome lint modified src/package files
Tested: bun run test:all (3704 pass, 0 fail, 6734 expects)
Tested: bun audit (No vulnerabilities found)
Tested: bun run build
Tested: bun run build:vite
Tested: git diff --check
Not-tested: End-to-end external UDS client driving a full production headless model turn.

* fix: harden bounded agent communication review fixes

CodeRabbit and Codecov surfaced real gaps in UDS framing, peer discovery, mailbox retention, and summary context coverage. This tightens those paths without suppressing review or coverage signals.

Constraint: PR #369 must address CodeRabbit and Codecov findings without warning suppression or fake fallbacks

Rejected: Suppress Codecov or CodeRabbit warnings | leaves real receive-path and test-isolation gaps

Rejected: Add unreachable feature-gated tests | bun:bundle keeps those branches compile-time gated in local tests

Confidence: high

Scope-risk: moderate

Directive: Keep UDS auth-token rejection outside feature flags; do not reintroduce inline token fallbacks

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run test:all; bun run lint; bun run build; bun run build:vite; bun audit; git diff --cached --check

Not-tested: Remote Codecov/CodeRabbit refreshed reports until pushed

* fix: prevent agent communication bounds from hiding CI regressions

Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests.

Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution

Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior

Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection

Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion

Confidence: high

Scope-risk: moderate

Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken

Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage

Tested: bun run test:all

Tested: bun audit

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-27 14:47:18 +08:00

1462 lines
41 KiB
TypeScript

/**
* 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 { randomBytes } from 'crypto'
import { mkdir, readFile, rename, stat, unlink, 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 '@claude-code-best/builtin-tools/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 const MAX_MAILBOX_MESSAGES = 1_000
export const MAX_READ_MAILBOX_MESSAGES = 200
export const MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES = 2_000
export const MAX_MAILBOX_MESSAGE_TEXT_BYTES = 64 * 1024
export const MAX_MAILBOX_RETAINED_BYTES = 2 * 1024 * 1024
export const MAX_MAILBOX_FILE_BYTES = 4 * 1024 * 1024
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
}
function isJsonLikeMessage(text: string): boolean {
const trimmed = text.trimStart()
return trimmed.startsWith('{') || trimmed.startsWith('[')
}
function shouldRetainUnreadAsProtocolMessage(
message: TeammateMessage,
): boolean {
if (message.read) return false
if (isStructuredProtocolMessage(message.text)) return true
if (!isJsonLikeMessage(message.text)) return false
try {
const parsed = jsonParse(message.text)
return Boolean(
parsed &&
typeof parsed === 'object' &&
'type' in (parsed as Record<string, unknown>),
)
} catch {
return false
}
}
function sameMailboxMessage(a: TeammateMessage, b: TeammateMessage): boolean {
return a.from === b.from && a.timestamp === b.timestamp && a.text === b.text
}
function mailboxMessageStorageBytes(message: TeammateMessage): number {
return Buffer.byteLength(jsonStringify(message), 'utf8')
}
function assertMailboxMessageSize(message: TeammateMessage): void {
const textBytes = Buffer.byteLength(message.text, 'utf8')
if (textBytes > MAX_MAILBOX_MESSAGE_TEXT_BYTES) {
throw new Error(
`Mailbox message text exceeds ${MAX_MAILBOX_MESSAGE_TEXT_BYTES} bytes`,
)
}
}
function toMailboxMessage(value: unknown): TeammateMessage {
if (!value || typeof value !== 'object') {
throw new Error('Invalid mailbox message: expected object')
}
const record = value as Record<string, unknown>
if (
typeof record.from !== 'string' ||
typeof record.text !== 'string' ||
typeof record.timestamp !== 'string' ||
typeof record.read !== 'boolean'
) {
throw new Error('Invalid mailbox message shape')
}
const message: TeammateMessage = {
from: record.from,
text: record.text,
timestamp: record.timestamp,
read: record.read,
...(typeof record.color === 'string' ? { color: record.color } : {}),
...(typeof record.summary === 'string' ? { summary: record.summary } : {}),
}
assertMailboxMessageSize(message)
return message
}
function parseMailboxMessages(content: string): TeammateMessage[] {
const parsed = jsonParse(content)
if (!Array.isArray(parsed)) {
throw new Error('Invalid mailbox file: expected message array')
}
return parsed.map(toMailboxMessage)
}
async function readMailboxFile(inboxPath: string): Promise<string> {
const info = await stat(inboxPath)
if (info.size > MAX_MAILBOX_FILE_BYTES) {
throw new Error(
`Mailbox file exceeds ${MAX_MAILBOX_FILE_BYTES} bytes: ${inboxPath}`,
)
}
return readFile(inboxPath, 'utf-8')
}
async function readMailboxForMutation(
agentName: string,
teamName?: string,
): Promise<TeammateMessage[]> {
const inboxPath = getInboxPath(agentName, teamName)
return parseMailboxMessages(await readMailboxFile(inboxPath))
}
async function writeMailboxAtomic(
inboxPath: string,
content: string,
): Promise<void> {
const bytes = Buffer.byteLength(content, 'utf8')
if (bytes > MAX_MAILBOX_FILE_BYTES) {
throw new Error(
`Compacted mailbox still exceeds ${MAX_MAILBOX_FILE_BYTES} bytes`,
)
}
const tempPath = `${inboxPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
try {
await writeFile(tempPath, content, 'utf-8')
await rename(tempPath, inboxPath)
} catch (error) {
await unlink(tempPath).catch(() => undefined)
throw error
}
}
export function compactMailboxMessages(
messages: TeammateMessage[],
limits: {
maxMessages?: number
maxReadMessages?: number
maxUnreadProtocolMessages?: number
maxRetainedBytes?: number
} = {},
): TeammateMessage[] {
const maxMessages = limits.maxMessages ?? MAX_MAILBOX_MESSAGES
const maxReadMessages = limits.maxReadMessages ?? MAX_READ_MAILBOX_MESSAGES
const maxUnreadProtocolMessages =
limits.maxUnreadProtocolMessages ?? MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES
const maxRetainedBytes = limits.maxRetainedBytes ?? MAX_MAILBOX_RETAINED_BYTES
if (
maxRetainedBytes <= 0 ||
(maxMessages <= 0 && maxUnreadProtocolMessages <= 0)
) {
return []
}
const keepIndexes = new Set<number>()
let retainedBytes = 0
let keptUnreadProtocolMessages = 0
const tryKeep = (index: number): boolean => {
if (keepIndexes.has(index)) return true
const message = messages[index]
if (!message) return false
const bytes = mailboxMessageStorageBytes(message)
if (bytes > maxRetainedBytes || retainedBytes + bytes > maxRetainedBytes) {
return false
}
keepIndexes.add(index)
retainedBytes += bytes
return true
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message || !shouldRetainUnreadAsProtocolMessage(message)) continue
if (keptUnreadProtocolMessages >= maxUnreadProtocolMessages) continue
if (tryKeep(i)) keptUnreadProtocolMessages++
}
let keptNonProtocolMessages = 0
for (let i = messages.length - 1; i >= 0; i--) {
if (keptNonProtocolMessages >= maxMessages) break
const message = messages[i]
if (
message &&
!message.read &&
!shouldRetainUnreadAsProtocolMessage(message)
) {
if (tryKeep(i)) keptNonProtocolMessages++
}
}
let keptReadMessages = 0
for (let i = messages.length - 1; i >= 0; i--) {
if (keptNonProtocolMessages >= maxMessages) break
if (keptReadMessages >= maxReadMessages) break
const message = messages[i]
if (message?.read) {
if (tryKeep(i)) {
keptReadMessages++
keptNonProtocolMessages++
}
}
}
return messages.filter((_message, index) => keepIndexes.has(index))
}
function logUnreadMailboxEvictions(
original: TeammateMessage[],
compacted: TeammateMessage[],
context: string,
): void {
const kept = new Set(compacted)
const unreadEvicted = original.filter(message => {
return !message.read && !kept.has(message)
})
if (unreadEvicted.length === 0) return
const protocolEvicted = count(unreadEvicted, message =>
shouldRetainUnreadAsProtocolMessage(message),
)
logError(
new Error(
`[TeammateMailbox] Compacted ${unreadEvicted.length} unread message(s) in ${context}; protocol_or_unknown=${protocolEvicted}`,
),
)
}
async function writeCompactedMailbox(
inboxPath: string,
messages: TeammateMessage[],
context: string,
): Promise<void> {
const compacted = compactMailboxMessages(messages)
logUnreadMailboxEvictions(messages, compacted, context)
await writeMailboxAtomic(inboxPath, jsonStringify(compacted, null, 2))
}
/**
* 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 messages = parseMailboxMessages(await readMailboxFile(inboxPath))
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)
throw error
}
}
/**
* 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)
throw error
}
}
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 readMailboxForMutation(recipientName, teamName)
const newMessage = toMailboxMessage({
...message,
read: false,
})
messages.push(newMessage)
await writeCompactedMailbox(inboxPath, messages, 'writeToMailbox')
logForDebugging(
`[TeammateMailbox] Wrote message to ${recipientName}'s inbox from ${message.from}`,
)
} catch (error) {
logForDebugging(`Failed to write to inbox for ${recipientName}: ${error}`)
logError(error)
throw 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 readMailboxForMutation(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 writeCompactedMailbox(inboxPath, messages, 'markMessageAsReadByIndex')
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`,
)
}
}
}
export async function markMessageAsReadByIdentity(
agentName: string,
teamName: string | undefined,
expectedMessage: TeammateMessage,
): Promise<boolean> {
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 readMailboxForMutation(agentName, teamName)
const messageIndex = messages.findIndex(message => {
return !message.read && sameMailboxMessage(message, expectedMessage)
})
if (messageIndex < 0) return false
messages[messageIndex] = { ...messages[messageIndex]!, read: true }
await writeCompactedMailbox(
inboxPath,
messages,
'markMessageAsReadByIdentity',
)
return true
} catch (error) {
const code = getErrnoCode(error)
if (code === 'ENOENT') return false
logError(error)
return false
} finally {
if (release) {
await release()
}
}
}
/**
* 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 readMailboxForMutation(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 writeCompactedMailbox(inboxPath, messages, 'markMessagesAsRead')
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 readMailboxForMutation(agentName, teamName)
if (messages.length === 0) {
return
}
const updatedMessages = messages.map(m =>
!m.read && predicate(m) ? { ...m, read: true } : m,
)
await writeCompactedMailbox(
inboxPath,
updatedMessages,
'markMessagesAsReadByPredicate',
)
} 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
}