SLOPSHOPPER

ssh-usage-band

Shows the SSH host, RAM in use and your 5-hour and 7-day usage limits in a row above the prompt

newbandtimer
v0.1.0MITupdated 2026-10-10RedRoosterKey/claude-code-ssh-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ssh-usage-band
› fix the failing auth test and add an audit log call ⏺ 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 5h ██░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ██░░░░░░ 31%
README

ssh-usage-band

A Claude Code mod that draws one row above the prompt: the machine you're on, how much of its RAM is in use, and your 5-hour and 7-day usage limits with the time until each resets.

SSH devbox (192.0.2.10)  RAM ██░░░░░░ 29%  5h █░░░░░░░ 18% ↻ 2h 41m  7d ███████░ 83% ↻ 6d  25m ago

ssh-usage-band above the Claude Code prompt

It was made for working in Claude Code over SSH on a small Linux box, where it helps to see at a glance which machine a session is on, whether it is running out of memory, and how close you are to a usage limit.

What it shows

PartExampleWhere it comes from
HostSSH devbox (192.0.2.10)/etc/hostname, and the server address (third field) of SSH_CONNECTION. Only the hostname when you're not on SSH.
RAMRAM ██░░░░░░ 29%MemAvailable and MemTotal in /proc/meminfo, read every 10 s. The bar fills with RAM in use.
5-hour limit5h █░░░░░░░ 18% ↻ 2h 41mThe session's usage readings. ↻ is the time until the window resets.
7-day limit7d ███████░ 83% ↻ 6dThe same. A day or more out it shows whole days only.
Spend limitspend ██░░░░░░ 25% …Shown instead of or beside the above when you're behind a Claude apps gateway with a spend limit.
Reading age25m agoShown once the session's last usage reading is 5 minutes old or more.

The row is sized to fit on one line in a normal-width window (about 100 columns with the example above). In a narrower window, or with a long hostname, it wraps onto a second line.

Colors

Every bar, RAM included, uses the same steps:

ColorUsage bars (% used)RAM bar (% free)
greenunder 70 %30 % or more
yellow70 % or moreunder 30 %
orange80 % or moreunder 20 %
red90 % or moreunder 10 %

The RAM bar is colored by what's free (strictly below each step), so it changes color where the usage bars do.

Why two sessions can show different usage figures

Claude Code only learns your usage figures from the API responses a session gets. Each session keeps its own last reading. A session that has been idle for a while still shows the figures from its last reply, even if another session has used more since then.

The band tells you when a reading may be out of date:

  • 25m ago (dim, at the end of the row): the session's last reading is that old. It shows from 5 minutes on. The next reply in that session refreshes it.
  • 5h ░░░░░░░░ reset (dim, no percentage): that window's reset time has passed since the reading was taken, so the old percentage no longer applies. The next reply brings the new window's figure.

The usage bars appear after the session's first reply. Until then the row shows only the host and RAM.

Why a mod and not a status line?

Claude Code's status line gets the same 5h and 7d figures, and a status line script could draw most of this row. But the Claude desktop app's Code tab doesn't draw custom status lines; only the terminal does. This mod draws above the prompt in both. It can also dim a window past its reset and show how old the reading is, which a status line can't: Claude Code drops a window from the status line's data once it resets, and that data carries no reading time.

