Shows the session's agents in a side pane: the main loop and every subagent it spawns, with tools, status, and answers.

A small CLI cockpit for teams that already have agents, hooks, and instructions, and that still use Claude Code in the terminal (including Bedrock). Mods draw live UI and can hold a tool call. They do not replace those files.
Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
claude plugin marketplace add yogeshvar/mod-claude
claude plugin install agents-side@mod-claude
claude plugin install context-board@mod-claude
claude plugin install session-map@mod-claude
claude plugin install blast-radius@mod-claude
Or clone and load for one session:
git clone https://github.com/yogeshvar/mod-claude.git
cd mod-claude
./start-claude
Then:
/tui fullscreen so panes sit beside the transcript.skills 4 · agents 2 · CLAUDE.md · plugins …)./agents-side./session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.pnpm migrate, git reset --hard, git push --force, DROP TABLE, rm -rf, or rm .env opens Blast Radius. 1 proceeds, 2 cancels. This is a safety net, not a permission system — keep hard denies in managed settings.| Plugin | Command | What it does |
|---|---|---|
| agents-side | /agents-side | Open or close the pane |
| agents-side | /agents-side clear | Drop finished agents |
| agents-side | /agents-side close | Close the pane |
| context-board | /context-board | Open or close the pane (the bar stays) |
| context-board | /context-board refresh | Recount every category with the token-count API |
| context-board | /context-board pane | Force-open the pane |
| context-board | /context-board close | Close the pane |
| session-map | /session-map | Open or close the file pane |
| session-map | /session-map clear | Drop the file list |
| session-map | /session-map close | Close the pane |
| blast-radius | /blast-radius | Re-open the hold pane if a command is waiting |
| blast-radius | /blast-radius close | Cancel the held command |
On the agents pane, keys 1–9 select a row (focus the pane first: click it, or ctrl+x tab). c clears finished agents, x closes it.
On the context pane: r refresh, x close.
On the session map: c clear, x close.
On Blast Radius: 1 Proceed, 2 Cancel.
openOnStart (agents pane; session-map, off by default) and openPaneOnStart (context pane, off by default) are plugin options under /config.
Claude Code on Amazon Bedrock / an API key does not load the built-in sec-default guard unless you push managed settings. Without that, a user-installed mod can override deny rules.
For the team:
sec-default in prependPlugins.$(…) and scripts that call rm can still get past it).$.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.context-board calls $.session.usage({ breakdown: "summary" }) after each main-loop turn (local estimates, no extra API). /context-board refresh uses breakdown: "full". The kit strip reads enabled plugins from settings, CLAUDE.md / AGENTS.md from the instruction walk, skill and agent counts from usage, and the last Skill tool name.
agents-side watches agent.spawn, tool.call, turn.start, turn.complete, and session.measure. It never blocks those events.
session-map observes Read, Edit, Write, and Glob after they run. It never blocks.
blast-radius classifies Bash, dry-runs git status / git clean -n when it can, and holds the call until Proceed or Cancel.
claude plugin validate .
claude plugin validate ./plugins/agents-side
claude plugin validate ./plugins/context-board
claude plugin validate ./plugins/session-map
claude plugin validate ./plugins/blast-radius
claude plugin test ./plugins/agents-side
claude plugin test ./plugins/context-board
claude plugin test ./plugins/session-map
claude plugin test ./plugins/blast-radiushooks/register.ts 235 lines1import { drawPane } from './draw.ts'
2import { clip } from './format.ts'
3import { MAIN, PANE, countsOf, emptyMain, treeOf, type AgentRow } from './model.ts'
4
5const agents: AgentRow[] = [emptyMain()]
6let selectedId = MAIN
7
8function rowOf(id: string): AgentRow {
9 return agents.find(row => row.id === id) ?? emptyMain()
10}
11
12function put(next: AgentRow) {
13 const i = agents.findIndex(row => row.id === next.id)
14 if (i === -1) agents.push(next)
15 else agents[i] = next
16}
17
18function patch(id: string, partial: Partial<AgentRow>) {
19 put({ ...rowOf(id), ...partial, id })
20}
21
22function statusLine() {
23 const { running, done } = countsOf(agents)
24 const main = rowOf(MAIN)
25 const ctx = main.percent != null ? ` · ctx ${main.percent}%` : ''
26 return `agents: ${running} running · ${done} done${ctx}`
27}
28
29function pin($: { ui: { status: (text: string | undefined) => void; invalidate: (event: 'ui.render') => void } }) {
30 $.ui.status(statusLine())
31 $.ui.invalidate('ui.render')
32}
33
34async function syncListed($: { agent: { list: () => Promise<Array<{ id: string; status: string; description: string; type: string; parentId?: string; name?: string }>> } }) {
35 const listed = await $.agent.list()
36 for (const info of listed) {
37 const prev = agents.find(row => row.id === info.id)
38 patch(info.id, {
39 type: info.type || prev?.type || 'agent',
40 description: info.description || prev?.description || info.type,
41 status: info.status || prev?.status || 'running',
42 parentId: info.parentId ?? prev?.parentId,
43 name: info.name ?? prev?.name,
44 })
45 }
46}
47
48function paneArgs(columns: number) {
49 return {
50 id: PANE,
51 title: 'Agents',
52 columns,
53 rows: 22,
54 }
55}
56
57async function isOpen($: { ui: { panes: () => Promise<Array<{ id: string; isPlaced: boolean }>> } }) {
58 const panes = await $.ui.panes()
59 return panes.some(pane => pane.id === PANE && pane.isPlaced)
60}
61
62export function register(
63 on: (
64 event: string,
65 matcher?: unknown,
66 hook?: (...args: never[]) => unknown,
67 ) => void,
68 options: Readonly<Record<string, string | number | boolean | readonly string[]>>,
69) {
70 const columns = typeof options.columns === 'number' ? options.columns : 48
71 const openOnStart = options.openOnStart !== false
72
73 on('session.start', async ($, e, next) => {
74 const result = await next(e)
75 await $.command.register({
76 name: 'agents-side',
77 description: 'Toggle the agents side pane',
78 argumentHint: '[clear|close]',
79 immediate: true,
80 })
81 if (e.isInteractive && openOnStart) {
82 await $.ui.open(paneArgs(columns))
83 }
84 await syncListed($)
85 pin($)
86 $.clock.every(2000, () => {
87 void syncListed($).then(() => pin($))
88 })
89 return result
90 })
91
92 on('turn.start', async ($, e, next) => {
93 const result = await next(e)
94 patch(MAIN, {
95 status: 'running',
96 prompt: e.text,
97 description: e.text ? clip(e.text, 40) : 'main loop',
98 })
99 pin($)
100 return result
101 })
102
103 on('session.measure', async ($, e, next) => {
104 const result = await next(e)
105 patch(MAIN, {
106 percent: e.context.percent,
107 window: e.context.window,
108 inputTokens: e.context.tokens,
109 })
110 pin($)
111 return result
112 })
113
114 on('agent.spawn', async ($, e, next) => {
115 const result = await next(e)
116 if ('deny' in result && result.deny) return result
117 const id = result.agentId ?? e.tool_use_id
118 put({
119 id,
120 parentId: e.parentAgentId ?? MAIN,
121 type: e.subagentType,
122 description: e.description || e.subagentType,
123 name: e.name,
124 status: 'running',
125 prompt: e.prompt,
126 model: result.model ?? e.model ?? e.parentModel,
127 fork: e.fork,
128 background: e.background,
129 toolCount: 0,
130 lastTools: [],
131 })
132 selectedId = id
133 pin($)
134 return result
135 }).catch(($, e, next) => next(e))
136
137 on('tool.call', async ($, e, next) => {
138 const result = await next(e)
139 const id = e.agentId ?? MAIN
140 if (!agents.some(row => row.id === id)) {
141 put({
142 ...emptyMain(),
143 id,
144 type: 'loop',
145 description: `loop ${id}`,
146 status: 'running',
147 parentId: MAIN,
148 })
149 }
150 const prev = rowOf(id)
151 patch(id, {
152 status: prev.status === 'idle' ? 'running' : prev.status,
153 toolCount: prev.toolCount + 1,
154 lastTools: [...prev.lastTools, String(e.tool)].slice(-6),
155 })
156 pin($)
157 return result
158 }).catch(($, e, next) => next(e))
159
160 on('turn.complete', async ($, e, next) => {
161 const result = await next(e)
162 const id = e.agentId ?? MAIN
163 let status = 'completed'
164 if (e.reason === 'aborted') status = 'aborted'
165 else if (e.reason === 'error' || e.reason === 'refusal') status = 'failed'
166 const prev = rowOf(id)
167 patch(id, {
168 status: id === MAIN && status === 'completed' ? 'idle' : status,
169 durationMs: e.durationMs,
170 answer: e.answer,
171 inputTokens: e.usage?.input_tokens ?? prev.inputTokens,
172 outputTokens: e.usage?.output_tokens,
173 model: e.usage?.model ?? prev.model,
174 })
175 pin($)
176 return result
177 })
178
179 on('command.run', { command: 'agents-side' }, async ($, e) => {
180 const arg = e.args.trim()
181 if (arg === 'close') {
182 await $.ui.close({ id: PANE })
183 return { text: 'Closed the agents pane.' }
184 }
185 if (arg === 'clear') {
186 const kept = agents.filter(row => row.id === MAIN || row.status === 'running')
187 agents.splice(0, agents.length, ...kept)
188 if (!agents.some(row => row.id === selectedId)) selectedId = MAIN
189 pin($)
190 return { text: 'Cleared finished agents.' }
191 }
192 if (await isOpen($)) {
193 await $.ui.close({ id: PANE })
194 return { text: 'Closed the agents pane.' }
195 }
196 await $.ui.open(paneArgs(columns))
197 return {
198 text: 'Opened the agents pane. Dock it beside the transcript with /tui fullscreen in a terminal at least 110 columns wide.',
199 }
200 })
201
202 on('ui.render', { component: 'Pane' }, ($, e, next) => {
203 if (e.requestId !== PANE) return next(e)
204 const { Box, Text, Button } = $.ui.resolve(e)
205 const width = e.props.bodyColumns
206 return drawPane(Box, Text, Button, {
207 nodes: treeOf(agents),
208 selectedId,
209 bodyColumns: width,
210 isFocused: e.props.isFocused,
211 })
212 })
213
214 on('ui.press', async ($, e, next) => {
215 if (e.requestId !== PANE) return next(e)
216 if (e.element === 'clear') {
217 const kept = agents.filter(row => row.id === MAIN || row.status === 'running')
218 agents.splice(0, agents.length, ...kept)
219 if (!agents.some(row => row.id === selectedId)) selectedId = MAIN
220 pin($)
221 return {}
222 }
223 if (e.element === 'close') {
224 await $.ui.close({ id: PANE })
225 return {}
226 }
227 if (e.element.startsWith('pick-')) {
228 selectedId = e.element.slice('pick-'.length)
229 pin($)
230 return {}
231 }
232 return next(e)
233 })
234}
235hooks/draw.ts 150 lines1import { bar, clip, duration, fillTone, mark, short, tone } from './format.ts'
2import { MAIN, type AgentRow, type TreeNode } from './model.ts'
3
4export type Node = unknown
5export type El = (props: Record<string, unknown>) => Node
6
7export type Snapshot = {
8 nodes: TreeNode[]
9 selectedId: string
10 bodyColumns: number
11 isFocused: boolean
12}
13
14function right(row: AgentRow): string {
15 const bits: string[] = []
16 if (row.percent != null) bits.push(`${row.percent}%`)
17 else if (row.inputTokens != null) bits.push(short(row.inputTokens))
18 if (row.toolCount > 0) bits.push(`${row.toolCount} tools`)
19 if (row.durationMs != null && row.status !== 'running') bits.push(duration(row.durationMs))
20 return bits.join(' · ')
21}
22
23function labelOf(row: AgentRow): string {
24 if (row.id === MAIN) return 'main'
25 const title = row.description || row.name || row.type
26 return row.type === title ? title : `${row.type} ${title}`
27}
28
29export function drawPane(Box: El, Text: El, Button: El, snap: Snapshot): Node {
30 const width = Math.max(24, snap.bodyColumns)
31 const nameWidth = Math.max(12, width - 18)
32 const selected = snap.nodes.find(node => node.row.id === snap.selectedId)?.row
33
34 const rows: Node[] = [
35 Text({ bold: true, children: 'Agents' }),
36 Text({
37 dimColor: true,
38 children: snap.nodes.length <= 1 ? 'waiting for subagents' : `${snap.nodes.length - 1} spawned this session`,
39 }),
40 ]
41
42 snap.nodes.forEach((node, index) => {
43 const hotkey = index < 9 ? String(index + 1) : undefined
44 const selectedMark = node.row.id === snap.selectedId ? '›' : ' '
45 const line = `${selectedMark}${node.prefix}${mark(node.row.status)} ${clip(labelOf(node.row), nameWidth - node.prefix.length - 3)}`
46 rows.push(
47 Box({
48 key: `row-${node.row.id}`,
49 flexDirection: 'row',
50 justifyContent: 'space-between',
51 children: [
52 Button({
53 key: `pick-${node.row.id}`,
54 label: line,
55 plain: true,
56 hotkey,
57 dimColor: node.row.id !== snap.selectedId,
58 onPress: () => undefined,
59 }),
60 Text({
61 dimColor: true,
62 children: right(node.row),
63 }),
64 ],
65 }),
66 )
67 })
68
69 if (selected) {
70 rows.push(Text({ children: ' ' }))
71 rows.push(drawDetail(Box, Text, selected, width))
72 }
73
74 rows.push(Text({ children: ' ' }))
75 rows.push(
76 Box({
77 flexDirection: 'row',
78 columnGap: 2,
79 children: [
80 Button({ key: 'clear', label: 'clear done', hotkey: 'c', plain: true, onPress: () => undefined }),
81 Button({ key: 'close', label: 'close', hotkey: 'x', plain: true, onPress: () => undefined }),
82 ],
83 }),
84 )
85
86 if (!snap.isFocused) {
87 rows.push(Text({ dimColor: true, children: 'click the pane or ctrl+x tab, then 1-9' }))
88 }
89
90 return Box({ flexDirection: 'column', paddingX: 1, paddingY: 0, children: rows })
91}
92
93function drawDetail(Box: El, Text: El, row: AgentRow, width: number): Node {
94 const inner = Math.max(16, width - 2)
95 const kids: Node[] = [
96 Text({
97 color: tone(row.status),
98 bold: true,
99 children: `${mark(row.status)} ${clip(labelOf(row), inner - 2)}`,
100 }),
101 Text({
102 dimColor: true,
103 children: [
104 row.model ?? (row.id === MAIN ? 'session model' : 'model pending'),
105 row.status,
106 row.fork ? 'fork' : undefined,
107 row.background ? 'background' : undefined,
108 ]
109 .filter(Boolean)
110 .join(' · '),
111 }),
112 ]
113
114 if (row.id === MAIN && row.percent != null && row.window != null) {
115 kids.push(
116 Text({
117 color: fillTone(row.percent),
118 children: `${bar(row.percent, Math.min(28, inner))} ${row.percent}% ${short(row.inputTokens ?? 0)}/${short(row.window)}`,
119 }),
120 )
121 }
122
123 if (row.prompt) {
124 kids.push(Text({ dimColor: true, children: `↓ in ${clip(row.prompt, inner - 6)}` }))
125 }
126
127 const work: string[] = []
128 if (row.toolCount > 0) work.push(`${row.toolCount} tools`)
129 if (row.lastTools.length > 0) work.push(row.lastTools.join(', '))
130 if (row.inputTokens != null) work.push(`in ${short(row.inputTokens)}`)
131 if (row.outputTokens != null) work.push(`out ${short(row.outputTokens)}`)
132 if (work.length > 0) {
133 kids.push(Text({ children: `⚙ ${clip(work.join(' · '), inner - 3)}` }))
134 }
135
136 if (row.answer) {
137 kids.push(Text({ dimColor: true, children: `↑ out ${clip(row.answer, inner - 6)}` }))
138 } else if (row.status === 'running') {
139 kids.push(Text({ color: 'cyan', children: '↑ out still running' }))
140 }
141
142 return Box({
143 flexDirection: 'column',
144 borderStyle: 'single',
145 borderColor: 'gray',
146 paddingX: 1,
147 children: kids,
148 })
149}
150hooks/format.ts 55 lines1export function short(n: number): string {
2 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
3 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
4 return String(Math.round(n))
5}
6
7export function clip(text: string, max: number): string {
8 const t = text.replace(/\s+/g, ' ').trim()
9 if (t.length <= max) return t
10 if (max <= 1) return '…'
11 return `${t.slice(0, max - 1)}…`
12}
13
14export function pad(text: string, width: number): string {
15 const t = clip(text, width)
16 return t.length >= width ? t : t + ' '.repeat(width - t.length)
17}
18
19export function bar(percent: number, width: number): string {
20 const w = Math.max(4, width)
21 const p = Math.max(0, Math.min(100, percent))
22 const filled = Math.round((p / 100) * w)
23 return `${'█'.repeat(filled)}${'░'.repeat(Math.max(0, w - filled))}`
24}
25
26export function mark(status: string): string {
27 if (status === 'running') return '◐'
28 if (status === 'completed') return '●'
29 if (status === 'failed' || status === 'error' || status === 'refusal') return '✗'
30 if (status === 'killed' || status === 'aborted') return '○'
31 return '·'
32}
33
34export function tone(status: string): string {
35 if (status === 'running') return 'cyan'
36 if (status === 'completed') return 'green'
37 if (status === 'failed' || status === 'error' || status === 'refusal') return 'red'
38 if (status === 'killed' || status === 'aborted') return 'yellow'
39 return 'white'
40}
41
42export function duration(ms: number): string {
43 if (ms < 1000) return `${Math.round(ms)}ms`
44 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
45 return `${Math.round(ms / 60_000)}m`
46}
47
48export function fillTone(percent: number | undefined): string {
49 if (percent == null) return 'white'
50 if (percent >= 90) return 'red'
51 if (percent >= 75) return 'magenta'
52 if (percent >= 50) return 'yellow'
53 return 'green'
54}
55hooks/model.ts 80 lines1export const MAIN = 'main'
2export const PANE = 'agents-side'
3
4export type AgentRow = {
5 id: string
6 parentId?: string
7 type: string
8 description: string
9 name?: string
10 status: string
11 prompt: string
12 model?: string
13 fork: boolean
14 background: boolean
15 toolCount: number
16 lastTools: string[]
17 durationMs?: number
18 answer?: string
19 inputTokens?: number
20 outputTokens?: number
21 percent?: number
22 window?: number
23}
24
25export type TreeNode = {
26 row: AgentRow
27 prefix: string
28 isLast: boolean
29}
30
31export function emptyMain(): AgentRow {
32 return {
33 id: MAIN,
34 type: 'main',
35 description: 'main loop',
36 status: 'idle',
37 prompt: '',
38 fork: false,
39 background: false,
40 toolCount: 0,
41 lastTools: [],
42 }
43}
44
45export function treeOf(rows: AgentRow[]): TreeNode[] {
46 const out: TreeNode[] = []
47 const kids = (parentId: string) =>
48 rows.filter(row => row.id !== MAIN && (row.parentId ?? MAIN) === parentId)
49
50 function walk(row: AgentRow, prefix: string, isLast: boolean) {
51 out.push({ row, prefix, isLast })
52 const childRows = kids(row.id)
53 childRows.forEach((child, i) => {
54 const last = i === childRows.length - 1
55 const branch = prefix === '' ? '' : `${prefix}${isLast ? ' ' : '│ '}`
56 walk(child, `${branch}${last ? '└─' : '├─'}`, last)
57 })
58 }
59
60 const main = rows.find(row => row.id === MAIN) ?? emptyMain()
61 walk(main, '', true)
62 for (const row of rows) {
63 if (row.id === MAIN) continue
64 if (out.some(node => node.row.id === row.id)) continue
65 walk(row, '└─', true)
66 }
67 return out
68}
69
70export function countsOf(rows: AgentRow[]) {
71 let running = 0
72 let done = 0
73 for (const row of rows) {
74 if (row.id === MAIN) continue
75 if (row.status === 'running') running += 1
76 else done += 1
77 }
78 return { running, done }
79}
80