SLOPSHOPPER

tmux

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

newguardtoolprocess
v0.1.0no licenseupdated 2026-10-10jeffsaracco/dotfiles/home/claude/mods/tmux
A shopper browsing a rack in a slop shop
README

Jeff Saracco's dotfiles

Dotbot manages links, vim-plug manages Neovim plugins, and Git submodules provide Dotbot, Oh My Zsh, TPM, and the custom Zsh plugins.

Supported platforms:

  • Apple Silicon macOS
  • Current Ubuntu and Debian releases
  • GitHub Codespaces based on Ubuntu or Debian

Linux installs the checksum-verified Neovim 0.12.5 release under ~/.local/lib; macOS uses Homebrew's current stable Neovim.

Install

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.

Safety

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

Local Configuration

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.example
  • home/zsh/zshrc.local.example

Services

Start only services needed on a particular machine. For example:

brew services start redis
sudo systemctl enable --now redis-server

Tmux Plugins

Inside tmux, install or update TPM plugins with <prefix> + I.

Source 1 files
hooks/register.ts 194 lines
1import 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