import { randomUUID } from 'crypto' import { useCallback, useEffect, useRef } from 'react' import { useInterval } from 'usehooks-ts' import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js' import { useTerminalNotification } from '../ink/useTerminalNotification.js' import { sendNotification } from '../services/notifier.js' import { type AppState, useAppState, useAppStateStore, useSetAppState, } from '../state/AppState.js' import { findToolByName } from '../Tool.js' import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' import { getAllBaseTools } from '../tools.js' import type { PermissionUpdate } from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' import { findInProcessTeammateTaskId, handlePlanApprovalResponse, } from '../utils/inProcessTeammateHelpers.js' import { createAssistantMessage } from '../utils/messages.js' import { permissionModeFromString, toExternalPermissionMode, } from '../utils/permissions/PermissionMode.js' import { applyPermissionUpdate } from '../utils/permissions/PermissionUpdate.js' import { jsonStringify } from '../utils/slowOperations.js' import { isInsideTmux } from '../utils/swarm/backends/detection.js' import { ensureBackendsRegistered, getBackendByType, } from '../utils/swarm/backends/registry.js' import type { PaneBackendType } from '../utils/swarm/backends/types.js' import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' import { sendPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js' import { removeTeammateFromTeamFile, setMemberMode, } from '../utils/swarm/teamHelpers.js' import { unassignTeammateTasks } from '../utils/tasks.js' import { getAgentName, isPlanModeRequired, isTeamLead, isTeammate, } from '../utils/teammate.js' import { isInProcessTeammate } from '../utils/teammateContext.js' import { isModeSetRequest, isPermissionRequest, isPermissionResponse, isPlanApprovalRequest, isPlanApprovalResponse, isSandboxPermissionRequest, isSandboxPermissionResponse, isShutdownApproved, isShutdownRequest, isTeamPermissionUpdate, markMessagesAsRead, readUnreadMessages, type TeammateMessage, writeToMailbox, } from '../utils/teammateMailbox.js' import { hasPermissionCallback, hasSandboxPermissionCallback, processMailboxPermissionResponse, processSandboxPermissionResponse, } from './useSwarmPermissionPoller.js' /** * Get the agent name to poll for messages. * - In-process teammates return undefined (they use waitForNextPromptOrShutdown instead) * - Process-based teammates use their CLAUDE_CODE_AGENT_NAME * - Team leads use their name from teamContext.teammates * - Standalone sessions return undefined */ function getAgentNameToPoll(appState: AppState): string | undefined { // In-process teammates should NOT use useInboxPoller - they have their own // polling mechanism via waitForNextPromptOrShutdown() in inProcessRunner.ts. // Using useInboxPoller would cause message routing issues since in-process // teammates share the same React context and AppState with the leader. // // Note: This can be called when the leader's REPL re-renders while an // in-process teammate's AsyncLocalStorage context is active (due to shared // setAppState). We return undefined to gracefully skip polling rather than // throwing, since this is a normal occurrence during concurrent execution. if (isInProcessTeammate()) { return undefined } if (isTeammate()) { return getAgentName() } // Team lead polls using their agent name (not ID) if (isTeamLead(appState.teamContext)) { const leadAgentId = appState.teamContext!.leadAgentId // Look up the lead's name from teammates map const leadName = appState.teamContext!.teammates[leadAgentId]?.name return leadName || 'team-lead' } return undefined } const INBOX_POLL_INTERVAL_MS = 1000 type Props = { enabled: boolean isLoading: boolean focusedInputDialog: string | undefined // Returns true if submission succeeded, false if rejected (e.g., query already running) // Dead code elimination: parameter named onSubmitMessage to avoid "teammate" string in external builds onSubmitMessage: (formatted: string) => boolean } /** * Polls the teammate inbox for new messages and submits them as turns. * * This hook: * 1. Polls every 1s for unread messages (teammates or team leads) * 2. When idle: submits messages immediately as a new turn * 3. When busy: queues messages in AppState.inbox for UI display, delivers when turn ends */ export function useInboxPoller({ enabled, isLoading, focusedInputDialog, onSubmitMessage, }: Props): void { // Assign to original name for clarity within the function const onSubmitTeammateMessage = onSubmitMessage const store = useAppStateStore() const setAppState = useSetAppState() const inboxMessageCount = useAppState(s => s.inbox.messages.length) const terminal = useTerminalNotification() const poll = useCallback(async () => { if (!enabled) return // Use ref to avoid dependency on appState object (prevents infinite loop) const currentAppState = store.getState() const agentName = getAgentNameToPoll(currentAppState) if (!agentName) return const unread = await readUnreadMessages( agentName, currentAppState.teamContext?.teamName, ) if (unread.length === 0) return logForDebugging(`[InboxPoller] Found ${unread.length} unread message(s)`) // Check for plan approval responses and transition out of plan mode if approved // Security: Only accept approval responses from the team lead if (isTeammate() && isPlanModeRequired()) { for (const msg of unread) { const approvalResponse = isPlanApprovalResponse(msg.text) // Verify the message is from the team lead to prevent teammates from forging approvals if (approvalResponse && msg.from === 'team-lead') { logForDebugging( `[InboxPoller] Received plan approval response from team-lead: approved=${approvalResponse.approved}`, ) if (approvalResponse.approved) { // Use leader's permission mode if provided, otherwise default const targetMode = approvalResponse.permissionMode ?? 'default' // Transition out of plan mode setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdate( prev.toolPermissionContext, { type: 'setMode', mode: toExternalPermissionMode(targetMode), destination: 'session', }, ), })) logForDebugging( `[InboxPoller] Plan approved by team lead, exited plan mode to ${targetMode}`, ) } else { logForDebugging( `[InboxPoller] Plan rejected by team lead: ${approvalResponse.feedback || 'No feedback provided'}`, ) } } else if (approvalResponse) { logForDebugging( `[InboxPoller] Ignoring plan approval response from non-team-lead: ${msg.from}`, ) } } } // Helper to mark messages as read in the inbox file. // Called after messages are successfully delivered or reliably queued. const markRead = () => { void markMessagesAsRead(agentName, currentAppState.teamContext?.teamName) } // Separate permission messages from regular teammate messages const permissionRequests: TeammateMessage[] = [] const permissionResponses: TeammateMessage[] = [] const sandboxPermissionRequests: TeammateMessage[] = [] const sandboxPermissionResponses: TeammateMessage[] = [] const shutdownRequests: TeammateMessage[] = [] const shutdownApprovals: TeammateMessage[] = [] const teamPermissionUpdates: TeammateMessage[] = [] const modeSetRequests: TeammateMessage[] = [] const planApprovalRequests: TeammateMessage[] = [] const regularMessages: TeammateMessage[] = [] for (const m of unread) { const permReq = isPermissionRequest(m.text) const permResp = isPermissionResponse(m.text) const sandboxReq = isSandboxPermissionRequest(m.text) const sandboxResp = isSandboxPermissionResponse(m.text) const shutdownReq = isShutdownRequest(m.text) const shutdownApproval = isShutdownApproved(m.text) const teamPermUpdate = isTeamPermissionUpdate(m.text) const modeSetReq = isModeSetRequest(m.text) const planApprovalReq = isPlanApprovalRequest(m.text) if (permReq) { permissionRequests.push(m) } else if (permResp) { permissionResponses.push(m) } else if (sandboxReq) { sandboxPermissionRequests.push(m) } else if (sandboxResp) { sandboxPermissionResponses.push(m) } else if (shutdownReq) { shutdownRequests.push(m) } else if (shutdownApproval) { shutdownApprovals.push(m) } else if (teamPermUpdate) { teamPermissionUpdates.push(m) } else if (modeSetReq) { modeSetRequests.push(m) } else if (planApprovalReq) { planApprovalRequests.push(m) } else { regularMessages.push(m) } } // Handle permission requests (leader side) - route to ToolUseConfirmQueue if ( permissionRequests.length > 0 && isTeamLead(currentAppState.teamContext) ) { logForDebugging( `[InboxPoller] Found ${permissionRequests.length} permission request(s)`, ) const setToolUseConfirmQueue = getLeaderToolUseConfirmQueue() const teamName = currentAppState.teamContext?.teamName for (const m of permissionRequests) { const parsed = isPermissionRequest(m.text) if (!parsed) continue if (setToolUseConfirmQueue) { // Route through the standard ToolUseConfirmQueue so tmux workers // get the same tool-specific UI (BashPermissionRequest, FileEditToolDiff, etc.) // as in-process teammates. const tool = findToolByName(getAllBaseTools(), parsed.tool_name) if (!tool) { logForDebugging( `[InboxPoller] Unknown tool ${parsed.tool_name}, skipping permission request`, ) continue } const entry: ToolUseConfirm = { assistantMessage: createAssistantMessage({ content: '' }), tool, description: parsed.description, input: parsed.input, toolUseContext: {} as ToolUseConfirm['toolUseContext'], toolUseID: parsed.tool_use_id, permissionResult: { behavior: 'ask', message: parsed.description, }, permissionPromptStartTimeMs: Date.now(), workerBadge: { name: parsed.agent_id, color: 'cyan', }, onUserInteraction() { // No-op for tmux workers (no classifier auto-approval) }, onAbort() { void sendPermissionResponseViaMailbox( parsed.agent_id, { decision: 'rejected', resolvedBy: 'leader' }, parsed.request_id, teamName, ) }, onAllow( updatedInput: Record, permissionUpdates: PermissionUpdate[], ) { void sendPermissionResponseViaMailbox( parsed.agent_id, { decision: 'approved', resolvedBy: 'leader', updatedInput, permissionUpdates, }, parsed.request_id, teamName, ) }, onReject(feedback?: string) { void sendPermissionResponseViaMailbox( parsed.agent_id, { decision: 'rejected', resolvedBy: 'leader', feedback, }, parsed.request_id, teamName, ) }, async recheckPermission() { // No-op for tmux workers — permission state is on the worker side }, } // Deduplicate: if markMessagesAsRead failed on a prior poll, // the same message will be re-read — skip if already queued. setToolUseConfirmQueue(queue => { if (queue.some(q => q.toolUseID === parsed.tool_use_id)) { return queue } return [...queue, entry] }) } else { logForDebugging( `[InboxPoller] ToolUseConfirmQueue unavailable, dropping permission request from ${parsed.agent_id}`, ) } } // Send desktop notification for the first request const firstParsed = isPermissionRequest(permissionRequests[0]?.text ?? '') if (firstParsed && !isLoading && !focusedInputDialog) { void sendNotification( { message: `${firstParsed.agent_id} needs permission for ${firstParsed.tool_name}`, notificationType: 'worker_permission_prompt', }, terminal, ) } } // Handle permission responses (worker side) - invoke registered callbacks if (permissionResponses.length > 0 && isTeammate()) { logForDebugging( `[InboxPoller] Found ${permissionResponses.length} permission response(s)`, ) for (const m of permissionResponses) { const parsed = isPermissionResponse(m.text) if (!parsed) continue if (hasPermissionCallback(parsed.request_id)) { logForDebugging( `[InboxPoller] Processing permission response for ${parsed.request_id}: ${parsed.subtype}`, ) if (parsed.subtype === 'success') { processMailboxPermissionResponse({ requestId: parsed.request_id, decision: 'approved', updatedInput: parsed.response?.updated_input, permissionUpdates: parsed.response?.permission_updates, }) } else { processMailboxPermissionResponse({ requestId: parsed.request_id, decision: 'rejected', feedback: parsed.error, }) } } } } // Handle sandbox permission requests (leader side) - add to workerSandboxPermissions queue if ( sandboxPermissionRequests.length > 0 && isTeamLead(currentAppState.teamContext) ) { logForDebugging( `[InboxPoller] Found ${sandboxPermissionRequests.length} sandbox permission request(s)`, ) const newSandboxRequests: Array<{ requestId: string workerId: string workerName: string workerColor?: string host: string createdAt: number }> = [] for (const m of sandboxPermissionRequests) { const parsed = isSandboxPermissionRequest(m.text) if (!parsed) continue // Validate required nested fields to prevent crashes from malformed messages if (!parsed.hostPattern?.host) { logForDebugging( `[InboxPoller] Invalid sandbox permission request: missing hostPattern.host`, ) continue } newSandboxRequests.push({ requestId: parsed.requestId, workerId: parsed.workerId, workerName: parsed.workerName, workerColor: parsed.workerColor, host: parsed.hostPattern.host, createdAt: parsed.createdAt, }) } if (newSandboxRequests.length > 0) { setAppState(prev => ({ ...prev, workerSandboxPermissions: { ...prev.workerSandboxPermissions, queue: [ ...prev.workerSandboxPermissions.queue, ...newSandboxRequests, ], }, })) // Send desktop notification for the first new request const firstRequest = newSandboxRequests[0] if (firstRequest && !isLoading && !focusedInputDialog) { void sendNotification( { message: `${firstRequest.workerName} needs network access to ${firstRequest.host}`, notificationType: 'worker_permission_prompt', }, terminal, ) } } } // Handle sandbox permission responses (worker side) - invoke registered callbacks if (sandboxPermissionResponses.length > 0 && isTeammate()) { logForDebugging( `[InboxPoller] Found ${sandboxPermissionResponses.length} sandbox permission response(s)`, ) for (const m of sandboxPermissionResponses) { const parsed = isSandboxPermissionResponse(m.text) if (!parsed) continue // Check if we have a registered callback for this request if (hasSandboxPermissionCallback(parsed.requestId)) { logForDebugging( `[InboxPoller] Processing sandbox permission response for ${parsed.requestId}: allow=${parsed.allow}`, ) // Process the response using the exported function processSandboxPermissionResponse({ requestId: parsed.requestId, host: parsed.host, allow: parsed.allow, }) // Clear the pending sandbox request indicator setAppState(prev => ({ ...prev, pendingSandboxRequest: null, })) } } } // Handle team permission updates (teammate side) - apply permission to context if (teamPermissionUpdates.length > 0 && isTeammate()) { logForDebugging( `[InboxPoller] Found ${teamPermissionUpdates.length} team permission update(s)`, ) for (const m of teamPermissionUpdates) { const parsed = isTeamPermissionUpdate(m.text) if (!parsed) { logForDebugging( `[InboxPoller] Failed to parse team permission update: ${m.text.substring(0, 100)}`, ) continue } // Validate required nested fields to prevent crashes from malformed messages if ( !parsed.permissionUpdate?.rules || !parsed.permissionUpdate?.behavior ) { logForDebugging( `[InboxPoller] Invalid team permission update: missing permissionUpdate.rules or permissionUpdate.behavior`, ) continue } // Apply the permission update to the teammate's context logForDebugging( `[InboxPoller] Applying team permission update: ${parsed.toolName} allowed in ${parsed.directoryPath}`, ) logForDebugging( `[InboxPoller] Permission update rules: ${jsonStringify(parsed.permissionUpdate.rules)}`, ) setAppState(prev => { const updated = applyPermissionUpdate(prev.toolPermissionContext, { type: 'addRules', rules: parsed.permissionUpdate.rules, behavior: parsed.permissionUpdate.behavior, destination: 'session', }) logForDebugging( `[InboxPoller] Updated session allow rules: ${jsonStringify(updated.alwaysAllowRules.session)}`, ) return { ...prev, toolPermissionContext: updated, } }) } } // Handle mode set requests (teammate side) - team lead changing teammate's mode if (modeSetRequests.length > 0 && isTeammate()) { logForDebugging( `[InboxPoller] Found ${modeSetRequests.length} mode set request(s)`, ) for (const m of modeSetRequests) { // Only accept mode changes from team-lead if (m.from !== 'team-lead') { logForDebugging( `[InboxPoller] Ignoring mode set request from non-team-lead: ${m.from}`, ) continue } const parsed = isModeSetRequest(m.text) if (!parsed) { logForDebugging( `[InboxPoller] Failed to parse mode set request: ${m.text.substring(0, 100)}`, ) continue } const targetMode = permissionModeFromString(parsed.mode) logForDebugging( `[InboxPoller] Applying mode change from team-lead: ${targetMode}`, ) // Update local permission context setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdate( prev.toolPermissionContext, { type: 'setMode', mode: toExternalPermissionMode(targetMode), destination: 'session', }, ), })) // Update config.json so team lead can see the new mode const teamName = currentAppState.teamContext?.teamName const agentName = getAgentName() if (teamName && agentName) { setMemberMode(teamName, agentName, targetMode) } } } // Handle plan approval requests (leader side) - auto-approve and write response to teammate inbox if ( planApprovalRequests.length > 0 && isTeamLead(currentAppState.teamContext) ) { logForDebugging( `[InboxPoller] Found ${planApprovalRequests.length} plan approval request(s), auto-approving`, ) const teamName = currentAppState.teamContext?.teamName const leaderExternalMode = toExternalPermissionMode( currentAppState.toolPermissionContext.mode, ) const modeToInherit = leaderExternalMode === 'plan' ? 'default' : leaderExternalMode for (const m of planApprovalRequests) { const parsed = isPlanApprovalRequest(m.text) if (!parsed) continue // Write approval response to teammate's inbox const approvalResponse = { type: 'plan_approval_response', requestId: parsed.requestId, approved: true, timestamp: new Date().toISOString(), permissionMode: modeToInherit, } void writeToMailbox( m.from, { from: TEAM_LEAD_NAME, text: jsonStringify(approvalResponse), timestamp: new Date().toISOString(), }, teamName, ) // Update in-process teammate task state if applicable const taskId = findInProcessTeammateTaskId(m.from, currentAppState) if (taskId) { handlePlanApprovalResponse( taskId, { type: 'plan_approval_response', requestId: parsed.requestId, approved: true, timestamp: new Date().toISOString(), permissionMode: modeToInherit, }, setAppState, ) } logForDebugging( `[InboxPoller] Auto-approved plan from ${m.from} (request ${parsed.requestId})`, ) // Still pass through as a regular message so the model has context // about what the teammate is doing, but the approval is already sent regularMessages.push(m) } } // Handle shutdown requests (teammate side) - preserve JSON for UI rendering if (shutdownRequests.length > 0 && isTeammate()) { logForDebugging( `[InboxPoller] Found ${shutdownRequests.length} shutdown request(s)`, ) // Pass through shutdown requests - the UI component will render them nicely // and the model will receive instructions via the tool prompt documentation for (const m of shutdownRequests) { regularMessages.push(m) } } // Handle shutdown approvals (leader side) - kill the teammate's pane if ( shutdownApprovals.length > 0 && isTeamLead(currentAppState.teamContext) ) { logForDebugging( `[InboxPoller] Found ${shutdownApprovals.length} shutdown approval(s)`, ) for (const m of shutdownApprovals) { const parsed = isShutdownApproved(m.text) if (!parsed) continue // Kill the pane if we have the info (pane-based teammates) if (parsed.paneId && parsed.backendType) { void (async () => { try { // Ensure backend classes are imported (no subprocess probes) await ensureBackendsRegistered() const insideTmux = await isInsideTmux() const backend = getBackendByType( parsed.backendType as PaneBackendType, ) const success = await backend?.killPane( parsed.paneId!, !insideTmux, ) logForDebugging( `[InboxPoller] Killed pane ${parsed.paneId} for ${parsed.from}: ${success}`, ) } catch (error) { logForDebugging( `[InboxPoller] Failed to kill pane for ${parsed.from}: ${error}`, ) } })() } // Remove the teammate from teamContext.teammates so the count is accurate const teammateToRemove = parsed.from if (teammateToRemove && currentAppState.teamContext?.teammates) { // Find the teammate ID by name const teammateId = Object.entries( currentAppState.teamContext.teammates, ).find(([, t]) => t.name === teammateToRemove)?.[0] if (teammateId) { // Remove from team file (leader owns team file mutations) const teamName = currentAppState.teamContext?.teamName if (teamName) { removeTeammateFromTeamFile(teamName, { agentId: teammateId, name: teammateToRemove, }) } // Unassign tasks and build notification message const { notificationMessage } = teamName ? await unassignTeammateTasks( teamName, teammateId, teammateToRemove, 'shutdown', ) : { notificationMessage: `${teammateToRemove} has shut down.` } setAppState(prev => { if (!prev.teamContext?.teammates) return prev if (!(teammateId in prev.teamContext.teammates)) return prev const { [teammateId]: _, ...remainingTeammates } = prev.teamContext.teammates // Mark the teammate's task as completed so hasRunningTeammates // becomes false and the spinner stops. Without this, out-of-process // (tmux) teammate tasks stay status:'running' forever because // only in-process teammates have a runner that sets 'completed'. const updatedTasks = { ...prev.tasks } for (const [tid, task] of Object.entries(updatedTasks)) { if ( isInProcessTeammateTask(task) && task.identity.agentId === teammateId ) { updatedTasks[tid] = { ...task, status: 'completed' as const, endTime: Date.now(), } } } return { ...prev, tasks: updatedTasks, teamContext: { ...prev.teamContext, teammates: remainingTeammates, }, inbox: { messages: [ ...prev.inbox.messages, { id: randomUUID(), from: 'system', text: jsonStringify({ type: 'teammate_terminated', message: notificationMessage, }), timestamp: new Date().toISOString(), status: 'pending' as const, }, ], }, } }) logForDebugging( `[InboxPoller] Removed ${teammateToRemove} (${teammateId}) from teamContext`, ) } } // Pass through for UI rendering - the component will render it nicely regularMessages.push(m) } } // Process regular teammate messages (existing logic) if (regularMessages.length === 0) { // No regular messages, but we may have processed non-regular messages // (permissions, shutdown requests, etc.) above — mark those as read. markRead() return } // Format messages with XML wrapper for Claude (include color if available) // Transform plan approval requests to include instructions for Claude const formatted = regularMessages .map(m => { const colorAttr = m.color ? ` color="${m.color}"` : '' const summaryAttr = m.summary ? ` summary="${m.summary}"` : '' const messageContent = m.text return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${messageContent}\n` }) .join('\n\n') // Helper to queue messages in AppState for later delivery const queueMessages = () => { setAppState(prev => ({ ...prev, inbox: { messages: [ ...prev.inbox.messages, ...regularMessages.map(m => ({ id: randomUUID(), from: m.from, text: m.text, timestamp: m.timestamp, status: 'pending' as const, color: m.color, summary: m.summary, })), ], }, })) } if (!isLoading && !focusedInputDialog) { // IDLE: Submit as new turn immediately logForDebugging(`[InboxPoller] Session idle, submitting immediately`) const submitted = onSubmitTeammateMessage(formatted) if (!submitted) { // Submission rejected (query already running), queue for later logForDebugging( `[InboxPoller] Submission rejected, queuing for later delivery`, ) queueMessages() } } else { // BUSY: Add to inbox queue for UI display + later delivery logForDebugging(`[InboxPoller] Session busy, queuing for later delivery`) queueMessages() } // Mark messages as read only after they have been successfully delivered // or reliably queued in AppState. This prevents permanent message loss // when the session is busy — if we crash before this point, the messages // will be re-read on the next poll cycle instead of being silently dropped. markRead() }, [ enabled, isLoading, focusedInputDialog, onSubmitTeammateMessage, setAppState, terminal, store, ]) // When session becomes idle, deliver any pending messages and clean up processed ones useEffect(() => { if (!enabled) return // Skip if busy or in a dialog if (isLoading || focusedInputDialog) { return } // Use ref to avoid dependency on appState object (prevents infinite loop) const currentAppState = store.getState() const agentName = getAgentNameToPoll(currentAppState) if (!agentName) return const pendingMessages = currentAppState.inbox.messages.filter( m => m.status === 'pending', ) const processedMessages = currentAppState.inbox.messages.filter( m => m.status === 'processed', ) // Clean up processed messages (they were already delivered mid-turn as attachments) if (processedMessages.length > 0) { logForDebugging( `[InboxPoller] Cleaning up ${processedMessages.length} processed message(s) that were delivered mid-turn`, ) const processedIds = new Set(processedMessages.map(m => m.id)) setAppState(prev => ({ ...prev, inbox: { messages: prev.inbox.messages.filter(m => !processedIds.has(m.id)), }, })) } // No pending messages to deliver if (pendingMessages.length === 0) return logForDebugging( `[InboxPoller] Session idle, delivering ${pendingMessages.length} pending message(s)`, ) // Format messages with XML wrapper for Claude (include color if available) const formatted = pendingMessages .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` }) .join('\n\n') // Try to submit - only clear messages if successful const submitted = onSubmitTeammateMessage(formatted) if (submitted) { // Clear the specific messages we just submitted by their IDs const submittedIds = new Set(pendingMessages.map(m => m.id)) setAppState(prev => ({ ...prev, inbox: { messages: prev.inbox.messages.filter(m => !submittedIds.has(m.id)), }, })) } else { logForDebugging( `[InboxPoller] Submission rejected, keeping messages queued`, ) } }, [ enabled, isLoading, focusedInputDialog, onSubmitTeammateMessage, setAppState, inboxMessageCount, store, ]) // Poll if running as a teammate or as a team lead const shouldPoll = enabled && !!getAgentNameToPoll(store.getState()) useInterval(() => void poll(), shouldPoll ? INBOX_POLL_INTERVAL_MS : null) // Initial poll on mount (only once) const hasDoneInitialPollRef = useRef(false) useEffect(() => { if (!enabled) return if (hasDoneInitialPollRef.current) return // Use store.getState() to avoid dependency on appState object if (getAgentNameToPoll(store.getState())) { hasDoneInitialPollRef.current = true void poll() } // Note: poll uses store.getState() (not appState) so it won't re-run on appState changes // The ref guard is a safety measure to ensure initial poll only happens once }, [enabled, poll, store]) }