import { feature } from 'bun:bundle' import { z } from 'zod/v4' import { isReplBridgeActive } from '../../bootstrap/state.js' import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js' import type { Tool, ToolUseContext } from '../../Tool.js' import { buildTool, type ToolDef } from '../../Tool.js' import { findTeammateTaskByAgentId } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' import { isLocalAgentTask, queuePendingMessage, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { isMainSessionTask } from '../../tasks/LocalMainSessionTask.js' import { toAgentId } from '../../types/ids.js' import { generateRequestId } from '../../utils/agentId.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { logForDebugging } from '../../utils/debug.js' import { errorMessage } from '../../utils/errors.js' import { truncate } from '../../utils/format.js' import { gracefulShutdown } from '../../utils/gracefulShutdown.js' import { lazySchema } from '../../utils/lazySchema.js' import { parseAddress } from '../../utils/peerAddress.js' import { semanticBoolean } from '../../utils/semanticBoolean.js' import { jsonStringify } from '../../utils/slowOperations.js' import type { BackendType } from '../../utils/swarm/backends/types.js' import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js' import { readTeamFileAsync } from '../../utils/swarm/teamHelpers.js' import { getAgentId, getAgentName, getTeammateColor, getTeamName, isTeamLead, isTeammate, } from '../../utils/teammate.js' import { createShutdownApprovedMessage, createShutdownRejectedMessage, createShutdownRequestMessage, writeToMailbox, } from '../../utils/teammateMailbox.js' import { resumeAgentBackground } from '../AgentTool/resumeAgent.js' import { SEND_MESSAGE_TOOL_NAME } from './constants.js' import { DESCRIPTION, getPrompt } from './prompt.js' import { renderToolResultMessage, renderToolUseMessage } from './UI.js' const StructuredMessage = lazySchema(() => z.discriminatedUnion('type', [ z.object({ type: z.literal('shutdown_request'), reason: z.string().optional(), }), z.object({ type: z.literal('shutdown_response'), request_id: z.string(), approve: semanticBoolean(), reason: z.string().optional(), }), z.object({ type: z.literal('plan_approval_response'), request_id: z.string(), approve: semanticBoolean(), feedback: z.string().optional(), }), ]), ) const inputSchema = lazySchema(() => z.object({ to: z .string() .describe( feature('UDS_INBOX') ? 'Recipient: teammate name, "*" for broadcast, "uds:" for a local peer, or "bridge:" for a Remote Control peer (use ListPeers to discover)' : 'Recipient: teammate name, or "*" for broadcast to all teammates', ), summary: z .string() .optional() .describe( 'A 5-10 word summary shown as a preview in the UI (required when message is a string)', ), message: z.union([ z.string().describe('Plain text message content'), StructuredMessage(), ]), }), ) type InputSchema = ReturnType export type Input = z.infer export type MessageRouting = { sender: string senderColor?: string target: string targetColor?: string summary?: string content?: string } export type MessageOutput = { success: boolean message: string routing?: MessageRouting } export type BroadcastOutput = { success: boolean message: string recipients: string[] routing?: MessageRouting } export type RequestOutput = { success: boolean message: string request_id: string target: string } export type ResponseOutput = { success: boolean message: string request_id?: string } export type SendMessageToolOutput = | MessageOutput | BroadcastOutput | RequestOutput | ResponseOutput function findTeammateColor( appState: { teamContext?: { teammates: { [id: string]: { color?: string } } } }, name: string, ): string | undefined { const teammates = appState.teamContext?.teammates if (!teammates) return undefined for (const teammate of Object.values(teammates)) { if ('name' in teammate && (teammate as { name: string }).name === name) { return teammate.color } } return undefined } async function handleMessage( recipientName: string, content: string, summary: string | undefined, context: ToolUseContext, ): Promise<{ data: MessageOutput }> { const appState = context.getAppState() const teamName = getTeamName(appState.teamContext) const senderName = getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME) const senderColor = getTeammateColor() await writeToMailbox( recipientName, { from: senderName, text: content, summary, timestamp: new Date().toISOString(), color: senderColor, }, teamName, ) const recipientColor = findTeammateColor(appState, recipientName) return { data: { success: true, message: `Message sent to ${recipientName}'s inbox`, routing: { sender: senderName, senderColor, target: `@${recipientName}`, targetColor: recipientColor, summary, content, }, }, } } async function handleBroadcast( content: string, summary: string | undefined, context: ToolUseContext, ): Promise<{ data: BroadcastOutput }> { const appState = context.getAppState() const teamName = getTeamName(appState.teamContext) if (!teamName) { throw new Error( 'Not in a team context. Create a team with Teammate spawnTeam first, or set CLAUDE_CODE_TEAM_NAME.', ) } const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { throw new Error(`Team "${teamName}" does not exist`) } const senderName = getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME) if (!senderName) { throw new Error( 'Cannot broadcast: sender name is required. Set CLAUDE_CODE_AGENT_NAME.', ) } const senderColor = getTeammateColor() const recipients: string[] = [] for (const member of teamFile.members) { if (member.name.toLowerCase() === senderName.toLowerCase()) { continue } recipients.push(member.name) } if (recipients.length === 0) { return { data: { success: true, message: 'No teammates to broadcast to (you are the only team member)', recipients: [], }, } } for (const recipientName of recipients) { await writeToMailbox( recipientName, { from: senderName, text: content, summary, timestamp: new Date().toISOString(), color: senderColor, }, teamName, ) } return { data: { success: true, message: `Message broadcast to ${recipients.length} teammate(s): ${recipients.join(', ')}`, recipients, routing: { sender: senderName, senderColor, target: '@team', summary, content, }, }, } } async function handleShutdownRequest( targetName: string, reason: string | undefined, context: ToolUseContext, ): Promise<{ data: RequestOutput }> { const appState = context.getAppState() const teamName = getTeamName(appState.teamContext) const senderName = getAgentName() || TEAM_LEAD_NAME const requestId = generateRequestId('shutdown', targetName) const shutdownMessage = createShutdownRequestMessage({ requestId, from: senderName, reason, }) await writeToMailbox( targetName, { from: senderName, text: jsonStringify(shutdownMessage), timestamp: new Date().toISOString(), color: getTeammateColor(), }, teamName, ) return { data: { success: true, message: `Shutdown request sent to ${targetName}. Request ID: ${requestId}`, request_id: requestId, target: targetName, }, } } async function handleShutdownApproval( requestId: string, context: ToolUseContext, ): Promise<{ data: ResponseOutput }> { const teamName = getTeamName() const agentId = getAgentId() const agentName = getAgentName() || 'teammate' logForDebugging( `[SendMessageTool] handleShutdownApproval: teamName=${teamName}, agentId=${agentId}, agentName=${agentName}`, ) let ownPaneId: string | undefined let ownBackendType: BackendType | undefined if (teamName) { const teamFile = await readTeamFileAsync(teamName) if (teamFile && agentId) { const selfMember = teamFile.members.find(m => m.agentId === agentId) if (selfMember) { ownPaneId = selfMember.tmuxPaneId ownBackendType = selfMember.backendType } } } const approvedMessage = createShutdownApprovedMessage({ requestId, from: agentName, paneId: ownPaneId, backendType: ownBackendType, }) await writeToMailbox( TEAM_LEAD_NAME, { from: agentName, text: jsonStringify(approvedMessage), timestamp: new Date().toISOString(), color: getTeammateColor(), }, teamName, ) if (ownBackendType === 'in-process') { logForDebugging( `[SendMessageTool] In-process teammate ${agentName} approving shutdown - signaling abort`, ) if (agentId) { const appState = context.getAppState() const task = findTeammateTaskByAgentId(agentId, appState.tasks) if (task?.abortController) { task.abortController.abort() logForDebugging( `[SendMessageTool] Aborted controller for in-process teammate ${agentName}`, ) } else { logForDebugging( `[SendMessageTool] Warning: Could not find task/abortController for ${agentName}`, ) } } } else { if (agentId) { const appState = context.getAppState() const task = findTeammateTaskByAgentId(agentId, appState.tasks) if (task?.abortController) { logForDebugging( `[SendMessageTool] Fallback: Found in-process task for ${agentName} via AppState, aborting`, ) task.abortController.abort() return { data: { success: true, message: `Shutdown approved (fallback path). Agent ${agentName} is now exiting.`, request_id: requestId, }, } } } setImmediate(async () => { await gracefulShutdown(0, 'other') }) } return { data: { success: true, message: `Shutdown approved. Sent confirmation to team-lead. Agent ${agentName} is now exiting.`, request_id: requestId, }, } } async function handleShutdownRejection( requestId: string, reason: string, ): Promise<{ data: ResponseOutput }> { const teamName = getTeamName() const agentName = getAgentName() || 'teammate' const rejectedMessage = createShutdownRejectedMessage({ requestId, from: agentName, reason, }) await writeToMailbox( TEAM_LEAD_NAME, { from: agentName, text: jsonStringify(rejectedMessage), timestamp: new Date().toISOString(), color: getTeammateColor(), }, teamName, ) return { data: { success: true, message: `Shutdown rejected. Reason: "${reason}". Continuing to work.`, request_id: requestId, }, } } async function handlePlanApproval( recipientName: string, requestId: string, context: ToolUseContext, ): Promise<{ data: ResponseOutput }> { const appState = context.getAppState() const teamName = appState.teamContext?.teamName if (!isTeamLead(appState.teamContext)) { throw new Error( 'Only the team lead can approve plans. Teammates cannot approve their own or other plans.', ) } const leaderMode = appState.toolPermissionContext.mode const modeToInherit = leaderMode === 'plan' ? 'default' : leaderMode const approvalResponse = { type: 'plan_approval_response', requestId, approved: true, timestamp: new Date().toISOString(), permissionMode: modeToInherit, } await writeToMailbox( recipientName, { from: TEAM_LEAD_NAME, text: jsonStringify(approvalResponse), timestamp: new Date().toISOString(), }, teamName, ) return { data: { success: true, message: `Plan approved for ${recipientName}. They will receive the approval and can proceed with implementation.`, request_id: requestId, }, } } async function handlePlanRejection( recipientName: string, requestId: string, feedback: string, context: ToolUseContext, ): Promise<{ data: ResponseOutput }> { const appState = context.getAppState() const teamName = appState.teamContext?.teamName if (!isTeamLead(appState.teamContext)) { throw new Error( 'Only the team lead can reject plans. Teammates cannot reject their own or other plans.', ) } const rejectionResponse = { type: 'plan_approval_response', requestId, approved: false, feedback, timestamp: new Date().toISOString(), } await writeToMailbox( recipientName, { from: TEAM_LEAD_NAME, text: jsonStringify(rejectionResponse), timestamp: new Date().toISOString(), }, teamName, ) return { data: { success: true, message: `Plan rejected for ${recipientName} with feedback: "${feedback}"`, request_id: requestId, }, } } export const SendMessageTool: Tool = buildTool({ name: SEND_MESSAGE_TOOL_NAME, searchHint: 'send messages to agent teammates (swarm protocol)', maxResultSizeChars: 100_000, userFacingName() { return 'SendMessage' }, get inputSchema(): InputSchema { return inputSchema() }, shouldDefer: true, isEnabled() { return isAgentSwarmsEnabled() }, isReadOnly(input) { return typeof input.message === 'string' }, backfillObservableInput(input) { if ('type' in input) return if (typeof input.to !== 'string') return if (input.to === '*') { input.type = 'broadcast' if (typeof input.message === 'string') input.content = input.message } else if (typeof input.message === 'string') { input.type = 'message' input.recipient = input.to input.content = input.message } else if (typeof input.message === 'object' && input.message !== null) { const msg = input.message as { type?: string request_id?: string approve?: boolean reason?: string feedback?: string } input.type = msg.type input.recipient = input.to if (msg.request_id !== undefined) input.request_id = msg.request_id if (msg.approve !== undefined) input.approve = msg.approve const content = msg.reason ?? msg.feedback if (content !== undefined) input.content = content } }, toAutoClassifierInput(input) { if (typeof input.message === 'string') { return `to ${input.to}: ${input.message}` } switch (input.message.type) { case 'shutdown_request': return `shutdown_request to ${input.to}` case 'shutdown_response': return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}` case 'plan_approval_response': return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}` } }, async checkPermissions(input, _context) { if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') { return { behavior: 'ask' as const, message: `Send a message to Remote Control session ${input.to}? It arrives as a user prompt on the receiving Claude (possibly another machine) via Anthropic's servers.`, // safetyCheck (not mode) — permissions.ts guards this before both // bypassPermissions (step 1g) and auto-mode's allowlist/classifier. // Cross-machine prompt injection must stay bypass-immune. decisionReason: { type: 'safetyCheck', reason: 'Cross-machine bridge message requires explicit user consent', classifierApprovable: false, }, } } return { behavior: 'allow' as const, updatedInput: input } }, async validateInput(input, _context) { if (input.to.trim().length === 0) { return { result: false, message: 'to must not be empty', errorCode: 9, } } const addr = parseAddress(input.to) if ( (addr.scheme === 'bridge' || addr.scheme === 'uds') && addr.target.trim().length === 0 ) { return { result: false, message: 'address target must not be empty', errorCode: 9, } } if (input.to.includes('@')) { return { result: false, message: 'to must be a bare teammate name or "*" — there is only one team per session', errorCode: 9, } } if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') { // Structured-message rejection first — it's the permanent constraint. // Showing "not connected" first would make the user reconnect only to // hit this error on retry. if (typeof input.message !== 'string') { return { result: false, message: 'structured messages cannot be sent cross-session — only plain text', errorCode: 9, } } // postInterClaudeMessage derives from= via getReplBridgeHandle() — // check handle directly for the init-timing window. Also check // isReplBridgeActive() to reject outbound-only (CCR mirror) mode // where the bridge is write-only and peer messaging is unsupported. if (!getReplBridgeHandle() || !isReplBridgeActive()) { return { result: false, message: 'Remote Control is not connected — cannot send to a bridge: target. Reconnect with /remote-control first.', errorCode: 9, } } return { result: true } } if ( feature('UDS_INBOX') && parseAddress(input.to).scheme === 'uds' && typeof input.message === 'string' ) { // UDS cross-session send: summary isn't rendered (UI.tsx returns null // for string messages), so don't require it. Structured messages fall // through to the rejection below. return { result: true } } if (typeof input.message === 'string') { if (!input.summary || input.summary.trim().length === 0) { return { result: false, message: 'summary is required when message is a string', errorCode: 9, } } return { result: true } } if (input.to === '*') { return { result: false, message: 'structured messages cannot be broadcast (to: "*")', errorCode: 9, } } if (feature('UDS_INBOX') && parseAddress(input.to).scheme !== 'other') { return { result: false, message: 'structured messages cannot be sent cross-session — only plain text', errorCode: 9, } } if ( input.message.type === 'shutdown_response' && input.to !== TEAM_LEAD_NAME ) { return { result: false, message: `shutdown_response must be sent to "${TEAM_LEAD_NAME}"`, errorCode: 9, } } if ( input.message.type === 'shutdown_response' && !input.message.approve && (!input.message.reason || input.message.reason.trim().length === 0) ) { return { result: false, message: 'reason is required when rejecting a shutdown request', errorCode: 9, } } return { result: true } }, async description() { return DESCRIPTION }, async prompt() { return getPrompt() }, mapToolResultToToolResultBlockParam(data, toolUseID) { return { tool_use_id: toolUseID, type: 'tool_result' as const, content: [ { type: 'text' as const, text: jsonStringify(data), }, ], } }, async call(input, context, canUseTool, assistantMessage) { if (feature('UDS_INBOX') && typeof input.message === 'string') { const addr = parseAddress(input.to) if (addr.scheme === 'bridge') { // Re-check handle — checkPermissions blocks on user approval (can be // minutes). validateInput's check is stale if the bridge dropped // during the prompt wait; without this, from="unknown" ships. // Also re-check isReplBridgeActive for outbound-only mode. if (!getReplBridgeHandle() || !isReplBridgeActive()) { return { data: { success: false, message: `Remote Control disconnected before send — cannot deliver to ${input.to}`, }, } } /* eslint-disable @typescript-eslint/no-require-imports */ const { postInterClaudeMessage } = require('../../bridge/peerSessions.js') as typeof import('../../bridge/peerSessions.js') /* eslint-enable @typescript-eslint/no-require-imports */ const result = await postInterClaudeMessage( addr.target, input.message, ) const preview = input.summary || truncate(input.message, 50) return { data: { success: result.ok, message: result.ok ? `“${preview}” → ${input.to}` : `Failed to send to ${input.to}: ${result.error ?? 'unknown'}`, }, } } if (addr.scheme === 'uds') { /* eslint-disable @typescript-eslint/no-require-imports */ const { sendToUdsSocket } = require('../../utils/udsClient.js') as typeof import('../../utils/udsClient.js') /* eslint-enable @typescript-eslint/no-require-imports */ try { await sendToUdsSocket(addr.target, input.message) const preview = input.summary || truncate(input.message, 50) return { data: { success: true, message: `“${preview}” → ${input.to}`, }, } } catch (e) { return { data: { success: false, message: `Failed to send to ${input.to}: ${errorMessage(e)}`, }, } } } } // Route to in-process subagent by name or raw agentId before falling // through to ambient-team resolution. Stopped agents are auto-resumed. if (typeof input.message === 'string' && input.to !== '*') { const appState = context.getAppState() const registered = appState.agentNameRegistry.get(input.to) const agentId = registered ?? toAgentId(input.to) if (agentId) { const task = appState.tasks[agentId] if (isLocalAgentTask(task) && !isMainSessionTask(task)) { if (task.status === 'running') { queuePendingMessage( agentId, input.message, context.setAppStateForTasks ?? context.setAppState, ) return { data: { success: true, message: `Message queued for delivery to ${input.to} at its next tool round.`, }, } } // task exists but stopped — auto-resume try { const result = await resumeAgentBackground({ agentId, prompt: input.message, toolUseContext: context, canUseTool, invokingRequestId: assistantMessage?.requestId, }) return { data: { success: true, message: `Agent "${input.to}" was stopped (${task.status}); resumed it in the background with your message. You'll be notified when it finishes. Output: ${result.outputFile}`, }, } } catch (e) { return { data: { success: false, message: `Agent "${input.to}" is stopped (${task.status}) and could not be resumed: ${errorMessage(e)}`, }, } } } else { // task evicted from state — try resume from disk transcript. // agentId is either a registered name or a format-matching raw ID // (toAgentId validates the createAgentId format, so teammate names // never reach this block). try { const result = await resumeAgentBackground({ agentId, prompt: input.message, toolUseContext: context, canUseTool, invokingRequestId: assistantMessage?.requestId, }) return { data: { success: true, message: `Agent "${input.to}" had no active task; resumed from transcript in the background with your message. You'll be notified when it finishes. Output: ${result.outputFile}`, }, } } catch (e) { return { data: { success: false, message: `Agent "${input.to}" is registered but has no transcript to resume. It may have been cleaned up. (${errorMessage(e)})`, }, } } } } } if (typeof input.message === 'string') { if (input.to === '*') { return handleBroadcast(input.message, input.summary, context) } return handleMessage(input.to, input.message, input.summary, context) } if (input.to === '*') { throw new Error('structured messages cannot be broadcast') } switch (input.message.type) { case 'shutdown_request': return handleShutdownRequest(input.to, input.message.reason, context) case 'shutdown_response': if (input.message.approve) { return handleShutdownApproval(input.message.request_id, context) } return handleShutdownRejection( input.message.request_id, input.message.reason!, ) case 'plan_approval_response': if (input.message.approve) { return handlePlanApproval( input.to, input.message.request_id, context, ) } return handlePlanRejection( input.to, input.message.request_id, input.message.feedback ?? 'Plan needs revision', context, ) } }, renderToolUseMessage, renderToolResultMessage, } satisfies ToolDef)