SLOPSHOPPER

agents

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…

newpanebandguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-13ThisaruGuruge/dotfiles/claude-mods/agents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents (0 running, 0 total) │ ┃ No subagents yet. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /agents-pane │ ⎿ agents: Agents pane opened. Rows above the prompt hide while it │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents (0 running, 0 total) No subagents yet.
README

Thisaru's Dotfiles

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.

Highlights

  • Fast Zsh environment – zinit-managed plugins, fzf-tab completion, syntax highlighting, autosuggestions, zoxide (via z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindings
  • Powerlevel10k prompt – pure-Zsh rendering (no binary subprocess per draw), instant prompt on startup, contextual Git state, Go/Java/Python/Ballerina indicators, transient prompt for clean scrollback, full Catppuccin Mocha theme matching the tmux status bar
  • Modern CLI stack – eza, bat, dust, ripgrep, fd, yazi (with inline image previews in Ghostty/iTerm2, chafa fallback for other terminals), jless, lazygit, lazydocker, tmux, direnv, atuin, gh, git-delta, gng (gw), uv (Python packages and projects), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)
  • Debugging in Neovim – nvim-dap with a DAP UI, inline variable values while stepping, and one keymap set (<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
  • Go tooling in Neovim – gopls with inlay hints and codelenses, <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)
  • Sessions and branch review in Neovim – persistence.nvim restores a project's buffers and layout on demand (<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)
  • Smart aliases – Single-letter shortcuts for modern tools (v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts
  • Suffix aliases – Automatically open files with the right tool based on extension (.md → mdcat, .json/.yaml → jless, .py/.bash/.zsh/.bal → $EDITOR)
  • Language runtimes – pyenv, rbenv, nvm, SDKMAN, and Ballerina (plus uv for Python packages and projects) with lazy-loading shell glue so heavy managers don't slow startup
  • Touch ID for sudo – Use your fingerprint instead of typing passwords in the terminal (works inside tmux too via pam-reattach)
  • Caps Lock remap – Hammerspoon + ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extension
  • Claude Code, two accounts – ccp (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 Claude
  • Project sessions in tmux – prefix + f opens a project with three windows ([Claude], [Nvim], [Shell])
  • Secrets handled correctly – SOPS + age encryption, edit_secrets workflow, and automatic .env handling inside init.sh
  • Brewfile-driven – Curated Brewfile with optional category files in packages/ for modular installation
  • Validation + profiling – bin/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 dives

Prerequisites

  1. macOS – Tested on Sonoma/Ventura (Apple Silicon friendly)
  2. Git – Already on macOS, confirm with git --version
  3. Homebrew – Install if missing:
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. Terminal – Ghostty (recommended and configured), iTerm2, or Terminal.app all work
  2. Nerd Font – Needed for icons in Powerlevel10k/lazygit:
   brew install --cask font-fira-code-nerd-font

Set the font once inside your terminal profile (Appearance/Text settings).

Installation

Option 1 – Automated (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:

  • Validate macOS + Xcode CLT, install Homebrew, jq, Go (needed to install bestow)
  • Install core packages from Brewfile with Brew
  • Offer opt-in categories (development, terminals, editors, etc.)
  • Install SDKMAN + Java 21 (optional) and brew-based Ballerina
  • Enable Touch ID for sudo (with tmux support via pam-reattach, loaded from a root-owned copy in /opt/pam)
  • Configure Atuin, direnv, tmux, git delta, lazygit, lazydocker, aliases, and helper functions
  • Generate/restore encrypted .env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt, per-machine ~/.sops.yaml)
  • Create Nerd Font + terminal integrations
  • Back up existing dotfiles and symlink everything via bestow
  • Install TPM (pinned release) and the tmux plugins
  • Check that the SSH commit-signing key exists (offers to create it)
  • Verify the install and show next steps (run dotdoctor for a full health check; the final output also lists the manual steps: the Hammerspoon spoon and permissions, the ~/.claude symlink, tmux plugins)

Option 2 – Manual setup

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.

Option 3 – Brewfile only

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.

Package Management

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

Installing optional categories

brew bundle --file=Brewfile                      # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile     # Add editors

Categories available

  • 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 CLI
  • editors – VS Code
  • terminals – iTerm2 (casks)
  • containers – colima (container runtime), Docker CLI + Compose + credential helper
  • productivity – Raycast, Rectangle, TablePlus, Alfred, Postman, Caffeine

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

Repository Layout & Symlink Packages

PathNotes
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.zshPowerlevel10k 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 Configuration

Ghostty is configured with:

  • Cmd+Left/Right sent as Home/End (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)
  • Option+Left/Right for word navigation (sent as Alt+b / Alt+f, so it works inside tmux too) and Cmd+Delete for delete-to-end-of-line (sent as Alt+k)
  • Catppuccin Mocha theme
  • FiraCode Nerd Font with ligatures
  • Tmux-aware keybindings for window/pane navigation (they send the tmux prefix Ctrl+a; test-zsh fails if the prefix changes)

Containers (colima)

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):

AliasCommand
colima-startcolima start
colima-stopcolima stop
colima-restartcolima restart
colima-statuscolima status
colima-listcolima list
colima-sshcolima 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.

Symlink Management (bestow)

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

bin is the exception: it links into ~/bin, so always stow it as bestow stow bin -d ~/bin. Don't run a bare bestow stow across every package — with the single $HOME destination in bestow's config it would link bin/* straight into ~.

Quick Start Commands

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)

Claude Code

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):

CommandAccountConfig dir
ccp, ccpr, ccpcpersonal: start, --resume, --continue~/.claude-personal
cce, ccer, ccecenterprise: 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.
  • Mods in 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.
  • Hooks call bin/claude-tmux-state, so tmux windows show when Claude is working, waiting or done (prefix + A jumps to the next waiting window).
  • Hooks also call 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.
  • Pin an account to a directory with envrc (below).

tmux project sessions

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.

Per-project accounts (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)
  • Replacing an existing .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 through

What overrides what

CommandCLAUDE_CONFIG_DIRGOOGLE_WORKSPACE_CLI_CONFIG_DIR
plain claude in a pinned treepinned accountpinned account (inherited)
ccp/ccpr/ccpc, cce/ccer/ccecthe account you typedblanked for that claude, so a pin never leaks the other account's gws into it
gwsp/gwseunchangedthe account you typed
bare gws in a pinned treeunchangedpinned account: the bin/gws guard lets it through because the pin supplies the account context
bare gws anywhere elseunchangedblocked (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).

GitHub PR helpers (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 @me
  • prw [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 checks

Both 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".

Shell behaviour worth knowing

  • 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
Source 3 files
hooks/register.tsx 321 lines
1import { 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}
321
hooks/format.ts 72 lines
1// 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}
72
types/index.d.ts 35 lines
1export 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