Loads nested AGENTS.md for files Claude touches through Bash, Edit or Write, where the built-in agents-md mod loads them only on Read.

A Claude Code mod. Claude Code's built-in agents-md mod loads a nested AGENTS.md only when Claude opens a file with the Read tool. Files Claude reads or changes through Bash (cat, sed, heredocs) or the Edit and Write tools get no nested guidance. This mod closes that gap.
When a Bash command names a file under the project root, or Edit or Write changes one, the mod attaches each AGENTS.md between the root and that file that this conversation has not had yet. It uses the same Contents of <path>: frame as agents-md.
tee targets (a heredoc that creates a file). Directories, patterns, and command output count for nothing, so grep -rn foo . attaches nothing.agents-md delivers so the mod does not send it again. The reverse cannot be known: a file this mod sent first comes once more on a later Read of that folder.agents-md in its default mode: a project with its own CLAUDE.md, or a folder with one, is left to the engine. Each file goes once per conversation and again after compaction or /clear.Why it exists: anthropics/claude-code#90450. In Auto and bypass modes Claude Code tells the model to prefer Bash for reads and edits, so nested AGENTS.md never load. Delete this mod when Claude Code loads nested AGENTS.md on every file access.
Needs Claude Code 2.1.287 or later (mods are on by default). Test with claude plugin test plugins/nested-agents-md.
hooks/register.ts 215 lines1import type { EngineInterface, FsAncestor, On } from 'claude-code'
2
3const AGENTS_NAMES = ['AGENTS.md', '.claude/AGENTS.md'] as const
4const CLAUDE_NAMES = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'] as const
5const MAIN_LOOP = 'main'
6const MAX_TOKENS = 32
7const TOKEN = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([^\s"'|;&<>()`]+)/g
8const WRITTEN = /(?:>>?|\btee(?:\s+-a)?)\s*(?:"([^"]*)"|'([^']*)'|([^\s"'|;&<>()`]+))/g
9const CD = /(?:^|[;&|(]\s*)cd\s+(?:"([^"]*)"|'([^']*)'|([^\s"'|;&<>()`]+))/g
10const NONE: readonly FsAncestor[] = []
11
12type Target = { path: string; mayBeNew: boolean }
13
14export function register(on: On): void {
15 const given = new Map<string, Set<string>>()
16 let handed = new Set<string>()
17 let isClaudeProject: boolean | undefined
18 let rootSeen: string | undefined
19
20 on('prompt.context', ($, e, next) => {
21 const files = e.instructionFiles
22
23 return next(e).finally(() => {
24 given.clear()
25 handed = new Set(files?.map(file => normal(file.path)))
26 })
27 })
28
29 on('agent.spawn', { fork: true }, async ($, e, next) => {
30 const result = await next(e)
31
32 if (result.agentId !== undefined) {
33 given.set(result.agentId, new Set(given.get(e.parentAgentId ?? MAIN_LOOP)))
34 }
35
36 return result
37 })
38
39 on('tool.call', async ($, e, next) => {
40 const result = await next(e)
41 const paths = pathsOf(e)
42 const isSettled = paths === undefined || result.deny !== undefined || result.isError
43
44 if (isSettled || !(await attaches($))) {
45 return result
46 }
47
48 const root = await $.session.root()
49
50 if (root !== rootSeen) {
51 rootSeen = root
52 isClaudeProject = undefined
53 given.clear()
54 }
55
56 isClaudeProject ??= (await $.fs.ancestors({ names: CLAUDE_NAMES })).length > 0
57
58 if (isClaudeProject) {
59 return result
60 }
61
62 const loop = e.agentId ?? MAIN_LOOP
63 const sent = given.get(loop) ?? new Set<string>()
64 given.set(loop, sent)
65 const targets = await targetsOf($, paths, cdDirsOf(e), root)
66 const fresh: FsAncestor[] = []
67
68 for (const target of targets) {
69 for (const file of await nestedOf($, target, root)) {
70 const path = normal(file.parts[0]?.path ?? `${file.dir}/${file.name}`)
71
72 if (!sent.has(path) && !handed.has(path)) {
73 sent.add(path)
74 fresh.push(file)
75 }
76 }
77 }
78
79 const isObservedOnly = e.tool === 'Read' || fresh.length === 0
80
81 return isObservedOnly
82 ? result
83 : { ...result, context: [...(result.context ?? []), ...fresh.flatMap(frames)] }
84 })
85}
86
87function pathsOf(e: { tool: string } & Record<string, unknown>): Target[] | undefined {
88 if (e.tool === 'Bash' && typeof e.command === 'string') {
89 const written = [...e.command.matchAll(WRITTEN)].map(match => ({ path: match[1] ?? match[2] ?? match[3] ?? '', mayBeNew: true }))
90
91 return [...written, ...tokensOf(e.command).map(path => ({ path, mayBeNew: false }))]
92 }
93
94 const isFileTool = e.tool === 'Read' || e.tool === 'Edit' || e.tool === 'Write'
95
96 return isFileTool && typeof e.file_path === 'string' ? [{ path: e.file_path, mayBeNew: e.tool === 'Write' }] : undefined
97}
98
99const cdDirsOf = (e: { tool: string } & Record<string, unknown>): string[] =>
100 e.tool === 'Bash' && typeof e.command === 'string'
101 ? [...e.command.matchAll(CD)].map(match => match[1] ?? match[2] ?? match[3] ?? '').reverse()
102 : []
103
104function tokensOf(command: string): string[] {
105 const tokens: string[] = []
106
107 for (const match of command.matchAll(TOKEN)) {
108 const raw = match[1] ?? match[2] ?? match[3] ?? ''
109 const token = raw.includes('=') ? raw.slice(raw.lastIndexOf('=') + 1) : raw
110 const isPathLike = token !== '' && !token.startsWith('-') && !/[$*?[\]{}]/.test(token)
111
112 if (isPathLike) {
113 tokens.push(token)
114 }
115
116 if (tokens.length === MAX_TOKENS) {
117 break
118 }
119 }
120
121 return tokens
122}
123
124async function targetsOf($: EngineInterface, targets: Target[], cdDirs: string[], root: string): Promise<string[]> {
125 const [cwd, home] = await Promise.all([$.session.cwd(), $.env.get('HOME')])
126 const bases = [...cdDirs.map(dir => absoluteOf(dir, cwd, home)), cwd]
127 const kept = await Promise.all(targets.map(target => firstTargetOf($, target, bases, home, root)))
128
129 return [...new Set(kept.filter(path => path !== undefined))]
130}
131
132async function firstTargetOf(
133 $: EngineInterface,
134 { path, mayBeNew }: Target,
135 bases: string[],
136 home: string | undefined,
137 root: string,
138): Promise<string | undefined> {
139 for (const base of path === '' ? [] : bases) {
140 const absolute = absoluteOf(path, base, home)
141
142 if (isBelow(absolute, root) && (await isFileTarget($, absolute, mayBeNew))) {
143 return absolute
144 }
145 }
146
147 return undefined
148}
149
150async function isFileTarget($: EngineInterface, path: string, mayBeNew: boolean): Promise<boolean> {
151 const kind = await kindOf($, path)
152 const isNewFile = mayBeNew && kind === undefined && (await kindOf($, dirOf(path))) === 'dir'
153
154 return kind === 'file' || isNewFile
155}
156
157const kindOf = ($: EngineInterface, path: string) =>
158 $.fs.stat(path).then(
159 stat => stat.kind,
160 () => undefined,
161 )
162
163async function nestedOf($: EngineInterface, of: string, root: string): Promise<readonly FsAncestor[]> {
164 const [agents, claude] = await Promise.all([
165 $.fs.ancestors({ names: AGENTS_NAMES, of, below: root }),
166 $.fs.ancestors({ names: CLAUDE_NAMES, of, below: root }),
167 ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
168 const claudeDirs = new Set(claude.map(file => normal(file.dir)))
169
170 return agents.filter(file => !claudeDirs.has(normal(file.dir)) && isBelow(file.dir, root))
171}
172
173const frames = (file: FsAncestor): string[] =>
174 file.parts.map(part => `Contents of ${part.path}:\n\n${part.content}`)
175
176async function attaches($: EngineInterface): Promise<boolean> {
177 const [simple, attachmentsOff] = await Promise.all([
178 $.env.get('CLAUDE_CODE_SIMPLE'),
179 $.env.get('CLAUDE_CODE_DISABLE_ATTACHMENTS'),
180 ])
181
182 return !isOn(simple) && !isOn(attachmentsOff)
183}
184
185const isOn = (value: string | undefined): boolean =>
186 value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
187
188function absoluteOf(path: string, base: string, home: string | undefined): string {
189 if ((path === '~' || path.startsWith('~/')) && home !== undefined) {
190 return normal(`${home}${path.slice(1)}`)
191 }
192
193 return normal(path.startsWith('/') ? path : `${base}/${path}`)
194}
195
196function normal(path: string): string {
197 const parts: string[] = []
198
199 for (const part of path.replaceAll('\\', '/').split('/')) {
200 if (part === '..' && parts.length > 1) {
201 parts.pop()
202 } else if (part === '..') {
203 continue
204 } else if (part !== '.' && (part !== '' || parts.length === 0)) {
205 parts.push(part)
206 }
207 }
208
209 return parts.join('/') || '/'
210}
211
212const isBelow = (path: string, dir: string): boolean => normal(path).startsWith(`${normal(dir)}/`)
213
214const dirOf = (path: string): string => normal(path).replace(/\/[^/]*$/, '') || '/'
215