SLOPSHOPPER

crew

A side panel with a card for each agent at work: the main loop and every subagent, with its model, task, current tool and context use.

newpanespinnerguardcommandprompt
v?MITupdated 2026-10-24fabioueno/koubou/plugins/crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew
│ ┃ crew-side ✕ › fix the failing auth test and add an audit log call │ ┃  Agents (0) │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ z  Main ⏺ Update(src/auth.ts) │ ┃ │ ▐─███─▌  Opus 5.5 ⎿ Added 2 lines, removed 1 line │ ┃ │ ▝▜█████▛▘  Waiting for you ⏺ 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 │ │ › /crew │ ⎿ crew: Crew panel closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crew-side
 Agents (0) ╭─────────────────────────────────────────────────────────── │ z  Main │ ▐─███─▌  Opus 5.5 │ ▝▜█████▛▘  Waiting for you │ ▘▘ ▝▝ ╰───────────────────────────────────────────────────────────
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 34 files
hooks/register.ts 26 lines
1import type { Register } from "claude-code";
2
3import { onAgentSpawn } from "./agent/spawn";
4import { onCommandRun } from "./command/run";
5import { onPromptSubmit } from "./prompt/submit";
6import { onPane } from "./render/pane";
7import { onPromptHint } from "./render/prompt-hint";
8import { onSpinner } from "./render/spinner";
9import { onSessionStart } from "./session/start";
10import { onToolCall } from "./tool/call";
11import { onTurnComplete } from "./turn/complete";
12import { onTurnStep } from "./turn/step";
13
14export const register: Register = (on, options) => {
15  onSessionStart(on);
16  onPromptSubmit(on, options);
17  onTurnStep(on);
18  onTurnComplete(on);
19  onAgentSpawn(on);
20  onToolCall(on);
21  onCommandRun(on);
22  onSpinner(on);
23  onPromptHint(on);
24  onPane(on);
25};
26
hooks/agent/spawn.ts 25 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { spawnRun } from "../agents/runs";
5import { modelName } from "../text/models";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const agents = atom({ plugin: "crew", key: "agents" } as const, {});
10
11export function onAgentSpawn(on: On): void {
12  on("agent.spawn", async ($, e, next) => {
13    const spawned = await next(e);
14    const id = spawned.agentId;
15
16    if (id !== undefined) {
17      const at = await $.clock.now();
18      const spawn = { type: e.subagentType, model: modelName(spawned.model), description: e.description, at };
19      await update($, agents, (all) => spawnRun(all, id, spawn));
20    }
21
22    return spawned;
23  });
24}
25
hooks/command/run.ts 25 lines
1import type { EngineInterface, On } from "claude-code";
2
3import { PANEL_ID } from "../panel/id";
4
5async function togglePanel($: EngineInterface): Promise<{ text: string }> {
6  if ((await $.ui.panes()).some((pane) => pane.id === PANEL_ID)) {
7    await $.ui.close({ id: PANEL_ID });
8    return { text: "Crew panel closed." };
9  }
10
11  await $.ui.open({ id: PANEL_ID, title: "Crew" });
12
13  return { text: "Crew panel opened." };
14}
15
16// The name may arrive under the plugin's prefix (crew:crew).
17const nameOf = (command: string): string => command.trim().split(/\s+/)[0]?.split(":").at(-1) ?? "";
18
19/**
20 * `/crew` shows or hides the agents panel.
21 */
22export function onCommandRun(on: On): void {
23  on("command.run", ($, e, next) => (nameOf(e.command) === "crew" ? togglePanel($) : next(e)));
24}
25
hooks/prompt/submit.ts 34 lines
1import type { On, PluginOptions } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { moodOf } from "../mood/classify";
5import { MOOD_MS } from "../mood/look";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const mood = atom({ plugin: "crew", key: "mood" } as const, null);
10
11/**
12 * A prompt the person typed sets Main's mood for a few seconds; the prompt
13 * itself reaches the model untouched, without waiting for the classifier.
14 */
15export function onPromptSubmit(on: On, options: PluginOptions): void {
16  on("prompt.submit", async ($, e, next) => {
17    const submitted = await next(e);
18    if (options.showMoods === false || e.origin.kind !== "composer") {
19      return submitted;
20    }
21
22    moodOf($, e.text)
23      .then(async (kind) => {
24        if (kind !== undefined) {
25          const until = (await $.clock.now()) + MOOD_MS;
26          await update($, mood, () => ({ kind, until }));
27        }
28      })
29      .catch(() => undefined);
30
31    return submitted;
32  }).catch((_$, e, next) => next(e));
33}
34
hooks/render/pane.tsx 56 lines
1import type { On } from "claude-code";
2import { atom, read } from "claude-code";
3
4import { agentsCards } from "../agents/card";
5import { GLYPHS } from "../palette/glyphs";
6import { PANEL_ID } from "../panel/id";
7
8// Declared in each file that reads them: the mod validator follows an atom only
9// within its own file.
10const agents = atom({ plugin: "crew", key: "agents" } as const, {});
11const effort = atom({ plugin: "crew", key: "effort" } as const, null);
12const frame = atom({ plugin: "crew", key: "frame" } as const, 0);
13const main = atom({ plugin: "crew", key: "main" } as const, null);
14const mood = atom({ plugin: "crew", key: "mood" } as const, null);
15const verb = atom({ plugin: "crew", key: "verb" } as const, null);
16
17/**
18 * The side panel: a card for the main loop and one for each subagent.
19 */
20export function onPane(on: On): void {
21  on("ui.render", { component: "Pane", requestId: PANEL_ID }, async ($, e, next) => {
22    if (e.surface !== "terminal") {
23      return next(e);
24    }
25
26    const E = $.ui.resolve(e);
27    const { Box, Text } = E;
28
29    const now = await $.clock.now();
30    const runs = await read($, agents);
31    const loop = await read($, main);
32    const current = await read($, mood);
33
34    const isMoody = current !== null && current.until > now;
35
36    return (
37      <Box flexDirection="column" gap={1}>
38        <Text bold>
39          {GLYPHS.agentsTitle} Agents ({Object.keys(runs).length})
40        </Text>
41
42        {agentsCards(E, {
43          runs,
44          tick: await read($, frame),
45          now,
46          width: e.props.bodyColumns,
47          model: loop?.model ?? "",
48          effort: await read($, effort),
49          mainWindow: loop?.contextWindow ?? 200_000,
50          moment: { verb: (await read($, verb)) ?? "Thinking", mood: isMoody ? current.kind : undefined },
51        })}
52      </Box>
53    );
54  });
55}
56
hooks/render/prompt-hint.ts 18 lines
1import type { On } from "claude-code";
2
3const AGENTS_COUNT = /\s*·\s*←?\s*\d+ agents?\b/g;
4
5/**
6 * The agents live in the side panel, so the hint line need not count them.
7 */
8export function onPromptHint(on: On): void {
9  on("ui.render", { component: "PromptHint" }, (_$, e, next) => {
10    const hint = e.props.hint.replace(AGENTS_COUNT, "");
11    if (e.surface !== "terminal" || hint === e.props.hint) {
12      return next(e);
13    }
14
15    return next({ ...e, props: { ...e.props, hint } });
16  });
17}
18
hooks/render/spinner.ts 27 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 verb = atom({ plugin: "crew", key: "verb" } as const, null);
7
8/**
9 * Passes the engine's line through, keeping its verb for the main agent's card.
10 */
11export function onSpinner(on: On): void {
12  let lastVerb = "";
13  on("ui.render", { component: "Spinner" }, ($, e, next) => {
14    const word = e.props.word;
15    if (word !== "" && word !== lastVerb) {
16      lastVerb = word;
17
18      // Drawing may not write state, so the write waits for a timer.
19      $.clock.after(0, () => {
20        update($, verb, () => word).catch(() => undefined);
21      });
22    }
23
24    return next(e);
25  });
26}
27
hooks/session/start.ts 73 lines
1import type { EngineInterface, On } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { MainLoop } from "../../types";
5import { isLive } from "../agents/runs";
6import { PANEL_ID } from "../panel/id";
7import { modelName } from "../text/models";
8
9// Declared in each file that reads them: the mod validator follows an atom only
10// within its own file.
11const agents = atom({ plugin: "crew", key: "agents" } as const, {});
12const frame = atom({ plugin: "crew", key: "frame" } as const, 0);
13const main = atom({ plugin: "crew", key: "main" } as const, null);
14const mood = atom({ plugin: "crew", key: "mood" } as const, null);
15
16async function refreshMain($: EngineInterface): Promise<void> {
17  const [usage, model] = await Promise.all([$.session.usage(), $.session.model()]);
18  const next: MainLoop = { model: modelName(model), contextWindow: usage.context.window };
19  await update($, main, () => next);
20}
21
22// Beats while nothing works: idle Main still breathes its z's, at a third of
23// the pace, so a quiet panel redraws less often.
24const IDLE_EVERY = 3;
25let quietTicks = 0;
26
27// The Clawds move at full pace while a loop works or Main shows a mood.
28async function tick($: EngineInterface): Promise<void> {
29  const now = await $.clock.now();
30  const isWorking = Object.values(await read($, agents)).some(isLive);
31  const current = await read($, mood);
32  const isMoody = current !== null && current.until > now;
33
34  // An expired mood is cleared, so its last frame does not stay on screen.
35  if (current !== null && !isMoody) {
36    await update($, mood, () => null);
37  }
38
39  quietTicks = isWorking || isMoody ? 0 : quietTicks + 1;
40  if (quietTicks % IDLE_EVERY === 0) {
41    await update($, frame, (count) => count + 1);
42  }
43}
44
45function startTimers($: EngineInterface): void {
46  $.clock.every(15_000, () => {
47    refreshMain($).catch(() => undefined);
48  });
49  $.clock.every(400, () => {
50    tick($).catch(() => undefined);
51  });
52}
53
54export function onSessionStart(on: On): void {
55  on("session.start", async ($, e, next) => {
56    const started = await next(e);
57    await $.command.register({
58      name: "crew",
59      description: "Show or hide the agents panel",
60      immediate: true,
61    });
62
63    await refreshMain($).catch(() => undefined);
64    startTimers($);
65
66    if (e.surface === "terminal" && e.isInteractive) {
67      await $.ui.open({ id: PANEL_ID, title: "Crew" });
68    }
69
70    return started;
71  });
72}
73
hooks/tool/call.ts 30 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { clearTool, MAIN, noteTool } from "../agents/runs";
5import { homeOf } from "../text/paths";
6import { toolSummary } from "../tools/summary";
7
8// Declared in each file that reads them: the mod validator follows an atom only
9// within its own file.
10const agents = atom({ plugin: "crew", key: "agents" } as const, {});
11
12/**
13 * Every tool call: its loop's card shows it while it runs.
14 */
15export function onToolCall(on: On): void {
16  on("tool.call", async ($, e, next) => {
17    const tool = String(e.tool);
18    const input = e as unknown as Record<string, unknown>;
19    const id = String(input.agentId ?? MAIN);
20    const lastTool = `${tool} ${toolSummary(tool, input, await $.session.cwd(), await homeOf($))}`;
21
22    await update($, agents, (all) => noteTool(all, id, lastTool));
23    const ran = await next(e);
24
25    await update($, agents, (all) => clearTool(all, id, lastTool));
26
27    return ran;
28  }).catch((_$, e, next) => next(e));
29}
30
hooks/turn/complete.ts 24 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { endRun, MAIN } from "../agents/runs";
5
6// Declared in each file that reads them: the mod validator follows an atom only
7// within its own file.
8const agents = atom({ plugin: "crew", key: "agents" } as const, {});
9
10/**
11 * A loop's turn ends: its card stops.
12 */
13export function onTurnComplete(on: On): void {
14  on("turn.complete", async ($, e, next) => {
15    const done = await next(e);
16    const now = await $.clock.now();
17    const isFailed = e.agentId !== undefined && "error" in e;
18
19    await update($, agents, (all) => endRun(all, e.agentId ?? MAIN, { at: now, isFailed }));
20
21    return done;
22  });
23}
24
hooks/turn/step.ts 48 lines
1import type { On } from "claude-code";
2import { atom, update } from "claude-code";
3
4import { MAIN, reviveRun, startMainTurn, stepRun } from "../agents/runs";
5import { modelName } from "../text/models";
6
7// Declared in each file that reads them: the mod validator follows an atom only
8// within its own file.
9const agents = atom({ plugin: "crew", key: "agents" } as const, {});
10const effort = atom({ plugin: "crew", key: "effort" } as const, null);
11
12/**
13 * Each model request: every loop's card counts its steps and tokens.
14 */
15export function onTurnStep(on: On): void {
16  on("turn.step", async function* ($, e, next) {
17    const id = e.agentId ?? MAIN;
18    const now = await $.clock.now();
19
20    if (e.agentId === undefined) {
21      const level = e.effort;
22      await update($, agents, (all) => startMainTurn(all, now));
23
24      if (level !== undefined) {
25        await update($, effort, () => String(level));
26      }
27    } else {
28      await update($, agents, (all) => reviveRun(all, id, { model: modelName(e.model), at: now }));
29    }
30
31    const step = yield* next(e);
32    const usage = step.usage;
33
34    if (usage) {
35      await update($, agents, (all) =>
36        stepRun(all, id, {
37          input: usage.input_tokens,
38          cacheRead: usage.cache_read_input_tokens,
39          cacheWrite: usage.cache_creation_input_tokens,
40          output: usage.output_tokens,
41        }),
42      );
43    }
44
45    return step;
46  });
47}
48
hooks/agents/runs.ts 126 lines
1import type { AgentRun } from "../../types";
2
3type Runs = Readonly<Record<string, AgentRun>>;
4
5function withRun(all: Runs, id: string, change: (run: AgentRun) => AgentRun): Runs {
6  const run = all[id];
7  return run === undefined ? all : { ...all, [id]: change(run) };
8}
9
10// The main loop's row; subagents are keyed by their agent id.
11export const MAIN = "main";
12
13/**
14 * The subagents in the order they started; this order picks each one's look.
15 */
16export function byStart(all: Runs): [string, AgentRun][] {
17  return Object.entries(all)
18    .filter(([id]) => id !== MAIN)
19    .sort(([, left], [, right]) => left.startedAt - right.startedAt);
20}
21
22/**
23 * The subagents as the panel lists them: working ones first, newest at the
24 * top, then finished ones, the most recently finished first.
25 */
26export function byActivity(all: Runs): [string, AgentRun][] {
27  const activeAt = (run: AgentRun): number => run.endedAt ?? Number.POSITIVE_INFINITY;
28
29  return byStart(all).sort(
30    ([, left], [, right]) => activeAt(right) - activeAt(left) || right.startedAt - left.startedAt,
31  );
32}
33
34export function clearTool(all: Runs, id: string, lastTool: string): Runs {
35  return withRun(all, id, (run) => (run.lastTool === lastTool ? { ...run, lastTool: undefined } : run));
36}
37
38export function endRun(all: Runs, id: string, end: { at: number; isFailed: boolean }): Runs {
39  return withRun(all, id, (run) => ({ ...run, endedAt: end.at, isFailed: end.isFailed }));
40}
41
42/**
43 * True while the loop waits on its own AskUserQuestion, which runs until the
44 * person answers.
45 */
46export function isAsking(run: AgentRun | undefined): boolean {
47  return isLive(run) && run?.lastTool?.startsWith("AskUserQuestion") === true;
48}
49
50export function isLive(run: AgentRun | undefined): boolean {
51  return run !== undefined && run.endedAt === undefined;
52}
53
54export function noteTool(all: Runs, id: string, lastTool: string): Runs {
55  return withRun(all, id, (run) => ({ ...run, lastTool }));
56}
57
58/**
59 * A step from a finished subagent means it was sent more work: it is working
60 * again, its clock from now.
61 */
62export function reviveRun(all: Runs, id: string, step: { model: string; at: number }): Runs {
63  return withRun(all, id, (run) => {
64    const revived =
65      run.endedAt === undefined ? run : { ...run, endedAt: undefined, startedAt: step.at, isFailed: false };
66
67    return { ...revived, model: run.model ?? step.model };
68  });
69}
70
71export function spawnRun(
72  all: Runs,
73  id: string,
74  spawn: { type: string; model: string; description: string; at: number },
75): Runs {
76  const run: AgentRun = {
77    type: spawn.type,
78    model: spawn.model,
79    description: spawn.description,
80    startedAt: spawn.at,
81    isFailed: false,
82    steps: 0,
83    context: 0,
84    output: 0,
85  };
86
87  return { ...all, [id]: run };
88}
89
90/**
91 * A step after the main loop went idle starts its next turn, keeping the
92 * counts.
93 */
94export function startMainTurn(all: Runs, at: number): Runs {
95  const main = all[MAIN];
96  if (isLive(main)) {
97    return all;
98  }
99
100  return {
101    ...all,
102    [MAIN]: {
103      type: "main",
104      description: "",
105      startedAt: at,
106      isFailed: false,
107      steps: main?.steps ?? 0,
108      context: main?.context ?? 0,
109      output: main?.output ?? 0,
110    },
111  };
112}
113
114export function stepRun(
115  all: Runs,
116  id: string,
117  usage: { input: number; cacheRead: number; cacheWrite: number; output: number },
118): Runs {
119  return withRun(all, id, (run) => ({
120    ...run,
121    steps: run.steps + 1,
122    context: usage.input + usage.cacheRead + usage.cacheWrite,
123    output: run.output + usage.output,
124  }));
125}
126