SLOPSHOPPER

manager

Buttons above the prompt: compact, clear or rewind the conversation, commit and push, or write a handoff, clear, and resume from it.

newbandguardtoasttimer
v0.3.1no licenseupdated 2026-10-10mlt-5/manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · manager
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Context window 97.4k / 200k (49%) ████████████████████░░░░░░░░░░░░░░░░░░░░ [ Compact ] [ Clear ] [ Commit & push ] [ Clear + handoff ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context window 97.4k / 200k (49%) ████████████████████░░░░░░░░░░░░░░░░░░░░ [ Compact ] [ Clear ] [ Commit & push ] [ Clear + handoff ]
README

manager

A Claude Code mod that adds a band above the prompt with a live context-window meter and five one-click buttons for keeping a long session under control.

Preview

Desktop (Code tab):

manager band in the desktop app

Terminal:

Context window                     62.5k / 1M (6%)
██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

[ Compact ] [ Clear ] [ Commit & push ] [ Clear + handoff ]

The bar turns to the warning color once the context window is 80% full.

Buttons

ButtonWhat it does
CompactRuns /compact, then refreshes the meter.
ClearAsks "Are you sure you want to clear the conversation?" with Yes and No. On Yes it runs /clear to start a fresh conversation, then refreshes the meter.
RewindRuns /rewind to open the rewind picker, so you can restore the conversation and code to an earlier point. Shown only where /rewind exists (the terminal); the desktop Code tab has no /rewind.
Commit & pushAsks Claude to commit every change in the repository with a descriptive message and push the current branch. Claude says so instead if there's no repository, nothing to commit, or no upstream.
Clear + handoffRuns the mattpocock-skills:handoff skill to write a handoff document. When that turn ends, it runs /clear and starts a new turn that reads the handoff file and picks up where it left off. While this runs, the band shows "Writing handoff…" and then "Clearing and resuming from handoff…".

The buttons only work between turns. While Claude is working they're dimmed and do nothing.

If the handoff turn is interrupted, or no handoff file was written, the conversation is kept and a toast says why.

Requirements

  • Claude Code with mods (plugin hooks) support, in the terminal or the desktop Code tab.
  • Clear + handoff needs the mattpocock-skills plugin, which provides the handoff skill.

Install

In a Claude Code terminal session:

/plugin install manager --marketplace mlt-5/manager

Answer y to add the marketplace, then pick a scope. Installed at the user scope, it also loads in the desktop Code tab.

Without the CLI, add this to ~/.claude/settings.json and start a new session:

{
  "extraKnownMarketplaces": {
    "manager": { "source": { "source": "github", "repo": "mlt-5/manager" } }
  },
  "enabledPlugins": { "manager@manager": true }
}

To run it from a local folder while developing:

claude --plugin-dir "/path/to/manager"

Files

manager/
├── .claude-plugin/plugin.json   manifest
├── .claude-plugin/marketplace.json  makes the repo a marketplace
├── hooks/hooks.json             lists the hooks module
├── hooks/register.tsx           the mod: meter, buttons, handoff flow
├── types/index.d.ts             types for the mod's saved state (phase, contextFill)
├── tests/                       buttons.test.ts, context.test.ts
├── docs/preview.svg             the preview image above
└── tsconfig.json

.claude-plugin/types/ holds editor types that Claude Code generates when it loads the mod. It recreates them, so there's no need to edit or commit them.

Development

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 202 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextFill, Phase } from '../types'
5
6const HANDOFF_SKILL = 'mattpocock-skills:handoff'
7
8const CLEAR_QUESTION = 'Are you sure you want to clear the conversation?'
9
10const COMMIT_PROMPT =
11  'I pressed the "Commit & push" button: commit all current changes in this repository with a clear commit message describing them, then push the current branch to its remote. If this is not a git repository, or there is nothing to commit, or the branch has no upstream, say so instead of guessing.'
12
13const phase = atom({ plugin: 'manager', key: 'phase' } as const, 'idle' as Phase)
14const contextFill = atom({ plugin: 'manager', key: 'contextFill' } as const, null as ContextFill | null)
15
16// Desktop draws each block character wider than a cell, so it gets fewer to keep the bar on one line.
17const barChars = (surface: string) => (surface === 'desktop' ? 30 : 40)
18
19// 60600 -> "60.6k", 1000000 -> "1M"
20const formatTokens = (n: number) => {
21  const [value, unit] = n >= 1e6 ? [n / 1e6, 'M'] : n >= 1e3 ? [n / 1e3, 'k'] : [n, '']
22  return `${Number(value.toFixed(1))}${unit}`
23}
24
25async function refreshFill($: EngineInterface) {
26  const { context } = await $.session.usage()
27  await update($, contextFill, () => ({ tokens: context.tokens, window: context.window, percent: context.percent }))
28}
29
30export const register: Register = on => {
31  // Path of the last file written while the handoff skill ran.
32  let handoffPath: string | undefined
33
34  on('session.start', async ($, e, next) => {
35    const result = await next(e)
36    await refreshFill($).catch(() => {})
37    return result
38  })
39
40  on('session.measure', async ($, e, next) => {
41    const { tokens, window, percent } = e.context
42    await update($, contextFill, () => ({ tokens, window, percent }))
43    return next(e)
44  })
45
46  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
47    if ((await read($, phase)) === 'writing-handoff') {
48      handoffPath = e.file_path
49    }
50
51    return next(e)
52  }).catch(($, e, next) => next(e)) // never block a Write on our failure
53
54  on('turn.complete', async ($, e, next) => {
55    const result = await next(e)
56
57    if (e.agentId !== undefined || (await read($, phase)) !== 'writing-handoff') {
58      return result
59    }
60
61    const path = handoffPath
62    handoffPath = undefined
63
64    if (e.isAborted || path === undefined) {
65      await update($, phase, () => 'idle')
66      $.ui.toast(e.isAborted ? 'Handoff interrupted; conversation kept.' : 'No handoff file was written; conversation kept.')
67
68      return result
69    }
70
71    await update($, phase, () => 'resuming')
72
73    // Wait for the turn to settle before clearing and resuming.
74    $.clock.after(0, async () => {
75      try {
76        await $.command.run({ command: 'clear' })
77        await $.prompt.submit({
78          text: `Continue from the handoff document at ${path}. Read it first, then pick up where it leaves off.`,
79        })
80      } catch (err) {
81        $.ui.toast(`Handoff resume failed: ${String(err)}. Handoff file: ${path}`)
82      } finally {
83        await update($, phase, () => 'idle')
84      }
85    })
86
87    return result
88  })
89
90  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91    if (e.props.hasSurvey) {
92      return next(e)
93    }
94
95    const { Box, Button, Text } = $.ui.resolve(e)
96    const current = await read($, phase)
97
98    if (current !== 'idle') {
99      return (
100        <Box>
101          <Text dimColor>
102            {current === 'writing-handoff' ? 'Writing handoff…' : 'Clearing and resuming from handoff…'}
103          </Text>
104        </Box>
105      )
106    }
107
108    // The buttons act between turns only; while Claude works they show dim and do nothing.
109    const isWorking = e.props.isWorking
110    const run = async (command: string) => {
111      if (isWorking) return
112      try {
113        await $.command.run({ command })
114      } catch (err) {
115        $.ui.toast(`/${command} failed: ${err instanceof Error ? err.message : String(err)}`)
116      }
117      await refreshFill($).catch(() => {})
118    }
119
120    // /rewind exists in the terminal but not in every surface (the desktop Code tab has none), so the button shows only where it can run.
121    const canRewind = (await $.command.list().catch(() => [])).some(c => c.name === 'rewind')
122    const fill = await read($, contextFill)
123    const percent = fill?.percent ?? 0
124    const chars = barChars(e.surface)
125    const filled = Math.min(chars, Math.round((percent / 100) * chars))
126    const usedLabel =
127      fill === null
128        ? '…'
129        : `${fill.tokens === undefined ? '—' : formatTokens(fill.tokens)} / ${formatTokens(fill.window)} (${fill.percent ?? 0}%)`
130
131    return (
132      <Box flexDirection="column">
133        {/* One column nested in the band's root, so the buttons stay under the bar on every surface. */}
134        <Box key="context" flexDirection="column" alignSelf="flex-start">
135          {/* As wide as the bar, so the header's right end lines up with the bar's. */}
136          <Box flexDirection="column" alignSelf="flex-start">
137            <Box justifyContent="space-between" columnGap={2}>
138              <Text dimColor>Context window</Text>
139              <Text dimColor>{usedLabel}</Text>
140            </Box>
141            <Text>
142              <Text color={percent >= 80 ? 'warning' : 'suggestion'}>{'█'.repeat(filled)}</Text>
143              <Text dimColor>{'░'.repeat(chars - filled)}</Text>
144            </Text>
145          </Box>
146          <Box flexDirection="row" gap={1} marginTop={1}>
147            <Button
148              key="compact"
149              label="Compact"
150              dimColor={isWorking}
151              onPress={() => run('compact')}
152            />
153            <Button
154              key="clear"
155              label="Clear"
156              dimColor={isWorking}
157              onPress={async () => {
158                if (isWorking) return
159                // Anything but "Yes" (No, a typed answer, or closing the dialog) keeps the conversation.
160                const answer = await $.ui.ask(CLEAR_QUESTION, { options: ['Yes', 'No'], header: 'Clear' }).catch(() => undefined)
161                if (answer === 'Yes') await run('clear')
162              }}
163            />
164            {canRewind && (
165              <Button
166                key="rewind"
167                label="Rewind"
168                dimColor={isWorking}
169                onPress={() => run('rewind')}
170              />
171            )}
172            <Button
173              key="commit"
174              label="Commit & push"
175              dimColor={isWorking}
176              onPress={async () => {
177                if (!isWorking) await $.prompt.submit({ text: COMMIT_PROMPT })
178              }}
179            />
180            <Button
181              key="handoff"
182              label="Clear + handoff"
183              dimColor={isWorking}
184              onPress={async () => {
185                if (isWorking) return
186                handoffPath = undefined
187                await update($, phase, () => 'writing-handoff')
188                try {
189                  await $.command.run({ command: HANDOFF_SKILL })
190                } catch (err) {
191                  await update($, phase, () => 'idle')
192                  $.ui.toast(`Could not run /${HANDOFF_SKILL}: ${String(err)}`)
193                }
194              }}
195            />
196          </Box>
197        </Box>
198      </Box>
199    )
200  })
201}
202
types/index.d.ts 11 lines
1export type Phase = 'idle' | 'writing-handoff' | 'resuming'
2
3/** The live context window: tokens and percent are absent until the window's first response. */
4export type ContextFill = { tokens?: number; window: number; percent?: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    manager: { phase: Phase; contextFill: ContextFill | null }
9  }
10}
11