SLOPSHOPPER

subagents-pane

Pane listing subagents with their progress and current tool call

newpaneguardcommandtimeragents
v0.1.0no licenseupdated 2026-10-06nikbrunner/dots/common/.claude/mods/subagents-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents-pane
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ subagents ✓ 0 │ ┃ No subagents yet. They show up here as ⏺ Read(src/auth.ts) │ ┃ soon as one spawns. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /subagents-pane │ ⎿ subagents-pane: Subagents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
◆ subagents ✓ 0 No subagents yet. They show up here as soon as one spawns.
README

dots

A clean, organized dotfiles repository using symlinks for easy management and deployment.

📚 Table of Contents

Overview

This dotfiles system uses a unified YAML configuration for symlink management:

  • Configuration files live in this repository organized by platform
  • Symlinks are defined in a single symlinks.yml file with OS-specific sections
  • Running dots link updates everything automatically (removes broken symlinks + creates new ones)
  • Supports wildcard patterns for flexible file management (e.g., "common/.local/bin/*": "~/.local/bin")
  • Eliminates duplication with shared common entries across platforms

Installation

Prerequisites

SSH access to GitHub via ProtonPass is required before cloning.

Complete Machine Setup

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)

Usage

Core Commands

  • 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.

Managing Configurations

Adding a Configuration
  1. Place file in common/ (cross-platform) or macos//arch/ (OS-specific), mirroring home directory structure
  2. Add entry to symlinks.yml in the appropriate section
  3. Run dots link
Removing a Configuration
  1. Delete the file from the repository
  2. Remove the entry from symlinks.yml
  3. Run dots link (broken symlinks are automatically cleaned up)
Renaming/Moving a Configuration
  1. Move the file in the repository
  2. Run dots link (old symlink removed, new one created)
Preview Changes
dots link --dry-run --verbose

How It Works

When you run dots link:

  1. Loads configuration: Reads the symlinks.yml file for your platform
  2. Cleans up: Removes any broken symlinks from previous configurations
  3. Processes entries: Creates symlinks as defined in the configuration:
  4. Directory symlinks for entire directories
  5. File symlinks for individual files
  6. Wildcard expansion for patterns like "common/.local/bin/*": "~/.local/bin"
  7. Backs up conflicts: If a real file exists where a symlink should go, it's backed up with a timestamp (unless --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 operation

OS-Specific Configurations

Place 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 OSYAML SectionDescription
commoncommon:Always processed on all platforms
macosmacos:macOS systems
archarch: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 Theme Integration

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.

Source 3 files
hooks/register.tsx 206 lines
1import { 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}
206
hooks/lib.ts 47 lines
1import 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}
47
types/index.d.ts 25 lines
1export 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