/ tools / SendMessageTool / SendMessageTool.ts
SendMessageTool.ts
  1  import { feature } from 'bun:bundle'
  2  import { z } from 'zod/v4'
  3  import { isReplBridgeActive } from '../../bootstrap/state.js'
  4  import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js'
  5  import type { Tool, ToolUseContext } from '../../Tool.js'
  6  import { buildTool, type ToolDef } from '../../Tool.js'
  7  import { findTeammateTaskByAgentId } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
  8  import {
  9    isLocalAgentTask,
 10    queuePendingMessage,
 11  } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
 12  import { isMainSessionTask } from '../../tasks/LocalMainSessionTask.js'
 13  import { toAgentId } from '../../types/ids.js'
 14  import { generateRequestId } from '../../utils/agentId.js'
 15  import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
 16  import { logForDebugging } from '../../utils/debug.js'
 17  import { errorMessage } from '../../utils/errors.js'
 18  import { truncate } from '../../utils/format.js'
 19  import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
 20  import { lazySchema } from '../../utils/lazySchema.js'
 21  import { parseAddress } from '../../utils/peerAddress.js'
 22  import { semanticBoolean } from '../../utils/semanticBoolean.js'
 23  import { jsonStringify } from '../../utils/slowOperations.js'
 24  import type { BackendType } from '../../utils/swarm/backends/types.js'
 25  import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js'
 26  import { readTeamFileAsync } from '../../utils/swarm/teamHelpers.js'
 27  import {
 28    getAgentId,
 29    getAgentName,
 30    getTeammateColor,
 31    getTeamName,
 32    isTeamLead,
 33    isTeammate,
 34  } from '../../utils/teammate.js'
 35  import {
 36    createShutdownApprovedMessage,
 37    createShutdownRejectedMessage,
 38    createShutdownRequestMessage,
 39    writeToMailbox,
 40  } from '../../utils/teammateMailbox.js'
 41  import { resumeAgentBackground } from '../AgentTool/resumeAgent.js'
 42  import { SEND_MESSAGE_TOOL_NAME } from './constants.js'
 43  import { DESCRIPTION, getPrompt } from './prompt.js'
 44  import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
 45  
 46  const StructuredMessage = lazySchema(() =>
 47    z.discriminatedUnion('type', [
 48      z.object({
 49        type: z.literal('shutdown_request'),
 50        reason: z.string().optional(),
 51      }),
 52      z.object({
 53        type: z.literal('shutdown_response'),
 54        request_id: z.string(),
 55        approve: semanticBoolean(),
 56        reason: z.string().optional(),
 57      }),
 58      z.object({
 59        type: z.literal('plan_approval_response'),
 60        request_id: z.string(),
 61        approve: semanticBoolean(),
 62        feedback: z.string().optional(),
 63      }),
 64    ]),
 65  )
 66  
 67  const inputSchema = lazySchema(() =>
 68    z.object({
 69      to: z
 70        .string()
 71        .describe(
 72          feature('UDS_INBOX')
 73            ? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer (use ListPeers to discover)'
 74            : 'Recipient: teammate name, or "*" for broadcast to all teammates',
 75        ),
 76      summary: z
 77        .string()
 78        .optional()
 79        .describe(
 80          'A 5-10 word summary shown as a preview in the UI (required when message is a string)',
 81        ),
 82      message: z.union([
 83        z.string().describe('Plain text message content'),
 84        StructuredMessage(),
 85      ]),
 86    }),
 87  )
 88  type InputSchema = ReturnType<typeof inputSchema>
 89  
 90  export type Input = z.infer<InputSchema>
 91  
 92  export type MessageRouting = {
 93    sender: string
 94    senderColor?: string
 95    target: string
 96    targetColor?: string
 97    summary?: string
 98    content?: string
 99  }
