SLOPSHOPPER

cache-timer

Counts down, in the prompt footer beside the model, the time left before the main thread's prompt cache lapses, so you know when the next message will pay to…

newspinnertimer
A shopper browsing a rack in a slop shop
README

cc-mods

Claude Code mods (function-hook plugins), forked from other authors or written here, kept in one repo. Each mod lives under mods/<name>/. A forked one is a squashed git subtree of its upstream: the upstream's license stays with it, and each squash commit names the upstream commit it came from. One written here has no upstream and carries its own license.

The repo is also a plugin marketplace, so a forked mod can be installed from here once it carries local changes.

Mods

ModUpstreamLocal changesLicense
md-promptnogu66/md-prompt0.2.0: none. The dunder-path fix carried here as 0.1.1-cc.1 was taken upstream in 0.1.2 (nogu66/md-prompt#4), so the mod is upstream as isMIT
plan-progresszycck/claude-mods0.7.1-cc.10, on upstream 0.7.1: the usage rules ride in the tool description instead of prompt.compose (missing from the desktop engine when forked); no plan-mode import (ExitPlanMode → bar plan); no hidden demo-reel entry (it wrote a file to a model-given path); agent strip text and tool word follow the light/dark theme; only a running bar twinkles, and only near its head, and agent strips hold still; agent strips fold into one summary row by default (waiting and failed agents keep theirs), opened and folded again by the ▾ N / ▴ after the bar's title; finished bars leave after a minute; colours from the desktop app's own tokens, and a theme-aware state glyph; no Progress button in the footer (/progress and each bar's ✕ hide the bars); a permission dialog put to you and left unanswered sounds decision, on the engine's permission_prompt notification (upstream makes no sound for it); running clocks (a strip's, the pill's hover time) are text drawn again on each wall-clock second instead of CSS reels that run by themselves (the hover tips still flash and go: the desktop rebuilds an interactive frame about once a second whatever the plugins do; its own hover card, tried in 0.7.1-cc.5, holds but hangs on the whole track, so it was taken back), and a strip's status morph and the track head's slide play once (the desktop shows the band's last drawing again on repaints that never reach the mod, another plugin's redraw each second or a tool's timer, and that restarts its pictures: a clock stepping a second back, 1m 23s, 1m 24s, 1m 23s, its old word flashing, a finished bar's time sliding in again from mid-track), and a draw holding such a slide or morph is followed by one more as soon as it has played, since a finished bar has no running clock to bring that draw (its slide to the end stayed the last drawing, and each footer second of cache-timer flashed its head back to the step before); agents the main thread starts in a turn before it opens the bar for that work move onto that bar as it opens (an agent from an earlier turn stays where it is), and the mod's own Agents bar draws its strips alone, with no track, pill or percent (upstream counted finished agents as its steps, 0% until the last one ended), and leaves when they fold; an agent's strip names the agent definition it runs as before its model and effort (worker · sonnet 5.5 · high; none for general-purpose, the Agent tool's default) and, before its time, the context its last request carried (ctx 42k), with its share of the window only where the model id names that window ([1m]: ctx 251k · 25%), since the engine reports no subagent's window and Claude Code settles it from a model catalog a plugin cannot read; the head's twinkle and a running agent's dot are no CSS loops but step on the wall-clock second like the clocks, written in at every draw and still in between (the four twinkle groups take turns through their levels, the dot is lit one second and dimmed the next): every repaint starts a picture's CSS over, and a draw on an event lands off the second, so a loop jumped there and at the next second (upstream's 1.1 to 3.3 s loops were cut off each second); the test engine shims Uint8Array#toBase64 for Node before 25MIT
cache-timernone, written here (idea from @savvyntsev)0.3.2: counts down, as a dim label in the footer left of the model, how long the main thread's prompt cache stays warm; the lifetime starts at 1h and follows the API (a model switch names it, a hit or miss after 5 to 60 idle minutes tells 1h from 5m); it is drawn again just after each wall-clock second, the instant plan-progress draws its band, so the screen changes once a second, not twiceMIT

Install a mod from this repo

Function hooks are early access, so turn them on first in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add mtmtian/cc-mods
claude plugin install md-prompt@cc-mods

To install from a local checkout instead, so your own edits are what gets installed, pass its path: claude plugin marketplace add <path to this checkout>.

Install a mod from one marketplace only. With both md-prompt@nogu66 and md-prompt@cc-mods enabled, the prompt box is painted twice.

Change a mod

A local change bumps the mod's version in its .claude-plugin/plugin.json with a -cc.N suffix (0.1.1-cc.1, 0.1.1-cc.2, ...), since installed copies only update when the version changes. Then:

claude plugin marketplace update cc-mods
claude plugin update md-prompt@cc-mods

While working on a mod, --plugin-dir loads the checkout directly and reloads on save (for md-prompt: claude --plugin-dir mods/md-prompt/plugins/md-prompt); each mod's own README has its test commands.

Run the checks

Use the Node.js version in .node-version, then run from the repo root:

npm ci
npm run ci

The root CI workflow runs on every pull request, pushes to main, and manual dispatch. Test and plugin checks run on standard Ubuntu and macOS runners; workflow and shell checks run in a separate Ubuntu job:

CheckWhat it covers
npm testmd-prompt's Bun tests, plan-progress's asserted stub-engine regressions, and the upstream-status script's local Git fixtures
npm run check:pluginsMarketplace/source/name/license consistency, strict Claude manifest validation, native hook tests for md-prompt and cache-timer, and TypeScript checks for all three plugins
Workflow and shell checksactionlint for the active root workflow and ShellCheck for the repository's shell scripts

The plugin checks copy the plugins into a temporary directory, use a fresh Claude config, and generate SDK declarations for the locked CLI version there. /cost is a local command: no login or model API key is needed. The API endpoint is set to a closed local port, so an accidental model request fails. The temporary copies are removed even when a check fails. These checks do not replace testing real prompt editing, desktop rendering or audio in Claude Desktop.

Development tools are version-pinned in package.json and package-lock.json; Actions use full commit SHAs. Dependabot proposes weekly npm-tool and Action updates; update actionlint's version and checksum together in the workflow. Claude and Bun require their native-binary install scripts, approved by exact version in allowScripts. When updating either package, review its install script, update the approval with npm install-scripts approve <package>, and rerun the checks. CI has read-only repository permissions, cancels superseded PR runs, and uses neither uploaded artifacts nor dependency caches.

Check and pull upstream changes

upstreams.tsv lists each mod's upstream URL and branch. scripts/upstream-status.sh reads it and, per mod, prints the upstream commit last synced, the upstream commits since, the paths changed here, and the paths changed on both sides (overlap_path=, where a pull may conflict). It only fetches; it never pulls or edits a mod. scripts/test-upstream-status.sh checks it against a throwaway upstream.

bash scripts/upstream-status.sh

To take the upstream changes, with the URL and branch from upstreams.tsv (on a branch, merged with a merge commit so the squash commit's git-subtree-split trailer stays on main):

git subtree pull --prefix=mods/<name> <url> <branch> --squash

Resolve conflicts by keeping each local change on top of upstream's version, never by taking one side whole. Then check that every local change still holds: the mod's tests (each local change has a regress case where it can), and a strict type check against the engine's types, which catches call sites the two sides changed differently.

Add another mod

git subtree add --prefix=mods/<name> https://github.com/<owner>/<repo>.git <branch> --squash

Then add the mod to upstreams.tsv, a row to the table above, and an entry to .claude-plugin/marketplace.json whose source points at the directory holding that mod's .claude-plugin/plugin.json.

A mod written here goes in mods/<name>/plugins/<name>/ with its own LICENSE beside it; it gets a row in the table and a marketplace entry, but no upstreams.tsv line.

Notes

  • GitHub only runs workflows from the repo root's .github/workflows, so the CI files inside mods/*/.github/ never run here.
  • Each forked mod here is a personal build on upstream: upstream is the base and the local changes are kept on top of it, listed in the table. A local change leaves only when upstream ships the same behaviour (upstream's code then replaces ours, as md-prompt's dunder fix did in 0.1.2) or when we drop it ourselves; an upstream declining it is no reason to drop it. A fix useful to everyone may also be offered upstream as an issue or a pull request.
Source 2 files
hooks/register.tsx 137 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { LastResponse, Ttl } from '../types'
5
6const lastResponse = atom({ plugin: 'cache-timer', key: 'last' } as const, null)
7const learnedTtl = atom({ plugin: 'cache-timer', key: 'learnedTtl' } as const, '1h')
8
9const MINUTE = 60_000
10const TTL_MS: Record<Ttl, number> = { '5m': 5 * MINUTE, '1h': 60 * MINUTE }
11// A request this close past five minutes may still have caught a 5m entry.
12const SLACK_MS = 15_000
13
14const label = (seconds: number): string => {
15  if (seconds <= 0) {
16    return 'Cache cold'
17  }
18  const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
19  const ss = String(seconds % 60).padStart(2, '0')
20
21  return `Cache ${mm}:${ss}`
22}
23
24// What one main-thread response says about the lifetime: only a request sent
25// 5 to 60 minutes after the previous one, on the same model, tells 5m from 1h.
26const infer = (idleMs: number, usage: TurnUsage): Ttl | null => {
27  const isTelling = idleMs > TTL_MS['5m'] + SLACK_MS && idleMs < TTL_MS['1h']
28  if (!isTelling) {
29    return null
30  }
31  if (usage.cache_read_input_tokens > 0) {
32    return '1h'
33  }
34
35  return usage.cache_creation_input_tokens > 0 ? '5m' : null
36}
37
38async function shown($: EngineInterface, fixedTtl: Ttl | null): Promise<string | null> {
39  const last = await read($, lastResponse)
40  if (last === null) {
41    return null
42  }
43  const ttl = fixedTtl ?? (await read($, learnedTtl))
44  // whole wall-clock seconds, so the label steps once at each redraw (beat) however late its timer fires
45  const seconds = Math.floor((last.at + TTL_MS[ttl]) / 1000) - Math.floor((await $.clock.now()) / 1000)
46
47  return label(seconds)
48}
49
50// Redraws just after each wall-clock second. Every redraw makes the desktop
51// show the other plugins' pictures afresh; plan-progress draws its band on
52// the same second, so the screen changes once a second, not twice. One chain
53// per module, however often the session starts.
54let isBeating = false
55function beat($: EngineInterface, now: number): void {
56  isBeating = true
57  $.clock.after(1000 - (now % 1000) + 25, async () => {
58    $.ui.invalidate('ui.render')
59    beat($, await $.clock.now())
60  })
61}
62
63export const register: Register = (on, options) => {
64  const fixedTtl: Ttl | null = options.ttl === '1h' || options.ttl === '5m' ? options.ttl : null
65
66  on('session.start', async ($, e, next) => {
67    const started = await next(e)
68    if (!isBeating) {
69      beat($, await $.clock.now())
70    }
71
72    return started
73  })
74
75  // A dim label at the head of the footer's mode chip, left of the model. A
76  // tree of its own: the desktop draws the chip from the props it holds, so a
77  // rewritten `modes` never reaches its screen; the status line would prefix
78  // the plugin's name.
79  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
80    const text = await shown($, fixedTtl)
81    if (text === null) {
82      return next(e)
83    }
84    const { Box, Text } = $.ui.resolve(e)
85    // other mods add to the chip beneath us; keep what they drew
86    const below = await next(e)
87
88    return (
89      <Box flexDirection="row" alignItems="center" gap={1}>
90        <Text dimColor>{text}</Text>
91        {below}
92      </Box>
93    )
94  })
95
96  // A resumed transcript's cache is as old as its last response.
97  on('classic.SessionStart', async ($, e, next) => {
98    if (e.seconds_since_last_response !== undefined) {
99      const resumed: LastResponse = { at: (await $.clock.now()) - e.seconds_since_last_response * 1000, model: null }
100      await update($, lastResponse, () => resumed)
101      $.ui.invalidate('ui.render')
102    }
103
104    return next(e)
105  })
106
107  // The one place the engine names the lifetime outright.
108  on('classic.PostModelSwitch', async ($, e, next) => {
109    await update($, learnedTtl, () => e.cache_ttl)
110    $.ui.invalidate('ui.render')
111
112    return next(e)
113  })
114
115  on('turn.step', async function* ($, e, next) {
116    const sentAt = await $.clock.now()
117    const result = yield* next(e)
118
119    // Subagents keep caches of their own; only the main thread's is timed.
120    if (e.agentId !== undefined || result.usage === null) {
121      return result
122    }
123    const previous = await read($, lastResponse)
124    if (previous !== null && previous.model === result.usage.model) {
125      const seen = infer(sentAt - previous.at, result.usage)
126      if (seen !== null) {
127        await update($, learnedTtl, () => seen)
128      }
129    }
130    const last: LastResponse = { at: sentAt, model: result.usage.model }
131    await update($, lastResponse, () => last)
132    $.ui.invalidate('ui.render')
133
134    return result
135  })
136}
137
types/index.d.ts 20 lines
1export type Ttl = '5m' | '1h'
2
3/** The main thread's last request that got a response. */
4export type LastResponse = {
5  /** When it was sent, ms since the epoch. */
6  at: number
7  /** The model that answered, null when unknown (a resumed session); a different one starts a cache of its own. */
8  model: string | null
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'cache-timer': {
14      last: LastResponse | null
15      /** The lifetime `auto` has settled on so far. */
16      learnedTtl: Ttl
17    }
18  }
19}
20