SLOPSHOPPER

orbit

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

paneguardcommandtoastpromptprocess
v0.1.1MITupdated 2026-10-23cuongngoht/orbit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orbit
│ ┃ Orbit Live ✕ › fix the failing auth test and add an audit log call │ ┃ ORBIT LIVE the Orbit engine did not answ… │ ┃ ───────────────────────────────────────────… ⏺ 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 │ ┃ │ ┃ › /orbit-live │ ┃ ⎿ orbit: Orbit Live opened beside this session. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orbit Live
ORBIT LIVE the Orbit engine did not answer; asking again ────────────────────────────────────────────────────────
README

Orbit

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 points

EntryUse for
orbitFull goals: multi-step work, PBIs, acceptance criteria, analyze → plan → execute → verify → report. Prefer stdin or --file for complete specification text.
orbit-helpNavigation and bounded coordination: where to start, one recommended workflow, continuing tracked work — not a second goal journal.
satelliteAutonomous 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.

Install

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.

CLI

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 ….

Machine store

All durable state lives outside your product repository:

  • Default: ~/.orbit/projects/<project-key>/
  • Override base: ORBIT_STORE_DIR=/path/to/base → /path/to/base/<project-key>/
  • Test data (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.

Reusable skills (0.0.4)

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 Live and Orbit Control

  • Orbit Live — read-only viewer beside your session; one feed from journals (tasks, goals, checks). /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 — local panel for skills, MCP, tools, automation, and approvals (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.

Release quality

npm test
npm run lint
ORBIT_UI_TESTS=test/fixtures/ui-tests node cli/query.mjs check
node skills/orbit-release/ship.mjs

Check observation adapters (§4.15)

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:

AdapterFrameworkCases shown
dotnet-testthe .NET test platform's console report (VSTest or Microsoft.Testing.Platform)failures
tapa TAP stream (Test Anything Protocol 13 or 14)reported
genericany commandnone

Unified work tracking (§4.18)

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.

System graph (0.1.0)

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.

Autonomous test generation and sandbox verification (0.1.0)

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 (0.1.1)

/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.

Architecture notes

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.

License

MIT — see LICENSE.

Source 2 files
hooks/live-pane.tsx 521 lines
1import { 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}
521
types/index.d.ts 59 lines
1// 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