SLOPSHOPPER

Usage Band

One tidy bar above the prompt: context against your auto-compact window, 5-hour and 7-day limits against the clock, prompt cache time left, session cost and…

newpanebandcommandpromptprocess
v1.1.0MITupdated 2026-10-16iamshadmantaqi/claude-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ◑ 97.4k / 200k │ 5h ━━──── 31% │ $0.42 │ 0 agents ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◑ 97.4k / 200k │ 5h ━━──── 31% │ $0.42 │ 0 agents ⟨Claude Code's own drawing⟩
README

Usage Band

A Claude Code mod that puts one tidy bar above the prompt:

◔ 281k / 400k +9.8k │ 5h ━━──── 28% 2h 00m │ 7d ─────── 7% 4d 4h │ cache 59m │ $191 │ 5 running / 38

In the desktop app each segment is drawn with a small ring, bar or icon and opens a hover card with the detail.

SegmentWhat it shows
ContextTokens in the context against the auto-compact window (for example autoCompactWindow: 400000 in settings), not the model's full window. The ring fills as you approach it. The hover card says where the window came from, the model's own window and the token count where auto-compaction runs.
5h / 7dShare of your plan's limits used. The bar's tick marks how much of the window has passed, so a fill past the tick means you are ahead of pace. Then the time to reset. A 7-day projection (out Fri) appears when the week's pace would use it up before the reset.
CacheTime left before the prompt cache lapses (1 hour on a plan, 5 minutes on an API key), cold once it has. The hover card shows the share of the last message read from the cache.
CostSession cost at API prices, as /cost totals it. The hover card adds the last prompt's cost.
AgentsSubagents running now, out of all the session has run, with what each is doing in the hover card.

Colour only carries state: blue while things are fine, amber when ahead of pace or running short, red at the edge.

Job queue row (optional)

On a machine that runs heavy jobs (builds, test runs, dev servers) through a queue script, the band can show that queue on a row above it:

◇ Heavy  Mac 0/2 · box-a 1/3 · box-b 0/8 · Queue 0 · Mem 79% free  Load 2.7

Click the row (or run the slash command named after the script, /heavy for heavy.sh) to open a side pane with each slot, the waiting jobs in order and each remote machine, in sections that open on click. Amber means jobs are waiting or the load has passed the core count; red means memory pressure is critical, the load has reached the script's own limit, or a remote machine is off.

Set the option to the script's path in /config or in settings.json:

"pluginConfigs": { "usage-band@usage-band": { "options": { "queueScript": "~/tools/heavy.sh" } } }

Left empty, nothing runs and no row shows. The script is run as <path> status every few seconds; when several sessions are open, one of them runs it and the rest read its result from the plugin's local store. The output it reads, one line each:

slot 1: free
slot 2: web/fix-x, run pid 123, 3 min, in /path/to/worktree
remote slot 1 (box-a): api/tests, run pid 456, 0 min, in /path/to/worktree
queue 1: api/orders, run pid 789, waiting 4 min, in /path/to/worktree
memory free: 79%
memory pressure: normal
load:  2.67 2.35 2.13
chats working (last 2 min): 2
gate: off (load limit 40, ...)
remote (box-a, 3 slots): on, last check: ok (1 min ago)
  box-a: memory 3 of 9 GB used, ..., load 1.61 0.44 0.15, ...

How the context window is read

The engine's /context breakdown ($.session.usage({ breakdown: "summary" })) reports the window the session compacts against as rawMaxTokens, along with its source and the auto-compact threshold. The band reads it at start, after a compaction and at most every five minutes. Without a breakdown it falls back to CLAUDE_CODE_AUTO_COMPACT_WINDOW, then the model's window.

Install

At the prompt of a terminal Claude Code session:

/plugin install usage-band --marketplace iamshadmantaqi/claude-usage-band

Answer y to add the marketplace, then pick the user scope. A mod installed at the user scope also draws in the desktop app's Code tab.

Develop

claude plugin validate .
claude plugin test .

Privacy

