Compacts a session once it has sat idle for 20 minutes with more than 200k tokens of context

Runs /compact in a Claude Code session once both hold:
It compacts once per idle stretch; the next turn starts a new one. The thresholds are POLICY in hooks/activity.ts.
/plugin install idle-compact --marketplace bence-boer/agent-resources
hooks/activity.ts — the session's activity as a pure state machine: transition(activity, event) and the compaction policy.hooks/register.ts — wires engine events and a one-minute check into that state machine and runs /compact.types/index.d.ts — the $.state contract for Activity.claude --plugin-dir plugins/idle-compact # loads it with hot reload and lays its types for tsc
claude plugin validate plugins/idle-compact
claude plugin test plugins/idle-compacthooks/register.ts 44 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { INITIAL_ACTIVITY, POLICY, isClaimedBy, isWorking, transition } from './activity'
5import type { ActivityEvent, Observation } from './activity'
6
7const activity = atom({ plugin: 'idle-compact', key: 'activity' } as const, INITIAL_ACTIVITY)
8
9const record = ($: EngineInterface, event: ActivityEvent) =>
10 update($, activity, current => transition(current, event))
11
12const observe = async ($: EngineInterface): Promise<Observation> => {
13 const [now, usage, agents] = await Promise.all([$.clock.now(), $.session.usage(), $.agent.list()])
14 return { now, contextTokens: usage.context.tokens ?? 0, hasWorkingAgents: agents.some(isWorking) }
15}
16
17const compactWhenIdle = async ($: EngineInterface) => {
18 const observation = await observe($)
19 const checked = await record($, { kind: 'checked', observation })
20 if (!isClaimedBy(checked, observation)) return
21
22 await $.command.run({ command: 'compact' })
23}
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await record($, { kind: 'session-started', at: await $.clock.now() })
28 $.clock.every(POLICY.checkIntervalMilliseconds, () => {
29 compactWhenIdle($).catch((error: unknown) => $.ui.log(`idle-compact: ${String(error)}`))
30 })
31 return next(e)
32 })
33
34 on('turn.start', async ($, e, next) => {
35 await record($, { kind: 'turn-started' })
36 return next(e)
37 })
38
39 on('turn.complete', async ($, e, next) => {
40 await record($, { kind: 'turn-completed', at: await $.clock.now(), agentId: e.agentId })
41 return next(e)
42 })
43}
44hooks/activity.ts 54 lines1import type { AgentInfo, AgentStatus } from 'claude-code'
2
3import type { Activity } from '../types'
4
5export const POLICY = {
6 idleMilliseconds: 20 * 60 * 1000,
7 contextTokenThreshold: 200_000,
8 checkIntervalMilliseconds: 60 * 1000,
9} as const
10
11const WORKING_AGENT_STATUSES: ReadonlySet<AgentStatus> = new Set(['pending', 'running', 'waiting'])
12
13export const INITIAL_ACTIVITY: Activity = { kind: 'unobserved' }
14
15export type Observation = {
16 readonly now: number
17 readonly contextTokens: number
18 readonly hasWorkingAgents: boolean
19}
20
21export type ActivityEvent =
22 | { readonly kind: 'session-started'; readonly at: number }
23 | { readonly kind: 'turn-started' }
24 | { readonly kind: 'turn-completed'; readonly at: number; readonly agentId?: string }
25 | { readonly kind: 'checked'; readonly observation: Observation }
26
27export const isWorking = (agent: AgentInfo): boolean => WORKING_AGENT_STATUSES.has(agent.status)
28
29const isDueForCompaction = (idleSince: number, { now, contextTokens }: Observation): boolean =>
30 now - idleSince >= POLICY.idleMilliseconds && contextTokens > POLICY.contextTokenThreshold
31
32const afterCheck = (activity: Activity, observation: Observation): Activity => {
33 if (activity.kind !== 'idle') return activity
34 if (observation.hasWorkingAgents) return { kind: 'idle', since: observation.now }
35 if (isDueForCompaction(activity.since, observation)) return { kind: 'compacted', claimedAt: observation.now }
36 return activity
37}
38
39export const transition = (activity: Activity, event: ActivityEvent): Activity => {
40 switch (event.kind) {
41 case 'session-started':
42 return activity.kind === 'unobserved' ? { kind: 'idle', since: event.at } : activity
43 case 'turn-started':
44 return { kind: 'busy' }
45 case 'turn-completed':
46 return event.agentId === undefined ? { kind: 'idle', since: event.at } : activity
47 case 'checked':
48 return afterCheck(activity, event.observation)
49 }
50}
51
52export const isClaimedBy = (activity: Activity, observation: Observation): boolean =>
53 activity.kind === 'compacted' && activity.claimedAt === observation.now
54types/index.d.ts 12 lines1export type Activity =
2 | { readonly kind: 'unobserved' }
3 | { readonly kind: 'busy' }
4 | { readonly kind: 'idle'; readonly since: number }
5 | { readonly kind: 'compacted'; readonly claimedAt: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'idle-compact': { activity: Activity }
10 }
11}
12