SLOPSHOPPER

omni-hud

The Omni Loop's band above the prompt: what this session is on, its stage and its links, from omni now.

newbandguardcommandprocesstimer
v0.0.284no licenseupdated 2026-10-09vertuoza/vertuo-omni-loop/kit/plugin-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omni-hud
› 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 › /omni-hud ⎿ omni-hud: The Omni band is off. Type /omni-hud to turn it on again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Omni Loop

Omni Loop — OmniMan and the fleets of beaver, octopod and picsou, flying to save a planet

Omni Loop turns product plans into delivered software across Vertuoza's engineering repositories. A PM writes a PRD. The PRD becomes a plan of waves and slices, and coding agents deliver it. Anyone with a coding-agent slot can lend it to a PRD they do not own.

On top of that delivery sits a game. Each PRD is a planet the organisation terraforms together. Unanswered questions, stuck slices and shipped bugs are Entropy, and they cost the owning team points until someone closes them. The game only reads the delivery layer and can be removed without touching it (game/, .github/workflows/game.yml).

Repository layout

A pnpm workspace:

PathWhat
kit/the delivery kit (omni CLI)
game/the game layer: projector, ledger, economy, banner, rankings
packages/design@omni/design: the Omni Loop design system — the colour tokens (tokens.css), the fonts (fonts.css), the 16-bit crest logo, the OMNI_LOOP brand, hand-placed pixel sprites and the planet renderer (README)
packages/galaxy@omni/galaxy: folds ledger events into the galaxy view; the demo world
apps/galaxy@omni/galaxy-app: the OMNI LOOP arcade, a Next.js app for Vercel
supabase/the game's database and source of truth: migrations, access checks, config, demo seed

Getting started

Prerequisites

  • Node 22 or later, and pnpm
  • The GitHub CLI, logged in: gh auth status

Install and test

pnpm install
pnpm test

The tests run entirely on fixtures and never call GitHub.

Environment variables

The kit, the game and the scripts read their environment once, in kit/lib/env/read.ts, as groups: each one set whole, or left unset and its feature off. A group half set or a malformed value stops the command with one line naming the variables, never a value (ADR-0057). Every one is optional:

<!-- omni:env-variables -->

  • OPENROUTER_API_KEY, and optionally OPENROUTER_MODEL: the model omni harvest asks where each decision belongs.
  • PROOF_URL and PROOF_STORAGE_STATE: the address /omni:prove films and the file omni proof session writes its sign-in to.
  • OMNI_LOOP_WORKSPACE: the workspace a game command plays for when --workspace names none.
  • SUPABASE_URL (or NEXT_PUBLIC_SUPABASE_URL) and SUPABASE_SERVICE_ROLE_KEY: the game's database, for the game:* commands, personas-import and schemas:verify --production.

<!-- /omni:env-variables -->

The app and the arcade each list their own: apps/omni-app/README.md, apps/galaxy/README.md.

Open the galaxy

pnpm galaxy:dev            # http://localhost:3000 — the arcade, on the demo galaxy

With a local Supabase, Vercel deployment and the artifact build: apps/galaxy/README.md.

Describe your repositories and fleets

Everything the game knows lives in Supabase (apps/galaxy/README.md), and belongs to a workspace: Vertuoza is the workspace vertuoza. Repositories are grouped into sectors and the fleets are rows in teams, both of a workspace, and both changed by a migration in supabase/migrations/:

insert into public.sectors (workspace_id, name, repos)
select id, 'core-belt', array['vertuo-core', 'vertuo-api'] from public.workspaces where slug = 'vertuoza';
insert into public.teams (workspace_id, name, label, color, motto, mascot, home, sort)
select id, 'pirates', 'PIRATES', '#2fc6a4', 'Takes the zones nobody claims.', 'pirate', 'core-belt', 50
  from public.workspaces where slug = 'vertuoza';
update public.teams set retired_at = now()                                    -- retire, never delete
 where name = 'invincible-team' and workspace_id = (select id from public.workspaces where slug = 'vertuoza');

The fleets today: BEAVER, OCTOPOD, PICSOU, C.I.A. and PIRATES. People join one in the arcade: they sign in with their GitHub account, which makes them members of the workspace of every GitHub org of theirs that has the Omni App installed (vertuoza for the vertuoza org), pick a fleet, enter a name and build a hero, and from then on their pull requests score for that fleet.

Run it by hand

With SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY set (locally: npx supabase status). Each command reads and writes one workspace, named by --workspace <slug> or, failing that, by OMNI_LOOP_WORKSPACE; there is no default:

