A fuse for your head: it shows the heat, warns you, then trips before you burn out.

<h1 align="center">cogload</h1>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a> <a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a> <a href="https://www.npmjs.com/package/@drakulavich/cogload"><img src="https://img.shields.io/npm/v/@drakulavich/cogload?logo=npm&color=cb3837" alt="npm"></a>
One command draws your last 14 days from the transcripts already on your disk, with nothing installed but Bun 1.4.2 or newer:
bunx @drakulavich/cogload@latest card
No Bun yet: curl -fsSL https://bun.sh/install | bash first. macOS draws the card with WebKit; elsewhere it needs an installed Chromium browser.
<img src="https://raw.githubusercontent.com/drakulavich/cogload/main/assets/plugin.webp" alt="The cognitive-load plugin in Claude Code: a rest holds a prompt, a skip lifts it, /cogload names what drives the hour" width="800">
Run these in a terminal, or paste this repository's link into Claude Code and ask it to install cogload.
For an AI agent installing cogload: run the block below top to bottom, skipping the Bun step if
bun --versionprints 1.4.2 or newer. Each command is safe to rerun. Then ask the user to restart Claude Code so the plugin loads.
# Bun 1.4.2 or newer; skip if `bun --version` already prints one
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
# The CLI; the same command upgrades it
bun add -g @drakulavich/cogload
cogload --version
# The cognitive-load plugin for Claude Code
claude plugin marketplace add drakulavich/cogload
claude plugin install cognitive-load@cogload
Restart Claude Code to load the plugin. With a Homebrew Bun, put ~/.bun/bin on your PATH yourself. To try the CLI without installing it, run bunx @drakulavich/cogload@latest.
cogload # the last 7 days
cogload yesterday --explain # one day, with the components behind each index
cogload card # the picture
A week from the synthetic fixture in tests/fixtures/busy-week: a calm morning of one session, a five-session storm in the middle of the day, and a late tail that runs past midnight.
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 peak active
Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 8h50
Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 7h55
Wed 16/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 15 1h55
Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81 0h35
Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 25 1h55
Sun 20/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
░ calm ▒ warming ▓ heating █ fried
21h10 active 323 prompts 0 reports 86 decisions 5 sessions at once
Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
cogload card turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and up to three highlights. It carries no dates and no hour totals, so it does not read as a timesheet.
cogload card # writes cogload-card.png to ~/Downloads
cogload card --out card.webp # WebP instead; --out card.html writes the page itself
macOS draws it with WebKit; elsewhere it needs an installed Chromium browser, or write --out card.html and open the page in any browser.
If your card told you something about your week, star the repository so other people can find cogload.
The cognitive-load plugin draws the last hour's level above the prompt, with the time your rest starts, and /cogload says what drives it:
● Heating · rest at 14:52
Heating 80 this hour, at the cap: 16 decisions and 13 context switches, 32 prompts, a 57m streak.
This week: 1 rest taken, 1 skipped (last: "prod is down, fixing it").
It also makes you rest. After 40 minutes of work without a ten-minute gap, or in a Fried hour, it holds your prompts for ten minutes. Each held prompt gets the next short idea for the break, the minutes left and the way out: Stand up and stretch. 7 min left, your prompt is saved. To go on: "skip: <reason>". A skip: <reason> line at the start or the end of the prompt, the reason three words or more, lifts the rest.
cogload reads ~/.claude/projects on your machine and sends nothing anywhere. It keeps, writes and prints no message text, prompt length, file path or session title: what survives from a transcript is timestamps, session ids, event kinds and token counts. The plugin keeps one piece of text, the reason you give for a skip. The reference lists every file cogload and the plugin write.
hooks/register.ts 324 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Level, Status } from '../types'
5
6const status = atom({ plugin: 'cognitive-load', key: 'status' } as const, null)
7const lastToast = atom({ plugin: 'cognitive-load', key: 'lastToast' } as const, 0)
8
9const LEVELS = ['Calm', 'Warming', 'Heating', 'Fried'] as const
10const COLOR: Record<Level, string> = { Calm: 'success', Warming: 'warning', Heating: '#ff8700', Fried: 'red' }
11const NARROW = 50
12const CLOCK_SKEW_MS = 60_000
13const TOAST_GAP_MS = 2100
14const HELD: readonly string[] = ['composer', 'bridge']
15const WEEK_MS = 7 * 24 * 60 * 60_000
16const HOUR_MS = 60 * 60_000
17const SKIP = /^(?:skip|override):(.*)$/i
18const PHRASES = [
19 'Stand up and stretch.',
20 'Water, then a window.',
21 'Look at something far away.',
22 'Walk to the kitchen and back.',
23 'Roll your shoulders, unclench your jaw.',
24 'Close your eyes for a minute.',
25 'Breathe out slower than you breathe in.',
26 'The code will wait.',
27]
28const SHORT = 'A skip needs a reason of three words or more.'
29const WELCOME = 'Keep your head cold. The dot above the prompt shows how hot this hour runs.'
30const MISSING = "cogload is not on Claude Code's PATH: bun add -g @drakulavich/cogload"
31const NO_READING = 'cogload gave no reading.'
32const OLDER = 'cogload is older than this plugin: bun add -g @drakulavich/cogload@latest'
33const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
34
35type Override = { at: number; reason: string }
36type Part = 'parallel' | 'pace' | 'supervision' | 'reading' | 'streak' | 'late'
37type Live = {
38 sessions: number
39 prompts: number
40 decisions: number
41 contextSwitches: number
42 outputTokens: number
43 streakMin: number
44 score: { index: number; level: Level; capped: Part[]; top: Part } | null
45}
46
47const isInt = (v: unknown, min: number, max: number): v is number =>
48 Number.isInteger(v) && (v as number) >= min && (v as number) <= max
49
50const isIndex = (v: unknown): v is number | null => v === null || isInt(v, 0, 100)
51
52const isInstant = (v: unknown): v is string => typeof v === 'string' && ISO_INSTANT.test(v)
53
54const decodeStatus = (stdout: string, nowMs: number): Status | null => {
55 let s: unknown
56 try {
57 s = JSON.parse(stdout.trim())
58 } catch {
59 return null
60 }
61 if (typeof s !== 'object' || s === null || Array.isArray(s)) return null
62 const o = s as Record<string, unknown>
63 const asOf = isInstant(o.asOf) ? Date.parse(o.asOf) : Number.NaN
64 const isLevel = o.index === null ? o.level === null : LEVELS.includes(o.level as Level)
65 const isValid =
66 o.schema === 1 &&
67 !Number.isNaN(asOf) &&
68 asOf <= nowMs + CLOCK_SKEW_MS &&
69 typeof o.date === 'string' &&
70 /^\d{4}-\d{2}-\d{2}$/.test(o.date) &&
71 isInt(o.hour, 0, 23) &&
72 isIndex(o.index) &&
73 isIndex(o.peak) &&
74 isLevel &&
75 isInt(o.activeMin, 0, 1500) &&
76 isInt(o.streakMin, 0, 1500) &&
77 (o.restAt === null || isInstant(o.restAt)) &&
78 isInt(o.restMin, 1, 60)
79 return isValid
80 ? {
81 schema: 1,
82 asOf: o.asOf as string,
83 date: o.date as string,
84 hour: o.hour as number,
85 index: o.index as number | null,
86 level: o.level as Level | null,
87 peak: o.peak as number | null,
88 activeMin: o.activeMin as number,
89 streakMin: o.streakMin as number,
90 restAt: o.restAt as string | null,
91 restMin: o.restMin as number,
92 }
93 : null
94}
95
96// Claude Code drops a plugin's toast that comes within 2000 ms of its last one.
97const toast = async ($: EngineInterface, text: string) => {
98 const now = await $.clock.now()
99 let at = now
100 await update($, lastToast, last => {
101 at = Math.max(now, last + TOAST_GAP_MS)
102 return at
103 })
104 if (at === now) $.ui.toast(text)
105 else $.clock.after(at - now, () => $.ui.toast(text))
106}
107
108const formatMinutes = (min: number): string =>
109 min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
110
111const clockTime = (ms: number): string => new Date(ms).toTimeString().slice(0, 5)
112
113const PHRASE: Record<Part, (l: Live) => string> = {
114 parallel: l => `${l.sessions} sessions at once`,
115 pace: l => `${l.prompts} prompts`,
116 supervision: l => `${l.decisions} decisions and ${l.contextSwitches} context switches`,
117 reading: l => `${Math.round(l.outputTokens / 1000)}k output tokens`,
118 streak: l => `a ${formatMinutes(l.streakMin)} streak`,
119 late: () => 'late at night',
120}
121
122const hourLine = (live: Live, score: NonNullable<Live['score']>): string => {
123 const head = `${score.level} ${score.index} this hour`
124 if (score.index === 0) return `${head}: you have been away.`
125 if (score.capped.length > 0) return `${head}, at the cap: ${score.capped.map(p => PHRASE[p](live)).join(', ')}.`
126 return `${head}, mostly ${PHRASE[score.top](live)}.`
127}
128
129type Day = { live?: Live | null }
130
131const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
132
133const restUntil = async ($: EngineInterface, now: number): Promise<number | null> => {
134 const until = await $.store.get('restUntil')
135 return typeof until === 'number' && now < until ? until : null
136}
137
138const overridesSince = async ($: EngineInterface, since: number): Promise<Override[]> => {
139 const list = await $.store.get('overrides')
140 return Array.isArray(list) ? (list as Override[]).filter(o => o.at > since) : []
141}
142
143const restsSince = async ($: EngineInterface, since: number): Promise<number[]> => {
144 const list = await $.store.get('rests')
145 return Array.isArray(list) ? (list as number[]).filter(at => at > since) : []
146}
147
148const isSpent = async ($: EngineInterface, s: Status): Promise<boolean> =>
149 s.restAt !== null && (await $.store.get('spent')) === s.restAt
150
151const startRestIfDue = async ($: EngineInterface, s: Status) => {
152 const now = await $.clock.now()
153 const isFried = s.level === 'Fried'
154 const friedAt = await $.store.get('friedAt')
155 const isFriedDue = isFried && (typeof friedAt !== 'number' || now - friedAt >= HOUR_MS)
156 const isStreakDue = s.restAt !== null && Date.parse(s.asOf) >= Date.parse(s.restAt)
157 if (!isStreakDue && !isFriedDue) return
158 if (await isSpent($, s)) return
159 const until = now + s.restMin * 60_000
160 await $.store.set('restUntil', until)
161 await $.store.set('rests', [...(await restsSince($, now - WEEK_MS)), now])
162 await $.store.set('spent', s.restAt)
163 if (isFried) await $.store.set('friedAt', now)
164 await toast($, `Rest until ${clockTime(until)}. Streak ${formatMinutes(s.streakMin)}, ${s.level} ${s.index}.`)
165}
166
167const toastOnce = async ($: EngineInterface, key: string, text: string) => {
168 if ((await $.store.get(key)) === true) return
169 await $.store.set(key, true)
170 await toast($, text)
171}
172
173const RUN_TIMEOUT_MS = 10_000
174const REFRESH_MS = 60_000
175const STALE_MS = 5 * 60_000
176
177const weekLine = async ($: EngineInterface, now: number): Promise<string | null> => {
178 const rests = await restsSince($, now - WEEK_MS)
179 if (rests.length === 0) return null
180 const pushed = (await overridesSince($, now - WEEK_MS)).filter(o => rests.some(at => at <= o.at))
181 const taken = `This week: ${plural(rests.length - pushed.length, 'rest')} taken`
182 const newest = pushed.at(-1)
183 return newest === undefined ? `${taken}.` : `${taken}, ${pushed.length} skipped (last: "${newest.reason}").`
184}
185
186const spawn = async ($: EngineInterface, argv: string[], env?: Record<string, string>) => {
187 const startedAt = await $.clock.now()
188 try {
189 return await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS, env })
190 } catch {
191 return (await $.clock.now()) - startedAt >= RUN_TIMEOUT_MS ? 'timeout' : 'not found'
192 }
193}
194
195const run = async ($: EngineInterface, args: string[]) => {
196 const r = await spawn($, ['cogload', ...args])
197 if (r !== 'not found') return r
198 const home = await $.env.get('HOME')
199 if (home === undefined) return r
200 const bin = `${home}/.bun/bin`
201 const path = await $.env.get('PATH')
202 // cogload is a `#!/usr/bin/env bun` script, and a script-installed bun sits beside it.
203 return spawn($, [`${bin}/cogload`, ...args], { PATH: path ? `${bin}:${path}` : bin })
204}
205
206const take = async ($: EngineInterface, s: Status) => {
207 await startRestIfDue($, s)
208 await update($, status, () => s)
209}
210
211const isOlder = (stdout: string): boolean => {
212 try {
213 const o = JSON.parse(stdout.trim())
214 return o?.schema === 1 && o.restAt === undefined
215 } catch {
216 return false
217 }
218}
219
220const refresh = async ($: EngineInterface) => {
221 const r = await run($, ['status'])
222 const decoded = typeof r === 'string' || r.exitCode !== 0 ? null : decodeStatus(r.stdout, await $.clock.now())
223 if (decoded === null) {
224 const failure = typeof r === 'string' ? r : r.exitCode === 0 ? 'bad line' : `exit ${r.exitCode}`
225 $.ui.log(failure, { to: 'debug' })
226 if (failure === 'not found') await toastOnce($, 'missingShown', MISSING)
227 if (typeof r !== 'string' && r.exitCode === 0 && isOlder(r.stdout)) await toastOnce($, 'olderShown', OLDER)
228 return false
229 }
230 await $.store.set('reading', decoded)
231 await take($, decoded)
232 return true
233}
234
235const tick = async ($: EngineInterface) => {
236 const reading = (await $.store.get('reading')) as Status | undefined
237 const age = reading === undefined ? Number.POSITIVE_INFINITY : (await $.clock.now()) - Date.parse(reading.asOf)
238 if (reading !== undefined && age < REFRESH_MS) return take($, reading)
239 if ((await refresh($)) || age < STALE_MS) return
240 await $.store.delete('reading')
241 await update($, status, () => null)
242}
243
244export const register: Register = on => {
245 // Not awaited: the engine holds the turn until these hooks return.
246 on('session.start', async ($, e, next) => {
247 const result = await next(e)
248 await $.command.register({ name: 'cogload', description: "What drives this hour's load, and the week's rests and skips" })
249 toastOnce($, 'welcomed', WELCOME)
250 .catch(() => {})
251 .then(() => refresh($))
252 .catch(() => {})
253 $.clock.every(REFRESH_MS, () => {
254 tick($).catch(() => {})
255 })
256 return result
257 })
258
259 on('turn.complete', async ($, e, next) => {
260 const result = await next(e)
261 refresh($).catch(() => {})
262 return result
263 })
264
265 on('command.run', { command: 'cogload' }, async $ => {
266 const r = await run($, ['today', '--json'])
267 if (r === 'not found') return { text: MISSING }
268 let day: unknown = null
269 try {
270 if (r !== 'timeout' && r.exitCode === 0) day = JSON.parse(r.stdout)
271 } catch {}
272 if (typeof day !== 'object' || day === null) return { text: NO_READING }
273 const live = (day as Day).live
274 const score = live?.score
275 if (score && !Array.isArray(score.capped)) return { text: OLDER }
276 const hour = live && score ? hourLine(live, score) : 'Nothing scored this hour.'
277 const week = await weekLine($, await $.clock.now())
278 return { text: [hour, ...(week === null ? [] : [week])].join('\n') }
279 })
280
281 on('prompt.submit', async ($, e, next) => {
282 if (!HELD.includes(e.origin.kind)) return next(e)
283 const now = await $.clock.now()
284 const until = await restUntil($, now)
285 if (until === null) return next(e)
286 const lines = e.text.trimEnd().split('\n')
287 const first = SKIP.exec(lines[0] ?? '')
288 const last = lines.length > 1 ? SKIP.exec(lines.at(-1) ?? '') : null
289 const skip = first ?? last
290 const reason = skip?.[1].trim() ?? ''
291 if (reason.split(/\s+/).length >= 3) {
292 await $.store.delete('restUntil')
293 await $.store.set('overrides', [...(await overridesSince($, now - WEEK_MS)), { at: now, reason }])
294 const text = lines.slice(first ? 1 : 0, last ? -1 : undefined).join('\n').trim()
295 return text === '' ? { drop: 'Rest lifted.' } : next({ ...e, text })
296 }
297 const isComposer = e.origin.kind === 'composer'
298 if (skip !== null) return { drop: `${SHORT}${isComposer ? ' Your prompt is saved.' : ''}` }
299 const phrase = await $.store.get('phrase')
300 const n = typeof phrase === 'number' ? phrase : 0
301 await $.store.set('phrase', (n + 1) % PHRASES.length)
302 const left = `${Math.ceil((until - now) / 60_000)} min left${isComposer ? ', your prompt is saved' : ''}`
303 return { drop: `${PHRASES[n % PHRASES.length]} ${left}. To go on: "skip: <reason>".` }
304 })
305
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 const beneath = await next(e)
308 const s = await read($, status)
309 if (e.props.hasSurvey || s === null || s.index === null || s.level === null) return beneath
310 const now = await $.clock.now()
311 const until = await restUntil($, now)
312 const isAhead = until === null && s.restAt !== null && Date.parse(s.restAt) > Date.parse(s.asOf) && !(await isSpent($, s))
313 const parts: string[] = []
314 if (until !== null) parts.push(`rest until ${clockTime(until)} (${Math.ceil((until - now) / 60_000)} min)`)
315 else if (isAhead && s.restAt !== null) parts.push(`rest at ${clockTime(Date.parse(s.restAt))}`)
316 else if (e.props.bodyColumns >= NARROW && s.streakMin > 0) parts.push(`streak ${formatMinutes(s.streakMin)}`)
317 const { Box, Text } = $.ui.resolve(e)
318 const head = Text({ color: COLOR[s.level], children: `● ${s.level}` })
319 const own =
320 parts.length === 0 ? head : Box({ children: [head, Text({ dimColor: true, children: parts.map(p => ` · ${p}`).join('') })] })
321 return Box({ flexDirection: 'column', children: [own, beneath] })
322 })
323}
324types/index.d.ts 22 lines1export type Level = 'Calm' | 'Warming' | 'Heating' | 'Fried'
2
3export type Status = {
4 schema: 1
5 asOf: string
6 date: string
7 hour: number
8 index: number | null
9 level: Level | null
10 peak: number | null
11 activeMin: number
12 streakMin: number
13 restAt: string | null
14 restMin: number
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'cognitive-load': { status: Status | null; lastToast: number }
20 }
21}
22