Subagent pane (rows above the prompt only as a fallback while the pane is not showing): a crab per agent type with model, effort, context, tokens, requests and…

A macOS-focused developer workstation built around Zsh, Powerlevel10k, modern CLI tools, and battle-tested automation. Everything is wired together with bestow (a Go-based GNU Stow successor), encrypted secrets, and a centralized package manifest so each new machine behaves exactly like the last one.
z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindingsgw), uv (Python packages and projects), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)<leader>d… plus F5/F10/F11) shared by every language. Go (delve) supports debugging the test under the cursor, launching the program with arguments, and attaching to a remote dlv --headless; Python, Java, and Ballerina are wired in too. Adding another debugger is a single drop-in file: a module at nvim/.config/nvim/lua/thisarug/dap/<lang>.lua exporting setup(dap) is picked up automatically by lua/plugins/dap.lua<leader>G… for code generation (if err != nil guards, struct tags, interface stubs, test and doc-comment generation), and <leader>T… to run tests from the buffer with a summary tree and watch mode (Go, plus pytest and Vitest via neotest adapters)<leader>qs, or s on the dashboard), and diffview.nvim reviews a branch against the default branch (<leader>gv) or walks a file's history (<leader>gh)v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts.md → mdcat, .json/.yaml → jless, .py/.bash/.zsh/.bal → $EDITOR)pam-reattach)ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extensionccp (personal) and cce (enterprise) run Claude Code against separate config dirs, with TypeScript mods for a status band, risk previews, secret redaction and a subagent pane (see Claude Code); tmux shows which windows are waiting on Claudeprefix + f opens a project with three windows ([Claude], [Nvim], [Shell])edit_secrets workflow, and automatic .env handling inside init.shBrewfile with optional category files in packages/ for modular installationbin/lint (shellcheck, shfmt, zsh -n, stylua, mod tests; also run by CI), test-zsh integration tests, dotdoctor health check, profile_startup quick timing, and bin/profile-zsh-startup for deep divesgit --version /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install --cask font-fira-code-nerd-font
Set the font once inside your terminal profile (Appearance/Text settings).
init.sh)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh
The checkout can live anywhere (the script finds the repo from its own path). The rest of the setup hard-codes ~/dotfiles, so when the checkout is elsewhere init.sh creates a ~/dotfiles symlink pointing at it (and stops with a clear message if ~/dotfiles is already something else). ./init.sh --dry-run is a preflight only: it checks the checkout and ~/dotfiles and changes nothing; ./init.sh --help lists the steps.
The script is interactive; it will:
bestow)Brewfile with Brewpam-reattach, loaded from a root-owned copy in /opt/pam).env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt, per-machine ~/.sops.yaml)bestowdotdoctor for a full health check; the final output also lists the manual steps: the Hammerspoon spoon and permissions, the ~/.claude symlink, tmux plugins)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Install everything that is currently enabled in Brewfile
brew bundle --file=Brewfile
# Install bestow (requires Go)
go install github.com/redpierrot/bestow@latest
# Bootstrap bestow's own config (source: this repo, destination: $HOME)
bestow init --source ~/dotfiles --destination ~/
# Symlink the packages you need (add/remove as desired)
bestow stow zsh git tmux direnv bash
bestow stow bin -d ~/bin # bin links into ~/bin, not ~
# Secrets: an age key and this machine's own sops config (not tracked)
mkdir -p ~/.config/sops/age && age-keygen -o ~/.config/sops/age/keys.txt
sed "s/AGE_PUBLIC_KEY/$(grep '^# public key:' ~/.config/sops/age/keys.txt | cut -d' ' -f4)/g" \
bash/.sops.yaml.example > ~/.sops.yaml
# Reload configuration, then create the encrypted ~/.env
source ~/.zshrc
edit_secrets
Git signs every commit with SSH (gpgSign = true), so set user.signingkey in ~/.gitconfig.local (see git/.gitconfig.local.example) and create the key it names (ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing) before your first commit.
Edit the Brewfile directly or use category Brewfiles in packages/ before running brew bundle if you want to customize packages.
Already have a preferred dotfiles strategy but want the curated tools? Run brew bundle --file=Brewfile and manually pick pieces (aliases, functions, etc.). Edit the Brewfile directly or use the category files in packages/ to customize your installation.
The main Brewfile contains core packages that are always installed. Optional packages are organized into category files in packages/:
packages/
├── cloud.brewfile # aws-vault, gcloud CLI
├── containers.brewfile # colima, Docker CLI + Compose + credential helper
├── development.brewfile # pyenv, rbenv, nvm, flutter
├── editors.brewfile # VS Code
├── productivity.brewfile # Raycast, Rectangle, Alfred, Caffeine, etc.
└── terminals.brewfile # iTerm2
brew bundle --file=Brewfile # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile # Add editors
core – powerlevel10k, zoxide, eza, bat, ripgrep, lazygit, lazydocker, tmux, direnv, atuin, gh, etc.security – sops + age for encrypted secrets (always enabled)development – pyenv, rbenv, nvm, flutter (optional)cloud – aws-vault and the gcloud CLIeditors – VS Codeterminals – iTerm2 (casks)containers – colima (container runtime), Docker CLI + Compose + credential helperproductivity – Raycast, Rectangle, TablePlus, Alfred, Postman, CaffeineComment out what you do not need in the Brewfile, then rerun brew bundle.
brew-sync (or ./bin/sync-brewfile) is a read-only drift report: it lists formulae and casks that are installed but in no Brewfile, and declared packages that are not installed. It never rewrites a Brewfile; add or remove lines by hand. brew-check verifies the core Brewfile is satisfied.
| Path | Notes |
|---|---|
zsh/ | .zshrc, .zshrc.d/ (modular shell config), .functions.d/ (modular functions), aliases, paths |
zsh/.zshrc.d/ | 6 modules: plugins, completion, keybindings, history, integrations, environment |
zsh/.functions.d/ | 9 modules: colors, core, navigation, archives, git, system, dotfiles, docs, packages |
<tool>/.config/<tool>/ | XDG configs, one stow package per tool (ghostty, lazygit, nvim, yazi, ripgrep, typos) |
zsh/.p10k.zsh | Powerlevel10k prompt config (Catppuccin Mocha, stowed to ~/.p10k.zsh) |
git/ | .gitconfig, ignore rules, delta settings |
tmux/ | Modern tmux config + keybinds, project session layout, Claude state in status bar |
claude/ | Per-account Claude Code settings.json and status line, stowed to ~/.claude-personal and ~/.claude-enterprise |
claude-mods/ | Claude Code mods (not a stow package; loaded through CLAUDE_CODE_PLUGIN_DIRS) |
tests/ | Golden-output and behaviour tests for the Claude Code helpers (tests/claude/check.sh, run by test-zsh) |
direnv/ | Project-specific environment automation |
packages/ | Optional category Brewfiles (cloud, containers, development, etc.) |
bin/ | Helper scripts (lint, dotdoctor, test-zsh-config, profile-zsh-startup, audit-configs, adopt-config; see bin/README.md) |
docs/ | Additional documentation (prompt guide, tmux keybindings, config management) |
Ghostty is configured with:
ESC [ H / ESC [ F), which 03-keybindings.zsh binds to start/end of line (a raw Ctrl+a would be eaten as the tmux prefix)Ctrl+a; test-zsh fails if the prefix changes)The optional containers category (packages/containers.brewfile) installs colima as the local container runtime, paired with the plain docker, docker-compose, and docker-credential-helper CLI formulae — no Docker Desktop app/VM required.
brew bundle --file=packages/containers.brewfile # colima + docker CLI + compose + credential helper
colima-start # boots the VM, sets the `colima` docker context
docker ps # talks to colima automatically
lzd # lazydocker also works unchanged — it just follows the active docker context
Colima lifecycle aliases (zsh/.aliases.sh, only defined if colima is installed):
| Alias | Command |
|---|---|
colima-start | colima start |
colima-stop | colima stop |
colima-restart | colima restart |
colima-status | colima status |
colima-list | colima list |
colima-ssh | colima ssh |
Homebrew's docker-compose formula installs as a CLI plugin, and docker-credential-helper installs docker-credential-osxkeychain to store registry credentials in the macOS Keychain instead of plaintext — but Docker only picks either up if ~/.docker/config.json says so:
{
"cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"],
"credsStore": "osxkeychain"
}
init.sh does not currently write these keys automatically — add them by hand (or merge into an existing ~/.docker/config.json) after installing docker-compose / docker-credential-helper.
Day-to-day package management (adding/removing symlinks after the initial install) uses bestow — a Go-based successor to GNU Stow — instead of stow directly. It replicates stow --no-folding behavior (always on, no flag needed) but is idempotent and conflict-safe with pre-existing absolute symlinks.
bestow stow zsh # Shell config (includes .p10k.zsh)
bestow stow nvim ghostty lazygit ripgrep yazi typos # Per-tool packages
bestow stow git tmux direnv # Git/Tmux/Direnv packages
# Remove a package
bestow unstow zsh
init.sh (the fresh-machine installer) installs bestow with go install github.com/redpierrot/bestow@latest and runs the same bestow stow commands per package. @latest resolves to bestow's newest tagged release once it publishes tags (until then, the newest commit on its default branch).
binis the exception: it links into~/bin, so always stow it asbestow stow bin -d ~/bin. Don't run a barebestow stowacross every package — with the single$HOMEdestination in bestow's config it would linkbin/*straight into~.
test-zsh # Full validation (tools, PATH, runtimes)
./bin/lint # Lint everything, as CI does (./bin/lint --file <path> for one file)
./bin/profile-zsh-startup # Deep component timing (zinit plugins, SDKMAN, p10k, etc.)
help # Alias documentation entry point
docs # Interactive alias browser (alias_docs)
show_tools # Overview of installed CLI upgrades
alias_search git # Search for aliases by keyword
edit_secrets # Safely edit encrypted ~/.env via SOPS
take my-service && code . # Smart project bootstrap / clone helper
kill_by_port 3000 # Stop the process LISTENING on port 3000 (TERM, then KILL); clients are left alone
awake # Keep the Mac from idle-sleeping (awake stop | status | for 2h | screen | ac)
dotdoctor # Health check (changes nothing): unlinked files, dangling links, secrets, mods (--full adds brew + plugin validation)
prs # Browse PRs with fzf: enter=checkout, ctrl-o=browser, ctrl-d=diff via delta
prw # Watch the current PR's checks, then notify with the result
envrc work|personal|status|off # Pin the Claude Code + gws account for this directory via direnv
lg # Launch lazygit with our config (arguments are forwarded, e.g. lg log)
lzd # Launch lazydocker for Docker management
gw build # Run Gradle build from any subdirectory
gwt # Alias for: gw test
gwc # Alias for: gw clean
gwcb # Alias for: gw clean build
Ctrl+R # Atuin search UI (fuzzy search all history; Ctrl+R again narrows the filter)
Up arrow # Atuin prefix search (as you type)
Ctrl+T / Alt+C # fzf file / directory picker with a preview (Ctrl+/ toggles it)
Two accounts, each with its own config directory (claude/ is the stow package that links settings.json and statusline-command.sh into both; history, sessions and auth stay local, auth in the macOS Keychain):
| Command | Account | Config dir |
|---|---|---|
ccp, ccpr, ccpc | personal: start, --resume, --continue | ~/.claude-personal |
cce, ccer, ccec | enterprise: start, --resume, --continue | ~/.claude-enterprise |
~/.claude must be a symlink to ~/.claude-personal (ln -s ~/.claude-personal ~/.claude), so plain claude outside a pinned directory is the personal account. dotdoctor checks it.ccp/ccpr/ccpc also load ~/.claude-personal/projects.d/<repo>.json with --settings when the current git repo has one. That is where a project's own autoMode rules go, since Claude Code ignores autoMode in a project's .claude/settings*.json and they must not live in this public repo. <repo> is the main checkout's directory name, so linked worktrees share it.claude-mods/ (statusbar, blast-radius, redact, agents, rabe, session-wrapped, conventions) load straight from the repo via CLAUDE_CODE_PLUGIN_DIRS in both settings.json files, with nothing to link. The band above the prompt (context bar, rate limits) is the statusbar mod, the line below it is bin/claude-statusline, and conventions runs bin/lint --file after each edit Claude makes in this repo. See claude-mods/README.md.bin/claude-tmux-state, so tmux windows show when Claude is working, waiting or done (prefix + A jumps to the next waiting window).bin/claude-notify, which shows a macOS notification when a session finishes (Stop), asks you something (AskUserQuestion) or needs you (permission prompt, idle: Notification). It stays quiet while the terminal and the tmux window are in view. CLAUDE_NOTIFY=0 turns it off, CLAUDE_NOTIFY_SUBAGENTS=1 adds subagent completions, CLAUDE_NOTIFY_ALWAYS=1 disables the in-view suppression, CLAUDE_NOTIFY_SOUND=<name> changes the sound. One-time setup: allow banners and sounds for Script Editor under System Settings > Notifications (osascript notifications are attributed to it); Focus modes silence them. Details in bin/README.md.envrc (below).prefix + f (sessionizer) and prefix + S (new session) create a session with three windows: [Claude] (runs claude with the directory's .envrc applied), [Nvim] (nvim over a small shell, the window the session opens on) and [Shell] (plain shell). SESSIONIZER_LAYOUT=bare gives a single shell instead. After pulling, run bestow stow tmux if a new helper script is missing. Key reference: docs/TMUX_KEYBINDINGS.md.
envrc)envrc work (enterprise) or envrc personal writes a .envrc in the current directory that pins CLAUDE_CONFIG_DIR and GOOGLE_WORKSPACE_CLI_CONFIG_DIR (plus the gws file keyring backend), creates the account's gws directory with mode 700, runs direnv allow and applies the pin to the current shell straight away. Plain claude and gws then use the right account anywhere under that directory, including subdirectories and new shells or tmux panes opened inside the tree (direnv stays lazy-loaded: it is only loaded once a .envrc exists in the current directory or any parent). The file is added to the repo's local exclude (info/exclude, also from a linked worktree; envrc confirms with git check-ignore) and inherits parent pins through source_up_if_exists.
envrc status shows which pin applies (and whether it is inherited from a parent directory)envrc off removes a pin written by envrc (it never touches a .envrc it did not write).envrc (not written by envrc, or edited by hand since) asks first and keeps a copy as .envrc.bak; a symlinked .envrc is refused rather than written throughWhat overrides what
| Command | CLAUDE_CONFIG_DIR | GOOGLE_WORKSPACE_CLI_CONFIG_DIR |
|---|---|---|
plain claude in a pinned tree | pinned account | pinned account (inherited) |
ccp/ccpr/ccpc, cce/ccer/ccec | the account you typed | blanked for that claude, so a pin never leaks the other account's gws into it |
gwsp/gwse | unchanged | the account you typed |
bare gws in a pinned tree | unchanged | pinned account: the bin/gws guard lets it through because the pin supplies the account context |
bare gws anywhere else | unchanged | blocked (exit 64) |
Inside a pinned tree a bare gws therefore acts on the pinned account instead of being refused; that is the point of the pin. Plain claude outside any pinned directory uses ~/.claude, which should be a symlink to ~/.claude-personal (dotdoctor checks this: ln -s ~/.claude-personal ~/.claude).
prs, prw)prs [gh pr list flags] – fzf over the repo's PRs with a gh pr view preview. enter checks the PR out, ctrl-o opens it in the browser, ctrl-d shows the full diff through delta. Example: prs --author @meprw [pr] – gh pr checks --watch for the current branch's PR (or the given number/URL), then a macOS notification with the result; the exit status mirrors gh pr checksBoth need gh and jq; prs also needs fzf. PR titles are untrusted text: control characters (including ESC) are replaced with spaces before they are shown, and prw prints titles verbatim (no backslash interpretation). prw says whether the checks failed, were cancelled or are still pending, or that gh reported no result (no checks, or gh could not be reached), instead of calling every non-zero exit "failed".
cp / mv are small functions, not aliases: they prompt before overwriting (-i, verbose) only when a human is at the terminal, and run the plain command otherwise (scripts, agents, pipes). They do not force -r; copy directories with cp -R. sudo cp / sudo mv run the plain commands (sudo runs the binary, not the shell function), so pass -i yourself there.awake keeps the Mac from idle-sleeping in the background (caffeinate -i: the display may still sleep or lock, and it works on AC and battery). awake screen also keeps the display on, awake ac hooks/register.tsx 321 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRec } from '../types'
5import { CRAB, clip, costumeOf, fillBar, fmt, fmtTime, modelName, targetOf, windowOf } from './format'
6
7const PANE = 'agents'
8const COMMAND = 'agents-pane'
9const MAX_AGENTS = 100
10
11const list = atom({ plugin: 'agents', key: 'list' } as const, [])
12const now = atom({ plugin: 'agents', key: 'now' } as const, 0)
13// Bumped when the pane opens or closes, so the rows above the prompt redraw and re-check whether the pane is showing.
14const paneTick = atom({ plugin: 'agents', key: 'paneTick' } as const, 0)
15
16const RED = '#e66767'
17const GREEN = '#199e70'
18const ACCENT = '#d95926'
19
20const isRunning = (a: AgentRec) => a.status === 'run'
21const elapsed = (a: AgentRec, at: number) => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
22
23// A resumed or background agent never fires `agent.spawn` in this session, so the first request or tool call that
24// carries an unknown agent id adopts it, and the pane opens as it would for a fresh spawn. With `revive`, one that
25// finished and is continued in this session (SendMessage) runs again: its counters carry on, its clock restarts.
26// Only what starts work revives (a request about to be sent, a tool call); the end of a request does not, so a
27// late one cannot reopen an agent its turn.complete just closed.
28const adopt = async ($: Parameters<Parameters<Register>[0]>[0], agentId: string, model?: string, revive = true) => {
29 const known = (await read($, list)).find(a => a.agentId === agentId)
30 if (known && (known.status === 'run' || !revive)) return
31 const at = await $.clock.now()
32 const wasIdle = !(await read($, list)).some(isRunning)
33 if (known) {
34 await update($, list, all =>
35 all.map(a => {
36 if (a.agentId !== agentId) return a
37 const { endedAt: _ended, ...rest } = a
38 return { ...rest, status: 'run' as const, startedAt: at }
39 }),
40 )
41 await update($, now, () => at)
42 if (wasIdle) void $.ui.open({ id: PANE, title: 'Agents' }).catch(() => {})
43 return
44 }
45 const rec: AgentRec = {
46 id: agentId,
47 agentId,
48 type: 'subagent',
49 label: `resumed ${agentId.slice(0, 8)}`,
50 model,
51 window: windowOf(model ?? ''),
52 status: 'run',
53 startedAt: at,
54 context: 0,
55 out: 0,
56 fresh: 0,
57 requests: 0,
58 tools: 0,
59 }
60 await update($, list, all => [...all.filter(a => a.id !== rec.id), rec].slice(-MAX_AGENTS))
61 await update($, now, () => at)
62 if (wasIdle) void $.ui.open({ id: PANE, title: 'Agents' }).catch(() => {})
63}
64
65export type RowsMode = 'auto' | 'off' | 'always'
66
67export const rowsModeOf = (value: unknown): RowsMode => (value === 'off' || value === 'always' ? value : 'auto')
68
69export const register: Register = (on, options) => {
70 const rowsMode = rowsModeOf(options?.rows)
71
72 on('session.start', async ($, e, next) => {
73 // Ticks the running agents' clocks; quiet when nothing runs.
74 $.clock.every(1000, async () => {
75 if (!(await read($, list)).some(isRunning)) return
76 const at = await $.clock.now()
77 await update($, now, () => at)
78 })
79
80 // `/agents` is Claude Code's own command, so the toggle is `/agents-pane`. A refused registration must not stop the hook.
81 await $.command
82 .register({
83 name: COMMAND,
84 description: 'Show or hide the subagent pane: model, effort, context, tokens and the latest tool call',
85 })
86 .catch(() => {})
87
88 return next(e)
89 })
90
91 on('command.run', { command: COMMAND }, async $ => {
92 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
93 if (isOpen) await $.ui.close({ id: PANE })
94 else await $.ui.open({ id: PANE, title: 'Agents' })
95
96 return { text: isOpen
97 ? 'Agents pane closed.'
98 : 'Agents pane opened. Rows above the prompt hide while it shows; if the pane is covered (e.g. by the diff panel), set the agents option rows to always.' }
99 })
100
101 // The rows above the prompt depend on whether the pane is showing, so a change of its seat redraws them.
102 on('ui.open', { id: PANE }, async ($, e, next) => {
103 const opened = await next(e)
104 await update($, paneTick, n => n + 1).catch(() => {})
105
106 return opened
107 })
108
109 on('ui.close', { id: PANE }, async ($, e, next) => {
110 await next(e)
111 await update($, paneTick, n => n + 1).catch(() => {})
112 })
113
114 on('agent.spawn', async ($, e, next) => {
115 const started = await next(e)
116 if (started.deny !== undefined) return started
117
118 const at = await $.clock.now()
119 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
120 const wasIdle = !(await read($, list)).some(isRunning)
121 const rec: AgentRec = {
122 id: started.agentId ?? e.tool_use_id,
123 agentId: started.agentId,
124 type: e.subagentType,
125 label: clip(e.description || e.prompt, 60),
126 model: started.model,
127 window: windowOf(started.model, usage?.context.window),
128 status: 'run',
129 startedAt: at,
130 context: 0,
131 out: 0,
132 fresh: 0,
133 requests: 0,
134 tools: 0,
135 }
136 await update($, list, all => [...all.filter(a => a.id !== rec.id), rec].slice(-MAX_AGENTS))
137 await update($, now, () => at)
138 // The first agent of a batch opens the pane; a seat below 144 columns waits for /agents-pane.
139 if (wasIdle) void $.ui.open({ id: PANE, title: 'Agents' }).catch(() => {})
140
141 return started
142 }).catch(($, e, next) => next(e))
143
144 // Each model request of a subagent: the effort it ran at and what it cost in tokens.
145 on('turn.step', async function* ($, e, next) {
146 // A finished agent sending a request again was continued: it runs again
147 const known = e.agentId ? (await read($, list).catch(() => [])).find(a => a.agentId === e.agentId) : undefined
148 if (e.agentId && known && known.status !== 'run') await adopt($, e.agentId).catch(() => {})
149 const result = yield* next(e)
150 const usage = result.usage
151 if (!e.agentId || !usage) return result
152 await adopt($, e.agentId, usage.model || e.model, false).catch(() => {})
153
154 const context =
155 usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
156 await update($, list, all =>
157 all.map(a =>
158 a.agentId !== e.agentId
159 ? a
160 : {
161 ...a,
162 model: usage.model || e.model,
163 effort: e.effort === undefined ? a.effort : String(e.effort),
164 context,
165 out: a.out + usage.output_tokens,
166 fresh: a.fresh + usage.input_tokens + usage.cache_creation_input_tokens,
167 requests: a.requests + 1,
168 },
169 ),
170 )
171
172 return result
173 })
174
175 on('tool.call', async ($, e, next) => {
176 const agentId = e.agentId
177 if (agentId) {
178 await adopt($, agentId).catch(() => {})
179 const last = clip(`${e.tool} ${targetOf(e as unknown as Record<string, unknown>, e.tool)}`, 140)
180 await update($, list, all =>
181 all.map(a => (a.agentId === agentId ? { ...a, tools: a.tools + 1, lastTool: last } : a)),
182 )
183 }
184
185 return next(e)
186 }).catch(($, e, next) => next(e))
187
188 on('turn.complete', async ($, e, next) => {
189 const agentId = e.agentId
190 if (agentId) {
191 const at = await $.clock.now()
192 await update($, list, all =>
193 all.map(a =>
194 a.agentId === agentId
195 ? { ...a, status: e.reason === 'answer' ? ('done' as const) : ('fail' as const), endedAt: at }
196 : a,
197 ),
198 )
199 await update($, now, () => at)
200 }
201
202 return next(e)
203 })
204
205 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
206 const { Box, Text } = $.ui.resolve(e)
207 const all = await read($, list)
208 const at = Math.max(await read($, now), ...all.map(a => a.startedAt), 0)
209 const running = all.filter(isRunning).length
210 const cols = e.props.bodyColumns
211 const cells = cols < 60 ? 5 : 8
212
213 return (
214 <Box flexDirection="column">
215 <Text bold>
216 Agents ({running} running, {all.length} total)
217 </Text>
218 {all.length === 0 && <Text dimColor>No subagents yet.</Text>}
219 {all
220 .slice()
221 .reverse()
222 .map(a => {
223 const max = a.window
224 const mark = a.status === 'run' ? '●' : a.status === 'done' ? '✓' : '✗'
225 const color = a.status === 'run' ? ACCENT : a.status === 'done' ? GREEN : RED
226 return (
227 <Box key={a.id} flexDirection="column" marginTop={1}>
228 <Text>
229 <Text color={color}>{mark}</Text> {CRAB}
230 {costumeOf(a.type)} <Text bold>{a.type.replace(/^[^:]*:/, '')}</Text>{' '}
231 <Text dimColor>{clip(a.label, Math.max(12, cols - 24))}</Text>
232 </Text>
233 <Text dimColor={a.status !== 'run'}>
234 {' '}
235 {modelName(a.model)}
236 {a.effort ? ` · ${a.effort}` : ''} · {fillBar(a.context, max, cells)} {fmt(a.context)}/{fmt(max)}
237 </Text>
238 <Text dimColor={a.status !== 'run'}>
239 {' '}↑{fmt(a.out)} · {a.requests} req · {a.tools} tools · {fmtTime(elapsed(a, at))}
240 </Text>
241 {a.status === 'run' && a.lastTool && (
242 <Text dimColor wrap="truncate-end">
243 {' '}↳ {a.lastTool}
244 </Text>
245 )}
246 </Box>
247 )
248 })}
249 </Box>
250 )
251 })
252
253 // One row per running agent above the prompt. They repeat what the pane shows, so `rows: auto` (the default) draws
254 // them only while the pane is NOT showing: closed, still waiting below its width floor, or not the shown pane.
255 // `always` draws them regardless (the engine's diff panel can take the pane's seat, and a plugin cannot see that);
256 // `off` never draws them. It sits above whatever other mods draw, so none of them goes missing.
257 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258 if (rowsMode === 'off') return next(e)
259 const running = (await read($, list)).filter(isRunning)
260 if (e.props.hasSurvey || running.length === 0) return next(e)
261 if (rowsMode === 'auto') {
262 await read($, paneTick)
263 // Nothing emits an event when the shown tab changes; reading the tick clock re-runs this check every second
264 // while an agent runs, so the rows follow the pane's real state.
265 await read($, now)
266 const panes = await $.ui.panes().catch(() => [])
267 if (panes.some(p => p.id === PANE && p.isPlaced && p.isShown)) return next(e)
268 }
269
270 const { Box, Text } = $.ui.resolve(e)
271 const at = Math.max(await read($, now), ...running.map(a => a.startedAt))
272 const cols = e.props.bodyColumns
273 const room = Math.max(2, Math.min(6, Math.floor(e.props.maxRows / 3)))
274 const shown = running.slice(0, room)
275 const cells = cols < 100 ? 4 : 6
276
277 return (
278 <Box flexDirection="column">
279 <Box flexDirection="column" paddingX={1}>
280 {shown.map(a => (
281 <Box key={a.id} flexDirection="row" columnGap={2}>
282 <Text color={ACCENT}>
283 {CRAB}
284 {costumeOf(a.type)}
285 </Text>
286 <Box width={Math.min(18, Math.max(10, Math.floor(cols / 7)))} flexShrink={0}>
287 <Text bold wrap="truncate-end">
288 {a.type.replace(/^[^:]*:/, '')}
289 </Text>
290 </Box>
291 <Text dimColor>
292 {modelName(a.model)}
293 {a.effort ? `·${a.effort}` : ''}
294 </Text>
295 <Text dimColor>
296 {fillBar(a.context, a.window, cells)} {fmt(a.context)}
297 </Text>
298 <Text dimColor>
299 ↑{fmt(a.out)} · {a.requests} req · {fmtTime(elapsed(a, at))}
300 </Text>
301 {cols >= 110 && a.lastTool && (
302 <Box flexShrink={1}>
303 <Text dimColor wrap="truncate-end">
304 ↳ {a.lastTool}
305 </Text>
306 </Box>
307 )}
308 </Box>
309 ))}
310 {running.length > shown.length && (
311 <Text dimColor>
312 +{running.length - shown.length} more · /agents-pane
313 </Text>
314 )}
315 </Box>
316 {await next(e)}
317 </Box>
318 )
319 })
320}
321hooks/format.ts 72 lines1// Pure helpers for the agents mod: no engine calls, so they test without a session.
2
3export const CRAB = '🦀'
4
5// One costume per agent type; the crab underneath stays the same.
6const COSTUMES: [RegExp, string][] = [
7 [/explore/i, '🧭'],
8 [/plan/i, '📐'],
9 [/review|critic/i, '🔬'],
10 [/secur|guard|audit/i, '🔒'],
11 [/test|verif|qa/i, '🧪'],
12 [/guide|doc/i, '📖'],
13 [/statusline|design|ui/i, '🎨'],
14 [/fork/i, '🍴'],
15 [/general|build|implement|dev|code/i, '🔧'],
16]
17
18export const costumeOf = (type: string): string => {
19 const bare = type.replace(/^[^:]*:/, '')
20 for (const [re, costume] of COSTUMES) if (re.test(bare)) return costume
21 return '🦀'
22}
23
24export const clip = (text: string, max: number): string => {
25 const one = text.replace(/\s+/g, ' ').trim()
26 return one.length > max ? `${one.slice(0, max - 1)}…` : one
27}
28
29export const fmt = (n: number): string => {
30 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
31 if (n >= 1e4) return `${Math.round(n / 1e3)}k`
32 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
33 return String(Math.round(n))
34}
35
36export const fmtTime = (ms: number): string => {
37 const s = Math.max(0, Math.round(ms / 1000))
38 const h = Math.floor(s / 3600)
39 const m = Math.floor((s % 3600) / 60)
40 const ss = String(s % 60).padStart(2, '0')
41 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
42}
43
44// `claude-sonnet-4-6` -> `Sonnet 4.6`; anything unrecognised is shown as given.
45export const modelName = (id = ''): string => {
46 const m = /(fable|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
47 const [, family = '', major = '', minor] = m ?? []
48 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
49 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
50}
51
52// A subagent's window is not reported: Haiku is 200k, anything else gets the session's own window.
53export const windowOf = (model: string, session = 200_000): number => (/haiku/i.test(model) ? 200_000 : session)
54
55export const fillBar = (used: number, max: number, cells: number): string => {
56 const filled = Math.max(0, Math.min(cells, Math.round((used / max) * cells)))
57 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
58}
59
60// A short, human target for a tool call: the file, the command, the pattern. A Bash call prefers the
61// description the agent wrote ("Read tmux sessionizer") over the raw command. Cut at 120; the layout trims it to fit.
62export const targetOf = (input: Record<string, unknown>, tool?: string): string => {
63 const pick = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
64 const path = pick('file_path') ?? pick('notebook_path')
65 if (path) return path.split('/').slice(-2).join('/')
66 const text =
67 tool === 'Bash'
68 ? (pick('description') ?? pick('command'))
69 : (pick('command') ?? pick('pattern') ?? pick('url') ?? pick('query') ?? pick('description') ?? pick('path'))
70 return text ? clip(text, 120) : ''
71}
72types/index.d.ts 35 lines1export type AgentStatus = 'run' | 'done' | 'fail'
2
3export type AgentRec = {
4 id: string
5 agentId?: string
6 type: string
7 label: string
8 model: string
9 effort?: string
10 status: AgentStatus
11 startedAt: number
12 endedAt?: number
13 /** Context window in tokens: the session's, or 200k for Haiku */
14 window: number
15 /** Size of the latest request: input, cache and output tokens */
16 context: number
17 /** Output tokens, summed over every request */
18 out: number
19 /** Input tokens that were not a cache read, summed over every request */
20 fresh: number
21 requests: number
22 tools: number
23 lastTool?: string
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 agents: {
29 list: AgentRec[]
30 now: number
31 paneTick: number
32 }
33 }
34}
35