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