Sends your CLAUDE.md, word for word, as a hidden part of your next message whenever Claude doesn't have its current text: after an edit made outside Claude…

A Claude Code plugin that makes sure Claude has your CLAUDE.md as it is on disk now, word for word. When Claude doesn't have a file's current text, the plugin sends it as a hidden part of your next message. You don't see it in the chat, but Claude does, and it stays in that message, so Claude can refer back to it later.
It never compacts a chat, never rewrites a message already sent, and never touches the system prompt.
Claude Code gives Claude your CLAUDE.md when a chat starts, and again after a compaction. Between those times it can fall out of date:
CLAUDE.md in your editor, in another chat or with git mid-chat, Claude keeps working from the old text.CLAUDE.md in a subfolder Claude was working in only comes back when Claude opens a file there again, so until then Claude works without it.At each message you send, before it goes to Claude, the plugin reads the conversation exactly as Claude will read it, finds the latest complete copy of each file in it, and compares that copy with the file on disk.
A message that sends nothing shows no toast.
Only a complete copy of that same file counts, and it has to be the whole file, nothing more and nothing less:
So deleting a line counts as a change too: Claude's old copy has more in it than the file now, so the new text is sent. Line endings and space at the very start or end don't count as differences.
Each file is read through Claude Code's own loader, so the plugin sees and sends it the way Claude Code gives it to Claude: without the HTML comments and frontmatter Claude Code leaves out. Changing only a comment sends nothing. A file left with nothing but comments counts as removed. A Read or Write of the file, comments and all, still counts as Claude's copy.
| When | What happens |
|---|---|
A chat starts, is resumed, or after /clear | Nothing extra. Claude Code gives Claude its CLAUDE.md with that message, so the plugin sends nothing. It notes which files Claude Code loaded: managed, user ~/.claude/CLAUDE.md, project, CLAUDE.local.md, rules, auto-memory and @imports. |
A CLAUDE.md is edited outside Claude Code | With your next message, the new text is sent, with one toast: CLAUDE.md edited and sent to Claude. Opening the pane shows the edit sooner. |
Claude edits a CLAUDE.md | A Write needs nothing more. After an Edit, the whole file is sent with your next message. |
| You send messages while Claude is working | Each is checked the same way. When several reach Claude together, the block goes with the first only. |
A compaction (/compact, or Claude Code's own when the chat is full) | Claude Code gives Claude its startup files again, so they aren't sent twice. Any pinned subfolder file it dropped is sent with your next message. |
A new CLAUDE.md appears | It's pinned and sent with your next message. |
| A file is deleted, emptied, or left with nothing but comments | This covers a CLAUDE.md, a rules file, an @import or auto-memory. If Claude has a copy of it, your next message says once that it was removed and no longer applies. |
Claude opens a file in a subfolder with its own CLAUDE.md | That file is pinned, with a toast: CLAUDE.md pinned: ~/app/api/CLAUDE.md. Claude Code gives Claude a copy itself; the plugin sends it only if that copy is missing or out of date. |
| 10 of your messages pass with no work in that subfolder | It's unpinned, with a toast: CLAUDE.md unpinned: ~/app/api/CLAUDE.md. It isn't sent again after a compaction, but while Claude still has a copy, an edit or deletion is still sent. Opening a file there again pins it again. |
No CLAUDE.md anywhere | Nothing is sent. Its line reads No CLAUDE.md found. |
| A subagent runs | It reads CLAUDE.md the way Claude Code gives it to subagents, unchanged. |
The block goes with your message as context added by a hook, the same way Claude Code adds its own reminders. Claude reads it as part of that message, wrapped in <system-reminder> tags. It isn't drawn in the chat in the terminal or the desktop app. It's saved with the message, so it's still there on later turns and when you resume the chat, and Claude can refer back to it. It looks like this:
# CLAUDE.md
These are the user's CLAUDE.md instructions, exactly as they are on disk now. Their current text isn't in this conversation, so here it is: where anything earlier in the conversation differs, this is current. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
Contents of C:\Users\you\app\CLAUDE.md (project instructions, checked into the codebase):
Use spaces.
Run the tests first.
Contents of C:\Users\you\app\api\CLAUDE.md (subfolder instructions; apply when working in C:\Users\you\app\api):
Validate every endpoint.
Removed: C:\Users\you\app\CLAUDE.local.md. Its instructions no longer apply.
Only the files Claude lacks are in it. Once a file's text has been sent, the block is Claude's latest copy, so it isn't sent again until the file changes.
Chats from versions before 0.9.0 may hold the CLAUDE.md message those versions put first. It's still never drawn in the chat, and Claude still counts its text.
~/.claude/CLAUDE.md (or $CLAUDE_CONFIG_DIR/CLAUDE.md), plus CLAUDE.md, .claude/CLAUDE.md and CLAUDE.local.md in the working directory and every folder above it, and files such as .claude/rules/*.md. All of them are pinned, top-level folder first.CLAUDE.md created, edited or deleted while you're idle shows within a second, and the line has them before your first message, though the desktop app doesn't load its CLAUDE.md until you send one. This check only feeds the line, and only while it's shown: it pins nothing and decides nothing about what Claude is sent. That waits for your next message.CLAUDE.md that is a symbolic link, say to an AGENTS.md, is read through the link, so editing AGENTS.md counts as editing the CLAUDE.md itself.CLAUDE.md is pinned once Claude opens a file in that folder (Read, Edit, Write, MultiEdit or NotebookEdit). Only folders Claude actually works in are pinned, so the rest of the repo costs nothing.A subfolder's file stays pinned while Claude keeps working in that folder. Any tool call with a path inside it counts, searches included, and resets the count. After 10 of your messages with no work there, the file is unpinned.
Unpinning doesn't take the file out of the chat: Claude keeps the copy it has. So until a compaction drops that copy, the file is still checked at each message, and if you edit or delete it, Claude is sent the new text or told it was removed. After the compaction it isn't sent again, unless Claude works in that folder again.
Both use your theme's own colors, so they look right in light and dark themes, in the terminal and the desktop app. Color only ever means something: a green dot when files are pinned, and a warning color when a subfolder file is 3 messages or fewer from being unpinned.
The line says what's pinned, in every chat until you hide it. CLAUDE.md pinned is in the text color, and the rest in gray:
● CLAUDE.md pinned 4 files, ~1.2k tokens api/ unpins in 3 web/ unpins in 7
Each subfolder file has its own count of messages left before it's unpinned. The line names the two closest to being unpinned, and adds +N more when there are others. The pane lists them all.
In the terminal, it's the status line under the prompt, below the mode line, so nothing is added above the prompt. The rest of the row is cut off when the terminal is too narrow for it all. /claudemd opens and closes the pane.
In the desktop app, which has no line under the prompt, it's a band above the prompt instead, with two buttons:
o) opens the pane.x) hides the band.These keys work while the band is focused, with a click or ctrl+x tab. They differ from the keys in usage-mod's band menu, so the two bands never fight over a key. On a narrow band, it names one subfolder file, then drops the file count and size.
If other plugins also draw bands above the prompt, they're all shown. This one comes last, next to the prompt, with a blank row and a line separating it from theirs. With no other band, there's no line.
The pane opens with /claudemd. In the desktop app:
● 2 files pinned ~376 tokens h: Hide band
───────────────────────────────────────────────────────────
As on disk now. Press a file to read it.
Loaded at startup
1: ~/app/CLAUDE.md ~114
This project, shared with the team
Subfolders
2: ~/app/api/CLAUDE.md ~87
Unpins after 10 more messages without work here
Last change
Edited and sent to Claude: ~/app/api/CLAUDE.md this message
In the terminal, the button reads Hide status line. Everything above the rule stays put and everything under it scrolls with the mouse wheel or the page keys, so a long file never pushes that button out of sight. A scroll bar on the right shows where you are, whenever there's more than fits. The desktop app scrolls the pane itself, with its own scroll bar, so there the whole pane scrolls, top included. So that you always know which file is open, the pane's tab title names it, where it comes from and its size, such as ~/app/CLAUDE.md: This project, shared with the team (~1.7k tokens, read-only). Both leave a margin between the text and the pane's edges.
The pane's frame and background come from Claude Code's theme. If the pane looks dark in a light terminal, choose a light theme with /theme.
Every file is named by where it is, with ~ for your home folder, so a project's file says which project it's in. Press a file's name to read it, or its number (1–9) in the terminal. The pane shows that file read-only, as it is on disk now and as Claude gets it, without HTML comments, with Back (b) in the top row to return to the list. Where the file comes from and its size sit above the line, so what scrolls is the file itself.
/claudemd opens the pane, on the list of files, or closes it when it's open. Pinning runs in the background either way.
Apart from the hidden block, the plugin never writes to the chat; everything it reports is a toast. It shows one only when:
There's no refresh button: opening the pane or a file in it reads the files from disk. Hide band (h) / Show band (s) in the pane hides or shows the line, and so does /claudemd band, with a toast: CLAUDE.md line hidden. Your choice is kept for every chat, new or old, until you change it. Chats that are already open follow it at their next message.
The terminal, the desktop app and claude -p work the same way.
This repository is its own plugin marketplace, so two commands install it, whether or not you've added a marketplace before. In a terminal:
claude plugin marketplace add jasmo13/always-read-claudemd
claude plugin install always-read-claudemd@always-read-claudemd
Then open a new chat, or restart the desktop app. The line shows CLAUDE.md pinned: under the prompt in the terminal, above it in the desktop app. You need to be able to read this repository on GitHub. While it's private, that means being signed in to GitHub as someone with access, the same as for git clone.
To try it from a local copy in the terminal without installing:
claude --plugin-dir path/to/always-read-claudemd/plugins/always-read-claudemd
The plugin uses Claude Code's function-hook plugin API, and it was built and tested on Claude Code 2.1.292 in the terminal and 2.1.293 in the desktop app.
Releases come from main. After a new version is merged, update with:
claude plugin marketplace update always-read-claudemd
claude plugin update always-read-claudemd@always-read-claudemd
Then reopen your chats or restart the app.
The plugin lives in plugins/always-read-claudemd/; the repository root holds the marketplace (.claude-plugin/marketplace.json).
| Path | Contents |
|---|---|
hooks/register.tsx | Hooks: capturing the files Claude Code loaded, reading files through Claude Code's loader, checking the conversation at each message and attaching the hidden block, re-syncing from disk, subfolder pins, the band and the pane, and the line's once-a-second look at the disk |
hooks/pin.ts | Pure helpers: the hidden block, finding each file's latest copy in the conversation, paths, token estimates |
tests/register.test.ts | Tests |
types/index.d.ts | Types for the values the plugin keeps between reloads |
.claude-plugin/plugin.json | The plugin's manifest and version |
Before opening a pull request, run these from plugins/always-read-claudemd/:
claude plugin test .
npx -p typescript tsc -p .
claude plugin validate .
tsc reads tsconfig.json, which points to the types Claude Code writes into the plugin's .claude-plugin/types/ folder the first time it loads the plugin, for example with claude --plugin-dir. Git ignores that folder through a .gitignore Claude Code writes inside it.
In the same pull request:
version in plugins/always-read-claudemd/.claude-plugin/plugin.json.hooks/register.tsx 925 lines1import { atom, read, update } from 'claude-code'
2import type { ClientElements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { OnDisk, Pin, PinnedFile } from '../types'
5import {
6 FILE_TOOLS,
7 PROJECT_NAMES,
8 TIERS,
9 UNPIN_AFTER,
10 asOnDisk,
11 blockOf,
12 copiedPaths,
13 copiesOf,
14 countOf,
15 displayPath,
16 folderName,
17 formatTokens,
18 isAbsolute,
19 isBlank,
20 isHeld,
21 isInside,
22 isMessage,
23 isSubfolderName,
24 keyOf,
25 lastCopy,
26 messagesLeft,
27 pathsOf,
28 pinnedText,
29 bandLayout,
30 scrollBar,
31 scrolled,
32 tabTitle,
33 tokensOf,
34 unpinnedFile,
35} from './pin'
36
37const PANE = 'always-read-claudemd'
38const TITLE = 'CLAUDE.md'
39const COMMAND = 'claudemd'
40const BAND_KEY = 'isBandShown'
41
42const EMPTY: Pin = { files: [], raw: null, source: null }
43const pinAtom = atom({ plugin: 'always-read-claudemd', key: 'pin' } as const, EMPTY)
44const turnAtom = atom({ plugin: 'always-read-claudemd', key: 'turn' } as const, 0)
45const changeAtom = atom({ plugin: 'always-read-claudemd', key: 'lastChange' } as const, null)
46const bandAtom = atom({ plugin: 'always-read-claudemd', key: 'isBandShown' } as const, true)
47const viewingAtom = atom({ plugin: 'always-read-claudemd', key: 'viewing' } as const, null)
48const paneTopAtom = atom({ plugin: 'always-read-claudemd', key: 'paneTop' } as const, 0)
49const NOTHING_MOVED: OnDisk = { found: [], gone: [] }
50const diskAtom = atom({ plugin: 'always-read-claudemd', key: 'onDisk' } as const, NOTHING_MOVED)
51
52/**
53 * The CLAUDE.md text Claude Code is about to give Claude with the next message: it reads its files
54 * for a new chat, after a compaction or /clear, and on resume, and adds them to the message only
55 * after that message's hooks have run. Spent by that message.
56 */
57let announced: string[] = []
58/**
59 * The blocks sent with messages typed while Claude was working. Those messages can join that turn
60 * together, so one's block isn't in the conversation yet when the next is checked: kept for that turn.
61 */
62let sentMidTurn: { turnId: string; blocks: string[] } | undefined
63
64async function mtimeOf($: EngineInterface, path: string): Promise<number | undefined> {
65 return (await $.fs.stat(path).catch(() => undefined))?.mtimeMs
66}
67
68/**
69 * A file's text as Claude Code's own loader gives it to Claude: HTML comments and frontmatter left
70 * out. Where the loader can't reach it, the file as it is; undefined when it can't be read.
71 */
72async function textOf($: EngineInterface, path: string): Promise<string | undefined> {
73 const dir = path.replace(/[\\/][^\\/]*$/, '')
74 const request = { names: [path.slice(dir.length + 1)], of: path, below: dir.replace(/[\\/][^\\/]*$/, '') }
75 const loaded = (await $.fs.ancestors(request).catch(() => []))[0]?.parts[0]?.content
76 if (loaded !== undefined) return loaded
77 const content = await $.fs.read(path).catch(() => undefined)
78 return typeof content === 'string' ? content : undefined
79}
80
81async function homeOf($: EngineInterface): Promise<string | undefined> {
82 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
83}
84
85async function placesOf($: EngineInterface) {
86 return { home: await homeOf($).catch(() => undefined) }
87}
88
89/** Claude Code's user folder: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
90async function userDirOf($: EngineInterface): Promise<string | undefined> {
91 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
92 const home = await homeOf($)
93 return configDir ?? (home === undefined ? undefined : `${home.replace(/[\\/]$/, '')}/.claude`)
94}
95
96/** The instruction files on disk where new ones can appear: user-level and the project's ancestors. */
97async function discover($: EngineInterface): Promise<PinnedFile[]> {
98 const found: PinnedFile[] = []
99
100 const userDir = await userDirOf($)
101 if (userDir !== undefined) {
102 const path = `${userDir}/CLAUDE.md`
103 const mtimeMs = await mtimeOf($, path)
104 const content = mtimeMs === undefined ? undefined : await textOf($, path)
105 if (mtimeMs !== undefined && content !== undefined) {
106 found.push({ path, kind: 'user', content, mtimeMs })
107 }
108 }
109
110 const ancestors = await $.fs.ancestors({ names: PROJECT_NAMES }).catch(() => [])
111 for (const entry of ancestors) {
112 const kind = entry.name === 'CLAUDE.local.md' ? 'local' : 'project'
113 for (const part of entry.parts) {
114 found.push({ path: part.path, kind, content: part.content, mtimeMs: (await mtimeOf($, part.path)) ?? -1 })
115 }
116 }
117
118 return found
119}
120
121/**
122 * What the band and the terminal's status line say: how many files are pinned and their size, and
123 * each subfolder file with its own count, the one closest to unpinning first.
124 */
125function lineOf(pin: Pin, turn: number) {
126 const count = countOf(pin)
127 const summary = `${count} ${count === 1 ? 'file' : 'files'}, ~${formatTokens(tokensOf(pinnedText(pin)))} tokens`
128 const nested = pin.files
129 .flatMap(f => (f.scope === undefined ? [] : [{ folder: folderName(f.scope), left: messagesLeft(f, turn) }]))
130 .sort((a, b) => a.left - b.left)
131 return { count, summary, nested }
132}
133
134/**
135 * The line itself, as the band and the status line both draw it: the dot, "CLAUDE.md pinned", the
136 * summary in gray, then the first `folders` subfolder files and how many more there are.
137 */
138function pinnedLine(
139 { Box, Text }: Pick<ClientElements, 'Box' | 'Text'>,
140 { count, summary, nested }: ReturnType<typeof lineOf>,
141 { folders, hasSummary }: { folders: number; hasSummary: boolean },
142): RenderElement[] {
143 const named = nested.slice(0, folders)
144 return [
145 <Box key="pinned" flexDirection="row" columnGap={1} flexGrow={1} flexShrink={1} minWidth={0}>
146 <Box flexShrink={0}>{count === 0 ? <Text dimColor>○</Text> : <Text color="success">●</Text>}</Box>
147 <Box flexShrink={0}>
148 <Text dimColor={count === 0}>{count === 0 ? 'No CLAUDE.md found' : 'CLAUDE.md pinned'}</Text>
149 </Box>
150 {count > 0 && hasSummary && (
151 <Box flexShrink={1} minWidth={0}>
152 <Text dimColor wrap="truncate-end">
153 {summary}
154 </Text>
155 </Box>
156 )}
157 </Box>,
158 ...(named.length === 0
159 ? []
160 : [
161 <Box key="folders" flexDirection="row" columnGap={2} flexShrink={0}>
162 {named.map(({ folder, left }) => (
163 <Box key={folder} flexShrink={0}>
164 {left <= NEAR_UNPIN ? (
165 <Text color="warning">{`${folder} unpins in ${left}`}</Text>
166 ) : (
167 <Text dimColor>{`${folder} unpins in ${left}`}</Text>
168 )}
169 </Box>
170 ))}
171 {nested.length > named.length && (
172 <Box key="more" flexShrink={0}>
173 <Text dimColor>{`+${nested.length - named.length} more`}</Text>
174 </Box>
175 )}
176 </Box>,
177 ]),
178 ]
179}
180
181/** Shows or hides the band as it was last chosen, in this chat or another. */
182async function followBand($: EngineInterface) {
183 const stored = await $.store.get(BAND_KEY).catch(() => undefined)
184 if (typeof stored === 'boolean' && stored !== (await read($, bandAtom))) await update($, bandAtom, () => stored)
185}
186
187/** Stores a new pin and says so: the pane's last change, and the tab title. */
188async function settle($: EngineInterface, pin: Pin, change?: string) {
189 await update($, pinAtom, () => pin)
190 if (change !== undefined) {
191 const turn = await read($, turnAtom)
192 await update($, changeAtom, () => ({ text: change, turn }))
193 }
194 await settleView($, pin).catch(() => undefined)
195}
196
197/** Where a file comes from, as the open file's view and its tab title say it. */
198function detailOf(file: PinnedFile): string {
199 return file.scope === undefined ? (TIERS[file.kind] ?? file.kind) : `Applies while Claude works in ${folderName(file.scope)}`
200}
201const RAW_DETAIL = 'As another plugin rewrote it'
202
203/**
204 * The pane's tab title for what it shows: the open file's name, where it comes from, its size and
205 * that it's read-only, or the plain title.
206 */
207async function titleFor($: EngineInterface, viewing: string | null): Promise<string> {
208 if (viewing === null) return TITLE
209 const pin = await read($, pinAtom)
210 if (viewing === RAW && pin.source === 'raw') return tabTitle(TITLE, RAW_DETAIL, tokensOf(pin.raw ?? ''))
211 const file = pin.files.find(f => keyOf(f.path) === viewing)
212 return file === undefined
213 ? TITLE
214 : tabTitle(displayPath(file.path, await placesOf($)), detailOf(file), tokensOf(file.content))
215}
216
217/**
218 * The pin changed while the pane shows a file: back to the list if the file is gone, and a tab
219 * title that names a file (the desktop's) kept to what the pane shows.
220 */
221async function settleView($: EngineInterface, pin: Pin) {
222 const viewing = await read($, viewingAtom)
223 if (viewing === null) return
224 const isGone = !(viewing === RAW && pin.source === 'raw') && !pin.files.some(f => keyOf(f.path) === viewing)
225 if (isGone) await update($, viewingAtom, () => null)
226 const pane = (await $.ui.panes()).find(p => p.id === PANE)
227 if (pane === undefined || pane.title === TITLE) return
228 const title = await titleFor($, isGone ? null : viewing)
229 if (title !== pane.title) await $.ui.open({ id: PANE, title })
230}
231
232const describe = (verb: string, paths: string[], places: { root?: string; home?: string }) =>
233 `${verb} ${paths.map(p => displayPath(p, places)).join(', ')}`
234
235/**
236 * The pinned files as they are on disk now: any whose mtime moved re-read, deleted ones dropped,
237 * and new ones added. Changes nothing; says what moved.
238 */
239async function onDisk($: EngineInterface, pin: Pin) {
240 const edited: string[] = []
241 const removed: string[] = []
242 const added: string[] = []
243 const files: PinnedFile[] = []
244 for (const file of pin.files) {
245 const mtimeMs = await mtimeOf($, file.path)
246 if (mtimeMs === undefined) {
247 removed.push(file.path)
248 continue
249 }
250 if (mtimeMs === file.mtimeMs) {
251 files.push(file)
252 continue
253 }
254 const content = await textOf($, file.path)
255 if (content === undefined) {
256 removed.push(file.path)
257 continue
258 }
259 if (content.trim() !== file.content.trim()) edited.push(file.path)
260 files.push({ ...file, content, mtimeMs })
261 }
262
263 const known = new Set(files.map(f => keyOf(f.path)))
264 for (const file of await discover($)) {
265 if (known.has(keyOf(file.path))) continue
266 known.add(keyOf(file.path))
267 files.push(file)
268 added.push(file.path)
269 }
270 return { files, edited, removed, added }
271}
272
273/** Re-checks the pinned files against disk and pins them as they are now. */
274async function sync($: EngineInterface): Promise<void> {
275 const pin = await read($, pinAtom)
276 const { files, edited, removed, added } = await onDisk($, pin)
277
278 const hasChanged = edited.length + removed.length + added.length > 0
279 if (!hasChanged && pin.source !== null) {
280 // Same text, but a file read back from the chat's message now has its mtime.
281 if (files.some((f, i) => f !== pin.files[i])) await update($, pinAtom, () => ({ ...pin, files }))
282 return
283 }
284
285 const places = await placesOf($)
286 const change = [
287 ...(edited.length > 0 ? [describe('Edited', edited, places)] : []),
288 ...(added.length > 0 ? [describe('Added', added, places)] : []),
289 ...(removed.length > 0 ? [describe('Removed', removed, places)] : []),
290 ].join('; ')
291 // Raw text can't be patched per file: once the disk moves, pin what is on disk.
292 const next: Pin = { files, raw: null, source: pin.source === 'engine' ? 'engine' : 'discovered' }
293 // No toast: what reaches Claude is toasted when it's sent.
294 await settle($, next, pin.source === null ? undefined : change)
295}
296
297// Whether the line's look at the disk is under way: one at a time.
298let isLooking = false
299
300/**
301 * The line's own look at the disk, as the chat opens and once a second: the files edited, created
302 * or deleted since they were pinned, so the band and the status line show them as they are now.
303 * Only the line reads what it finds: what is pinned, and what Claude is sent, are decided at each
304 * message alone.
305 */
306async function look($: EngineInterface): Promise<void> {
307 if (isLooking || !(await read($, bandAtom))) return
308 isLooking = true
309 try {
310 const { files, edited, removed, added } = await onDisk($, await read($, pinAtom))
311 const moved = new Set([...edited, ...added])
312 const disk: OnDisk = { found: files.filter(f => moved.has(f.path)), gone: removed }
313 if (JSON.stringify(disk) !== JSON.stringify(await read($, diskAtom))) await update($, diskAtom, () => disk)
314 } finally {
315 isLooking = false
316 }
317}
318
319/**
320 * Claude worked at these paths: keep the subfolder pins whose folder holds
321 * one, and when it opened a file, pin the CLAUDE.md of each subfolder
322 * between the project root and that file.
323 */
324async function touch($: EngineInterface, paths: string[], isFileTool: boolean): Promise<void> {
325 if (paths.length === 0) return
326 const root = await $.session.root()
327 const turn = await read($, turnAtom)
328 const pin = await read($, pinAtom)
329
330 let hasTouched = false
331 const added: string[] = []
332 let files = pin.files
333 const known = new Set(files.map(f => keyOf(f.path)))
334 for (const path of paths) {
335 const absolute = isAbsolute(path) ? path : `${root}/${path}`
336 files = files.map(f => {
337 if (f.scope === undefined || f.lastUsedTurn === turn || !isInside(absolute, f.scope)) return f
338 hasTouched = true
339 return { ...f, lastUsedTurn: turn }
340 })
341
342 if (!isFileTool || !isInside(absolute, root)) continue
343 const nested = await $.fs.ancestors({ names: PROJECT_NAMES, of: absolute, below: root }).catch(() => [])
344 for (const entry of nested) {
345 const kind = entry.name === 'CLAUDE.local.md' ? 'local' : 'project'
346 for (const part of entry.parts) {
347 if (known.has(keyOf(part.path))) continue
348 known.add(keyOf(part.path))
349 const mtimeMs = (await mtimeOf($, part.path)) ?? -1
350 files = [...files, { path: part.path, kind, content: part.content, mtimeMs, scope: entry.dir, lastUsedTurn: turn }]
351 added.push(part.path)
352 }
353 }
354 }
355
356 if (added.length === 0) {
357 if (hasTouched) await update($, pinAtom, () => ({ ...pin, files }))
358 return
359 }
360 const places = await placesOf($)
361 await settle($, { ...pin, files, source: pin.source ?? 'discovered' }, describe('Pinned', added, places))
362 $.ui.toast(`CLAUDE.md pinned: ${added.map(p => displayPath(p, places)).join(', ')}`)
363}
364
365/** A new user message: age the subfolder pins and unpin the ones unused for UNPIN_AFTER messages. */
366async function age($: EngineInterface): Promise<void> {
367 const turn = await update($, turnAtom, n => n + 1)
368 const pin = await read($, pinAtom)
369 const stale = pin.files.filter(f => f.scope !== undefined && messagesLeft(f, turn) === 0)
370 if (stale.length === 0) return
371
372 const places = await placesOf($)
373 const files = pin.files.filter(f => !stale.includes(f))
374 await settle($, { ...pin, files }, `${describe('Unpinned', stale.map(f => f.path), places)} (no work there for ${UNPIN_AFTER} messages)`)
375 $.ui.toast(`CLAUDE.md unpinned: ${stale.map(f => displayPath(f.path, places)).join(', ')}`)
376}
377
378/**
379 * The hidden block for the message being sent: every file whose latest copy in the conversation, as
380 * Claude reads it (counting what Claude Code is about to give it), isn't its whole text as it is on
381 * disk now. That's each pinned file, and each CLAUDE.md Claude still has a copy of though it's
382 * unpinned or deleted: one deleted or emptied is reported removed. Null when Claude has them all.
383 */
384async function missing($: EngineInterface, turnId: string | undefined): Promise<string | null> {
385 const pin = await read($, pinAtom)
386 const sameTurn = turnId !== undefined && sentMidTurn?.turnId === turnId ? sentMidTurn.blocks : []
387 const copies = copiesOf(await $.session.messages({ as: 'api' }), [...announced, ...sameTurn])
388 announced = []
389
390 const files: PinnedFile[] = []
391 const removed: string[] = []
392 // Files Claude has an older copy of: edited since.
393 const edited = new Set<string>()
394 const check = async (file: PinnedFile) => {
395 const copy = lastCopy(file.path, copies)
396 // A Read or a Write shows Claude the file as it is on disk, comments and all.
397 const raw = await $.fs.read(file.path).catch(() => undefined)
398 if (isHeld(file.content, copy, typeof raw === 'string' ? raw : file.content)) return
399 if (file.content.trim() === '') removed.push(file.path)
400 else files.push(file)
401 if (copy !== null) edited.add(file.path)
402 }
403 // Text another plugin rewrote stands for the startup files until they change on disk.
404 for (const file of pin.source === 'raw' ? pin.files.filter(f => f.scope !== undefined) : pin.files) await check(file)
405 for (const path of copiedPaths(copies)) {
406 if (pin.files.some(f => keyOf(f.path) === keyOf(path))) continue
407 const content = await textOf($, path)
408 // Any file deleted is reported removed; a subfolder's CLAUDE.md still there is kept current.
409 if (content === undefined || isSubfolderName(path)) await check(unpinnedFile(path, content ?? ''))
410 }
411 const block = blockOf(files, removed)
412 if (block === null) return null
413 if (turnId !== undefined) sentMidTurn = { turnId, blocks: [...sameTurn, block] }
414
415 const places = await placesOf($)
416 const changed = files.filter(f => edited.has(f.path)).map(f => displayPath(f.path, places))
417 const sent = [
418 ...files.filter(f => !edited.has(f.path)).map(f => displayPath(f.path, places)),
419 ...removed.map(path => `${displayPath(path, places)} (removed)`),
420 ]
421 const text = [
422 ...(changed.length > 0 ? [`Edited and sent to Claude: ${changed.join(', ')}`] : []),
423 ...(sent.length > 0 ? [`Sent to Claude: ${sent.join(', ')}`] : []),
424 ].join('; ')
425 const turn = await read($, turnAtom)
426 await update($, changeAtom, () => ({ text, turn }))
427 $.ui.toast(`CLAUDE.md ${text[0]?.toLowerCase()}${text.slice(1)}`)
428 return block
429}
430
431async function setBand($: EngineInterface, isShown: boolean) {
432 await update($, bandAtom, () => isShown)
433 await $.store.set(BAND_KEY, isShown).catch(() => undefined)
434}
435
436// The Markdown element draws at most this many characters.
437const VIEW_LIMIT = 10_000
438const RAW = 'raw'
439// A divider: longer than any band or pane is wide, set in a Box wider still so it never wraps, inside
440// a one-row Box that clips it to fit, so no surface cuts it short with an ellipsis or shows a second line.
441const RULE = '─'.repeat(500)
442const RULE_COLUMNS = 1000
443// A subfolder pin this close to unpinning is shown in the theme's warning color.
444const NEAR_UNPIN = 3
445// Columns of space between the pane's edges and its text.
446const MARGIN = 1
447
448/** How long ago a change was, in the person's messages. */
449const ago = (messages: number) =>
450 messages <= 0 ? 'this message' : messages === 1 ? '1 message ago' : `${messages} messages ago`
451
452// How far the pane's body can scroll, from its last drawing; the ui.scroll hook clamps to it.
453let paneMaxTop = 0
454// Whether the pane last drew its own window under a fixed top, so the ui.scroll hook moves it.
455let paneScrollsItself = false
456
457/** About how many rows markdown takes at a width: each line wrapped, plus a gap after headings. */
458function markdownRows(markdown: string, columns: number): number {
459 return markdown
460 .split('\n')
461 .reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / columns)) + (/^#{1,6} /.test(line) ? 1 : 0), 0)
462}
463
464/** Opens the pane on its list of files, as they are on disk now; an open pane is left as it is. */
465async function openPane($: EngineInterface) {
466 if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
467 await sync($).catch(() => undefined)
468 await update($, viewingAtom, () => null)
469 await update($, paneTopAtom, () => 0)
470 const opened = await $.ui.open({ id: PANE, title: TITLE })
471 if (opened.isPlaced) $.ui.toast('CLAUDE.md pane opened.')
472}
473
474// /claudemd: opens the pane, or closes it when it's open. The terminal has no band, so no Details
475// button; the command is the way in and out.
476async function togglePane($: EngineInterface) {
477 if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
478 else await openPane($)
479}
480
481export const register: Register = on => {
482 on('session.start', async ($, e, next) => {
483 const started = await next(e)
484 await $.command.register({
485 name: COMMAND,
486 description: 'Open or close the pane showing what CLAUDE.md is pinned; "/claudemd band" shows or hides its line',
487 }).catch(() => undefined)
488 await followBand($)
489 // The line looks at the disk as the chat opens and then once a second (see look).
490 await look($).catch(() => undefined)
491 $.clock.every(1000, () => void look($).catch(() => undefined))
492
493 return started
494 })
495
496 // Answers with toasts and the pane alone: nothing is written to the chat.
497 on('command.run', { command: COMMAND }, async ($, e) => {
498 if (e.args.trim().toLowerCase() === 'band') {
499 const isShown = !(await read($, bandAtom))
500 await setBand($, isShown)
501 // The terminal shows this line under the prompt, the desktop as the band; a command can't
502 // tell which surface ran it, so the toast names neither.
503 $.ui.toast(isShown ? 'CLAUDE.md line shown' : 'CLAUDE.md line hidden')
504 return {}
505 }
506 await togglePane($)
507
508 return {}
509 })
510
511 // Claude Code reads its CLAUDE.md files (every tier, @imports resolved) for a new chat, after a
512 // compaction or /clear, and on resume: they're what is pinned, and what Claude is about to be given
513 // with the next message.
514 on('prompt.context', async ($, e, next) => {
515 const context = await next(e)
516 const block = context.blocks.find(b => b.name === 'claudeMd')
517 const instructionFiles = context.instructionFiles ?? []
518 announced = block === undefined ? [] : instructionFiles.map(f => `Contents of ${f.path}:\n\n${f.content}`)
519 // No block (none on disk, or a subagent that omits it): leave the pin as is;
520 // sync notices deletions by itself.
521 if (block === undefined || block.text.trim() === '') return context
522
523 const old = await read($, pinAtom)
524 const engineKeys = new Set(instructionFiles.map(f => keyOf(f.path)))
525 // Subfolder pins outlive a re-read (compaction, /clear): the engine's block never holds them.
526 const nested = old.files.filter(f => f.scope !== undefined && !engineKeys.has(keyOf(f.path)))
527 const pin: Pin =
528 instructionFiles.length === 0
529 ? // Another plugin rewrote the text: pin it as is, and watch what is on disk.
530 { files: [...(await discover($)), ...nested], raw: block.text, source: 'raw' }
531 : {
532 files: [
533 ...(await Promise.all(
534 instructionFiles.map(async f => {
535 const mtimeMs = (await mtimeOf($, f.path)) ?? -1
536 // Claude Code's copy can be older than the disk (a file edited since it read it): the
537 // copy read from disk at this mtime wins, and a pinned file that has changed since
538 // is re-read at the next check.
539 const mine = old.files.find(p => keyOf(p.path) === keyOf(f.path))
540 if (mine !== undefined && mine.mtimeMs === mtimeMs) return { path: f.path, kind: f.kind, content: mine.content, mtimeMs }
541 return { path: f.path, kind: f.kind, content: f.content, mtimeMs: mine === undefined ? mtimeMs : -1 }
542 }),
543 )),
544 ...nested,
545 ],
546 raw: null,
547 source: 'engine',
548 }
549 await settle($, pin)
550
551 return context
552 })
553
554 // A new message: the one check. Follow a band choice made in another chat, age the subfolder pins,
555 // re-read the files from disk, and attach what Claude lacks to the message.
556 on('prompt.submit', async ($, e, next) => {
557 await followBand($).catch(() => undefined)
558 await age($).catch(() => undefined)
559 await sync($).catch(() => undefined)
560 const block = await missing($, e.turnId).catch(() => null)
561 return next(block === null ? e : { ...e, context: [...(e.context ?? []), block] })
562 })
563
564 // Claude opened a file: pin its subfolder's CLAUDE.md, sent with a message when Claude lacks it.
565 on('tool.call', async ($, e, next) => {
566 const ran = await next(e)
567 await touch($, pathsOf(e as unknown as Record<string, unknown>), FILE_TOOLS.has(e.tool)).catch(() => undefined)
568 return ran
569 })
570
571 // The chat doesn't draw the CLAUDE.md message versions before 0.9.0 put first in it: the line and
572 // the pane show what is pinned.
573 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
574 if (!isMessage(e.props.text.trim())) return next(e)
575 const { Box } = $.ui.resolve(e)
576 return <Box />
577 })
578
579 // The status line: in the terminal the band's line moves here, to a row of its own under the hint
580 // line below the prompt, which only the terminal draws, so there's no band above the prompt.
581 // ($.ui.status would pin it above, among the engine's notices, under a warning sign.) The band
582 // choice shows and hides it. The row has no width to measure, so the summary is cut to fit.
583 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
584 const hint = await next(e)
585 if (e.surface !== 'terminal' || !(await read($, bandAtom))) return hint
586 const { Box, Text } = $.ui.resolve(e)
587 const line = lineOf(asOnDisk(await read($, pinAtom), await read($, diskAtom)), await read($, turnAtom))
588
589 return (
590 <Box flexDirection="column">
591 {hint}
592 <Box key="status" flexDirection="row" columnGap={2}>
593 {pinnedLine({ Box, Text }, line, { folders: 2, hasSummary: true })}
594 </Box>
595 </Box>
596 )
597 })
598
599 // The band above the prompt, on the desktop: one quiet line, shown until hidden, beneath any other
600 // plugin's band. The terminal draws the same line under the prompt instead (see PromptHint).
601 // Colors are theme keys or dim alone, never raw, so it reads in light and dark themes.
602 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
603 if (e.surface === 'terminal' || e.props.hasSurvey || !(await read($, bandAtom))) return next(e)
604
605 // The slot holds one tree, so draw the other plugins' bands too rather than replacing them,
606 // then a blank row and a rule, and this one last, at the bottom, next to the prompt.
607 const others = await next(e)
608 const hasOthers = !isBlank(others)
609 const { Box, Button, Text } = $.ui.resolve(e)
610 const line = lineOf(asOnDisk(await read($, pinAtom), await read($, diskAtom)), await read($, turnAtom))
611 // The band names the first two subfolder files, or one when it's narrow, and counts the rest,
612 // which the pane lists. What fits its width: fewer folders first, then no summary. The desktop
613 // draws its buttons as keys in boxes.
614 const fit = bandLayout(e.props.bodyColumns, {
615 status: line.count === 0 ? '○ No CLAUDE.md found' : '● CLAUDE.md pinned',
616 summary: line.summary,
617 folders: line.nested.map(({ folder, left }) => `${folder} unpins in ${left}`),
618 buttons: 25,
619 })
620
621 return (
622 <Box flexDirection="column">
623 {others}
624 {hasOthers && (
625 <Box marginTop={1} height={1} overflow="hidden">
626 <Box width={RULE_COLUMNS} flexShrink={0}>
627 <Text dimColor>{RULE}</Text>
628 </Box>
629 </Box>
630 )}
631 <Box flexDirection="row" columnGap={2}>
632 {pinnedLine({ Box, Text }, line, fit)}
633 <Box flexDirection="row" columnGap={2} flexShrink={0}>
634 <Button key="details" label="Details" hotkey="o" plain dimColor onPress={() => void openPane($)} />
635 <Button key="hide" label="Hide" hotkey="x" plain dimColor onPress={() => setBand($, false)} />
636 </Box>
637 </Box>
638 </Box>
639 )
640 })
641
642 // The /claudemd pane: a fixed toolbar, then a body the plugin scrolls itself (see ui.scroll),
643 // so the toolbar stays put. Rows are cut to fit rather than wrapped; color carries state only.
644 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
645 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
646 const pin = await read($, pinAtom)
647 const turn = await read($, turnAtom)
648 const lastChange = await read($, changeAtom)
649 const isBandShown = await read($, bandAtom)
650 const viewing = await read($, viewingAtom)
651 const top = await read($, paneTopAtom)
652 const places = await placesOf($)
653 const count = countOf(pin)
654 const text = pinnedText(pin)
655 // The terminal sends the wheel and page keys here, so there the pane is clipped to its height
656 // and scrolls its own body under a fixed top. Other surfaces, the desktop app among them, scroll
657 // the whole pane themselves, top included, so there it's drawn whole and the open file's name
658 // and size go in the pane's tab title, which stays put.
659 const isFixed = e.surface === 'terminal'
660 // The width text wraps at: the body less its margins, and the scroll bar with its gap.
661 const columns = Math.max(1, e.props.bodyColumns - 2 * MARGIN - (isFixed ? 2 : 0))
662 // Opening a file, or going back to the list, reads the files from disk first: the pane shows them
663 // as they are now, even one not watched yet (a subfolder's pinned mid-chat, one just created).
664 const view = (id: string | null) => () =>
665 void (async () => {
666 await update($, viewingAtom, () => id)
667 await update($, paneTopAtom, () => 0)
668 await sync($).catch(() => undefined)
669 if (!isFixed) await $.ui.open({ id: PANE, title: await titleFor($, id) })
670 })().catch(() => undefined)
671
672 // The toolbar and its rule: drawn above the scrolled body, so they never move.
673 // In the terminal the band's line is the status line under the prompt, so the button names that.
674 const lineName = isFixed ? 'status line' : 'band'
675 const bandButton = isBandShown ? (
676 <Button key="band" label={`Hide ${lineName}`} hotkey="h" plain dimColor onPress={() => setBand($, false)} />
677 ) : (
678 <Button key="band" label={`Show ${lineName}`} hotkey="s" plain dimColor onPress={() => setBand($, true)} />
679 )
680 // The fixed top: the toolbar, then `sub` a row below it when there is one, then a rule; the
681 // body scrolls under it.
682 const frame = (left: RenderElement, body: RenderElement, bodyRows: number, sub?: RenderElement) => {
683 const shownRows = Math.max(1, e.props.scroll.bodyRows - (sub === undefined ? 2 : 4))
684 paneScrollsItself = isFixed
685 paneMaxTop = isFixed ? Math.max(0, bodyRows - shownRows) : 0
686 const offset = Math.min(top, paneMaxTop)
687 const toolbar = (
688 <Box flexDirection="row" columnGap={2} flexShrink={0}>
689 <Box flexDirection="row" columnGap={2} flexGrow={1} flexShrink={1} minWidth={0}>
690 {left}
691 </Box>
692 <Box flexShrink={0}>{bandButton}</Box>
693 </Box>
694 )
695 const divider = (
696 <Box height={1} flexShrink={0} overflow="hidden">
697 <Box width={RULE_COLUMNS} flexShrink={0}>
698 <Text dimColor>{RULE}</Text>
699 </Box>
700 </Box>
701 )
702 if (!isFixed) {
703 return (
704 <Box flexDirection="column" paddingX={MARGIN}>
705 {toolbar}
706 {sub}
707 {divider}
708 {body}
709 </Box>
710 )
711 }
712 const bar = scrollBar(shownRows, bodyRows, offset)
713 return (
714 <Box flexDirection="column" height={e.props.scroll.bodyRows} paddingX={MARGIN}>
715 {toolbar}
716 {sub}
717 {divider}
718 <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} columnGap={1}>
719 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
720 <Box flexDirection="column" flexShrink={0} position="relative" top={offset === 0 ? 0 : -offset}>
721 {body}
722 </Box>
723 </Box>
724 {bar !== null && (
725 <Box key="scrollbar" flexDirection="column" width={1} flexShrink={0}>
726 {bar.map((isThumb, i) =>
727 // The theme's own text color stands out on the pane's background, light or dark.
728 isThumb ? (
729 <Text key={`${i}`} color="text">
730 ┃
731 </Text>
732 ) : (
733 <Text key={`${i}`} color="subtle">
734 │
735 </Text>
736 ),
737 )}
738 </Box>
739 )}
740 </Box>
741 </Box>
742 )
743 }
744
745 // One file, read-only, with a way back to the list.
746 const opened = viewing === null ? undefined : pin.files.find(f => keyOf(f.path) === viewing)
747 const shown =
748 viewing === RAW && pin.source === 'raw'
749 ? { name: 'CLAUDE.md', detail: RAW_DETAIL, content: pin.raw ?? '' }
750 : opened === undefined
751 ? undefined
752 : {
753 name: displayPath(opened.path, places),
754 detail: detailOf(opened),
755 content: opened.content,
756 }
757 if (shown !== undefined) {
758 const isCut = shown.content.length > VIEW_LIMIT
759 const isEmpty = shown.content.trim() === ''
760 return frame(
761 <Box flexDirection="row" columnGap={2} flexShrink={1} minWidth={0}>
762 <Box flexShrink={0}>
763 <Button key="back" label="Back" hotkey="b" plain onPress={view(null)} />
764 </Box>
765 <Box flexShrink={1} minWidth={0}>
766 <Text bold wrap="truncate-start">
767 {shown.name}
768 </Text>
769 </Box>
770 </Box>,
771 <Box flexDirection="column">
772 {isEmpty ? (
773 <Text dimColor>This file is empty.</Text>
774 ) : (
775 <Markdown text={shown.content.slice(0, VIEW_LIMIT)} />
776 )}
777 {isCut && (
778 <Box marginTop={1}>
779 <Text dimColor>The rest is cut off here, but the whole file is pinned.</Text>
780 </Box>
781 )}
782 </Box>,
783 (isEmpty ? 1 : markdownRows(shown.content.slice(0, VIEW_LIMIT), columns)) + (isCut ? 2 : 0),
784 // Where the file comes from, in the fixed top, so the scrolled body starts with the file itself.
785 <Box flexDirection="row" columnGap={2} flexShrink={0} marginTop={1}>
786 <Box flexGrow={1} flexShrink={1} minWidth={0}>
787 <Text dimColor wrap="truncate-end">
788 {shown.detail}
789 </Text>
790 </Box>
791 <Box flexShrink={0}>
792 <Text dimColor>{`~${formatTokens(tokensOf(shown.content))} tokens, read-only`}</Text>
793 </Box>
794 </Box>,
795 )
796 }
797
798 if (count === 0) {
799 return frame(
800 <Box flexDirection="row" columnGap={1}>
801 <Text dimColor>○</Text>
802 <Text bold>Nothing pinned</Text>
803 </Box>,
804 <Box flexDirection="column">
805 <Text>No CLAUDE.md was found for this project.</Text>
806 <Text dimColor>Create CLAUDE.md in the project folder and it's pinned on your next message.</Text>
807 </Box>,
808 2,
809 )
810 }
811
812 const heading = (title: string) => (
813 <Box marginTop={1}>
814 <Text bold>{title}</Text>
815 </Box>
816 )
817 // Each file's name is a button that opens it; the first nine take a digit.
818 let rows = 0
819 const row = (key: string, name: string, tokens: string, detail: string, isNear = false) => {
820 rows += 1
821 const hotkey = rows <= 9 ? { hotkey: `${rows}` } : {}
822 return (
823 <Box key={key} flexDirection="column">
824 <Box flexDirection="row" columnGap={2}>
825 <Box flexGrow={1} flexShrink={1} minWidth={0}>
826 <Button key={`open:${key}`} label={name} plain {...hotkey} onPress={view(key)} />
827 </Box>
828 <Box flexShrink={0}>
829 <Text dimColor>{`~${tokens}`}</Text>
830 </Box>
831 </Box>
832 <Box paddingLeft={3}>
833 {isNear ? (
834 <Text color="warning" wrap="truncate-end">
835 {detail}
836 </Text>
837 ) : (
838 <Text dimColor wrap="truncate-end">
839 {detail}
840 </Text>
841 )}
842 </Box>
843 </Box>
844 )
845 }
846 const always = pin.source === 'raw' ? [] : pin.files.filter(f => f.scope === undefined)
847 const nested = pin.files.filter(f => f.scope !== undefined)
848 const isRaw = pin.source === 'raw'
849 // Rows the body takes: the hint, each section's gap and heading, two a file, the last change.
850 const bodyRows =
851 1 +
852 (isRaw || always.length > 0 ? 2 : 0) +
853 (nested.length > 0 ? 2 : 0) +
854 (lastChange !== null ? 3 : 0) +
855 2 * ((isRaw ? 1 : 0) + always.length + nested.length)
856
857 return frame(
858 <Box flexDirection="row" columnGap={2} flexShrink={1} minWidth={0}>
859 <Box flexDirection="row" columnGap={1} flexShrink={0}>
860 <Text color="success">●</Text>
861 <Text bold>{`${count} ${count === 1 ? 'file' : 'files'} pinned`}</Text>
862 </Box>
863 <Box flexShrink={1} minWidth={0}>
864 <Text dimColor wrap="truncate-end">{`~${formatTokens(tokensOf(text))} tokens`}</Text>
865 </Box>
866 </Box>,
867 <Box flexDirection="column">
868 <Text dimColor wrap="truncate-end">
869 As on disk now. Press a file to read it.
870 </Text>
871
872 {isRaw && heading('Loaded at startup')}
873 {isRaw &&
874 row(RAW, 'CLAUDE.md', formatTokens(tokensOf(pin.raw ?? '')), 'As another plugin rewrote it, until a file changes')}
875 {always.length > 0 && heading('Loaded at startup')}
876 {always.map(f =>
877 row(keyOf(f.path), displayPath(f.path, places), formatTokens(tokensOf(f.content)), TIERS[f.kind] ?? f.kind),
878 )}
879
880 {nested.length > 0 && heading('Subfolders')}
881 {nested.map(f => {
882 const left = messagesLeft(f, turn)
883 return row(
884 keyOf(f.path),
885 displayPath(f.path, places),
886 formatTokens(tokensOf(f.content)),
887 `Unpins after ${left} more ${left === 1 ? 'message' : 'messages'} without work here`,
888 left <= NEAR_UNPIN,
889 )
890 })}
891
892 {lastChange !== null && heading('Last change')}
893 {lastChange !== null && (
894 <Box flexDirection="row" columnGap={2}>
895 <Box flexGrow={1} flexShrink={1} minWidth={0}>
896 <Text wrap="truncate-end">{lastChange.text}</Text>
897 </Box>
898 <Box flexShrink={0}>
899 <Text dimColor>{ago(turn - lastChange.turn)}</Text>
900 </Box>
901 </Box>
902 )}
903 </Box>,
904 bodyRows,
905 )
906 })
907
908 // The pane closing, by /claudemd or its own close mark or key: a toast says so.
909 on('ui.close', { id: PANE }, async ($, e, next) => {
910 const closed = await next(e)
911 $.ui.toast('CLAUDE.md pane closed; /claudemd opens it again.')
912 return closed
913 })
914
915 // The wheel and scroll keys over the pane move its body; the top above it stays put.
916 // A pane drawn whole, where it doesn't scroll itself, is scrolled by the engine, as usual.
917 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
918 if (!paneScrollsItself) return next(e)
919 const top = await read($, paneTopAtom)
920 const moved = scrolled(top, e.by, paneMaxTop)
921 if (moved !== top) await update($, paneTopAtom, () => moved)
922 return {}
923 })
924}
925hooks/pin.ts 331 lines1import type { OnDisk, Pin, PinnedFile } from '../types'
2
3// Where an instruction file can appear in a directory.
4export const PROJECT_NAMES = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md']
5// A subfolder's CLAUDE.md is unpinned after this many user messages with no work in its folder.
6export const UNPIN_AFTER = 10
7// Tools whose path argument means Claude opened a file there, so its folder's CLAUDE.md applies.
8export const FILE_TOOLS: ReadonlySet<string> = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
9
10const LABELS: Record<string, string> = {
11 managed: "organization's managed policy",
12 user: "user's private global instructions for all projects",
13 project: 'project instructions, checked into the codebase',
14 local: "user's private project instructions, not checked in",
15 memory: "user's auto-memory, persists across conversations",
16}
17
18// What each tier is, as the pane words it for the person.
19export const TIERS: Record<string, string> = {
20 managed: "Your organization's policy",
21 user: 'Yours, for every project',
22 project: 'This project, shared with the team',
23 local: 'This project, only on your machine',
24 memory: 'Auto memory',
25}
26
27const OVERRIDE = 'IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.'
28
29// How versions before 0.9.0 began the CLAUDE.md message they put first in a chat at a compaction.
30// Older chats still hold one, and the chat never draws it.
31const OLD_HEADERS = [
32 "This is the CLAUDE.md file: the user's instructions, exactly as they were on disk when this conversation was last compacted.",
33 "This is the CLAUDE.md file: the user's instructions, exactly as they are on disk now. This message is kept first",
34].map(line => `# CLAUDE.md\n\n${line}`)
35
36/** Whether a message's text is the CLAUDE.md message a version before 0.9.0 put first in the chat. */
37export function isMessage(text: string): boolean {
38 const trimmed = text.trim()
39 if (!trimmed.startsWith('<system-reminder>') || !trimmed.endsWith('</system-reminder>')) return false
40 const body = trimmed.slice('<system-reminder>'.length).trim()
41 return OLD_HEADERS.some(header => body.startsWith(header))
42}
43
44export const keyOf = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
45
46export const isAbsolute = (path: string) => /^([A-Za-z]:[\\/]|[\\/])/.test(path)
47
48/** Whether `path` is `dir` or lies under it. */
49export const isInside = (path: string, dir: string) => {
50 const p = keyOf(path)
51 const d = keyOf(dir)
52 return p === d || p.startsWith(`${d}/`)
53}
54
55/** The path arguments of a tool call: a file it opens, or a folder it searches. */
56export function pathsOf(input: Record<string, unknown>): string[] {
57 return ['file_path', 'notebook_path', 'path'].flatMap(name => {
58 const value = input[name]
59 return typeof value === 'string' && value !== '' ? [value] : []
60 })
61}
62
63const SUBFOLDER = 'subfolder instructions; apply when working in '
64
65const labelOf = (file: PinnedFile) => (file.scope === undefined ? (LABELS[file.kind] ?? file.kind) : `${SUBFOLDER}${file.scope}`)
66
67const section = (file: PinnedFile) => `Contents of ${file.path} (${labelOf(file)}):\n\n${plain(file.content)}`
68
69/**
70 * The hidden block the plugin attaches to a message for Claude: each file in full, as it is on disk
71 * now and as Claude Code loads it, then a line for each one removed or emptied. Null when
72 * there is nothing to send.
73 */
74export function blockOf(files: PinnedFile[], removed: string[] = []): string | null {
75 const shown = files.filter(f => plain(f.content) !== '')
76 if (shown.length === 0 && removed.length === 0) return null
77 const intro =
78 shown.length === 0
79 ? []
80 : [
81 "These are the user's CLAUDE.md instructions, exactly as they are on disk now. Their current text isn't in this conversation, so here it is: where anything earlier in the conversation differs, this is current. " +
82 OVERRIDE,
83 ]
84 const gone = removed.map(path => `Removed: ${path}. Its instructions no longer apply.`)
85 return ['# CLAUDE.md', ...intro, ...shown.map(section), ...gone].join('\n\n')
86}
87
88/** Everything pinned, as the hidden block would carry it all: what the line and the pane size. */
89export function pinnedText(pin: Pin): string {
90 const raw = pin.source === 'raw' ? (pin.raw ?? '').trim() : ''
91 const files = pin.source === 'raw' ? pin.files.filter(f => f.scope !== undefined) : pin.files
92 return [raw, blockOf(files) ?? ''].filter(t => t !== '').join('\n\n')
93}
94
95/** Text as it's compared: line endings as LF, and no space at either end. */
96const plain = (text: string) => text.replace(/\r\n?/g, '\n').trim()
97
98/**
99 * One place the conversation shows Claude a file: a complete copy, under the heading or the tool
100 * call that names the file (`head`), or the line saying it was removed (`text` null). A heading's
101 * text runs on to the end of the text it's in; `isHeld` finds where the file ends.
102 */
103export type Copy = { head: string; text: string | null; isHeading: boolean }
104
105// Claude Code's copies and the hidden block both head a file "Contents of <path>:" or
106// "Contents of <path> (<label>):"; the block says a file was removed with a line of its own.
107const MARKS = /^(?:Contents of (.+):\n\n|Removed: (.+)\. Its instructions no longer apply\.$)/gm
108const REMINDERS = /<system-reminder>([\s\S]*?)(?:<\/system-reminder>|$)/g
109
110// Read numbers each line ("12\t"; a last empty line keeps only its number) and can have notes added after the file.
111const NUMBERED = /^ *\d+(?:\t|→|$)/
112const readText = (result: string) => {
113 const lines = (result.replace(/\r\n?/g, '\n').split('\n\n<system-reminder>')[0] ?? '').split('\n')
114 return lines.every(line => NUMBERED.test(line)) ? lines.map(line => line.replace(NUMBERED, '')).join('\n') : null
115}
116
117type Block = { type?: string; text?: unknown; id?: string; name?: string; input?: Record<string, unknown>; tool_use_id?: string; content?: unknown; is_error?: boolean }
118
119/**
120 * Every copy of a file the conversation shows Claude, in order (its Messages API form), then those
121 * in `extra`, texts Claude is about to be given: Claude Code's copies and the hidden blocks, by
122 * their headings; a Write's content; and a Read's result when it read the whole file.
123 */
124export function copiesOf(messages: readonly unknown[], extra: readonly string[] = []): Copy[] {
125 const copies: Copy[] = []
126 const marksIn = (text: string) => {
127 for (const mark of text.matchAll(MARKS)) {
128 const end = (mark.index ?? 0) + mark[0].length
129 copies.push({ head: mark[1] ?? mark[2] ?? '', text: mark[1] === undefined ? null : text.slice(end), isHeading: true })
130 }
131 }
132 // Claude Code's copies and the hidden blocks reach Claude as reminders: a file quoted anywhere
133 // else, such as a command's output, isn't one.
134 const scan = (text: string) => {
135 for (const reminder of text.replace(/\r\n?/g, '\n').matchAll(REMINDERS)) marksIn(reminder[1] ?? '')
136 }
137 const calls = new Map<string, Block>()
138 for (const message of messages) {
139 const content = (message as { content?: unknown }).content
140 if (typeof content === 'string') scan(content)
141 if (!Array.isArray(content)) continue
142 for (const block of content as Block[]) {
143 if (block.type === 'text' && typeof block.text === 'string') scan(block.text)
144 if (block.type === 'tool_use' && block.id !== undefined) calls.set(block.id, block)
145 if (block.type !== 'tool_result') continue
146 const result =
147 typeof block.content === 'string'
148 ? block.content
149 : Array.isArray(block.content)
150 ? (block.content as Block[]).flatMap(b => (typeof b.text === 'string' ? [b.text] : [])).join('\n')
151 : ''
152 const call = calls.get(block.tool_use_id ?? '')
153 const input = call?.input ?? {}
154 const path = input.file_path
155 if (!block.is_error && typeof path === 'string') {
156 if (call?.name === 'Write' && typeof input.content === 'string') copies.push({ head: path, text: input.content, isHeading: false })
157 const isWhole = input.limit === undefined && (input.offset === undefined || Number(input.offset) <= 1)
158 const text = call?.name === 'Read' && isWhole ? readText(result) : null
159 if (text !== null) copies.push({ head: path, text, isHeading: false })
160 }
161 // Claude Code adds a subfolder's CLAUDE.md to the result of the tool that opened a file there.
162 scan(result)
163 }
164 }
165 for (const text of extra) marksIn(text.replace(/\r\n?/g, '\n'))
166 return copies
167}
168
169const names = (head: string, path: string) => {
170 const h = keyOf(head)
171 const k = keyOf(path)
172 return h === k || h.startsWith(`${k} (`)
173}
174
175/** The text Claude saw last of a file: its latest copy; null when there's none, or Claude was last told it was removed. */
176export function lastCopy(path: string, copies: readonly Copy[]): string | null {
177 let last: string | null = null
178 for (const copy of copies) if (names(copy.head, path)) last = copy.text
179 return last
180}
181
182// Where a copy under a heading can end: the end of its text, Claude Code's closing tag, or the next file.
183const ENDS = /^(?:$|<\/system-reminder>|Contents of |Removed: )/
184
185/**
186 * Whether Claude's latest copy of a file (`lastCopy`) is the file's whole text as it is now: the
187 * copy must be that text and nothing more. `content` is the file as Claude Code loads it, `raw` as
188 * it is on disk. An empty file is held when Claude has no text of it.
189 */
190export function isHeld(content: string, copy: string | null, raw = content): boolean {
191 const shown = plain(content)
192 if (copy === null) return shown === ''
193 const seen = plain(copy)
194 // Claude Code's copy and the hidden block show the file as Claude Code loads it; a Write or a Read, as it is on disk.
195 const wants = shown === '' ? [''] : [shown, plain(raw)]
196 return wants.some(want => seen.startsWith(want) && ENDS.test(seen.slice(want.length).trimStart()))
197}
198
199// A heading's path, before the label some headings add.
200const HEADED = /^(.*?\.md)(?: \(.*\))?$/i
201
202/** Whether a path has a subfolder file's name: CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md. */
203export const isSubfolderName = (path: string) => /[\\/](?:\.claude[\\/])?CLAUDE(?:\.local)?\.md$/i.test(path)
204
205/**
206 * The instruction files Claude has a copy of under a heading, from Claude Code or a hidden block:
207 * each path once. A file unpinned or deleted since is still checked while its copy is here.
208 */
209export function copiedPaths(copies: readonly Copy[]): string[] {
210 const paths = new Map<string, string>()
211 for (const copy of copies) {
212 const path = copy.isHeading ? HEADED.exec(copy.head)?.[1] : undefined
213 if (path !== undefined && !paths.has(keyOf(path))) paths.set(keyOf(path), path)
214 }
215 return [...paths.values()]
216}
217
218/** A subfolder's file Claude has a copy of that isn't pinned, as the hidden block names it. */
219export function unpinnedFile(path: string, content: string): PinnedFile {
220 const scope = path.replace(/[\\/][^\\/]*$/, '').replace(/[\\/]\.claude$/i, '')
221 return { path, kind: /CLAUDE\.local\.md$/i.test(path) ? 'local' : 'project', content, mtimeMs: -1, scope }
222}
223
224/**
225 * The pin as the band and the status line show it: with what their look at the disk found, edited
226 * and created files as they are now and deleted ones left out, as the next message will pin them.
227 */
228export function asOnDisk(pin: Pin, disk: OnDisk): Pin {
229 if (disk.found.length + disk.gone.length === 0) return pin
230 const gone = new Set(disk.gone.map(keyOf))
231 const found = new Map(disk.found.map(f => [keyOf(f.path), f]))
232 const files = pin.files.flatMap(f => (gone.has(keyOf(f.path)) ? [] : [{ ...f, content: found.get(keyOf(f.path))?.content ?? f.content }]))
233 const added = disk.found.filter(f => !pin.files.some(p => keyOf(p.path) === keyOf(f.path)))
234 return { files: [...files, ...added], raw: null, source: pin.source === 'engine' ? 'engine' : 'discovered' }
235}
236
237/** How many files the pin carries, as the band and status line count them. */
238export function countOf(pin: Pin): number {
239 if (pin.source !== 'raw') return pin.files.length
240 return 1 + pin.files.filter(f => f.scope !== undefined).length
241}
242
243/** A rough token count: about four characters a token. */
244export const tokensOf = (text: string) => Math.ceil(text.length / 4)
245
246export function formatTokens(tokens: number): string {
247 if (tokens < 1000) return `${tokens}`
248 return `${(tokens / 1000).toFixed(1).replace(/\.0$/, '')}k`
249}
250
251/** User messages left before a subfolder's file is unpinned for lack of work in its folder. */
252export function messagesLeft(file: PinnedFile, turn: number): number {
253 return Math.max(0, UNPIN_AFTER - (turn - (file.lastUsedTurn ?? turn)))
254}
255
256/**
257 * Where a file is, as the pane, its tab title and the toasts show it: the whole path, with `~` for
258 * the home folder and forward slashes, so a project's CLAUDE.md says which project it's in.
259 */
260export function displayPath(path: string, places: { home?: string }): string {
261 const { home } = places
262 const shown = home !== undefined && home !== '' && isInside(path, home) ? `~${path.slice(home.replace(/[\\/]+$/, '').length)}` : path
263 return shown.replace(/\\/g, '/')
264}
265
266/** A folder's own name, as the band shows it: `api/` for `C:\work\app\api`. */
267export const folderName = (dir: string) => `${dir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? dir}/`
268
269/** The pane's tab title while a file is open: where it is, where it comes from, and its size. */
270export function tabTitle(shown: string, detail: string, tokens: number): string {
271 return `${shown}: ${detail} (~${formatTokens(tokens)} tokens, read-only)`
272}
273
274/** Where a scroll by `by` rows lands, kept between the top and the last row that can scroll into view. */
275export const scrolled = (top: number, by: number, max: number) => Math.max(0, Math.min(Math.max(0, max), top + by))
276
277/**
278 * A scroll bar `shown` rows tall for content `total` rows tall scrolled `top` rows: true where the
279 * thumb is. Null when everything fits, so there is nothing to scroll.
280 */
281export function scrollBar(shown: number, total: number, top: number): boolean[] | null {
282 if (shown < 1 || total <= shown) return null
283 const thumb = Math.max(1, Math.round((shown * shown) / total))
284 const start = Math.round(((shown - thumb) * Math.min(top, total - shown)) / (total - shown))
285 return Array.from({ length: shown }, (_, row) => row >= start && row < start + thumb)
286}
287
288/** Cells a row of texts takes, two apart. */
289const rowWidth = (texts: string[]) => texts.reduce((n, t) => n + t.length, 0) + 2 * Math.max(0, texts.length - 1)
290
291/**
292 * What the band fits in `columns`: how many subfolder counts it names (two, then one with the rest
293 * counted), and whether it keeps the file and token summary, dropped before the last folder.
294 */
295export function bandLayout(
296 columns: number,
297 parts: { status: string; summary: string; folders: string[]; buttons: number },
298): { folders: number; hasSummary: boolean } {
299 const n = parts.folders.length
300 const tries =
301 n === 0
302 ? [{ folders: 0, hasSummary: true }]
303 : [
304 { folders: Math.min(2, n), hasSummary: true },
305 { folders: 1, hasSummary: true },
306 { folders: 1, hasSummary: false },
307 ]
308 const fits = tries.find(({ folders, hasSummary }) => {
309 const more = n - folders
310 const texts = [
311 parts.status,
312 ...(hasSummary ? [parts.summary] : []),
313 ...parts.folders.slice(0, folders),
314 ...(more > 0 ? [`+${more} more`] : []),
315 'x'.repeat(parts.buttons),
316 ]
317 return rowWidth(texts) <= columns
318 })
319 return fits ?? { folders: 0, hasSummary: false }
320}
321
322// True for a tree that draws nothing. Beneath every band, the engine answers with a placeholder,
323// { type: 'engine' }, that draws nothing in this slot; empty Boxes and nulls draw nothing either.
324export function isBlank(node: unknown): boolean {
325 if (node === null || node === undefined || typeof node === 'boolean' || node === '') return true
326 if (Array.isArray(node)) return node.every(isBlank)
327 if (typeof node !== 'object') return false
328 const { type, children = [] } = node as { type?: string; children?: unknown[] }
329 return type === 'engine' || (type === 'Box' && children.every(isBlank))
330}
331types/index.d.ts 57 lines1/** One instruction file whose text is pinned, as last read. */
2export type PinnedFile = {
3 /** Absolute path. */
4 path: string
5 /** `managed`, `user`, `project`, `local` or `memory`. */
6 kind: string
7 /** The file's text as last read, as Claude Code's loader gives it to Claude (no HTML comments or frontmatter). */
8 content: string
9 /** Last modification seen, ms since the epoch; -1 when not yet stat'd. */
10 mtimeMs: number
11 /**
12 * For a subfolder's CLAUDE.md, pinned once Claude worked in it: the folder
13 * it applies to. Absent for the files loaded at startup.
14 */
15 scope?: string
16 /** For a subfolder's file: the user message count when Claude last worked in its folder. */
17 lastUsedTurn?: number
18}
19
20/**
21 * What is pinned: the files behind it, or `raw` text when another plugin
22 * rewrote the claudeMd block (then `files` are the ones on disk, watched for
23 * changes, and the first change replaces the raw text with them).
24 */
25export type Pin = {
26 files: PinnedFile[]
27 raw: string | null
28 /** Where the current pin came from. */
29 source: 'engine' | 'discovered' | 'raw' | null
30}
31
32/**
33 * What the line's once-a-second look at the disk found since the files were pinned: files edited or
34 * created, as they are now, and the paths of ones deleted. Only the band and the status line read it.
35 */
36export type OnDisk = { found: PinnedFile[]; gone: string[] }
37
38/** The latest change to what is pinned, as the pane words it. */
39export type PinChange = { text: string; turn: number }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'always-read-claudemd': {
44 pin: Pin
45 /** User messages sent this session; subfolder pins age by it. */
46 turn: number
47 lastChange: PinChange | null
48 onDisk: OnDisk
49 isBandShown: boolean
50 /** The file the pane shows read-only (its path, or `raw` for rewritten text); null for the list. */
51 viewing: string | null
52 /** How many rows the pane's body is scrolled, under its fixed toolbar. */
53 paneTop: number
54 }
55 }
56}
57