Pane listing subagents with their progress and current tool call

A clean, organized dotfiles repository using symlinks for easy management and deployment.
This dotfiles system uses a unified YAML configuration for symlink management:
symlinks.yml file with OS-specific sectionsdots link updates everything automatically (removes broken symlinks + creates new ones)"common/.local/bin/*": "~/.local/bin")SSH access to GitHub via ProtonPass is required before cloning.
See install/README.md for the full bootstrap guide.
Quick version:
git clone git@github.com:nikbrunner/dots.git ~/repos/nikbrunner/dots
cd ~/repos/nikbrunner/dots
./install/install.sh
Flags: --dry-run (preview), --debug (diagnostics)
dots — Dotfiles management (pull, push, link, chores, purge). Run dots with no args for usage.dots pull — Installs missing mise and platform-native packages without upgrading existing packages. Add --upgrade to upgrade declared packages.dots chores — Refreshes the mise lock metadata and stages config.toml with mise.lock alongside routine changes. It does not bump fuzzy version selectors.dots purge — Reviews undeclared Homebrew, mise, and Arch entries. Nothing is selected by default; removal requires explicit selection and confirmation. Use --dry-run to inspect candidates without changes.mole uninstall — Separate macOS application cleanup. It does not replace dots purge for package-manager entries.shiplog — AI-powered git operations (commit, branch). Run shiplog --help for usage.helm — External tool for multi-repo management. Invoked by dots pull and dots push.common/ (cross-platform) or macos//arch/ (OS-specific), mirroring home directory structuresymlinks.yml in the appropriate sectiondots linksymlinks.ymldots link (broken symlinks are automatically cleaned up)dots link (old symlink removed, new one created)dots link --dry-run --verbose
When you run dots link:
symlinks.yml file for your platform"common/.local/bin/*": "~/.local/bin"--no-backup is used)Options:
--dry-run: Preview what would happen without making changes--no-backup: Overwrite existing files instead of backing them up--verbose: Show detailed output for each symlink operationPlace OS-specific files in macos/, linux/, or arch/ following the home directory structure. The system uses the symlinks.yml configuration file with OS-specific sections to define which files get symlinked.
Important: The YAML section names must match the OS detection output:
| Detected OS | YAML Section | Description |
|---|---|---|
common | common: | Always processed on all platforms |
macos | macos: | macOS systems |
arch | arch: | Arch Linux systems |
The system always processes the common section first, then adds the platform-specific section if it exists. OS detection is handled by scripts/dots/detect-os.sh.
Black Atom Livery owns theme provisioning. The tracked configuration at common/.config/black-atom/livery/config.json records the active theme and enabled app integrations.
dots pull runs livery reapply after linking files and refreshing repositories when the configuration contains setup data and an active theme. Run livery apply to choose a different theme interactively.
hooks/register.tsx 206 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Sub } from '../types'
5import { bar, compact, describeCall, duration, lastProgress, report, shortModel } from './lib'
6
7const PANE = 'subagents'
8const STALL_MS = 60_000
9const LABEL = 9
10
11const subs = atom({ plugin: 'subagents-pane', key: 'subs' } as const, [])
12const tick = atom({ plugin: 'subagents-pane', key: 'tick' } as const, 0)
13
14const isOk = (status: string) => status === 'completed' || status === 'idle'
15
16function updateSub($: EngineInterface, id: string, fn: (s: Sub) => Sub) {
17 return update($, subs, list => list.map(s => (s.id === id ? fn(s) : s)))
18}
19
20async function syncSubs($: EngineInterface) {
21 const live = await $.agent.list()
22 const at = await $.clock.now()
23 await update($, subs, list =>
24 list.map(s => {
25 const status = live.find(a => a.id === s.id)?.status ?? 'completed'
26 const isDone = ['completed', 'failed', 'killed', 'idle'].includes(status)
27 return { ...s, status, endedAt: isDone ? (s.endedAt ?? at) : undefined }
28 }),
29 )
30}
31
32export const register: Register = on => {
33 on('session.start', async ($, e, next) => {
34 await $.command.register({ name: 'subagents-pane', description: 'Open the subagents pane' })
35 $.clock.every(1000, () => {
36 void read($, subs).then(list => {
37 if (list.some(s => !s.endedAt)) void update($, tick, n => n + 1)
38 })
39 })
40 return next(e)
41 })
42
43 on('command.run', { command: 'subagents-pane' }, async $ => {
44 await $.ui.open({ id: PANE, title: 'Subagents' })
45 return { text: 'Subagents pane opened.' }
46 })
47
48 on('agent.spawn', async ($, e, next) => {
49 const result = await next(e)
50 if (result.agentId) {
51 const at = await $.clock.now()
52 const sub: Sub = {
53 id: result.agentId,
54 type: e.subagentType,
55 description: e.description,
56 model: shortModel(result.model),
57 isBackground: e.background,
58 status: 'running',
59 startedAt: at,
60 lastAt: at,
61 tools: 0,
62 tokens: 0,
63 }
64 await update($, subs, list => [...list, sub].slice(-20))
65 void $.ui.open({ id: PANE, title: 'Subagents' })
66 }
67 return result
68 })
69
70 on('turn.step', async function* ($, e, next) {
71 const result = yield* next(e)
72 if (e.agentId) {
73 const at = await $.clock.now()
74 const progress = lastProgress(result.answer)
75 const u = result.usage
76 const tokens = u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
77 void updateSub($, e.agentId, s => ({
78 ...s,
79 progress: progress ?? s.progress,
80 tokens: s.tokens + tokens,
81 lastAt: at,
82 }))
83 }
84 return result
85 })
86
87 on('tool.call', async ($, e, next) => {
88 if (e.agentId) {
89 const tool = describeCall(e as unknown as Record<string, unknown>)
90 const at = await $.clock.now()
91 void updateSub($, e.agentId, s => ({ ...s, tool, tools: s.tools + 1, lastAt: at }))
92 }
93 return next(e)
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 const result = await next(e)
98 if (e.agentId) {
99 const answer = report(e.answer)
100 if (answer) void updateSub($, e.agentId, s => ({ ...s, result: answer }))
101 void syncSubs($)
102 }
103 return result
104 })
105
106 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107 const { Box, Text } = $.ui.resolve(e)
108 const list = await read($, subs)
109 await read($, tick)
110 const at = await $.clock.now()
111 const running = list.filter(s => !s.endedAt).length
112 const failed = list.filter(s => s.endedAt && !isOk(s.status)).length
113 const rule = '╌'.repeat(Math.max(10, (e.viewport?.columns ?? 40) - 2))
114
115 const kv = (name: string, value: RenderChildren) => (
116 <Text wrap="truncate-end">
117 <Text color="inactive">{name.padEnd(LABEL)}</Text>
118 {value}
119 </Text>
120 )
121
122 return (
123 <Box flexDirection="column" paddingX={1}>
124 <Box justifyContent="space-between">
125 <Text color="claude" bold>
126 ◆ subagents
127 </Text>
128 <Text>
129 {running > 0 && <Text color="warning">● {running} running </Text>}
130 <Text color="success">✓ {list.length - running - failed}</Text>
131 {failed > 0 && <Text color="error"> ✗ {failed}</Text>}
132 </Text>
133 </Box>
134 {list.length === 0 && <Text dimColor>No subagents yet. They show up here as soon as one spawns.</Text>}
135 {[...list].reverse().map(s => {
136 const isDone = s.endedAt !== undefined
137 const accent = isDone ? (isOk(s.status) ? 'success' : 'error') : 'warning'
138 const idle = at - (s.lastAt ?? s.startedAt)
139 const isStalled = !isDone && idle > STALL_MS
140 const p = s.progress
141 return (
142 <Box flexDirection="column">
143 <Text dimColor wrap="truncate-end">
144 {rule}
145 </Text>
146 <Box justifyContent="space-between">
147 <Text color={accent} bold wrap="truncate-end">
148 {isDone ? (isOk(s.status) ? '✓' : '✗') : '●'} {s.type}
149 </Text>
150 <Text dimColor>
151 {isDone ? s.status : 'running'} · {duration((s.endedAt ?? at) - s.startedAt)}
152 </Text>
153 </Box>
154 {kv('task', <Text dimColor={isDone}>{s.description}</Text>)}
155 {s.model &&
156 kv(
157 'model',
158 <Text>
159 <Text color="success">{s.model}</Text>
160 <Text dimColor> · {s.isBackground ? 'background' : 'foreground'}</Text>
161 </Text>,
162 )}
163 {p &&
164 kv(
165 'progress',
166 <Text>
167 <Text color={isDone ? undefined : 'success'} dimColor={isDone}>
168 {bar((p.step / Math.max(1, p.total)) * 100, 8)}
169 </Text>
170 <Text bold>
171 {' '}
172 {p.step}/{p.total}
173 </Text>
174 <Text dimColor> {p.text}</Text>
175 </Text>,
176 )}
177 {!isDone && s.tool && kv('now', <Text color="suggestion">↳ {s.tool}</Text>)}
178 {kv(
179 'activity',
180 <Text>
181 <Text>{s.tools ?? 0} tools</Text>
182 <Text dimColor> · {compact(s.tokens ?? 0)} tokens</Text>
183 {!isDone && (
184 <Text color={isStalled ? 'error' : undefined} dimColor={!isStalled}>
185 {' '}
186 · {isStalled ? 'stalled' : 'idle'} {duration(idle)}
187 </Text>
188 )}
189 </Text>,
190 )}
191 {isDone && s.result && (
192 <Box>
193 <Box width={LABEL} flexShrink={0}>
194 <Text color="inactive">result</Text>
195 </Box>
196 <Text wrap="wrap">{s.result}</Text>
197 </Box>
198 )}
199 </Box>
200 )
201 })}
202 </Box>
203 )
204 })
205}
206hooks/lib.ts 47 lines1import type { Progress } from '../types'
2
3export function lastProgress(text: string): Progress | undefined {
4 const all = [...text.matchAll(/PROGRESS:\s*(\d+)\s*\/\s*(\d+)\s*·?\s*(.*)/g)]
5 const m = all[all.length - 1]
6 if (!m) return undefined
7 return { step: Number(m[1]), total: Number(m[2]), text: m[3]!.trim() }
8}
9
10export function bar(percent: number, width = 10): string {
11 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
12 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
13}
14
15export function duration(ms: number): string {
16 const s = Math.max(0, Math.floor(ms / 1000))
17 if (s < 60) return `${s}s`
18 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
19 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`
20}
21
22export function describeCall(e: Record<string, unknown>): string {
23 const arg =
24 [e.file_path, e.path, e.notebook_path].find(v => typeof v === 'string')?.toString().split('/').pop() ??
25 [e.description, e.pattern, e.query, e.subject, e.command, e.url, e.skill].find(v => typeof v === 'string')
26 return `${e.tool}${arg ? ` ${String(arg).split('\n')[0]}` : ''}`.slice(0, 80)
27}
28
29export function shortModel(id: string): string {
30 const m = id.match(/^claude-([a-z]+)-(\d+)-(\d+)/)
31 return m ? `${m[1]} ${m[2]}.${m[3]}` : id
32}
33
34export function compact(n: number): string {
35 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
36 if (n >= 1000) return `${Math.round(n / 1000)}k`
37 return String(n)
38}
39
40export function report(text: string): string {
41 return text
42 .split('\n')
43 .filter(l => !l.startsWith('PROGRESS:'))
44 .join('\n')
45 .trim()
46}
47types/index.d.ts 25 lines1export type Progress = { step: number; total: number; text: string }
2
3export type Sub = {
4 id: string
5 type: string
6 description: string
7 model: string
8 isBackground: boolean
9 status: string
10 startedAt: number
11 lastAt: number
12 endedAt?: number
13 progress?: Progress
14 tool?: string
15 tools: number
16 tokens: number
17 result?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'subagents-pane': { subs: Sub[]; tick: number }
23 }
24}
25