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.

<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.
| Plugin | Provides | What it does |
|---|---|---|
crew | Sidebar mod | Shows a side panel with a card for each agent at work. |
ember | Background mod | Keeps the prompt cache warm while you are away, and warns when a resumed one went cold. |
git | git:gitignore | Builds and updates .gitignore files from the official github/gitignore templates. |
kanso | Two skills, a hook | Sets one writing style for all text, and turns substantial answers into pages. |
ribbon | Prompt band mod | Shows a band above the prompt with the session's time, cost, project, branch and limits. |
workflow | Five skills | Plans, coordinates, builds test first, reviews and opens pull requests. Needs kanso. |
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.
| Document | Description |
|---|---|
| [CONTRIBUTING.md][contributing] | Conventions, adding a plugin, and running evals. |
[MIT][license]
[contributing]: CONTRIBUTING.md [license]: LICENSE
hooks/register.ts 22 lines1import 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};
22hooks/classic/model-switch.ts 23 lines1import 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}
23hooks/classic/session-start.ts 52 lines1import 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}
52hooks/prompt/submit.ts 19 lines1import 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}
19hooks/render/above-prompt.tsx 37 lines1import 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}
37hooks/session/end.ts 21 lines1import 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}
21hooks/session/start.ts 125 lines1import 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}
125hooks/turn/complete.ts 39 lines1import 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}
39hooks/turn/step.ts 28 lines1import 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}
28hooks/cache/timing.ts 40 lines1import 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}
40hooks/text/units.ts 43 lines1export 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}
43hooks/notice/look.ts 12 lines1import 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