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

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:
minContextTokens (default 100,000) tokens.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.
| Option | Type | Default | Description |
|---|---|---|---|
idleMinutes | number | 59 | Minutes since the last model request before an idle session with background work running is compacted. |
minContextTokens | number | 100000 | Only 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 } } } }
Stop event at the end of each turn, and checked again just before compacting:$.agent.list()); one that is not pending, running or waiting does not count.Stop takes the count again./config) carries on where it was. /clear resets it.hooks/register.ts 168 lines1import { 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};
168types/index.d.ts 23 lines1export 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