A side pane that lists subagent runs and chosen shell commands with their state and elapsed time.

A small plugin ("mod") for Claude Code's terminal UI that opens a side pane listing subagent runs, and optionally chosen shell commands, with their state and elapsed time.
日本語の説明は README.ja.md にあります。
deploy.sh or a review script): add its name to the commands setting.Not for you if you run Claude non-interactively (claude -p), or on a narrow terminal: when there is no room for a side pane, nothing opens, and /supervisor reports that instead.
A pane titled Supervisors opens beside the conversation when the first matching call starts. Each row is one call:
… code-reviewer 12s
✓ test-runner 41s
✗ deploy.sh 3s
done 2 / 3
… is running, ✓ finished, ✗ failed. /supervisor opens or closes the pane.
From the marketplace in this repository:
claude plugin marketplace add i-noma-ru/claude-supervisor-pane
claude plugin install supervisor-pane@claude-supervisor-pane
Or for one session only, from a clone:
claude --plugin-dir /path/to/claude-supervisor-pane
All settings have defaults, so the mod works without configuration. Change them with /plugin configure supervisor-pane@claude-supervisor-pane or in /config.
| Setting | Default | Meaning |
|---|---|---|
agents | empty | subagent_type values to list. Empty lists every subagent. |
commands | empty | Substrings of Bash commands to list as rows. Empty lists no shell commands. |
max_rows | 30 | Oldest finished rows are dropped beyond this count. |
Agent tool call whose subagent_type matches (or on every one when agents is empty), a row starts. A subagent launched in the background returns before it finishes, so the mod keeps the row running and checks the engine's agent list every 5 seconds to mark it finished or failed.Bash tool call whose command contains one of commands, a row starts and finishes with the call. A non-zero exit or an error result marks it failed.The subagent_type and result status of Agent calls, the command text of Bash calls, and the engine's list of agents. It reads no files and sends nothing anywhere.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 159 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SupervisorRow } from '../types'
5import {
6 PANE_ID,
7 PANE_TITLE,
8 hasRunning,
9 outcomeOf,
10 prune,
11 renderLines,
12 rowNameFor,
13 settingsFrom,
14 settleByAgents,
15} from './logic'
16import type { RanShape } from './logic'
17
18const rows = atom({ plugin: 'supervisor-pane', key: 'rows' } as const, [])
19
20// Functions that take $ live at module level (passing $ into a function made inside a hook fails validate)
21
22async function openPane($: EngineInterface): Promise<boolean> {
23 try {
24 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
25 return opened.isPlaced
26 } catch {
27 // No screen (claude -p): nothing to open
28 return false
29 }
30}
31
32async function beginRow($: EngineInterface, id: string, name: string, max: number): Promise<void> {
33 const startedAt = await $.clock.now()
34 const row: SupervisorRow = { id, name, status: 'running', startedAt }
35 const before = await read($, rows)
36 await update($, rows, list => prune([...list, row], max))
37 // Open only for the first row; never reopen a pane the user closed
38 if (before.length === 0) await openPane($)
39}
40
41async function finishRow($: EngineInterface, id: string, ran: RanShape): Promise<void> {
42 const outcome = outcomeOf(ran)
43 const endedAt = outcome.status === 'running' ? undefined : await $.clock.now()
44 await update($, rows, list =>
45 list.map(row => {
46 if (row.id !== id) return row
47 if (outcome.status === 'running') return { ...row, agentId: outcome.agentId }
48 return { ...row, status: outcome.status, endedAt }
49 }),
50 )
51}
52
53async function failRow($: EngineInterface, id: string): Promise<void> {
54 const endedAt = await $.clock.now()
55 await update($, rows, list =>
56 list.map(row => (row.id === id ? { ...row, status: 'error' as const, endedAt } : row)),
57 )
58}
59
60/** Every 5 s: settle background Agents from agent.list and redraw elapsed seconds of running rows */
61async function tick($: EngineInterface): Promise<void> {
62 try {
63 const list = await read($, rows)
64 if (!hasRunning(list)) return
65 if (list.some(row => row.status === 'running' && row.agentId !== undefined)) {
66 const agents = await $.agent.list()
67 const now = await $.clock.now()
68 if (settleByAgents(list, agents, now).changed) {
69 await update($, rows, current => settleByAgents(current, agents, now).rows)
70 }
71 }
72 $.ui.invalidate('ui.render')
73 } catch {
74 // A failed list or redraw is retried on the next tick
75 }
76}
77
78export const register: Register = (on, options) => {
79 const settings = settingsFrom(options)
80
81 on('session.start', async ($, e, next) => {
82 try {
83 await $.command.register({
84 name: 'supervisor',
85 description: 'Open or close the Supervisors pane (subagent runs and chosen shell commands)',
86 })
87 } catch {
88 // A failed registration must not stop the session
89 }
90 $.clock.every(5000, () => {
91 void tick($)
92 })
93
94 return next(e)
95 })
96
97 on('command.run', { command: 'supervisor' }, async $ => {
98 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
99 if (isUp) {
100 await $.ui.close({ id: PANE_ID })
101 return { text: 'Supervisors pane closed.' }
102 }
103 const placed = await openPane($)
104 return {
105 text: placed
106 ? 'Supervisors pane opened.'
107 : 'Supervisors pane opened, but it cannot be placed on this screen (terminal too narrow, or no screen).',
108 }
109 })
110
111 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
112 const name = rowNameFor('Agent', e.subagent_type, settings)
113 if (name === undefined) return next(e)
114 await beginRow($, e.tool_use_id, name, settings.maxRows)
115 let ran: Awaited<ReturnType<typeof next>>
116 try {
117 ran = await next(e)
118 } catch (error) {
119 // If next rejects (interrupt), do not leave the row running
120 await failRow($, e.tool_use_id).catch(() => undefined)
121 throw error
122 }
123 await finishRow($, e.tool_use_id, ran)
124
125 return ran
126 })
127
128 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
129 const name = rowNameFor('Bash', e.command, settings)
130 if (name === undefined) return next(e)
131 await beginRow($, e.tool_use_id, name, settings.maxRows)
132 let ran: Awaited<ReturnType<typeof next>>
133 try {
134 ran = await next(e)
135 } catch (error) {
136 await failRow($, e.tool_use_id).catch(() => undefined)
137 throw error
138 }
139 await finishRow($, e.tool_use_id, ran)
140
141 return ran
142 })
143
144 // The matcher is a literal (a constant makes validate read requestId=?)
145 on('ui.render', { component: 'Pane', requestId: 'supervisor' }, async ($, e) => {
146 const { Box, Text } = $.ui.resolve(e)
147 const list = await read($, rows)
148 const now = await $.clock.now()
149
150 return (
151 <Box flexDirection="column">
152 {renderLines(list, now).map((line, index) => (
153 <Text key={`line${index}`} wrap="truncate-end">{line}</Text>
154 ))}
155 </Box>
156 )
157 })
158}
159hooks/logic.ts 132 lines1import type { PluginOptions } from 'claude-code'
2
3import type { SupervisorRow } from '../types'
4
5export const PANE_ID = 'supervisor'
6export const PANE_TITLE = 'Supervisors'
7export const DEFAULT_MAX_ROWS = 30
8
9/** The plugin's userConfig, normalized once per activation */
10export type Settings = {
11 /** subagent_type values that get a row; empty = every subagent */
12 agents: readonly string[]
13 /** substrings; a Bash command containing one gets a row named after it; empty = no Bash rows */
14 commands: readonly string[]
15 maxRows: number
16}
17
18/** A `multiple` string field arrives as an array; accept a lone string too */
19function stringList(value: unknown): readonly string[] {
20 if (value === undefined || value === null) return []
21 return ([] as unknown[]).concat(value).filter((one): one is string => typeof one === 'string')
22}
23
24export function settingsFrom(options: PluginOptions): Settings {
25 const max = options.max_rows
26 return {
27 agents: stringList(options.agents),
28 commands: stringList(options.commands),
29 maxRows: typeof max === 'number' && Number.isFinite(max) ? max : DEFAULT_MAX_ROWS,
30 }
31}
32
33/**
34 * The row's name. Agent passes subagent_type, Bash passes the command, as `detail`.
35 * undefined = no row.
36 */
37export function rowNameFor(tool: string, detail: unknown, settings: Settings): string | undefined {
38 if (typeof detail !== 'string') return undefined
39 if (tool === 'Agent') {
40 return settings.agents.length === 0 || settings.agents.includes(detail) ? detail : undefined
41 }
42 if (tool === 'Bash') return settings.commands.find(part => detail.includes(part))
43 return undefined
44}
45
46/** The part of next(e)'s result that decides where the row goes */
47export type RanShape = {
48 deny?: string
49 isError?: boolean
50 text?: string
51 result?: unknown
52}
53
54export type Outcome = { status: 'done' | 'error' } | { status: 'running'; agentId: string }
55
56// "exit code 0" is not a failure, so the first digit is limited to 1-9
57const NONZERO_EXIT = /exit code[:\s]*[1-9]\d*/i
58
59/** done / error / running (background Agent) from next(e)'s result */
60export function outcomeOf(ran: RanShape): Outcome {
61 if (ran.deny !== undefined || ran.isError === true) return { status: 'error' }
62 const result = ran.result
63 const record = result && typeof result === 'object' ? (result as Record<string, unknown>) : {}
64 // A background Agent answers at launch (status: async_launched); its end is read from agent.list.
65 // A record with agentId but no status has not finished either, so it is treated the same.
66 if (typeof record.agentId === 'string' && record.status !== 'completed') {
67 return { status: 'running', agentId: record.agentId }
68 }
69 const text = [ran.text, record.stdout, record.stderr]
70 .filter((part): part is string => typeof part === 'string')
71 .join('\n')
72 return { status: NONZERO_EXIT.test(text) ? 'error' : 'done' }
73}
74
75/** Keep at most `max` rows: drop the oldest done/error first; if all are running, drop the oldest */
76export function prune(rows: SupervisorRow[], max = DEFAULT_MAX_ROWS): SupervisorRow[] {
77 if (rows.length <= max) return rows
78 const kept = [...rows]
79 while (kept.length > max) {
80 const index = kept.findIndex(row => row.status !== 'running')
81 kept.splice(index < 0 ? 0 : index, 1)
82 }
83 return kept
84}
85
86export function hasRunning(rows: readonly SupervisorRow[]): boolean {
87 return rows.some(row => row.status === 'running')
88}
89
90type AgentLike = { id: string; status: string }
91
92/** Copy background Agents' end state onto rows: completed -> done, failed/killed -> error */
93export function settleByAgents(
94 rows: SupervisorRow[],
95 agents: readonly AgentLike[],
96 now: number,
97): { rows: SupervisorRow[]; changed: boolean } {
98 let changed = false
99 const settled = rows.map(row => {
100 if (row.status !== 'running' || row.agentId === undefined) return row
101 const agent = agents.find(one => one.id === row.agentId)
102 if (agent === undefined) return row
103 if (agent.status === 'completed') {
104 changed = true
105 return { ...row, status: 'done' as const, endedAt: now }
106 }
107 if (agent.status === 'failed' || agent.status === 'killed') {
108 changed = true
109 return { ...row, status: 'error' as const, endedAt: now }
110 }
111 return row
112 })
113 return { rows: changed ? settled : rows, changed }
114}
115
116const MARK = { running: '…', done: '✓', error: '✗' } as const
117
118export function elapsedSeconds(row: SupervisorRow, now: number): number {
119 return Math.max(0, Math.floor(((row.endedAt ?? now) - row.startedAt) / 1000))
120}
121
122export const EMPTY_LINE = 'No supervisors have run yet.'
123
124/** Pane lines: one per row as "<mark> <name> <seconds>s", then "done n / m" (n counts done and error) */
125export function renderLines(rows: readonly SupervisorRow[], now: number): string[] {
126 const lines = rows.map(row => `${MARK[row.status]} ${row.name} ${elapsedSeconds(row, now)}s`)
127 if (lines.length === 0) lines.push(EMPTY_LINE)
128 const finished = rows.filter(row => row.status !== 'running').length
129 lines.push(`done ${finished} / ${rows.length}`)
130 return lines
131}
132types/index.d.ts 23 lines1export type SupervisorStatus = 'running' | 'done' | 'error'
2
3/** One pane row = one tracked call (the same subagent running twice in parallel makes two rows). */
4export type SupervisorRow = {
5 /** tool_use_id of the tool.call */
6 id: string
7 /** The subagent_type as given, or the matched command substring for Bash */
8 name: string
9 status: SupervisorStatus
10 /** Milliseconds ($.clock.now) */
11 startedAt: number
12 /** Set when the row finishes; absent while running */
13 endedAt?: number
14 /** Id of a background Agent; its end is read from $.agent.list() */
15 agentId?: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'supervisor-pane': { rows: SupervisorRow[] }
21 }
22}
23