/ utils / swarm / inProcessRunner.ts
inProcessRunner.ts
   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  
  12  import { feature } from 'bun:bundle'
  13  import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
  14  import { getSystemPrompt } from '../../constants/prompts.js'
  15  import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js'
  16  import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
  17  import {
  18    processMailboxPermissionResponse,
  19    registerPermissionCallback,
  20    unregisterPermissionCallback,
  21  } from '../../hooks/useSwarmPermissionPoller.js'
  22  import {
  23    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  24    logEvent,
  25  } from '../../services/analytics/index.js'
  26  import { getAutoCompactThreshold } from '../../services/compact/autoCompact.js'
  27  import {
  28    buildPostCompactMessages,
  29    compactConversation,
  30    ERROR_MESSAGE_USER_ABORT,
  31  } from '../../services/compact/compact.js'
  32  import { resetMicrocompactState } from '../../services/compact/microCompact.js'
  33  import type { AppState } from '../../state/AppState.js'
  34  import type { Tool, ToolUseContext } from '../../Tool.js'
  35  import { appendTeammateMessage } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
  36  import type {
  37    InProcessTeammateTaskState,
  38    TeammateIdentity,
  39  } from '../../tasks/InProcessTeammateTask/types.js'
  40  import { appendCappedMessage } from '../../tasks/InProcessTeammateTask/types.js'
  41  import {
  42    createActivityDescriptionResolver,
  43    createProgressTracker,
  44    getProgressUpdate,
  45    updateProgressFromMessage,
  46  } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
  47  import type { CustomAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
  48  import { runAgent } from '../../tools/AgentTool/runAgent.js'
  49  import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
  50  import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
  51  import { SEND_MESSAGE_TOOL_NAME } from '../../tools/SendMessageTool/constants.js'
  52  import { TASK_CREATE_TOOL_NAME } from '../../tools/TaskCreateTool/constants.js'
  53  import { TASK_GET_TOOL_NAME } from '../../tools/TaskGetTool/constants.js'
  54  import { TASK_LIST_TOOL_NAME } from '../../tools/TaskListTool/constants.js'
  55  import { TASK_UPDATE_TOOL_NAME } from '../../tools/TaskUpdateTool/constants.js'
  56  import { TEAM_CREATE_TOOL_NAME } from '../../tools/TeamCreateTool/constants.js'
  57  import { TEAM_DELETE_TOOL_NAME } from '../../tools/TeamDeleteTool/constants.js'
  58  import type { Message } from '../../types/message.js'
  59  import type { PermissionDecision } from '../../types/permissions.js'
  60  import {
  61    createAssistantAPIErrorMessage,
  62    createUserMessage,
  63  } from '../../utils/messages.js'
  64  import { evictTaskOutput } from '../../utils/task/diskOutput.js'
  65  import { evictTerminalTask } from '../../utils/task/framework.js'
  66  import { tokenCountWithEstimation } from '../../utils/tokens.js'
  67  import { createAbortController } from '../abortController.js'
  68  import { type AgentContext, runWithAgentContext } from '../agentContext.js'
  69  import { count } from '../array.js'
  70  import { logForDebugging } from '../debug.js'
  71  import { cloneFileStateCache } from '../fileStateCache.js'
  72  import {
  73    SUBAGENT_REJECT_MESSAGE,
  74    SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
  75  } from '../messages.js'
  76  import type { ModelAlias } from '../model/aliases.js'
  77  import {
  78    applyPermissionUpdates,
  79    persistPermissionUpdates,
  80  } from '../permissions/PermissionUpdate.js'
  81  import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
  82  import { hasPermissionsToUseTool } from '../permissions/permissions.js'
  83  import { emitTaskTerminatedSdk } from '../sdkEventQueue.js'
  84  import { sleep } from '../sleep.js'
  85  import { jsonStringify } from '../slowOperations.js'
  86  import { asSystemPrompt } from '../systemPromptType.js'
  87  import { claimTask, listTasks, type Task, updateTask } from '../tasks.js'
  88  import type { TeammateContext } from '../teammateContext.js'
  89  import { runWithTeammateContext } from '../teammateContext.js'
  90  import {
  91    createIdleNotification,
  92    getLastPeerDmSummary,
  93    isPermissionResponse,
  94    isShutdownRequest,
  95    markMessageAsReadByIndex,
  96    readMailbox,
  97    writeToMailbox,
  98  } from '../teammateMailbox.js'
  99  import { unregisterAgent as unregisterPerfettoAgent } from '../telemetry/perfettoTracing.js'
 100  import { createContentReplacementState } from '../toolResultStorage.js'
 101  import { TEAM_LEAD_NAME } from './constants.js'
 102  import {
 103    getLeaderSetToolPermissionContext,
 104    getLeaderToolUseConfirmQueue,
 105  } from './leaderPermissionBridge.js'
 106  import {
 107    createPermissionRequest,
 108    sendPermissionRequestViaMailbox,
 109  } from './permissionSync.js'
 110  import { TEAMMATE_SYSTEM_PROMPT_ADDENDUM } from './teammatePromptAddendum.js'
 111  
 112  type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
 113  
 114  const 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   */
 128  function 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   */
 457  function 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   */
 471  export 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   */
 507  export 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   */
 519  function 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   */
 547  async 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   */
 569  async 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   */
 595  function 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   */
 610  function 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   */
 624  async 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   */
 662  type 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   */
 689  async 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   */
 883  export 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   */
1544  export 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  }