SLOPSHOPPER

idle-compact

Runs /compact while the session sits idle waiting on background work, right before the prompt cache expires.

newtoaststatustimer
v0.1.0no licenseupdated 2026-10-04ShadowDog007/claude-mods/plugins/idle-compact
A shopper browsing a rack in a slop shop
README

idle-compact

Automatically runs /compact when a session has been left waiting on background work, right before the prompt cache expires.

It compacts when all of these hold:

  1. The current turn has ended (the agent is not working).
  2. At least one background task (a background shell command, subagent, monitor, ...) is still running.
  3. The context holds at least minContextTokens (default 100,000) tokens.
  4. idleMinutes (default 59) have passed since the last model request of the main conversation was sent.

It compacts at most once per idle stretch; the next model request re-arms it. When a turn ends with background work running, it sets a timer for the moment the idle time runs out and checks the conditions again when it fires.

The idle time counts from when a request is sent, because that is when the prompt cache's lifetime is refreshed. Compacting just before the one-hour lifetime runs out means the compaction request still reads the conversation from cache, and the session wakes to a smaller context when the background work reports back. The default leaves a minute for the compaction request to be sent; keep idleMinutes under 60. This only helps where the session uses the one-hour cache; under the five-minute cache, the cache has expired long before any useful idle time.

While a compaction is scheduled and the context is big enough for it, the plugin pins idle-compact scheduled for HH:MM (local time) as its own status line under the prompt. The line clears when a turn starts, when it compacts, or when there's nothing left to compact for. When it compacts, it also shows a toast.

Configuration

OptionTypeDefaultDescription
idleMinutesnumber59Minutes since the last model request before an idle session with background work running is compacted.
minContextTokensnumber100000Only compact when the context holds at least this many tokens.

Set them from /config, or in settings:

{ "pluginConfigs": { "idle-compact": { "options": { "idleMinutes": 57, "minContextTokens": 150000 } } } }

Notes

  • Background tasks are taken from the Stop event at the end of each turn, and checked again just before compacting:
  • Background subagents are checked live ($.agent.list()); one that is not pending, running or waiting does not count.
  • Shell commands and monitors have no live listing in the plugin API. Each sends a notification when it ends (stopped or killed too), which starts a turn, and that turn's Stop takes the count again.
  • An interrupted turn counts no background tasks, so nothing is compacted until a turn ends normally.
  • The context size is the input of the last model response, as the status line reports it.
  • What it tracks is kept in the session's plugin state, so a reload of the plugin (such as changing an option in /config) carries on where it was. /clear resets it.
