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…

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.
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.
/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.
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.
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.
Only text, and only to Claude, inside your own conversation. Nothing goes to any network, server or other service.
--every-turn are added to Claude's system prompt, the standing instructions of your session, as one section.It reads no password, key, token or other credential, and no environment variable.
The GLOVEBOX.md files of the levels below, the files you point it at with add-file, and the output of git status --short.
Like CLAUDE.md, Glovebox reads more than one glovebox. Outer levels reach Claude first, and each entry says where it came from:
GLOVEBOX.md in Claude Code's managed-policy folder, C:\Program Files\ClaudeCode\, /Library/Application Support/ClaudeCode/ or /etc/claude-code/..claude/GLOVEBOX.md in each folder above the project, outermost first..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.
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 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.
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.
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.
What is known, from Claude Code's own docs (read 4 Oct 2026):
managed-settings.json and its managed CLAUDE.md, and a managed CLAUDE.md cannot be excluded by users. (managed settings, memory)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.enabledPlugins turns a plugin on per scope, and strictKnownMarketplaces (managed only) limits which marketplaces users can install from. (settings, settings reference)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.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.0.1.1
rostech-glovebox, because the name glovebox already belongs to someone else in the directory. The title and the /glovebox command are unchanged.~/.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.Tested with Claude Code 2.1.289.
See every Rostech mod, live, at rostech.app.
hooks/register.tsx 508 lines1import 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}
508types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'rostech-glovebox': { lines: number; everyLines: number; everyTokens: number }
4 }
5}
6