SLOPSHOPPER

agent-cast

While subagents and teammates run, shows each one as a character: one row per agent in a band above the prompt (face, type, task, current tool, elapsed time)…

newbandspinnerguardprompttimer
★ 1v0.1.0MITupdated 2026-10-05insession-space/claude-ship/plugins/agent-cast
A shopper browsing a rack in a slop shop
README

English | 日本語

agent-cast

A Claude Code mod that shows each running child agent (subagents started with the Agent tool, and teammates) as a character. Above the prompt and on the spinner line you can see how many agents are running, what role each has, and which tool each one is using right now.

Install

claude plugin marketplace add insession-space/claude-ship
claude plugin install agent-cast@claude-ship

Restart Claude Code to load it. The mod is a hooks module (hooks/cast-band.tsx) listed under modules in hooks/hooks.json. A Claude Code version without mods does not read the module, so nothing happens there.

What it shows

Band above the prompt

While at least one child agent exists, a band above the prompt shows one row per agent.

╭─────────────────────────────────────────────────────────╮
│ Subagents: 3                                            │
│ (o.o) Explore  find the auth code        Read     12s   │
│ [-_-] Plan     plan the change           thinking  4s   │
│ (^_^) general-purpose read the diff      done     31s   │
╰─────────────────────────────────────────────────────────╯

Each row shows the face, the agent type, the description given to the Agent tool, the current state, and the elapsed seconds.

  • The state is the tool name while a tool runs, and thinking otherwise
  • A finished agent shows done, and its row goes away 5 seconds later
  • If the main turn ends without a finish event for a foreground agent, that agent shows interrupted and goes away 5 seconds later. Background agents and teammates keep running after the main turn ends, so they stay
  • With 6 or more agents, the band shows 5 rows and folds the rest into one N more row
  • A long description is cut at the end to fit the width

When another mod, such as ship-session's progress band, also draws a band above the prompt, both are stacked vertically.

Spinner line

While child agents run, the spinner text is replaced with their faces and a count. Up to 5 faces are shown, and the rest as +N. The elapsed time and token count stay as the engine draws them.

✻ (o.o) [-_-] 2 agents working… (12s · ↓ 2.1k tokens)

While the engine shows its own text (for example during compaction), that text is kept.

Faces

Each agent type has a fixed face.

TypeFace
Explore(o.o)
Plan[-_-]
general-purpose(^_^)
claude-code-guide(?_?)
fork(=_=)
teammate(^o^)

Any other type, such as a plugin's agent, gets a spare face picked from a hash of its type name, so the same name always gets the same face. Agents of the same type running in parallel share a face; tell them apart by their descriptions.

Language

It follows Claude Code's language setting, then the language of your latest message, then English.

Where it appears

In the terminal and in the desktop app's Code tab. Only those two have a spinner line, so VS Code and mobile show the band alone.

Not included

  • Agents started by the Workflow tool (it is not yet confirmed that they go through agent.spawn)
  • Swapping characters, or stopping an agent from the band

Tests

