What a Claude Code session has in context: budget, files and how much of each was read, stale files, skills, helper agents, and what compaction summarised.

A live sidebar for Claude Code that shows what the session has in context: the budget, every file and how much of it was read, files that changed on disk after the read, the skills loaded, helper agents, and what compaction turned into summary.
It reads the session transcript Claude Code already writes (~/.claude/projects/…/<session>.jsonl). Nothing is sent anywhere. No dependencies beyond Node 18+.
CONTEXT 42k / 200k 21%
━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━┃━━━━━━━━
118k free compacts ≈160k
██████████████████████████████████████
■ sys 13k ■ chat 22k ■ files 7k ■ 330
──────────────────────────────────────
FILES 7 ●4 △1 ■1 ○1 size ↓
▾ arcsql 4.8k
● schema.generated.ts 4.3k
● pyodideService.ts 38% 416
▾ drive-eqms 832
△ release_guard_test.ts 14% 311
...
In Claude Code:
/plugin install context-compass --marketplace JesseVent/claude-mods
While developing it, load the folder in every session instead, so edits to the mod reload as you save:
export CLAUDE_CODE_PLUGIN_DIRS="/path/to/claude-mods/plugins/context-compass"
Restart Claude Code once so the hooks load. Then:
/context-compass
In Claude Code's fullscreen layout, 110 columns or wider, that docks the Context compass pane beside the transcript, and the pane opens there by itself in later sessions until you close it. Anywhere else it splits a 42-column terminal pane to the right (tmux, or iTerm2 without tmux) and leaves focus on Claude. CTX_SIDEBAR_WIDTH=48 changes the width.
The plugin adds two hooks (SessionStart, UserPromptSubmit) that record which transcript belongs to which directory, and where the plugin is installed, in ~/.claude/context-compass/. That keeps the sidebar on the right session after /clear or /resume.
Run it in any terminal, in the same directory as the Claude Code session:
node /path/to/claude-mods/plugins/context-compass/bin/context-compass.mjs
It follows the newest transcript for that directory.
# ~/.tmux.conf: prefix + C opens the sidebar beside the current pane
bind-key C run-shell -c "#{pane_current_path}" "TMUX_PANE=#{pane_id} sh /path/to/claude-mods/plugins/context-compass/bin/ctx-open.sh"
The same data as a single line under the prompt. Add to ~/.claude/settings.json:
{ "statusLine": { "type": "command", "command": "node /path/to/claude-mods/plugins/context-compass/bin/context-compass.mjs --band" } }
ctx ━━╸━━━━━━━┃━━ 42k/200k · ●5 △1 · ◆1
| Glyph | Meaning |
|---|---|
| ● | in context, full text |
| △ | changed on disk after Claude last read or edited it |
| ■ | image |
| ○ | summarised: read before a compaction, only the summary remains |
| ◆ | helper agent; its reads stay out of main context, only its summary counts |
| ✦ | skill loaded, by the Skill tool or a typed /name, with the tokens its text added |
| » | text a hook put in context: a SessionStart or UserPromptSubmit hook's output, or any hook's additional context. Labelled with the skill it names, else its first line; ×5 means a per-prompt hook added it five times |
38% | share of the file's lines Claude has seen (blank means the whole file) |
The top bar is the window; ┃ marks the auto-compact point. The second bar splits what is in context: system prompt and tools, conversation, files, helper summaries.
File names are terminal hyperlinks. Cmd-click (macOS) or ctrl-click opens the file in your system's default app for that file type. In file detail, the folder path is a link too.
This works in iTerm2, WezTerm, Kitty, Ghostty and the VS Code terminal. Terminals without hyperlink support show plain text.
Inside tmux (3.4 or later), add this to ~/.tmux.conf and restart tmux:
set -as terminal-features ",*:hyperlinks"
To open in an editor instead, set a URL template. {path} is the file and {line} is the first line Claude read:
export CTX_LINK_URL='vscode://file/{path}:{line}' # or cursor://file/{path}:{line}
CTX_LINKS=0 or --no-links turns links off.
| Key | Action |
|---|---|
↑↓ j k | move |
enter | file detail (line ranges seen, cost, read history), or fold a project |
space | fold the project |
/ | filter by path |
s | sort: size, name, recent |
o | open in $EDITOR in a new tmux window |
y | copy the path |
r | type "read the parts of … you have not seen" (or "re-read …") into the Claude pane, without pressing enter |
? | keys and legend |
q | quit |
r needs tmux, and the sidebar opened with ctx-open.sh or /context-compass, which records the Claude pane.
| Flag / env | Default | |
|---|---|---|
--window N, CTX_WINDOW | 200k, or 1M once a session passes 200k | context window |
--compact-at N, CTX_COMPACT_AT | CLAUDE_AUTOCOMPACT_PCT_OVERRIDE if set, else 80% (shown as ≈) | auto-compact point |
--root DIR, CTX_ROOT | common parent of the files read | where project groups start |
--transcript FILE, CTX_TRANSCRIPT | follow this directory's session | pin one transcript |
--cwd DIR | current directory | which session to follow |
--ascii, CTX_ASCII=1 | auto from locale | ASCII glyphs only |
--no-color, NO_COLOR | ||
--no-links, CTX_LINKS=0 | links on | clickable file names |
CTX_LINK_URL | file:// (system default app) | link template, {path} and {line} |
--once --cols N --rows N | print one frame and exit | |
--band | one-line status; reads Claude Code's statusLine JSON on stdin |
Colours use truecolor when COLORTERM=truecolor, otherwise the nearest xterm-256 values.
--window to be exact.npm test
They build a transcript in the same shape as real ones and check coverage, compaction, staleness, helpers, totals, and that every rendered line is exactly the pane width at several sizes, in Unicode and ASCII, and that file links are well formed.
hooks/register.tsx 439 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextFile, HookText, LoadedSkill } from '../types'
5
6const PANE = 'context-compass'
7const files = atom({ plugin: 'context-compass', key: 'files' } as const, [] as ContextFile[])
8const shell = atom({ plugin: 'context-compass', key: 'shell' } as const, [] as string[])
9const skills = atom({ plugin: 'context-compass', key: 'skills' } as const, [] as LoadedSkill[])
10const hookText = atom({ plugin: 'context-compass', key: 'hookText' } as const, [] as HookText[])
11
12let home = ''
13
14type Seen = Omit<ContextFile, 'isCompacted'>
15
16const tokensOf = (text: string) => Math.ceil(text.length / 4)
17const group = (via: ContextFile['via']) => (via === 'mention' ? 'read' : via === 'write' ? 'edit' : via)
18
19export function mergeRanges(ranges: readonly (readonly [number, number])[]): [number, number][] {
20 const out: [number, number][] = []
21 for (const [from, to] of [...ranges].sort((a, b) => a[0] - b[0])) {
22 const last = out[out.length - 1]
23 if (last && from <= last[1] + 1) last[1] = Math.max(last[1], to)
24 else out.push([from, to])
25 }
26 return out
27}
28
29function merge(old: ContextFile, seen: Seen): ContextFile {
30 // A re-read after compaction starts over: what entered before it is summary only.
31 if (old.isCompacted) return { ...seen, isCompacted: false }
32 return {
33 ...old,
34 ...seen,
35 detail: seen.detail ?? old.detail,
36 ranges: old.ranges && seen.ranges ? mergeRanges([...old.ranges, ...seen.ranges]) : undefined,
37 total: seen.total ?? old.total,
38 isSnippet: old.isSnippet && seen.isSnippet ? true : undefined,
39 tokens: old.tokens + seen.tokens,
40 }
41}
42
43async function record($: EngineInterface, seen: Seen) {
44 await update($, files, list => {
45 const i = list.findIndex(
46 f => f.path === seen.path && f.agentId === seen.agentId && group(f.via) === group(seen.via),
47 )
48 if (i < 0) return [...list, { ...seen, isCompacted: false }]
49 return list.map((f, j) => (j === i ? merge(f, seen) : f))
50 })
51}
52
53// As lib/parse.mjs hookLabel: the skill a hook's text names, else its first line, past the engine's "<Event> hook success: " prefix.
54export function hookLabel(text: string): string {
55 const named = /'([\w:.-]+)' skill/.exec(text)
56 if (named?.[1]) return named[1]
57 const body = text.replace(/^(?:<[^>]*>\s*)?[^\n]{0,80}?\bhook [a-z ]+: /, '')
58 for (const line of body.split('\n')) {
59 const l = line.replace(/<[^>]*>/g, '').replace(/^[#=*\s]+|[=*\s]+$/g, '')
60 if (l) return l
61 }
62 return ''
63}
64
65// ponytail: digits blanked so a per-prompt hook (a timestamp) is one row; two hooks whose first lines differ only in numbers merge
66const hookKey = (h: Pick<HookText, 'event' | 'label'>) => `${h.event} ${h.label.replace(/\d+/g, '#')}`
67
68export function depth(f: ContextFile): string {
69 if (f.isSnippet) return 'partial'
70 if (!f.ranges || !f.total) return 'full'
71 const seen = f.ranges.reduce((n, [from, to]) => n + to - from + 1, 0)
72 return seen >= f.total ? 'full' : `partial ${Math.floor((seen / f.total) * 100)}%`
73}
74
75const kTokens = (n: number) => (n === 0 ? '' : n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`)
76const fit = (text: string, width: number) =>
77 text.length <= width ? text : `…${text.slice(text.length - Math.max(1, width - 1))}`
78
79// A sidebar only: docked beside a fullscreen transcript, never inline above the prompt.
80const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Context compass', columns: 64 })
81let triedOpen = false
82
83// Where the pane cannot dock, the sidebar is a tmux or iTerm2 split running bin/context-compass.mjs.
84// A mod is not told its own folder; this plugin's SessionStart command hook records it in plugin-root.
85const OPEN_SPLIT = `f="$HOME/.claude/context-compass/plugin-root"
86[ -s "$f" ] || { echo "Context compass: its SessionStart hook has not run yet; restart Claude Code."; exit 1; }
87sh "$(cat "$f")/bin/ctx-open.sh"`
88const openSplit = async ($: EngineInterface) => {
89 const r = await $.process.run(['sh', '-c', OPEN_SPLIT])
90 return (r.stdout + r.stderr).trim() || `context-compass exited ${r.exitCode}`
91}
92
93export const register: Register = on => {
94 on('session.start', async ($, e, next) => {
95 home = (await $.env.get('HOME')) ?? ''
96 await $.command.register({
97 name: 'context-compass',
98 description: "Show which files and skills are in Claude's context, and how much of each",
99 })
100
101 return next(e)
102 })
103
104 // Only a render hook's viewport says whether the surface docks panes, so the unasked open waits for one.
105 // As the built-in sidebars do: a pane the person closed stays closed in later sessions until /context-compass.
106 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107 if (!triedOpen && e.viewport?.isFullscreen === true) {
108 triedOpen = true
109 if ((await $.store.get('open')) !== false) void openPane($)
110 }
111
112 return next(e)
113 }).catch(($, e, next) => next(e))
114
115 on('ui.close', { id: PANE }, async ($, e, next) => {
116 if (e.origin.kind === 'person') await $.store.set('open', false)
117
118 return next(e)
119 }).catch(($, e, next) => next(e))
120
121 on('command.run', { command: 'context-compass' }, async ($, e) => {
122 const { isFullscreen, columns } = e.presentation
123 // The main screen, and a fullscreen one under 110 columns, would seat the pane inline.
124 if (!isFullscreen || columns < 110) return { text: await openSplit($) }
125 await $.store.set('open', true)
126 const opened = await openPane($)
127 if (!opened.isPlaced) return { text: `Context compass pane is open but not drawn yet: ${opened.reason}` }
128
129 return { text: 'Context compass pane docked beside the transcript.' }
130 })
131
132 on('session.end', async ($, e, next) => {
133 if (e.reason === 'clear') {
134 await update($, files, () => [])
135 await update($, shell, () => [])
136 await update($, skills, () => [])
137 await update($, hookText, () => [])
138 }
139
140 return next(e)
141 })
142
143 // A settings hook's text (SessionStart output, additionalContext, ...) as Claude reads it; a subagent's stays out of main context.
144 on('session.append', async ($, e, next) => {
145 const r = await next(e)
146 if (e.origin.kind === 'hook' && e.message.role && !e.agentId) {
147 const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
148 const seen = { event: e.origin.event, label: hookLabel(text) }
149 if (text.trim()) {
150 await update($, hookText, list => {
151 const old = list.find(h => hookKey(h) === hookKey(seen))
152 const count = (old?.count ?? 0) + 1
153 return [...list.filter(h => h !== old), { ...seen, count, tokens: (old?.tokens ?? 0) + tokensOf(text) }]
154 })
155 }
156 }
157
158 return r
159 }).catch(($, e, next) => next(e))
160
161 // Typed as /name, run by the Skill tool, or preloaded into a subagent. A compaction re-attaches loaded skills, so they stay live.
162 on('skill.prompt', async ($, e, next) => {
163 const r = await next(e)
164 await update($, skills, list => {
165 const old = list.find(s => s.name === e.skill)
166 const tokens = (old?.tokens ?? 0) + tokensOf(r.text)
167 return [...list.filter(s => s !== old), { name: e.skill, source: e.pluginName ?? e.source, tokens }]
168 })
169
170 return r
171 }).catch(($, e, next) => next(e))
172
173 on('prompt.context', async ($, e, next) => {
174 const r = await next(e)
175 const loaded = r.instructionFiles ?? e.instructionFiles
176 if (loaded) {
177 await update($, files, list => [
178 ...list.filter(f => f.via !== 'instructions' || f.detail === 'nested'),
179 ...loaded.map(f => ({
180 path: f.path,
181 via: 'instructions' as const,
182 detail: f.kind,
183 tokens: tokensOf(f.content),
184 isCompacted: false,
185 })),
186 ])
187 }
188
189 return r
190 }).catch(($, e, next) => next(e))
191
192 on('prompt.mention', async ($, e, next) => {
193 const r = await next(e)
194 if (r.deny === undefined && r.type === 'file') {
195 await record($, {
196 path: e.path,
197 via: 'mention',
198 ranges: e.offset === undefined ? undefined : [[e.offset, e.offset + (e.limit ?? 1) - 1]],
199 tokens: 0,
200 agentId: e.agentId,
201 })
202 }
203
204 return r
205 }).catch(($, e, next) => next(e))
206
207 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
208 const r = await next(e)
209 if (r.deny !== undefined || r.isError) return r
210 const res = r.result
211 const base = { path: e.file_path, via: 'read' as const, agentId: e.agentId }
212 if (res.type === 'text') {
213 const { startLine, numLines, totalLines, content, truncatedByTokenCap } = res.file
214 await record($, {
215 ...base,
216 ranges: numLines > 0 ? [[startLine, startLine + numLines - 1]] : undefined,
217 total: totalLines,
218 isSnippet: truncatedByTokenCap ? true : undefined,
219 tokens: tokensOf(content),
220 })
221 } else if (res.type !== 'file_unchanged') {
222 await record($, { ...base, detail: res.type, isSnippet: e.pages ? true : undefined, tokens: 0 })
223 }
224
225 return r
226 }).catch(($, e, next) => next(e))
227
228 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
229 const r = await next(e)
230 if (r.deny === undefined && !r.isError) {
231 await record($, {
232 path: e.file_path,
233 via: 'edit',
234 isSnippet: true,
235 tokens: tokensOf(e.old_string + e.new_string),
236 agentId: e.agentId,
237 })
238 }
239
240 return r
241 }).catch(($, e, next) => next(e))
242
243 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
244 const r = await next(e)
245 if (r.deny === undefined && !r.isError) {
246 await record($, {
247 path: e.notebook_path,
248 via: 'edit',
249 isSnippet: true,
250 tokens: tokensOf(e.new_source),
251 agentId: e.agentId,
252 })
253 }
254
255 return r
256 }).catch(($, e, next) => next(e))
257
258 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
259 const r = await next(e)
260 if (r.deny === undefined && !r.isError) {
261 await record($, { path: e.file_path, via: 'write', tokens: tokensOf(e.content), agentId: e.agentId })
262 }
263
264 return r
265 }).catch(($, e, next) => next(e))
266
267 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268 const r = await next(e)
269 if (r.deny === undefined && !e.agentId) await update($, shell, list => [...list, e.command].slice(-20))
270
271 return r
272 }).catch(($, e, next) => next(e))
273
274 on('session.append', { door: 'attachment' }, async ($, e, next) => {
275 const r = await next(e)
276 if (e.message.name === 'nested_memory') {
277 const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
278 // ponytail: path parsed from the rendered row (the payload is not on e); breaks if its wording changes
279 for (const [, path] of text.matchAll(/Contents of (.+?)(?: \([^)]*\))?:\n/g)) {
280 if (path) await record($, { path, via: 'instructions', detail: 'nested', tokens: tokensOf(text), agentId: e.agentId })
281 }
282 }
283
284 return r
285 }).catch(($, e, next) => next(e))
286
287 on('session.compact', async ($, e, next) => {
288 const r = await next(e)
289 // CLAUDE.md files are sent again after a compaction; everything else is summary only.
290 // ponytail: the engine also re-attaches a few recent files; they read pre-compact until re-read
291 if (e.trigger !== 'precompute' && r.messages) {
292 await update($, files, list =>
293 list.map(f =>
294 f.agentId === e.agentId && !(f.via === 'instructions' && f.detail !== 'nested')
295 ? { ...f, isCompacted: true }
296 : f,
297 ),
298 )
299 // What hooks added before is summary now; SessionStart hooks run again and add theirs afresh.
300 if (!e.agentId) await update($, hookText, () => [])
301 }
302
303 return r
304 }).catch(($, e, next) => next(e))
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Text } = $.ui.resolve(e)
308 const list = await read($, files)
309 const cmds = await read($, shell)
310 const loadedSkills = await read($, skills)
311 const hooks = [...(await read($, hookText))].sort((a, b) => b.tokens - a.tokens)
312 const columns = e.props.bodyColumns
313
314 const live = list.filter(f => !f.agentId && !f.isCompacted)
315 const partial = live.filter(f => depth(f) !== 'full').length
316 const liveTokens = live.reduce((n, f) => n + f.tokens, 0)
317 const stale = list.filter(f => !f.agentId && f.isCompacted).length
318 const helper = list.filter(f => f.agentId).length
319
320 const row = (f: ContextFile) => {
321 const where = f.agentId ? 'helper' : 'main'
322 const how = depth(f)
323 const when = f.isCompacted ? 'pre-compact' : 'live'
324 const lines =
325 how.startsWith('partial') && f.ranges
326 ? ` L${f.ranges.map(([from, to]) => `${from}-${to}`).join(',')}${f.total ? ` of ${f.total}` : ''}`
327 : f.detail && f.via !== 'instructions'
328 ? ` ${f.detail}`
329 : ''
330 const tok = kTokens(f.tokens)
331 const shown = home && f.path.startsWith(home) ? `~${f.path.slice(home.length)}` : f.path
332 const width = Math.max(8, columns - 33 - fit(lines, 24).length - tok.length)
333 const isFaded = Boolean(f.agentId) || f.isCompacted
334
335 // A sidebar is too narrow for one line: the path on its own line, the labels under it.
336 if (columns < 90) {
337 return (
338 <Box key={`${f.via}:${f.agentId ?? ''}:${f.path}`} flexDirection="column">
339 <Text dimColor={isFaded}>{fit(shown, columns)}</Text>
340 <Box flexDirection="row">
341 <Text dimColor={Boolean(f.agentId)}>{` ${where}`}</Text>
342 <Text dimColor> · </Text>
343 <Text color={how === 'full' ? 'success' : 'warning'}>{how}</Text>
344 <Text dimColor> · </Text>
345 <Text dimColor={f.isCompacted}>{when}</Text>
346 <Text dimColor>{fit(`${lines}${tok ? ` · ${tok}` : ''}`, Math.max(8, columns - 40))}</Text>
347 </Box>
348 </Box>
349 )
350 }
351
352 return (
353 <Box key={`${f.via}:${f.agentId ?? ''}:${f.path}`} flexDirection="row">
354 <Text dimColor={Boolean(f.agentId)}>{where.padEnd(7)}</Text>
355 <Text color={how === 'full' ? 'success' : 'warning'}>{how.padEnd(13)}</Text>
356 <Text dimColor={f.isCompacted}>{when.padEnd(12)}</Text>
357 <Text dimColor={isFaded}>{fit(shown, width)}</Text>
358 <Text dimColor>{fit(lines, 24)}</Text>
359 <Text dimColor> {tok}</Text>
360 </Box>
361 )
362 }
363
364 const sections: [string, ContextFile[]][] = [
365 ['Read', list.filter(f => group(f.via) === 'read')],
366 ['Edited / written', list.filter(f => group(f.via) === 'edit')],
367 ['Instructions', list.filter(f => f.via === 'instructions')],
368 ]
369 const body = sections.flatMap(([title, entries]) =>
370 entries.length === 0
371 ? []
372 : [
373 <Text key={title} bold>
374 {title} ({entries.length})
375 </Text>,
376 ...entries.map(row),
377 ],
378 )
379 if (loadedSkills.length > 0) {
380 body.push(
381 <Text key="skills" bold>
382 Skills ({loadedSkills.length})
383 </Text>,
384 ...loadedSkills.map(s => {
385 const tail = ` ${s.source} · ${kTokens(s.tokens)}`
386 return (
387 <Box key={`skill:${s.name}`} flexDirection="row">
388 <Text>{fit(s.name, Math.max(8, columns - tail.length))}</Text>
389 <Text dimColor>{tail}</Text>
390 </Box>
391 )
392 }),
393 )
394 }
395 if (hooks.length > 0) {
396 body.push(
397 <Text key="hooks" bold>
398 Hook text ({hooks.length})
399 </Text>,
400 ...hooks.map(hook => {
401 const tail = ` ${hook.event}${hook.count > 1 ? ` ×${hook.count}` : ''} · ${kTokens(hook.tokens)}`
402 return (
403 <Box key={`hook:${hookKey(hook)}`} flexDirection="row">
404 <Text>{fit(hook.label, Math.max(8, columns - tail.length))}</Text>
405 <Text dimColor>{tail}</Text>
406 </Box>
407 )
408 }),
409 )
410 }
411 if (cmds.length > 0) {
412 body.push(
413 <Text key="shell" bold>
414 Shell: output in context, files not tracked ({cmds.length})
415 </Text>,
416 ...cmds.slice(-5).map((c, i) => (
417 <Text key={`sh${i}`} dimColor>
418 {fit(`$ ${c.replace(/\s+/g, ' ')}`, columns)}
419 </Text>
420 )),
421 )
422 }
423
424 return (
425 <Box flexDirection="column">
426 <Text>
427 main live {live.length} ({live.length - partial} full · {partial} partial) · pre-compact {stale} · helper{' '}
428 {helper} · ~{kTokens(liveTokens) || 0} tok
429 </Text>
430 <Text dimColor>main/helper · full/partial · live/pre-compact (pre-compact = summary only)</Text>
431 {list.length === 0 && cmds.length === 0 && loadedSkills.length === 0 && hooks.length === 0 && (
432 <Text dimColor>No files in context yet.</Text>
433 )}
434 {body}
435 </Box>
436 )
437 })
438}
439types/index.d.ts 43 lines1export type ContextFile = {
2 path: string
3 via: 'instructions' | 'mention' | 'read' | 'edit' | 'write'
4 /** CLAUDE.md tier (user, project, memory, nested), or a Read's kind (image, pdf) */
5 detail?: string
6 /** Lines seen, merged; absent means the whole file */
7 ranges?: [number, number][]
8 /** The file's total lines, from a Read */
9 total?: number
10 /** Only part of the file entered (an Edit's snippet, a capped Read, PDF pages) */
11 isSnippet?: boolean
12 /** ≈ chars/4 of what entered */
13 tokens: number
14 /** Set when a helper (subagent) loop read it: not in the main context */
15 agentId?: string
16 /** A compaction ran after it entered: only the summary remains */
17 isCompacted: boolean
18}
19
20export type LoadedSkill = {
21 name: string
22 /** The plugin it came with, else where it was found (userSettings, built-in, ...) */
23 source: string
24 /** ≈ chars/4 of its prompt, summed over every load */
25 tokens: number
26}
27
28/** Text a settings hook put in front of Claude (SessionStart output, additionalContext, ...), grouped by event and first line */
29export type HookText = {
30 event: string
31 /** The skill the text names, else its first line */
32 label: string
33 /** How many times it was added (a per-prompt hook adds once per prompt) */
34 count: number
35 tokens: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'context-compass': { files: ContextFile[]; shell: string[]; skills: LoadedSkill[]; hookText: HookText[] }
41 }
42}
43