SLOPSHOPPER

self-compact

Lets a session compact itself as it closes one piece of work and moves to the next, then resume on its own: a tool queues the compaction, and it runs as the…

newguardtoaststatustooltimer
A shopper browsing a rack in a slop shop
README

self-compact

Lets a session compact its own conversation at the break between two pieces of work, then carry on by itself. Part of claude-code-self-tools.

Tool

compact_after_turn (Claude sees mcp__self-compact__compact_after_turn):

  • keep (required): what the summary must hold on to, such as open threads, decisions and where the session's notes are. It works like the text you type after /compact.
  • resume (optional): the message that starts the session's next turn once it has compacted, naming the next piece of work and where its details are.

How it behaves

  • The compaction waits for the turn to end. Claude Code can't compact while a turn is running, so the call queues it. If a message you sent starts a new turn first, the compaction waits for that turn to end as well.
  • With resume, the session carries on. Once compacted, it sends itself the resume message, which starts a new turn. If Claude Code skips the compaction, the message is still sent and says so. Without resume, the session waits for your next message.
  • One resume per compaction. A resumed turn compacts again only if it calls the tool again.
  • An interrupted turn cancels it, with a notice.
  • Subagents can't use it. A subagent compacts on its own near its context limit; one that calls the tool is told to finish its piece and return its result instead.

Install

/plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-compact@claude-code-self-tools

Pairs with self-context, whose context_usage tells the session how full the window is before it decides.

Source 2 files
hooks/register.ts 113 lines
1// A session compacts itself as it closes one piece of work and moves to the next. The model calls
2// compact_after_turn with what the summary must keep; compaction can't run while a turn does ($.session.compact
3// rejects then), so the call is queued and runs once the turn has ended. An interrupted turn
4// cancels it. Only the main conversation compacts this way, never a subagent. With `resume`, the
5// session sends itself that message once compacted, a turn of its own; one per compaction, so a
6// resumed turn compacts again only by calling the tool again.
7import type { EngineInterface, Register } from 'claude-code'
8import type { Queued } from '../types'
9
10const queued = { plugin: 'self-compact', key: 'queued' } as const
11
12/** A beat after the turn's end, so the turn is no longer running when compaction starts. */
13const SETTLE_MS = 500
14
15const DESCRIPTION = [
16  'Compacts this conversation once the current turn ends, as /compact does, so the next piece of work starts with room to go deep.',
17  'Call it when you are closing one piece of work in your area and moving to the next: the piece merged or handed off, and its notes written where your notes live.',
18  'Never mid-piece, and never when the next step needs detail the summary would drop.',
19  "`keep` is what the summary must hold on to: your area's open threads, PR numbers and heads, decisions, and where your notes are.",
20  '`resume` is the message that starts your next turn once compacted: the next piece and where its details are.',
21  'Give it whenever you have a next piece of your own, so the work goes on without a halt; leave it out only when the next step waits on someone else (the user, another session, a check).',
22  'End the turn after calling it; the compaction starts once your answer is shown.',
23].join(' ')
24
25/** What is queued: `resume` only when there is one, since stored state holds no undefined. */
26const entry = (keep: string, resume: string | undefined): Queued => (resume === undefined ? { keep } : { keep, resume })
27
28/** Trimmed text, or undefined when it is not a string or is blank. */
29function text(raw: unknown): string | undefined {
30  const trimmed = typeof raw === 'string' ? raw.trim() : ''
31  return trimmed === '' ? undefined : trimmed
32}
33
34async function compactNow($: EngineInterface, { keep, resume }: Queued): Promise<void> {
35  $.ui.status(undefined)
36  let skipped: string | undefined
37  try {
38    const done = await $.session.compact({ instructions: keep })
39    skipped = done.skip
40  } catch {
41    // A turn started first (a queued message): run it when that turn ends.
42    await $.state.set(queued, entry(keep, resume))
43    $.ui.status('compaction queued for when this turn ends')
44    return
45  }
46  if (skipped !== undefined) $.ui.toast(`Compaction skipped: ${skipped}`)
47  if (resume === undefined) return
48  const said = skipped === undefined ? 'Compacted.' : `Compaction was skipped (${skipped}).`
49  try {
50    const sent = await $.prompt.submit({ text: `${said} Resume: ${resume}` })
51    if (sent.drop !== undefined) $.ui.toast(`Resume not sent: ${sent.drop}`)
52  } catch {
53    $.ui.toast('Resume not sent; the session waits for the next message.')
54  }
55}
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    const started = await next(e)
60    await $.tool.register({
61      name: 'compact_after_turn',
62      description: DESCRIPTION,
63      inputSchema: {
64        type: 'object',
65        properties: {
66          keep: { type: 'string', description: 'What the summary must keep, as typed after /compact.' },
67          resume: {
68            type: 'string',
69            description:
70              'The message that starts your next turn once compacted: the next piece and where its details are. Leave it out only when the next step waits on someone else.',
71          },
72        },
73        required: ['keep'],
74        additionalProperties: false,
75      },
76      isDeferred: false,
77    })
78    return started
79  })
80
81  on('tool.call', { tool: 'mcp__self-compact__compact_after_turn' }, async ($, e) => {
82    if (e.agentId !== undefined) {
83      return {
84        deny:
85          "A subagent can't compact on demand; it compacts on its own near its context limit. To close a piece cleanly, finish it and return its result: the lead starts a fresh agent for the next piece.",
86      }
87    }
88    const input = e as { keep?: unknown; resume?: unknown }
89    const keep = text(input.keep)
90    if (keep === undefined) return { deny: 'Say what the summary must keep (`keep`).' }
91    const resume = text(input.resume)
92    await $.state.set(queued, entry(keep, resume))
93    $.ui.status('compaction queued for when this turn ends')
94    const then = resume === undefined ? 'then waits for the next message' : `then resumes with: ${resume}`
95    return { result: `Queued. This conversation compacts once this turn ends, keeping: ${keep}. It ${then}` }
96  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-compact: queuing the compaction failed; nothing is queued.' }))
97
98  on('turn.complete', async ($, e, next) => {
99    const done = await next(e)
100    if (e.agentId !== undefined) return done
101    const { value } = await $.state.get(queued)
102    if (!value) return done
103    await $.state.set(queued, null)
104    if (e.isAborted || e.reason !== 'answer') {
105      $.ui.status(undefined)
106      $.ui.toast("Compaction cancelled: the turn didn't finish.")
107      return done
108    }
109    $.clock.after(SETTLE_MS, () => void compactNow($, value))
110    return done
111  })
112}
113
types/index.d.ts 12 lines
1/**
2 * A compaction the session asked for, waiting for its turn to end: what the summary must keep, and
3 * what the session resumes with once compacted (absent: it stays idle until the next message).
4 */
5export type Queued = { keep: string; resume?: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'self-compact': { queued: Queued | null }
10  }
11}
12