SLOPSHOPPER

cognitive-load

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

newbandcommandtoastpromptprocess
★ 1v0.10.2MITupdated 2026-10-09drakulavich/cogload/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cognitive-load
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ cognitive-load │ ● cognitive-load: bad line │ Keep your head cold. The dot above the │ ● cognitive-load: bad line │ prompt shows how hot this hour runs. │ ⏺ 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 › /cogload ⎿ cognitive-load: cogload gave no reading. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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>

See your last two weeks

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">

Install

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 --version prints 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

What it looks like

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.

Share a card

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.

Inside Claude Code

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.

Privacy

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.

More

  • Reference: every command and flag, exit codes, the plugin and its rest in full, privacy and the cache, limits, the formula and what each column counts, development, and where cogload comes from.
  • How the numbers are made: the path from a transcript line to the status line.
  • Changelog.

Source 2 files
hooks/register.ts 324 lines
1import { 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}
324
types/index.d.ts 22 lines
1export 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