100  
101  export type MessageOutput = {
102    success: boolean
103    message: string
104    routing?: MessageRouting
105  }
106  
107  export type BroadcastOutput = {
108    success: boolean
109    message: string
110    recipients: string[]
111    routing?: MessageRouting
112  }
113  
114  export type RequestOutput = {
115    success: boolean
116    message: string
117    request_id: string
118    target: string
119  }
120  
121  export type ResponseOutput = {
122    success: boolean
123    message: string
124    request_id?: string
125  }
126  
127  export type SendMessageToolOutput =
128    | MessageOutput
129    | BroadcastOutput
130    | RequestOutput
131    | ResponseOutput
132  
133  function findTeammateColor(
134    appState: {
135      teamContext?: { teammates: { [id: string]: { color?: string } } }
136    },
137    name: string,
138  ): string | undefined {
139    const teammates = appState.teamContext?.teammates
140    if (!teammates) return undefined
141    for (const teammate of Object.values(teammates)) {
142      if ('name' in teammate && (teammate as { name: string }).name === name) {
143        return teammate.color
144      }
145    }
146    return undefined
147  }
148  
149  async function handleMessage(
150    recipientName: string,
151    content: string,
152    summary: string | undefined,
153    context: ToolUseContext,
154  ): Promise<{ data: MessageOutput }> {
155    const appState = context.getAppState()
156    const teamName = getTeamName(appState.teamContext)
157    const senderName =
158      getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME)
159    const senderColor = getTeammateColor()
160  
161    await writeToMailbox(
162      recipientName,
163      {
164        from: senderName,
165        text: content,
166        summary,
167        timestamp: new Date().toISOString(),
168        color: senderColor,
169      },
170      teamName,
171    )
172  
173    const recipientColor = findTeammateColor(appState, recipientName)
174  
175    return {
176      data: {
177        success: true,
178        message: `Message sent to ${recipientName}'s inbox`,
179        routing: {
180          sender: senderName,
181          senderColor,
182          target: `@${recipientName}`,
183          targetColor: recipientColor,
184          summary,
185          content,
186        },
187      },
188    }
189  }
190  
191  async function handleBroadcast(
192    content: string,
193    summary: string | undefined,
194    context: ToolUseContext,
195  ): Promise<{ data: BroadcastOutput }> {
196    const appState = context.getAppState()
197    const teamName = getTeamName(appState.teamContext)
198  
199    if (!teamName) {
200      throw new Error(
201        'Not in a team context. Create a team with Teammate spawnTeam first, or set CLAUDE_CODE_TEAM_NAME.',
202      )
203    }
204  
205    const teamFile = await readTeamFileAsync(teamName)
206    if (!teamFile) {
207      throw new Error(`Team "${teamName}" does not exist`)
208    }
209  
210    const senderName =
211      getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME)
212    if (!senderName) {
213      throw new Error(
214        'Cannot broadcast: sender name is required. Set CLAUDE_CODE_AGENT_NAME.',
215      )
216    }
217  
218    const senderColor = getTeammateColor()
219  
220    const recipients: string[] = []
221    for (const member of teamFile.members) {
222      if (member.name.toLowerCase() === senderName.toLowerCase()) {
223        continue
224      }
225      recipients.push(member.name)
226    }
227  
228    if (recipients.length === 0) {
229      return {
230        data: {
231          success: true,
232          message: 'No teammates to broadcast to (you are the only team member)',
233          recipients: [],
234        },
235      }
236    }
237  
238    for (const recipientName of recipients) {
239      await writeToMailbox(
240        recipientName,
241        {
242          from: senderName,
243          text: content,
244          summary,
245          timestamp: new Date().toISOString(),
246          color: senderColor,
247        },
248        teamName,
249      )
250    }
251  
252    return {
253      data: {
254        success: true,
255        message: `Message broadcast to ${recipients.length} teammate(s): ${recipients.join(', ')}`,
256        recipients,
257        routing: {
258          sender: senderName,
259          senderColor,
260          target: '@team',
261          summary,
262          content,
263        },
264      },
265    }
266  }
267  
268  async function handleShutdownRequest(
269    targetName: string,
270    reason: string | undefined,
271    context: ToolUseContext,
272  ): Promise<{ data: RequestOutput }> {
273    const appState = context.getAppState()
274    const teamName = getTeamName(appState.teamContext)
275    const senderName = getAgentName() || TEAM_LEAD_NAME
276    const requestId = generateRequestId('shutdown', targetName)
277  
278    const shutdownMessage = createShutdownRequestMessage({
279      requestId,
280      from: senderName,
281      reason,
282    })
283  
284    await writeToMailbox(
285      targetName,
286      {
287        from: senderName,
288        text: jsonStringify(shutdownMessage),
289        timestamp: new Date().toISOString(),
290        color: getTeammateColor(),
291      },
292      teamName,
293    )
294  
295    return {
296      data: {
297        success: true,
298        message: `Shutdown request sent to ${targetName}. Request ID: ${requestId}`,
299        request_id: requestId,
300        target: targetName,
301      },
302    }
303  }
304  
305  async function handleShutdownApproval(
306    requestId: string,
307    context: ToolUseContext,
308  ): Promise<{ data: ResponseOutput }> {
309    const teamName = getTeamName()
310    const agentId = getAgentId()
311    const agentName = getAgentName() || 'teammate'
312  
313    logForDebugging(
314      `[SendMessageTool] handleShutdownApproval: teamName=${teamName}, agentId=${agentId}, agentName=${agentName}`,
315    )
316  
317    let ownPaneId: string | undefined
318    let ownBackendType: BackendType | undefined
319    if (teamName) {
320      const teamFile = await readTeamFileAsync(teamName)
321      if (teamFile && agentId) {
322        const selfMember = teamFile.members.find(m => m.agentId === agentId)
323        if (selfMember) {
324          ownPaneId = selfMember.tmuxPaneId
325          ownBackendType = selfMember.backendType
326        }
327      }
328    }
329  
330    const approvedMessage = createShutdownApprovedMessage({
331      requestId,
332      from: agentName,
333      paneId: ownPaneId,
334      backendType: ownBackendType,
335    })
336  
337    await writeToMailbox(
338      TEAM_LEAD_NAME,
339      {
340        from: agentName,
341        text: jsonStringify(approvedMessage),
342        timestamp: new Date().toISOString(),
343        color: getTeammateColor(),
344      },
345      teamName,
346    )
347  
348    if (ownBackendType === 'in-process') {
349      logForDebugging(
350        `[SendMessageTool] In-process teammate ${agentName} approving shutdown - signaling abort`,
351      )
352  
353      if (agentId) {
354        const appState = context.getAppState()
355        const task = findTeammateTaskByAgentId(agentId, appState.tasks)
356        if (task?.abortController) {
357          task.abortController.abort()
358          logForDebugging(
359            `[SendMessageTool] Aborted controller for in-process teammate ${agentName}`,
360          )
361        } else {
362          logForDebugging(
363            `[SendMessageTool] Warning: Could not find task/abortController for ${agentName}`,
364          )
365        }
366      }
367    } else {
368      if (agentId) {
369        const appState = context.getAppState()
370        const task = findTeammateTaskByAgentId(agentId, appState.tasks)
371        if (task?.abortController) {
372          logForDebugging(
373            `[SendMessageTool] Fallback: Found in-process task for ${agentName} via AppState, aborting`,
374          )
375          task.abortController.abort()
376  
377          return {
378            data: {
379              success: true,
380              message: `Shutdown approved (fallback path). Agent ${agentName} is now exiting.`,
381              request_id: requestId,
382            },
383          }
384        }
385      }
386  
387      setImmediate(async () => {
388        await gracefulShutdown(0, 'other')
389      })
390    }
391  
392    return {
393      data: {
394        success: true,
395        message: `Shutdown approved. Sent confirmation to team-lead. Agent ${agentName} is now exiting.`,
396        request_id: requestId,
397      },
398    }
399  }
400  
401  async function handleShutdownRejection(
402    requestId: string,
403    reason: string,
404  ): Promise<{ data: ResponseOutput }> {
405    const teamName = getTeamName()
406    const agentName = getAgentName() || 'teammate'
407  
408    const rejectedMessage = createShutdownRejectedMessage({
409      requestId,
410      from: agentName,
411      reason,
412    })
413  
414    await writeToMailbox(
415      TEAM_LEAD_NAME,
416      {
417        from: agentName,
418        text: jsonStringify(rejectedMessage),
419        timestamp: new Date().toISOString(),
420        color: getTeammateColor(),
421      },
422      teamName,
423    )
424  
425    return {
426      data: {
427        success: true,
428        message: `Shutdown rejected. Reason: "${reason}". Continuing to work.`,
429        request_id: requestId,
430      },
431    }
432  }
433  
434  async function handlePlanApproval(
435    recipientName: string,
436    requestId: string,
437    context: ToolUseContext,
438  ): Promise<{ data: ResponseOutput }> {
439    const appState = context.getAppState()
440    const teamName = appState.teamContext?.teamName
441  
442    if (!isTeamLead(appState.teamContext)) {
443      throw new Error(
444        'Only the team lead can approve plans. Teammates cannot approve their own or other plans.',
445      )
446    }
447  
448    const leaderMode = appState.toolPermissionContext.mode
449    const modeToInherit = leaderMode === 'plan' ? 'default' : leaderMode
450  
451    const approvalResponse = {
452      type: 'plan_approval_response',
453      requestId,
454      approved: true,
455      timestamp: new Date().toISOString(),
456      permissionMode: modeToInherit,
457    }
458  
459    await writeToMailbox(
460      recipientName,
461      {
462        from: TEAM_LEAD_NAME,
463        text: jsonStringify(approvalResponse),
464        timestamp: new Date().toISOString(),
465      },
466      teamName,
467    )
468  
469    return {
470      data: {
471        success: true,
472        message: `Plan approved for ${recipientName}. They will receive the approval and can proceed with implementation.`,
473        request_id: requestId,
474      },
475    }
476  }
477  
478  async function handlePlanRejection(
479    recipientName: string,
480    requestId: string,
481    feedback: string,
482    context: ToolUseContext,
483  ): Promise<{ data: ResponseOutput }> {
484    const appState = context.getAppState()
485    const teamName = appState.teamContext?.teamName
486  
487    if (!isTeamLead(appState.teamContext)) {
488      throw new Error(
489        'Only the team lead can reject plans. Teammates cannot reject their own or other plans.',
490      )
491    }
492  
493    const rejectionResponse = {
494      type: 'plan_approval_response',
495      requestId,
496      approved: false,
497      feedback,
498      timestamp: new Date().toISOString(),
499    }
500  
501    await writeToMailbox(
502      recipientName,
503      {
504        from: TEAM_LEAD_NAME,
505        text: jsonStringify(rejectionResponse),
506        timestamp: new Date().toISOString(),
507      },
508      teamName,
509    )
510  
511    return {
512      data: {
513        success: true,
514        message: `Plan rejected for ${recipientName} with feedback: "${feedback}"`,
515        request_id: requestId,
516      },
517    }
518  }
519  
520  export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
521    buildTool({
522      name: SEND_MESSAGE_TOOL_NAME,
523      searchHint: 'send messages to agent teammates (swarm protocol)',
524      maxResultSizeChars: 100_000,
525  
526      userFacingName() {
527        return 'SendMessage'
528      },
529  
530      get inputSchema(): InputSchema {
531        return inputSchema()
532      },
533      shouldDefer: true,
534  
535      isEnabled() {
536        return isAgentSwarmsEnabled()
537      },
538  
539      isReadOnly(input) {
540        return typeof input.message === 'string'
541      },
542  
543      backfillObservableInput(input) {
544        if ('type' in input) return
545        if (typeof input.to !== 'string') return
546  
547        if (input.to === '*') {
548          input.type = 'broadcast'
549          if (typeof input.message === 'string') input.content = input.message
550        } else if (typeof input.message === 'string') {
551          input.type = 'message'
552          input.recipient = input.to
553          input.content = input.message
554        } else if (typeof input.message === 'object' && input.message !== null) {
555          const msg = input.message as {
556            type?: string
557            request_id?: string
558            approve?: boolean
559            reason?: string
560            feedback?: string
561          }
562          input.type = msg.type
563          input.recipient = input.to
564          if (msg.request_id !== undefined) input.request_id = msg.request_id
565          if (msg.approve !== undefined) input.approve = msg.approve
566          const content = msg.reason ?? msg.feedback
567          if (content !== undefined) input.content = content
568        }
569      },
570  
571      toAutoClassifierInput(input) {
572        if (typeof input.message === 'string') {
573          return `to ${input.to}: ${input.message}`
574        }
575        switch (input.message.type) {
576          case 'shutdown_request':
577            return `shutdown_request to ${input.to}`
578          case 'shutdown_response':
579            return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
580          case 'plan_approval_response':
581            return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
582        }
583      },
584  
585      async checkPermissions(input, _context) {
586        if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') {
587          return {
588            behavior: 'ask' as const,
589            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.`,
590            // safetyCheck (not mode) — permissions.ts guards this before both
591            // bypassPermissions (step 1g) and auto-mode's allowlist/classifier.
592            // Cross-machine prompt injection must stay bypass-immune.
593            decisionReason: {
594              type: 'safetyCheck',
595              reason:
596                'Cross-machine bridge message requires explicit user consent',
597              classifierApprovable: false,
598            },
599          }
600        }
601        return { behavior: 'allow' as const, updatedInput: input }
602      },
603  
604      async validateInput(input, _context) {
605        if (input.to.trim().length === 0) {
606          return {
607            result: false,
608            message: 'to must not be empty',
609            errorCode: 9,
610          }
611        }
612        const addr = parseAddress(input.to)
613        if (
614          (addr.scheme === 'bridge' || addr.scheme === 'uds') &&
615          addr.target.trim().length === 0
616        ) {
617          return {
618            result: false,
619            message: 'address target must not be empty',
620            errorCode: 9,
621          }
622        }
623        if (input.to.includes('@')) {
624          return {
625            result: false,
626            message:
627              'to must be a bare teammate name or "*" — there is only one team per session',
628            errorCode: 9,
629          }
630        }
631        if (feature('UDS_INBOX') && parseAddress(input.to).scheme === 'bridge') {
632          // Structured-message rejection first — it's the permanent constraint.
633          // Showing "not connected" first would make the user reconnect only to
634          // hit this error on retry.
635          if (typeof input.message !== 'string') {
636            return {
637              result: false,
638              message:
639                'structured messages cannot be sent cross-session — only plain text',
640              errorCode: 9,
641            }
642          }
643          // postInterClaudeMessage derives from= via getReplBridgeHandle() —
644          // check handle directly for the init-timing window. Also check
645          // isReplBridgeActive() to reject outbound-only (CCR mirror) mode
646          // where the bridge is write-only and peer messaging is unsupported.
647          if (!getReplBridgeHandle() || !isReplBridgeActive()) {
648            return {
649              result: false,
650              message:
651                'Remote Control is not connected — cannot send to a bridge: target. Reconnect with /remote-control first.',
652              errorCode: 9,
653            }
654          }
655          return { result: true }
656        }
657        if (
658          feature('UDS_INBOX') &&
659          parseAddress(input.to).scheme === 'uds' &&
660          typeof input.message === 'string'
661        ) {
662          // UDS cross-session send: summary isn't rendered (UI.tsx returns null
663          // for string messages), so don't require it. Structured messages fall
664          // through to the rejection below.
665          return { result: true }
666        }
667        if (typeof input.message === 'string') {
668          if (!input.summary || input.summary.trim().length === 0) {
669            return {
670              result: false,
671              message: 'summary is required when message is a string',
672              errorCode: 9,
673            }
674          }
675          return { result: true }
676        }
677  
678        if (input.to === '*') {
679          return {
680            result: false,
681            message: 'structured messages cannot be broadcast (to: "*")',
682            errorCode: 9,
683          }
684        }
685        if (feature('UDS_INBOX') && parseAddress(input.to).scheme !== 'other') {
686          return {
687            result: false,
688            message:
689              'structured messages cannot be sent cross-session — only plain text',
690            errorCode: 9,
691          }
692        }
693  
694        if (
695          input.message.type === 'shutdown_response' &&
696          input.to !== TEAM_LEAD_NAME
697        ) {
698          return {
699            result: false,
700            message: `shutdown_response must be sent to "${TEAM_LEAD_NAME}"`,
701            errorCode: 9,
702          }
703        }
704  
705        if (
706          input.message.type === 'shutdown_response' &&
707          !input.message.approve &&
708          (!input.message.reason || input.message.reason.trim().length === 0)
709        ) {
710          return {
711            result: false,
712            message: 'reason is required when rejecting a shutdown request',
713            errorCode: 9,
714          }
715        }
716  
717        return { result: true }
718      },
719  
720      async description() {
721        return DESCRIPTION
722      },
723  
724      async prompt() {
725        return getPrompt()
726      },
727  
728      mapToolResultToToolResultBlockParam(data, toolUseID) {
729        return {
730          tool_use_id: toolUseID,
731          type: 'tool_result' as const,
732          content: [
733            {
734              type: 'text' as const,
735              text: jsonStringify(data),
736            },
737          ],
738        }
739      },
740  
741      async call(input, context, canUseTool, assistantMessage) {
742        if (feature('UDS_INBOX') && typeof input.message === 'string') {
743          const addr = parseAddress(input.to)
744          if (addr.scheme === 'bridge') {
745            // Re-check handle — checkPermissions blocks on user approval (can be
746            // minutes). validateInput's check is stale if the bridge dropped
747            // during the prompt wait; without this, from="unknown" ships.
748            // Also re-check isReplBridgeActive for outbound-only mode.
749            if (!getReplBridgeHandle() || !isReplBridgeActive()) {
750              return {
751                data: {
752                  success: false,
753                  message: `Remote Control disconnected before send — cannot deliver to ${input.to}`,
754                },
755              }
756            }
757            /* eslint-disable @typescript-eslint/no-require-imports */
758            const { postInterClaudeMessage } =
759              require('../../bridge/peerSessions.js') as typeof import('../../bridge/peerSessions.js')
760            /* eslint-enable @typescript-eslint/no-require-imports */
761            const result = await postInterClaudeMessage(
762              addr.target,
763              input.message,
764            )
765            const preview = input.summary || truncate(input.message, 50)
766            return {
767              data: {
768                success: result.ok,
769                message: result.ok
770                  ? `“${preview}” → ${input.to}`
771                  : `Failed to send to ${input.to}: ${result.error ?? 'unknown'}`,
772              },
773            }
774          }
775          if (addr.scheme === 'uds') {
776            /* eslint-disable @typescript-eslint/no-require-imports */
777            const { sendToUdsSocket } =
778              require('../../utils/udsClient.js') as typeof import('../../utils/udsClient.js')
779            /* eslint-enable @typescript-eslint/no-require-imports */
780            try {
781              await sendToUdsSocket(addr.target, input.message)
782              const preview = input.summary || truncate(input.message, 50)
783              return {
784                data: {
785                  success: true,
786                  message: `“${preview}” → ${input.to}`,
787                },
788              }
789            } catch (e) {
790              return {
791                data: {
792                  success: false,
793                  message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
794                },
795              }
796            }
797          }
798        }
799  
800        // Route to in-process subagent by name or raw agentId before falling
801        // through to ambient-team resolution. Stopped agents are auto-resumed.
802        if (typeof input.message === 'string' && input.to !== '*') {
803          const appState = context.getAppState()
804          const registered = appState.agentNameRegistry.get(input.to)
805          const agentId = registered ?? toAgentId(input.to)
806          if (agentId) {
807            const task = appState.tasks[agentId]
808            if (isLocalAgentTask(task) && !isMainSessionTask(task)) {
809              if (task.status === 'running') {
810                queuePendingMessage(
811                  agentId,
812                  input.message,
813                  context.setAppStateForTasks ?? context.setAppState,
814                )
815                return {
816                  data: {
817                    success: true,
818                    message: `Message queued for delivery to ${input.to} at its next tool round.`,
819                  },
820                }
821              }
822              // task exists but stopped — auto-resume
823              try {
824                const result = await resumeAgentBackground({
825                  agentId,
826                  prompt: input.message,
827                  toolUseContext: context,
828                  canUseTool,
829                  invokingRequestId: assistantMessage?.requestId,
830                })
831                return {
832                  data: {
833                    success: true,
834                    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}`,
835                  },
836                }
837              } catch (e) {
838                return {
839                  data: {
840                    success: false,
841                    message: `Agent "${input.to}" is stopped (${task.status}) and could not be resumed: ${errorMessage(e)}`,
842                  },
843                }
844              }
845            } else {
846              // task evicted from state — try resume from disk transcript.
847              // agentId is either a registered name or a format-matching raw ID
848              // (toAgentId validates the createAgentId format, so teammate names
849              // never reach this block).
850              try {
851                const result = await resumeAgentBackground({
852                  agentId,
853                  prompt: input.message,
854                  toolUseContext: context,
855                  canUseTool,
856                  invokingRequestId: assistantMessage?.requestId,
857                })
858                return {
859                  data: {
860                    success: true,
861                    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}`,
862                  },
863                }
864              } catch (e) {
865                return {
866                  data: {
867                    success: false,
868                    message: `Agent "${input.to}" is registered but has no transcript to resume. It may have been cleaned up. (${errorMessage(e)})`,
869                  },
870                }
871              }
872            }
873          }
874        }
875  
876        if (typeof input.message === 'string') {
877          if (input.to === '*') {
878            return handleBroadcast(input.message, input.summary, context)
879          }
880          return handleMessage(input.to, input.message, input.summary, context)
881        }
882  
883        if (input.to === '*') {
884          throw new Error('structured messages cannot be broadcast')
885        }
886  
887        switch (input.message.type) {
888          case 'shutdown_request':
889            return handleShutdownRequest(input.to, input.message.reason, context)
890          case 'shutdown_response':
891            if (input.message.approve) {
892              return handleShutdownApproval(input.message.request_id, context)
893            }
894            return handleShutdownRejection(
895              input.message.request_id,
896              input.message.reason!,
897            )
898          case 'plan_approval_response':
899            if (input.message.approve) {
900              return handlePlanApproval(
901                input.to,
902                input.message.request_id,
903                context,
904              )
905            }
906            return handlePlanRejection(
907              input.to,
908              input.message.request_id,
909              input.message.feedback ?? 'Plan needs revision',
910              context,
911            )
912        }
913      },
914  
915      renderToolUseMessage,
916      renderToolResultMessage,
917    } satisfies ToolDef<InputSchema, SendMessageToolOutput>)