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