/ src / utils / attachments.ts
attachments.ts
   1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
   2  import {
   3    logEvent,
   4    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
   5  } from 'src/services/analytics/index.js'
   6  import {
   7    toolMatchesName,
   8    type Tools,
   9    type ToolUseContext,
  10    type ToolPermissionContext,
  11  } from '../Tool.js'
  12  import {
  13    FileReadTool,
  14    MaxFileReadTokenExceededError,
  15    type Output as FileReadToolOutput,
  16    readImageWithTokenBudget,
  17  } from '../tools/FileReadTool/FileReadTool.js'
  18  import { FileTooLargeError, readFileInRange } from './readFileInRange.js'
  19  import { expandPath } from './path.js'
  20  import { countCharInString } from './stringUtils.js'
  21  import { count, uniq } from './array.js'
  22  import { getFsImplementation } from './fsOperations.js'
  23  import { readdir, stat } from 'fs/promises'
  24  import type { IDESelection } from '../hooks/useIdeSelection.js'
  25  import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
  26  import { TASK_CREATE_TOOL_NAME } from '../tools/TaskCreateTool/constants.js'
  27  import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js'
  28  import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
  29  import { SKILL_TOOL_NAME } from '../tools/SkillTool/constants.js'
  30  import type { TodoList } from './todo/types.js'
  31  import {
  32    type Task,
  33    listTasks,
  34    getTaskListId,
  35    isTodoV2Enabled,
  36  } from './tasks.js'
  37  import { getPlanFilePath, getPlan } from './plans.js'
  38  import { getConnectedIdeName } from './ide.js'
  39  import {
  40    filterInjectedMemoryFiles,
  41    getManagedAndUserConditionalRules,
  42    getMemoryFiles,
  43    getMemoryFilesForNestedDirectory,
  44    getConditionalRulesForCwdLevelDirectory,
  45    type MemoryFileInfo,
  46  } from './claudemd.js'
  47  import { dirname, parse, relative, resolve } from 'path'
  48  import { getCwd } from 'src/utils/cwd.js'
  49  import { getViewedTeammateTask } from '../state/selectors.js'
  50  import { logError } from './log.js'
  51  import { logAntError } from './debug.js'
  52  import { isENOENT, toError } from './errors.js'
  53  import type { DiagnosticFile } from '../services/diagnosticTracking.js'
  54  import { diagnosticTracker } from '../services/diagnosticTracking.js'
  55  import type {
  56    AttachmentMessage,
  57    Message,
  58    MessageOrigin,
  59  } from 'src/types/message.js'
  60  import {
  61    type QueuedCommand,
  62    getImagePasteIds,
  63    isValidImagePaste,
  64  } from 'src/types/textInputTypes.js'
  65  import { randomUUID, type UUID } from 'crypto'
  66  import { getSettings_DEPRECATED } from './settings/settings.js'
  67  import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js'
  68  import type {
  69    ContentBlockParam,
  70    ImageBlockParam,
  71    Base64ImageSource,
  72  } from '@anthropic-ai/sdk/resources/messages.mjs'
  73  import { maybeResizeAndDownsampleImageBlock } from './imageResizer.js'
  74  import type { PastedContent } from './config.js'
  75  import { getGlobalConfig } from './config.js'
  76  import {
  77    getDefaultSonnetModel,
  78    getDefaultHaikuModel,
  79    getDefaultOpusModel,
  80  } from './model/model.js'
  81  import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
  82  import { getSkillToolCommands, getMcpSkillCommands } from '../commands.js'
  83  import type { Command } from '../types/command.js'
  84  import uniqBy from 'lodash-es/uniqBy.js'
  85  import { getProjectRoot } from '../bootstrap/state.js'
  86  import { formatCommandsWithinBudget } from '../tools/SkillTool/prompt.js'
  87  import { getContextWindowForModel } from './context.js'
  88  import type { DiscoverySignal } from '../services/skillSearch/signals.js'
  89  // Conditional require for DCE. All skill-search string literals that would
  90  // otherwise leak into external builds live inside these modules. The only
  91  // surfaces in THIS file are: the maybe() call (gated via spread below) and
  92  // the skill_listing suppression check (uses the same skillSearchModules null
  93  // check). The type-only DiscoverySignal import above is erased at compile time.
  94  /* eslint-disable @typescript-eslint/no-require-imports */
  95  const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH')
  96    ? {
  97        featureCheck:
  98          require('../services/skillSearch/featureCheck.js') as typeof import('../services/skillSearch/featureCheck.js'),
  99        prefetch:
 100          require('../services/skillSearch/prefetch.js') as typeof import('../services/skillSearch/prefetch.js'),
 101      }
 102    : null
 103  const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
 104    ? (require('./permissions/autoModeState.js') as typeof import('./permissions/autoModeState.js'))
 105    : null
 106  /* eslint-enable @typescript-eslint/no-require-imports */
 107  import {
 108    MAX_LINES_TO_READ,
 109    FILE_READ_TOOL_NAME,
 110  } from 'src/tools/FileReadTool/prompt.js'
 111  import { getDefaultFileReadingLimits } from 'src/tools/FileReadTool/limits.js'
 112  import { cacheKeys, type FileStateCache } from './fileStateCache.js'
 113  import {
 114    createAbortController,
 115    createChildAbortController,
 116  } from './abortController.js'
 117  import { isAbortError } from './errors.js'
 118  import {
 119    getFileModificationTimeAsync,
 120    isFileWithinReadSizeLimit,
 121  } from './file.js'
 122  import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
 123  import { filterAgentsByMcpRequirements } from '../tools/AgentTool/loadAgentsDir.js'
 124  import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'
 125  import {
 126    formatAgentLine,
 127    shouldInjectAgentListInMessages,
 128  } from '../tools/AgentTool/prompt.js'
 129  import { filterDeniedAgents } from './permissions/permissions.js'
 130  import { getSubscriptionType } from './auth.js'
 131  import { mcpInfoFromString } from '../services/mcp/mcpStringUtils.js'
 132  import {
 133    matchingRuleForInput,
 134    pathInAllowedWorkingPath,
 135  } from './permissions/filesystem.js'
 136  import {
 137    generateTaskAttachments,
 138    applyTaskOffsetsAndEvictions,
 139  } from './task/framework.js'
 140  import { getTaskOutputPath } from './task/diskOutput.js'
 141  import { drainPendingMessages } from '../tasks/LocalAgentTask/LocalAgentTask.js'
 142  import type { TaskType, TaskStatus } from '../Task.js'
 143  import {
 144    getOriginalCwd,
 145    getSessionId,
 146    getSdkBetas,
 147    getTotalCostUSD,
 148    getTotalOutputTokens,
 149    getCurrentTurnTokenBudget,
 150    getTurnOutputTokens,
 151    hasExitedPlanModeInSession,
 152    setHasExitedPlanMode,
 153    needsPlanModeExitAttachment,
 154    setNeedsPlanModeExitAttachment,
 155    needsAutoModeExitAttachment,
 156    setNeedsAutoModeExitAttachment,
 157    getLastEmittedDate,
 158    setLastEmittedDate,
 159    getKairosActive,
 160  } from '../bootstrap/state.js'
 161  import type { QuerySource } from '../constants/querySource.js'
 162  import {
 163    getDeferredToolsDelta,
 164    isDeferredToolsDeltaEnabled,
 165    isToolSearchEnabledOptimistic,
 166    isToolSearchToolAvailable,
 167    modelSupportsToolReference,
 168    type DeferredToolsDeltaScanContext,
 169  } from './toolSearch.js'
 170  import {
 171    getMcpInstructionsDelta,
 172    isMcpInstructionsDeltaEnabled,
 173    type ClientSideInstruction,
 174  } from './mcpInstructionsDelta.js'
 175  import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from './claudeInChrome/common.js'
 176  import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from './claudeInChrome/prompt.js'
 177  import type { MCPServerConnection } from '../services/mcp/types.js'
 178  import type {
 179    HookEvent,
 180    SyncHookJSONOutput,
 181  } from 'src/entrypoints/agentSdkTypes.js'
 182  import {
 183    checkForAsyncHookResponses,
 184    removeDeliveredAsyncHooks,
 185  } from './hooks/AsyncHookRegistry.js'
 186  import {
 187    checkForLSPDiagnostics,
 188    clearAllLSPDiagnostics,
 189  } from '../services/lsp/LSPDiagnosticRegistry.js'
 190  import { logForDebugging } from './debug.js'
 191  import {
 192    extractTextContent,
 193    getUserMessageText,
 194    isThinkingMessage,
 195  } from './messages.js'
 196  import { isHumanTurn } from './messagePredicates.js'
 197  import { isEnvTruthy, getClaudeConfigHomeDir } from './envUtils.js'
 198  import { feature } from 'bun:bundle'
 199  /* eslint-disable @typescript-eslint/no-require-imports */
 200  const BRIEF_TOOL_NAME: string | null =
 201    feature('KAIROS') || feature('KAIROS_BRIEF')
 202      ? (
 203          require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js')
 204        ).BRIEF_TOOL_NAME
 205      : null
 206  const sessionTranscriptModule = feature('KAIROS')
 207    ? (require('../services/sessionTranscript/sessionTranscript.js') as typeof import('../services/sessionTranscript/sessionTranscript.js'))
 208    : null
 209  /* eslint-enable @typescript-eslint/no-require-imports */
 210  import { hasUltrathinkKeyword, isUltrathinkEnabled } from './thinking.js'
 211  import {
 212    tokenCountFromLastAPIResponse,
 213    tokenCountWithEstimation,
 214  } from './tokens.js'
 215  import {
 216    getEffectiveContextWindowSize,
 217    isAutoCompactEnabled,
 218  } from '../services/compact/autoCompact.js'
 219  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
 220  import {
 221    hasInstructionsLoadedHook,
 222    executeInstructionsLoadedHooks,
 223    type HookBlockingError,
 224    type InstructionsMemoryType,
 225  } from './hooks.js'
 226  import { jsonStringify } from './slowOperations.js'
 227  import { isPDFExtension } from './pdfUtils.js'
 228  import { getLocalISODate } from '../constants/common.js'
 229  import { getPDFPageCount } from './pdf.js'
 230  import { PDF_AT_MENTION_INLINE_THRESHOLD } from '../constants/apiLimits.js'
 231  import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
 232  import { findRelevantMemories } from '../memdir/findRelevantMemories.js'
 233  import { memoryAge, memoryFreshnessText } from '../memdir/memoryAge.js'
 234  import { getAutoMemPath, isAutoMemoryEnabled } from '../memdir/paths.js'
 235  import { getAgentMemoryDir } from '../tools/AgentTool/agentMemory.js'
 236  import {
 237    readUnreadMessages,
 238    markMessagesAsReadByPredicate,
 239    isShutdownApproved,
 240    isStructuredProtocolMessage,
 241    isIdleNotification,
 242  } from './teammateMailbox.js'
 243  import {
 244    getAgentName,
 245    getAgentId,
 246    getTeamName,
 247    isTeamLead,
 248  } from './teammate.js'
 249  import { isInProcessTeammate } from './teammateContext.js'
 250  import { removeTeammateFromTeamFile } from './swarm/teamHelpers.js'
 251  import { unassignTeammateTasks } from './tasks.js'
 252  import { getCompanionIntroAttachment } from '../buddy/prompt.js'
 253  
 254  export const TODO_REMINDER_CONFIG = {
 255    TURNS_SINCE_WRITE: 10,
 256    TURNS_BETWEEN_REMINDERS: 10,
 257  } as const
 258  
 259  export const PLAN_MODE_ATTACHMENT_CONFIG = {
 260    TURNS_BETWEEN_ATTACHMENTS: 5,
 261    FULL_REMINDER_EVERY_N_ATTACHMENTS: 5,
 262  } as const
 263  
 264  export const AUTO_MODE_ATTACHMENT_CONFIG = {
 265    TURNS_BETWEEN_ATTACHMENTS: 5,
 266    FULL_REMINDER_EVERY_N_ATTACHMENTS: 5,
 267  } as const
 268  
 269  const MAX_MEMORY_LINES = 200
 270  // Line cap alone doesn't bound size (200 × 500-char lines = 100KB).  The
 271  // surfacer injects up to 5 files per turn via <system-reminder>, bypassing
 272  // the per-message tool-result budget, so a tight per-file byte cap keeps
 273  // aggregate injection bounded (5 × 4KB = 20KB/turn).  Enforced via
 274  // readFileInRange's truncateOnByteLimit option.  Truncation means the
 275  // most-relevant memory still surfaces: the frontmatter + opening context
 276  // is usually what matters.
 277  const MAX_MEMORY_BYTES = 4096
 278  
 279  export const RELEVANT_MEMORIES_CONFIG = {
 280    // Per-turn cap (5 × 4KB = 20KB) bounds a single injection, but over a
 281    // long session the selector keeps surfacing distinct files — ~26K tokens/
 282    // session observed in prod.  Cap the cumulative bytes: once hit, stop
 283    // prefetching entirely.  Budget is ~3 full injections; after that the
 284    // most-relevant memories are already in context.  Scanning messages
 285    // (rather than tracking in toolUseContext) means compact naturally
 286    // resets the counter — old attachments are gone from context, so
 287    // re-surfacing is valid.
 288    MAX_SESSION_BYTES: 60 * 1024,
 289  } as const
 290  
 291  export const VERIFY_PLAN_REMINDER_CONFIG = {
 292    TURNS_BETWEEN_REMINDERS: 10,
 293  } as const
 294  
 295  export type FileAttachment = {
 296    type: 'file'
 297    filename: string
 298    content: FileReadToolOutput
 299    /**
 300     * Whether the file was truncated due to size limits
 301     */
 302    truncated?: boolean
 303    /** Path relative to CWD at creation time, for stable display */
 304    displayPath: string
 305  }
 306  
 307  export type CompactFileReferenceAttachment = {
 308    type: 'compact_file_reference'
 309    filename: string
 310    /** Path relative to CWD at creation time, for stable display */
 311    displayPath: string
 312  }
 313  
 314  export type PDFReferenceAttachment = {
 315    type: 'pdf_reference'
 316    filename: string
 317    pageCount: number
 318    fileSize: number
 319    /** Path relative to CWD at creation time, for stable display */
 320    displayPath: string
 321  }
 322  
 323  export type AlreadyReadFileAttachment = {
 324    type: 'already_read_file'
 325    filename: string
 326    content: FileReadToolOutput
 327    /**
 328     * Whether the file was truncated due to size limits
 329     */
 330    truncated?: boolean
 331    /** Path relative to CWD at creation time, for stable display */
 332    displayPath: string
 333  }
 334  
 335  export type AgentMentionAttachment = {
 336    type: 'agent_mention'
 337    agentType: string
 338  }
 339  
 340  export type AsyncHookResponseAttachment = {
 341    type: 'async_hook_response'
 342    processId: string
 343    hookName: string
 344    hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion'
 345    toolName?: string
 346    response: SyncHookJSONOutput
 347    stdout: string
 348    stderr: string
 349    exitCode?: number
 350  }
 351  
 352  export type HookAttachment =
 353    | HookCancelledAttachment
 354    | {
 355        type: 'hook_blocking_error'
 356        blockingError: HookBlockingError
 357        hookName: string
 358        toolUseID: string
 359        hookEvent: HookEvent
 360      }
 361    | HookNonBlockingErrorAttachment
 362    | HookErrorDuringExecutionAttachment
 363    | {
 364        type: 'hook_stopped_continuation'
 365        message: string
 366        hookName: string
 367        toolUseID: string
 368        hookEvent: HookEvent
 369      }
 370    | HookSuccessAttachment
 371    | {
 372        type: 'hook_additional_context'
 373        content: string[]
 374        hookName: string
 375        toolUseID: string
 376        hookEvent: HookEvent
 377      }
 378    | HookSystemMessageAttachment
 379    | HookPermissionDecisionAttachment
 380  
 381  export type HookPermissionDecisionAttachment = {
 382    type: 'hook_permission_decision'
 383    decision: 'allow' | 'deny'
 384    toolUseID: string
 385    hookEvent: HookEvent
 386  }
 387  
 388  export type HookSystemMessageAttachment = {
 389    type: 'hook_system_message'
 390    content: string
 391    hookName: string
 392    toolUseID: string
 393    hookEvent: HookEvent
 394  }
 395  
 396  export type HookCancelledAttachment = {
 397    type: 'hook_cancelled'
 398    hookName: string
 399    toolUseID: string
 400    hookEvent: HookEvent
 401    command?: string
 402    durationMs?: number
 403  }
 404  
 405  export type HookErrorDuringExecutionAttachment = {
 406    type: 'hook_error_during_execution'
 407    content: string
 408    hookName: string
 409    toolUseID: string
 410    hookEvent: HookEvent
 411    command?: string
 412    durationMs?: number
 413  }
 414  
 415  export type HookSuccessAttachment = {
 416    type: 'hook_success'
 417    content: string
 418    hookName: string
 419    toolUseID: string
 420    hookEvent: HookEvent
 421    stdout?: string
 422    stderr?: string
 423    exitCode?: number
 424    command?: string
 425    durationMs?: number
 426  }
 427  
 428  export type HookNonBlockingErrorAttachment = {
 429    type: 'hook_non_blocking_error'
 430    hookName: string
 431    stderr: string
 432    stdout: string
 433    exitCode: number
 434    toolUseID: string
 435    hookEvent: HookEvent
 436    command?: string
 437    durationMs?: number
 438  }
 439  
 440  export type Attachment =
 441    /**
 442     * User at-mentioned the file
 443     */
 444    | FileAttachment
 445    | CompactFileReferenceAttachment
 446    | PDFReferenceAttachment
 447    | AlreadyReadFileAttachment
 448    /**
 449     * An at-mentioned file was edited
 450     */
 451    | {
 452        type: 'edited_text_file'
 453        filename: string
 454        snippet: string
 455      }
 456    | {
 457        type: 'edited_image_file'
 458        filename: string
 459        content: FileReadToolOutput
 460      }
 461    | {
 462        type: 'directory'
 463        path: string
 464        content: string
 465        /** Path relative to CWD at creation time, for stable display */
 466        displayPath: string
 467      }
 468    | {
 469        type: 'selected_lines_in_ide'
 470        ideName: string
 471        lineStart: number
 472        lineEnd: number
 473        filename: string
 474        content: string
 475        /** Path relative to CWD at creation time, for stable display */
 476        displayPath: string
 477      }
 478    | {
 479        type: 'opened_file_in_ide'
 480        filename: string
 481      }
 482    | {
 483        type: 'todo_reminder'
 484        content: TodoList
 485        itemCount: number
 486      }
 487    | {
 488        type: 'task_reminder'
 489        content: Task[]
 490        itemCount: number
 491      }
 492    | {
 493        type: 'nested_memory'
 494        path: string
 495        content: MemoryFileInfo
 496        /** Path relative to CWD at creation time, for stable display */
 497        displayPath: string
 498      }
 499    | {
 500        type: 'relevant_memories'
 501        memories: {
 502          path: string
 503          content: string
 504          mtimeMs: number
 505          /**
 506           * Pre-computed header string (age + path prefix).  Computed once
 507           * at attachment-creation time so the rendered bytes are stable
 508           * across turns — recomputing memoryAge(mtimeMs) at render time
 509           * calls Date.now(), so "saved 3 days ago" becomes "saved 4 days
 510           * ago" across turns → different bytes → prompt cache bust.
 511           * Optional for backward compat with resumed sessions; render
 512           * path falls back to recomputing if missing.
 513           */
 514          header?: string
 515          /**
 516           * lineCount when the file was truncated by readMemoriesForSurfacing,
 517           * else undefined. Threaded to the readFileState write so
 518           * getChangedFiles skips truncated memories (partial content would
 519           * yield a misleading diff).
 520           */
 521          limit?: number
 522        }[]
 523      }
 524    | {
 525        type: 'dynamic_skill'
 526        skillDir: string
 527        skillNames: string[]
 528        /** Path relative to CWD at creation time, for stable display */
 529        displayPath: string
 530      }
 531    | {
 532        type: 'skill_listing'
 533        content: string
 534        skillCount: number
 535        isInitial: boolean
 536      }
 537    | {
 538        type: 'skill_discovery'
 539        skills: { name: string; description: string; shortId?: string }[]
 540        signal: DiscoverySignal
 541        source: 'native' | 'aki' | 'both'
 542      }
 543    | {
 544        type: 'queued_command'
 545        prompt: string | Array<ContentBlockParam>
 546        source_uuid?: UUID
 547        imagePasteIds?: number[]
 548        /** Original queue mode — 'prompt' for user messages, 'task-notification' for system events */
 549        commandMode?: string
 550        /** Provenance carried from QueuedCommand so mid-turn drains preserve it */
 551        origin?: MessageOrigin
 552        /** Carried from QueuedCommand.isMeta — distinguishes human-typed from system-injected */
 553        isMeta?: boolean
 554      }
 555    | {
 556        type: 'output_style'
 557        style: string
 558      }
 559    | {
 560        type: 'diagnostics'
 561        files: DiagnosticFile[]
 562        isNew: boolean
 563      }
 564    | {
 565        type: 'plan_mode'
 566        reminderType: 'full' | 'sparse'
 567        isSubAgent?: boolean
 568        planFilePath: string
 569        planExists: boolean
 570      }
 571    | {
 572        type: 'plan_mode_reentry'
 573        planFilePath: string
 574      }
 575    | {
 576        type: 'plan_mode_exit'
 577        planFilePath: string
 578        planExists: boolean
 579      }
 580    | {
 581        type: 'auto_mode'
 582        reminderType: 'full' | 'sparse'
 583      }
 584    | {
 585        type: 'auto_mode_exit'
 586      }
 587    | {
 588        type: 'critical_system_reminder'
 589        content: string
 590      }
 591    | {
 592        type: 'plan_file_reference'
 593        planFilePath: string
 594        planContent: string
 595      }
 596    | {
 597        type: 'mcp_resource'
 598        server: string
 599        uri: string
 600        name: string
 601        description?: string
 602        content: ReadResourceResult
 603      }
 604    | {
 605        type: 'command_permissions'
 606        allowedTools: string[]
 607        model?: string
 608      }
 609    | AgentMentionAttachment
 610    | {
 611        type: 'task_status'
 612        taskId: string
 613        taskType: TaskType
 614        status: TaskStatus
 615        description: string
 616        deltaSummary: string | null
 617        outputFilePath?: string
 618      }
 619    | AsyncHookResponseAttachment
 620    | {
 621        type: 'token_usage'
 622        used: number
 623        total: number
 624        remaining: number
 625      }
 626    | {
 627        type: 'budget_usd'
 628        used: number
 629        total: number
 630        remaining: number
 631      }
 632    | {
 633        type: 'output_token_usage'
 634        turn: number
 635        session: number
 636        budget: number | null
 637      }
 638    | {
 639        type: 'structured_output'
 640        data: unknown
 641      }
 642    | TeammateMailboxAttachment
 643    | TeamContextAttachment
 644    | HookAttachment
 645    | {
 646        type: 'invoked_skills'
 647        skills: Array<{
 648          name: string
 649          path: string
 650          content: string
 651        }>
 652      }
 653    | {
 654        type: 'verify_plan_reminder'
 655      }
 656    | {
 657        type: 'max_turns_reached'
 658        maxTurns: number
 659        turnCount: number
 660      }
 661    | {
 662        type: 'current_session_memory'
 663        content: string
 664        path: string
 665        tokenCount: number
 666      }
 667    | {
 668        type: 'teammate_shutdown_batch'
 669        count: number
 670      }
 671    | {
 672        type: 'compaction_reminder'
 673      }
 674    | {
 675        type: 'context_efficiency'
 676      }
 677    | {
 678        type: 'date_change'
 679        newDate: string
 680      }
 681    | {
 682        type: 'ultrathink_effort'
 683        level: 'high'
 684      }
 685    | {
 686        type: 'deferred_tools_delta'
 687        addedNames: string[]
 688        addedLines: string[]
 689        removedNames: string[]
 690      }
 691    | {
 692        type: 'agent_listing_delta'
 693        addedTypes: string[]
 694        addedLines: string[]
 695        removedTypes: string[]
 696        /** True when this is the first announcement in the conversation */
 697        isInitial: boolean
 698        /** Whether to include the "launch multiple agents concurrently" note (non-pro subscriptions) */
 699        showConcurrencyNote: boolean
 700      }
 701    | {
 702        type: 'mcp_instructions_delta'
 703        addedNames: string[]
 704        addedBlocks: string[]
 705        removedNames: string[]
 706      }
 707    | {
 708        type: 'companion_intro'
 709        name: string
 710        species: string
 711      }
 712    | {
 713        type: 'bagel_console'
 714        errorCount: number
 715        warningCount: number
 716        sample: string
 717      }
 718  
 719  export type TeammateMailboxAttachment = {
 720    type: 'teammate_mailbox'
 721    messages: Array<{
 722      from: string
 723      text: string
 724      timestamp: string
 725      color?: string
 726      summary?: string
 727    }>
 728  }
 729  
 730  export type TeamContextAttachment = {
 731    type: 'team_context'
 732    agentId: string
 733    agentName: string
 734    teamName: string
 735    teamConfigPath: string
 736    taskListPath: string
 737  }
 738  
 739  /**
 740   * This is janky
 741   * TODO: Generate attachments when we create messages
 742   */
 743  export async function getAttachments(
 744    input: string | null,
 745    toolUseContext: ToolUseContext,
 746    ideSelection: IDESelection | null,
 747    queuedCommands: QueuedCommand[],
 748    messages?: Message[],
 749    querySource?: QuerySource,
 750    options?: { skipSkillDiscovery?: boolean },
 751  ): Promise<Attachment[]> {
 752    if (
 753      isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS) ||
 754      isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
 755    ) {
 756      // query.ts:removeFromQueue dequeues these unconditionally after
 757      // getAttachmentMessages runs — returning [] here silently drops them.
 758      // Coworker runs with --bare and depends on task-notification for
 759      // mid-tool-call notifications from Local*Task/Remote*Task.
 760      return getQueuedCommandAttachments(queuedCommands)
 761    }
 762  
 763    // This will slow down submissions
 764    // TODO: Compute attachments as the user types, not here (though we use this
 765    // function for slash command prompts too)
 766    const abortController = createAbortController()
 767    const timeoutId = setTimeout(ac => ac.abort(), 1000, abortController)
 768    const context = { ...toolUseContext, abortController }
 769  
 770    const isMainThread = !toolUseContext.agentId
 771  
 772    // Attachments which are added in response to on user input
 773    const userInputAttachments = input
 774      ? [
 775          maybe('at_mentioned_files', () =>
 776            processAtMentionedFiles(input, context),
 777          ),
 778          maybe('mcp_resources', () =>
 779            processMcpResourceAttachments(input, context),
 780          ),
 781          maybe('agent_mentions', () =>
 782            Promise.resolve(
 783              processAgentMentions(
 784                input,
 785                toolUseContext.options.agentDefinitions.activeAgents,
 786              ),
 787            ),
 788          ),
 789          // Skill discovery on turn 0 (user input as signal). Inter-turn
 790          // discovery runs via startSkillDiscoveryPrefetch in query.ts,
 791          // gated on write-pivot detection — see skillSearch/prefetch.ts.
 792          // feature() here lets DCE drop the 'skill_discovery' string (and the
 793          // function it calls) from external builds.
 794          //
 795          // skipSkillDiscovery gates out the SKILL.md-expansion path
 796          // (getMessagesForPromptSlashCommand). When a skill is invoked, its
 797          // SKILL.md content is passed as `input` here to extract @-mentions —
 798          // but that content is NOT user intent and must not trigger discovery.
 799          // Without this gate, a 110KB SKILL.md fires ~3.3s of chunked AKI
 800          // queries on every skill invocation (session 13a9afae).
 801          ...(feature('EXPERIMENTAL_SKILL_SEARCH') &&
 802          skillSearchModules &&
 803          !options?.skipSkillDiscovery
 804            ? [
 805                maybe('skill_discovery', () =>
 806                  skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
 807                    input,
 808                    messages ?? [],
 809                    context,
 810                  ),
 811                ),
 812              ]
 813            : []),
 814        ]
 815      : []
 816  
 817    // Process user input attachments first (includes @mentioned files)
 818    // This ensures files are added to nestedMemoryAttachmentTriggers before nested_memory processes them
 819    const userAttachmentResults = await Promise.all(userInputAttachments)
 820  
 821    // Thread-safe attachments available in sub-agents
 822    // NOTE: These must be created AFTER userInputAttachments completes to ensure
 823    // nestedMemoryAttachmentTriggers is populated before getNestedMemoryAttachments runs
 824    const allThreadAttachments = [
 825      // queuedCommands is already agent-scoped by the drain gate in query.ts —
 826      // main thread gets agentId===undefined, subagents get their own agentId.
 827      // Must run for all threads or subagent notifications drain into the void
 828      // (removed from queue by removeFromQueue but never attached).
 829      maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)),
 830      maybe('date_change', () =>
 831        Promise.resolve(getDateChangeAttachments(messages)),
 832      ),
 833      maybe('ultrathink_effort', () =>
 834        Promise.resolve(getUltrathinkEffortAttachment(input)),
 835      ),
 836      maybe('deferred_tools_delta', () =>
 837        Promise.resolve(
 838          getDeferredToolsDeltaAttachment(
 839            toolUseContext.options.tools,
 840            toolUseContext.options.mainLoopModel,
 841            messages,
 842            {
 843              callSite: isMainThread
 844                ? 'attachments_main'
 845                : 'attachments_subagent',
 846              querySource,
 847            },
 848          ),
 849        ),
 850      ),
 851      maybe('agent_listing_delta', () =>
 852        Promise.resolve(getAgentListingDeltaAttachment(toolUseContext, messages)),
 853      ),
 854      maybe('mcp_instructions_delta', () =>
 855        Promise.resolve(
 856          getMcpInstructionsDeltaAttachment(
 857            toolUseContext.options.mcpClients,
 858            toolUseContext.options.tools,
 859            toolUseContext.options.mainLoopModel,
 860            messages,
 861          ),
 862        ),
 863      ),
 864      ...(feature('BUDDY')
 865        ? [
 866            maybe('companion_intro', () =>
 867              Promise.resolve(getCompanionIntroAttachment(messages)),
 868            ),
 869          ]
 870        : []),
 871      maybe('changed_files', () => getChangedFiles(context)),
 872      maybe('nested_memory', () => getNestedMemoryAttachments(context)),
 873      // relevant_memories moved to async prefetch (startRelevantMemoryPrefetch)
 874      maybe('dynamic_skill', () => getDynamicSkillAttachments(context)),
 875      maybe('skill_listing', () => getSkillListingAttachments(context)),
 876      // Inter-turn skill discovery now runs via startSkillDiscoveryPrefetch
 877      // (query.ts, concurrent with the main turn). The blocking call that
 878      // previously lived here was the assistant_turn signal — 97% of those
 879      // Haiku calls found nothing in prod. Prefetch + await-at-collection
 880      // replaces it; see src/services/skillSearch/prefetch.ts.
 881      maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)),
 882      maybe('plan_mode_exit', () => getPlanModeExitAttachment(toolUseContext)),
 883      ...(feature('TRANSCRIPT_CLASSIFIER')
 884        ? [
 885            maybe('auto_mode', () =>
 886              getAutoModeAttachments(messages, toolUseContext),
 887            ),
 888            maybe('auto_mode_exit', () =>
 889              getAutoModeExitAttachment(toolUseContext),
 890            ),
 891          ]
 892        : []),
 893      maybe('todo_reminders', () =>
 894        isTodoV2Enabled()
 895          ? getTaskReminderAttachments(messages, toolUseContext)
 896          : getTodoReminderAttachments(messages, toolUseContext),
 897      ),
 898      ...(isAgentSwarmsEnabled()
 899        ? [
 900            // Skip teammate mailbox for the session_memory forked agent.
 901            // It shares AppState.teamContext with the leader, so isTeamLead resolves
 902            // true and it reads+marks-as-read the leader's DMs as ephemeral attachments,
 903            // silently stealing messages that should be delivered as permanent turns.
 904            ...(querySource === 'session_memory'
 905              ? []
 906              : [
 907                  maybe('teammate_mailbox', async () =>
 908                    getTeammateMailboxAttachments(toolUseContext),
 909                  ),
 910                ]),
 911            maybe('team_context', async () =>
 912              getTeamContextAttachment(messages ?? []),
 913            ),
 914          ]
 915        : []),
 916      maybe('agent_pending_messages', async () =>
 917        getAgentPendingMessageAttachments(toolUseContext),
 918      ),
 919      maybe('critical_system_reminder', () =>
 920        Promise.resolve(getCriticalSystemReminderAttachment(toolUseContext)),
 921      ),
 922      ...(feature('COMPACTION_REMINDERS')
 923        ? [
 924            maybe('compaction_reminder', () =>
 925              Promise.resolve(
 926                getCompactionReminderAttachment(
 927                  messages ?? [],
 928                  toolUseContext.options.mainLoopModel,
 929                ),
 930              ),
 931            ),
 932          ]
 933        : []),
 934      ...(feature('HISTORY_SNIP')
 935        ? [
 936            maybe('context_efficiency', () =>
 937              Promise.resolve(getContextEfficiencyAttachment(messages ?? [])),
 938            ),
 939          ]
 940        : []),
 941    ]
 942  
 943    // Attachments which are semantically only for the main conversation or don't have concurrency-safe implementations
 944    const mainThreadAttachments = isMainThread
 945      ? [
 946          maybe('ide_selection', async () =>
 947            getSelectedLinesFromIDE(ideSelection, toolUseContext),
 948          ),
 949          maybe('ide_opened_file', async () =>
 950            getOpenedFileFromIDE(ideSelection, toolUseContext),
 951          ),
 952          maybe('output_style', async () =>
 953            Promise.resolve(getOutputStyleAttachment()),
 954          ),
 955          maybe('diagnostics', async () =>
 956            getDiagnosticAttachments(toolUseContext),
 957          ),
 958          maybe('lsp_diagnostics', async () =>
 959            getLSPDiagnosticAttachments(toolUseContext),
 960          ),
 961          maybe('unified_tasks', async () =>
 962            getUnifiedTaskAttachments(toolUseContext),
 963          ),
 964          maybe('async_hook_responses', async () =>
 965            getAsyncHookResponseAttachments(),
 966          ),
 967          maybe('token_usage', async () =>
 968            Promise.resolve(
 969              getTokenUsageAttachment(
 970                messages ?? [],
 971                toolUseContext.options.mainLoopModel,
 972              ),
 973            ),
 974          ),
 975          maybe('budget_usd', async () =>
 976            Promise.resolve(
 977              getMaxBudgetUsdAttachment(toolUseContext.options.maxBudgetUsd),
 978            ),
 979          ),
 980          maybe('output_token_usage', async () =>
 981            Promise.resolve(getOutputTokenUsageAttachment()),
 982          ),
 983          maybe('verify_plan_reminder', async () =>
 984            getVerifyPlanReminderAttachment(messages, toolUseContext),
 985          ),
 986        ]
 987      : []
 988  
 989    // Process thread and main thread attachments in parallel (no dependencies between them)
 990    const [threadAttachmentResults, mainThreadAttachmentResults] =
 991      await Promise.all([
 992        Promise.all(allThreadAttachments),
 993        Promise.all(mainThreadAttachments),
 994      ])
 995  
 996    clearTimeout(timeoutId)
 997    // Defensive: a getter leaking [undefined] crashes .map(a => a.type) below.
 998    return [
 999      ...userAttachmentResults.flat(),
1000      ...threadAttachmentResults.flat(),
1001      ...mainThreadAttachmentResults.flat(),
1002    ].filter(a => a !== undefined && a !== null)
1003  }
1004  
1005  async function maybe<A>(label: string, f: () => Promise<A[]>): Promise<A[]> {
1006    const startTime = Date.now()
1007    try {
1008      const result = await f()
1009      const duration = Date.now() - startTime
1010      // Log only 5% of events to reduce volume
1011      if (Math.random() < 0.05) {
1012        // jsonStringify(undefined) returns undefined, so .length would throw
1013        const attachmentSizeBytes = result
1014          .filter(a => a !== undefined && a !== null)
1015          .reduce((total, attachment) => {
1016            return total + jsonStringify(attachment).length
1017          }, 0)
1018        logEvent('tengu_attachment_compute_duration', {
1019          label,
1020          duration_ms: duration,
1021          attachment_size_bytes: attachmentSizeBytes,
1022          attachment_count: result.length,
1023        } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
1024      }
1025      return result
1026    } catch (e) {
1027      const duration = Date.now() - startTime
1028      // Log only 5% of events to reduce volume
1029      if (Math.random() < 0.05) {
1030        logEvent('tengu_attachment_compute_duration', {
1031          label,
1032          duration_ms: duration,
1033          error: true,
1034        } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
1035      }
1036      logError(e)
1037      // For Ant users, log the full error to help with debugging
1038      logAntError(`Attachment error in ${label}`, e)
1039  
1040      return []
1041    }
1042  }
1043  
1044  const INLINE_NOTIFICATION_MODES = new Set(['prompt', 'task-notification'])
1045  
1046  export async function getQueuedCommandAttachments(
1047    queuedCommands: QueuedCommand[],
1048  ): Promise<Attachment[]> {
1049    if (!queuedCommands) {
1050      return []
1051    }
1052    // Include both 'prompt' and 'task-notification' commands as attachments.
1053    // During proactive agentic loops, task-notification commands would otherwise
1054    // stay in the queue permanently (useQueueProcessor can't run while a query
1055    // is active), causing hasPendingNotifications() to return true and Sleep to
1056    // wake immediately with 0ms duration in an infinite loop.
1057    const filtered = queuedCommands.filter(_ =>
1058      INLINE_NOTIFICATION_MODES.has(_.mode),
1059    )
1060    return Promise.all(
1061      filtered.map(async _ => {
1062        const imageBlocks = await buildImageContentBlocks(_.pastedContents)
1063        let prompt: string | Array<ContentBlockParam> = _.value
1064        if (imageBlocks.length > 0) {
1065          // Build content block array with text + images so the model sees them
1066          const textValue =
1067            typeof _.value === 'string'
1068              ? _.value
1069              : extractTextContent(_.value, '\n')
1070          prompt = [{ type: 'text' as const, text: textValue }, ...imageBlocks]
1071        }
1072        return {
1073          type: 'queued_command' as const,
1074          prompt,
1075          source_uuid: _.uuid,
1076          imagePasteIds: getImagePasteIds(_.pastedContents),
1077          commandMode: _.mode,
1078          origin: _.origin,
1079          isMeta: _.isMeta,
1080        }
1081      }),
1082    )
1083  }
1084  
1085  export function getAgentPendingMessageAttachments(
1086    toolUseContext: ToolUseContext,
1087  ): Attachment[] {
1088    const agentId = toolUseContext.agentId
1089    if (!agentId) return []
1090    const drained = drainPendingMessages(
1091      agentId,
1092      toolUseContext.getAppState,
1093      toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState,
1094    )
1095    return drained.map(msg => ({
1096      type: 'queued_command' as const,
1097      prompt: msg,
1098      origin: { kind: 'coordinator' as const },
1099      isMeta: true,
1100    }))
1101  }
1102  
1103  async function buildImageContentBlocks(
1104    pastedContents: Record<number, PastedContent> | undefined,
1105  ): Promise<ImageBlockParam[]> {
1106    if (!pastedContents) {
1107      return []
1108    }
1109    const imageContents = Object.values(pastedContents).filter(isValidImagePaste)
1110    if (imageContents.length === 0) {
1111      return []
1112    }
1113    const results = await Promise.all(
1114      imageContents.map(async img => {
1115        const imageBlock: ImageBlockParam = {
1116          type: 'image',
1117          source: {
1118            type: 'base64',
1119            media_type: (img.mediaType ||
1120              'image/png') as Base64ImageSource['media_type'],
1121            data: img.content,
1122          },
1123        }
1124        const resized = await maybeResizeAndDownsampleImageBlock(imageBlock)
1125        return resized.block
1126      }),
1127    )
1128    return results
1129  }
1130  
1131  function getPlanModeAttachmentTurnCount(messages: Message[]): {
1132    turnCount: number
1133    foundPlanModeAttachment: boolean
1134  } {
1135    let turnsSinceLastAttachment = 0
1136    let foundPlanModeAttachment = false
1137  
1138    // Iterate backwards to find most recent plan_mode attachment.
1139    // Count HUMAN turns (non-meta, non-tool-result user messages), not assistant
1140    // messages — the tool loop in query.ts calls getAttachmentMessages on every
1141    // tool round, so counting assistant messages would fire the reminder every
1142    // 5 tool calls instead of every 5 human turns.
1143    for (let i = messages.length - 1; i >= 0; i--) {
1144      const message = messages[i]
1145  
1146      if (
1147        message?.type === 'user' &&
1148        !message.isMeta &&
1149        !hasToolResultContent(message.message.content)
1150      ) {
1151        turnsSinceLastAttachment++
1152      } else if (
1153        message?.type === 'attachment' &&
1154        (message.attachment.type === 'plan_mode' ||
1155          message.attachment.type === 'plan_mode_reentry')
1156      ) {
1157        foundPlanModeAttachment = true
1158        break
1159      }
1160    }
1161  
1162    return { turnCount: turnsSinceLastAttachment, foundPlanModeAttachment }
1163  }
1164  
1165  /**
1166   * Count plan_mode attachments since the last plan_mode_exit (or from start if no exit).
1167   * This ensures the full/sparse cycle resets when re-entering plan mode.
1168   */
1169  function countPlanModeAttachmentsSinceLastExit(messages: Message[]): number {
1170    let count = 0
1171    // Iterate backwards - if we hit a plan_mode_exit, stop counting
1172    for (let i = messages.length - 1; i >= 0; i--) {
1173      const message = messages[i]
1174      if (message?.type === 'attachment') {
1175        if (message.attachment.type === 'plan_mode_exit') {
1176          break // Stop counting at the last exit
1177        }
1178        if (message.attachment.type === 'plan_mode') {
1179          count++
1180        }
1181      }
1182    }
1183    return count
1184  }
1185  
1186  async function getPlanModeAttachments(
1187    messages: Message[] | undefined,
1188    toolUseContext: ToolUseContext,
1189  ): Promise<Attachment[]> {
1190    const appState = toolUseContext.getAppState()
1191    const permissionContext = appState.toolPermissionContext
1192    if (permissionContext.mode !== 'plan') {
1193      return []
1194    }
1195  
1196    // Check if we should attach based on turn count (except for first turn)
1197    if (messages && messages.length > 0) {
1198      const { turnCount, foundPlanModeAttachment } =
1199        getPlanModeAttachmentTurnCount(messages)
1200      // Only throttle if we've already sent a plan_mode attachment before
1201      // On first turn in plan mode, always attach
1202      if (
1203        foundPlanModeAttachment &&
1204        turnCount < PLAN_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS
1205      ) {
1206        return []
1207      }
1208    }
1209  
1210    const planFilePath = getPlanFilePath(toolUseContext.agentId)
1211    const existingPlan = getPlan(toolUseContext.agentId)
1212  
1213    const attachments: Attachment[] = []
1214  
1215    // Check for re-entry: flag is set AND plan file exists
1216    if (hasExitedPlanModeInSession() && existingPlan !== null) {
1217      attachments.push({ type: 'plan_mode_reentry', planFilePath })
1218      setHasExitedPlanMode(false) // Clear flag - one-time guidance
1219    }
1220  
1221    // Determine if this should be a full or sparse reminder
1222    // Full reminder on 1st, 6th, 11th... (every Nth attachment)
1223    const attachmentCount =
1224      countPlanModeAttachmentsSinceLastExit(messages ?? []) + 1
1225    const reminderType: 'full' | 'sparse' =
1226      attachmentCount %
1227        PLAN_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS ===
1228      1
1229        ? 'full'
1230        : 'sparse'
1231  
1232    // Always add the main plan_mode attachment
1233    attachments.push({
1234      type: 'plan_mode',
1235      reminderType,
1236      isSubAgent: !!toolUseContext.agentId,
1237      planFilePath,
1238      planExists: existingPlan !== null,
1239    })
1240  
1241    return attachments
1242  }
1243  
1244  /**
1245   * Returns a plan_mode_exit attachment if we just exited plan mode.
1246   * This is a one-time notification to tell the model it's no longer in plan mode.
1247   */
1248  async function getPlanModeExitAttachment(
1249    toolUseContext: ToolUseContext,
1250  ): Promise<Attachment[]> {
1251    // Only trigger if the flag is set (we just exited plan mode)
1252    if (!needsPlanModeExitAttachment()) {
1253      return []
1254    }
1255  
1256    const appState = toolUseContext.getAppState()
1257    if (appState.toolPermissionContext.mode === 'plan') {
1258      setNeedsPlanModeExitAttachment(false)
1259      return []
1260    }
1261  
1262    // Clear the flag - this is a one-time notification
1263    setNeedsPlanModeExitAttachment(false)
1264  
1265    const planFilePath = getPlanFilePath(toolUseContext.agentId)
1266    const planExists = getPlan(toolUseContext.agentId) !== null
1267  
1268    // Note: skill discovery does NOT fire on plan exit. By the time the plan is
1269    // written, it's too late — the model should have had relevant skills WHILE
1270    // planning. The user_message signal already fires on the request that
1271    // triggers planning ("plan how to deploy this"), which is the right moment.
1272    return [{ type: 'plan_mode_exit', planFilePath, planExists }]
1273  }
1274  
1275  function getAutoModeAttachmentTurnCount(messages: Message[]): {
1276    turnCount: number
1277    foundAutoModeAttachment: boolean
1278  } {
1279    let turnsSinceLastAttachment = 0
1280    let foundAutoModeAttachment = false
1281  
1282    // Iterate backwards to find most recent auto_mode attachment.
1283    // Count HUMAN turns (non-meta, non-tool-result user messages), not assistant
1284    // messages — the tool loop in query.ts calls getAttachmentMessages on every
1285    // tool round, so a single human turn with 100 tool calls would fire ~20
1286    // reminders if we counted assistant messages. Auto mode's target use case is
1287    // long agentic sessions, where this accumulated 60-105× per session.
1288    for (let i = messages.length - 1; i >= 0; i--) {
1289      const message = messages[i]
1290  
1291      if (
1292        message?.type === 'user' &&
1293        !message.isMeta &&
1294        !hasToolResultContent(message.message.content)
1295      ) {
1296        turnsSinceLastAttachment++
1297      } else if (
1298        message?.type === 'attachment' &&
1299        message.attachment.type === 'auto_mode'
1300      ) {
1301        foundAutoModeAttachment = true
1302        break
1303      } else if (
1304        message?.type === 'attachment' &&
1305        message.attachment.type === 'auto_mode_exit'
1306      ) {
1307        // Exit resets the throttle — treat as if no prior attachment exists
1308        break
1309      }
1310    }
1311  
1312    return { turnCount: turnsSinceLastAttachment, foundAutoModeAttachment }
1313  }
1314  
1315  /**
1316   * Count auto_mode attachments since the last auto_mode_exit (or from start if no exit).
1317   * This ensures the full/sparse cycle resets when re-entering auto mode.
1318   */
1319  function countAutoModeAttachmentsSinceLastExit(messages: Message[]): number {
1320    let count = 0
1321    for (let i = messages.length - 1; i >= 0; i--) {
1322      const message = messages[i]
1323      if (message?.type === 'attachment') {
1324        if (message.attachment.type === 'auto_mode_exit') {
1325          break
1326        }
1327        if (message.attachment.type === 'auto_mode') {
1328          count++
1329        }
1330      }
1331    }
1332    return count
1333  }
1334  
1335  async function getAutoModeAttachments(
1336    messages: Message[] | undefined,
1337    toolUseContext: ToolUseContext,
1338  ): Promise<Attachment[]> {
1339    const appState = toolUseContext.getAppState()
1340    const permissionContext = appState.toolPermissionContext
1341    const inAuto = permissionContext.mode === 'auto'
1342    const inPlanWithAuto =
1343      permissionContext.mode === 'plan' &&
1344      (autoModeStateModule?.isAutoModeActive() ?? false)
1345    if (!inAuto && !inPlanWithAuto) {
1346      return []
1347    }
1348  
1349    // Check if we should attach based on turn count (except for first turn)
1350    if (messages && messages.length > 0) {
1351      const { turnCount, foundAutoModeAttachment } =
1352        getAutoModeAttachmentTurnCount(messages)
1353      // Only throttle if we've already sent an auto_mode attachment before
1354      // On first turn in auto mode, always attach
1355      if (
1356        foundAutoModeAttachment &&
1357        turnCount < AUTO_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS
1358      ) {
1359        return []
1360      }
1361    }
1362  
1363    // Determine if this should be a full or sparse reminder
1364    const attachmentCount =
1365      countAutoModeAttachmentsSinceLastExit(messages ?? []) + 1
1366    const reminderType: 'full' | 'sparse' =
1367      attachmentCount %
1368        AUTO_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS ===
1369      1
1370        ? 'full'
1371        : 'sparse'
1372  
1373    return [{ type: 'auto_mode', reminderType }]
1374  }
1375  
1376  /**
1377   * Returns an auto_mode_exit attachment if we just exited auto mode.
1378   * This is a one-time notification to tell the model it's no longer in auto mode.
1379   */
1380  async function getAutoModeExitAttachment(
1381    toolUseContext: ToolUseContext,
1382  ): Promise<Attachment[]> {
1383    if (!needsAutoModeExitAttachment()) {
1384      return []
1385    }
1386  
1387    const appState = toolUseContext.getAppState()
1388    // Suppress when auto is still active — covers both mode==='auto' and
1389    // plan-with-auto-active (where mode==='plan' but classifier runs).
1390    if (
1391      appState.toolPermissionContext.mode === 'auto' ||
1392      (autoModeStateModule?.isAutoModeActive() ?? false)
1393    ) {
1394      setNeedsAutoModeExitAttachment(false)
1395      return []
1396    }
1397  
1398    setNeedsAutoModeExitAttachment(false)
1399    return [{ type: 'auto_mode_exit' }]
1400  }
1401  
1402  /**
1403   * Detects when the local date has changed since the last turn (user coding
1404   * past midnight) and emits an attachment to notify the model.
1405   *
1406   * The date_change attachment is appended at the tail of the conversation,
1407   * so the model learns the new date without mutating the cached prefix.
1408   * messages[0] (from getUserContext → prependUserContext) intentionally
1409   * keeps the stale date — clearing that cache would regenerate the prefix
1410   * and turn the entire conversation into cache_creation on the next turn
1411   * (~920K effective tokens per midnight crossing per overnight session).
1412   *
1413   * Exported for testing — regression guard for the cache-clear removal.
1414   */
1415  export function getDateChangeAttachments(
1416    messages: Message[] | undefined,
1417  ): Attachment[] {
1418    const currentDate = getLocalISODate()
1419    const lastDate = getLastEmittedDate()
1420  
1421    if (lastDate === null) {
1422      // First turn — just record, no attachment needed
1423      setLastEmittedDate(currentDate)
1424      return []
1425    }
1426  
1427    if (currentDate === lastDate) {
1428      return []
1429    }
1430  
1431    setLastEmittedDate(currentDate)
1432  
1433    // Assistant mode: flush yesterday's transcript to the per-day file so
1434    // the /dream skill (1–5am local) finds it even if no compaction fires
1435    // today. Fire-and-forget; writeSessionTranscriptSegment buckets by
1436    // message timestamp so a multi-day gap flushes each day correctly.
1437    if (feature('KAIROS')) {
1438      if (getKairosActive() && messages !== undefined) {
1439        sessionTranscriptModule?.flushOnDateChange(messages, currentDate)
1440      }
1441    }
1442  
1443    return [{ type: 'date_change', newDate: currentDate }]
1444  }
1445  
1446  function getUltrathinkEffortAttachment(input: string | null): Attachment[] {
1447    if (!isUltrathinkEnabled() || !input || !hasUltrathinkKeyword(input)) {
1448      return []
1449    }
1450    logEvent('tengu_ultrathink', {})
1451    return [{ type: 'ultrathink_effort', level: 'high' }]
1452  }
1453  
1454  // Exported for compact.ts — the gate must be identical at both call sites.
1455  export function getDeferredToolsDeltaAttachment(
1456    tools: Tools,
1457    model: string,
1458    messages: Message[] | undefined,
1459    scanContext?: DeferredToolsDeltaScanContext,
1460  ): Attachment[] {
1461    if (!isDeferredToolsDeltaEnabled()) return []
1462    // These three checks mirror the sync parts of isToolSearchEnabled —
1463    // the attachment text says "available via ToolSearch", so ToolSearch
1464    // has to actually be in the request. The async auto-threshold check
1465    // is not replicated (would double-fire tengu_tool_search_mode_decision);
1466    // in tst-auto below-threshold the attachment can fire while ToolSearch
1467    // is filtered out, but that's a narrow case and the tools announced
1468    // are directly callable anyway.
1469    if (!isToolSearchEnabledOptimistic()) return []
1470    if (!modelSupportsToolReference(model)) return []
1471    if (!isToolSearchToolAvailable(tools)) return []
1472    const delta = getDeferredToolsDelta(tools, messages ?? [], scanContext)
1473    if (!delta) return []
1474    return [{ type: 'deferred_tools_delta', ...delta }]
1475  }
1476  
1477  /**
1478   * Diff the current filtered agent pool against what's already been announced
1479   * in this conversation (reconstructed from prior agent_listing_delta
1480   * attachments). Returns [] if nothing changed or the gate is off.
1481   *
1482   * The agent list was embedded in AgentTool's description, causing ~10.2% of
1483   * fleet cache_creation: MCP async connect, /reload-plugins, or
1484   * permission-mode change → description changes → full tool-schema cache bust.
1485   * Moving the list here keeps the tool description static.
1486   *
1487   * Exported for compact.ts — re-announces the full set after compaction eats
1488   * prior deltas.
1489   */
1490  export function getAgentListingDeltaAttachment(
1491    toolUseContext: ToolUseContext,
1492    messages: Message[] | undefined,
1493  ): Attachment[] {
1494    if (!shouldInjectAgentListInMessages()) return []
1495  
1496    // Skip if AgentTool isn't in the pool — the listing would be unactionable.
1497    if (
1498      !toolUseContext.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME))
1499    ) {
1500      return []
1501    }
1502  
1503    const { activeAgents, allowedAgentTypes } =
1504      toolUseContext.options.agentDefinitions
1505  
1506    // Mirror AgentTool.prompt()'s filtering: MCP requirements → deny rules →
1507    // allowedAgentTypes restriction. Keep this in sync with AgentTool.tsx.
1508    const mcpServers = new Set<string>()
1509    for (const tool of toolUseContext.options.tools) {
1510      const info = mcpInfoFromString(tool.name)
1511      if (info) mcpServers.add(info.serverName)
1512    }
1513    const permissionContext = toolUseContext.getAppState().toolPermissionContext
1514    let filtered = filterDeniedAgents(
1515      filterAgentsByMcpRequirements(activeAgents, [...mcpServers]),
1516      permissionContext,
1517      AGENT_TOOL_NAME,
1518    )
1519    if (allowedAgentTypes) {
1520      filtered = filtered.filter(a => allowedAgentTypes.includes(a.agentType))
1521    }
1522  
1523    // Reconstruct announced set from prior deltas in the transcript.
1524    const announced = new Set<string>()
1525    for (const msg of messages ?? []) {
1526      if (msg.type !== 'attachment') continue
1527      if (msg.attachment.type !== 'agent_listing_delta') continue
1528      for (const t of msg.attachment.addedTypes) announced.add(t)
1529      for (const t of msg.attachment.removedTypes) announced.delete(t)
1530    }
1531  
1532    const currentTypes = new Set(filtered.map(a => a.agentType))
1533    const added = filtered.filter(a => !announced.has(a.agentType))
1534    const removed: string[] = []
1535    for (const t of announced) {
1536      if (!currentTypes.has(t)) removed.push(t)
1537    }
1538  
1539    if (added.length === 0 && removed.length === 0) return []
1540  
1541    // Sort for deterministic output — agent load order is nondeterministic
1542    // (plugin load races, MCP async connect).
1543    added.sort((a, b) => a.agentType.localeCompare(b.agentType))
1544    removed.sort()
1545  
1546    return [
1547      {
1548        type: 'agent_listing_delta',
1549        addedTypes: added.map(a => a.agentType),
1550        addedLines: added.map(formatAgentLine),
1551        removedTypes: removed,
1552        isInitial: announced.size === 0,
1553        showConcurrencyNote: getSubscriptionType() !== 'pro',
1554      },
1555    ]
1556  }
1557  
1558  // Exported for compact.ts / reactiveCompact.ts — single source of truth for the gate.
1559  export function getMcpInstructionsDeltaAttachment(
1560    mcpClients: MCPServerConnection[],
1561    tools: Tools,
1562    model: string,
1563    messages: Message[] | undefined,
1564  ): Attachment[] {
1565    if (!isMcpInstructionsDeltaEnabled()) return []
1566  
1567    // The chrome ToolSearch hint is client-authored and ToolSearch-conditional;
1568    // actual server `instructions` are unconditional. Decide the chrome part
1569    // here, pass it into the pure diff as a synthesized entry.
1570    const clientSide: ClientSideInstruction[] = []
1571    if (
1572      isToolSearchEnabledOptimistic() &&
1573      modelSupportsToolReference(model) &&
1574      isToolSearchToolAvailable(tools)
1575    ) {
1576      clientSide.push({
1577        serverName: CLAUDE_IN_CHROME_MCP_SERVER_NAME,
1578        block: CHROME_TOOL_SEARCH_INSTRUCTIONS,
1579      })
1580    }
1581  
1582    const delta = getMcpInstructionsDelta(mcpClients, messages ?? [], clientSide)
1583    if (!delta) return []
1584    return [{ type: 'mcp_instructions_delta', ...delta }]
1585  }
1586  
1587  function getCriticalSystemReminderAttachment(
1588    toolUseContext: ToolUseContext,
1589  ): Attachment[] {
1590    const reminder = toolUseContext.criticalSystemReminder_EXPERIMENTAL
1591    if (!reminder) {
1592      return []
1593    }
1594    return [{ type: 'critical_system_reminder', content: reminder }]
1595  }
1596  
1597  function getOutputStyleAttachment(): Attachment[] {
1598    const settings = getSettings_DEPRECATED()
1599    const outputStyle = settings?.outputStyle || 'default'
1600  
1601    // Only show for non-default styles
1602    if (outputStyle === 'default') {
1603      return []
1604    }
1605  
1606    return [
1607      {
1608        type: 'output_style',
1609        style: outputStyle,
1610      },
1611    ]
1612  }
1613  
1614  async function getSelectedLinesFromIDE(
1615    ideSelection: IDESelection | null,
1616    toolUseContext: ToolUseContext,
1617  ): Promise<Attachment[]> {
1618    const ideName = getConnectedIdeName(toolUseContext.options.mcpClients)
1619    if (
1620      !ideName ||
1621      ideSelection?.lineStart === undefined ||
1622      !ideSelection.text ||
1623      !ideSelection.filePath
1624    ) {
1625      return []
1626    }
1627  
1628    const appState = toolUseContext.getAppState()
1629    if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) {
1630      return []
1631    }
1632  
1633    return [
1634      {
1635        type: 'selected_lines_in_ide',
1636        ideName,
1637        lineStart: ideSelection.lineStart,
1638        lineEnd: ideSelection.lineStart + ideSelection.lineCount - 1,
1639        filename: ideSelection.filePath,
1640        content: ideSelection.text,
1641        displayPath: relative(getCwd(), ideSelection.filePath),
1642      },
1643    ]
1644  }
1645  
1646  /**
1647   * Computes the directories to process for nested memory file loading.
1648   * Returns two lists:
1649   * - nestedDirs: Directories between CWD and targetPath (processed for CLAUDE.md + all rules)
1650   * - cwdLevelDirs: Directories from root to CWD (processed for conditional rules only)
1651   *
1652   * @param targetPath The target file path
1653   * @param originalCwd The original current working directory
1654   * @returns Object with nestedDirs and cwdLevelDirs arrays, both ordered from parent to child
1655   */
1656  export function getDirectoriesToProcess(
1657    targetPath: string,
1658    originalCwd: string,
1659  ): { nestedDirs: string[]; cwdLevelDirs: string[] } {
1660    // Build list of directories from original CWD to targetPath's directory
1661    const targetDir = dirname(resolve(targetPath))
1662    const nestedDirs: string[] = []
1663    let currentDir = targetDir
1664  
1665    // Walk up from target directory to original CWD
1666    while (currentDir !== originalCwd && currentDir !== parse(currentDir).root) {
1667      if (currentDir.startsWith(originalCwd)) {
1668        nestedDirs.push(currentDir)
1669      }
1670      currentDir = dirname(currentDir)
1671    }
1672  
1673    // Reverse to get order from CWD down to target
1674    nestedDirs.reverse()
1675  
1676    // Build list of directories from root to CWD (for conditional rules only)
1677    const cwdLevelDirs: string[] = []
1678    currentDir = originalCwd
1679  
1680    while (currentDir !== parse(currentDir).root) {
1681      cwdLevelDirs.push(currentDir)
1682      currentDir = dirname(currentDir)
1683    }
1684  
1685    // Reverse to get order from root to CWD
1686    cwdLevelDirs.reverse()
1687  
1688    return { nestedDirs, cwdLevelDirs }
1689  }
1690  
1691  /**
1692   * Converts memory files to attachments, filtering out already-loaded files.
1693   *
1694   * @param memoryFiles The memory files to convert
1695   * @param toolUseContext The tool use context (for tracking loaded files)
1696   * @returns Array of nested memory attachments
1697   */
1698  function isInstructionsMemoryType(
1699    type: MemoryFileInfo['type'],
1700  ): type is InstructionsMemoryType {
1701    return (
1702      type === 'User' ||
1703      type === 'Project' ||
1704      type === 'Local' ||
1705      type === 'Managed'
1706    )
1707  }
1708  
1709  /** Exported for testing — regression guard for LRU-eviction re-injection. */
1710  export function memoryFilesToAttachments(
1711    memoryFiles: MemoryFileInfo[],
1712    toolUseContext: ToolUseContext,
1713    triggerFilePath?: string,
1714  ): Attachment[] {
1715    const attachments: Attachment[] = []
1716    const shouldFireHook = hasInstructionsLoadedHook()
1717  
1718    for (const memoryFile of memoryFiles) {
1719      // Dedup: loadedNestedMemoryPaths is a non-evicting Set; readFileState
1720      // is a 100-entry LRU that drops entries in busy sessions, so relying
1721      // on it alone re-injects the same CLAUDE.md on every eviction cycle.
1722      if (toolUseContext.loadedNestedMemoryPaths?.has(memoryFile.path)) {
1723        continue
1724      }
1725      if (!toolUseContext.readFileState.has(memoryFile.path)) {
1726        attachments.push({
1727          type: 'nested_memory',
1728          path: memoryFile.path,
1729          content: memoryFile,
1730          displayPath: relative(getCwd(), memoryFile.path),
1731        })
1732        toolUseContext.loadedNestedMemoryPaths?.add(memoryFile.path)
1733  
1734        // Mark as loaded in readFileState — this provides cross-function and
1735        // cross-turn dedup via the .has() check above.
1736        //
1737        // When the injected content doesn't match disk (stripped HTML comments,
1738        // stripped frontmatter, truncated MEMORY.md), cache the RAW disk bytes
1739        // with `isPartialView: true`. Edit/Write see the flag and require a real
1740        // Read first; getChangedFiles sees real content + undefined offset/limit
1741        // so mid-session change detection still works.
1742        toolUseContext.readFileState.set(memoryFile.path, {
1743          content: memoryFile.contentDiffersFromDisk
1744            ? (memoryFile.rawContent ?? memoryFile.content)
1745            : memoryFile.content,
1746          timestamp: Date.now(),
1747          offset: undefined,
1748          limit: undefined,
1749          isPartialView: memoryFile.contentDiffersFromDisk,
1750        })
1751  
1752  
1753        // Fire InstructionsLoaded hook for audit/observability (fire-and-forget)
1754        if (shouldFireHook && isInstructionsMemoryType(memoryFile.type)) {
1755          const loadReason = memoryFile.globs
1756            ? 'path_glob_match'
1757            : memoryFile.parent
1758              ? 'include'
1759              : 'nested_traversal'
1760          void executeInstructionsLoadedHooks(
1761            memoryFile.path,
1762            memoryFile.type,
1763            loadReason,
1764            {
1765              globs: memoryFile.globs,
1766              triggerFilePath,
1767              parentFilePath: memoryFile.parent,
1768            },
1769          )
1770        }
1771      }
1772    }
1773  
1774    return attachments
1775  }
1776  
1777  /**
1778   * Loads nested memory files for a given file path and returns them as attachments.
1779   * This function performs directory traversal to find CLAUDE.md files and conditional rules
1780   * that apply to the target file path.
1781   *
1782   * Processing order (must be preserved):
1783   * 1. Managed/User conditional rules matching targetPath
1784   * 2. Nested directories (CWD → target): CLAUDE.md + unconditional + conditional rules
1785   * 3. CWD-level directories (root → CWD): conditional rules only
1786   *
1787   * @param filePath The file path to get nested memory files for
1788   * @param toolUseContext The tool use context
1789   * @param appState The app state containing tool permission context
1790   * @returns Array of nested memory attachments
1791   */
1792  async function getNestedMemoryAttachmentsForFile(
1793    filePath: string,
1794    toolUseContext: ToolUseContext,
1795    appState: { toolPermissionContext: ToolPermissionContext },
1796  ): Promise<Attachment[]> {
1797    const attachments: Attachment[] = []
1798  
1799    try {
1800      // Early return if path is not in allowed working path
1801      if (!pathInAllowedWorkingPath(filePath, appState.toolPermissionContext)) {
1802        return attachments
1803      }
1804  
1805      const processedPaths = new Set<string>()
1806      const originalCwd = getOriginalCwd()
1807  
1808      // Phase 1: Process Managed and User conditional rules
1809      const managedUserRules = await getManagedAndUserConditionalRules(
1810        filePath,
1811        processedPaths,
1812      )
1813      attachments.push(
1814        ...memoryFilesToAttachments(managedUserRules, toolUseContext, filePath),
1815      )
1816  
1817      // Phase 2: Get directories to process
1818      const { nestedDirs, cwdLevelDirs } = getDirectoriesToProcess(
1819        filePath,
1820        originalCwd,
1821      )
1822  
1823      const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE(
1824        'tengu_paper_halyard',
1825        false,
1826      )
1827  
1828      // Phase 3: Process nested directories (CWD → target)
1829      // Each directory gets: CLAUDE.md + unconditional rules + conditional rules
1830      for (const dir of nestedDirs) {
1831        const memoryFiles = (
1832          await getMemoryFilesForNestedDirectory(dir, filePath, processedPaths)
1833        ).filter(
1834          f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'),
1835        )
1836        attachments.push(
1837          ...memoryFilesToAttachments(memoryFiles, toolUseContext, filePath),
1838        )
1839      }
1840  
1841      // Phase 4: Process CWD-level directories (root → CWD)
1842      // Only conditional rules (unconditional rules are already loaded eagerly)
1843      for (const dir of cwdLevelDirs) {
1844        const conditionalRules = (
1845          await getConditionalRulesForCwdLevelDirectory(
1846            dir,
1847            filePath,
1848            processedPaths,
1849          )
1850        ).filter(
1851          f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'),
1852        )
1853        attachments.push(
1854          ...memoryFilesToAttachments(conditionalRules, toolUseContext, filePath),
1855        )
1856      }
1857    } catch (error) {
1858      logError(error)
1859    }
1860  
1861    return attachments
1862  }
1863  
1864  async function getOpenedFileFromIDE(
1865    ideSelection: IDESelection | null,
1866    toolUseContext: ToolUseContext,
1867  ): Promise<Attachment[]> {
1868    if (!ideSelection?.filePath || ideSelection.text) {
1869      return []
1870    }
1871  
1872    const appState = toolUseContext.getAppState()
1873    if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) {
1874      return []
1875    }
1876  
1877    // Get nested memory files
1878    const nestedMemoryAttachments = await getNestedMemoryAttachmentsForFile(
1879      ideSelection.filePath,
1880      toolUseContext,
1881      appState,
1882    )
1883  
1884    // Return nested memory attachments followed by the opened file attachment
1885    return [
1886      ...nestedMemoryAttachments,
1887      {
1888        type: 'opened_file_in_ide',
1889        filename: ideSelection.filePath,
1890      },
1891    ]
1892  }
1893  
1894  async function processAtMentionedFiles(
1895    input: string,
1896    toolUseContext: ToolUseContext,
1897  ): Promise<Attachment[]> {
1898    const files = extractAtMentionedFiles(input)
1899    if (files.length === 0) return []
1900  
1901    const appState = toolUseContext.getAppState()
1902    const results = await Promise.all(
1903      files.map(async file => {
1904        try {
1905          const { filename, lineStart, lineEnd } = parseAtMentionedFileLines(file)
1906          const absoluteFilename = expandPath(filename)
1907  
1908          if (
1909            isFileReadDenied(absoluteFilename, appState.toolPermissionContext)
1910          ) {
1911            return null
1912          }
1913  
1914          // Check if it's a directory
1915          try {
1916            const stats = await stat(absoluteFilename)
1917            if (stats.isDirectory()) {
1918              try {
1919                const entries = await readdir(absoluteFilename, {
1920                  withFileTypes: true,
1921                })
1922                const MAX_DIR_ENTRIES = 1000
1923                const truncated = entries.length > MAX_DIR_ENTRIES
1924                const names = entries.slice(0, MAX_DIR_ENTRIES).map(e => e.name)
1925                if (truncated) {
1926                  names.push(
1927                    `\u2026 and ${entries.length - MAX_DIR_ENTRIES} more entries`,
1928                  )
1929                }
1930                const stdout = names.join('\n')
1931                logEvent('tengu_at_mention_extracting_directory_success', {})
1932  
1933                return {
1934                  type: 'directory' as const,
1935                  path: absoluteFilename,
1936                  content: stdout,
1937                  displayPath: relative(getCwd(), absoluteFilename),
1938                }
1939              } catch {
1940                return null
1941              }
1942            }
1943          } catch {
1944            // If stat fails, continue with file logic
1945          }
1946  
1947          return await generateFileAttachment(
1948            absoluteFilename,
1949            toolUseContext,
1950            'tengu_at_mention_extracting_filename_success',
1951            'tengu_at_mention_extracting_filename_error',
1952            'at-mention',
1953            {
1954              offset: lineStart,
1955              limit: lineEnd && lineStart ? lineEnd - lineStart + 1 : undefined,
1956            },
1957          )
1958        } catch {
1959          logEvent('tengu_at_mention_extracting_filename_error', {})
1960        }
1961      }),
1962    )
1963    return results.filter(Boolean) as Attachment[]
1964  }
1965  
1966  function processAgentMentions(
1967    input: string,
1968    agents: AgentDefinition[],
1969  ): Attachment[] {
1970    const agentMentions = extractAgentMentions(input)
1971    if (agentMentions.length === 0) return []
1972  
1973    const results = agentMentions.map(mention => {
1974      const agentType = mention.replace('agent-', '')
1975      const agentDef = agents.find(def => def.agentType === agentType)
1976  
1977      if (!agentDef) {
1978        logEvent('tengu_at_mention_agent_not_found', {})
1979        return null
1980      }
1981  
1982      logEvent('tengu_at_mention_agent_success', {})
1983  
1984      return {
1985        type: 'agent_mention' as const,
1986        agentType: agentDef.agentType,
1987      }
1988    })
1989  
1990    return results.filter(
1991      (result): result is NonNullable<typeof result> => result !== null,
1992    )
1993  }
1994  
1995  async function processMcpResourceAttachments(
1996    input: string,
1997    toolUseContext: ToolUseContext,
1998  ): Promise<Attachment[]> {
1999    const resourceMentions = extractMcpResourceMentions(input)
2000    if (resourceMentions.length === 0) return []
2001  
2002    const mcpClients = toolUseContext.options.mcpClients || []
2003  
2004    const results = await Promise.all(
2005      resourceMentions.map(async mention => {
2006        try {
2007          const [serverName, ...uriParts] = mention.split(':')
2008          const uri = uriParts.join(':') // Rejoin in case URI contains colons
2009  
2010          if (!serverName || !uri) {
2011            logEvent('tengu_at_mention_mcp_resource_error', {})
2012            return null
2013          }
2014  
2015          // Find the MCP client
2016          const client = mcpClients.find(c => c.name === serverName)
2017          if (!client || client.type !== 'connected') {
2018            logEvent('tengu_at_mention_mcp_resource_error', {})
2019            return null
2020          }
2021  
2022          // Find the resource in available resources to get its metadata
2023          const serverResources =
2024            toolUseContext.options.mcpResources?.[serverName] || []
2025          const resourceInfo = serverResources.find(r => r.uri === uri)
2026          if (!resourceInfo) {
2027            logEvent('tengu_at_mention_mcp_resource_error', {})
2028            return null
2029          }
2030  
2031          try {
2032            const result = await client.client.readResource({
2033              uri,
2034            })
2035  
2036            logEvent('tengu_at_mention_mcp_resource_success', {})
2037  
2038            return {
2039              type: 'mcp_resource' as const,
2040              server: serverName,
2041              uri,
2042              name: resourceInfo.name || uri,
2043              description: resourceInfo.description,
2044              content: result,
2045            }
2046          } catch (error) {
2047            logEvent('tengu_at_mention_mcp_resource_error', {})
2048            logError(error)
2049            return null
2050          }
2051        } catch {
2052          logEvent('tengu_at_mention_mcp_resource_error', {})
2053          return null
2054        }
2055      }),
2056    )
2057  
2058    return results.filter(
2059      (result): result is NonNullable<typeof result> => result !== null,
2060    ) as Attachment[]
2061  }
2062  
2063  export async function getChangedFiles(
2064    toolUseContext: ToolUseContext,
2065  ): Promise<Attachment[]> {
2066    const filePaths = cacheKeys(toolUseContext.readFileState)
2067    if (filePaths.length === 0) return []
2068  
2069    const appState = toolUseContext.getAppState()
2070    const results = await Promise.all(
2071      filePaths.map(async filePath => {
2072        const fileState = toolUseContext.readFileState.get(filePath)
2073        if (!fileState) return null
2074  
2075        // TODO: Implement offset/limit support for changed files
2076        if (fileState.offset !== undefined || fileState.limit !== undefined) {
2077          return null
2078        }
2079  
2080        const normalizedPath = expandPath(filePath)
2081  
2082        // Check if file has a deny rule configured
2083        if (isFileReadDenied(normalizedPath, appState.toolPermissionContext)) {
2084          return null
2085        }
2086  
2087        try {
2088          const mtime = await getFileModificationTimeAsync(normalizedPath)
2089          if (mtime <= fileState.timestamp) {
2090            return null
2091          }
2092  
2093          const fileInput = { file_path: normalizedPath }
2094  
2095          // Validate file path is valid
2096          const isValid = await FileReadTool.validateInput(
2097            fileInput,
2098            toolUseContext,
2099          )
2100          if (!isValid.result) {
2101            return null
2102          }
2103  
2104          const result = await FileReadTool.call(fileInput, toolUseContext)
2105          // Extract only the changed section
2106          if (result.data.type === 'text') {
2107            const snippet = getSnippetForTwoFileDiff(
2108              fileState.content,
2109              result.data.file.content,
2110            )
2111  
2112            // File was touched but not modified
2113            if (snippet === '') {
2114              return null
2115            }
2116  
2117            return {
2118              type: 'edited_text_file' as const,
2119              filename: normalizedPath,
2120              snippet,
2121            }
2122          }
2123  
2124          // For non-text files (images), apply the same token limit logic as FileReadTool
2125          if (result.data.type === 'image') {
2126            try {
2127              const data = await readImageWithTokenBudget(normalizedPath)
2128              return {
2129                type: 'edited_image_file' as const,
2130                filename: normalizedPath,
2131                content: data,
2132              }
2133            } catch (compressionError) {
2134              logError(compressionError)
2135              logEvent('tengu_watched_file_compression_failed', {
2136                file: normalizedPath,
2137              } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
2138              return null
2139            }
2140          }
2141  
2142          // notebook / pdf / parts — no diff representation; explicitly
2143          // null so the map callback has no implicit-undefined path.
2144          return null
2145        } catch (err) {
2146          // Evict ONLY on ENOENT (file truly deleted). Transient stat
2147          // failures — atomic-save races (editor writes tmp→rename and
2148          // stat hits the gap), EACCES churn, network-FS hiccups — must
2149          // NOT evict, or the next Edit fails code-6 even though the
2150          // file still exists and the model just read it. VS Code
2151          // auto-save/format-on-save hits this race especially often.
2152          // See regression analysis on PR #18525.
2153          if (isENOENT(err)) {
2154            toolUseContext.readFileState.delete(filePath)
2155          }
2156          return null
2157        }
2158      }),
2159    )
2160    return results.filter(result => result != null) as Attachment[]
2161  }
2162  
2163  /**
2164   * Processes paths that need nested memory attachments and checks for nested CLAUDE.md files
2165   * Uses nestedMemoryAttachmentTriggers field from ToolUseContext
2166   */
2167  async function getNestedMemoryAttachments(
2168    toolUseContext: ToolUseContext,
2169  ): Promise<Attachment[]> {
2170    // Check triggers first — getAppState() waits for a React render cycle,
2171    // and the common case is an empty trigger set.
2172    if (
2173      !toolUseContext.nestedMemoryAttachmentTriggers ||
2174      toolUseContext.nestedMemoryAttachmentTriggers.size === 0
2175    ) {
2176      return []
2177    }
2178  
2179    const appState = toolUseContext.getAppState()
2180    const attachments: Attachment[] = []
2181  
2182    for (const filePath of toolUseContext.nestedMemoryAttachmentTriggers) {
2183      const nestedAttachments = await getNestedMemoryAttachmentsForFile(
2184        filePath,
2185        toolUseContext,
2186        appState,
2187      )
2188      attachments.push(...nestedAttachments)
2189    }
2190  
2191    toolUseContext.nestedMemoryAttachmentTriggers.clear()
2192  
2193    return attachments
2194  }
2195  
2196  async function getRelevantMemoryAttachments(
2197    input: string,
2198    agents: AgentDefinition[],
2199    readFileState: FileStateCache,
2200    recentTools: readonly string[],
2201    signal: AbortSignal,
2202    alreadySurfaced: ReadonlySet<string>,
2203  ): Promise<Attachment[]> {
2204    // If an agent is @-mentioned, search only its memory dir (isolation).
2205    // Otherwise search the auto-memory dir.
2206    const memoryDirs = extractAgentMentions(input).flatMap(mention => {
2207      const agentType = mention.replace('agent-', '')
2208      const agentDef = agents.find(def => def.agentType === agentType)
2209      return agentDef?.memory
2210        ? [getAgentMemoryDir(agentType, agentDef.memory)]
2211        : []
2212    })
2213    const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()]
2214  
2215    const allResults = await Promise.all(
2216      dirs.map(dir =>
2217        findRelevantMemories(
2218          input,
2219          dir,
2220          signal,
2221          recentTools,
2222          alreadySurfaced,
2223        ).catch(() => []),
2224      ),
2225    )
2226    // alreadySurfaced is filtered inside the selector so Sonnet spends its
2227    // 5-slot budget on fresh candidates; readFileState catches files the
2228    // model read via FileReadTool. The redundant alreadySurfaced check here
2229    // is a belt-and-suspenders guard (multi-dir results may re-introduce a
2230    // path the selector filtered in a different dir).
2231    const selected = allResults
2232      .flat()
2233      .filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path))
2234      .slice(0, 5)
2235  
2236    const memories = await readMemoriesForSurfacing(selected, signal)
2237  
2238    if (memories.length === 0) {
2239      return []
2240    }
2241    return [{ type: 'relevant_memories' as const, memories }]
2242  }
2243  
2244  /**
2245   * Scan messages for past relevant_memories attachments.  Returns both the
2246   * set of surfaced paths (for selector de-dup) and cumulative byte count
2247   * (for session-total throttle).  Scanning messages rather than tracking
2248   * in toolUseContext means compact naturally resets both — old attachments
2249   * are gone from the compacted transcript, so re-surfacing is valid again.
2250   */
2251  export function collectSurfacedMemories(messages: ReadonlyArray<Message>): {
2252    paths: Set<string>
2253    totalBytes: number
2254  } {
2255    const paths = new Set<string>()
2256    let totalBytes = 0
2257    for (const m of messages) {
2258      if (m.type === 'attachment' && m.attachment.type === 'relevant_memories') {
2259        for (const mem of m.attachment.memories) {
2260          paths.add(mem.path)
2261          totalBytes += mem.content.length
2262        }
2263      }
2264    }
2265    return { paths, totalBytes }
2266  }
2267  
2268  /**
2269   * Reads a set of relevance-ranked memory files for injection as
2270   * <system-reminder> attachments. Enforces both MAX_MEMORY_LINES and
2271   * MAX_MEMORY_BYTES via readFileInRange's truncateOnByteLimit option.
2272   * Truncation surfaces partial
2273   * content with a note rather than dropping the file — findRelevantMemories
2274   * already picked this as most-relevant, so the frontmatter + opening context
2275   * is worth surfacing even if later lines are cut.
2276   *
2277   * Exported for direct testing without mocking the ranker + GB gates.
2278   */
2279  export async function readMemoriesForSurfacing(
2280    selected: ReadonlyArray<{ path: string; mtimeMs: number }>,
2281    signal?: AbortSignal,
2282  ): Promise<
2283    Array<{
2284      path: string
2285      content: string
2286      mtimeMs: number
2287      header: string
2288      limit?: number
2289    }>
2290  > {
2291    const results = await Promise.all(
2292      selected.map(async ({ path: filePath, mtimeMs }) => {
2293        try {
2294          const result = await readFileInRange(
2295            filePath,
2296            0,
2297            MAX_MEMORY_LINES,
2298            MAX_MEMORY_BYTES,
2299            signal,
2300            { truncateOnByteLimit: true },
2301          )
2302          const truncated =
2303            result.totalLines > MAX_MEMORY_LINES || result.truncatedByBytes
2304          const content = truncated
2305            ? result.content +
2306              `\n\n> This memory file was truncated (${result.truncatedByBytes ? `${MAX_MEMORY_BYTES} byte limit` : `first ${MAX_MEMORY_LINES} lines`}). Use the ${FILE_READ_TOOL_NAME} tool to view the complete file at: ${filePath}`
2307            : result.content
2308          return {
2309            path: filePath,
2310            content,
2311            mtimeMs,
2312            header: memoryHeader(filePath, mtimeMs),
2313            limit: truncated ? result.lineCount : undefined,
2314          }
2315        } catch {
2316          return null
2317        }
2318      }),
2319    )
2320    return results.filter(r => r !== null)
2321  }
2322  
2323  /**
2324   * Header string for a relevant-memory block.  Exported so messages.ts
2325   * can fall back for resumed sessions where the stored header is missing.
2326   */
2327  export function memoryHeader(path: string, mtimeMs: number): string {
2328    const staleness = memoryFreshnessText(mtimeMs)
2329    return staleness
2330      ? `${staleness}\n\nMemory: ${path}:`
2331      : `Memory (saved ${memoryAge(mtimeMs)}): ${path}:`
2332  }
2333  
2334  /**
2335   * A memory relevance-selector prefetch handle. The promise is started once
2336   * per user turn and runs while the main model streams and tools execute.
2337   * At the collect point (post-tools), the caller reads settledAt to
2338   * consume-if-ready or skip-and-retry-next-iteration — the prefetch never
2339   * blocks the turn.
2340   *
2341   * Disposable: query.ts binds with `using`, so [Symbol.dispose] fires on all
2342   * generator exit paths (return, throw, .return() closure) — aborting the
2343   * in-flight request and emitting terminal telemetry without instrumenting
2344   * each of the ~13 return sites inside the while loop.
2345   */
2346  export type MemoryPrefetch = {
2347    promise: Promise<Attachment[]>
2348    /** Set by promise.finally(). null until the promise settles. */
2349    settledAt: number | null
2350    /** Set by the collect point in query.ts. -1 until consumed. */
2351    consumedOnIteration: number
2352    [Symbol.dispose](): void
2353  }
2354  
2355  /**
2356   * Starts the relevant memory search as an async prefetch.
2357   * Extracts the last real user prompt from messages (skipping isMeta system
2358   * injections) and kicks off a non-blocking search. Returns a Disposable
2359   * handle with settlement tracking. Bound with `using` in query.ts.
2360   */
2361  export function startRelevantMemoryPrefetch(
2362    messages: ReadonlyArray<Message>,
2363    toolUseContext: ToolUseContext,
2364  ): MemoryPrefetch | undefined {
2365    if (
2366      !isAutoMemoryEnabled() ||
2367      !getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false)
2368    ) {
2369      return undefined
2370    }
2371  
2372    const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta)
2373    if (!lastUserMessage) {
2374      return undefined
2375    }
2376  
2377    const input = getUserMessageText(lastUserMessage)
2378    // Single-word prompts lack enough context for meaningful term extraction
2379    if (!input || !/\s/.test(input.trim())) {
2380      return undefined
2381    }
2382  
2383    const surfaced = collectSurfacedMemories(messages)
2384    if (surfaced.totalBytes >= RELEVANT_MEMORIES_CONFIG.MAX_SESSION_BYTES) {
2385      return undefined
2386    }
2387  
2388    // Chained to the turn-level abort so user Escape cancels the sideQuery
2389    // immediately, not just on [Symbol.dispose] when queryLoop exits.
2390    const controller = createChildAbortController(toolUseContext.abortController)
2391    const firedAt = Date.now()
2392    const promise = getRelevantMemoryAttachments(
2393      input,
2394      toolUseContext.options.agentDefinitions.activeAgents,
2395      toolUseContext.readFileState,
2396      collectRecentSuccessfulTools(messages, lastUserMessage),
2397      controller.signal,
2398      surfaced.paths,
2399    ).catch(e => {
2400      if (!isAbortError(e)) {
2401        logError(e)
2402      }
2403      return []
2404    })
2405  
2406    const handle: MemoryPrefetch = {
2407      promise,
2408      settledAt: null,
2409      consumedOnIteration: -1,
2410      [Symbol.dispose]() {
2411        controller.abort()
2412        logEvent('tengu_memdir_prefetch_collected', {
2413          hidden_by_first_iteration:
2414            handle.settledAt !== null && handle.consumedOnIteration === 0,
2415          consumed_on_iteration: handle.consumedOnIteration,
2416          latency_ms: (handle.settledAt ?? Date.now()) - firedAt,
2417        })
2418      },
2419    }
2420    void promise.finally(() => {
2421      handle.settledAt = Date.now()
2422    })
2423    return handle
2424  }
2425  
2426  type ToolResultBlock = {
2427    type: 'tool_result'
2428    tool_use_id: string
2429    is_error?: boolean
2430  }
2431  
2432  function isToolResultBlock(b: unknown): b is ToolResultBlock {
2433    return (
2434      typeof b === 'object' &&
2435      b !== null &&
2436      (b as ToolResultBlock).type === 'tool_result' &&
2437      typeof (b as ToolResultBlock).tool_use_id === 'string'
2438    )
2439  }
2440  
2441  /**
2442   * Check whether a user message's content contains tool_result blocks.
2443   * This is more reliable than checking `toolUseResult === undefined` because
2444   * sub-agent tool result messages explicitly set `toolUseResult` to `undefined`
2445   * when `preserveToolUseResults` is false (the default for Explore agents).
2446   */
2447  function hasToolResultContent(content: unknown): boolean {
2448    return Array.isArray(content) && content.some(isToolResultBlock)
2449  }
2450  
2451  /**
2452   * Tools that succeeded (and never errored) since the previous real turn
2453   * boundary.  The memory selector uses this to suppress docs about tools
2454   * that are working — surfacing reference material for a tool the model
2455   * is already calling successfully is noise.
2456   *
2457   * Any error → tool excluded (model is struggling, docs stay available).
2458   * No result yet → also excluded (outcome unknown).
2459   *
2460   * tool_use lives in assistant content; tool_result in user content
2461   * (toolUseResult set, isMeta undefined).  Both are within the scan window.
2462   * Backward scan sees results before uses so we collect both by id and
2463   * resolve after.
2464   */
2465  export function collectRecentSuccessfulTools(
2466    messages: ReadonlyArray<Message>,
2467    lastUserMessage: Message,
2468  ): readonly string[] {
2469    const useIdToName = new Map<string, string>()
2470    const resultByUseId = new Map<string, boolean>()
2471    for (let i = messages.length - 1; i >= 0; i--) {
2472      const m = messages[i]
2473      if (!m) continue
2474      if (isHumanTurn(m) && m !== lastUserMessage) break
2475      if (m.type === 'assistant' && typeof m.message.content !== 'string') {
2476        for (const block of m.message.content) {
2477          if (block.type === 'tool_use') useIdToName.set(block.id, block.name)
2478        }
2479      } else if (
2480        m.type === 'user' &&
2481        'message' in m &&
2482        Array.isArray(m.message.content)
2483      ) {
2484        for (const block of m.message.content) {
2485          if (isToolResultBlock(block)) {
2486            resultByUseId.set(block.tool_use_id, block.is_error === true)
2487          }
2488        }
2489      }
2490    }
2491    const failed = new Set<string>()
2492    const succeeded = new Set<string>()
2493    for (const [id, name] of useIdToName) {
2494      const errored = resultByUseId.get(id)
2495      if (errored === undefined) continue
2496      if (errored) {
2497        failed.add(name)
2498      } else {
2499        succeeded.add(name)
2500      }
2501    }
2502    return [...succeeded].filter(t => !failed.has(t))
2503  }
2504  
2505  
2506  /**
2507   * Filters prefetched memory attachments to exclude memories the model already
2508   * has in context via FileRead/Write/Edit tool calls (any iteration this turn)
2509   * or a previous turn's memory surfacing — both tracked in the cumulative
2510   * readFileState. Survivors are then marked in readFileState so subsequent
2511   * turns won't re-surface them.
2512   *
2513   * The mark-after-filter ordering is load-bearing: readMemoriesForSurfacing
2514   * used to write to readFileState during the prefetch, which meant the filter
2515   * saw every prefetch-selected path as "already in context" and dropped them
2516   * all (self-referential filter). Deferring the write to here, after the
2517   * filter runs, breaks that cycle while still deduping against tool calls
2518   * from any iteration.
2519   */
2520  export function filterDuplicateMemoryAttachments(
2521    attachments: Attachment[],
2522    readFileState: FileStateCache,
2523  ): Attachment[] {
2524    return attachments
2525      .map(attachment => {
2526        if (attachment.type !== 'relevant_memories') return attachment
2527        const filtered = attachment.memories.filter(
2528          m => !readFileState.has(m.path),
2529        )
2530        for (const m of filtered) {
2531          readFileState.set(m.path, {
2532            content: m.content,
2533            timestamp: m.mtimeMs,
2534            offset: undefined,
2535            limit: m.limit,
2536          })
2537        }
2538        return filtered.length > 0 ? { ...attachment, memories: filtered } : null
2539      })
2540      .filter((a): a is Attachment => a !== null)
2541  }
2542  
2543  /**
2544   * Processes skill directories that were discovered during file operations.
2545   * Uses dynamicSkillDirTriggers field from ToolUseContext
2546   */
2547  async function getDynamicSkillAttachments(
2548    toolUseContext: ToolUseContext,
2549  ): Promise<Attachment[]> {
2550    const attachments: Attachment[] = []
2551  
2552    if (
2553      toolUseContext.dynamicSkillDirTriggers &&
2554      toolUseContext.dynamicSkillDirTriggers.size > 0
2555    ) {
2556      // Parallelize: readdir all skill dirs concurrently
2557      const perDirResults = await Promise.all(
2558        Array.from(toolUseContext.dynamicSkillDirTriggers).map(async skillDir => {
2559          try {
2560            const entries = await readdir(skillDir, { withFileTypes: true })
2561            const candidates = entries
2562              .filter(e => e.isDirectory() || e.isSymbolicLink())
2563              .map(e => e.name)
2564            // Parallelize: stat all SKILL.md candidates concurrently
2565            const checked = await Promise.all(
2566              candidates.map(async name => {
2567                try {
2568                  await stat(resolve(skillDir, name, 'SKILL.md'))
2569                  return name
2570                } catch {
2571                  return null // SKILL.md doesn't exist, skip this entry
2572                }
2573              }),
2574            )
2575            return {
2576              skillDir,
2577              skillNames: checked.filter((n): n is string => n !== null),
2578            }
2579          } catch {
2580            // Ignore errors reading skill directories (e.g., directory doesn't exist)
2581            return { skillDir, skillNames: [] }
2582          }
2583        }),
2584      )
2585  
2586      for (const { skillDir, skillNames } of perDirResults) {
2587        if (skillNames.length > 0) {
2588          attachments.push({
2589            type: 'dynamic_skill',
2590            skillDir,
2591            skillNames,
2592            displayPath: relative(getCwd(), skillDir),
2593          })
2594        }
2595      }
2596  
2597      toolUseContext.dynamicSkillDirTriggers.clear()
2598    }
2599  
2600    return attachments
2601  }
2602  
2603  // Track which skills have been sent to avoid re-sending. Keyed by agentId
2604  // (empty string = main thread) so subagents get their own turn-0 listing —
2605  // without per-agent scoping, the main thread populating this Set would cause
2606  // every subagent's filterToBundledAndMcp result to dedup to empty.
2607  const sentSkillNames = new Map<string, Set<string>>()
2608  
2609  // Called when the skill set genuinely changes (plugin reload, skill file
2610  // change on disk) so new skills get announced. NOT called on compact —
2611  // post-compact re-injection costs ~4K tokens/event for marginal benefit.
2612  export function resetSentSkillNames(): void {
2613    sentSkillNames.clear()
2614    suppressNext = false
2615  }
2616  
2617  /**
2618   * Suppress the next skill-listing injection. Called by conversationRecovery
2619   * on --resume when a skill_listing attachment already exists in the
2620   * transcript.
2621   *
2622   * `sentSkillNames` is module-scope — process-local. Each `claude -p` spawn
2623   * starts with an empty Map, so without this every resume re-injects the
2624   * full ~600-token listing even though it's already in the conversation from
2625   * the prior process. Shows up on every --resume; particularly loud for
2626   * daemons that respawn frequently.
2627   *
2628   * Trade-off: skills added between sessions won't be announced until the
2629   * next non-resume session. Acceptable — skill_listing was never meant to
2630   * cover cross-process deltas, and the agent can still call them (they're
2631   * in the Skill tool's runtime registry regardless).
2632   */
2633  export function suppressNextSkillListing(): void {
2634    suppressNext = true
2635  }
2636  let suppressNext = false
2637  
2638  // When skill-search is enabled and the filtered (bundled + MCP) listing exceeds
2639  // this count, fall back to bundled-only. Protects MCP-heavy users (100+ servers)
2640  // from truncation while keeping the turn-0 guarantee for typical setups.
2641  const FILTERED_LISTING_MAX = 30
2642  
2643  /**
2644   * Filter skills to bundled (Anthropic-curated) + MCP (user-connected) only.
2645   * Used when skill-search is enabled to resolve the turn-0 gap for subagents:
2646   * these sources are small, intent-signaled, and won't hit the truncation budget.
2647   * User/project/plugin skills (the long tail — 200+) go through discovery instead.
2648   *
2649   * Falls back to bundled-only if bundled+mcp exceeds FILTERED_LISTING_MAX.
2650   */
2651  export function filterToBundledAndMcp(commands: Command[]): Command[] {
2652    const filtered = commands.filter(
2653      cmd => cmd.loadedFrom === 'bundled' || cmd.loadedFrom === 'mcp',
2654    )
2655    if (filtered.length > FILTERED_LISTING_MAX) {
2656      return filtered.filter(cmd => cmd.loadedFrom === 'bundled')
2657    }
2658    return filtered
2659  }
2660  
2661  async function getSkillListingAttachments(
2662    toolUseContext: ToolUseContext,
2663  ): Promise<Attachment[]> {
2664    if (process.env.NODE_ENV === 'test') {
2665      return []
2666    }
2667  
2668    // Skip skill listing for agents that don't have the Skill tool — they can't use skills directly.
2669    if (
2670      !toolUseContext.options.tools.some(t => toolMatchesName(t, SKILL_TOOL_NAME))
2671    ) {
2672      return []
2673    }
2674  
2675    const cwd = getProjectRoot()
2676    const localCommands = await getSkillToolCommands(cwd)
2677    const mcpSkills = getMcpSkillCommands(
2678      toolUseContext.getAppState().mcp.commands,
2679    )
2680    let allCommands =
2681      mcpSkills.length > 0
2682        ? uniqBy([...localCommands, ...mcpSkills], 'name')
2683        : localCommands
2684  
2685    // When skill search is active, filter to bundled + MCP instead of full
2686    // suppression. Resolves the turn-0 gap: main thread gets turn-0 discovery
2687    // via getTurnZeroSkillDiscovery (blocking), but subagents use the async
2688    // subagent_spawn signal (collected post-tools, visible turn 1). Bundled +
2689    // MCP are small and intent-signaled; user/project/plugin skills go through
2690    // discovery. feature() first for DCE — the property-access string leaks
2691    // otherwise even with ?. on null.
2692    if (
2693      feature('EXPERIMENTAL_SKILL_SEARCH') &&
2694      skillSearchModules?.featureCheck.isSkillSearchEnabled()
2695    ) {
2696      allCommands = filterToBundledAndMcp(allCommands)
2697    }
2698  
2699    const agentKey = toolUseContext.agentId ?? ''
2700    let sent = sentSkillNames.get(agentKey)
2701    if (!sent) {
2702      sent = new Set()
2703      sentSkillNames.set(agentKey, sent)
2704    }
2705  
2706    // Resume path: prior process already injected a listing; it's in the
2707    // transcript. Mark everything current as sent so only post-resume deltas
2708    // (skills loaded later via /reload-plugins etc) get announced.
2709    if (suppressNext) {
2710      suppressNext = false
2711      for (const cmd of allCommands) {
2712        sent.add(cmd.name)
2713      }
2714      return []
2715    }
2716  
2717    // Find skills we haven't sent yet
2718    const newSkills = allCommands.filter(cmd => !sent.has(cmd.name))
2719  
2720    if (newSkills.length === 0) {
2721      return []
2722    }
2723  
2724    // If no skills have been sent yet, this is the initial batch
2725    const isInitial = sent.size === 0
2726  
2727    // Mark as sent
2728    for (const cmd of newSkills) {
2729      sent.add(cmd.name)
2730    }
2731  
2732    logForDebugging(
2733      `Sending ${newSkills.length} skills via attachment (${isInitial ? 'initial' : 'dynamic'}, ${sent.size} total sent)`,
2734    )
2735  
2736    // Format within budget using existing logic
2737    const contextWindowTokens = getContextWindowForModel(
2738      toolUseContext.options.mainLoopModel,
2739      getSdkBetas(),
2740    )
2741    const content = formatCommandsWithinBudget(newSkills, contextWindowTokens)
2742  
2743    return [
2744      {
2745        type: 'skill_listing',
2746        content,
2747        skillCount: newSkills.length,
2748        isInitial,
2749      },
2750    ]
2751  }
2752  
2753  // getSkillDiscoveryAttachment moved to skillSearch/prefetch.ts as
2754  // getTurnZeroSkillDiscovery — keeps the 'skill_discovery' string literal inside
2755  // a feature-gated module so it doesn't leak into external builds.
2756  
2757  export function extractAtMentionedFiles(content: string): string[] {
2758    // Extract filenames mentioned with @ symbol, including line range syntax: @file.txt#L10-20
2759    // Also supports quoted paths for files with spaces: @"my/file with spaces.txt"
2760    // Example: "foo bar @baz moo" would extract "baz"
2761    // Example: 'check @"my file.txt" please' would extract "my file.txt"
2762  
2763    // Two patterns: quoted paths and regular paths
2764    const quotedAtMentionRegex = /(^|\s)@"([^"]+)"/g
2765    const regularAtMentionRegex = /(^|\s)@([^\s]+)\b/g
2766  
2767    const quotedMatches: string[] = []
2768    const regularMatches: string[] = []
2769  
2770    // Extract quoted mentions first (skip agent mentions like @"code-reviewer (agent)")
2771    let match
2772    while ((match = quotedAtMentionRegex.exec(content)) !== null) {
2773      if (match[2] && !match[2].endsWith(' (agent)')) {
2774        quotedMatches.push(match[2]) // The content inside quotes
2775      }
2776    }
2777  
2778    // Extract regular mentions
2779    const regularMatchArray = content.match(regularAtMentionRegex) || []
2780    regularMatchArray.forEach(match => {
2781      const filename = match.slice(match.indexOf('@') + 1)
2782      // Don't include if it starts with a quote (already handled as quoted)
2783      if (!filename.startsWith('"')) {
2784        regularMatches.push(filename)
2785      }
2786    })
2787  
2788    // Combine and deduplicate
2789    return uniq([...quotedMatches, ...regularMatches])
2790  }
2791  
2792  export function extractMcpResourceMentions(content: string): string[] {
2793    // Extract MCP resources mentioned with @ symbol in format @server:uri
2794    // Example: "@server1:resource/path" would extract "server1:resource/path"
2795    const atMentionRegex = /(^|\s)@([^\s]+:[^\s]+)\b/g
2796    const matches = content.match(atMentionRegex) || []
2797  
2798    // Remove the prefix (everything before @) from each match
2799    return uniq(matches.map(match => match.slice(match.indexOf('@') + 1)))
2800  }
2801  
2802  export function extractAgentMentions(content: string): string[] {
2803    // Extract agent mentions in two formats:
2804    // 1. @agent-<agent-type> (legacy/manual typing)
2805    //    Example: "@agent-code-elegance-refiner" → "agent-code-elegance-refiner"
2806    // 2. @"<agent-type> (agent)" (from autocomplete selection)
2807    //    Example: '@"code-reviewer (agent)"' → "code-reviewer"
2808    // Supports colons, dots, and at-signs for plugin-scoped agents like "@agent-asana:project-status-updater"
2809    const results: string[] = []
2810  
2811    // Match quoted format: @"<type> (agent)"
2812    const quotedAgentRegex = /(^|\s)@"([\w:.@-]+) \(agent\)"/g
2813    let match
2814    while ((match = quotedAgentRegex.exec(content)) !== null) {
2815      if (match[2]) {
2816        results.push(match[2])
2817      }
2818    }
2819  
2820    // Match unquoted format: @agent-<type>
2821    const unquotedAgentRegex = /(^|\s)@(agent-[\w:.@-]+)/g
2822    const unquotedMatches = content.match(unquotedAgentRegex) || []
2823    for (const m of unquotedMatches) {
2824      results.push(m.slice(m.indexOf('@') + 1))
2825    }
2826  
2827    return uniq(results)
2828  }
2829  
2830  interface AtMentionedFileLines {
2831    filename: string
2832    lineStart?: number
2833    lineEnd?: number
2834  }
2835  
2836  export function parseAtMentionedFileLines(
2837    mention: string,
2838  ): AtMentionedFileLines {
2839    // Parse mentions like "file.txt#L10-20", "file.txt#heading", or just "file.txt"
2840    // Supports line ranges (#L10, #L10-20) and strips non-line-range fragments (#heading)
2841    const match = mention.match(/^([^#]+)(?:#L(\d+)(?:-(\d+))?)?(?:#[^#]*)?$/)
2842  
2843    if (!match) {
2844      return { filename: mention }
2845    }
2846  
2847    const [, filename, lineStartStr, lineEndStr] = match
2848    const lineStart = lineStartStr ? parseInt(lineStartStr, 10) : undefined
2849    const lineEnd = lineEndStr ? parseInt(lineEndStr, 10) : lineStart
2850  
2851    return { filename: filename ?? mention, lineStart, lineEnd }
2852  }
2853  
2854  async function getDiagnosticAttachments(
2855    toolUseContext: ToolUseContext,
2856  ): Promise<Attachment[]> {
2857    // Diagnostics are only useful if the agent has the Bash tool to act on them
2858    if (
2859      !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME))
2860    ) {
2861      return []
2862    }
2863  
2864    // Get new diagnostics from the tracker (IDE diagnostics via MCP)
2865    const newDiagnostics = await diagnosticTracker.getNewDiagnostics()
2866    if (newDiagnostics.length === 0) {
2867      return []
2868    }
2869  
2870    return [
2871      {
2872        type: 'diagnostics',
2873        files: newDiagnostics,
2874        isNew: true,
2875      },
2876    ]
2877  }
2878  
2879  /**
2880   * Get LSP diagnostic attachments from passive LSP servers.
2881   * Follows the AsyncHookRegistry pattern for consistent async attachment delivery.
2882   */
2883  async function getLSPDiagnosticAttachments(
2884    toolUseContext: ToolUseContext,
2885  ): Promise<Attachment[]> {
2886    // LSP diagnostics are only useful if the agent has the Bash tool to act on them
2887    if (
2888      !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME))
2889    ) {
2890      return []
2891    }
2892  
2893    logForDebugging('LSP Diagnostics: getLSPDiagnosticAttachments called')
2894  
2895    try {
2896      const diagnosticSets = checkForLSPDiagnostics()
2897  
2898      if (diagnosticSets.length === 0) {
2899        return []
2900      }
2901  
2902      logForDebugging(
2903        `LSP Diagnostics: Found ${diagnosticSets.length} pending diagnostic set(s)`,
2904      )
2905  
2906      // Convert each diagnostic set to an attachment
2907      const attachments: Attachment[] = diagnosticSets.map(({ files }) => ({
2908        type: 'diagnostics' as const,
2909        files,
2910        isNew: true,
2911      }))
2912  
2913      // Clear delivered diagnostics from registry to prevent memory leak
2914      // Follows same pattern as removeDeliveredAsyncHooks
2915      if (diagnosticSets.length > 0) {
2916        clearAllLSPDiagnostics()
2917        logForDebugging(
2918          `LSP Diagnostics: Cleared ${diagnosticSets.length} delivered diagnostic(s) from registry`,
2919        )
2920      }
2921  
2922      logForDebugging(
2923        `LSP Diagnostics: Returning ${attachments.length} diagnostic attachment(s)`,
2924      )
2925  
2926      return attachments
2927    } catch (error) {
2928      const err = toError(error)
2929      logError(
2930        new Error(`Failed to get LSP diagnostic attachments: ${err.message}`),
2931      )
2932      // Return empty array to allow other attachments to proceed
2933      return []
2934    }
2935  }
2936  
2937  export async function* getAttachmentMessages(
2938    input: string | null,
2939    toolUseContext: ToolUseContext,
2940    ideSelection: IDESelection | null,
2941    queuedCommands: QueuedCommand[],
2942    messages?: Message[],
2943    querySource?: QuerySource,
2944    options?: { skipSkillDiscovery?: boolean },
2945  ): AsyncGenerator<AttachmentMessage, void> {
2946    // TODO: Compute this upstream
2947    const attachments = await getAttachments(
2948      input,
2949      toolUseContext,
2950      ideSelection,
2951      queuedCommands,
2952      messages,
2953      querySource,
2954      options,
2955    )
2956  
2957    if (attachments.length === 0) {
2958      return
2959    }
2960  
2961    logEvent('tengu_attachments', {
2962      attachment_types: attachments.map(
2963        _ => _.type,
2964      ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
2965    })
2966  
2967    for (const attachment of attachments) {
2968      yield createAttachmentMessage(attachment)
2969    }
2970  }
2971  
2972  /**
2973   * Generates a file attachment by reading a file with proper validation and truncation.
2974   * This is the core file reading logic shared between @-mentioned files and post-compact restoration.
2975   *
2976   * @param filename The absolute path to the file to read
2977   * @param toolUseContext The tool use context for calling FileReadTool
2978   * @param options Optional configuration for file reading
2979   * @returns A new_file attachment or null if the file couldn't be read
2980   */
2981  /**
2982   * Check if a PDF file should be represented as a lightweight reference
2983   * instead of being inlined. Returns a PDFReferenceAttachment for large PDFs
2984   * (more than PDF_AT_MENTION_INLINE_THRESHOLD pages), or null otherwise.
2985   */
2986  export async function tryGetPDFReference(
2987    filename: string,
2988  ): Promise<PDFReferenceAttachment | null> {
2989    const ext = parse(filename).ext.toLowerCase()
2990    if (!isPDFExtension(ext)) {
2991      return null
2992    }
2993    try {
2994      const [stats, pageCount] = await Promise.all([
2995        getFsImplementation().stat(filename),
2996        getPDFPageCount(filename),
2997      ])
2998      // Use page count if available, otherwise fall back to size heuristic (~100KB per page)
2999      const effectivePageCount = pageCount ?? Math.ceil(stats.size / (100 * 1024))
3000      if (effectivePageCount > PDF_AT_MENTION_INLINE_THRESHOLD) {
3001        logEvent('tengu_pdf_reference_attachment', {
3002          pageCount: effectivePageCount,
3003          fileSize: stats.size,
3004          hadPdfinfo: pageCount !== null,
3005        } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
3006        return {
3007          type: 'pdf_reference',
3008          filename,
3009          pageCount: effectivePageCount,
3010          fileSize: stats.size,
3011          displayPath: relative(getCwd(), filename),
3012        }
3013      }
3014    } catch {
3015      // If we can't stat the file, return null to proceed with normal reading
3016    }
3017    return null
3018  }
3019  
3020  export async function generateFileAttachment(
3021    filename: string,
3022    toolUseContext: ToolUseContext,
3023    successEventName: string,
3024    errorEventName: string,
3025    mode: 'compact' | 'at-mention',
3026    options?: {
3027      offset?: number
3028      limit?: number
3029    },
3030  ): Promise<
3031    | FileAttachment
3032    | CompactFileReferenceAttachment
3033    | PDFReferenceAttachment
3034    | AlreadyReadFileAttachment
3035    | null
3036  > {
3037    const { offset, limit } = options ?? {}
3038  
3039    // Check if file has a deny rule configured
3040    const appState = toolUseContext.getAppState()
3041    if (isFileReadDenied(filename, appState.toolPermissionContext)) {
3042      return null
3043    }
3044  
3045    // Check file size before attempting to read (skip for PDFs — they have their own size/page handling below)
3046    if (
3047      mode === 'at-mention' &&
3048      !isFileWithinReadSizeLimit(
3049        filename,
3050        getDefaultFileReadingLimits().maxSizeBytes,
3051      )
3052    ) {
3053      const ext = parse(filename).ext.toLowerCase()
3054      if (!isPDFExtension(ext)) {
3055        try {
3056          const stats = await getFsImplementation().stat(filename)
3057          logEvent('tengu_attachment_file_too_large', {
3058            size_bytes: stats.size,
3059            mode,
3060          } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
3061          return null
3062        } catch {
3063          // If we can't stat the file, proceed with normal reading (will fail later if file doesn't exist)
3064        }
3065      }
3066    }
3067  
3068    // For large PDFs on @ mention, return a lightweight reference instead of inlining
3069    if (mode === 'at-mention') {
3070      const pdfRef = await tryGetPDFReference(filename)
3071      if (pdfRef) {
3072        return pdfRef
3073      }
3074    }
3075  
3076    // Check if file is already in context with latest version
3077    const existingFileState = toolUseContext.readFileState.get(filename)
3078    if (existingFileState && mode === 'at-mention') {
3079      try {
3080        // Check if the file has been modified since we last read it
3081        const mtimeMs = await getFileModificationTimeAsync(filename)
3082  
3083        // Handle timestamp format inconsistency:
3084        // - FileReadTool stores Date.now() (current time when read)
3085        // - FileEdit/WriteTools store mtimeMs (file modification time)
3086        //
3087        // If timestamp > mtimeMs, it was stored by FileReadTool using Date.now()
3088        // In this case, we should not use the optimization since we can't reliably
3089        // compare modification times. Only use optimization when timestamp <= mtimeMs,
3090        // indicating it was stored by FileEdit/WriteTool with actual mtimeMs.
3091  
3092        if (
3093          existingFileState.timestamp <= mtimeMs &&
3094          mtimeMs === existingFileState.timestamp
3095        ) {
3096          // File hasn't been modified, return already_read_file attachment
3097          // This tells the system the file is already in context and doesn't need to be sent to API
3098          logEvent(successEventName, {})
3099          return {
3100            type: 'already_read_file',
3101            filename,
3102            displayPath: relative(getCwd(), filename),
3103            content: {
3104              type: 'text',
3105              file: {
3106                filePath: filename,
3107                content: existingFileState.content,
3108                numLines: countCharInString(existingFileState.content, '\n') + 1,
3109                startLine: offset ?? 1,
3110                totalLines:
3111                  countCharInString(existingFileState.content, '\n') + 1,
3112              },
3113            },
3114          }
3115        }
3116      } catch {
3117        // If we can't stat the file, proceed with normal reading
3118      }
3119    }
3120  
3121    try {
3122      const fileInput = {
3123        file_path: filename,
3124        offset,
3125        limit,
3126      }
3127  
3128      async function readTruncatedFile(): Promise<
3129        | FileAttachment
3130        | CompactFileReferenceAttachment
3131        | AlreadyReadFileAttachment
3132        | null
3133      > {
3134        if (mode === 'compact') {
3135          return {
3136            type: 'compact_file_reference',
3137            filename,
3138            displayPath: relative(getCwd(), filename),
3139          }
3140        }
3141  
3142        // Check deny rules before reading truncated file
3143        const appState = toolUseContext.getAppState()
3144        if (isFileReadDenied(filename, appState.toolPermissionContext)) {
3145          return null
3146        }
3147  
3148        try {
3149          // Read only the first MAX_LINES_TO_READ lines for files that are too large
3150          const truncatedInput = {
3151            file_path: filename,
3152            offset: offset ?? 1,
3153            limit: MAX_LINES_TO_READ,
3154          }
3155          const result = await FileReadTool.call(truncatedInput, toolUseContext)
3156          logEvent(successEventName, {})
3157  
3158          return {
3159            type: 'file' as const,
3160            filename,
3161            content: result.data,
3162            truncated: true,
3163            displayPath: relative(getCwd(), filename),
3164          }
3165        } catch {
3166          logEvent(errorEventName, {})
3167          return null
3168        }
3169      }
3170  
3171      // Validate file path is valid
3172      const isValid = await FileReadTool.validateInput(fileInput, toolUseContext)
3173      if (!isValid.result) {
3174        return null
3175      }
3176  
3177      try {
3178        const result = await FileReadTool.call(fileInput, toolUseContext)
3179        logEvent(successEventName, {})
3180        return {
3181          type: 'file',
3182          filename,
3183          content: result.data,
3184          displayPath: relative(getCwd(), filename),
3185        }
3186      } catch (error) {
3187        if (
3188          error instanceof MaxFileReadTokenExceededError ||
3189          error instanceof FileTooLargeError
3190        ) {
3191          return await readTruncatedFile()
3192        }
3193        throw error
3194      }
3195    } catch {
3196      logEvent(errorEventName, {})
3197      return null
3198    }
3199  }
3200  
3201  export function createAttachmentMessage(
3202    attachment: Attachment,
3203  ): AttachmentMessage {
3204    return {
3205      attachment,
3206      type: 'attachment',
3207      uuid: randomUUID(),
3208      timestamp: new Date().toISOString(),
3209    }
3210  }
3211  
3212  function getTodoReminderTurnCounts(messages: Message[]): {
3213    turnsSinceLastTodoWrite: number
3214    turnsSinceLastReminder: number
3215  } {
3216    let lastTodoWriteIndex = -1
3217    let lastReminderIndex = -1
3218    let assistantTurnsSinceWrite = 0
3219    let assistantTurnsSinceReminder = 0
3220  
3221    // Iterate backwards to find most recent events
3222    for (let i = messages.length - 1; i >= 0; i--) {
3223      const message = messages[i]
3224  
3225      if (message?.type === 'assistant') {
3226        if (isThinkingMessage(message)) {
3227          // Skip thinking messages
3228          continue
3229        }
3230  
3231        // Check for TodoWrite usage BEFORE incrementing counter
3232        // (we don't want to count the TodoWrite message itself as "1 turn since write")
3233        if (
3234          lastTodoWriteIndex === -1 &&
3235          'message' in message &&
3236          Array.isArray(message.message?.content) &&
3237          message.message.content.some(
3238            block => block.type === 'tool_use' && block.name === 'TodoWrite',
3239          )
3240        ) {
3241          lastTodoWriteIndex = i
3242        }
3243  
3244        // Count assistant turns before finding events
3245        if (lastTodoWriteIndex === -1) assistantTurnsSinceWrite++
3246        if (lastReminderIndex === -1) assistantTurnsSinceReminder++
3247      } else if (
3248        lastReminderIndex === -1 &&
3249        message?.type === 'attachment' &&
3250        message.attachment.type === 'todo_reminder'
3251      ) {
3252        lastReminderIndex = i
3253      }
3254  
3255      if (lastTodoWriteIndex !== -1 && lastReminderIndex !== -1) {
3256        break
3257      }
3258    }
3259  
3260    return {
3261      turnsSinceLastTodoWrite: assistantTurnsSinceWrite,
3262      turnsSinceLastReminder: assistantTurnsSinceReminder,
3263    }
3264  }
3265  
3266  async function getTodoReminderAttachments(
3267    messages: Message[] | undefined,
3268    toolUseContext: ToolUseContext,
3269  ): Promise<Attachment[]> {
3270    // Skip if TodoWrite tool is not available
3271    if (
3272      !toolUseContext.options.tools.some(t =>
3273        toolMatchesName(t, TODO_WRITE_TOOL_NAME),
3274      )
3275    ) {
3276      return []
3277    }
3278  
3279    // When SendUserMessage is in the toolkit, it's the primary communication
3280    // channel and the model is always told to use it (#20467). TodoWrite
3281    // becomes a side channel — nudging the model about it conflicts with the
3282    // brief workflow. The tool itself stays available; this only gates the
3283    // "you haven't used it in a while" nag.
3284    if (
3285      BRIEF_TOOL_NAME &&
3286      toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME))
3287    ) {
3288      return []
3289    }
3290  
3291    // Skip if no messages provided
3292    if (!messages || messages.length === 0) {
3293      return []
3294    }
3295  
3296    const { turnsSinceLastTodoWrite, turnsSinceLastReminder } =
3297      getTodoReminderTurnCounts(messages)
3298  
3299    // Check if we should show a reminder
3300    if (
3301      turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE &&
3302      turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
3303    ) {
3304      const todoKey = toolUseContext.agentId ?? getSessionId()
3305      const appState = toolUseContext.getAppState()
3306      const todos = appState.todos[todoKey] ?? []
3307      return [
3308        {
3309          type: 'todo_reminder',
3310          content: todos,
3311          itemCount: todos.length,
3312        },
3313      ]
3314    }
3315  
3316    return []
3317  }
3318  
3319  function getTaskReminderTurnCounts(messages: Message[]): {
3320    turnsSinceLastTaskManagement: number
3321    turnsSinceLastReminder: number
3322  } {
3323    let lastTaskManagementIndex = -1
3324    let lastReminderIndex = -1
3325    let assistantTurnsSinceTaskManagement = 0
3326    let assistantTurnsSinceReminder = 0
3327  
3328    // Iterate backwards to find most recent events
3329    for (let i = messages.length - 1; i >= 0; i--) {
3330      const message = messages[i]
3331  
3332      if (message?.type === 'assistant') {
3333        if (isThinkingMessage(message)) {
3334          // Skip thinking messages
3335          continue
3336        }
3337  
3338        // Check for TaskCreate or TaskUpdate usage BEFORE incrementing counter
3339        if (
3340          lastTaskManagementIndex === -1 &&
3341          'message' in message &&
3342          Array.isArray(message.message?.content) &&
3343          message.message.content.some(
3344            block =>
3345              block.type === 'tool_use' &&
3346              (block.name === TASK_CREATE_TOOL_NAME ||
3347                block.name === TASK_UPDATE_TOOL_NAME),
3348          )
3349        ) {
3350          lastTaskManagementIndex = i
3351        }
3352  
3353        // Count assistant turns before finding events
3354        if (lastTaskManagementIndex === -1) assistantTurnsSinceTaskManagement++
3355        if (lastReminderIndex === -1) assistantTurnsSinceReminder++
3356      } else if (
3357        lastReminderIndex === -1 &&
3358        message?.type === 'attachment' &&
3359        message.attachment.type === 'task_reminder'
3360      ) {
3361        lastReminderIndex = i
3362      }
3363  
3364      if (lastTaskManagementIndex !== -1 && lastReminderIndex !== -1) {
3365        break
3366      }
3367    }
3368  
3369    return {
3370      turnsSinceLastTaskManagement: assistantTurnsSinceTaskManagement,
3371      turnsSinceLastReminder: assistantTurnsSinceReminder,
3372    }
3373  }
3374  
3375  async function getTaskReminderAttachments(
3376    messages: Message[] | undefined,
3377    toolUseContext: ToolUseContext,
3378  ): Promise<Attachment[]> {
3379    if (!isTodoV2Enabled()) {
3380      return []
3381    }
3382  
3383    // Skip for ant users
3384    if (process.env.USER_TYPE === 'ant') {
3385      return []
3386    }
3387  
3388    // When SendUserMessage is in the toolkit, it's the primary communication
3389    // channel and the model is always told to use it (#20467). TaskUpdate
3390    // becomes a side channel — nudging the model about it conflicts with the
3391    // brief workflow. The tool itself stays available; this only gates the nag.
3392    if (
3393      BRIEF_TOOL_NAME &&
3394      toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME))
3395    ) {
3396      return []
3397    }
3398  
3399    // Skip if TaskUpdate tool is not available
3400    if (
3401      !toolUseContext.options.tools.some(t =>
3402        toolMatchesName(t, TASK_UPDATE_TOOL_NAME),
3403      )
3404    ) {
3405      return []
3406    }
3407  
3408    // Skip if no messages provided
3409    if (!messages || messages.length === 0) {
3410      return []
3411    }
3412  
3413    const { turnsSinceLastTaskManagement, turnsSinceLastReminder } =
3414      getTaskReminderTurnCounts(messages)
3415  
3416    // Check if we should show a reminder
3417    if (
3418      turnsSinceLastTaskManagement >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE &&
3419      turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS
3420    ) {
3421      const tasks = await listTasks(getTaskListId())
3422      return [
3423        {
3424          type: 'task_reminder',
3425          content: tasks,
3426          itemCount: tasks.length,
3427        },
3428      ]
3429    }
3430  
3431    return []
3432  }
3433  
3434  /**
3435   * Get attachments for all unified tasks using the Task framework.
3436   * Replaces the old getBackgroundShellAttachments, getBackgroundRemoteSessionAttachments,
3437   * and getAsyncAgentAttachments functions.
3438   */
3439  async function getUnifiedTaskAttachments(
3440    toolUseContext: ToolUseContext,
3441  ): Promise<Attachment[]> {
3442    const appState = toolUseContext.getAppState()
3443    const { attachments, updatedTaskOffsets, evictedTaskIds } =
3444      await generateTaskAttachments(appState)
3445  
3446    applyTaskOffsetsAndEvictions(
3447      toolUseContext.setAppState,
3448      updatedTaskOffsets,
3449      evictedTaskIds,
3450    )
3451  
3452    // Convert TaskAttachment to Attachment format
3453    return attachments.map(taskAttachment => ({
3454      type: 'task_status' as const,
3455      taskId: taskAttachment.taskId,
3456      taskType: taskAttachment.taskType,
3457      status: taskAttachment.status,
3458      description: taskAttachment.description,
3459      deltaSummary: taskAttachment.deltaSummary,
3460      outputFilePath: getTaskOutputPath(taskAttachment.taskId),
3461    }))
3462  }
3463  
3464  async function getAsyncHookResponseAttachments(): Promise<Attachment[]> {
3465    const responses = await checkForAsyncHookResponses()
3466  
3467    if (responses.length === 0) {
3468      return []
3469    }
3470  
3471    logForDebugging(
3472      `Hooks: getAsyncHookResponseAttachments found ${responses.length} responses`,
3473    )
3474  
3475    const attachments = responses.map(
3476      ({
3477        processId,
3478        response,
3479        hookName,
3480        hookEvent,
3481        toolName,
3482        pluginId,
3483        stdout,
3484        stderr,
3485        exitCode,
3486      }) => {
3487        logForDebugging(
3488          `Hooks: Creating attachment for ${processId} (${hookName}): ${jsonStringify(response)}`,
3489        )
3490        return {
3491          type: 'async_hook_response' as const,
3492          processId,
3493          hookName,
3494          hookEvent,
3495          toolName,
3496          response,
3497          stdout,
3498          stderr,
3499          exitCode,
3500        }
3501      },
3502    )
3503  
3504    // Remove delivered hooks from registry to prevent re-processing
3505    if (responses.length > 0) {
3506      const processIds = responses.map(r => r.processId)
3507      removeDeliveredAsyncHooks(processIds)
3508      logForDebugging(
3509        `Hooks: Removed ${processIds.length} delivered hooks from registry`,
3510      )
3511    }
3512  
3513    logForDebugging(
3514      `Hooks: getAsyncHookResponseAttachments found ${attachments.length} attachments`,
3515    )
3516  
3517    return attachments
3518  }
3519  
3520  /**
3521   * Get teammate mailbox attachments for agent swarm communication
3522   * Teammates are independent Claude Code sessions running in parallel (swarms),
3523   * not parent-child subagent relationships.
3524   *
3525   * This function checks two sources for messages:
3526   * 1. File-based mailbox (for messages that arrived between polls)
3527   * 2. AppState.inbox (for messages queued mid-turn by useInboxPoller)
3528   *
3529   * Messages from AppState.inbox are delivered mid-turn as attachments,
3530   * allowing teammates to receive messages without waiting for the turn to end.
3531   */
3532  async function getTeammateMailboxAttachments(
3533    toolUseContext: ToolUseContext,
3534  ): Promise<Attachment[]> {
3535    if (!isAgentSwarmsEnabled()) {
3536      return []
3537    }
3538    if (process.env.USER_TYPE !== 'ant') {
3539      return []
3540    }
3541  
3542    // Get AppState early to check for team lead status
3543    const appState = toolUseContext.getAppState()
3544  
3545    // Use agent name from helper (checks AsyncLocalStorage, then dynamicTeamContext)
3546    const envAgentName = getAgentName()
3547  
3548    // Get team name (checks AsyncLocalStorage, dynamicTeamContext, then AppState)
3549    const teamName = getTeamName(appState.teamContext)
3550  
3551    // Check if we're the team lead (uses shared logic from swarm utils)
3552    const teamLeadStatus = isTeamLead(appState.teamContext)
3553  
3554    // Check if viewing a teammate's transcript (for in-process teammates)
3555    const viewedTeammate = getViewedTeammateTask(appState)
3556  
3557    // Resolve agent name based on who we're VIEWING:
3558    // - If viewing a teammate, use THEIR name (to read from their mailbox)
3559    // - Otherwise use env var if set, or leader's name if we're the team lead
3560    let agentName = viewedTeammate?.identity.agentName ?? envAgentName
3561    if (!agentName && teamLeadStatus && appState.teamContext) {
3562      const leadAgentId = appState.teamContext.leadAgentId
3563      // Look up the lead's name from agents map (not the UUID)
3564      agentName = appState.teamContext.teammates[leadAgentId]?.name || 'team-lead'
3565    }
3566  
3567    logForDebugging(
3568      `[SwarmMailbox] getTeammateMailboxAttachments called: envAgentName=${envAgentName}, isTeamLead=${teamLeadStatus}, resolved agentName=${agentName}, teamName=${teamName}`,
3569    )
3570  
3571    // Only check inbox if running as an agent in a swarm or team lead
3572    if (!agentName) {
3573      logForDebugging(
3574        `[SwarmMailbox] Not checking inbox - not in a swarm or team lead`,
3575      )
3576      return []
3577    }
3578  
3579    logForDebugging(
3580      `[SwarmMailbox] Checking inbox for agent="${agentName}" team="${teamName || 'default'}"`,
3581    )
3582  
3583    // Check mailbox for unread messages (routes to in-process or file-based)
3584    // Filter out structured protocol messages (permission requests/responses, shutdown
3585    // messages, etc.) — these must be left unread for useInboxPoller to route to their
3586    // proper handlers (workerPermissions queue, sandbox queue, etc.). Without filtering,
3587    // attachment generation races with InboxPoller: whichever reads first marks all
3588    // messages as read, and if attachments wins, protocol messages get bundled as raw
3589    // LLM context text instead of being routed to their UI handlers.
3590    const allUnreadMessages = await readUnreadMessages(agentName, teamName)
3591    const unreadMessages = allUnreadMessages.filter(
3592      m => !isStructuredProtocolMessage(m.text),
3593    )
3594    logForDebugging(
3595      `[MailboxBridge] Found ${allUnreadMessages.length} unread message(s) for "${agentName}" (${allUnreadMessages.length - unreadMessages.length} structured protocol messages filtered out)`,
3596    )
3597  
3598    // Also check AppState.inbox for pending messages (queued mid-turn by useInboxPoller)
3599    // IMPORTANT: appState.inbox contains messages FROM teammates TO the leader.
3600    // Only show these when viewing the leader's transcript (not a teammate's).
3601    // When viewing a teammate, their messages come from the file-based mailbox above.
3602    // In-process teammates share AppState with the leader — appState.inbox contains
3603    // the LEADER's queued messages, not the teammate's. Skip it to prevent leakage
3604    // (including self-echo from broadcasts). Teammates receive messages exclusively
3605    // through their file-based mailbox + waitForNextPromptOrShutdown.
3606    // Note: viewedTeammate was already computed above for agentName resolution
3607    const pendingInboxMessages =
3608      viewedTeammate || isInProcessTeammate()
3609        ? [] // Viewing teammate or running as in-process teammate - don't show leader's inbox
3610        : appState.inbox.messages.filter(m => m.status === 'pending')
3611    logForDebugging(
3612      `[SwarmMailbox] Found ${pendingInboxMessages.length} pending message(s) in AppState.inbox`,
3613    )
3614  
3615    // Combine both sources of messages WITH DEDUPLICATION
3616    // The same message could exist in both file mailbox and AppState.inbox due to race conditions:
3617    // 1. getTeammateMailboxAttachments reads file -> finds message M
3618    // 2. InboxPoller reads same file -> queues M in AppState.inbox
3619    // 3. getTeammateMailboxAttachments reads AppState -> finds M again
3620    // We deduplicate using from+timestamp+text prefix as the key
3621    const seen = new Set<string>()
3622    let allMessages: Array<{
3623      from: string
3624      text: string
3625      timestamp: string
3626      color?: string
3627      summary?: string
3628    }> = []
3629  
3630    for (const m of [...unreadMessages, ...pendingInboxMessages]) {
3631      const key = `${m.from}|${m.timestamp}|${m.text.slice(0, 100)}`
3632      if (!seen.has(key)) {
3633        seen.add(key)
3634        allMessages.push({
3635          from: m.from,
3636          text: m.text,
3637          timestamp: m.timestamp,
3638          color: m.color,
3639          summary: m.summary,
3640        })
3641      }
3642    }
3643  
3644    // Collapse multiple idle notifications per agent — keep only the latest.
3645    // Single pass to parse, then filter without re-parsing.
3646    const idleAgentByIndex = new Map<number, string>()
3647    const latestIdleByAgent = new Map<string, number>()
3648    for (let i = 0; i < allMessages.length; i++) {
3649      const idle = isIdleNotification(allMessages[i]!.text)
3650      if (idle) {
3651        idleAgentByIndex.set(i, idle.from)
3652        latestIdleByAgent.set(idle.from, i)
3653      }
3654    }
3655    if (idleAgentByIndex.size > latestIdleByAgent.size) {
3656      const beforeCount = allMessages.length
3657      allMessages = allMessages.filter((_m, i) => {
3658        const agent = idleAgentByIndex.get(i)
3659        if (agent === undefined) return true
3660        return latestIdleByAgent.get(agent) === i
3661      })
3662      logForDebugging(
3663        `[SwarmMailbox] Collapsed ${beforeCount - allMessages.length} duplicate idle notification(s)`,
3664      )
3665    }
3666  
3667    if (allMessages.length === 0) {
3668      logForDebugging(`[SwarmMailbox] No messages to deliver, returning empty`)
3669      return []
3670    }
3671  
3672    logForDebugging(
3673      `[SwarmMailbox] Returning ${allMessages.length} message(s) as attachment for "${agentName}" (${unreadMessages.length} from file, ${pendingInboxMessages.length} from AppState, after dedup)`,
3674    )
3675  
3676    // Build the attachment BEFORE marking messages as processed
3677    // This prevents message loss if any operation below fails
3678    const attachment: Attachment[] = [
3679      {
3680        type: 'teammate_mailbox',
3681        messages: allMessages,
3682      },
3683    ]
3684  
3685    // Mark only non-structured mailbox messages as read after attachment is built.
3686    // Structured protocol messages stay unread for useInboxPoller to handle.
3687    if (unreadMessages.length > 0) {
3688      await markMessagesAsReadByPredicate(
3689        agentName,
3690        m => !isStructuredProtocolMessage(m.text),
3691        teamName,
3692      )
3693      logForDebugging(
3694        `[MailboxBridge] marked ${unreadMessages.length} non-structured message(s) as read for agent="${agentName}" team="${teamName || 'default'}"`,
3695      )
3696    }
3697  
3698    // Process shutdown_approved messages - remove teammates from team file
3699    // This mirrors what useInboxPoller does in interactive mode (lines 546-606)
3700    // In -p mode, useInboxPoller doesn't run, so we must handle this here
3701    if (teamLeadStatus && teamName) {
3702      for (const m of allMessages) {
3703        const shutdownApproval = isShutdownApproved(m.text)
3704        if (shutdownApproval) {
3705          const teammateToRemove = shutdownApproval.from
3706          logForDebugging(
3707            `[SwarmMailbox] Processing shutdown_approved from ${teammateToRemove}`,
3708          )
3709  
3710          // Find the teammate ID by name
3711          const teammateId = appState.teamContext?.teammates
3712            ? Object.entries(appState.teamContext.teammates).find(
3713                ([, t]) => t.name === teammateToRemove,
3714              )?.[0]
3715            : undefined
3716  
3717          if (teammateId) {
3718            // Remove from team file
3719            removeTeammateFromTeamFile(teamName, {
3720              agentId: teammateId,
3721              name: teammateToRemove,
3722            })
3723            logForDebugging(
3724              `[SwarmMailbox] Removed ${teammateToRemove} from team file`,
3725            )
3726  
3727            // Unassign tasks owned by this teammate
3728            await unassignTeammateTasks(
3729              teamName,
3730              teammateId,
3731              teammateToRemove,
3732              'shutdown',
3733            )
3734  
3735            // Remove from teamContext in AppState
3736            toolUseContext.setAppState(prev => {
3737              if (!prev.teamContext?.teammates) return prev
3738              if (!(teammateId in prev.teamContext.teammates)) return prev
3739              const { [teammateId]: _, ...remainingTeammates } =
3740                prev.teamContext.teammates
3741              return {
3742                ...prev,
3743                teamContext: {
3744                  ...prev.teamContext,
3745                  teammates: remainingTeammates,
3746                },
3747              }
3748            })
3749          }
3750        }
3751      }
3752    }
3753  
3754    // Mark AppState inbox messages as processed LAST, after attachment is built
3755    // This ensures messages aren't lost if earlier operations fail
3756    if (pendingInboxMessages.length > 0) {
3757      const pendingIds = new Set(pendingInboxMessages.map(m => m.id))
3758      toolUseContext.setAppState(prev => ({
3759        ...prev,
3760        inbox: {
3761          messages: prev.inbox.messages.map(m =>
3762            pendingIds.has(m.id) ? { ...m, status: 'processed' as const } : m,
3763          ),
3764        },
3765      }))
3766    }
3767  
3768    return attachment
3769  }
3770  
3771  /**
3772   * Get team context attachment for teammates in a swarm.
3773   * Only injected on the first turn to provide team coordination instructions.
3774   */
3775  function getTeamContextAttachment(messages: Message[]): Attachment[] {
3776    const teamName = getTeamName()
3777    const agentId = getAgentId()
3778    const agentName = getAgentName()
3779  
3780    // Only inject for teammates (not team lead or non-team sessions)
3781    if (!teamName || !agentId) {
3782      return []
3783    }
3784  
3785    // Only inject on first turn - check if there are no assistant messages yet
3786    const hasAssistantMessage = messages.some(m => m.type === 'assistant')
3787    if (hasAssistantMessage) {
3788      return []
3789    }
3790  
3791    const configDir = getClaudeConfigHomeDir()
3792    const teamConfigPath = `${configDir}/teams/${teamName}/config.json`
3793    const taskListPath = `${configDir}/tasks/${teamName}/`
3794  
3795    return [
3796      {
3797        type: 'team_context',
3798        agentId,
3799        agentName: agentName || agentId,
3800        teamName,
3801        teamConfigPath,
3802        taskListPath,
3803      },
3804    ]
3805  }
3806  
3807  function getTokenUsageAttachment(
3808    messages: Message[],
3809    model: string,
3810  ): Attachment[] {
3811    if (!isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT)) {
3812      return []
3813    }
3814  
3815    const contextWindow = getEffectiveContextWindowSize(model)
3816    const usedTokens = tokenCountFromLastAPIResponse(messages)
3817  
3818    return [
3819      {
3820        type: 'token_usage',
3821        used: usedTokens,
3822        total: contextWindow,
3823        remaining: contextWindow - usedTokens,
3824      },
3825    ]
3826  }
3827  
3828  function getOutputTokenUsageAttachment(): Attachment[] {
3829    if (feature('TOKEN_BUDGET')) {
3830      const budget = getCurrentTurnTokenBudget()
3831      if (budget === null || budget <= 0) {
3832        return []
3833      }
3834      return [
3835        {
3836          type: 'output_token_usage',
3837          turn: getTurnOutputTokens(),
3838          session: getTotalOutputTokens(),
3839          budget,
3840        },
3841      ]
3842    }
3843    return []
3844  }
3845  
3846  function getMaxBudgetUsdAttachment(maxBudgetUsd?: number): Attachment[] {
3847    if (maxBudgetUsd === undefined) {
3848      return []
3849    }
3850  
3851    const usedCost = getTotalCostUSD()
3852    const remainingBudget = maxBudgetUsd - usedCost
3853  
3854    return [
3855      {
3856        type: 'budget_usd',
3857        used: usedCost,
3858        total: maxBudgetUsd,
3859        remaining: remainingBudget,
3860      },
3861    ]
3862  }
3863  
3864  /**
3865   * Count human turns since plan mode exit (plan_mode_exit attachment).
3866   * Returns 0 if no plan_mode_exit attachment found.
3867   *
3868   * tool_result messages are type:'user' without isMeta, so filter by
3869   * toolUseResult to avoid counting them — otherwise the 10-turn reminder
3870   * interval fires every ~10 tool calls instead of ~10 human turns.
3871   */
3872  export function getVerifyPlanReminderTurnCount(messages: Message[]): number {
3873    let turnCount = 0
3874    for (let i = messages.length - 1; i >= 0; i--) {
3875      const message = messages[i]
3876      if (message && isHumanTurn(message)) {
3877        turnCount++
3878      }
3879      // Stop counting at plan_mode_exit attachment (marks when implementation started)
3880      if (
3881        message?.type === 'attachment' &&
3882        message.attachment.type === 'plan_mode_exit'
3883      ) {
3884        return turnCount
3885      }
3886    }
3887    // No plan_mode_exit found
3888    return 0
3889  }
3890  
3891  /**
3892   * Get verify plan reminder attachment if the model hasn't called VerifyPlanExecution yet.
3893   */
3894  async function getVerifyPlanReminderAttachment(
3895    messages: Message[] | undefined,
3896    toolUseContext: ToolUseContext,
3897  ): Promise<Attachment[]> {
3898    if (
3899      process.env.USER_TYPE !== 'ant' ||
3900      !isEnvTruthy(process.env.CLAUDE_CODE_VERIFY_PLAN)
3901    ) {
3902      return []
3903    }
3904  
3905    const appState = toolUseContext.getAppState()
3906    const pending = appState.pendingPlanVerification
3907  
3908    // Only remind if plan exists and verification not started or completed
3909    if (
3910      !pending ||
3911      pending.verificationStarted ||
3912      pending.verificationCompleted
3913    ) {
3914      return []
3915    }
3916  
3917    // Only remind every N turns
3918    if (messages && messages.length > 0) {
3919      const turnCount = getVerifyPlanReminderTurnCount(messages)
3920      if (
3921        turnCount === 0 ||
3922        turnCount % VERIFY_PLAN_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS !== 0
3923      ) {
3924        return []
3925      }
3926    }
3927  
3928    return [{ type: 'verify_plan_reminder' }]
3929  }
3930  
3931  export function getCompactionReminderAttachment(
3932    messages: Message[],
3933    model: string,
3934  ): Attachment[] {
3935    if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_fox', false)) {
3936      return []
3937    }
3938  
3939    if (!isAutoCompactEnabled()) {
3940      return []
3941    }
3942  
3943    const contextWindow = getContextWindowForModel(model, getSdkBetas())
3944    if (contextWindow < 1_000_000) {
3945      return []
3946    }
3947  
3948    const effectiveWindow = getEffectiveContextWindowSize(model)
3949    const usedTokens = tokenCountWithEstimation(messages)
3950    if (usedTokens < effectiveWindow * 0.25) {
3951      return []
3952    }
3953  
3954    return [{ type: 'compaction_reminder' }]
3955  }
3956  
3957  /**
3958   * Context-efficiency nudge. Injected after every N tokens of growth without
3959   * a snip. Pacing is handled entirely by shouldNudgeForSnips — the 10k
3960   * interval resets on prior nudges, snip markers, snip boundaries, and
3961   * compact boundaries.
3962   */
3963  export function getContextEfficiencyAttachment(
3964    messages: Message[],
3965  ): Attachment[] {
3966    if (!feature('HISTORY_SNIP')) {
3967      return []
3968    }
3969    // Gate must match SnipTool.isEnabled() — don't nudge toward a tool that
3970    // isn't in the tool list. Lazy require keeps this file snip-string-free.
3971    const { isSnipRuntimeEnabled, shouldNudgeForSnips } =
3972      // eslint-disable-next-line @typescript-eslint/no-require-imports
3973      require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js')
3974    if (!isSnipRuntimeEnabled()) {
3975      return []
3976    }
3977  
3978    if (!shouldNudgeForSnips(messages)) {
3979      return []
3980    }
3981  
3982    return [{ type: 'context_efficiency' }]
3983  }
3984  
3985  
3986  function isFileReadDenied(
3987    filePath: string,
3988    toolPermissionContext: ToolPermissionContext,
3989  ): boolean {
3990    const denyRule = matchingRuleForInput(
3991      filePath,
3992      toolPermissionContext,
3993      'read',
3994      'deny',
3995    )
3996    return denyRule !== null
3997  }