SLOPSHOPPER

ember

Keeps the prompt cache warm while you are away, with a short ping before it would expire, and warns when a resumed session's cache has gone cold.

newbandpromptmodeltimer
v?MITupdated 2026-10-24fabioueno/koubou/plugins/ember
A shopper browsing a rack in a slop shop
README

<h1>Koubou (工房)</h1>

Koubou is a Claude Code plugin marketplace. Each plugin does one job, and you can enable or disable each one on its own. Each plugin has its own evals, or its own tests when it is a mod.


Plugins

PluginProvidesWhat it does
crewSidebar modShows a side panel with a card for each agent at work.
emberBackground modKeeps the prompt cache warm while you are away, and warns when a resumed one went cold.
gitgit:gitignoreBuilds and updates .gitignore files from the official github/gitignore templates.
kansoTwo skills, a hookSets one writing style for all text, and turns substantial answers into pages.
ribbonPrompt band modShows a band above the prompt with the session's time, cost, project, branch and limits.
workflowFive skillsPlans, coordinates, builds test first, reviews and opens pull requests. Needs kanso.

Installation

Add the marketplace, then install the plugins you want:

/plugin marketplace add fabioueno/koubou
/plugin install crew@koubou
/plugin install ember@koubou
/plugin install git@koubou
/plugin install kanso@koubou
/plugin install ribbon@koubou
/plugin install workflow@koubou

Disable a plugin, or enable it again, without uninstalling it:

/plugin disable kanso@koubou
/plugin enable kanso@koubou

Plugins carry no version, so each one updates when a commit changes its folder. Run /plugin marketplace update koubou to fetch new commits.

Documentation

DocumentDescription
[CONTRIBUTING.md][contributing]Conventions, adding a plugin, and running evals.

License

[MIT][license]

[contributing]: CONTRIBUTING.md [license]: LICENSE

