source dump of claude code
at main 1552 lines 54 kB view raw
1/** 2 * In-process teammate runner 3 * 4 * Wraps runAgent() for in-process teammates, providing: 5 * - AsyncLocalStorage-based context isolation via runWithTeammateContext() 6 * - Progress tracking and AppState updates 7 * - Idle notification to leader when complete 8 * - Plan mode approval flow support 9 * - Cleanup on completion or abort 10 */ 11 12import { feature } from 'bun:bundle' 13import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 14import { getSystemPrompt } from '../../constants/prompts.js' 15import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js' 16import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 17import { 18 processMailboxPermissionResponse, 19 registerPermissionCallback, 20 unregisterPermissionCallback, 21} from '../../hooks/useSwarmPermissionPoller.js' 22import { 23 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 24 logEvent, 25} from '../../services/analytics/index.js' 26import { getAutoCompactThreshold } from '../../services/compact/autoCompact.js' 27import { 28 buildPostCompactMessages, 29 compactConversation, 30 ERROR_MESSAGE_USER_ABORT, 31} from '../../services/compact/compact.js' 32import { resetMicrocompactState } from '../../services/compact/microCompact.js' 33import type { AppState } from '../../state/AppState.js' 34import type { Tool, ToolUseContext } from '../../Tool.js' 35import { appendTeammateMessage } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' 36import type { 37 InProcessTeammateTaskState, 38 TeammateIdentity, 39} from '../../tasks/InProcessTeammateTask/types.js' 40import { appendCappedMessage } from '../../tasks/InProcessTeammateTask/types.js' 41import { 42 createActivityDescriptionResolver, 43 createProgressTracker, 44 getProgressUpdate, 45 updateProgressFromMessage, 46} from '../../tasks/LocalAgentTask/LocalAgentTask.js' 47import type { CustomAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 48import { runAgent } from '../../tools/AgentTool/runAgent.js' 49import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js' 50import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 51import { SEND_MESSAGE_TOOL_NAME } from '../../tools/SendMessageTool/constants.js' 52import { TASK_CREATE_TOOL_NAME } from '../../tools/TaskCreateTool/constants.js' 53import { TASK_GET_TOOL_NAME } from '../../tools/TaskGetTool/constants.js' 54import { TASK_LIST_TOOL_NAME } from '../../tools/TaskListTool/constants.js' 55import { TASK_UPDATE_TOOL_NAME } from '../../tools/TaskUpdateTool/constants.js' 56import { TEAM_CREATE_TOOL_NAME } from '../../tools/TeamCreateTool/constants.js' 57import { TEAM_DELETE_TOOL_NAME } from '../../tools/TeamDeleteTool/constants.js' 58import type { Message } from '../../types/message.js' 59import type { PermissionDecision } from '../../types/permissions.js' 60import { 61 createAssistantAPIErrorMessage, 62 createUserMessage, 63} from '../../utils/messages.js' 64import { evictTaskOutput } from '../../utils/task/diskOutput.js' 65import { evictTerminalTask } from '../../utils/task/framework.js' 66import { tokenCountWithEstimation } from '../../utils/tokens.js' 67import { createAbortController } from '../abortController.js' 68import { type AgentContext, runWithAgentContext } from '../agentContext.js' 69import { count } from '../array.js' 70import { logForDebugging } from '../debug.js' 71import { cloneFileStateCache } from '../fileStateCache.js' 72import { 73 SUBAGENT_REJECT_MESSAGE, 74 SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX, 75} from '../messages.js' 76import type { ModelAlias } from '../model/aliases.js' 77import { 78 applyPermissionUpdates, 79 persistPermissionUpdates, 80} from '../permissions/PermissionUpdate.js' 81import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' 82import { hasPermissionsToUseTool } from '../permissions/permissions.js' 83import { emitTaskTerminatedSdk } from '../sdkEventQueue.js' 84import { sleep } from '../sleep.js' 85import { jsonStringify } from '../slowOperations.js' 86import { asSystemPrompt } from '../systemPromptType.js' 87import { claimTask, listTasks, type Task, updateTask } from '../tasks.js' 88import type { TeammateContext } from '../teammateContext.js' 89import { runWithTeammateContext } from '../teammateContext.js' 90import { 91 createIdleNotification, 92 getLastPeerDmSummary, 93 isPermissionResponse, 94 isShutdownRequest, 95 markMessageAsReadByIndex, 96 readMailbox, 97 writeToMailbox, 98} from '../teammateMailbox.js' 99import { unregisterAgent as unregisterPerfettoAgent } from '../telemetry/perfettoTracing.js' 100import { createContentReplacementState } from '../toolResultStorage.js' 101import { TEAM_LEAD_NAME } from './constants.js' 102import { 103 getLeaderSetToolPermissionContext, 104 getLeaderToolUseConfirmQueue, 105} from './leaderPermissionBridge.js' 106import { 107 createPermissionRequest, 108 sendPermissionRequestViaMailbox, 109} from './permissionSync.js' 110import { TEAMMATE_SYSTEM_PROMPT_ADDENDUM } from './teammatePromptAddendum.js' 111 112type SetAppStateFn = (updater: (prev: AppState) => AppState) => void 113 114const PERMISSION_POLL_INTERVAL_MS = 500 115 116/** 117 * Creates a canUseTool function for in-process teammates that properly resolves 118 * 'ask' permissions via the UI rather than treating them as denials. 119 * 120 * Always uses the leader's ToolUseConfirm dialog with a worker badge when 121 * the bridge is available, giving teammates the same tool-specific UI 122 * (BashPermissionRequest, FileEditToolDiff, etc.) as the leader's own tools. 123 * 124 * Falls back to the mailbox system when the bridge is unavailable: 125 * sends a permission request to the leader's inbox, waits for the response 126 * in the teammate's own mailbox. 127 */ 128function createInProcessCanUseTool( 129 identity: TeammateIdentity, 130 abortController: AbortController, 131 onPermissionWaitMs?: (waitMs: number) => void, 132): CanUseToolFn { 133 return async ( 134 tool, 135 input, 136 toolUseContext, 137 assistantMessage, 138 toolUseID, 139 forceDecision, 140 ) => { 141 const result = 142 forceDecision ?? 143 (await hasPermissionsToUseTool( 144 tool, 145 input, 146 toolUseContext, 147 assistantMessage, 148 toolUseID, 149 )) 150 151 // Pass through allow/deny decisions directly 152 if (result.behavior !== 'ask') { 153 return result 154 } 155 156 // For bash commands, try classifier auto-approval before showing leader dialog. 157 // Agents await the classifier result (rather than racing it against user 158 // interaction like the main agent). 159 if ( 160 feature('BASH_CLASSIFIER') && 161 tool.name === BASH_TOOL_NAME && 162 result.pendingClassifierCheck 163 ) { 164 const classifierDecision = await awaitClassifierAutoApproval( 165 result.pendingClassifierCheck, 166 abortController.signal, 167 toolUseContext.options.isNonInteractiveSession, 168 ) 169 if (classifierDecision) { 170 return { 171 behavior: 'allow', 172 updatedInput: input as Record<string, unknown>, 173 decisionReason: classifierDecision, 174 } 175 } 176 } 177 178 // Check if aborted before showing UI 179 if (abortController.signal.aborted) { 180 return { behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE } 181 } 182 183 const appState = toolUseContext.getAppState() 184 185 const description = await (tool as Tool).description(input as never, { 186 isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, 187 toolPermissionContext: appState.toolPermissionContext, 188 tools: toolUseContext.options.tools, 189 }) 190 191 if (abortController.signal.aborted) { 192 return { behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE } 193 } 194 195 const setToolUseConfirmQueue = getLeaderToolUseConfirmQueue() 196 197 // Standard path: use ToolUseConfirm dialog with worker badge 198 if (setToolUseConfirmQueue) { 199 return new Promise<PermissionDecision>(resolve => { 200 let decisionMade = false 201 const permissionStartMs = Date.now() 202 203 // Report permission wait time to the caller so it can be 204 // subtracted from the displayed elapsed time. 205 const reportPermissionWait = () => { 206 onPermissionWaitMs?.(Date.now() - permissionStartMs) 207 } 208 209 const onAbortListener = () => { 210 if (decisionMade) return 211 decisionMade = true 212 reportPermissionWait() 213 resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 214 setToolUseConfirmQueue(queue => 215 queue.filter(item => item.toolUseID !== toolUseID), 216 ) 217 } 218 219 abortController.signal.addEventListener('abort', onAbortListener, { 220 once: true, 221 }) 222 223 setToolUseConfirmQueue(queue => [ 224 ...queue, 225 { 226 assistantMessage, 227 tool: tool as Tool, 228 description, 229 input, 230 toolUseContext, 231 toolUseID, 232 permissionResult: result, 233 permissionPromptStartTimeMs: permissionStartMs, 234 workerBadge: identity.color 235 ? { name: identity.agentName, color: identity.color } 236 : undefined, 237 onUserInteraction() { 238 // No-op for teammates (no classifier auto-approval) 239 }, 240 onAbort() { 241 if (decisionMade) return 242 decisionMade = true 243 abortController.signal.removeEventListener( 244 'abort', 245 onAbortListener, 246 ) 247 reportPermissionWait() 248 resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 249 }, 250 async onAllow( 251 updatedInput: Record<string, unknown>, 252 permissionUpdates: PermissionUpdate[], 253 feedback?: string, 254 contentBlocks?: ContentBlockParam[], 255 ) { 256 if (decisionMade) return 257 decisionMade = true 258 abortController.signal.removeEventListener( 259 'abort', 260 onAbortListener, 261 ) 262 reportPermissionWait() 263 persistPermissionUpdates(permissionUpdates) 264 // Write back permission updates to the leader's shared context 265 if (permissionUpdates.length > 0) { 266 const setToolPermissionContext = 267 getLeaderSetToolPermissionContext() 268 if (setToolPermissionContext) { 269 const currentAppState = toolUseContext.getAppState() 270 const updatedContext = applyPermissionUpdates( 271 currentAppState.toolPermissionContext, 272 permissionUpdates, 273 ) 274 // Preserve the leader's mode to prevent workers' 275 // transformed 'acceptEdits' context from leaking back 276 // to the coordinator 277 setToolPermissionContext(updatedContext, { 278 preserveMode: true, 279 }) 280 } 281 } 282 const trimmedFeedback = feedback?.trim() 283 resolve({ 284 behavior: 'allow', 285 updatedInput, 286 userModified: false, 287 acceptFeedback: trimmedFeedback || undefined, 288 ...(contentBlocks && 289 contentBlocks.length > 0 && { contentBlocks }), 290 }) 291 }, 292 onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { 293 if (decisionMade) return 294 decisionMade = true 295 abortController.signal.removeEventListener( 296 'abort', 297 onAbortListener, 298 ) 299 reportPermissionWait() 300 const message = feedback 301 ? `${SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}` 302 : SUBAGENT_REJECT_MESSAGE 303 resolve({ behavior: 'ask', message, contentBlocks }) 304 }, 305 async recheckPermission() { 306 if (decisionMade) return 307 const freshResult = await hasPermissionsToUseTool( 308 tool, 309 input, 310 toolUseContext, 311 assistantMessage, 312 toolUseID, 313 ) 314 if (freshResult.behavior === 'allow') { 315 decisionMade = true 316 abortController.signal.removeEventListener( 317 'abort', 318 onAbortListener, 319 ) 320 reportPermissionWait() 321 setToolUseConfirmQueue(queue => 322 queue.filter(item => item.toolUseID !== toolUseID), 323 ) 324 resolve({ 325 ...freshResult, 326 updatedInput: input, 327 userModified: false, 328 }) 329 } 330 }, 331 }, 332 ]) 333 }) 334 } 335 336 // Fallback: use mailbox system when leader UI queue is unavailable 337 return new Promise<PermissionDecision>(resolve => { 338 const request = createPermissionRequest({ 339 toolName: (tool as Tool).name, 340 toolUseId: toolUseID, 341 input, 342 description, 343 permissionSuggestions: result.suggestions, 344 workerId: identity.agentId, 345 workerName: identity.agentName, 346 workerColor: identity.color, 347 teamName: identity.teamName, 348 }) 349 350 // Register callback to be invoked when the leader responds 351 registerPermissionCallback({ 352 requestId: request.id, 353 toolUseId: toolUseID, 354 onAllow( 355 updatedInput: Record<string, unknown> | undefined, 356 permissionUpdates: PermissionUpdate[], 357 _feedback?: string, 358 contentBlocks?: ContentBlockParam[], 359 ) { 360 cleanup() 361 persistPermissionUpdates(permissionUpdates) 362 const finalInput = 363 updatedInput && Object.keys(updatedInput).length > 0 364 ? updatedInput 365 : input 366 resolve({ 367 behavior: 'allow', 368 updatedInput: finalInput, 369 userModified: false, 370 ...(contentBlocks && contentBlocks.length > 0 && { contentBlocks }), 371 }) 372 }, 373 onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { 374 cleanup() 375 const message = feedback 376 ? `${SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}` 377 : SUBAGENT_REJECT_MESSAGE 378 resolve({ behavior: 'ask', message, contentBlocks }) 379 }, 380 }) 381 382 // Send request to leader's mailbox 383 void sendPermissionRequestViaMailbox(request) 384 385 // Poll teammate's mailbox for the response 386 const pollInterval = setInterval( 387 async (abortController, cleanup, resolve, identity, request) => { 388 if (abortController.signal.aborted) { 389 cleanup() 390 resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 391 return 392 } 393 394 const allMessages = await readMailbox( 395 identity.agentName, 396 identity.teamName, 397 ) 398 for (let i = 0; i < allMessages.length; i++) { 399 const msg = allMessages[i] 400 if (msg && !msg.read) { 401 const parsed = isPermissionResponse(msg.text) 402 if (parsed && parsed.request_id === request.id) { 403 await markMessageAsReadByIndex( 404 identity.agentName, 405 identity.teamName, 406 i, 407 ) 408 if (parsed.subtype === 'success') { 409 processMailboxPermissionResponse({ 410 requestId: parsed.request_id, 411 decision: 'approved', 412 updatedInput: parsed.response?.updated_input, 413 permissionUpdates: parsed.response?.permission_updates, 414 }) 415 } else { 416 processMailboxPermissionResponse({ 417 requestId: parsed.request_id, 418 decision: 'rejected', 419 feedback: parsed.error, 420 }) 421 } 422 return // Callback already resolves the promise 423 } 424 } 425 } 426 }, 427 PERMISSION_POLL_INTERVAL_MS, 428 abortController, 429 cleanup, 430 resolve, 431 identity, 432 request, 433 ) 434 435 const onAbortListener = () => { 436 cleanup() 437 resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 438 } 439 440 abortController.signal.addEventListener('abort', onAbortListener, { 441 once: true, 442 }) 443 444 function cleanup() { 445 clearInterval(pollInterval) 446 unregisterPermissionCallback(request.id) 447 abortController.signal.removeEventListener('abort', onAbortListener) 448 } 449 }) 450 } 451} 452 453/** 454 * Formats a message as <teammate-message> XML for injection into the conversation. 455 * This ensures the model sees messages in the same format as tmux teammates. 456 */ 457function formatAsTeammateMessage( 458 from: string, 459 content: string, 460 color?: string, 461 summary?: string, 462): string { 463 const colorAttr = color ? ` color="${color}"` : '' 464 const summaryAttr = summary ? ` summary="${summary}"` : '' 465 return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${from}"${colorAttr}${summaryAttr}>\n${content}\n</${TEAMMATE_MESSAGE_TAG}>` 466} 467 468/** 469 * Configuration for running an in-process teammate. 470 */ 471export type InProcessRunnerConfig = { 472 /** Teammate identity for context */ 473 identity: TeammateIdentity 474 /** Task ID in AppState */ 475 taskId: string 476 /** Initial prompt for the teammate */ 477 prompt: string 478 /** Optional agent definition (for specialized agents) */ 479 agentDefinition?: CustomAgentDefinition 480 /** Teammate context for AsyncLocalStorage */ 481 teammateContext: TeammateContext 482 /** Parent's tool use context */ 483 toolUseContext: ToolUseContext 484 /** Abort controller linked to parent */ 485 abortController: AbortController 486 /** Optional model override for this teammate */ 487 model?: string 488 /** Optional system prompt override for this teammate */ 489 systemPrompt?: string 490 /** How to apply the system prompt: 'replace' or 'append' to default */ 491 systemPromptMode?: 'default' | 'replace' | 'append' 492 /** Tool permissions to auto-allow for this teammate */ 493 allowedTools?: string[] 494 /** Whether this teammate can show permission prompts for unlisted tools. 495 * When false (default), unlisted tools are auto-denied. */ 496 allowPermissionPrompts?: boolean 497 /** Short description of the task (used as summary for the initial prompt header) */ 498 description?: string 499 /** request_id of the API call that spawned this teammate, for lineage 500 * tracing on tengu_api_* events. */ 501 invokingRequestId?: string 502} 503 504/** 505 * Result from running an in-process teammate. 506 */ 507export type InProcessRunnerResult = { 508 /** Whether the run completed successfully */ 509 success: boolean 510 /** Error message if failed */ 511 error?: string 512 /** Messages produced by the agent */ 513 messages: Message[] 514} 515 516/** 517 * Updates task state in AppState. 518 */ 519function updateTaskState( 520 taskId: string, 521 updater: (task: InProcessTeammateTaskState) => InProcessTeammateTaskState, 522 setAppState: SetAppStateFn, 523): void { 524 setAppState(prev => { 525 const task = prev.tasks[taskId] 526 if (!task || task.type !== 'in_process_teammate') { 527 return prev 528 } 529 const updated = updater(task) 530 if (updated === task) { 531 return prev 532 } 533 return { 534 ...prev, 535 tasks: { 536 ...prev.tasks, 537 [taskId]: updated, 538 }, 539 } 540 }) 541} 542 543/** 544 * Sends a message to the leader's file-based mailbox. 545 * Uses the same mailbox system as tmux teammates for consistency. 546 */ 547async function sendMessageToLeader( 548 from: string, 549 text: string, 550 color: string | undefined, 551 teamName: string, 552): Promise<void> { 553 await writeToMailbox( 554 TEAM_LEAD_NAME, 555 { 556 from, 557 text, 558 timestamp: new Date().toISOString(), 559 color, 560 }, 561 teamName, 562 ) 563} 564 565/** 566 * Sends idle notification to the leader via file-based mailbox. 567 * Uses agentName (not agentId) for consistency with process-based teammates. 568 */ 569async function sendIdleNotification( 570 agentName: string, 571 agentColor: string | undefined, 572 teamName: string, 573 options?: { 574 idleReason?: 'available' | 'interrupted' | 'failed' 575 summary?: string 576 completedTaskId?: string 577 completedStatus?: 'resolved' | 'blocked' | 'failed' 578 failureReason?: string 579 }, 580): Promise<void> { 581 const notification = createIdleNotification(agentName, options) 582 583 await sendMessageToLeader( 584 agentName, 585 jsonStringify(notification), 586 agentColor, 587 teamName, 588 ) 589} 590 591/** 592 * Find an available task from the team's task list. 593 * A task is available if it's pending, has no owner, and is not blocked. 594 */ 595function findAvailableTask(tasks: Task[]): Task | undefined { 596 const unresolvedTaskIds = new Set( 597 tasks.filter(t => t.status !== 'completed').map(t => t.id), 598 ) 599 600 return tasks.find(task => { 601 if (task.status !== 'pending') return false 602 if (task.owner) return false 603 return task.blockedBy.every(id => !unresolvedTaskIds.has(id)) 604 }) 605} 606 607/** 608 * Format a task as a prompt for the teammate to work on. 609 */ 610function formatTaskAsPrompt(task: Task): string { 611 let prompt = `Complete all open tasks. Start with task #${task.id}: \n\n ${task.subject}` 612 613 if (task.description) { 614 prompt += `\n\n${task.description}` 615 } 616 617 return prompt 618} 619 620/** 621 * Try to claim an available task from the team's task list. 622 * Returns the formatted prompt if a task was claimed, or undefined if none available. 623 */ 624async function tryClaimNextTask( 625 taskListId: string, 626 agentName: string, 627): Promise<string | undefined> { 628 try { 629 const tasks = await listTasks(taskListId) 630 const availableTask = findAvailableTask(tasks) 631 632 if (!availableTask) { 633 return undefined 634 } 635 636 const result = await claimTask(taskListId, availableTask.id, agentName) 637 638 if (!result.success) { 639 logForDebugging( 640 `[inProcessRunner] Failed to claim task #${availableTask.id}: ${result.reason}`, 641 ) 642 return undefined 643 } 644 645 // Also set status to in_progress so the UI reflects it immediately 646 await updateTask(taskListId, availableTask.id, { status: 'in_progress' }) 647 648 logForDebugging( 649 `[inProcessRunner] Claimed task #${availableTask.id}: ${availableTask.subject}`, 650 ) 651 652 return formatTaskAsPrompt(availableTask) 653 } catch (err) { 654 logForDebugging(`[inProcessRunner] Error checking task list: ${err}`) 655 return undefined 656 } 657} 658 659/** 660 * Result of waiting for messages. 661 */ 662type WaitResult = 663 | { 664 type: 'shutdown_request' 665 request: ReturnType<typeof isShutdownRequest> 666 originalMessage: string 667 } 668 | { 669 type: 'new_message' 670 message: string 671 from: string 672 color?: string 673 summary?: string 674 } 675 | { 676 type: 'aborted' 677 } 678 679/** 680 * Waits for new prompts or shutdown request. 681 * Polls the teammate's mailbox every 500ms, checking for: 682 * - Shutdown request from leader (returned to caller for model decision) 683 * - New messages/prompts from leader 684 * - Abort signal 685 * 686 * This keeps the teammate alive in 'idle' state instead of terminating. 687 * Does NOT auto-approve shutdown - the model should make that decision. 688 */ 689async function waitForNextPromptOrShutdown( 690 identity: TeammateIdentity, 691 abortController: AbortController, 692 taskId: string, 693 getAppState: () => AppState, 694 setAppState: SetAppStateFn, 695 taskListId: string, 696): Promise<WaitResult> { 697 const POLL_INTERVAL_MS = 500 698 699 logForDebugging( 700 `[inProcessRunner] ${identity.agentName} starting poll loop (abort=${abortController.signal.aborted})`, 701 ) 702 703 let pollCount = 0 704 while (!abortController.signal.aborted) { 705 // Check for in-memory pending messages on every iteration (from transcript viewing) 706 const appState = getAppState() 707 const task = appState.tasks[taskId] 708 if ( 709 task && 710 task.type === 'in_process_teammate' && 711 task.pendingUserMessages.length > 0 712 ) { 713 const message = task.pendingUserMessages[0]! // Safe: checked length > 0 714 // Pop the message from the queue 715 setAppState(prev => { 716 const prevTask = prev.tasks[taskId] 717 if (!prevTask || prevTask.type !== 'in_process_teammate') { 718 return prev 719 } 720 return { 721 ...prev, 722 tasks: { 723 ...prev.tasks, 724 [taskId]: { 725 ...prevTask, 726 pendingUserMessages: prevTask.pendingUserMessages.slice(1), 727 }, 728 }, 729 } 730 }) 731 logForDebugging( 732 `[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`, 733 ) 734 return { 735 type: 'new_message', 736 message, 737 from: 'user', 738 } 739 } 740 741 // Wait before next poll (skip on first iteration to check immediately) 742 if (pollCount > 0) { 743 await sleep(POLL_INTERVAL_MS) 744 } 745 pollCount++ 746 747 // Check for abort 748 if (abortController.signal.aborted) { 749 logForDebugging( 750 `[inProcessRunner] ${identity.agentName} aborted while waiting (poll #${pollCount})`, 751 ) 752 return { type: 'aborted' } 753 } 754 755 // Check for messages in mailbox 756 logForDebugging( 757 `[inProcessRunner] ${identity.agentName} poll #${pollCount}: checking mailbox`, 758 ) 759 try { 760 // Read all messages and scan unread for shutdown requests first. 761 // Shutdown requests are prioritized over regular messages to prevent 762 // starvation when peer-to-peer messages flood the queue. 763 const allMessages = await readMailbox( 764 identity.agentName, 765 identity.teamName, 766 ) 767 768 // Scan all unread messages for shutdown requests (highest priority). 769 // readMailbox() already reads all messages from disk, so this scan 770 // adds only ~1-2ms of JSON parsing overhead. 771 let shutdownIndex = -1 772 let shutdownParsed: ReturnType<typeof isShutdownRequest> = null 773 for (let i = 0; i < allMessages.length; i++) { 774 const m = allMessages[i] 775 if (m && !m.read) { 776 const parsed = isShutdownRequest(m.text) 777 if (parsed) { 778 shutdownIndex = i 779 shutdownParsed = parsed 780 break 781 } 782 } 783 } 784 785 if (shutdownIndex !== -1) { 786 const msg = allMessages[shutdownIndex]! 787 const skippedUnread = count( 788 allMessages.slice(0, shutdownIndex), 789 m => !m.read, 790 ) 791 logForDebugging( 792 `[inProcessRunner] ${identity.agentName} received shutdown request from ${shutdownParsed?.from} (prioritized over ${skippedUnread} unread messages)`, 793 ) 794 await markMessageAsReadByIndex( 795 identity.agentName, 796 identity.teamName, 797 shutdownIndex, 798 ) 799 return { 800 type: 'shutdown_request', 801 request: shutdownParsed, 802 originalMessage: msg.text, 803 } 804 } 805 806 // No shutdown request found. Prioritize team-lead messages over peer 807 // messages — the leader represents user intent and coordination, so 808 // their messages should not be starved behind peer-to-peer chatter. 809 // Fall back to FIFO for peer messages. 810 let selectedIndex = -1 811 812 // Check for unread team-lead messages first 813 for (let i = 0; i < allMessages.length; i++) { 814 const m = allMessages[i] 815 if (m && !m.read && m.from === TEAM_LEAD_NAME) { 816 selectedIndex = i 817 break 818 } 819 } 820 821 // Fall back to first unread message (any sender) 822 if (selectedIndex === -1) { 823 selectedIndex = allMessages.findIndex(m => !m.read) 824 } 825 826 if (selectedIndex !== -1) { 827 const msg = allMessages[selectedIndex] 828 if (msg) { 829 logForDebugging( 830 `[inProcessRunner] ${identity.agentName} received new message from ${msg.from} (index ${selectedIndex})`, 831 ) 832 await markMessageAsReadByIndex( 833 identity.agentName, 834 identity.teamName, 835 selectedIndex, 836 ) 837 return { 838 type: 'new_message', 839 message: msg.text, 840 from: msg.from, 841 color: msg.color, 842 summary: msg.summary, 843 } 844 } 845 } 846 } catch (err) { 847 logForDebugging( 848 `[inProcessRunner] ${identity.agentName} poll error: ${err}`, 849 ) 850 // Continue polling even if one read fails 851 } 852 853 // Check the team's task list for unclaimed tasks 854 const taskPrompt = await tryClaimNextTask(taskListId, identity.agentName) 855 if (taskPrompt) { 856 return { 857 type: 'new_message', 858 message: taskPrompt, 859 from: 'task-list', 860 } 861 } 862 } 863 864 logForDebugging( 865 `[inProcessRunner] ${identity.agentName} exiting poll loop (abort=${abortController.signal.aborted}, polls=${pollCount})`, 866 ) 867 return { type: 'aborted' } 868} 869 870/** 871 * Runs an in-process teammate with a continuous prompt loop. 872 * 873 * Executes runAgent() within the teammate's AsyncLocalStorage context, 874 * tracks progress, updates task state, sends idle notification on completion, 875 * then waits for new prompts or shutdown requests. 876 * 877 * Unlike background tasks, teammates stay alive and can receive multiple prompts. 878 * The loop only exits on abort or after shutdown is approved by the model. 879 * 880 * @param config - Runner configuration 881 * @returns Result with messages and success status 882 */ 883export async function runInProcessTeammate( 884 config: InProcessRunnerConfig, 885): Promise<InProcessRunnerResult> { 886 const { 887 identity, 888 taskId, 889 prompt, 890 description, 891 agentDefinition, 892 teammateContext, 893 toolUseContext, 894 abortController, 895 model, 896 systemPrompt, 897 systemPromptMode, 898 allowedTools, 899 allowPermissionPrompts, 900 invokingRequestId, 901 } = config 902 const { setAppState } = toolUseContext 903 904 logForDebugging( 905 `[inProcessRunner] Starting agent loop for ${identity.agentId}`, 906 ) 907 908 // Create AgentContext for analytics attribution 909 const agentContext: AgentContext = { 910 agentId: identity.agentId, 911 parentSessionId: identity.parentSessionId, 912 agentName: identity.agentName, 913 teamName: identity.teamName, 914 agentColor: identity.color, 915 planModeRequired: identity.planModeRequired, 916 isTeamLead: false, 917 agentType: 'teammate', 918 invokingRequestId, 919 invocationKind: 'spawn', 920 invocationEmitted: false, 921 } 922 923 // Build system prompt based on systemPromptMode 924 let teammateSystemPrompt: string 925 if (systemPromptMode === 'replace' && systemPrompt) { 926 teammateSystemPrompt = systemPrompt 927 } else { 928 const fullSystemPromptParts = await getSystemPrompt( 929 toolUseContext.options.tools, 930 toolUseContext.options.mainLoopModel, 931 undefined, 932 toolUseContext.options.mcpClients, 933 ) 934 935 const systemPromptParts = [ 936 ...fullSystemPromptParts, 937 TEAMMATE_SYSTEM_PROMPT_ADDENDUM, 938 ] 939 940 // If custom agent definition provided, append its prompt 941 if (agentDefinition) { 942 const customPrompt = agentDefinition.getSystemPrompt() 943 if (customPrompt) { 944 systemPromptParts.push(`\n# Custom Agent Instructions\n${customPrompt}`) 945 } 946 947 // Log agent memory loaded event for in-process teammates 948 if (agentDefinition.memory) { 949 logEvent('tengu_agent_memory_loaded', { 950 ...(process.env.USER_TYPE === 'ant' 951 ? { 952 agent_type: 953 agentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 954 } 955 : {}), 956 scope: 957 agentDefinition.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 958 source: 959 'in-process-teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 960 }) 961 } 962 } 963 964 // Append mode: add provided system prompt after default 965 if (systemPromptMode === 'append' && systemPrompt) { 966 systemPromptParts.push(systemPrompt) 967 } 968 969 teammateSystemPrompt = systemPromptParts.join('\n') 970 } 971 972 // Resolve agent definition - use full system prompt with teammate addendum 973 // IMPORTANT: Set permissionMode to 'default' so teammates always get full tool 974 // access regardless of the leader's permission mode. 975 const resolvedAgentDefinition: CustomAgentDefinition = { 976 agentType: identity.agentName, 977 whenToUse: `In-process teammate: ${identity.agentName}`, 978 getSystemPrompt: () => teammateSystemPrompt, 979 // Inject team-essential tools so teammates can always respond to 980 // shutdown requests, send messages, and coordinate via the task list, 981 // even with explicit tool lists 982 tools: agentDefinition?.tools 983 ? [ 984 ...new Set([ 985 ...agentDefinition.tools, 986 SEND_MESSAGE_TOOL_NAME, 987 TEAM_CREATE_TOOL_NAME, 988 TEAM_DELETE_TOOL_NAME, 989 TASK_CREATE_TOOL_NAME, 990 TASK_GET_TOOL_NAME, 991 TASK_LIST_TOOL_NAME, 992 TASK_UPDATE_TOOL_NAME, 993 ]), 994 ] 995 : ['*'], 996 source: 'projectSettings', 997 permissionMode: 'default', 998 // Propagate model from custom agent definition so getAgentModel() 999 // can use it as a fallback when no tool-level model is specified 1000 ...(agentDefinition?.model ? { model: agentDefinition.model } : {}), 1001 } 1002 1003 // All messages across all prompts 1004 const allMessages: Message[] = [] 1005 // Wrap initial prompt with XML for proper styling in transcript view 1006 const wrappedInitialPrompt = formatAsTeammateMessage( 1007 'team-lead', 1008 prompt, 1009 undefined, 1010 description, 1011 ) 1012 let currentPrompt = wrappedInitialPrompt 1013 let shouldExit = false 1014 1015 // Try to claim an available task immediately so the UI can show activity 1016 // from the very start. The idle loop handles claiming for subsequent tasks. 1017 // Use parentSessionId as the task list ID since the leader creates tasks 1018 // under its session ID, not the team name. 1019 await tryClaimNextTask(identity.parentSessionId, identity.agentName) 1020 1021 try { 1022 // Add initial prompt to task.messages for display (wrapped with XML) 1023 updateTaskState( 1024 taskId, 1025 task => ({ 1026 ...task, 1027 messages: appendCappedMessage( 1028 task.messages, 1029 createUserMessage({ content: wrappedInitialPrompt }), 1030 ), 1031 }), 1032 setAppState, 1033 ) 1034 1035 // Per-teammate content replacement state. The while-loop below calls 1036 // runAgent repeatedly over an accumulating `allMessages` buffer (which 1037 // carries FULL original tool result content, not previews — query() yields 1038 // originals, enforcement is non-mutating). Without persisting state across 1039 // iterations, each call gets a fresh empty state from createSubagentContext 1040 // and makes holistic replace-globally-largest decisions, diverging from 1041 // earlier iterations' incremental frozen-first decisions → wire prefix 1042 // differs → cache miss. Gated on parent to inherit feature-flag-off. 1043 let teammateReplacementState = toolUseContext.contentReplacementState 1044 ? createContentReplacementState() 1045 : undefined 1046 1047 // Main teammate loop - runs until abort or shutdown approved 1048 while (!abortController.signal.aborted && !shouldExit) { 1049 logForDebugging( 1050 `[inProcessRunner] ${identity.agentId} processing prompt: ${currentPrompt.substring(0, 50)}...`, 1051 ) 1052 1053 // Create a per-turn abort controller for this iteration. 1054 // This allows Escape to stop current work without killing the whole teammate. 1055 // The lifecycle abortController still kills the whole teammate if needed. 1056 const currentWorkAbortController = createAbortController() 1057 1058 // Store the work controller in task state so UI can abort it 1059 updateTaskState( 1060 taskId, 1061 task => ({ ...task, currentWorkAbortController }), 1062 setAppState, 1063 ) 1064 1065 // Prepare prompt messages for this iteration 1066 // For the first iteration, start fresh 1067 // For subsequent iterations, pass accumulated messages as context 1068 const userMessage = createUserMessage({ content: currentPrompt }) 1069 const promptMessages: Message[] = [userMessage] 1070 1071 // Check if compaction is needed before building context 1072 let contextMessages = allMessages 1073 const tokenCount = tokenCountWithEstimation(allMessages) 1074 if ( 1075 tokenCount > 1076 getAutoCompactThreshold(toolUseContext.options.mainLoopModel) 1077 ) { 1078 logForDebugging( 1079 `[inProcessRunner] ${identity.agentId} compacting history (${tokenCount} tokens)`, 1080 ) 1081 // Create an isolated copy of toolUseContext so that compaction 1082 // does not clear the main session's readFileState cache or 1083 // trigger the main session's UI callbacks. 1084 const isolatedContext: ToolUseContext = { 1085 ...toolUseContext, 1086 readFileState: cloneFileStateCache(toolUseContext.readFileState), 1087 onCompactProgress: undefined, 1088 setStreamMode: undefined, 1089 } 1090 const compactedSummary = await compactConversation( 1091 allMessages, 1092 isolatedContext, 1093 { 1094 systemPrompt: asSystemPrompt([]), 1095 userContext: {}, 1096 systemContext: {}, 1097 toolUseContext: isolatedContext, 1098 forkContextMessages: [], 1099 }, 1100 true, // suppressFollowUpQuestions 1101 undefined, // customInstructions 1102 true, // isAutoCompact 1103 ) 1104 contextMessages = buildPostCompactMessages(compactedSummary) 1105 // Reset microcompact state since full compact replaces all 1106 // messages — old tool IDs are no longer relevant 1107 resetMicrocompactState() 1108 // Reset content replacement state — compact replaces all messages 1109 // so old tool_use_ids are gone. Stale Map entries are harmless 1110 // (UUID keys never match) but accumulate memory over long runs. 1111 if (teammateReplacementState) { 1112 teammateReplacementState = createContentReplacementState() 1113 } 1114 // Update allMessages in place with compacted version 1115 allMessages.length = 0 1116 allMessages.push(...contextMessages) 1117 1118 // Mirror compaction into task.messages — otherwise the AppState 1119 // mirror grows unbounded (500 turns = 500+ messages, 10-50MB). 1120 // Replace with the compacted messages, matching allMessages. 1121 updateTaskState( 1122 taskId, 1123 task => ({ ...task, messages: [...contextMessages, userMessage] }), 1124 setAppState, 1125 ) 1126 } 1127 1128 // Pass previous messages as context to preserve conversation history 1129 // allMessages accumulates all previous messages (user + assistant) from prior iterations 1130 const forkContextMessages = 1131 contextMessages.length > 0 ? [...contextMessages] : undefined 1132 1133 // Add the user message to allMessages so it's included in future context 1134 // This ensures the full conversation (user + assistant turns) is preserved 1135 allMessages.push(userMessage) 1136 1137 // Create fresh progress tracker for this prompt 1138 const tracker = createProgressTracker() 1139 const resolveActivity = createActivityDescriptionResolver( 1140 toolUseContext.options.tools, 1141 ) 1142 const iterationMessages: Message[] = [] 1143 1144 // Read current permission mode from task state (may have been cycled by leader via Shift+Tab) 1145 const currentAppState = toolUseContext.getAppState() 1146 const currentTask = currentAppState.tasks[taskId] 1147 const currentPermissionMode = 1148 currentTask && currentTask.type === 'in_process_teammate' 1149 ? currentTask.permissionMode 1150 : 'default' 1151 const iterationAgentDefinition = { 1152 ...resolvedAgentDefinition, 1153 permissionMode: currentPermissionMode, 1154 } 1155 1156 // Track if this iteration was interrupted by work abort (not lifecycle abort) 1157 let workWasAborted = false 1158 1159 // Run agent within contexts 1160 await runWithTeammateContext(teammateContext, async () => { 1161 return runWithAgentContext(agentContext, async () => { 1162 // Mark task as running (not idle) 1163 updateTaskState( 1164 taskId, 1165 task => ({ ...task, status: 'running', isIdle: false }), 1166 setAppState, 1167 ) 1168 1169 // Run the normal agent loop - same runAgent() used by AgentTool/subagents. 1170 // This calls query() internally, so we share the core API infrastructure. 1171 // Pass forkContextMessages to preserve conversation history across prompts. 1172 // In-process teammates are async but run in the same process as the leader, 1173 // so they CAN show permission prompts (unlike true background agents). 1174 // Use currentWorkAbortController so Escape stops this turn only, not the teammate. 1175 for await (const message of runAgent({ 1176 agentDefinition: iterationAgentDefinition, 1177 promptMessages, 1178 toolUseContext, 1179 canUseTool: createInProcessCanUseTool( 1180 identity, 1181 currentWorkAbortController, 1182 (waitMs: number) => { 1183 updateTaskState( 1184 taskId, 1185 task => ({ 1186 ...task, 1187 totalPausedMs: (task.totalPausedMs ?? 0) + waitMs, 1188 }), 1189 setAppState, 1190 ) 1191 }, 1192 ), 1193 isAsync: true, 1194 canShowPermissionPrompts: allowPermissionPrompts ?? true, 1195 forkContextMessages, 1196 querySource: 'agent:custom', 1197 override: { abortController: currentWorkAbortController }, 1198 model: model as ModelAlias | undefined, 1199 preserveToolUseResults: true, 1200 availableTools: toolUseContext.options.tools, 1201 allowedTools, 1202 contentReplacementState: teammateReplacementState, 1203 })) { 1204 // Check lifecycle abort first (kills whole teammate) 1205 if (abortController.signal.aborted) { 1206 logForDebugging( 1207 `[inProcessRunner] ${identity.agentId} lifecycle aborted`, 1208 ) 1209 break 1210 } 1211 1212 // Check work abort (stops current turn only) 1213 if (currentWorkAbortController.signal.aborted) { 1214 logForDebugging( 1215 `[inProcessRunner] ${identity.agentId} current work aborted (Escape pressed)`, 1216 ) 1217 workWasAborted = true 1218 break 1219 } 1220 1221 iterationMessages.push(message) 1222 allMessages.push(message) 1223 1224 updateProgressFromMessage( 1225 tracker, 1226 message, 1227 resolveActivity, 1228 toolUseContext.options.tools, 1229 ) 1230 const progress = getProgressUpdate(tracker) 1231 1232 updateTaskState( 1233 taskId, 1234 task => { 1235 // Track in-progress tool use IDs for animation in transcript view 1236 let inProgressToolUseIDs = task.inProgressToolUseIDs 1237 if (message.type === 'assistant') { 1238 for (const block of message.message.content) { 1239 if (block.type === 'tool_use') { 1240 inProgressToolUseIDs = new Set([ 1241 ...(inProgressToolUseIDs ?? []), 1242 block.id, 1243 ]) 1244 } 1245 } 1246 } else if (message.type === 'user') { 1247 const content = message.message.content 1248 if (Array.isArray(content)) { 1249 for (const block of content) { 1250 if ( 1251 typeof block === 'object' && 1252 'type' in block && 1253 block.type === 'tool_result' 1254 ) { 1255 if (inProgressToolUseIDs) { 1256 inProgressToolUseIDs = new Set(inProgressToolUseIDs) 1257 inProgressToolUseIDs.delete(block.tool_use_id) 1258 } 1259 } 1260 } 1261 } 1262 } 1263 1264 return { 1265 ...task, 1266 progress, 1267 messages: appendCappedMessage(task.messages, message), 1268 inProgressToolUseIDs, 1269 } 1270 }, 1271 setAppState, 1272 ) 1273 } 1274 1275 return { success: true, messages: iterationMessages } 1276 }) 1277 }) 1278 1279 // Clear the work controller from state (it's no longer valid) 1280 updateTaskState( 1281 taskId, 1282 task => ({ ...task, currentWorkAbortController: undefined }), 1283 setAppState, 1284 ) 1285 1286 // Check if lifecycle aborted during agent run (kills whole teammate) 1287 if (abortController.signal.aborted) { 1288 break 1289 } 1290 1291 // If work was aborted (Escape), log it and add interrupt message, then continue to idle state 1292 if (workWasAborted) { 1293 logForDebugging( 1294 `[inProcessRunner] ${identity.agentId} work interrupted, returning to idle`, 1295 ) 1296 1297 // Add interrupt message to teammate's messages so it appears in their scrollback 1298 const interruptMessage = createAssistantAPIErrorMessage({ 1299 content: ERROR_MESSAGE_USER_ABORT, 1300 }) 1301 updateTaskState( 1302 taskId, 1303 task => ({ 1304 ...task, 1305 messages: appendCappedMessage(task.messages, interruptMessage), 1306 }), 1307 setAppState, 1308 ) 1309 } 1310 1311 // Check if already idle before updating (to skip duplicate notification) 1312 const prevAppState = toolUseContext.getAppState() 1313 const prevTask = prevAppState.tasks[taskId] 1314 const wasAlreadyIdle = 1315 prevTask?.type === 'in_process_teammate' && prevTask.isIdle 1316 1317 // Mark task as idle (NOT completed) and notify any waiters 1318 updateTaskState( 1319 taskId, 1320 task => { 1321 // Call any registered idle callbacks 1322 task.onIdleCallbacks?.forEach(cb => cb()) 1323 return { ...task, isIdle: true, onIdleCallbacks: [] } 1324 }, 1325 setAppState, 1326 ) 1327 1328 // Note: We do NOT automatically send the teammate's response to the leader. 1329 // Teammates should use the Teammate tool to communicate with the leader. 1330 // This matches process-based teammates where output is not visible to the leader. 1331 1332 // Only send idle notification on transition to idle (not if already idle) 1333 if (!wasAlreadyIdle) { 1334 await sendIdleNotification( 1335 identity.agentName, 1336 identity.color, 1337 identity.teamName, 1338 { 1339 idleReason: workWasAborted ? 'interrupted' : 'available', 1340 summary: getLastPeerDmSummary(allMessages), 1341 }, 1342 ) 1343 } else { 1344 logForDebugging( 1345 `[inProcessRunner] Skipping duplicate idle notification for ${identity.agentName}`, 1346 ) 1347 } 1348 1349 logForDebugging( 1350 `[inProcessRunner] ${identity.agentId} finished prompt, waiting for next`, 1351 ) 1352 1353 // Wait for next message or shutdown 1354 const waitResult = await waitForNextPromptOrShutdown( 1355 identity, 1356 abortController, 1357 taskId, 1358 toolUseContext.getAppState, 1359 setAppState, 1360 identity.parentSessionId, 1361 ) 1362 1363 switch (waitResult.type) { 1364 case 'shutdown_request': 1365 // Pass shutdown request to model for decision 1366 // Format as teammate-message for consistency with how tmux teammates receive it 1367 // The model will use approveShutdown or rejectShutdown tool 1368 logForDebugging( 1369 `[inProcessRunner] ${identity.agentId} received shutdown request - passing to model`, 1370 ) 1371 currentPrompt = formatAsTeammateMessage( 1372 waitResult.request?.from || 'team-lead', 1373 waitResult.originalMessage, 1374 ) 1375 // Add shutdown request to task.messages for transcript display 1376 appendTeammateMessage( 1377 taskId, 1378 createUserMessage({ content: currentPrompt }), 1379 setAppState, 1380 ) 1381 break 1382 1383 case 'new_message': 1384 // New prompt from leader or teammate 1385 logForDebugging( 1386 `[inProcessRunner] ${identity.agentId} received new message from ${waitResult.from}`, 1387 ) 1388 // Messages from the user should be plain text (not wrapped in XML) 1389 // Messages from other teammates get XML wrapper for identification 1390 if (waitResult.from === 'user') { 1391 currentPrompt = waitResult.message 1392 } else { 1393 currentPrompt = formatAsTeammateMessage( 1394 waitResult.from, 1395 waitResult.message, 1396 waitResult.color, 1397 waitResult.summary, 1398 ) 1399 // Add to task.messages for transcript display (only for non-user messages) 1400 // Messages from 'user' come from pendingUserMessages which are already 1401 // added by injectUserMessageToTeammate 1402 appendTeammateMessage( 1403 taskId, 1404 createUserMessage({ content: currentPrompt }), 1405 setAppState, 1406 ) 1407 } 1408 break 1409 1410 case 'aborted': 1411 logForDebugging( 1412 `[inProcessRunner] ${identity.agentId} aborted while waiting`, 1413 ) 1414 shouldExit = true 1415 break 1416 } 1417 } 1418 1419 // Mark as completed when exiting the loop 1420 let alreadyTerminal = false 1421 let toolUseId: string | undefined 1422 updateTaskState( 1423 taskId, 1424 task => { 1425 // killInProcessTeammate may have already set status:killed + 1426 // notified:true + cleared fields. Don't overwrite (would flip 1427 // killed → completed and double-emit the SDK bookend). 1428 if (task.status !== 'running') { 1429 alreadyTerminal = true 1430 return task 1431 } 1432 toolUseId = task.toolUseId 1433 task.onIdleCallbacks?.forEach(cb => cb()) 1434 task.unregisterCleanup?.() 1435 return { 1436 ...task, 1437 status: 'completed' as const, 1438 notified: true, 1439 endTime: Date.now(), 1440 messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 1441 pendingUserMessages: [], 1442 inProgressToolUseIDs: undefined, 1443 abortController: undefined, 1444 unregisterCleanup: undefined, 1445 currentWorkAbortController: undefined, 1446 onIdleCallbacks: [], 1447 } 1448 }, 1449 setAppState, 1450 ) 1451 void evictTaskOutput(taskId) 1452 // Eagerly evict task from AppState since it's been consumed 1453 evictTerminalTask(taskId, setAppState) 1454 // notified:true pre-set → no XML notification → print.ts won't emit 1455 // the SDK task_notification. Close the task_started bookend directly. 1456 if (!alreadyTerminal) { 1457 emitTaskTerminatedSdk(taskId, 'completed', { 1458 toolUseId, 1459 summary: identity.agentId, 1460 }) 1461 } 1462 1463 unregisterPerfettoAgent(identity.agentId) 1464 return { success: true, messages: allMessages } 1465 } catch (error) { 1466 const errorMessage = 1467 error instanceof Error ? error.message : 'Unknown error' 1468 1469 logForDebugging( 1470 `[inProcessRunner] Agent ${identity.agentId} failed: ${errorMessage}`, 1471 ) 1472 1473 // Mark task as failed and notify any waiters 1474 let alreadyTerminal = false 1475 let toolUseId: string | undefined 1476 updateTaskState( 1477 taskId, 1478 task => { 1479 if (task.status !== 'running') { 1480 alreadyTerminal = true 1481 return task 1482 } 1483 toolUseId = task.toolUseId 1484 task.onIdleCallbacks?.forEach(cb => cb()) 1485 task.unregisterCleanup?.() 1486 return { 1487 ...task, 1488 status: 'failed' as const, 1489 notified: true, 1490 error: errorMessage, 1491 isIdle: true, 1492 endTime: Date.now(), 1493 onIdleCallbacks: [], 1494 messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 1495 pendingUserMessages: [], 1496 inProgressToolUseIDs: undefined, 1497 abortController: undefined, 1498 unregisterCleanup: undefined, 1499 currentWorkAbortController: undefined, 1500 } 1501 }, 1502 setAppState, 1503 ) 1504 void evictTaskOutput(taskId) 1505 // Eagerly evict task from AppState since it's been consumed 1506 evictTerminalTask(taskId, setAppState) 1507 // notified:true pre-set → no XML notification → close SDK bookend directly. 1508 if (!alreadyTerminal) { 1509 emitTaskTerminatedSdk(taskId, 'failed', { 1510 toolUseId, 1511 summary: identity.agentId, 1512 }) 1513 } 1514 1515 // Send idle notification with failure via file-based mailbox 1516 await sendIdleNotification( 1517 identity.agentName, 1518 identity.color, 1519 identity.teamName, 1520 { 1521 idleReason: 'failed', 1522 completedStatus: 'failed', 1523 failureReason: errorMessage, 1524 }, 1525 ) 1526 1527 unregisterPerfettoAgent(identity.agentId) 1528 return { 1529 success: false, 1530 error: errorMessage, 1531 messages: allMessages, 1532 } 1533 } 1534} 1535 1536/** 1537 * Starts an in-process teammate in the background. 1538 * 1539 * This is the main entry point called after spawn. It starts the agent 1540 * execution loop in a fire-and-forget manner. 1541 * 1542 * @param config - Runner configuration 1543 */ 1544export function startInProcessTeammate(config: InProcessRunnerConfig): void { 1545 // Extract agentId before the closure so the catch handler doesn't retain 1546 // the full config object (including toolUseContext) while the promise is 1547 // pending - which can be hours for a long-running teammate. 1548 const agentId = config.identity.agentId 1549 void runInProcessTeammate(config).catch(error => { 1550 logForDebugging(`[inProcessRunner] Unhandled error in ${agentId}: ${error}`) 1551 }) 1552}