/ hooks / useInboxPoller.ts
useInboxPoller.ts
  1  import { randomUUID } from 'crypto'
  2  import { useCallback, useEffect, useRef } from 'react'
  3  import { useInterval } from 'usehooks-ts'
  4  import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
  5  import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
  6  import { useTerminalNotification } from '../ink/useTerminalNotification.js'
  7  import { sendNotification } from '../services/notifier.js'
  8  import {
  9    type AppState,
 10    useAppState,
 11    useAppStateStore,
 12    useSetAppState,
 13  } from '../state/AppState.js'
 14  import { findToolByName } from '../Tool.js'
 15  import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
 16  import { getAllBaseTools } from '../tools.js'
 17  import type { PermissionUpdate } from '../types/permissions.js'
 18  import { logForDebugging } from '../utils/debug.js'
 19  import {
 20    findInProcessTeammateTaskId,
 21    handlePlanApprovalResponse,
 22  } from '../utils/inProcessTeammateHelpers.js'
 23  import { createAssistantMessage } from '../utils/messages.js'
 24  import {
 25    permissionModeFromString,
 26    toExternalPermissionMode,
 27  } from '../utils/permissions/PermissionMode.js'
 28  import { applyPermissionUpdate } from '../utils/permissions/PermissionUpdate.js'
 29  import { jsonStringify } from '../utils/slowOperations.js'
 30  import { isInsideTmux } from '../utils/swarm/backends/detection.js'
 31  import {
 32    ensureBackendsRegistered,
 33    getBackendByType,
 34  } from '../utils/swarm/backends/registry.js'
 35  import type { PaneBackendType } from '../utils/swarm/backends/types.js'
 36  import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'
 37  import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'
 38  import { sendPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'
 39  import {
 40    removeTeammateFromTeamFile,
 41    setMemberMode,
 42  } from '../utils/swarm/teamHelpers.js'
 43  import { unassignTeammateTasks } from '../utils/tasks.js'
 44  import {
 45    getAgentName,
 46    isPlanModeRequired,
 47    isTeamLead,
 48    isTeammate,
 49  } from '../utils/teammate.js'
 50  import { isInProcessTeammate } from '../utils/teammateContext.js'
 51  import {
 52    isModeSetRequest,
 53    isPermissionRequest,
 54    isPermissionResponse,
 55    isPlanApprovalRequest,
 56    isPlanApprovalResponse,
 57    isSandboxPermissionRequest,
 58    isSandboxPermissionResponse,
 59    isShutdownApproved,
 60    isShutdownRequest,
 61    isTeamPermissionUpdate,
 62    markMessagesAsRead,
 63    readUnreadMessages,
 64    type TeammateMessage,
 65    writeToMailbox,
 66  } from '../utils/teammateMailbox.js'
 67  import {
 68    hasPermissionCallback,
 69    hasSandboxPermissionCallback,
 70    processMailboxPermissionResponse,
 71    processSandboxPermissionResponse,
 72  } from './useSwarmPermissionPoller.js'
 73  
 74  /**
 75   * Get the agent name to poll for messages.
 76   * - In-process teammates return undefined (they use waitForNextPromptOrShutdown instead)
 77   * - Process-based teammates use their CLAUDE_CODE_AGENT_NAME
 78   * - Team leads use their name from teamContext.teammates
 79   * - Standalone sessions return undefined
 80   */
 81  function getAgentNameToPoll(appState: AppState): string | undefined {
 82    // In-process teammates should NOT use useInboxPoller - they have their own
 83    // polling mechanism via waitForNextPromptOrShutdown() in inProcessRunner.ts.
 84    // Using useInboxPoller would cause message routing issues since in-process
 85    // teammates share the same React context and AppState with the leader.
 86    //
 87    // Note: This can be called when the leader's REPL re-renders while an
 88    // in-process teammate's AsyncLocalStorage context is active (due to shared
 89    // setAppState). We return undefined to gracefully skip polling rather than
 90    // throwing, since this is a normal occurrence during concurrent execution.
 91    if (isInProcessTeammate()) {
 92      return undefined
 93    }
 94    if (isTeammate()) {
 95      return getAgentName()
 96    }
 97    // Team lead polls using their agent name (not ID)
 98    if (isTeamLead(appState.teamContext)) {
 99      const leadAgentId = appState.teamContext!.leadAgentId
100      // Look up the lead's name from teammates map
101      const leadName = appState.teamContext!.teammates[leadAgentId]?.name
102      return leadName || 'team-lead'
103    }
104    return undefined
105  }
106  
107  const INBOX_POLL_INTERVAL_MS = 1000
108  
109  type Props = {
110    enabled: boolean
111    isLoading: boolean
112    focusedInputDialog: string | undefined
113    // Returns true if submission succeeded, false if rejected (e.g., query already running)
114    // Dead code elimination: parameter named onSubmitMessage to avoid "teammate" string in external builds
115    onSubmitMessage: (formatted: string) => boolean
116  }
117  
118  /**
119   * Polls the teammate inbox for new messages and submits them as turns.
120   *
121   * This hook:
122   * 1. Polls every 1s for unread messages (teammates or team leads)
123   * 2. When idle: submits messages immediately as a new turn
124   * 3. When busy: queues messages in AppState.inbox for UI display, delivers when turn ends
125   */
126  export function useInboxPoller({
127    enabled,
128    isLoading,
129    focusedInputDialog,
130    onSubmitMessage,
131  }: Props): void {
132    // Assign to original name for clarity within the function
133    const onSubmitTeammateMessage = onSubmitMessage
134    const store = useAppStateStore()
135    const setAppState = useSetAppState()
136    const inboxMessageCount = useAppState(s => s.inbox.messages.length)
137    const terminal = useTerminalNotification()
138  
139    const poll = useCallback(async () => {
140      if (!enabled) return
141  
142      // Use ref to avoid dependency on appState object (prevents infinite loop)
143      const currentAppState = store.getState()
144      const agentName = getAgentNameToPoll(currentAppState)
145      if (!agentName) return
146  
147      const unread = await readUnreadMessages(
148        agentName,
149        currentAppState.teamContext?.teamName,
150      )
151  
152      if (unread.length === 0) return
153  
154      logForDebugging(`[InboxPoller] Found ${unread.length} unread message(s)`)
155  
156      // Check for plan approval responses and transition out of plan mode if approved
157      // Security: Only accept approval responses from the team lead
158      if (isTeammate() && isPlanModeRequired()) {
159        for (const msg of unread) {
160          const approvalResponse = isPlanApprovalResponse(msg.text)
161          // Verify the message is from the team lead to prevent teammates from forging approvals
162          if (approvalResponse && msg.from === 'team-lead') {
163            logForDebugging(
164              `[InboxPoller] Received plan approval response from team-lead: approved=${approvalResponse.approved}`,
165            )
166            if (approvalResponse.approved) {
167              // Use leader's permission mode if provided, otherwise default
168              const targetMode = approvalResponse.permissionMode ?? 'default'
169  
170              // Transition out of plan mode
171              setAppState(prev => ({
172                ...prev,
173                toolPermissionContext: applyPermissionUpdate(
174                  prev.toolPermissionContext,
175                  {
176                    type: 'setMode',
177                    mode: toExternalPermissionMode(targetMode),
178                    destination: 'session',
179                  },
180                ),
181              }))
182              logForDebugging(
183                `[InboxPoller] Plan approved by team lead, exited plan mode to ${targetMode}`,
184              )
185            } else {
186              logForDebugging(
187                `[InboxPoller] Plan rejected by team lead: ${approvalResponse.feedback || 'No feedback provided'}`,
188              )
189            }
190          } else if (approvalResponse) {
191            logForDebugging(
192              `[InboxPoller] Ignoring plan approval response from non-team-lead: ${msg.from}`,
193            )
194          }
195        }
196      }
197  
198      // Helper to mark messages as read in the inbox file.
199      // Called after messages are successfully delivered or reliably queued.
200      const markRead = () => {
201        void markMessagesAsRead(agentName, currentAppState.teamContext?.teamName)
202      }
203  
204      // Separate permission messages from regular teammate messages
205      const permissionRequests: TeammateMessage[] = []
206      const permissionResponses: TeammateMessage[] = []
207      const sandboxPermissionRequests: TeammateMessage[] = []
208      const sandboxPermissionResponses: TeammateMessage[] = []
209      const shutdownRequests: TeammateMessage[] = []
210      const shutdownApprovals: TeammateMessage[] = []
211      const teamPermissionUpdates: TeammateMessage[] = []
212      const modeSetRequests: TeammateMessage[] = []
213      const planApprovalRequests: TeammateMessage[] = []
214      const regularMessages: TeammateMessage[] = []
215  
216      for (const m of unread) {
217        const permReq = isPermissionRequest(m.text)
218        const permResp = isPermissionResponse(m.text)
219        const sandboxReq = isSandboxPermissionRequest(m.text)
220        const sandboxResp = isSandboxPermissionResponse(m.text)
221        const shutdownReq = isShutdownRequest(m.text)
222        const shutdownApproval = isShutdownApproved(m.text)
223        const teamPermUpdate = isTeamPermissionUpdate(m.text)
224        const modeSetReq = isModeSetRequest(m.text)
225        const planApprovalReq = isPlanApprovalRequest(m.text)
226  
227        if (permReq) {
228          permissionRequests.push(m)
229        } else if (permResp) {
230          permissionResponses.push(m)
231        } else if (sandboxReq) {
232          sandboxPermissionRequests.push(m)
233        } else if (sandboxResp) {
234          sandboxPermissionResponses.push(m)
235        } else if (shutdownReq) {
236          shutdownRequests.push(m)
237        } else if (shutdownApproval) {
238          shutdownApprovals.push(m)
239        } else if (teamPermUpdate) {
240          teamPermissionUpdates.push(m)
241        } else if (modeSetReq) {
242          modeSetRequests.push(m)
243        } else if (planApprovalReq) {
244          planApprovalRequests.push(m)
245        } else {
246          regularMessages.push(m)
247        }
248      }
249  
250      // Handle permission requests (leader side) - route to ToolUseConfirmQueue
251      if (
252        permissionRequests.length > 0 &&
253        isTeamLead(currentAppState.teamContext)
254      ) {
255        logForDebugging(
256          `[InboxPoller] Found ${permissionRequests.length} permission request(s)`,
257        )
258  
259        const setToolUseConfirmQueue = getLeaderToolUseConfirmQueue()
260        const teamName = currentAppState.teamContext?.teamName
261  
262        for (const m of permissionRequests) {
263          const parsed = isPermissionRequest(m.text)
264          if (!parsed) continue
265  
266          if (setToolUseConfirmQueue) {
267            // Route through the standard ToolUseConfirmQueue so tmux workers
268            // get the same tool-specific UI (BashPermissionRequest, FileEditToolDiff, etc.)
269            // as in-process teammates.
270            const tool = findToolByName(getAllBaseTools(), parsed.tool_name)
271            if (!tool) {
272              logForDebugging(
273                `[InboxPoller] Unknown tool ${parsed.tool_name}, skipping permission request`,
274              )
275              continue
276            }
277  
278            const entry: ToolUseConfirm = {
279              assistantMessage: createAssistantMessage({ content: '' }),
280              tool,
281              description: parsed.description,
282              input: parsed.input,
283              toolUseContext: {} as ToolUseConfirm['toolUseContext'],
284              toolUseID: parsed.tool_use_id,
285              permissionResult: {
286                behavior: 'ask',
287                message: parsed.description,
288              },
289              permissionPromptStartTimeMs: Date.now(),
290              workerBadge: {
291                name: parsed.agent_id,
292                color: 'cyan',
293              },
294              onUserInteraction() {
295                // No-op for tmux workers (no classifier auto-approval)
296              },
297              onAbort() {
298                void sendPermissionResponseViaMailbox(
299                  parsed.agent_id,
300                  { decision: 'rejected', resolvedBy: 'leader' },
301                  parsed.request_id,
302                  teamName,
303                )
304              },
305              onAllow(
306                updatedInput: Record<string, unknown>,
307                permissionUpdates: PermissionUpdate[],
308              ) {
309                void sendPermissionResponseViaMailbox(
310                  parsed.agent_id,
311                  {
312                    decision: 'approved',
313                    resolvedBy: 'leader',
314                    updatedInput,
315                    permissionUpdates,
316                  },
317                  parsed.request_id,
318                  teamName,
319                )
320              },
321              onReject(feedback?: string) {
322                void sendPermissionResponseViaMailbox(
323                  parsed.agent_id,
324                  {
325                    decision: 'rejected',
326                    resolvedBy: 'leader',
327                    feedback,
328                  },
329                  parsed.request_id,
330                  teamName,
331                )
332              },
333              async recheckPermission() {
334                // No-op for tmux workers — permission state is on the worker side
335              },
336            }
337  
338            // Deduplicate: if markMessagesAsRead failed on a prior poll,
339            // the same message will be re-read — skip if already queued.
340            setToolUseConfirmQueue(queue => {
341              if (queue.some(q => q.toolUseID === parsed.tool_use_id)) {
342                return queue
343              }
344              return [...queue, entry]
345            })
346          } else {
347            logForDebugging(
348              `[InboxPoller] ToolUseConfirmQueue unavailable, dropping permission request from ${parsed.agent_id}`,
349            )
350          }
351        }
352  
353        // Send desktop notification for the first request
354        const firstParsed = isPermissionRequest(permissionRequests[0]?.text ?? '')
355        if (firstParsed && !isLoading && !focusedInputDialog) {
356          void sendNotification(
357            {
358              message: `${firstParsed.agent_id} needs permission for ${firstParsed.tool_name}`,
359              notificationType: 'worker_permission_prompt',
360            },
361            terminal,
362          )
363        }
364      }
365  
366      // Handle permission responses (worker side) - invoke registered callbacks
367      if (permissionResponses.length > 0 && isTeammate()) {
368        logForDebugging(
369          `[InboxPoller] Found ${permissionResponses.length} permission response(s)`,
370        )
371  
372        for (const m of permissionResponses) {
373          const parsed = isPermissionResponse(m.text)
374          if (!parsed) continue
375  
376          if (hasPermissionCallback(parsed.request_id)) {
377            logForDebugging(
378              `[InboxPoller] Processing permission response for ${parsed.request_id}: ${parsed.subtype}`,
379            )
380  
381            if (parsed.subtype === 'success') {
382              processMailboxPermissionResponse({
383                requestId: parsed.request_id,
384                decision: 'approved',
385                updatedInput: parsed.response?.updated_input,
386                permissionUpdates: parsed.response?.permission_updates,
387              })
388            } else {
389              processMailboxPermissionResponse({
390                requestId: parsed.request_id,
391                decision: 'rejected',
392                feedback: parsed.error,
393              })
394            }
395          }
396        }
397      }
398  
399      // Handle sandbox permission requests (leader side) - add to workerSandboxPermissions queue
400      if (
401        sandboxPermissionRequests.length > 0 &&
402        isTeamLead(currentAppState.teamContext)
403      ) {
404        logForDebugging(
405          `[InboxPoller] Found ${sandboxPermissionRequests.length} sandbox permission request(s)`,
406        )
407  
408        const newSandboxRequests: Array<{
409          requestId: string
410          workerId: string
411          workerName: string
412          workerColor?: string
413          host: string
414          createdAt: number
415        }> = []
416  
417        for (const m of sandboxPermissionRequests) {
418          const parsed = isSandboxPermissionRequest(m.text)
419          if (!parsed) continue
420  
421          // Validate required nested fields to prevent crashes from malformed messages
422          if (!parsed.hostPattern?.host) {
423            logForDebugging(
424              `[InboxPoller] Invalid sandbox permission request: missing hostPattern.host`,
425            )
426            continue
427          }
428  
429          newSandboxRequests.push({
430            requestId: parsed.requestId,
431            workerId: parsed.workerId,
432            workerName: parsed.workerName,
433            workerColor: parsed.workerColor,
434            host: parsed.hostPattern.host,
435            createdAt: parsed.createdAt,
436          })
437        }
438  
439        if (newSandboxRequests.length > 0) {
440          setAppState(prev => ({
441            ...prev,
442            workerSandboxPermissions: {
443              ...prev.workerSandboxPermissions,
444              queue: [
445                ...prev.workerSandboxPermissions.queue,
446                ...newSandboxRequests,
447              ],
448            },
449          }))
450  
451          // Send desktop notification for the first new request
452          const firstRequest = newSandboxRequests[0]
453          if (firstRequest && !isLoading && !focusedInputDialog) {
454            void sendNotification(
455              {
456                message: `${firstRequest.workerName} needs network access to ${firstRequest.host}`,
457                notificationType: 'worker_permission_prompt',
458              },
459              terminal,
460            )
461          }
462        }
463      }
464  
465      // Handle sandbox permission responses (worker side) - invoke registered callbacks
466      if (sandboxPermissionResponses.length > 0 && isTeammate()) {
467        logForDebugging(
468          `[InboxPoller] Found ${sandboxPermissionResponses.length} sandbox permission response(s)`,
469        )
470  
471        for (const m of sandboxPermissionResponses) {
472          const parsed = isSandboxPermissionResponse(m.text)
473          if (!parsed) continue
474  
475          // Check if we have a registered callback for this request
476          if (hasSandboxPermissionCallback(parsed.requestId)) {
477            logForDebugging(
478              `[InboxPoller] Processing sandbox permission response for ${parsed.requestId}: allow=${parsed.allow}`,
479            )
480  
481            // Process the response using the exported function
482            processSandboxPermissionResponse({
483              requestId: parsed.requestId,
484              host: parsed.host,
485              allow: parsed.allow,
486            })
487  
488            // Clear the pending sandbox request indicator
489            setAppState(prev => ({
490              ...prev,
491              pendingSandboxRequest: null,
492            }))
493          }
494        }
495      }
496  
497      // Handle team permission updates (teammate side) - apply permission to context
498      if (teamPermissionUpdates.length > 0 && isTeammate()) {
499        logForDebugging(
500          `[InboxPoller] Found ${teamPermissionUpdates.length} team permission update(s)`,
501        )
502  
503        for (const m of teamPermissionUpdates) {
504          const parsed = isTeamPermissionUpdate(m.text)
505          if (!parsed) {
506            logForDebugging(
507              `[InboxPoller] Failed to parse team permission update: ${m.text.substring(0, 100)}`,
508            )
509            continue
510          }
511  
512          // Validate required nested fields to prevent crashes from malformed messages
513          if (
514            !parsed.permissionUpdate?.rules ||
515            !parsed.permissionUpdate?.behavior
516          ) {
517            logForDebugging(
518              `[InboxPoller] Invalid team permission update: missing permissionUpdate.rules or permissionUpdate.behavior`,
519            )
520            continue
521          }
522  
523          // Apply the permission update to the teammate's context
524          logForDebugging(
525            `[InboxPoller] Applying team permission update: ${parsed.toolName} allowed in ${parsed.directoryPath}`,
526          )
527          logForDebugging(
528            `[InboxPoller] Permission update rules: ${jsonStringify(parsed.permissionUpdate.rules)}`,
529          )
530  
531          setAppState(prev => {
532            const updated = applyPermissionUpdate(prev.toolPermissionContext, {
533              type: 'addRules',
534              rules: parsed.permissionUpdate.rules,
535              behavior: parsed.permissionUpdate.behavior,
536              destination: 'session',
537            })
538            logForDebugging(
539              `[InboxPoller] Updated session allow rules: ${jsonStringify(updated.alwaysAllowRules.session)}`,
540            )
541            return {
542              ...prev,
543              toolPermissionContext: updated,
544            }
545          })
546        }
547      }
548  
549      // Handle mode set requests (teammate side) - team lead changing teammate's mode
550      if (modeSetRequests.length > 0 && isTeammate()) {
551        logForDebugging(
552          `[InboxPoller] Found ${modeSetRequests.length} mode set request(s)`,
553        )
554  
555        for (const m of modeSetRequests) {
556          // Only accept mode changes from team-lead
557          if (m.from !== 'team-lead') {
558            logForDebugging(
559              `[InboxPoller] Ignoring mode set request from non-team-lead: ${m.from}`,
560            )
561            continue
562          }
563  
564          const parsed = isModeSetRequest(m.text)
565          if (!parsed) {
566            logForDebugging(
567              `[InboxPoller] Failed to parse mode set request: ${m.text.substring(0, 100)}`,
568            )
569            continue
570          }
571  
572          const targetMode = permissionModeFromString(parsed.mode)
573          logForDebugging(
574            `[InboxPoller] Applying mode change from team-lead: ${targetMode}`,
575          )
576  
577          // Update local permission context
578          setAppState(prev => ({
579            ...prev,
580            toolPermissionContext: applyPermissionUpdate(
581              prev.toolPermissionContext,
582              {
583                type: 'setMode',
584                mode: toExternalPermissionMode(targetMode),
585                destination: 'session',
586              },
587            ),
588          }))
589  
590          // Update config.json so team lead can see the new mode
591          const teamName = currentAppState.teamContext?.teamName
592          const agentName = getAgentName()
593          if (teamName && agentName) {
594            setMemberMode(teamName, agentName, targetMode)
595          }
596        }
597      }
598  
599      // Handle plan approval requests (leader side) - auto-approve and write response to teammate inbox
600      if (
601        planApprovalRequests.length > 0 &&
602        isTeamLead(currentAppState.teamContext)
603      ) {
604        logForDebugging(
605          `[InboxPoller] Found ${planApprovalRequests.length} plan approval request(s), auto-approving`,
606        )
607  
608        const teamName = currentAppState.teamContext?.teamName
609        const leaderExternalMode = toExternalPermissionMode(
610          currentAppState.toolPermissionContext.mode,
611        )
612        const modeToInherit =
613          leaderExternalMode === 'plan' ? 'default' : leaderExternalMode
614  
615        for (const m of planApprovalRequests) {
616          const parsed = isPlanApprovalRequest(m.text)
617          if (!parsed) continue
618  
619          // Write approval response to teammate's inbox
620          const approvalResponse = {
621            type: 'plan_approval_response',
622            requestId: parsed.requestId,
623            approved: true,
624            timestamp: new Date().toISOString(),
625            permissionMode: modeToInherit,
626          }
627  
628          void writeToMailbox(
629            m.from,
630            {
631              from: TEAM_LEAD_NAME,
632              text: jsonStringify(approvalResponse),
633              timestamp: new Date().toISOString(),
634            },
635            teamName,
636          )
637  
638          // Update in-process teammate task state if applicable
639          const taskId = findInProcessTeammateTaskId(m.from, currentAppState)
640          if (taskId) {
641            handlePlanApprovalResponse(
642              taskId,
643              {
644                type: 'plan_approval_response',
645                requestId: parsed.requestId,
646                approved: true,
647                timestamp: new Date().toISOString(),
648                permissionMode: modeToInherit,
649              },
650              setAppState,
651            )
652          }
653  
654          logForDebugging(
655            `[InboxPoller] Auto-approved plan from ${m.from} (request ${parsed.requestId})`,
656          )
657  
658          // Still pass through as a regular message so the model has context
659          // about what the teammate is doing, but the approval is already sent
660          regularMessages.push(m)
661        }
662      }
663  
664      // Handle shutdown requests (teammate side) - preserve JSON for UI rendering
665      if (shutdownRequests.length > 0 && isTeammate()) {
666        logForDebugging(
667          `[InboxPoller] Found ${shutdownRequests.length} shutdown request(s)`,
668        )
669  
670        // Pass through shutdown requests - the UI component will render them nicely
671        // and the model will receive instructions via the tool prompt documentation
672        for (const m of shutdownRequests) {
673          regularMessages.push(m)
674        }
675      }
676  
677      // Handle shutdown approvals (leader side) - kill the teammate's pane
678      if (
679        shutdownApprovals.length > 0 &&
680        isTeamLead(currentAppState.teamContext)
681      ) {
682        logForDebugging(
683          `[InboxPoller] Found ${shutdownApprovals.length} shutdown approval(s)`,
684        )
685  
686        for (const m of shutdownApprovals) {
687          const parsed = isShutdownApproved(m.text)
688          if (!parsed) continue
689  
690          // Kill the pane if we have the info (pane-based teammates)
691          if (parsed.paneId && parsed.backendType) {
692            void (async () => {
693              try {
694                // Ensure backend classes are imported (no subprocess probes)
695                await ensureBackendsRegistered()
696                const insideTmux = await isInsideTmux()
697                const backend = getBackendByType(
698                  parsed.backendType as PaneBackendType,
699                )
700                const success = await backend?.killPane(
701                  parsed.paneId!,
702                  !insideTmux,
703                )
704                logForDebugging(
705                  `[InboxPoller] Killed pane ${parsed.paneId} for ${parsed.from}: ${success}`,
706                )
707              } catch (error) {
708                logForDebugging(
709                  `[InboxPoller] Failed to kill pane for ${parsed.from}: ${error}`,
710                )
711              }
712            })()
713          }
714  
715          // Remove the teammate from teamContext.teammates so the count is accurate
716          const teammateToRemove = parsed.from
717          if (teammateToRemove && currentAppState.teamContext?.teammates) {
718            // Find the teammate ID by name
719            const teammateId = Object.entries(
720              currentAppState.teamContext.teammates,
721            ).find(([, t]) => t.name === teammateToRemove)?.[0]
722  
723            if (teammateId) {
724              // Remove from team file (leader owns team file mutations)
725              const teamName = currentAppState.teamContext?.teamName
726              if (teamName) {
727                removeTeammateFromTeamFile(teamName, {
728                  agentId: teammateId,
729                  name: teammateToRemove,
730                })
731              }
732  
733              // Unassign tasks and build notification message
734              const { notificationMessage } = teamName
735                ? await unassignTeammateTasks(
736                    teamName,
737                    teammateId,
738                    teammateToRemove,
739                    'shutdown',
740                  )
741                : { notificationMessage: `${teammateToRemove} has shut down.` }
742  
743              setAppState(prev => {
744                if (!prev.teamContext?.teammates) return prev
745                if (!(teammateId in prev.teamContext.teammates)) return prev
746                const { [teammateId]: _, ...remainingTeammates } =
747                  prev.teamContext.teammates
748  
749                // Mark the teammate's task as completed so hasRunningTeammates
750                // becomes false and the spinner stops. Without this, out-of-process
751                // (tmux) teammate tasks stay status:'running' forever because
752                // only in-process teammates have a runner that sets 'completed'.
753                const updatedTasks = { ...prev.tasks }
754                for (const [tid, task] of Object.entries(updatedTasks)) {
755                  if (
756                    isInProcessTeammateTask(task) &&
757                    task.identity.agentId === teammateId
758                  ) {
759                    updatedTasks[tid] = {
760                      ...task,
761                      status: 'completed' as const,
762                      endTime: Date.now(),
763                    }
764                  }
765                }
766  
767                return {
768                  ...prev,
769                  tasks: updatedTasks,
770                  teamContext: {
771                    ...prev.teamContext,
772                    teammates: remainingTeammates,
773                  },
774                  inbox: {
775                    messages: [
776                      ...prev.inbox.messages,
777                      {
778                        id: randomUUID(),
779                        from: 'system',
780                        text: jsonStringify({
781                          type: 'teammate_terminated',
782                          message: notificationMessage,
783                        }),
784                        timestamp: new Date().toISOString(),
785                        status: 'pending' as const,
786                      },
787                    ],
788                  },
789                }
790              })
791              logForDebugging(
792                `[InboxPoller] Removed ${teammateToRemove} (${teammateId}) from teamContext`,
793              )
794            }
795          }
796  
797          // Pass through for UI rendering - the component will render it nicely
798          regularMessages.push(m)
799        }
800      }
801  
802      // Process regular teammate messages (existing logic)
803      if (regularMessages.length === 0) {
804        // No regular messages, but we may have processed non-regular messages
805        // (permissions, shutdown requests, etc.) above — mark those as read.
806        markRead()
807        return
808      }
809  
810      // Format messages with XML wrapper for Claude (include color if available)
811      // Transform plan approval requests to include instructions for Claude
812      const formatted = regularMessages
813        .map(m => {
814          const colorAttr = m.color ? ` color="${m.color}"` : ''
815          const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
816          const messageContent = m.text
817  
818          return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${messageContent}\n</${TEAMMATE_MESSAGE_TAG}>`
819        })
820        .join('\n\n')
821  
822      // Helper to queue messages in AppState for later delivery
823      const queueMessages = () => {
824        setAppState(prev => ({
825          ...prev,
826          inbox: {
827            messages: [
828              ...prev.inbox.messages,
829              ...regularMessages.map(m => ({
830                id: randomUUID(),
831                from: m.from,
832                text: m.text,
833                timestamp: m.timestamp,
834                status: 'pending' as const,
835                color: m.color,
836                summary: m.summary,
837              })),
838            ],
839          },
840        }))
841      }
842  
843      if (!isLoading && !focusedInputDialog) {
844        // IDLE: Submit as new turn immediately
845        logForDebugging(`[InboxPoller] Session idle, submitting immediately`)
846        const submitted = onSubmitTeammateMessage(formatted)
847        if (!submitted) {
848          // Submission rejected (query already running), queue for later
849          logForDebugging(
850            `[InboxPoller] Submission rejected, queuing for later delivery`,
851          )
852          queueMessages()
853        }
854      } else {
855        // BUSY: Add to inbox queue for UI display + later delivery
856        logForDebugging(`[InboxPoller] Session busy, queuing for later delivery`)
857        queueMessages()
858      }
859  
860      // Mark messages as read only after they have been successfully delivered
861      // or reliably queued in AppState. This prevents permanent message loss
862      // when the session is busy — if we crash before this point, the messages
863      // will be re-read on the next poll cycle instead of being silently dropped.
864      markRead()
865    }, [
866      enabled,
867      isLoading,
868      focusedInputDialog,
869      onSubmitTeammateMessage,
870      setAppState,
871      terminal,
872      store,
873    ])
874  
875    // When session becomes idle, deliver any pending messages and clean up processed ones
876    useEffect(() => {
877      if (!enabled) return
878  
879      // Skip if busy or in a dialog
880      if (isLoading || focusedInputDialog) {
881        return
882      }
883  
884      // Use ref to avoid dependency on appState object (prevents infinite loop)
885      const currentAppState = store.getState()
886      const agentName = getAgentNameToPoll(currentAppState)
887      if (!agentName) return
888  
889      const pendingMessages = currentAppState.inbox.messages.filter(
890        m => m.status === 'pending',
891      )
892      const processedMessages = currentAppState.inbox.messages.filter(
893        m => m.status === 'processed',
894      )
895  
896      // Clean up processed messages (they were already delivered mid-turn as attachments)
897      if (processedMessages.length > 0) {
898        logForDebugging(
899          `[InboxPoller] Cleaning up ${processedMessages.length} processed message(s) that were delivered mid-turn`,
900        )
901        const processedIds = new Set(processedMessages.map(m => m.id))
902        setAppState(prev => ({
903          ...prev,
904          inbox: {
905            messages: prev.inbox.messages.filter(m => !processedIds.has(m.id)),
906          },
907        }))
908      }
909  
910      // No pending messages to deliver
911      if (pendingMessages.length === 0) return
912  
913      logForDebugging(
914        `[InboxPoller] Session idle, delivering ${pendingMessages.length} pending message(s)`,
915      )
916  
917      // Format messages with XML wrapper for Claude (include color if available)
918      const formatted = pendingMessages
919        .map(m => {
920          const colorAttr = m.color ? ` color="${m.color}"` : ''
921          const summaryAttr = m.summary ? ` summary="${m.summary}"` : ''
922          return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${colorAttr}${summaryAttr}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`
923        })
924        .join('\n\n')
925  
926      // Try to submit - only clear messages if successful
927      const submitted = onSubmitTeammateMessage(formatted)
928      if (submitted) {
929        // Clear the specific messages we just submitted by their IDs
930        const submittedIds = new Set(pendingMessages.map(m => m.id))
931        setAppState(prev => ({
932          ...prev,
933          inbox: {
934            messages: prev.inbox.messages.filter(m => !submittedIds.has(m.id)),
935          },
936        }))
937      } else {
938        logForDebugging(
939          `[InboxPoller] Submission rejected, keeping messages queued`,
940        )
941      }
942    }, [
943      enabled,
944      isLoading,
945      focusedInputDialog,
946      onSubmitTeammateMessage,
947      setAppState,
948      inboxMessageCount,
949      store,
950    ])
951  
952    // Poll if running as a teammate or as a team lead
953    const shouldPoll = enabled && !!getAgentNameToPoll(store.getState())
954    useInterval(() => void poll(), shouldPoll ? INBOX_POLL_INTERVAL_MS : null)
955  
956    // Initial poll on mount (only once)
957    const hasDoneInitialPollRef = useRef(false)
958    useEffect(() => {
959      if (!enabled) return
960      if (hasDoneInitialPollRef.current) return
961      // Use store.getState() to avoid dependency on appState object
962      if (getAgentNameToPoll(store.getState())) {
963        hasDoneInitialPollRef.current = true
964        void poll()
965      }
966      // Note: poll uses store.getState() (not appState) so it won't re-run on appState changes
967      // The ref guard is a safety measure to ensure initial poll only happens once
968    }, [enabled, poll, store])
969  }