pnpm game:banner 2332 --workspace vertuoza      # one planet's banner, read live from GitHub
pnpm game:project --workspace vertuoza          # snapshot the workspace's GitHub and append new events to its ledger
pnpm game:score --workspace vertuoza            # fold its ledger into this month's season
pnpm game:export backup/ --workspace vertuoza   # its row, ledger, sectors, fleets and players as JSONL

game:project writes permanent history. Run it against production only once the sectors hold the real repositories.

Switch on the scheduled workflow

The workflow polls every 15 minutes and posts rankings every Monday. It does nothing until it is switched on. The token, rankings issue and GAME_ENABLED steps are described in game/README.md › Setup.

Source 3 files
hooks/register.tsx 93 lines
1// The omni-hud mod (PRD 1208's spec, "The band"): a band above the prompt drawn from
2// `omni now --json`, and the slash command that turns it off and on. It only draws: it reads
3// nothing but `omni now`'s answer, sends nothing anywhere and changes no file.
4//
5// - **When it asks.** `node <project root>/.omni-loop/bin/omni.mjs now --json --session <id>`, at
6//   the session's start, after a tool call (at most once every 10 seconds) and every 30 seconds.
7//   The latest answer is held in `$.state`, so the band redraws when it changes. While the answer
8//   holds an approval wait (PRD 1322's s5), it asks again every 5 seconds, so the approved line shows
9//   within seconds, and once more when its toast ends, so the highlight lasts 10 seconds.
10// - **Hidden** while the session is on nothing, while the command fails or prints no answer of
11//   `omni now`'s shape, while a survey holds the band, and while the person turned it off.
12// - **The command** turns the band off, or on again, for this person across sessions (`$.store`).
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16import type { HudNow } from '../types/index.d.ts'
17import { bandRows, parseNow, waitAgainIn } from './band.ts'
18
19const OFF_KEY = 'isOff'
20const EVERY_MS = 30_000
21const AFTER_TOOL_MS = 10_000
22
23const now = atom({ plugin: 'omni-hud', key: 'now' } as const, null)
24const isOff = atom({ plugin: 'omni-hud', key: 'isOff' } as const, false)
25
26/** When the band last asked `omni now`, in `$.clock.now()`'s milliseconds. */
27let askedAt = Number.NEGATIVE_INFINITY
28/** The next ask an approval wait asked for, cancelled by any ask before it. */
29let waitAgain: Timer | null = null
30
31/** Asks `omni now --json` and holds its answer, `null` when it failed or had another shape. */
32async function ask($: EngineInterface): Promise<void> {
33  askedAt = await $.clock.now()
34  let answer: HudNow | null = null
35  try {
36    const root = await $.session.root()
37    const session = await $.session.id()
38    const run = await $.process.run(['node', `${root}/.omni-loop/bin/omni.mjs`, 'now', '--json', '--session', session], { cwd: root })
39    answer = run.exitCode === 0 ? parseNow(run.stdout) : null
40  } catch {
41    answer = null
42  }
43  await update($, now, () => answer)
44  waitAgain?.cancel()
45  const again = waitAgainIn(answer, await $.clock.now())
46  waitAgain = again === null ? null : $.clock.after(again, () => void ask($))
47}
48
49export const register: Register = on => {
50  on('session.start', async ($, e, next) => {
51    await $.command.register({ name: 'omni-hud', description: 'Turn the Omni band above the prompt off, or on again' })
52    const stored = await $.store.get(OFF_KEY)
53    await update($, isOff, () => stored === true)
54    await ask($)
55    $.clock.every(EVERY_MS, () => void ask($))
56    return next(e)
57  })
58
59  on('tool.call', async ($, e, next) => {
60    const result = await next(e)
61    if ((await $.clock.now()) - askedAt >= AFTER_TOOL_MS) $.clock.after(0, () => void ask($))
62    return result
63  }).catch(($, e, next) => next(e))
64
65  on('command.run', { command: 'omni-hud' }, async $ => {
66    const off = !(await read($, isOff))
67    await $.store.set(OFF_KEY, off)
68    await update($, isOff, () => off)
69    return { text: off ? 'The Omni band is off. Type /omni-hud to turn it on again.' : 'The Omni band is on.' }
70  })
71
72  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
73    if (e.props.hasSurvey || (await read($, isOff))) return next(e)
74    const rows = bandRows(await read($, now))
75    if (rows.length === 0) return next(e)
76    const { Box, Link, Text } = $.ui.resolve(e)
77    return (
78      <Box key="omni-hud" flexDirection="column">
79        {rows.map(row => (
80          <Box key={row.key}>
81            <Text wrap="truncate-end" {...(row.toast ? { inverse: true, bold: true } : {})}>
82              {row.parts.flatMap((part, index) => [
83                ...(index > 0 ? [<Text dimColor> · </Text>] : []),
84                'href' in part ? <Link href={part.href} label={part.label} /> : <Text>{part.text}</Text>,
85              ])}
86            </Text>
87          </Box>
88        ))}
89      </Box>
90    )
91  })
92}
93
types/index.d.ts 26 lines
1// The omni-hud mod's state contract: what its band draws from, held by the host for the session.
2
3/** A link of what the session is on. */
4export type HudLink = { label: string; href: string };
5
6/** A slice being built, or stuck. */
7export type HudSlice = { id: string; name: string | null; state: string };
8
9/** What the session works on: a PRD, a bug fix or a visual fix. */
10export type HudWork = { kind: string; number: number; topic: string; stage: string | null; slices: HudSlice[]; links: HudLink[] };
11
12/** The loop, or the roadmap it drives, above the work. */
13export type HudHeadline = { kind: string; number?: number; progress?: string; links: HudLink[] };
14
15/** The approval wait's line (PRD 1322's s5), a toast until `until` (milliseconds since the epoch) when `toast`. */
16export type HudWait = { line: string; toast: boolean; until: number | null };
17
18/** `omni now --json`'s answer, as the band reads it; `wait` only while an approval wait shows something. */
19export type HudNow = { headline: HudHeadline | null; work: HudWork | null; doing: string | null; wait: HudWait | null };
20
21declare module 'claude-code' {
22  interface PluginState {
23    'omni-hud': { now: HudNow | null; isOff: boolean };
24  }
25}
26
hooks/band.ts 149 lines
1// What the band draws, from `omni now --json`'s answer (PRD 1208's spec, "The band"): pure.
2//
3// - **The answer** is read only when it parses and has the shape `omni now` prints; anything else
4//   is no answer, and the band hides.
5// - **At most four rows.** The headline (`roadmap 7 · 3/7 merged · roadmap page`), only with one;
6//   the work (`PRD 315 help-and-status · building · now: s3 tabs, s4 board`), with `doing` in place
7//   of the "now" part while a loop runs; the approval wait's line, below; the work's links, only
8//   with some.
9// - **The approval wait** (PRD 1322's spec, §6): while `omni wait approval` runs, its line, the
10//   `wait` row, after the work and before its links; the approved or voided line is the `toast` row,
11//   drawn highlighted, until the answer says it is over.
12// - **Nothing** while the session is on nothing: no headline, no work and no wait.
13import type { HudHeadline, HudLink, HudNow, HudSlice, HudWait, HudWork } from '../types/index.d.ts'
14
15/** One piece of a row: plain text, or a link. */
16export type BandPart = { text: string } | HudLink
17
18/** One row of the band: its pieces, drawn with ` · ` between them; highlighted when `toast`. */
19export type BandRow = { key: string; parts: BandPart[]; toast?: boolean }
20
21const IN_FLIGHT = ['in-flight', 'claimed-stale']
22
23const isRecord = (value: unknown): value is Record<string, unknown> =>
24  typeof value === 'object' && value !== null && !Array.isArray(value)
25
26function asLinks(value: unknown): HudLink[] | null {
27  if (!Array.isArray(value)) return null
28  const links: HudLink[] = []
29  for (const item of value) {
30    if (!isRecord(item) || typeof item.label !== 'string' || typeof item.href !== 'string') return null
31    links.push({ label: item.label, href: item.href })
32  }
33  return links
34}
35
36function asSlices(value: unknown): HudSlice[] | null {
37  if (!Array.isArray(value)) return null
38  const slices: HudSlice[] = []
39  for (const item of value) {
40    if (!isRecord(item) || typeof item.id !== 'string' || typeof item.state !== 'string') return null
41    const name = typeof item.name === 'string' ? item.name : null
42    slices.push({ id: item.id, name, state: item.state })
43  }
44  return slices
45}
46
47function asWork(value: unknown): HudWork | null | undefined {
48  if (value === null) return null
49  if (!isRecord(value)) return undefined
50  const { kind, number, topic, stage } = value
51  const slices = asSlices(value.slices ?? [])
52  const links = asLinks(value.links ?? [])
53  if (typeof kind !== 'string' || typeof number !== 'number' || typeof topic !== 'string') return undefined
54  if (stage !== null && stage !== undefined && typeof stage !== 'string') return undefined
55  if (!slices || !links) return undefined
56  return { kind, number, topic, stage: stage ?? null, slices, links }
57}
58
59function asHeadline(value: unknown): HudHeadline | null | undefined {
60  if (value === null) return null
61  if (!isRecord(value) || typeof value.kind !== 'string') return undefined
62  const links = asLinks(value.links ?? [])
63  if (!links) return undefined
64  const headline: HudHeadline = { kind: value.kind, links }
65  if (typeof value.number === 'number') headline.number = value.number
66  if (typeof value.progress === 'string') headline.progress = value.progress
67  return headline
68}
69
70function asWait(value: unknown): HudWait | null | undefined {
71  if (value === null) return null
72  if (!isRecord(value)) return undefined
73  const { line, toast, until } = value
74  if (typeof line !== 'string' || typeof toast !== 'boolean') return undefined
75  if (until !== null && typeof until !== 'number') return undefined
76  return { line, toast, until }
77}
78
79/** `omni now --json`'s stdout as an answer, or `null` when it does not parse or has another shape. */
80export function parseNow(stdout: string): HudNow | null {
81  let data: unknown
82  try {
83    data = JSON.parse(stdout)
84  } catch {
85    return null
86  }
87  if (!isRecord(data)) return null
88  const headline = asHeadline(data.headline ?? null)
89  const work = asWork(data.work ?? null)
90  const doing = data.doing ?? null
91  const wait = asWait(data.wait ?? null)
92  if (headline === undefined || work === undefined || wait === undefined) return null
93  if (doing !== null && typeof doing !== 'string') return null
94  return { headline, work, doing, wait }
95}
96
97const slug = (kind: string, number: number): string =>
98  kind === 'prd' ? `PRD ${number}` : kind === 'bug' || kind === 'visual' ? `${kind} #${number}` : `${kind} ${number}`
99
100const sliceLabel = ({ id, name }: HudSlice): string => (name ? `${id} ${name}` : id)
101
102function headlineRow(headline: HudHeadline): BandRow {
103  const title = headline.number === undefined ? headline.kind : `${headline.kind} ${headline.number}`
104  const parts: BandPart[] = [{ text: title }]
105  if (headline.progress) parts.push({ text: headline.progress })
106  return { key: 'headline', parts: [...parts, ...headline.links] }
107}
108
109function workRow(now: HudNow): BandRow | null {
110  const { work, headline, doing } = now
111  if (!work) return doing ? { key: 'work', parts: [{ text: doing }] } : null
112  const parts: BandPart[] = [{ text: `${slug(work.kind, work.number)} ${work.topic}` }]
113  if (work.stage) parts.push({ text: work.stage })
114  const building = work.slices.filter((slice) => IN_FLIGHT.includes(slice.state))
115  if (headline && doing) parts.push({ text: doing })
116  else if (building.length > 0) parts.push({ text: `now: ${building.map(sliceLabel).join(', ')}` })
117  return { key: 'work', parts }
118}
119
120const waitRow = ({ line, toast }: HudWait): BandRow =>
121  toast ? { key: 'toast', parts: [{ text: line }], toast: true } : { key: 'wait', parts: [{ text: line }] }
122
123/** The band's rows for an answer: none while the session is on nothing. */
124export function bandRows(now: HudNow | null): BandRow[] {
125  if (!now || (!now.work && !now.headline && !now.wait)) return []
126  const rows: BandRow[] = []
127  if (now.headline) rows.push(headlineRow(now.headline))
128  const work = workRow(now)
129  if (work) rows.push(work)
130  if (now.wait) rows.push(waitRow(now.wait))
131  if (now.work && now.work.links.length > 0) rows.push({ key: 'links', parts: now.work.links })
132  return rows
133}
134
135/** How often the band asks `omni now` while a wait waits. */
136const WAITING_EVERY_MS = 5_000
137
138/**
139 * When the band asks `omni now` again for the wait in `now`, in milliseconds from `at`: when the
140 * toast ends, every 5 seconds while it waits so the approval shows within seconds, else `null`.
141 */
142export function waitAgainIn(now: HudNow | null, at: number): number | null {
143  const wait = now?.wait
144  if (!wait) return null
145  if (wait.toast && wait.until !== null) return Math.max(0, wait.until - at)
146  return WAITING_EVERY_MS
147}
148
149