source dump of claude code
at main 969 lines 34 kB view raw
1import { randomUUID } from 'crypto' 2import { useCallback, useEffect, useRef } from 'react' 3import { useInterval } from 'usehooks-ts' 4import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' 5import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js' 6import { useTerminalNotification } from '../ink/useTerminalNotification.js' 7import { sendNotification } from '../services/notifier.js' 8import { 9 type AppState, 10 useAppState, 11 useAppStateStore, 12 useSetAppState, 13} from '../state/AppState.js' 14import { findToolByName } from '../Tool.js' 15import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' 16import { getAllBaseTools } from '../tools.js' 17import type { PermissionUpdate } from '../types/permissions.js' 18import { logForDebugging } from '../utils/debug.js' 19import { 20 findInProcessTeammateTaskId, 21 handlePlanApprovalResponse, 22} from '../utils/inProcessTeammateHelpers.js' 23import { createAssistantMessage } from '../utils/messages.js' 24import { 25 permissionModeFromString, 26 toExternalPermissionMode, 27} from '../utils/permissions/PermissionMode.js' 28import { applyPermissionUpdate } from '../utils/permissions/PermissionUpdate.js' 29import { jsonStringify } from '../utils/slowOperations.js' 30import { isInsideTmux } from '../utils/swarm/backends/detection.js' 31import { 32 ensureBackendsRegistered, 33 getBackendByType, 34} from '../utils/swarm/backends/registry.js' 35import type { PaneBackendType } from '../utils/swarm/backends/types.js' 36import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' 37import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' 38import { sendPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js' 39import { 40 removeTeammateFromTeamFile, 41 setMemberMode, 42} from '../utils/swarm/teamHelpers.js' 43import { unassignTeammateTasks } from '../utils/tasks.js' 44import { 45 getAgentName, 46 isPlanModeRequired, 47 isTeamLead, 48 isTeammate, 49} from '../utils/teammate.js' 50import { isInProcessTeammate } from '../utils/teammateContext.js' 51import { 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' 67import { 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 */ 81function 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 107const INBOX_POLL_INTERVAL_MS = 1000 108 109type 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 */ 126export 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}