SLOPSHOPPER

Glovebox

Keeps the notes that matter in the glovebox, and hands them back to Claude after it compacts its memory. Text notes only: never video, never the screen, never…

bandcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rostech-glovebox
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /glovebox ⎿ rostech-glovebox: The glovebox is empty. /glovebox add <note> keeps one; /glovebox add-file <path> keeps a file. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Glovebox

Keeps the notes that matter in the glovebox, and hands them back to Claude after it compacts its memory.

Text notes only. Never video, never the screen, never your location. Glovebox reads files on your own machine, writes three small files in your project, and sends nothing to any network.

What it is for

A long session fills Claude's memory, and Claude Code compacts it: the conversation is replaced by a summary. A decision you made an hour ago, a rule you found the hard way, or the task you are halfway through can be lost in that summary.

Put those things in the glovebox. After every compaction, Glovebox hands them straight back to Claude, word for word, with the list of files that had changed.

Your CLAUDE.md already loads every session. The glovebox is for what this session has learned since then.

Use it

/glovebox add the build command is make quux keep a note /glovebox add-file docs/RULES.md keep a file: a pointer, read fresh each time /glovebox show what is in it, level by level /glovebox drop 2 take entry 2 out /glovebox clear empty it /glovebox receipts the last 10 receipts (see below)

Add --every-turn to make an entry reach Claude on every turn, not just after a compaction.

A file is kept as a pointer, never a copy, so Glovebox always reads what is on disk now. If the file is missing, Claude is told so in one line.

The files it writes

Glovebox writes exactly three files, all inside the project folder Claude Code runs in:

  • .claude/GLOVEBOX.md: an instructions file. It is the list you build with /glovebox, and its entries are given to Claude as described below. It is plain text: you can read it and edit it by hand, and lines that are not entries are left alone.
  • .claude/glovebox/last-compact.md: the list of changed files at the last compaction.
  • .claude/glovebox/receipts.log: the receipts (see below).

It changes no build, start-up or settings file, and nothing outside the project.

The programs it runs

One: git status --short, in the project folder, once just before each compaction. It lists the files that had changed, so Claude gets that list back afterwards. It waits at most 5 seconds; if git is missing or the folder is not a repository, the list is simply left out.

What it sends, and where

Only text, and only to Claude, inside your own conversation. Nothing goes to any network, server or other service.

  • Every turn: the entries marked --every-turn are added to Claude's system prompt, the standing instructions of your session, as one section.
  • After a compaction: the other entries, and the changed-file list, are added as one message at the end of the compacted conversation.

It reads no password, key, token or other credential, and no environment variable.

What it reads

The GLOVEBOX.md files of the levels below, the files you point it at with add-file, and the output of git status --short.

Levels: as high up the tree as it goes

Like CLAUDE.md, Glovebox reads more than one glovebox. Outer levels reach Claude first, and each entry says where it came from:

  1. managed: GLOVEBOX.md in Claude Code's managed-policy folder, C:\Program Files\ClaudeCode\, /Library/Application Support/ClaudeCode/ or /etc/claude-code/.
  2. parent: .claude/GLOVEBOX.md in each folder above the project, outermost first.
  3. project: .claude/GLOVEBOX.md in the folder Claude Code started in.

/glovebox changes only the project's own glovebox. The others are read only: edit them by hand.

The band and the budget

A band above the prompt shows how full the glovebox is: 🧤 Glovebox 12/200 lines. It turns red over the budget. Entries marked every turn are shown separately with their size in tokens, because they are paid on every message.

The budget is 200 lines across every level. A note counts as one line, and a file counts its own lines at that moment. A 196-line rules file fits on its own. Glovebox warns when you go over and never blocks: a long glovebox works, but Claude may skim it.

Every turn

--every-turn puts an entry into Claude's system prompt on every turn. It is read fresh from disk each time. While the text does not change, the prompt cache keeps it cheap.

🔴 This guarantees the rules are put in front of Claude every turn. It does not guarantee Claude obeys them. For a rule that must never be broken, pair it with a hook that blocks the action, like the Hands-Off mod in this repo, or a PreToolUse hook.

