SLOPSHOPPER

idle-compact

Compacts the session once it is 20% full and has been idle 50 minutes, while the prompt cache is still warm.

newprompttimer
v0.1.0no licenseupdated 2026-10-09wryskware/claude-mods/mods/idle-compact
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods by Wrysk, in one marketplace.

ModWhat it does
idle-compactCompacts a session that is at least 20% full once it has sat idle for 50 to 60 minutes, while the prompt cache is still warm.
codex-laneRuns worker jobs on Codex in the background, with completion notifications and a /codex pane. Needs the codex CLI.

Install

In any terminal session, at the prompt:

/plugin install idle-compact --marketplace wryskware/claude-mods
/plugin install codex-lane --marketplace wryskware/claude-mods

Answer y to add the marketplace, then choose the user scope so every session gets the mod. Its hooks run in that session from then on, with no reload.

Or from a shell:

claude plugin marketplace add wryskware/claude-mods
claude plugin install idle-compact@claude-mods --scope user
claude plugin install codex-lane@claude-mods --scope user

Update

To pick up a new version, run claude plugin marketplace update claude-mods and claude plugin update <mod>@claude-mods, then /reload-plugins in the session.

Develop

To work on a mod from a clone, add the clone as the marketplace instead of the GitHub one: claude plugin marketplace add <path to the clone>. Edits then reach a session on /reload-plugins, with no new version.

claude plugin validate mods/idle-compact
claude plugin test mods/idle-compact
claude plugin validate mods/codex-lane

Each mod is one folder under mods/: .claude-plugin/plugin.json, hooks/hooks.json naming the hooks module, and the *.test.ts files beside it. The mod's .claude-plugin/types/ is written by the engine and is not tracked.

Sharing

Anyone can install these from wryskware/claude-mods with the lines above. No clone is needed.

Source 1 files
hooks/register.ts 74 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// The prompt cache lapses an hour after the last request. A compaction reads
4// the whole context once, which is cheap only while that cache is still warm,
5// so it runs in the window from the idle mark until the cache lapses.
6const IDLE_MS = 50 * 60_000
7const CACHE_LAPSES_MS = 60 * 60_000
8const THRESHOLD_PERCENT = 20
9const TICK_MS = 60_000
10
11// Kept in the module, so a reload starts over. A fresh start counts as
12// activity, which errs toward waiting longer, never toward compacting early.
13let lastActivity: number | undefined
14let compactedFor: number | undefined
15let armed = false
16let busy = false
17
18const inWindow = (now: number, stamp: number) => {
19  const idle = now - stamp
20  return idle >= IDLE_MS && idle < CACHE_LAPSES_MS
21}
22
23const tick = async ($: EngineInterface) => {
24  if (busy || lastActivity === undefined) return
25  const stamp = lastActivity
26  if (compactedFor === stamp) return
27  busy = true
28  try {
29    if (!inWindow(await $.clock.now(), stamp)) return
30    const { context } = await $.session.usage()
31    if ((context.percent ?? 0) < THRESHOLD_PERCENT) return
32    // The usage read takes time: the window must still hold, and nothing may
33    // have run since, or the compaction is left alone.
34    if (lastActivity !== stamp || !inWindow(await $.clock.now(), stamp)) return
35    const result = await $.session.compact()
36    compactedFor = stamp
37    $.ui.log(`idle compact: ${result.skip ?? 'compacted'}`, { to: 'debug' })
38  } catch {
39    // Refused (a turn was running) or a failed read: the next tick tries
40    // again, for as long as the window holds.
41  } finally {
42    busy = false
43  }
44}
45
46const arm = ($: EngineInterface) => {
47  if (armed) return
48  armed = true
49  $.clock.every(TICK_MS, () => void tick($))
50}
51
52const touch = async ($: EngineInterface) => {
53  lastActivity = await $.clock.now()
54  arm($)
55}
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    lastActivity ??= await $.clock.now()
60    arm($)
61    return next(e)
62  })
63
64  on('prompt.submit', async ($, e, next) => {
65    await touch($)
66    return next(e)
67  }).catch(($, e, next) => next(e))
68
69  on('turn.complete', async ($, e, next) => {
70    await touch($)
71    return next(e)
72  })
73}
74