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)…

English | 日本語
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.
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.
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.
thinking otherwisedone, and its row goes away 5 seconds laterinterrupted and goes away 5 seconds later. Background agents and teammates keep running after the main turn ends, so they stayN more rowWhen another mod, such as ship-session's progress band, also draws a band above the prompt, both are stacked vertically.
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.
Each agent type has a fixed face.
| Type | Face |
|---|---|
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.
It follows Claude Code's language setting, then the language of your latest message, then English.
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.
agent.spawn)claude plugin validate plugins/agent-cast
claude plugin test plugins/agent-casthooks/cast-band.tsx 171 lines1// 子エージェント(サブエージェントとチームメイト)が走っている間、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}
171hooks/cast.ts 139 lines1// 子エージェントの一覧を、観測したイベントから組み立てる純粋関数と、表示の文言。
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}
139types/index.d.ts 31 lines1/** 走っている(または終わって間もない)子エージェント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