Receipts

Each time an entry reaches Claude, Glovebox adds one line to .claude/glovebox/receipts.log. That line is the evidence that the rule was in front of Claude at that moment. Each turn is logged once per entry, however many requests the turn makes, and again if the text changes within the turn.

/glovebox receipts shows the newest ten and checks the chain.

A receipt that cannot be written never stops the rules from reaching Claude.

It only grows. The log is one file, never trimmed and never rotated. Each receipt is about 245 bytes, so one every-turn entry over 1,000 turns adds about 245 KB. Delete or move the file yourself when you no longer need it; Glovebox starts a new chain.

Receipt format v1

One JSON object per line (JSON Lines), with exactly these keys, in this order:

{"v":1,"t":"2026-10-04T15:29:51.478Z","trigger":"turn","level":"project","entry":"note:1","sha256":"…","prev":"…"}

  • v: the format version, 1.
  • t: when, as an ISO 8601 time.
  • trigger: turn (in the system prompt for a turn) or compact (handed back after a compaction).
  • level: managed, ancestor or project (user is reserved).
  • entry: a file's path as written in the glovebox, or note:<n> for the n-th note of its level.
  • sha256: the hex SHA-256 of the exact text given to Claude for that entry.
  • prev: the hex SHA-256 of the previous line as written, or 64 zeros for the first line.

The prev chain makes the log tamper-evident: changing or deleting any line breaks the chain from there on.

For an organisation: compliance

What is known, from Claude Code's own docs (read 4 Oct 2026):

  • KNOWN. The managed-policy folders above are where Claude Code reads an organisation's managed-settings.json and its managed CLAUDE.md, and a managed CLAUDE.md cannot be excluded by users. (managed settings, memory)
  • Glovebox's own choice. It reads a GLOVEBOX.md in that same folder as its top level. Claude Code does not read this file itself. It works only while Glovebox is installed and enabled.
  • KNOWN. Managed settings outrank every other settings level. enabledPlugins turns a plugin on per scope, and strictKnownMarketplaces (managed only) limits which marketplaces users can install from. (settings, settings reference)
  • UNKNOWN. Whether a user can turn off a plugin that managed settings turned on. The docs do not say.
  • UNKNOWN. Whether allowManagedHooksOnly (managed only, "Run only the hooks your organization deploys") also stops plugin hooks like Glovebox's. If it does, Glovebox will not run under it.
  • KNOWN. For instructions that must load whatever the user does, Claude Code's managed CLAUDE.md is the documented route. Glovebox adds what that file does not do: fresh reads of pointed-to files, the hand-back after compaction, and receipts.

Changes

0.1.1

  • The plugin's id is now rostech-glovebox, because the name glovebox already belongs to someone else in the directory. The title and the /glovebox command are unchanged.
  • The user-level glovebox (~/.claude/GLOVEBOX.md) and --user are gone: finding them meant reading the home folder from the environment. Parent folders and the managed level are still read.
  • The receipt log is one file that only grows, with no rotation.
  • A glovebox that cannot be saved (Claude Code started in a folder it may not write to) now says where and why, instead of the command failing with an engine error.

Next, not in 0.1.1

  • A setting for the budget.
  • A user-level glovebox for every project, found without reading the environment.

