A pane that replays every Bash command any agent runs, as a terminal session with the command and its output.

A Claude Code mod: a pane that shows every Bash command any agent runs (the main agent and subagents) as a terminal box. Each box has a short title, and opens to the command after a $ prompt with its full output below. Run /agent-shell to open the pane. The mod also asks the model to give every Bash call a very short description, which becomes the box title.
/plugin install agent-shell --marketplace poldap/agent-shell
Answer y to add the marketplace, then pick a scope.
hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ShellEntry } from '../types'
5
6const PANE = 'agent-shell'
7const entries = atom({ plugin: 'agent-shell', key: 'entries' } as const, [])
8
9const MAX_ENTRIES = 500
10
11// A title when the call gave no description: the first real command, minus leading VAR=value prefixes.
12const ASSIGNMENT = /^[A-Za-z_]\w*=("[^"]*"|'[^']*'|\S*)\s*(;|&&)?\s*/
13const titleOf = (command: string, description?: string) => {
14 const given = description?.trim()
15 if (given) return given
16 for (const line of command.split('\n')) {
17 let rest = line.trim()
18 while (ASSIGNMENT.test(rest)) rest = rest.replace(ASSIGNMENT, '')
19 if (rest !== '' && !rest.startsWith('#') && !/^cd\s/.test(rest)) return rest.slice(0, 60)
20 }
21 return command.trim().slice(0, 60)
22}
23
24// Entries saved before `isDescribed` existed: a title that is a piece of the command was derived, not given.
25const shownTitle = (one: ShellEntry) =>
26 (one.isDescribed ?? (one.title !== undefined && !one.command.includes(one.title)))
27 ? one.title
28 : titleOf(one.command)
29
30type Token = { text: string; color?: string; dim?: boolean }
31
32const COLORS = {
33 command: '#61afef',
34 flag: '#d19a66',
35 string: '#98c379',
36 variable: '#c678dd',
37 operator: '#56b6c2',
38 number: '#e5c07b',
39}
40
41// A small bash highlighter: commands, flags, strings, variables, operators, comments.
42const highlight = (source: string): Token[] => {
43 const tokens: Token[] = []
44 const re =
45 /(#.*)|('[^']*'|"(?:[^"\\]|\\.)*")|(\$\{[^}]*\}|\$\(|\$\w+|\$[?!@*#])|(\|\||&&|[|;&<>]+)|(\s+)|([^\s|;&<>'"$#]+)|[\s\S]/g
46 let isCommandNext = true
47 for (const m of source.matchAll(re)) {
48 const [text, comment, str, variable, operator, space, word] = m
49 if (comment) tokens.push({ text, dim: true })
50 else if (str) tokens.push({ text, color: COLORS.string })
51 else if (variable) tokens.push({ text, color: COLORS.variable })
52 else if (operator) {
53 tokens.push({ text, color: COLORS.operator })
54 isCommandNext = true
55 } else if (space) {
56 tokens.push({ text })
57 if (space.includes('\n')) isCommandNext = true
58 } else if (word) {
59 if (isCommandNext && !/^\w+=/.test(word)) {
60 tokens.push({ text, color: COLORS.command })
61 isCommandNext = false
62 } else if (word.startsWith('-') && word.length > 1) tokens.push({ text, color: COLORS.flag })
63 else if (/^\d+$/.test(word)) tokens.push({ text, color: COLORS.number })
64 else tokens.push({ text })
65 } else tokens.push({ text })
66 }
67 return tokens
68}
69
70export const register: Register = on => {
71 on('session.start', async ($, e, next) => {
72 await $.command.register({
73 name: 'agent-shell',
74 description: 'Show the shell commands every agent runs, as terminal boxes',
75 })
76 void $.ui.open({ id: PANE, title: 'Agent shell' })
77
78 return next(e)
79 })
80
81 on('command.run', { command: 'agent-shell' }, async $ => {
82 await $.ui.open({ id: PANE, title: 'Agent shell' })
83
84 return { text: 'Agent shell pane opened.' }
85 })
86
87 // Every model call gets the instruction, subagents' included: the box title is the description.
88 on('prompt.compose', async ($, e, next) => {
89 const { sections } = await next(e)
90
91 return {
92 sections: [
93 ...sections,
94 {
95 id: 'agent-shell:titles',
96 scope: 'session' as const,
97 text: 'Every Bash call must set the `description` parameter to a very short title (2-6 words, imperative, e.g. "List dragon folder", "Run host tests") saying what the command does. It is shown as the heading of the command in the user\'s Agent shell pane. Never leave it out, however simple the command.',
98 },
99 ],
100 }
101 })
102
103 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
104 let agent = 'main'
105 if (e.agentId) {
106 const found = (await $.agent.list()).find(a => a.id === e.agentId)
107 agent = found?.name ?? found?.type ?? e.agentId.slice(0, 8)
108 }
109 const entry: ShellEntry = {
110 id: e.tool_use_id,
111 agent,
112 title: titleOf(e.command, e.description),
113 isDescribed: Boolean(e.description?.trim()),
114 command: e.command,
115 output: '',
116 isDone: false,
117 isError: false,
118 isOpen: false,
119 }
120 await update($, entries, list => [...list, entry].slice(-MAX_ENTRIES))
121
122 const ran = await next(e)
123
124 const output =
125 ran.deny !== undefined
126 ? `denied: ${ran.deny}`
127 : ran.result && typeof ran.result === 'object' && 'stdout' in ran.result
128 ? [ran.result.stdout, ran.result.stderr].filter(Boolean).join('\n')
129 : (ran.text ?? '')
130 await update($, entries, list =>
131 list.map(one =>
132 one.id === entry.id
133 ? {
134 ...one,
135 output: output.replace(/\s+$/, ''),
136 isDone: true,
137 isError: ran.deny !== undefined || ran.isError === true,
138 }
139 : one,
140 ),
141 )
142
143 return ran
144 }).catch(($, e, next) => next(e))
145
146 // Under each Bash row in the conversation: a button that opens that command's box.
147 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
148 const row = await next(e)
149 if (e.tool !== 'Bash') return row
150 const { Box, Button } = $.ui.resolve(e)
151
152 return (
153 <Box flexDirection="column">
154 {row}
155 <Button
156 key={`open-${e.tool_use_id}`}
157 dimColor
158 onPress={async () => {
159 await update($, entries, list =>
160 list.map(one => (one.id === e.tool_use_id ? { ...one, isOpen: true } : one)),
161 )
162 await $.ui.open({ id: PANE, title: 'Agent shell' })
163 }}
164 >
165 ▸ open in Agent shell
166 </Button>
167 </Box>
168 )
169 }).catch(($, e, next) => next(e))
170
171 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172 const { Box, Text, Button, Code } = $.ui.resolve(e)
173 const list = await read($, entries)
174
175 const toggle = (id: string) =>
176 update($, entries, all =>
177 all.map(one => (one.id === id ? { ...one, isOpen: !one.isOpen } : one)),
178 )
179
180 return (
181 <Box flexDirection="column" gap={1}>
182 {list.length === 0 && <Text dimColor>No shell commands yet.</Text>}
183 {list.map(one => (
184 <Box
185 key={`box-${one.id}`}
186 flexDirection="column"
187 borderStyle="round"
188 borderColor={one.isError ? 'red' : undefined}
189 paddingX={1}
190 >
191 <Box flexDirection="row" gap={1}>
192 <Button
193 key={`toggle-${one.id}`}
194 plain
195 label={`${one.isOpen ? '▾' : '▸'} ${shownTitle(one)}`}
196 onPress={() => toggle(one.id)}
197 />
198 <Text dimColor>
199 {one.agent}
200 {one.isDone ? (one.isError ? ' · failed' : '') : ' · running…'}
201 </Text>
202 </Box>
203 {one.isOpen && (
204 <Box backgroundColor="#111111" paddingX={1} marginTop={1} flexDirection="column">
205 <Box flexDirection="row" justifyContent="space-between" gap={2}>
206 <Text>
207 <Text color="green" bold>
208 ${' '}
209 </Text>
210 {highlight(one.command).map(t => (
211 <Text color={t.color} dimColor={t.dim}>
212 {t.text}
213 </Text>
214 ))}
215 </Text>
216 <Button
217 key={`copy-${one.id}`}
218 plain
219 dimColor
220 label="copy"
221 onPress={press => $.ui.copy({ text: one.command, surface: press.surface })}
222 />
223 </Box>
224 {one.output !== '' && <Code source={one.output} />}
225 </Box>
226 )}
227 </Box>
228 ))}
229 </Box>
230 )
231 })
232}
233types/index.d.ts 18 lines1export type ShellEntry = {
2 id: string
3 agent: string
4 title: string
5 command: string
6 output: string
7 isDone: boolean
8 isError: boolean
9 isOpen: boolean
10 isDescribed?: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'agent-shell': { entries: ShellEntry[] }
16 }
17}
18