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


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).
docs/guide/, served at /docs by the app. Joining a team that already runs it? Start with Join a team, then How the loop works and Use cases.docs/superpowers/specs/2026-09-24-omni-plan-game-design.mdgame/README.mdapps/galaxy/README.mdA pnpm workspace:
| Path | What |
|---|---|
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 |
gh auth statuspnpm install
pnpm test
The tests run entirely on fixtures and never call GitHub.
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.
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.
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.
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.
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.
hooks/register.tsx 93 lines1// 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}
93types/index.d.ts 26 lines1// 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}
26hooks/band.ts 149 lines1// 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