Tells the model its context size and plan usage at each 100k and usage mark, suggests a clear, logs it, and lets it compact or clear itself and carry on…

A small mod for Claude Code that keeps an eye on two things while you work:
When either crosses a mark, it tells the model (and logs it). It also gives the model two tools to make room and keep going: compact and continue, or clear and restart from a written brief.
context-watch is free and will stay free. It is a non-commercial project and will never be monetized. MIT licensed.
Every turn, the model re-reads the whole conversation so far. A turn at 300k tokens of context costs roughly three times what the same turn costs at 100k. Prompt caching makes re-reading cheaper, not free, and it still uses up your plan's limits.
So long sessions get expensive quietly. In the author's own use, most of a week's cost turned out to be re-reading long contexts, not doing new work.
The fix is a habit, not a feature:
context-watch helps with both halves:
clear_and_restart with a brief. The clear and the fresh start happen by themselves when the turn ends. No copy, /clear, paste.After each tool call in the main conversation, context-watch reads the session's usage and checks it against the marks:
contextStep (default 100k tokens).clearAt (off by default), with "land the work in files, then clear". /context-watch epoch moves it for the current context alone (below).fiveHourMarks).weeklyMarks).When one is crossed, a short notice is added to that tool result, which only the model reads:
[context-watch] context passed 200k: context 201k of 1000k; 5h 34% (resets 16:00Z),
week 41% (resets 00:00Z); autocompact at 950k. Landing work at a seam keeps a
compaction cheap; mcp__context-watch__compact_and_continue compacts and resumes
when that helps, and mcp__context-watch__clear_and_restart starts fresh from a
brief once the state is in files and no agents run.
Notes:
After each tool call the status line shows the same reading, with the 5-hour reset time in local time at the end:
context 201k of 1000k, clear at 250k; 5h 34%, week 41%; resets 18:00
clear at shows when a suggested clear is set, with (this context) after it when /context-watch epoch set the current context's own. Turn the status line off with statusLine: false.
Each crossing, compaction and clear is appended to context-watch.log in the data folder (default ~/.claude/context-watch.log). One line each: the time, the working folder, what happened, and the last three tool calls ("in hand"):
2026-01-05T14:02:11.120Z /home/me/src/app context passed 200k | context 201k of 1000k; ... | in hand: Bash: Run the tests / Edit: src/app.ts / Read: src/app.ts
Past logMaxBytes (default 200 KB) the log keeps its newer half.
Both are registered as MCP tools of the session, so the model sees them as:
mcp__context-watch__compact_and_continue with keep and nextmcp__context-watch__clear_and_restart with briefBoth are queued when called and run when the current turn ends, once the session is idle. The model is told to end its turn right after calling one.
compact_and_continue (keep, next)
keep).next as the prompt that resumes the work.next is in the summary's instructions too, so it isn't lost.clear_and_restart (brief)
<session id>.jsonl in the project's transcript folder), in case a detail is missing.If the session never goes idle, each tool gives up after about 10 minutes and logs it. A prompt you send before the clear runs drops it, as you are still reading and working in that conversation; the model is told at its next tool call.
resumeNote, when set, goes into both prompts before the model's own words: something every fresh start should do first, like reading the project's notes.
/context-watch shows the settings, the context's reading and how to change them:
context 120k of 1000k, clear at 250k
target 250k the suggested clear, every context
epoch the target's this context's own, until the next clear or compaction
step 100k a notice at every multiple
5h 50,80,95 5-hour usage marks (%)
...
/context-watch <name> <value> changes one:
| Name | Option | Example |
|---|---|---|
target | clearAt | target 250k, target +50k, target off |
epoch | none: the current context's suggested clear | epoch 400k, epoch +50k, epoch off, epoch reset |
step | contextStep | step 50k |
5h, week | fiveHourMarks, weeklyMarks | week 60,80,95, 5h none |
limitweek, limit5h | none: a usage limit for this window, until its meter resets | limitweek 44%, limitweek +14%, limit5h off |
resume | resumeNote | resume "Read NOTES.md first.", resume none |
status, log | statusLine, log | status off |
logcap, turns, data | logMaxBytes, keepTurns, dataDir | turns 5 |
epoch writes its /config row (pluginConfigs in ~/.claude/settings.json), which reloads the mod. While a compaction or clear is on its way it refuses, as the reload would drop it.epoch is held for the session and lasts until the next clear or compaction; then the next context starts on target again. epoch reset goes back to target right away.limitweek and limit5h cap a usage meter in the window that sets them: when a reading reaches the limit, the model is told to land what is in flight, say where things stand and start nothing new. The conversation stays as it is; nothing clears. A context that starts past the limit is told once. A limit holds in that window alone (other sessions on the machine carry on), through its clears, until its meter's window resets. +14% counts from the meter's reading now. (0.4.0 kept limits for every session and cleared at them; 0.5.0 drops that shared limit when it loads.)A /clear normally wipes the answer you were in the middle of reading. context-watch keeps the words of each turn in a small file per project (context-watch-said-<project path>.md in the data folder, the last keepTurns turns, newest first).
/clear, the clear's row at the top of the fresh conversation shows the last turn in full, as markdown, and names that file.Set keepTurns: 0 to turn this off.
| Event | What it does |
|---|---|
session.start | Registers the two tools and /context-watch. |
tool.call (every call) | Reads usage, updates the status line, checks the marks, adds the notice, logs. |
tool.call (its own two tools) | Checks the input and queues the compaction or clear. |
turn.complete | Saves the turn's words; starts a queued compaction or clear a second later. |
prompt.submit | Notes that a prompt came, so a compaction doesn't send a second resume prompt; one you typed also drops a queued clear. |
session.compact | Logs the tokens before and after; resets the context marks and the context's own suggested clear. |
command.run (/clear) | Holds the last turn's words for the clear's row; resets the context marks and the context's own suggested clear. |
command.run (/context-watch) | Shows the settings, or changes one. |
ui.render (CommandOutput) | Draws those words under /clear. |
register(on, options) API). That API is early access. context-watch was built and tested on Claude Code 2.1.30x.At the prompt of a Claude Code terminal session:
/plugin install context-watch --marketplace spychiatrist/context-watch
y to add the marketplace (github:spychiatrist/context-watch).It is active right away, and in every session after.
From a shell instead:
claude plugin install context-watch --marketplace spychiatrist/context-watch
Options can be set at install time with --config, e.g. --config contextStep=150000.
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with spychiatrist/context-watch, then Add plugin.
CLAUDE_CODE_PLUGIN_DIRSUseful if you want to read or change the code, or run it straight from a folder you control.
git clone https://github.com/spychiatrist/context-watch.git ~/.claude/mods/context-watch
env block of ~/.claude/settings.json (user settings; a project's settings are not read for this): {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/context-watch"
}
}
Several folders are separated by : (; on Windows). You can also export the variable in your shell instead.
Claude Code watches that folder: saving a file in it reloads the mod in a running session. A git pull updates it.
claude --plugin-dir ~/.claude/mods/context-watch
The notices already name the tools. A line in your CLAUDE.md makes the habit explicit, for example:
When context-watch says the context passed the suggested clear, finish the current
step, land it in files (commit, update the notes), then call clear_and_restart with a brief:
what to read first, what is in flight, what to do next.
Every option is optional. The defaults are what the mod has always done.
| Option | Type | Default | What it does |
|---|---|---|---|
contextStep | number | 100000 | A notice each time the context passes a multiple of this many tokens. |
clearAt | number | 0 | The suggested clear: a notice when the context passes this many tokens. 0: none. |
fiveHourMarks | string | "50,80,95" | Percentages of the 5-hour usage window that raise a notice. Empty: none. |
weeklyMarks | string | "50,75,90" | Percentages of the weekly usage window that raise a notice. Empty: none. |
resumeNote | string | "" | Put into every prompt that resumes after the mod's compaction or clear, before the model's own words. |
statusLine | boolean | true | Show the reading in the status line after each tool call. |
dataDir | string | "~/.claude" | Where the log and the per-project turn files go. Use an absolute path or one starting with ~; a relative path would land in each project. Created if missing. |
log | boolean | true | Write context-watch.log in the data folder. |
logMaxBytes | number | 200000 | Past this size the log keeps its newer half. Minimum 1000. |
keepTurns | number | 10 | Turns kept per project to show again after a /clear. 0 turns that off. |
How to set them:
/context-watch <name> <value> in a session (above)./config in a session. Each option is a row; a change reloads the mod.claude plugin configure context-watch shows the options and which are set.~/.claude/settings.json, under pluginConfigs. For Option B or C the key is the plugin's name: {
"pluginConfigs": {
"context-watch": {
"options": { "contextStep": 150000, "clearAt": 250000, "weeklyMarks": "60,80,95", "keepTurns": 5 }
}
}
}
All in the data folder (default ~/.claude):
context-watch.log: the log described above.context-watch-said-<project path>.md: the last turns' words for each project, the project's path with every non-alphanumeric character as -.Both are plain text and stay on your machine. The turn files hold the text of the model's recent answers, so treat them like your transcripts.
Installed from GitHub (Option A):
/plugin uninstall context-watch
/plugin marketplace remove context-watch
(or claude plugin uninstall context-watch and claude plugin marketplace remove context-watch from a shell)
From a clone (Option B):
CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.Either way, the files it wrote stay until you delete them:
rm ~/.claude/context-watch.log ~/.claude/context-watch-said-*.md
and remove the context-watch entry from pluginConfigs in ~/.claude/settings.json if you set options by hand.
session.compact don't run for plugins a person installs. There, compactions aren't logged and the context marks don't reset after one. Everything else works.clear_and_restart, background agents' reports may not reach the new conversation; use it when none are running. Anything not in files or the brief is gone (the old transcript is still on disk).claude plugin validate . # what the module hooks and calls, and anything Claude Code would refuse
claude plugin test . # runs hooks/register.test.tsx against Claude Code's test kit
To type-check, load the folder in a session once (Option B or C). Claude Code then writes the API types to .claude-plugin/types/ (ignored by git), and tsc -p . works.
Layout:
.claude-plugin/plugin.json: the manifest, with the options (userConfig)..claude-plugin/marketplace.json: makes this repo installable with /plugin install.hooks/hooks.json: names the hooks module.hooks/register.tsx: the mod.hooks/register.test.tsx: its tests.types/index.d.ts: the type of the state it keeps on screen.MIT. Free, non-commercial, and never to be monetized.
hooks/register.tsx 781 lines1// context-watch: tells the model where it stands (context size, the 5-hour and
2// weekly plan usage) each time a mark is crossed, logs it with what was in hand,
3// and gives it two tools to make room and carry on:
4//
5// - compact_and_continue compacts the conversation, telling the summarizer what
6// must survive, then resumes with a prompt the model wrote beforehand.
7// - clear_and_restart clears the conversation (no summary to lose things in) and
8// starts again from a brief the model wrote beforehand.
9//
10// The engine's own automatic compaction point is the settings' autoCompactWindow
11// (less its buffer), not this mod's.
12//
13// Each turn's words also go to a file (the last few, per project), so a clear never
14// loses the answer the user was reading:
15//
16// - After the user's /clear, the clear's row draws the last turn whole, as markdown.
17// - After clear_and_restart, the last answer is pasted at the end of the brief
18// prompt instead: a row drawn by the clear scrolls away before it can be read.
19// That clear draws no row, so it never shows an older clear's words.
20// - A prompt that comes while a compaction runs (the user's, a background task's
21// report, another session's message) resumes the session by itself, so the
22// resume prompt is skipped (it would run as a second "carry on"), and `next`
23// rides in the summary's instructions so nothing is lost.
24// - A prompt the user sends before a queued clear runs drops the clear: they are
25// reading and working in that conversation.
26//
27// A usage limit (/context-watch limitweek, limit5h) holds in the window that set it,
28// and reaching it tells the model to land what is in flight and stop. It never
29// clears the conversation.
30//
31// A suggested clear (clearAt) adds a notice when the context passes it. The
32// /context-watch command shows the settings and changes them: each one for good
33// (the rows /config shows), or the suggested clear for the current context alone,
34// until the next clear or compaction.
35import { atom, read, update } from 'claude-code'
36import type { EngineInterface, PluginOptions, Register, SessionUsage } from 'claude-code'
37
38import type { Epoch, Limit, Limits, Shown } from '../types'
39
40type Engine = EngineInterface
41
42const NAMES: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
43const TOOL = 'compact_and_continue'
44const CLEAR = 'clear_and_restart'
45const SAID_SEP = '\n\n<!-- turn -->\n\n'
46
47// The options, from the manifest's userConfig (see the README); defaults filled in.
48type Settings = {
49 step: number // a notice at every multiple of this many tokens of context
50 clearAt: number // the suggested clear, in tokens of context; 0, none
51 marks: Record<string, number[]> // usage percentages per window, ascending
52 resumeNote: string // put into every resume prompt, before the model's own words
53 statusLine: boolean
54 dataDir: string // the log and the words files; a leading ~ is HOME
55 log: boolean
56 logCap: number // bytes; past it the log keeps its newer half
57 keepTurns: number // turns the words file keeps; 0, none
58}
59
60const DEFAULTS: Settings = {
61 step: 100_000,
62 clearAt: 0,
63 marks: { five_hour: [50, 80, 95], seven_day: [50, 75, 90] },
64 resumeNote: '',
65 statusLine: true,
66 dataDir: '~/.claude',
67 log: true,
68 logCap: 200_000,
69 keepTurns: 10,
70}
71
72function num(v: unknown, fallback: number, min: number) {
73 return typeof v === 'number' && Number.isFinite(v) && v >= min ? Math.floor(v) : fallback
74}
75
76function flag(v: unknown, fallback: boolean) {
77 return typeof v === 'boolean' ? v : fallback
78}
79
80// "50, 80,95" -> [50, 80, 95]; an empty string, no marks.
81function percents(v: unknown, fallback: readonly number[]) {
82 if (typeof v !== 'string') return [...fallback]
83 return v
84 .split(',')
85 .map(s => s.trim())
86 .filter(s => s !== '')
87 .map(Number)
88 .filter(n => Number.isFinite(n) && n > 0 && n <= 100)
89 .sort((a, b) => a - b)
90}
91
92function settings(o: PluginOptions): Settings {
93 const dir = typeof o.dataDir === 'string' ? o.dataDir.trim().replace(/(.)\/+$/, '$1') : ''
94 return {
95 step: num(o.contextStep, DEFAULTS.step, 1),
96 clearAt: num(o.clearAt, DEFAULTS.clearAt, 0),
97 marks: {
98 five_hour: percents(o.fiveHourMarks, DEFAULTS.marks.five_hour ?? []),
99 seven_day: percents(o.weeklyMarks, DEFAULTS.marks.seven_day ?? []),
100 },
101 resumeNote: typeof o.resumeNote === 'string' ? o.resumeNote.trim() : DEFAULTS.resumeNote,
102 statusLine: flag(o.statusLine, DEFAULTS.statusLine),
103 dataDir: dir || DEFAULTS.dataDir,
104 log: flag(o.log, DEFAULTS.log),
105 logCap: num(o.logMaxBytes, DEFAULTS.logCap, 1000),
106 keepTurns: num(o.keepTurns, DEFAULTS.keepTurns, 0),
107 }
108}
109
110let cfg: Settings = DEFAULTS
111
112// The words the clear's row draws: $.state, so a reload of the mod keeps them on screen.
113const shown = atom({ plugin: 'context-watch', key: 'shown' } as const, null)
114// The current context's own suggested clear: $.state too, as a change of a setting reloads the mod.
115const epoch = atom({ plugin: 'context-watch', key: 'epoch' } as const, null)
116
117// Module variables start over on a reload; the first reading after one is taken
118// silently, so a reload never repeats a notice.
119let bucket: number | undefined
120let lastTokens: number | undefined
121const marks: Record<string, number> = {}
122const recent: string[] = []
123let pending: { keep: string; next: string } | undefined
124let pendingClear: { brief: string } | undefined
125// Each meter's last reading against its limit, and the limits this context was told it is past.
126const seen: Record<string, number> = {}
127const told = new Set<string>()
128// Words for the model at its next tool call: a queued clear the user's prompt dropped.
129const notes: string[] = []
130// From the end of the turn that queued a compaction or clear until its resume prompt is sent: a
131// reload then would drop it, so /context-watch changes no setting meanwhile.
132let running = false
133// A prompt came while a compaction ran, from where (the user, a task's notice, a peer): its
134// turn resumes the session, so the resume prompt is not sent (it would run as a second
135// "carry on" after that turn). The mod's own prompts never reach its own hook.
136let resumedBy: string | undefined
137// The user's own prompt came after a clear was queued: they are reading and working in
138// this conversation, so the clear is dropped. A notice or a peer's message is not them.
139let spoke = false
140
141const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
142const clock = (iso?: string) => (iso ? new Date(iso).toISOString().slice(11, 16) + 'Z' : '?')
143
144function windows(u: SessionUsage, lim: Limits = {}, resets = true) {
145 return u.rateLimits
146 .filter(r => NAMES[r.kind])
147 .map(r => {
148 const l = lim[r.kind]
149 const notes = [...(l ? [`limit ${l.percent}%`] : []), ...(resets ? [`resets ${clock(r.resetsAt)}`] : [])]
150 return `${NAMES[r.kind]} ${r.percentUsed}%${notes.length ? ` (${notes.join('; ')})` : ''}`
151 })
152 .join(', ')
153}
154
155// The suggested clear in force: the current context's own, else the setting's (0, none).
156type Target = { at: number; own: boolean }
157
158async function target($: Engine): Promise<Target> {
159 const own = await read($, epoch)
160 if (own && own.session === (await $.session.id())) return { at: own.clearAt, own: true }
161 return { at: cfg.clearAt, own: false }
162}
163
164// The status line: no reset times but the 5-hour window's, last, in local time.
165function status(u: SessionUsage, t: Target, lim: Limits = {}) {
166 const line = now(u, t, lim, false)
167 const at = u.rateLimits.find(r => r.kind === 'five_hour')?.resetsAt
168 if (!at) return line
169 const d = new Date(at)
170 return `${line}; resets ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
171}
172
173function now(u: SessionUsage, t?: Target, lim: Limits = {}, resets = true) {
174 const c = u.context
175 let ctx = c.tokens === undefined ? 'context ?' : `context ${k(c.tokens)} of ${k(c.window)}`
176 if (t?.at) ctx += `, clear at ${k(t.at)}${t.own ? ' (this context)' : ''}`
177 const w = windows(u, lim, resets)
178 return w ? `${ctx}; ${w}` : ctx
179}
180
181// The usage limits /context-watch limitweek and limit5h set, by meter: in $.state, so each holds in
182// the window that set it alone (through its clears too), until its meter's window resets.
183const LIMITS: Record<string, string> = { limitweek: 'seven_day', limit5h: 'five_hour' }
184const limitCommand = (kind: string) => Object.keys(LIMITS).find(n => LIMITS[n] === kind) ?? kind
185const held = atom({ plugin: 'context-watch', key: 'limits' } as const, null)
186
187async function limits($: Engine): Promise<Limits> {
188 const all = (await read($, held)) ?? {}
189 const t = Date.now()
190 return Object.fromEntries(Object.entries(all).filter(([, l]) => !l.until || Date.parse(l.until) > t))
191}
192
193// What the model is told of a limit reached: land, say where things stand, start nothing new.
194const stopWords = (name: string, cmd: string, how: string) =>
195 `[context-watch] ${name} usage ${how} (/context-watch ${cmd}): land what is in flight (commit, notes), say in a ` +
196 "few lines where things stand and what waits, and start nothing new on this plan's usage. The conversation stays " +
197 `as it is; the user raises the limit with /context-watch ${cmd} +N, or off.`
198
199// A limit reached, crossed since the last reading or already past when the context began: a word
200// to the model, once a context. It never clears: the user may be reading the conversation.
201function limitWords(u: SessionUsage, lim: Limits): string[] {
202 const out: string[] = []
203 for (const r of u.rateLimits) {
204 const before = seen[r.kind]
205 seen[r.kind] = r.percentUsed
206 const l = lim[r.kind]
207 if (!l || r.percentUsed < l.percent || told.has(r.kind)) continue
208 told.add(r.kind)
209 const crossed = before !== undefined && before < l.percent
210 const how = crossed ? `reached its limit, ${l.percent}%` : `${r.percentUsed}% is past its limit, ${l.percent}%`
211 out.push(stopWords(NAMES[r.kind] ?? r.kind, limitCommand(r.kind), how))
212 }
213 return out
214}
215
216// The data folder, a leading ~ expanded; undefined where it needs a home and there is none.
217async function dataDir($: Engine) {
218 const dir = cfg.dataDir
219 if (dir !== '~' && !dir.startsWith('~/')) return dir
220 const home = await $.env.get('HOME')
221 return home ? `${home}${dir.slice(1)}` : undefined
222}
223
224async function log($: Engine, line: string) {
225 if (!cfg.log) return
226 const dir = await dataDir($)
227 if (!dir) return
228 const path = `${dir}/context-watch.log`
229 let text = ''
230 try {
231 text = await $.fs.read(path)
232 } catch {
233 text = ''
234 }
235 if (text.length > cfg.logCap) text = text.slice(text.indexOf('\n', text.length / 2) + 1)
236 const cwd = (await $.env.get('PWD')) ?? ''
237 await $.fs.write(path, `${text}${new Date().toISOString()} ${cwd} ${line}\n`)
238}
239
240// The words file: the project's last turns, newest first, each under its time and session.
241async function saidPath($: Engine) {
242 if (cfg.keepTurns <= 0) return undefined
243 const dir = await dataDir($)
244 if (!dir) return undefined
245 return `${dir}/context-watch-said${(await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')}.md`
246}
247
248async function readSaid($: Engine, path: string) {
249 try {
250 return await $.fs.read(path)
251 } catch {
252 return ''
253 }
254}
255
256// The turn's words: every assistant text since the prompt that started it.
257async function turnWords($: Engine) {
258 const messages = await $.session.messages()
259 const parts: string[] = []
260 for (let i = messages.length - 1; i >= 0; i--) {
261 const m = messages[i]
262 if (!m) continue
263 if (m.role === 'user' && !m.toolResults?.length) break
264 if (m.role === 'assistant' && m.text.trim()) parts.unshift(m.text.trim())
265 }
266 return parts.join('\n\n')
267}
268
269// The last answer: the assistant texts after the turn's last tool result, the words
270// the user was left reading.
271async function lastAnswer($: Engine) {
272 const messages = await $.session.messages()
273 const parts: string[] = []
274 for (let i = messages.length - 1; i >= 0; i--) {
275 const m = messages[i]
276 if (!m) continue
277 if (m.role === 'user') {
278 if (parts.length || !m.toolResults?.length) break
279 continue
280 }
281 if (m.text.trim()) parts.unshift(m.text.trim())
282 }
283 return parts.join('\n\n')
284}
285
286async function remember($: Engine, words: string) {
287 const path = await saidPath($)
288 if (!path) return
289 const text = await readSaid($, path)
290 const entry = `## ${new Date().toISOString().slice(0, 16)}Z (${await $.session.id()})\n\n${words}`
291 await $.fs.write(path, [entry, ...(text ? text.split(SAID_SEP) : [])].slice(0, cfg.keepTurns).join(SAID_SEP) + '\n')
292}
293
294// The newest turn in the words file, the conversation's last, held for the clear's row to
295// draw; how many characters it has (0, nothing held).
296async function stage($: Engine) {
297 const path = await saidPath($)
298 const entry = path ? (await readSaid($, path)).split(SAID_SEP)[0]?.trim() ?? '' : ''
299 const head = entry.match(/^## (\S+)[^\n]*\n+/)
300 const words = head ? entry.slice(head[0].length).trim() : entry
301 if (!path || !words) return 0
302 const at = head?.[1]?.slice(11) ?? ''
303 await update($, shown, () => ({ words, at, path }))
304 return words.length
305}
306
307// Held before the clear and again after it: the row is printed into scrollback as the
308// clear draws it and never drawn again, so words set only after the clear would leave
309// the previous clear's words on screen. The second hold is for a host whose clear
310// starts its state over. `how` is who cleared.
311async function replay($: Engine, how: string) {
312 const n = await stage($)
313 await log($, n ? `after the clear (${how}), showed the last turn's words (${n} characters)` : `after the clear (${how}), no words to show`)
314}
315
316// What crossed a mark since the last reading, in words; empty when nothing did.
317function crossings(u: SessionUsage, t: Target): string[] {
318 const out: string[] = []
319 const tokens = u.context.tokens
320 if (tokens !== undefined) {
321 const b = Math.floor(tokens / cfg.step)
322 if (bucket !== undefined && b > bucket) out.push(`context passed ${k(b * cfg.step)}`)
323 bucket = b // also goes down after a compaction
324 if (t.at && lastTokens !== undefined && lastTokens < t.at && tokens >= t.at)
325 out.push(`context passed ${k(t.at)}, the suggested clear${t.own ? ' for this context' : ''}: land the work in files, then clear`)
326 lastTokens = tokens
327 }
328 for (const r of u.rateLimits) {
329 const list = cfg.marks[r.kind]
330 if (!list) continue
331 const top = list.filter(m => r.percentUsed >= m).pop() ?? 0
332 const before = marks[r.kind]
333 if (before !== undefined && top > before) out.push(`${NAMES[r.kind]} usage passed ${top}%`)
334 marks[r.kind] = top // a window that reset starts over
335 }
336 return out
337}
338
339// The resume note, set off from what follows it; empty when none is set.
340const note = () => (cfg.resumeNote ? `${cfg.resumeNote} ` : '')
341
342// A clear or a compaction starts a new context, back on the setting's suggested clear.
343async function newContext($: Engine) {
344 bucket = undefined
345 lastTokens = undefined
346 told.clear()
347 await update($, epoch, () => null)
348}
349
350async function resume($: Engine, p: { keep: string; next: string }) {
351 try {
352 await compactAndResume($, p)
353 } finally {
354 running = false
355 }
356}
357
358async function compactAndResume($: Engine, p: { keep: string; next: string }) {
359 // compact() rejects while a turn runs: wait for the session to settle.
360 for (let tries = 0; tries < 120; tries++) {
361 try {
362 resumedBy = undefined
363 // `next` in the summary too, for when another prompt resumes it instead of ours.
364 const done = await $.session.compact({ instructions: `${p.keep}\n\nNext, once it resumes: ${p.next}` })
365 if ('skip' in done && done.skip !== undefined) {
366 await log($, `compaction skipped by a hook: ${done.skip}`)
367 return
368 }
369 // A plugin's own $.session.compact skips its own hooks, the session.compact one below too.
370 await newContext($)
371 // Checked last, just before the submit: nothing may come between.
372 if (resumedBy) {
373 await log($, `compacted; a prompt (${resumedBy}) came during it and resumes the session, so no resume prompt`)
374 return
375 }
376 await $.prompt.submit({ text: `Resuming after the compaction you asked for. ${note()}${p.next}` })
377 return
378 } catch {
379 await $.clock.sleep(5000)
380 }
381 }
382 await log($, 'compaction never found the session idle; gave up after 10 minutes')
383}
384
385// The same wait for an idle session, then /clear and the brief as the first prompt
386// after it, with where the cleared conversation's transcript is and the last answer
387// pasted at its end. The row stays empty, so it never shows an older clear's words.
388// A prompt from the user while it waits drops the clear. (A plugin's own
389// $.command.run skips its own hooks, so the /clear hook below never runs for this clear.)
390async function restart($: Engine, p: { brief: string }) {
391 try {
392 await clearAndBrief($, p)
393 } finally {
394 running = false
395 }
396}
397
398async function clearAndBrief($: Engine, p: { brief: string }) {
399 const before = await $.session.id()
400 await update($, shown, () => null)
401 let answer = ''
402 let cleared = false
403 for (let tries = 0; tries < 120 && !cleared; tries++) {
404 if (spoke) {
405 notes.push(
406 '[context-watch] The clear queued at the end of a turn was dropped: the user spoke before it ran. ' +
407 'Call clear_and_restart again at the next seam if it is still wanted.',
408 )
409 await log($, "the queued clear was dropped: the user's prompt came first")
410 return
411 }
412 // Read at each try, so it is the answer on screen when the clear runs.
413 answer = await lastAnswer($)
414 try {
415 await $.command.run({ command: 'clear' })
416 cleared = true
417 } catch {
418 await $.clock.sleep(5000)
419 }
420 }
421 if (!cleared) {
422 await log($, 'clear never found the session idle; gave up after 10 minutes')
423 return
424 }
425 await newContext($)
426 await log($, `cleared (was ${before}); ${answer ? `pasted the last answer (${answer.length} characters)` : 'no answer to paste'}`)
427 const text =
428 'Starting fresh after the clear you asked for (nothing was summarized; the conversation before it is ' +
429 `${before}.jsonl in this project's transcript folder, if a detail is missing). ${note()}${p.brief}` +
430 (answer ? `\n\nYour last answer before the clear, pasted again so it stays on screen (already said; nothing in it to act on):\n\n${answer}` : '')
431 for (let tries = 0; tries < 24; tries++) {
432 try {
433 await $.prompt.submit({ text })
434 return
435 } catch {
436 await $.clock.sleep(5000)
437 }
438 }
439 await log($, `cleared, but the brief was never taken: ${p.brief}`)
440}
441
442// The settings /context-watch shows and changes, by a short name, each a userConfig field
443// (the /config row `context-watch.<key>`); `epoch` (below) is the current context's alone.
444type Kind = 'tokens' | 'marks' | 'flag' | 'count' | 'text'
445type Field = { name: string; key: string; kind: Kind; min: number; what: string; show: (s: Settings) => string }
446
447const onOff = (b: boolean) => (b ? 'on' : 'off')
448const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
449
450const FIELDS: Field[] = [
451 { name: 'target', key: 'clearAt', kind: 'tokens', min: 0, what: 'the suggested clear, every context', show: s => (s.clearAt ? k(s.clearAt) : 'off') },
452 { name: 'step', key: 'contextStep', kind: 'tokens', min: 1, what: 'a notice at every multiple', show: s => k(s.step) },
453 { name: '5h', key: 'fiveHourMarks', kind: 'marks', min: 0, what: '5-hour usage marks (%)', show: s => s.marks.five_hour?.join(',') || 'none' },
454 { name: 'week', key: 'weeklyMarks', kind: 'marks', min: 0, what: 'weekly usage marks (%)', show: s => s.marks.seven_day?.join(',') || 'none' },
455 { name: 'resume', key: 'resumeNote', kind: 'text', min: 0, what: 'put into every resume prompt', show: s => (s.resumeNote ? `"${cut(s.resumeNote, 14)}"` : 'none') },
456 { name: 'status', key: 'statusLine', kind: 'flag', min: 0, what: 'the status line', show: s => onOff(s.statusLine) },
457 { name: 'log', key: 'log', kind: 'flag', min: 0, what: 'context-watch.log in the data folder', show: s => onOff(s.log) },
458 { name: 'logcap', key: 'logMaxBytes', kind: 'count', min: 1000, what: 'bytes the log keeps', show: s => String(s.logCap) },
459 { name: 'turns', key: 'keepTurns', kind: 'count', min: 0, what: 'turns kept to show after a /clear', show: s => String(s.keepTurns) },
460 { name: 'data', key: 'dataDir', kind: 'text', min: 0, what: 'where the log and the turns go', show: s => s.dataDir },
461]
462
463const HOW =
464 'Change one: /context-watch <name> <value>, as `target 300k`, `epoch 400k`, `epoch +50k`, `epoch reset`, ' +
465 '`limitweek 44%`, `limitweek +14%`, `5h 50,90`, `status off`, `resume none`. The settings are /config rows too.'
466
467// "250k", "1.5m", "250000"; "off", "none" or "0" is 0; "+50k" or "-50k" from `from`. Undefined: no number.
468function tokens(s: string, from: number): number | undefined {
469 const t = s.toLowerCase().replace(/[_,]/g, '')
470 if (t === 'off' || t === 'none') return 0
471 const m = t.match(/^([+-]?)(\d+(?:\.\d+)?)([km]?)$/)
472 if (!m) return undefined
473 const n = Math.round(Number(m[2]) * (m[3] === 'k' ? 1000 : m[3] === 'm' ? 1_000_000 : 1))
474 return m[1] === '+' ? from + n : m[1] === '-' ? Math.max(0, from - n) : n
475}
476
477// The value a field's /config row takes, from what was typed; a string says why it can't be one.
478function parse(f: Field, raw: string): { value: string | number | boolean } | string {
479 const lower = raw.toLowerCase()
480 if (f.kind === 'tokens' || f.kind === 'count') {
481 const n = f.kind === 'tokens' ? tokens(raw, f.key === 'clearAt' ? cfg.clearAt : cfg.step) : /^\d+$/.test(raw) ? Number(raw) : undefined
482 if (n === undefined) return `${f.name} takes a number${f.kind === 'tokens' ? ' of tokens, as 300k' : ''}.`
483 if (n < f.min) return `${f.name} is at least ${f.min}.`
484 return { value: n }
485 }
486 if (f.kind === 'flag') {
487 if (['on', 'true', 'yes'].includes(lower)) return { value: true }
488 if (['off', 'false', 'no'].includes(lower)) return { value: false }
489 return `${f.name} is on or off.`
490 }
491 if (f.kind === 'marks') {
492 if (lower === 'none' || lower === 'off') return { value: '' }
493 const list = percents(raw, [])
494 if (!list.length || list.length !== raw.split(',').filter(s => s.trim()).length)
495 return `${f.name} takes percentages from 1 to 100, as 50,80,95, or none.`
496 return { value: list.join(',') }
497 }
498 const text = raw.replace(/^(["'])(.*)\1$/s, '$2').trim()
499 return { value: lower === 'none' || lower === 'off' ? '' : text }
500}
501
502async function overview($: Engine) {
503 const u = await $.session.usage()
504 const t = await target($)
505 const lim = await limits($)
506 const row = (f: Field) => [f.name, f.show(cfg), f.what] as [string, string, string]
507 const rows: [string, string, string][] = [
508 ...FIELDS.slice(0, 1).map(row),
509 ['epoch', t.own ? (t.at ? k(t.at) : 'off') : "the target's", "this context's own, until the next clear or compaction"],
510 ...FIELDS.slice(1, 4).map(row),
511 ['limitweek', showLimit(lim.seven_day), 'stops new work once the week meter reaches it, in this window'],
512 ['limit5h', showLimit(lim.five_hour), 'stops new work once the 5-hour meter reaches it, in this window'],
513 ...FIELDS.slice(4).map(row),
514 ]
515 const w = Math.min(16, Math.max(...rows.map(r => r[1].length)))
516 return [now(u, t, lim), ...rows.map(([n, v, what]) => ` ${n.padEnd(10)}${v.padEnd(w)} ${what}`), HOW].join('\n')
517}
518
519const showLimit = (l?: Limit) => (l ? `${l.percent}%${l.until ? ` to ${l.until.slice(5, 16).replace('T', ' ')}Z` : ''}` : 'off')
520
521// /context-watch limitweek 44% (or +14%, from the meter's reading now, or off): once a reading reaches
522// it, the model is told to land and stop, in this window alone, until the meter's window resets.
523async function limit($: Engine, cmd: string, kind: string, raw: string): Promise<{ text: string; context?: string[] }> {
524 const all = await limits($)
525 const r = (await $.session.usage()).rateLimits.find(x => x.kind === kind)
526 const name = NAMES[kind] ?? kind
527 if (!raw) return { text: `${cmd}: ${showLimit(all[kind])}${r ? `; the ${name} meter reads ${r.percentUsed}%` : ''}.` }
528 told.delete(kind)
529 if (['off', 'none'].includes(raw.toLowerCase())) {
530 delete all[kind]
531 await update($, held, () => all)
532 return { text: `${cmd}: off.`, context: [`[context-watch] The user took the ${name} usage limit off: carry on.`] }
533 }
534 const m = raw.match(/^(\+?)(\d+(?:\.\d+)?)%?$/)
535 if (!m) return { text: `${cmd} takes a percentage of the ${name} meter: 44%, +14% (from its reading now), or off.` }
536 if (m[1] && !r) return { text: `No ${name} meter to count from on this plan.` }
537 const percent = Math.round((m[1] ? (r?.percentUsed ?? 0) : 0) + Number(m[2]))
538 if (percent < 1 || percent > 100) return { text: `${cmd} is between 1% and 100%.` }
539 all[kind] = { percent, ...(r?.resetsAt ? { until: r.resetsAt } : {}) }
540 await update($, held, () => all)
541 if (r) seen[kind] = r.percentUsed
542 const past = r !== undefined && r.percentUsed >= percent
543 if (past) told.add(kind)
544 await log($, `/context-watch ${cmd} ${raw}: ${showLimit(all[kind])}, the meter at ${r?.percentUsed ?? '?'}%`)
545 return {
546 text: past
547 ? `${cmd}: ${percent}%, reached already (${r.percentUsed}%): the model is told to land what is in flight and start nothing new.`
548 : `${cmd}: ${showLimit(all[kind])}, the meter at ${r?.percentUsed ?? '?'}%: once a reading reaches it, the model is told to land and stop, in this window alone.`,
549 context: past
550 ? [stopWords(name, cmd, `${r.percentUsed}% is past the limit the user just set, ${percent}%`)]
551 : [`[context-watch] The user set a ${name} usage limit of ${percent}% for this window, until the meter's window resets.`],
552 }
553}
554
555// /context-watch: the settings; `/context-watch <name> <value>` changes one.
556async function command($: Engine, args: string): Promise<{ text: string; context?: string[] }> {
557 const name = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
558 const raw = args.trim().slice(name.length).trim()
559 if (!name) return { text: await overview($) }
560 const kind = LIMITS[name]
561 if (kind) return limit($, name, kind, raw)
562
563 if (name === 'epoch') {
564 const before = await target($)
565 if (!raw) return { text: `epoch: ${before.own ? (before.at ? k(before.at) : 'off') : `the target's (${before.at ? k(before.at) : 'off'})`}. ${HOW}` }
566 let text: string
567 if (raw.toLowerCase() === 'reset') {
568 await update($, epoch, () => null)
569 text = `This context's suggested clear is the target's again: ${cfg.clearAt ? k(cfg.clearAt) : 'off'}.`
570 } else {
571 const n = tokens(raw, before.at)
572 if (n === undefined) return { text: `epoch takes a number of tokens (300k, +50k, off) or reset.` }
573 const session = await $.session.id()
574 await update($, epoch, (): Epoch => ({ clearAt: n, session }))
575 text = `This context's suggested clear: ${n ? k(n) : 'off'}, until the next clear or compaction (the target stays ${cfg.clearAt ? k(cfg.clearAt) : 'off'}).`
576 }
577 const u = await $.session.usage()
578 const t = await target($)
579 lastTokens = u.context.tokens
580 if (cfg.statusLine) $.ui.status(status(u, t))
581 await log($, `/context-watch epoch ${raw}: ${now(u, t)}`)
582 return { text, context: [`[context-watch] The user set this context's suggested clear: ${now(u, t)}.`] }
583 }
584
585 const f = FIELDS.find(x => x.name === name || x.key.toLowerCase() === name)
586 if (!f) return { text: `context-watch has no setting "${name}". ${HOW}` }
587 if (!raw) return { text: `${f.name}: ${f.show(cfg)} (${f.what}).` }
588 if (pending || pendingClear || running)
589 return { text: 'A clear or compaction is on its way, and a change of a setting would reload the mod and drop it: change it after.' }
590 const parsed = parse(f, raw)
591 if (typeof parsed === 'string') return { text: parsed }
592 const done = await $.config.set({ key: `context-watch.${f.key}`, value: parsed.value })
593 if ('deny' in done && done.deny !== undefined) return { text: `${f.name} is unchanged: ${done.deny}` }
594 const after = f.show(settings({ ...optionsNow, [f.key]: parsed.value }))
595 await log($, `/context-watch ${f.name}: ${f.show(cfg)} -> ${after}`)
596 return { text: `${f.name}: ${f.show(cfg)} → ${after}, from now on.`, context: [`[context-watch] The user set ${f.name} (${f.what}) to ${after}.`] }
597}
598
599// The options as the module received them, to show a changed setting the way the next load reads it.
600let optionsNow: PluginOptions = {}
601
602export const register: Register = (on, options) => {
603 optionsNow = options
604 cfg = settings(options)
605
606 on('session.start', async ($, e, next) => {
607 const started = await next(e)
608 // 0.4.0 kept the limits in the store, for every session on the machine: gone, they hold per window now.
609 try {
610 await $.store.delete('limits')
611 } catch {
612 // no store to clean
613 }
614 await $.tool.register({
615 name: TOOL,
616 description:
617 'Compact this conversation and carry on. Use it at a seam (work committed, the plan or task list current) ' +
618 'when the context is large and a summary would lose nothing the next step needs. It runs when the current ' +
619 'turn ends: finish the turn right after calling it. `keep` tells the summarizer what must survive (the plan, ' +
620 'ids, decisions in flight, file paths); `next` is the prompt that resumes the work afterwards.',
621 inputSchema: {
622 type: 'object',
623 properties: {
624 keep: { type: 'string', description: 'What the summary must keep.' },
625 next: { type: 'string', description: 'The prompt that resumes the work after the compaction.' },
626 },
627 required: ['keep', 'next'],
628 },
629 })
630 await $.tool.register({
631 name: CLEAR,
632 description:
633 'Clear this conversation and start again from a brief: nothing is summarized, so nothing is lost to a ' +
634 'summary, but nothing carries over except the files (CLAUDE.md, memory, notes, the task list) and the brief. ' +
635 'Use it at a seam where the state is all in files (work committed, notes current) and no background agents ' +
636 'are running (their reports may not reach the new conversation). It runs when the current turn ends: finish ' +
637 'the turn right after calling it. `brief` is the first prompt afterwards: what to read first, what is in ' +
638 'flight, what to do next.',
639 inputSchema: {
640 type: 'object',
641 properties: {
642 brief: { type: 'string', description: 'The prompt the fresh conversation starts from.' },
643 },
644 required: ['brief'],
645 },
646 })
647 await $.command.register({
648 name: 'context-watch',
649 description: "Show or change context-watch's settings, or this context's own suggested clear",
650 argumentHint: '[setting value]',
651 immediate: true,
652 })
653 return started
654 })
655
656 on('command.run', { command: 'context-watch' }, async ($, e) => command($, e.args))
657
658 on('tool.call', { tool: 'mcp__context-watch__clear_and_restart' }, async ($, e) => {
659 const input = e as unknown as { brief?: string }
660 if (!input.brief) return { deny: 'context-watch: give a `brief` to start from.' }
661 if (pending) return { deny: 'context-watch: a compaction is already queued for this turn.' }
662 pendingClear = { brief: input.brief }
663 spoke = false
664 const u = await $.session.usage()
665 await log($, `clear asked for at ${now(u)} | brief: ${input.brief.slice(0, 200)}`)
666 return { result: 'Queued: the conversation clears when this turn ends, then the brief starts the next one. End the turn now.' }
667 })
668
669 on('tool.call', { tool: 'mcp__context-watch__compact_and_continue' }, async ($, e) => {
670 const input = e as unknown as { keep?: string; next?: string }
671 if (!input.keep || !input.next) return { deny: 'context-watch: give both `keep` and `next`.' }
672 if (pendingClear) return { deny: 'context-watch: a clear is already queued for this turn.' }
673 pending = { keep: input.keep, next: input.next }
674 const u = await $.session.usage()
675 await log($, `compaction asked for at ${now(u)} | keep: ${input.keep.slice(0, 200)}`)
676 return { result: 'Queued: the conversation compacts when this turn ends, then a prompt resumes with `next`. End the turn now.' }
677 })
678
679 on('tool.call', async ($, e, next) => {
680 const ran = await next(e)
681 if (e.agentId !== undefined || e.tool === `mcp__context-watch__${TOOL}` || e.tool === `mcp__context-watch__${CLEAR}`)
682 return ran
683 const args = e as unknown as { description?: string; file_path?: string; command?: string }
684 recent.push(`${e.tool}: ${(args.description ?? args.file_path ?? args.command ?? '').slice(0, 80)}`)
685 if (recent.length > 3) recent.shift()
686 if ('deny' in ran && ran.deny !== undefined) return ran
687
688 const u = await $.session.usage()
689 const t = await target($)
690 const lim = await limits($)
691 if (cfg.statusLine) $.ui.status(status(u, t, lim))
692 const crossed = crossings(u, t)
693 const reached = limitWords(u, lim)
694 if (reached.length) await log($, `${reached.join(' ').slice(0, 300)} | ${now(u, t, lim)}`)
695 const words = [...notes.splice(0), ...reached]
696 if (!crossed.length) return words.length ? { ...ran, context: [...(ran.context ?? []), ...words] } : ran
697
698 let threshold = ''
699 try {
700 const at = (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.autoCompactThreshold
701 if (at) threshold = `; autocompact at ${k(at)}`
702 } catch {
703 threshold = ''
704 }
705 await log($, `${crossed.join(', ')} | ${now(u, t, lim)}${threshold} | in hand: ${recent.join(' / ')}`)
706 const notice =
707 `[context-watch] ${crossed.join(', ')}: ${now(u, t, lim)}${threshold}. ` +
708 `Landing work at a seam keeps a compaction cheap; mcp__context-watch__${TOOL} compacts and resumes when that helps, ` +
709 `and mcp__context-watch__${CLEAR} starts fresh from a brief once the state is in files and no agents run.`
710 return { ...ran, context: [...(ran.context ?? []), notice, ...words] }
711 })
712
713 // Any prompt but the mod's own: typed or queued (the event fires at the user's Enter, mid-turn
714 // too), from the terminal or the phone, or a notice, a peer's message, another mod's.
715 on('prompt.submit', async ($, e, next) => {
716 resumedBy = e.origin.kind
717 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') spoke = true
718 return next(e)
719 })
720
721 on('session.compact', async ($, e, next) => {
722 const done = await next(e)
723 if (e.agentId === undefined && !('skip' in done && done.skip !== undefined)) {
724 const before = done.tokensBefore === undefined ? '?' : k(done.tokensBefore)
725 const after = done.tokensAfter === undefined ? '?' : k(done.tokensAfter)
726 await log($, `compacted (${e.trigger}) ${before} -> ${after} | in hand: ${recent.join(' / ')}`)
727 await newContext($)
728 }
729 return done
730 })
731
732 // After the user's /clear, the last turn's words again (restart pastes its own into its prompt).
733 on('command.run', { command: 'clear' }, async ($, e, next) => {
734 await stage($)
735 const done = await next(e)
736 await newContext($)
737 await replay($, e.origin.kind)
738 return done
739 })
740
741 // The clear's row, at the top of the fresh conversation: the last turn whole, drawn
742 // as the answer was, between two dim lines. The model reads the row as it was (empty).
743 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
744 if (e.props.command !== 'clear') return next(e)
745 const said = await read($, shown)
746 if (!said) return next(e)
747 const { Box, Markdown, Text } = $.ui.resolve(e)
748 return (
749 <Box flexDirection="column">
750 <Text dimColor>Before the clear ({said.at}), the last turn said:</Text>
751 <Markdown text={said.words} />
752 <Text dimColor>The turns before it: {said.path}</Text>
753 </Box>
754 )
755 })
756
757 on('turn.complete', async ($, e, next) => {
758 const done = await next(e)
759 if (e.agentId === undefined) {
760 try {
761 const words = await turnWords($)
762 if (words) await remember($, words)
763 } catch (err) {
764 await log($, `the turn's words were not kept: ${String(err).slice(0, 200)}`)
765 }
766 }
767 if (pending) {
768 const p = pending
769 pending = undefined
770 running = true
771 $.clock.after(1000, () => void resume($, p))
772 } else if (pendingClear) {
773 const p = pendingClear
774 pendingClear = undefined
775 running = true
776 $.clock.after(1000, () => void restart($, p))
777 }
778 return done
779 })
780}
781types/index.d.ts 18 lines1// What the clear's row shows: the last turn's words before it, and the file with the turns before.
2export type Shown = { words: string; at: string; path: string }
3
4// The suggested clear for the current context alone (`/context-watch epoch`), and the session
5// it was set in: a clear or a compaction starts the next context on the setting again.
6export type Epoch = { clearAt: number; session: string }
7
8// A usage limit (`/context-watch limitweek`, `limit5h`): a percentage of the meter, until its
9// window resets (an ISO time). Limits is keyed by the meter's kind, as seven_day.
10export type Limit = { percent: number; until?: string }
11export type Limits = Record<string, Limit>
12
13declare module 'claude-code' {
14 interface PluginState {
15 'context-watch': { shown: Shown | null; epoch: Epoch | null; limits: Limits | null }
16 }
17}
18