Claude state and usage in the tmux status line, a tool to read other panes, and long-running commands moved to their own tmux window

Dotbot manages links, vim-plug manages Neovim plugins, and Git submodules provide Dotbot, Oh My Zsh, TPM, and the custom Zsh plugins.
Supported platforms:
Linux installs the checksum-verified Neovim 0.12.5 release under ~/.local/lib; macOS uses Homebrew's current stable Neovim.
Clone over HTTPS so a new machine does not require SSH credentials before bootstrap:
git clone https://github.com/jeffsaracco/dotfiles.git ~/dotfiles
cd ~/dotfiles
./install
With no arguments, install applies the safe CLI base. Profiles are additive:
./install desktop
./install services
./install desktop services
desktop adds Alacritty, AeroSpace, a Nerd Font, and their configuration.services installs database, cache, and web-server packages without explicitly enabling or starting them.On Ubuntu or Debian, authorize sudo before running the installer:
sudo -v
./install
On macOS, install Xcode Command Line Tools first:
xcode-select --install
The bootstrap explicitly updates Homebrew before evaluating the Brewfiles so an existing installation recognizes the host macOS release.
The bootstrap does not clean $HOME, force-overwrite files, or replace an existing non-dotfiles configuration. If a managed destination already exists, Dotbot stops so it can be reviewed and moved manually.
The one automatic migration is a legacy ~/.oh-my-zsh checkout created by an older version of this repository. It is moved to a timestamped backup before the pinned Oh My Zsh submodule is linked.
The installer does not change the login shell. To opt in after installation:
chsh -s "$(command -v zsh)"
The installer creates ~/.gitconfig.local and ~/.zshrc.local with mode 600 when they are absent. It never overwrites existing values. The Git file is seeded with this repository owner's identity and the macOS credential helper. Put signing settings, private environment variables, and machine-specific paths in the local files.
Examples are tracked at:
home/git/gitconfig.local.examplehome/zsh/zshrc.local.exampleStart only services needed on a particular machine. For example:
brew services start redis
sudo systemctl enable --now redis-server
Inside tmux, install or update TPM plugins with <prefix> + I.
hooks/register.ts 194 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Window options the tmux config reads:
4// @claude working | waiting | done | idle
5// @claude_meter "ctx 42% · 5h 13%"
6// @claude_renamed 1 when this mod named the window
7type State = 'working' | 'waiting' | 'done' | 'idle'
8
9// Commands that never exit on their own: servers, watchers, followers. Each
10// must start the command's last segment, so a mention inside a heredoc,
11// string or earlier step never matches.
12const LONG_RUNNING: RegExp[] = [
13 /^(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b/,
14 /^(npx\s+)?(next|nuxt|astro|remix)\s+dev\b/,
15 /^(npx\s+)?vite(\s+(dev|serve))?$/,
16 /^cargo\s+watch\b/,
17 /^\S+(\s+\S+)*\s--watch\b/,
18 /^tail\s+-[a-zA-Z]*[fF]/,
19 /^docker(-|\s+)compose\s+up\b(?!.*\s(-d|--detach)\b)/,
20 /^python3?\s+-m\s+http\.server\b/,
21 /^(uvicorn|gunicorn|flask\s+run|rails\s+s(erver)?|hugo\s+server|jekyll\s+serve)\b/,
22]
23
24export function longRunningName(command: string): string | undefined {
25 if (command.includes('\n')) return undefined
26 const last = (command.split(/&&|\|\||;/).pop() ?? '').trim().replace(/^(\w+=\S*\s+)*/, '')
27 if (/^tmux\b/.test(last)) return undefined
28 for (const re of LONG_RUNNING) {
29 const m = last.match(re)
30 if (m) return m[0].trim().slice(0, 24)
31 }
32 return undefined
33}
34
35export function shellQuote(s: string): string {
36 return `'${s.replaceAll("'", `'\\''`)}'`
37}
38
39// The Bash command that starts `command` in a new window after Claude's own
40// (`window`, a window id like @4: new-window refuses a pane id) and tells the
41// model where it went.
42export function inWindow(command: string, name: string, window: string): string {
43 return [
44 `p=$(tmux new-window -d -a -t ${window} -P -F '#{pane_id}' -n ${shellQuote(name)} -c "$PWD")`,
45 `tmux send-keys -t "$p" -l -- ${shellQuote(command)}`,
46 `tmux send-keys -t "$p" Enter`,
47 `echo "Started in tmux window ${name.replaceAll('"', '')} (pane $p) so it keeps running. ` +
48 `Read its output with mcp__tmux__capture, target $p. Stop it with: tmux send-keys -t $p C-c"`,
49 ].join(' && ')
50}
51
52let last: State | undefined
53
54async function tmux($: EngineInterface, ...args: string[]) {
55 try {
56 return await $.process.run(['tmux', ...args], { timeoutMs: 5000 })
57 } catch {
58 return undefined
59 }
60}
61
62async function setState($: EngineInterface, state: State) {
63 const pane = await $.env.get('TMUX_PANE')
64 if (!pane || state === last) return
65 last = state
66 await tmux($, 'set', '-w', '-t', pane, '@claude', state)
67}
68
69async function refreshMeter($: EngineInterface) {
70 const pane = await $.env.get('TMUX_PANE')
71 if (!pane) return
72 const usage = await $.session.usage()
73 const parts = [`ctx ${usage.context.percent ?? 0}%`]
74 const fiveHour = usage.rateLimits.find(l => l.kind === 'five_hour')
75 if (fiveHour) parts.push(`5h ${fiveHour.percentUsed}%`)
76 await tmux($, 'set', '-w', '-t', pane, '@claude_meter', parts.join(' · '))
77}
78
79export const register: Register = on => {
80 on('session.start', async ($, e, next) => {
81 const started = await next(e)
82 const pane = await $.env.get('TMUX_PANE')
83 if (!pane) return started
84
85 await $.tool.register({
86 name: 'capture',
87 description:
88 'Read another tmux pane: a dev server, test watcher, REPL or log the user has open. ' +
89 'With no target, lists every pane (id, location, window name, running command, cwd). ' +
90 'With a target (a pane id like %3, or session:window.pane), returns its last `lines` lines.',
91 inputSchema: {
92 type: 'object',
93 properties: {
94 target: { type: 'string', description: 'tmux pane target; omit to list panes' },
95 lines: { type: 'number', description: 'Scrollback lines to return (default 200)' },
96 },
97 },
98 isDeferred: false,
99 })
100
101 // Name the window after the project, unless the user named it already.
102 const auto = await tmux($, 'display', '-p', '-t', pane, '#{automatic-rename}')
103 if (auto?.stdout.trim() === '1') {
104 const root = (await $.session.repo())?.root ?? (await $.session.cwd())
105 const name = root.split('/').filter(Boolean).pop() ?? 'claude'
106 await tmux($, 'rename-window', '-t', pane, name)
107 await tmux($, 'set', '-w', '-t', pane, '@claude_renamed', '1')
108 }
109
110 last = undefined
111 await setState($, 'idle')
112 await refreshMeter($)
113 return started
114 })
115
116 on('session.end', async ($, e, next) => {
117 const pane = await $.env.get('TMUX_PANE')
118 if (pane && e.reason !== 'clear') {
119 const renamed = await tmux($, 'display', '-p', '-t', pane, '#{@claude_renamed}')
120 if (renamed?.stdout.trim() === '1') {
121 await tmux($, 'set', '-w', '-t', pane, 'automatic-rename', 'on')
122 }
123 for (const opt of ['@claude', '@claude_meter', '@claude_renamed']) {
124 await tmux($, 'set', '-w', '-u', '-t', pane, opt)
125 }
126 }
127 return next(e)
128 })
129
130 on('turn.start', async ($, e, next) => {
131 await setState($, 'working')
132 return next(e)
133 })
134
135 on('turn.complete', async ($, e, next) => {
136 if (!e.agentId) {
137 await setState($, 'done')
138 await refreshMeter($)
139 }
140 return next(e)
141 })
142
143 on('session.measure', async ($, e, next) => {
144 await refreshMeter($)
145 return next(e)
146 })
147
148 on('classic.Notification', async ($, e, next) => {
149 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
150 await setState($, 'waiting')
151 }
152 return next(e)
153 }).catch(($, e, next) => next(e))
154
155 // A permission prompt or question has been answered once a tool runs.
156 on('tool.call', async ($, e, next) => {
157 if (last !== 'waiting' && e.tool !== 'AskUserQuestion') return next(e)
158 if (e.tool === 'AskUserQuestion') await setState($, 'waiting')
159 const ran = await next(e)
160 await setState($, 'working')
161 return ran
162 }).catch(($, e, next) => next(e))
163
164 on('tool.call', { tool: 'mcp__tmux__capture' }, async ($, e) => {
165 const { target, lines } = e as unknown as { target?: string; lines?: number }
166 if (!target) {
167 const self = await $.env.get('TMUX_PANE')
168 const listed = await tmux(
169 $, 'list-panes', '-a', '-F',
170 '#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{window_name}\t#{pane_current_command}\t#{pane_current_path}',
171 )
172 if (!listed || listed.exitCode !== 0) return { deny: `tmux list-panes failed: ${listed?.stderr ?? 'tmux not reachable'}` }
173 const rows = listed.stdout.trim().split('\n').map(r => (r.startsWith(`${self}\t`) ? `${r}\t(this Claude session)` : r))
174 return { result: `pane\tlocation\twindow\tcommand\tcwd\n${rows.join('\n')}` }
175 }
176 const n = Math.max(1, Math.min(Math.floor(lines ?? 200), 5000))
177 const cap = await tmux($, 'capture-pane', '-p', '-J', '-t', target, '-S', `-${n}`)
178 if (!cap || cap.exitCode !== 0) return { deny: `tmux capture-pane failed: ${cap?.stderr.trim() ?? 'tmux not reachable'}` }
179 const text = cap.stdout.replace(/\s+$/, '').split('\n').slice(-n).join('\n')
180 return { result: text || '(pane is empty)' }
181 }).catch(($, e, next) =>
182 next.called ? next(e) : { deny: `${$.plugin.name}: capture failed.` },
183 )
184
185 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
186 const pane = await $.env.get('TMUX_PANE')
187 const name = pane && !e.run_in_background ? longRunningName(e.command) : undefined
188 if (!pane || !name) return next(e)
189 const window = (await tmux($, 'display', '-p', '-t', pane, '#{window_id}'))?.stdout.trim()
190 if (!window?.startsWith('@')) return next(e)
191 return next({ ...e, command: inWindow(e.command, name, window) })
192 }).catch(($, e, next) => next(e))
193}
194