Requirements

  • Claude Code 2.1.293. That's the version this was tested with (the tests also pass on 2.1.286). The function-hooks plugin API it uses is early access and can change between releases.
  • A claude.ai Pro or Max subscription for the 5h and 7d bars. Claude Code only reports usage limits to subscribers (or a spend limit behind a Claude apps gateway). With API-key billing there are no usage limits to show, and the row shows only the host and RAM. See rate_limits in the status line docs.
  • Linux for the host and RAM parts. They read /etc/hostname and /proc/meminfo. On macOS (and anywhere else those files don't exist) those parts are left out and only the usage bars show. Over SSH into a Mac the host part shows just SSH <server ip>, with no hostname.

Install

Install the mod where Claude Code runs:

  • A remote host you use through the desktop app's SSH sessions: install it on that host. See On a remote host. This is the recommended way for remote hosts.
  • Your own computer (terminal sessions, or the desktop app's local sessions): see On your computer.
  • Working on the mod itself: see From a clone.

On a remote host (recommended for remote hosts)

In the desktop app's SSH sessions, Claude Code runs on the remote host, so the mod goes there too. The band then shows that host's name and RAM.

Don't also install it on the computer running the desktop app. With the mod installed in both places, the band doesn't show in SSH sessions. If you installed it locally, remove that copy first (see Uninstall).

The desktop app puts its own copy of Claude Code on the host the first time you connect, under ~/.claude/remote/ccd-cli/ (one file per version), but not on your PATH. These steps use that copy, so the host needs nothing else installed.

  1. Connect to the host once from the desktop app, if you haven't yet, so that copy exists.
  2. In a terminal, SSH to the host and point CC at the newest version:
   CC=~/.claude/remote/ccd-cli/$(ls ~/.claude/remote/ccd-cli/ | sort -V | tail -1)

Check it works. This should print a Claude Code version:

   "$CC" --version
  1. In the same shell, add the marketplace and install:
   "$CC" plugin marketplace add RedRoosterKey/claude-code-ssh-usage-band
   "$CC" plugin install ssh-usage-band@ssh-usage-band
  1. Start a new SSH session to the host in the desktop app.

Set CC again in each new shell. Don't point an alias or symlink at one of the ccd-cli files: the desktop app replaces them when it updates. If the host has its own claude on the PATH, you can use that in place of "$CC".

On your computer

This repository is its own plugin marketplace. In a Claude Code session in a terminal, type:

/plugin install ssh-usage-band --marketplace RedRoosterKey/claude-code-ssh-usage-band

Answer y to add the marketplace, then pick a scope (user scope, the first one offered, loads it in every session). The band shows up in that session straight away.

Or the same from your shell:

claude plugin marketplace add RedRoosterKey/claude-code-ssh-usage-band
claude plugin install ssh-usage-band@ssh-usage-band

/plugin isn't available in the Claude desktop app's Code tab. Install it from a terminal at user scope and it loads in the desktop app's local sessions too.

From a clone

Clone the repository, then point Claude Code at the folder. For one session:

claude --plugin-dir /path/to/claude-code-ssh-usage-band

For every session, including ones the desktop app starts, add it to the env block of ~/.claude/settings.json (it is not read from a project's settings). Use an absolute path (~ is allowed); separate several folders with :.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/src/claude-code-ssh-usage-band"
  }
}

Update

On a remote host: SSH to the host, set CC as in On a remote host, then run:

"$CC" plugin marketplace update ssh-usage-band
"$CC" plugin update ssh-usage-band@ssh-usage-band

Then start a new SSH session in the desktop app.

On your computer:

claude plugin marketplace update ssh-usage-band
claude plugin update ssh-usage-band@ssh-usage-band

Then run /reload-plugins in a session, or start a new one.

From a clone: git pull. See Editing the mod for when the change shows up.

Uninstall

On a remote host: SSH to the host, set CC as in On a remote host, then run:

"$CC" plugin uninstall ssh-usage-band@ssh-usage-band
"$CC" plugin marketplace remove ssh-usage-band

On your computer:

claude plugin uninstall ssh-usage-band@ssh-usage-band
claude plugin marketplace remove ssh-usage-band

From a clone: drop the --plugin-dir flag, or remove the folder from CLAUDE_CODE_PLUGIN_DIRS, and start a new session.

Development

.claude-plugin/plugin.json       manifest
.claude-plugin/marketplace.json  makes this repo a marketplace
hooks/hooks.json                 names the hooks module
hooks/register.tsx               the mod
types/index.d.ts                 the state contract
tests/band.test.ts               tests

Running the tests

claude plugin test .

The tests run against Claude Code's own engine with a mocked clock, environment and filesystem. They need no login and no network, which is also how CI runs them.

claude plugin validate . checks the manifest, the marketplace file and the hooks module the way Claude Code will load them.

Type-checking

Claude Code writes its type declarations into .claude-plugin/types/ each time it loads the mod from a plugin folder (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS). That folder is generated, so it's in .gitignore. Once it exists:

npx -p typescript tsc -p .

Editing the mod

When the mod is loaded from a plugin folder (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS):

  • An interactive terminal session watches the folder. Saving a file reloads the mod in that session.
  • The desktop app's Code tab and other long-lived headless sessions (SDK) load the mod once. Edits show up in a new session, unless CLAUDE_CODE_PLUGIN_DIR_WATCH=1 is set in the environment or in the env block of ~/.claude/settings.json.
  • claude -p always loads it fresh.

A copy installed from the GitHub marketplace doesn't see edits to a clone at all. It changes only through Update.

License

MIT

Source 2 files
hooks/register.tsx 222 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit, Ram } from '../types'
5
6const limits = atom({ plugin: 'ssh-usage-band', key: 'limits' } as const, [])
7const now = atom({ plugin: 'ssh-usage-band', key: 'now' } as const, 0)
8const host = atom({ plugin: 'ssh-usage-band', key: 'host' } as const, '')
9const ram = atom({ plugin: 'ssh-usage-band', key: 'ram' } as const, null)
10// When the session last got a usage reading: each turn's measurement carries the
11// figures from that turn's last API response, so this is the session's last turn.
12const readAt = atom({ plugin: 'ssh-usage-band', key: 'readAt' } as const, 0)
13
14const LABELS: Record<string, string> = {
15  five_hour: '5h',
16  seven_day: '7d',
17  spend_limit: 'spend',
18}
19
20const BAR_WIDTH = 8
21const ORANGE = '#ff8700'
22
23export function bar(percent: number): string {
24  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_WIDTH)
25  return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
26}
27
28export function colorFor(percent: number): string {
29  if (percent >= 90) return 'red'
30  if (percent >= 80) return ORANGE
31  if (percent >= 70) return 'yellow'
32  return 'green'
33}
34
35function span(minutes: number): string {
36  const days = Math.floor(minutes / 1440)
37  const hours = Math.floor((minutes % 1440) / 60)
38  const mins = minutes % 60
39
40  if (days > 0) return `${days}d ${hours}h`
41  if (hours > 0) return `${hours}h ${mins}m`
42  return `${mins}m`
43}
44
45// A window whose reset time has passed: the session's reading of it predates the
46// reset, so its percentage is no longer true until the session's next turn.
47export function hasReset(resetsAt: string | undefined, nowMs: number): boolean {
48  if (!resetsAt || nowMs <= 0) return false
49  return Date.parse(resetsAt) <= nowMs
50}
51
52// A day or more out, whole days are enough and keep the row on one line.
53export function untilReset(resetsAt: string | undefined, nowMs: number): string | undefined {
54  if (!resetsAt) return undefined
55  const ms = Date.parse(resetsAt) - nowMs
56  if (Number.isNaN(ms) || ms <= 0) return undefined
57  const minutes = Math.ceil(ms / 60_000)
58  return minutes >= 1440 ? `↻ ${Math.floor(minutes / 1440)}d` : `↻ ${span(minutes)}`
59}
60
61// Readings younger than this are the session you're working in; no label.
62const AGE_SHOWN_FROM_MS = 5 * 60_000
63
64export function readingAge(readAtMs: number, nowMs: number): string | undefined {
65  if (readAtMs <= 0 || nowMs <= 0) return undefined
66  const ms = nowMs - readAtMs
67  if (ms < AGE_SHOWN_FROM_MS) return undefined
68  return `${span(Math.floor(ms / 60_000))} ago`
69}
70
71const RAM_POLL_MS = 10_000
72
73// Percent of MemTotal that MemAvailable is under. Under 30 % free is over 70 % used,
74// 20 % free is 80 % used, 10 % free is 90 % used: the same yellow, orange and red
75// steps as colorFor, so the RAM bar changes color where the usage bars do.
76const RAM_YELLOW_BELOW = 30
77const RAM_ORANGE_BELOW = 20
78const RAM_RED_BELOW = 10
79
80export function hostLabel(hostname: string, sshConnection: string | undefined): string {
81  // SSH_CONNECTION is "client_ip client_port server_ip server_port".
82  const serverIp = sshConnection?.trim().split(/\s+/)[2]
83  if (!serverIp) return hostname
84  return hostname ? `SSH ${hostname} (${serverIp})` : `SSH ${serverIp}`
85}
86
87export function parseMeminfo(text: string): Ram | null {
88  const kb = (field: string) => Number(new RegExp(`^${field}:\\s+(\\d+) kB$`, 'm').exec(text)?.[1])
89  const totalKb = kb('MemTotal')
90  const availableKb = kb('MemAvailable')
91  if (!(totalKb > 0) || Number.isNaN(availableKb)) return null
92  return { totalKb, availableKb }
93}
94
95export function ramColorFor(availablePercent: number): string {
96  if (availablePercent < RAM_RED_BELOW) return 'red'
97  if (availablePercent < RAM_ORANGE_BELOW) return ORANGE
98  if (availablePercent < RAM_YELLOW_BELOW) return 'yellow'
99  return 'green'
100}
101
102// The bar fills with RAM in use, like the usage bars; the color follows what's free.
103export function ramShown(r: Ram): { usedPercent: number; color: string } {
104  const availablePercent = (r.availableKb / r.totalKb) * 100
105  return {
106    usedPercent: 100 - availablePercent,
107    color: ramColorFor(availablePercent),
108  }
109}
110
111export const register: Register = on => {
112  on('session.start', async ($, e, next) => {
113    const hostname = await $.fs.read('/etc/hostname').then(
114      text => text.trim(),
115      () => '',
116    )
117    const ssh = await $.env.get('SSH_CONNECTION')
118    await update($, host, () => hostLabel(hostname, ssh))
119
120    // Write only when what the band draws changes, so polling doesn't redraw it.
121    let drawn = ''
122    const sampleRam = async () => {
123      let sample: Ram | null = null
124      try {
125        sample = parseMeminfo(await $.fs.read('/proc/meminfo'))
126      } catch {
127        sample = null
128      }
129      const shown = sample ? ramShown(sample) : null
130      const key = shown ? `${Math.round(shown.usedPercent)} ${shown.color}` : ''
131      if (key === drawn) return
132      drawn = key
133      await update($, ram, () => sample)
134    }
135    void sampleRam()
136    $.clock.every(RAM_POLL_MS, () => void sampleRam())
137
138    const usage = await $.session.usage()
139    await update($, limits, () => usage.rateLimits as Limit[])
140    await update($, now, () => 0)
141    if (usage.rateLimits.length > 0) {
142      const t = await $.clock.now()
143      await update($, readAt, () => t)
144    }
145
146    const tick = async () => {
147      const t = await $.clock.now()
148      await update($, now, () => t)
149    }
150    void tick()
151    $.clock.every(60_000, () => void tick())
152
153    return next(e)
154  })
155
156  on('session.measure', async ($, e, next) => {
157    // Every measurement carries the current figures, even when only the context
158    // moved, so it refreshes the reading's age; the bars redraw only on a change.
159    if (e.rateLimits.length > 0) {
160      const t = await $.clock.now()
161      if (e.changed.includes('rateLimits')) await update($, limits, () => e.rateLimits as Limit[])
162      await update($, readAt, () => t)
163      await update($, now, () => t)
164    }
165
166    return next(e)
167  })
168
169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170    const current = await read($, limits)
171    const hostText = await read($, host)
172    const mem = await read($, ram)
173    if (e.props.hasSurvey || (current.length === 0 && !hostText && !mem)) {
174      return next(e)
175    }
176
177    const nowMs = await read($, now)
178    const age = current.length > 0 ? readingAge(await read($, readAt), nowMs) : undefined
179    const { Box, Text } = $.ui.resolve(e)
180    const shown = mem ? ramShown(mem) : null
181
182    return (
183      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
184        {hostText ? <Text dimColor>{hostText}</Text> : null}
185        {shown ? (
186          <Box key="ram" flexDirection="row" gap={1}>
187            <Text dimColor>RAM</Text>
188            <Text color={shown.color}>{bar(shown.usedPercent)}</Text>
189            <Text bold color={shown.color}>
190              {`${Math.round(shown.usedPercent)}%`}
191            </Text>
192          </Box>
193        ) : null}
194        {current.map(limit => {
195          const label = LABELS[limit.kind] ?? limit.kind
196          if (hasReset(limit.resetsAt, nowMs)) {
197            return (
198              <Box key={limit.kind} flexDirection="row" gap={1}>
199                <Text dimColor>{label}</Text>
200                <Text dimColor>{bar(0)}</Text>
201                <Text dimColor>reset</Text>
202              </Box>
203            )
204          }
205          const reset = nowMs > 0 ? untilReset(limit.resetsAt, nowMs) : undefined
206          return (
207            <Box key={limit.kind} flexDirection="row" gap={1}>
208              <Text dimColor>{label}</Text>
209              <Text color={colorFor(limit.percentUsed)}>{bar(limit.percentUsed)}</Text>
210              <Text bold color={colorFor(limit.percentUsed)}>
211                {`${Math.round(limit.percentUsed)}%`}
212              </Text>
213              {reset ? <Text dimColor>{reset}</Text> : null}
214            </Box>
215          )
216        })}
217        {age ? <Text dimColor>{age}</Text> : null}
218      </Box>
219    )
220  })
221}
222
types/index.d.ts 9 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Ram = { totalKb: number; availableKb: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'ssh-usage-band': { limits: Limit[]; now: number; host: string; ram: Ram | null; readAt: number }
7  }
8}
9