// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } from 'src/services/analytics/index.js' import { toolMatchesName, type Tools, type ToolUseContext, type ToolPermissionContext, } from '../Tool.js' import { FileReadTool, MaxFileReadTokenExceededError, type Output as FileReadToolOutput, readImageWithTokenBudget, } from '../tools/FileReadTool/FileReadTool.js' import { FileTooLargeError, readFileInRange } from './readFileInRange.js' import { expandPath } from './path.js' import { countCharInString } from './stringUtils.js' import { count, uniq } from './array.js' import { getFsImplementation } from './fsOperations.js' import { readdir, stat } from 'fs/promises' import type { IDESelection } from '../hooks/useIdeSelection.js' import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js' import { TASK_CREATE_TOOL_NAME } from '../tools/TaskCreateTool/constants.js' import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js' import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' import { SKILL_TOOL_NAME } from '../tools/SkillTool/constants.js' import type { TodoList } from './todo/types.js' import { type Task, listTasks, getTaskListId, isTodoV2Enabled, } from './tasks.js' import { getPlanFilePath, getPlan } from './plans.js' import { getConnectedIdeName } from './ide.js' import { filterInjectedMemoryFiles, getManagedAndUserConditionalRules, getMemoryFiles, getMemoryFilesForNestedDirectory, getConditionalRulesForCwdLevelDirectory, type MemoryFileInfo, } from './claudemd.js' import { dirname, parse, relative, resolve } from 'path' import { getCwd } from 'src/utils/cwd.js' import { getViewedTeammateTask } from '../state/selectors.js' import { logError } from './log.js' import { logAntError } from './debug.js' import { isENOENT, toError } from './errors.js' import type { DiagnosticFile } from '../services/diagnosticTracking.js' import { diagnosticTracker } from '../services/diagnosticTracking.js' import type { AttachmentMessage, Message, MessageOrigin, } from 'src/types/message.js' import { type QueuedCommand, getImagePasteIds, isValidImagePaste, } from 'src/types/textInputTypes.js' import { randomUUID, type UUID } from 'crypto' import { getSettings_DEPRECATED } from './settings/settings.js' import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js' import type { ContentBlockParam, ImageBlockParam, Base64ImageSource, } from '@anthropic-ai/sdk/resources/messages.mjs' import { maybeResizeAndDownsampleImageBlock } from './imageResizer.js' import type { PastedContent } from './config.js' import { getGlobalConfig } from './config.js' import { getDefaultSonnetModel, getDefaultHaikuModel, getDefaultOpusModel, } from './model/model.js' import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' import { getSkillToolCommands, getMcpSkillCommands } from '../commands.js' import type { Command } from '../types/command.js' import uniqBy from 'lodash-es/uniqBy.js' import { getProjectRoot } from '../bootstrap/state.js' import { formatCommandsWithinBudget } from '../tools/SkillTool/prompt.js' import { getContextWindowForModel } from './context.js' import type { DiscoverySignal } from '../services/skillSearch/signals.js' // Conditional require for DCE. All skill-search string literals that would // otherwise leak into external builds live inside these modules. The only // surfaces in THIS file are: the maybe() call (gated via spread below) and // the skill_listing suppression check (uses the same skillSearchModules null // check). The type-only DiscoverySignal import above is erased at compile time. /* eslint-disable @typescript-eslint/no-require-imports */ const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH') ? { featureCheck: require('../services/skillSearch/featureCheck.js') as typeof import('../services/skillSearch/featureCheck.js'), prefetch: require('../services/skillSearch/prefetch.js') as typeof import('../services/skillSearch/prefetch.js'), } : null const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('./permissions/autoModeState.js') as typeof import('./permissions/autoModeState.js')) : null /* eslint-enable @typescript-eslint/no-require-imports */ import { MAX_LINES_TO_READ, FILE_READ_TOOL_NAME, } from 'src/tools/FileReadTool/prompt.js' import { getDefaultFileReadingLimits } from 'src/tools/FileReadTool/limits.js' import { cacheKeys, type FileStateCache } from './fileStateCache.js' import { createAbortController, createChildAbortController, } from './abortController.js' import { isAbortError } from './errors.js' import { getFileModificationTimeAsync, isFileWithinReadSizeLimit, } from './file.js' import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' import { filterAgentsByMcpRequirements } from '../tools/AgentTool/loadAgentsDir.js' import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' import { formatAgentLine, shouldInjectAgentListInMessages, } from '../tools/AgentTool/prompt.js' import { filterDeniedAgents } from './permissions/permissions.js' import { getSubscriptionType } from './auth.js' import { mcpInfoFromString } from '../services/mcp/mcpStringUtils.js' import { matchingRuleForInput, pathInAllowedWorkingPath, } from './permissions/filesystem.js' import { generateTaskAttachments, applyTaskOffsetsAndEvictions, } from './task/framework.js' import { getTaskOutputPath } from './task/diskOutput.js' import { drainPendingMessages } from '../tasks/LocalAgentTask/LocalAgentTask.js' import type { TaskType, TaskStatus } from '../Task.js' import { getOriginalCwd, getSessionId, getSdkBetas, getTotalCostUSD, getTotalOutputTokens, getCurrentTurnTokenBudget, getTurnOutputTokens, hasExitedPlanModeInSession, setHasExitedPlanMode, needsPlanModeExitAttachment, setNeedsPlanModeExitAttachment, needsAutoModeExitAttachment, setNeedsAutoModeExitAttachment, getLastEmittedDate, setLastEmittedDate, getKairosActive, } from '../bootstrap/state.js' import type { QuerySource } from '../constants/querySource.js' import { getDeferredToolsDelta, isDeferredToolsDeltaEnabled, isToolSearchEnabledOptimistic, isToolSearchToolAvailable, modelSupportsToolReference, type DeferredToolsDeltaScanContext, } from './toolSearch.js' import { getMcpInstructionsDelta, isMcpInstructionsDeltaEnabled, type ClientSideInstruction, } from './mcpInstructionsDelta.js' import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from './claudeInChrome/common.js' import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from './claudeInChrome/prompt.js' import type { MCPServerConnection } from '../services/mcp/types.js' import type { HookEvent, SyncHookJSONOutput, } from 'src/entrypoints/agentSdkTypes.js' import { checkForAsyncHookResponses, removeDeliveredAsyncHooks, } from './hooks/AsyncHookRegistry.js' import { checkForLSPDiagnostics, clearAllLSPDiagnostics, } from '../services/lsp/LSPDiagnosticRegistry.js' import { logForDebugging } from './debug.js' import { extractTextContent, getUserMessageText, isThinkingMessage, } from './messages.js' import { isHumanTurn } from './messagePredicates.js' import { isEnvTruthy, getClaudeConfigHomeDir } from './envUtils.js' import { feature } from 'bun:bundle' /* eslint-disable @typescript-eslint/no-require-imports */ const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') ).BRIEF_TOOL_NAME : null const sessionTranscriptModule = feature('KAIROS') ? (require('../services/sessionTranscript/sessionTranscript.js') as typeof import('../services/sessionTranscript/sessionTranscript.js')) : null /* eslint-enable @typescript-eslint/no-require-imports */ import { hasUltrathinkKeyword, isUltrathinkEnabled } from './thinking.js' import { tokenCountFromLastAPIResponse, tokenCountWithEstimation, } from './tokens.js' import { getEffectiveContextWindowSize, isAutoCompactEnabled, } from '../services/compact/autoCompact.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { hasInstructionsLoadedHook, executeInstructionsLoadedHooks, type HookBlockingError, type InstructionsMemoryType, } from './hooks.js' import { jsonStringify } from './slowOperations.js' import { isPDFExtension } from './pdfUtils.js' import { getLocalISODate } from '../constants/common.js' import { getPDFPageCount } from './pdf.js' import { PDF_AT_MENTION_INLINE_THRESHOLD } from '../constants/apiLimits.js' import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js' import { findRelevantMemories } from '../memdir/findRelevantMemories.js' import { memoryAge, memoryFreshnessText } from '../memdir/memoryAge.js' import { getAutoMemPath, isAutoMemoryEnabled } from '../memdir/paths.js' import { getAgentMemoryDir } from '../tools/AgentTool/agentMemory.js' import { readUnreadMessages, markMessagesAsReadByPredicate, isShutdownApproved, isStructuredProtocolMessage, isIdleNotification, } from './teammateMailbox.js' import { getAgentName, getAgentId, getTeamName, isTeamLead, } from './teammate.js' import { isInProcessTeammate } from './teammateContext.js' import { removeTeammateFromTeamFile } from './swarm/teamHelpers.js' import { unassignTeammateTasks } from './tasks.js' import { getCompanionIntroAttachment } from '../buddy/prompt.js' export const TODO_REMINDER_CONFIG = { TURNS_SINCE_WRITE: 10, TURNS_BETWEEN_REMINDERS: 10, } as const export const PLAN_MODE_ATTACHMENT_CONFIG = { TURNS_BETWEEN_ATTACHMENTS: 5, FULL_REMINDER_EVERY_N_ATTACHMENTS: 5, } as const export const AUTO_MODE_ATTACHMENT_CONFIG = { TURNS_BETWEEN_ATTACHMENTS: 5, FULL_REMINDER_EVERY_N_ATTACHMENTS: 5, } as const const MAX_MEMORY_LINES = 200 // Line cap alone doesn't bound size (200 × 500-char lines = 100KB). The // surfacer injects up to 5 files per turn via , bypassing // the per-message tool-result budget, so a tight per-file byte cap keeps // aggregate injection bounded (5 × 4KB = 20KB/turn). Enforced via // readFileInRange's truncateOnByteLimit option. Truncation means the // most-relevant memory still surfaces: the frontmatter + opening context // is usually what matters. const MAX_MEMORY_BYTES = 4096 export const RELEVANT_MEMORIES_CONFIG = { // Per-turn cap (5 × 4KB = 20KB) bounds a single injection, but over a // long session the selector keeps surfacing distinct files — ~26K tokens/ // session observed in prod. Cap the cumulative bytes: once hit, stop // prefetching entirely. Budget is ~3 full injections; after that the // most-relevant memories are already in context. Scanning messages // (rather than tracking in toolUseContext) means compact naturally // resets the counter — old attachments are gone from context, so // re-surfacing is valid. MAX_SESSION_BYTES: 60 * 1024, } as const export const VERIFY_PLAN_REMINDER_CONFIG = { TURNS_BETWEEN_REMINDERS: 10, } as const export type FileAttachment = { type: 'file' filename: string content: FileReadToolOutput /** * Whether the file was truncated due to size limits */ truncated?: boolean /** Path relative to CWD at creation time, for stable display */ displayPath: string } export type CompactFileReferenceAttachment = { type: 'compact_file_reference' filename: string /** Path relative to CWD at creation time, for stable display */ displayPath: string } export type PDFReferenceAttachment = { type: 'pdf_reference' filename: string pageCount: number fileSize: number /** Path relative to CWD at creation time, for stable display */ displayPath: string } export type AlreadyReadFileAttachment = { type: 'already_read_file' filename: string content: FileReadToolOutput /** * Whether the file was truncated due to size limits */ truncated?: boolean /** Path relative to CWD at creation time, for stable display */ displayPath: string } export type AgentMentionAttachment = { type: 'agent_mention' agentType: string } export type AsyncHookResponseAttachment = { type: 'async_hook_response' processId: string hookName: string hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' toolName?: string response: SyncHookJSONOutput stdout: string stderr: string exitCode?: number } export type HookAttachment = | HookCancelledAttachment | { type: 'hook_blocking_error' blockingError: HookBlockingError hookName: string toolUseID: string hookEvent: HookEvent } | HookNonBlockingErrorAttachment | HookErrorDuringExecutionAttachment | { type: 'hook_stopped_continuation' message: string hookName: string toolUseID: string hookEvent: HookEvent } | HookSuccessAttachment | { type: 'hook_additional_context' content: string[] hookName: string toolUseID: string hookEvent: HookEvent } | HookSystemMessageAttachment | HookPermissionDecisionAttachment export type HookPermissionDecisionAttachment = { type: 'hook_permission_decision' decision: 'allow' | 'deny' toolUseID: string hookEvent: HookEvent } export type HookSystemMessageAttachment = { type: 'hook_system_message' content: string hookName: string toolUseID: string hookEvent: HookEvent } export type HookCancelledAttachment = { type: 'hook_cancelled' hookName: string toolUseID: string hookEvent: HookEvent command?: string durationMs?: number } export type HookErrorDuringExecutionAttachment = { type: 'hook_error_during_execution' content: string hookName: string toolUseID: string hookEvent: HookEvent command?: string durationMs?: number } export type HookSuccessAttachment = { type: 'hook_success' content: string hookName: string toolUseID: string hookEvent: HookEvent stdout?: string stderr?: string exitCode?: number command?: string durationMs?: number } export type HookNonBlockingErrorAttachment = { type: 'hook_non_blocking_error' hookName: string stderr: string stdout: string exitCode: number toolUseID: string hookEvent: HookEvent command?: string durationMs?: number } export type Attachment = /** * User at-mentioned the file */ | FileAttachment | CompactFileReferenceAttachment | PDFReferenceAttachment | AlreadyReadFileAttachment /** * An at-mentioned file was edited */ | { type: 'edited_text_file' filename: string snippet: string } | { type: 'edited_image_file' filename: string content: FileReadToolOutput } | { type: 'directory' path: string content: string /** Path relative to CWD at creation time, for stable display */ displayPath: string } | { type: 'selected_lines_in_ide' ideName: string lineStart: number lineEnd: number filename: string content: string /** Path relative to CWD at creation time, for stable display */ displayPath: string } | { type: 'opened_file_in_ide' filename: string } | { type: 'todo_reminder' content: TodoList itemCount: number } | { type: 'task_reminder' content: Task[] itemCount: number } | { type: 'nested_memory' path: string content: MemoryFileInfo /** Path relative to CWD at creation time, for stable display */ displayPath: string } | { type: 'relevant_memories' memories: { path: string content: string mtimeMs: number /** * Pre-computed header string (age + path prefix). Computed once * at attachment-creation time so the rendered bytes are stable * across turns — recomputing memoryAge(mtimeMs) at render time * calls Date.now(), so "saved 3 days ago" becomes "saved 4 days * ago" across turns → different bytes → prompt cache bust. * Optional for backward compat with resumed sessions; render * path falls back to recomputing if missing. */ header?: string /** * lineCount when the file was truncated by readMemoriesForSurfacing, * else undefined. Threaded to the readFileState write so * getChangedFiles skips truncated memories (partial content would * yield a misleading diff). */ limit?: number }[] } | { type: 'dynamic_skill' skillDir: string skillNames: string[] /** Path relative to CWD at creation time, for stable display */ displayPath: string } | { type: 'skill_listing' content: string skillCount: number isInitial: boolean } | { type: 'skill_discovery' skills: { name: string; description: string; shortId?: string }[] signal: DiscoverySignal source: 'native' | 'aki' | 'both' } | { type: 'queued_command' prompt: string | Array source_uuid?: UUID imagePasteIds?: number[] /** Original queue mode — 'prompt' for user messages, 'task-notification' for system events */ commandMode?: string /** Provenance carried from QueuedCommand so mid-turn drains preserve it */ origin?: MessageOrigin /** Carried from QueuedCommand.isMeta — distinguishes human-typed from system-injected */ isMeta?: boolean } | { type: 'output_style' style: string } | { type: 'diagnostics' files: DiagnosticFile[] isNew: boolean } | { type: 'plan_mode' reminderType: 'full' | 'sparse' isSubAgent?: boolean planFilePath: string planExists: boolean } | { type: 'plan_mode_reentry' planFilePath: string } | { type: 'plan_mode_exit' planFilePath: string planExists: boolean } | { type: 'auto_mode' reminderType: 'full' | 'sparse' } | { type: 'auto_mode_exit' } | { type: 'critical_system_reminder' content: string } | { type: 'plan_file_reference' planFilePath: string planContent: string } | { type: 'mcp_resource' server: string uri: string name: string description?: string content: ReadResourceResult } | { type: 'command_permissions' allowedTools: string[] model?: string } | AgentMentionAttachment | { type: 'task_status' taskId: string taskType: TaskType status: TaskStatus description: string deltaSummary: string | null outputFilePath?: string } | AsyncHookResponseAttachment | { type: 'token_usage' used: number total: number remaining: number } | { type: 'budget_usd' used: number total: number remaining: number } | { type: 'output_token_usage' turn: number session: number budget: number | null } | { type: 'structured_output' data: unknown } | TeammateMailboxAttachment | TeamContextAttachment | HookAttachment | { type: 'invoked_skills' skills: Array<{ name: string path: string content: string }> } | { type: 'verify_plan_reminder' } | { type: 'max_turns_reached' maxTurns: number turnCount: number } | { type: 'current_session_memory' content: string path: string tokenCount: number } | { type: 'teammate_shutdown_batch' count: number } | { type: 'compaction_reminder' } | { type: 'context_efficiency' } | { type: 'date_change' newDate: string } | { type: 'ultrathink_effort' level: 'high' } | { type: 'deferred_tools_delta' addedNames: string[] addedLines: string[] removedNames: string[] } | { type: 'agent_listing_delta' addedTypes: string[] addedLines: string[] removedTypes: string[] /** True when this is the first announcement in the conversation */ isInitial: boolean /** Whether to include the "launch multiple agents concurrently" note (non-pro subscriptions) */ showConcurrencyNote: boolean } | { type: 'mcp_instructions_delta' addedNames: string[] addedBlocks: string[] removedNames: string[] } | { type: 'companion_intro' name: string species: string } | { type: 'bagel_console' errorCount: number warningCount: number sample: string } export type TeammateMailboxAttachment = { type: 'teammate_mailbox' messages: Array<{ from: string text: string timestamp: string color?: string summary?: string }> } export type TeamContextAttachment = { type: 'team_context' agentId: string agentName: string teamName: string teamConfigPath: string taskListPath: string } /** * This is janky * TODO: Generate attachments when we create messages */ export async function getAttachments( input: string | null, toolUseContext: ToolUseContext, ideSelection: IDESelection | null, queuedCommands: QueuedCommand[], messages?: Message[], querySource?: QuerySource, options?: { skipSkillDiscovery?: boolean }, ): Promise { if ( isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS) || isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) ) { // query.ts:removeFromQueue dequeues these unconditionally after // getAttachmentMessages runs — returning [] here silently drops them. // Coworker runs with --bare and depends on task-notification for // mid-tool-call notifications from Local*Task/Remote*Task. return getQueuedCommandAttachments(queuedCommands) } // This will slow down submissions // TODO: Compute attachments as the user types, not here (though we use this // function for slash command prompts too) const abortController = createAbortController() const timeoutId = setTimeout(ac => ac.abort(), 1000, abortController) const context = { ...toolUseContext, abortController } const isMainThread = !toolUseContext.agentId // Attachments which are added in response to on user input const userInputAttachments = input ? [ maybe('at_mentioned_files', () => processAtMentionedFiles(input, context), ), maybe('mcp_resources', () => processMcpResourceAttachments(input, context), ), maybe('agent_mentions', () => Promise.resolve( processAgentMentions( input, toolUseContext.options.agentDefinitions.activeAgents, ), ), ), // Skill discovery on turn 0 (user input as signal). Inter-turn // discovery runs via startSkillDiscoveryPrefetch in query.ts, // gated on write-pivot detection — see skillSearch/prefetch.ts. // feature() here lets DCE drop the 'skill_discovery' string (and the // function it calls) from external builds. // // skipSkillDiscovery gates out the SKILL.md-expansion path // (getMessagesForPromptSlashCommand). When a skill is invoked, its // SKILL.md content is passed as `input` here to extract @-mentions — // but that content is NOT user intent and must not trigger discovery. // Without this gate, a 110KB SKILL.md fires ~3.3s of chunked AKI // queries on every skill invocation (session 13a9afae). ...(feature('EXPERIMENTAL_SKILL_SEARCH') && skillSearchModules && !options?.skipSkillDiscovery ? [ maybe('skill_discovery', () => skillSearchModules.prefetch.getTurnZeroSkillDiscovery( input, messages ?? [], context, ), ), ] : []), ] : [] // Process user input attachments first (includes @mentioned files) // This ensures files are added to nestedMemoryAttachmentTriggers before nested_memory processes them const userAttachmentResults = await Promise.all(userInputAttachments) // Thread-safe attachments available in sub-agents // NOTE: These must be created AFTER userInputAttachments completes to ensure // nestedMemoryAttachmentTriggers is populated before getNestedMemoryAttachments runs const allThreadAttachments = [ // queuedCommands is already agent-scoped by the drain gate in query.ts — // main thread gets agentId===undefined, subagents get their own agentId. // Must run for all threads or subagent notifications drain into the void // (removed from queue by removeFromQueue but never attached). maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)), maybe('date_change', () => Promise.resolve(getDateChangeAttachments(messages)), ), maybe('ultrathink_effort', () => Promise.resolve(getUltrathinkEffortAttachment(input)), ), maybe('deferred_tools_delta', () => Promise.resolve( getDeferredToolsDeltaAttachment( toolUseContext.options.tools, toolUseContext.options.mainLoopModel, messages, { callSite: isMainThread ? 'attachments_main' : 'attachments_subagent', querySource, }, ), ), ), maybe('agent_listing_delta', () => Promise.resolve(getAgentListingDeltaAttachment(toolUseContext, messages)), ), maybe('mcp_instructions_delta', () => Promise.resolve( getMcpInstructionsDeltaAttachment( toolUseContext.options.mcpClients, toolUseContext.options.tools, toolUseContext.options.mainLoopModel, messages, ), ), ), ...(feature('BUDDY') ? [ maybe('companion_intro', () => Promise.resolve(getCompanionIntroAttachment(messages)), ), ] : []), maybe('changed_files', () => getChangedFiles(context)), maybe('nested_memory', () => getNestedMemoryAttachments(context)), // relevant_memories moved to async prefetch (startRelevantMemoryPrefetch) maybe('dynamic_skill', () => getDynamicSkillAttachments(context)), maybe('skill_listing', () => getSkillListingAttachments(context)), // Inter-turn skill discovery now runs via startSkillDiscoveryPrefetch // (query.ts, concurrent with the main turn). The blocking call that // previously lived here was the assistant_turn signal — 97% of those // Haiku calls found nothing in prod. Prefetch + await-at-collection // replaces it; see src/services/skillSearch/prefetch.ts. maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)), maybe('plan_mode_exit', () => getPlanModeExitAttachment(toolUseContext)), ...(feature('TRANSCRIPT_CLASSIFIER') ? [ maybe('auto_mode', () => getAutoModeAttachments(messages, toolUseContext), ), maybe('auto_mode_exit', () => getAutoModeExitAttachment(toolUseContext), ), ] : []), maybe('todo_reminders', () => isTodoV2Enabled() ? getTaskReminderAttachments(messages, toolUseContext) : getTodoReminderAttachments(messages, toolUseContext), ), ...(isAgentSwarmsEnabled() ? [ // Skip teammate mailbox for the session_memory forked agent. // It shares AppState.teamContext with the leader, so isTeamLead resolves // true and it reads+marks-as-read the leader's DMs as ephemeral attachments, // silently stealing messages that should be delivered as permanent turns. ...(querySource === 'session_memory' ? [] : [ maybe('teammate_mailbox', async () => getTeammateMailboxAttachments(toolUseContext), ), ]), maybe('team_context', async () => getTeamContextAttachment(messages ?? []), ), ] : []), maybe('agent_pending_messages', async () => getAgentPendingMessageAttachments(toolUseContext), ), maybe('critical_system_reminder', () => Promise.resolve(getCriticalSystemReminderAttachment(toolUseContext)), ), ...(feature('COMPACTION_REMINDERS') ? [ maybe('compaction_reminder', () => Promise.resolve( getCompactionReminderAttachment( messages ?? [], toolUseContext.options.mainLoopModel, ), ), ), ] : []), ...(feature('HISTORY_SNIP') ? [ maybe('context_efficiency', () => Promise.resolve(getContextEfficiencyAttachment(messages ?? [])), ), ] : []), ] // Attachments which are semantically only for the main conversation or don't have concurrency-safe implementations const mainThreadAttachments = isMainThread ? [ maybe('ide_selection', async () => getSelectedLinesFromIDE(ideSelection, toolUseContext), ), maybe('ide_opened_file', async () => getOpenedFileFromIDE(ideSelection, toolUseContext), ), maybe('output_style', async () => Promise.resolve(getOutputStyleAttachment()), ), maybe('diagnostics', async () => getDiagnosticAttachments(toolUseContext), ), maybe('lsp_diagnostics', async () => getLSPDiagnosticAttachments(toolUseContext), ), maybe('unified_tasks', async () => getUnifiedTaskAttachments(toolUseContext), ), maybe('async_hook_responses', async () => getAsyncHookResponseAttachments(), ), maybe('token_usage', async () => Promise.resolve( getTokenUsageAttachment( messages ?? [], toolUseContext.options.mainLoopModel, ), ), ), maybe('budget_usd', async () => Promise.resolve( getMaxBudgetUsdAttachment(toolUseContext.options.maxBudgetUsd), ), ), maybe('output_token_usage', async () => Promise.resolve(getOutputTokenUsageAttachment()), ), maybe('verify_plan_reminder', async () => getVerifyPlanReminderAttachment(messages, toolUseContext), ), ] : [] // Process thread and main thread attachments in parallel (no dependencies between them) const [threadAttachmentResults, mainThreadAttachmentResults] = await Promise.all([ Promise.all(allThreadAttachments), Promise.all(mainThreadAttachments), ]) clearTimeout(timeoutId) // Defensive: a getter leaking [undefined] crashes .map(a => a.type) below. return [ ...userAttachmentResults.flat(), ...threadAttachmentResults.flat(), ...mainThreadAttachmentResults.flat(), ].filter(a => a !== undefined && a !== null) } async function maybe(label: string, f: () => Promise): Promise { const startTime = Date.now() try { const result = await f() const duration = Date.now() - startTime // Log only 5% of events to reduce volume if (Math.random() < 0.05) { // jsonStringify(undefined) returns undefined, so .length would throw const attachmentSizeBytes = result .filter(a => a !== undefined && a !== null) .reduce((total, attachment) => { return total + jsonStringify(attachment).length }, 0) logEvent('tengu_attachment_compute_duration', { label, duration_ms: duration, attachment_size_bytes: attachmentSizeBytes, attachment_count: result.length, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) } return result } catch (e) { const duration = Date.now() - startTime // Log only 5% of events to reduce volume if (Math.random() < 0.05) { logEvent('tengu_attachment_compute_duration', { label, duration_ms: duration, error: true, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) } logError(e) // For Ant users, log the full error to help with debugging logAntError(`Attachment error in ${label}`, e) return [] } } const INLINE_NOTIFICATION_MODES = new Set(['prompt', 'task-notification']) export async function getQueuedCommandAttachments( queuedCommands: QueuedCommand[], ): Promise { if (!queuedCommands) { return [] } // Include both 'prompt' and 'task-notification' commands as attachments. // During proactive agentic loops, task-notification commands would otherwise // stay in the queue permanently (useQueueProcessor can't run while a query // is active), causing hasPendingNotifications() to return true and Sleep to // wake immediately with 0ms duration in an infinite loop. const filtered = queuedCommands.filter(_ => INLINE_NOTIFICATION_MODES.has(_.mode), ) return Promise.all( filtered.map(async _ => { const imageBlocks = await buildImageContentBlocks(_.pastedContents) let prompt: string | Array = _.value if (imageBlocks.length > 0) { // Build content block array with text + images so the model sees them const textValue = typeof _.value === 'string' ? _.value : extractTextContent(_.value, '\n') prompt = [{ type: 'text' as const, text: textValue }, ...imageBlocks] } return { type: 'queued_command' as const, prompt, source_uuid: _.uuid, imagePasteIds: getImagePasteIds(_.pastedContents), commandMode: _.mode, origin: _.origin, isMeta: _.isMeta, } }), ) } export function getAgentPendingMessageAttachments( toolUseContext: ToolUseContext, ): Attachment[] { const agentId = toolUseContext.agentId if (!agentId) return [] const drained = drainPendingMessages( agentId, toolUseContext.getAppState, toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState, ) return drained.map(msg => ({ type: 'queued_command' as const, prompt: msg, origin: { kind: 'coordinator' as const }, isMeta: true, })) } async function buildImageContentBlocks( pastedContents: Record | undefined, ): Promise { if (!pastedContents) { return [] } const imageContents = Object.values(pastedContents).filter(isValidImagePaste) if (imageContents.length === 0) { return [] } const results = await Promise.all( imageContents.map(async img => { const imageBlock: ImageBlockParam = { type: 'image', source: { type: 'base64', media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], data: img.content, }, } const resized = await maybeResizeAndDownsampleImageBlock(imageBlock) return resized.block }), ) return results } function getPlanModeAttachmentTurnCount(messages: Message[]): { turnCount: number foundPlanModeAttachment: boolean } { let turnsSinceLastAttachment = 0 let foundPlanModeAttachment = false // Iterate backwards to find most recent plan_mode attachment. // Count HUMAN turns (non-meta, non-tool-result user messages), not assistant // messages — the tool loop in query.ts calls getAttachmentMessages on every // tool round, so counting assistant messages would fire the reminder every // 5 tool calls instead of every 5 human turns. for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if ( message?.type === 'user' && !message.isMeta && !hasToolResultContent(message.message.content) ) { turnsSinceLastAttachment++ } else if ( message?.type === 'attachment' && (message.attachment.type === 'plan_mode' || message.attachment.type === 'plan_mode_reentry') ) { foundPlanModeAttachment = true break } } return { turnCount: turnsSinceLastAttachment, foundPlanModeAttachment } } /** * Count plan_mode attachments since the last plan_mode_exit (or from start if no exit). * This ensures the full/sparse cycle resets when re-entering plan mode. */ function countPlanModeAttachmentsSinceLastExit(messages: Message[]): number { let count = 0 // Iterate backwards - if we hit a plan_mode_exit, stop counting for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message?.type === 'attachment') { if (message.attachment.type === 'plan_mode_exit') { break // Stop counting at the last exit } if (message.attachment.type === 'plan_mode') { count++ } } } return count } async function getPlanModeAttachments( messages: Message[] | undefined, toolUseContext: ToolUseContext, ): Promise { const appState = toolUseContext.getAppState() const permissionContext = appState.toolPermissionContext if (permissionContext.mode !== 'plan') { return [] } // Check if we should attach based on turn count (except for first turn) if (messages && messages.length > 0) { const { turnCount, foundPlanModeAttachment } = getPlanModeAttachmentTurnCount(messages) // Only throttle if we've already sent a plan_mode attachment before // On first turn in plan mode, always attach if ( foundPlanModeAttachment && turnCount < PLAN_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS ) { return [] } } const planFilePath = getPlanFilePath(toolUseContext.agentId) const existingPlan = getPlan(toolUseContext.agentId) const attachments: Attachment[] = [] // Check for re-entry: flag is set AND plan file exists if (hasExitedPlanModeInSession() && existingPlan !== null) { attachments.push({ type: 'plan_mode_reentry', planFilePath }) setHasExitedPlanMode(false) // Clear flag - one-time guidance } // Determine if this should be a full or sparse reminder // Full reminder on 1st, 6th, 11th... (every Nth attachment) const attachmentCount = countPlanModeAttachmentsSinceLastExit(messages ?? []) + 1 const reminderType: 'full' | 'sparse' = attachmentCount % PLAN_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS === 1 ? 'full' : 'sparse' // Always add the main plan_mode attachment attachments.push({ type: 'plan_mode', reminderType, isSubAgent: !!toolUseContext.agentId, planFilePath, planExists: existingPlan !== null, }) return attachments } /** * Returns a plan_mode_exit attachment if we just exited plan mode. * This is a one-time notification to tell the model it's no longer in plan mode. */ async function getPlanModeExitAttachment( toolUseContext: ToolUseContext, ): Promise { // Only trigger if the flag is set (we just exited plan mode) if (!needsPlanModeExitAttachment()) { return [] } const appState = toolUseContext.getAppState() if (appState.toolPermissionContext.mode === 'plan') { setNeedsPlanModeExitAttachment(false) return [] } // Clear the flag - this is a one-time notification setNeedsPlanModeExitAttachment(false) const planFilePath = getPlanFilePath(toolUseContext.agentId) const planExists = getPlan(toolUseContext.agentId) !== null // Note: skill discovery does NOT fire on plan exit. By the time the plan is // written, it's too late — the model should have had relevant skills WHILE // planning. The user_message signal already fires on the request that // triggers planning ("plan how to deploy this"), which is the right moment. return [{ type: 'plan_mode_exit', planFilePath, planExists }] } function getAutoModeAttachmentTurnCount(messages: Message[]): { turnCount: number foundAutoModeAttachment: boolean } { let turnsSinceLastAttachment = 0 let foundAutoModeAttachment = false // Iterate backwards to find most recent auto_mode attachment. // Count HUMAN turns (non-meta, non-tool-result user messages), not assistant // messages — the tool loop in query.ts calls getAttachmentMessages on every // tool round, so a single human turn with 100 tool calls would fire ~20 // reminders if we counted assistant messages. Auto mode's target use case is // long agentic sessions, where this accumulated 60-105× per session. for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if ( message?.type === 'user' && !message.isMeta && !hasToolResultContent(message.message.content) ) { turnsSinceLastAttachment++ } else if ( message?.type === 'attachment' && message.attachment.type === 'auto_mode' ) { foundAutoModeAttachment = true break } else if ( message?.type === 'attachment' && message.attachment.type === 'auto_mode_exit' ) { // Exit resets the throttle — treat as if no prior attachment exists break } } return { turnCount: turnsSinceLastAttachment, foundAutoModeAttachment } } /** * Count auto_mode attachments since the last auto_mode_exit (or from start if no exit). * This ensures the full/sparse cycle resets when re-entering auto mode. */ function countAutoModeAttachmentsSinceLastExit(messages: Message[]): number { let count = 0 for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message?.type === 'attachment') { if (message.attachment.type === 'auto_mode_exit') { break } if (message.attachment.type === 'auto_mode') { count++ } } } return count } async function getAutoModeAttachments( messages: Message[] | undefined, toolUseContext: ToolUseContext, ): Promise { const appState = toolUseContext.getAppState() const permissionContext = appState.toolPermissionContext const inAuto = permissionContext.mode === 'auto' const inPlanWithAuto = permissionContext.mode === 'plan' && (autoModeStateModule?.isAutoModeActive() ?? false) if (!inAuto && !inPlanWithAuto) { return [] } // Check if we should attach based on turn count (except for first turn) if (messages && messages.length > 0) { const { turnCount, foundAutoModeAttachment } = getAutoModeAttachmentTurnCount(messages) // Only throttle if we've already sent an auto_mode attachment before // On first turn in auto mode, always attach if ( foundAutoModeAttachment && turnCount < AUTO_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS ) { return [] } } // Determine if this should be a full or sparse reminder const attachmentCount = countAutoModeAttachmentsSinceLastExit(messages ?? []) + 1 const reminderType: 'full' | 'sparse' = attachmentCount % AUTO_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS === 1 ? 'full' : 'sparse' return [{ type: 'auto_mode', reminderType }] } /** * Returns an auto_mode_exit attachment if we just exited auto mode. * This is a one-time notification to tell the model it's no longer in auto mode. */ async function getAutoModeExitAttachment( toolUseContext: ToolUseContext, ): Promise { if (!needsAutoModeExitAttachment()) { return [] } const appState = toolUseContext.getAppState() // Suppress when auto is still active — covers both mode==='auto' and // plan-with-auto-active (where mode==='plan' but classifier runs). if ( appState.toolPermissionContext.mode === 'auto' || (autoModeStateModule?.isAutoModeActive() ?? false) ) { setNeedsAutoModeExitAttachment(false) return [] } setNeedsAutoModeExitAttachment(false) return [{ type: 'auto_mode_exit' }] } /** * Detects when the local date has changed since the last turn (user coding * past midnight) and emits an attachment to notify the model. * * The date_change attachment is appended at the tail of the conversation, * so the model learns the new date without mutating the cached prefix. * messages[0] (from getUserContext → prependUserContext) intentionally * keeps the stale date — clearing that cache would regenerate the prefix * and turn the entire conversation into cache_creation on the next turn * (~920K effective tokens per midnight crossing per overnight session). * * Exported for testing — regression guard for the cache-clear removal. */ export function getDateChangeAttachments( messages: Message[] | undefined, ): Attachment[] { const currentDate = getLocalISODate() const lastDate = getLastEmittedDate() if (lastDate === null) { // First turn — just record, no attachment needed setLastEmittedDate(currentDate) return [] } if (currentDate === lastDate) { return [] } setLastEmittedDate(currentDate) // Assistant mode: flush yesterday's transcript to the per-day file so // the /dream skill (1–5am local) finds it even if no compaction fires // today. Fire-and-forget; writeSessionTranscriptSegment buckets by // message timestamp so a multi-day gap flushes each day correctly. if (feature('KAIROS')) { if (getKairosActive() && messages !== undefined) { sessionTranscriptModule?.flushOnDateChange(messages, currentDate) } } return [{ type: 'date_change', newDate: currentDate }] } function getUltrathinkEffortAttachment(input: string | null): Attachment[] { if (!isUltrathinkEnabled() || !input || !hasUltrathinkKeyword(input)) { return [] } logEvent('tengu_ultrathink', {}) return [{ type: 'ultrathink_effort', level: 'high' }] } // Exported for compact.ts — the gate must be identical at both call sites. export function getDeferredToolsDeltaAttachment( tools: Tools, model: string, messages: Message[] | undefined, scanContext?: DeferredToolsDeltaScanContext, ): Attachment[] { if (!isDeferredToolsDeltaEnabled()) return [] // These three checks mirror the sync parts of isToolSearchEnabled — // the attachment text says "available via ToolSearch", so ToolSearch // has to actually be in the request. The async auto-threshold check // is not replicated (would double-fire tengu_tool_search_mode_decision); // in tst-auto below-threshold the attachment can fire while ToolSearch // is filtered out, but that's a narrow case and the tools announced // are directly callable anyway. if (!isToolSearchEnabledOptimistic()) return [] if (!modelSupportsToolReference(model)) return [] if (!isToolSearchToolAvailable(tools)) return [] const delta = getDeferredToolsDelta(tools, messages ?? [], scanContext) if (!delta) return [] return [{ type: 'deferred_tools_delta', ...delta }] } /** * Diff the current filtered agent pool against what's already been announced * in this conversation (reconstructed from prior agent_listing_delta * attachments). Returns [] if nothing changed or the gate is off. * * The agent list was embedded in AgentTool's description, causing ~10.2% of * fleet cache_creation: MCP async connect, /reload-plugins, or * permission-mode change → description changes → full tool-schema cache bust. * Moving the list here keeps the tool description static. * * Exported for compact.ts — re-announces the full set after compaction eats * prior deltas. */ export function getAgentListingDeltaAttachment( toolUseContext: ToolUseContext, messages: Message[] | undefined, ): Attachment[] { if (!shouldInjectAgentListInMessages()) return [] // Skip if AgentTool isn't in the pool — the listing would be unactionable. if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) ) { return [] } const { activeAgents, allowedAgentTypes } = toolUseContext.options.agentDefinitions // Mirror AgentTool.prompt()'s filtering: MCP requirements → deny rules → // allowedAgentTypes restriction. Keep this in sync with AgentTool.tsx. const mcpServers = new Set() for (const tool of toolUseContext.options.tools) { const info = mcpInfoFromString(tool.name) if (info) mcpServers.add(info.serverName) } const permissionContext = toolUseContext.getAppState().toolPermissionContext let filtered = filterDeniedAgents( filterAgentsByMcpRequirements(activeAgents, [...mcpServers]), permissionContext, AGENT_TOOL_NAME, ) if (allowedAgentTypes) { filtered = filtered.filter(a => allowedAgentTypes.includes(a.agentType)) } // Reconstruct announced set from prior deltas in the transcript. const announced = new Set() for (const msg of messages ?? []) { if (msg.type !== 'attachment') continue if (msg.attachment.type !== 'agent_listing_delta') continue for (const t of msg.attachment.addedTypes) announced.add(t) for (const t of msg.attachment.removedTypes) announced.delete(t) } const currentTypes = new Set(filtered.map(a => a.agentType)) const added = filtered.filter(a => !announced.has(a.agentType)) const removed: string[] = [] for (const t of announced) { if (!currentTypes.has(t)) removed.push(t) } if (added.length === 0 && removed.length === 0) return [] // Sort for deterministic output — agent load order is nondeterministic // (plugin load races, MCP async connect). added.sort((a, b) => a.agentType.localeCompare(b.agentType)) removed.sort() return [ { type: 'agent_listing_delta', addedTypes: added.map(a => a.agentType), addedLines: added.map(formatAgentLine), removedTypes: removed, isInitial: announced.size === 0, showConcurrencyNote: getSubscriptionType() !== 'pro', }, ] } // Exported for compact.ts / reactiveCompact.ts — single source of truth for the gate. export function getMcpInstructionsDeltaAttachment( mcpClients: MCPServerConnection[], tools: Tools, model: string, messages: Message[] | undefined, ): Attachment[] { if (!isMcpInstructionsDeltaEnabled()) return [] // The chrome ToolSearch hint is client-authored and ToolSearch-conditional; // actual server `instructions` are unconditional. Decide the chrome part // here, pass it into the pure diff as a synthesized entry. const clientSide: ClientSideInstruction[] = [] if ( isToolSearchEnabledOptimistic() && modelSupportsToolReference(model) && isToolSearchToolAvailable(tools) ) { clientSide.push({ serverName: CLAUDE_IN_CHROME_MCP_SERVER_NAME, block: CHROME_TOOL_SEARCH_INSTRUCTIONS, }) } const delta = getMcpInstructionsDelta(mcpClients, messages ?? [], clientSide) if (!delta) return [] return [{ type: 'mcp_instructions_delta', ...delta }] } function getCriticalSystemReminderAttachment( toolUseContext: ToolUseContext, ): Attachment[] { const reminder = toolUseContext.criticalSystemReminder_EXPERIMENTAL if (!reminder) { return [] } return [{ type: 'critical_system_reminder', content: reminder }] } function getOutputStyleAttachment(): Attachment[] { const settings = getSettings_DEPRECATED() const outputStyle = settings?.outputStyle || 'default' // Only show for non-default styles if (outputStyle === 'default') { return [] } return [ { type: 'output_style', style: outputStyle, }, ] } async function getSelectedLinesFromIDE( ideSelection: IDESelection | null, toolUseContext: ToolUseContext, ): Promise { const ideName = getConnectedIdeName(toolUseContext.options.mcpClients) if ( !ideName || ideSelection?.lineStart === undefined || !ideSelection.text || !ideSelection.filePath ) { return [] } const appState = toolUseContext.getAppState() if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) { return [] } return [ { type: 'selected_lines_in_ide', ideName, lineStart: ideSelection.lineStart, lineEnd: ideSelection.lineStart + ideSelection.lineCount - 1, filename: ideSelection.filePath, content: ideSelection.text, displayPath: relative(getCwd(), ideSelection.filePath), }, ] } /** * Computes the directories to process for nested memory file loading. * Returns two lists: * - nestedDirs: Directories between CWD and targetPath (processed for CLAUDE.md + all rules) * - cwdLevelDirs: Directories from root to CWD (processed for conditional rules only) * * @param targetPath The target file path * @param originalCwd The original current working directory * @returns Object with nestedDirs and cwdLevelDirs arrays, both ordered from parent to child */ export function getDirectoriesToProcess( targetPath: string, originalCwd: string, ): { nestedDirs: string[]; cwdLevelDirs: string[] } { // Build list of directories from original CWD to targetPath's directory const targetDir = dirname(resolve(targetPath)) const nestedDirs: string[] = [] let currentDir = targetDir // Walk up from target directory to original CWD while (currentDir !== originalCwd && currentDir !== parse(currentDir).root) { if (currentDir.startsWith(originalCwd)) { nestedDirs.push(currentDir) } currentDir = dirname(currentDir) } // Reverse to get order from CWD down to target nestedDirs.reverse() // Build list of directories from root to CWD (for conditional rules only) const cwdLevelDirs: string[] = [] currentDir = originalCwd while (currentDir !== parse(currentDir).root) { cwdLevelDirs.push(currentDir) currentDir = dirname(currentDir) } // Reverse to get order from root to CWD cwdLevelDirs.reverse() return { nestedDirs, cwdLevelDirs } } /** * Converts memory files to attachments, filtering out already-loaded files. * * @param memoryFiles The memory files to convert * @param toolUseContext The tool use context (for tracking loaded files) * @returns Array of nested memory attachments */ function isInstructionsMemoryType( type: MemoryFileInfo['type'], ): type is InstructionsMemoryType { return ( type === 'User' || type === 'Project' || type === 'Local' || type === 'Managed' ) } /** Exported for testing — regression guard for LRU-eviction re-injection. */ export function memoryFilesToAttachments( memoryFiles: MemoryFileInfo[], toolUseContext: ToolUseContext, triggerFilePath?: string, ): Attachment[] { const attachments: Attachment[] = [] const shouldFireHook = hasInstructionsLoadedHook() for (const memoryFile of memoryFiles) { // Dedup: loadedNestedMemoryPaths is a non-evicting Set; readFileState // is a 100-entry LRU that drops entries in busy sessions, so relying // on it alone re-injects the same CLAUDE.md on every eviction cycle. if (toolUseContext.loadedNestedMemoryPaths?.has(memoryFile.path)) { continue } if (!toolUseContext.readFileState.has(memoryFile.path)) { attachments.push({ type: 'nested_memory', path: memoryFile.path, content: memoryFile, displayPath: relative(getCwd(), memoryFile.path), }) toolUseContext.loadedNestedMemoryPaths?.add(memoryFile.path) // Mark as loaded in readFileState — this provides cross-function and // cross-turn dedup via the .has() check above. // // When the injected content doesn't match disk (stripped HTML comments, // stripped frontmatter, truncated MEMORY.md), cache the RAW disk bytes // with `isPartialView: true`. Edit/Write see the flag and require a real // Read first; getChangedFiles sees real content + undefined offset/limit // so mid-session change detection still works. toolUseContext.readFileState.set(memoryFile.path, { content: memoryFile.contentDiffersFromDisk ? (memoryFile.rawContent ?? memoryFile.content) : memoryFile.content, timestamp: Date.now(), offset: undefined, limit: undefined, isPartialView: memoryFile.contentDiffersFromDisk, }) // Fire InstructionsLoaded hook for audit/observability (fire-and-forget) if (shouldFireHook && isInstructionsMemoryType(memoryFile.type)) { const loadReason = memoryFile.globs ? 'path_glob_match' : memoryFile.parent ? 'include' : 'nested_traversal' void executeInstructionsLoadedHooks( memoryFile.path, memoryFile.type, loadReason, { globs: memoryFile.globs, triggerFilePath, parentFilePath: memoryFile.parent, }, ) } } } return attachments } /** * Loads nested memory files for a given file path and returns them as attachments. * This function performs directory traversal to find CLAUDE.md files and conditional rules * that apply to the target file path. * * Processing order (must be preserved): * 1. Managed/User conditional rules matching targetPath * 2. Nested directories (CWD → target): CLAUDE.md + unconditional + conditional rules * 3. CWD-level directories (root → CWD): conditional rules only * * @param filePath The file path to get nested memory files for * @param toolUseContext The tool use context * @param appState The app state containing tool permission context * @returns Array of nested memory attachments */ async function getNestedMemoryAttachmentsForFile( filePath: string, toolUseContext: ToolUseContext, appState: { toolPermissionContext: ToolPermissionContext }, ): Promise { const attachments: Attachment[] = [] try { // Early return if path is not in allowed working path if (!pathInAllowedWorkingPath(filePath, appState.toolPermissionContext)) { return attachments } const processedPaths = new Set() const originalCwd = getOriginalCwd() // Phase 1: Process Managed and User conditional rules const managedUserRules = await getManagedAndUserConditionalRules( filePath, processedPaths, ) attachments.push( ...memoryFilesToAttachments(managedUserRules, toolUseContext, filePath), ) // Phase 2: Get directories to process const { nestedDirs, cwdLevelDirs } = getDirectoriesToProcess( filePath, originalCwd, ) const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_paper_halyard', false, ) // Phase 3: Process nested directories (CWD → target) // Each directory gets: CLAUDE.md + unconditional rules + conditional rules for (const dir of nestedDirs) { const memoryFiles = ( await getMemoryFilesForNestedDirectory(dir, filePath, processedPaths) ).filter( f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'), ) attachments.push( ...memoryFilesToAttachments(memoryFiles, toolUseContext, filePath), ) } // Phase 4: Process CWD-level directories (root → CWD) // Only conditional rules (unconditional rules are already loaded eagerly) for (const dir of cwdLevelDirs) { const conditionalRules = ( await getConditionalRulesForCwdLevelDirectory( dir, filePath, processedPaths, ) ).filter( f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'), ) attachments.push( ...memoryFilesToAttachments(conditionalRules, toolUseContext, filePath), ) } } catch (error) { logError(error) } return attachments } async function getOpenedFileFromIDE( ideSelection: IDESelection | null, toolUseContext: ToolUseContext, ): Promise { if (!ideSelection?.filePath || ideSelection.text) { return [] } const appState = toolUseContext.getAppState() if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) { return [] } // Get nested memory files const nestedMemoryAttachments = await getNestedMemoryAttachmentsForFile( ideSelection.filePath, toolUseContext, appState, ) // Return nested memory attachments followed by the opened file attachment return [ ...nestedMemoryAttachments, { type: 'opened_file_in_ide', filename: ideSelection.filePath, }, ] } async function processAtMentionedFiles( input: string, toolUseContext: ToolUseContext, ): Promise { const files = extractAtMentionedFiles(input) if (files.length === 0) return [] const appState = toolUseContext.getAppState() const results = await Promise.all( files.map(async file => { try { const { filename, lineStart, lineEnd } = parseAtMentionedFileLines(file) const absoluteFilename = expandPath(filename) if ( isFileReadDenied(absoluteFilename, appState.toolPermissionContext) ) { return null } // Check if it's a directory try { const stats = await stat(absoluteFilename) if (stats.isDirectory()) { try { const entries = await readdir(absoluteFilename, { withFileTypes: true, }) const MAX_DIR_ENTRIES = 1000 const truncated = entries.length > MAX_DIR_ENTRIES const names = entries.slice(0, MAX_DIR_ENTRIES).map(e => e.name) if (truncated) { names.push( `\u2026 and ${entries.length - MAX_DIR_ENTRIES} more entries`, ) } const stdout = names.join('\n') logEvent('tengu_at_mention_extracting_directory_success', {}) return { type: 'directory' as const, path: absoluteFilename, content: stdout, displayPath: relative(getCwd(), absoluteFilename), } } catch { return null } } } catch { // If stat fails, continue with file logic } return await generateFileAttachment( absoluteFilename, toolUseContext, 'tengu_at_mention_extracting_filename_success', 'tengu_at_mention_extracting_filename_error', 'at-mention', { offset: lineStart, limit: lineEnd && lineStart ? lineEnd - lineStart + 1 : undefined, }, ) } catch { logEvent('tengu_at_mention_extracting_filename_error', {}) } }), ) return results.filter(Boolean) as Attachment[] } function processAgentMentions( input: string, agents: AgentDefinition[], ): Attachment[] { const agentMentions = extractAgentMentions(input) if (agentMentions.length === 0) return [] const results = agentMentions.map(mention => { const agentType = mention.replace('agent-', '') const agentDef = agents.find(def => def.agentType === agentType) if (!agentDef) { logEvent('tengu_at_mention_agent_not_found', {}) return null } logEvent('tengu_at_mention_agent_success', {}) return { type: 'agent_mention' as const, agentType: agentDef.agentType, } }) return results.filter( (result): result is NonNullable => result !== null, ) } async function processMcpResourceAttachments( input: string, toolUseContext: ToolUseContext, ): Promise { const resourceMentions = extractMcpResourceMentions(input) if (resourceMentions.length === 0) return [] const mcpClients = toolUseContext.options.mcpClients || [] const results = await Promise.all( resourceMentions.map(async mention => { try { const [serverName, ...uriParts] = mention.split(':') const uri = uriParts.join(':') // Rejoin in case URI contains colons if (!serverName || !uri) { logEvent('tengu_at_mention_mcp_resource_error', {}) return null } // Find the MCP client const client = mcpClients.find(c => c.name === serverName) if (!client || client.type !== 'connected') { logEvent('tengu_at_mention_mcp_resource_error', {}) return null } // Find the resource in available resources to get its metadata const serverResources = toolUseContext.options.mcpResources?.[serverName] || [] const resourceInfo = serverResources.find(r => r.uri === uri) if (!resourceInfo) { logEvent('tengu_at_mention_mcp_resource_error', {}) return null } try { const result = await client.client.readResource({ uri, }) logEvent('tengu_at_mention_mcp_resource_success', {}) return { type: 'mcp_resource' as const, server: serverName, uri, name: resourceInfo.name || uri, description: resourceInfo.description, content: result, } } catch (error) { logEvent('tengu_at_mention_mcp_resource_error', {}) logError(error) return null } } catch { logEvent('tengu_at_mention_mcp_resource_error', {}) return null } }), ) return results.filter( (result): result is NonNullable => result !== null, ) as Attachment[] } export async function getChangedFiles( toolUseContext: ToolUseContext, ): Promise { const filePaths = cacheKeys(toolUseContext.readFileState) if (filePaths.length === 0) return [] const appState = toolUseContext.getAppState() const results = await Promise.all( filePaths.map(async filePath => { const fileState = toolUseContext.readFileState.get(filePath) if (!fileState) return null // TODO: Implement offset/limit support for changed files if (fileState.offset !== undefined || fileState.limit !== undefined) { return null } const normalizedPath = expandPath(filePath) // Check if file has a deny rule configured if (isFileReadDenied(normalizedPath, appState.toolPermissionContext)) { return null } try { const mtime = await getFileModificationTimeAsync(normalizedPath) if (mtime <= fileState.timestamp) { return null } const fileInput = { file_path: normalizedPath } // Validate file path is valid const isValid = await FileReadTool.validateInput( fileInput, toolUseContext, ) if (!isValid.result) { return null } const result = await FileReadTool.call(fileInput, toolUseContext) // Extract only the changed section if (result.data.type === 'text') { const snippet = getSnippetForTwoFileDiff( fileState.content, result.data.file.content, ) // File was touched but not modified if (snippet === '') { return null } return { type: 'edited_text_file' as const, filename: normalizedPath, snippet, } } // For non-text files (images), apply the same token limit logic as FileReadTool if (result.data.type === 'image') { try { const data = await readImageWithTokenBudget(normalizedPath) return { type: 'edited_image_file' as const, filename: normalizedPath, content: data, } } catch (compressionError) { logError(compressionError) logEvent('tengu_watched_file_compression_failed', { file: normalizedPath, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return null } } // notebook / pdf / parts — no diff representation; explicitly // null so the map callback has no implicit-undefined path. return null } catch (err) { // Evict ONLY on ENOENT (file truly deleted). Transient stat // failures — atomic-save races (editor writes tmp→rename and // stat hits the gap), EACCES churn, network-FS hiccups — must // NOT evict, or the next Edit fails code-6 even though the // file still exists and the model just read it. VS Code // auto-save/format-on-save hits this race especially often. // See regression analysis on PR #18525. if (isENOENT(err)) { toolUseContext.readFileState.delete(filePath) } return null } }), ) return results.filter(result => result != null) as Attachment[] } /** * Processes paths that need nested memory attachments and checks for nested CLAUDE.md files * Uses nestedMemoryAttachmentTriggers field from ToolUseContext */ async function getNestedMemoryAttachments( toolUseContext: ToolUseContext, ): Promise { // Check triggers first — getAppState() waits for a React render cycle, // and the common case is an empty trigger set. if ( !toolUseContext.nestedMemoryAttachmentTriggers || toolUseContext.nestedMemoryAttachmentTriggers.size === 0 ) { return [] } const appState = toolUseContext.getAppState() const attachments: Attachment[] = [] for (const filePath of toolUseContext.nestedMemoryAttachmentTriggers) { const nestedAttachments = await getNestedMemoryAttachmentsForFile( filePath, toolUseContext, appState, ) attachments.push(...nestedAttachments) } toolUseContext.nestedMemoryAttachmentTriggers.clear() return attachments } async function getRelevantMemoryAttachments( input: string, agents: AgentDefinition[], readFileState: FileStateCache, recentTools: readonly string[], signal: AbortSignal, alreadySurfaced: ReadonlySet, ): Promise { // If an agent is @-mentioned, search only its memory dir (isolation). // Otherwise search the auto-memory dir. const memoryDirs = extractAgentMentions(input).flatMap(mention => { const agentType = mention.replace('agent-', '') const agentDef = agents.find(def => def.agentType === agentType) return agentDef?.memory ? [getAgentMemoryDir(agentType, agentDef.memory)] : [] }) const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()] const allResults = await Promise.all( dirs.map(dir => findRelevantMemories( input, dir, signal, recentTools, alreadySurfaced, ).catch(() => []), ), ) // alreadySurfaced is filtered inside the selector so Sonnet spends its // 5-slot budget on fresh candidates; readFileState catches files the // model read via FileReadTool. The redundant alreadySurfaced check here // is a belt-and-suspenders guard (multi-dir results may re-introduce a // path the selector filtered in a different dir). const selected = allResults .flat() .filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path)) .slice(0, 5) const memories = await readMemoriesForSurfacing(selected, signal) if (memories.length === 0) { return [] } return [{ type: 'relevant_memories' as const, memories }] } /** * Scan messages for past relevant_memories attachments. Returns both the * set of surfaced paths (for selector de-dup) and cumulative byte count * (for session-total throttle). Scanning messages rather than tracking * in toolUseContext means compact naturally resets both — old attachments * are gone from the compacted transcript, so re-surfacing is valid again. */ export function collectSurfacedMemories(messages: ReadonlyArray): { paths: Set totalBytes: number } { const paths = new Set() let totalBytes = 0 for (const m of messages) { if (m.type === 'attachment' && m.attachment.type === 'relevant_memories') { for (const mem of m.attachment.memories) { paths.add(mem.path) totalBytes += mem.content.length } } } return { paths, totalBytes } } /** * Reads a set of relevance-ranked memory files for injection as * attachments. Enforces both MAX_MEMORY_LINES and * MAX_MEMORY_BYTES via readFileInRange's truncateOnByteLimit option. * Truncation surfaces partial * content with a note rather than dropping the file — findRelevantMemories * already picked this as most-relevant, so the frontmatter + opening context * is worth surfacing even if later lines are cut. * * Exported for direct testing without mocking the ranker + GB gates. */ export async function readMemoriesForSurfacing( selected: ReadonlyArray<{ path: string; mtimeMs: number }>, signal?: AbortSignal, ): Promise< Array<{ path: string content: string mtimeMs: number header: string limit?: number }> > { const results = await Promise.all( selected.map(async ({ path: filePath, mtimeMs }) => { try { const result = await readFileInRange( filePath, 0, MAX_MEMORY_LINES, MAX_MEMORY_BYTES, signal, { truncateOnByteLimit: true }, ) const truncated = result.totalLines > MAX_MEMORY_LINES || result.truncatedByBytes const content = truncated ? result.content + `\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}` : result.content return { path: filePath, content, mtimeMs, header: memoryHeader(filePath, mtimeMs), limit: truncated ? result.lineCount : undefined, } } catch { return null } }), ) return results.filter(r => r !== null) } /** * Header string for a relevant-memory block. Exported so messages.ts * can fall back for resumed sessions where the stored header is missing. */ export function memoryHeader(path: string, mtimeMs: number): string { const staleness = memoryFreshnessText(mtimeMs) return staleness ? `${staleness}\n\nMemory: ${path}:` : `Memory (saved ${memoryAge(mtimeMs)}): ${path}:` } /** * A memory relevance-selector prefetch handle. The promise is started once * per user turn and runs while the main model streams and tools execute. * At the collect point (post-tools), the caller reads settledAt to * consume-if-ready or skip-and-retry-next-iteration — the prefetch never * blocks the turn. * * Disposable: query.ts binds with `using`, so [Symbol.dispose] fires on all * generator exit paths (return, throw, .return() closure) — aborting the * in-flight request and emitting terminal telemetry without instrumenting * each of the ~13 return sites inside the while loop. */ export type MemoryPrefetch = { promise: Promise /** Set by promise.finally(). null until the promise settles. */ settledAt: number | null /** Set by the collect point in query.ts. -1 until consumed. */ consumedOnIteration: number [Symbol.dispose](): void } /** * Starts the relevant memory search as an async prefetch. * Extracts the last real user prompt from messages (skipping isMeta system * injections) and kicks off a non-blocking search. Returns a Disposable * handle with settlement tracking. Bound with `using` in query.ts. */ export function startRelevantMemoryPrefetch( messages: ReadonlyArray, toolUseContext: ToolUseContext, ): MemoryPrefetch | undefined { if ( !isAutoMemoryEnabled() || !getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false) ) { return undefined } const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta) if (!lastUserMessage) { return undefined } const input = getUserMessageText(lastUserMessage) // Single-word prompts lack enough context for meaningful term extraction if (!input || !/\s/.test(input.trim())) { return undefined } const surfaced = collectSurfacedMemories(messages) if (surfaced.totalBytes >= RELEVANT_MEMORIES_CONFIG.MAX_SESSION_BYTES) { return undefined } // Chained to the turn-level abort so user Escape cancels the sideQuery // immediately, not just on [Symbol.dispose] when queryLoop exits. const controller = createChildAbortController(toolUseContext.abortController) const firedAt = Date.now() const promise = getRelevantMemoryAttachments( input, toolUseContext.options.agentDefinitions.activeAgents, toolUseContext.readFileState, collectRecentSuccessfulTools(messages, lastUserMessage), controller.signal, surfaced.paths, ).catch(e => { if (!isAbortError(e)) { logError(e) } return [] }) const handle: MemoryPrefetch = { promise, settledAt: null, consumedOnIteration: -1, [Symbol.dispose]() { controller.abort() logEvent('tengu_memdir_prefetch_collected', { hidden_by_first_iteration: handle.settledAt !== null && handle.consumedOnIteration === 0, consumed_on_iteration: handle.consumedOnIteration, latency_ms: (handle.settledAt ?? Date.now()) - firedAt, }) }, } void promise.finally(() => { handle.settledAt = Date.now() }) return handle } type ToolResultBlock = { type: 'tool_result' tool_use_id: string is_error?: boolean } function isToolResultBlock(b: unknown): b is ToolResultBlock { return ( typeof b === 'object' && b !== null && (b as ToolResultBlock).type === 'tool_result' && typeof (b as ToolResultBlock).tool_use_id === 'string' ) } /** * Check whether a user message's content contains tool_result blocks. * This is more reliable than checking `toolUseResult === undefined` because * sub-agent tool result messages explicitly set `toolUseResult` to `undefined` * when `preserveToolUseResults` is false (the default for Explore agents). */ function hasToolResultContent(content: unknown): boolean { return Array.isArray(content) && content.some(isToolResultBlock) } /** * Tools that succeeded (and never errored) since the previous real turn * boundary. The memory selector uses this to suppress docs about tools * that are working — surfacing reference material for a tool the model * is already calling successfully is noise. * * Any error → tool excluded (model is struggling, docs stay available). * No result yet → also excluded (outcome unknown). * * tool_use lives in assistant content; tool_result in user content * (toolUseResult set, isMeta undefined). Both are within the scan window. * Backward scan sees results before uses so we collect both by id and * resolve after. */ export function collectRecentSuccessfulTools( messages: ReadonlyArray, lastUserMessage: Message, ): readonly string[] { const useIdToName = new Map() const resultByUseId = new Map() for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] if (!m) continue if (isHumanTurn(m) && m !== lastUserMessage) break if (m.type === 'assistant' && typeof m.message.content !== 'string') { for (const block of m.message.content) { if (block.type === 'tool_use') useIdToName.set(block.id, block.name) } } else if ( m.type === 'user' && 'message' in m && Array.isArray(m.message.content) ) { for (const block of m.message.content) { if (isToolResultBlock(block)) { resultByUseId.set(block.tool_use_id, block.is_error === true) } } } } const failed = new Set() const succeeded = new Set() for (const [id, name] of useIdToName) { const errored = resultByUseId.get(id) if (errored === undefined) continue if (errored) { failed.add(name) } else { succeeded.add(name) } } return [...succeeded].filter(t => !failed.has(t)) } /** * Filters prefetched memory attachments to exclude memories the model already * has in context via FileRead/Write/Edit tool calls (any iteration this turn) * or a previous turn's memory surfacing — both tracked in the cumulative * readFileState. Survivors are then marked in readFileState so subsequent * turns won't re-surface them. * * The mark-after-filter ordering is load-bearing: readMemoriesForSurfacing * used to write to readFileState during the prefetch, which meant the filter * saw every prefetch-selected path as "already in context" and dropped them * all (self-referential filter). Deferring the write to here, after the * filter runs, breaks that cycle while still deduping against tool calls * from any iteration. */ export function filterDuplicateMemoryAttachments( attachments: Attachment[], readFileState: FileStateCache, ): Attachment[] { return attachments .map(attachment => { if (attachment.type !== 'relevant_memories') return attachment const filtered = attachment.memories.filter( m => !readFileState.has(m.path), ) for (const m of filtered) { readFileState.set(m.path, { content: m.content, timestamp: m.mtimeMs, offset: undefined, limit: m.limit, }) } return filtered.length > 0 ? { ...attachment, memories: filtered } : null }) .filter((a): a is Attachment => a !== null) } /** * Processes skill directories that were discovered during file operations. * Uses dynamicSkillDirTriggers field from ToolUseContext */ async function getDynamicSkillAttachments( toolUseContext: ToolUseContext, ): Promise { const attachments: Attachment[] = [] if ( toolUseContext.dynamicSkillDirTriggers && toolUseContext.dynamicSkillDirTriggers.size > 0 ) { // Parallelize: readdir all skill dirs concurrently const perDirResults = await Promise.all( Array.from(toolUseContext.dynamicSkillDirTriggers).map(async skillDir => { try { const entries = await readdir(skillDir, { withFileTypes: true }) const candidates = entries .filter(e => e.isDirectory() || e.isSymbolicLink()) .map(e => e.name) // Parallelize: stat all SKILL.md candidates concurrently const checked = await Promise.all( candidates.map(async name => { try { await stat(resolve(skillDir, name, 'SKILL.md')) return name } catch { return null // SKILL.md doesn't exist, skip this entry } }), ) return { skillDir, skillNames: checked.filter((n): n is string => n !== null), } } catch { // Ignore errors reading skill directories (e.g., directory doesn't exist) return { skillDir, skillNames: [] } } }), ) for (const { skillDir, skillNames } of perDirResults) { if (skillNames.length > 0) { attachments.push({ type: 'dynamic_skill', skillDir, skillNames, displayPath: relative(getCwd(), skillDir), }) } } toolUseContext.dynamicSkillDirTriggers.clear() } return attachments } // Track which skills have been sent to avoid re-sending. Keyed by agentId // (empty string = main thread) so subagents get their own turn-0 listing — // without per-agent scoping, the main thread populating this Set would cause // every subagent's filterToBundledAndMcp result to dedup to empty. const sentSkillNames = new Map>() // Called when the skill set genuinely changes (plugin reload, skill file // change on disk) so new skills get announced. NOT called on compact — // post-compact re-injection costs ~4K tokens/event for marginal benefit. export function resetSentSkillNames(): void { sentSkillNames.clear() suppressNext = false } /** * Suppress the next skill-listing injection. Called by conversationRecovery * on --resume when a skill_listing attachment already exists in the * transcript. * * `sentSkillNames` is module-scope — process-local. Each `claude -p` spawn * starts with an empty Map, so without this every resume re-injects the * full ~600-token listing even though it's already in the conversation from * the prior process. Shows up on every --resume; particularly loud for * daemons that respawn frequently. * * Trade-off: skills added between sessions won't be announced until the * next non-resume session. Acceptable — skill_listing was never meant to * cover cross-process deltas, and the agent can still call them (they're * in the Skill tool's runtime registry regardless). */ export function suppressNextSkillListing(): void { suppressNext = true } let suppressNext = false // When skill-search is enabled and the filtered (bundled + MCP) listing exceeds // this count, fall back to bundled-only. Protects MCP-heavy users (100+ servers) // from truncation while keeping the turn-0 guarantee for typical setups. const FILTERED_LISTING_MAX = 30 /** * Filter skills to bundled (Anthropic-curated) + MCP (user-connected) only. * Used when skill-search is enabled to resolve the turn-0 gap for subagents: * these sources are small, intent-signaled, and won't hit the truncation budget. * User/project/plugin skills (the long tail — 200+) go through discovery instead. * * Falls back to bundled-only if bundled+mcp exceeds FILTERED_LISTING_MAX. */ export function filterToBundledAndMcp(commands: Command[]): Command[] { const filtered = commands.filter( cmd => cmd.loadedFrom === 'bundled' || cmd.loadedFrom === 'mcp', ) if (filtered.length > FILTERED_LISTING_MAX) { return filtered.filter(cmd => cmd.loadedFrom === 'bundled') } return filtered } async function getSkillListingAttachments( toolUseContext: ToolUseContext, ): Promise { if (process.env.NODE_ENV === 'test') { return [] } // Skip skill listing for agents that don't have the Skill tool — they can't use skills directly. if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, SKILL_TOOL_NAME)) ) { return [] } const cwd = getProjectRoot() const localCommands = await getSkillToolCommands(cwd) const mcpSkills = getMcpSkillCommands( toolUseContext.getAppState().mcp.commands, ) let allCommands = mcpSkills.length > 0 ? uniqBy([...localCommands, ...mcpSkills], 'name') : localCommands // When skill search is active, filter to bundled + MCP instead of full // suppression. Resolves the turn-0 gap: main thread gets turn-0 discovery // via getTurnZeroSkillDiscovery (blocking), but subagents use the async // subagent_spawn signal (collected post-tools, visible turn 1). Bundled + // MCP are small and intent-signaled; user/project/plugin skills go through // discovery. feature() first for DCE — the property-access string leaks // otherwise even with ?. on null. if ( feature('EXPERIMENTAL_SKILL_SEARCH') && skillSearchModules?.featureCheck.isSkillSearchEnabled() ) { allCommands = filterToBundledAndMcp(allCommands) } const agentKey = toolUseContext.agentId ?? '' let sent = sentSkillNames.get(agentKey) if (!sent) { sent = new Set() sentSkillNames.set(agentKey, sent) } // Resume path: prior process already injected a listing; it's in the // transcript. Mark everything current as sent so only post-resume deltas // (skills loaded later via /reload-plugins etc) get announced. if (suppressNext) { suppressNext = false for (const cmd of allCommands) { sent.add(cmd.name) } return [] } // Find skills we haven't sent yet const newSkills = allCommands.filter(cmd => !sent.has(cmd.name)) if (newSkills.length === 0) { return [] } // If no skills have been sent yet, this is the initial batch const isInitial = sent.size === 0 // Mark as sent for (const cmd of newSkills) { sent.add(cmd.name) } logForDebugging( `Sending ${newSkills.length} skills via attachment (${isInitial ? 'initial' : 'dynamic'}, ${sent.size} total sent)`, ) // Format within budget using existing logic const contextWindowTokens = getContextWindowForModel( toolUseContext.options.mainLoopModel, getSdkBetas(), ) const content = formatCommandsWithinBudget(newSkills, contextWindowTokens) return [ { type: 'skill_listing', content, skillCount: newSkills.length, isInitial, }, ] } // getSkillDiscoveryAttachment moved to skillSearch/prefetch.ts as // getTurnZeroSkillDiscovery — keeps the 'skill_discovery' string literal inside // a feature-gated module so it doesn't leak into external builds. export function extractAtMentionedFiles(content: string): string[] { // Extract filenames mentioned with @ symbol, including line range syntax: @file.txt#L10-20 // Also supports quoted paths for files with spaces: @"my/file with spaces.txt" // Example: "foo bar @baz moo" would extract "baz" // Example: 'check @"my file.txt" please' would extract "my file.txt" // Two patterns: quoted paths and regular paths const quotedAtMentionRegex = /(^|\s)@"([^"]+)"/g const regularAtMentionRegex = /(^|\s)@([^\s]+)\b/g const quotedMatches: string[] = [] const regularMatches: string[] = [] // Extract quoted mentions first (skip agent mentions like @"code-reviewer (agent)") let match while ((match = quotedAtMentionRegex.exec(content)) !== null) { if (match[2] && !match[2].endsWith(' (agent)')) { quotedMatches.push(match[2]) // The content inside quotes } } // Extract regular mentions const regularMatchArray = content.match(regularAtMentionRegex) || [] regularMatchArray.forEach(match => { const filename = match.slice(match.indexOf('@') + 1) // Don't include if it starts with a quote (already handled as quoted) if (!filename.startsWith('"')) { regularMatches.push(filename) } }) // Combine and deduplicate return uniq([...quotedMatches, ...regularMatches]) } export function extractMcpResourceMentions(content: string): string[] { // Extract MCP resources mentioned with @ symbol in format @server:uri // Example: "@server1:resource/path" would extract "server1:resource/path" const atMentionRegex = /(^|\s)@([^\s]+:[^\s]+)\b/g const matches = content.match(atMentionRegex) || [] // Remove the prefix (everything before @) from each match return uniq(matches.map(match => match.slice(match.indexOf('@') + 1))) } export function extractAgentMentions(content: string): string[] { // Extract agent mentions in two formats: // 1. @agent- (legacy/manual typing) // Example: "@agent-code-elegance-refiner" → "agent-code-elegance-refiner" // 2. @" (agent)" (from autocomplete selection) // Example: '@"code-reviewer (agent)"' → "code-reviewer" // Supports colons, dots, and at-signs for plugin-scoped agents like "@agent-asana:project-status-updater" const results: string[] = [] // Match quoted format: @" (agent)" const quotedAgentRegex = /(^|\s)@"([\w:.@-]+) \(agent\)"/g let match while ((match = quotedAgentRegex.exec(content)) !== null) { if (match[2]) { results.push(match[2]) } } // Match unquoted format: @agent- const unquotedAgentRegex = /(^|\s)@(agent-[\w:.@-]+)/g const unquotedMatches = content.match(unquotedAgentRegex) || [] for (const m of unquotedMatches) { results.push(m.slice(m.indexOf('@') + 1)) } return uniq(results) } interface AtMentionedFileLines { filename: string lineStart?: number lineEnd?: number } export function parseAtMentionedFileLines( mention: string, ): AtMentionedFileLines { // Parse mentions like "file.txt#L10-20", "file.txt#heading", or just "file.txt" // Supports line ranges (#L10, #L10-20) and strips non-line-range fragments (#heading) const match = mention.match(/^([^#]+)(?:#L(\d+)(?:-(\d+))?)?(?:#[^#]*)?$/) if (!match) { return { filename: mention } } const [, filename, lineStartStr, lineEndStr] = match const lineStart = lineStartStr ? parseInt(lineStartStr, 10) : undefined const lineEnd = lineEndStr ? parseInt(lineEndStr, 10) : lineStart return { filename: filename ?? mention, lineStart, lineEnd } } async function getDiagnosticAttachments( toolUseContext: ToolUseContext, ): Promise { // Diagnostics are only useful if the agent has the Bash tool to act on them if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME)) ) { return [] } // Get new diagnostics from the tracker (IDE diagnostics via MCP) const newDiagnostics = await diagnosticTracker.getNewDiagnostics() if (newDiagnostics.length === 0) { return [] } return [ { type: 'diagnostics', files: newDiagnostics, isNew: true, }, ] } /** * Get LSP diagnostic attachments from passive LSP servers. * Follows the AsyncHookRegistry pattern for consistent async attachment delivery. */ async function getLSPDiagnosticAttachments( toolUseContext: ToolUseContext, ): Promise { // LSP diagnostics are only useful if the agent has the Bash tool to act on them if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME)) ) { return [] } logForDebugging('LSP Diagnostics: getLSPDiagnosticAttachments called') try { const diagnosticSets = checkForLSPDiagnostics() if (diagnosticSets.length === 0) { return [] } logForDebugging( `LSP Diagnostics: Found ${diagnosticSets.length} pending diagnostic set(s)`, ) // Convert each diagnostic set to an attachment const attachments: Attachment[] = diagnosticSets.map(({ files }) => ({ type: 'diagnostics' as const, files, isNew: true, })) // Clear delivered diagnostics from registry to prevent memory leak // Follows same pattern as removeDeliveredAsyncHooks if (diagnosticSets.length > 0) { clearAllLSPDiagnostics() logForDebugging( `LSP Diagnostics: Cleared ${diagnosticSets.length} delivered diagnostic(s) from registry`, ) } logForDebugging( `LSP Diagnostics: Returning ${attachments.length} diagnostic attachment(s)`, ) return attachments } catch (error) { const err = toError(error) logError( new Error(`Failed to get LSP diagnostic attachments: ${err.message}`), ) // Return empty array to allow other attachments to proceed return [] } } export async function* getAttachmentMessages( input: string | null, toolUseContext: ToolUseContext, ideSelection: IDESelection | null, queuedCommands: QueuedCommand[], messages?: Message[], querySource?: QuerySource, options?: { skipSkillDiscovery?: boolean }, ): AsyncGenerator { // TODO: Compute this upstream const attachments = await getAttachments( input, toolUseContext, ideSelection, queuedCommands, messages, querySource, options, ) if (attachments.length === 0) { return } logEvent('tengu_attachments', { attachment_types: attachments.map( _ => _.type, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) for (const attachment of attachments) { yield createAttachmentMessage(attachment) } } /** * Generates a file attachment by reading a file with proper validation and truncation. * This is the core file reading logic shared between @-mentioned files and post-compact restoration. * * @param filename The absolute path to the file to read * @param toolUseContext The tool use context for calling FileReadTool * @param options Optional configuration for file reading * @returns A new_file attachment or null if the file couldn't be read */ /** * Check if a PDF file should be represented as a lightweight reference * instead of being inlined. Returns a PDFReferenceAttachment for large PDFs * (more than PDF_AT_MENTION_INLINE_THRESHOLD pages), or null otherwise. */ export async function tryGetPDFReference( filename: string, ): Promise { const ext = parse(filename).ext.toLowerCase() if (!isPDFExtension(ext)) { return null } try { const [stats, pageCount] = await Promise.all([ getFsImplementation().stat(filename), getPDFPageCount(filename), ]) // Use page count if available, otherwise fall back to size heuristic (~100KB per page) const effectivePageCount = pageCount ?? Math.ceil(stats.size / (100 * 1024)) if (effectivePageCount > PDF_AT_MENTION_INLINE_THRESHOLD) { logEvent('tengu_pdf_reference_attachment', { pageCount: effectivePageCount, fileSize: stats.size, hadPdfinfo: pageCount !== null, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return { type: 'pdf_reference', filename, pageCount: effectivePageCount, fileSize: stats.size, displayPath: relative(getCwd(), filename), } } } catch { // If we can't stat the file, return null to proceed with normal reading } return null } export async function generateFileAttachment( filename: string, toolUseContext: ToolUseContext, successEventName: string, errorEventName: string, mode: 'compact' | 'at-mention', options?: { offset?: number limit?: number }, ): Promise< | FileAttachment | CompactFileReferenceAttachment | PDFReferenceAttachment | AlreadyReadFileAttachment | null > { const { offset, limit } = options ?? {} // Check if file has a deny rule configured const appState = toolUseContext.getAppState() if (isFileReadDenied(filename, appState.toolPermissionContext)) { return null } // Check file size before attempting to read (skip for PDFs — they have their own size/page handling below) if ( mode === 'at-mention' && !isFileWithinReadSizeLimit( filename, getDefaultFileReadingLimits().maxSizeBytes, ) ) { const ext = parse(filename).ext.toLowerCase() if (!isPDFExtension(ext)) { try { const stats = await getFsImplementation().stat(filename) logEvent('tengu_attachment_file_too_large', { size_bytes: stats.size, mode, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return null } catch { // If we can't stat the file, proceed with normal reading (will fail later if file doesn't exist) } } } // For large PDFs on @ mention, return a lightweight reference instead of inlining if (mode === 'at-mention') { const pdfRef = await tryGetPDFReference(filename) if (pdfRef) { return pdfRef } } // Check if file is already in context with latest version const existingFileState = toolUseContext.readFileState.get(filename) if (existingFileState && mode === 'at-mention') { try { // Check if the file has been modified since we last read it const mtimeMs = await getFileModificationTimeAsync(filename) // Handle timestamp format inconsistency: // - FileReadTool stores Date.now() (current time when read) // - FileEdit/WriteTools store mtimeMs (file modification time) // // If timestamp > mtimeMs, it was stored by FileReadTool using Date.now() // In this case, we should not use the optimization since we can't reliably // compare modification times. Only use optimization when timestamp <= mtimeMs, // indicating it was stored by FileEdit/WriteTool with actual mtimeMs. if ( existingFileState.timestamp <= mtimeMs && mtimeMs === existingFileState.timestamp ) { // File hasn't been modified, return already_read_file attachment // This tells the system the file is already in context and doesn't need to be sent to API logEvent(successEventName, {}) return { type: 'already_read_file', filename, displayPath: relative(getCwd(), filename), content: { type: 'text', file: { filePath: filename, content: existingFileState.content, numLines: countCharInString(existingFileState.content, '\n') + 1, startLine: offset ?? 1, totalLines: countCharInString(existingFileState.content, '\n') + 1, }, }, } } } catch { // If we can't stat the file, proceed with normal reading } } try { const fileInput = { file_path: filename, offset, limit, } async function readTruncatedFile(): Promise< | FileAttachment | CompactFileReferenceAttachment | AlreadyReadFileAttachment | null > { if (mode === 'compact') { return { type: 'compact_file_reference', filename, displayPath: relative(getCwd(), filename), } } // Check deny rules before reading truncated file const appState = toolUseContext.getAppState() if (isFileReadDenied(filename, appState.toolPermissionContext)) { return null } try { // Read only the first MAX_LINES_TO_READ lines for files that are too large const truncatedInput = { file_path: filename, offset: offset ?? 1, limit: MAX_LINES_TO_READ, } const result = await FileReadTool.call(truncatedInput, toolUseContext) logEvent(successEventName, {}) return { type: 'file' as const, filename, content: result.data, truncated: true, displayPath: relative(getCwd(), filename), } } catch { logEvent(errorEventName, {}) return null } } // Validate file path is valid const isValid = await FileReadTool.validateInput(fileInput, toolUseContext) if (!isValid.result) { return null } try { const result = await FileReadTool.call(fileInput, toolUseContext) logEvent(successEventName, {}) return { type: 'file', filename, content: result.data, displayPath: relative(getCwd(), filename), } } catch (error) { if ( error instanceof MaxFileReadTokenExceededError || error instanceof FileTooLargeError ) { return await readTruncatedFile() } throw error } } catch { logEvent(errorEventName, {}) return null } } export function createAttachmentMessage( attachment: Attachment, ): AttachmentMessage { return { attachment, type: 'attachment', uuid: randomUUID(), timestamp: new Date().toISOString(), } } function getTodoReminderTurnCounts(messages: Message[]): { turnsSinceLastTodoWrite: number turnsSinceLastReminder: number } { let lastTodoWriteIndex = -1 let lastReminderIndex = -1 let assistantTurnsSinceWrite = 0 let assistantTurnsSinceReminder = 0 // Iterate backwards to find most recent events for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message?.type === 'assistant') { if (isThinkingMessage(message)) { // Skip thinking messages continue } // Check for TodoWrite usage BEFORE incrementing counter // (we don't want to count the TodoWrite message itself as "1 turn since write") if ( lastTodoWriteIndex === -1 && 'message' in message && Array.isArray(message.message?.content) && message.message.content.some( block => block.type === 'tool_use' && block.name === 'TodoWrite', ) ) { lastTodoWriteIndex = i } // Count assistant turns before finding events if (lastTodoWriteIndex === -1) assistantTurnsSinceWrite++ if (lastReminderIndex === -1) assistantTurnsSinceReminder++ } else if ( lastReminderIndex === -1 && message?.type === 'attachment' && message.attachment.type === 'todo_reminder' ) { lastReminderIndex = i } if (lastTodoWriteIndex !== -1 && lastReminderIndex !== -1) { break } } return { turnsSinceLastTodoWrite: assistantTurnsSinceWrite, turnsSinceLastReminder: assistantTurnsSinceReminder, } } async function getTodoReminderAttachments( messages: Message[] | undefined, toolUseContext: ToolUseContext, ): Promise { // Skip if TodoWrite tool is not available if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, TODO_WRITE_TOOL_NAME), ) ) { return [] } // When SendUserMessage is in the toolkit, it's the primary communication // channel and the model is always told to use it (#20467). TodoWrite // becomes a side channel — nudging the model about it conflicts with the // brief workflow. The tool itself stays available; this only gates the // "you haven't used it in a while" nag. if ( BRIEF_TOOL_NAME && toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME)) ) { return [] } // Skip if no messages provided if (!messages || messages.length === 0) { return [] } const { turnsSinceLastTodoWrite, turnsSinceLastReminder } = getTodoReminderTurnCounts(messages) // Check if we should show a reminder if ( turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS ) { const todoKey = toolUseContext.agentId ?? getSessionId() const appState = toolUseContext.getAppState() const todos = appState.todos[todoKey] ?? [] return [ { type: 'todo_reminder', content: todos, itemCount: todos.length, }, ] } return [] } function getTaskReminderTurnCounts(messages: Message[]): { turnsSinceLastTaskManagement: number turnsSinceLastReminder: number } { let lastTaskManagementIndex = -1 let lastReminderIndex = -1 let assistantTurnsSinceTaskManagement = 0 let assistantTurnsSinceReminder = 0 // Iterate backwards to find most recent events for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message?.type === 'assistant') { if (isThinkingMessage(message)) { // Skip thinking messages continue } // Check for TaskCreate or TaskUpdate usage BEFORE incrementing counter if ( lastTaskManagementIndex === -1 && 'message' in message && Array.isArray(message.message?.content) && message.message.content.some( block => block.type === 'tool_use' && (block.name === TASK_CREATE_TOOL_NAME || block.name === TASK_UPDATE_TOOL_NAME), ) ) { lastTaskManagementIndex = i } // Count assistant turns before finding events if (lastTaskManagementIndex === -1) assistantTurnsSinceTaskManagement++ if (lastReminderIndex === -1) assistantTurnsSinceReminder++ } else if ( lastReminderIndex === -1 && message?.type === 'attachment' && message.attachment.type === 'task_reminder' ) { lastReminderIndex = i } if (lastTaskManagementIndex !== -1 && lastReminderIndex !== -1) { break } } return { turnsSinceLastTaskManagement: assistantTurnsSinceTaskManagement, turnsSinceLastReminder: assistantTurnsSinceReminder, } } async function getTaskReminderAttachments( messages: Message[] | undefined, toolUseContext: ToolUseContext, ): Promise { if (!isTodoV2Enabled()) { return [] } // Skip for ant users if (process.env.USER_TYPE === 'ant') { return [] } // When SendUserMessage is in the toolkit, it's the primary communication // channel and the model is always told to use it (#20467). TaskUpdate // becomes a side channel — nudging the model about it conflicts with the // brief workflow. The tool itself stays available; this only gates the nag. if ( BRIEF_TOOL_NAME && toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME)) ) { return [] } // Skip if TaskUpdate tool is not available if ( !toolUseContext.options.tools.some(t => toolMatchesName(t, TASK_UPDATE_TOOL_NAME), ) ) { return [] } // Skip if no messages provided if (!messages || messages.length === 0) { return [] } const { turnsSinceLastTaskManagement, turnsSinceLastReminder } = getTaskReminderTurnCounts(messages) // Check if we should show a reminder if ( turnsSinceLastTaskManagement >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS ) { const tasks = await listTasks(getTaskListId()) return [ { type: 'task_reminder', content: tasks, itemCount: tasks.length, }, ] } return [] } /** * Get attachments for all unified tasks using the Task framework. * Replaces the old getBackgroundShellAttachments, getBackgroundRemoteSessionAttachments, * and getAsyncAgentAttachments functions. */ async function getUnifiedTaskAttachments( toolUseContext: ToolUseContext, ): Promise { const appState = toolUseContext.getAppState() const { attachments, updatedTaskOffsets, evictedTaskIds } = await generateTaskAttachments(appState) applyTaskOffsetsAndEvictions( toolUseContext.setAppState, updatedTaskOffsets, evictedTaskIds, ) // Convert TaskAttachment to Attachment format return attachments.map(taskAttachment => ({ type: 'task_status' as const, taskId: taskAttachment.taskId, taskType: taskAttachment.taskType, status: taskAttachment.status, description: taskAttachment.description, deltaSummary: taskAttachment.deltaSummary, outputFilePath: getTaskOutputPath(taskAttachment.taskId), })) } async function getAsyncHookResponseAttachments(): Promise { const responses = await checkForAsyncHookResponses() if (responses.length === 0) { return [] } logForDebugging( `Hooks: getAsyncHookResponseAttachments found ${responses.length} responses`, ) const attachments = responses.map( ({ processId, response, hookName, hookEvent, toolName, pluginId, stdout, stderr, exitCode, }) => { logForDebugging( `Hooks: Creating attachment for ${processId} (${hookName}): ${jsonStringify(response)}`, ) return { type: 'async_hook_response' as const, processId, hookName, hookEvent, toolName, response, stdout, stderr, exitCode, } }, ) // Remove delivered hooks from registry to prevent re-processing if (responses.length > 0) { const processIds = responses.map(r => r.processId) removeDeliveredAsyncHooks(processIds) logForDebugging( `Hooks: Removed ${processIds.length} delivered hooks from registry`, ) } logForDebugging( `Hooks: getAsyncHookResponseAttachments found ${attachments.length} attachments`, ) return attachments } /** * Get teammate mailbox attachments for agent swarm communication * Teammates are independent Claude Code sessions running in parallel (swarms), * not parent-child subagent relationships. * * This function checks two sources for messages: * 1. File-based mailbox (for messages that arrived between polls) * 2. AppState.inbox (for messages queued mid-turn by useInboxPoller) * * Messages from AppState.inbox are delivered mid-turn as attachments, * allowing teammates to receive messages without waiting for the turn to end. */ async function getTeammateMailboxAttachments( toolUseContext: ToolUseContext, ): Promise { if (!isAgentSwarmsEnabled()) { return [] } if (process.env.USER_TYPE !== 'ant') { return [] } // Get AppState early to check for team lead status const appState = toolUseContext.getAppState() // Use agent name from helper (checks AsyncLocalStorage, then dynamicTeamContext) const envAgentName = getAgentName() // Get team name (checks AsyncLocalStorage, dynamicTeamContext, then AppState) const teamName = getTeamName(appState.teamContext) // Check if we're the team lead (uses shared logic from swarm utils) const teamLeadStatus = isTeamLead(appState.teamContext) // Check if viewing a teammate's transcript (for in-process teammates) const viewedTeammate = getViewedTeammateTask(appState) // Resolve agent name based on who we're VIEWING: // - If viewing a teammate, use THEIR name (to read from their mailbox) // - Otherwise use env var if set, or leader's name if we're the team lead let agentName = viewedTeammate?.identity.agentName ?? envAgentName if (!agentName && teamLeadStatus && appState.teamContext) { const leadAgentId = appState.teamContext.leadAgentId // Look up the lead's name from agents map (not the UUID) agentName = appState.teamContext.teammates[leadAgentId]?.name || 'team-lead' } logForDebugging( `[SwarmMailbox] getTeammateMailboxAttachments called: envAgentName=${envAgentName}, isTeamLead=${teamLeadStatus}, resolved agentName=${agentName}, teamName=${teamName}`, ) // Only check inbox if running as an agent in a swarm or team lead if (!agentName) { logForDebugging( `[SwarmMailbox] Not checking inbox - not in a swarm or team lead`, ) return [] } logForDebugging( `[SwarmMailbox] Checking inbox for agent="${agentName}" team="${teamName || 'default'}"`, ) // Check mailbox for unread messages (routes to in-process or file-based) // Filter out structured protocol messages (permission requests/responses, shutdown // messages, etc.) — these must be left unread for useInboxPoller to route to their // proper handlers (workerPermissions queue, sandbox queue, etc.). Without filtering, // attachment generation races with InboxPoller: whichever reads first marks all // messages as read, and if attachments wins, protocol messages get bundled as raw // LLM context text instead of being routed to their UI handlers. const allUnreadMessages = await readUnreadMessages(agentName, teamName) const unreadMessages = allUnreadMessages.filter( m => !isStructuredProtocolMessage(m.text), ) logForDebugging( `[MailboxBridge] Found ${allUnreadMessages.length} unread message(s) for "${agentName}" (${allUnreadMessages.length - unreadMessages.length} structured protocol messages filtered out)`, ) // Also check AppState.inbox for pending messages (queued mid-turn by useInboxPoller) // IMPORTANT: appState.inbox contains messages FROM teammates TO the leader. // Only show these when viewing the leader's transcript (not a teammate's). // When viewing a teammate, their messages come from the file-based mailbox above. // In-process teammates share AppState with the leader — appState.inbox contains // the LEADER's queued messages, not the teammate's. Skip it to prevent leakage // (including self-echo from broadcasts). Teammates receive messages exclusively // through their file-based mailbox + waitForNextPromptOrShutdown. // Note: viewedTeammate was already computed above for agentName resolution const pendingInboxMessages = viewedTeammate || isInProcessTeammate() ? [] // Viewing teammate or running as in-process teammate - don't show leader's inbox : appState.inbox.messages.filter(m => m.status === 'pending') logForDebugging( `[SwarmMailbox] Found ${pendingInboxMessages.length} pending message(s) in AppState.inbox`, ) // Combine both sources of messages WITH DEDUPLICATION // The same message could exist in both file mailbox and AppState.inbox due to race conditions: // 1. getTeammateMailboxAttachments reads file -> finds message M // 2. InboxPoller reads same file -> queues M in AppState.inbox // 3. getTeammateMailboxAttachments reads AppState -> finds M again // We deduplicate using from+timestamp+text prefix as the key const seen = new Set() let allMessages: Array<{ from: string text: string timestamp: string color?: string summary?: string }> = [] for (const m of [...unreadMessages, ...pendingInboxMessages]) { const key = `${m.from}|${m.timestamp}|${m.text.slice(0, 100)}` if (!seen.has(key)) { seen.add(key) allMessages.push({ from: m.from, text: m.text, timestamp: m.timestamp, color: m.color, summary: m.summary, }) } } // Collapse multiple idle notifications per agent — keep only the latest. // Single pass to parse, then filter without re-parsing. const idleAgentByIndex = new Map() const latestIdleByAgent = new Map() for (let i = 0; i < allMessages.length; i++) { const idle = isIdleNotification(allMessages[i]!.text) if (idle) { idleAgentByIndex.set(i, idle.from) latestIdleByAgent.set(idle.from, i) } } if (idleAgentByIndex.size > latestIdleByAgent.size) { const beforeCount = allMessages.length allMessages = allMessages.filter((_m, i) => { const agent = idleAgentByIndex.get(i) if (agent === undefined) return true return latestIdleByAgent.get(agent) === i }) logForDebugging( `[SwarmMailbox] Collapsed ${beforeCount - allMessages.length} duplicate idle notification(s)`, ) } if (allMessages.length === 0) { logForDebugging(`[SwarmMailbox] No messages to deliver, returning empty`) return [] } logForDebugging( `[SwarmMailbox] Returning ${allMessages.length} message(s) as attachment for "${agentName}" (${unreadMessages.length} from file, ${pendingInboxMessages.length} from AppState, after dedup)`, ) // Build the attachment BEFORE marking messages as processed // This prevents message loss if any operation below fails const attachment: Attachment[] = [ { type: 'teammate_mailbox', messages: allMessages, }, ] // Mark only non-structured mailbox messages as read after attachment is built. // Structured protocol messages stay unread for useInboxPoller to handle. if (unreadMessages.length > 0) { await markMessagesAsReadByPredicate( agentName, m => !isStructuredProtocolMessage(m.text), teamName, ) logForDebugging( `[MailboxBridge] marked ${unreadMessages.length} non-structured message(s) as read for agent="${agentName}" team="${teamName || 'default'}"`, ) } // Process shutdown_approved messages - remove teammates from team file // This mirrors what useInboxPoller does in interactive mode (lines 546-606) // In -p mode, useInboxPoller doesn't run, so we must handle this here if (teamLeadStatus && teamName) { for (const m of allMessages) { const shutdownApproval = isShutdownApproved(m.text) if (shutdownApproval) { const teammateToRemove = shutdownApproval.from logForDebugging( `[SwarmMailbox] Processing shutdown_approved from ${teammateToRemove}`, ) // Find the teammate ID by name const teammateId = appState.teamContext?.teammates ? Object.entries(appState.teamContext.teammates).find( ([, t]) => t.name === teammateToRemove, )?.[0] : undefined if (teammateId) { // Remove from team file removeTeammateFromTeamFile(teamName, { agentId: teammateId, name: teammateToRemove, }) logForDebugging( `[SwarmMailbox] Removed ${teammateToRemove} from team file`, ) // Unassign tasks owned by this teammate await unassignTeammateTasks( teamName, teammateId, teammateToRemove, 'shutdown', ) // Remove from teamContext in AppState toolUseContext.setAppState(prev => { if (!prev.teamContext?.teammates) return prev if (!(teammateId in prev.teamContext.teammates)) return prev const { [teammateId]: _, ...remainingTeammates } = prev.teamContext.teammates return { ...prev, teamContext: { ...prev.teamContext, teammates: remainingTeammates, }, } }) } } } } // Mark AppState inbox messages as processed LAST, after attachment is built // This ensures messages aren't lost if earlier operations fail if (pendingInboxMessages.length > 0) { const pendingIds = new Set(pendingInboxMessages.map(m => m.id)) toolUseContext.setAppState(prev => ({ ...prev, inbox: { messages: prev.inbox.messages.map(m => pendingIds.has(m.id) ? { ...m, status: 'processed' as const } : m, ), }, })) } return attachment } /** * Get team context attachment for teammates in a swarm. * Only injected on the first turn to provide team coordination instructions. */ function getTeamContextAttachment(messages: Message[]): Attachment[] { const teamName = getTeamName() const agentId = getAgentId() const agentName = getAgentName() // Only inject for teammates (not team lead or non-team sessions) if (!teamName || !agentId) { return [] } // Only inject on first turn - check if there are no assistant messages yet const hasAssistantMessage = messages.some(m => m.type === 'assistant') if (hasAssistantMessage) { return [] } const configDir = getClaudeConfigHomeDir() const teamConfigPath = `${configDir}/teams/${teamName}/config.json` const taskListPath = `${configDir}/tasks/${teamName}/` return [ { type: 'team_context', agentId, agentName: agentName || agentId, teamName, teamConfigPath, taskListPath, }, ] } function getTokenUsageAttachment( messages: Message[], model: string, ): Attachment[] { if (!isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT)) { return [] } const contextWindow = getEffectiveContextWindowSize(model) const usedTokens = tokenCountFromLastAPIResponse(messages) return [ { type: 'token_usage', used: usedTokens, total: contextWindow, remaining: contextWindow - usedTokens, }, ] } function getOutputTokenUsageAttachment(): Attachment[] { if (feature('TOKEN_BUDGET')) { const budget = getCurrentTurnTokenBudget() if (budget === null || budget <= 0) { return [] } return [ { type: 'output_token_usage', turn: getTurnOutputTokens(), session: getTotalOutputTokens(), budget, }, ] } return [] } function getMaxBudgetUsdAttachment(maxBudgetUsd?: number): Attachment[] { if (maxBudgetUsd === undefined) { return [] } const usedCost = getTotalCostUSD() const remainingBudget = maxBudgetUsd - usedCost return [ { type: 'budget_usd', used: usedCost, total: maxBudgetUsd, remaining: remainingBudget, }, ] } /** * Count human turns since plan mode exit (plan_mode_exit attachment). * Returns 0 if no plan_mode_exit attachment found. * * tool_result messages are type:'user' without isMeta, so filter by * toolUseResult to avoid counting them — otherwise the 10-turn reminder * interval fires every ~10 tool calls instead of ~10 human turns. */ export function getVerifyPlanReminderTurnCount(messages: Message[]): number { let turnCount = 0 for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message && isHumanTurn(message)) { turnCount++ } // Stop counting at plan_mode_exit attachment (marks when implementation started) if ( message?.type === 'attachment' && message.attachment.type === 'plan_mode_exit' ) { return turnCount } } // No plan_mode_exit found return 0 } /** * Get verify plan reminder attachment if the model hasn't called VerifyPlanExecution yet. */ async function getVerifyPlanReminderAttachment( messages: Message[] | undefined, toolUseContext: ToolUseContext, ): Promise { if ( process.env.USER_TYPE !== 'ant' || !isEnvTruthy(process.env.CLAUDE_CODE_VERIFY_PLAN) ) { return [] } const appState = toolUseContext.getAppState() const pending = appState.pendingPlanVerification // Only remind if plan exists and verification not started or completed if ( !pending || pending.verificationStarted || pending.verificationCompleted ) { return [] } // Only remind every N turns if (messages && messages.length > 0) { const turnCount = getVerifyPlanReminderTurnCount(messages) if ( turnCount === 0 || turnCount % VERIFY_PLAN_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS !== 0 ) { return [] } } return [{ type: 'verify_plan_reminder' }] } export function getCompactionReminderAttachment( messages: Message[], model: string, ): Attachment[] { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_fox', false)) { return [] } if (!isAutoCompactEnabled()) { return [] } const contextWindow = getContextWindowForModel(model, getSdkBetas()) if (contextWindow < 1_000_000) { return [] } const effectiveWindow = getEffectiveContextWindowSize(model) const usedTokens = tokenCountWithEstimation(messages) if (usedTokens < effectiveWindow * 0.25) { return [] } return [{ type: 'compaction_reminder' }] } /** * Context-efficiency nudge. Injected after every N tokens of growth without * a snip. Pacing is handled entirely by shouldNudgeForSnips — the 10k * interval resets on prior nudges, snip markers, snip boundaries, and * compact boundaries. */ export function getContextEfficiencyAttachment( messages: Message[], ): Attachment[] { if (!feature('HISTORY_SNIP')) { return [] } // Gate must match SnipTool.isEnabled() — don't nudge toward a tool that // isn't in the tool list. Lazy require keeps this file snip-string-free. const { isSnipRuntimeEnabled, shouldNudgeForSnips } = // eslint-disable-next-line @typescript-eslint/no-require-imports require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js') if (!isSnipRuntimeEnabled()) { return [] } if (!shouldNudgeForSnips(messages)) { return [] } return [{ type: 'context_efficiency' }] } function isFileReadDenied( filePath: string, toolPermissionContext: ToolPermissionContext, ): boolean { const denyRule = matchingRuleForInput( filePath, toolPermissionContext, 'read', 'deny', ) return denyRule !== null }