SLOPSHOPPER

Factory HUD

A status entry with the time, context fill, plan usage, the agent factory's counts and the tool versions, drawn on every surface a session shows on (terminal…

newcommandtoaststatusprocesstimer
★ 2v0.40.0no licenseupdated 2026-10-16cbundy/dev-system/plugins/factory-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory-hud
› fix the failing auth test and add an audit log call ╭─────────────────────────────────╮ │ factory-hud │ ⏺ Read(src/auth.ts) │ HUD hidden in this conversation │ ⎿ 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 › /hud ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

factory-hud

A Claude Code mod that keeps a one-line readout on screen:

20:41  ctx 42%  5h 73%  7d 53%  | 6 live: design 2 dev 1 pipe 2 merge 1 | queued 2  | cc 2.1.296 flow 0.27.0 img 2.27.0 nm 1.84.0
  • the time (in the workspace's TZ), the context window fill, and the plan's 5-hour and 7-day usage when Claude Code supplies those figures;
  • for an orchestrator, the factory's counts for the session's repo, by callum-flow-factory-state stage: live is every active stage, design designing, dev implementing, pipe in pipeline plus parked, merge ready to merge. Refreshed every minute; counts at least five minutes old show their age;
  • the versions of Claude Code, callum-flow, the base image and no-mistakes.

On usage measurements, it shows a toast once per conversation for each threshold: context at 85% or more, 5-hour usage at 90% or more, and 7-day usage at 90% or more.

It only draws, apart from one local file: the session's context window size, which the worker heartbeat pushes to the fleet (sessions in factory.workers). Nothing it shows reaches the conversation, and it never calls a model.

Unlike dev-statusline, which only the terminal draws, a mod is drawn by every client the session shows on: the terminal, the desktop app, VS Code and the Claude mobile app through Remote Control.

Turn it on

It ships in the callum marketplace that every dev-system workspace already has, from the first release that includes it. It is opt-in: install it where you want it. If your workspace is pinned to an older release, first follow the consumer update flow.

An orchestrator workspace (Coder). Install it once, at user scope, from the workspace's own Claude session: attach to it (coder ssh <workspace> -- tmux attach -t claude) and type

/plugin install factory-hud@callum

and pick the user scope. It runs from then on, in that session and every later one. From a shell in the workspace instead: claude plugin install factory-hud@callum --scope user, then /reload-plugins in the session (from the terminal, the phone or claude.ai). The counts show on their own there, since the workspace runs with DEV_ROLE=orchestrator.

Watching it from your phone or the desktop app. Nothing to install on that side: the mod runs where the session runs (the workspace), and the app you watch from draws its readout. Open the session in the Claude app or claude.ai as usual.

Local sessions on your own machine (terminal or the desktop app's Code tab). If the callum marketplace is not registered there, first run claude plugin marketplace add cbundy/dev-system#<release-tag>, replacing <release-tag> with a published release tag containing factory-hud. For an existing older pin, use the consumer update flow linked above. Then install from a terminal with claude plugin install factory-hud@callum --scope user and start a new local session (or /reload-plugins in an existing terminal session). The desktop app's local sessions load it too. (/plugin does not work inside the desktop app.)

Use it

  • /hud hides it in the current conversation, and shows it again. It answers with a toast, so the conversation stays untouched.
  • The factory option picks when the counts show: auto (the default: when DEV_ROLE is orchestrator), on or off. Change it in /config, or from the phone with /config factory-hud.factory=on.
  • Uninstall with claude plugin uninstall factory-hud@callum.

How it works

  • The context and usage figures come from Claude Code itself ($.session.usage(), then the session.measure event each time they move), so they cost nothing.
  • The counts come from callum-flow-factory-state --repo <owner/name> --github off --format json, using the session repo's GitHub origin, run once a minute in the background, so the line never waits on the database. A failed read keeps the last counts.
  • The versions come from dev-version's snapshot (DEV_VERSIONS_FILE, default /tmp/dev-versions.json); outside a dev-system image it shows Claude Code's alone.

Develop it

claude --plugin-dir plugins/factory-hud loads this checkout's copy for one session and reloads it as files change. npm test validates it and runs plugins/factory-hud/tests/*.test.ts through claude plugin validate and claude plugin test when claude is on PATH. Without it, the other suites still run and the summary notes that mods were skipped. CI installs Claude Code and sets TEST_REQUIRE_CLAUDE=1, so a missing claude fails the gate.

Source 3 files
hooks/register.ts 154 lines
1// factory-hud: the session readout as a mod, so it draws wherever the session does
2// (terminal, desktop, VS Code, mobile through Remote Control). It only ever draws: the
3// status entry and the toasts never reach the conversation, and no model is called.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import { counts, dueAlerts, repoSlug, statusText, versionsText } from './format'
8import type { Readout } from './format'
9
10// Hidden in this conversation (/hud): kept across a reload, not into a new conversation.
11const isHidden = atom({ plugin: 'factory-hud', key: 'isHidden' } as const, false)
12// The toasts already shown in this conversation, so each shows once.
13const alerted = atom({ plugin: 'factory-hud', key: 'alerted' } as const, [] as string[])
14
15const CLOCK_EVERY_MS = 30_000
16const FACTORY_EVERY_MS = 60_000
17
18type Hud = Readout & { repo?: string; zone?: string }
19
20// The module's own state; a reload starts it over and session.start fills it again.
21const hud: Hud = { limits: [], isFactoryShown: false, isFactoryLoading: false, versions: '' }
22
23async function draw($: EngineInterface) {
24  if (await read($, isHidden)) {
25    $.ui.status(undefined)
26    return
27  }
28  $.ui.status(statusText(hud, await $.clock.now(), hud.zone))
29}
30
31async function alert($: EngineInterface) {
32  const due = dueAlerts(hud.context, hud.limits, await read($, alerted))
33  if (due.length === 0) return
34  await update($, alerted, shown => [...shown, ...due.map(([key]) => key)])
35  for (const [, text] of due) $.ui.toast(text, { timeoutMs: 8000 })
36}
37
38// output: a command's stdout, or undefined when it cannot run or fails.
39async function output($: EngineInterface, argv: string[], cwd: string) {
40  try {
41    const { exitCode, stdout } = await $.process.run(argv, { cwd, timeoutMs: 30_000 })
42    return exitCode === 0 ? stdout : undefined
43  } catch {
44    return undefined
45  }
46}
47
48async function refreshFactory($: EngineInterface, cwd: string) {
49  if (!hud.repo) return
50  const json = await output(
51    $,
52    ['callum-flow-factory-state', '--repo', hud.repo, '--github', 'off', '--format', 'json'],
53    cwd,
54  )
55  // A failed read keeps the last counts, which then show their age.
56  const next = json === undefined ? undefined : counts(json, await $.clock.now())
57  if (next) hud.factory = next
58  hud.isFactoryLoading = false
59  await draw($)
60}
61
62// recordWindow: the session's context window size, one file per session id, which the
63// worker heartbeat pushes to the fleet with the session's liveness. Written only on a
64// change; a failed write is dropped.
65let recorded = ''
66async function recordWindow($: EngineInterface) {
67  const window = hud.context?.window
68  if (!window) return
69  try {
70    const id = await $.session.id()
71    if (recorded === `${id}=${window}`) return
72    const dir = (await $.env.get('DEV_CONTEXT_WINDOW_DIR')) || '/tmp/dev-context-window'
73    await $.fs.write(`${dir}/${id}`, `${window}\n`)
74    recorded = `${id}=${window}`
75  } catch {
76    // a missing window stays a gap in the fleet data
77  }
78}
79
80async function snapshot($: EngineInterface) {
81  try {
82    return await $.fs.read((await $.env.get('DEV_VERSIONS_FILE')) || '/tmp/dev-versions.json')
83  } catch {
84    return undefined
85  }
86}
87
88export const register: Register = (on, options) => {
89  on('session.start', async ($, e, next) => {
90    await $.command.register({
91      name: 'hud',
92      description: 'Hide or show the factory HUD in this conversation',
93    })
94    const role = await $.env.get('DEV_ROLE')
95    // A new session starts from nothing: no counts or repo carried over from the last.
96    hud.factory = undefined
97    hud.repo = undefined
98    hud.isFactoryLoading = false
99    hud.isFactoryShown = options.factory === 'on' || (options.factory !== 'off' && role === 'orchestrator')
100    hud.zone = (await $.env.get('TZ')) || undefined
101    const usage = await $.session.usage()
102    hud.context = usage.context
103    hud.limits = usage.rateLimits
104    hud.versions = versionsText((await $.session.version()).version, await snapshot($))
105    await recordWindow($)
106    if (hud.isFactoryShown) {
107      const url = await output($, ['git', 'remote', 'get-url', 'origin'], e.cwd)
108      hud.repo = url === undefined ? undefined : repoSlug(url)
109      hud.isFactoryLoading = hud.repo !== undefined
110    }
111    await draw($)
112    // Background work: a failure (the session ending under it) is dropped, never surfaced.
113    $.clock.every(CLOCK_EVERY_MS, () => draw($).catch(() => undefined))
114    if (hud.isFactoryShown && hud.repo) {
115      void refreshFactory($, e.cwd).catch(() => undefined)
116      $.clock.every(FACTORY_EVERY_MS, () => refreshFactory($, e.cwd).catch(() => undefined))
117    }
118
119    return next(e)
120  })
121
122  on('session.measure', async ($, e, next) => {
123    hud.context = e.context
124    hud.limits = e.rateLimits
125    await draw($)
126    await alert($)
127    await recordWindow($)
128
129    return next(e)
130  })
131
132  // A /clear or /resume moves to another conversation in the same process, with no
133  // session.start: the last one's context figure, toasts and /hud do not carry over.
134  on('session.end', async ($, e, next) => {
135    if (e.reason === 'clear' || e.reason === 'resume') {
136      hud.context = undefined
137      await update($, isHidden, () => false)
138      await update($, alerted, () => [])
139      await draw($)
140    }
141
142    return next(e)
143  })
144
145  // Answered with a toast, not command output, so nothing is added to the conversation.
146  on('command.run', { command: 'hud' }, async $ => {
147    const isNowHidden = await update($, isHidden, was => !was)
148    await draw($)
149    $.ui.toast(isNowHidden ? 'HUD hidden in this conversation' : 'HUD shown')
150
151    return {}
152  })
153}
154
hooks/format.ts 131 lines
1// The pure half of factory-hud: what the status entry says and which toasts are due.
2// No $ here, so the tests drive it directly.
3import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
4
5export type Factory = {
6  live: number
7  design: number
8  dev: number
9  pipe: number
10  merge: number
11  queued: number
12  at: number
13}
14
15export type Readout = {
16  context?: SessionContextUsage
17  limits: readonly SessionRateLimit[]
18  factory?: Factory
19  isFactoryShown: boolean
20  isFactoryLoading: boolean
21  versions: string
22}
23
24// The stages callum-flow-factory-state reports (docs/factory-state.md) that count as live.
25const LIVE = ['designing', 'implementing', 'in pipeline', 'parked', 'ready to merge']
26const WINDOWS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
27const STALE_MS = 5 * 60_000
28
29// counts: the factory-state JSON document as the counts the entry shows, or undefined
30// when it is not one.
31export function counts(json: string, at: number): Factory | undefined {
32  let doc: { repos?: { issues?: { stage?: unknown }[] }[] }
33  try {
34    doc = JSON.parse(json)
35  } catch {
36    return undefined
37  }
38  if (!Array.isArray(doc?.repos)) return undefined
39  const stages = doc.repos.flatMap(repo => repo.issues ?? []).map(issue => String(issue.stage))
40  const n = (names: string[]) => stages.filter(stage => names.includes(stage)).length
41  return {
42    live: n(LIVE),
43    design: n(['designing']),
44    dev: n(['implementing']),
45    pipe: n(['in pipeline', 'parked']),
46    merge: n(['ready to merge']),
47    queued: n(['queued']),
48    at,
49  }
50}
51
52// repoSlug: owner/name from a GitHub origin URL (https or ssh).
53export function repoSlug(url: string): string | undefined {
54  return url.trim().match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1]
55}
56
57// clock: HH:MM in the zone, or UTC when the zone is unknown.
58export function clock(ms: number, zone: string | undefined): string {
59  try {
60    return new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: zone })
61  } catch {
62    return new Date(ms).toISOString().slice(11, 16)
63  }
64}
65
66// statusText: the one line the status entry shows.
67export function statusText(r: Readout, now: number, zone: string | undefined): string {
68  const parts = [clock(now, zone)]
69  if (r.context?.percent !== undefined) parts.push(`ctx ${Math.ceil(r.context.percent)}%`)
70  for (const limit of r.limits) {
71    const name = WINDOWS[limit.kind]
72    if (name) parts.push(`${name} ${Math.ceil(limit.percentUsed)}%`)
73  }
74  if (r.isFactoryShown && r.factory) {
75    const f = r.factory
76    const age = now - f.at
77    parts.push(
78      `| ${f.live} live: design ${f.design} dev ${f.dev} pipe ${f.pipe}` +
79        (f.merge > 0 ? ` merge ${f.merge}` : '') +
80        ` | queued ${f.queued}` +
81        (age >= STALE_MS ? ` (${Math.floor(age / 60_000)}m old)` : ''),
82    )
83  } else if (r.isFactoryShown && r.isFactoryLoading) {
84    parts.push('| factory loading')
85  }
86  if (r.versions) parts.push(`| ${r.versions}`)
87  return parts.join('  ')
88}
89
90// versionsText: Claude Code's version, then callum-flow, the base image and no-mistakes
91// from dev-version's snapshot when there is one.
92export function versionsText(claude: string, snapshot: string | undefined): string {
93  const parts = [`cc ${claude.split(' ')[0]}`]
94  if (snapshot) {
95    try {
96      const v = JSON.parse(snapshot) as {
97        plugin_version?: string | null
98        base_image?: string | null
99        components?: { name?: string; running?: string | null }[]
100      }
101      if (v.plugin_version) parts.push(`flow ${v.plugin_version}`)
102      if (v.base_image) parts.push(`img ${v.base_image}`)
103      const nm = v.components?.find(c => c.name === 'no-mistakes')?.running
104      if (nm) parts.push(`nm ${nm}`)
105    } catch {
106      // A snapshot that is not JSON adds nothing.
107    }
108  }
109  return parts.join(' ')
110}
111
112// dueAlerts: the toasts due now and not yet shown, as [key, text]: context at 85%, a usage
113// window at 90%.
114export function dueAlerts(
115  context: SessionContextUsage | undefined,
116  limits: readonly SessionRateLimit[],
117  shown: readonly string[],
118): [string, string][] {
119  const due: [string, string][] = []
120  if ((context?.percent ?? 0) >= 85) {
121    due.push(['context-85', `Context is ${Math.ceil(context?.percent ?? 0)}% full`])
122  }
123  for (const limit of limits) {
124    const name = WINDOWS[limit.kind]
125    if (name && limit.percentUsed >= 90) {
126      due.push([`${limit.kind}-90`, `${name} usage is at ${Math.ceil(limit.percentUsed)}%`])
127    }
128  }
129  return due.filter(([key]) => !shown.includes(key))
130}
131
types/index.d.ts 10 lines
1// The alerts the HUD can raise, by key: context at 85%, a usage window at 90%.
2export type AlertKey = string
3
4declare module 'claude-code' {
5  interface PluginState {
6    // isHidden: the HUD is hidden in this conversation (/hud). alerted: the toasts already shown.
7    'factory-hud': { isHidden: boolean; alerted: AlertKey[] }
8  }
9}
10