Tested with Claude Code 2.1.289.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.tsx 508 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Glovebox: short text notes, and pointers to files, that Claude gets back after it
4// compacts its memory, or on every turn. Text only. It reads files on this machine,
5// writes three files of its own in the project, and sends nothing to any network.
6
7// 🏷️ THE NAME. The plugin's id is `rostech-glovebox` (plugin.json, the types key, the
8// state refs and the section id below); the title and the command are Glovebox.
9// Every write path and every state ref is written out as fixed text where it is used,
10// so the directory can read it: a rename touches those lines too.
11export const TITLE = 'Glovebox'
12export const COMMAND = 'glovebox'
13export const FILENAME = 'GLOVEBOX.md'
14const ICON = '🧤'
15
16// The project's own glovebox, its snapshot and its receipts. Writes spell these out.
17export const FILE = '.claude/GLOVEBOX.md'
18export const SNAPSHOT = '.claude/glovebox/last-compact.md'
19export const RECEIPTS = '.claude/glovebox/receipts.log'
20
21// The budget, in lines, across every level. A file counts by its size at the moment.
22// 200 lines fits a rules file the size of a long style guide (196 lines, 8.5 KB) on its own.
23export const BUDGET = 200
24// How many changed files the snapshot keeps.
25export const SNAPSHOT_CAP = 30
26
27// The managed-policy folders Claude Code documents for its own CLAUDE.md and
28// managed-settings.json. A GLOVEBOX.md placed there by an organisation is read first.
29export const MANAGED_DIRS = ['C:/Program Files/ClaudeCode', '/Library/Application Support/ClaudeCode', '/etc/claude-code']
30
31// ─── Levels: managed, then each folder above the project, then the project ───
32
33export type LevelKind = 'managed' | 'parent' | 'project'
34export type Level = { kind: LevelKind; label: string; dir: string; file: string }
35
36export function norm(p: string): string {
37  const s = p.replace(/\\/g, '/')
38  return s.length > 1 && s.endsWith('/') && !/^[A-Za-z]:\/$/.test(s) ? s.slice(0, -1) : s
39}
40
41export function join(a: string, b: string): string {
42  return a.endsWith('/') ? a + b : `${a}/${b}`
43}
44
45export function isAbsolute(p: string): boolean {
46  return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/') || p.startsWith('\\')
47}
48
49// Every folder from the filesystem's root down to `dir`, outermost first.
50export function walkDown(dir: string): string[] {
51  const d = norm(dir)
52  const drive = /^([A-Za-z]:)(\/|$)/.exec(d)
53  const root = drive ? `${drive[1]}/` : '/'
54  const rest = d.slice(drive ? drive[1].length : 0).split('/').filter(Boolean)
55  const out = [root]
56  let at = root
57  for (const part of rest) { at = join(at, part); out.push(at) }
58  return out
59}
60
61// The levels in the order they reach Claude: outer first, the project last.
62export function levels(cwd: string, managedDirs: readonly string[]): Level[] {
63  const out: Level[] = []
64  const seen = new Set<string>()
65  const add = (kind: LevelKind, label: string, dir: string, file: string) => {
66    const key = norm(file).toLowerCase()
67    if (seen.has(key)) return
68    seen.add(key)
69    out.push({ kind, label, dir: norm(dir), file: norm(file) })
70  }
71  for (const m of managedDirs) add('managed', 'managed', m, join(m, FILENAME))
72  const chain = walkDown(cwd)
73  for (const dir of chain.slice(0, -1)) add('parent', `parent: ${dir}`, dir, join(dir, FILE))
74  add('project', 'project', cwd, join(norm(cwd), FILE))
75  return out
76}
77
78// ─── Entries: one line each in a GLOVEBOX.md ───
79
80export type Entry = { kind: 'note' | 'file'; every: boolean; text: string }
81
82export function parseLine(line: string): Entry | null {
83  if (!line.startsWith('- ')) return null
84  let rest = line.slice(2).trim()
85  let every = false
86  let isFile = false
87  for (;;) {
88    const tag = /^\[(every-turn|file)\]\s*/.exec(rest)
89    if (!tag) break
90    if (tag[1] === 'every-turn') every = true
91    else isFile = true
92    rest = rest.slice(tag[0].length)
93  }
94  return rest ? { kind: isFile ? 'file' : 'note', every, text: rest } : null
95}
96
97export function parse(text: string): Entry[] {
98  return text.split(/\r?\n/).map(parseLine).filter((e): e is Entry => e !== null)
99}
100
101export function entryLine(e: Entry): string {
102  return `- ${e.every ? '[every-turn] ' : ''}${e.kind === 'file' ? '[file] ' : ''}${e.text}`
103}
104
105const HEADER = `# ${TITLE}\n\nNotes and files Claude gets back after it compacts its memory. Lines marked [every-turn] reach it on every turn.\nEdit with /${COMMAND}. Lines that are not entries are kept as they are.\n`
106
107// Rewrites only the entry lines; every other line in the file stays as the person wrote it.
108export function rewrite(existing: string, entries: readonly Entry[]): string {
109  const kept = existing ? existing.split(/\r?\n/).filter(l => parseLine(l) === null) : HEADER.split('\n')
110  while (kept.length && kept[kept.length - 1].trim() === '') kept.pop()
111  return [...kept, '', ...entries.map(entryLine)].join('\n') + '\n'
112}
113
114// ─── Reading what is in the glovebox, fresh from disk ───
115
116export type Loaded = Entry & { level: Level; path?: string; content?: string | null; lines: number }
117
118export function countLines(text: string): number {
119  const t = text.replace(/\r?\n$/, '')
120  return t ? t.split(/\r?\n/).length : 0
121}
122
123async function readText($: EngineInterface, path: string): Promise<string | null> {
124  try {
125    if (!(await $.fs.exists(path))) return null
126    const text = await $.fs.read(path)
127    return typeof text === 'string' ? text : null
128  } catch {
129    return null
130  }
131}
132
133async function gather($: EngineInterface): Promise<Loaded[]> {
134  const out: Loaded[] = []
135  const cwd = await $.session.cwd()
136  for (const level of levels(cwd, MANAGED_DIRS)) {
137    const text = await readText($, level.file)
138    if (!text) continue
139    for (const e of parse(text)) {
140      if (e.kind === 'note') { out.push({ ...e, level, lines: 1 }); continue }
141      const path = isAbsolute(e.text) ? norm(e.text) : join(level.dir, e.text)
142      const content = await readText($, path)
143      out.push({ ...e, level, path, content, lines: content === null ? 0 : countLines(content) })
144    }
145  }
146  return out
147}
148
149// ─── What Claude reads ───
150
151// One entry as Claude reads it. The receipt hashes exactly this text.
152export function piece(e: Loaded): string {
153  const where = `[${e.level.label}]`
154  if (e.kind === 'note') return `${where} ${e.text}`
155  if (e.content === null || e.content === undefined) return `${where} File ${e.text}: missing, it could not be read just now.`
156  return `${where} File ${e.text}:\n${e.content.replace(/\s+$/, '')}`
157}
158
159export function standing(pieces: readonly string[]): string {
160  return (
161    `Standing instructions from ${TITLE}. The person keeps these in front of you on every turn, outer levels first. ` +
162    `They stay in force until the person changes them.\n\n` +
163    pieces.join('\n\n')
164  )
165}
166
167export function handBack(pieces: readonly string[], changed: string): string {
168  const parts: string[] = []
169  if (pieces.length) {
170    parts.push(
171      `${TITLE}: anchored state from before compaction. The person kept these with /${COMMAND}, outer levels first. ` +
172        `Treat them as still true unless they are replaced:\n\n` +
173        pieces.join('\n\n'),
174    )
175  }
176  if (changed.trim()) parts.push(`Files changed when the conversation was compacted (git status --short):\n${changed.trim()}`)
177  return parts.join('\n\n')
178}
179
180// ─── Receipts: proof of what was put in front of Claude, and when ───
181
182// Receipt format v1, one JSON object per line (JSON Lines):
183// {"v":1,"t":ISO time,"trigger":"turn"|"compact","level":"managed"|"user"|"ancestor"|"project",
184//  "entry":"<path as written>" or "note:<n>","sha256":hex of the exact text injected for that entry,
185//  "prev":hex sha256 of the previous receipt line as written, or 64 zeros for the very first}
186// The prev chain makes the log tamper-evident. The free Glovebox keeps one file and only
187// ever appends to it: never trimmed, never rotated.
188
189export const ZERO = '0'.repeat(64)
190
191export async function sha(text: string): Promise<string> {
192  const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
193  return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
194}
195
196export type Trigger = 'turn' | 'compact'
197export type ReceiptLevel = 'managed' | 'user' | 'ancestor' | 'project'
198export type Receipt = { v: 1; t: string; trigger: Trigger; level: ReceiptLevel; entry: string; sha256: string; prev: string }
199export type Stamp = { level: ReceiptLevel; entry: string; sha256: string }
200
201export function receiptLevel(kind: LevelKind): ReceiptLevel {
202  return kind === 'parent' ? 'ancestor' : kind
203}
204
205// The chained lines for a batch, continuing from the line before them (null: the very first).
206export async function chain(prevLine: string | null, t: string, trigger: Trigger, stamps: readonly Stamp[]): Promise<string[]> {
207  const out: string[] = []
208  let before = prevLine
209  for (const s of stamps) {
210    const r: Receipt = { v: 1, t, trigger, level: s.level, entry: s.entry, sha256: s.sha256, prev: before === null ? ZERO : await sha(before) }
211    const line = JSON.stringify(r)
212    out.push(line)
213    before = line
214  }
215  return out
216}
217
218// Checks a log's chain. `prevLine` is the line before the first, or null.
219export async function verifyChain(text: string, prevLine: string | null): Promise<{ ok: boolean; lines: number; brokenAt?: number }> {
220  const lines = text.split(/\r?\n/).filter(Boolean)
221  let before = prevLine
222  for (let i = 0; i < lines.length; i++) {
223    let prev = ''
224    try { prev = String((JSON.parse(lines[i]) as Receipt).prev) } catch { return { ok: false, lines: lines.length, brokenAt: i + 1 } }
225    if (prev !== (before === null ? ZERO : await sha(before))) return { ok: false, lines: lines.length, brokenAt: i + 1 }
226    before = lines[i]
227  }
228  return { ok: true, lines: lines.length }
229}
230
231export function lastLine(text: string | null): string | null {
232  const lines = (text ?? '').split(/\r?\n/).filter(Boolean)
233  return lines.length ? lines[lines.length - 1] : null
234}
235
236// Appends the batch's receipts. The whole file is read and written back with the new
237// lines on the end; nothing before them is changed or dropped.
238async function writeReceipts($: EngineInterface, trigger: Trigger, stamps: readonly Stamp[]): Promise<void> {
239  if (!stamps.length) return
240  const t = new Date(await $.clock.now().catch(() => Date.now())).toISOString()
241  const current = (await readText($, RECEIPTS)) ?? ''
242  const lines = current.split(/\r?\n/).filter(Boolean)
243  const added = await chain(lastLine(current), t, trigger, stamps)
244  await $.fs.write('.claude/glovebox/receipts.log', [...lines, ...added].join('\n') + '\n')
245}
246
247// One stamp per entry: files by their path as written, notes by their number in their level.
248async function stampsOf(entries: readonly Loaded[], pieces: readonly string[]): Promise<Stamp[]> {
249  const out: Stamp[] = []
250  const notes = new Map<string, number>()
251  for (let i = 0; i < entries.length; i++) {
252    const e = entries[i]
253    let entry = e.text
254    if (e.kind === 'note') {
255      const n = (notes.get(e.level.file) ?? 0) + 1
256      notes.set(e.level.file, n)
257      entry = `note:${n}`
258    }
259    out.push({ level: receiptLevel(e.level.kind), entry, sha256: await sha(pieces[i]) })
260  }
261  return out
262}
263
264// ─── The band's numbers ───
265
266export function tokensOf(text: string): number {
267  return Math.ceil(text.length / 4)
268}
269
270async function refresh($: EngineInterface, all: readonly Loaded[]): Promise<void> {
271  const every = all.filter(e => e.every)
272  await $.state.set({ plugin: 'rostech-glovebox', key: 'lines' }, all.reduce((a, e) => a + e.lines, 0))
273  await $.state.set({ plugin: 'rostech-glovebox', key: 'everyLines' }, every.reduce((a, e) => a + e.lines, 0))
274  await $.state.set({ plugin: 'rostech-glovebox', key: 'everyTokens' }, every.length ? tokensOf(standing(every.map(piece))) : 0)
275}
276
277function overNote(total: number): string {
278  return total > BUDGET ? ` ⚠️ The glovebox is ${total} lines, over its ${BUDGET}-line budget: Claude may skim it.` : ''
279}
280
281// ─── The command ───
282
283type Flags = { every: boolean; words: string[] }
284
285export function flags(args: string): Flags {
286  const words: string[] = []
287  let every = false
288  for (const w of args.trim().split(/\s+/).filter(Boolean)) {
289    if (w === '--every-turn') every = true
290    else words.push(w)
291  }
292  return { every, words }
293}
294
295function describe(e: Loaded, n: number): string {
296  const turn = e.every ? '🔁 ' : ''
297  if (e.kind === 'note') return `${n}. ${turn}${e.text}`
298  const size = e.content === null ? 'missing' : `${e.lines} lines`
299  return `${n}. ${turn}📄 ${e.text} · ${size}`
300}
301
302function show(all: readonly Loaded[]): string {
303  if (!all.length) return `The ${TITLE.toLowerCase()} is empty. /${COMMAND} add <note> keeps one; /${COMMAND} add-file <path> keeps a file.`
304  const total = all.reduce((a, e) => a + e.lines, 0)
305  const every = all.filter(e => e.every)
306  const out = [`${ICON} ${TITLE} · ${total} of ${BUDGET} lines${every.length ? ` · 🔁 every turn: ${every.reduce((a, e) => a + e.lines, 0)} lines` : ''}`]
307  let label = ''
308  let n = 0
309  for (const e of all) {
310    if (e.level.label !== label) {
311      label = e.level.label
312      n = 0
313      out.push('', `${label}${e.level.kind === 'project' ? '' : ' (read only)'}:`)
314    }
315    out.push(describe(e, ++n))
316  }
317  return out.join('\n') + overNote(total)
318}
319
320async function receiptsText($: EngineInterface, rest: string): Promise<string> {
321  const n = Math.max(1, Math.min(200, Number(rest) || 10))
322  const text = (await readText($, RECEIPTS)) ?? ''
323  const log = text.split(/\r?\n/).filter(Boolean)
324  if (!log.length) return 'No receipts yet. One is written each time the glovebox reaches Claude.'
325  const check = await verifyChain(text, null)
326  const rows = log.slice(-n).map(l => {
327    try {
328      const r = JSON.parse(l) as Receipt
329      return `${r.t}  ${r.trigger.padEnd(7)}  ${r.level.padEnd(8)}  ${r.entry}  ${r.sha256.slice(0, 12)}`
330    } catch {
331      return `(unreadable) ${l.slice(0, 60)}`
332    }
333  })
334  const verdict = check.ok ? `chain intact, ${check.lines} lines` : `⚠️ chain broken at line ${check.brokenAt}: a line was changed or removed`
335  return `${ICON} The last ${rows.length} receipts (${RECEIPTS}, ${verdict}):\n` + rows.join('\n')
336}
337
338// The project glovebox, the one /glovebox changes. A failed write is raised as a
339// SaveError so the command can say where and why, instead of the hook throwing.
340class SaveError extends Error {}
341async function writeGlovebox($: EngineInterface, text: string): Promise<void> {
342  try {
343    await $.fs.write('.claude/GLOVEBOX.md', text)
344  } catch (err) {
345    const why = err instanceof Error ? err.message : String(err)
346    const code = /\b(E[A-Z]{2,})\b/.exec(why)?.[1]
347    throw new SaveError(`${join(norm(await $.session.cwd()), FILE)}${code ? ` (${code})` : ''}`)
348  }
349}
350
351async function answer($: EngineInterface, args: string): Promise<string> {
352  const { every, words } = flags(args)
353  const verb = (words[0] ?? '').toLowerCase()
354  const rest = words.slice(1).join(' ').trim()
355
356  if (verb === '' || verb === 'show') {
357    const all = await gather($)
358    await refresh($, all)
359    return show(all)
360  }
361  if (verb === 'receipts') return receiptsText($, rest)
362
363  // Read where gather() reads the project level; written back as fixed text, relative
364  // to the working directory, which is the session's.
365  const existing = (await readText($, join(norm(await $.session.cwd()), FILE))) ?? ''
366  const entries = parse(existing)
367
368  if (verb === 'add' || verb === 'add-file') {
369    if (!rest) return verb === 'add' ? `Nothing to add. /${COMMAND} add <note>` : `No file named. /${COMMAND} add-file <path>`
370    const entry: Entry = { kind: verb === 'add-file' ? 'file' : 'note', every, text: rest.replace(/\s+/g, ' ') }
371    await writeGlovebox($, rewrite(existing, [...entries, entry]))
372    const all = await gather($)
373    await refresh($, all)
374    const total = all.reduce((a, x) => a + x.lines, 0)
375    const turn = every ? ' 🔁 Every turn.' : ''
376    if (entry.kind === 'note') return `${ICON} Kept in the glovebox: ${entry.text}.${turn}${overNote(total)}`
377    const added = all.find(x => x.kind === 'file' && x.text === entry.text && x.level.kind === 'project')
378    const size = !added || added.content === null ? ' ⚠️ It is missing right now; it will be read again each time.' : ` ${added.lines} lines.`
379    const alone = added && added.lines > BUDGET ? ` ⚠️ This file alone is over the ${BUDGET}-line budget.` : ''
380    return `${ICON} 📄 ${entry.text} is in the glovebox, read fresh each time.${size}${turn}${alone}${overNote(total)}`
381  }
382
383  if (verb === 'drop') {
384    const n = Number(rest)
385    if (!Number.isInteger(n) || n < 1 || n > entries.length) {
386      return entries.length ? `No entry ${rest}. Pick 1 to ${entries.length}.` : 'The glovebox is empty.'
387    }
388    const gone = entries[n - 1]
389    await writeGlovebox($, rewrite(existing, entries.filter((_, i) => i !== n - 1)))
390    await refresh($, await gather($))
391    return `${ICON} Dropped entry ${n}: ${gone.kind === 'file' ? '📄 ' : ''}${gone.text}`
392  }
393
394  if (verb === 'clear') {
395    await writeGlovebox($, rewrite(existing, []))
396    await refresh($, await gather($))
397    return `${ICON} The glovebox is cleared (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}).`
398  }
399
400  return `Try /${COMMAND}, add <note>, add-file <path>, drop <n>, clear, or receipts. Add --every-turn to send an entry every turn.`
401}
402
403// Before a compaction: the changed files, from `git status --short`, capped.
404async function changedFiles($: EngineInterface): Promise<string> {
405  const git = await $.process.run(['git', 'status', '--short'], { timeoutMs: 5000 }).catch(() => null)
406  if (!git || git.exitCode !== 0) return ''
407  const all = git.stdout.split(/\r?\n/).filter(Boolean)
408  return all.slice(0, SNAPSHOT_CAP).join('\n') + (all.length > SNAPSHOT_CAP ? `\n… and ${all.length - SNAPSHOT_CAP} more` : '')
409}
410
411async function writeSnapshot($: EngineInterface, changed: string): Promise<void> {
412  await $.fs.write('.claude/glovebox/last-compact.md', `# ${TITLE}: files changed at the last compaction\n\n${changed}\n`)
413}
414
415// Every turn: the [every-turn] entries, as one system-prompt section, with their receipts.
416// A receipt that cannot be written never keeps the rules from Claude.
417let turnLogged = -1
418const loggedThisTurn = new Set<string>()
419
420async function everyTurn($: EngineInterface): Promise<string | null> {
421  const all = await gather($)
422  await refresh($, all)
423  const every = all.filter(x => x.every)
424  if (!every.length) return null
425  const pieces = every.map(piece)
426  const turn = await $.session.turns()
427  if (turn !== turnLogged) { turnLogged = turn; loggedThisTurn.clear() }
428  const fresh = (await stampsOf(every, pieces)).filter(s => !loggedThisTurn.has(`${s.level}|${s.entry}|${s.sha256}`))
429  fresh.forEach(s => loggedThisTurn.add(`${s.level}|${s.entry}|${s.sha256}`))
430  await writeReceipts($, 'turn', fresh).catch(() => undefined)
431  return standing(pieces)
432}
433
434// After a compaction: the other entries and the changed files, as one message.
435async function afterCompact($: EngineInterface, changed: string): Promise<string> {
436  const kept = (await gather($)).filter(x => !x.every)
437  const pieces = kept.map(piece)
438  const text = handBack(pieces, changed)
439  if (text) await writeReceipts($, 'compact', await stampsOf(kept, pieces)).catch(() => undefined)
440  return text
441}
442
443export const register: Register = on => {
444  on('session.start', async ($, e, next) => {
445    await $.command.register({
446      name: 'glovebox',
447      description: 'Keep notes and files Claude gets back after it compacts. /glovebox add <note> · add-file <path> · --every-turn · drop <n> · clear · receipts',
448    })
449    await refresh($, await gather($))
450    return next(e)
451  })
452
453  // Only /glovebox is answered here; any other command passes on unchanged.
454  on('command.run', { command: 'glovebox' }, async ($, e, next) => {
455    if (e.command !== 'glovebox') return next(e)
456    try {
457      return { text: await answer($, e.args) }
458    } catch (err) {
459      if (!(err instanceof SaveError)) throw err
460      return { text: `${ICON} Nothing changed: the glovebox could not be saved to ${err.message}. Start Claude Code in your project folder and try again.` }
461    }
462  })
463
464  on('prompt.compose', async ($, e, next) => {
465    const composed = await next(e)
466    const text = await everyTurn($)
467    if (text === null) return composed
468    return { sections: [...composed.sections, { id: 'rostech-glovebox:standing', text, scope: 'session' as const }] }
469  })
470
471  on('session.compact', async ($, e, next) => {
472    // A compaction computed ahead of time is replayed by the real one, which comes
473    // through here again; the entries are added then, so they are the newest.
474    // A subagent's own compaction is left alone.
475    if (e.trigger === 'precompute' || e.agentId) return next(e)
476    // A conversation with nothing in it has nothing to compact, and the engine
477    // refuses a next() handed an empty transcript. Say so, the way core does.
478    if (e.messages.length === 0) return { skip: 'Not enough messages to compact.' }
479    const changed = await changedFiles($)
480    if (changed) await writeSnapshot($, changed)
481    const result = await next(e)
482    if (result.skip !== undefined) return result
483    const text = await afterCompact($, changed)
484    if (!text) return result
485    return { ...result, messages: [...result.messages, { role: 'user' as const, text, toolUses: [] }] }
486  })
487
488  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
489    const { value: n = 0 } = await $.state.get({ plugin: 'rostech-glovebox', key: 'lines' })
490    if (e.props.hasSurvey || n === 0) return next(e)
491    const { value: perTurn = 0 } = await $.state.get({ plugin: 'rostech-glovebox', key: 'everyLines' })
492    const { value: perTurnTokens = 0 } = await $.state.get({ plugin: 'rostech-glovebox', key: 'everyTokens' })
493    const { Box, Text } = $.ui.resolve(e)
494    const below = await next(e)
495    const isOver = n > BUDGET
496    const every = perTurn > 0 ? ` · 🔁 every turn ${perTurn} lines ≈${perTurnTokens} tokens` : ''
497    return (
498      <Box flexDirection="column">
499        <Box flexDirection="row">
500          <Text color={isOver ? 'red' : 'cyan'} bold>{`${ICON} ${TITLE} `}</Text>
501          <Text color={isOver ? 'red' : undefined}>{`${n}/${BUDGET} lines${isOver ? ' · over budget' : ''}${every}`}</Text>
502        </Box>
503        {below}
504      </Box>
505    )
506  })
507}
508
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'rostech-glovebox': { lines: number; everyLines: number; everyTokens: number }
4  }
5}
6