SLOPSHOPPER

always-read-claudemd

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…

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · always-read-claudemd
│ ┃ CLAUDE.md ✕ › fix the failing auth test and add an aud╭────────────────────────╮ │ ┃ ○ Nothing pinned h: Hide status line │ always-read-claudemd │ │ ┃ ─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ CLAUDE.md pane opened. │ │ ┃ No CLAUDE.md was found for this project. ⎿ Read 6 lines ╰────────────────────────╯ │ ┃ Create CLAUDE.md in the project folder and ⏺ Update(src/auth.ts) │ ┃ it's pinned on your next message. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /claudemd │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ○ No CLAUDE.md found

Draws

Pane · CLAUDE.md
○ Nothing pinned h: Hide status line ─────────────────────────────────────────────────────────── No CLAUDE.md was found for this project. Create CLAUDE.md in the project folder and it's pinned on your next message.
Prompt hint
⟨Claude Code's own drawing⟩ ○ No CLAUDE.md found
README

Always read claudemd

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.

Why

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:

  • Edits made outside Claude Code aren't sent. If you change a CLAUDE.md in your editor, in another chat or with git mid-chat, Claude keeps working from the old text.
  • A compaction drops subfolder rules. A 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.
  • You can't see which rules Claude is working under, or how many tokens they cost.

What it does

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.

  • If Claude's latest copy is the file, exactly as it is now, nothing is sent.
  • If it isn't, the file is sent in full with your message, with a toast: CLAUDE.md edited and sent to Claude: ~/app/CLAUDE.md. If Claude has no copy at all (a new file, or one a compaction dropped), the toast reads CLAUDE.md sent to Claude: ~/app/CLAUDE.md.

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:

  • Claude Code's own copy, and the plugin's earlier hidden blocks, each under a heading naming the file.
  • Claude's own Write of the file, which holds its whole text.
  • A Read of the whole file. The line numbers Read adds are ignored. A Read of part of the file doesn't count.
  • An Edit doesn't count. It shows Claude only a snippet, so after an Edit the whole file goes with your next message.
  • Nothing else counts: not your messages, Claude's replies or a command's output, even when they quote the file.

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.

WhenWhat happens
A chat starts, is resumed, or after /clearNothing 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 CodeWith 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.mdA Write needs nothing more. After an Edit, the whole file is sent with your next message.
You send messages while Claude is workingEach 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 appearsIt's pinned and sent with your next message.
A file is deleted, emptied, or left with nothing but commentsThis 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.mdThat 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 subfolderIt'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 anywhereNothing is sent. Its line reads No CLAUDE.md found.
A subagent runsIt reads CLAUDE.md the way Claude Code gives it to subagents, unchanged.

The hidden block

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.

Where it looks for files

  • At startup: whatever Claude Code itself loads. That's ~/.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.
  • At each of your messages: every pinned file is read again, and the user-level and parent-folder locations are checked for new files. This is the only check that decides what Claude is sent, and nothing is sent while you're idle.
  • For the line, as a chat opens and then once a second: the files are checked on disk, so the line shows them as they are now. A 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.
  • When you open the pane, press a file in it, or go back to the list: the files are read from disk right then, so the pane shows them as they are now.
  • Symlinks: a 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.
  • Subfolders: a subfolder's 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.

Subfolder files

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.

The line and the pane

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:

  • Details (o) opens the pane.
  • Hide (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.

  • Loaded at startup lists the files Claude Code loaded when the chat began, each with where it comes from: yours for every project, this project's shared file, your private one for this project, your organization's policy or auto memory.
  • Subfolders lists the subfolder files, each with how many more messages before it's unpinned.
  • Last change is the last thing that happened, and when: a file edited, added, removed, pinned or unpinned, or sent to Claude. An edit sent with your message reads Edited and sent to Claude.

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:

  • it sends Claude a file;
  • it pins or unpins a subfolder file;
  • you open or close the pane: CLAUDE.md pane opened., CLAUDE.md pane closed; /claudemd opens it again.;
  • you hide or show the line.

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.

Installing

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.

Updating

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.

Developing

The plugin lives in plugins/always-read-claudemd/; the repository root holds the marketplace (.claude-plugin/marketplace.json).

PathContents
hooks/register.tsxHooks: 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.tsPure helpers: the hidden block, finding each file's latest copy in the conversation, paths, token estimates
tests/register.test.tsTests
types/index.d.tsTypes for the values the plugin keeps between reloads
.claude-plugin/plugin.jsonThe 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:

  • Update this README whenever a change adds a feature or changes what the plugin pins, what it sends Claude, what the band and pane show, or how it behaves.
  • To release, bump version in plugins/always-read-claudemd/.claude-plugin/plugin.json.

License

MIT

Source 3 files
hooks/register.tsx 925 lines
1import { 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}
925
hooks/pin.ts 331 lines
1import 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}
331
types/index.d.ts 57 lines
1/** 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