Download src/hooks/useInboxPoller.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 34.4 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useInboxPoller.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useInboxPoller.ts
-
curl -L -o useInboxPoller.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/hooks/useInboxPoller.ts
34.4 kB
| 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]) | |
| } | |