Source 14 files
hooks/register.ts 22 lines
1import type { Register } from "claude-code";
2
3import { onClassicModelSwitch } from "./classic/model-switch";
4import { onClassicSessionStart } from "./classic/session-start";
5import { onPromptSubmit } from "./prompt/submit";
6import { onAbovePrompt } from "./render/above-prompt";
7import { onSessionEnd } from "./session/end";
8import { onSessionStart } from "./session/start";
9import { onTurnComplete } from "./turn/complete";
10import { onTurnStep } from "./turn/step";
11
12export const register: Register = (on) => {
13  onSessionStart(on);
14  onSessionEnd(on);
15  onClassicSessionStart(on);
16  onClassicModelSwitch(on);
17  onTurnStep(on);
18  onTurnComplete(on);
19  onPromptSubmit(on);
20  onAbovePrompt(on);
21};
22
hooks/classic/model-switch.ts 23 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import type { Warming } from "../../types";
5import { stopWarming } from "../cache/timing";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const warming = atom({ plugin: "ember", key: "warming" } as const, { isOn: false, pings: 0, stopped: null } as Warming);
10
11/**
12 * A model switch forfeits the cache, so a ping now would only pay to write it
13 * again; warming waits for the next turn, which caches the new model's.
14 */
15export function onClassicModelSwitch(on: On): void {
16  on("classic.PostModelSwitch", async ($, e, next) => {
17    const switched = await next(e);
18    await update($, warming, (current) => stopWarming(current, "the model changed"));
19
20    return switched;
21  });
22}
23
hooks/classic/session-start.ts 52 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { CACHE_TTL_MS } from "../cache/timing";
5import { compact, since } from "../text/units";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const lastRequestAt = atom({ plugin: "ember", key: "lastRequestAt" } as const, 0);
10const notice = atom({ plugin: "ember", key: "notice" } as const, null);
11
12function coldNotice(e: {
13  seconds_since_last_response: number;
14  context_tokens?: number;
15  estimated_cache_write_usd?: number;
16}): string {
17  const ago = since(e.seconds_since_last_response * 1000 - CACHE_TTL_MS);
18  const tokens = e.context_tokens === undefined ? "the whole context" : `${compact(e.context_tokens)} tokens`;
19  const price = e.estimated_cache_write_usd === undefined ? "" : ` (about $${e.estimated_cache_write_usd.toFixed(2)})`;
20
21  return `Prompt cache expired ${ago} ago: your next message re-caches ${tokens}${price}.`;
22}
23
24/**
25 * A resumed transcript's last response is its last request: the cache counts
26 * from there, and a cold one is said above the prompt until the next message.
27 */
28export function onClassicSessionStart(on: On): void {
29  on("classic.SessionStart", async ($, e, next) => {
30    const started = await next(e);
31    if (e.source === "clear") {
32      await update($, lastRequestAt, () => 0);
33      return started;
34    }
35
36    const seconds = e.seconds_since_last_response;
37    if ((e.source !== "resume" && e.source !== "fork") || seconds === undefined) {
38      return started;
39    }
40
41    const at = (await $.clock.now()) - seconds * 1000;
42    await update($, lastRequestAt, () => at);
43
44    if (e.prompt_cache_likely_expired === true) {
45      const text = coldNotice({ ...e, seconds_since_last_response: seconds });
46      await update($, notice, () => ({ kind: "cold" as const, text }));
47    }
48
49    return started;
50  });
51}
52
hooks/prompt/submit.ts 19 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4// Declared in each file that reads them: the mod validator follows an atom only
5// within its own file.
6const notice = atom({ plugin: "ember", key: "notice" } as const, null);
7
8/**
9 * The next message is what the notice was about, so sending it clears it.
10 */
11export function onPromptSubmit(on: On): void {
12  on("prompt.submit", async ($, e, next) => {
13    const submitted = await next(e);
14    await update($, notice, () => null);
15
16    return submitted;
17  }).catch((_$, e, next) => next(e));
18}
19
hooks/render/above-prompt.tsx 37 lines
1import type { On } from "claude-code";
2import { atom, read } from "claude-code";
3
4import { INDENT, LOOKS } from "../notice/look";
5
6// Declared in each file that reads them: the mod validator follows an atom only
7// within its own file.
8const notice = atom({ plugin: "ember", key: "notice" } as const, null);
9
10/**
11 * The notice above whatever else sits above the prompt, after a blank row and
12 * in line with the band's text, until the next message clears it.
13 */
14export function onAbovePrompt(on: On): void {
15  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
16    const current = await read($, notice);
17    if (e.surface !== "terminal" || e.props.hasSurvey || current === null) {
18      return next(e);
19    }
20
21    const { Box, Text } = $.ui.resolve(e);
22    const below = await next(e);
23    const look = LOOKS[current.kind];
24
25    return (
26      <Box flexDirection="column">
27        <Box marginTop={1} paddingLeft={INDENT}>
28          <Text color={look.color} wrap="wrap">
29            {look.glyph} {current.text}
30          </Text>
31        </Box>
32        {below}
33      </Box>
34    );
35  });
36}
37
hooks/session/end.ts 21 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import type { Warming } from "../../types";
5import { stopWarming } from "../cache/timing";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const warming = atom({ plugin: "ember", key: "warming" } as const, { isOn: false, pings: 0, stopped: null } as Warming);
10
11/**
12 * The session ends: nothing is left to keep warm. A /clear ends it too while the
13 * process goes on, with no new session start, so the pings stop here.
14 */
15export function onSessionEnd(on: On): void {
16  on("session.end", async ($, e, next) => {
17    await update($, warming, (current) => stopWarming(current, `the session ended (${e.reason})`));
18    return next(e);
19  });
20}
21
hooks/session/start.ts 125 lines
1import type { EngineInterface, On } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { Warming } from "../../types";
5import { ping } from "../cache/ping";
6import {
7  CACHE_TTL_MS,
8  isAbandoned,
9  isWarmPing,
10  PING_PROMPT,
11  shouldPing,
12  startWarming,
13  stopWarming,
14} from "../cache/timing";
15import { compact, since } from "../text/units";
16
17// Declared in each file that reads them: the mod validator follows an atom only
18// within its own file.
19const busy = atom({ plugin: "ember", key: "busy" } as const, false);
20const warming = atom({ plugin: "ember", key: "warming" } as const, { isOn: false, pings: 0, stopped: null } as Warming);
21
22const lastRequestAt = atom({ plugin: "ember", key: "lastRequestAt" } as const, 0);
23const lastTurnAt = atom({ plugin: "ember", key: "lastTurnAt" } as const, 0);
24const notice = atom({ plugin: "ember", key: "notice" } as const, null);
25
26async function stop($: EngineInterface, why: string): Promise<void> {
27  await update($, warming, (current) => stopWarming(current, why));
28  await update($, notice, () => ({ kind: "stopped" as const, text: `Cache warming stopped: ${why}.` }));
29}
30
31async function pingCache($: EngineInterface): Promise<void> {
32  const reply = await $.model.fork({ prompt: PING_PROMPT });
33  if (!reply.isAnswered) {
34    await stop($, `the ping was not answered (${reply.reason})`);
35    return;
36  }
37
38  // A ping that finds the cache gone has written it again, unavoidably: ember
39  // stops there, so it never keeps alive a cache only it re-wrote.
40  if (!isWarmPing(reply.usage)) {
41    await update($, warming, (current) => stopWarming(current, "the cache had expired early"));
42    await update($, notice, () => ({
43      kind: "cold" as const,
44      text: "While you were away, the prompt cache expired early, so warming stopped.",
45    }));
46
47    return;
48  }
49
50  const pingedAt = await $.clock.now();
51
52  await update($, lastRequestAt, () => pingedAt);
53  await update($, warming, (current) => ({ ...current, pings: current.pings + 1 }));
54}
55
56async function sayExpired($: EngineInterface, ago: number): Promise<void> {
57  await update($, warming, (current) => stopWarming(current, "the cache expired with no ping"));
58
59  // A resume already said it, with Claude Code's own price.
60  if ((await read($, notice)) !== null) {
61    return;
62  }
63
64  const tokens = (await $.session.usage()).context.tokens;
65  const resent = tokens === undefined ? "the whole context" : `${compact(tokens)} tokens`;
66
67  await update($, notice, () => ({
68    kind: "cold" as const,
69    text: `Prompt cache expired ${since(ago)} ago: your next message re-caches ${resent}.`,
70  }));
71}
72
73// While warming is on and the main loop idle, a ping before the cache would
74// expire. It runs for as long as the session does.
75async function warmCache($: EngineInterface): Promise<void> {
76  const [current, now, at] = [await read($, warming), await $.clock.now(), await read($, lastRequestAt)];
77  if (!current.isOn) {
78    return;
79  }
80
81  // After this long nobody is there to read a notice, so it stops silently.
82  if (isAbandoned(await read($, lastTurnAt), now)) {
83    await update($, warming, (warm) => stopWarming(warm, "no turn in 72 hours"));
84    return;
85  }
86
87  // Past its expiry with no ping, as when the computer slept: nothing is left
88  // to keep warm, so ember says what the next message costs and stops.
89  if (at > 0 && now - at >= CACHE_TTL_MS) {
90    await sayExpired($, now - at - CACHE_TTL_MS);
91    return;
92  }
93
94  if (ping.isRunning || (await read($, busy)) || !shouldPing(at, now)) {
95    return;
96  }
97
98  ping.isRunning = true;
99
100  try {
101    await pingCache($);
102  } finally {
103    ping.isRunning = false;
104  }
105}
106
107export function onSessionStart(on: On): void {
108  on("session.start", async ($, e, next) => {
109    const started = await next(e);
110
111    $.clock.every(60_000, () => {
112      warmCache($).catch(() => undefined);
113    });
114
115    if (e.surface === "terminal" && e.isInteractive) {
116      const now = await $.clock.now();
117
118      await update($, lastTurnAt, () => now);
119      await update($, warming, startWarming);
120    }
121
122    return started;
123  });
124}
125
hooks/turn/complete.ts 39 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import type { Warming } from "../../types";
5import { ping } from "../cache/ping";
6import { startWarming } from "../cache/timing";
7
8// Declared in each file that reads them: the mod validator follows an atom only
9// within its own file.
10const busy = atom({ plugin: "ember", key: "busy" } as const, false);
11const warming = atom({ plugin: "ember", key: "warming" } as const, { isOn: false, pings: 0, stopped: null } as Warming);
12
13const lastRequestAt = atom({ plugin: "ember", key: "lastRequestAt" } as const, 0);
14const lastTurnAt = atom({ plugin: "ember", key: "lastTurnAt" } as const, 0);
15
16/**
17 * The main loop's turn ends: from now on the cache is kept warm, even after a
18 * ping had stopped it.
19 */
20export function onTurnComplete(on: On): void {
21  on("turn.complete", async ($, e, next) => {
22    const done = await next(e);
23    if (ping.isRunning || e.agentId !== undefined) {
24      return done;
25    }
26
27    const now = await $.clock.now();
28
29    await update($, busy, () => false);
30    await update($, warming, startWarming);
31    await update($, lastTurnAt, () => now);
32
33    // turn.step stamps each request; a turn with none ends at the floor.
34    await update($, lastRequestAt, (at) => (now - at > e.durationMs ? now : at));
35
36    return done;
37  });
38}
39
hooks/turn/step.ts 28 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { ping } from "../cache/ping";
5
6// Declared in each file that reads them: the mod validator follows an atom only
7// within its own file.
8const busy = atom({ plugin: "ember", key: "busy" } as const, false);
9const lastRequestAt = atom({ plugin: "ember", key: "lastRequestAt" } as const, 0);
10
11/**
12 * Each main loop request: the cache counts from it, and pings wait while
13 * the loop works.
14 */
15export function onTurnStep(on: On): void {
16  on("turn.step", async function* ($, e, next) {
17    if (ping.isRunning || e.agentId !== undefined) {
18      return yield* next(e);
19    }
20
21    const now = await $.clock.now();
22    await update($, lastRequestAt, () => now);
23    await update($, busy, () => true);
24
25    return yield* next(e);
26  });
27}
28
hooks/cache/timing.ts 40 lines
1import type { Warming } from "../../types";
2
3export const CACHE_TTL_MS = 60 * 60_000;
4
5// A session with no turn for this long is taken as left for good.
6export const IDLE_LIMIT_MS = 72 * 60 * 60_000;
7
8// Ten minutes before the cache would expire.
9export const PING_AFTER_MS = 50 * 60_000;
10export const PING_PROMPT = "Reply with the single word: warm";
11
12export function startWarming(warming: Warming): Warming {
13  return { ...warming, isOn: true, stopped: null };
14}
15
16/**
17 * A ping that writes the cache again instead of reading it means the cache was
18 * already gone.
19 */
20export function isWarmPing(usage: { cache_read_input_tokens: number; cache_creation_input_tokens: number }): boolean {
21  return usage.cache_read_input_tokens > 0 && usage.cache_creation_input_tokens < 0.1 * usage.cache_read_input_tokens;
22}
23
24export function shouldPing(lastRequestAt: number, now: number): boolean {
25  const idle = now - lastRequestAt;
26  return lastRequestAt > 0 && idle >= PING_AFTER_MS && idle < CACHE_TTL_MS;
27}
28
29export function stopWarming(warming: Warming, why: string): Warming {
30  return { ...warming, isOn: false, stopped: why };
31}
32
33/**
34 * True once the person has sent no turn for IDLE_LIMIT_MS. The pings move the
35 * cache's clock, not this one.
36 */
37export function isAbandoned(lastTurnAt: number, now: number): boolean {
38  return now - lastTurnAt >= IDLE_LIMIT_MS;
39}
40
hooks/text/units.ts 43 lines
1export function compact(count: number): string {
2  if (count >= 1_000_000) {
3    return `${(count / 1_000_000).toFixed(1)}M`;
4  }
5
6  if (count >= 1000) {
7    return `${(count / 1000).toFixed(1)}k`;
8  }
9
10  return String(count);
11}
12
13/**
14 * The two largest units that apply: `1h 02m`, `7m 03s`, `12s`.
15 */
16export function duration(milliseconds: number): string {
17  const seconds = Math.max(0, Math.floor(milliseconds / 1000));
18  const hours = Math.floor(seconds / 3600);
19  const minutes = Math.floor((seconds % 3600) / 60);
20
21  if (hours > 0) {
22    return `${hours}h ${String(minutes).padStart(2, "0")}m`;
23  }
24
25  if (minutes > 0) {
26    return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
27  }
28
29  return `${seconds}s`;
30}
31
32/**
33 * How long ago, for a message: whole days past the first, else `duration`.
34 */
35export function since(milliseconds: number): string {
36  const days = Math.floor(milliseconds / 86_400_000);
37  if (days >= 2) {
38    return `${days} days`;
39  }
40
41  return duration(milliseconds);
42}
43
hooks/notice/look.ts 12 lines
1import type { Notice } from "../../types";
2
3// Pastel tints, blended toward the Carbonfox background (#161616) like the other
4// mods' colours: blue ice for a cache gone cold, red fire for warming stopped.
5export const LOOKS: Readonly<Record<Notice["kind"], { glyph: string; color: string }>> = {
6  cold: { glyph: "\u{f2dc}", color: "#89B4FA" },
7  stopped: { glyph: "\u{f06d}", color: "#F38BA8" },
8};
9
10// The band's border and padding, so the line starts where the band's text does.
11export const INDENT = 2;
12