useInboxPoller.ts
hooks/useInboxPoller.ts
No strong subsystem tag
970
Lines
34375
Bytes
1
Exports
30
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 970 lines, 30 detected imports, and 1 detected exports.
Important relationships
Detected exports
useInboxPoller
Keywords
parsedlogfordebuggingmessagesinboxpollerprevlengthtextpermissionteamcontextteammate
Detected imports
cryptoreactusehooks-ts../components/permissions/PermissionRequest.js../constants/xml.js../ink/useTerminalNotification.js../services/notifier.js../state/AppState.js../Tool.js../tasks/InProcessTeammateTask/types.js../tools.js../types/permissions.js../utils/debug.js../utils/inProcessTeammateHelpers.js../utils/messages.js../utils/permissions/PermissionMode.js../utils/permissions/PermissionUpdate.js../utils/slowOperations.js../utils/swarm/backends/detection.js../utils/swarm/backends/registry.js../utils/swarm/backends/types.js../utils/swarm/constants.js../utils/swarm/leaderPermissionBridge.js../utils/swarm/permissionSync.js../utils/swarm/teamHelpers.js../utils/tasks.js../utils/teammate.js../utils/teammateContext.js../utils/teammateMailbox.js./useSwarmPermissionPoller.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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<string, unknown>,
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</${TEAMMATE_MESSAGE_TAG}>`
})
.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</${TEAMMATE_MESSAGE_TAG}>`
})
.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])
}