Lets an agent compact its own session at a stopping point of its choosing, and nudges it to find one when the compaction window is nearly full

A Claude Code mod that lets an agent compact its own session at a stopping point of its choosing, and asks it to find one when the compaction window is nearly full. The owner's design (2026-10-16): each agent watches its own context; when it gets low it reaches a natural stopping point, saves its durable record, and then asks to be compacted. Auto-compact at the window stays as the backstop.
It loads in every session on the machine and acts only on the session it runs in. After every main-loop turn (a subagent's or fork's turns are passed through) it does one of two things:
<state>/self-compact/<this session's id>.json exists and is newer than the last one acted on (<state> is $XDG_STATE_HOME, else $HOME/.local/state). Its content is {} or {"instructions": "<what the summary must keep>"}. The mod calls the engine's own compact, the same call /compact makes, with those instructions, and logs self-compact: compacted at the agent's request. A file that is not a JSON object compacts nothing and is named once in the log. A hook's veto is reported, not retried. The agent may write the file at any time, not only when nudged: an early compaction after a milestone is the agent's call.COMPACT_AFTER for this agent when the config file (below) gives one, else where 20% of the room remains. The room runs to the hard point: where this session actually auto-compacts, read from the engine's context breakdown each turn (its settled window less its auto-compact buffer, ~33k; a running session keeps the window it started with or the one /autocompact gave it, whatever the settings file says now). Without a breakdown the settings window less 33k stands in (measured 2026-10-16: auto-compact at 266.7k–269.3k on 300k, 217,132 on 250k). The mod submits one prompt, marked [self-compact mod, not the owner], naming the figures and the exact path to write: finish at a natural stopping point, bring the durable record up to date, then write the file and end the turn. One nudge per fill: it re-arms only once the count has dropped, that is, after a compaction.The soft point, COMPACT_AFTER. One file per box, $XDG_CONFIG_HOME/self-compact/compact-after.json (~/.config/... when unset), read at every turn end, so a change needs no restart and no typing into any session:
{ "default": 200000, "agents": { "Vera": 230000 } }
default is the token count at which every agent is nudged; agents overrides it by name, the name being the launch line's GIT_AUTHOR_NAME (more room for an agent that runs big suites). No file, or no entry for this agent, means the 20% rule. A malformed file changes nothing and is named once in the log. The owner sets the numbers (2026-10-16: default 200k, autoCompactWindow 300k, so auto-compact at ~267k and ~67k of room).
The guard. Every turn the status line shows <used> used, nudge at <soft>, auto-compact at <hard>, room <hard − soft>. When the room is under 40,000 (MIN_FINISH_ROOM) the line ends in ⚠ and one log line says so, naming whether the hard point was read from this session or estimated, and the remedy: /autocompact <window> in that session, or a lower COMPACT_AFTER. That is the silent miss made loud: settings raised while a running session kept its old window, or a soft point set too close.
Writing the request in one command. bash mods/self-compact/compact-me.sh "<what the summary must keep>" writes {"instructions": …} to this session's own request path and tells you to end your turn. The session id is CLAUDE_CODE_SESSION_ID, which Claude Code sets in every Bash an agent runs; without it, or with one that is not a plain token, or with no state directory, the script refuses (exit 2, one line) rather than guess, since a guessed id could only name a file no session reads. The write is atomic and the text is JSON-escaped. --self-test runs its pure arms; scripts/check-mods-tests-pass.sh runs every mod's *.sh --self-test on every PR.
What it cannot do, by construction rather than by rule: it never types into any pane (the engine call is not a keystroke, so nothing it does can be mistaken for the owner's typing or answer a prompt), and it cannot reach another session (the only id in the path is its own, and the compact call takes no target). The compact call runs between turns only and rejects during one, so a request written mid-turn is read at that turn's end.
Install it, with every other mod in this folder, by naming the folder in the env block of ~/.claude/settings.json (each subfolder holding .claude-plugin/plugin.json loads as a mod; several folders are separated by :):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/harness/mods" } }
It takes effect in sessions started after that. A running session does not reload autoCompactWindow from settings either; /autocompact 250000 in that session brings it onto the new window, and claude --settings does not override it.
hooks/compact.ts is the pure part: the request and config paths, the window arithmetic (soft point, hard point, guard), the nudge and status text, and the two parsers. hooks/register.ts reads and acts after each turn. hooks/compact.test.ts holds the tests, including the negative control (a garbled request compacts nothing).claude plugin validate mods/self-compact and claude plugin test mods/self-compact check it. scripts/verify.sh runs the tests on every PR through scripts/check-mods-tests-pass.sh. .claude-plugin/types/, which tsconfig.json extends, is written by Claude Code when it loads the mod and is not committed.hooks/register.ts 114 lines1import type { EngineInterface, Register } from 'claude-code'
2import { compactWindowOf, configPath, guard, hardAtOf, nudgeDue, nudgeText, parseCompactAfter, parseRequest, requestPath, statusText, type Window } from './compact.ts'
3
4// Store keys, per session: the token count at the last nudge (re-armed when the count drops under it),
5// the mtime of the last request file acted on (a file is acted on once; the agent may write it again),
6// and the last guard warning and config refusal logged (each is logged once, not every turn).
7const NUDGED_AT = 'nudgedAtTokens'
8const HANDLED_MTIME = 'handledRequestMtimeMs'
9const WARNED = 'guardWarningLogged'
10const REFUSED = 'configRefusalLogged'
11
12async function envOf($: EngineInterface) {
13 return {
14 XDG_STATE_HOME: await $.env.get('XDG_STATE_HOME'),
15 XDG_CONFIG_HOME: await $.env.get('XDG_CONFIG_HOME'),
16 HOME: await $.env.get('HOME'),
17 }
18}
19
20// Where this session's request file is: its OWN id in the path and nothing else. There is no way to
21// name another session here, and `$.session.compact` takes no target, so a request can only ever
22// compact the session that reads it.
23async function ownRequestPath($: EngineInterface): Promise<string> {
24 return requestPath(await $.session.id(), await envOf($))
25}
26
27// COMPACT_AFTER for this agent: the config file is read every turn end (no restart to change it),
28// the agent is the launch line's GIT_AUTHOR_NAME. No file, or no entry for this agent, means none.
29async function compactAfterOf($: EngineInterface): Promise<number | undefined> {
30 const path = configPath(await envOf($))
31 if (!path || !(await $.fs.exists(path))) return undefined
32 const parsed = parseCompactAfter(await $.fs.read(path), await $.env.get('GIT_AUTHOR_NAME'))
33 if ('refuse' in parsed) {
34 const line = `${path} ignored — ${parsed.refuse}`
35 if ((await $.store.get(REFUSED)) !== line) { await $.store.set(REFUSED, line); $.ui.log(line) }
36 return undefined
37 }
38 return parsed.compactAfter
39}
40
41// The window as this session has it. The breakdown (`summary`: local estimates, no API call) carries
42// the session's SETTLED window and its buffer row, so the hard point is read, not assumed: a running
43// session keeps the window it started with, or the one /autocompact gave it, whatever the settings
44// file says now. Without a breakdown the settings window less the measured reserve stands in.
45async function windowOf($: EngineInterface): Promise<Window | undefined> {
46 const { context } = await $.session.usage({ breakdown: 'summary' })
47 if (typeof context.tokens !== 'number') return undefined
48 const settings = await $.settings.read().catch(() => ({}))
49 const live = hardAtOf((context as { breakdown?: unknown }).breakdown)
50 return {
51 tokens: context.tokens,
52 compactWindow: compactWindowOf(settings, context.window),
53 hardAt: live?.hardAt,
54 hardSource: live?.source,
55 compactAfter: await compactAfterOf($),
56 }
57}
58
59// A request file newer than the last one handled is read, acted on, and remembered by its mtime.
60// The compact call runs between turns and rejects while a turn runs, so a request written mid-turn
61// is simply read at the next turn's end.
62async function actOnRequest($: EngineInterface, path: string): Promise<boolean> {
63 if (!(await $.fs.exists(path))) return false
64 const { mtimeMs } = await $.fs.stat(path)
65 const handled = await $.store.get(HANDLED_MTIME)
66 if (typeof handled === 'number' && mtimeMs <= handled) return false
67 await $.store.set(HANDLED_MTIME, mtimeMs)
68 const parsed = parseRequest(await $.fs.read(path))
69 if ('refuse' in parsed) {
70 $.ui.log(`${path} ignored — ${parsed.refuse}`)
71 return false
72 }
73 const result = await $.session.compact(parsed.request.instructions === undefined ? {} : { instructions: parsed.request.instructions })
74 if ('skip' in result && result.skip) $.ui.log(`a hook vetoed the compaction — ${result.skip}`)
75 else $.ui.log('compacted at the agent\'s request')
76 return true
77}
78
79// Every turn: the status line shows both points and the room; a guard warning is logged once per
80// distinct text. Once per fill: when the soft point is reached and nothing has been requested, the
81// agent is asked (as a prompt of its own, marked as the mod's) to find a stopping point and write
82// the file.
83async function reportAndNudge($: EngineInterface, path: string): Promise<void> {
84 const w = await windowOf($)
85 if (!w) return
86 $.ui.status(statusText(w))
87 const { warning } = guard(w)
88 if (warning && (await $.store.get(WARNED)) !== warning) { await $.store.set(WARNED, warning); $.ui.log(warning) }
89 if (!warning && (await $.store.get(WARNED)) !== undefined) await $.store.delete(WARNED)
90 const nudgedAt = await $.store.get(NUDGED_AT)
91 const prior = typeof nudgedAt === 'number' ? nudgedAt : undefined
92 if (prior !== undefined && w.tokens < prior) await $.store.delete(NUDGED_AT)
93 if (!nudgeDue(w, prior)) return
94 await $.store.set(NUDGED_AT, w.tokens)
95 await $.prompt.submit({ text: nudgeText(w, path) })
96}
97
98// After every main-loop turn (a subagent's or fork's turns are passed through untouched): act on a
99// request if one is waiting, else report and nudge if the window says so. Errors are logged, never
100// thrown into the turn.
101export const register: Register = on => {
102 on('turn.complete', async ($, e, next) => {
103 const result = await next(e)
104 if ((e as { agentId?: string }).agentId !== undefined) return result
105 try {
106 const path = await ownRequestPath($)
107 if (!(await actOnRequest($, path))) await reportAndNudge($, path)
108 } catch (err) {
109 $.ui.log(String(err))
110 }
111 return result
112 })
113}
114hooks/compact.ts 171 lines1// The pure part of self-compact: where the request and config files live, when a nudge is due, what
2// the guard says, and what a request file means. Nothing here touches the engine; register.ts does
3// the reading and acting.
4
5// With no COMPACT_AFTER configured, a nudge is due once this fraction of the room remains, or less.
6export const NUDGE_AT_REMAINING = 0.20
7
8// The room an agent needs between the nudge and the hard point to finish and save: the guard
9// speaks when COMPACT_AFTER plus this would pass where the session actually auto-compacts
10// (owner, 2026-10-16: 40k).
11export const MIN_FINISH_ROOM = 40_000
12
13// Auto-compact does not fire at the window: it fires this many tokens short of it. Measured by
14// Alfred on 2026-10-16 from compact_boundary entries (compactMetadata.preTokens) in every agent
15// transcript on a 300k window: 266.7k–269.3k, so ~33k early; on 250k that is ~217k (confirmed at
16// 217,132 the same morning). Used only when the engine's breakdown is not available: with it,
17// `hardAt` carries the session's own buffer, read live.
18export const AUTO_COMPACT_RESERVE = 33_000
19
20export type Window = {
21 /** Input tokens the last response was answered over. */
22 tokens: number
23 /** The compaction window: `autoCompactWindow` from the merged settings, else the model's window. */
24 compactWindow: number
25 /**
26 * Where THIS session actually auto-compacts, from the engine's breakdown: its settled window
27 * (`rawMaxTokens`, which /autocompact or the env may have set, not the settings file) less its
28 * buffer row. Absent when the breakdown is not available.
29 */
30 hardAt?: number
31 /** How the session's window was settled, as the breakdown names it (`settings`, `env`, …). */
32 hardSource?: string
33 /** The soft point: COMPACT_AFTER for this agent, from the config file. Absent = the 20% rule. */
34 compactAfter?: number
35}
36
37// Where auto-compact fires with no live figure: the settings window less the measured reserve.
38export function effectiveWindow(w: Window): number {
39 return Math.max(0, w.compactWindow - AUTO_COMPACT_RESERVE)
40}
41
42// The hard point: the session's own, when the engine gave it, else the estimate.
43export function hardPoint(w: Window): number {
44 return w.hardAt !== undefined && w.hardAt > 0 ? w.hardAt : effectiveWindow(w)
45}
46
47// The fraction of the room (up to the hard point) still free, 0..1; no room is read as full.
48export function remainingFraction(w: Window): number {
49 const hard = hardPoint(w)
50 if (hard <= 0) return 0
51 return Math.max(0, Math.min(1, (hard - w.tokens) / hard))
52}
53
54// The compaction window: the merged settings' `autoCompactWindow` when it is a positive number,
55// else the model's own window.
56export function compactWindowOf(settings: unknown, modelWindow: number): number {
57 const v = (settings as { autoCompactWindow?: unknown } | null | undefined)?.autoCompactWindow
58 return typeof v === 'number' && v > 0 ? v : modelWindow
59}
60
61// Where this session actually auto-compacts, from `$.session.usage({ breakdown })`'s context
62// breakdown: the settled window less every `buffer` row. Undefined without a breakdown.
63export function hardAtOf(breakdown: unknown): { hardAt: number; source: string } | undefined {
64 const b = breakdown as { rawMaxTokens?: unknown; autocompactSource?: unknown; categories?: unknown } | null | undefined
65 if (!b || typeof b.rawMaxTokens !== 'number' || b.rawMaxTokens <= 0) return undefined
66 const rows = Array.isArray(b.categories) ? b.categories as { kind?: unknown; tokens?: unknown }[] : []
67 const buffer = rows.filter(r => r.kind === 'buffer').reduce((n, r) => n + (typeof r.tokens === 'number' ? r.tokens : 0), 0)
68 return { hardAt: Math.max(0, b.rawMaxTokens - buffer), source: typeof b.autocompactSource === 'string' ? b.autocompactSource : 'unknown' }
69}
70
71type Env = { XDG_STATE_HOME?: string; XDG_CONFIG_HOME?: string; HOME?: string }
72
73// The request file of ONE session: the only file this mod ever reads for a request, and the path
74// the nudge tells the agent to write. `<state>/self-compact/<session id>.json`, where state is
75// `$XDG_STATE_HOME` or `$HOME/.local/state`. No other session's id is ever put in this path.
76export function requestPath(sessionId: string, env: Env): string {
77 const state = env.XDG_STATE_HOME || (env.HOME ? `${env.HOME}/.local/state` : undefined)
78 if (!state) throw new Error('neither XDG_STATE_HOME nor HOME is set, so the request path cannot be built')
79 if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error(`session id ${JSON.stringify(sessionId)} is not a plain token`)
80 return `${state}/self-compact/${sessionId}.json`
81}
82
83// The COMPACT_AFTER file, one per box, read at every turn end so a change needs no restart:
84// `<config>/self-compact/compact-after.json`, config being `$XDG_CONFIG_HOME` or `$HOME/.config`.
85export function configPath(env: Env): string | undefined {
86 const config = env.XDG_CONFIG_HOME || (env.HOME ? `${env.HOME}/.config` : undefined)
87 return config ? `${config}/self-compact/compact-after.json` : undefined
88}
89
90// The config file's content: `{ "default": <tokens>, "agents": { "<Name>": <tokens> } }`, both
91// parts optional. The agent's own entry wins over the default; a file naming neither for this
92// agent yields no soft point and the 20% rule applies. Anything malformed is refused with the
93// reason, so a garbled file changes nothing.
94export function parseCompactAfter(text: string, agentName: string | undefined): { compactAfter: number | undefined } | { refuse: string } {
95 let doc: unknown
96 try { doc = JSON.parse(text) } catch { return { refuse: 'the COMPACT_AFTER file is not JSON' } }
97 if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return { refuse: 'the COMPACT_AFTER file is not a JSON object' }
98 const d = doc as { default?: unknown; agents?: unknown }
99 const positive = (v: unknown, what: string): number | undefined | { refuse: string } => {
100 if (v === undefined) return undefined
101 if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return { refuse: `${what} is not a positive number` }
102 return v
103 }
104 if (d.agents !== undefined && (d.agents === null || typeof d.agents !== 'object' || Array.isArray(d.agents))) return { refuse: '"agents" is not an object' }
105 const agents = (d.agents ?? {}) as Record<string, unknown>
106 const own = agentName !== undefined ? positive(agents[agentName], `"agents"."${agentName}"`) : undefined
107 if (typeof own === 'object') return own
108 const dflt = positive(d.default, '"default"')
109 if (typeof dflt === 'object') return dflt
110 return { compactAfter: own ?? dflt }
111}
112
113export type Request = { instructions?: string }
114
115// A request file's content: `{ "instructions": "..." }` or `{}`; anything else is refused with the
116// reason, so a garbled file compacts nothing.
117export function parseRequest(text: string): { request: Request } | { refuse: string } {
118 let doc: unknown
119 try { doc = JSON.parse(text) } catch { return { refuse: 'the request file is not JSON' } }
120 if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return { refuse: 'the request file is not a JSON object' }
121 const i = (doc as { instructions?: unknown }).instructions
122 if (i !== undefined && typeof i !== 'string') return { refuse: '"instructions" is not a string' }
123 return { request: i === undefined ? {} : { instructions: i } }
124}
125
126// The soft point: COMPACT_AFTER when configured, else where 20% of the room remains.
127export function softPoint(w: Window): number {
128 return w.compactAfter ?? Math.ceil(hardPoint(w) * (1 - NUDGE_AT_REMAINING))
129}
130
131// Whether a nudge is due: the count is at or past the soft point, and no nudge has gone out since
132// the count was last under it (one nudge per fill; a compaction drops the count and re-arms it).
133export function nudgeDue(w: Window, nudgedAtTokens: number | undefined): boolean {
134 if (w.tokens < softPoint(w)) return false
135 return nudgedAtTokens === undefined || w.tokens < nudgedAtTokens
136}
137
138const n = (x: number) => Math.round(x).toLocaleString('en-GB')
139
140// The guard: the room between the soft point and the hard point, and a warning when it is under
141// MIN_FINISH_ROOM. That is the silent miss made loud: settings raised while a running session kept
142// its old window, or a COMPACT_AFTER set too close. `live` says whether the hard point is the
143// session's own figure or the estimate.
144export function guard(w: Window): { room: number; warning?: string } {
145 const room = hardPoint(w) - softPoint(w)
146 if (room >= MIN_FINISH_ROOM) return { room }
147 const how = w.hardAt !== undefined ? `read from this session (${w.hardSource ?? 'unknown'})` : 'estimated from the settings window'
148 return {
149 room,
150 warning: `only ${n(room)} tokens between the nudge at ${n(softPoint(w))} and auto-compact at ${n(hardPoint(w))} (${how}); ${n(MIN_FINISH_ROOM)} is the minimum. Raise this session's window with /autocompact, or lower COMPACT_AFTER.`,
151 }
152}
153
154// The status line, every turn: both points and the room, so a drift is visible before it bites.
155// No mod-name prefix here or in any ui line: the engine prefixes every status and log line with
156// "⚠ <mod name>: " itself, so a prefix of our own showed the name twice (owner, 2026-10-16).
157export function statusText(w: Window): string {
158 const g = guard(w)
159 return `${n(w.tokens)} used, nudge at ${n(softPoint(w))}, auto-compact at ${n(hardPoint(w))}, room ${n(g.room)}${g.warning ? ' ⚠' : ''}`
160}
161
162// The nudge, marked as the mod's and not the owner's, carrying the exact path to write.
163export function nudgeText(w: Window, path: string): string {
164 const left = Math.max(0, hardPoint(w) - w.tokens)
165 return [
166 `[self-compact mod, not the owner] ${n(w.tokens)} tokens used; auto-compact fires at about ${n(hardPoint(w))}, so about ${n(left)} remain to finish and save.`,
167 'Finish at a natural stopping point and bring your durable record up to date (session entry, row notes, memory) before anything else.',
168 `Then write {"instructions":"<what the summary must keep>"} to ${path} and end your turn: this mod compacts the session between turns, with those instructions, and clears the request. Do not start new work first.`,
169 ].join('\n')
170}
171