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…

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
TZ), the context window fill, and the plan's 5-hour and 7-day usage when Claude Code supplies those figures;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;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.
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.)
/hud hides it in the current conversation, and shows it again. It answers with a toast, so the conversation stays untouched.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.claude plugin uninstall factory-hud@callum.$.session.usage(), then the session.measure event each time they move), so they cost nothing.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.dev-version's snapshot (DEV_VERSIONS_FILE, default /tmp/dev-versions.json); outside a dev-system image it shows Claude Code's alone.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.
hooks/register.ts 154 lines1// 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}
154hooks/format.ts 131 lines1// 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}
131types/index.d.ts 10 lines1// 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