Source 2 files
hooks/register.ts 168 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register, Timer } from 'claude-code';
3
4import type { IdleCompactTracker } from '../types';
5
6const DEFAULT_IDLE_MINUTES = 59;
7const DEFAULT_MIN_CONTEXT_TOKENS = 100_000;
8// A subagent still doing work; `idle` is a teammate waiting on a message.
9const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting']);
10
11type Limits = { idleMs: number; minContextTokens: number };
12
13const IDLE: IdleCompactTracker = {
14  lastModelCallAt: null,
15  contextTokens: null,
16  backgroundTasks: [],
17  hasCompacted: false,
18};
19
20// Kept in the session's state, so a reload of the module carries on where it
21// was rather than waiting for the next turn.
22const tracker = atom({ plugin: 'idle-compact', key: 'tracker' } as const, IDLE);
23
24function track($: EngineInterface, change: Partial<IdleCompactTracker>) {
25  return update($, tracker, current => ({ ...current, ...change }));
26}
27
28// The one pending compaction. A plain variable: a reload cancels the timer
29// along with the module, and `session.start` arms it again from the state.
30let timer: Timer | undefined;
31
32function disarm($: EngineInterface) {
33  timer?.cancel();
34  timer = undefined;
35  $.ui.status(undefined);
36}
37
38// The time of day `at` falls on, as hours and minutes.
39function clockTime(at: number) {
40  const date = new Date(at);
41  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
42}
43
44// Pins when the armed timer will compact, if the context is big enough for it
45// to; clears it otherwise. The status line is this plugin's own, beside the
46// engine's pinned notices, so it covers nothing else on screen.
47async function showSchedule($: EngineInterface, limits: Limits) {
48  const session = await read($, tracker);
49  const isDue = timer !== undefined && (session.contextTokens ?? 0) >= limits.minContextTokens;
50  $.ui.status(
51    isDue && session.lastModelCallAt !== null
52      ? `idle-compact scheduled for ${clockTime(session.lastModelCallAt + limits.idleMs)}`
53      : undefined,
54  );
55}
56
57// Sets the timer for when the session will have sat idle for the idle time,
58// if it is waiting on background work and has not compacted since its last
59// model request.
60async function arm($: EngineInterface, limits: Limits) {
61  disarm($);
62  const session = await read($, tracker);
63  if (session.hasCompacted) return;
64  if (session.backgroundTasks.length < 1 || session.lastModelCallAt === null) return;
65  const remainingMs = session.lastModelCallAt + limits.idleMs - (await $.clock.now());
66  timer = $.clock.after(Math.max(remainingMs, 0), () => compactIfIdle($, limits));
67  await showSchedule($, limits);
68}
69
70// The tasks from the last turn's snapshot still running now. Subagents are
71// checked live; shell and monitor tasks have no live listing, but each sends
72// a notification when it ends (killed too), whose turn retakes the snapshot.
73async function runningTasks($: EngineInterface, tasks: IdleCompactTracker['backgroundTasks']) {
74  if (!tasks.some(task => task.type === 'subagent')) return tasks;
75  const activeAgents = new Set(
76    (await $.agent.list()).filter(agent => ACTIVE_AGENT.has(agent.status)).map(agent => agent.id),
77  );
78  return tasks.filter(task => task.type !== 'subagent' || activeAgents.has(task.id));
79}
80
81async function compactIfIdle($: EngineInterface, limits: Limits) {
82  timer = undefined;
83  $.ui.status(undefined);
84  const session = await read($, tracker);
85  if (session.hasCompacted) return;
86  if (session.backgroundTasks.length < 1 || session.lastModelCallAt === null) return;
87  if ((session.contextTokens ?? 0) < limits.minContextTokens) return;
88  // Not yet, should the timer have fired early: wait out the rest.
89  if ((await $.clock.now()) - session.lastModelCallAt < limits.idleMs) {
90    await arm($, limits);
91    return;
92  }
93
94  const backgroundTasks = await runningTasks($, session.backgroundTasks);
95  if (backgroundTasks.length < 1) {
96    await track($, { backgroundTasks });
97    return;
98  }
99
100  // Counted whether it lands or not, so a failure is not retried; the next
101  // model request re-arms it.
102  await track($, { hasCompacted: true });
103  try {
104    const result = await $.session.compact();
105    if ('skip' in result) $.ui.log(`idle-compact: compaction skipped: ${result.skip}`);
106    else $.ui.toast('idle-compact: compacted the idle session');
107  } catch (error) {
108    $.ui.log(`idle-compact: compaction failed: ${error instanceof Error ? error.message : String(error)}`);
109  }
110}
111
112function positive(value: unknown, fallback: number) {
113  const number = Number(value);
114  return number > 0 ? number : fallback;
115}
116
117export const register: Register = (on, options) => {
118  const limits: Limits = {
119    idleMs: positive(options.idleMinutes, DEFAULT_IDLE_MINUTES) * 60_000,
120    minContextTokens: positive(options.minContextTokens, DEFAULT_MIN_CONTEXT_TOKENS),
121  };
122
123  // Fires again on every reload, which arms the timer from the session state.
124  on('session.start', async ($, e, next) => {
125    await arm($, limits);
126    return next(e);
127  });
128
129  // A /clear starts the conversation over.
130  on('session.end', async ($, e, next) => {
131    if (e.reason === 'clear') {
132      disarm($);
133      await track($, IDLE);
134    }
135    return next(e);
136  });
137
138  on('session.measure', async ($, e, next) => {
139    await track($, { contextTokens: e.context.tokens ?? null });
140    await showSchedule($, limits);
141    return next(e);
142  });
143
144  on('turn.start', async ($, e, next) => {
145    // Retaken when the turn stops, so none count while it runs; an
146    // interrupted turn has no Stop and leaves none counted.
147    disarm($);
148    await track($, { backgroundTasks: [] });
149    return next(e);
150  });
151
152  on('turn.step', async function* ($, e, next) {
153    if (e.agentId === undefined) {
154      await track($, { lastModelCallAt: await $.clock.now(), hasCompacted: false });
155    }
156    return yield* next(e);
157  });
158
159  on('classic.Stop', async ($, e, next) => {
160    if (e.agent_id === undefined) {
161      const backgroundTasks = (e.background_tasks ?? []).map(({ id, type }) => ({ id, type }));
162      await track($, { backgroundTasks });
163      await arm($, limits);
164    }
165    return next(e);
166  });
167};
168
types/index.d.ts 23 lines
1export type IdleCompactTask = { id: string; type: string };
2
3// What the plugin tracks of the main conversation, kept in the session's
4// state so a reload (an option changed in /config, /reload-plugins) keeps it.
5export type IdleCompactTracker = {
6  // When the last model request was sent (the prompt cache's lifetime runs
7  // from there), in `$.clock.now()` milliseconds; null before the first.
8  lastModelCallAt: number | null;
9  // The input tokens of the last response; null until one is measured.
10  contextTokens: number | null;
11  // The background tasks the last finished turn left in flight; emptied when
12  // a turn starts, so empty while one runs and after one is interrupted.
13  backgroundTasks: IdleCompactTask[];
14  // One compaction per idle stretch: the next model request re-arms it.
15  hasCompacted: boolean;
16};
17
18declare module 'claude-code' {
19  interface PluginState {
20    'idle-compact': { tracker: IdleCompactTracker };
21  }
22}
23