claude plugin validate plugins/agent-cast
claude plugin test plugins/agent-cast
Source 3 files
hooks/cast-band.tsx 171 lines
1// 子エージェント(サブエージェントとチームメイト)が走っている間、1体ずつキャラクターとして出す mod。
2// 入力欄の上のバンドに1体1行、スピナー行の先頭に顔を並べる。
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { Cast } from '../types'
7import {
8  LABELS,
9  bandRows,
10  elapsedSeconds,
11  faceFor,
12  hasJapanese,
13  initial,
14  langFromSetting,
15  onDone,
16  onMainTurnEnd,
17  onSpawn,
18  onTool,
19  spinnerText,
20  statusText,
21  tick,
22} from './cast'
23
24const cast = atom({ plugin: 'agent-cast', key: 'cast' } as const, initial() as Cast)
25const lang = atom({ plugin: 'agent-cast', key: 'lang' } as const, null as 'ja' | 'en' | null)
26
27// 経過秒を進め、終わったエージェントを消すためのタイマー。一覧が空になったら止める。
28// 再読み込みでこの変数は消えるが、session.start で一覧が残っていれば張り直す
29let ticker: Timer | null = null
30
31const step = async ($: EngineInterface) => {
32  const now = await $.clock.now()
33  const next = await update($, cast, c => tick(c, now))
34  if (next.agents.length === 0) {
35    ticker?.cancel()
36    ticker = null
37  }
38}
39
40const ensureTicker = ($: EngineInterface) => {
41  if (ticker === null) ticker = $.clock.every(1000, () => void step($))
42}
43
44export const register: Register = on => {
45  // settings の `language` が決まっていればそれを使い、無ければプロンプトの文字で決める
46  let isLangFromSettings = false
47
48  on('session.start', async ($, e, next) => {
49    const fromSettings = langFromSetting((await $.settings.read()).language)
50    isLangFromSettings = fromSettings !== null
51    if (fromSettings !== null) await update($, lang, () => fromSettings)
52    if ((await read($, cast)).agents.length > 0) ensureTicker($)
53
54    return next(e)
55  })
56
57  on('session.end', async ($, e, next) => {
58    if (e.reason === 'clear') {
59      ticker?.cancel()
60      ticker = null
61      await update($, cast, () => initial())
62    }
63
64    return next(e)
65  })
66
67  on('prompt.submit', async ($, e, next) => {
68    const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
69    if (isUserText && !isLangFromSettings) await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
70
71    return next(e)
72  })
73
74  on('agent.spawn', async ($, e, next) => {
75    const ran = await next(e)
76    if (ran.deny !== undefined || ran.agentId === undefined) return ran
77
78    const agent = {
79      id: ran.agentId,
80      type: e.subagentType,
81      description: e.description,
82      isDetached: e.background || e.isTeammate === true,
83    }
84    const now = await $.clock.now()
85    await update($, cast, c => onSpawn(c, agent, now))
86    ensureTicker($)
87
88    return ran
89  })
90
91  // 子エージェントのループのツール呼び出し。実行中だけツール名を出す
92  on('tool.call', async ($, e, next) => {
93    const id = e.agentId
94    if (id === undefined || !(await read($, cast)).agents.some(a => a.id === id)) return next(e)
95
96    await update($, cast, c => onTool(c, id, e.tool))
97    try {
98      return await next(e)
99    } finally {
100      await update($, cast, c => (c.agents.find(a => a.id === id)?.tool === e.tool ? onTool(c, id, null) : c))
101    }
102  })
103
104  on('turn.complete', async ($, e, next) => {
105    const now = await $.clock.now()
106    const id = e.agentId
107    const { agents } = await read($, cast)
108    if (id !== undefined) {
109      if (agents.some(a => a.id === id)) await update($, cast, c => onDone(c, id, now))
110    } else if (agents.some(a => a.state === 'running' && !a.isDetached)) {
111      await update($, cast, c => onMainTurnEnd(c, now))
112    }
113
114    return next(e)
115  })
116
117  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
118    // 圧縮中などエンジンが message を出しているときは、そちらを優先する
119    if (e.props.message !== null) return next(e)
120    const text = spinnerText(await read($, cast), (await read($, lang)) ?? 'en')
121    if (text === null) return next(e)
122
123    return next({ ...e, props: { ...e.props, message: text } })
124  })
125
126  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
127    const c = await read($, cast)
128    if (e.props.hasSurvey || c.agents.length === 0) return next(e)
129
130    // 他のプラグイン(ship-session のバンドなど)が描くものの下に並べる
131    const below = await next(e)
132    const { Box, Text } = $.ui.resolve(e)
133    const l = (await read($, lang)) ?? 'en'
134    const { rows, rest } = bandRows(c)
135    // 種別名の長さが違っても説明の列が揃うよう、種別の列を一番長い名前の幅にする
136    const typeWidth = Math.max(...rows.map(a => a.type.length))
137
138    return (
139      <Box flexDirection="column">
140        {below}
141        <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1}>
142          <Text bold color="magenta">
143            {LABELS[l].title(c.agents.length)}
144          </Text>
145          {rows.map(a => (
146            <Box key={a.id} columnGap={1}>
147              <Text color={a.state === 'running' ? 'magenta' : undefined} dimColor={a.state !== 'running'}>
148                {faceFor(a.type)}
149              </Text>
150              <Box width={typeWidth} flexShrink={0}>
151                <Text bold>{a.type}</Text>
152              </Box>
153              <Box flexGrow={1} flexShrink={1}>
154                <Text wrap="truncate-end">{a.description}</Text>
155              </Box>
156              <Text
157                color={a.state === 'done' ? 'green' : a.state === 'aborted' ? 'red' : 'yellow'}
158                bold={a.state === 'running' && a.tool !== null}
159              >
160                {statusText(a, l)}
161              </Text>
162              <Text dimColor>{elapsedSeconds(a, c.now)}s</Text>
163            </Box>
164          ))}
165          {rest > 0 && <Text dimColor>{LABELS[l].rest(rest)}</Text>}
166        </Box>
167      </Box>
168    )
169  })
170}
171
hooks/cast.ts 139 lines
1// 子エージェントの一覧を、観測したイベントから組み立てる純粋関数と、表示の文言。
2// 描画(cast-band.tsx)から切り離して、遷移だけをテストできるようにしている。
3import type { Cast, CastAgent, Lang } from '../types'
4
5/** 終わったエージェントを、消すまでに残しておく時間 */
6export const LINGER_MS = 5000
7
8/** バンドに出す行数の上限。超えた分は「ほか N 体」の1行にまとめる */
9export const MAX_ROWS = 5
10
11/** スピナー行に並べる顔の上限。超えた分は「+N」にする */
12export const MAX_FACES = 5
13
14/** 組み込みのエージェント種別の顔。どれも半角5文字で幅を揃える */
15export const FACES: Readonly<Record<string, string>> = {
16  Explore: '(o.o)',
17  Plan: '[-_-]',
18  'general-purpose': '(^_^)',
19  'claude-code-guide': '(?_?)',
20  fork: '(=_=)',
21  teammate: '(^o^)',
22}
23
24/** 一覧にない種別に、名前のハッシュで割り当てる顔 */
25export const SPARE_FACES = ['(*_*)', '(O_O)', '(~_~)', '(@_@)', '(u_u)', '(>_<)', '(T_T)', '(;_;)']
26
27/** 種別名から顔を決める。同じ名前なら常に同じ顔になる。 */
28export const faceFor = (type: string): string => {
29  const known = FACES[type]
30  if (known !== undefined) return known
31  let hash = 0
32  for (const ch of type) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
33  return SPARE_FACES[hash % SPARE_FACES.length] ?? '(._.)'
34}
35
36export const initial = (): Cast => ({ agents: [], now: 0 })
37
38export const onSpawn = (
39  c: Cast,
40  a: { id: string; type: string; description: string; isDetached: boolean },
41  now: number,
42): Cast => ({
43  now,
44  agents: [
45    ...c.agents.filter(x => x.id !== a.id),
46    { ...a, startedAt: now, endedAt: null, state: 'running', tool: null },
47  ],
48})
49
50const patch = (c: Cast, id: string, fn: (a: CastAgent) => CastAgent): Cast => {
51  if (!c.agents.some(a => a.id === id && a.state === 'running')) return c
52  return { ...c, agents: c.agents.map(a => (a.id === id && a.state === 'running' ? fn(a) : a)) }
53}
54
55/** そのエージェントのツール呼び出しの開始(tool に名前)と終了(null)。 */
56export const onTool = (c: Cast, id: string, tool: string | null): Cast => patch(c, id, a => ({ ...a, tool }))
57
58/** そのエージェントのループが終わった。 */
59export const onDone = (c: Cast, id: string, now: number): Cast => ({
60  ...patch(c, id, a => ({ ...a, state: 'done', endedAt: now, tool: null })),
61  now,
62})
63
64/**
65 * メインのターンが終わった。フォアグラウンドの子エージェントはメインのターンの中でしか
66 * 走らないので、まだ running のものは終了が届かなかった(中断された)とみなす。
67 * バックグラウンドとチームメイトは走り続けるので触らない。
68 */
69export const onMainTurnEnd = (c: Cast, now: number): Cast => ({
70  now,
71  agents: c.agents.map(a =>
72    a.state === 'running' && !a.isDetached ? { ...a, state: 'aborted', endedAt: now, tool: null } : a,
73  ),
74})
75
76/** 時刻を進め、終わってから LINGER_MS 以上経ったエージェントを消す。 */
77export const tick = (c: Cast, now: number): Cast => ({
78  now,
79  agents: c.agents.filter(a => a.endedAt === null || now - a.endedAt < LINGER_MS),
80})
81
82export const running = (c: Cast): CastAgent[] => c.agents.filter(a => a.state === 'running')
83
84/** 経過秒。終わったものは終了時刻で止める。 */
85export const elapsedSeconds = (a: CastAgent, now: number): number =>
86  Math.max(0, Math.floor(((a.endedAt ?? now) - a.startedAt) / 1000))
87
88/** バンドに出す行と、まとめた残りの体数。 */
89export const bandRows = (c: Cast): { rows: CastAgent[]; rest: number } => ({
90  rows: c.agents.slice(0, MAX_ROWS),
91  rest: Math.max(0, c.agents.length - MAX_ROWS),
92})
93
94export const LABELS = {
95  ja: {
96    title: (n: number) => `子エージェント ${n}体`,
97    thinking: '思考中',
98    done: '完了',
99    aborted: '中断',
100    rest: (n: number) => `ほか ${n} 体`,
101    working: (n: number) => `${n}体が作業中`,
102  },
103  en: {
104    title: (n: number) => `Subagents: ${n}`,
105    thinking: 'thinking',
106    done: 'done',
107    aborted: 'interrupted',
108    rest: (n: number) => `${n} more`,
109    working: (n: number) => (n === 1 ? '1 agent working' : `${n} agents working`),
110  },
111} as const
112
113/** 行の「今の状態」欄の文言。 */
114export const statusText = (a: CastAgent, lang: Lang): string => {
115  const l = LABELS[lang]
116  if (a.state === 'done') return l.done
117  if (a.state === 'aborted') return l.aborted
118  return a.tool ?? l.thinking
119}
120
121/** スピナー行に出す文言(走っているエージェントが0体なら null)。 */
122export const spinnerText = (c: Cast, lang: Lang): string | null => {
123  const now = running(c)
124  if (now.length === 0) return null
125  const faces = now.slice(0, MAX_FACES).map(a => faceFor(a.type))
126  if (now.length > MAX_FACES) faces.push(`+${now.length - MAX_FACES}`)
127  return `${faces.join(' ')} ${LABELS[lang].working(now.length)}`
128}
129
130// 表示言語の決め方は ship-session の ship-progress.ts と揃える(別プラグインなので複製している)
131export const hasJapanese = (text: string): boolean => /[぀-ヿ一-鿿]/.test(text)
132
133/** settings の `language` から表示言語を決める。決まらなければ null。 */
134export const langFromSetting = (language: unknown): Lang | null => {
135  if (typeof language !== 'string' || language.trim() === '') return null
136  // `ja` / `ja-JP` / `日本語` / `Japanese` だけを日本語とみなす(`Javanese` は含めない)
137  return /^(ja([-_].*)?|日本語?|japanese)$/i.test(language.trim()) ? 'ja' : 'en'
138}
139
types/index.d.ts 31 lines
1/** 走っている(または終わって間もない)子エージェント1体。 */
2export type CastAgent = {
3  /** `agent.spawn` の結果の agentId。そのループの `tool.call` と `turn.complete` が同じ値を持つ */
4  id: string
5  /** `Explore` や `general-purpose` などのエージェント種別。顔はこれで決まる */
6  type: string
7  /** Agent ツールの description(数語の説明) */
8  description: string
9  /** バックグラウンドで走るか、チームメイトか。メインのターンが終わっても止まらない */
10  isDetached: boolean
11  /** 開始時刻(`$.clock.now()` のミリ秒) */
12  startedAt: number
13  /** 終了時刻。走っている間は null */
14  endedAt: number | null
15  state: 'running' | 'done' | 'aborted'
16  /** 今実行しているツール名。ツールを使っていなければ null */
17  tool: string | null
18}
19
20/** 表示言語 */
21export type Lang = 'ja' | 'en'
22
23/** mod の状態。`now` は経過秒の表示に使う時刻で、走っている間は1秒ごとに進める */
24export type Cast = { agents: CastAgent[]; now: number }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'agent-cast': { cast: Cast; lang: Lang | null }
29  }
30}
31