Orbit Engineering for Claude Code, Codex and Antigravity: From Goal to Verified Outcome — context, requirements, sandboxes, runtime, UI tests, autonomous QA…

From Goal to Verified Outcome
Orbit is a host plugin (Claude Code, Codex, Antigravity CLI, Grok Build) that keeps durable goals, project knowledge, requirements, runtime understanding, UI tests, autonomous engineering, autonomous QA, Orbit Live, and Orbit Control on your machine — with evidence and governance, no cloud dependency, and zero npm dependencies in the engine.
| Entry | Use for |
|---|---|
orbit | Full goals: multi-step work, PBIs, acceptance criteria, analyze → plan → execute → verify → report. Prefer stdin or --file for complete specification text. |
orbit-help | Navigation and bounded coordination: where to start, one recommended workflow, continuing tracked work — not a second goal journal. |
satellite | Autonomous multi-agent missions (0.1.1): any goal taken through an investigated, revisable work graph of fresh agent runs, each under a least-privilege profile, settled only on evidence an independent owner keeps. |
Specialized skills (orbit-develop, orbit-engineer, orbit-qa, orbit-ui-test, …) and role agents (orbit-qa-case-writer, orbit-engineering-agent, …) handle one capability each; the engine enforces deterministic rules.
Claude Code
/plugin marketplace add cuongngoht/orbit
/plugin install orbit@orbit
Repository: github.com/cuongngoht/orbit (public). First-time publish: docs/github-orbit-repo.md.
Local development
claude --plugin-dir .
# or
node cli/orbit.mjs doctor
Codex / Antigravity / Grok — see docs/hosts.md, docs/codex.md, docs/antigravity.md, docs/grok.md.
orbit --help
orbit goal start --stdin
orbit goal status <goal-id>
orbit doctor
orbit live status
node cli/help.mjs --for="<task>"
The reliable form on any host is node <plugin-root>/cli/orbit.mjs ….
All durable state lives outside your product repository:
~/.orbit/projects/<project-key>/ORBIT_STORE_DIR=/path/to/base → /path/to/base/<project-key>/ui-tests/) defaults to the store; a person may explicitly select the checkout (see node cli/storage.mjs where).Reports, evidence, lessons, goals, tasks, and generated instruction playbooks are store-only unless you explicitly export test data.
When a coordinated workflow needs a capability nothing here provides, the skill factory drafts a project-scoped playbook; the host writes its SKILL.md and -- when the work is a sequence of calls to capabilities this machine already provides -- a declared workflow.json. The engine validates it, registers it in the machine store, and at the playbook step RUNS it: pinned to one version, each step asked through its provider's own gate (the MCP broker or the capability pipeline), every call a receipt of digests. A later workflow, in any session, reuses the pinned skill instead of drafting again (ADR 0030).
node cli/skill-factory.mjs discover --for=release.readiness.verification # REUSABLE (and its pin) or which gap
node cli/skill-factory.mjs workflow <id|pin> # its steps, in the order they run
node cli/skill-factory.mjs runs <id> # what each run came to
node cli/skill-factory.mjs review <id> # what the runs show; a successor proposed, never written
A step is VERIFIED only by a record its owner keeps and proves; a call that answered within its contract is reported. Goals plan from a structured intent the host's model writes (skills/orbit/SKILL.md), never from the objective's words, so a goal in any language plans the same.
/orbit and /orbit-help open it (a docked pane in Claude Code, a split or a window elsewhere). Its header opens with an orbit whose satellite goes round its planet the whole time the viewer is open, in a peach-on-dark look on terminals with 256 or 24-bit colour (ADR 0027, 0029). The orbit is motion, never progress: the badge's word (ORBITING, IDLE, BLOCKED, COMPLETED …) says what the task does, and only on evidence. A tick redraws the orbit's row alone. The pane reads as its mockup does (ADR 0028): a bordered ORBITING badge, tabs with each view's words, TASK EXECUTION with NOW, the baseline as a card, a workflow's milestones under their step, and a footer of bars; a viewer waiting for a task shows a larger orbit. pane.animation: off keeps it still; pane.orbitSpeed (slow, normal, fast) sets its pace.orbit-control skill or control-server).VS Code: install the companion under integrations/vscode-orbit-live (machine-scoped settings: orbit.live.storeDirs, orbit.live.nodePath).
Current contracts and fresh-install behavior: implementation inventory.
npm test
npm run lint
ORBIT_UI_TESTS=test/fixtures/ui-tests node cli/query.mjs check
node skills/orbit-release/ship.mjs
When a declared check runs beside an engineering task, Orbit binds one adapter by the command’s output. Each adapter names what it can show:
| Adapter | Framework | Cases shown |
|---|---|---|
dotnet-test | the .NET test platform's console report (VSTest or Microsoft.Testing.Platform) | failures |
tap | a TAP stream (Test Anything Protocol 13 or 14) | reported |
generic | any command | none |
A tracked work’s lifecycle is one of: RECEIVED, IN_PROGRESS, INTERRUPTED, WAITING, BLOCKED, PAUSED, REPORTED_DONE, FAILED, CANCELLED. Evidence (none · observed · reported · verified) and session liveness are separate axes.
architecture graph [--json] projects the running system from the architecture snapshot: node facts (components, workloads, interfaces, channels, schedules, stores …) and relation facts (calls, publishes-to, consumes-from, schedules …), each with its evidence, freshness and unknowns, and a digest over what the graph means. architecture ensure reads what a composition file declares (services, start order, networks); everything else arrives as candidates through architecture record, which refuses a key that is not the subject, an end marked certain that nobody could fix, a secret attribute and a file the repository does not track. What a kind or a relation MEANS is the vocabulary's (catalog/topology.json and the organisation's and project's layers): an undeclared name is kept and derives nothing. The orbit-reverse-engineer skill runs a standalone pass as its own task, one orbit-reverse-engineering-agent per unknown scope, and closes on the graph's digest; it never sets an acceptance criterion.
From an accepted requirement to a verified outcome, with the engine holding every step to the record it keeps: obligations are DERIVED from accepted plans against the frozen system graph; a host proposes a design and the engine compiles every verdict, window and page script from accepted criteria and fixed templates; a frozen version runs only in a task's ISOLATED sandbox (Docker), its refs sealed, its writes under grants minted for its own fixture, its browser acting as the identity the design approved; the host checks what the worker left before anything is written, and ordered RESULT rows give the outcome. Backends are reached through adapters (orbit.adapter/v1) shipped as packs a project opts into, and what each is proven to support is computed from conformance receipts on real systems.
node cli/autonomous-qa.mjs plan --plan=<id> --sandbox=<env-id> --mode=fixture-write # an investigation of a task's isolated sandbox
node cli/autonomous-qa.mjs derive --investigation=<id> # the obligations its accepted plans imply
node cli/autonomous-qa.mjs step --investigation=<id> # one decision on stdin: generate a design, run-version a frozen one
node cli/autonomous-qa.mjs reconcile --investigation=<id> # a dispatch that never settled, read back -- never resent
node cli/security-audit.mjs assess --investigation=<id> # the security controls in effect, from that investigation's record
node cli/qa-plan.mjs expects <contract.json> # an accepted OpenAPI, AsyncAPI or schedule policy, as candidate expectations
node cli/query.mjs adapters --matrix # what each adapter is proven to support, and why
node acceptance/run.mjs [--stack=…] [--variant=…] # the native acceptance: two stacks, planted defects, a ground truth
Security catalogs (ASVS 5.0.0 L1 by default, labelled with the OWASP Top 10 2025 and the API Security Top 10 2023), limits, isolation and trust are a person's, set in Orbit Control (ADR 0040).
/orbit:satellite <goal> (or node <orbit>/satellite/cli/satellite.mjs on any host) takes a goal through a mission: the goal becomes a structured intent, an investigation comes before any decision about who changes what, and a work graph is revised only on evidence. Roles, agent templates, skills and workflows are reused first and generated only when nothing covers the need, each an immutable definition pinned by digest and reused across missions. Every work unit goes to a fresh agent run under a least-privilege profile, through one execution gateway, in a mode of its own. A source change happens only in an Orbit engineering task's own workspace, and a criterion is VERIFIED only by an independent owner's record: an engineering task's verification, a declared check or a QA investigation.
node satellite/cli/satellite.mjs mission start --stdin < goal.txt # a mission, the goal kept as submitted
node satellite/cli/satellite.mjs mission context <msn> # what stands, what is allowed, the next step
node satellite/cli/satellite.mjs mission step <msn> < decision.json # one decision, judged before anything runs
node satellite/cli/satellite.mjs live tui <msn> # Satellite Live: nine views, every action in its mode
node satellite/cli/satellite.mjs host capabilities # what this host can do, verified or reported
Claude Code dispatches agent runs natively and in parallel; Codex, Antigravity CLI and Grok Build run them serially in the main session, and the envelope says so. A host capability is verified only on a native acceptance receipt for the same source. Details: satellite/docs/README.md and ADR 0041.
Engine rules, module map, and extension guidance:
Orbit Goal Orchestrator (src/goal/, skill orbit) owns full-text goals; work, tasks, and evidence stay in their existing owners.
MIT — see LICENSE.
hooks/live-pane.tsx 521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PaneHeader, PaneModel, PaneOrbit, PaneOrbitRing, PaneRow, PaneSegment, PaneView } from '../types'
5
6// Orbit LIVE, docked beside the Claude Code session (Orbit 1.8.5; 1.8.6 Live Pane 2.0): the AI CLI keeps the left, the
7// prompt and the keyboard; this pane is a viewer on the right. It draws what the Orbit engine answers -- the header
8// line (0.0.3: its two orbits, turning on this pane's own clock the whole time it is open, in every state of the task),
9// the work as a TASK EXECUTION checklist (the steps it actually took, the checks nested under them), what it is
10// doing NOW, the views a person switches between (events, tests, changes, notices, plan, timeline), task and test
11// progress, and a status bar ending in the LIVE indicator -- from
12// `node <engine>/cli/live.mjs pane --host=claude`, asked every two seconds while the pane is drawn. It adds no word of
13// its own and decides nothing: the engine reads the task's journals; the pane draws. It never takes the keyboard, and
14// it asks for the share of the terminal's width a person set (`live settings`: pane.widthPercent, 36 by default); a
15// width the person dragged wins.
16//
17// WHEN: /orbit or /orbit-help opens it -- typed, or either skill run by the model -- before the skill's text reaches the
18// model, so the pane already is this session's viewer when the skill runs `live ensure`, and no Terminal window opens
19// beside it. `/orbit-live` shows or hides it. A close is the person's: the engine is told (`--left=person`), the task
20// goes on untouched, and the next /orbit or /orbit-help opens it again. Docked on the right in Claude Code's fullscreen layout
21// from 110 columns; elsewhere above the prompt, saying so.
22//
23// WHICH ENGINE: the one it ships in. The pane is part of the Orbit plugin, so `live pane` is always this plugin's own,
24// at its root, and of the same release.
25
26const PANE = 'orbit-live'
27const TITLE = 'Orbit Live'
28const EVERY_MS = 2000
29const ASK_MS = 5000
30const INLINE_ROWS = 18
31// The skills that open it: `orbit` (a goal) and `orbit-help` -- by name, so no other orbit-* skill does.
32const ENTRY = /(^|:)orbit(-help)?$/
33// How often the orbits turn: their frames are the engine's, at most ten a second.
34const SPIN_MS = 100
35const SHARE = 36
36// A terminal draws a row of Buttons as written, with nothing between them: the view tabs keep two cells apart.
37const TAB_GAP = 2
38
39const BRAND: PaneSegment = { text: ' ORBIT LIVE ', tone: 'chip' }
40
41const saying = (text: string, tone = 'warning'): PaneModel => ({ version: 1, lease: null, held: false, task: null, header: [BRAND, { text: ` ${text}`, tone }], overview: [], now: [], tier: null, titles: { checklist: '', current: '' }, checklist: [], current: [], statusBar: null, views: [], progress: [], look: { widthPercent: SHARE, focusCli: true }, hints: { inline: null } })
42
43const model = atom({ plugin: 'orbit', key: 'model' } as const, saying('reading this session’s task…', 'subtle'))
44const lease = atom({ plugin: 'orbit', key: 'lease' } as const, '')
45const view = atom({ plugin: 'orbit', key: 'view' } as const, 'events')
46// The phase this pane drew the orbits at last, in steps of the largest ring; -1 while the engine's own header stands
47// (the orbits parked: the animation is off, or the pane is not following).
48const spin = atom({ plugin: 'orbit', key: 'spin' } as const, -1)
49
50type Run = { timer: Timer | null; spin: Timer | null; busy: boolean; columns: number; rows: number; terminal: number | null; asked: number | null }
51
52// The module's own, started over on every reload: the timer while the pane is open, the room it was last drawn in,
53// the terminal's width as last seen and the dock width last asked for. What must outlive a
54// reload -- the picture, the lease token, the view picked -- is in $.state.
55const run: Run = { timer: null, spin: null, busy: false, columns: 60, rows: 30, terminal: null, asked: null }
56
57// ---- which engine ---------------------------------------------------------------------------------------------
58
59const engineOf = ($: EngineInterface): string => $.plugin.root
60
61const parsed = (text: string): unknown => {
62 try {
63 return JSON.parse(text)
64 } catch {
65 return null
66 }
67}
68
69const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
70
71// ---- the engine's answer, read strictly: what is not the shape asked for is left out --------------------------
72
73const text = (v: unknown): string | null => (typeof v === 'string' ? v : null)
74
75const segmentsOf = (value: unknown): PaneSegment[] => (Array.isArray(value) ? value.filter(isRecord).map((s) => ({ text: text(s.text) ?? '', tone: text(s.tone) })) : [])
76
77const linesOf = (value: unknown): PaneSegment[][] => (Array.isArray(value) ? value.map(segmentsOf) : [])
78
79const rowOf = (value: unknown): PaneRow | null => (isRecord(value) ? { left: segmentsOf(value.left), right: segmentsOf(value.right), depth: typeof value.depth === 'number' ? value.depth : 0, status: text(value.status), label: text(value.label), box: text(value.box) } : null)
80
81const rowsOf = (value: unknown): PaneRow[] => (Array.isArray(value) ? value.map(rowOf).filter((r): r is PaneRow => r !== null) : [])
82
83const viewOf = (value: unknown): PaneView | null => (isRecord(value) && typeof value.id === 'string' ? { id: value.id, label: text(value.label) ?? value.id, rows: rowsOf(value.rows), hint: text(value.hint) } : null)
84
85// 0.0.2: the header in its parts, or null for an answer without them (the flat header is drawn then).
86const headerPartsOf = (value: unknown): PaneHeader | null => (isRecord(value) ? { left: segmentsOf(value.left), badge: segmentsOf(value.badge), right: segmentsOf(value.right) } : null)
87
88const viewsOf = (value: unknown): PaneView[] => (Array.isArray(value) ? value.map(viewOf).filter((v): v is PaneView => v !== null) : [])
89
90const titlesOf = (value: unknown) => ({ checklist: (isRecord(value) && text(value.checklist)) || '', current: (isRecord(value) && text(value.current)) || '' })
91
92const lookOfAnswer = (value: unknown) => ({ widthPercent: isRecord(value) && typeof value.widthPercent === 'number' ? value.widthPercent : SHARE, focusCli: !isRecord(value) || value.focusCli !== false })
93
94const hintsOf = (value: unknown) => ({ inline: isRecord(value) ? text(value.inline) : null })
95
96// The versions of `live pane` this pane draws: 1 (1.8.5) and 2 (1.8.6, additive -- the progress lines, the Plan and
97// Timeline views, the LIVE indicator in the status bar). What a version adds that this pane does not know is left out.
98const VERSION = 2
99
100const progressOf = (value: unknown): PaneRow[] => (isRecord(value) ? rowsOf(value.rows) : [])
101
102const numberOf = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
103
104// 0.0.3: one of the header's orbits (src/live/orbit.mjs `orbitTrackOf`): the parked text the header holds for it, and
105// every frame of it as segments -- or null for one this pane could not turn.
106const ringOf = (value: unknown): PaneOrbitRing | null =>
107 isRecord(value) && typeof value.parked === 'string' && Array.isArray(value.frames) && value.frames.length > 0
108 ? { size: text(value.size) ?? '', parked: value.parked, frames: value.frames.map(segmentsOf) }
109 : null
110
111const isRing = (r: PaneOrbitRing | null): r is PaneOrbitRing => r !== null
112
113// What turning the header's orbits takes, or null for an answer without them: an answer whose orbit this pane does not
114// know (the one an older engine drew) leaves the header as the engine said it, parked.
115const orbitOf = (value: unknown): PaneOrbit | null => {
116 const rings = isRecord(value) && Array.isArray(value.rings) ? value.rings.map(ringOf).filter(isRing) : []
117 return isRecord(value) && rings.length > 0 ? { moving: value.moving === true, revMs: Math.max(100, numberOf(value.revMs, 3000)), rings } : null
118}
119
120export function modelOf(stdout: string): PaneModel | null {
121 const doc = parsed(stdout)
122 if (!isRecord(doc) || typeof doc.version !== 'number' || doc.version !== VERSION) return null
123 return { version: doc.version, lease: text(doc.lease), held: doc.held === true, task: text(doc.task), header: segmentsOf(doc.header), headerParts: headerPartsOf(doc.headerParts), overview: linesOf(doc.overview), now: linesOf(doc.now), tier: text(doc.tier), titles: titlesOf(doc.titles), checklist: rowsOf(doc.checklist), current: rowsOf(doc.current), statusBar: rowOf(doc.statusBar), views: viewsOf(doc.views), progress: progressOf(doc.progress), look: lookOfAnswer(doc.look), hints: hintsOf(doc.hints), orbit: orbitOf(doc.orbit) }
124}
125
126// `node <engine>/cli/live.mjs pane --host=claude …` once, with this session's id and directory on stdin, as the
127// status line hands them: its stdout, or null when it could not be asked. The directory is the session's PROJECT ROOT,
128// never the shell's: a host that runs `cd` into Orbit's store for a command does not move the checkout this pane
129// follows (1.8.6; `$.session.root()` moves only on /cd, a host's directory change or a worktree move).
130async function engineSaid($: EngineInterface, root: string, extra: readonly string[]): Promise<string | null> {
131 const token = await read($, lease)
132 const argv = ['node', `${root}/cli/live.mjs`, 'pane', '--host=claude', `--columns=${run.columns}`, `--rows=${run.rows}`, ...(token ? [`--lease=${token}`] : []), ...extra]
133 const stdin = JSON.stringify({ session_id: await $.session.id(), cwd: await $.session.root() })
134 const got = await $.process.run(argv, { stdin, timeoutMs: ASK_MS }).catch(() => null)
135 return got?.stdout ?? null
136}
137
138const asked = async ($: EngineInterface, root: string, extra: readonly string[]): Promise<PaneModel | null> => modelOf((await engineSaid($, root, extra)) ?? '')
139
140// Whether /orbit or /orbit-help opens the pane by itself here: the person's word in Orbit Control (live.open, Live display), which
141// `pane --auto` reads and answers without writing anything. Anything but a plain yes -- no repository,
142// Live switched off, an answer that did not parse -- opens nothing; /orbit-live still does.
143async function opensHere($: EngineInterface): Promise<boolean> {
144 const doc = parsed((await engineSaid($, engineOf($), ['--auto'])) ?? '')
145 return isRecord(doc) && doc.opens === true
146}
147
148// A picture the same as the one drawn is not written again: a write redraws every reader, and a pane asked every two
149// seconds about a quiet task would redraw for nothing. Only what the pane DRAWS is compared (the overview and NOW's
150// lines are not drawn while a status bar is). The engine leaves the wall clock and "how long ago" out of its answer,
151// so a quiet task costs no redraw; a running action's elapsed time, and the LIVE glyph when evidence arrives, do redraw.
152const drawnOf = (m: PaneModel): string => JSON.stringify([m.header, m.headerParts ?? null, m.tier, m.titles, m.checklist, m.current, m.views, m.progress ?? [], m.statusBar, m.statusBar ? null : (m.now[0] ?? null), m.look, m.hints, m.orbit ?? null])
153
154const same = (a: PaneModel, b: PaneModel): boolean => drawnOf(a) === drawnOf(b)
155
156async function answered($: EngineInterface, extra: readonly string[]): Promise<void> {
157 const got = (await asked($, engineOf($), extra)) ?? saying('the Orbit engine did not answer; asking again')
158 if (!same(await read($, model), got)) await update($, model, () => got)
159 await update($, lease, () => got.lease ?? '')
160}
161
162// One ask at a time: a tick that comes while the last ask still runs is skipped, never queued.
163async function refreshed($: EngineInterface, extra: readonly string[] = []): Promise<void> {
164 if (run.busy) return
165 run.busy = true
166 try {
167 await answered($, extra)
168 await sized($)
169 await spun($)
170 } finally {
171 run.busy = false
172 }
173}
174
175// ---- the orbits: turned here, on this pane's clock, the whole time it is open (0.0.3, ADR 0029) ------------------
176// In every state of the task: the orbit is the viewer's motion, never the task's -- the badge's word says what the task
177// does. They stand still only where the engine says they do not turn (`pane.animation: off`).
178
179// The largest ring's steps: the phase this pane keeps, from which each ring takes its own frame at one angle.
180const stepsOf = (o: PaneOrbit): number => Math.max(1, ...o.rings.map((r) => r.frames.length))
181
182// Where the orbits are now: the share of a revolution this pane's clock is at (src/live/orbit.mjs `indexAt`), in steps.
183export const phaseOf = (o: PaneOrbit, nowMs: number): number => Math.floor(((((nowMs % o.revMs) + o.revMs) % o.revMs) / o.revMs) * stepsOf(o)) % stepsOf(o)
184
185// One ring's frame at a phase: the same share of its own frames.
186export const orbitFrameAt = (o: PaneOrbit, ring: PaneOrbitRing, phase: number): PaneSegment[] =>
187 ring.frames[Math.floor((phase / stepsOf(o)) * ring.frames.length) % ring.frames.length] ?? [{ text: ring.parked, tone: null }]
188
189async function turned($: EngineInterface): Promise<void> {
190 const o = (await read($, model)).orbit
191 const phase = o?.moving ? phaseOf(o, await $.clock.now()) : -1
192 if (phase !== (await read($, spin))) await update($, spin, () => phase)
193}
194
195// A timer of its own while the orbits turn and the pane follows; gone the moment either stops, and never two.
196async function spun($: EngineInterface): Promise<void> {
197 if ((await read($, model)).orbit?.moving && run.timer) {
198 run.spin ??= $.clock.every(SPIN_MS, () => void turned($))
199 return
200 }
201 run.spin?.cancel()
202 run.spin = null
203 if ((await read($, spin)) !== -1) await update($, spin, () => -1)
204}
205
206// The header with its orbits turned: each segment holding a ring's parked text is that ring's frame at the phase -- the
207// one the header opens with and the one in the badge, at one angle. A header without them is drawn as it was said.
208export const withOrbit = (header: PaneSegment[], o: PaneOrbit | null | undefined, phase: number): PaneSegment[] =>
209 o && phase >= 0 ? header.flatMap((s) => ((ring) => (ring ? orbitFrameAt(o, ring, phase) : [s]))(o.rings.find((r) => r.parked === s.text))) : header
210
211// ---- the width a person set: asked of the dock once the terminal's width is known, again only when it moved ----
212
213export const shareOf = (terminal: number, percent: number): number => Math.max(32, Math.round((terminal * percent) / 100))
214
215async function sized($: EngineInterface): Promise<void> {
216 const m = await read($, model)
217 const columns = run.terminal === null ? null : shareOf(run.terminal, m.look.widthPercent)
218 if (columns === null || columns === run.asked) return
219 run.asked = columns
220 await $.ui.open({ id: PANE, title: TITLE, columns })
221}
222
223// ---- opening, following, closing -----------------------------------------------------------------------------
224
225const paneNow = async ($: EngineInterface) => (await $.ui.panes()).find((p) => p.id === PANE)
226
227const isOpen = async ($: EngineInterface): Promise<boolean> => (await paneNow($)) !== undefined
228
229// Only a pane a person can see asks, and so only it holds the session's viewer lease: one opened unasked on a narrow
230// terminal waits undrawn, holding nothing, and `live ensure` opens a window beside the session as it would without it.
231const ticked = async ($: EngineInterface): Promise<void> => {
232 if ((await paneNow($))?.isPlaced === true) await refreshed($)
233}
234
235function following($: EngineInterface): void {
236 run.timer ??= $.clock.every(EVERY_MS, () => void ticked($))
237}
238
239// Never `focus`: the prompt keeps the keyboard. The width is asked for as soon as the terminal's is known.
240async function opened($: EngineInterface): Promise<void> {
241 const columns = run.terminal === null ? null : shareOf(run.terminal, (await read($, model)).look.widthPercent)
242 const placed = await $.ui.open({ id: PANE, title: TITLE, ...(columns === null ? {} : { columns }) })
243 run.asked = columns
244 following($)
245 if (placed.isPlaced) return refreshed($)
246 $.ui.toast(`Orbit Live opens beside this session once there is room: ${placed.reason}`)
247}
248
249// A close reaches the engine even while a tick's ask is still out: a beat that lands after it holds nothing again.
250async function left($: EngineInterface, why: 'plugin' | 'person' | 'unload'): Promise<void> {
251 run.timer?.cancel()
252 run.spin?.cancel()
253 Object.assign(run, { timer: null, spin: null, asked: null })
254 if (why !== 'unload') await answered($, ['--left=person'])
255}
256
257const opening = async ($: EngineInterface, name: string | undefined): Promise<void> => {
258 if (ENTRY.test(name ?? '') && !(await isOpen($)) && (await opensHere($))) await opened($)
259}
260
261// ---- drawing --------------------------------------------------------------------------------------------------
262
263type Look = { color?: string; dimColor?: boolean; weight?: 'bold'; inverse?: boolean }
264
265// Each tone in the person's theme (0.0.1, ADR 0027: the brand's tones in 'claude', a pill's in its state's colour).
266const LOOKS: Readonly<Record<string, Look>> = {
267 success: { color: 'success' },
268 danger: { color: 'error' },
269 warning: { color: 'warning' },
270 info: { color: 'suggestion' },
271 brand: { color: 'claude', weight: 'bold' },
272 subtle: { dimColor: true },
273 label: { weight: 'bold' },
274 match: { inverse: true },
275 title: { color: 'claude', weight: 'bold' },
276 chip: { color: 'claude', weight: 'bold' },
277 key: { color: 'claude' },
278 selected: { inverse: true },
279 track: { dimColor: true },
280 'pill:success': { color: 'success', weight: 'bold' },
281 'pill:danger': { color: 'error', weight: 'bold' },
282 'pill:warning': { color: 'warning', weight: 'bold' },
283 'pill:info': { color: 'suggestion', weight: 'bold' },
284 'pill:subtle': { dimColor: true },
285 // The orbits (0.0.3, ADR 0029): the planet and the satellite in front of it the brand's, behind it dimmer; grey while
286 // the task does not move, the track dim. Never bold: a Braille cell drawn bold may come out as all eight of its dots.
287 'orbit:planet': { color: 'claude' },
288 'orbit:near': { color: 'claude' },
289 'orbit:far': { color: 'claude', dimColor: true },
290 'orbit:near:gentle': { dimColor: true },
291 'orbit:far:gentle': { dimColor: true },
292 'orbit:track': { dimColor: true },
293}
294
295export const lookOf = (tone: string | null): Look => (tone === null ? {} : LOOKS[tone] ?? {})
296
297// The badge's border, by its word's tone: the brand's, bright, while the task orbits (`chip`); else the indicator's own colour.
298export const edgeOf = (badge: PaneSegment[]): { color: string; dim: boolean } =>
299 ((tone) => ({ color: lookOf(tone).color ?? 'claude', dim: tone !== 'chip' }))(badge.at(-1)?.tone ?? null)
300
301// The room the engine lays the picture out for: docked, the whole column; above the prompt, a short block.
302function measured(columns: number, rows: number, docked: boolean): void {
303 run.columns = Math.max(20, columns)
304 run.rows = Math.max(10, docked ? rows : Math.min(rows, INLINE_ROWS))
305}
306
307// How many rows each block may have, by the pane's tier: what the work went through and what it does now first, the
308// view a person picked takes what is left. Wide puts the two blocks side by side.
309export function budgetOf(m: PaneModel, rows: number): { checklist: number; current: number; view: number } {
310 const room = Math.max(0, rows - 4 - (m.headerParts ? HEADER_EXTRA : 0) - (m.hints.inline ? 1 : 0) - (m.progress ?? []).length - cardEdges(m.checklist))
311 const tier = m.tier ?? 'medium'
312 if (tier === 'tiny' || tier === 'narrow') return { checklist: 0, current: tier === 'tiny' ? 0 : Math.max(0, room - TITLES), view: 0 }
313 const top = Math.min(Math.max(m.checklist.length, m.current.length), Math.floor(room * 0.62))
314 if (tier === 'wide') return { checklist: top, current: top, view: Math.max(0, room - top - TITLES - 1) }
315 const checklist = Math.min(m.checklist.length, Math.floor(room * 0.6))
316 const current = Math.min(m.current.length, Math.floor(room * 0.15))
317 return { checklist, current, view: Math.max(0, room - checklist - current - TITLES - 1) }
318}
319
320// 0.0.2 (ADR 0028): the header's badge is drawn in a border, two rows taller than a line, and a rule closes the header;
321// TASK EXECUTION and NOW are a title row and a rule over the blocks; a card adds its two edges.
322const HEADER_EXTRA = 3
323const TITLES = 2
324
325export const cardEdges = (rows: PaneRow[]): number => rows.filter((r, i) => r.box && rows[i - 1]?.box !== r.box).length * 2
326
327// Rows in their cards: consecutive rows of one card are one group, every other row stands alone.
328export function carded(rows: PaneRow[]): Array<{ box: string | null; rows: PaneRow[] }> {
329 const out: Array<{ box: string | null; rows: PaneRow[] }> = []
330 for (const r of rows) {
331 const last = out[out.length - 1]
332 if (r.box && last?.box === r.box) last.rows.push(r)
333 else out.push({ box: r.box ?? null, rows: [r] })
334 }
335 return out
336}
337
338// The status bar's parts, its separators taken out: the pane spreads them across the width with a dot between each.
339export const barParts = (r: PaneRow): PaneSegment[] => [...r.left.filter((s) => s.text.trim() !== '·'), ...r.right]
340
341// 0.0.2: whatever a row says on its right keeps the right edge -- a check's status word, a step's detail, a bar -- and
342// its left flows and is cut at the end.
343const fixedOf = (r: PaneRow): PaneSegment[] => r.right
344
345const flowingOf = (r: PaneRow): PaneSegment[] => r.left
346
347export const register: Register = (on) => {
348 on('session.start', async ($, e, next) => {
349 await $.command.register({ name: 'orbit-live', description: 'Show or hide Orbit Live beside this session' })
350 if (await isOpen($)) following($)
351 return next(e)
352 })
353
354 // Three doors to one pane, each failing open: whatever goes wrong here, the prompt, the skill and the call go on.
355 on('prompt.submit', async ($, e, next) => {
356 await opening($, e.command?.name)
357 return next(e)
358 }).catch(($, e, next) => next(e))
359
360 on('skill.prompt', async ($, e, next) => {
361 await opening($, e.skill)
362 return next(e)
363 }).catch(($, e, next) => next(e))
364
365 // Where an organization manages Claude Code, an installed plugin's skill.prompt hooks do not run: the model's
366 // Skill call is the door that remains.
367 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
368 await opening($, e.skill)
369 return next(e)
370 }).catch(($, e, next) => next(e))
371
372 on('command.run', { command: 'orbit-live' }, async ($, e) => {
373 run.terminal = e.presentation.columns
374 if (await isOpen($)) {
375 await $.ui.close({ id: PANE })
376 return { text: 'Orbit Live closed; the task goes on. /orbit, /orbit-help or /orbit-live opens it again.' }
377 }
378 await opened($)
379 return { text: 'Orbit Live opened beside this session.' }
380 })
381
382 on('ui.close', async ($, e, next) => {
383 if (e.id === PANE) await left($, e.origin.kind)
384 return next(e)
385 }).catch(($, e, next) => next(e))
386
387 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
388 const { Box, Text, Button } = $.ui.resolve(e)
389 const docked = e.props.placement === 'dock'
390 measured(e.props.bodyColumns, e.props.scroll.bodyRows, docked)
391 // Docked, the viewport is the transcript's, not the terminal's: the terminal is the transcript, the pane and its frame.
392 run.terminal = e.viewport ? e.viewport.columns + (docked ? e.props.bodyColumns + 3 : 0) : run.terminal
393 const m = await read($, model)
394 const picked = await read($, view)
395 const phase = await read($, spin)
396 const header = withOrbit(m.header, m.orbit, phase)
397 const b = budgetOf(m, run.rows)
398 const line = (segs: PaneSegment[], key: string) => (
399 <Text key={key} wrap="truncate-end">
400 {segs.map((s, i) => (
401 <Text key={`${key}.${i}`} {...lookOf(s.tone)}>
402 {s.text}
403 </Text>
404 ))}
405 </Text>
406 )
407 // A row: its left flows and is cut, its right keeps the right edge.
408 const rowEl = (r: PaneRow, key: string) => (
409 <Box key={key} flexDirection="row">
410 <Box flexGrow={1} flexShrink={1} overflow="hidden">
411 {line(flowingOf(r), `${key}.f`)}
412 </Box>
413 {fixedOf(r).length ? <Box flexShrink={0} marginLeft={1}>{line(fixedOf(r), `${key}.s`)}</Box> : null}
414 </Box>
415 )
416 // Rows in their cards: a card's rows inside a round border, the others as they are.
417 const rowsEl = (rows: PaneRow[], key: string) =>
418 carded(rows).map((g, i) =>
419 g.box ? (
420 <Box key={`${key}.c${i}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} flexShrink={0}>
421 {g.rows.map((r, j) => rowEl({ ...r, left: r.left.map((s, k) => (k === 0 && /^ *$/.test(s.text) ? { ...s, text: ' '.repeat(Math.max(0, r.depth - g.rows[0].depth)) } : s)) }, `${key}.c${i}.${j}`))}
422 </Box>
423 ) : (
424 rowEl(g.rows[0], `${key}.r${i}`)
425 ),
426 )
427 const parts: PaneHeader | null = m.headerParts ? { left: withOrbit(m.headerParts.left, m.orbit, phase), badge: withOrbit(m.headerParts.badge, m.orbit, phase), right: m.headerParts.right } : null
428 // The header (0.0.2, ADR 0028): where the viewer stands on the left; the badge in a border and the task on the right.
429 // The badge's border (0.0.3) is the brand's while the task orbits, and the indicator's own colour otherwise.
430 const edge = parts ? edgeOf(parts.badge) : { color: 'claude', dim: true }
431 const headerEl = parts ? (
432 <Box key="header" flexDirection="row" justifyContent="space-between" alignItems="center" flexShrink={0}>
433 <Box flexShrink={1} overflow="hidden">
434 {line(parts.left, 'h.l')}
435 </Box>
436 <Box flexDirection="row" alignItems="center" flexShrink={0}>
437 <Box borderStyle="round" borderColor={edge.color} borderDimColor={edge.dim} paddingX={1}>
438 {line(parts.badge, 'h.b')}
439 </Box>
440 <Box marginLeft={1}>{line(parts.right, 'h.r')}</Box>
441 </Box>
442 </Box>
443 ) : (
444 line(header, 'header')
445 )
446 const shown: PaneView | undefined = m.views.find((v) => v.id === picked) ?? m.views[0]
447 // The tabs under the header: each view's key over its label, the picked one bright, and what it shows on the right.
448 const tabsEl = m.views.length ? (
449 <Box key="tabbar" flexDirection="row" justifyContent="space-between" flexShrink={0}>
450 <Box key="tabs" flexDirection="row" gap={TAB_GAP}>
451 {m.views.map((v, i) => (
452 <Button key={`tab.${v.id}`} label={v.label} hotkey={String(i + 1)} plain {...(v.id === shown?.id ? { variant: 'primary' as const } : { dimColor: true })} onPress={() => void update($, view, () => v.id)} />
453 ))}
454 </Box>
455 {shown?.hint ? (
456 <Box flexShrink={1} marginLeft={2} overflow="hidden">
457 <Text dimColor wrap="truncate-end">
458 {shown.hint}
459 </Text>
460 </Box>
461 ) : null}
462 </Box>
463 ) : null
464 const rule = (key: string) => (
465 <Text key={key} dimColor wrap="truncate-end">
466 {'─'.repeat(run.columns)}
467 </Text>
468 )
469 // TASK EXECUTION on the left, NOW on the right; under them the checklist, and NOW beside it (wide) or below it.
470 const titleEl = (
471 <Box key="titles" flexDirection="row" justifyContent="space-between" flexShrink={0}>
472 <Text {...lookOf('title')}>{b.checklist > 0 ? m.titles.checklist : ''}</Text>
473 <Text color="success" bold>
474 {m.titles.current}
475 </Text>
476 </Box>
477 )
478 const block = (rows: PaneRow[], room: number, key: string) =>
479 room > 0 ? (
480 <Box key={key} flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
481 {key === 'checklist' ? rowsEl(rows.slice(0, room), key) : rows.slice(0, room).map((r, i) => rowEl(r, `${key}.${i}`))}
482 </Box>
483 ) : null
484 const bar = m.statusBar ? barParts(m.statusBar) : []
485 // The status bar spread across the width, a dot between each part, the indicator on the right edge.
486 const statusEl = m.statusBar ? (
487 <Box key="status" flexDirection="row" justifyContent="space-between">
488 {bar.flatMap((s, i) => [...(i ? [<Text key={`status.d${i}`} dimColor>·</Text>] : []), <Text key={`status.${i}`} {...lookOf(s.tone)}>{s.text}</Text>])}
489 </Box>
490 ) : (
491 line(m.now[0] ?? [], 'status')
492 )
493 return (
494 <Box flexDirection="column" {...(docked ? { height: e.props.scroll.bodyRows } : {})}>
495 {headerEl}
496 {parts ? rule('rule.header') : null}
497 {!docked && m.hints.inline ? line([{ text: `⚠ ${m.hints.inline}`, tone: 'warning' }], 'hint') : null}
498 {tabsEl}
499 {b.checklist > 0 || b.current > 0 ? titleEl : null}
500 {b.checklist > 0 || b.current > 0 ? rule('rule.top') : null}
501 <Box flexDirection={m.tier === 'wide' ? 'row' : 'column'} flexShrink={0}>
502 {block(m.checklist, b.checklist, 'checklist')}
503 {m.tier === 'wide' && b.checklist > 0 ? <Box width={2} /> : null}
504 {block(m.current, b.current, 'current')}
505 </Box>
506 {b.view > 0 && shown ? (
507 <Box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
508 {rule('rule.view')}
509 {(shown.rows ?? []).slice(0, b.view).map((r, i) => rowEl(r, `view.${i}`))}
510 </Box>
511 ) : (
512 <Box flexGrow={1} />
513 )}
514 {(m.progress ?? []).map((r, i) => rowEl(r, `progress.${i}`))}
515 {rule('rule.status')}
516 {statusEl}
517 </Box>
518 )
519 })
520}
521types/index.d.ts 59 lines1// What the Orbit Live pane keeps in the session's state: the last picture the engine answered (`live pane`), the lease
2// token that makes this pane the session's viewer while it is open, and which view a person picked.
3
4/** One run of text and the tone Orbit gave it (the engine's words; the pane adds none). */
5export type PaneSegment = { text: string; tone: string | null }
6
7/** One row of the workspace: what is on its left, what is on its right, how deep it sits, what it came to. */
8export type PaneRow = { left: PaneSegment[]; right: PaneSegment[]; depth: number; status: string | null; label: string | null; /** 0.0.2: the card it is drawn in */ box?: string | null }
9
10/** A view a person switches to under the workspace: its id, its label, its rows. */
11export type PaneView = { id: string; label: string; rows: PaneRow[]; /** 0.0.2: what the view shows, beside the tabs */ hint?: string | null }
12
13/** 0.0.2: the header in three parts -- where the viewer stands, the badge (drawn in a border), what the task is and how old. */
14export type PaneHeader = { left: PaneSegment[]; badge: PaneSegment[]; right: PaneSegment[] }
15
16/** 0.0.3: one of the header's two orbits -- the parked text the engine's header holds for it, and every frame of its one row. */
17export type PaneOrbitRing = { size: string; parked: string; frames: PaneSegment[][] }
18
19/** 0.0.3 (ADR 0029): what turning the header's orbits takes -- whether they turn at all (the animation is not off), one
20 * revolution's length, and their rings, turned at one angle. It holds no time: a quiet task's answer stays the same. */
21export type PaneOrbit = { moving: boolean; revMs: number; rings: PaneOrbitRing[] }
22
23/** `node <engine>/cli/live.mjs pane --host=claude`'s one JSON object, as the pane draws it. */
24export type PaneModel = {
25 version: number
26 lease: string | null
27 held: boolean
28 task: string | null
29 header: PaneSegment[]
30 /** 0.0.2: the same header in its parts; null when the answer has none. */
31 headerParts?: PaneHeader | null
32 overview: PaneSegment[][]
33 now: PaneSegment[][]
34 tier: string | null
35 titles: { checklist: string; current: string }
36 checklist: PaneRow[]
37 current: PaneRow[]
38 statusBar: PaneRow | null
39 views: PaneView[]
40 /** 1.8.6: task progress and test progress, over the denominators their owners recorded -- two rows, or none. */
41 progress: PaneRow[]
42 look: { widthPercent: number; focusCli: boolean }
43 hints: { inline: string | null }
44 /** 0.0.3: the header's orbits, turned by the pane on its own clock in every state; null when the answer has none. */
45 orbit?: PaneOrbit | null
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 orbit: {
51 model: PaneModel
52 lease: string
53 view: string
54 /** 0.0.3: the share of a revolution the orbits were drawn at, in steps of the largest ring; 0 while parked. */
55 spin: number
56 }
57 }
58}
59