SLOPSHOPPER

idle-compact

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

newtimer
v0.2.0no licenseupdated 2026-10-10bence-boer/agent-resources/plugins/idle-compact
A shopper browsing a rack in a slop shop
README

idle-compact

Runs /compact in a Claude Code session once both hold:

  • the main conversation has had no open turn for 20 minutes — a pending AskUserQuestion or permission prompt keeps the turn open, a background shell process does not, and a working background agent counts as activity;
  • the context window holds more than 200k tokens.

It compacts once per idle stretch; the next turn starts a new one. The thresholds are POLICY in hooks/activity.ts.

Install

/plugin install idle-compact --marketplace bence-boer/agent-resources

Layout

  • 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.

Develop

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-compact
Source 3 files
hooks/register.ts 44 lines
1import { 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}
44
hooks/activity.ts 54 lines
1import 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
54
types/index.d.ts 12 lines
1export 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