No network requests and nothing sent anywhere. With the job queue option set it also runs that script locally. The band reads the usage figures Claude Code already has (context, limits, cost, each request's cache token counts, the list of agents) and keeps the latest limits reading in the plugin's local store so other sessions can show it.

Licence

MIT

Source 3 files
hooks/register.tsx 850 lines
1// Usage Band: one bar above the prompt.
2//
3//   Desktop:  ◔ 281k / 400k +9.8k   5h ▬▬▭▭ 28% 8m   7d ▬▭▭▭ 7% 4d 4h   ⚡ 59m   $191   ● 5 running / 38
4//   Terminal: ◔ 281k/400k +9.8k │ 5h ━━──── 28% 8m │ 7d ━───── 7% 4d 4h │ cache 59m │ $191 │ 5 running / 38
5//
6// Colour only carries state: blue while things are fine, amber when ahead of pace or running
7// short, red at the edge. Every segment has a hover card in the app with the detail.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, On, Register, RenderChildren, RenderElement, RenderInput, SessionRateLimit } from 'claude-code'
11
12import type { CacheHit, Context, QueueReading, QueueSlot, QueueWaiter, RunningAgent, View } from '../types'
13import { clock as queueClock, hostSummary, isOpen, loadTone, memoryTone, namesOf, oneDecimal, parseStatus, summary, used } from './queue'
14
15type $ = EngineInterface
16
17const EMPTY: View = { context: null, limits: { at: 0, list: [] }, cost: null, lastPromptCost: null, cache: null, running: [], seen: [] }
18const view = atom({ plugin: 'usage-band', key: 'view' } as const, EMPTY)
19
20const MIN = 60_000
21const HOUR = 60 * MIN
22const DAY = 24 * HOUR
23const SPANS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
24const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
25const ORDER = ['five_hour', 'seven_day', 'spend_limit']
26// The account's limits are shared by every session: the newest reading lives in the store.
27const LIMITS_KEY = 'limits'
28// The compaction window rarely changes; the breakdown that reports it is read this often at most.
29const WINDOW_TTL = 5 * MIN
30const RUNNING = new Set(['pending', 'running', 'waiting'])
31
32type Tone = 'ok' | 'warn' | 'alert'
33// SVG fills, readable on light and dark backgrounds.
34const FILL: Record<Tone, string> = { ok: '#3f8cff', warn: '#e09b2d', alert: '#e5534b' }
35// Text colours, as theme keys where the theme has one.
36const INK: Record<Tone, string | undefined> = { ok: undefined, warn: 'warning', alert: 'error' }
37const TRACK = 'rgba(127,127,127,0.28)'
38const MARK = 'rgba(127,127,127,0.95)'
39const MUTED_ICON = '#8b95a5'
40
41// Not drawn from, so module variables: they start over on a reload, as session.start does.
42let windowReadAt = 0
43let costAtSubmit: number | null = null
44let tokensAtSubmit: number | null = null
45let cachingOff = false
46let timer: { cancel: () => void } | null = null
47
48export const register: Register = (on, options) => {
49  const queueScript = String(options.queueScript ?? '').trim()
50  if (queueScript) registerQueue(on, queueScript)
51
52  on('session.start', async ($, e, next) => {
53    timer?.cancel()
54    if (queueScript) await startQueue($, queueScript)
55    windowReadAt = 0
56    cachingOff = Boolean(await $.env.get('DISABLE_PROMPT_CACHING'))
57    const usage = await $.session.usage()
58    const context = await contextOf($, usage.context, null)
59    const limits = await newestLimits($, usage.rateLimits)
60    await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, limits, cost: usage.cost?.usd ?? v?.cost ?? null }))
61    await refreshAgents($)
62    // Time moves on (cache countdown, reset clocks) and other sessions read newer limits.
63    timer = $.clock.every(15_000, async () => {
64      await adoptSharedLimits($)
65      await refreshAgents($)
66      $.ui.invalidate('ui.render')
67    })
68    return next(e)
69  })
70
71  on('session.end', async ($, e, next) => {
72    if (e.reason === 'prompt_input_exit' || e.reason === 'other') {
73      timer?.cancel()
74      stopQueue()
75    }
76    return next(e)
77  })
78
79  on('prompt.submit', async ($, e, next) => {
80    const v = await read($, view)
81    costAtSubmit = v?.cost ?? null
82    tokensAtSubmit = v?.context?.tokens ?? null
83    return next(e)
84  })
85
86  // Each main-loop request: how much of its prompt the cache served. The cache's lifetime runs
87  // from the request's start.
88  on('turn.step', async function* ($, e, next) {
89    if (e.agentId) return yield* next(e)
90    const at = await $.clock.now()
91    const result = yield* next(e)
92    const u = result?.usage
93    if (u) {
94      const cache: CacheHit = { at, read: u.cache_read_input_tokens ?? 0, write: u.cache_creation_input_tokens ?? 0, fresh: u.input_tokens ?? 0 }
95      await update($, view, (v) => ({ ...(v ?? EMPTY), cache }))
96    }
97    return result
98  })
99
100  on('turn.complete', async ($, e, next) => {
101    const result = await next(e)
102    if (e.agentId) {
103      await refreshAgents($)
104      return result
105    }
106    try {
107      const usage = await $.session.usage()
108      const prev = await read($, view)
109      const context = await contextOf($, usage.context, prev?.context ?? null)
110      const cost = usage.cost?.usd ?? prev?.cost ?? null
111      const lastPromptCost = cost !== null && costAtSubmit !== null && cost >= costAtSubmit ? cost - costAtSubmit : prev?.lastPromptCost ?? null
112      if (context && tokensAtSubmit !== null) context.delta = context.tokens - tokensAtSubmit
113      const limits = usage.rateLimits.length > 0 ? await shareLimits($, usage.rateLimits) : prev?.limits ?? EMPTY.limits
114      await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, cost, lastPromptCost, limits }))
115      await refreshAgents($)
116    } catch {
117      // No reading this turn: the bar keeps the last one.
118    }
119    return result
120  })
121
122  // A compaction drops the context at once; the next request writes a new cache.
123  on('session.compact', async ($, e, next) => {
124    const result = await next(e)
125    if (e.agentId || e.trigger === 'precompute' || !result || 'skip' in result) return result
126    try {
127      windowReadAt = 0
128      const prev = await read($, view)
129      const usage = await $.session.usage()
130      const context = await contextOf($, usage.context, prev?.context ?? null)
131      const after = (result as { tokensAfter?: number }).tokensAfter
132      if (context && typeof after === 'number' && after > 0) context.tokens = after
133      if (context) context.delta = null
134      await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, cache: null }))
135    } catch {
136      // The bar catches up at the end of the next prompt.
137    }
138    return result
139  })
140
141  on('session.measure', async ($, e, next) => {
142    const limits = e.changed.includes('rateLimits') && e.rateLimits.length > 0 ? await shareLimits($, e.rateLimits) : null
143    await update($, view, (v) => {
144      const base = v ?? EMPTY
145      const out: View = { ...base }
146      if (limits) out.limits = limits
147      if (e.cost) out.cost = e.cost.usd
148      if (e.changed.includes('context') && base.context && typeof e.context.tokens === 'number') {
149        out.context = { ...base.context, tokens: e.context.tokens, modelWindow: e.context.window || base.context.modelWindow }
150      }
151      return out
152    })
153    return next(e)
154  })
155
156  on('agent.spawn', async ($, e, next) => {
157    const result = await next(e)
158    try {
159      await refreshAgents($)
160    } catch {}
161    return result
162  })
163
164  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165    if (e.props.hasSurvey) return next(e)
166    const els = $.ui.resolve(e)
167    const { Box } = els
168    // The job queue row, when the option is set, sits a row of air above the band.
169    const row = queueScript ? await queueRow($, e) : null
170    const v = (await read($, view)) ?? EMPTY
171    const hasBand = Boolean(v.context || v.limits.list.length > 0)
172    const now = await $.clock.now()
173    const mine = hasBand ? (e.surface === 'terminal' || !('Svg' in els) ? drawTerminal(els, segmentsOf(v, now), e.props.bodyColumns ?? 80) : drawDesktop(els, segmentsOf(v, now))) : null
174    const below = await next(e)
175    const band = mine && below ? (
176      <Box flexDirection="column">
177        {mine}
178        {below}
179      </Box>
180    ) : (mine ?? below)
181    if (!row) return band
182    if (!band) return row
183    return (
184      <Box flexDirection="column" rowGap={1}>
185        {row}
186        {band}
187      </Box>
188    )
189  })
190}
191
192// ---------- Readings ----------
193
194// The context against the auto-compact window. The engine's /context breakdown names that window
195// (`rawMaxTokens`): the model's own limit, or the smaller one settings, the environment or the
196// account set. Read at most every few minutes; between reads the last one stands.
197async function contextOf($: $, live: { tokens?: number; window: number }, prev: Context | null): Promise<Context | null> {
198  if (!live || !(live.window > 0)) return prev ? { ...prev } : null
199  const tokens = typeof live.tokens === 'number' ? live.tokens : prev?.tokens ?? 0
200  let win = prev && prev.modelWindow === live.window ? { window: prev.window, source: prev.source, threshold: prev.threshold } : null
201  const now = await $.clock.now()
202  if (!win || now - windowReadAt > WINDOW_TTL) win = (await compactWindow($, live.window, now)) ?? win
203  if (!win) win = { window: live.window, source: 'model', threshold: null }
204  return { tokens, modelWindow: live.window, window: win.window, source: win.source, threshold: win.threshold, delta: prev?.delta ?? null }
205}
206
207async function compactWindow($: $, modelWindow: number, now: number) {
208  try {
209    const usage = await $.session.usage({ breakdown: 'summary' })
210    const b = usage.context.breakdown
211    if (b && b.rawMaxTokens > 0) {
212      windowReadAt = now
213      const threshold = b.isAutoCompactEnabled && typeof b.autoCompactThreshold === 'number' ? b.autoCompactThreshold : null
214      return { window: b.rawMaxTokens, source: String(b.autocompactSource ?? 'auto'), threshold }
215    }
216  } catch {
217    // No session bound yet, or the breakdown failed: the variable below, then the model's window.
218  }
219  const fromEnv = Number(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'))
220  if (fromEnv > 0) return { window: Math.min(fromEnv, modelWindow), source: 'env', threshold: null }
221  return null
222}
223
224function sortLimits(list: readonly SessionRateLimit[]) {
225  const rank = (k: string) => (ORDER.includes(k) ? ORDER.indexOf(k) : ORDER.length)
226  return [...list].sort((a, b) => rank(a.kind) - rank(b.kind))
227}
228
229// This session's reading, published when it is the newest the store knows.
230async function shareLimits($: $, list: readonly SessionRateLimit[]) {
231  const limits = { at: await $.clock.now(), list: sortLimits(list) }
232  try {
233    const stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
234    if (!stored || !(stored.at > limits.at)) await $.store.set(LIMITS_KEY, limits)
235  } catch {}
236  return limits
237}
238
239// At start: the newest of this session's reading and the store's.
240async function newestLimits($: $, list: readonly SessionRateLimit[]) {
241  let stored: View['limits'] | undefined
242  try {
243    stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
244  } catch {}
245  if (stored && Array.isArray(stored.list) && stored.list.length > 0) return { at: stored.at, list: sortLimits(stored.list) }
246  return list.length > 0 ? shareLimits($, list) : EMPTY.limits
247}
248
249async function adoptSharedLimits($: $) {
250  try {
251    const stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
252    const v = await read($, view)
253    if (stored && Array.isArray(stored.list) && stored.at > (v?.limits.at ?? 0)) {
254      await update($, view, (x) => ({ ...(x ?? EMPTY), limits: { at: stored.at, list: sortLimits(stored.list) } }))
255    }
256  } catch {}
257}
258
259async function refreshAgents($: $) {
260  let list: Awaited<ReturnType<$['agent']['list']>>
261  try {
262    list = await $.agent.list()
263  } catch {
264    return
265  }
266  const running: RunningAgent[] = list.filter((a) => RUNNING.has(a.status)).map((a) => ({ id: a.id, type: a.type, description: a.description }))
267  const v = (await read($, view)) ?? EMPTY
268  const seen = [...new Set([...v.seen, ...list.map((a) => a.id)])]
269  const same = seen.length === v.seen.length && running.length === v.running.length && running.every((r, i) => r.id === v.running[i]?.id)
270  if (!same) await update($, view, (x) => ({ ...(x ?? EMPTY), running, seen }))
271}
272
273// ---------- What each segment says ----------
274
275type Part = { text: string; tone?: Tone; strong?: boolean; dim?: boolean; detail?: boolean }
276type Segment = {
277  key: string
278  icon?: { svg: string; alt: string; glyph: string; tone?: Tone }
279  bar?: { used: number; mark: number | null; tone: Tone }
280  parts: Part[]
281  card: string[]
282}
283
284function segmentsOf(v: View, now: number): Segment[] {
285  const out: Segment[] = []
286  if (v.context) out.push(contextSegment(v.context))
287  for (const l of v.limits.list) out.push(limitSegment(l, now))
288  const cache = cacheSegment(v, now)
289  if (cache) out.push(cache)
290  if (v.cost !== null && v.cost >= 0.005) out.push(costSegment(v))
291  out.push(agentsSegment(v))
292  return out
293}
294
295function contextSegment(c: Context): Segment {
296  const pct = c.window > 0 ? (c.tokens / c.window) * 100 : 0
297  const tone: Tone = pct >= 85 ? 'alert' : pct >= 60 ? 'warn' : 'ok'
298  const parts: Part[] = [{ text: tokens(c.tokens), strong: true, tone: tone === 'ok' ? undefined : tone }, { text: `/ ${tokens(c.window)}`, dim: true }]
299  if (c.delta !== null && Math.abs(c.delta) >= 1000) parts.push({ text: c.delta > 0 ? `+${tokens(c.delta)}` : `−${tokens(-c.delta)}`, dim: true, detail: true })
300  const card = [`Context ${tokens(c.tokens)} of ${tokens(c.window)} (${Math.round(pct)}%)`]
301  if (c.window < c.modelWindow) card.push(`  Auto-compact window ${tokens(c.window)}, ${SOURCES[c.source] ?? c.source}; model allows ${tokens(c.modelWindow)}`)
302  else card.push(`  The model's full window`)
303  if (c.threshold !== null) card.push(`Auto-compacts at about ${tokens(c.threshold)}`, `  ${tokens(Math.max(0, c.threshold - c.tokens))} to go`)
304  if (c.delta !== null) card.push(`Last prompt ${c.delta >= 0 ? '+' : '−'}${tokens(Math.abs(c.delta))}`)
305  return { key: 'context', icon: { svg: ringSvg(pct, FILL[tone]), alt: `Context ${Math.round(pct)}% of the auto-compact window`, glyph: ringGlyph(pct), tone }, parts, card }
306}
307
308const SOURCES: Record<string, string> = {
309  settings: 'from your settings',
310  env: 'from CLAUDE_CODE_AUTO_COMPACT_WINDOW',
311  clientdata: 'set by your account',
312  experiment: 'set by an experiment',
313  'model-default': "the model's default",
314  'unknown-model': 'a default for this model',
315}
316
317function limitSegment(l: SessionRateLimit, now: number): Segment {
318  const label = LABELS[l.kind] ?? l.kind
319  const reset = Date.parse(l.resetsAt ?? '')
320  const left = Number.isFinite(reset) ? reset - now : null
321  const span = SPANS[l.kind]
322  // A window that already reset: its reading says nothing until the next response.
323  if (left !== null && left <= 0) {
324    return { key: 'limit-' + l.kind, parts: [{ text: label, dim: true }, { text: '–', dim: true }], card: [`${NAMES[l.kind] ?? label} reset at ${clock(reset)}`, '  The next message brings a new reading'] }
325  }
326  const used = clamp(l.percentUsed)
327  const elapsed = span && left !== null ? clamp(((span - left) / span) * 100) : null
328  const ahead = elapsed === null ? 0 : used - elapsed
329  const tone: Tone = used >= 90 || ahead > 20 ? 'alert' : ahead > 5 ? 'warn' : 'ok'
330  const parts: Part[] = [{ text: label, dim: true }, { text: `${Math.round(used)}%`, strong: true, tone: tone === 'ok' ? undefined : tone }]
331  if (left !== null) parts.push({ text: duration(left), dim: true, detail: true })
332  const card = [`${NAMES[l.kind] ?? label}: ${Math.round(used)}% used`]
333  if (elapsed !== null) card.push(`  ${Math.round(elapsed)}% of the window has passed${ahead > 0 ? `, ${Math.round(ahead)} points ahead of pace` : ''}`)
334  if (left !== null) card.push(`Resets ${l.kind === 'five_hour' ? 'at ' + clock(reset) : dayTime(reset)}`, `  in ${duration(left)}`)
335  // The week's average pace so far, projected: shown after a day and from 15% used.
336  if (l.kind === 'seven_day' && span && left !== null && span - left >= DAY && used >= 15 && used < 100) {
337    const out = now + ((100 - used) * (span - left)) / used
338    if (out < reset) {
339      parts.push({ text: `out ${weekday(out)}`, tone: out - now < DAY ? 'alert' : 'warn', strong: true })
340      card.push(`At this week's pace it runs out ${dayTime(out)}`, `  before the reset; an estimate`)
341    }
342  }
343  return { key: 'limit-' + l.kind, bar: { used, mark: elapsed, tone }, parts, card }
344}
345
346const NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: '7-day limit', spend_limit: 'Spend limit' }
347
348// The prompt cache keeps the conversation's start for an hour on a subscription, five minutes on
349// an API key; each request that reads it starts the time again.
350function cacheSegment(v: View, now: number): Segment | null {
351  if (cachingOff || !v.cache) return null
352  const ttl = v.limits.list.length > 0 ? HOUR : 5 * MIN
353  const left = ttl - (now - v.cache.at)
354  const total = v.cache.read + v.cache.write + v.cache.fresh
355  const hit = total > 0 ? Math.round((v.cache.read / total) * 100) : 0
356  const card: string[] = []
357  let parts: Part[]
358  let tone: Tone
359  if (left > 0) {
360    tone = left < ttl / 5 ? 'warn' : 'ok'
361    parts = [{ text: duration(left), strong: tone !== 'ok', tone: tone === 'ok' ? undefined : tone }]
362    card.push(`Prompt cache warm until ${clock(v.cache.at + ttl)}`, `  ${duration(left)} left, ${ttl === HOUR ? '1 hour' : '5 minute'} lifetime`)
363  } else {
364    tone = 'alert'
365    parts = [{ text: 'cold', strong: true, tone }]
366    card.push(`Prompt cache expired ${duration(-left)} ago`)
367    if (v.context) card.push(`  The next message writes about ${tokens(v.context.tokens)} again`)
368    if (v.context && v.context.tokens >= 100_000) card.push(`/compact first makes that rewrite smaller`)
369  }
370  if (total > 0) card.push(`Last message: ${hit}% read from the cache`, `  ${tokens(v.cache.read)} read, ${tokens(v.cache.write)} written`)
371  return { key: 'cache', icon: { svg: boltSvg(tone === 'ok' ? MUTED_ICON : FILL[tone]), alt: 'Prompt cache', glyph: 'cache', tone }, parts, card }
372}
373
374function costSegment(v: View): Segment {
375  const cost = v.cost ?? 0
376  const card = [`Session ${money(cost, true)} at API prices`]
377  if (v.limits.list.length > 0) card.push('  On a plan this counts toward its limits, not a bill')
378  if (v.lastPromptCost !== null && v.lastPromptCost >= 0.005) card.push(`Last prompt +${money(v.lastPromptCost, true)}`)
379  return { key: 'cost', parts: [{ text: money(cost, false) }], card }
380}
381
382// Always drawn: a session that has run no agent shows a muted 0.
383function agentsSegment(v: View): Segment {
384  const n = v.running.length
385  const total = v.seen.length
386  const card = [`${total} agent${total === 1 ? '' : 's'} this session, ${n} running`]
387  for (const a of v.running.slice(0, 8)) card.push(`  ${a.type}: ${fit(a.description || 'working', 48)}`)
388  if (n > 8) card.push(`  and ${n - 8} more`)
389  const parts: Part[] = n > 0 ? [{ text: `${n} running`, strong: true }, { text: `/ ${total}`, dim: true }] : [{ text: `${total} agent${total === 1 ? '' : 's'}`, dim: true }]
390  return { key: 'agents', icon: n > 0 ? { svg: dotSvg(FILL.ok), alt: 'Agents running', glyph: '●', tone: 'ok' } : undefined, parts, card }
391}
392
393// ---------- Drawing ----------
394
395type Els = ReturnType<$['ui']['resolve']>
396
397function drawDesktop(els: Els, segments: Segment[]) {
398  const { Box, Text } = els
399  const Svg = (els as { Svg?: any }).Svg
400  const half = Math.ceil(segments.length / 2)
401  return (
402    <Box flexDirection="row" flexWrap="wrap" justifyContent="center" alignItems="center" columnGap={1} rowGap={0} paddingX={1}>
403      {segments.map((s, i) => (
404        <Box key={s.key} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} paddingX={1} hover={{ backgroundColor: 'rgba(127,127,127,0.14)' }}>
405          {s.icon ? <Svg key="icon" source={s.icon.svg} alt={s.icon.alt} width={14} height={14} /> : null}
406          {partsOf(Text, s.parts.slice(0, s.bar ? 1 : s.parts.length))}
407          {s.bar ? <Svg key="bar" source={barSvg(s.bar.used, s.bar.mark, FILL[s.bar.tone])} alt={`${Math.round(s.bar.used)}% used`} width={BAR.w} height={BAR.h} /> : null}
408          {s.bar ? partsOf(Text, s.parts.slice(1), 'p') : null}
409          {card(els, s.card, i >= half)}
410        </Box>
411      ))}
412    </Box>
413  )
414}
415
416function partsOf(Text: Els['Text'], parts: Part[], prefix = 't') {
417  return parts.map((p, i) => (
418    <Text key={prefix + i} bold={p.strong} dimColor={p.dim && !p.tone} color={p.tone ? INK[p.tone] : undefined}>
419      {p.text}
420    </Text>
421  ))
422}
423
424// Hidden until the pointer is over its segment; drawn above the bar, anchored to the segment's
425// left edge, or its right edge for segments in the bar's right half.
426function card(els: Els, lines: string[], fromRight: boolean) {
427  const { Box, Text } = els
428  if (lines.length === 0) return null
429  return (
430    <Box position="absolute" bottom={1} {...(fromRight ? { right: 0 } : { left: 0 })} display="none" hover={{ display: 'flex' }} flexDirection="column" paddingX={1} borderStyle="round" borderColor="subtle" backgroundColor="background">
431      {lines.map((line, i) =>
432        line.startsWith('  ') ? (
433          <Text key={'c' + i} dimColor>
434            {line.trim()}
435          </Text>
436        ) : (
437          <Text key={'c' + i} bold={i === 0}>
438            {line}
439          </Text>
440        ),
441      )}
442    </Box>
443  )
444}
445
446const TEXT_BAR = 6
447
448function drawTerminal(els: Els, segments: Segment[], columns: number) {
449  const { Box, Text } = els
450  // Too wide for the terminal: the details go first, then the bars.
451  const width = (withDetail: boolean, withBar: boolean) =>
452    segments.reduce((n, s) => n + segmentWidth(s, withDetail, withBar), 0) + 3 * Math.max(0, segments.length - 1) + 2
453  const withDetail = width(true, true) <= columns
454  const withBar = withDetail || width(false, true) <= columns
455  const children: RenderChildren[] = []
456  segments.forEach((s, i) => {
457    if (i > 0) children.push(<Text key={'sep' + i} dimColor>{' │ '}</Text>)
458    const parts = s.parts.filter((p) => withDetail || !p.detail)
459    const inner: RenderChildren[] = []
460    if (s.icon) inner.push(<Text key="icon" color={glyphInk(s)} dimColor={!glyphInk(s)}>{s.icon.glyph + ' '}</Text>)
461    const head = s.bar ? parts.slice(0, 1) : parts
462    head.forEach((p, j) => inner.push(textPart(Text, p, 'h' + j, j > 0)))
463    if (s.bar && withBar) inner.push(textBar(Text, s.bar))
464    if (s.bar) parts.slice(1).forEach((p, j) => inner.push(textPart(Text, p, 'b' + j, true)))
465    children.push(
466      <Box key={s.key} flexDirection="row">
467        {inner}
468      </Box>,
469    )
470  })
471  return (
472    <Box flexDirection="row" justifyContent="center" paddingX={1}>
473      {children}
474    </Box>
475  )
476}
477
478// The terminal's icon colour: the state's when it is not fine; blue context, green agents.
479function glyphInk(s: Segment) {
480  const tone = s.icon?.tone
481  if (tone && tone !== 'ok') return INK[tone]
482  return s.key === 'context' ? 'blue' : s.key === 'agents' ? 'success' : undefined
483}
484
485function textPart(Text: Els['Text'], p: Part, key: string, space: boolean) {
486  return (
487    <Text key={key} bold={p.strong} dimColor={p.dim && !p.tone} color={p.tone ? INK[p.tone] ?? (p.tone === 'ok' ? 'blue' : undefined) : undefined}>
488      {(space ? ' ' : '') + p.text}
489    </Text>
490  )
491}
492
493function textBar(Text: Els['Text'], bar: NonNullable<Segment['bar']>) {
494  // Any share above zero shows at least one cell.
495  const filled = bar.used > 0 ? Math.max(1, Math.round((bar.used / 100) * TEXT_BAR)) : 0
496  return (
497    <Text key="bar">
498      {' '}
499      <Text color={bar.tone === 'ok' ? 'blue' : INK[bar.tone]}>{'━'.repeat(filled)}</Text>
500      <Text dimColor>{'─'.repeat(TEXT_BAR - filled)}</Text>
501    </Text>
502  )
503}
504
505function segmentWidth(s: Segment, withDetail: boolean, withBar: boolean) {
506  const parts = s.parts.filter((p) => withDetail || !p.detail)
507  return (s.icon ? [...s.icon.glyph].length + 1 : 0) + parts.reduce((n, p) => n + [...p.text].length + 1, 0) + (s.bar && withBar ? TEXT_BAR + 1 : 0)
508}
509
510// ---------- Pictures ----------
511
512const BAR = { w: 40, h: 10 }
513
514// A rounded bar filled to the share used, with a tick where the clock stands in the window.
515function barSvg(used: number, mark: number | null, color: string) {
516  const { w, h } = BAR
517  const y = 2
518  const th = h - 4
519  const fill = Math.max(used > 0 ? th : 0, (used / 100) * w)
520  let body = `<rect x="0" y="${y}" width="${w}" height="${th}" rx="${th / 2}" fill="${TRACK}"/>`
521  if (fill > 0) body += `<rect x="0" y="${y}" width="${fill.toFixed(1)}" height="${th}" rx="${th / 2}" fill="${color}"/>`
522  if (mark !== null) {
523    const x = Math.min(w - 1, Math.max(1, (mark / 100) * w))
524    body += `<rect x="${(x - 0.75).toFixed(2)}" y="0" width="1.5" height="${h}" rx="0.75" fill="${MARK}"/>`
525  }
526  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
527}
528
529// A ring filled clockwise to the share of the window in use.
530function ringSvg(pct: number, color: string) {
531  const r = 5.5
532  const c = 2 * Math.PI * r
533  const len = (clamp(pct) / 100) * c
534  return (
535    `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">` +
536    `<circle cx="7" cy="7" r="${r}" fill="none" stroke="${TRACK}" stroke-width="2.4"/>` +
537    `<circle cx="7" cy="7" r="${r}" fill="none" stroke="${color}" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="${len.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 7 7)"/></svg>`
538  )
539}
540
541function boltSvg(color: string) {
542  return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path d="M8 1 2.5 8H7l-1 5 5.5-7H7z" fill="${color}" stroke="${color}" stroke-width="0.8" stroke-linejoin="round"/></svg>`
543}
544
545function dotSvg(color: string) {
546  return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" fill="${color}" fill-opacity="0.22"/><circle cx="7" cy="7" r="3" fill="${color}"/></svg>`
547}
548
549function ringGlyph(pct: number) {
550  return pct < 12.5 ? '○' : pct < 37.5 ? '◔' : pct < 62.5 ? '◑' : pct < 87.5 ? '◕' : '●'
551}
552
553// ---------- Formatting ----------
554
555function clamp(n: number) {
556  return Math.min(100, Math.max(0, Number.isFinite(n) ? n : 0))
557}
558
559// 1M, 1.2M, 281k, 9.8k, 950.
560export function tokens(n: number) {
561  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
562  if (n >= 100_000) return `${Math.round(n / 1000)}k`
563  if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
564  return String(Math.round(n))
565}
566
567// 8m, 2h 05m, 4d 4h.
568export function duration(ms: number) {
569  const m = Math.max(0, Math.round(ms / MIN))
570  if (m < 60) return `${m}m`
571  if (m < 1440) return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
572  return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
573}
574
575// $4.32 in the bar, $191 from a hundred; the card keeps the cents.
576export function money(usd: number, exact: boolean) {
577  if (usd < 0.01) return '< $0.01'
578  if (!exact && usd >= 100) return `$${Math.round(usd)}`
579  return `$${usd.toFixed(2)}`
580}
581
582function fmt(ms: number, opts: Intl.DateTimeFormatOptions) {
583  try {
584    return new Intl.DateTimeFormat('en-GB', { hourCycle: 'h23', ...opts }).format(ms)
585  } catch {
586    return new Date(ms).toISOString().slice(11, 16) + ' UTC'
587  }
588}
589
590const clock = (ms: number) => fmt(ms, { hour: '2-digit', minute: '2-digit' })
591const weekday = (ms: number) => fmt(ms, { weekday: 'short' })
592const dayTime = (ms: number) => fmt(ms, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
593
594function fit(text: string, n: number) {
595  const c = [...text]
596  return c.length > n ? c.slice(0, n - 1).join('') + '…' : text
597}
598
599// ---------- Job queue (optional: the queueScript option) ----------
600
601const QUEUE_PANE = 'job-queue'
602const QUEUE_EVERY = 5_000
603// A reading another session took this recently is used as it is.
604const QUEUE_FRESH = 4_000
605const QUEUE_STORE_KEY = 'queue-reading'
606
607const queueReading = atom({ plugin: 'usage-band', key: 'queue' } as const, { at: 0, snapshot: null, error: null } as QueueReading)
608const queueSections = atom({ plugin: 'usage-band', key: 'queueSections' } as const, {} as Record<string, boolean>)
609
610// Not drawn from: they start over on a reload, as session.start does.
611let queuePath = ''
612let queueName = 'Queue'
613let queueCores = 8
614let queueTimer: { cancel: () => void } | null = null
615let queueBusy = false
616
617// Every session runs the same timer, so the newest reading lives in the store and only a session that finds it
618// stale runs the script: one run per few seconds for the whole machine, not one per session.
619// True when the stored reading was fresh enough to use.
620async function adoptQueueReading($: $) {
621  const now = await $.clock.now()
622  const shared = (await $.store.get(QUEUE_STORE_KEY)) as QueueReading | undefined
623  if (!shared || !shared.snapshot) return false
624  await update($, queueReading, (prev) => (prev && prev.at >= shared.at ? prev : shared))
625  return now - shared.at < QUEUE_FRESH
626}
627
628async function refreshQueue($: $, force = false) {
629  if (!force && (await adoptQueueReading($))) return
630  if (queueBusy) return
631  queueBusy = true
632  try {
633    const ran = await $.process.run([queuePath, 'status'], { timeoutMs: 20_000 })
634    const at = await $.clock.now()
635    const next: QueueReading =
636      ran.exitCode === 0
637        ? { at, snapshot: parseStatus(ran.stdout), error: null }
638        : { at, snapshot: null, error: (ran.stderr || ran.stdout).trim().split('\n')[0] || `exit ${ran.exitCode}` }
639    await update($, queueReading, (prev) => (next.snapshot || !prev?.snapshot ? next : { ...prev, error: next.error }))
640    if (next.snapshot) await $.store.set(QUEUE_STORE_KEY, next)
641  } catch (err) {
642    const at = await $.clock.now()
643    await update($, queueReading, (prev) => ({ at, snapshot: prev?.snapshot ?? null, error: String((err as Error)?.message ?? err) }))
644  } finally {
645    queueBusy = false
646  }
647}
648
649async function queuePaneOpen($: $) {
650  return (await $.ui.panes()).some((p) => p.id === QUEUE_PANE)
651}
652
653async function toggleQueuePane($: $) {
654  if (await queuePaneOpen($)) await $.ui.close({ id: QUEUE_PANE })
655  else await $.ui.open({ id: QUEUE_PANE, title: `${queueName} queue` })
656}
657
658// The row above the band, or null while there is no reading yet.
659export async function queueRow($: $, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
660    const s = (await read($, queueReading))?.snapshot
661    if (!s) return null
662    const { Box, Text, Button } = $.ui.resolve(e)
663    const sum = summary(s)
664    const calm = sum.queue === 0 && !s.gateOn
665    return (
666      <Box key="queue-row" flexDirection="row" justifyContent="center" alignItems="center" columnGap={1} paddingX={1}>
667        <Button
668          key="queue-row-chip"
669          plain
670          dimColor={calm}
671          label={[`${calm ? '◇' : '◆'} ${queueName}  Mac ${sum.mac}`, ...sum.hosts.filter((x) => x.isOn).map((x) => `${x.name} ${x.count}`), `Queue ${sum.queue}`].join(' · ')}
672          onPress={() => toggleQueuePane($)}
673        />
674        {sum.hosts
675          .filter((x) => !x.isOn)
676          .map((x) => (
677            <Text key={`off-${x.name}`} color="error">
678              {x.name} off
679            </Text>
680          ))}
681        {sum.queue > 0 ? <Text color="warning">{sum.queue} waiting</Text> : null}
682        {s.gateOn ? <Text color="warning">gate on</Text> : null}
683        <Text dimColor>·</Text>
684        <Text color={memoryTone(s.pressure)} dimColor={!memoryTone(s.pressure)}>
685          Mem {s.memoryFree || '?'} free
686        </Text>
687        <Text color={loadTone(s.load, queueCores, s.loadLimit)} dimColor={!loadTone(s.load, queueCores, s.loadLimit)}>
688          Load {oneDecimal(s.load)}
689        </Text>
690      </Box>
691    )
692}
693
694// Called from the band's own session.start, since a plugin hooks an event once without a matcher.
695export async function startQueue($: $, path: string) {
696  queueTimer?.cancel()
697  const home = (await $.env.get('HOME')) ?? ''
698  queuePath = path.startsWith('~/') ? home + path.slice(1) : path
699  try {
700    const ran = await $.process.run(['sysctl', '-n', 'hw.ncpu'], { timeoutMs: 5_000 })
701    queueCores = Number(ran.stdout.trim()) || queueCores
702  } catch {
703    // Not macOS: the default stands.
704  }
705  await $.command.register({ name: namesOf(path).command, description: `Show or hide the ${queueName.toLowerCase()} queue pane` })
706  // The last reading any session took draws at once; a stale one is replaced in the background.
707  await adoptQueueReading($)
708  void refreshQueue($)
709  queueTimer = $.clock.every(QUEUE_EVERY, () => refreshQueue($))
710}
711
712export function stopQueue() {
713  queueTimer?.cancel()
714}
715
716// The queue's own command and pane, both matched to it.
717function registerQueue(on: On, path: string) {
718  const names = namesOf(path)
719  queueName = names.name
720
721  on('command.run', { command: names.command }, async ($) => {
722    const wasOpen = await queuePaneOpen($)
723    await toggleQueuePane($)
724    if (!wasOpen) await refreshQueue($, true)
725    return { text: wasOpen ? `${queueName} queue pane closed.` : `${queueName} queue pane opened.` }
726  })
727
728  on('ui.render', { component: 'Pane', requestId: QUEUE_PANE }, async ($, e) => {
729    const { Box, Text, Button } = $.ui.resolve(e)
730    const r = await read($, queueReading)
731    const s = r?.snapshot
732    const open = (await read($, queueSections)) ?? {}
733
734    if (!s) {
735      return (
736        <Box flexDirection="column">
737          <Text dimColor>{r?.error ? `${queueName} status failed: ${r.error}` : `Reading ${queueName.toLowerCase()} status…`}</Text>
738          <Button key="refresh" label="Refresh" onPress={() => refreshQueue($, true)} />
739        </Box>
740      )
741    }
742
743    const toggle = (id: string) => () => update($, queueSections, (o) => ({ ...(o ?? {}), [id]: !isOpen(o ?? {}, id, s) }))
744
745    // A section header: the arrow and name are the button, its count sits beside it.
746    const header = (id: string, title: string, count: string, tone?: 'warning' | 'error') => (
747      <Box key={`head-${id}`} flexDirection="row" gap={1}>
748        <Button key={`sec-${id}`} plain label={`${isOpen(open, id, s) ? '▾' : '▸'} ${title}`} onPress={toggle(id)} />
749        <Text color={tone} dimColor={!tone}>
750          {count}
751        </Text>
752      </Box>
753    )
754
755    const slotRow = (slot: QueueSlot) =>
756      slot.label ? (
757        <Box key={`${slot.host ?? 'mac'}-${slot.n}`} flexDirection="row" gap={1}>
758          <Text color="success">●</Text>
759          <Text wrap="truncate-end">{slot.label}</Text>
760          <Text dimColor>
761            {slot.kind} · {slot.mins} min
762          </Text>
763        </Box>
764      ) : null
765
766    const freeRow = (key: string, free: number) =>
767      free > 0 ? (
768        <Text key={key} dimColor>
769          ○ {free} free
770        </Text>
771      ) : null
772
773    const hostsByName = new Map(s.hosts.map((x) => [x.name, x]))
774    const remoteHosts = [...new Set(s.remote.map((x) => x.host ?? ''))]
775    const queued = s.queue as QueueWaiter[]
776    const sum = summary(s)
777    const stale = r && r.error ? ` · last read failed: ${r.error}` : ''
778
779    return (
780      <Box flexDirection="column" gap={1}>
781        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
782          <Text bold>Mac {sum.mac}</Text>
783          {sum.hosts.map((x) => (
784            <Text key={`top-${x.name}`} bold color={x.isOn ? undefined : 'error'}>
785              {x.name} {x.isOn ? x.count : 'off'}
786            </Text>
787          ))}
788          <Text bold color={queued.length ? 'warning' : undefined}>
789            Queue {queued.length}
790          </Text>
791        </Box>
792
793        <Box flexDirection="column">
794          {header('queue', 'Queue', queued.length ? `${queued.length} waiting` : 'empty', queued.length ? 'warning' : undefined)}
795          {isOpen(open, 'queue', s)
796            ? queued.map((w) => (
797                <Box key={`q-${w.pos}`} flexDirection="row" gap={1}>
798                  <Text color="warning">{w.pos}.</Text>
799                  <Text wrap="truncate-end">{w.label}</Text>
800                  <Text dimColor>
801                    {w.kind} · waiting {w.mins} min
802                  </Text>
803                </Box>
804              ))
805            : null}
806        </Box>
807
808        <Box flexDirection="column">
809          {header('mac', 'This Mac', sum.mac)}
810          {isOpen(open, 'mac', s) ? s.local.map(slotRow) : null}
811          {isOpen(open, 'mac', s) ? freeRow('mac-free', s.local.length - used(s.local)) : null}
812        </Box>
813
814        {remoteHosts.map((host) => {
815          const slots = s.remote.filter((x) => (x.host ?? '') === host)
816          const info = hostsByName.get(host)
817          const down = Boolean(info && info.state !== 'on')
818          const id = `host-${host}`
819          return (
820            <Box key={id} flexDirection="column">
821              {header(id, host, down ? `${info?.state}` : `${used(slots)}/${slots.length}`, down ? 'error' : undefined)}
822              {isOpen(open, id, s) ? (
823                <Box flexDirection="column">
824                  {info?.detail ? <Text dimColor>{hostSummary(info.detail)}</Text> : null}
825                  {down && info?.check ? <Text color="error">last check: {info.check}</Text> : null}
826                  {slots.map(slotRow)}
827                  {freeRow(`free-${host}`, slots.length - used(slots))}
828                </Box>
829              ) : null}
830            </Box>
831          )
832        })}
833
834        <Box flexDirection="column">
835          <Text dimColor>
836            Memory free {s.memoryFree || '?'} · pressure {s.pressure || '?'} · load {s.load || '?'} · chats {s.chats || '?'}
837          </Text>
838          <Text color={s.gateOn ? 'warning' : undefined} dimColor={!s.gateOn}>
839            Gate {s.gateOn ? `on: ${s.gate}` : 'off'}
840          </Text>
841          <Text dimColor>
842            Updated {queueClock(r!.at)}{stale}
843          </Text>
844        </Box>
845        <Button key="refresh" label="Refresh" onPress={() => refreshQueue($, true)} />
846      </Box>
847    )
848  })
849}
850
hooks/queue.tsx 122 lines
1// The job queue row: an optional line above the band, and a side pane, for a machine that runs heavy
2// jobs through a queue script. Set the `queueScript` option to the script's path; the row stays off
3// while it is empty. The script's `status` output is read as these lines:
4//
5//   slot 1: free                                    slot 2: <label>, run pid 123, 3 min, in <dir>
6//   remote slot 1 (<host>): free                    remote slot 2 (<host>): <label>, run pid 9, 0 min, in <dir>
7//   queue 1: <label>, run pid 77, waiting 4 min, in <dir>
8//   memory free: 79%   memory pressure: normal   load: 2.67 2.35 2.13   chats working (last 2 min): 2
9//   gate: off (load limit 40, ...)                  remote (<host>, 3 slots): on, last check: ok (1 min ago)
10//     <host>: memory 3 of 9 GB used, ..., load 1.61 0.44 0.15, ...
11//
12// The row (drawn by register.tsx):  ◇ Heavy  Mac 0/2 · <host> 1/3 · Queue 0 · Mem 79% free  Load 2.7   (click: the pane)
13// Its label and its slash command come from the script's file name (heavy.sh: "Heavy", /heavy).
14
15import type { QueueHost, QueueSnapshot, QueueSlot } from '../types'
16
17// heavy.sh -> { name: 'Heavy', command: 'heavy' }
18export function namesOf(path: string) {
19  const base = (path.split('/').pop() ?? '').replace(/\.[^.]*$/, '')
20  const command = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'queue'
21  return { name: command.charAt(0).toUpperCase() + command.slice(1), command }
22}
23
24const mins = (s: string) => Number(s) || 0
25
26export function parseStatus(text: string): QueueSnapshot {
27  const snap: QueueSnapshot = { local: [], remote: [], queue: [], memoryFree: '', pressure: '', load: '', loadLimit: null, chats: '', gate: '', gateOn: false, hosts: [] }
28  let host: QueueHost | null = null
29  for (const raw of text.split('\n')) {
30    const line = raw.trimEnd()
31    // Each line's groups as strings, '' where a group did not take part.
32    const match = (re: RegExp) => {
33      const m = line.match(re)
34      return m ? Array.from(m, (x) => x ?? '') : null
35    }
36    let m: string[] | null
37    if ((m = match(/^slot (\d+): free$/))) {
38      snap.local.push({ n: Number(m[1]), host: null, label: null, kind: '', mins: 0, cwd: '' })
39    } else if ((m = match(/^slot (\d+): (.+?), (hold until \S+|run pid \d+), (\d+) min, in (.*)$/))) {
40      const [, n = '', label = '', kind = '', min = '', cwd = ''] = m
41      snap.local.push({ n: Number(n), host: null, label, kind: kind.startsWith('hold') ? kind : 'run', mins: mins(min), cwd })
42    } else if ((m = match(/^remote slot (\d+) \(([^)]+)\): free$/))) {
43      snap.remote.push({ n: Number(m[1]), host: m[2] ?? '', label: null, kind: '', mins: 0, cwd: '' })
44    } else if ((m = match(/^remote slot (\d+) \(([^)]+)\): (.+?), run pid \d+, (\d+) min, in (.*)$/))) {
45      const [, n = '', where = '', label = '', min = '', cwd = ''] = m
46      snap.remote.push({ n: Number(n), host: where, label, kind: 'run', mins: mins(min), cwd })
47    } else if ((m = match(/^queue (\d+): (.+?), (\S+) pid \d+, waiting (\d+) min, in (.*)$/))) {
48      const [, pos = '', label = '', kind = '', min = '', cwd = ''] = m
49      snap.queue.push({ pos: Number(pos), label, kind, mins: mins(min), cwd })
50    } else if ((m = match(/^memory free: (.*)$/))) {
51      snap.memoryFree = (m[1] ?? '').trim()
52    } else if ((m = match(/^memory pressure: (.*)$/))) {
53      snap.pressure = (m[1] ?? '').trim()
54    } else if ((m = match(/^load: (.*)$/))) {
55      snap.load = (m[1] ?? '').trim().split(/\s+/)[0] ?? ''
56    } else if ((m = match(/^chats working \(last 2 min\): (.*)$/))) {
57      snap.chats = (m[1] ?? '').trim()
58    } else if ((m = match(/^gate: (on|off)(?:, | )?(.*)$/))) {
59      snap.gateOn = m[1] === 'on'
60      snap.gate = snap.gateOn ? (m[2] ?? '') : 'off'
61      const limit = (m[2] ?? '').match(/load limit (\d+(?:\.\d+)?)/)
62      if (limit) snap.loadLimit = Number(limit[1])
63    } else if ((m = match(/^remote \(([^,]+), (\d+) slots?\): (\w+)(?:, last check: (.*))?$/))) {
64      const [, name = '', slots = '', state = '', check = ''] = m
65      host = { name, slots: Number(slots), state, check, detail: '' }
66      snap.hosts.push(host)
67    } else if (host && (m = match(/^\s+([^:]+): (.*)$/)) && m[1] === host.name) {
68      host.detail = m[2] ?? ''
69    }
70  }
71  return snap
72}
73
74// "memory 3 of 9 GB used, agents account ..., load 1.61 0.44 0.15, 1 jobs, ..." to "mem 3/9 GB · load 1.61".
75export function hostSummary(detail: string): string {
76  const mem = detail.match(/memory (\d+) of (\d+) GB used/)
77  const load = detail.match(/load ([\d.]+)/)
78  return [mem ? `mem ${mem[1]}/${mem[2]} GB` : '', load ? `load ${load[1]}` : ''].filter(Boolean).join(' · ')
79}
80
81export const used = (slots: QueueSlot[]) => slots.filter((s) => s.label).length
82
83// One count per machine, in the order the script lists its slots. A host it reports as anything
84// but on is marked off: its slots take no jobs.
85export function summary(s: QueueSnapshot) {
86  const states = new Map(s.hosts.map((x) => [x.name, x.state]))
87  const names = [...new Set(s.remote.map((x) => x.host ?? ''))]
88  const hosts = names.map((name) => {
89    const slots = s.remote.filter((x) => (x.host ?? '') === name)
90    return { name, count: `${used(slots)}/${slots.length}`, isOn: (states.get(name) ?? 'on') === 'on' }
91  })
92  return { mac: `${used(s.local)}/${s.local.length}`, hosts, queue: s.queue.length }
93}
94
95// Memory reads by macOS memory pressure, not the free figure: free memory stays low while the cache is full.
96export function memoryTone(pressure: string): 'warning' | 'error' | undefined {
97  return pressure === 'critical' ? 'error' : pressure === 'warn' ? 'warning' : undefined
98}
99
100// Amber once the load passes the core count, red at the script's own load limit (four per core when
101// it does not say).
102export function loadTone(load: string, cores: number, limit: number | null): 'warning' | 'error' | undefined {
103  const n = Number(load)
104  if (load === '' || !Number.isFinite(n)) return undefined
105  return n >= (limit ?? cores * 4) ? 'error' : n >= cores ? 'warning' : undefined
106}
107
108export const oneDecimal = (load: string) => (Number.isFinite(Number(load)) && load !== '' ? Number(load).toFixed(1) : '?')
109
110// A section is open when the person opened it; until then only a non-empty queue starts open.
111export function isOpen(open: Record<string, boolean>, id: string, s: QueueSnapshot) {
112  return open[id] ?? (id === 'queue' && s.queue.length > 0)
113}
114
115export function clock(ms: number) {
116  try {
117    return new Intl.DateTimeFormat('en-GB', { hour: 'numeric', minute: '2-digit', second: '2-digit', hourCycle: 'h12' }).format(ms)
118  } catch {
119    return new Date(ms).toISOString().slice(11, 19) + ' UTC'
120  }
121}
122
types/index.d.ts 66 lines
1/** One rate-limit window, as the engine reports it. */
2export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
3
4/** The live context, measured against the window auto-compaction works to. */
5export type Context = {
6  /** Input tokens the last main-loop response was answered over. */
7  tokens: number
8  /** The model's own context window. */
9  modelWindow: number
10  /** The window the band measures against: the auto-compact window when one is set. */
11  window: number
12  /** Where `window` came from: the engine's breakdown source, `env`, or `model`. */
13  source: string
14  /** Token count at which auto-compaction runs; null when it is off or unknown. */
15  threshold: number | null
16  /** Tokens the last prompt added; null before one finished. */
17  delta: number | null
18}
19
20/** The last main-loop request's prompt, as the cache served it. */
21export type CacheHit = { at: number; read: number; write: number; fresh: number }
22
23export type RunningAgent = { id: string; type: string; description: string }
24
25export type View = {
26  context: Context | null
27  /** The newest rate-limit reading, from this or another session. */
28  limits: { at: number; list: RateLimit[] }
29  cost: number | null
30  lastPromptCost: number | null
31  cache: CacheHit | null
32  running: RunningAgent[]
33  /** Ids of every agent seen this session. */
34  seen: string[]
35}
36
37/** One job slot of the queue script, on this machine (`host` null) or a remote one. */
38export type QueueSlot = { n: number; host: string | null; label: string | null; kind: string; mins: number; cwd: string }
39/** A job waiting for a slot, in the order the queue serves them. */
40export type QueueWaiter = { pos: number; label: string; kind: string; mins: number; cwd: string }
41/** A remote machine the script sends jobs to: whether it is on, and its own memory and load line. */
42export type QueueHost = { name: string; slots: number; state: string; check: string; detail: string }
43/** One `status` reading of the queue script. */
44export type QueueSnapshot = {
45  local: QueueSlot[]
46  remote: QueueSlot[]
47  queue: QueueWaiter[]
48  memoryFree: string
49  pressure: string
50  /** The one-minute load average, as printed. */
51  load: string
52  /** The load at which the script runs one job at a time, when it says. */
53  loadLimit: number | null
54  chats: string
55  gate: string
56  gateOn: boolean
57  hosts: QueueHost[]
58}
59export type QueueReading = { at: number; snapshot: QueueSnapshot | null; error: string | null }
60
61declare module 'claude-code' {
62  interface PluginState {
63    'usage-band': { view: View; queue: QueueReading; queueSections: Record<string, boolean> }
64  }
65}
66