Hands off and clears before context gets dumb. Past a token limit, the next time Claude stops it writes NOTES.md + CLAUDE.md, commits, and tells you to start…

A Claude Code plugin that hands off and clears before context gets dumb.
Long sessions compacted over and over lose the constraints you stated early, and Claude gets worse as the context window fills. The fix everyone knows and nobody does by hand: keep state in files, /clear between tasks. This plugin does it for you.
Stop hook reads the session transcript and checks the live context size./no-dumb-zone:handoff.CLAUDE.md with durable learnings, writes NOTES.md with task state and next steps, commits, and tells you how to start fresh (/clear in the terminal, a new task in Cowork).UserPromptSubmit hook briefs Claude from NOTES.md. In the terminal it also names the session from the file's first line. You continue where you left off at ~20k tokens instead of 500k.It never interrupts a task. Stop only fires when Claude has stopped on its own, so a task in progress always finishes first.
On Claude Code 2.1.287 or later the plugin also loads a small mod, hooks/register.ts. It changes nothing about when the handoff fires; the hooks above still own that. It adds:
NDZ 212k / 500k 42%, dim until 80% of the limit, then highlighted. The count is Claude Code's own context figure, refreshed after every turn.Handoff now button on that line, and /ndz handoff for the keyboard. Either one runs the same handoff the hook would, before the limit./clear waiting in the prompt box once the handoff turn ends, so starting fresh is one Enter. A draft you are typing is never overwritten; the line then shows a Put /clear in the prompt button instead./ndz, which prints the same figures as text. It runs without a model turn, and it is the way to read the meter where nothing is drawn, such as claude -p.Nothing here is needed for the handoff to work. Where the mod does not load, or draws nothing, the plugin behaves exactly as before.
Cowork does not load the mod (checked on 0.4.0: /ndz is not a command in a Cowork task). The meter there is /no-dumb-zone:limit show, which prints context now: 310,000 tokens, 52% of the limit under the limit. It works in the terminal too; it costs a model turn where /ndz does not.
The plugin detects which surface it is on (CLAUDE_CODE_ENTRYPOINT=remote_cowork means Cowork; NDZ_SURFACE=cowork|terminal overrides) and behaves differently, because Cowork is a different shape:
| Terminal, VS Code, Claude Code on the web | Cowork | |
|---|---|---|
| Where the project is | On the same filesystem as the hooks | On your computer, in a connected folder; the hooks run in a cloud workspace and cannot see it |
Handoff writes NOTES.md to | the working directory | the connected folder, through the device shell |
| Fresh session | /clear | a new task in the same folder |
| Pickup | hook reads NOTES.md, sets the title, injects the notes | hook tells Claude where NOTES.md lives; Claude reads it from your computer on its first turn |
| Session name | from NOTES.md line 1 | Cowork names chats from your first message and ignores hook titles, so the handoff hands you a line to paste as that first message |
So in Cowork the handoff ends with two lines: the confirmation, and a line like inkbook: booking flow (3). Continue from NOTES.md. Start a new task in the same folder and paste that line. The chat gets a real name and Claude goes straight to the notes.
That paste line is also how a limit survives in Cowork. The limit file lives in the task's container and dies with it, so when the limit came from /no-dumb-zone:limit, the handoff line ends with Then /no-dumb-zone:limit 150000. and the pickup hook in the next task writes the file from that first message before Claude takes a turn. Test-sized limits (under 10k) are never carried, so a /no-dumb-zone:limit 1000 test does not re-fire in every task after it. It also works when the task's first turn restarts the session (it does when linking your computer loads the device tools): the hook then reads your first message from the transcript.
hooks.json has only been run on 2.1.293, so if an older Claude Code rejects its modules line, stay on 0.3.2 or update.node on PATH (v18+). Node is the one interpreter that spawns by the same name on Windows, macOS, Linux and the Cowork workspace, which is why the hooks use it.git on PATH for the commit step and the branch-name fallbackFrom GitHub (terminal, VS Code, Claude Code on the web):
claude plugin marketplace add CjRegan12/no-dumb-zone
claude plugin install no-dumb-zone@cjregan
This does not reach Cowork; for that, upload the zip (below).
Try it for one session without installing (PowerShell on Windows; macOS and Linux use the same flag):
git clone https://github.com/CjRegan12/no-dumb-zone
claude --plugin-dir .\no-dumb-zone
Install everywhere at once (Cowork + terminal): zip the folder contents and upload under Customize > Plugins > Add > Upload plugin in the desktop app:
cd path\to\no-dumb-zone
mkdir dist -Force | Out-Null
tar -a -c -f dist\no-dumb-zone-plugin.zip .claude-plugin/plugin.json .gitignore README.md hooks scripts skills
Use tar -a, not Compress-Archive: the latter writes backslash entry names that Linux unzippers read as flat filenames. Hooks and skills both load in Cowork, and the same install reaches your terminal sessions as no-dumb-zone@synced at the next session start. After changing the plugin, re-zip and re-upload; running sessions keep the old version, new ones get the new one.
Terminal only, from a local clone: keep using --plugin-dir, or set it once per shell with $env:CLAUDE_CODE_PLUGIN_DIRS = "C:\path\to\no-dumb-zone".
| Setting | Where | Default | Notes |
|---|---|---|---|
NDZ_LIMIT | env block in ~/.claude/settings.json (%USERPROFILE%\.claude\settings.json on Windows), or a project's .claude/settings.json | 500000 terminal, 600000 Cowork | Tokens. Cowork's default is higher because a Cowork task already carries ~130k of system prompt and tool schemas before you type. Both defaults favor long sessions over the published long-context data, which bends around 256k; lower them if you see quality slip. Must be below your auto-compact point or the hook never fires. On a 200k model use ~130000. |
autoCompactEnabled | same settings file | true | Set false to let this plugin replace compaction instead of racing it. |
/no-dumb-zone:limit | a skill; /no-dumb-zone:limit 100000, 100k, show, clear | Writes, prints or removes the limit file below and tells you which limit the hook will actually use. show also prints the session's current context against that limit. Works in the terminal and inside a Cowork task. Claude also runs it when you say "lower the handoff limit to 100k". | |
limit file | $CLAUDE_PLUGIN_DATA/limit (~/.claude/plugins/data/no-dumb-zone-synced/limit for the uploaded plugin, .../no-dumb-zone-inline/limit for --plugin-dir) | none | One number. Used when NDZ_LIMIT is unset. The only way to change the limit from inside a Cowork task, where env vars can't be set. In the terminal it persists across sessions; a Cowork container is thrown away with the task, so there it lasts one task, and the handoff's paste line carries it into the next (limits under 10k excepted). |
Example settings:
{
"env": { "NDZ_LIMIT": "130000" },
"autoCompactEnabled": false
}
Run /context in a session to see your window size and current usage.
no-dumb-zone/
.claude-plugin/plugin.json
hooks/hooks.json three hooks, exec form, node, plus the mod's entry under "modules"
hooks/register.ts the mod: meter above the prompt, Handoff now, /ndz, /clear prefill
scripts/ndz-check.js Stop: measure context, force the handoff once, then nag
scripts/ndz-allow-handoff.js PermissionRequest: approve the Skill call for this plugin's handoff, nothing else
scripts/ndz-pickup.js UserPromptSubmit: brief (and in the terminal, name) the new session from NOTES.md
scripts/ndz-limit.js set / show / clear the limit file; what /no-dumb-zone:limit runs
scripts/ndz-common.js shared helpers, surface detection, limit resolution
skills/handoff/SKILL.md the handoff procedure Claude follows
skills/limit/SKILL.md /no-dumb-zone:limit [tokens | show | clear]
tests/ndz-mod.test.ts the mod's tests (claude plugin test .); not part of the upload
Per-session markers live in $CLAUDE_PLUGIN_DATA (or ~/.cache/no-dumb-zone when run by hand) and are pruned after 7 days.
Calling a plugin skill through the Skill tool asks for permission. In a terminal you'd click yes; in headless, Cowork, or dontAsk sessions nobody can, and the handoff is silently denied. ndz-allow-handoff.js approves exactly one call, Skill(no-dumb-zone:handoff), and nothing else. If you'd rather approve it yourself, delete the PermissionRequest entry from hooks/hooks.json and add this to your settings instead:
{ "permissions": { "allow": ["Skill(no-dumb-zone:handoff)"] } }
Set a tiny limit in a scratch repo so the hook fires on the very first stop:
mkdir $env:TEMP\ndz-test; cd $env:TEMP\ndz-test; git init
$env:NDZ_LIMIT = "1000"
claude --plugin-dir C:\path\to\no-dumb-zone
Or, with the plugin installed, skip the env var and type /no-dumb-zone:limit 1000 as your first message.
Ask for one small thing. Claude should finish it, then run the handoff and tell you to /clear. Clear, type anything, and the session title should match the first line of NOTES.md. Remove $env:NDZ_LIMIT afterwards (Remove-Item Env:NDZ_LIMIT) or close the terminal.
Cowork: no env vars, so use the limit file. In a task with the project folder connected, type /no-dumb-zone:limit 1000, then ask for one small thing. Claude should finish it, run the handoff into the connected folder, and end with the two-line message. Start a new task, paste the second line, and Claude should read NOTES.md from the folder on its first turn.
Run a hook by hand with fake input:
'{"session_id":"t1","transcript_path":"C:\\path\\to\\some.jsonl","stop_hook_active":false}' | node scripts\ndz-check.js; $LASTEXITCODE
/no-dumb-zone:limit 130000) or set autoCompactEnabled: false./no-dumb-zone:limit wrote the file but the hook still uses the old number. NDZ_LIMIT in the environment wins over the file; /no-dumb-zone:limit show says so when that is the case. In Cowork the file is per task; it reaches the next task only through the handoff's paste line (... Then /no-dumb-zone:limit 150000.), so a task started with a plain first message starts at the default again. Type /no-dumb-zone:limit 150k there, or start the task with that line.claude plugin list says no-dumb-zone@synced was not loaded. You also installed it from the marketplace or --plugin-dir. Same name, so the terminal loads only the local copy and skips the synced one; Cowork is unaffected and keeps running the synced copy. Pick one for the terminal (claude plugin uninstall no-dumb-zone@cjregan to go back to synced).<hook> hook error in the transcript. Run claude --debug-file ndz.log and read the log. Usually node isn't on PATH for the process that launched Claude, or the transcript layout changed; ndz-check.js looks for message.usage on type: assistant lines.--name, /rename). It also only acts on the first prompt; check /hooks to confirm UserPromptSubmit is listed.NOTES.md. If you lost the line, <project>: <task>. Continue from NOTES.md. works too.NOTES.md./clear or opening the new task for you. Hooks can't run slash commands or drive the Cowork UI. In the terminal the mod gets as close as it can: /clear is typed into the prompt box and you press Enter.sessionTitle; the pasted first line is the workaround.hooks/register.ts 256 lines1// no-dumb-zone mod: a live context meter and a handoff button above the prompt.
2//
3// The settings hooks in hooks.json stay in charge of the handoff itself
4// (scripts/ndz-check.js on Stop fires it past the limit). This module only
5// draws and offers shortcuts, so it is safe wherever it loads and the plugin
6// works unchanged wherever it does not (Claude Code before 2.1.287, or a
7// surface that draws nothing).
8//
9// band NDZ 212k / 500k 42% [ Handoff now ]
10// /ndz the same figures as text, for surfaces that draw no band
11// /ndz handoff what the button does
12// after the handoff is written: puts /clear in an empty prompt box
13//
14// The limit is resolved exactly as scripts/ndz-common.js resolveLimit() does:
15// NDZ_LIMIT, then <data dir>/limit, then the surface default. Keep the two in
16// step; scripts are CommonJS run by node, this file is an ES module run by
17// Claude Code, so they cannot share code.
18
19import type { EngineInterface, Register } from 'claude-code'
20
21const PLUGIN = 'no-dumb-zone'
22const DEFAULT_LIMIT_TERMINAL = 500000
23const DEFAULT_LIMIT_COWORK = 600000
24const TEST_SIZED_BELOW = 10000
25
26type Source = 'env' | 'file' | 'default'
27type Phase = 'idle' | 'writing' | 'done'
28
29// What the band draws from. Module variables are lost on a reload, and that is
30// fine: session.start fires again and measures afresh.
31let tokens: number | undefined
32let limit = DEFAULT_LIMIT_TERMINAL
33let source: Source = 'default'
34let phase: Phase = 'idle'
35let isCowork = false
36let dir = ''
37let filledFor = ''
38
39function positiveInt(s: string | undefined): number {
40 const n = parseInt(String(s ?? '').trim(), 10)
41 return Number.isFinite(n) && n > 0 ? n : 0
42}
43
44/** Where the hooks keep the limit file and the handoff markers. Hook scripts
45 * get it as CLAUDE_PLUGIN_DATA; this process may not, so it is rebuilt the
46 * way Claude Code names it: <config dir>/plugins/data/<plugin id, @ as ->. */
47function dataDirFrom(
48 root: string,
49 pluginData: string | undefined,
50 configDir: string | undefined,
51 home: string | undefined,
52): string {
53 if (pluginData) return pluginData.replace(/\\/g, '/')
54 const r = root.replace(/\\/g, '/')
55 const cached = /\/plugins\/cache\/([^/]+)\//.exec(r)
56 let id = `${PLUGIN}-inline`
57 if (/\/plugins\/synced\//.test(r)) id = `${PLUGIN}-synced`
58 else if (cached) id = `${PLUGIN}-${cached[1]!.replace(/[^A-Za-z0-9_-]/g, '-')}`
59 const base = configDir || (home ? `${home.replace(/[\\/]+$/, '')}/.claude` : '')
60 return base ? `${base.replace(/\\/g, '/')}/plugins/data/${id}` : ''
61}
62
63function short(n: number): string {
64 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
65}
66
67function percent(): number {
68 return tokens === undefined ? 0 : Math.round((tokens / limit) * 100)
69}
70
71async function markerPath($: EngineInterface): Promise<string> {
72 return dir ? `${dir}/handoff-${await $.session.id()}` : ''
73}
74
75/** Re-reads everything the band shows except the token count. */
76async function refresh($: EngineInterface): Promise<void> {
77 const forced = ((await $.env.get('NDZ_SURFACE')) ?? '').toLowerCase()
78 isCowork = forced ? forced === 'cowork' : (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'remote_cowork'
79
80 dir = dataDirFrom(
81 $.plugin.root,
82 await $.env.get('CLAUDE_PLUGIN_DATA'),
83 await $.env.get('CLAUDE_CONFIG_DIR'),
84 (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
85 )
86
87 const fromEnv = positiveInt(await $.env.get('NDZ_LIMIT'))
88 let fromFile = 0
89 if (dir && (await $.fs.exists(`${dir}/limit`))) {
90 fromFile = positiveInt(await $.fs.read(`${dir}/limit`))
91 }
92 if (fromEnv) {
93 limit = fromEnv
94 source = 'env'
95 } else if (fromFile) {
96 limit = fromFile
97 source = 'file'
98 } else {
99 limit = isCowork ? DEFAULT_LIMIT_COWORK : DEFAULT_LIMIT_TERMINAL
100 source = 'default'
101 }
102
103 const marker = await markerPath($)
104 const hasMarker = marker !== '' && (await $.fs.exists(marker))
105 if (!hasMarker) phase = 'idle'
106 else if (phase === 'idle') phase = 'done'
107}
108
109function statusLine(): string {
110 const where = { env: 'NDZ_LIMIT', file: 'the limit file', default: 'the default' }[source]
111 const cap = limit.toLocaleString('en-US')
112 const fill =
113 tokens === undefined
114 ? `context not measured yet (no turn has run), limit ${cap} tokens`
115 : `${tokens.toLocaleString('en-US')} of ${cap} tokens (${percent()}%), limit`
116 const state = { idle: '', writing: ' Handoff in progress.', done: ' Handoff written: start fresh.' }[phase]
117 return `${fill} from ${where}.${state}`
118}
119
120/** The same request the Stop hook makes, sent on the person's say-so. */
121async function startHandoff($: EngineInterface): Promise<string> {
122 if (phase !== 'idle') return 'The handoff for this session is already written or under way.'
123 await refresh($)
124 const marker = await markerPath($)
125 // The Stop hook reads this marker as "handoff done": it will remind, not re-run.
126 if (marker) await $.fs.write(marker, String(tokens ?? 0))
127 phase = 'writing'
128 $.ui.invalidate('ui.render')
129
130 const carry =
131 isCowork && source === 'file' && limit >= TEST_SIZED_BELOW
132 ? ` Limit carry-over: this task's limit was set with /no-dumb-zone:limit, so end the second line of the handoff with " Then /no-dumb-zone:limit ${limit}." and the next task keeps it.`
133 : ''
134 await $.prompt.submit({
135 text:
136 'NO DUMB ZONE: handoff requested. Do not start anything new. ' +
137 'Run the /no-dumb-zone:handoff skill now, follow it exactly, then stop. ' +
138 (isCowork
139 ? "Surface: Cowork. The project folder is a connected folder on the user's computer, " +
140 'not in this workspace: write NOTES.md and CLAUDE.md there and commit there.' +
141 carry
142 : 'Surface: terminal.'),
143 })
144 return 'Handoff started.'
145}
146
147/** Puts /clear in the prompt box, once per session, and only into an empty box. */
148async function offerClear($: EngineInterface): Promise<void> {
149 if (isCowork) return
150 const id = await $.session.id()
151 if (filledFor === id) return
152 filledFor = id
153 if ((await $.prompt.read()).text.trim() !== '') return
154 await $.prompt.fill({ text: '/clear' })
155}
156
157export const register: Register = on => {
158 on('session.start', async ($, e, next) => {
159 await $.command.register({
160 name: 'ndz',
161 description: 'no-dumb-zone: show context against the handoff limit',
162 argumentHint: '[handoff]',
163 })
164 tokens = (await $.session.usage()).context.tokens
165 await refresh($)
166 return next(e)
167 })
168
169 // After each turn: the fill moved.
170 on('session.measure', async ($, e, next) => {
171 tokens = e.context.tokens
172 await refresh($)
173 $.ui.invalidate('ui.render')
174 return next(e)
175 })
176
177 // A /clear keeps this module loaded under a new session id.
178 on('session.end', ($, e, next) => {
179 tokens = undefined
180 phase = 'idle'
181 $.ui.invalidate('ui.render')
182 return next(e)
183 })
184
185 // Wraps the plugin's own Stop hook (scripts/ndz-check.js), which writes the
186 // marker the first time it sends Claude off to write the handoff. A marker
187 // that was there before this stop means the handoff turn itself just ended.
188 on('classic.Stop', async ($, e, next) => {
189 if (e.agent_id) return next(e)
190 const marker = await markerPath($)
191 const hadMarker = marker !== '' && (await $.fs.exists(marker))
192 const result = await next(e)
193 const hasMarker = marker !== '' && (await $.fs.exists(marker))
194 if (hasMarker && !hadMarker) phase = 'writing'
195 if (hadMarker && phase !== 'done') {
196 phase = 'done'
197 await offerClear($)
198 }
199 $.ui.invalidate('ui.render')
200 return result
201 // If this hook fails, the plugin's Stop hook must still run.
202 }).catch(($, e, next) => next(e))
203
204 on('command.run', { command: 'ndz' }, async ($, e) => {
205 if (e.args.trim().toLowerCase() === 'handoff') return { text: await startHandoff($) }
206 tokens = (await $.session.usage()).context.tokens
207 await refresh($)
208 return { text: statusLine() }
209 })
210
211 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212 if (e.props.hasSurvey || tokens === undefined) return next(e)
213 const { Box, Text, Button } = $.ui.resolve(e)
214 const pct = percent()
215 const meter = `NDZ ${short(tokens)} / ${short(limit)} ${pct}%`
216
217 if (phase === 'done') {
218 return Box({
219 flexDirection: 'row',
220 columnGap: 2,
221 children: [
222 Text({ color: 'warning', children: [`${meter} handoff written`] }),
223 Button({
224 key: 'ndz-clear',
225 label: 'Put /clear in the prompt',
226 onPress: async () => {
227 await $.prompt.fill({ text: '/clear' })
228 },
229 }),
230 ],
231 })
232 }
233
234 if (phase === 'writing') {
235 return Text({ color: 'warning', children: [`${meter} writing handoff…`] })
236 }
237
238 return Box({
239 flexDirection: 'row',
240 columnGap: 2,
241 children: [
242 pct >= 80
243 ? Text({ color: pct >= 100 ? 'error' : 'warning', children: [meter] })
244 : Text({ dimColor: true, children: [meter] }),
245 Button({
246 key: 'ndz-handoff',
247 label: 'Handoff now',
248 onPress: async () => {
249 await startHandoff($)
250 },
251 }),
252 ],
253 })
254 })
255}
256