import { randomUUID } from "node:crypto" import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool" import { z } from "zod" import type { TeamModeConfig } from "../../../config/schema/team-mode" import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../../hooks/shared/prompt-async-gate" import { log } from "../../../shared/logger" import { isAmbiguousPromptDispatchFailure } from "../../../shared/prompt-failure-classifier" import { applyMemberSessionRouting, buildMemberPromptBody } from "../member-session-routing" import { buildEnvelope } from "../team-mailbox/poll" import { releaseDeliveryReservation, reserveMessageForDelivery, } from "../team-mailbox/reservation" import { BroadcastNotPermittedError, sendMessage } from "../team-mailbox/send" import { lookupTeamSession } from "../team-session-registry" import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store" import type { Message, RuntimeState } from "../types" import { MessageSchema } from "../types" const MESSAGE_TOOL_KINDS = ["message", "announcement"] as const export type LiveDeliveryClient = { session: { promptAsync(input: { path: { id: string } body: { parts: Array<{ type: "text"; text: string }> agent?: string model?: { providerID: string; modelID: string } variant?: string } query?: { directory: string } }): Promise status?: () => Promise } } type TeamRuntimeDetails = { teamRunId: string isLead: boolean senderName: string activeMembers: string[] } export type TeamSendMessageToolDeps = { loadRuntimeState: typeof loadRuntimeState } const defaultTeamSendMessageToolDeps: TeamSendMessageToolDeps = { loadRuntimeState, } const TeamReferenceArgsSchema = z.object({ path: z.string().min(1), description: z.string().optional(), }) const TeamSendMessageArgsSchema = z.object({ teamRunId: z.string().min(1), to: z.string().min(1), body: z.string(), kind: z.enum(MESSAGE_TOOL_KINDS).optional(), correlationId: z.uuid().optional(), summary: z.string().optional(), references: z.array(TeamReferenceArgsSchema).optional(), }) type DeliveryReservation = Awaited> type RuntimeMember = RuntimeState["members"][number] function canPreReserveForLiveDelivery(member: RuntimeMember, senderName: string): boolean { return member.name !== senderName && member.sessionId !== undefined && member.status === "idle" && member.pendingInjectedMessageIds.length === 0 } async function resolveTeamRuntimeDetails( teamRunId: string, sessionID: string, config: TeamModeConfig, deps: TeamSendMessageToolDeps, ): Promise { const registryEntry = lookupTeamSession(sessionID) if (registryEntry?.teamRunId === teamRunId) { const runtimeState = await deps.loadRuntimeState(teamRunId, config) return { teamRunId: runtimeState.teamRunId, isLead: registryEntry.role === "lead", senderName: registryEntry.memberName, activeMembers: runtimeState.members .map((entry) => entry.name) .filter((name) => name !== registryEntry.memberName), } } try { const runtimeState = await deps.loadRuntimeState(teamRunId, config) const isLead = runtimeState.leadSessionId === sessionID const leadMember = isLead ? runtimeState.members.find((member) => member.agentType === "leader") : undefined const member = runtimeState.members.find((entry) => entry.sessionId === sessionID) const senderName = leadMember?.name ?? member?.name ?? "unknown" return { teamRunId: runtimeState.teamRunId, isLead, senderName, activeMembers: runtimeState.members .map((entry) => entry.name) .filter((name) => name !== senderName), } } catch { return { teamRunId, isLead: false, senderName: "unknown", activeMembers: [], } } } async function releaseReservationSafely( reservation: DeliveryReservation, input: { teamRunId: string; recipient: string; messageId: string }, ): Promise { if (reservation === null) return try { await releaseDeliveryReservation(reservation) } catch (releaseError) { log("[team-mailbox] failed to release delivery reservation", { error: releaseError instanceof Error ? releaseError.message : String(releaseError), teamRunId: input.teamRunId, recipient: input.recipient, messageId: input.messageId, }) } } async function markLiveDeliveryPending( teamRunId: string, recipientName: string, messageId: string, config: TeamModeConfig, ): Promise { await transitionRuntimeState(teamRunId, (currentRuntimeState) => ({ ...currentRuntimeState, members: currentRuntimeState.members.map((member) => ( member.name === recipientName ? { ...member, pendingInjectedMessageIds: Array.from(new Set([...member.pendingInjectedMessageIds, messageId])), } : member )), }), config) } async function releaseReservationsForRecipients( teamRunId: string, recipientNames: readonly string[], messageId: string, config: TeamModeConfig, ): Promise { for (const recipientName of recipientNames) { const reservation = await reserveMessageForDelivery(teamRunId, recipientName, messageId, config) await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId, }) } } async function deliverLive( client: LiveDeliveryClient, message: Message, teamRunId: string, deliveredTo: readonly string[], config: TeamModeConfig, directory: string, deps: TeamSendMessageToolDeps, ): Promise { let runtimeState: RuntimeState try { runtimeState = await deps.loadRuntimeState(teamRunId, config) } catch (error) { await releaseReservationsForRecipients(teamRunId, deliveredTo, message.messageId, config) log("[team-mailbox] live delivery unavailable after pre-reserve, released recipients to inbox", { teamRunId, messageId: message.messageId, deliveredTo, error: error instanceof Error ? error.message : String(error), }) return } const envelope = buildEnvelope(message) for (const recipientName of deliveredTo) { // Reserve the inbox file before delivering so the transform-hook fallback // cannot re-read the same message while promptAsync is in flight. const reservation = await reserveMessageForDelivery(teamRunId, recipientName, message.messageId, config) if (reservation === null) continue const recipientMember = runtimeState.members.find((entry) => entry.name === recipientName) if (!recipientMember) { await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) continue } if (recipientMember.pendingInjectedMessageIds.length > 0) { await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) continue } if (recipientMember.status !== "idle") { log("[team-mailbox] live delivery unavailable, recipient is not idle", { reason: "recipient-not-idle", teamRunId, recipient: recipientName, status: recipientMember.status, messageId: message.messageId, }) await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) continue } const recipientSessionId = recipientMember.sessionId if (!recipientSessionId) { log("[team-mailbox] live delivery unavailable, falling back to inbox injection", { reason: "missing-session-id", teamRunId, recipient: recipientName, messageId: message.messageId, }) await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) continue } applyMemberSessionRouting(recipientSessionId, recipientMember) try { const promptResult = await dispatchInternalPrompt({ mode: "async", client, sessionID: recipientSessionId, source: "team-live-delivery", queueBehavior: "defer", input: { path: { id: recipientSessionId }, body: buildMemberPromptBody(recipientMember, envelope), query: { directory: recipientMember.worktreePath ?? directory }, }, }) if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) { try { await markLiveDeliveryPending(teamRunId, recipientName, message.messageId, config) } catch (markError) { log("[team-mailbox] live delivery prompt may be accepted but pending mark failed, keeping reservation hidden", { teamRunId, recipient: recipientName, recipientSessionId, messageId: message.messageId, error: markError instanceof Error ? markError.message : String(markError), }) continue } log("[team-mailbox] live delivery prompt failed after dispatch attempt, keeping reservation pending", { teamRunId, recipient: recipientName, recipientSessionId, messageId: message.messageId, error: promptResult.error instanceof Error ? promptResult.error.message : String(promptResult.error), }) continue } if (!isInternalPromptDispatchAccepted(promptResult)) { log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", { status: promptResult.status, teamRunId, recipient: recipientName, recipientSessionId, messageId: message.messageId, }) await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) continue } try { await markLiveDeliveryPending(teamRunId, recipientName, message.messageId, config) } catch (markError) { log("[team-mailbox] live delivery prompt dispatched but pending mark failed, keeping reservation hidden", { teamRunId, recipient: recipientName, recipientSessionId, messageId: message.messageId, error: markError instanceof Error ? markError.message : String(markError), }) continue } log("[team-mailbox] live delivery reserved until recipient idle", { teamRunId, recipient: recipientName, recipientSessionId, messageId: message.messageId, }) } catch (error) { log("[team-mailbox] live delivery failed, falling back to inbox injection", { error: error instanceof Error ? error.message : String(error), teamRunId, recipient: recipientName, messageId: message.messageId, }) await releaseReservationSafely(reservation, { teamRunId, recipient: recipientName, messageId: message.messageId, }) } } } export function createTeamSendMessageTool( config: TeamModeConfig, client: LiveDeliveryClient, deps: TeamSendMessageToolDeps = defaultTeamSendMessageToolDeps, ): ToolDefinition { return tool({ description: "Send a message to a team member or broadcast to the team.", args: { teamRunId: tool.schema.string().describe("Team run ID"), to: tool.schema.string().describe("Recipient name or * for broadcast"), body: tool.schema.string().describe("Message body"), kind: tool.schema.enum(MESSAGE_TOOL_KINDS).optional().default("message").describe("Message kind"), correlationId: tool.schema.string().optional().describe("Optional UUID correlation ID. Do not use task IDs like 'task-1'."), summary: tool.schema.string().optional().describe("Optional summary"), references: tool.schema.array(tool.schema.object({ path: tool.schema.string(), description: tool.schema.string().optional(), })).optional().describe("Optional references as [{ path, description? }]"), }, execute: async (rawArgs, context) => { const args = TeamSendMessageArgsSchema.parse(rawArgs) const runtimeContext = context as { sessionID?: string; directory?: string } const sessionID = runtimeContext.sessionID if (!sessionID) { throw new Error("session ID is required") } const targetDirectory = typeof runtimeContext.directory === "string" ? runtimeContext.directory : process.cwd() const teamRuntime = await resolveTeamRuntimeDetails(args.teamRunId, sessionID, config, deps) const message = MessageSchema.parse({ version: 1, messageId: randomUUID(), from: teamRuntime.senderName, to: args.to, body: args.body, kind: args.kind ?? "message", timestamp: Date.now(), correlationId: args.correlationId, summary: args.summary, references: args.references, }) if (message.kind === "shutdown_request" || message.kind === "shutdown_approved" || message.kind === "shutdown_rejected") { throw new Error("must use lifecycle tools for shutdown kinds") } if (message.to === "*" && !teamRuntime.isLead) { throw new BroadcastNotPermittedError() } const runtimeState = await deps.loadRuntimeState(teamRuntime.teamRunId, config) const reservedRecipients = new Set( runtimeState.members .filter((member) => canPreReserveForLiveDelivery(member, teamRuntime.senderName)) .map((member) => member.name), ) const result = await sendMessage(message, teamRuntime.teamRunId, config, { isLead: teamRuntime.isLead, activeMembers: teamRuntime.activeMembers, reservedRecipients, }) try { await deliverLive(client, message, teamRuntime.teamRunId, result.deliveredTo, config, targetDirectory, deps) } catch (liveError) { log("[team-mailbox] deliverLive top-level error (message already in inbox, safe to ignore)", { error: liveError instanceof Error ? liveError.message : String(liveError), teamRunId: teamRuntime.teamRunId, messageId: message.messageId, }) } return JSON.stringify(result) }, }) }